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 FieldSplat TextureEditor
+ 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 @@