From 64bd712aad5016a47da4acd6ef2d808a390b4392 Mon Sep 17 00:00:00 2001 From: ZigaSajovic Date: Sat, 7 Feb 2026 13:32:12 +0100 Subject: [PATCH 1/3] ICP mesh registration, Taubin smoothing, interactive mesh registration demo Closes #9 - fit_icp_alignment with point-to-point and point-to-plane modes - configurable convergence (max_iterations, min_relative_improvement) - correct transform composition for all source/target combinations - Taubin smoothing for mesh fairing - interactive WASM alignment example with drag-to-misalign - C++, Python, and VTK bindings with full documentation --- .github/workflows/build-python.yml | 2 +- CMakeLists.txt | 2 +- docs/app/components/TryItBanner.vue | 2 +- docs/app/examples/AlignmentExample.ts | 269 ++ docs/app/examples/PositioningExample.ts | 200 +- docs/app/examples/ThreejsBase.ts | 11 +- docs/app/pages/live-examples/alignment.vue | 173 + .../pages/live-examples/closest-points.vue | 18 +- docs/app/utils/liveExamples.ts | 7 +- .../cpp/1.getting-started/3.live-examples.md | 4 +- docs/content/cpp/2.modules/4.geometry.md | 250 +- docs/content/cpp/4.vtk/3.functions.md | 122 +- docs/content/cpp/4.vtk/5.examples.md | 35 + docs/content/cpp/5.examples/0.index.md | 2 +- docs/content/cpp/5.examples/2.alignment.md | 133 +- docs/content/index.md | 18 +- .../py/1.getting-started/3.live-examples.md | 4 +- docs/content/py/2.modules/4.geometry.md | 235 +- docs/content/py/5.examples/0.index.md | 4 +- .../py/5.examples/3.vtk-integration.md | 57 + docs/package.json | 26 +- docs/pnpm-lock.yaml | 4077 +++++++++-------- examples/CMakeLists.txt | 6 +- examples/src/alignment.cpp | 238 +- include/trueform/core/frame.hpp | 12 + include/trueform/core/frame_of.hpp | 302 +- include/trueform/core/orthogonalize.hpp | 90 + include/trueform/core/policy/frame.hpp | 229 +- include/trueform/core/views.hpp | 2 + .../core/views/cyclic_sequence_range.hpp | 91 + include/trueform/geometry.hpp | 60 +- include/trueform/geometry/chamfer_error.hpp | 99 +- .../trueform/geometry/fit_icp_alignment.hpp | 149 + .../trueform/geometry/fit_knn_alignment.hpp | 61 +- .../trueform/geometry/fit_obb_alignment.hpp | 4 + include/trueform/geometry/icp_config.hpp | 44 + include/trueform/geometry/icp_state.hpp | 38 + .../impl/fit_knn_alignment_point_to_plane.hpp | 177 +- .../impl/fit_knn_alignment_point_to_point.hpp | 192 +- .../fit_rigid_alignment_point_to_plane.hpp | 30 +- .../fit_rigid_alignment_point_to_point.hpp | 14 - .../geometry/knn_alignment_config.hpp | 41 + .../trueform/geometry/knn_alignment_state.hpp | 80 + include/trueform/geometry/taubin_smoothed.hpp | 81 + python/examples/alignment.py | 490 +- python/examples/vtk/alignment.py | 427 ++ python/include/trueform/python/geometry.hpp | 4 + .../python/geometry/fit_icp_alignment.hpp | 173 + .../python/geometry/fit_knn_alignment.hpp | 119 +- .../python/geometry/fit_rigid_alignment.hpp | 89 + .../python/geometry/taubin_smoothed.hpp | 42 + python/src/geometry.cpp | 2 + python/src/geometry/fit_icp_alignment.cpp | 217 + python/src/geometry/fit_knn_alignment.cpp | 117 +- python/src/geometry/fit_rigid_alignment.cpp | 71 + python/src/geometry/taubin_smoothed.cpp | 82 + python/src/trueform/__init__.py | 6 +- python/src/trueform/_dispatch/__init__.py | 2 + .../trueform/_dispatch/ensure_point_cloud.py | 68 + python/src/trueform/_geometry/__init__.py | 4 + .../src/trueform/_geometry/chamfer_error.py | 50 +- .../trueform/_geometry/fit_icp_alignment.py | 149 + .../trueform/_geometry/fit_knn_alignment.py | 99 +- .../trueform/_geometry/fit_obb_alignment.py | 25 +- .../trueform/_geometry/fit_rigid_alignment.py | 99 +- .../src/trueform/_geometry/taubin_smoothed.py | 123 + python/tests/test_chamfer_error.py | 2 +- python/tests/test_fit_icp_alignment.py | 771 ++++ tests/geometry/test_alignment.cpp | 1195 ++++- vtk/CMakeLists.txt | 3 + vtk/examples/CMakeLists.txt | 2 + vtk/examples/alignment.cpp | 260 ++ vtk/examples/laplacian_smoothing.cpp | 5 - vtk/include/trueform/vtk/functions.hpp | 3 + .../vtk/functions/fit_icp_alignment.hpp | 78 + .../vtk/functions/fit_knn_alignment.hpp | 28 +- .../vtk/functions/fit_rigid_alignment.hpp | 3 + .../vtk/functions/laplacian_smoothed.hpp | 39 + .../vtk/functions/taubin_smoothed.hpp | 48 + vtk/src/functions/fit_icp_alignment.cpp | 136 + vtk/src/functions/fit_knn_alignment.cpp | 109 +- vtk/src/functions/fit_rigid_alignment.cpp | 82 +- vtk/src/functions/laplacian_smoothed.cpp | 34 + vtk/src/functions/taubin_smoothed.cpp | 34 + wasm-examples/src/alignment_web.h | 239 + wasm-examples/src/main.cpp | 106 + wasm-examples/src/positioning_web.h | 88 + wasm-examples/src/utils/bridge_web.h | 4 + .../src/utils/cursor_interactor_interface.h | 4 + 89 files changed, 10371 insertions(+), 3051 deletions(-) create mode 100644 docs/app/examples/AlignmentExample.ts create mode 100644 docs/app/pages/live-examples/alignment.vue create mode 100644 include/trueform/core/orthogonalize.hpp create mode 100644 include/trueform/core/views/cyclic_sequence_range.hpp create mode 100644 include/trueform/geometry/fit_icp_alignment.hpp create mode 100644 include/trueform/geometry/icp_config.hpp create mode 100644 include/trueform/geometry/icp_state.hpp create mode 100644 include/trueform/geometry/knn_alignment_config.hpp create mode 100644 include/trueform/geometry/knn_alignment_state.hpp create mode 100644 include/trueform/geometry/taubin_smoothed.hpp create mode 100644 python/examples/vtk/alignment.py create mode 100644 python/include/trueform/python/geometry/fit_icp_alignment.hpp create mode 100644 python/include/trueform/python/geometry/taubin_smoothed.hpp create mode 100644 python/src/geometry/fit_icp_alignment.cpp create mode 100644 python/src/geometry/taubin_smoothed.cpp create mode 100644 python/src/trueform/_dispatch/ensure_point_cloud.py create mode 100644 python/src/trueform/_geometry/fit_icp_alignment.py create mode 100644 python/src/trueform/_geometry/taubin_smoothed.py create mode 100644 python/tests/test_fit_icp_alignment.py create mode 100644 vtk/examples/alignment.cpp create mode 100644 vtk/include/trueform/vtk/functions/fit_icp_alignment.hpp create mode 100644 vtk/include/trueform/vtk/functions/laplacian_smoothed.hpp create mode 100644 vtk/include/trueform/vtk/functions/taubin_smoothed.hpp create mode 100644 vtk/src/functions/fit_icp_alignment.cpp create mode 100644 vtk/src/functions/laplacian_smoothed.cpp create mode 100644 vtk/src/functions/taubin_smoothed.cpp create mode 100644 wasm-examples/src/alignment_web.h diff --git a/.github/workflows/build-python.yml b/.github/workflows/build-python.yml index b108d46..893212d 100644 --- a/.github/workflows/build-python.yml +++ b/.github/workflows/build-python.yml @@ -1,4 +1,4 @@ -name: PyPI +name: Build Py env: BLENDER_VERSION: "5.0" diff --git a/CMakeLists.txt b/CMakeLists.txt index 2514656..bc79210 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ endif() # ============================================================================== # Project Configuration # ============================================================================== -project(trueform VERSION 0.4.0 LANGUAGES CXX) +project(trueform VERSION 0.5.0 LANGUAGES CXX) configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.hpp.in diff --git a/docs/app/components/TryItBanner.vue b/docs/app/components/TryItBanner.vue index fab72f4..6a749dc 100644 --- a/docs/app/components/TryItBanner.vue +++ b/docs/app/components/TryItBanner.vue @@ -8,7 +8,7 @@ const props = withDefaults( { to: "/live-examples/boolean", title: "Try it in your browser", - description: "Interactive mesh booleans, collisions, isobands and more. No install needed.", + description: "Interactive mesh booleans, registration, isobands, and more. No install needed.", }, ); diff --git a/docs/app/examples/AlignmentExample.ts b/docs/app/examples/AlignmentExample.ts new file mode 100644 index 0000000..cc372af --- /dev/null +++ b/docs/app/examples/AlignmentExample.ts @@ -0,0 +1,269 @@ +import type { MainModule } from "@/examples/native"; +import { ThreejsBase } from "@/examples/ThreejsBase"; +import { fitCameraToAllMeshesFromZPlane } from "@/utils/sceneUtils"; +import * as THREE from "three"; + +export type InteractionMode = "move" | "rotate"; + +export class AlignmentExample extends ThreejsBase { + private alignmentTime = 0; + private interactionMode: InteractionMode = "move"; + + // Rotation state + private isRotating = false; + private lastMouseX = 0; + private lastMouseY = 0; + + constructor( + wasmInstance: MainModule, + paths: string[], + 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(); + } + + 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 any).camera = orthoCamera; + this.sceneBundle1.controls.setCamera(orthoCamera); + + // Now fit the orthographic camera to all meshes + this.fitOrthographicCamera(container); + } + + 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 positions from both instances to compute bounding box + 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); + 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 + diag) * 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 + diag * 3); + camera.lookAt(center); + + this.sceneBundle1.controls.target.copy(center); + this.sceneBundle1.controls.update(); + } + + private positionMeshesForScreen(container: HTMLElement): void { + const rect = container.getBoundingClientRect(); + const isLandscape = rect.width > rect.height; + const diag = this.wasmInstance.alignment_get_aabb_diagonal() ?? 1; + + // 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(); + } + + public setMode(mode: InteractionMode): void { + this.interactionMode = mode; + } + + public getMode(): InteractionMode { + return this.interactionMode; + } + + // 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); + } + } + + 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; + } else { + return super.onPointerMove(event, touchHover); + } + } + + public override onPointerUp(event: PointerEvent): void { + if (this.isRotating) { + this.isRotating = false; + this.sceneBundle1.controls.enabled = true; + event.stopPropagation(); + } else { + super.onPointerUp(event); + } + } + + 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); + } + } + + public align(): number { + this.alignmentTime = this.wasmInstance.alignment_run_align(); + this.updateMeshes(); + return this.alignmentTime; + } + + public isAligned(): boolean { + return this.wasmInstance.alignment_is_aligned(); + } + + public getAlignmentTime(): number { + return this.alignmentTime; + } +} diff --git a/docs/app/examples/PositioningExample.ts b/docs/app/examples/PositioningExample.ts index 21030af..87331cb 100644 --- a/docs/app/examples/PositioningExample.ts +++ b/docs/app/examples/PositioningExample.ts @@ -1,17 +1,25 @@ import type { MainModule } from "@/examples/native"; -import { fitCameraToAllMeshesFromZPlane } from "@/utils/sceneUtils"; 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, ) { - super(wasmInstance, paths, container, undefined, false, false, isDarkMode); + // 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; @@ -25,7 +33,193 @@ export class PositioningExample extends ThreejsBase { window.addEventListener("keydown", interceptKeyDownEvent); window.addEventListener("keyup", interceptKeyUpEvent); - fitCameraToAllMeshesFromZPlane(this.sceneBundle1, 1.5); + this.setupClosestPointsVisuals(); + this.positionMeshesForScreen(container); + this.setupOrthographicCamera(container); + + // Update to show initial visualization + 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() { diff --git a/docs/app/examples/ThreejsBase.ts b/docs/app/examples/ThreejsBase.ts index 22996a8..ced7475 100644 --- a/docs/app/examples/ThreejsBase.ts +++ b/docs/app/examples/ThreejsBase.ts @@ -401,7 +401,7 @@ export abstract class ThreejsBase implements IThreejsBase { indexCounters.set(meshDataId, 0); } - // Build instanceIndices map + // Build instanceIndices map and handle per-mesh opacity for (let i = 0; i < numInstances; i++) { const inst = this.wasmInstance.get_instance_on_idx(i); if (!inst) continue; @@ -409,6 +409,15 @@ export abstract class ThreejsBase implements IThreejsBase { const indexInBatch = indexCounters.get(meshDataId) ?? 0; this.instanceIndices.set(i, { meshDataId, indexInBatch }); indexCounters.set(meshDataId, indexInBatch + 1); + + // Handle opacity - if single instance per mesh, set material opacity + const instancedMesh = this.instancedMeshes.get(meshDataId); + if (instancedMesh && countPerMeshData.get(meshDataId) === 1 && inst.opacity < 1.0) { + const material = instancedMesh.material as THREE.MeshMatcapMaterial; + material.transparent = true; + material.opacity = inst.opacity; + material.needsUpdate = true; + } } } diff --git a/docs/app/pages/live-examples/alignment.vue b/docs/app/pages/live-examples/alignment.vue new file mode 100644 index 0000000..7fd2206 --- /dev/null +++ b/docs/app/pages/live-examples/alignment.vue @@ -0,0 +1,173 @@ + + + diff --git a/docs/app/pages/live-examples/closest-points.vue b/docs/app/pages/live-examples/closest-points.vue index 37c54bd..101468d 100644 --- a/docs/app/pages/live-examples/closest-points.vue +++ b/docs/app/pages/live-examples/closest-points.vue @@ -31,7 +31,17 @@ const meshCount = 2; const meshes = computed(() => buildMeshes(meshCount)); const polygonLabel = computed(() => formatPolygonLabel(meshCount)); +const avgTime = ref("0"); +const updateAvgTime = () => { + if (exampleClass) { + avgTime.value = exampleClass.getAverageTime().toFixed(2); + } +}; + const badge = computed(() => ({ + icon: "i-lucide-gauge", + label: "Query:", + value: `${avgTime.value} ms`, polygons: polygonLabel.value, })); @@ -63,12 +73,16 @@ const loadThreejs = async () => { return null; } - return new PositioningExample( + const instance = new PositioningExample( wasmInstance, meshFilenames, el, isDark.value, ); + instance.refreshTimeValue = updateAvgTime; + // Update time after initial load + nextTick(() => updateAvgTime()); + return instance; }, }); }; @@ -101,7 +115,7 @@ watch(isDark, (dark) => {