diff --git a/README.md b/README.md index 7c1bf6ae..7147d619 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,18 @@ +> ## Spark 2.0 Preview +> +> Spark 2.0 Developer Preview is now available! +> - [Read the docs](https://sparkjs.dev/2.0.0-preview/docs/) +> - [Check out the preview branch](https://github.com/sparkjsdev/spark/tree/v2.0.0-preview) +> +> Version 2.0 is a major rewrite of the renderer to enable huge worlds made of dynamic 3D Gaussian Splats. It's a complete solution for creating, streaming, and rendering huge 3DGS worlds on the web on any device. It is mostly backward compatible with with Spark 0.1.*. +> +> Read about all the [New Features in 2.0](https://sparkjs.dev/2.0.0-preview/docs/new-features-2.0/), learn how to migrate in our [1.0 → 2.0 Migration Guide](https://sparkjs.dev/2.0.0-preview/docs/0.1-2.0-migration-guide/), and get started quick with our [Level-of-Detail system](https://sparkjs.dev/2.0.0-preview/docs/lod-getting-started/). + +> New [Spark 2.0 examples](https://sparkjs.dev/2.0.0-preview/examples/) have been added, including [huge streaming LoD worlds](https://sparkjs.dev/2.0.0-preview/examples/#streaming-lod) and [streaming multiple simultaneous LoD worlds](https://sparkjs.dev/2.0.0-preview/examples/#multi-lod). + ## Features - Integrates with THREE.js rendering pipeline to fuse splat and mesh-based objects diff --git a/docs/distance-rescale-guide-ja.md b/docs/distance-rescale-guide-ja.md new file mode 100644 index 00000000..24f2fb84 --- /dev/null +++ b/docs/distance-rescale-guide-ja.md @@ -0,0 +1,122 @@ +# Distance Measurement & Rescale 使用ガイド + +## アクセス方法 + +**URL**: [https://nice-meadow-018297c00.eastasia.6.azurestaticapps.net/examples/distance-rescale/](https://nice-meadow-018297c00.eastasia.6.azurestaticapps.net/examples/distance-rescale/) + +--- + +## 機能概要 + +3DGSモデル上で2点間の距離を測定し、実際の寸法に合わせてモデルをリスケール(拡大縮小)できます。座標系の可視化、原点の変更、柔軟なファイル読み込み機能を備えています。 + +--- + +## 使い方 + +### 1. 3DGSファイルの読み込み + +3つの方法でファイルを読み込むことができます: + +**方法A: ボタンから読み込み** +1. 画面右上の **Controls** パネルにある **「Load PLY File」** ボタンをクリック +2. ファイル選択ダイアログから `.ply`、`.spz`、`.splat` ファイルを選択 +3. モデルが自動的に読み込まれ、カメラが自動調整される + +**方法B: ドラッグ&ドロップ** +1. `.ply`、`.spz`、`.splat` ファイルをファイルエクスプローラーからドラッグ +2. 3Dキャンバス上に直接ドロップ +3. モデルが自動的に読み込まれる + +**方法C: デフォルトモデル** +- ページを開くとペンギンのモデルがデフォルトで読み込まれます + +### 2. 座標軸の表示 + +1. Controls パネルの **「Toggle Axes」** ボタンをクリック +2. 原点(0,0,0)から赤(X軸)、緑(Y軸)、青(Z軸)の軸が表示される +3. もう一度クリックすると軸を非表示にできる + +### 3. 新しい原点の設定 + +1. モデル上の任意の点を **右ダブルクリック** +2. モデル全体の座標が変換され、その点が新しい原点(0,0,0)になる +3. 座標軸(表示されている場合)が新しい原点を示す +4. 既存の測定点はすべてクリアされる + +**ヒント:** +- カメラ位置は視点を維持するように自動調整される +- 異なる点を右ダブルクリックすることで、何度でも原点を変更できる +- エクスポートされるPLYファイルには変換後の座標が含まれる + +### 4. 測定点の配置 + +1. **1点目**: モデル上の任意の位置を左クリック(緑のマーカーが表示) +2. **2点目**: モデル上の別の位置を左クリック(青のマーカーが表示) +3. 2点間に黄色の線が表示され、距離が表示される + +### 5. 測定点の調整 + +- マーカーをドラッグすると、視線方向に沿って位置を調整可能 +- 距離はリアルタイムで更新される +- 測定距離はControlsパネルと画面右下に表示される + +### 6. リスケール(サイズ変更) + +1. **「Measured Distance」** フィールドに現在の測定値が表示される +2. **「New Distance」** に実際の距離(メートル単位)を入力 +3. **「Apply Rescale」** をクリック +4. モデル全体が指定した寸法に合わせてスケーリングされる + +### 7. モデルの保存 + +1. **「Export PLY」** をクリック +2. `rescaled_model.ply` としてダウンロードされる +3. エクスポートされるファイルにはすべての変換(リスケールと原点変更)が含まれる + +### 8. リセット + +- **「Reset Points」** をクリックすると測定点がクリアされ、最初からやり直せる +- 原点の変換とリスケールは保持される + +--- + +## 操作方法 + +| 操作 | 動作 | +|------|------| +| 左クリック | 測定点を配置 | +| マーカーをドラッグ | 測定点の位置を調整 | +| 左ドラッグ(空白部分) | カメラ回転(無制限回転) | +| 右ドラッグ / 2本指ドラッグ | カメラ移動 | +| スクロール / ピンチ | ズーム | +| 右ダブルクリック | 座標原点を設定 | + +**カメラ操作:** +- すべての方向に無制限回転可能(角度制限なし) +- ズーム制限によりパフォーマンス問題を防止(最小: 0.5、最大: 50) + +--- + +## UI レイアウト + +- **左上**: 操作説明 +- **右上**: Controls パネル(折りたたみ可能) + - Load PLY File(PLYファイル読み込み) + - Toggle Axes(軸の表示切替) + - Measured Distance(測定距離・読み取り専用) + - New Distance(新しい距離) + - Apply Rescale(リスケール適用) + - Reset Points(測定点リセット) + - Export PLY(PLY出力) +- **右下**: 距離表示(測定中に表示) + +--- + +## 注意事項 + +- リスケール後のモデルは現在の原点を基準にスケーリングされます +- 新しい原点を設定すると、すべての測定がクリアされます +- 原点変更時、カメラは視点を維持するように自動調整されます +- エクスポートされるPLYファイルには最終的な変換後の座標が含まれます +- すべてのファイル形式(.ply、.spz、.splat)が読み込みとドラッグ&ドロップに対応しています diff --git a/docs/distance-rescale-guide.md b/docs/distance-rescale-guide.md new file mode 100644 index 00000000..2a27ea53 --- /dev/null +++ b/docs/distance-rescale-guide.md @@ -0,0 +1,122 @@ +# Distance Measurement & Rescale Usage Guide + +## Access + +**URL**: [https://nice-meadow-018297c00.eastasia.6.azurestaticapps.net/examples/distance-rescale/](https://nice-meadow-018297c00.eastasia.6.azurestaticapps.net/examples/distance-rescale/) + +--- + +## Overview + +Measure distances between two points on a 3D Gaussian Splatting (3DGS) model and rescale the model to match real-world dimensions. Features include coordinate system visualization, custom origin setting, and flexible file loading. + +--- + +## Usage + +### 1. Loading 3DGS Files + +You can load files in three ways: + +**Option A: Load Button** +1. Click the **"Load PLY File"** button in the Controls panel (top-right) +2. Select a `.ply`, `.spz`, or `.splat` file from the dialog +3. The model loads automatically and the camera adjusts + +**Option B: Drag & Drop** +1. Drag a `.ply`, `.spz`, or `.splat` file from your file explorer +2. Drop it directly onto the 3D canvas +3. The model loads automatically + +**Option C: Default Model** +- A penguin model loads by default when you first open the page + +### 2. Coordinate Axes Display + +1. Click the **"Toggle Axes"** button in the Controls panel +2. Red (X), Green (Y), Blue (Z) axes appear from the origin (0,0,0) +3. Click again to hide the axes + +### 3. Setting a New Origin + +1. Right double-click on any point of the model +2. The model geometry transforms so that point becomes the new origin (0,0,0) +3. The coordinate axes (if visible) mark the new origin +4. All previous measurements are cleared + +**Tips:** +- The camera position adjusts to maintain your view +- You can set multiple new origins by right double-clicking different points +- Exported PLY files include the transformed coordinates + +### 4. Placing Measurement Points + +1. **First point**: Left-click anywhere on the model (green marker appears) +2. **Second point**: Left-click another location (blue marker appears) +3. A yellow line connects the points and displays the distance + +### 5. Adjusting Measurement Points + +- Drag any marker to adjust its position along the view direction +- The distance updates in real-time +- The measured distance appears in the Controls panel and bottom-right display + +### 6. Rescaling the Model + +1. The **"Measured Distance"** field shows the current measurement +2. Enter the actual real-world distance (in meters) in **"New Distance"** +3. Click **"Apply Rescale"** +4. The entire model scales to match the specified dimensions + +### 7. Exporting the Model + +1. Click **"Export PLY"** +2. The file downloads as `rescaled_model.ply` +3. The exported file includes all transformations (rescale and origin changes) + +### 8. Reset + +- Click **"Reset Points"** to clear measurement markers and start over +- Origin transformations and rescaling remain applied + +--- + +## Controls + +| Action | Function | +|--------|----------| +| Left-click | Place measurement point | +| Drag marker | Adjust measurement point position | +| Left-drag (empty space) | Rotate camera (infinite rotation) | +| Right-drag / Two-finger drag | Pan camera | +| Scroll / Pinch | Zoom | +| Right double-click | Set new coordinate origin | + +**Camera Controls:** +- Infinite rotation in all directions (no angle limits) +- Zoom limits prevent performance issues (min: 0.5, max: 50) + +--- + +## UI Layout + +- **Top-left**: Instructions +- **Top-right**: Controls panel (collapsible) + - Load PLY File + - Toggle Axes + - Measured Distance (read-only) + - New Distance + - Apply Rescale + - Reset Points + - Export PLY +- **Bottom-right**: Distance display (appears during measurement) + +--- + +## Notes + +- After rescaling, the model scales relative to the current origin +- After setting a new origin, all measurements are cleared +- The camera automatically adjusts to maintain view during origin changes +- Exported PLY files contain the final transformed coordinates +- All file formats (.ply, .spz, .splat) are supported for both loading and drag & drop diff --git a/examples.html b/examples.html index ffe2426f..158110be 100644 --- a/examples.html +++ b/examples.html @@ -246,7 +246,8 @@ 'debug-color': '/examples/debug-color/index.html', 'depth-of-field': '/examples/depth-of-field/index.html', 'splat-texture': '/examples/splat-texture/index.html', - 'editor': '/examples/editor/index.html' + 'editor': '/examples/editor/index.html', + 'distance-rescale': '/examples/distance-rescale/index.html' }; function getExampleFromHash() { @@ -451,6 +452,7 @@ Depth of Field Splat Texture Editor + Distance Measurement & Rescale
diff --git a/examples/distance-rescale/index.html b/examples/distance-rescale/index.html new file mode 100644 index 00000000..e8876704 --- /dev/null +++ b/examples/distance-rescale/index.html @@ -0,0 +1,83 @@ + + + + + + + Spark - Distance Measurement & Rescale + + + + +
Left-click to measure distance | Right double-click to set origin
+ + + + +
+
Distance
+
0.000
+
+ + + + + + diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js new file mode 100644 index 00000000..7fa5fd7e --- /dev/null +++ b/examples/distance-rescale/main.js @@ -0,0 +1,943 @@ +import { PlyWriter, SparkRenderer, SplatMesh } from "@sparkjsdev/spark"; +import { GUI } from "lil-gui"; +import * as THREE from "three"; +import { TrackballControls } from "three/addons/controls/TrackballControls.js"; +import { getAssetFileURL } from "/examples/js/get-asset-url.js"; + +// ============================================================================ +// Scene Setup +// ============================================================================ + +const scene = new THREE.Scene(); +const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 100000, +); +const renderer = new THREE.WebGLRenderer({ antialias: false }); +renderer.setSize(window.innerWidth, window.innerHeight); +document.body.appendChild(renderer.domElement); + +const spark = new SparkRenderer({ renderer }); +scene.add(spark); + +// Camera controls - using TrackballControls for infinite rotation +const controls = new TrackballControls(camera, renderer.domElement); +controls.rotateSpeed = 1.5; +controls.zoomSpeed = 0.8; // Reduced from 1.2 for smoother zoom +controls.panSpeed = 0.8; +controls.staticMoving = true; // Disable smooth damping for better performance +controls.minDistance = 0.5; // Prevent zooming too close (causes slowness) +controls.maxDistance = 50; // Prevent zooming too far +camera.position.set(0, 2, 5); +camera.lookAt(0, 0, 0); + +window.addEventListener("resize", onWindowResize, false); +function onWindowResize() { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + controls.handleResize(); +} + +// ============================================================================ +// State Management +// ============================================================================ + +const state = { + // Point 1 + point1: null, + ray1Origin: null, + ray1Direction: null, + marker1: null, + rayLine1: null, + + // Point 2 + point2: null, + ray2Origin: null, + ray2Direction: null, + marker2: null, + rayLine2: null, + + // Measurement + distanceLine: null, + currentDistance: 0, + + // Interaction + mode: "select1", // 'select1' | 'select2' | 'complete' + dragging: null, // 'point1' | 'point2' | null + + // Coordinate axes + axesHelper: null, + axesVisible: false, +}; + +let splatMesh = null; +const raycaster = new THREE.Raycaster(); + +// ============================================================================ +// Visual Elements +// ============================================================================ + +let rayLineLength = 100; // Will be updated based on model size +const MARKER_SCREEN_SIZE = 0.03; // Constant screen-space size (percentage of screen height) +const POINT1_COLOR = 0x00ff00; // Green +const POINT2_COLOR = 0x0088ff; // Blue +const DISTANCE_LINE_COLOR = 0xffff00; // Yellow + +function createMarker(color) { + // Create a group to hold both the sphere and its outline + // Use unit size - will be scaled dynamically based on camera distance + const group = new THREE.Group(); + + // Inner sphere (unit radius = 1) + const geometry = new THREE.SphereGeometry(1, 16, 16); + const material = new THREE.MeshBasicMaterial({ + color, + depthTest: false, + transparent: true, + opacity: 0.9, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.renderOrder = 1000; + group.add(mesh); + + // Outer ring/outline for better visibility + const ringGeometry = new THREE.RingGeometry(1.2, 1.8, 32); + const ringMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + depthTest: false, + transparent: true, + opacity: 0.8, + side: THREE.DoubleSide, + }); + const ring = new THREE.Mesh(ringGeometry, ringMaterial); + ring.renderOrder = 999; + group.add(ring); + + // Make ring always face camera (billboard) + group.userData.ring = ring; + + return group; +} + +function createRayLine(origin, direction, color) { + const farPoint = origin + .clone() + .add(direction.clone().multiplyScalar(rayLineLength)); + const geometry = new THREE.BufferGeometry().setFromPoints([origin, farPoint]); + const material = new THREE.LineBasicMaterial({ + color, + depthTest: false, + transparent: true, + opacity: 0.6, + }); + const line = new THREE.Line(geometry, material); + line.renderOrder = 998; + return line; +} + +function updateRayLine(line, origin, direction) { + const positions = line.geometry.attributes.position.array; + const farPoint = origin + .clone() + .add(direction.clone().multiplyScalar(rayLineLength)); + positions[0] = origin.x; + positions[1] = origin.y; + positions[2] = origin.z; + positions[3] = farPoint.x; + positions[4] = farPoint.y; + positions[5] = farPoint.z; + line.geometry.attributes.position.needsUpdate = true; +} + +function createDistanceLine() { + const geometry = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(), + new THREE.Vector3(), + ]); + const material = new THREE.LineBasicMaterial({ + color: DISTANCE_LINE_COLOR, + depthTest: false, + linewidth: 2, + }); + const line = new THREE.Line(geometry, material); + line.renderOrder = 997; + return line; +} + +function updateDistanceLine() { + if (!state.distanceLine || !state.point1 || !state.point2) return; + + const positions = state.distanceLine.geometry.attributes.position.array; + positions[0] = state.point1.x; + positions[1] = state.point1.y; + positions[2] = state.point1.z; + positions[3] = state.point2.x; + positions[4] = state.point2.y; + positions[5] = state.point2.z; + state.distanceLine.geometry.attributes.position.needsUpdate = true; +} + +// ============================================================================ +// Mouse / Touch Utilities +// ============================================================================ + +function getMouseNDC(event) { + const rect = renderer.domElement.getBoundingClientRect(); + return new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); +} + +function getHitPoint(ndc) { + if (!splatMesh) return null; + raycaster.setFromCamera(ndc, camera); + const hits = raycaster.intersectObject(splatMesh, false); + if (hits && hits.length > 0) { + return hits[0].point.clone(); + } + return null; +} + +// ============================================================================ +// Point Selection +// ============================================================================ + +function selectPoint1(hitPoint) { + state.point1 = hitPoint.clone(); + state.ray1Origin = camera.position.clone(); + state.ray1Direction = raycaster.ray.direction.clone(); + + // Create marker + if (state.marker1) scene.remove(state.marker1); + state.marker1 = createMarker(POINT1_COLOR); + state.marker1.position.copy(hitPoint); + scene.add(state.marker1); + + // Create ray line + if (state.rayLine1) scene.remove(state.rayLine1); + state.rayLine1 = createRayLine( + state.ray1Origin, + state.ray1Direction, + POINT1_COLOR, + ); + scene.add(state.rayLine1); + + state.mode = "select2"; + updateInstructions("Left-click to select second measurement point"); +} + +function selectPoint2(hitPoint) { + state.point2 = hitPoint.clone(); + state.ray2Origin = camera.position.clone(); + state.ray2Direction = raycaster.ray.direction.clone(); + + // Create marker + if (state.marker2) scene.remove(state.marker2); + state.marker2 = createMarker(POINT2_COLOR); + state.marker2.position.copy(hitPoint); + scene.add(state.marker2); + + // Create ray line + if (state.rayLine2) scene.remove(state.rayLine2); + state.rayLine2 = createRayLine( + state.ray2Origin, + state.ray2Direction, + POINT2_COLOR, + ); + scene.add(state.rayLine2); + + // Create distance line + if (!state.distanceLine) { + state.distanceLine = createDistanceLine(); + scene.add(state.distanceLine); + } + updateDistanceLine(); + + state.mode = "complete"; + calculateDistance(); + updateInstructions( + "Drag markers to adjust | Right double-click to set origin", + ); +} + +// ============================================================================ +// Drag Along Ray +// ============================================================================ + +function closestPointOnRay(viewRay, rayOrigin, rayDir, currentPoint) { + // Find the point on the selection ray closest to the view ray + const w0 = rayOrigin.clone().sub(viewRay.origin); + const a = rayDir.dot(rayDir); + const b = rayDir.dot(viewRay.direction); + const c = viewRay.direction.dot(viewRay.direction); + const d = rayDir.dot(w0); + const e = viewRay.direction.dot(w0); + + const denom = a * c - b * b; + if (Math.abs(denom) < 0.0001) { + // Rays are nearly parallel - keep current point + return currentPoint.clone(); + } + + const t = (b * e - c * d) / denom; + + // Very minimal clamping - just prevent going behind ray origin or too far + const minT = 0.01; // Almost at ray origin + const maxT = rayLineLength * 2; // Allow movement beyond visible ray line + const clampedT = Math.max(minT, Math.min(maxT, t)); + return rayOrigin.clone().add(rayDir.clone().multiplyScalar(clampedT)); +} + +function checkMarkerHit(ndc) { + raycaster.setFromCamera(ndc, camera); + + const objects = []; + if (state.marker1) objects.push(state.marker1); + if (state.marker2) objects.push(state.marker2); + + if (objects.length === 0) return null; + + // Use recursive=true to hit children (sphere and ring inside group) + const hits = raycaster.intersectObjects(objects, true); + if (hits.length > 0) { + // Check if the hit object or its parent is marker1 or marker2 + let hitObj = hits[0].object; + while (hitObj) { + if (hitObj === state.marker1) return "point1"; + if (hitObj === state.marker2) return "point2"; + hitObj = hitObj.parent; + } + } + return null; +} + +// ============================================================================ +// Distance Calculation +// ============================================================================ + +function calculateDistance() { + if (!state.point1 || !state.point2) { + state.currentDistance = 0; + return; + } + + state.currentDistance = state.point1.distanceTo(state.point2); + updateDistanceDisplay(state.currentDistance); + guiParams.measuredDistance = state.currentDistance.toFixed(4); +} + +function updateDistanceDisplay(distance) { + const display = document.getElementById("distance-display"); + const value = document.getElementById("distance-value"); + display.style.display = "block"; + value.textContent = distance.toFixed(4); +} + +// ============================================================================ +// Rescaling +// ============================================================================ + +function rescaleModel(newDistance) { + if (!splatMesh || state.currentDistance <= 0) { + console.warn("Cannot rescale: no model or zero distance"); + return; + } + + const scaleFactor = newDistance / state.currentDistance; + + // Scale all splat centers and scales + splatMesh.packedSplats.forEachSplat( + (i, center, scales, quat, opacity, color) => { + center.multiplyScalar(scaleFactor); + scales.multiplyScalar(scaleFactor); + splatMesh.packedSplats.setSplat(i, center, scales, quat, opacity, color); + }, + ); + + splatMesh.packedSplats.needsUpdate = true; + + // Update points and markers + if (state.point1) { + state.point1.multiplyScalar(scaleFactor); + state.marker1.position.copy(state.point1); + state.ray1Origin.multiplyScalar(scaleFactor); + updateRayLine(state.rayLine1, state.ray1Origin, state.ray1Direction); + } + + if (state.point2) { + state.point2.multiplyScalar(scaleFactor); + state.marker2.position.copy(state.point2); + state.ray2Origin.multiplyScalar(scaleFactor); + updateRayLine(state.rayLine2, state.ray2Origin, state.ray2Direction); + } + + updateDistanceLine(); + state.currentDistance = newDistance; + updateDistanceDisplay(newDistance); + guiParams.measuredDistance = newDistance.toFixed(4); +} + +// ============================================================================ +// Coordinate Origin Transform +// ============================================================================ + +function transformOriginTo(newOrigin) { + if (!splatMesh) return; + + // Calculate translation: move newOrigin to (0,0,0) + const translation = newOrigin.clone().negate(); + + // Transform all splat centers + splatMesh.packedSplats.forEachSplat( + (i, center, scales, quat, opacity, color) => { + center.add(translation); + splatMesh.packedSplats.setSplat(i, center, scales, quat, opacity, color); + }, + ); + splatMesh.packedSplats.needsUpdate = true; + + // Axes helper stays at (0,0,0) to mark the new origin + // No need to move it - it already represents world origin + + // Reset measurements (user preference) + resetSelection(); + + // Transform camera to maintain view + camera.position.add(translation); + + // TrackballControls don't have a target, just update + controls.update(); + + updateInstructions( + "Origin set! Left-click to measure | Right double-click for new origin", + ); +} + +// ============================================================================ +// Reset +// ============================================================================ + +function disposeObject(obj) { + if (!obj) return; + scene.remove(obj); + obj.traverse((child) => { + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (Array.isArray(child.material)) { + for (const m of child.material) { + m.dispose(); + } + } else { + child.material.dispose(); + } + } + }); +} + +function resetSelection() { + // Remove and dispose visual elements + disposeObject(state.marker1); + state.marker1 = null; + disposeObject(state.marker2); + state.marker2 = null; + disposeObject(state.rayLine1); + state.rayLine1 = null; + disposeObject(state.rayLine2); + state.rayLine2 = null; + disposeObject(state.distanceLine); + state.distanceLine = null; + + // Reset state + state.point1 = null; + state.point2 = null; + state.ray1Origin = null; + state.ray1Direction = null; + state.ray2Origin = null; + state.ray2Direction = null; + state.currentDistance = 0; + state.mode = "select1"; + state.dragging = null; + + // Update UI + document.getElementById("distance-display").style.display = "none"; + guiParams.measuredDistance = "0.0000"; + updateInstructions( + "Left-click to measure distance | Right double-click to set origin", + ); +} + +// ============================================================================ +// PLY Export +// ============================================================================ + +function exportPly() { + if (!splatMesh) { + console.warn("No model to export"); + return; + } + + const writer = new PlyWriter(splatMesh.packedSplats); + writer.downloadAs("rescaled_model.ply"); +} + +// ============================================================================ +// UI Updates +// ============================================================================ + +function updateInstructions(text) { + document.getElementById("instructions").textContent = text; +} + +// ============================================================================ +// Event Handlers +// ============================================================================ + +let pointerDownPos = null; + +renderer.domElement.addEventListener("pointerdown", (event) => { + pointerDownPos = { x: event.clientX, y: event.clientY }; + + const ndc = getMouseNDC(event); + + // Check if clicking on a marker to start dragging + const markerHit = checkMarkerHit(ndc); + if (markerHit) { + state.dragging = markerHit; + controls.enabled = false; + return; + } +}); + +renderer.domElement.addEventListener("pointermove", (event) => { + if (!state.dragging) return; + + const ndc = getMouseNDC(event); + raycaster.setFromCamera(ndc, camera); + + let newPoint; + if (state.dragging === "point1") { + newPoint = closestPointOnRay( + raycaster.ray, + state.ray1Origin, + state.ray1Direction, + state.point1, + ); + state.point1.copy(newPoint); + state.marker1.position.copy(newPoint); + } else if (state.dragging === "point2") { + newPoint = closestPointOnRay( + raycaster.ray, + state.ray2Origin, + state.ray2Direction, + state.point2, + ); + state.point2.copy(newPoint); + state.marker2.position.copy(newPoint); + } + + updateDistanceLine(); + calculateDistance(); +}); + +renderer.domElement.addEventListener("pointerup", (event) => { + if (state.dragging) { + state.dragging = null; + controls.enabled = true; + return; + } + + // Only handle left clicks for measurement points + if (event.button !== 0) return; + + // Check if it was a click (not a drag) + if (pointerDownPos) { + const dx = event.clientX - pointerDownPos.x; + const dy = event.clientY - pointerDownPos.y; + if (Math.sqrt(dx * dx + dy * dy) > 5) { + pointerDownPos = null; + return; // Was a drag, not a click + } + } + + if (!splatMesh) return; + + const ndc = getMouseNDC(event); + const hitPoint = getHitPoint(ndc); + + if (!hitPoint) return; + + if (state.mode === "select1") { + selectPoint1(hitPoint); + } else if (state.mode === "select2") { + selectPoint2(hitPoint); + } + + pointerDownPos = null; +}); + +// Right double-click detection using manual timing +let lastRightClickTime = 0; +let lastRightClickPos = null; +const RIGHT_DOUBLE_CLICK_DELAY = 300; // milliseconds + +// Track right mouse down position to detect drags +let rightPointerDownPos = null; + +renderer.domElement.addEventListener( + "mousedown", + (event) => { + if (event.button !== 2) return; // Only right button + rightPointerDownPos = { x: event.clientX, y: event.clientY }; + }, + { capture: true }, +); + +renderer.domElement.addEventListener( + "mouseup", + (event) => { + if (event.button !== 2) return; // Only right button + + // Check if it was a click (not a drag) + if (rightPointerDownPos) { + const dx = event.clientX - rightPointerDownPos.x; + const dy = event.clientY - rightPointerDownPos.y; + const dragDistance = Math.sqrt(dx * dx + dy * dy); + if (dragDistance > 5) { + rightPointerDownPos = null; + return; // Was a drag, not a click + } + } + + const now = Date.now(); + const currentPos = { x: event.clientX, y: event.clientY }; + + // Check if this is a double-click (same position, within time limit) + if (lastRightClickPos) { + const dx = currentPos.x - lastRightClickPos.x; + const dy = currentPos.y - lastRightClickPos.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const timeSinceLastClick = now - lastRightClickTime; + + if (timeSinceLastClick < RIGHT_DOUBLE_CLICK_DELAY && distance < 10) { + // Right double-click detected! + event.preventDefault(); + event.stopPropagation(); + + if (!splatMesh) { + console.warn("No model loaded"); + return; + } + + const ndc = getMouseNDC(event); + const hitPoint = getHitPoint(ndc); + + if (!hitPoint) { + lastRightClickTime = 0; + lastRightClickPos = null; + rightPointerDownPos = null; + return; + } + + transformOriginTo(hitPoint); + lastRightClickTime = 0; + lastRightClickPos = null; + rightPointerDownPos = null; + return; + } + } + + lastRightClickTime = now; + lastRightClickPos = currentPos; + rightPointerDownPos = null; + }, + { capture: true }, +); + +// Prevent context menu on right-click +renderer.domElement.addEventListener("contextmenu", (event) => { + event.preventDefault(); +}); + +// Drag and drop handlers +const onDragover = (e) => { + e.preventDefault(); + // Add visual feedback + renderer.domElement.style.outline = "3px solid #00ff00"; +}; + +const onDragLeave = (e) => { + e.preventDefault(); + if (e.target !== renderer.domElement) return; + renderer.domElement.style.outline = "none"; +}; + +const onDrop = (e) => { + e.preventDefault(); + renderer.domElement.style.outline = "none"; + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + const validExtensions = [".ply", ".spz", ".splat"]; + const ext = file.name.toLowerCase().slice(file.name.lastIndexOf(".")); + if (!validExtensions.includes(ext)) { + console.warn( + `Unsupported file type: ${ext}. Expected: ${validExtensions.join(", ")}`, + ); + return; + } + loadSplatFile(file); + } else { + console.warn("No files dropped"); + } +}; + +renderer.domElement.addEventListener("dragover", onDragover); +renderer.domElement.addEventListener("dragleave", onDragLeave); +renderer.domElement.addEventListener("drop", onDrop); + +// ============================================================================ +// GUI +// ============================================================================ + +const gui = new GUI(); +const guiParams = { + measuredDistance: "0.0000", + newDistance: 1.0, + loadPlyFile: () => { + // Trigger file input click + document.getElementById("file-input").click(); + }, + toggleAxes: () => toggleAxes(), + reset: resetSelection, + rescale: () => rescaleModel(guiParams.newDistance), + exportPly: exportPly, +}; + +// Add load button at the top +gui.add(guiParams, "loadPlyFile").name("Load PLY File"); +gui.add(guiParams, "toggleAxes").name("Toggle Axes"); + +// Measurement controls +gui + .add(guiParams, "measuredDistance") + .name("Measured Distance") + .listen() + .disable(); +gui.add(guiParams, "newDistance").name("New Distance"); +gui.add(guiParams, "rescale").name("Apply Rescale"); +gui.add(guiParams, "reset").name("Reset Points"); +gui.add(guiParams, "exportPly").name("Export PLY"); + +// ============================================================================ +// File Loading +// ============================================================================ + +async function loadSplatFile(urlOrFile) { + // Remove existing splat mesh + if (splatMesh) { + scene.remove(splatMesh); + splatMesh = null; + } + + resetSelection(); + updateInstructions("Loading model..."); + + try { + if (typeof urlOrFile === "string") { + // Load from URL + console.log("Loading from URL:", urlOrFile); + splatMesh = new SplatMesh({ url: urlOrFile }); + } else { + // Load from File object + console.log("Loading from file:", urlOrFile.name); + const arrayBuffer = await urlOrFile.arrayBuffer(); + console.log("File size:", arrayBuffer.byteLength, "bytes"); + splatMesh = new SplatMesh({ fileBytes: new Uint8Array(arrayBuffer) }); + } + + // No fixed rotation applied - users can rotate freely with OrbitControls + scene.add(splatMesh); + + await splatMesh.initialized; + console.log(`Loaded ${splatMesh.packedSplats.numSplats} splats`); + + // Auto-center camera on the model + centerCameraOnModel(); + + // Create or update coordinate axes + createOrUpdateAxes(); + + updateInstructions( + "Left-click to measure distance | Right double-click to set origin", + ); + } catch (error) { + console.error("Error loading splat:", error); + updateInstructions("Error loading model. Check console for details."); + } +} + +function centerCameraOnModel() { + if (!splatMesh) { + console.warn("centerCameraOnModel: no splatMesh"); + return; + } + + try { + // Use built-in getBoundingBox method + const bbox = splatMesh.getBoundingBox(true); + console.log("Bounding box:", bbox); + + const center = new THREE.Vector3(); + bbox.getCenter(center); + const size = new THREE.Vector3(); + bbox.getSize(size); + const maxDim = Math.max(size.x, size.y, size.z); + + console.log( + "Center:", + center.x.toFixed(2), + center.y.toFixed(2), + center.z.toFixed(2), + ); + console.log( + "Size:", + size.x.toFixed(2), + size.y.toFixed(2), + size.z.toFixed(2), + ); + console.log("Max dimension:", maxDim.toFixed(2)); + + if (maxDim === 0 || !Number.isFinite(maxDim)) { + console.warn("Invalid bounding box size"); + return; + } + + // Update ray line length based on model scale + rayLineLength = maxDim * 5; // 5x model size + console.log("Ray line length:", rayLineLength.toFixed(2)); + + // Position camera to see the entire model + const fov = camera.fov * (Math.PI / 180); + const cameraDistance = (maxDim / (2 * Math.tan(fov / 2))) * 1.5; + + camera.position.set(center.x, center.y, center.z + cameraDistance); + camera.lookAt(center); + camera.near = cameraDistance * 0.001; + camera.far = cameraDistance * 10; + camera.updateProjectionMatrix(); + + // Update TrackballControls + controls.update(); + + console.log( + "Camera position:", + camera.position.x.toFixed(2), + camera.position.y.toFixed(2), + camera.position.z.toFixed(2), + ); + console.log("Camera far:", camera.far); + } catch (error) { + console.error("Error computing bounding box:", error); + } +} + +function createOrUpdateAxes() { + if (!splatMesh) return; + + // Remove existing axes + if (state.axesHelper) { + disposeObject(state.axesHelper); + } + + // Get model bounding box to size axes appropriately + const bbox = splatMesh.getBoundingBox(true); + const size = new THREE.Vector3(); + bbox.getSize(size); + const maxDim = Math.max(size.x, size.y, size.z); + + // Create axes helper (1.5x model size) + state.axesHelper = new THREE.AxesHelper(maxDim * 1.5); + state.axesHelper.visible = state.axesVisible; + scene.add(state.axesHelper); +} + +function toggleAxes() { + if (!splatMesh) { + console.warn("No model loaded"); + return; + } + + state.axesVisible = !state.axesVisible; + + if (!state.axesHelper) { + createOrUpdateAxes(); + } else { + state.axesHelper.visible = state.axesVisible; + } + + // Update instructions to show state + const stateText = state.axesVisible ? "shown" : "hidden"; + console.log(`Axes ${stateText}`); +} + +// File input handler +document + .getElementById("file-input") + .addEventListener("change", async (event) => { + const file = event.target.files[0]; + if (file) { + await loadSplatFile(file); + } + }); + +// Load default asset +async function loadDefaultAsset() { + try { + const url = await getAssetFileURL("penguin.spz"); + if (url) { + await loadSplatFile(url); + } + } catch (error) { + console.error("Error loading default asset:", error); + } +} + +loadDefaultAsset(); + +// ============================================================================ +// Render Loop +// ============================================================================ + +function updateMarkerScale(marker) { + if (!marker) return; + + // Calculate distance from camera to marker + const distance = camera.position.distanceTo(marker.position); + + // Calculate scale to maintain constant screen size + // Based on FOV and desired screen percentage + const fov = camera.fov * (Math.PI / 180); + const scale = distance * Math.tan(fov / 2) * MARKER_SCREEN_SIZE; + + marker.scale.setScalar(scale); + + // Billboard: make ring face camera + if (marker.userData.ring) { + marker.userData.ring.lookAt(camera.position); + } +} + +renderer.setAnimationLoop(() => { + controls.update(); + + // Update marker scales to maintain constant screen size + updateMarkerScale(state.marker1); + updateMarkerScale(state.marker2); + + renderer.render(scene, camera); +}); diff --git a/examples/js/orbit-controls-utils.js b/examples/js/orbit-controls-utils.js new file mode 100644 index 00000000..f4ab395b --- /dev/null +++ b/examples/js/orbit-controls-utils.js @@ -0,0 +1,27 @@ +/** + * Utility functions for OrbitControls configuration + */ + +/** + * Configures OrbitControls for infinite rotation without angle limits. + * + * @param {OrbitControls} controls - The OrbitControls instance to configure + * @returns {OrbitControls} The configured controls instance + * + * @example + * import { setupInfiniteRotation } from './orbit-controls-utils.js'; + * + * const controls = new OrbitControls(camera, renderer.domElement); + * setupInfiniteRotation(controls); + */ +export function setupInfiniteRotation(controls) { + // Enable infinite horizontal rotation (azimuth) + controls.minAzimuthAngle = Number.NEGATIVE_INFINITY; + controls.maxAzimuthAngle = Number.POSITIVE_INFINITY; + + // Enable infinite vertical rotation (polar angle) + controls.minPolarAngle = Number.NEGATIVE_INFINITY; + controls.maxPolarAngle = Number.POSITIVE_INFINITY; + + return controls; +} diff --git a/index.html b/index.html index bd1e79ad..5eaedd4b 100644 --- a/index.html +++ b/index.html @@ -139,6 +139,7 @@

Examples

  • Multiple Viewpoints
  • Procedural Splats
  • Raycasting
  • +
  • Distance Measurement & Rescale
  • Dynamic Lighting
  • Particle Animation
  • Particle Simulation
  • diff --git a/lefthook.yml b/lefthook.yml index a6b9fee1..2386a268 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -3,4 +3,12 @@ pre-commit: - name: lint run: npm run lint - name: test - run: npm run test \ No newline at end of file + run: npm run test + - name: detect-secrets + run: | + if command -v detect-secrets >/dev/null 2>&1; then + detect-secrets-hook --baseline ~/.config/git/hooks/.secrets.baseline {staged_files} + else + echo "⚠️ Warning: detect-secrets not installed, skipping secret scan" + echo " Install with: pip install detect-secrets" + fi \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index bbbc39c4..5a95a803 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,8 @@ nav: - Dyno standard library: docs/dyno-stdlib.md - Controls: docs/controls.md - Performance tuning: docs/performance.md + - Distance Measurement & Rescale Guide: distance-rescale-guide.md + - Distance Measurement & Rescale Guide (日本語): distance-rescale-guide-ja.md - Community Resources: docs/community-resources.md markdown_extensions: - pymdownx.highlight: diff --git a/package-lock.json b/package-lock.json index 3e2da764..f3e7325f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1350,24 +1350,37 @@ } } }, + "node_modules/@vue/language-core/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2190,9 +2203,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index aee4e536..72691700 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,9 @@ "peerDependencies": { "three": "^0.178.0" }, - "keywords": ["3d", "three.js", "gsplats", "3dgs", "gaussian", "splats"] + "keywords": ["3d", "three.js", "gsplats", "3dgs", "gaussian", "splats"], + "overrides": { + "minimatch@<3.1.3": "3.1.3", + "minimatch@>=9.0.0 <9.0.7": "9.0.7" + } } diff --git a/src/PlyWriter.ts b/src/PlyWriter.ts new file mode 100644 index 00000000..31ba56dd --- /dev/null +++ b/src/PlyWriter.ts @@ -0,0 +1,166 @@ +// PLY file format writer for Gaussian Splatting data + +import type { PackedSplats } from "./PackedSplats"; +import { SH_C0 } from "./ply"; + +export type PlyWriterOptions = { + // Output format (default: binary_little_endian) + format?: "binary_little_endian" | "binary_big_endian"; +}; + +/** + * PlyWriter exports PackedSplats data to standard PLY format. + * + * The output PLY file is compatible with common 3DGS tools and can be + * re-imported into Spark or other Gaussian splatting renderers. + */ +export class PlyWriter { + packedSplats: PackedSplats; + options: Required; + + constructor(packedSplats: PackedSplats, options: PlyWriterOptions = {}) { + this.packedSplats = packedSplats; + this.options = { + format: options.format ?? "binary_little_endian", + }; + } + + /** + * Generate the PLY header string. + */ + private generateHeader(): string { + const numSplats = this.packedSplats.numSplats; + // PLY format uses underscores: binary_little_endian, binary_big_endian + const format = this.options.format; + + const lines = [ + "ply", + `format ${format} 1.0`, + `element vertex ${numSplats}`, + "property float x", + "property float y", + "property float z", + "property float scale_0", + "property float scale_1", + "property float scale_2", + "property float rot_0", + "property float rot_1", + "property float rot_2", + "property float rot_3", + "property float opacity", + "property float f_dc_0", + "property float f_dc_1", + "property float f_dc_2", + "end_header", + ]; + + return `${lines.join("\n")}\n`; + } + + /** + * Write binary data for all splats. + * Each splat is 14 float32 values = 56 bytes. + */ + private writeBinaryData(): ArrayBuffer { + const numSplats = this.packedSplats.numSplats; + const bytesPerSplat = 14 * 4; // 14 float32 properties + const buffer = new ArrayBuffer(numSplats * bytesPerSplat); + const dataView = new DataView(buffer); + const littleEndian = this.options.format === "binary_little_endian"; + + let offset = 0; + + this.packedSplats.forEachSplat( + (index, center, scales, quaternion, opacity, color) => { + // Position: x, y, z + dataView.setFloat32(offset, center.x, littleEndian); + offset += 4; + dataView.setFloat32(offset, center.y, littleEndian); + offset += 4; + dataView.setFloat32(offset, center.z, littleEndian); + offset += 4; + + // Scale: log scale (scale_0, scale_1, scale_2) + // Splats with scale=0 are 2DGS, use a very small value + const lnScaleX = scales.x > 0 ? Math.log(scales.x) : -12; + const lnScaleY = scales.y > 0 ? Math.log(scales.y) : -12; + const lnScaleZ = scales.z > 0 ? Math.log(scales.z) : -12; + dataView.setFloat32(offset, lnScaleX, littleEndian); + offset += 4; + dataView.setFloat32(offset, lnScaleY, littleEndian); + offset += 4; + dataView.setFloat32(offset, lnScaleZ, littleEndian); + offset += 4; + + // Rotation: quaternion (rot_0=w, rot_1=x, rot_2=y, rot_3=z) + dataView.setFloat32(offset, quaternion.w, littleEndian); + offset += 4; + dataView.setFloat32(offset, quaternion.x, littleEndian); + offset += 4; + dataView.setFloat32(offset, quaternion.y, littleEndian); + offset += 4; + dataView.setFloat32(offset, quaternion.z, littleEndian); + offset += 4; + + // Opacity: inverse sigmoid + // opacity = 1 / (1 + exp(-x)) => x = -ln(1/opacity - 1) = ln(opacity / (1 - opacity)) + // Clamp opacity to avoid log(0) or log(inf) + const clampedOpacity = Math.max(0.001, Math.min(0.999, opacity)); + const sigmoidOpacity = Math.log(clampedOpacity / (1 - clampedOpacity)); + dataView.setFloat32(offset, sigmoidOpacity, littleEndian); + offset += 4; + + // Color: DC coefficients (f_dc_0, f_dc_1, f_dc_2) + // color = f_dc * SH_C0 + 0.5 => f_dc = (color - 0.5) / SH_C0 + const f_dc_0 = (color.r - 0.5) / SH_C0; + const f_dc_1 = (color.g - 0.5) / SH_C0; + const f_dc_2 = (color.b - 0.5) / SH_C0; + dataView.setFloat32(offset, f_dc_0, littleEndian); + offset += 4; + dataView.setFloat32(offset, f_dc_1, littleEndian); + offset += 4; + dataView.setFloat32(offset, f_dc_2, littleEndian); + offset += 4; + }, + ); + + return buffer; + } + + /** + * Export the PackedSplats as a complete PLY file. + * @returns Uint8Array containing the PLY file data + */ + export(): Uint8Array { + const header = this.generateHeader(); + const headerBytes = new TextEncoder().encode(header); + const binaryData = this.writeBinaryData(); + + // Combine header and binary data + const result = new Uint8Array(headerBytes.length + binaryData.byteLength); + result.set(headerBytes, 0); + result.set(new Uint8Array(binaryData), headerBytes.length); + + return result; + } + + /** + * Export and trigger a file download. + * @param filename The name of the file to download + */ + downloadAs(filename: string): void { + const data = this.export(); + const blob = new Blob([data], { type: "application/octet-stream" }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); + } +} diff --git a/src/index.ts b/src/index.ts index 8288360b..b8ea944d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export { isPcSogs, } from "./SplatLoader"; export { PlyReader } from "./ply"; +export { PlyWriter, type PlyWriterOptions } from "./PlyWriter"; export { SpzReader, SpzWriter, transcodeSpz } from "./spz"; export { PackedSplats, type PackedSplatsOptions } from "./PackedSplats"; diff --git a/test/PlyWriter.test.ts b/test/PlyWriter.test.ts new file mode 100644 index 00000000..bfe18203 --- /dev/null +++ b/test/PlyWriter.test.ts @@ -0,0 +1,619 @@ +import assert from "node:assert"; +import type { PackedSplats } from "../src/PackedSplats.js"; +import { PlyWriter } from "../src/PlyWriter.js"; +import { SH_C0 } from "../src/ply.js"; + +// Mock Vector3-like object +interface Vec3 { + x: number; + y: number; + z: number; +} + +// Mock Quaternion-like object +interface Quat { + x: number; + y: number; + z: number; + w: number; +} + +// Mock Color-like object +interface Col { + r: number; + g: number; + b: number; +} + +// Mock splat data structure +interface MockSplat { + center: Vec3; + scales: Vec3; + quaternion: Quat; + opacity: number; + color: Col; +} + +// Create a mock PackedSplats that mimics the real interface +function createMockPackedSplats(splats: MockSplat[]): PackedSplats { + return { + numSplats: splats.length, + forEachSplat( + callback: ( + index: number, + center: Vec3, + scales: Vec3, + quaternion: Quat, + opacity: number, + color: Col, + ) => void, + ) { + for (let i = 0; i < splats.length; i++) { + const s = splats[i]; + callback(i, s.center, s.scales, s.quaternion, s.opacity, s.color); + } + }, + } as PackedSplats; +} + +// Helper to find header end in PLY data +function findHeaderEnd(data: Uint8Array): number { + const decoder = new TextDecoder(); + for (let i = 0; i < data.length - 10; i++) { + const slice = decoder.decode(data.slice(i, i + 11)); + if (slice === "end_header\n") { + return i + 11; + } + } + return -1; +} + +// Test 1: PlyWriter constructor with default options +{ + const mockSplats = createMockPackedSplats([]); + const writer = new PlyWriter(mockSplats); + + assert.strictEqual( + writer.options.format, + "binary_little_endian", + "Default format should be binary_little_endian", + ); +} + +// Test 2: PlyWriter constructor with custom format +{ + const mockSplats = createMockPackedSplats([]); + const writer = new PlyWriter(mockSplats, { format: "binary_big_endian" }); + + assert.strictEqual( + writer.options.format, + "binary_big_endian", + "Custom format should be respected", + ); +} + +// Test 3: Export generates valid PLY header +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + { + center: { x: 1, y: 1, z: 1 }, + scales: { x: 0.2, y: 0.2, z: 0.2 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.8, + color: { r: 1.0, g: 0.5, b: 0.0 }, + }, + { + center: { x: 2, y: 2, z: 2 }, + scales: { x: 0.3, y: 0.3, z: 0.3 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 1.0, + color: { r: 0.0, g: 1.0, b: 0.5 }, + }, + ]); + + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + assert.ok(headerEndIndex > 0, "Should find end_header marker"); + + const header = new TextDecoder().decode(result.slice(0, headerEndIndex)); + + assert.ok(header.startsWith("ply\n"), "Header should start with 'ply'"); + assert.ok( + header.includes("format binary_little_endian 1.0"), + "Header should include format", + ); + assert.ok( + header.includes("element vertex 3"), + "Header should include correct vertex count", + ); + assert.ok(header.includes("property float x"), "Header should include x"); + assert.ok(header.includes("property float y"), "Header should include y"); + assert.ok(header.includes("property float z"), "Header should include z"); + assert.ok( + header.includes("property float scale_0"), + "Header should include scale_0", + ); + assert.ok( + header.includes("property float scale_1"), + "Header should include scale_1", + ); + assert.ok( + header.includes("property float scale_2"), + "Header should include scale_2", + ); + assert.ok( + header.includes("property float rot_0"), + "Header should include rot_0", + ); + assert.ok( + header.includes("property float rot_1"), + "Header should include rot_1", + ); + assert.ok( + header.includes("property float rot_2"), + "Header should include rot_2", + ); + assert.ok( + header.includes("property float rot_3"), + "Header should include rot_3", + ); + assert.ok( + header.includes("property float opacity"), + "Header should include opacity", + ); + assert.ok( + header.includes("property float f_dc_0"), + "Header should include f_dc_0", + ); + assert.ok( + header.includes("property float f_dc_1"), + "Header should include f_dc_1", + ); + assert.ok( + header.includes("property float f_dc_2"), + "Header should include f_dc_2", + ); + assert.ok(header.includes("end_header"), "Header should end with end_header"); +} + +// Test 4: Export generates correct binary size +{ + const numSplats = 5; + const splats: MockSplat[] = []; + for (let i = 0; i < numSplats; i++) { + splats.push({ + center: { x: i, y: i, z: i }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }); + } + + const mockSplats = createMockPackedSplats(splats); + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + // Each splat is 14 float32 = 56 bytes + const bytesPerSplat = 14 * 4; + const expectedBinarySize = numSplats * bytesPerSplat; + + const headerEndIndex = findHeaderEnd(result); + const binarySize = result.length - headerEndIndex; + + assert.strictEqual( + binarySize, + expectedBinarySize, + `Binary data size should be ${expectedBinarySize} bytes (${numSplats} splats * 56 bytes)`, + ); +} + +// Test 5: Binary data contains correct position values (little endian) +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 1.5, y: 2.5, z: 3.5 }, + scales: { x: 0.1, y: 0.2, z: 0.3 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + const binaryData = result.slice(headerEndIndex); + const dataView = new DataView(binaryData.buffer, binaryData.byteOffset); + + // Position is first 3 floats (little endian) + const x = dataView.getFloat32(0, true); + const y = dataView.getFloat32(4, true); + const z = dataView.getFloat32(8, true); + + assert.strictEqual(x, 1.5, "X position should be 1.5"); + assert.strictEqual(y, 2.5, "Y position should be 2.5"); + assert.strictEqual(z, 3.5, "Z position should be 3.5"); +} + +// Test 6: Scale values are log-encoded +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 1.0, y: Math.E, z: Math.exp(2) }, // log: 0, 1, 2 + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + const binaryData = result.slice(headerEndIndex); + const dataView = new DataView(binaryData.buffer, binaryData.byteOffset); + + // Scale values start at offset 12 (after x, y, z) + const scale0 = dataView.getFloat32(12, true); + const scale1 = dataView.getFloat32(16, true); + const scale2 = dataView.getFloat32(20, true); + + assert.ok( + Math.abs(scale0 - 0) < 0.0001, + `Log scale_0 for scale=1 should be 0, got ${scale0}`, + ); + assert.ok( + Math.abs(scale1 - 1) < 0.0001, + `Log scale_1 for scale=e should be 1, got ${scale1}`, + ); + assert.ok( + Math.abs(scale2 - 2) < 0.0001, + `Log scale_2 for scale=e^2 should be 2, got ${scale2}`, + ); +} + +// Test 7: Zero scale uses fallback value +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 0, y: 0, z: 0 }, // Zero scale (2DGS) + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + const binaryData = result.slice(headerEndIndex); + const dataView = new DataView(binaryData.buffer, binaryData.byteOffset); + + // Scale values start at offset 12 + const scale0 = dataView.getFloat32(12, true); + const scale1 = dataView.getFloat32(16, true); + const scale2 = dataView.getFloat32(20, true); + + // Zero scale should use -12 as fallback + assert.strictEqual(scale0, -12, "Zero scale_0 should use -12 fallback"); + assert.strictEqual(scale1, -12, "Zero scale_1 should use -12 fallback"); + assert.strictEqual(scale2, -12, "Zero scale_2 should use -12 fallback"); +} + +// Test 8: Quaternion values are correctly written +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.9 }, // Custom rotation + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + const binaryData = result.slice(headerEndIndex); + const dataView = new DataView(binaryData.buffer, binaryData.byteOffset); + + // Quaternion starts at offset 24 (after x,y,z,scale0,1,2) + // Order is w, x, y, z (rot_0=w, rot_1=x, rot_2=y, rot_3=z) + const rot0 = dataView.getFloat32(24, true); // w + const rot1 = dataView.getFloat32(28, true); // x + const rot2 = dataView.getFloat32(32, true); // y + const rot3 = dataView.getFloat32(36, true); // z + + assert.ok( + Math.abs(rot0 - 0.9) < 0.0001, + `rot_0 (w) should be 0.9, got ${rot0}`, + ); + assert.ok( + Math.abs(rot1 - 0.1) < 0.0001, + `rot_1 (x) should be 0.1, got ${rot1}`, + ); + assert.ok( + Math.abs(rot2 - 0.2) < 0.0001, + `rot_2 (y) should be 0.2, got ${rot2}`, + ); + assert.ok( + Math.abs(rot3 - 0.3) < 0.0001, + `rot_3 (z) should be 0.3, got ${rot3}`, + ); +} + +// Test 9: Opacity is sigmoid-encoded +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, // sigmoid inverse = ln(0.5/0.5) = 0 + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + const binaryData = result.slice(headerEndIndex); + const dataView = new DataView(binaryData.buffer, binaryData.byteOffset); + + // Opacity is at offset 40 (after x,y,z, scale0,1,2, rot0,1,2,3) + const sigmoidOpacity = dataView.getFloat32(40, true); + + assert.ok( + Math.abs(sigmoidOpacity) < 0.0001, + `Sigmoid opacity for 0.5 should be 0, got ${sigmoidOpacity}`, + ); +} + +// Test 10: Opacity edge cases are clamped +{ + // Test opacity = 1.0 (would be inf without clamping) + const mockSplats1 = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 1.0, // Clamped to 0.999 + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer1 = new PlyWriter(mockSplats1); + const result1 = writer1.export(); + const headerEndIndex1 = findHeaderEnd(result1); + const binaryData1 = result1.slice(headerEndIndex1); + const dataView1 = new DataView(binaryData1.buffer, binaryData1.byteOffset); + const opacity1 = dataView1.getFloat32(40, true); + + assert.ok( + Number.isFinite(opacity1), + `Opacity 1.0 should produce finite value, got ${opacity1}`, + ); + assert.ok(opacity1 > 0, "Opacity 1.0 should produce positive sigmoid value"); + + // Test opacity = 0.0 (would be -inf without clamping) + const mockSplats0 = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.0, // Clamped to 0.001 + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer0 = new PlyWriter(mockSplats0); + const result0 = writer0.export(); + const headerEndIndex0 = findHeaderEnd(result0); + const binaryData0 = result0.slice(headerEndIndex0); + const dataView0 = new DataView(binaryData0.buffer, binaryData0.byteOffset); + const opacity0 = dataView0.getFloat32(40, true); + + assert.ok( + Number.isFinite(opacity0), + `Opacity 0.0 should produce finite value, got ${opacity0}`, + ); + assert.ok(opacity0 < 0, "Opacity 0.0 should produce negative sigmoid value"); +} + +// Test 11: Color DC coefficients are correctly encoded +{ + // color = f_dc * SH_C0 + 0.5 => f_dc = (color - 0.5) / SH_C0 + const testColor = { r: 0.75, g: 0.25, b: 1.0 }; + const expectedDC0 = (testColor.r - 0.5) / SH_C0; + const expectedDC1 = (testColor.g - 0.5) / SH_C0; + const expectedDC2 = (testColor.b - 0.5) / SH_C0; + + const mockSplats = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: testColor, + }, + ]); + + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + const binaryData = result.slice(headerEndIndex); + const dataView = new DataView(binaryData.buffer, binaryData.byteOffset); + + // Color DC coefficients start at offset 44 (after opacity) + const f_dc_0 = dataView.getFloat32(44, true); + const f_dc_1 = dataView.getFloat32(48, true); + const f_dc_2 = dataView.getFloat32(52, true); + + assert.ok( + Math.abs(f_dc_0 - expectedDC0) < 0.0001, + `f_dc_0 should be ${expectedDC0}, got ${f_dc_0}`, + ); + assert.ok( + Math.abs(f_dc_1 - expectedDC1) < 0.0001, + `f_dc_1 should be ${expectedDC1}, got ${f_dc_1}`, + ); + assert.ok( + Math.abs(f_dc_2 - expectedDC2) < 0.0001, + `f_dc_2 should be ${expectedDC2}, got ${f_dc_2}`, + ); +} + +// Test 12: Empty PackedSplats exports correctly +{ + const mockSplats = createMockPackedSplats([]); + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const decoder = new TextDecoder(); + const headerStr = decoder.decode(result); + + assert.ok( + headerStr.includes("element vertex 0"), + "Empty export should have 0 vertices", + ); + assert.ok( + headerStr.includes("end_header"), + "Empty export should have valid header", + ); + + // Should only contain header, no binary data + const headerEnd = findHeaderEnd(result); + assert.strictEqual( + result.length, + headerEnd, + "Empty export should have no binary data after header", + ); +} + +// Test 13: Big endian format header +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 1, y: 2, z: 3 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer = new PlyWriter(mockSplats, { format: "binary_big_endian" }); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + const header = new TextDecoder().decode(result.slice(0, headerEndIndex)); + + assert.ok( + header.includes("format binary_big_endian 1.0"), + "Header should specify big endian format", + ); +} + +// Test 14: Big endian binary data is byte-swapped +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 1.5, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const littleWriter = new PlyWriter(mockSplats, { + format: "binary_little_endian", + }); + const bigWriter = new PlyWriter(mockSplats, { format: "binary_big_endian" }); + + const littleResult = littleWriter.export(); + const bigResult = bigWriter.export(); + + const littleHeaderEnd = findHeaderEnd(littleResult); + const bigHeaderEnd = findHeaderEnd(bigResult); + + const littleBinary = littleResult.slice(littleHeaderEnd); + const bigBinary = bigResult.slice(bigHeaderEnd); + + // Read x value from both + const littleView = new DataView(littleBinary.buffer, littleBinary.byteOffset); + const bigView = new DataView(bigBinary.buffer, bigBinary.byteOffset); + + const littleX = littleView.getFloat32(0, true); // Read as little endian + const bigX = bigView.getFloat32(0, false); // Read as big endian + + assert.strictEqual(littleX, 1.5, "Little endian X should be 1.5"); + assert.strictEqual( + bigX, + 1.5, + "Big endian X should be 1.5 when read correctly", + ); +} + +// Test 15: Multiple splats are exported in order +{ + const mockSplats = createMockPackedSplats([ + { + center: { x: 0, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + { + center: { x: 10, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + { + center: { x: 20, y: 0, z: 0 }, + scales: { x: 0.1, y: 0.1, z: 0.1 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + opacity: 0.5, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, + ]); + + const writer = new PlyWriter(mockSplats); + const result = writer.export(); + + const headerEndIndex = findHeaderEnd(result); + const binaryData = result.slice(headerEndIndex); + const dataView = new DataView(binaryData.buffer, binaryData.byteOffset); + + // Each splat is 56 bytes + const x0 = dataView.getFloat32(0, true); + const x1 = dataView.getFloat32(56, true); + const x2 = dataView.getFloat32(112, true); + + assert.strictEqual(x0, 0, "First splat X should be 0"); + assert.strictEqual(x1, 10, "Second splat X should be 10"); + assert.strictEqual(x2, 20, "Third splat X should be 20"); +} + +console.log("✅ All PlyWriter test cases passed!");