From f8c7eb9651e3e8971df2c2f1025262c0876ca80c Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 13:42:18 +0900 Subject: [PATCH 01/10] Add distance measurement and rescaling feature for 3DGS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PlyWriter class to export PackedSplats to standard PLY format - Add distance-rescale example with interactive point selection - Support ray-based point selection with visible ray lines - Allow dragging points along ray lines to adjust depth - Implement model rescaling based on measured vs desired distance - Export rescaled models as PLY files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/distance-rescale/index.html | 109 +++++ examples/distance-rescale/main.js | 571 +++++++++++++++++++++++++++ src/PlyWriter.ts | 165 ++++++++ src/index.ts | 1 + 4 files changed, 846 insertions(+) create mode 100644 examples/distance-rescale/index.html create mode 100644 examples/distance-rescale/main.js create mode 100644 src/PlyWriter.ts diff --git a/examples/distance-rescale/index.html b/examples/distance-rescale/index.html new file mode 100644 index 0000000..ecaad5f --- /dev/null +++ b/examples/distance-rescale/index.html @@ -0,0 +1,109 @@ + + + + + + + Spark - Distance Measurement & Rescale + + + + +
Click on the model to select first measurement point
+ +
+ + +
+ +
+
Distance
+
0.000
+
+ + + + + + diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js new file mode 100644 index 0000000..ec187c7 --- /dev/null +++ b/examples/distance-rescale/main.js @@ -0,0 +1,571 @@ +import { + PlyWriter, + SparkControls, + SparkRenderer, + SplatMesh, +} from "@sparkjsdev/spark"; +import { GUI } from "lil-gui"; +import * as THREE from "three"; +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, + 1000, +); +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 +const controls = new SparkControls({ + control: camera, + canvas: renderer.domElement, +}); +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); +} + +// ============================================================================ +// 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 +}; + +let splatMesh = null; +const raycaster = new THREE.Raycaster(); + +// ============================================================================ +// Visual Elements +// ============================================================================ + +const MARKER_RADIUS = 0.02; +const POINT1_COLOR = 0x00ff00; // Green +const POINT2_COLOR = 0x0088ff; // Blue +const DISTANCE_LINE_COLOR = 0xffff00; // Yellow + +function createMarker(color) { + const geometry = new THREE.SphereGeometry(MARKER_RADIUS, 16, 16); + const material = new THREE.MeshBasicMaterial({ color, depthTest: false }); + const mesh = new THREE.Mesh(geometry, material); + mesh.renderOrder = 999; + return mesh; +} + +function createRayLine(origin, direction, color) { + const farPoint = origin.clone().add(direction.clone().multiplyScalar(100)); + 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(100)); + 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("Click on the model 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 position along ray lines"); +} + +// ============================================================================ +// Drag Along Ray +// ============================================================================ + +function closestPointOnRay(viewRay, rayOrigin, rayDir) { + // 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 + return rayOrigin.clone().add(rayDir.clone().multiplyScalar(1)); + } + + const t = (b * e - c * d) / denom; + + // Clamp t to reasonable range + const clampedT = Math.max(0.1, Math.min(100, 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; + + const hits = raycaster.intersectObjects(objects); + if (hits.length > 0) { + return hits[0].object === state.marker1 ? "point1" : "point2"; + } + 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); +} + +// ============================================================================ +// Reset +// ============================================================================ + +function resetSelection() { + // Remove visual elements + if (state.marker1) { + scene.remove(state.marker1); + state.marker1 = null; + } + if (state.marker2) { + scene.remove(state.marker2); + state.marker2 = null; + } + if (state.rayLine1) { + scene.remove(state.rayLine1); + state.rayLine1 = null; + } + if (state.rayLine2) { + scene.remove(state.rayLine2); + state.rayLine2 = null; + } + if (state.distanceLine) { + scene.remove(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("Click on the model to select first measurement point"); +} + +// ============================================================================ +// 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.copy(newPoint); + state.marker1.position.copy(newPoint); + } else { + newPoint = closestPointOnRay( + raycaster.ray, + state.ray2Origin, + state.ray2Direction, + ); + 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; + } + + // 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; +}); + +// ============================================================================ +// GUI +// ============================================================================ + +const gui = new GUI(); +const guiParams = { + measuredDistance: "0.0000", + newDistance: 1.0, + reset: resetSelection, + rescale: () => rescaleModel(guiParams.newDistance), + exportPly: exportPly, +}; + +gui + .add(guiParams, "measuredDistance") + .name("Measured Distance") + .listen() + .disable(); +gui.add(guiParams, "newDistance", 0.001, 100).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(); + + try { + if (typeof urlOrFile === "string") { + // Load from URL + splatMesh = new SplatMesh({ url: urlOrFile }); + } else { + // Load from File object + const arrayBuffer = await urlOrFile.arrayBuffer(); + splatMesh = new SplatMesh({ fileBytes: new Uint8Array(arrayBuffer) }); + } + + splatMesh.rotation.x = Math.PI; // Common orientation fix + scene.add(splatMesh); + + await splatMesh.initialized; + console.log(`Loaded ${splatMesh.packedSplats.numSplats} splats`); + } catch (error) { + console.error("Error loading splat:", error); + } +} + +// 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 +// ============================================================================ + +renderer.setAnimationLoop((time) => { + controls.update(time); + renderer.render(scene, camera); +}); diff --git a/src/PlyWriter.ts b/src/PlyWriter.ts new file mode 100644 index 0000000..1390e2d --- /dev/null +++ b/src/PlyWriter.ts @@ -0,0 +1,165 @@ +// 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; + const format = this.options.format.replace("_", " "); + + 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 8288360..b8ea944 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"; From 9ccb266af48480dc6043167f71f078330e3aed6d Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 13:42:18 +0900 Subject: [PATCH 02/10] Add distance measurement and rescaling feature for 3DGS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PlyWriter class to export PackedSplats to standard PLY format - Add distance-rescale example with interactive point selection - Support ray-based point selection with visible ray lines - Allow dragging points along ray lines to adjust depth - Implement model rescaling based on measured vs desired distance - Export rescaled models as PLY files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/index.html b/index.html index bd1e79a..5eaedd4 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
  • From cb569d4ab1d87d861292fd1d85a9ec89104621b9 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 14:05:17 +0900 Subject: [PATCH 03/10] Fix distance-rescale example for arbitrary model sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch from SparkControls to OrbitControls for reliability - Auto-center camera on model using getBoundingBox - Dynamic marker/ray sizing based on model dimensions - Change newDistance to text input (was slider limited to 0.001-100) - Add proper rotation.x = Math.PI for PLY orientation - Improve error handling and add debug logging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/distance-rescale/main.js | 128 ++++++++++++++++++++++++------ 1 file changed, 105 insertions(+), 23 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index ec187c7..99d2a14 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -1,11 +1,7 @@ -import { - PlyWriter, - SparkControls, - SparkRenderer, - SplatMesh, -} from "@sparkjsdev/spark"; +import { PlyWriter, SparkRenderer, SplatMesh } from "@sparkjsdev/spark"; import { GUI } from "lil-gui"; import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { getAssetFileURL } from "/examples/js/get-asset-url.js"; // ============================================================================ @@ -17,7 +13,7 @@ const camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, - 1000, + 100000, ); const renderer = new THREE.WebGLRenderer({ antialias: false }); renderer.setSize(window.innerWidth, window.innerHeight); @@ -26,11 +22,10 @@ document.body.appendChild(renderer.domElement); const spark = new SparkRenderer({ renderer }); scene.add(spark); -// Camera controls -const controls = new SparkControls({ - control: camera, - canvas: renderer.domElement, -}); +// Camera controls - using OrbitControls for reliability +const controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; +controls.dampingFactor = 0.05; camera.position.set(0, 2, 5); camera.lookAt(0, 0, 0); @@ -76,13 +71,14 @@ const raycaster = new THREE.Raycaster(); // Visual Elements // ============================================================================ -const MARKER_RADIUS = 0.02; +let markerRadius = 0.02; // Will be updated based on model size +let rayLineLength = 100; // Will be updated based on model size const POINT1_COLOR = 0x00ff00; // Green const POINT2_COLOR = 0x0088ff; // Blue const DISTANCE_LINE_COLOR = 0xffff00; // Yellow function createMarker(color) { - const geometry = new THREE.SphereGeometry(MARKER_RADIUS, 16, 16); + const geometry = new THREE.SphereGeometry(markerRadius, 16, 16); const material = new THREE.MeshBasicMaterial({ color, depthTest: false }); const mesh = new THREE.Mesh(geometry, material); mesh.renderOrder = 999; @@ -90,7 +86,9 @@ function createMarker(color) { } function createRayLine(origin, direction, color) { - const farPoint = origin.clone().add(direction.clone().multiplyScalar(100)); + const farPoint = origin + .clone() + .add(direction.clone().multiplyScalar(rayLineLength)); const geometry = new THREE.BufferGeometry().setFromPoints([origin, farPoint]); const material = new THREE.LineBasicMaterial({ color, @@ -105,7 +103,9 @@ function createRayLine(origin, direction, color) { function updateRayLine(line, origin, direction) { const positions = line.geometry.attributes.position.array; - const farPoint = origin.clone().add(direction.clone().multiplyScalar(100)); + const farPoint = origin + .clone() + .add(direction.clone().multiplyScalar(rayLineLength)); positions[0] = origin.x; positions[1] = origin.y; positions[2] = origin.z; @@ -241,13 +241,17 @@ function closestPointOnRay(viewRay, rayOrigin, rayDir) { const denom = a * c - b * b; if (Math.abs(denom) < 0.0001) { // Rays are nearly parallel - return rayOrigin.clone().add(rayDir.clone().multiplyScalar(1)); + return rayOrigin + .clone() + .add(rayDir.clone().multiplyScalar(markerRadius * 10)); } const t = (b * e - c * d) / denom; - // Clamp t to reasonable range - const clampedT = Math.max(0.1, Math.min(100, t)); + // Clamp t to reasonable range based on model size + const minT = markerRadius * 5; + const maxT = rayLineLength; + const clampedT = Math.max(minT, Math.min(maxT, t)); return rayOrigin.clone().add(rayDir.clone().multiplyScalar(clampedT)); } @@ -499,7 +503,7 @@ gui .name("Measured Distance") .listen() .disable(); -gui.add(guiParams, "newDistance", 0.001, 100).name("New Distance"); +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"); @@ -516,24 +520,102 @@ async function loadSplatFile(urlOrFile) { } 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) }); } - splatMesh.rotation.x = Math.PI; // Common orientation fix + // Apply rotation to match common PLY orientation + splatMesh.rotation.x = Math.PI; scene.add(splatMesh); await splatMesh.initialized; console.log(`Loaded ${splatMesh.packedSplats.numSplats} splats`); + + // Auto-center camera on the model + centerCameraOnModel(); + updateInstructions("Click on the model to select first measurement point"); } 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 marker and ray sizes based on model scale + markerRadius = maxDim * 0.005; // 0.5% of model size + rayLineLength = maxDim * 5; // 5x model size + console.log("Marker radius:", markerRadius.toFixed(4)); + 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 OrbitControls target + controls.target.copy(center); + 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); } } @@ -565,7 +647,7 @@ loadDefaultAsset(); // Render Loop // ============================================================================ -renderer.setAnimationLoop((time) => { - controls.update(time); +renderer.setAnimationLoop(() => { + controls.update(); renderer.render(scene, camera); }); From c9bb9b46e0f07051067f2e61b523a0134d7acee9 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 14:07:46 +0900 Subject: [PATCH 04/10] Improve marker visibility with larger size and white ring outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase marker radius from 0.5% to 2% of model size - Add white ring outline around markers for contrast - Ring billboards to always face camera - Markers now visible against any background 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/distance-rescale/main.js | 48 ++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index 99d2a14..08d2eb7 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -78,11 +78,42 @@ 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 + const group = new THREE.Group(); + + // Inner sphere const geometry = new THREE.SphereGeometry(markerRadius, 16, 16); - const material = new THREE.MeshBasicMaterial({ color, depthTest: false }); + const material = new THREE.MeshBasicMaterial({ + color, + depthTest: false, + transparent: true, + opacity: 0.9, + }); const mesh = new THREE.Mesh(geometry, material); - mesh.renderOrder = 999; - return mesh; + mesh.renderOrder = 1000; + group.add(mesh); + + // Outer ring/outline for better visibility + const ringGeometry = new THREE.RingGeometry( + markerRadius * 1.2, + markerRadius * 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) { @@ -588,7 +619,7 @@ function centerCameraOnModel() { } // Update marker and ray sizes based on model scale - markerRadius = maxDim * 0.005; // 0.5% of model size + markerRadius = maxDim * 0.02; // 2% of model size for better visibility rayLineLength = maxDim * 5; // 5x model size console.log("Marker radius:", markerRadius.toFixed(4)); console.log("Ray line length:", rayLineLength.toFixed(2)); @@ -649,5 +680,14 @@ loadDefaultAsset(); renderer.setAnimationLoop(() => { controls.update(); + + // Make marker rings always face the camera (billboard effect) + if (state.marker1?.userData.ring) { + state.marker1.userData.ring.lookAt(camera.position); + } + if (state.marker2?.userData.ring) { + state.marker2.userData.ring.lookAt(camera.position); + } + renderer.render(scene, camera); }); From aa9f7cf3f4672d889465dfcbfcf912c61862b926 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 14:15:12 +0900 Subject: [PATCH 05/10] Fix point placement and movement restrictions on ray line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove overly restrictive minT clamping (was markerRadius*5, now 0.01) - Allow movement beyond visible ray line (maxT = rayLineLength * 2) - Keep current point when rays are parallel instead of jumping - Pass current point to closestPointOnRay for better fallback behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/distance-rescale/main.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index 08d2eb7..c80ee13 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -260,7 +260,7 @@ function selectPoint2(hitPoint) { // Drag Along Ray // ============================================================================ -function closestPointOnRay(viewRay, rayOrigin, rayDir) { +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); @@ -271,17 +271,15 @@ function closestPointOnRay(viewRay, rayOrigin, rayDir) { const denom = a * c - b * b; if (Math.abs(denom) < 0.0001) { - // Rays are nearly parallel - return rayOrigin - .clone() - .add(rayDir.clone().multiplyScalar(markerRadius * 10)); + // Rays are nearly parallel - keep current point + return currentPoint.clone(); } const t = (b * e - c * d) / denom; - // Clamp t to reasonable range based on model size - const minT = markerRadius * 5; - const maxT = rayLineLength; + // 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)); } @@ -466,6 +464,7 @@ renderer.domElement.addEventListener("pointermove", (event) => { raycaster.ray, state.ray1Origin, state.ray1Direction, + state.point1, ); state.point1.copy(newPoint); state.marker1.position.copy(newPoint); @@ -474,6 +473,7 @@ renderer.domElement.addEventListener("pointermove", (event) => { raycaster.ray, state.ray2Origin, state.ray2Direction, + state.point2, ); state.point2.copy(newPoint); state.marker2.position.copy(newPoint); From 42485693e8957507b8a4ba24604806b0ff7e983d Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 14:19:59 +0900 Subject: [PATCH 06/10] Fix marker drag detection for Group objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The markers are now THREE.Group (sphere + ring), so intersectObjects returns child meshes, not the group. Walk up the parent chain to find which marker group was actually hit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/distance-rescale/main.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index c80ee13..fc33aa4 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -293,9 +293,16 @@ function checkMarkerHit(ndc) { if (objects.length === 0) return null; - const hits = raycaster.intersectObjects(objects); + // Use recursive=true to hit children (sphere and ring inside group) + const hits = raycaster.intersectObjects(objects, true); if (hits.length > 0) { - return hits[0].object === state.marker1 ? "point1" : "point2"; + // 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; } From 221751bdd848b0011541fb52bb2e8d4b0de64f09 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 14:29:43 +0900 Subject: [PATCH 07/10] Make markers constant screen size regardless of zoom level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove world-space marker sizing (markerRadius) - Add MARKER_SCREEN_SIZE constant (3% of screen height) - Scale markers dynamically based on camera distance in render loop - Markers now stay same visual size when zooming in/out 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/distance-rescale/main.js | 46 +++++++++++++++++++------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index fc33aa4..954553d 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -71,18 +71,19 @@ const raycaster = new THREE.Raycaster(); // Visual Elements // ============================================================================ -let markerRadius = 0.02; // Will be updated based on model size 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 - const geometry = new THREE.SphereGeometry(markerRadius, 16, 16); + // Inner sphere (unit radius = 1) + const geometry = new THREE.SphereGeometry(1, 16, 16); const material = new THREE.MeshBasicMaterial({ color, depthTest: false, @@ -94,11 +95,7 @@ function createMarker(color) { group.add(mesh); // Outer ring/outline for better visibility - const ringGeometry = new THREE.RingGeometry( - markerRadius * 1.2, - markerRadius * 1.8, - 32, - ); + const ringGeometry = new THREE.RingGeometry(1.2, 1.8, 32); const ringMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, depthTest: false, @@ -625,10 +622,8 @@ function centerCameraOnModel() { return; } - // Update marker and ray sizes based on model scale - markerRadius = maxDim * 0.02; // 2% of model size for better visibility + // Update ray line length based on model scale rayLineLength = maxDim * 5; // 5x model size - console.log("Marker radius:", markerRadius.toFixed(4)); console.log("Ray line length:", rayLineLength.toFixed(2)); // Position camera to see the entire model @@ -685,16 +680,31 @@ 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(); - // Make marker rings always face the camera (billboard effect) - if (state.marker1?.userData.ring) { - state.marker1.userData.ring.lookAt(camera.position); - } - if (state.marker2?.userData.ring) { - state.marker2.userData.ring.lookAt(camera.position); - } + // Update marker scales to maintain constant screen size + updateMarkerScale(state.marker1); + updateMarkerScale(state.marker2); renderer.render(scene, camera); }); From f51b39623d61e263a40b0388690de034f770402d Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Fri, 26 Dec 2025 11:22:41 +0900 Subject: [PATCH 08/10] Fix code review issues: explicit point2 check and memory disposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit else-if check for point2 in drag handler for safety - Add disposeObject helper to properly dispose Three.js geometries/materials - Prevent memory leaks when resetting selection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/distance-rescale/main.js | 51 ++++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index 954553d..e15aea5 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -374,28 +374,35 @@ function rescaleModel(newDistance) { // 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 visual elements - if (state.marker1) { - scene.remove(state.marker1); - state.marker1 = null; - } - if (state.marker2) { - scene.remove(state.marker2); - state.marker2 = null; - } - if (state.rayLine1) { - scene.remove(state.rayLine1); - state.rayLine1 = null; - } - if (state.rayLine2) { - scene.remove(state.rayLine2); - state.rayLine2 = null; - } - if (state.distanceLine) { - scene.remove(state.distanceLine); - state.distanceLine = null; - } + // 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; @@ -472,7 +479,7 @@ renderer.domElement.addEventListener("pointermove", (event) => { ); state.point1.copy(newPoint); state.marker1.position.copy(newPoint); - } else { + } else if (state.dragging === "point2") { newPoint = closestPointOnRay( raycaster.ray, state.ray2Origin, From a6f53bcd5b0499d50b0d46a4e179601a85bcc829 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Fri, 26 Dec 2025 11:44:45 +0900 Subject: [PATCH 09/10] Add unit tests for PlyWriter and fix format string bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 15 comprehensive unit tests for PlyWriter: - Constructor with default/custom options - PLY header generation validation - Binary data size verification - Position, scale, quaternion encoding - Log scale encoding with zero fallback - Sigmoid opacity encoding with edge case clamping - Color DC coefficient encoding - Empty splats handling - Big endian format support - Multiple splats ordering - Fix bug: replace() only replaced first underscore in format string Changed to replaceAll() for proper "binary little endian" output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/PlyWriter.ts | 2 +- test/PlyWriter.test.ts | 619 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 test/PlyWriter.test.ts diff --git a/src/PlyWriter.ts b/src/PlyWriter.ts index 1390e2d..6156377 100644 --- a/src/PlyWriter.ts +++ b/src/PlyWriter.ts @@ -30,7 +30,7 @@ export class PlyWriter { */ private generateHeader(): string { const numSplats = this.packedSplats.numSplats; - const format = this.options.format.replace("_", " "); + const format = this.options.format.replaceAll("_", " "); const lines = [ "ply", diff --git a/test/PlyWriter.test.ts b/test/PlyWriter.test.ts new file mode 100644 index 0000000..80c5ea3 --- /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!"); From 210275918e037ed3b7c361ce52152b24afe70993 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Fri, 26 Dec 2025 12:14:32 +0900 Subject: [PATCH 10/10] Fix PLY format string to use underscores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLY reader expects "binary_little_endian" with underscores, not spaces. This was causing exported PLY files to fail to load (appearing all black). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/PlyWriter.ts | 3 ++- test/PlyWriter.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/PlyWriter.ts b/src/PlyWriter.ts index 6156377..31ba56d 100644 --- a/src/PlyWriter.ts +++ b/src/PlyWriter.ts @@ -30,7 +30,8 @@ export class PlyWriter { */ private generateHeader(): string { const numSplats = this.packedSplats.numSplats; - const format = this.options.format.replaceAll("_", " "); + // PLY format uses underscores: binary_little_endian, binary_big_endian + const format = this.options.format; const lines = [ "ply", diff --git a/test/PlyWriter.test.ts b/test/PlyWriter.test.ts index 80c5ea3..bfe1820 100644 --- a/test/PlyWriter.test.ts +++ b/test/PlyWriter.test.ts @@ -128,7 +128,7 @@ function findHeaderEnd(data: Uint8Array): number { assert.ok(header.startsWith("ply\n"), "Header should start with 'ply'"); assert.ok( - header.includes("format binary little endian 1.0"), + header.includes("format binary_little_endian 1.0"), "Header should include format", ); assert.ok( @@ -527,7 +527,7 @@ function findHeaderEnd(data: Uint8Array): number { const header = new TextDecoder().decode(result.slice(0, headerEndIndex)); assert.ok( - header.includes("format binary big endian 1.0"), + header.includes("format binary_big_endian 1.0"), "Header should specify big endian format", ); }