diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 47bd3e2..b02ec0f 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -15,20 +15,27 @@ jobs: - name: Build WebAssembly assets run: | - docker compose -f wasm-examples/extras/docker-compose.yml \ + docker compose -f docs/wasm-examples/extras/docker-compose.yml \ up --build --abort-on-container-exit --exit-code-from web-example - name: Tear down docker compose if: always() run: | - docker compose -f wasm-examples/extras/docker-compose.yml down \ + docker compose -f docs/wasm-examples/extras/docker-compose.yml down \ --volumes --remove-orphans - - name: Upload artifacts + - name: Upload WASM artifacts uses: actions/upload-artifact@v4 with: name: wasm-build - path: wasm-examples/build/dist + path: docs/wasm-examples/build/dist + retention-days: 7 + + - name: Upload TypeScript SDK artifacts + uses: actions/upload-artifact@v4 + with: + name: ts-sdk-build + path: typescript/dist retention-days: 7 build: @@ -43,7 +50,13 @@ jobs: uses: actions/download-artifact@v4 with: name: wasm-build - path: wasm-examples/build/dist + path: docs/wasm-examples/build/dist + + - name: Download TypeScript SDK artifacts + uses: actions/download-artifact@v4 + with: + name: ts-sdk-build + path: typescript/dist - name: Install pnpm uses: pnpm/action-setup@v4 diff --git a/.gitignore b/.gitignore index 2b6f26e..4e36475 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ __pycache__/ # Generated include/trueform/version.hpp +typescript/LICENSE +typescript/LICENSE.noncommercial +typescript/package-lock.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a3cb06..65667b6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ endif() # ============================================================================== # Project Configuration # ============================================================================== -project(trueform VERSION 0.6.0 LANGUAGES CXX) +project(trueform VERSION 0.7.0 LANGUAGES CXX) configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.hpp.in diff --git a/README.md b/README.md index a556042..d3b1fe1 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ [![Docs](https://github.com/polydera/trueform/actions/workflows/generate-docs.yml/badge.svg)](https://github.com/polydera/trueform/actions/workflows/generate-docs.yml) [![Build](https://github.com/polydera/trueform/actions/workflows/build-python.yml/badge.svg)](https://github.com/polydera/trueform/actions/workflows/build-python.yml) [![PyPI](https://img.shields.io/pypi/v/trueform)](https://pypi.org/project/trueform/) +[![npm](https://img.shields.io/npm/v/@polydera/trueform)](https://www.npmjs.com/package/@polydera/trueform) Real-time geometric processing. Easy to use, robust on real-world data. -Mesh booleans, registration, remeshing and queries — at interactive speed on million-polygon meshes. Robust to non-manifold flaps, inconsistent winding, and pipeline artifacts. Header-only C++17; works directly on your data with zero-copy views. +Mesh booleans, registration, remeshing and queries — at interactive speed on million-polygon meshes. Robust to non-manifold flaps, inconsistent winding, and pipeline artifacts. One engine across C++, Python, and TypeScript. **[▶ Try it live](https://trueform.polydera.com/live-examples/boolean)** — Interactive mesh booleans, collisions, isobands and more. No install needed. @@ -40,10 +41,22 @@ cmake -B build -Dtrueform_ROOT=$(python -m trueform.cmake) For manual installation without pip (FetchContent, system install, conan from repo), see the [full installation guide](https://trueform.polydera.com/cpp/getting-started/installation). +**Python** — the same pip package includes Python bindings: +```python +import trueform as tf +mesh = tf.read_stl("model.stl") +``` + +**TypeScript** — browser and Node.js: +```bash +npm install @polydera/trueform +``` + ## Integrations -- **[VTK](https://trueform.polydera.com/cpp/vtk)** — Filters and functions that integrate with VTK pipelines - **[Python](https://trueform.polydera.com/py/getting-started)** — NumPy in, NumPy out +- **[TypeScript](https://trueform.polydera.com/ts/getting-started)** — NDArrays in, NDArrays out. Browser and Node.js. +- **[VTK](https://trueform.polydera.com/cpp/vtk)** — Filters and functions that integrate with VTK pipelines - **[Blender](https://trueform.polydera.com/py/blender)** — Cached meshes with automatic updates for live preview ## Quick Tour @@ -205,6 +218,7 @@ Apple M4 Max, 16 threads, Clang `-O3 -march=native`. Full methodology, interacti - [Benchmarks](https://trueform.polydera.com/cpp/benchmarks) — Performance comparisons - [Examples](https://trueform.polydera.com/cpp/examples) — Workflows and library comparisons - [Python Bindings](https://trueform.polydera.com/py/getting-started) — Full API for Python +- [TypeScript SDK](https://trueform.polydera.com/ts/getting-started) — WASM-powered, browser and Node.js - [Research](https://trueform.polydera.com/cpp/about/research) — Theory, publications, and citation ## License diff --git a/docs/app/components/LibPicker.vue b/docs/app/components/LibPicker.vue index 11f675f..2d9e67c 100644 --- a/docs/app/components/LibPicker.vue +++ b/docs/app/components/LibPicker.vue @@ -4,27 +4,29 @@ const route = useRoute(); const items = [ { - label: "C++", icon: "i-vscode-icons:file-type-cpp", value: "cpp", }, { - label: "Python", icon: "i-vscode-icons:file-type-python", value: "py", }, + { + icon: "i-vscode-icons:file-type-typescript", + value: "ts", + }, ]; const router = useRouter(); const handleChange = async (value: string | number) => { - const newLibrary = value as "cpp" | "py"; - const newCollection = newLibrary === "cpp" ? "docsCpp" : "docsPy"; + const newLibrary = value as "cpp" | "py" | "ts"; + const newCollection = newLibrary === "cpp" ? "docsCpp" : newLibrary === "py" ? "docsPy" : "docsTs"; // If we're on a library-specific path, try to find the equivalent page const currentPath = route.path; const pathParts = currentPath.split("/"); - if (pathParts[1] === "cpp" || pathParts[1] === "py") { + if (pathParts[1] === "cpp" || pathParts[1] === "py" || pathParts[1] === "ts") { // Replace the library prefix const newPath = `/${newLibrary}${currentPath.slice(pathParts[1].length + 1)}`; diff --git a/docs/app/components/content/PythonFlowDiagram.vue b/docs/app/components/content/PythonFlowDiagram.vue index 3b5a7e5..65d1bbf 100644 --- a/docs/app/components/content/PythonFlowDiagram.vue +++ b/docs/app/components/content/PythonFlowDiagram.vue @@ -4,156 +4,162 @@ @@ -171,8 +177,8 @@ background: rgba(0, 0, 0, 0.02); border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 12px; - padding: 1rem 0.5rem; - max-width: 30rem; + padding: 1rem 0.25rem; + max-width: 32rem; width: 100%; } @@ -213,17 +219,6 @@ stroke: rgba(255, 255, 255, 0.12); } -.stage-inner-container { - fill: rgba(0, 0, 0, 0.02); - stroke: rgba(0, 0, 0, 0.06); - stroke-width: 1; -} - -.dark .stage-inner-container { - fill: rgba(255, 255, 255, 0.02); - stroke: rgba(255, 255, 255, 0.06); -} - .stage-label { font-size: 11px; font-weight: 500; @@ -240,12 +235,6 @@ opacity: 0.6; } -.divider-line { - stroke: currentColor; - stroke-width: 1; - opacity: 0.1; -} - .arrow-line { stroke: currentColor; stroke-width: 1.5; @@ -253,7 +242,6 @@ opacity: 0.4; } -/* Code text styles with monospace font */ .code-text { font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 13px; @@ -273,14 +261,6 @@ } /* VS Code Light+ theme colors */ -.syn-kw { - fill: #0000ff; -} - -.syn-ctrl { - fill: #af00db; -} - .syn-type { fill: #267f99; } @@ -297,19 +277,7 @@ fill: #098658; } -.syn-str { - fill: #a31515; -} - /* VS Code Dark+ theme colors */ -.dark .syn-kw { - fill: #569CD6; -} - -.dark .syn-ctrl { - fill: #C586C0; -} - .dark .syn-type { fill: #4EC9B0; } @@ -326,10 +294,6 @@ fill: #B5CEA8; } -.dark .syn-str { - fill: #CE9178; -} - .diagram-caption { text-align: center; font-size: 0.85rem; diff --git a/docs/app/components/content/PythonQueryDiagram.vue b/docs/app/components/content/PythonQueryDiagram.vue new file mode 100644 index 0000000..9ce5768 --- /dev/null +++ b/docs/app/components/content/PythonQueryDiagram.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/docs/app/components/content/TsFlowDiagram.vue b/docs/app/components/content/TsFlowDiagram.vue new file mode 100644 index 0000000..fc317c3 --- /dev/null +++ b/docs/app/components/content/TsFlowDiagram.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/docs/app/components/content/TsQueryDiagram.vue b/docs/app/components/content/TsQueryDiagram.vue new file mode 100644 index 0000000..82a5446 --- /dev/null +++ b/docs/app/components/content/TsQueryDiagram.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/docs/app/composables/useLibraryCollection.ts b/docs/app/composables/useLibraryCollection.ts index fd4997e..1a9179a 100644 --- a/docs/app/composables/useLibraryCollection.ts +++ b/docs/app/composables/useLibraryCollection.ts @@ -4,13 +4,13 @@ export const useLibraryCollection = () => { // Determine library from route path, fallback to storage const library = computed(() => { const pathLibrary = route.path.split("/")[1]; - if (pathLibrary === "cpp" || pathLibrary === "py") { - return pathLibrary as "cpp" | "py"; + if (pathLibrary === "cpp" || pathLibrary === "py" || pathLibrary === "ts") { + return pathLibrary as "cpp" | "py" | "ts"; } return "cpp"; }); - const collection = computed(() => (library.value === "cpp" ? "docsCpp" : "docsPy")); + const collection = computed(() => (library.value === "cpp" ? "docsCpp" : library.value === "py" ? "docsPy" : "docsTs")); // Fetch navigation data const { data: navigation } = useAsyncData( diff --git a/docs/app/composables/useTrueform.ts b/docs/app/composables/useTrueform.ts new file mode 100644 index 0000000..47396ea --- /dev/null +++ b/docs/app/composables/useTrueform.ts @@ -0,0 +1,19 @@ +let _tf: any = null; +let _promise: Promise | null = null; + +export function useTrueform() { + const load = async () => { + if (_tf) return _tf; + if (!_promise) { + _promise = import("@/examples/trueform/index.js"); + } + try { + _tf = await _promise; + return _tf; + } catch (error) { + _promise = null; + throw error; + } + }; + return { load }; +} diff --git a/docs/app/examples/AlignmentExample.ts b/docs/app/examples/AlignmentExample.ts index cc372af..71ecbbf 100644 --- a/docs/app/examples/AlignmentExample.ts +++ b/docs/app/examples/AlignmentExample.ts @@ -1,67 +1,292 @@ -import type { MainModule } from "@/examples/native"; -import { ThreejsBase } from "@/examples/ThreejsBase"; -import { fitCameraToAllMeshesFromZPlane } from "@/utils/sceneUtils"; +import { createScene, type SceneBundle } from "@/utils/sceneUtils"; +import { centerAndScale, pickMesh } from "@/utils/utils"; import * as THREE from "three"; +import { ArcballControls } from "three/addons/controls/ArcballControls.js"; + +type TF = typeof import("@/examples/trueform/index.js"); export type InteractionMode = "move" | "rotate"; -export class AlignmentExample extends ThreejsBase { - private alignmentTime = 0; +// ════════════════════════════════════════════════════════════════════════════ +// Matrix convention helpers: TF row-major ↔ Three.js column-major +// ════════════════════════════════════════════════════════════════════════════ + +function tfToThree(tfObj: any): THREE.Matrix4 { + const mat = tfObj.transformation; + if (!mat) return new THREE.Matrix4(); + const m = new THREE.Matrix4().fromArray(mat.data).transpose(); + mat.delete(); + return m; +} + +function threeToTf(tf: TF, m: THREE.Matrix4): any { + const rm = m.clone().transpose(); + const mat = tf.ndarray(new Float32Array(rm.toArray())).reshape([4, 4]); + return mat; // caller must set .transformation then .delete() +} + +export class AlignmentExample { + private tf: TF; + private smoothedTarget: any; + private source: any; + private targetPC: any; + private sourcePC: any; + private aabbDiagonal: number; + + private container: HTMLElement; + private renderer: THREE.WebGLRenderer; + private sceneBundle: SceneBundle; + + private targetThree: THREE.Mesh; + private sourceThree: THREE.Mesh; + private materials: THREE.MeshMatcapMaterial[] = []; + + private running = true; + private cleanups: (() => void)[] = []; + private interactionMode: InteractionMode = "move"; - // Rotation state + // Move state + private selectedId: number | null = null; + private dragging = false; + private movingPlane = new THREE.Plane(); + private lastPoint = new THREE.Vector3(); + + // Rotate state private isRotating = false; private lastMouseX = 0; private lastMouseY = 0; + private raycaster = new THREE.Raycaster(); + private ndc = new THREE.Vector2(); + constructor( - wasmInstance: MainModule, - paths: string[], + tf: TF, + sourceBuffer: ArrayBuffer, + sourceFilename: string, + targetBuffer: ArrayBuffer, container: HTMLElement, isDarkMode = true, ) { - // skipUpdate = true so we can position meshes before fitting camera - super(wasmInstance, paths, container, undefined, true, false, isDarkMode); - this.updateMeshes(); - this.positionMeshesForScreen(container); - this.setupOrthographicCamera(container); - - // Warmup alignment (run once, then reposition) - this.wasmInstance.alignment_run_align(); - this.positionMeshesForScreen(container); - this.updateMeshes(); + this.tf = tf; + this.container = container; + + // ── Load and prepare TARGET mesh ──────────────────────────────────── + const target = tf.readStl(targetBuffer); + centerAndScale(tf, target); + // Taubin smooth BEFORE creating point cloud (lambda=0.9, kpb=0.1) + this.smoothedTarget = tf.taubinSmoothed(target, 50, 0.9, 0.1); + target.delete(); + + // ── Create target PointCloud (from smoothed mesh) ────────────────── + const PointCloud = (tf as any).PointCloud; + this.targetPC = PointCloud.fromMesh(this.smoothedTarget); + this.targetPC.buildTree(); + + // ── Load and prepare SOURCE mesh ─────────────────────────────────── + const ext = sourceFilename.split(".").pop()?.toLowerCase(); + this.source = ext === "stl" ? tf.readStl(sourceBuffer) : tf.readObj(sourceBuffer); + centerAndScale(tf, this.source); + + // ── Create source PointCloud ─────────────────────────────────────── + this.sourcePC = PointCloud.fromMesh(this.source); + + // ── Compute AABB diagonal (from source, after centerAndScale) ────── + { + const pts = this.source.points; + const pMin = tf.min(pts, 0); + const pMax = tf.max(pts, 0); + const diff = pMax.sub(pMin); + this.aabbDiagonal = tf.norm(diff) as number; + pMin.delete(); pMax.delete(); diff.delete(); pts.delete(); + } + + // ── Set identity transformation on target ────────────────────────── + const identityMat = tf.ndarray(new Float32Array([ + 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, + ])).reshape([4, 4]); + this.smoothedTarget.transformation = identityMat; + this.targetPC.transformation = identityMat; + identityMat.delete(); + + // ── Renderer setup ───────────────────────────────────────────────── + const bgColor = isDarkMode ? 0x1e1e1e : 0xfafafa; + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer.toneMappingExposure = 1.0; + container.appendChild(this.renderer.domElement); + + // ── Scene + camera ───────────────────────────────────────────────── + this.sceneBundle = createScene(this.renderer, { backgroundColor: bgColor, enableFog: false }); + this.switchToOrthographicCamera(); + + // ── Target Three.js mesh (teal, semi-transparent) ────────────────── + const targetGeometry = new THREE.BufferGeometry(); + { + const pts = this.smoothedTarget.points; + const fcs = this.smoothedTarget.faces; + targetGeometry.setAttribute("position", new THREE.BufferAttribute(pts.data, 3)); + targetGeometry.setIndex(new THREE.BufferAttribute( + new Uint32Array(fcs.data.buffer, fcs.data.byteOffset, fcs.data.length), 1)); + targetGeometry.computeBoundingSphere(); + pts.delete(); fcs.delete(); + } + const targetColor = new THREE.Color(); + targetColor.setRGB(0, 0.835, 0.745, THREE.SRGBColorSpace); + const targetMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + color: targetColor, + transparent: true, + opacity: 0.5, + }); + this.materials.push(targetMaterial); + this.targetThree = new THREE.Mesh(targetGeometry, targetMaterial); + this.targetThree.matrixAutoUpdate = false; + this.syncThreeMatrix(this.targetThree, this.smoothedTarget); + this.sceneBundle.scene.add(this.targetThree); + + // ── Source Three.js mesh (white, opaque) ─────────────────────────── + const sourceGeometry = new THREE.BufferGeometry(); + { + const pts = this.source.points; + const fcs = this.source.faces; + sourceGeometry.setAttribute("position", new THREE.BufferAttribute(pts.data, 3)); + sourceGeometry.setIndex(new THREE.BufferAttribute( + new Uint32Array(fcs.data.buffer, fcs.data.byteOffset, fcs.data.length), 1)); + sourceGeometry.computeBoundingSphere(); + pts.delete(); fcs.delete(); + } + const sourceMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + }); + this.materials.push(sourceMaterial); + this.sourceThree = new THREE.Mesh(sourceGeometry, sourceMaterial); + this.sourceThree.matrixAutoUpdate = false; + this.sceneBundle.scene.add(this.sourceThree); + + // ── Matcap textures ──────────────────────────────────────────────── + const matcapUrl = isDarkMode + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + new THREE.TextureLoader().load(matcapUrl, (tex) => { + for (let i = 0; i < this.materials.length; i++) { + this.materials[i]!.matcap = i === 0 ? tex : tex.clone(); + this.materials[i]!.needsUpdate = true; + } + }); + + // ── Position meshes + fit camera ─────────────────────────────────── + this.positionMeshesForScreen(); + this.fitOrthographicCamera(); + + // ── Warmup alignment (run once, then reposition) ─────────────────── + this.align(); + this.positionMeshesForScreen(); + this.fitOrthographicCamera(); + + // ── Pointer events ───────────────────────────────────────────────── + const onPointerMove = (e: PointerEvent) => { + if (!container.contains(e.target as Node)) return; + this.updateNDC(e); + if (this.interactionMode === "rotate" && this.isRotating) { + this.handleRotation(e); + } else if (this.dragging && this.selectedId !== null) { + this.handleDrag(); + } else if (!this.dragging && !this.isRotating) { + this.handleHover(); + } + }; + const onPointerDown = (e: PointerEvent) => { + if (!container.contains(e.target as Node)) return; + this.updateNDC(e); + this.handleHover(); + + if (this.selectedId !== null) { + if (this.interactionMode === "rotate") { + this.isRotating = true; + this.lastMouseX = e.clientX; + this.lastMouseY = e.clientY; + } else { + this.dragging = true; + } + this.sceneBundle.controls.enabled = false; + } + }; + const onPointerUp = () => { + this.dragging = false; + this.isRotating = false; + this.selectedId = null; + this.sceneBundle.controls.enabled = true; + }; + + container.addEventListener("pointermove", onPointerMove); + container.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointerup", onPointerUp); + this.cleanups.push(() => { + container.removeEventListener("pointermove", onPointerMove); + container.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointerup", onPointerUp); + }); + + // ── Resize observer ──────────────────────────────────────────────── + const resizeObs = new ResizeObserver(() => this.resize()); + resizeObs.observe(container); + this.cleanups.push(() => resizeObs.disconnect()); + this.resize(); + + // ── Animation loop ───────────────────────────────────────────────── + this.animate(); } - private setupOrthographicCamera(container: HTMLElement): void { + // ════════════════════════════════════════════════════════════════════════ + // Orthographic camera + // ════════════════════════════════════════════════════════════════════════ + + private switchToOrthographicCamera() { + const rect = this.container.getBoundingClientRect(); + const aspect = rect.width / rect.height; + const frustumSize = 50; + const orthoCamera = new THREE.OrthographicCamera( - -1, 1, 1, -1, 0.1, 1000 + -frustumSize * aspect / 2, frustumSize * aspect / 2, + frustumSize / 2, -frustumSize / 2, + 0.1, 1000, ); + orthoCamera.position.copy(this.sceneBundle.camera.position); + orthoCamera.quaternion.copy(this.sceneBundle.camera.quaternion); + + this.sceneBundle.scene.remove(this.sceneBundle.camera); + this.sceneBundle.scene.add(orthoCamera); - // Replace camera in scene bundle - (this.sceneBundle1 as any).camera = orthoCamera; - this.sceneBundle1.controls.setCamera(orthoCamera); + const oldTarget = ((this.sceneBundle.controls as any).target as THREE.Vector3).clone(); + this.sceneBundle.controls.dispose(); + const newControls = new ArcballControls( + orthoCamera, this.container.querySelector("canvas")!, this.sceneBundle.scene, + ); + newControls.rotateSpeed = 1.2; + newControls.setGizmosVisible(false); + (newControls as any).target.copy(oldTarget); + newControls.update(); - // Now fit the orthographic camera to all meshes - this.fitOrthographicCamera(container); + (this.sceneBundle as any).camera = orthoCamera; + (this.sceneBundle as any).controls = newControls; } - private fitOrthographicCamera(container: HTMLElement): void { - const rect = container.getBoundingClientRect(); + private fitOrthographicCamera() { + const rect = this.container.getBoundingClientRect(); const aspect = rect.width / rect.height; const isLandscape = rect.width > rect.height; - const camera = this.sceneBundle1.camera as unknown as THREE.OrthographicCamera; + const camera = this.sceneBundle.camera as unknown as THREE.OrthographicCamera; + const diag = this.aabbDiagonal; - // Get positions from both instances to compute bounding box + // Get positions from both meshes const positions: THREE.Vector3[] = []; - const diag = this.wasmInstance.alignment_get_aabb_diagonal() ?? 1; - - for (let i = 0; i < 2; i++) { - const inst = this.wasmInstance.get_instance_on_idx(i); - if (!inst) continue; - const matrix = new Float32Array(inst.get_matrix()); - const m = new THREE.Matrix4().fromArray(matrix).transpose(); - const pos = new THREE.Vector3(); - pos.setFromMatrixPosition(m); + for (const tfMesh of [this.smoothedTarget, this.source]) { + const m = tfToThree(tfMesh); + const pos = new THREE.Vector3().setFromMatrixPosition(m); positions.push(pos); } @@ -70,9 +295,7 @@ export class AlignmentExample extends ThreejsBase { if (positions.length >= 2) { center.addVectors(positions[0]!, positions[1]!).multiplyScalar(0.5); } - const separation = positions.length >= 2 ? positions[0]!.distanceTo(positions[1]!) : 0; - // Different zoom for landscape vs portrait const zoomFactor = isLandscape ? 0.5 : 0.7; const extent = (separation + diag) * zoomFactor; @@ -87,183 +310,264 @@ export class AlignmentExample extends ThreejsBase { camera.position.set(center.x, center.y, center.z + diag * 3); camera.lookAt(center); - this.sceneBundle1.controls.target.copy(center); - this.sceneBundle1.controls.update(); + (this.sceneBundle.controls as any).target.copy(center); + this.sceneBundle.controls.update(); } - private positionMeshesForScreen(container: HTMLElement): void { - const rect = container.getBoundingClientRect(); + // ════════════════════════════════════════════════════════════════════════ + // Mesh positioning + // ════════════════════════════════════════════════════════════════════════ + + private positionMeshesForScreen() { + const rect = this.container.getBoundingClientRect(); const isLandscape = rect.width > rect.height; - const diag = this.wasmInstance.alignment_get_aabb_diagonal() ?? 1; + const diag = this.aabbDiagonal; - // More spacing for the axis we're spreading along - // Landscape: spread in X, Portrait: spread in Z const spacing = isLandscape ? diag * 1.2 : diag * 1.0; - - // Target stays at origin, source gets offset - // Camera on Z axis looking at XY plane: X = screen horizontal, Y = screen vertical - // Landscape: target left, source right (positive X) - // Portrait: target below, source above (positive Y) - const offset = isLandscape - ? [spacing, 0, 0] - : [0, spacing, 0]; - - // Build translation matrix for source - const m = new THREE.Matrix4().makeTranslation(offset[0], offset[1], offset[2]); - m.transpose(); - - const arr = m.toArray() as [ - number, number, number, number, - number, number, number, number, - number, number, number, number, - number, number, number, number - ]; - this.wasmInstance.alignment_set_source_matrix(arr); - this.updateMeshes(); + const offset = isLandscape ? [spacing, 0, 0] : [0, spacing, 0]; + + // Target: identity + const identityMat = this.tf.ndarray(new Float32Array([ + 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, + ])).reshape([4, 4]); + this.smoothedTarget.transformation = identityMat; + this.targetPC.transformation = identityMat; + identityMat.delete(); + this.syncThreeMatrix(this.targetThree, this.smoothedTarget); + + // Source: offset translation + const m = new THREE.Matrix4().makeTranslation(offset[0]!, offset[1]!, offset[2]!); + const mat = threeToTf(this.tf, m); + this.source.transformation = mat; + this.sourcePC.transformation = mat; + mat.delete(); + this.syncThreeMatrix(this.sourceThree, this.source); } - public setMode(mode: InteractionMode): void { - this.interactionMode = mode; - } + // ════════════════════════════════════════════════════════════════════════ + // Three.js matrix sync + // ════════════════════════════════════════════════════════════════════════ - public getMode(): InteractionMode { - return this.interactionMode; + private syncThreeMatrix(threeMesh: THREE.Mesh, tfMesh: any) { + const mat = tfMesh.transformation; + if (!mat) return; + const m = new THREE.Matrix4().fromArray(mat.data).transpose(); + threeMesh.matrix.copy(m); + mat.delete(); } - // Override pointer handlers to support rotate mode - public override onPointerDown(event: PointerEvent): void { - if (this.interactionMode === "rotate" && event.buttons === 1) { - // First do a mouse move to update selection state in WASM - const rect = this.renderer.domElement.getBoundingClientRect(); - const ndc = new THREE.Vector2( - ((event.clientX - rect.left) / rect.width) * 2 - 1, - -((event.clientY - rect.top) / rect.height) * 2 + 1 - ); - - const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(ndc, this.sceneBundle1.camera); - const ray = raycaster.ray; - const cameraPos = this.sceneBundle1.camera.position; - const dir = new THREE.Vector3(); - this.sceneBundle1.camera.getWorldDirection(dir); - const focalPoint = cameraPos.clone().add(dir.multiplyScalar(100)); - - this.wasmInstance.OnMouseMove( - [ray.origin.x, ray.origin.y, ray.origin.z], - [ray.direction.x, ray.direction.y, ray.direction.z], - [cameraPos.x, cameraPos.y, cameraPos.z], - [focalPoint.x, focalPoint.y, focalPoint.z] - ); - - // Check if we hit a selectable mesh - const hitMesh = this.wasmInstance.OnLeftButtonDown(); - if (hitMesh) { - // Cancel WASM's drag mode, we handle rotation ourselves - this.wasmInstance.OnLeftButtonUp(); - this.isRotating = true; - this.lastMouseX = event.clientX; - this.lastMouseY = event.clientY; - this.sceneBundle1.controls.enabled = false; - event.stopPropagation(); - } - } else { - super.onPointerDown(event); - } + // ════════════════════════════════════════════════════════════════════════ + // Interaction: hover, drag, rotate + // ════════════════════════════════════════════════════════════════════════ + + private updateNDC(e: PointerEvent) { + const rect = this.container.getBoundingClientRect(); + this.ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; } - public override onPointerMove(event: PointerEvent, touchHover = false): boolean { - if (this.interactionMode === "rotate" && this.isRotating) { - const dx = event.clientX - this.lastMouseX; - const dy = event.clientY - this.lastMouseY; - this.lastMouseX = event.clientX; - this.lastMouseY = event.clientY; - - // Convert mouse movement to rotation (similar to C++ example) - const angleX = dy * 0.5; // degrees - const angleY = dx * 0.5; - - // Get current source matrix (source is instance 1) - const sourceInst = this.wasmInstance.get_instance_on_idx(1); - if (!sourceInst) return true; - - const matrix = new Float32Array(sourceInst.get_matrix()); - const m = new THREE.Matrix4().fromArray(matrix).transpose(); - - // Get rotation center (translation part of current matrix) - const center = new THREE.Vector3(); - center.setFromMatrixPosition(m); - - // Create rotation matrices around world X and Y axes - const rotX = new THREE.Matrix4().makeRotationAxis( - new THREE.Vector3(1, 0, 0), - THREE.MathUtils.degToRad(angleX) - ); - const rotY = new THREE.Matrix4().makeRotationAxis( - new THREE.Vector3(0, 1, 0), - THREE.MathUtils.degToRad(angleY) - ); - - // Apply rotations centered at the mesh position - const toOrigin = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z); - const fromOrigin = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z); - - const newMatrix = new THREE.Matrix4() - .multiply(fromOrigin) - .multiply(rotY) - .multiply(rotX) - .multiply(toOrigin) - .multiply(m); - - // Send to WASM - newMatrix.transpose(); - const arr = newMatrix.toArray() as [ - number, number, number, number, - number, number, number, number, - number, number, number, number, - number, number, number, number - ]; - this.wasmInstance.alignment_set_source_matrix(arr); - this.updateMeshes(); - - event.stopPropagation(); - return true; + private handleHover() { + this.raycaster.setFromCamera(this.ndc, this.sceneBundle.camera); + const o = this.raycaster.ray.origin; + const d = this.raycaster.ray.direction; + const tfRay = this.tf.ray([o.x, o.y, o.z, d.x, d.y, d.z]); + + // Only source is pickable + const hit = pickMesh(this.tf, tfRay, [this.source]); + tfRay.delete(); + + if (hit) { + this.selectedId = 0; // index in [source] array + const hitPoint = this.raycaster.ray.at(hit.t, new THREE.Vector3()); + const camDir = new THREE.Vector3(); + this.sceneBundle.camera.getWorldDirection(camDir); + this.movingPlane.setFromNormalAndCoplanarPoint(camDir, hitPoint); + this.lastPoint.copy(hitPoint); } else { - return super.onPointerMove(event, touchHover); + this.selectedId = null; } } - public override onPointerUp(event: PointerEvent): void { - if (this.isRotating) { - this.isRotating = false; - this.sceneBundle1.controls.enabled = true; - event.stopPropagation(); - } else { - super.onPointerUp(event); - } + private handleDrag() { + this.raycaster.setFromCamera(this.ndc, this.sceneBundle.camera); + const nextPoint = new THREE.Vector3(); + this.raycaster.ray.intersectPlane(this.movingPlane, nextPoint); + if (!nextPoint) return; + + const dx = nextPoint.x - this.lastPoint.x; + const dy = nextPoint.y - this.lastPoint.y; + const dz = nextPoint.z - this.lastPoint.z; + this.lastPoint.copy(nextPoint); + + // Update TF transformation: read, modify translation, write back + const mat = this.source.transformation; + const dd = mat.data; + dd[3] += dx; dd[7] += dy; dd[11] += dz; + this.source.transformation = mat; + this.sourcePC.transformation = mat; + mat.delete(); + + this.syncThreeMatrix(this.sourceThree, this.source); } - public runMain() { - const v = new this.wasmInstance.VectorString(); - for (const path of this.paths) { - v.push_back(path); - } - this.wasmInstance.run_main_alignment(v); - for (const path of this.paths) { - this.wasmInstance.FS.unlink(path); - } + private handleRotation(event: PointerEvent) { + const dx = event.clientX - this.lastMouseX; + const dy = event.clientY - this.lastMouseY; + this.lastMouseX = event.clientX; + this.lastMouseY = event.clientY; + + const angleX = dy * 0.5; // degrees per pixel + const angleY = dx * 0.5; + + // Read current transform + const m = tfToThree(this.source); + const center = new THREE.Vector3().setFromMatrixPosition(m); + + // Create rotation matrices around world X and Y axes + const rotX = new THREE.Matrix4().makeRotationAxis( + new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(angleX), + ); + const rotY = new THREE.Matrix4().makeRotationAxis( + new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(angleY), + ); + + // Apply rotations centered at the mesh position + const toOrigin = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z); + const fromOrigin = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z); + + const newMatrix = new THREE.Matrix4() + .multiply(fromOrigin) + .multiply(rotY) + .multiply(rotX) + .multiply(toOrigin) + .multiply(m); + + // Write back + const newMat = threeToTf(this.tf, newMatrix); + this.source.transformation = newMat; + this.sourcePC.transformation = newMat; + newMat.delete(); + + this.syncThreeMatrix(this.sourceThree, this.source); } + // ════════════════════════════════════════════════════════════════════════ + // Alignment: OBB + ICP + // ════════════════════════════════════════════════════════════════════════ + public align(): number { - this.alignmentTime = this.wasmInstance.alignment_run_align(); - this.updateMeshes(); - return this.alignmentTime; + const t0 = performance.now(); + + // Read current source transform + const mCurrent = tfToThree(this.source); + + // Stage 1: OBB coarse alignment + const T_obb = this.tf.fitObbAlignment(this.sourcePC, this.targetPC); + const mObb = new THREE.Matrix4().fromArray(T_obb.data).transpose(); + T_obb.delete(); + + // Compose: T_after_obb = delta_obb * T_current + const mAfterObb = mObb.multiply(mCurrent); + + // Apply OBB result so ICP sees aligned position + let mat = threeToTf(this.tf, mAfterObb); + this.sourcePC.transformation = mat; + mat.delete(); + + // Stage 2: ICP refinement (point-to-plane) + const T_icp = this.tf.fitIcpAlignment(this.sourcePC, this.targetPC, { + maxIterations: 50, + nSamples: 1000, + k: 1, + }); + const mIcp = new THREE.Matrix4().fromArray(T_icp.data).transpose(); + T_icp.delete(); + + // Compose: T_final = delta_icp * T_after_obb + const mFinal = mIcp.multiply(mAfterObb); + + // Apply final transform to both source mesh and PC + mat = threeToTf(this.tf, mFinal); + this.source.transformation = mat; + this.sourcePC.transformation = mat; + mat.delete(); + + this.syncThreeMatrix(this.sourceThree, this.source); + return performance.now() - t0; } - public isAligned(): boolean { - return this.wasmInstance.alignment_is_aligned(); + // ════════════════════════════════════════════════════════════════════════ + // Public API + // ════════════════════════════════════════════════════════════════════════ + + public setMode(mode: InteractionMode) { + this.interactionMode = mode; + } + + public getMode(): InteractionMode { + return this.interactionMode; + } + + public applyTheme(isDark: boolean) { + this.sceneBundle.scene.background = new THREE.Color(isDark ? 0x1e1e1e : 0xfafafa); + + const matcapUrl = isDark + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + new THREE.TextureLoader().load(matcapUrl, (tex) => { + for (let i = 0; i < this.materials.length; i++) { + const mat = this.materials[i]!; + if (mat.matcap) mat.matcap.dispose(); + mat.matcap = i === 0 ? tex : tex.clone(); + mat.needsUpdate = true; + } + }); } - public getAlignmentTime(): number { - return this.alignmentTime; + // ════════════════════════════════════════════════════════════════════════ + // Resize + animate + // ════════════════════════════════════════════════════════════════════════ + + private resize() { + const w = this.container.clientWidth; + const h = this.container.clientHeight; + this.renderer.setSize(w, h); + this.renderer.setPixelRatio(window.devicePixelRatio); + const cam = this.sceneBundle.camera as unknown as THREE.OrthographicCamera; + const frustumH = cam.top - cam.bottom; + const aspect = w / h; + cam.left = -frustumH * aspect / 2; + cam.right = frustumH * aspect / 2; + cam.updateProjectionMatrix(); + } + + private animate() { + if (!this.running) return; + requestAnimationFrame(() => this.animate()); + this.sceneBundle.controls.update(); + this.renderer.render(this.sceneBundle.scene, this.sceneBundle.camera); + } + + // ════════════════════════════════════════════════════════════════════════ + // Dispose + // ════════════════════════════════════════════════════════════════════════ + + public dispose() { + this.running = false; + for (const fn of this.cleanups) fn(); + this.cleanups = []; + + this.sceneBundle.controls.dispose(); + this.targetThree.geometry.dispose(); + this.sourceThree.geometry.dispose(); + for (const mat of this.materials) mat.dispose(); + + this.renderer.dispose(); + this.renderer.domElement.remove(); + + this.sourcePC.delete(); + this.targetPC.delete(); + this.source.delete(); + this.smoothedTarget.delete(); } } diff --git a/docs/app/examples/BooleanExample.ts b/docs/app/examples/BooleanExample.ts index e2a2573..64ef73b 100644 --- a/docs/app/examples/BooleanExample.ts +++ b/docs/app/examples/BooleanExample.ts @@ -1,265 +1,638 @@ -import type { MainModule } from "@/examples/native"; -import { syncOrbitControls, type SceneBundle } from "@/utils/sceneUtils"; -import { - buffersToCurves, - createMesh, - updateResultMesh, - CurveRenderer, -} from "@/utils/utils"; -import { ThreejsBase } from "@/examples/ThreejsBase"; +import { syncOrbitControls, createScene, type SceneBundle } from "@/utils/sceneUtils"; +import { centerAndScale, pickMesh, randomTransformation, RollingAverage, CurveRenderer } from "@/utils/utils"; import * as THREE from "three"; import { ArcballControls } from "three/addons/controls/ArcballControls.js"; -export class BooleanExample extends ThreejsBase { +type TF = typeof import("@/examples/trueform/index.js"); + +export class BooleanExample { + private tf: TF; + private dragon: any; + private sphere: any; + private tfMeshes: any[] = []; // [0] = dragon, [1] = sphere + private resultMesh: any = null; + + private container1: HTMLElement; + private container2: HTMLElement; + private renderer1: THREE.WebGLRenderer; + private renderer2: THREE.WebGLRenderer; + private sceneBundle1: SceneBundle; + private sceneBundle2: SceneBundle; + + private dragonThree: THREE.Mesh; + private sphereThree: THREE.Mesh; + private resultThree: THREE.Mesh; + private resultGeometry: THREE.BufferGeometry; private curveRenderer: CurveRenderer; + + private materials: THREE.MeshMatcapMaterial[] = []; + private running = true; + private cleanups: (() => void)[] = []; + + private selectedId: number | null = null; + private dragging = false; + private movingPlane = new THREE.Plane(); + private lastPoint = new THREE.Vector3(); private keyPressed = false; - public onSphereSizeDelta?: (deltaSteps: number) => void; + private lastActiveScene: 1 | 2 = 1; + private isSyncing = false; - // private pointDebug = createPoints(); - public randomize() { - this.wasmInstance.OnKeyPress("n"); - this.updateMeshes(); - } + private sphereScale = 2.0; + private booleanTiming = new RollingAverage(); + private raycaster = new THREE.Raycaster(); + private ndc = new THREE.Vector2(); - public resyncCamera() { - this.syncSceneControls = true; - if (this.sceneBundle2) { - syncOrbitControls(this.sceneBundle1.controls, this.sceneBundle2.controls); - } - } + public refreshTimeValue: (() => number) | null = null; + public onSphereSizeDelta?: (deltaSteps: number) => void; - public adjustSphereSize(deltaSteps: number) { - const steps = Math.trunc(deltaSteps); - if (steps === 0) return false; - const direction = Math.sign(steps); - let handled = false; - for (let i = 0; i < Math.abs(steps); i++) { - handled = this.wasmInstance.OnMouseWheel(direction, true) || handled; + constructor( + tf: TF, + fileBuffer: ArrayBuffer, + fileName: string, + container: HTMLElement, + container2: HTMLElement, + isDarkMode = true, + ) { + this.tf = tf; + this.container1 = container; + this.container2 = container2; + + // ── Load dragon ────────────────────────────────────────────────────── + const ext = fileName.split(".").pop()?.toLowerCase(); + this.dragon = ext === "stl" ? tf.readStl(fileBuffer) : tf.readObj(fileBuffer); + centerAndScale(tf, this.dragon); + + // Set identity transformation (required for booleanDifference to tag it) + const dragonMat = tf.ndarray(new Float32Array([ + 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, + ])).reshape([4, 4]); + this.dragon.transformation = dragonMat; + dragonMat.delete(); + + // ── Sphere position (C++ uses obb.axes[1] * 4.0; OBB not exposed in TS) ─ + // After centerAndScale the dragon is deterministic: OBB 2nd axis ≈ Y. + const spherePos = [2.5, 1.5, -1.2]; + + // ── Create sphere ──────────────────────────────────────────────────── + this.sphere = tf.sphereMesh(1.0, 32, 32); + + const s = this.sphereScale; + const sphereMat = tf.ndarray(new Float32Array([ + s, 0, 0, spherePos[0]!, 0, s, 0, spherePos[1]!, 0, 0, s, spherePos[2]!, 0, 0, 0, 1, + ])).reshape([4, 4]); + this.sphere.transformation = sphereMat; + sphereMat.delete(); + + this.tfMeshes = [this.dragon, this.sphere]; + + // ── Two renderers + scenes ─────────────────────────────────────────── + const bgColor1 = isDarkMode ? 0x1e1e1e : 0xfafafa; + const bgColor2 = isDarkMode ? 0x262626 : 0xf5f5f5; + + this.renderer1 = new THREE.WebGLRenderer({ antialias: true }); + this.renderer1.outputColorSpace = THREE.SRGBColorSpace; + this.renderer1.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer1.toneMappingExposure = 1.0; + container.appendChild(this.renderer1.domElement); + this.sceneBundle1 = createScene(this.renderer1, { backgroundColor: bgColor1, enableFog: false }); + + this.renderer2 = new THREE.WebGLRenderer({ antialias: true }); + this.renderer2.outputColorSpace = THREE.SRGBColorSpace; + this.renderer2.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer2.toneMappingExposure = 1.0; + container2.appendChild(this.renderer2.domElement); + this.sceneBundle2 = createScene(this.renderer2, { backgroundColor: bgColor2, enableFog: false }); + + // Switch both to orthographic cameras + this.switchToOrthographicCamera(this.sceneBundle1, container); + this.switchToOrthographicCamera(this.sceneBundle2, container2); + + // ── Matcap texture ─────────────────────────────────────────────────── + const matcapUrl = isDarkMode + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + + // ── Dragon Three.js mesh (flat shading, white) ─────────────────────── + const dragonGeometry = new THREE.BufferGeometry(); + { + const pts = this.dragon.points; + const fcs = this.dragon.faces; + dragonGeometry.setAttribute("position", new THREE.BufferAttribute(pts.data, 3)); + dragonGeometry.setIndex(new THREE.BufferAttribute( + new Uint32Array(fcs.data.buffer, fcs.data.byteOffset, fcs.data.length), 1)); + dragonGeometry.computeBoundingSphere(); + pts.delete(); fcs.delete(); } - this.updateMeshes(); - return handled; + const dragonMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + }); + this.materials.push(dragonMaterial); + this.dragonThree = new THREE.Mesh(dragonGeometry, dragonMaterial); + this.dragonThree.matrixAutoUpdate = false; + this.syncThreeMatrix(0, this.dragonThree); + this.sceneBundle1.scene.add(this.dragonThree); + + // ── Sphere Three.js mesh (smooth shading, light blue) ──────────────── + const sphereGeometry = new THREE.BufferGeometry(); + { + const pts = this.sphere.points; + const fcs = this.sphere.faces; + sphereGeometry.setAttribute("position", new THREE.BufferAttribute(pts.data, 3)); + sphereGeometry.setIndex(new THREE.BufferAttribute( + new Uint32Array(fcs.data.buffer, fcs.data.byteOffset, fcs.data.length), 1)); + sphereGeometry.computeVertexNormals(); + sphereGeometry.computeBoundingSphere(); + pts.delete(); fcs.delete(); + } + const sphereColor = new THREE.Color(); + sphereColor.setRGB(0.7, 0.85, 1.0, THREE.SRGBColorSpace); + const sphereMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: false, + color: sphereColor, + }); + this.materials.push(sphereMaterial); + this.sphereThree = new THREE.Mesh(sphereGeometry, sphereMaterial); + this.sphereThree.matrixAutoUpdate = false; + this.syncThreeMatrix(1, this.sphereThree); + this.sceneBundle1.scene.add(this.sphereThree); + + // ── CurveRenderer (teal, instanced tubes) ──────────────────────────── + this.curveRenderer = new CurveRenderer({ + color: 0x00d5be, + radius: 0.075, + maxSegments: 20000, + }); + this.sceneBundle1.scene.add(this.curveRenderer.object); + + // ── Result Three.js mesh (flat shading, right scene) ───────────────── + this.resultGeometry = new THREE.BufferGeometry(); + const resultMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + }); + this.materials.push(resultMaterial); + this.resultThree = new THREE.Mesh(this.resultGeometry, resultMaterial); + this.resultThree.matrixAutoUpdate = false; + this.sceneBundle2.scene.add(this.resultThree); + + // Load matcap textures for all materials + new THREE.TextureLoader().load(matcapUrl, (tex) => { + for (let i = 0; i < this.materials.length; i++) { + this.materials[i]!.matcap = i === 0 ? tex : tex.clone(); + this.materials[i]!.needsUpdate = true; + } + }); + + // ── Initial boolean computation (warm-up, discard timing) ─────────── + this.computeBoolean(); + this.booleanTiming.clear(); + + // ── Fit cameras ────────────────────────────────────────────────────── + this.fitOrthoCameraToMeshes(this.sceneBundle1, 1.8); + + // Adjust camera angle slightly right and up for better dragon view + const camera = this.sceneBundle1.camera; + const target = (this.sceneBundle1.controls as any).target as THREE.Vector3; + const offset = camera.position.clone().sub(target); + const dist = offset.length(); + const angleH = 0.15; + const angleV = 0.1; + camera.position.set( + target.x + dist * Math.sin(angleH), + target.y + dist * Math.sin(angleV), + target.z + dist * Math.cos(angleH) * Math.cos(angleV), + ); + camera.lookAt(target); + this.sceneBundle1.controls.update(); + + this.fitOrthoCameraToMeshes(this.sceneBundle2, 1.8); + syncOrbitControls(this.sceneBundle1.controls, this.sceneBundle2.controls); + + // ── Bidirectional camera sync ───────────────────────────────────────── + const syncFrom = (source: 1 | 2) => { + if (this.isSyncing) return; + this.isSyncing = true; + if (source === 1) { + syncOrbitControls(this.sceneBundle1.controls, this.sceneBundle2.controls); + } else { + syncOrbitControls(this.sceneBundle2.controls, this.sceneBundle1.controls); + } + this.isSyncing = false; + }; + + this.sceneBundle1.controls.addEventListener("change", () => { + if (this.lastActiveScene === 1) syncFrom(1); + }); + this.sceneBundle2.controls.addEventListener("change", () => { + if (this.lastActiveScene === 2) syncFrom(2); + }); + + const onEnterContainer1 = () => { this.lastActiveScene = 1; }; + const onEnterContainer2 = () => { this.lastActiveScene = 2; }; + container.addEventListener("pointerenter", onEnterContainer1); + container2.addEventListener("pointerenter", onEnterContainer2); + this.cleanups.push(() => { + container.removeEventListener("pointerenter", onEnterContainer1); + container2.removeEventListener("pointerenter", onEnterContainer2); + }); + + // ── Pointer events (on left container) ─────────────────────────────── + const onPointerMove = (e: PointerEvent) => { + if (!container.contains(e.target as Node)) return; + this.updateNDC(e); + if (this.dragging && this.selectedId !== null) { + this.handleDrag(); + } else if (!this.dragging) { + this.handleHover(); + } + }; + const onPointerDown = (e: PointerEvent) => { + if (!container.contains(e.target as Node)) return; + this.updateNDC(e); + this.handleHover(); + if (this.selectedId !== null) { + this.dragging = true; + this.sceneBundle1.controls.enabled = false; + } + }; + const onPointerUp = () => { + this.dragging = false; + this.selectedId = null; + this.sceneBundle1.controls.enabled = true; + }; + + container.addEventListener("pointermove", onPointerMove); + container.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointerup", onPointerUp); + this.cleanups.push(() => { + container.removeEventListener("pointermove", onPointerMove); + container.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointerup", onPointerUp); + }); + + // ── Keyboard events ────────────────────────────────────────────────── + const onKeyDown = (event: KeyboardEvent) => { + if (this.keyPressed) return; + this.keyPressed = true; + if (event.key === "r") { this.resyncCamera(); return; } + if (event.key === "n") { this.randomize(); return; } + }; + const onKeyUp = () => { this.keyPressed = false; }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + this.cleanups.push(() => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }); + + // ── Ctrl+scroll for sphere size ────────────────────────────────────── + const onWheel = (event: WheelEvent) => { + if (!event.ctrlKey) return; + event.preventDefault(); + const delta = event.deltaY !== 0 ? event.deltaY : event.deltaX; + if (delta === 0) return; + const normalizedDelta = delta / Math.abs(delta); + const handled = this.adjustSphereSize(-normalizedDelta); + if (handled) { + this.onSphereSizeDelta?.(-normalizedDelta); + event.stopImmediatePropagation(); + } + }; + const wheelOpts = { passive: false, capture: true } as const; + window.addEventListener("wheel", onWheel, wheelOpts); + this.cleanups.push(() => { + window.removeEventListener("wheel", onWheel, wheelOpts); + }); + + // ── Resize observers ───────────────────────────────────────────────── + const resizeObs1 = new ResizeObserver(() => this.resize1()); + resizeObs1.observe(container); + const resizeObs2 = new ResizeObserver(() => this.resize2()); + resizeObs2.observe(container2); + this.cleanups.push(() => { resizeObs1.disconnect(); resizeObs2.disconnect(); }); + this.resize1(); + this.resize2(); + + // ── Animation loop ─────────────────────────────────────────────────── + this.animate(); } + // ════════════════════════════════════════════════════════════════════════ + // Orthographic camera helpers + // ════════════════════════════════════════════════════════════════════════ + private switchToOrthographicCamera(sceneBundle: SceneBundle, container: HTMLElement) { const rect = container.getBoundingClientRect(); const aspect = rect.width / rect.height; const frustumSize = 50; const orthoCamera = new THREE.OrthographicCamera( - -frustumSize * aspect / 2, - frustumSize * aspect / 2, - frustumSize / 2, - -frustumSize / 2, - 0.1, - 1000 + -frustumSize * aspect / 2, frustumSize * aspect / 2, + frustumSize / 2, -frustumSize / 2, + 0.1, 1000, ); - - // Copy position and orientation from perspective camera orthoCamera.position.copy(sceneBundle.camera.position); orthoCamera.quaternion.copy(sceneBundle.camera.quaternion); - // Remove old camera and add new one sceneBundle.scene.remove(sceneBundle.camera); sceneBundle.scene.add(orthoCamera); - // Dispose old controls and create new ones with ortho camera - const oldTarget = sceneBundle.controls.target.clone(); + const oldTarget = ((sceneBundle.controls as any).target as THREE.Vector3).clone(); sceneBundle.controls.dispose(); - const newControls = new ArcballControls(orthoCamera, container.querySelector('canvas')!, sceneBundle.scene); + const newControls = new ArcballControls(orthoCamera, container.querySelector("canvas")!, sceneBundle.scene); newControls.rotateSpeed = 1.2; newControls.setGizmosVisible(false); - newControls.target.copy(oldTarget); + (newControls as any).target.copy(oldTarget); newControls.update(); - // Update bundle references (cast to any to bypass readonly) (sceneBundle as any).camera = orthoCamera; (sceneBundle as any).controls = newControls; } - private fitOrthoCameraToMeshes(sceneBundle: SceneBundle, offset: number = 1.25) { + private fitOrthoCameraToMeshes(sceneBundle: SceneBundle, offset = 1.25) { const { scene, controls } = sceneBundle; const camera = sceneBundle.camera as unknown as THREE.OrthographicCamera; - // Find all meshes in the scene const meshes: THREE.Mesh[] = []; scene.traverse((child) => { - if (child.type === 'Mesh' || child.type === 'InstancedMesh') { + if (child.type === "Mesh" || child.type === "InstancedMesh") { meshes.push(child as THREE.Mesh); } }); - if (meshes.length === 0) return; - // Calculate bounding box of all meshes const combinedBox = new THREE.Box3(); - meshes.forEach(mesh => { - const meshBox = new THREE.Box3().setFromObject(mesh); - combinedBox.union(meshBox); - }); + meshes.forEach((mesh) => { combinedBox.union(new THREE.Box3().setFromObject(mesh)); }); const center = combinedBox.getCenter(new THREE.Vector3()); const size = combinedBox.getSize(new THREE.Vector3()); const maxDimension = Math.max(size.x, size.y, size.z) * offset; - // Update orthographic frustum to fit the scene const aspect = (camera.right - camera.left) / (camera.top - camera.bottom); camera.left = -maxDimension * aspect / 2; camera.right = maxDimension * aspect / 2; camera.top = maxDimension / 2; camera.bottom = -maxDimension / 2; - // Position camera along Z axis looking at center const distance = maxDimension * 2; camera.position.set(center.x, center.y, center.z + distance); camera.lookAt(center); camera.updateProjectionMatrix(); - controls.target.copy(center); + (controls as any).target.copy(center); controls.update(); } - constructor( - wasmInstance: MainModule, - path: string[], - container: HTMLElement, - container2: HTMLElement, - isDarkMode = true, - ) { - super(wasmInstance, path, container, container2, true, false, isDarkMode); + // ════════════════════════════════════════════════════════════════════════ + // Boolean computation + // ════════════════════════════════════════════════════════════════════════ + + private computeBoolean() { + + if (this.resultMesh) { this.resultMesh.delete(); this.resultMesh = null; } + + const t0 = performance.now(); + const result = this.tf.booleanDifference(this.dragon, this.sphere, { returnCurves: true }); + this.booleanTiming.add(performance.now() - t0); + + // Update result geometry — views, zero copies + const rPts = result.mesh.points; + const rFaces = result.mesh.faces; + this.resultGeometry.setAttribute("position", new THREE.BufferAttribute(rPts.data, 3)); + this.resultGeometry.setIndex(new THREE.BufferAttribute( + new Uint32Array(rFaces.data.buffer, rFaces.data.byteOffset, rFaces.data.length), 1)); + this.resultGeometry.computeBoundingSphere(); + this.resultGeometry.computeBoundingBox(); + rPts.delete(); rFaces.delete(); + + // Update curves — zero-copy via updateFromBuffers + const pts = result.curves.points; + const pathsBuf = result.curves.paths; + const pathsData = pathsBuf.data; + const pathsOffsets = pathsBuf.offsets; + this.curveRenderer.updateFromBuffers( + pts.data as Float32Array, + pathsData.data as Int32Array, + pathsOffsets.data as Int32Array, + ); + pathsOffsets.delete(); pathsData.delete(); pathsBuf.delete(); pts.delete(); - // Switch to orthographic camera for scene 1 - this.switchToOrthographicCamera(this.sceneBundle1, container); - if (this.sceneBundle2) { - this.switchToOrthographicCamera(this.sceneBundle2, container2); - } + this.resultMesh = result.mesh; + result.labels.delete(); + result.curves.delete(); - // Enable smooth shading for the sphere (meshDataId = 1) - const sphereGeometry = this.geometries.get(1); - const sphereInstancedMesh = this.instancedMeshes.get(1); - if (sphereGeometry && sphereInstancedMesh) { - sphereGeometry.computeVertexNormals(); - const oldMaterial = sphereInstancedMesh.material as THREE.MeshMatcapMaterial; - const smoothMaterial = new THREE.MeshMatcapMaterial({ - matcap: oldMaterial.matcap, - side: THREE.DoubleSide, - flatShading: false, - }); - sphereInstancedMesh.material = smoothMaterial; - } + if (this.refreshTimeValue) this.refreshTimeValue(); + } - const interceptKeyDownEvent = (event: KeyboardEvent) => { - if (this.keyPressed) return; - this.keyPressed = true; - if (event.key === "r") { - this.resyncCamera(); - return; - } - if (event.key === "n") { - this.randomize(); - return; - } - this.wasmInstance.OnKeyPress(event.key); - this.updateMeshes(); - }; - const interceptKeyUpEvent = (_event: KeyboardEvent) => { - this.keyPressed = false; - }; - window.addEventListener("keydown", interceptKeyDownEvent); - window.addEventListener("keyup", interceptKeyUpEvent); - this.addCleanup(() => { - window.removeEventListener("keydown", interceptKeyDownEvent); - window.removeEventListener("keyup", interceptKeyUpEvent); - }); + // ════════════════════════════════════════════════════════════════════════ + // Three.js matrix sync + // ════════════════════════════════════════════════════════════════════════ + + private syncThreeMatrix(index: number, mesh?: THREE.Mesh) { + const target = mesh ?? (index === 0 ? this.dragonThree : this.sphereThree); + const mat = this.tfMeshes[index]!.transformation; + if (!mat) return; + const m = new THREE.Matrix4(); + m.fromArray(mat.data); + m.transpose(); + target.matrix.copy(m); + mat.delete(); + } - // Ctrl+scroll to change sphere radius - const interceptWheelEvent = (event: WheelEvent) => { - if (!event.ctrlKey) return; - event.preventDefault(); - const delta = event.deltaY !== 0 ? event.deltaY : event.deltaX; - if (delta === 0) return; - const normalizedDelta = delta / Math.abs(delta); - const handled = this.adjustSphereSize(-normalizedDelta); - if (handled) { - this.onSphereSizeDelta?.(-normalizedDelta); - event.stopImmediatePropagation(); - } - }; - const wheelListenerOptions = { - passive: false, - capture: true, - }; - window.addEventListener("wheel", interceptWheelEvent, wheelListenerOptions); - this.addCleanup(() => { - window.removeEventListener("wheel", interceptWheelEvent, wheelListenerOptions); - }); + // ════════════════════════════════════════════════════════════════════════ + // Interaction: drag + // ════════════════════════════════════════════════════════════════════════ - this.curveRenderer = new CurveRenderer({ - color: 0x00d5be, - radius: 0.075, - maxSegments: 20000, - }); - this.sceneBundle1.scene.add(this.curveRenderer.object); + private updateNDC(e: PointerEvent) { + const rect = this.container1.getBoundingClientRect(); + this.ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + } - if (this.sceneBundle2 && this.renderer2) { - const mesh = createMesh(this.isDarkMode); - this.meshes2.set(0, mesh); - this.sceneBundle2.scene.add(mesh); + private handleHover() { + this.raycaster.setFromCamera(this.ndc, this.sceneBundle1.camera); + const o = this.raycaster.ray.origin; + const d = this.raycaster.ray.direction; + const tfRay = this.tf.ray([o.x, o.y, o.z, d.x, d.y, d.z]); + + const hit = pickMesh(this.tf, tfRay, this.tfMeshes); + tfRay.delete(); + + if (hit) { + this.selectedId = hit.index; + const hitPoint = this.raycaster.ray.at(hit.t, new THREE.Vector3()); + const camDir = new THREE.Vector3(); + this.sceneBundle1.camera.getWorldDirection(camDir); + this.movingPlane.setFromNormalAndCoplanarPoint(camDir, hitPoint); + this.lastPoint.copy(hitPoint); + } else { + this.selectedId = null; } + } - this.updateMeshes(); - this.fitOrthoCameraToMeshes(this.sceneBundle1, 1.8); + private handleDrag() { + const id = this.selectedId!; - // Adjust camera angle slightly right and up for better dragon view - const camera = this.sceneBundle1.camera; - const target = this.sceneBundle1.controls.target; - const offset = camera.position.clone().sub(target); - const distance = offset.length(); - const angleH = 0.15; // horizontal angle, positive = right - const angleV = 0.1; // vertical angle, positive = up - camera.position.set( - target.x + distance * Math.sin(angleH), - target.y + distance * Math.sin(angleV), - target.z + distance * Math.cos(angleH) * Math.cos(angleV) - ); - camera.lookAt(target); - this.sceneBundle1.controls.update(); + this.raycaster.setFromCamera(this.ndc, this.sceneBundle1.camera); + const nextPoint = new THREE.Vector3(); + this.raycaster.ray.intersectPlane(this.movingPlane, nextPoint); + if (!nextPoint) return; - if (this.sceneBundle2) { - this.fitOrthoCameraToMeshes(this.sceneBundle2, 1.8); - syncOrbitControls(this.sceneBundle1.controls, this.sceneBundle2.controls); - } + const dx = nextPoint.x - this.lastPoint.x; + const dy = nextPoint.y - this.lastPoint.y; + const dz = nextPoint.z - this.lastPoint.z; + this.lastPoint.copy(nextPoint); + + // Update TF transformation: read, modify translation, write back + const mat = this.tfMeshes[id]!.transformation; + const d = mat.data; + d[3] += dx; d[7] += dy; d[11] += dz; + this.tfMeshes[id]!.transformation = mat; + mat.delete(); + + this.syncThreeMatrix(id); + this.computeBoolean(); } - public runMain() { - const v = new this.wasmInstance.VectorString(); - for (let i = 0; i < this.paths.length; i++) { - v.push_back(this.paths[i]!); - } - this.wasmInstance.run_main(v); - for (let i = 0; i < this.paths.length; i++) { - this.wasmInstance.FS.unlink(this.paths[i]); - } + // ════════════════════════════════════════════════════════════════════════ + // Public API + // ════════════════════════════════════════════════════════════════════════ + + public getAverageTime(): number { + return this.booleanTiming.average; } - public override updateMeshes() { - super.updateMeshes(); - - // Update curve mesh (intersection curves) - const cO = this.wasmInstance.get_curve_mesh(); - if (cO && cO.updated) { - const points = cO.get_curve_points(); - const ids = cO.get_curve_ids(); - const offsets = cO.get_curve_offsets(); - const curves = buffersToCurves(points, ids, offsets); - this.curveRenderer.update(curves); - } + public resyncCamera() { + syncOrbitControls(this.sceneBundle1.controls, this.sceneBundle2.controls); + } + + public randomize() { + const tf = this.tf; + + // Dragon: random rotation, preserve translation + const dMat = this.dragon.transformation; + const dtx = dMat.data[3], dty = dMat.data[7], dtz = dMat.data[11]; + dMat.delete(); + const dNew = randomTransformation(tf, dtx, dty, dtz); + this.dragon.transformation = dNew; + dNew.delete(); + + // Sphere: random rotation with scale, preserve translation + const sMat = this.sphere.transformation; + const stx = sMat.data[3], sty = sMat.data[7], stz = sMat.data[11]; + sMat.delete(); + const sNew = randomTransformation(tf, stx, sty, stz); + const sd = sNew.data; + const sc = this.sphereScale; + sd[0] *= sc; sd[1] *= sc; sd[2] *= sc; + sd[4] *= sc; sd[5] *= sc; sd[6] *= sc; + sd[8] *= sc; sd[9] *= sc; sd[10] *= sc; + this.sphere.transformation = sNew; + sNew.delete(); + + this.syncThreeMatrix(0); + this.syncThreeMatrix(1); + this.computeBoolean(); + } + + public adjustSphereSize(deltaSteps: number): boolean { + const steps = Math.trunc(deltaSteps); + if (steps === 0) return false; + const newScale = Math.max(0.1, Math.min(5.0, this.sphereScale + steps * 0.05)); + if (newScale === this.sphereScale) return false; + this.sphereScale = newScale; + + // Set only diagonal — matches old WASM behavior (matrix[0/5/10] = scale) + const mat = this.sphere.transformation; + const d = mat.data; + d[0] = newScale; d[5] = newScale; d[10] = newScale; + this.sphere.transformation = mat; + mat.delete(); + + this.syncThreeMatrix(1); + this.computeBoolean(); + return true; + } - if (this.renderer2 && this.sceneBundle2) { - const resultMesh = this.wasmInstance.get_result_mesh(); - const mesh = this.meshes2.get(0); - if (resultMesh && mesh) { - updateResultMesh(resultMesh, mesh); + public applyTheme(isDark: boolean) { + this.sceneBundle1.scene.background = new THREE.Color(isDark ? 0x1e1e1e : 0xfafafa); + this.sceneBundle2.scene.background = new THREE.Color(isDark ? 0x262626 : 0xf5f5f5); + + const matcapUrl = isDark + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + new THREE.TextureLoader().load(matcapUrl, (tex) => { + for (let i = 0; i < this.materials.length; i++) { + const mat = this.materials[i]!; + if (mat.matcap) mat.matcap.dispose(); + mat.matcap = i === 0 ? tex : tex.clone(); + mat.needsUpdate = true; } - } + }); + } + + // ════════════════════════════════════════════════════════════════════════ + // Resize + animate + // ════════════════════════════════════════════════════════════════════════ + + private resize1() { + const w = this.container1.clientWidth; + const h = this.container1.clientHeight; + this.renderer1.setSize(w, h); + this.renderer1.setPixelRatio(window.devicePixelRatio); + const cam = this.sceneBundle1.camera as unknown as THREE.OrthographicCamera; + const frustumH = cam.top - cam.bottom; + const aspect = w / h; + cam.left = -frustumH * aspect / 2; + cam.right = frustumH * aspect / 2; + cam.updateProjectionMatrix(); + } + + private resize2() { + const w = this.container2.clientWidth; + const h = this.container2.clientHeight; + this.renderer2.setSize(w, h); + this.renderer2.setPixelRatio(window.devicePixelRatio); + const cam = this.sceneBundle2.camera as unknown as THREE.OrthographicCamera; + const frustumH = cam.top - cam.bottom; + const aspect = w / h; + cam.left = -frustumH * aspect / 2; + cam.right = frustumH * aspect / 2; + cam.updateProjectionMatrix(); + } + + private animate() { + if (!this.running) return; + requestAnimationFrame(() => this.animate()); + this.sceneBundle1.controls.update(); + this.sceneBundle2.controls.update(); + this.renderer1.render(this.sceneBundle1.scene, this.sceneBundle1.camera); + this.renderer2.render(this.sceneBundle2.scene, this.sceneBundle2.camera); + } + + public dispose() { + this.running = false; + for (const fn of this.cleanups) fn(); + this.cleanups = []; + + this.sceneBundle1.controls.dispose(); + this.sceneBundle2.controls.dispose(); + this.curveRenderer.dispose(); + + this.dragonThree.geometry.dispose(); + this.sphereThree.geometry.dispose(); + this.resultGeometry.dispose(); + for (const mat of this.materials) mat.dispose(); + + this.renderer1.dispose(); + this.renderer1.domElement.remove(); + this.renderer2.dispose(); + this.renderer2.domElement.remove(); + + if (this.resultMesh) { this.resultMesh.delete(); this.resultMesh = null; } + this.sphere.delete(); + this.dragon.delete(); } } diff --git a/docs/app/examples/ClosestPointsExample.ts b/docs/app/examples/ClosestPointsExample.ts new file mode 100644 index 0000000..a48ef4a --- /dev/null +++ b/docs/app/examples/ClosestPointsExample.ts @@ -0,0 +1,492 @@ +import { fitCameraToAllMeshesFromZPlane, createScene, type SceneBundle } from "@/utils/sceneUtils"; +import { centerAndScale, pickMesh, randomTransformation, RollingAverage } from "@/utils/utils"; +import * as THREE from "three"; + +type TF = typeof import("@/examples/trueform/index.js"); + +export class ClosestPointsExample { + private tf: TF; + private baseMesh: any; + private tfMeshes: any[] = []; + private container: HTMLElement; + private renderer: THREE.WebGLRenderer; + private sceneBundle: SceneBundle; + private threeMeshes: THREE.Mesh[] = []; + private materials: THREE.MeshMatcapMaterial[] = []; + private running = true; + private cleanups: (() => void)[] = []; + + // Interaction + private selectedId: number | null = null; + private dragging = false; + private movingPlane = new THREE.Plane(); + private lastPoint = new THREE.Vector3(); + private raycaster = new THREE.Raycaster(); + private ndc = new THREE.Vector2(); + + // Closest points visualization + private closestGroup!: THREE.Group; + private sphere1!: THREE.Mesh; + private sphere2!: THREE.Mesh; + private connector!: THREE.Mesh; + private hasClosestPoints = false; + private closestPtSelected = new THREE.Vector3(); // on the dragged mesh + private closestPtOther = new THREE.Vector3(); // on the other mesh + private aabbDiag = 10; + + // Animation (pointer-up slide) + private animating = false; + private animSelectedId = 0; + private animRay: { origin: THREE.Vector3; dir: THREE.Vector3 } | null = null; + private animFocalRay: { origin: THREE.Vector3; dir: THREE.Vector3 } | null = null; + private animPrev = new THREE.Vector3(); + private animStart = 0; + + // Timing + private timing = new RollingAverage(); + + public refreshTimeValue: (() => number) | null = null; + + constructor(tf: TF, fileBuffer: ArrayBuffer, fileName: string, container: HTMLElement, isDarkMode = true) { + this.tf = tf; + this.container = container; + + // Load mesh + const ext = fileName.split(".").pop()?.toLowerCase(); + this.baseMesh = ext === "stl" ? tf.readStl(fileBuffer) : tf.readObj(fileBuffer); + centerAndScale(tf, this.baseMesh); + + // Compute AABB diagonal for sizing + const points = this.baseMesh.points; + const pMin = tf.min(points, 0); + const pMax = tf.max(points, 0); + const diag = pMax.sub(pMin); + this.aabbDiag = tf.norm(diag) as number; + pMin.delete(); pMax.delete(); diag.delete(); + + // Create 2 tf meshes, positioned based on screen orientation + const rect = container.getBoundingClientRect(); + const isLandscape = rect.width > rect.height; + const spacing = isLandscape ? this.aabbDiag * 1.2 : this.aabbDiag * 1.0; + const offsets: [number, number, number][] = isLandscape + ? [[-spacing / 2, 0, 0], [spacing / 2, 0, 0]] + : [[0, -spacing / 2, 0], [0, spacing / 2, 0]]; + + const mesh0 = this.baseMesh; + const mesh1 = this.baseMesh.sharedView(); + // No random rotation on first frame — just translate into position + const mat0 = tf.makeTranslation(...offsets[0]!); + mesh0.transformation = mat0; + mat0.delete(); + const mat1 = tf.makeTranslation(...offsets[1]!); + mesh1.transformation = mat1; + mat1.delete(); + this.tfMeshes = [mesh0, mesh1]; + + // Three.js setup + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + container.appendChild(this.renderer.domElement); + + this.sceneBundle = createScene(this.renderer, { + backgroundColor: isDarkMode ? 0x1e1e1e : 0xfafafa, + enableFog: false, + }); + + // Shared geometry from base mesh + const faces = this.baseMesh.faces; + const sharedGeometry = new THREE.BufferGeometry(); + sharedGeometry.setAttribute("position", new THREE.BufferAttribute(points.data, 3)); + sharedGeometry.setIndex(new THREE.BufferAttribute( + new Uint32Array(faces.data.buffer, faces.data.byteOffset, faces.data.length), 1, + )); + sharedGeometry.computeVertexNormals(); + sharedGeometry.computeBoundingSphere(); + + // Matcap URL + const matcapUrl = isDarkMode + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + + // Create 2 Three.js meshes + for (let idx = 0; idx < 2; idx++) { + const material = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + color: new THREE.Color(0.8, 0.8, 0.8), + }); + this.materials.push(material); + const mesh = new THREE.Mesh(sharedGeometry, material); + mesh.matrixAutoUpdate = false; + this.syncThreeMatrix(idx, mesh); + this.sceneBundle.scene.add(mesh); + this.threeMeshes.push(mesh); + } + + // Load matcap texture + new THREE.TextureLoader().load(matcapUrl, (tex) => { + for (let i = 0; i < this.materials.length; i++) { + this.materials[i].matcap = i === 0 ? tex : tex.clone(); + this.materials[i].needsUpdate = true; + } + }); + + // Closest points visuals + this.setupClosestPointsVisuals(); + + // Compute initial closest points + this.computeClosestPoints(); + + // Fit camera + fitCameraToAllMeshesFromZPlane(this.sceneBundle, 1.5); + + // Pointer events + const onPointerMove = (e: PointerEvent) => { + if (!container.contains(e.target as Node)) return; + this.updateNDC(e); + if (this.dragging && this.selectedId !== null) { + this.handleDrag(); + } else if (!this.dragging && !this.animating) { + this.handleHover(); + } + }; + const onPointerDown = (e: PointerEvent) => { + if (!container.contains(e.target as Node)) return; + if (this.animating) return; + this.updateNDC(e); + this.handleHover(); + if (this.selectedId !== null) { + this.dragging = true; + this.sceneBundle.controls.enabled = false; + } + }; + const onPointerUp = () => { + if (this.animating) return; + if (this.dragging && this.selectedId !== null && this.hasClosestPoints) { + this.startSnapAnimation(); + } else { + this.dragging = false; + this.selectedId = null; + this.sceneBundle.controls.enabled = true; + } + }; + + container.addEventListener("pointermove", onPointerMove); + container.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointerup", onPointerUp); + this.cleanups.push(() => { + container.removeEventListener("pointermove", onPointerMove); + container.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointerup", onPointerUp); + }); + + // Resize + const resizeObs = new ResizeObserver(() => this.resize()); + resizeObs.observe(container); + this.cleanups.push(() => resizeObs.disconnect()); + this.resize(); + + // Animate + this.animate(); + } + + // ========== Closest points visuals ========== + + private setupClosestPointsVisuals() { + this.closestGroup = new THREE.Group(); + + const sphereGeom = new THREE.SphereGeometry(1, 16, 12); + const sphereMat = new THREE.MeshStandardMaterial({ + color: 0x00d5be, + roughness: 0.3, + metalness: 0.1, + }); + const cylGeom = new THREE.CylinderGeometry(1, 1, 1, 8); + const cylMat = new THREE.MeshStandardMaterial({ + color: 0x00a89a, + roughness: 0.3, + metalness: 0.1, + }); + + this.sphere1 = new THREE.Mesh(sphereGeom, sphereMat); + this.sphere2 = new THREE.Mesh(sphereGeom.clone(), sphereMat.clone()); + this.connector = new THREE.Mesh(cylGeom, cylMat); + + this.closestGroup.add(this.sphere1); + this.closestGroup.add(this.sphere2); + this.closestGroup.add(this.connector); + this.closestGroup.visible = false; + this.sceneBundle.scene.add(this.closestGroup); + } + + private updateClosestVisuals() { + if (!this.hasClosestPoints) { + this.closestGroup.visible = false; + return; + } + + const sphereRadius = this.aabbDiag * 0.015; + const cylRadius = this.aabbDiag * 0.0075; + const dist = this.closestPtSelected.distanceTo(this.closestPtOther); + + this.sphere1.position.copy(this.closestPtSelected); + this.sphere1.scale.setScalar(sphereRadius); + + this.sphere2.position.copy(this.closestPtOther); + this.sphere2.scale.setScalar(sphereRadius); + + if (dist > 0.001) { + const mid = new THREE.Vector3().addVectors(this.closestPtSelected, this.closestPtOther).multiplyScalar(0.5); + this.connector.position.copy(mid); + const direction = new THREE.Vector3().subVectors(this.closestPtOther, this.closestPtSelected).normalize(); + const quaternion = new THREE.Quaternion(); + quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction); + this.connector.quaternion.copy(quaternion); + this.connector.scale.set(cylRadius, dist, cylRadius); + this.connector.visible = true; + } else { + this.connector.visible = false; + } + + this.closestGroup.visible = true; + } + + // ========== Closest points computation ========== + + private computeClosestPoints(draggedId = 0) { + const tf = this.tf; + const otherId = draggedId === 0 ? 1 : 0; + const t0 = performance.now(); + if (tf.intersects(this.tfMeshes[draggedId], this.tfMeshes[otherId])) { + this.hasClosestPoints = false; + } else { + // neighborSearch(dragged, other) → point0 on dragged, point1 on other + const result = tf.neighborSearch(this.tfMeshes[draggedId], this.tfMeshes[otherId]); + this.closestPtSelected.fromArray(result.point0.data as Float32Array); + this.closestPtOther.fromArray(result.point1.data as Float32Array); + result.point0.delete(); + result.point1.delete(); + this.hasClosestPoints = true; + } + this.timing.add(performance.now() - t0); + this.updateClosestVisuals(); + if (this.refreshTimeValue) this.refreshTimeValue(); + } + + // ========== Pointer interaction ========== + + private updateNDC(e: PointerEvent) { + const rect = this.container.getBoundingClientRect(); + this.ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + } + + private handleHover() { + const tf = this.tf; + this.raycaster.setFromCamera(this.ndc, this.sceneBundle.camera); + const o = this.raycaster.ray.origin; + const d = this.raycaster.ray.direction; + const tfRay = tf.ray([o.x, o.y, o.z, d.x, d.y, d.z]); + + const hit = pickMesh(tf, tfRay, this.tfMeshes); + tfRay.delete(); + + if (hit) { + this.selectedId = hit.index; + const hitPoint = this.raycaster.ray.at(hit.t, new THREE.Vector3()); + const camDir = new THREE.Vector3(); + this.sceneBundle.camera.getWorldDirection(camDir); + this.movingPlane.setFromNormalAndCoplanarPoint(camDir, hitPoint); + this.lastPoint.copy(hitPoint); + } else { + this.selectedId = null; + } + } + + private handleDrag() { + const id = this.selectedId!; + + this.raycaster.setFromCamera(this.ndc, this.sceneBundle.camera); + const nextPoint = new THREE.Vector3(); + this.raycaster.ray.intersectPlane(this.movingPlane, nextPoint); + if (!nextPoint) return; + + const dx = nextPoint.x - this.lastPoint.x; + const dy = nextPoint.y - this.lastPoint.y; + const dz = nextPoint.z - this.lastPoint.z; + this.lastPoint.copy(nextPoint); + + // Update tf mesh transformation + const mat = this.tfMeshes[id].transformation; + const d = mat.data; + d[3] += dx; d[7] += dy; d[11] += dz; + this.tfMeshes[id].transformation = mat; + mat.delete(); + + this.syncThreeMatrix(id); + this.computeClosestPoints(id); + } + + // ========== Snap animation ========== + + private startSnapAnimation() { + this.animSelectedId = this.selectedId!; + const origin = this.closestPtSelected.clone(); + const dir = new THREE.Vector3().subVectors(this.closestPtOther, this.closestPtSelected); + this.animRay = { origin, dir }; + + // Animate camera focal point toward the other mesh's closest point + const camTarget = (this.sceneBundle.controls as any).target.clone(); + this.animFocalRay = { + origin: camTarget, + dir: new THREE.Vector3().subVectors(this.closestPtOther, camTarget), + }; + + this.animPrev.copy(origin); + this.animStart = performance.now(); + this.animating = true; + this.dragging = false; + this.selectedId = null; + this.sceneBundle.controls.enabled = false; + } + + private stepAnimation() { + if (!this.animRay) return; + + const elapsed = (performance.now() - this.animStart) / 1000; + const t = Math.min(1, 1.75 * elapsed); + const s = 3 * t * t - 2 * t * t * t; // smooth-step + + const pt = this.animRay.origin.clone().addScaledVector(this.animRay.dir, s); + const delta = pt.clone().sub(this.animPrev); + + // Move tf mesh + const mat = this.tfMeshes[this.animSelectedId].transformation; + const d = mat.data; + d[3] += delta.x; d[7] += delta.y; d[11] += delta.z; + this.tfMeshes[this.animSelectedId].transformation = mat; + mat.delete(); + this.syncThreeMatrix(this.animSelectedId); + this.animPrev.copy(pt); + + // Update closest point visualization (shrinking line) + this.closestPtSelected.copy(pt); + this.updateClosestVisuals(); + + // Animate focal point + if (this.animFocalRay) { + const focal = this.animFocalRay.origin.clone().addScaledVector(this.animFocalRay.dir, s); + (this.sceneBundle.controls as any).target.copy(focal); + } + + if (t >= 1) { + this.animating = false; + this.animRay = null; + this.animFocalRay = null; + this.hasClosestPoints = false; + this.updateClosestVisuals(); + this.sceneBundle.controls.enabled = true; + } + } + + // ========== Helpers ========== + + private syncThreeMatrix(index: number, mesh?: THREE.Mesh) { + const target = mesh ?? this.threeMeshes[index]; + const mat = this.tfMeshes[index].transformation; + if (!mat) return; + const m = new THREE.Matrix4(); + m.fromArray(mat.data); + m.transpose(); + target.matrix.copy(m); + mat.delete(); + } + + // ========== Public API ========== + + public getAverageTime(): number { + return this.timing.average; + } + + public randomize() { + const tf = this.tf; + for (let i = 0; i < this.tfMeshes.length; i++) { + // Keep current translation, re-roll rotation + const oldMat = this.tfMeshes[i].transformation; + const tx = oldMat.data[3]; + const ty = oldMat.data[7]; + const tz = oldMat.data[11]; + oldMat.delete(); + + const newMat = randomTransformation(tf, tx, ty, tz); + this.tfMeshes[i].transformation = newMat; + newMat.delete(); + this.syncThreeMatrix(i); + } + this.computeClosestPoints(); + } + + public applyTheme(isDark: boolean) { + this.sceneBundle.scene.background = new THREE.Color(isDark ? 0x1e1e1e : 0xfafafa); + const matcapUrl = isDark + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + new THREE.TextureLoader().load(matcapUrl, (tex) => { + for (let i = 0; i < this.materials.length; i++) { + if (this.materials[i].matcap) this.materials[i].matcap!.dispose(); + this.materials[i].matcap = i === 0 ? tex : tex.clone(); + this.materials[i].needsUpdate = true; + } + }); + } + + private resize() { + const w = this.container.clientWidth; + const h = this.container.clientHeight; + this.renderer.setSize(w, h); + this.renderer.setPixelRatio(window.devicePixelRatio); + this.sceneBundle.camera.aspect = w / h; + this.sceneBundle.camera.updateProjectionMatrix(); + } + + private animate() { + if (!this.running) return; + requestAnimationFrame(() => this.animate()); + if (this.animating) { + this.stepAnimation(); + } + this.sceneBundle.controls.update(); + this.renderer.render(this.sceneBundle.scene, this.sceneBundle.camera); + } + + public dispose() { + this.running = false; + for (const fn of this.cleanups) fn(); + this.cleanups = []; + this.sceneBundle.controls.dispose(); + + // Dispose shared geometry (only once) + if (this.threeMeshes.length > 0) { + this.threeMeshes[0].geometry.dispose(); + } + for (const mesh of this.threeMeshes) { + (mesh.material as THREE.Material).dispose(); + } + + // Dispose closest points visuals + this.sphere1.geometry.dispose(); + (this.sphere1.material as THREE.Material).dispose(); + this.sphere2.geometry.dispose(); + (this.sphere2.material as THREE.Material).dispose(); + this.connector.geometry.dispose(); + (this.connector.material as THREE.Material).dispose(); + + this.renderer.dispose(); + this.renderer.domElement.remove(); + + // Delete tf meshes (sharedView first, then baseMesh) + for (let i = this.tfMeshes.length - 1; i >= 0; i--) { + this.tfMeshes[i].delete(); + } + this.tfMeshes = []; + } +} diff --git a/docs/app/examples/CollisionExample.ts b/docs/app/examples/CollisionExample.ts index 686a037..6c8d791 100644 --- a/docs/app/examples/CollisionExample.ts +++ b/docs/app/examples/CollisionExample.ts @@ -1,19 +1,302 @@ -import type { MainModule } from "@/examples/native"; -import { ThreejsBase } from "@/examples/ThreejsBase"; +import { fitCameraToAllMeshesFromZPlane, createScene, type SceneBundle } from "@/utils/sceneUtils"; +import { centerAndScale, pickMesh, randomTransformation, RollingAverage } from "@/utils/utils"; +import * as THREE from "three"; -export class CollisionExample extends ThreejsBase { - constructor(wasmInstance: MainModule, paths: string[], container: HTMLElement, isDarkMode = true) { - super(wasmInstance, paths, container, undefined, false, false, isDarkMode); +type TF = typeof import("@/examples/trueform/index.js"); + +export class CollisionExample { + private tf: TF; + private baseMesh: any; + private tfMeshes: any[] = []; + private container: HTMLElement; + private renderer: THREE.WebGLRenderer; + private sceneBundle: SceneBundle; + private threeMeshes: THREE.Mesh[] = []; + private running = true; + private cleanups: (() => void)[] = []; + + private selectedId: number | null = null; + private dragging = false; + private movingPlane = new THREE.Plane(); + private lastPoint = new THREE.Vector3(); + private colliding = new Set(); + private pickTiming = new RollingAverage(); + private collideTiming = new RollingAverage(); + + private raycaster = new THREE.Raycaster(); + private ndc = new THREE.Vector2(); + + private normalColor = new THREE.Color(0.8, 0.8, 0.8); + private collidingColor = new THREE.Color(0.7, 1, 1); + + public refreshTimeValue: (() => number) | null = null; + + constructor(tf: TF, fileBuffer: ArrayBuffer, fileName: string, container: HTMLElement, isDarkMode = true) { + this.tf = tf; + this.container = container; + + // Load mesh + const ext = fileName.split(".").pop()?.toLowerCase(); + this.baseMesh = ext === "stl" ? tf.readStl(fileBuffer) : tf.readObj(fileBuffer); + centerAndScale(tf, this.baseMesh); + + // Create 5x5 grid of meshes: baseMesh for first slot, sharedView() for the rest + const gridSize = 5; + const spacing = 15; + for (let j = 0; j < gridSize; j++) { + for (let i = 0; i < gridSize; i++) { + const tfMesh = (i === 0 && j === 0) ? this.baseMesh : this.baseMesh.sharedView(); + const mat = randomTransformation(tf, i * spacing, j * spacing, 0); + tfMesh.transformation = mat; + mat.delete(); + this.tfMeshes.push(tfMesh); + } + } + + // Three.js setup + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + container.appendChild(this.renderer.domElement); + + this.sceneBundle = createScene(this.renderer, { + backgroundColor: isDarkMode ? 0x1e1e1e : 0xfafafa, + enableFog: false, + }); + + // Shared geometry from base mesh points/faces + const points = this.baseMesh.points; + const faces = this.baseMesh.faces; + const sharedGeometry = new THREE.BufferGeometry(); + sharedGeometry.setAttribute("position", new THREE.BufferAttribute(points.data, 3)); + sharedGeometry.setIndex(new THREE.BufferAttribute( + new Uint32Array(faces.data.buffer, faces.data.byteOffset, faces.data.length), 1, + )); + sharedGeometry.computeVertexNormals(); + sharedGeometry.computeBoundingSphere(); + + // Matcap URL + const matcapUrl = isDarkMode + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + + // Create 25 Three.js meshes, each with own material (for independent color), sharing geometry + const materials: THREE.MeshMatcapMaterial[] = []; + for (let idx = 0; idx < this.tfMeshes.length; idx++) { + const material = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + color: this.normalColor.clone(), + }); + materials.push(material); + const mesh = new THREE.Mesh(sharedGeometry, material); + mesh.matrixAutoUpdate = false; + this.syncThreeMatrix(idx, mesh); + this.sceneBundle.scene.add(mesh); + this.threeMeshes.push(mesh); + } + + // Load matcap texture and assign to all materials + new THREE.TextureLoader().load(matcapUrl, (tex) => { + for (let i = 0; i < materials.length; i++) { + materials[i].matcap = i === 0 ? tex : tex.clone(); + materials[i].needsUpdate = true; + } + }); + + // Fit camera + fitCameraToAllMeshesFromZPlane(this.sceneBundle, 1.5); + + // Pointer events + const onPointerMove = (e: PointerEvent) => { + if (!container.contains(e.target as Node)) return; + this.updateNDC(e); + if (this.dragging && this.selectedId !== null) { + this.handleDrag(); + } else if (!this.dragging) { + this.handleHover(); + } + }; + const onPointerDown = (e: PointerEvent) => { + if (!container.contains(e.target as Node)) return; + this.updateNDC(e); + this.handleHover(); // pick on click + if (this.selectedId !== null) { + this.dragging = true; + this.sceneBundle.controls.enabled = false; + } + }; + const onPointerUp = () => { + this.dragging = false; + this.selectedId = null; + this.sceneBundle.controls.enabled = true; + this.colliding.clear(); + this.updateColors(); + }; + + container.addEventListener("pointermove", onPointerMove); + container.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointerup", onPointerUp); + this.cleanups.push(() => { + container.removeEventListener("pointermove", onPointerMove); + container.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointerup", onPointerUp); + }); + + // Resize + const resizeObs = new ResizeObserver(() => this.resize()); + resizeObs.observe(container); + this.cleanups.push(() => resizeObs.disconnect()); + this.resize(); + + // Animate + this.animate(); + } + + private updateNDC(e: PointerEvent) { + const rect = this.container.getBoundingClientRect(); + this.ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + } + + private handleHover() { + const tf = this.tf; + this.raycaster.setFromCamera(this.ndc, this.sceneBundle.camera); + const o = this.raycaster.ray.origin; + const d = this.raycaster.ray.direction; + const tfRay = tf.ray([o.x, o.y, o.z, d.x, d.y, d.z]); + + const t0 = performance.now(); + const hit = pickMesh(tf, tfRay, this.tfMeshes); + this.pickTiming.add(performance.now() - t0); + tfRay.delete(); + + if (hit) { + this.selectedId = hit.index; + const hitPoint = this.raycaster.ray.at(hit.t, new THREE.Vector3()); + const camDir = new THREE.Vector3(); + this.sceneBundle.camera.getWorldDirection(camDir); + this.movingPlane.setFromNormalAndCoplanarPoint(camDir, hitPoint); + this.lastPoint.copy(hitPoint); + } else { + this.selectedId = null; + } + + if (this.refreshTimeValue) this.refreshTimeValue(); + } + + private handleDrag() { + const tf = this.tf; + const id = this.selectedId!; + + this.raycaster.setFromCamera(this.ndc, this.sceneBundle.camera); + const nextPoint = new THREE.Vector3(); + this.raycaster.ray.intersectPlane(this.movingPlane, nextPoint); + if (!nextPoint) return; + + const dx = nextPoint.x - this.lastPoint.x; + const dy = nextPoint.y - this.lastPoint.y; + const dz = nextPoint.z - this.lastPoint.z; + this.lastPoint.copy(nextPoint); + + // Update tf mesh transformation: read, modify translation in-place, write back + const mat = this.tfMeshes[id].transformation; + const d = mat.data; // mutable WASM heap view + d[3] += dx; d[7] += dy; d[11] += dz; + this.tfMeshes[id].transformation = mat; + mat.delete(); + + // Sync Three.js matrix + this.syncThreeMatrix(id); + + // Collision detection + const t0 = performance.now(); + this.colliding.clear(); + for (let i = 0; i < this.tfMeshes.length; i++) { + if (i === id) continue; + if (tf.intersects(this.tfMeshes[id], this.tfMeshes[i])) { + this.colliding.add(i); + } + } + this.collideTiming.add(performance.now() - t0); + + this.updateColors(); + if (this.refreshTimeValue) this.refreshTimeValue(); } - runMain() { - const v = new this.wasmInstance.VectorString(); - for (const path of this.paths) { - v.push_back(path); + private syncThreeMatrix(index: number, mesh?: THREE.Mesh) { + const target = mesh ?? this.threeMeshes[index]; + const mat = this.tfMeshes[index].transformation; + if (!mat) return; + const m = new THREE.Matrix4(); + m.fromArray(mat.data); + m.transpose(); // row-major → column-major + target.matrix.copy(m); + mat.delete(); + } + + private updateColors() { + for (let i = 0; i < this.threeMeshes.length; i++) { + const mat = this.threeMeshes[i].material as THREE.MeshMatcapMaterial; + mat.color.copy(this.colliding.has(i) ? this.collidingColor : this.normalColor); + } + } + + public getAverageTime(): number { + return this.collideTiming.average; + } + + public getAveragePickTime(): number { + return this.pickTiming.average; + } + + public applyTheme(isDark: boolean) { + this.sceneBundle.scene.background = new THREE.Color(isDark ? 0x1e1e1e : 0xfafafa); + const matcapUrl = isDark + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + new THREE.TextureLoader().load(matcapUrl, (tex) => { + for (let i = 0; i < this.threeMeshes.length; i++) { + const mat = this.threeMeshes[i].material as THREE.MeshMatcapMaterial; + if (mat.matcap) mat.matcap.dispose(); + mat.matcap = i === 0 ? tex : tex.clone(); + mat.needsUpdate = true; + } + }); + } + + private resize() { + const w = this.container.clientWidth; + const h = this.container.clientHeight; + this.renderer.setSize(w, h); + this.renderer.setPixelRatio(window.devicePixelRatio); + this.sceneBundle.camera.aspect = w / h; + this.sceneBundle.camera.updateProjectionMatrix(); + } + + private animate() { + if (!this.running) return; + requestAnimationFrame(() => this.animate()); + this.sceneBundle.controls.update(); + this.renderer.render(this.sceneBundle.scene, this.sceneBundle.camera); + } + + public dispose() { + this.running = false; + for (const fn of this.cleanups) fn(); + this.cleanups = []; + this.sceneBundle.controls.dispose(); + // Dispose shared geometry (only once) + if (this.threeMeshes.length > 0) { + this.threeMeshes[0].geometry.dispose(); + } + for (const mesh of this.threeMeshes) { + (mesh.material as THREE.Material).dispose(); } - this.wasmInstance.run_main_collisions(v); - for (const path of this.paths) { - this.wasmInstance.FS.unlink(path); + this.renderer.dispose(); + this.renderer.domElement.remove(); + // Delete tf meshes (sharedViews first, then baseMesh) + for (let i = this.tfMeshes.length - 1; i >= 0; i--) { + this.tfMeshes[i].delete(); } + this.tfMeshes = []; } } diff --git a/docs/app/examples/CrossSectionExample.ts b/docs/app/examples/CrossSectionExample.ts index 447ffe5..e0fc191 100644 --- a/docs/app/examples/CrossSectionExample.ts +++ b/docs/app/examples/CrossSectionExample.ts @@ -1,193 +1,289 @@ -import type { MainModule } from "@/examples/native"; -import { fitCameraToAllMeshesFromZPlane } from "@/utils/sceneUtils"; -import { - buffersToCurves, - createMesh, - updateResultMesh, - CurveRenderer, -} from "@/utils/utils"; -import { ThreejsBase } from "@/examples/ThreejsBase"; +import { fitCameraToAllMeshesFromZPlane, createScene, type SceneBundle } from "@/utils/sceneUtils"; +import { centerAndScale, CurveRenderer, RollingAverage } from "@/utils/utils"; import * as THREE from "three"; -export class CrossSectionExample extends ThreejsBase { +type TF = typeof import("@/examples/trueform/index.js"); + +export class CrossSectionExample { + private tf: TF; + private mesh: any; + private container: HTMLElement; + private renderer: THREE.WebGLRenderer; + private sceneBundle: SceneBundle; private curveRenderer: CurveRenderer; + private baseMesh: THREE.Mesh; private crossSectionMesh: THREE.Mesh; - private keyPressed = false; + private running = true; + private cleanups: (() => void)[] = []; - public randomize() { - this.wasmInstance.OnKeyPress("n"); - this.updateMeshes(); - } + // Scalar field (NDArray [N], persists across scroll events) + private scalarsND: any = null; + private scalarMin = 0; + private scalarMax = 1; + private planeOffset = 0; + private normalVec: any = null; + private timing = new RollingAverage(); + + public refreshTimeValue: (() => number) | null = null; + + constructor(tf: TF, fileBuffer: ArrayBuffer, fileName: string, container: HTMLElement, isDarkMode = true) { + this.tf = tf; + this.container = container; + + // Load mesh + const ext = fileName.split(".").pop()?.toLowerCase(); + this.mesh = ext === "stl" ? tf.readStl(fileBuffer) : tf.readObj(fileBuffer); + + // Center and scale mesh (AABB center at origin, diagonal/2 = 10) + centerAndScale(this.tf, this.mesh); - constructor( - wasmInstance: MainModule, - paths: string[], - container: HTMLElement, - isDarkMode = true, - ) { - // Single viewport - no second container - super(wasmInstance, paths, container, undefined, true, false, isDarkMode); - this.sceneBundle1.controls.enableZoom = false; - - // Make the original mesh semi-transparent - this.instancedMeshes.forEach((instancedMesh) => { - const material = instancedMesh.material as THREE.MeshMatcapMaterial; - material.transparent = true; - material.opacity = 0.25; - material.depthWrite = false; + // Initial normal [1, 2, 1] normalized + this.normalVec = tf.normalize(tf.vector(1, 2, 1)); + + // Compute initial scalar field + this.computeScalars(); + + // Three.js setup + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + container.appendChild(this.renderer.domElement); + + this.sceneBundle = createScene(this.renderer, { + backgroundColor: isDarkMode ? 0x1e1e1e : 0xfafafa, + enableFog: false, }); - const interceptKeyDownEvent = (event: KeyboardEvent) => { - if (this.keyPressed) return; - this.keyPressed = true; - if (event.key === "n") { - this.randomize(); - return; - } - this.wasmInstance.OnKeyPress(event.key); - this.updateMeshes(); - }; - const interceptKeyUpEvent = (_event: KeyboardEvent) => { - this.keyPressed = false; - }; - window.addEventListener("keydown", interceptKeyDownEvent); - window.addEventListener("keyup", interceptKeyUpEvent); - - const isEventFromCanvas = (eventTarget: EventTarget | null) => { - if (!eventTarget) return false; - const target = eventTarget as Node; - const container1 = this.renderer.domElement.parentElement; - return !!container1 && container1.contains(target); + // Base mesh (semi-transparent) + const points = this.mesh.points; + const faces = this.mesh.faces; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(points.data, 3)); + geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(faces.data.buffer, faces.data.byteOffset, faces.data.length), 1)); + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + + const matcapUrl = isDarkMode + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + const baseMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + transparent: true, + opacity: 0.25, + depthWrite: false, + }); + this.baseMesh = new THREE.Mesh(geometry, baseMaterial); + this.baseMesh.matrixAutoUpdate = false; + this.sceneBundle.scene.add(this.baseMesh); + + // Cross-section cap mesh + const capMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + color: new THREE.Color(0x00a89a), + }); + this.crossSectionMesh = new THREE.Mesh(new THREE.BufferGeometry(), capMaterial); + this.crossSectionMesh.matrixAutoUpdate = false; + this.sceneBundle.scene.add(this.crossSectionMesh); + + // Load matcap texture + new THREE.TextureLoader().load(matcapUrl, (tex) => { + baseMaterial.matcap = tex; + baseMaterial.needsUpdate = true; + capMaterial.matcap = tex.clone(); + capMaterial.needsUpdate = true; + }); + + // Curve renderer + this.curveRenderer = new CurveRenderer({ color: 0x00d5be, radius: 0.075, maxSegments: 20000 }); + this.sceneBundle.scene.add(this.curveRenderer.object); + + // Compute initial cross-section + this.recomputeCrossSection(); + + // Fit camera + fitCameraToAllMeshesFromZPlane(this.sceneBundle, 1.5); + this.sceneBundle.controls.enableZoom = false; + + // Scroll interaction (matches WASM cross_section_web.h:104) + const range = () => this.scalarMax - this.scalarMin; + const margin = () => range() * 0.01; + const scrollStep = (delta: number) => { + this.planeOffset += delta * 0.003 * range(); + this.planeOffset = Math.max( + this.scalarMin + margin(), + Math.min(this.scalarMax - margin(), this.planeOffset), + ); + this.recomputeCrossSection(); }; - const interceptWheelEvent = (event: WheelEvent) => { - if (!isEventFromCanvas(event.target)) return; + const onWheel = (event: WheelEvent) => { + if (!container.contains(event.target as Node)) return; + event.preventDefault(); const delta = event.deltaY !== 0 ? event.deltaY : event.deltaX; if (delta === 0) return; - event.preventDefault(); - const normalizedDelta = delta / Math.abs(delta); - const handled = this.wasmInstance.OnMouseWheel(normalizedDelta, true); - this.updateMeshes(); - if (handled) event.stopImmediatePropagation(); - }; - const wheelListenerOptions = { - passive: false, - capture: true, + scrollStep(Math.sign(delta)); }; - window.addEventListener("wheel", interceptWheelEvent, wheelListenerOptions); + window.addEventListener("wheel", onWheel, { passive: false, capture: true }); + this.cleanups.push(() => window.removeEventListener("wheel", onWheel, { passive: false, capture: true } as any)); - let touchScrollActive = false; + // Touch scroll + let touchActive = false; let lastTouchY = 0; - const setTouchScrollMode = (active: boolean) => { - touchScrollActive = active; - this.sceneBundle1.controls.enabled = !active; + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1 || !container.contains(e.target as Node)) return; + e.preventDefault(); + touchActive = true; + this.sceneBundle.controls.enabled = false; + lastTouchY = e.touches[0]!.clientY; }; - const touchScrollThresholdPx = 10; - const getAverageTouchY = (touches: TouchList) => { - let sum = 0; - for (let i = 0; i < touches.length; i++) { - sum += touches[i]!.clientY; - } - return sum / touches.length; - }; - - const interceptTouchStart = (event: TouchEvent) => { - if (event.touches.length !== 1) return; - if (!isEventFromCanvas(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - setTouchScrollMode(true); - lastTouchY = getAverageTouchY(event.touches); + const onTouchMove = (e: TouchEvent) => { + if (!touchActive || e.touches.length !== 1) { touchActive = false; this.sceneBundle.controls.enabled = true; return; } + e.preventDefault(); + const dy = e.touches[0]!.clientY - lastTouchY; + if (Math.abs(dy) < 10) return; + scrollStep(Math.sign(dy)); + lastTouchY = e.touches[0]!.clientY; }; + const onTouchEnd = () => { touchActive = false; this.sceneBundle.controls.enabled = true; }; + const touchOpts = { passive: false, capture: true }; + window.addEventListener("touchstart", onTouchStart, touchOpts); + window.addEventListener("touchmove", onTouchMove, touchOpts); + window.addEventListener("touchend", onTouchEnd, touchOpts); + window.addEventListener("touchcancel", onTouchEnd, touchOpts); + this.cleanups.push(() => { + window.removeEventListener("touchstart", onTouchStart, touchOpts as any); + window.removeEventListener("touchmove", onTouchMove, touchOpts as any); + window.removeEventListener("touchend", onTouchEnd, touchOpts as any); + window.removeEventListener("touchcancel", onTouchEnd, touchOpts as any); + }); - const interceptTouchMove = (event: TouchEvent) => { - if (!touchScrollActive) return; - if (event.touches.length !== 1) { - setTouchScrollMode(false); - return; - } - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - const currentY = getAverageTouchY(event.touches); - const deltaY = currentY - lastTouchY; - if (Math.abs(deltaY) < touchScrollThresholdPx) return; - const normalizedDelta = deltaY / Math.abs(deltaY); - const handled = this.wasmInstance.OnMouseWheel(normalizedDelta, true); - this.updateMeshes(); - if (handled) { - event.stopImmediatePropagation(); - } - lastTouchY = currentY; - }; + // Resize + const resizeObs = new ResizeObserver(() => this.resize()); + resizeObs.observe(container); + this.cleanups.push(() => resizeObs.disconnect()); + this.resize(); - const interceptTouchEnd = (event: TouchEvent) => { - if (touchScrollActive) { - setTouchScrollMode(false); - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - } - }; + // Animate + this.animate(); + } - const touchListenerOptions = { - passive: false, - capture: true, - }; - window.addEventListener("touchstart", interceptTouchStart, touchListenerOptions); - window.addEventListener("touchmove", interceptTouchMove, touchListenerOptions); - window.addEventListener("touchend", interceptTouchEnd, touchListenerOptions); - window.addEventListener("touchcancel", interceptTouchEnd, touchListenerOptions); - - this.addCleanup(() => { - window.removeEventListener("keydown", interceptKeyDownEvent); - window.removeEventListener("keyup", interceptKeyUpEvent); - window.removeEventListener("wheel", interceptWheelEvent, wheelListenerOptions); - window.removeEventListener("touchstart", interceptTouchStart, touchListenerOptions); - window.removeEventListener("touchmove", interceptTouchMove, touchListenerOptions); - window.removeEventListener("touchend", interceptTouchEnd, touchListenerOptions); - window.removeEventListener("touchcancel", interceptTouchEnd, touchListenerOptions); - }); + private computeScalars() { + const tf = this.tf; + if (this.scalarsND) { this.scalarsND.delete(); this.scalarsND = null; } - this.curveRenderer = new CurveRenderer({ - color: 0x00d5be, - radius: 0.075, - maxSegments: 20000, - }); - this.sceneBundle1.scene.add(this.curveRenderer.object); + const points = this.mesh.points; + const centroid = tf.mean(points, 0) as any; + const d = -(tf.dot(this.normalVec, centroid) as number); + const p = tf.plane(this.normalVec, d); + const pts = tf.point(points); - this.crossSectionMesh = createMesh(this.isDarkMode); - const crossSectionMaterial = this.crossSectionMesh.material as THREE.MeshMatcapMaterial; - crossSectionMaterial.color = new THREE.Color(0x00a89a); - this.sceneBundle1.scene.add(this.crossSectionMesh); + this.scalarsND = tf.distance(pts, p); + this.scalarMin = this.scalarsND.min() as number; + this.scalarMax = this.scalarsND.max() as number; + this.planeOffset = (this.scalarMin + this.scalarMax) * 0.5; - this.updateMeshes(); - fitCameraToAllMeshesFromZPlane(this.sceneBundle1, 1.5); + centroid.delete(); } - public override updateMeshes() { - super.updateMeshes(); - - // Update curves - const curveOutput = this.wasmInstance.get_curve_mesh(); - if (curveOutput && curveOutput.updated) { - const points = curveOutput.get_curve_points(); - const ids = curveOutput.get_curve_ids(); - const offsets = curveOutput.get_curve_offsets(); - const curves = buffersToCurves(points, ids, offsets); - this.curveRenderer.update(curves); + private recomputeCrossSection() { + const t0 = performance.now(); + const tf = this.tf; + + // 1. Isocontours at the cut value → boundary curves + const curves = tf.isocontours(this.mesh, this.scalarsND, this.planeOffset); + + // 2. Triangulate curve paths as polygons → filled cap mesh + const cap = tf.triangulate({ faces: curves.paths, points: curves.points }); + + this.timing.add(performance.now() - t0); + + // 3. Update cap mesh geometry + const capPoints = cap.points; + const capFaces = cap.faces; + const geom = this.crossSectionMesh.geometry; + geom.setAttribute("position", new THREE.BufferAttribute(capPoints.data, 3)); + geom.setIndex(new THREE.BufferAttribute( + new Uint32Array(capFaces.data.buffer, capFaces.data.byteOffset, capFaces.data.length), 1, + )); + if (capPoints.data.length >= 3) { + geom.computeBoundingSphere(); + geom.computeBoundingBox(); } - // Update filled cross-section mesh - const resultMesh = this.wasmInstance.get_result_mesh(); - if (resultMesh) { - updateResultMesh(resultMesh, this.crossSectionMesh); + // 4. Update curves for tube rendering + const curvePts = curves.points.data as Float32Array; + const paths: number[][] = []; + for (const p of curves.paths) { + paths.push(Array.from(p.data)); + p.delete(); } + this.curveRenderer.update({ points: curvePts, paths }); + + // Cleanup + cap.delete(); + curves.delete(); + if (this.refreshTimeValue) this.refreshTimeValue(); + } + + public randomize() { + const tf = this.tf; + this.normalVec.delete(); + this.normalVec = tf.normalize(tf.vector(tf.random("float32", [3], -1, 1))); + this.computeScalars(); + this.recomputeCrossSection(); + } + + public getAverageTime(): number { + return this.timing.average; + } + + public applyTheme(isDark: boolean) { + this.sceneBundle.scene.background = new THREE.Color(isDark ? 0x1e1e1e : 0xfafafa); + + const matcapUrl = isDark + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + const newTex = new THREE.TextureLoader().load(matcapUrl); + + const baseMat = this.baseMesh.material as THREE.MeshMatcapMaterial; + if (baseMat.matcap) baseMat.matcap.dispose(); + baseMat.matcap = newTex; + + const capMat = this.crossSectionMesh.material as THREE.MeshMatcapMaterial; + if (capMat.matcap) capMat.matcap.dispose(); + capMat.matcap = newTex.clone(); + } + + private resize() { + const w = this.container.clientWidth; + const h = this.container.clientHeight; + this.renderer.setSize(w, h); + this.renderer.setPixelRatio(window.devicePixelRatio); + this.sceneBundle.camera.aspect = w / h; + this.sceneBundle.camera.updateProjectionMatrix(); + } + + private animate() { + if (!this.running) return; + requestAnimationFrame(() => this.animate()); + this.sceneBundle.controls.update(); + this.renderer.render(this.sceneBundle.scene, this.sceneBundle.camera); } - public runMain() { - this.wasmInstance.run_main_cross_section(this.paths[0]!); - this.wasmInstance.FS.unlink(this.paths[0]); + public dispose() { + this.running = false; + for (const fn of this.cleanups) fn(); + this.cleanups = []; + this.sceneBundle.controls.dispose(); + this.baseMesh.geometry.dispose(); + (this.baseMesh.material as THREE.Material).dispose(); + this.crossSectionMesh.geometry.dispose(); + (this.crossSectionMesh.material as THREE.Material).dispose(); + this.curveRenderer.dispose(); + this.renderer.dispose(); + this.renderer.domElement.remove(); + if (this.scalarsND) { this.scalarsND.delete(); this.scalarsND = null; } + if (this.normalVec) { this.normalVec.delete(); this.normalVec = null; } + this.mesh.delete(); } } diff --git a/docs/app/examples/IsobandsExample.ts b/docs/app/examples/IsobandsExample.ts index c5ec6f9..6c3f66d 100644 --- a/docs/app/examples/IsobandsExample.ts +++ b/docs/app/examples/IsobandsExample.ts @@ -1,189 +1,310 @@ -import type { MainModule } from "@/examples/native"; -import { fitCameraToAllMeshesFromZPlane } from "@/utils/sceneUtils"; -import { - buffersToCurves, - createMesh, - updateResultMesh, - CurveRenderer, -} from "@/utils/utils"; -import { ThreejsBase } from "@/examples/ThreejsBase"; +import { fitCameraToAllMeshesFromZPlane, createScene, type SceneBundle } from "@/utils/sceneUtils"; +import { centerAndScale, CurveRenderer, RollingAverage } from "@/utils/utils"; import * as THREE from "three"; -export class IsobandsExample extends ThreejsBase { +type TF = typeof import("@/examples/trueform/index.js"); + +export class IsobandsExample { + private tf: TF; + private mesh: any; + private container: HTMLElement; + private renderer: THREE.WebGLRenderer; + private sceneBundle: SceneBundle; private curveRenderer: CurveRenderer; + private baseMesh: THREE.Mesh; private isobandsMesh: THREE.Mesh; - private keyPressed = false; + private running = true; + private cleanups: (() => void)[] = []; - public randomize() { - this.wasmInstance.OnKeyPress("n"); - this.updateMeshes(); - } + // Scalar field (NDArray [N], persists across scroll events) + private scalarsND: any = null; + private scalarMin = 0; + private scalarMax = 1; + private planeOffset = 0; + private normalVec: any = null; // Vector primitive [3] + private timing = new RollingAverage(); + + public refreshTimeValue: (() => number) | null = null; + + constructor(tf: TF, fileBuffer: ArrayBuffer, fileName: string, container: HTMLElement, isDarkMode = true) { + this.tf = tf; + this.container = container; + + // Load mesh + const ext = fileName.split(".").pop()?.toLowerCase(); + this.mesh = ext === "stl" ? tf.readStl(fileBuffer) : tf.readObj(fileBuffer); + + // Center and scale mesh (match old pipeline: AABB center at origin, diagonal/2 = 10) + centerAndScale(this.tf, this.mesh); + + // Initial normal [1, 2, 1] normalized + this.normalVec = tf.normalize(tf.vector(1, 2, 1)); - constructor( - wasmInstance: MainModule, - paths: string[], - container: HTMLElement, - isDarkMode = true, - ) { - super(wasmInstance, paths, container, undefined, true, false, isDarkMode); - this.sceneBundle1.controls.enableZoom = false; - - this.instancedMeshes.forEach((instancedMesh) => { - const material = instancedMesh.material as THREE.MeshMatcapMaterial; - material.transparent = true; - material.opacity = 0.25; - material.depthWrite = false; + // Compute initial scalar field + this.computeScalars(); + + // Three.js setup + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + container.appendChild(this.renderer.domElement); + + this.sceneBundle = createScene(this.renderer, { + backgroundColor: isDarkMode ? 0x1e1e1e : 0xfafafa, + enableFog: false, }); - const interceptKeyDownEvent = (event: KeyboardEvent) => { - if (this.keyPressed) return; - this.keyPressed = true; - if (event.key === "n") { - this.randomize(); - return; - } - this.wasmInstance.OnKeyPress(event.key); - this.updateMeshes(); - }; - const interceptKeyUpEvent = (_event: KeyboardEvent) => { - this.keyPressed = false; - }; - window.addEventListener("keydown", interceptKeyDownEvent); - window.addEventListener("keyup", interceptKeyUpEvent); - - const isEventFromCanvas = (eventTarget: EventTarget | null) => { - if (!eventTarget) return false; - const target = eventTarget as Node; - const container1 = this.renderer.domElement.parentElement; - return !!container1 && container1.contains(target); + // Base mesh (semi-transparent) + const points = this.mesh.points; + const faces = this.mesh.faces; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(points.data, 3)); + geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(faces.data.buffer, faces.data.byteOffset, faces.data.length), 1)); + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + + const matcapUrl = isDarkMode + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + const baseMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + transparent: true, + opacity: 0.25, + depthWrite: false, + }); + this.baseMesh = new THREE.Mesh(geometry, baseMaterial); + this.baseMesh.matrixAutoUpdate = false; + this.sceneBundle.scene.add(this.baseMesh); + + // Isobands mesh + const isoMaterial = new THREE.MeshMatcapMaterial({ + side: THREE.DoubleSide, + flatShading: true, + color: new THREE.Color(0x00a89a), + }); + this.isobandsMesh = new THREE.Mesh(new THREE.BufferGeometry(), isoMaterial); + this.isobandsMesh.matrixAutoUpdate = false; + this.sceneBundle.scene.add(this.isobandsMesh); + + // Load matcap texture, assign to both materials once ready + new THREE.TextureLoader().load(matcapUrl, (tex) => { + baseMaterial.matcap = tex; + baseMaterial.needsUpdate = true; + isoMaterial.matcap = tex.clone(); + isoMaterial.needsUpdate = true; + }); + + // Curve renderer + this.curveRenderer = new CurveRenderer({ color: 0x00d5be, radius: 0.075, maxSegments: 20000 }); + this.sceneBundle.scene.add(this.curveRenderer.object); + + // Compute initial isobands + this.recomputeIsobands(); + + // Fit camera + fitCameraToAllMeshesFromZPlane(this.sceneBundle, 1.5); + this.sceneBundle.controls.enableZoom = false; + + // Scroll interaction (wraps around like old pipeline) + const range = () => this.scalarMax - this.scalarMin; + const scrollStep = (delta: number) => { + this.planeOffset += delta * 0.003 * range(); + // Wrap with fmod + let offset = (this.planeOffset - this.scalarMin) % range(); + if (offset < 0) offset += range(); + this.planeOffset = this.scalarMin + offset; + this.recomputeIsobands(); }; - const interceptWheelEvent = (event: WheelEvent) => { - if (!isEventFromCanvas(event.target)) return; + const onWheel = (event: WheelEvent) => { + if (!container.contains(event.target as Node)) return; + event.preventDefault(); const delta = event.deltaY !== 0 ? event.deltaY : event.deltaX; if (delta === 0) return; - event.preventDefault(); - const normalizedDelta = delta / Math.abs(delta); - const handled = this.wasmInstance.OnMouseWheel(normalizedDelta, true); - this.updateMeshes(); - if (handled) event.stopImmediatePropagation(); + scrollStep(Math.sign(delta)); }; - const wheelListenerOptions = { - passive: false, - capture: true, - }; - window.addEventListener("wheel", interceptWheelEvent, wheelListenerOptions); + window.addEventListener("wheel", onWheel, { passive: false, capture: true }); + this.cleanups.push(() => window.removeEventListener("wheel", onWheel, { passive: false, capture: true } as any)); - let touchScrollActive = false; + // Touch scroll + let touchActive = false; let lastTouchY = 0; - const setTouchScrollMode = (active: boolean) => { - touchScrollActive = active; - this.sceneBundle1.controls.enabled = !active; + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1 || !container.contains(e.target as Node)) return; + e.preventDefault(); + touchActive = true; + this.sceneBundle.controls.enabled = false; + lastTouchY = e.touches[0]!.clientY; }; - const touchScrollThresholdPx = 10; - const getAverageTouchY = (touches: TouchList) => { - let sum = 0; - for (let i = 0; i < touches.length; i++) { - sum += touches[i]!.clientY; - } - return sum / touches.length; + const onTouchMove = (e: TouchEvent) => { + if (!touchActive || e.touches.length !== 1) { touchActive = false; this.sceneBundle.controls.enabled = true; return; } + e.preventDefault(); + const dy = e.touches[0]!.clientY - lastTouchY; + if (Math.abs(dy) < 10) return; + scrollStep(Math.sign(dy)); + lastTouchY = e.touches[0]!.clientY; }; + const onTouchEnd = () => { touchActive = false; this.sceneBundle.controls.enabled = true; }; + const touchOpts = { passive: false, capture: true }; + window.addEventListener("touchstart", onTouchStart, touchOpts); + window.addEventListener("touchmove", onTouchMove, touchOpts); + window.addEventListener("touchend", onTouchEnd, touchOpts); + window.addEventListener("touchcancel", onTouchEnd, touchOpts); + this.cleanups.push(() => { + window.removeEventListener("touchstart", onTouchStart, touchOpts as any); + window.removeEventListener("touchmove", onTouchMove, touchOpts as any); + window.removeEventListener("touchend", onTouchEnd, touchOpts as any); + window.removeEventListener("touchcancel", onTouchEnd, touchOpts as any); + }); - const interceptTouchStart = (event: TouchEvent) => { - if (event.touches.length !== 1) return; - if (!isEventFromCanvas(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - setTouchScrollMode(true); - lastTouchY = getAverageTouchY(event.touches); - }; + // Resize + const resizeObs = new ResizeObserver(() => this.resize()); + resizeObs.observe(container); + this.cleanups.push(() => resizeObs.disconnect()); + this.resize(); - const interceptTouchMove = (event: TouchEvent) => { - if (!touchScrollActive) return; - if (event.touches.length !== 1) { - setTouchScrollMode(false); - return; - } - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - const currentY = getAverageTouchY(event.touches); - const deltaY = currentY - lastTouchY; - if (Math.abs(deltaY) < touchScrollThresholdPx) return; - const normalizedDelta = deltaY / Math.abs(deltaY); - const handled = this.wasmInstance.OnMouseWheel(normalizedDelta, true); - this.updateMeshes(); - if (handled) { - event.stopImmediatePropagation(); - } - lastTouchY = currentY; - }; + // Animate + this.animate(); + } - const interceptTouchEnd = (event: TouchEvent) => { - if (touchScrollActive) { - setTouchScrollMode(false); - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - } - }; + private computeScalars() { + const tf = this.tf; + if (this.scalarsND) { this.scalarsND.delete(); this.scalarsND = null; } - const touchListenerOptions = { - passive: false, - capture: true, - }; - window.addEventListener("touchstart", interceptTouchStart, touchListenerOptions); - window.addEventListener("touchmove", interceptTouchMove, touchListenerOptions); - window.addEventListener("touchend", interceptTouchEnd, touchListenerOptions); - window.addEventListener("touchcancel", interceptTouchEnd, touchListenerOptions); - - this.addCleanup(() => { - window.removeEventListener("keydown", interceptKeyDownEvent); - window.removeEventListener("keyup", interceptKeyUpEvent); - window.removeEventListener("wheel", interceptWheelEvent, wheelListenerOptions); - window.removeEventListener("touchstart", interceptTouchStart, touchListenerOptions); - window.removeEventListener("touchmove", interceptTouchMove, touchListenerOptions); - window.removeEventListener("touchend", interceptTouchEnd, touchListenerOptions); - window.removeEventListener("touchcancel", interceptTouchEnd, touchListenerOptions); - }); + const points = this.mesh.points; + const centroid = tf.mean(points, 0) as any; + const d = -(tf.dot(this.normalVec, centroid) as number); + const p = tf.plane(this.normalVec, d); + const pts = tf.point(points); - this.curveRenderer = new CurveRenderer({ - color: 0x00d5be, - radius: 0.075, - maxSegments: 20000, - }); - this.sceneBundle1.scene.add(this.curveRenderer.object); + this.scalarsND = tf.distance(pts, p); + this.scalarMin = this.scalarsND.min() as number; + this.scalarMax = this.scalarsND.max() as number; - this.isobandsMesh = createMesh(this.isDarkMode); - const isobandsMaterial = this.isobandsMesh.material as THREE.MeshMatcapMaterial; - isobandsMaterial.color = new THREE.Color(0x00a89a); - this.sceneBundle1.scene.add(this.isobandsMesh); + const neg = tf.sum(this.scalarsND.lt(0)) as number; + const pos = this.scalarsND.length - neg; + console.log("[computeScalars] min:", this.scalarMin, "max:", this.scalarMax, "neg:", neg, "pos:", pos); - this.updateMeshes(); - fitCameraToAllMeshesFromZPlane(this.sceneBundle1, 1.5); + centroid.delete(); } - public override updateMeshes() { - super.updateMeshes(); + private recomputeIsobands() { + const t0 = performance.now(); + const tf = this.tf; + const n = 10; + const range = this.scalarMax - this.scalarMin; + const s = range / n; + + // Which band does the current offset fall in? + const a = (this.planeOffset - this.scalarMin) / s; + const k = Math.max(0, Math.min(n - 1, Math.floor(a))); - const curveOutput = this.wasmInstance.get_curve_mesh(); - if (curveOutput && curveOutput.updated) { - const points = curveOutput.get_curve_points(); - const ids = curveOutput.get_curve_ids(); - const offsets = curveOutput.get_curve_offsets(); - const curves = buffersToCurves(points, ids, offsets); - this.curveRenderer.update(curves); + // Cut values centered on planeOffset (same logic as old C++ pipeline) + const cutValues = new Float32Array(n); + for (let i = 0; i < n; i++) { + cutValues[i] = this.planeOffset + (i - k) * s; } - const resultMesh = this.wasmInstance.get_result_mesh(); - if (resultMesh) { - updateResultMesh(resultMesh, this.isobandsMesh); + // Select alternating bands based on parity of k + const parity = k & 1; + const selectedBands: number[] = []; + for (let i = 0; i < n; i++) { + if ((i & 1) === parity) selectedBands.push(i); } + + const result = tf.isobands(this.mesh, this.scalarsND, cutValues, { selectedBands, returnCurves: true }); + this.timing.add(performance.now() - t0); + + // Update isobands geometry + const isoPoints = result.mesh.points; + const isoFaces = result.mesh.faces; + const geom = this.isobandsMesh.geometry; + geom.setAttribute("position", new THREE.BufferAttribute(isoPoints.data, 3)); + geom.setIndex(new THREE.BufferAttribute( + new Uint32Array(isoFaces.data.buffer, isoFaces.data.byteOffset, isoFaces.data.length), 1, + )); + if (isoPoints.data.length >= 3) { + geom.computeBoundingSphere(); + geom.computeBoundingBox(); + } + + // Update curves + const curvePts = result.curves.points.data as Float32Array; + const paths: number[][] = []; + for (const p of result.curves.paths) { + paths.push(Array.from(p.data)); + p.delete(); + } + this.curveRenderer.update({ points: curvePts, paths }); + + // Cleanup + result.mesh.delete(); + result.labels.delete(); + result.curves.delete(); + + if (this.refreshTimeValue) this.refreshTimeValue(); + } + + public randomize() { + const tf = this.tf; + this.normalVec.delete(); + this.normalVec = tf.normalize(tf.vector(tf.random("float32", [3], -1, 1))); + this.planeOffset = 0; + this.computeScalars(); + this.recomputeIsobands(); + } + + public getAverageTime(): number { + return this.timing.average; + } + + public applyTheme(isDark: boolean) { + this.sceneBundle.scene.background = new THREE.Color(isDark ? 0x1e1e1e : 0xfafafa); + + // Swap matcap textures + const matcapUrl = isDark + ? "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/635D52_A9BCC0_B1AEA0_819598.png" + : "https://raw.githubusercontent.com/nidorx/matcaps/master/1024/2D2D2F_C6C2C5_727176_94949B.png"; + const newTex = new THREE.TextureLoader().load(matcapUrl); + + const baseMat = this.baseMesh.material as THREE.MeshMatcapMaterial; + if (baseMat.matcap) baseMat.matcap.dispose(); + baseMat.matcap = newTex; + + const isoMat = this.isobandsMesh.material as THREE.MeshMatcapMaterial; + if (isoMat.matcap) isoMat.matcap.dispose(); + isoMat.matcap = newTex.clone(); + } + + private resize() { + const w = this.container.clientWidth; + const h = this.container.clientHeight; + this.renderer.setSize(w, h); + this.renderer.setPixelRatio(window.devicePixelRatio); + this.sceneBundle.camera.aspect = w / h; + this.sceneBundle.camera.updateProjectionMatrix(); + } + + private animate() { + if (!this.running) return; + requestAnimationFrame(() => this.animate()); + this.sceneBundle.controls.update(); + this.renderer.render(this.sceneBundle.scene, this.sceneBundle.camera); } - public runMain() { - this.wasmInstance.run_main_isobands(this.paths[0]!); - this.wasmInstance.FS.unlink(this.paths[0]); + public dispose() { + this.running = false; + for (const fn of this.cleanups) fn(); + this.cleanups = []; + this.sceneBundle.controls.dispose(); + this.baseMesh.geometry.dispose(); + (this.baseMesh.material as THREE.Material).dispose(); + this.isobandsMesh.geometry.dispose(); + (this.isobandsMesh.material as THREE.Material).dispose(); + this.curveRenderer.dispose(); + this.renderer.dispose(); + this.renderer.domElement.remove(); + if (this.scalarsND) { this.scalarsND.delete(); this.scalarsND = null; } + if (this.normalVec) { this.normalVec.delete(); this.normalVec = null; } + this.mesh.delete(); } } diff --git a/docs/app/examples/PositioningExample.ts b/docs/app/examples/PositioningExample.ts deleted file mode 100644 index 30767fb..0000000 --- a/docs/app/examples/PositioningExample.ts +++ /dev/null @@ -1,299 +0,0 @@ -import type { MainModule } from "@/examples/native"; -import { ThreejsBase } from "@/examples/ThreejsBase"; -import * as THREE from "three"; - -export class PositioningExample extends ThreejsBase { - private keyPressed = false; - - // Closest points visualization - private closestPointsGroup!: THREE.Group; - private sphere1!: THREE.Mesh; - private sphere2!: THREE.Mesh; - private connector!: THREE.Mesh; - - constructor( - wasmInstance: MainModule, - paths: string[], - container: HTMLElement, - isDarkMode = true, - ) { - // skipUpdate = true so we can set up camera before fitting - super(wasmInstance, paths, container, undefined, true, false, isDarkMode); - this.updateMeshes(); - - const interceptKeyDownEvent = (event: KeyboardEvent) => { - if (this.keyPressed) return; - this.keyPressed = true; - this.wasmInstance.OnKeyPress(event.key); - this.updateMeshes(); - }; - const interceptKeyUpEvent = (_event: KeyboardEvent) => { - this.keyPressed = false; - }; - window.addEventListener("keydown", interceptKeyDownEvent); - window.addEventListener("keyup", interceptKeyUpEvent); - - this.setupClosestPointsVisuals(); - this.positionMeshesForScreen(container); - this.setupOrthographicCamera(container); - - // Recompute closest points after repositioning and show - this.wasmInstance.positioning_compute_closest_points?.(); - this.updateMeshes(); - } - - private setupOrthographicCamera(container: HTMLElement): void { - const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000); - - // Replace camera in scene bundle - (this.sceneBundle1 as unknown as { camera: THREE.Camera }).camera = orthoCamera; - this.sceneBundle1.controls.setCamera(orthoCamera); - - this.fitOrthographicCamera(container); - } - - private positionMeshesForScreen(container: HTMLElement): void { - const rect = container.getBoundingClientRect(); - const isLandscape = rect.width > rect.height; - const aabbDiag = this.wasmInstance.positioning_get_aabb_diagonal?.() ?? 10; - - // Spacing between meshes - const spacing = isLandscape ? aabbDiag * 1.2 : aabbDiag * 1.0; - - // Camera on Z axis looking at XY plane: X = screen horizontal, Y = screen vertical - // Landscape: side by side (X axis) - // Portrait: stacked (Y axis) - const offsets: [number, number, number][] = isLandscape - ? [[-spacing / 2, 0, 0], [spacing / 2, 0, 0]] - : [[0, -spacing / 2, 0], [0, spacing / 2, 0]]; - - for (let i = 0; i < 2; i++) { - const [ox, oy, oz] = offsets[i]!; - const m = new THREE.Matrix4().makeTranslation(ox, oy, oz); - m.transpose(); - - const arr = m.toArray() as [ - number, number, number, number, - number, number, number, number, - number, number, number, number, - number, number, number, number - ]; - this.wasmInstance.positioning_set_instance_matrix?.(i, arr); - } - this.updateMeshes(); - } - - private fitOrthographicCamera(container: HTMLElement): void { - const rect = container.getBoundingClientRect(); - const aspect = rect.width / rect.height; - const isLandscape = rect.width > rect.height; - const camera = this.sceneBundle1.camera as unknown as THREE.OrthographicCamera; - - // Get bounding box of all instances - const positions: THREE.Vector3[] = []; - const aabbDiag = this.wasmInstance.positioning_get_aabb_diagonal?.() ?? 10; - - for (let i = 0; i < this.wasmInstance.get_number_of_instances(); i++) { - const inst = this.wasmInstance.get_instance_on_idx(i); - if (!inst) continue; - const matrix = new Float32Array(inst.get_matrix()); - const m = new THREE.Matrix4().fromArray(matrix).transpose(); - const pos = new THREE.Vector3(); - pos.setFromMatrixPosition(m); - positions.push(pos); - } - - // Compute center and extent - const center = new THREE.Vector3(); - if (positions.length >= 2) { - center.addVectors(positions[0]!, positions[1]!).multiplyScalar(0.5); - } - - const separation = positions.length >= 2 ? positions[0]!.distanceTo(positions[1]!) : 0; - // Different zoom for landscape vs portrait - const zoomFactor = isLandscape ? 0.5 : 0.7; - const extent = (separation + aabbDiag) * zoomFactor; - - // Set orthographic frustum - camera.left = -extent * aspect; - camera.right = extent * aspect; - camera.top = extent; - camera.bottom = -extent; - camera.updateProjectionMatrix(); - - // Position camera - camera.position.set(center.x, center.y, center.z + aabbDiag * 3); - camera.lookAt(center); - - this.sceneBundle1.controls.target.copy(center); - this.sceneBundle1.controls.update(); - } - - private setupClosestPointsVisuals(): void { - this.closestPointsGroup = new THREE.Group(); - this.closestPointsGroup.name = "closest_points"; - - // Sphere geometry and materials - const sphereGeom = new THREE.SphereGeometry(1, 16, 12); - const sphereMat = new THREE.MeshStandardMaterial({ - color: 0x00d5be, // Bright teal (like cross-section boundary) - roughness: 0.3, - metalness: 0.1, - }); - - // Cylinder geometry and material - const cylGeom = new THREE.CylinderGeometry(1, 1, 1, 8); - const cylMat = new THREE.MeshStandardMaterial({ - color: 0x00a89a, // Darker teal (like cross-section fill) - roughness: 0.3, - metalness: 0.1, - }); - - this.sphere1 = new THREE.Mesh(sphereGeom, sphereMat); - this.sphere2 = new THREE.Mesh(sphereGeom.clone(), sphereMat.clone()); - this.connector = new THREE.Mesh(cylGeom, cylMat); - - this.closestPointsGroup.add(this.sphere1); - this.closestPointsGroup.add(this.sphere2); - this.closestPointsGroup.add(this.connector); - - this.closestPointsGroup.visible = false; - this.sceneBundle1.scene.add(this.closestPointsGroup); - } - - private updateClosestPointsVisuals(): void { - if (!this.closestPointsGroup) return; - - const hasPoints = this.wasmInstance.positioning_has_closest_points?.(); - if (!hasPoints) { - this.closestPointsGroup.visible = false; - return; - } - - const pts = this.wasmInstance.positioning_get_closest_points?.() as number[] | undefined; - if (!pts || pts.length < 6) { - this.closestPointsGroup.visible = false; - return; - } - - const p0 = new THREE.Vector3(pts[0], pts[1], pts[2]); - const p1 = new THREE.Vector3(pts[3], pts[4], pts[5]); - const dist = p0.distanceTo(p1); - - // Fixed size based on AABB diagonal (1.5% for spheres, 0.75% for cylinder) - const aabbDiag = this.wasmInstance.positioning_get_aabb_diagonal?.() ?? 10; - const sphereRadius = aabbDiag * 0.015; - const cylRadius = aabbDiag * 0.0075; - - // Position and scale spheres - this.sphere1.position.copy(p0); - this.sphere1.scale.setScalar(sphereRadius); - - this.sphere2.position.copy(p1); - this.sphere2.scale.setScalar(sphereRadius); - - // Position and orient cylinder - if (dist > 0.001) { - const mid = new THREE.Vector3().addVectors(p0, p1).multiplyScalar(0.5); - this.connector.position.copy(mid); - - // Orient cylinder to point from p0 to p1 - const direction = new THREE.Vector3().subVectors(p1, p0).normalize(); - const quaternion = new THREE.Quaternion(); - quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction); - this.connector.quaternion.copy(quaternion); - - // Scale: radius for X/Z, length for Y - this.connector.scale.set(cylRadius, dist, cylRadius); - this.connector.visible = true; - } else { - this.connector.visible = false; - } - - this.closestPointsGroup.visible = true; - } - - public override updateMeshes(): void { - super.updateMeshes(); - this.updateClosestPointsVisuals(); - this.refreshTimeValue?.(); - } - - public override getAverageTime(): number { - return this.wasmInstance.get_average_time(); - } - - public randomize() { - this.wasmInstance.OnKeyPress("n"); - this.updateMeshes(); - } - - public override onPointerUp(event: PointerEvent) { - const cameraPosition = this.sceneBundle1.camera.position.clone(); - const dir = new THREE.Vector3(); - this.sceneBundle1.camera.getWorldDirection(dir); - const cameraFocalPoint = cameraPosition.clone().add(dir.multiplyScalar(100)); - - const updateFocalPoint = (x: number, y: number, z: number) => { - this.sceneBundle1.controls.target.set(x, y, z); - this.sceneBundle1.controls.update(); - - // Update instanced mesh matrices - const tempMatrix = new THREE.Matrix4(); - const needsUpdate = new Set(); - - for (let i = 0; i < this.wasmInstance.get_number_of_instances(); i++) { - const inst = this.wasmInstance.get_instance_on_idx(i); - const indices = this.instanceIndices.get(i); - if (!inst || !indices) continue; - - const { meshDataId, indexInBatch } = indices; - const instancedMesh = this.instancedMeshes.get(meshDataId); - if (!instancedMesh) continue; - - const matrix = new Float32Array(inst.get_matrix()); - tempMatrix.fromArray(matrix); - tempMatrix.transpose(); - instancedMesh.setMatrixAt(indexInBatch, tempMatrix); - needsUpdate.add(meshDataId); - } - - for (const meshDataId of needsUpdate) { - const mesh = this.instancedMeshes.get(meshDataId); - if (mesh) mesh.instanceMatrix.needsUpdate = true; - } - - this.sceneBundle1.scene.updateMatrixWorld(true); - this.renderer.render(this.sceneBundle1.scene, this.sceneBundle1.camera); - }; - - let t = 0; - const stepPositioning = () => { - if (this.isDisposed()) return; - t = this.wasmInstance.OnLeftButtonUpCustom( - [cameraFocalPoint.x, cameraFocalPoint.y, cameraFocalPoint.z], - updateFocalPoint, - t, - ); - if (t < 1.0) { - requestAnimationFrame(stepPositioning); - } - this.updateMeshes(); - if (t < 1) { - event.stopPropagation(); - } - }; - requestAnimationFrame(stepPositioning); - } - - public runMain() { - const v = new this.wasmInstance.VectorString(); - for (const path of this.paths) { - v.push_back(path); - } - this.wasmInstance.run_main_positioning(v); - for (const path of this.paths) { - this.wasmInstance.FS.unlink(path); - } - } -} diff --git a/docs/app/pages/[...slug].vue b/docs/app/pages/[...slug].vue index 4c5fe32..b6b4859 100644 --- a/docs/app/pages/[...slug].vue +++ b/docs/app/pages/[...slug].vue @@ -31,7 +31,7 @@ const { data: surround } = await useAsyncData( const title = page.value.seo?.title || page.value.title; const description = page.value.seo?.description || page.value.description; -const headline = computed(() => `${findPageHeadline(navigation?.value, page.value?.path)} | ${library.value === "cpp" ? "C++" : "PY"}`); +const headline = computed(() => `${findPageHeadline(navigation?.value, page.value?.path)} | ${library.value === "cpp" ? "C++" : library.value === "py" ? "PY" : "TS"}`); // Generate OG image using nuxt-og-image with headline defineOgImageComponent("Docs", { diff --git a/docs/app/pages/live-examples/alignment.vue b/docs/app/pages/live-examples/alignment.vue index 7fd2206..3b05ed9 100644 --- a/docs/app/pages/live-examples/alignment.vue +++ b/docs/app/pages/live-examples/alignment.vue @@ -1,5 +1,5 @@