From f8c7eb9651e3e8971df2c2f1025262c0876ca80c Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 13:42:18 +0900 Subject: [PATCH 01/35] 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 00000000..ecaad5f8 --- /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 00000000..ec187c73 --- /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 00000000..1390e2d5 --- /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 8288360b..b8ea944d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export { isPcSogs, } from "./SplatLoader"; export { PlyReader } from "./ply"; +export { PlyWriter, type PlyWriterOptions } from "./PlyWriter"; export { SpzReader, SpzWriter, transcodeSpz } from "./spz"; export { PackedSplats, type PackedSplatsOptions } from "./PackedSplats"; From 9ccb266af48480dc6043167f71f078330e3aed6d Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 13:42:18 +0900 Subject: [PATCH 02/35] 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 bd1e79ad..5eaedd4b 100644 --- a/index.html +++ b/index.html @@ -139,6 +139,7 @@

Examples

  • Multiple Viewpoints
  • Procedural Splats
  • Raycasting
  • +
  • Distance Measurement & Rescale
  • Dynamic Lighting
  • Particle Animation
  • Particle Simulation
  • From cb569d4ab1d87d861292fd1d85a9ec89104621b9 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Thu, 25 Dec 2025 14:05:17 +0900 Subject: [PATCH 03/35] 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 ec187c73..99d2a145 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/35] 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 99d2a145..08d2eb76 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/35] 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 08d2eb76..c80ee13d 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/35] 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 c80ee13d..fc33aa4c 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/35] 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 fc33aa4c..954553d0 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/35] 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 954553d0..e15aea5a 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/35] 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 1390e2d5..61563777 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 00000000..80c5ea3a --- /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/35] 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 61563777..31ba56dd 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 80c5ea3a..bfe18203 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", ); } From ed8177983b4d79be3152efbf2d20ffde7ddfc359 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 12:38:43 +0900 Subject: [PATCH 11/35] Add Azure Static Web Apps deployment configuration - Add GitHub Actions workflow for automated deployment - Add staticwebapp.config.json for SWA configuration - Update rename-assets-to-static.js to copy SWA config to site output Co-Authored-By: Claude Opus 4.5 --- .github/workflows/azure-static-web-apps.yml | 46 +++++++++++++++++++++ scripts/rename-assets-to-static.js | 10 +++++ staticwebapp.config.json | 23 +++++++++++ 3 files changed, 79 insertions(+) create mode 100644 .github/workflows/azure-static-web-apps.yml create mode 100644 staticwebapp.config.json diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml new file mode 100644 index 00000000..01cda17f --- /dev/null +++ b/.github/workflows/azure-static-web-apps.yml @@ -0,0 +1,46 @@ +name: Deploy to Azure Static Web Apps + +on: + push: + branches: + - main + - feature/azure_hosting + paths: + - 'src/**' + - 'examples/**' + - 'docs/**' + - 'index.html' + - 'package.json' + +jobs: + build_and_deploy: + runs-on: ubuntu-latest + name: Build and Deploy + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install MkDocs + run: pip install mkdocs-material + + - name: Build site + run: npm run site:build + + - name: Deploy to Azure Static Web Apps + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + repo_token: ${{ secrets.GITHUB_TOKEN }} + action: "upload" + skip_app_build: true + app_location: "site" diff --git a/scripts/rename-assets-to-static.js b/scripts/rename-assets-to-static.js index 47e2d3f1..a0af46c7 100644 --- a/scripts/rename-assets-to-static.js +++ b/scripts/rename-assets-to-static.js @@ -1,5 +1,6 @@ import { execSync } from "node:child_process"; import { + copyFileSync, readFileSync, readdirSync, renameSync, @@ -38,3 +39,12 @@ function replaceInHtmlFiles(dir) { } } } + +// Copy Azure Static Web Apps config if it exists +const swaConfig = "staticwebapp.config.json"; +try { + copyFileSync(swaConfig, join(siteDirectory, swaConfig)); + console.log(`Copied ${swaConfig} to ${siteDirectory}/`); +} catch { + // Config file is optional - skip if not present +} diff --git a/staticwebapp.config.json b/staticwebapp.config.json new file mode 100644 index 00000000..b2886502 --- /dev/null +++ b/staticwebapp.config.json @@ -0,0 +1,23 @@ +{ + "navigationFallback": { + "rewrite": "/index.html", + "exclude": [ + "/dist/*", + "/examples/*", + "*.js", + "*.css", + "*.spz", + "*.ply", + "*.glb" + ] + }, + "mimeTypes": { + ".spz": "application/octet-stream", + ".splat": "application/octet-stream", + ".ply": "application/octet-stream", + ".wasm": "application/wasm" + }, + "globalHeaders": { + "X-Content-Type-Options": "nosniff" + } +} From f6c86e1c74436b2ed66d529f91293cc989f79c39 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:01:17 +0900 Subject: [PATCH 12/35] ci: add Azure Static Web Apps workflow file on-behalf-of: @Azure opensource@microsoft.com --- ...-static-web-apps-nice-meadow-018297c00.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/azure-static-web-apps-nice-meadow-018297c00.yml diff --git a/.github/workflows/azure-static-web-apps-nice-meadow-018297c00.yml b/.github/workflows/azure-static-web-apps-nice-meadow-018297c00.yml new file mode 100644 index 00000000..e6a39a09 --- /dev/null +++ b/.github/workflows/azure-static-web-apps-nice-meadow-018297c00.yml @@ -0,0 +1,46 @@ +name: Azure Static Web Apps CI/CD + +on: + push: + branches: + - feature/azure_hosting + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - feature/azure_hosting + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_NICE_MEADOW_018297C00 }} + repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + action: "upload" + ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### + # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig + app_location: "/" # App source code path + api_location: "" # Api source code path - optional + output_location: "site" # Built app content directory - optional + ###### End of Repository/Build Configurations ###### + + close_pull_request_job: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request Job + steps: + - name: Close Pull Request + id: closepullrequest + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_NICE_MEADOW_018297C00 }} + action: "close" From 11ce12174f30b1319c230c5b535251ddda329864 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:06:48 +0900 Subject: [PATCH 13/35] Use existing Azure secret and remove auto-generated workflow - Update workflow to use AZURE_STATIC_WEB_APPS_API_TOKEN_NICE_MEADOW_018297C00 - Remove Azure auto-generated workflow (missing build steps) Co-Authored-By: Claude Opus 4.5 --- ...-static-web-apps-nice-meadow-018297c00.yml | 46 ------------------- .github/workflows/azure-static-web-apps.yml | 2 +- 2 files changed, 1 insertion(+), 47 deletions(-) delete mode 100644 .github/workflows/azure-static-web-apps-nice-meadow-018297c00.yml diff --git a/.github/workflows/azure-static-web-apps-nice-meadow-018297c00.yml b/.github/workflows/azure-static-web-apps-nice-meadow-018297c00.yml deleted file mode 100644 index e6a39a09..00000000 --- a/.github/workflows/azure-static-web-apps-nice-meadow-018297c00.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Azure Static Web Apps CI/CD - -on: - push: - branches: - - feature/azure_hosting - pull_request: - types: [opened, synchronize, reopened, closed] - branches: - - feature/azure_hosting - -jobs: - build_and_deploy_job: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') - runs-on: ubuntu-latest - name: Build and Deploy Job - steps: - - uses: actions/checkout@v3 - with: - submodules: true - lfs: false - - name: Build And Deploy - id: builddeploy - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_NICE_MEADOW_018297C00 }} - repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) - action: "upload" - ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### - # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig - app_location: "/" # App source code path - api_location: "" # Api source code path - optional - output_location: "site" # Built app content directory - optional - ###### End of Repository/Build Configurations ###### - - close_pull_request_job: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - name: Close Pull Request Job - steps: - - name: Close Pull Request - id: closepullrequest - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_NICE_MEADOW_018297C00 }} - action: "close" diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml index 01cda17f..2a2ed6bc 100644 --- a/.github/workflows/azure-static-web-apps.yml +++ b/.github/workflows/azure-static-web-apps.yml @@ -39,7 +39,7 @@ jobs: - name: Deploy to Azure Static Web Apps uses: Azure/static-web-apps-deploy@v1 with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_NICE_MEADOW_018297C00 }} repo_token: ${{ secrets.GITHUB_TOKEN }} action: "upload" skip_app_build: true From 36afa7a5e60e48665bcebebc457f6ff39aab599a Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:10:47 +0900 Subject: [PATCH 14/35] Fix Azure SWA deployment to skip Oryx npm install - Use --ignore-scripts for npm ci to skip prepare script - Add skip_api_build: true to prevent API build attempts - Set output_location to empty for pre-built content - Add conditional WASM build step Co-Authored-By: Claude Opus 4.5 --- .github/workflows/azure-static-web-apps.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml index 2a2ed6bc..60ad3373 100644 --- a/.github/workflows/azure-static-web-apps.yml +++ b/.github/workflows/azure-static-web-apps.yml @@ -28,7 +28,15 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts + + - name: Build WASM (if needed) + run: | + if command -v rustup &> /dev/null; then + npm run build:wasm + else + echo "Rust not available, using pre-built WASM" + fi - name: Install MkDocs run: pip install mkdocs-material @@ -43,4 +51,6 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} action: "upload" skip_app_build: true + skip_api_build: true app_location: "site" + output_location: "" From 0e5723c9efef9d9e43ce198a38363f9a4c990185 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:14:52 +0900 Subject: [PATCH 15/35] Add workflow file to paths trigger Co-Authored-By: Claude Opus 4.5 --- .github/workflows/azure-static-web-apps.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml index 60ad3373..143310b5 100644 --- a/.github/workflows/azure-static-web-apps.yml +++ b/.github/workflows/azure-static-web-apps.yml @@ -11,6 +11,7 @@ on: - 'docs/**' - 'index.html' - 'package.json' + - '.github/workflows/azure-static-web-apps.yml' jobs: build_and_deploy: From e5b8237cfb71ba33431e951d9e920e4f26713bd7 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:16:29 +0900 Subject: [PATCH 16/35] Remove unnecessary WASM build step Pre-built WASM files are already in the repo. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/azure-static-web-apps.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml index 143310b5..153d9c5f 100644 --- a/.github/workflows/azure-static-web-apps.yml +++ b/.github/workflows/azure-static-web-apps.yml @@ -31,14 +31,6 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts - - name: Build WASM (if needed) - run: | - if command -v rustup &> /dev/null; then - npm run build:wasm - else - echo "Rust not available, using pre-built WASM" - fi - - name: Install MkDocs run: pip install mkdocs-material From 4f2b537dd48c6881e472caec21a2a14db0411b4f Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:20:32 +0900 Subject: [PATCH 17/35] Add pre-built WASM package for CI Remove gitignore and commit pre-built spark-internal-rs WASM files so CI can build without Rust toolchain. Co-Authored-By: Claude Opus 4.5 --- rust/spark-internal-rs/pkg/README.md | 35 +++ rust/spark-internal-rs/pkg/package.json | 23 ++ .../pkg/spark_internal_rs.d.ts | 37 +++ .../pkg/spark_internal_rs.js | 239 ++++++++++++++++++ .../pkg/spark_internal_rs_bg.wasm | Bin 0 -> 32140 bytes .../pkg/spark_internal_rs_bg.wasm.d.ts | 8 + 6 files changed, 342 insertions(+) create mode 100644 rust/spark-internal-rs/pkg/README.md create mode 100644 rust/spark-internal-rs/pkg/package.json create mode 100644 rust/spark-internal-rs/pkg/spark_internal_rs.d.ts create mode 100644 rust/spark-internal-rs/pkg/spark_internal_rs.js create mode 100644 rust/spark-internal-rs/pkg/spark_internal_rs_bg.wasm create mode 100644 rust/spark-internal-rs/pkg/spark_internal_rs_bg.wasm.d.ts diff --git a/rust/spark-internal-rs/pkg/README.md b/rust/spark-internal-rs/pkg/README.md new file mode 100644 index 00000000..a9891d6e --- /dev/null +++ b/rust/spark-internal-rs/pkg/README.md @@ -0,0 +1,35 @@ +# spark-internal-rs + +Rust WebAssembly functions for spark-internal. + +## Installing build tools + +First, we need to install Rust. Though it is possible to install it using Homebrew, we recommend installing `rustup` using the approach on the Rust homepage: + +https://www.rust-lang.org/tools/install + +It will most likely involve simply running: +``` +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +Once you have `rustup` and Rust, we need to install dependencies for building Rust Wasm. +``` +rustup target add wasm32-unknown-unknown +cargo install wasm-pack +``` + +## Building + +Run the following script inside `spark-internal/rust`: +``` +./build_rust_wasm.sh +``` + +You can also build it manually by running these commands: +``` +cd spark-internal-rs +wasm-pack build --target web +``` + +The generated files will be in the `pkg/` subdirectory, which is already symlinked in `spark-internal/package.json`. diff --git a/rust/spark-internal-rs/pkg/package.json b/rust/spark-internal-rs/pkg/package.json new file mode 100644 index 00000000..11ea65c0 --- /dev/null +++ b/rust/spark-internal-rs/pkg/package.json @@ -0,0 +1,23 @@ +{ + "name": "spark-internal-rs", + "type": "module", + "collaborators": [ + "World Labs Technologies" + ], + "version": "0.1.0", + "license": "Proprietary", + "repository": { + "type": "git", + "url": "https://github.com/sparkjs-dev/spark" + }, + "files": [ + "spark_internal_rs_bg.wasm", + "spark_internal_rs.js", + "spark_internal_rs.d.ts" + ], + "main": "spark_internal_rs.js", + "types": "spark_internal_rs.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} \ No newline at end of file diff --git a/rust/spark-internal-rs/pkg/spark_internal_rs.d.ts b/rust/spark-internal-rs/pkg/spark_internal_rs.d.ts new file mode 100644 index 00000000..92dcbd6a --- /dev/null +++ b/rust/spark-internal-rs/pkg/spark_internal_rs.d.ts @@ -0,0 +1,37 @@ +/* tslint:disable */ +/* eslint-disable */ +export function sort_splats(num_splats: number, readback: Uint16Array, ordering: Uint32Array): number; +export function sort32_splats(num_splats: number, readback: Uint32Array, ordering: Uint32Array): number; +export function raycast_splats(origin_x: number, origin_y: number, origin_z: number, dir_x: number, dir_y: number, dir_z: number, near: number, far: number, num_splats: number, packed_splats: Uint32Array, raycast_ellipsoid: boolean, ln_scale_min: number, ln_scale_max: number): Float32Array; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly raycast_splats: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: any, k: number, l: number, m: number) => any; + readonly sort32_splats: (a: number, b: any, c: any) => number; + readonly sort_splats: (a: number, b: any, c: any) => number; + readonly __wbindgen_export_0: WebAssembly.Table; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; +/** +* Instantiates the given `module`, which can either be bytes or +* a precompiled `WebAssembly.Module`. +* +* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. +* +* @returns {InitOutput} +*/ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** +* If `module_or_path` is {RequestInfo} or {URL}, makes a request and +* for everything else, calls `WebAssembly.instantiate` directly. +* +* @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. +* +* @returns {Promise} +*/ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/rust/spark-internal-rs/pkg/spark_internal_rs.js b/rust/spark-internal-rs/pkg/spark_internal_rs.js new file mode 100644 index 00000000..f36da0d9 --- /dev/null +++ b/rust/spark-internal-rs/pkg/spark_internal_rs.js @@ -0,0 +1,239 @@ +let wasm; + +const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } ); + +if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); }; + +let cachedUint8ArrayMemory0 = null; + +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} +/** + * @param {number} num_splats + * @param {Uint16Array} readback + * @param {Uint32Array} ordering + * @returns {number} + */ +export function sort_splats(num_splats, readback, ordering) { + const ret = wasm.sort_splats(num_splats, readback, ordering); + return ret >>> 0; +} + +/** + * @param {number} num_splats + * @param {Uint32Array} readback + * @param {Uint32Array} ordering + * @returns {number} + */ +export function sort32_splats(num_splats, readback, ordering) { + const ret = wasm.sort32_splats(num_splats, readback, ordering); + return ret >>> 0; +} + +/** + * @param {number} origin_x + * @param {number} origin_y + * @param {number} origin_z + * @param {number} dir_x + * @param {number} dir_y + * @param {number} dir_z + * @param {number} near + * @param {number} far + * @param {number} num_splats + * @param {Uint32Array} packed_splats + * @param {boolean} raycast_ellipsoid + * @param {number} ln_scale_min + * @param {number} ln_scale_max + * @returns {Float32Array} + */ +export function raycast_splats(origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, near, far, num_splats, packed_splats, raycast_ellipsoid, ln_scale_min, ln_scale_max) { + const ret = wasm.raycast_splats(origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, near, far, num_splats, packed_splats, raycast_ellipsoid, ln_scale_min, ln_scale_max); + return ret; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + + } catch (e) { + if (module.headers.get('Content-Type') != 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + + } else { + return instance; + } + } +} + +function __wbg_get_imports() { + const imports = {}; + imports.wbg = {}; + imports.wbg.__wbg_buffer_609cc3eee51ed158 = function(arg0) { + const ret = arg0.buffer; + return ret; + }; + imports.wbg.__wbg_length_3b4f022188ae8db6 = function(arg0) { + const ret = arg0.length; + return ret; + }; + imports.wbg.__wbg_length_6ca527665d89694d = function(arg0) { + const ret = arg0.length; + return ret; + }; + imports.wbg.__wbg_length_8cfd2c6409af88ad = function(arg0) { + const ret = arg0.length; + return ret; + }; + imports.wbg.__wbg_new_9fee97a409b32b68 = function(arg0) { + const ret = new Uint16Array(arg0); + return ret; + }; + imports.wbg.__wbg_new_e3b321dcfef89fc7 = function(arg0) { + const ret = new Uint32Array(arg0); + return ret; + }; + imports.wbg.__wbg_newwithbyteoffsetandlength_e6b7e69acd4c7354 = function(arg0, arg1, arg2) { + const ret = new Float32Array(arg0, arg1 >>> 0, arg2 >>> 0); + return ret; + }; + imports.wbg.__wbg_newwithbyteoffsetandlength_f1dead44d1fc7212 = function(arg0, arg1, arg2) { + const ret = new Uint32Array(arg0, arg1 >>> 0, arg2 >>> 0); + return ret; + }; + imports.wbg.__wbg_newwithlength_5a5efe313cfd59f1 = function(arg0) { + const ret = new Float32Array(arg0 >>> 0); + return ret; + }; + imports.wbg.__wbg_set_10bad9bee0e9c58b = function(arg0, arg1, arg2) { + arg0.set(arg1, arg2 >>> 0); + }; + imports.wbg.__wbg_set_d23661d19148b229 = function(arg0, arg1, arg2) { + arg0.set(arg1, arg2 >>> 0); + }; + imports.wbg.__wbg_set_f4f1f0daa30696fc = function(arg0, arg1, arg2) { + arg0.set(arg1, arg2 >>> 0); + }; + imports.wbg.__wbg_subarray_3aaeec89bb2544f0 = function(arg0, arg1, arg2) { + const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); + return ret; + }; + imports.wbg.__wbg_subarray_769e1e0f81bb259b = function(arg0, arg1, arg2) { + const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); + return ret; + }; + imports.wbg.__wbindgen_init_externref_table = function() { + const table = wasm.__wbindgen_export_0; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + ; + }; + imports.wbg.__wbindgen_memory = function() { + const ret = wasm.memory; + return ret; + }; + imports.wbg.__wbindgen_throw = function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }; + + return imports; +} + +function __wbg_init_memory(imports, memory) { + +} + +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + __wbg_init.__wbindgen_wasm_module = module; + cachedUint8ArrayMemory0 = null; + + + wasm.__wbindgen_start(); + return wasm; +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (typeof module !== 'undefined') { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + + __wbg_init_memory(imports); + + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + + const instance = new WebAssembly.Instance(module, imports); + + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (typeof module_or_path !== 'undefined') { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (typeof module_or_path === 'undefined') { + module_or_path = new URL('spark_internal_rs_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + __wbg_init_memory(imports); + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync }; +export default __wbg_init; diff --git a/rust/spark-internal-rs/pkg/spark_internal_rs_bg.wasm b/rust/spark-internal-rs/pkg/spark_internal_rs_bg.wasm new file mode 100644 index 0000000000000000000000000000000000000000..5be7ba80748dcc1478211b93c608028ca6406f98 GIT binary patch literal 32140 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 L&Mu<9C71s any; +export const sort32_splats: (a: number, b: any, c: any) => number; +export const sort_splats: (a: number, b: any, c: any) => number; +export const __wbindgen_export_0: WebAssembly.Table; +export const __wbindgen_start: () => void; From c28dda75f1c9a2f7ae84657bad937deeb3d7e340 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:22:10 +0900 Subject: [PATCH 18/35] Trigger CI with workflow comment Co-Authored-By: Claude Opus 4.5 --- .github/workflows/azure-static-web-apps.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml index 153d9c5f..557c355d 100644 --- a/.github/workflows/azure-static-web-apps.yml +++ b/.github/workflows/azure-static-web-apps.yml @@ -1,3 +1,4 @@ +# Builds and deploys Spark examples site to Azure Static Web Apps name: Deploy to Azure Static Web Apps on: From 82a0c7cb414cd8a48879c7d186c2994defb0fc70 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:51:55 +0900 Subject: [PATCH 19/35] Add distance-rescale example to examples page navigation The example folder existed but wasn't listed in examples.html. Co-Authored-By: Claude Opus 4.5 --- examples.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples.html b/examples.html index ffe2426f..158110be 100644 --- a/examples.html +++ b/examples.html @@ -246,7 +246,8 @@ 'debug-color': '/examples/debug-color/index.html', 'depth-of-field': '/examples/depth-of-field/index.html', 'splat-texture': '/examples/splat-texture/index.html', - 'editor': '/examples/editor/index.html' + 'editor': '/examples/editor/index.html', + 'distance-rescale': '/examples/distance-rescale/index.html' }; function getExampleFromHash() { @@ -451,6 +452,7 @@ Depth of Field Splat Texture Editor + Distance Measurement & Rescale
    From 156c7d6ad036e684de897f0fafe70f84e9c89e63 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 15:53:47 +0900 Subject: [PATCH 20/35] Add examples.html to workflow paths trigger Co-Authored-By: Claude Opus 4.5 --- .github/workflows/azure-static-web-apps.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml index 557c355d..c9f67774 100644 --- a/.github/workflows/azure-static-web-apps.yml +++ b/.github/workflows/azure-static-web-apps.yml @@ -11,6 +11,7 @@ on: - 'examples/**' - 'docs/**' - 'index.html' + - 'examples.html' - 'package.json' - '.github/workflows/azure-static-web-apps.yml' From 2a9e5055d0897ef3365b489bb70c6ed8a38f26db Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 20 Jan 2026 16:06:09 +0900 Subject: [PATCH 21/35] Add Japanese usage guide for Distance Measurement & Rescale Co-Authored-By: Claude Opus 4.5 --- docs/distance-rescale-guide-ja.md | 67 +++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/distance-rescale-guide-ja.md diff --git a/docs/distance-rescale-guide-ja.md b/docs/distance-rescale-guide-ja.md new file mode 100644 index 00000000..60d553ae --- /dev/null +++ b/docs/distance-rescale-guide-ja.md @@ -0,0 +1,67 @@ +# Distance Measurement & Rescale 使用ガイド + +## アクセス方法 + +**URL**: [https://nice-meadow-018297c00.6.azurestaticapps.net/examples/distance-rescale/](https://nice-meadow-018297c00.6.azurestaticapps.net/examples/distance-rescale/) + +--- + +## 機能概要 + +3DGSモデル上で2点間の距離を測定し、実際の寸法に合わせてモデルをリスケール(拡大縮小)できます。 + +--- + +## 使い方 + +### 1. 3DGSファイルの読み込み + +1. 画面左下の **「Load PLY File」** ボタンをクリック +2. ファイル選択ダイアログから `.ply`、`.spz`、`.splat` ファイルを選択 +3. モデルが自動的に読み込まれ、カメラが自動調整される + +### 2. 測定点の配置 + +1. **1点目**: モデル上の任意の位置をクリック(緑のマーカーが表示) +2. **2点目**: モデル上の別の位置をクリック(青のマーカーが表示) +3. 2点間に黄色の線が表示され、距離が画面右下に表示される + +### 3. 測定点の調整 + +- マーカーをドラッグすると、視線方向に沿って位置を調整可能 +- 距離はリアルタイムで更新される + +### 4. リスケール(サイズ変更) + +1. 画面右上のGUIパネルで **「Measured Distance」** に現在の測定値が表示される +2. **「New Distance」** に実際の距離(メートル単位)を入力 +3. **「Apply Rescale」** をクリック +4. モデル全体が指定した寸法に合わせてスケーリングされる + +### 5. モデルの保存 + +1. **「Export PLY」** をクリック +2. `rescaled_model.ply` としてダウンロードされる + +### 6. リセット + +- **「Reset Points」** をクリックすると測定点がクリアされ、最初からやり直せる + +--- + +## 操作方法 + +| 操作 | 動作 | +|------|------| +| 左クリック | 測定点を配置 | +| マーカーをドラッグ | 測定点の位置を調整 | +| 左ドラッグ(モデル上以外) | カメラ回転 | +| 右ドラッグ / 2本指ドラッグ | カメラ移動 | +| スクロール / ピンチ | ズーム | + +--- + +## 注意事項 + +- リスケール後のモデルは元の座標系の原点を基準にスケーリングされます +- エクスポートされるPLYファイルはリスケール後の座標値を含みます From f9fd629fcbd669db61f2a815b45d49a344b6089a Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Wed, 4 Mar 2026 13:26:22 +0900 Subject: [PATCH 22/35] Enhance Distance Measurement example with 5 improvements --- examples/distance-rescale/index.html | 30 +----- examples/distance-rescale/main.js | 147 ++++++++++++++++++++++++++- examples/js/orbit-controls-utils.js | 27 +++++ 3 files changed, 174 insertions(+), 30 deletions(-) create mode 100644 examples/js/orbit-controls-utils.js diff --git a/examples/distance-rescale/index.html b/examples/distance-rescale/index.html index ecaad5f8..464b6f36 100644 --- a/examples/distance-rescale/index.html +++ b/examples/distance-rescale/index.html @@ -30,30 +30,6 @@ z-index: 10; } - #file-input-container { - position: absolute; - bottom: 20px; - left: 20px; - z-index: 100; - } - - #file-input-container label { - background: #333; - color: white; - padding: 10px 20px; - border-radius: 5px; - cursor: pointer; - font-size: 14px; - } - - #file-input-container label:hover { - background: #555; - } - - #file-input { - display: none; - } - #distance-display { position: absolute; bottom: 20px; @@ -83,10 +59,8 @@
    Click on the model to select first measurement point
    -
    - - -
    + +
    Distance
    diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index e15aea5a..60d8bed7 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -3,6 +3,7 @@ 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"; +import { setupInfiniteRotation } from "/examples/js/orbit-controls-utils.js"; // ============================================================================ // Scene Setup @@ -26,6 +27,7 @@ scene.add(spark); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; +setupInfiniteRotation(controls); // Enable infinite rotation without angle limits camera.position.set(0, 2, 5); camera.lookAt(0, 0, 0); @@ -62,6 +64,10 @@ const state = { // Interaction mode: "select1", // 'select1' | 'select2' | 'complete' dragging: null, // 'point1' | 'point2' | null + + // Coordinate axes + axesHelper: null, + axesVisible: false, }; let splatMesh = null; @@ -370,6 +376,45 @@ function rescaleModel(newDistance) { guiParams.measuredDistance = newDistance.toFixed(4); } +// ============================================================================ +// Coordinate Origin Transform +// ============================================================================ + +function transformOriginTo(newOrigin) { + if (!splatMesh) return; + + console.log("Transforming origin to:", newOrigin.toFixed(2)); + + // 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; + + // Transform axes helper if exists + if (state.axesHelper) { + state.axesHelper.position.add(translation); + } + + // Reset measurements (user preference) + resetSelection(); + + // Transform camera to maintain view + camera.position.add(translation); + controls.target.add(translation); + controls.update(); + + updateInstructions( + "Coordinate origin set. Click to select first measurement point", + ); +} + // ============================================================================ // Reset // ============================================================================ @@ -527,6 +572,51 @@ renderer.domElement.addEventListener("pointerup", (event) => { pointerDownPos = null; }); +// Double-click handler for setting coordinate origin +renderer.domElement.addEventListener("dblclick", (event) => { + if (event.button !== 0) return; // Only left button + + if (!splatMesh) return; + + const ndc = getMouseNDC(event); + const hitPoint = getHitPoint(ndc); + + if (!hitPoint) { + console.log("Double-click missed model"); + return; + } + + transformOriginTo(hitPoint); +}); + +// Drag and drop handlers +const onDragover = (e) => { + e.preventDefault(); + // Add visual feedback + renderer.domElement.style.outline = "3px solid #00ff00"; +}; + +const onDragLeave = (e) => { + e.preventDefault(); + renderer.domElement.style.outline = "none"; +}; + +const onDrop = (e) => { + e.preventDefault(); + renderer.domElement.style.outline = "none"; + + const files = e.dataTransfer.files; + if (files.length > 0) { + loadSplatFile(files[0]); + } else { + console.warn("No files dropped"); + } +}; + +renderer.domElement.addEventListener("dragover", onDragover); +renderer.domElement.addEventListener("dragleave", onDragLeave); +renderer.domElement.addEventListener("drop", onDrop); + // ============================================================================ // GUI // ============================================================================ @@ -535,11 +625,21 @@ 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") @@ -577,8 +677,7 @@ async function loadSplatFile(urlOrFile) { splatMesh = new SplatMesh({ fileBytes: new Uint8Array(arrayBuffer) }); } - // Apply rotation to match common PLY orientation - splatMesh.rotation.x = Math.PI; + // No fixed rotation applied - users can rotate freely with OrbitControls scene.add(splatMesh); await splatMesh.initialized; @@ -586,6 +685,10 @@ async function loadSplatFile(urlOrFile) { // Auto-center camera on the model centerCameraOnModel(); + + // Create or update coordinate axes + createOrUpdateAxes(); + updateInstructions("Click on the model to select first measurement point"); } catch (error) { console.error("Error loading splat:", error); @@ -659,6 +762,46 @@ function centerCameraOnModel() { } } +function createOrUpdateAxes() { + if (!splatMesh) return; + + // Remove existing axes + if (state.axesHelper) { + scene.remove(state.axesHelper); + state.axesHelper.dispose(); + } + + // 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") diff --git a/examples/js/orbit-controls-utils.js b/examples/js/orbit-controls-utils.js new file mode 100644 index 00000000..317f79ca --- /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; + + // Allow full vertical rotation (polar angle) + controls.minPolarAngle = 0; + controls.maxPolarAngle = Math.PI; + + return controls; +} From 5c968ca35e6cdbb4d3e7a223e3ba92a3bf529ee1 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 11:51:55 +0900 Subject: [PATCH 23/35] Add detect-secrets to pre-commit hook for secret scanning Configure lefthook to run detect-secrets before commits to prevent accidentally pushing sensitive information like API keys, tokens, or passwords. - Uses baseline file from global hooks: ~/.config/git/hooks/.secrets.baseline - Gracefully handles case where detect-secrets is not installed - Runs alongside existing lint and test jobs Co-Authored-By: Claude Sonnet 4.5 --- lefthook.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lefthook.yml b/lefthook.yml index a6b9fee1..2386a268 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -3,4 +3,12 @@ pre-commit: - name: lint run: npm run lint - name: test - run: npm run test \ No newline at end of file + run: npm run test + - name: detect-secrets + run: | + if command -v detect-secrets >/dev/null 2>&1; then + detect-secrets-hook --baseline ~/.config/git/hooks/.secrets.baseline {staged_files} + else + echo "⚠️ Warning: detect-secrets not installed, skipping secret scan" + echo " Install with: pip install detect-secrets" + fi \ No newline at end of file From 07bd53545fee8357da4618c1f0fa19ac54e62303 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 13:48:29 +0900 Subject: [PATCH 24/35] Enable infinite vertical rotation for OrbitControls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove polar angle limits to allow infinite vertical (up/down) rotation, not just horizontal rotation. Before: Vertical rotation limited to 180° (0 to π) After: Vertical rotation unlimited (can rotate continuously) This allows users to spin the model infinitely in all directions. Co-Authored-By: Claude Sonnet 4.5 --- examples/js/orbit-controls-utils.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/js/orbit-controls-utils.js b/examples/js/orbit-controls-utils.js index 317f79ca..f4ab395b 100644 --- a/examples/js/orbit-controls-utils.js +++ b/examples/js/orbit-controls-utils.js @@ -19,9 +19,9 @@ export function setupInfiniteRotation(controls) { controls.minAzimuthAngle = Number.NEGATIVE_INFINITY; controls.maxAzimuthAngle = Number.POSITIVE_INFINITY; - // Allow full vertical rotation (polar angle) - controls.minPolarAngle = 0; - controls.maxPolarAngle = Math.PI; + // Enable infinite vertical rotation (polar angle) + controls.minPolarAngle = Number.NEGATIVE_INFINITY; + controls.maxPolarAngle = Number.POSITIVE_INFINITY; return controls; } From f869ebaa5d88fa049b45a448ed17e6d4da782a74 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 14:01:55 +0900 Subject: [PATCH 25/35] Change origin transform to right double-click and improve instructions Fix issues with coordinate origin transform and measurement points: 1. **Change to right double-click for origin transform** - Left double-click was conflicting with measurement point placement - Now: Right double-click to set coordinate origin - Prevents context menu with contextmenu event handler 2. **Fix measurement point placement** - Only left clicks place measurement points (was both left and right) - Right clicks no longer duplicate left click behavior 3. **Update all instructions for clarity** - Initial: 'Left-click to measure distance | Right double-click to set origin' - After first point: 'Left-click to select second measurement point' - After complete: 'Drag markers to adjust | Right double-click to set origin' - After origin set: 'Origin set! Left-click to measure | Right double-click for new origin' This provides a clear separation between measurement (left click) and origin transform (right double-click) functions. Co-Authored-By: Claude Sonnet 4.5 --- examples/distance-rescale/index.html | 2 +- examples/distance-rescale/main.js | 30 ++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/examples/distance-rescale/index.html b/examples/distance-rescale/index.html index 464b6f36..e8876704 100644 --- a/examples/distance-rescale/index.html +++ b/examples/distance-rescale/index.html @@ -57,7 +57,7 @@ -
    Click on the model to select first measurement point
    +
    Left-click to measure distance | Right double-click to set origin
    diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index 60d8bed7..973c96a6 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -224,7 +224,7 @@ function selectPoint1(hitPoint) { scene.add(state.rayLine1); state.mode = "select2"; - updateInstructions("Click on the model to select second measurement point"); + updateInstructions("Left-click to select second measurement point"); } function selectPoint2(hitPoint) { @@ -256,7 +256,9 @@ function selectPoint2(hitPoint) { state.mode = "complete"; calculateDistance(); - updateInstructions("Drag markers to adjust position along ray lines"); + updateInstructions( + "Drag markers to adjust | Right double-click to set origin", + ); } // ============================================================================ @@ -411,7 +413,7 @@ function transformOriginTo(newOrigin) { controls.update(); updateInstructions( - "Coordinate origin set. Click to select first measurement point", + "Origin set! Left-click to measure | Right double-click for new origin", ); } @@ -463,7 +465,9 @@ function resetSelection() { // Update UI document.getElementById("distance-display").style.display = "none"; guiParams.measuredDistance = "0.0000"; - updateInstructions("Click on the model to select first measurement point"); + updateInstructions( + "Left-click to measure distance | Right double-click to set origin", + ); } // ============================================================================ @@ -546,6 +550,9 @@ renderer.domElement.addEventListener("pointerup", (event) => { 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; @@ -572,9 +579,9 @@ renderer.domElement.addEventListener("pointerup", (event) => { pointerDownPos = null; }); -// Double-click handler for setting coordinate origin +// Right double-click handler for setting coordinate origin renderer.domElement.addEventListener("dblclick", (event) => { - if (event.button !== 0) return; // Only left button + if (event.button !== 2) return; // Only right button if (!splatMesh) return; @@ -582,13 +589,18 @@ renderer.domElement.addEventListener("dblclick", (event) => { const hitPoint = getHitPoint(ndc); if (!hitPoint) { - console.log("Double-click missed model"); + console.log("Right double-click missed model"); return; } transformOriginTo(hitPoint); }); +// Prevent context menu on right-click +renderer.domElement.addEventListener("contextmenu", (event) => { + event.preventDefault(); +}); + // Drag and drop handlers const onDragover = (e) => { e.preventDefault(); @@ -689,7 +701,9 @@ async function loadSplatFile(urlOrFile) { // Create or update coordinate axes createOrUpdateAxes(); - updateInstructions("Click on the model to select first measurement point"); + 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."); From 95c8720a8746fbf84b8af1493091f6dcfc3cab01 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 14:15:59 +0900 Subject: [PATCH 26/35] Fix right double-click and enable true infinite rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two major fixes to address user-reported issues: 1. **Fix right double-click for origin transform** - dblclick event doesn't reliably capture button property - Implement manual right-click timing detection - Track mousedown events with 300ms threshold - Position tolerance of 10px for double-click detection - Now works reliably for setting coordinate origin 2. **Enable true infinite vertical rotation** - Switch from OrbitControls to TrackballControls - OrbitControls has built-in gimbal lock prevention (limits polar angle) - TrackballControls allows full 360° rotation in ALL directions - No azimuth/polar angle restrictions - Smooth damping with dynamicDampingFactor = 0.15 - Remove controls.target references (TrackballControls don't have target) - Add controls.handleResize() for proper window resizing TrackballControls differences from OrbitControls: - No 'target' property (uses camera lookAt) - No gimbal lock - truly infinite rotation - Different feel: can rotate past vertical axis - Update required in render loop This provides the requested functionality: - Right double-click to set origin (separated from measurement) - Infinite rotation in both horizontal AND vertical directions Co-Authored-By: Claude Sonnet 4.5 --- examples/distance-rescale/main.js | 67 ++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index 973c96a6..a3739cee 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -1,9 +1,8 @@ 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 { TrackballControls } from "three/addons/controls/TrackballControls.js"; import { getAssetFileURL } from "/examples/js/get-asset-url.js"; -import { setupInfiniteRotation } from "/examples/js/orbit-controls-utils.js"; // ============================================================================ // Scene Setup @@ -23,11 +22,13 @@ document.body.appendChild(renderer.domElement); const spark = new SparkRenderer({ renderer }); scene.add(spark); -// Camera controls - using OrbitControls for reliability -const controls = new OrbitControls(camera, renderer.domElement); -controls.enableDamping = true; -controls.dampingFactor = 0.05; -setupInfiniteRotation(controls); // Enable infinite rotation without angle limits +// Camera controls - using TrackballControls for infinite rotation +const controls = new TrackballControls(camera, renderer.domElement); +controls.rotateSpeed = 1.5; +controls.zoomSpeed = 1.2; +controls.panSpeed = 0.8; +controls.staticMoving = false; // Enable smooth movement +controls.dynamicDampingFactor = 0.15; camera.position.set(0, 2, 5); camera.lookAt(0, 0, 0); @@ -36,6 +37,7 @@ function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); + controls.handleResize(); } // ============================================================================ @@ -409,7 +411,7 @@ function transformOriginTo(newOrigin) { // Transform camera to maintain view camera.position.add(translation); - controls.target.add(translation); + // TrackballControls don't have a target, just update controls.update(); updateInstructions( @@ -579,21 +581,49 @@ renderer.domElement.addEventListener("pointerup", (event) => { pointerDownPos = null; }); -// Right double-click handler for setting coordinate origin -renderer.domElement.addEventListener("dblclick", (event) => { +// Right double-click detection using manual timing +let lastRightClickTime = 0; +let lastRightClickPos = null; +const RIGHT_DOUBLE_CLICK_DELAY = 300; // milliseconds + +renderer.domElement.addEventListener("mousedown", (event) => { if (event.button !== 2) return; // Only right button - if (!splatMesh) return; + const now = Date.now(); + const currentPos = { x: event.clientX, y: event.clientY }; - const ndc = getMouseNDC(event); - const hitPoint = getHitPoint(ndc); + // 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 (!hitPoint) { - console.log("Right double-click missed model"); - return; + if (timeSinceLastClick < RIGHT_DOUBLE_CLICK_DELAY && distance < 10) { + // Right double-click detected! + event.preventDefault(); + + if (!splatMesh) return; + + const ndc = getMouseNDC(event); + const hitPoint = getHitPoint(ndc); + + if (!hitPoint) { + console.log("Right double-click missed model"); + lastRightClickTime = 0; + lastRightClickPos = null; + return; + } + + transformOriginTo(hitPoint); + lastRightClickTime = 0; + lastRightClickPos = null; + return; + } } - transformOriginTo(hitPoint); + lastRightClickTime = now; + lastRightClickPos = currentPos; }); // Prevent context menu on right-click @@ -760,8 +790,7 @@ function centerCameraOnModel() { camera.far = cameraDistance * 10; camera.updateProjectionMatrix(); - // Update OrbitControls target - controls.target.copy(center); + // Update TrackballControls controls.update(); console.log( From ce1e7b366ad3bf252ca1cb90cd00d14c1d153517 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 14:24:00 +0900 Subject: [PATCH 27/35] Fix right double-click origin transform - Use mouseup instead of mousedown for better event handling - Add drag detection to distinguish clicks from drags - Stop event propagation to prevent TrackballControls interference - Fix axes helper to stay at world origin (0,0,0) - Add extensive debug logging to track transformation --- examples/distance-rescale/main.js | 51 +++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index a3739cee..7c34008f 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -387,10 +387,11 @@ function rescaleModel(newDistance) { function transformOriginTo(newOrigin) { if (!splatMesh) return; - console.log("Transforming origin to:", newOrigin.toFixed(2)); + console.log("🎯 Transforming origin to:", newOrigin.toFixed(2)); // Calculate translation: move newOrigin to (0,0,0) const translation = newOrigin.clone().negate(); + console.log("📐 Translation vector:", translation.toFixed(2)); // Transform all splat centers splatMesh.packedSplats.forEachSplat( @@ -400,23 +401,29 @@ function transformOriginTo(newOrigin) { }, ); splatMesh.packedSplats.needsUpdate = true; + console.log("✓ Splats transformed"); - // Transform axes helper if exists - if (state.axesHelper) { - state.axesHelper.position.add(translation); - } + // 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(); + console.log("✓ Measurements reset"); // Transform camera to maintain view + const oldCameraPos = camera.position.clone(); camera.position.add(translation); + console.log( + `📷 Camera moved from ${oldCameraPos.toFixed(2)} to ${camera.position.toFixed(2)}`, + ); + // TrackballControls don't have a target, just update controls.update(); updateInstructions( "Origin set! Left-click to measure | Right double-click for new origin", ); + console.log("✅ Origin transformation complete!"); } // ============================================================================ @@ -586,8 +593,26 @@ 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 }; +}); + +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; + if (Math.sqrt(dx * dx + dy * dy) > 5) { + rightPointerDownPos = null; + return; // Was a drag, not a click + } + } const now = Date.now(); const currentPos = { x: event.clientX, y: event.clientY }; @@ -599,11 +624,20 @@ renderer.domElement.addEventListener("mousedown", (event) => { const distance = Math.sqrt(dx * dx + dy * dy); const timeSinceLastClick = now - lastRightClickTime; + console.log( + `Right click: distance=${distance.toFixed(1)}px, time=${timeSinceLastClick}ms`, + ); + if (timeSinceLastClick < RIGHT_DOUBLE_CLICK_DELAY && distance < 10) { // Right double-click detected! + console.log("✓ Right double-click detected!"); event.preventDefault(); + event.stopPropagation(); - if (!splatMesh) return; + if (!splatMesh) { + console.warn("No model loaded"); + return; + } const ndc = getMouseNDC(event); const hitPoint = getHitPoint(ndc); @@ -612,18 +646,23 @@ renderer.domElement.addEventListener("mousedown", (event) => { console.log("Right double-click missed model"); lastRightClickTime = 0; lastRightClickPos = null; + rightPointerDownPos = null; return; } + console.log("Hit point:", hitPoint); transformOriginTo(hitPoint); lastRightClickTime = 0; lastRightClickPos = null; + rightPointerDownPos = null; return; } } lastRightClickTime = now; lastRightClickPos = currentPos; + rightPointerDownPos = null; + console.log("First right click registered"); }); // Prevent context menu on right-click From ba299c7fe6fd7eac444d0d688357d3b604a95783 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 14:29:23 +0900 Subject: [PATCH 28/35] Fix right-click event capture and zoom performance - Add event capture to ensure handlers run before TrackballControls - Set staticMoving=true to disable smooth damping (improves performance) - Reduce zoomSpeed from 1.2 to 0.8 for better control - Add minDistance=0.5 to prevent zooming too close (causes slowness) - Add maxDistance=50 to limit zoom range - Add more debug logging for right-click events --- examples/distance-rescale/main.js | 128 +++++++++++++++++------------- 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index 7c34008f..f9fc40a4 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -25,10 +25,11 @@ scene.add(spark); // Camera controls - using TrackballControls for infinite rotation const controls = new TrackballControls(camera, renderer.domElement); controls.rotateSpeed = 1.5; -controls.zoomSpeed = 1.2; +controls.zoomSpeed = 0.8; // Reduced from 1.2 for smoother zoom controls.panSpeed = 0.8; -controls.staticMoving = false; // Enable smooth movement -controls.dynamicDampingFactor = 0.15; +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); @@ -596,74 +597,87 @@ 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 }; -}); - -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; - if (Math.sqrt(dx * dx + dy * dy) > 5) { - rightPointerDownPos = null; - return; // Was a drag, not a click - } - } +renderer.domElement.addEventListener( + "mousedown", + (event) => { + if (event.button !== 2) return; // Only right button + rightPointerDownPos = { x: event.clientX, y: event.clientY }; + console.log("🖱️ Right mousedown captured"); + }, + { capture: true }, +); - const now = Date.now(); - const currentPos = { x: event.clientX, y: event.clientY }; +renderer.domElement.addEventListener( + "mouseup", + (event) => { + if (event.button !== 2) return; // Only right button - // 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; + console.log("🖱️ Right mouseup captured"); - console.log( - `Right click: distance=${distance.toFixed(1)}px, time=${timeSinceLastClick}ms`, - ); + // 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) { + console.log(`❌ Was a drag (${dragDistance.toFixed(1)}px), ignoring`); + rightPointerDownPos = null; + return; // Was a drag, not a click + } + } - if (timeSinceLastClick < RIGHT_DOUBLE_CLICK_DELAY && distance < 10) { - // Right double-click detected! - console.log("✓ Right double-click detected!"); - event.preventDefault(); - event.stopPropagation(); + 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; + + console.log( + `Right click: distance=${distance.toFixed(1)}px, time=${timeSinceLastClick}ms`, + ); + + if (timeSinceLastClick < RIGHT_DOUBLE_CLICK_DELAY && distance < 10) { + // Right double-click detected! + console.log("✓ Right double-click detected!"); + event.preventDefault(); + event.stopPropagation(); + + if (!splatMesh) { + console.warn("No model loaded"); + return; + } - if (!splatMesh) { - console.warn("No model loaded"); - return; - } + const ndc = getMouseNDC(event); + const hitPoint = getHitPoint(ndc); - const ndc = getMouseNDC(event); - const hitPoint = getHitPoint(ndc); + if (!hitPoint) { + console.log("Right double-click missed model"); + lastRightClickTime = 0; + lastRightClickPos = null; + rightPointerDownPos = null; + return; + } - if (!hitPoint) { - console.log("Right double-click missed model"); + console.log("Hit point:", hitPoint); + transformOriginTo(hitPoint); lastRightClickTime = 0; lastRightClickPos = null; rightPointerDownPos = null; return; } - - console.log("Hit point:", hitPoint); - transformOriginTo(hitPoint); - lastRightClickTime = 0; - lastRightClickPos = null; - rightPointerDownPos = null; - return; } - } - lastRightClickTime = now; - lastRightClickPos = currentPos; - rightPointerDownPos = null; - console.log("First right click registered"); -}); + lastRightClickTime = now; + lastRightClickPos = currentPos; + rightPointerDownPos = null; + console.log("First right click registered"); + }, + { capture: true }, +); // Prevent context menu on right-click renderer.domElement.addEventListener("contextmenu", (event) => { From 39d2c66efd81be3402389311a4bfc856fdd6374d Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 14:51:09 +0900 Subject: [PATCH 29/35] Fix Vector3 logging bug in transformOriginTo The bug: Vector3.toFixed() doesn't exist - toFixed() is a Number method Fix: Access individual x, y, z properties and format each coordinate This was preventing the entire transformOriginTo function from running, causing right double-click to silently fail. --- examples/distance-rescale/main.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index f9fc40a4..0cbda397 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -388,11 +388,15 @@ function rescaleModel(newDistance) { function transformOriginTo(newOrigin) { if (!splatMesh) return; - console.log("🎯 Transforming origin to:", newOrigin.toFixed(2)); + console.log( + `🎯 Transforming origin to: (${newOrigin.x.toFixed(2)}, ${newOrigin.y.toFixed(2)}, ${newOrigin.z.toFixed(2)})`, + ); // Calculate translation: move newOrigin to (0,0,0) const translation = newOrigin.clone().negate(); - console.log("📐 Translation vector:", translation.toFixed(2)); + console.log( + `📐 Translation vector: (${translation.x.toFixed(2)}, ${translation.y.toFixed(2)}, ${translation.z.toFixed(2)})`, + ); // Transform all splat centers splatMesh.packedSplats.forEachSplat( @@ -415,7 +419,7 @@ function transformOriginTo(newOrigin) { const oldCameraPos = camera.position.clone(); camera.position.add(translation); console.log( - `📷 Camera moved from ${oldCameraPos.toFixed(2)} to ${camera.position.toFixed(2)}`, + `📷 Camera moved from (${oldCameraPos.x.toFixed(2)}, ${oldCameraPos.y.toFixed(2)}, ${oldCameraPos.z.toFixed(2)}) to (${camera.position.x.toFixed(2)}, ${camera.position.y.toFixed(2)}, ${camera.position.z.toFixed(2)})`, ); // TrackballControls don't have a target, just update From 61e4e46767d601c686f5d7d4fadb0a14ad97b9c0 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 15:00:46 +0900 Subject: [PATCH 30/35] Clean up debug logging from production code Removed emoji-based debug console.log statements that were added during development: - transformOriginTo: Removed verbose transformation logs - Right-click handlers: Removed mousedown/mouseup/detection logs Kept only essential warning messages (e.g., 'No model loaded'). --- examples/distance-rescale/main.js | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index 0cbda397..87eec81d 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -388,15 +388,8 @@ function rescaleModel(newDistance) { function transformOriginTo(newOrigin) { if (!splatMesh) return; - console.log( - `🎯 Transforming origin to: (${newOrigin.x.toFixed(2)}, ${newOrigin.y.toFixed(2)}, ${newOrigin.z.toFixed(2)})`, - ); - // Calculate translation: move newOrigin to (0,0,0) const translation = newOrigin.clone().negate(); - console.log( - `📐 Translation vector: (${translation.x.toFixed(2)}, ${translation.y.toFixed(2)}, ${translation.z.toFixed(2)})`, - ); // Transform all splat centers splatMesh.packedSplats.forEachSplat( @@ -406,21 +399,15 @@ function transformOriginTo(newOrigin) { }, ); splatMesh.packedSplats.needsUpdate = true; - console.log("✓ Splats transformed"); // 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(); - console.log("✓ Measurements reset"); // Transform camera to maintain view - const oldCameraPos = camera.position.clone(); camera.position.add(translation); - console.log( - `📷 Camera moved from (${oldCameraPos.x.toFixed(2)}, ${oldCameraPos.y.toFixed(2)}, ${oldCameraPos.z.toFixed(2)}) to (${camera.position.x.toFixed(2)}, ${camera.position.y.toFixed(2)}, ${camera.position.z.toFixed(2)})`, - ); // TrackballControls don't have a target, just update controls.update(); @@ -428,7 +415,6 @@ function transformOriginTo(newOrigin) { updateInstructions( "Origin set! Left-click to measure | Right double-click for new origin", ); - console.log("✅ Origin transformation complete!"); } // ============================================================================ @@ -606,7 +592,6 @@ renderer.domElement.addEventListener( (event) => { if (event.button !== 2) return; // Only right button rightPointerDownPos = { x: event.clientX, y: event.clientY }; - console.log("🖱️ Right mousedown captured"); }, { capture: true }, ); @@ -616,15 +601,12 @@ renderer.domElement.addEventListener( (event) => { if (event.button !== 2) return; // Only right button - console.log("🖱️ Right mouseup captured"); - // 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) { - console.log(`❌ Was a drag (${dragDistance.toFixed(1)}px), ignoring`); rightPointerDownPos = null; return; // Was a drag, not a click } @@ -640,13 +622,8 @@ renderer.domElement.addEventListener( const distance = Math.sqrt(dx * dx + dy * dy); const timeSinceLastClick = now - lastRightClickTime; - console.log( - `Right click: distance=${distance.toFixed(1)}px, time=${timeSinceLastClick}ms`, - ); - if (timeSinceLastClick < RIGHT_DOUBLE_CLICK_DELAY && distance < 10) { // Right double-click detected! - console.log("✓ Right double-click detected!"); event.preventDefault(); event.stopPropagation(); @@ -659,14 +636,12 @@ renderer.domElement.addEventListener( const hitPoint = getHitPoint(ndc); if (!hitPoint) { - console.log("Right double-click missed model"); lastRightClickTime = 0; lastRightClickPos = null; rightPointerDownPos = null; return; } - console.log("Hit point:", hitPoint); transformOriginTo(hitPoint); lastRightClickTime = 0; lastRightClickPos = null; @@ -678,7 +653,6 @@ renderer.domElement.addEventListener( lastRightClickTime = now; lastRightClickPos = currentPos; rightPointerDownPos = null; - console.log("First right click registered"); }, { capture: true }, ); From 1a0abbd92e4d9a1b3bebe0f089f6e674c09200ee Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Mon, 26 Jan 2026 15:03:17 +0900 Subject: [PATCH 31/35] Add comprehensive documentation for Distance Measurement & Rescale - Create English guide: distance-rescale-guide.md - Update Japanese guide: distance-rescale-guide-ja.md - Add guides to mkdocs.yml navigation New features documented: - Three file loading methods (button, drag & drop, default) - Toggle coordinate axes visualization - Right double-click to set new origin - Infinite camera rotation in all directions - Zoom performance limits - Updated UI layout (lil-gui top-right) Both guides include: - Complete usage instructions - Control reference table - UI layout description - Tips and notes --- docs/distance-rescale-guide-ja.md | 85 +++++++++++++++++---- docs/distance-rescale-guide.md | 122 ++++++++++++++++++++++++++++++ mkdocs.yml | 2 + 3 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 docs/distance-rescale-guide.md diff --git a/docs/distance-rescale-guide-ja.md b/docs/distance-rescale-guide-ja.md index 60d553ae..0ea38227 100644 --- a/docs/distance-rescale-guide-ja.md +++ b/docs/distance-rescale-guide-ja.md @@ -2,13 +2,13 @@ ## アクセス方法 -**URL**: [https://nice-meadow-018297c00.6.azurestaticapps.net/examples/distance-rescale/](https://nice-meadow-018297c00.6.azurestaticapps.net/examples/distance-rescale/) +**URL**: [https://sparkjs.dev/examples/distance-rescale/](https://sparkjs.dev/examples/distance-rescale/) --- ## 機能概要 -3DGSモデル上で2点間の距離を測定し、実際の寸法に合わせてモデルをリスケール(拡大縮小)できます。 +3DGSモデル上で2点間の距離を測定し、実際の寸法に合わせてモデルをリスケール(拡大縮小)できます。座標系の可視化、原点の変更、柔軟なファイル読み込み機能を備えています。 --- @@ -16,36 +16,68 @@ ### 1. 3DGSファイルの読み込み -1. 画面左下の **「Load PLY File」** ボタンをクリック +3つの方法でファイルを読み込むことができます: + +**方法A: ボタンから読み込み** +1. 画面右上の **Controls** パネルにある **「Load PLY File」** ボタンをクリック 2. ファイル選択ダイアログから `.ply`、`.spz`、`.splat` ファイルを選択 3. モデルが自動的に読み込まれ、カメラが自動調整される -### 2. 測定点の配置 +**方法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ファイルには変換後の座標が含まれる -1. **1点目**: モデル上の任意の位置をクリック(緑のマーカーが表示) -2. **2点目**: モデル上の別の位置をクリック(青のマーカーが表示) -3. 2点間に黄色の線が表示され、距離が画面右下に表示される +### 4. 測定点の配置 -### 3. 測定点の調整 +1. **1点目**: モデル上の任意の位置を左クリック(緑のマーカーが表示) +2. **2点目**: モデル上の別の位置を左クリック(青のマーカーが表示) +3. 2点間に黄色の線が表示され、距離が表示される + +### 5. 測定点の調整 - マーカーをドラッグすると、視線方向に沿って位置を調整可能 - 距離はリアルタイムで更新される +- 測定距離はControlsパネルと画面右下に表示される -### 4. リスケール(サイズ変更) +### 6. リスケール(サイズ変更) -1. 画面右上のGUIパネルで **「Measured Distance」** に現在の測定値が表示される +1. **「Measured Distance」** フィールドに現在の測定値が表示される 2. **「New Distance」** に実際の距離(メートル単位)を入力 3. **「Apply Rescale」** をクリック 4. モデル全体が指定した寸法に合わせてスケーリングされる -### 5. モデルの保存 +### 7. モデルの保存 1. **「Export PLY」** をクリック 2. `rescaled_model.ply` としてダウンロードされる +3. エクスポートされるファイルにはすべての変換(リスケールと原点変更)が含まれる -### 6. リセット +### 8. リセット - **「Reset Points」** をクリックすると測定点がクリアされ、最初からやり直せる +- 原点の変換とリスケールは保持される --- @@ -55,13 +87,36 @@ |------|------| | 左クリック | 測定点を配置 | | マーカーをドラッグ | 測定点の位置を調整 | -| 左ドラッグ(モデル上以外) | カメラ回転 | +| 左ドラッグ(空白部分) | カメラ回転(無制限回転) | | 右ドラッグ / 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ファイルには最終的な変換後の座標が含まれます +- すべてのファイル形式(.ply、.spz、.splat)が読み込みとドラッグ&ドロップに対応しています diff --git a/docs/distance-rescale-guide.md b/docs/distance-rescale-guide.md new file mode 100644 index 00000000..61c8876f --- /dev/null +++ b/docs/distance-rescale-guide.md @@ -0,0 +1,122 @@ +# Distance Measurement & Rescale Usage Guide + +## Access + +**URL**: [https://sparkjs.dev/examples/distance-rescale/](https://sparkjs.dev/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/mkdocs.yml b/mkdocs.yml index bbbc39c4..5a95a803 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,8 @@ nav: - Dyno standard library: docs/dyno-stdlib.md - Controls: docs/controls.md - Performance tuning: docs/performance.md + - Distance Measurement & Rescale Guide: distance-rescale-guide.md + - Distance Measurement & Rescale Guide (日本語): distance-rescale-guide-ja.md - Community Resources: docs/community-resources.md markdown_extensions: - pymdownx.highlight: From 86bc0365bc8626e9e7a8e77893cf2ad70ee64994 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 27 Jan 2026 16:12:42 +0900 Subject: [PATCH 32/35] Fix deployment URL in distance-rescale docs Replace sparkjs.dev URL with correct Azure Static Web Apps deployment URL. Co-Authored-By: Claude Opus 4.5 --- docs/distance-rescale-guide-ja.md | 2 +- docs/distance-rescale-guide.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/distance-rescale-guide-ja.md b/docs/distance-rescale-guide-ja.md index 0ea38227..24f2fb84 100644 --- a/docs/distance-rescale-guide-ja.md +++ b/docs/distance-rescale-guide-ja.md @@ -2,7 +2,7 @@ ## アクセス方法 -**URL**: [https://sparkjs.dev/examples/distance-rescale/](https://sparkjs.dev/examples/distance-rescale/) +**URL**: [https://nice-meadow-018297c00.eastasia.6.azurestaticapps.net/examples/distance-rescale/](https://nice-meadow-018297c00.eastasia.6.azurestaticapps.net/examples/distance-rescale/) --- diff --git a/docs/distance-rescale-guide.md b/docs/distance-rescale-guide.md index 61c8876f..2a27ea53 100644 --- a/docs/distance-rescale-guide.md +++ b/docs/distance-rescale-guide.md @@ -2,7 +2,7 @@ ## Access -**URL**: [https://sparkjs.dev/examples/distance-rescale/](https://sparkjs.dev/examples/distance-rescale/) +**URL**: [https://nice-meadow-018297c00.eastasia.6.azurestaticapps.net/examples/distance-rescale/](https://nice-meadow-018297c00.eastasia.6.azurestaticapps.net/examples/distance-rescale/) --- From e034f5fa3528e46ace9798edacca2c5f4b76dde9 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Tue, 27 Jan 2026 16:04:31 +0900 Subject: [PATCH 33/35] Fix issues from Copilot PR review - Use disposeObject() helper for AxesHelper instead of non-existent dispose() - Add e.target check in dragleave to prevent flicker from child elements - Add file extension validation (.ply, .spz, .splat) for drag-and-drop Co-Authored-By: Claude Opus 4.5 --- examples/distance-rescale/main.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/distance-rescale/main.js b/examples/distance-rescale/main.js index 87eec81d..7fa5fd7e 100644 --- a/examples/distance-rescale/main.js +++ b/examples/distance-rescale/main.js @@ -671,6 +671,7 @@ const onDragover = (e) => { const onDragLeave = (e) => { e.preventDefault(); + if (e.target !== renderer.domElement) return; renderer.domElement.style.outline = "none"; }; @@ -680,7 +681,16 @@ const onDrop = (e) => { const files = e.dataTransfer.files; if (files.length > 0) { - loadSplatFile(files[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"); } @@ -841,8 +851,7 @@ function createOrUpdateAxes() { // Remove existing axes if (state.axesHelper) { - scene.remove(state.axesHelper); - state.axesHelper.dispose(); + disposeObject(state.axesHelper); } // Get model bounding box to size axes appropriately From 1be2325325b19bf51161bc59794d7f5e1f6280f9 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Wed, 4 Mar 2026 13:54:33 +0900 Subject: [PATCH 34/35] Remove Azure-specific files and infrastructure from feature branch Remove files that are specific to the sensyn-robotics fork's Azure hosting and should not be submitted upstream: - Azure Static Web Apps workflow and config - Pre-built WASM package (for Azure CI) - Azure config copy in rename-assets script - Restore upstream README content Co-Authored-By: Claude Opus 4.6 --- .github/workflows/azure-static-web-apps.yml | 51 ---- README.md | 12 + rust/spark-internal-rs/pkg/README.md | 35 --- rust/spark-internal-rs/pkg/package.json | 23 -- .../pkg/spark_internal_rs.d.ts | 37 --- .../pkg/spark_internal_rs.js | 239 ------------------ .../pkg/spark_internal_rs_bg.wasm | Bin 32140 -> 0 bytes .../pkg/spark_internal_rs_bg.wasm.d.ts | 8 - scripts/rename-assets-to-static.js | 10 - staticwebapp.config.json | 23 -- 10 files changed, 12 insertions(+), 426 deletions(-) delete mode 100644 .github/workflows/azure-static-web-apps.yml delete mode 100644 rust/spark-internal-rs/pkg/README.md delete mode 100644 rust/spark-internal-rs/pkg/package.json delete mode 100644 rust/spark-internal-rs/pkg/spark_internal_rs.d.ts delete mode 100644 rust/spark-internal-rs/pkg/spark_internal_rs.js delete mode 100644 rust/spark-internal-rs/pkg/spark_internal_rs_bg.wasm delete mode 100644 rust/spark-internal-rs/pkg/spark_internal_rs_bg.wasm.d.ts delete mode 100644 staticwebapp.config.json diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml deleted file mode 100644 index c9f67774..00000000 --- a/.github/workflows/azure-static-web-apps.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Builds and deploys Spark examples site to Azure Static Web Apps -name: Deploy to Azure Static Web Apps - -on: - push: - branches: - - main - - feature/azure_hosting - paths: - - 'src/**' - - 'examples/**' - - 'docs/**' - - 'index.html' - - 'examples.html' - - 'package.json' - - '.github/workflows/azure-static-web-apps.yml' - -jobs: - build_and_deploy: - runs-on: ubuntu-latest - name: Build and Deploy - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '22.x' - cache: 'npm' - - - name: Install dependencies - run: npm ci --ignore-scripts - - - name: Install MkDocs - run: pip install mkdocs-material - - - name: Build site - run: npm run site:build - - - name: Deploy to Azure Static Web Apps - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_NICE_MEADOW_018297C00 }} - repo_token: ${{ secrets.GITHUB_TOKEN }} - action: "upload" - skip_app_build: true - skip_api_build: true - app_location: "site" - output_location: "" 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/rust/spark-internal-rs/pkg/README.md b/rust/spark-internal-rs/pkg/README.md deleted file mode 100644 index a9891d6e..00000000 --- a/rust/spark-internal-rs/pkg/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# spark-internal-rs - -Rust WebAssembly functions for spark-internal. - -## Installing build tools - -First, we need to install Rust. Though it is possible to install it using Homebrew, we recommend installing `rustup` using the approach on the Rust homepage: - -https://www.rust-lang.org/tools/install - -It will most likely involve simply running: -``` -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -``` - -Once you have `rustup` and Rust, we need to install dependencies for building Rust Wasm. -``` -rustup target add wasm32-unknown-unknown -cargo install wasm-pack -``` - -## Building - -Run the following script inside `spark-internal/rust`: -``` -./build_rust_wasm.sh -``` - -You can also build it manually by running these commands: -``` -cd spark-internal-rs -wasm-pack build --target web -``` - -The generated files will be in the `pkg/` subdirectory, which is already symlinked in `spark-internal/package.json`. diff --git a/rust/spark-internal-rs/pkg/package.json b/rust/spark-internal-rs/pkg/package.json deleted file mode 100644 index 11ea65c0..00000000 --- a/rust/spark-internal-rs/pkg/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "spark-internal-rs", - "type": "module", - "collaborators": [ - "World Labs Technologies" - ], - "version": "0.1.0", - "license": "Proprietary", - "repository": { - "type": "git", - "url": "https://github.com/sparkjs-dev/spark" - }, - "files": [ - "spark_internal_rs_bg.wasm", - "spark_internal_rs.js", - "spark_internal_rs.d.ts" - ], - "main": "spark_internal_rs.js", - "types": "spark_internal_rs.d.ts", - "sideEffects": [ - "./snippets/*" - ] -} \ No newline at end of file diff --git a/rust/spark-internal-rs/pkg/spark_internal_rs.d.ts b/rust/spark-internal-rs/pkg/spark_internal_rs.d.ts deleted file mode 100644 index 92dcbd6a..00000000 --- a/rust/spark-internal-rs/pkg/spark_internal_rs.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -export function sort_splats(num_splats: number, readback: Uint16Array, ordering: Uint32Array): number; -export function sort32_splats(num_splats: number, readback: Uint32Array, ordering: Uint32Array): number; -export function raycast_splats(origin_x: number, origin_y: number, origin_z: number, dir_x: number, dir_y: number, dir_z: number, near: number, far: number, num_splats: number, packed_splats: Uint32Array, raycast_ellipsoid: boolean, ln_scale_min: number, ln_scale_max: number): Float32Array; - -export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; - -export interface InitOutput { - readonly memory: WebAssembly.Memory; - readonly raycast_splats: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: any, k: number, l: number, m: number) => any; - readonly sort32_splats: (a: number, b: any, c: any) => number; - readonly sort_splats: (a: number, b: any, c: any) => number; - readonly __wbindgen_export_0: WebAssembly.Table; - readonly __wbindgen_start: () => void; -} - -export type SyncInitInput = BufferSource | WebAssembly.Module; -/** -* Instantiates the given `module`, which can either be bytes or -* a precompiled `WebAssembly.Module`. -* -* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. -* -* @returns {InitOutput} -*/ -export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; - -/** -* If `module_or_path` is {RequestInfo} or {URL}, makes a request and -* for everything else, calls `WebAssembly.instantiate` directly. -* -* @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. -* -* @returns {Promise} -*/ -export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/rust/spark-internal-rs/pkg/spark_internal_rs.js b/rust/spark-internal-rs/pkg/spark_internal_rs.js deleted file mode 100644 index f36da0d9..00000000 --- a/rust/spark-internal-rs/pkg/spark_internal_rs.js +++ /dev/null @@ -1,239 +0,0 @@ -let wasm; - -const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } ); - -if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); }; - -let cachedUint8ArrayMemory0 = null; - -function getUint8ArrayMemory0() { - if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { - cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); - } - return cachedUint8ArrayMemory0; -} - -function getStringFromWasm0(ptr, len) { - ptr = ptr >>> 0; - return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); -} -/** - * @param {number} num_splats - * @param {Uint16Array} readback - * @param {Uint32Array} ordering - * @returns {number} - */ -export function sort_splats(num_splats, readback, ordering) { - const ret = wasm.sort_splats(num_splats, readback, ordering); - return ret >>> 0; -} - -/** - * @param {number} num_splats - * @param {Uint32Array} readback - * @param {Uint32Array} ordering - * @returns {number} - */ -export function sort32_splats(num_splats, readback, ordering) { - const ret = wasm.sort32_splats(num_splats, readback, ordering); - return ret >>> 0; -} - -/** - * @param {number} origin_x - * @param {number} origin_y - * @param {number} origin_z - * @param {number} dir_x - * @param {number} dir_y - * @param {number} dir_z - * @param {number} near - * @param {number} far - * @param {number} num_splats - * @param {Uint32Array} packed_splats - * @param {boolean} raycast_ellipsoid - * @param {number} ln_scale_min - * @param {number} ln_scale_max - * @returns {Float32Array} - */ -export function raycast_splats(origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, near, far, num_splats, packed_splats, raycast_ellipsoid, ln_scale_min, ln_scale_max) { - const ret = wasm.raycast_splats(origin_x, origin_y, origin_z, dir_x, dir_y, dir_z, near, far, num_splats, packed_splats, raycast_ellipsoid, ln_scale_min, ln_scale_max); - return ret; -} - -async function __wbg_load(module, imports) { - if (typeof Response === 'function' && module instanceof Response) { - if (typeof WebAssembly.instantiateStreaming === 'function') { - try { - return await WebAssembly.instantiateStreaming(module, imports); - - } catch (e) { - if (module.headers.get('Content-Type') != 'application/wasm') { - console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); - - } else { - throw e; - } - } - } - - const bytes = await module.arrayBuffer(); - return await WebAssembly.instantiate(bytes, imports); - - } else { - const instance = await WebAssembly.instantiate(module, imports); - - if (instance instanceof WebAssembly.Instance) { - return { instance, module }; - - } else { - return instance; - } - } -} - -function __wbg_get_imports() { - const imports = {}; - imports.wbg = {}; - imports.wbg.__wbg_buffer_609cc3eee51ed158 = function(arg0) { - const ret = arg0.buffer; - return ret; - }; - imports.wbg.__wbg_length_3b4f022188ae8db6 = function(arg0) { - const ret = arg0.length; - return ret; - }; - imports.wbg.__wbg_length_6ca527665d89694d = function(arg0) { - const ret = arg0.length; - return ret; - }; - imports.wbg.__wbg_length_8cfd2c6409af88ad = function(arg0) { - const ret = arg0.length; - return ret; - }; - imports.wbg.__wbg_new_9fee97a409b32b68 = function(arg0) { - const ret = new Uint16Array(arg0); - return ret; - }; - imports.wbg.__wbg_new_e3b321dcfef89fc7 = function(arg0) { - const ret = new Uint32Array(arg0); - return ret; - }; - imports.wbg.__wbg_newwithbyteoffsetandlength_e6b7e69acd4c7354 = function(arg0, arg1, arg2) { - const ret = new Float32Array(arg0, arg1 >>> 0, arg2 >>> 0); - return ret; - }; - imports.wbg.__wbg_newwithbyteoffsetandlength_f1dead44d1fc7212 = function(arg0, arg1, arg2) { - const ret = new Uint32Array(arg0, arg1 >>> 0, arg2 >>> 0); - return ret; - }; - imports.wbg.__wbg_newwithlength_5a5efe313cfd59f1 = function(arg0) { - const ret = new Float32Array(arg0 >>> 0); - return ret; - }; - imports.wbg.__wbg_set_10bad9bee0e9c58b = function(arg0, arg1, arg2) { - arg0.set(arg1, arg2 >>> 0); - }; - imports.wbg.__wbg_set_d23661d19148b229 = function(arg0, arg1, arg2) { - arg0.set(arg1, arg2 >>> 0); - }; - imports.wbg.__wbg_set_f4f1f0daa30696fc = function(arg0, arg1, arg2) { - arg0.set(arg1, arg2 >>> 0); - }; - imports.wbg.__wbg_subarray_3aaeec89bb2544f0 = function(arg0, arg1, arg2) { - const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); - return ret; - }; - imports.wbg.__wbg_subarray_769e1e0f81bb259b = function(arg0, arg1, arg2) { - const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); - return ret; - }; - imports.wbg.__wbindgen_init_externref_table = function() { - const table = wasm.__wbindgen_export_0; - const offset = table.grow(4); - table.set(0, undefined); - table.set(offset + 0, undefined); - table.set(offset + 1, null); - table.set(offset + 2, true); - table.set(offset + 3, false); - ; - }; - imports.wbg.__wbindgen_memory = function() { - const ret = wasm.memory; - return ret; - }; - imports.wbg.__wbindgen_throw = function(arg0, arg1) { - throw new Error(getStringFromWasm0(arg0, arg1)); - }; - - return imports; -} - -function __wbg_init_memory(imports, memory) { - -} - -function __wbg_finalize_init(instance, module) { - wasm = instance.exports; - __wbg_init.__wbindgen_wasm_module = module; - cachedUint8ArrayMemory0 = null; - - - wasm.__wbindgen_start(); - return wasm; -} - -function initSync(module) { - if (wasm !== undefined) return wasm; - - - if (typeof module !== 'undefined') { - if (Object.getPrototypeOf(module) === Object.prototype) { - ({module} = module) - } else { - console.warn('using deprecated parameters for `initSync()`; pass a single object instead') - } - } - - const imports = __wbg_get_imports(); - - __wbg_init_memory(imports); - - if (!(module instanceof WebAssembly.Module)) { - module = new WebAssembly.Module(module); - } - - const instance = new WebAssembly.Instance(module, imports); - - return __wbg_finalize_init(instance, module); -} - -async function __wbg_init(module_or_path) { - if (wasm !== undefined) return wasm; - - - if (typeof module_or_path !== 'undefined') { - if (Object.getPrototypeOf(module_or_path) === Object.prototype) { - ({module_or_path} = module_or_path) - } else { - console.warn('using deprecated parameters for the initialization function; pass a single object instead') - } - } - - if (typeof module_or_path === 'undefined') { - module_or_path = new URL('spark_internal_rs_bg.wasm', import.meta.url); - } - const imports = __wbg_get_imports(); - - if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { - module_or_path = fetch(module_or_path); - } - - __wbg_init_memory(imports); - - const { instance, module } = await __wbg_load(await module_or_path, imports); - - return __wbg_finalize_init(instance, module); -} - -export { initSync }; -export default __wbg_init; diff --git a/rust/spark-internal-rs/pkg/spark_internal_rs_bg.wasm b/rust/spark-internal-rs/pkg/spark_internal_rs_bg.wasm deleted file mode 100644 index 5be7ba80748dcc1478211b93c608028ca6406f98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32140 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 L&Mu<9C71s any; -export const sort32_splats: (a: number, b: any, c: any) => number; -export const sort_splats: (a: number, b: any, c: any) => number; -export const __wbindgen_export_0: WebAssembly.Table; -export const __wbindgen_start: () => void; diff --git a/scripts/rename-assets-to-static.js b/scripts/rename-assets-to-static.js index a0af46c7..47e2d3f1 100644 --- a/scripts/rename-assets-to-static.js +++ b/scripts/rename-assets-to-static.js @@ -1,6 +1,5 @@ import { execSync } from "node:child_process"; import { - copyFileSync, readFileSync, readdirSync, renameSync, @@ -39,12 +38,3 @@ function replaceInHtmlFiles(dir) { } } } - -// Copy Azure Static Web Apps config if it exists -const swaConfig = "staticwebapp.config.json"; -try { - copyFileSync(swaConfig, join(siteDirectory, swaConfig)); - console.log(`Copied ${swaConfig} to ${siteDirectory}/`); -} catch { - // Config file is optional - skip if not present -} diff --git a/staticwebapp.config.json b/staticwebapp.config.json deleted file mode 100644 index b2886502..00000000 --- a/staticwebapp.config.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "navigationFallback": { - "rewrite": "/index.html", - "exclude": [ - "/dist/*", - "/examples/*", - "*.js", - "*.css", - "*.spz", - "*.ply", - "*.glb" - ] - }, - "mimeTypes": { - ".spz": "application/octet-stream", - ".splat": "application/octet-stream", - ".ply": "application/octet-stream", - ".wasm": "application/wasm" - }, - "globalHeaders": { - "X-Content-Type-Options": "nosniff" - } -} From a210c9e8aac4857bced4d1b9adcd37729a50e4b1 Mon Sep 17 00:00:00 2001 From: Masahiro Ogawa Date: Wed, 4 Mar 2026 13:57:20 +0900 Subject: [PATCH 35/35] Fix minimatch ReDoS vulnerabilities (2 high severity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add npm overrides to force patched minimatch versions: - minimatch <3.1.3 → 3.1.3 (via @microsoft/api-extractor) - minimatch >=9.0.0 <9.0.7 → 9.0.7 (via @vue/language-core) Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 35 ++++++++++++++++++++++++----------- package.json | 6 +++++- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e2da764..f3e7325f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1350,24 +1350,37 @@ } } }, + "node_modules/@vue/language-core/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2190,9 +2203,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index aee4e536..72691700 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,9 @@ "peerDependencies": { "three": "^0.178.0" }, - "keywords": ["3d", "three.js", "gsplats", "3dgs", "gaussian", "splats"] + "keywords": ["3d", "three.js", "gsplats", "3dgs", "gaussian", "splats"], + "overrides": { + "minimatch@<3.1.3": "3.1.3", + "minimatch@>=9.0.0 <9.0.7": "9.0.7" + } }