From 5aae750570923789cea905342f5a3480e27b7d07 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 10:40:47 -0400 Subject: [PATCH 01/19] feat: add visible-only scene exports --- apps/editor/components/scene-loader.tsx | 18 +++++- .../src/components/editor/export-manager.tsx | 11 ++-- .../sidebar/panels/settings-panel/index.tsx | 16 ++++- packages/editor/src/lib/glb-export.test.ts | 62 +++++++++++++++++++ packages/editor/src/lib/glb-export.ts | 60 +++++++++++++++--- packages/viewer/src/index.ts | 3 + packages/viewer/src/store/use-viewer.d.ts | 12 +++- packages/viewer/src/store/use-viewer.ts | 12 +++- 8 files changed, 175 insertions(+), 19 deletions(-) diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 00fd99bd04..d7538ead71 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -9,7 +9,7 @@ import { type SceneGraph, type SidebarTab, } from '@pascal-app/editor' -import { Hammer, Layers } from 'lucide-react' +import { Hammer, Layers, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' @@ -66,6 +66,22 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ /> ), }, + { + id: 'settings', + label: 'Settings', + component: () => null, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, ] interface SceneLoaderProps { diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 1368bcbeb5..aa995447b8 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -1,7 +1,7 @@ 'use client' import { emitter, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { type SceneExportFormat, type SceneExportOptions, useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect } from 'react' import * as THREE from 'three' @@ -36,7 +36,10 @@ export function ExportManager() { const setExportScene = useViewer((state) => state.setExportScene) useEffect(() => { - const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => { + const exportFn = async ( + format: SceneExportFormat = 'glb', + options: SceneExportOptions = {}, + ) => { // Find the scene renderer group by name const sceneGroup = scene.getObjectByName('scene-renderer') if (!sceneGroup) { @@ -55,7 +58,7 @@ export function ExportManager() { await nextFrames() if (format === 'glb') { - const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, options) const blob = new Blob([buffer], { type: 'model/gltf-binary' }) downloadBlob(blob, `model_${date}.glb`) return @@ -68,7 +71,7 @@ export function ExportManager() { emitter.emit('thumbnail:before-capture', undefined) let prepared: ReturnType try { - prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes) + prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes, options) } finally { emitter.emit('thumbnail:after-capture', undefined) } diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index e1ad6a8c63..7098627b80 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -195,6 +195,7 @@ export function SettingsPanel({ const setPhase = useEditor((state) => state.setPhase) const floorplanMode = useFloorplanMode((state) => state.mode) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) + const [exportOnlyVisible, setExportOnlyVisible] = useState(true) const [pendingImport, setPendingImport] = useState(null) const sceneGraphValue = useMemo( () => buildSceneGraphValue(nodes as Record, rootNodeIds), @@ -374,9 +375,18 @@ export function SettingsPanel({
3D model
+
+
+
Visible nodes only
+
+ Exclude hidden furniture and other hidden scene nodes +
+
+ +
+ +
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts new file mode 100644 index 0000000000..98f2914b5c --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test' +import type { SceneExport, SceneExportOptions } from '@pascal-app/viewer' +import type { PrintExportReport } from '../../../../../lib/print-export' +import { preparePrintExport } from './print-export-card' + +const report: PrintExportReport = { + kind: 'print-stl-report', + version: 1, + scale: 50, + units: 'millimeter', + orientation: 'z-up', + status: 'pass', + bounds: { + min: { x: -50, y: -30, z: 0 }, + max: { x: 50, y: 30, z: 40 }, + width: 100, + depth: 60, + height: 40, + }, + triangleCount: 12, + invalidTriangleCount: 0, + degenerateTriangleCount: 0, + boundaryEdgeCount: 0, + nonManifoldEdgeCount: 0, + volumeMm3: 240_000, + diagnostics: [], +} + +describe('print export card contract', () => { + test('prepares a visible-only scaled artifact without downloading immediately', async () => { + const calls: { format?: string; options?: SceneExportOptions }[] = [] + const artifact = { blob: new Blob(['stl']), filename: 'house.stl', metadata: report } + const exportScene: SceneExport = async (format, options) => { + calls.push({ format, options }) + return artifact + } + + const prepared = await preparePrintExport(exportScene, true, '50') + + expect(calls).toEqual([ + { + format: 'print-stl', + options: { onlyVisible: true, download: false, printScale: 50 }, + }, + ]) + expect(prepared).toEqual({ artifact, report }) + }) + + test('rejects invalid scale input before invoking the exporter', async () => { + let invoked = false + const exportScene: SceneExport = async () => { + invoked = true + return null + } + + await expect(preparePrintExport(exportScene, true, '0')).rejects.toThrow( + 'Enter a positive scale denominator', + ) + expect(invoked).toBe(false) + }) + + test('rejects an artifact without the print preflight contract', async () => { + const exportScene: SceneExport = async () => ({ + blob: new Blob(['stl']), + filename: 'house.stl', + }) + + await expect(preparePrintExport(exportScene, false, '100')).rejects.toThrow( + 'did not return a preflight report', + ) + }) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx new file mode 100644 index 0000000000..bc276eb599 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx @@ -0,0 +1,201 @@ +import { useScene } from '@pascal-app/core' +import { + type SceneExport, + type SceneExportArtifact, + useViewer, +} from '@pascal-app/viewer' +import { AlertTriangle, CheckCircle2, Download, Printer, XCircle } from 'lucide-react' +import { useEffect, useId, useState } from 'react' +import { Button } from '../../../../../components/ui/primitives/button' +import { + isPrintExportReport, + type PrintExportReport, +} from '../../../../../lib/print-export' + +export type PreparedPrintExport = { + artifact: SceneExportArtifact + report: PrintExportReport +} + +function formatMillimeters(value: number): string { + if (value >= 100) return value.toFixed(1) + if (value >= 10) return value.toFixed(2) + return value.toFixed(3) +} + +function downloadArtifact(artifact: SceneExportArtifact) { + const url = URL.createObjectURL(artifact.blob) + const link = document.createElement('a') + link.href = url + link.download = artifact.filename + link.click() + URL.revokeObjectURL(url) +} + +export async function preparePrintExport( + exportScene: SceneExport, + onlyVisible: boolean, + scaleInput: string, +): Promise { + const scale = Number(scaleInput) + if (!Number.isFinite(scale) || scale <= 0) { + throw new RangeError('Enter a positive scale denominator, such as 25, 50, or 100.') + } + + const artifact = await exportScene('print-stl', { + onlyVisible, + download: false, + printScale: scale, + }) + if (!artifact || !isPrintExportReport(artifact.metadata)) { + throw new Error('The print exporter did not return a preflight report.') + } + return { artifact, report: artifact.metadata } +} + +export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { + const scaleInputId = useId() + const nodes = useScene((state) => state.nodes) + const exportScene = useViewer((state) => state.exportScene) + const [printScale, setPrintScale] = useState('100') + const [isPreparing, setIsPreparing] = useState(false) + const [prepared, setPrepared] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + setPrepared(null) + setError(null) + }, [nodes, onlyVisible, printScale]) + + const handlePrepare = async () => { + if (!exportScene) { + setError('The 3D exporter is still loading.') + return + } + + setIsPreparing(true) + setPrepared(null) + setError(null) + try { + setPrepared(await preparePrintExport(exportScene, onlyVisible, printScale)) + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Print export failed.') + } finally { + setIsPreparing(false) + } + } + + return ( +
+
+ +
+
Print STL
+
+ Experimental millimeter, Z-up export centered on the print bed +
+
+
+ + + + + + {error && ( +
+ + {error} +
+ )} + + {prepared && ( +
+
+ {prepared.report.status === 'blocked' ? ( + + ) : prepared.report.status === 'warning' ? ( + + ) : ( + + )} + + {prepared.report.status === 'blocked' + ? 'Basic preflight blocked this download.' + : prepared.report.status === 'warning' + ? 'Prepared with printability warnings.' + : 'Basic surface checks passed.'} + +
+ +
+
Physical size
+
+ {prepared.report.bounds + ? `${formatMillimeters(prepared.report.bounds.width)} × ${formatMillimeters( + prepared.report.bounds.depth, + )} × ${formatMillimeters(prepared.report.bounds.height)} mm` + : '—'} +
+
Triangles
+
+ {prepared.report.triangleCount.toLocaleString()} +
+
Boundary edges
+
+ {prepared.report.boundaryEdgeCount?.toLocaleString() ?? 'Not checked'} +
+
Non-manifold edges
+
+ {prepared.report.nonManifoldEdgeCount?.toLocaleString() ?? 'Not checked'} +
+
+ +
    + {prepared.report.diagnostics.map((diagnostic) => ( +
  • · {diagnostic.message}
  • + ))} +
+ + +
+ )} +
+ ) +} diff --git a/packages/editor/src/lib/print-export.test.ts b/packages/editor/src/lib/print-export.test.ts new file mode 100644 index 0000000000..64eba1e68f --- /dev/null +++ b/packages/editor/src/lib/print-export.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNode, sceneRegistry } from '@pascal-app/core' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { exportSceneToPrintStl, prepareSceneForPrint } from './print-export' + +function binaryStlBounds(buffer: ArrayBuffer): { triangles: number; bounds: THREE.Box3 } { + const view = new DataView(buffer) + const triangles = view.getUint32(80, true) + const bounds = new THREE.Box3() + const point = new THREE.Vector3() + let offset = 84 + + for (let triangle = 0; triangle < triangles; triangle += 1) { + offset += 12 + for (let vertex = 0; vertex < 3; vertex += 1) { + point.set( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ) + bounds.expandByPoint(point) + offset += 12 + } + offset += 2 + } + + return { triangles, bounds } +} + +describe('print STL export', () => { + afterEach(() => { + sceneRegistry.nodes.clear() + }) + + test('writes millimeter-scaled Z-up geometry centered on the print bed', () => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(10, 4, 6)) + mesh.position.set(5, 2, -7) + + const { buffer, report } = exportSceneToPrintStl(mesh, { scale: 100 }) + const parsed = binaryStlBounds(buffer) + const size = parsed.bounds.getSize(new THREE.Vector3()) + + expect(parsed.triangles).toBe(12) + expect(size.x).toBeCloseTo(100, 4) + expect(size.y).toBeCloseTo(60, 4) + expect(size.z).toBeCloseTo(40, 4) + expect(parsed.bounds.min.x).toBeCloseTo(-50, 4) + expect(parsed.bounds.max.x).toBeCloseTo(50, 4) + expect(parsed.bounds.min.y).toBeCloseTo(-30, 4) + expect(parsed.bounds.max.y).toBeCloseTo(30, 4) + expect(parsed.bounds.min.z).toBeCloseTo(0, 4) + expect(parsed.bounds.max.z).toBeCloseTo(40, 4) + + expect(report.status).toBe('pass') + expect(report.bounds?.width).toBeCloseTo(100, 4) + expect(report.bounds?.depth).toBeCloseTo(60, 4) + expect(report.bounds?.height).toBeCloseTo(40, 4) + expect(report.boundaryEdgeCount).toBe(0) + expect(report.nonManifoldEdgeCount).toBe(0) + expect(report.volumeMm3).toBeCloseTo(240_000, 4) + }) + + test('reports open, zero-volume surface geometry before download', () => { + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 3)) + + const { report } = prepareSceneForPrint(mesh, { scale: 50 }) + + expect(report.status).toBe('warning') + expect(report.boundaryEdgeCount).toBe(4) + expect(report.volumeMm3).toBeCloseTo(0) + expect(report.diagnostics.map((diagnostic) => diagnostic.code)).toEqual( + expect.arrayContaining(['open_boundaries', 'zero_volume', 'compiler_pending']), + ) + }) + + test('omits semantically hidden meshes from the parsed print artifact', () => { + const root = new THREE.Group() + const visibleGroup = new THREE.Group() + const hiddenGroup = new THREE.Group() + visibleGroup.add(new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2))) + hiddenGroup.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))) + root.add(visibleGroup, hiddenGroup) + + const visibleId = 'visible-structure' + const hiddenId = 'hidden-furniture' + sceneRegistry.nodes.set(visibleId, visibleGroup) + sceneRegistry.nodes.set(hiddenId, hiddenGroup) + const nodes = { + [visibleId]: { + object: 'node', + id: visibleId, + type: 'wall', + parentId: null, + visible: true, + } as unknown as AnyNode, + [hiddenId]: { + object: 'node', + id: hiddenId, + type: 'item', + parentId: null, + visible: false, + } as unknown as AnyNode, + } + + const prepared = prepareSceneForExport(root, nodes) + const print = exportSceneToPrintStl(prepared.scene, { scale: 100 }) + + expect(binaryStlBounds(print.buffer).triangles).toBe(12) + expect(print.report.triangleCount).toBe(12) + }) + + test('rejects an invalid architectural scale', () => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + + expect(() => prepareSceneForPrint(mesh, { scale: 0 })).toThrow( + 'Print scale must be a positive finite denominator', + ) + }) +}) diff --git a/packages/editor/src/lib/print-export.ts b/packages/editor/src/lib/print-export.ts new file mode 100644 index 0000000000..ee7e85db45 --- /dev/null +++ b/packages/editor/src/lib/print-export.ts @@ -0,0 +1,365 @@ +import * as THREE from 'three' +import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' + +const MILLIMETERS_PER_METER = 1000 +const EDGE_QUANTIZATION_MM = 0.0001 +const MAX_EDGE_CHECK_TRIANGLES = 500_000 +const DEGENERATE_CROSS_LENGTH_SQ = 1e-12 + +const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry() +EMPTY_POSITION_GEOMETRY.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array(0), 3), +) + +export type PrintExportDiagnostic = { + severity: 'error' | 'warning' | 'info' + code: string + message: string +} + +export type PrintExportBounds = { + min: { x: number; y: number; z: number } + max: { x: number; y: number; z: number } + width: number + depth: number + height: number +} + +export type PrintExportReport = { + kind: 'print-stl-report' + version: 1 + scale: number + units: 'millimeter' + orientation: 'z-up' + status: 'pass' | 'warning' | 'blocked' + bounds: PrintExportBounds | null + triangleCount: number + invalidTriangleCount: number + degenerateTriangleCount: number + boundaryEdgeCount: number | null + nonManifoldEdgeCount: number | null + volumeMm3: number + diagnostics: PrintExportDiagnostic[] +} + +export type PrintStlExport = { + buffer: ArrayBuffer + report: PrintExportReport +} + +type BoundsMeasurement = { + min: THREE.Vector3 + max: THREE.Vector3 +} | null + +function ensureMeshPositions(root: THREE.Object3D) { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh && !mesh.geometry?.getAttribute('position')) { + mesh.geometry = EMPTY_POSITION_GEOMETRY + } + }) +} + +function isFiniteVector(vector: THREE.Vector3): boolean { + return Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z) +} + +function forEachTriangle( + root: THREE.Object3D, + visit: (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3) => void, +) { + root.updateMatrixWorld(true) + + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + + const position = mesh.geometry.getAttribute('position') + if (!position) return + const index = mesh.geometry.index + const skinnedMesh = mesh as THREE.SkinnedMesh + + const readVertex = (vertexIndex: number, target: THREE.Vector3) => { + target.fromBufferAttribute(position, vertexIndex) + if (skinnedMesh.isSkinnedMesh) skinnedMesh.applyBoneTransform(vertexIndex, target) + target.applyMatrix4(mesh.matrixWorld) + } + + const visitIndices = (indexA: number, indexB: number, indexC: number) => { + readVertex(indexA, a) + readVertex(indexB, b) + readVertex(indexC, c) + visit(a, b, c) + } + + if (index) { + for (let offset = 0; offset + 2 < index.count; offset += 3) { + visitIndices(index.getX(offset), index.getX(offset + 1), index.getX(offset + 2)) + } + return + } + + for (let offset = 0; offset + 2 < position.count; offset += 3) { + visitIndices(offset, offset + 1, offset + 2) + } + }) +} + +function measureBounds(root: THREE.Object3D): BoundsMeasurement { + const min = new THREE.Vector3( + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + ) + const max = new THREE.Vector3( + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ) + let hasFiniteTriangle = false + + forEachTriangle(root, (a, b, c) => { + if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) return + min.min(a).min(b).min(c) + max.max(a).max(b).max(c) + hasFiniteTriangle = true + }) + + return hasFiniteTriangle ? { min, max } : null +} + +function pointKey(point: THREE.Vector3): string { + return `${Math.round(point.x / EDGE_QUANTIZATION_MM)},${Math.round( + point.y / EDGE_QUANTIZATION_MM, + )},${Math.round(point.z / EDGE_QUANTIZATION_MM)}` +} + +function addEdge(edges: Map, a: THREE.Vector3, b: THREE.Vector3) { + const keyA = pointKey(a) + const keyB = pointKey(b) + const key = keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}` + edges.set(key, (edges.get(key) ?? 0) + 1) +} + +function analyzePrintScene(root: THREE.Object3D, scale: number): PrintExportReport { + const min = new THREE.Vector3( + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + ) + const max = new THREE.Vector3( + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ) + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const areaCross = new THREE.Vector3() + const volumeCross = new THREE.Vector3() + const edges = new Map() + let edgeCheckComplete = true + let triangleCount = 0 + let invalidTriangleCount = 0 + let degenerateTriangleCount = 0 + let signedVolumeMm3 = 0 + let hasFiniteTriangle = false + + forEachTriangle(root, (a, b, c) => { + triangleCount += 1 + if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) { + invalidTriangleCount += 1 + return + } + + min.min(a).min(b).min(c) + max.max(a).max(b).max(c) + hasFiniteTriangle = true + + ab.subVectors(b, a) + ac.subVectors(c, a) + areaCross.crossVectors(ab, ac) + if (areaCross.lengthSq() <= DEGENERATE_CROSS_LENGTH_SQ) { + degenerateTriangleCount += 1 + } + + volumeCross.crossVectors(b, c) + signedVolumeMm3 += a.dot(volumeCross) / 6 + + if (edgeCheckComplete && triangleCount > MAX_EDGE_CHECK_TRIANGLES) { + edges.clear() + edgeCheckComplete = false + } + if (edgeCheckComplete) { + addEdge(edges, a, b) + addEdge(edges, b, c) + addEdge(edges, c, a) + } + }) + + let boundaryEdgeCount: number | null = null + let nonManifoldEdgeCount: number | null = null + if (edgeCheckComplete) { + boundaryEdgeCount = 0 + nonManifoldEdgeCount = 0 + for (const count of edges.values()) { + if (count === 1) boundaryEdgeCount += 1 + if (count > 2) nonManifoldEdgeCount += 1 + } + } + + const bounds = hasFiniteTriangle + ? { + min: { x: min.x, y: min.y, z: min.z }, + max: { x: max.x, y: max.y, z: max.z }, + width: max.x - min.x, + depth: max.y - min.y, + height: max.z - min.z, + } + : null + + const diagnostics: PrintExportDiagnostic[] = [] + if (triangleCount === 0) { + diagnostics.push({ + severity: 'error', + code: 'no_triangles', + message: 'No printable triangles remain after applying the export scope.', + }) + } + if (invalidTriangleCount > 0) { + diagnostics.push({ + severity: 'error', + code: 'non_finite_geometry', + message: `${invalidTriangleCount.toLocaleString()} triangle${ + invalidTriangleCount === 1 ? '' : 's' + } contain non-finite coordinates.`, + }) + } + if (degenerateTriangleCount > 0) { + diagnostics.push({ + severity: 'warning', + code: 'degenerate_triangles', + message: `${degenerateTriangleCount.toLocaleString()} zero-area or near-zero-area triangle${ + degenerateTriangleCount === 1 ? '' : 's' + } should be repaired.`, + }) + } + if (boundaryEdgeCount && boundaryEdgeCount > 0) { + diagnostics.push({ + severity: 'warning', + code: 'open_boundaries', + message: `${boundaryEdgeCount.toLocaleString()} boundary edge${ + boundaryEdgeCount === 1 ? '' : 's' + } indicate open surfaces.`, + }) + } + if (nonManifoldEdgeCount && nonManifoldEdgeCount > 0) { + diagnostics.push({ + severity: 'warning', + code: 'non_manifold_edges', + message: `${nonManifoldEdgeCount.toLocaleString()} edge${ + nonManifoldEdgeCount === 1 ? '' : 's' + } are shared by more than two triangles.`, + }) + } + if (!edgeCheckComplete) { + diagnostics.push({ + severity: 'warning', + code: 'edge_check_skipped', + message: `Edge checks were skipped above ${MAX_EDGE_CHECK_TRIANGLES.toLocaleString()} triangles.`, + }) + } + if (triangleCount > 0 && Math.abs(signedVolumeMm3) <= 1e-6) { + diagnostics.push({ + severity: 'warning', + code: 'zero_volume', + message: 'The exported surfaces enclose no measurable signed volume.', + }) + } + diagnostics.push({ + severity: 'info', + code: 'compiler_pending', + message: 'Boolean union, shell intersections, and minimum wall thickness are not checked yet.', + }) + + const status = diagnostics.some((diagnostic) => diagnostic.severity === 'error') + ? 'blocked' + : diagnostics.some((diagnostic) => diagnostic.severity === 'warning') + ? 'warning' + : 'pass' + + return { + kind: 'print-stl-report', + version: 1, + scale, + units: 'millimeter', + orientation: 'z-up', + status, + bounds, + triangleCount, + invalidTriangleCount, + degenerateTriangleCount, + boundaryEdgeCount, + nonManifoldEdgeCount, + volumeMm3: Math.abs(signedVolumeMm3), + diagnostics, + } +} + +export function prepareSceneForPrint( + source: THREE.Object3D, + options: { scale: number }, +): { scene: THREE.Object3D; report: PrintExportReport } { + if (!Number.isFinite(options.scale) || options.scale <= 0) { + throw new RangeError('Print scale must be a positive finite denominator') + } + + ensureMeshPositions(source) + + const scene = new THREE.Group() + scene.name = 'print-export' + scene.add(source) + scene.rotation.x = Math.PI / 2 + scene.scale.setScalar(MILLIMETERS_PER_METER / options.scale) + scene.updateMatrixWorld(true) + + const initialBounds = measureBounds(scene) + if (initialBounds) { + scene.position.set( + -(initialBounds.min.x + initialBounds.max.x) / 2, + -(initialBounds.min.y + initialBounds.max.y) / 2, + -initialBounds.min.z, + ) + scene.updateMatrixWorld(true) + } + + return { scene, report: analyzePrintScene(scene, options.scale) } +} + +export function exportSceneToPrintStl( + source: THREE.Object3D, + options: { scale: number }, +): PrintStlExport { + const { scene, report } = prepareSceneForPrint(source, options) + const exporter = new STLExporter() + const output = exporter.parse(scene, { binary: true }) as ArrayBuffer | DataView + const buffer = + output instanceof DataView + ? (output.buffer.slice( + output.byteOffset, + output.byteOffset + output.byteLength, + ) as ArrayBuffer) + : output + return { buffer, report } +} + +export function isPrintExportReport(value: unknown): value is PrintExportReport { + if (!value || typeof value !== 'object') return false + const report = value as Partial + return report.kind === 'print-stl-report' && report.version === 1 +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index da32e0fd9b..57cd4ca546 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -176,6 +176,7 @@ export { default as useViewer, type MetricNotation, type SceneExport, + type SceneExportArtifact, type SceneExportFormat, type SceneExportOptions, type WallMode, diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 673cee6932..254519cf82 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -1,14 +1,21 @@ import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' import type { Object3D } from 'three' -export type SceneExportFormat = 'glb' | 'stl' | 'obj' +export type SceneExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' export type SceneExportOptions = { onlyVisible?: boolean + download?: boolean + printScale?: number +} +export type SceneExportArtifact = { + blob: Blob + filename: string + metadata?: unknown } export type SceneExport = ( format?: SceneExportFormat, options?: SceneExportOptions, -) => Promise +) => Promise type SelectionPath = { buildingId: BuildingNode['id'] | null levelId: LevelNode['id'] | null diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index c27295ac23..75e351af7a 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -12,14 +12,21 @@ import { SCENE_THEME_IDS } from '../lib/scene-themes' export type RenderContext = 'editor' | 'viewer' export type MetricNotation = 'meters' | 'millimeters' export type WallMode = 'up' | 'cutaway' | 'down' | 'translucent' -export type SceneExportFormat = 'glb' | 'stl' | 'obj' +export type SceneExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' export type SceneExportOptions = { onlyVisible?: boolean + download?: boolean + printScale?: number +} +export type SceneExportArtifact = { + blob: Blob + filename: string + metadata?: unknown } export type SceneExport = ( format?: SceneExportFormat, options?: SceneExportOptions, -) => Promise +) => Promise type SelectionPath = { buildingId: BuildingNode['id'] | null From 302cf689d4ae8ea759f22d60e637eb44cf9abbd9 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 11:14:29 -0400 Subject: [PATCH 03/19] feat: export visible levels as print STL bundle --- bun.lock | 1 + packages/editor/package.json | 1 + .../src/components/editor/export-manager.tsx | 14 +- .../settings-panel/print-export-card.test.ts | 36 ++- .../settings-panel/print-export-card.tsx | 113 +++++--- .../editor/src/lib/level-print-export.test.ts | 160 ++++++++++++ packages/editor/src/lib/level-print-export.ts | 241 ++++++++++++++++++ packages/viewer/src/store/use-viewer.d.ts | 1 + packages/viewer/src/store/use-viewer.ts | 1 + 9 files changed, 534 insertions(+), 34 deletions(-) create mode 100644 packages/editor/src/lib/level-print-export.test.ts create mode 100644 packages/editor/src/lib/level-print-export.ts diff --git a/bun.lock b/bun.lock index 68c2a21e46..8784ac6c3f 100644 --- a/bun.lock +++ b/bun.lock @@ -167,6 +167,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "fflate": "^0.8.3", "howler": "^2.2.4", "lucide-react": "^1.7.0", "mitt": "^3.0.1", diff --git a/packages/editor/package.json b/packages/editor/package.json index 9d3f89bea4..9b667aa54e 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -45,6 +45,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "fflate": "^0.8.3", "howler": "^2.2.4", "lucide-react": "^1.7.0", "mitt": "^3.0.1", diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index c96cf6e605..441b308cd4 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -13,6 +13,7 @@ import * as THREE from 'three' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export' +import { exportSceneLevelsToPrintStl } from '../../lib/level-print-export' import { exportSceneToPrintStl } from '../../lib/print-export' // prepareSceneForExport neutralises container meshes (door/window hitbox roots, @@ -72,9 +73,10 @@ export function ExportManager() { // window, so the export snapshots the clean building, then restore. emitter.emit('thumbnail:before-capture', undefined) const restoreLevels = snapLevelsToTruePositions() + const nodes = useScene.getState().nodes let prepared: ReturnType try { - prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes, options) + prepared = prepareSceneForExport(sceneGroup, nodes, options) } finally { restoreLevels() emitter.emit('thumbnail:after-capture', undefined) @@ -84,6 +86,16 @@ export function ExportManager() { if (format === 'print-stl') { const scale = options.printScale ?? 100 + if (options.printScope === 'levels') { + const { archive, report } = exportSceneLevelsToPrintStl(exportScene, nodes, { scale }) + const blob = new Blob([archive], { type: 'application/zip' }) + return finishArtifact( + blob, + `print_levels_1-${scale}_${date}.zip`, + options.download, + report, + ) + } const { buffer, report } = exportSceneToPrintStl(exportScene, { scale }) const blob = new Blob([buffer], { type: 'model/stl' }) return finishArtifact( diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts index 98f2914b5c..b1d88572aa 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { SceneExport, SceneExportOptions } from '@pascal-app/viewer' +import type { PrintLevelBundleReport } from '../../../../../lib/level-print-export' import type { PrintExportReport } from '../../../../../lib/print-export' import { preparePrintExport } from './print-export-card' @@ -26,6 +27,19 @@ const report: PrintExportReport = { diagnostics: [], } +const levelReport: PrintLevelBundleReport = { + kind: 'print-level-stl-report', + version: 1, + scale: 50, + units: 'millimeter', + orientation: 'z-up', + status: 'pass', + partCount: 1, + parts: [{ levelId: 'level_ground', label: 'Ground', filename: '01_ground.stl', report }], + excludedNodeIds: [], + diagnostics: [], +} + describe('print export card contract', () => { test('prepares a visible-only scaled artifact without downloading immediately', async () => { const calls: { format?: string; options?: SceneExportOptions }[] = [] @@ -35,12 +49,17 @@ describe('print export card contract', () => { return artifact } - const prepared = await preparePrintExport(exportScene, true, '50') + const prepared = await preparePrintExport(exportScene, true, '50', 'whole') expect(calls).toEqual([ { format: 'print-stl', - options: { onlyVisible: true, download: false, printScale: 50 }, + options: { + onlyVisible: true, + download: false, + printScale: 50, + printScope: 'whole', + }, }, ]) expect(prepared).toEqual({ artifact, report }) @@ -53,19 +72,28 @@ describe('print export card contract', () => { return null } - await expect(preparePrintExport(exportScene, true, '0')).rejects.toThrow( + await expect(preparePrintExport(exportScene, true, '0', 'levels')).rejects.toThrow( 'Enter a positive scale denominator', ) expect(invoked).toBe(false) }) + test('accepts the per-level archive report contract', async () => { + const artifact = { blob: new Blob(['zip']), filename: 'levels.zip', metadata: levelReport } + const exportScene: SceneExport = async () => artifact + + const prepared = await preparePrintExport(exportScene, true, '50', 'levels') + + expect(prepared).toEqual({ artifact, report: levelReport }) + }) + test('rejects an artifact without the print preflight contract', async () => { const exportScene: SceneExport = async () => ({ blob: new Blob(['stl']), filename: 'house.stl', }) - await expect(preparePrintExport(exportScene, false, '100')).rejects.toThrow( + await expect(preparePrintExport(exportScene, false, '100', 'whole')).rejects.toThrow( 'did not return a preflight report', ) }) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx index bc276eb599..c0b65cc693 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx @@ -7,6 +7,10 @@ import { import { AlertTriangle, CheckCircle2, Download, Printer, XCircle } from 'lucide-react' import { useEffect, useId, useState } from 'react' import { Button } from '../../../../../components/ui/primitives/button' +import { + isPrintLevelBundleReport, + type PrintLevelBundleReport, +} from '../../../../../lib/level-print-export' import { isPrintExportReport, type PrintExportReport, @@ -14,7 +18,7 @@ import { export type PreparedPrintExport = { artifact: SceneExportArtifact - report: PrintExportReport + report: PrintExportReport | PrintLevelBundleReport } function formatMillimeters(value: number): string { @@ -36,6 +40,7 @@ export async function preparePrintExport( exportScene: SceneExport, onlyVisible: boolean, scaleInput: string, + scope: 'whole' | 'levels', ): Promise { const scale = Number(scaleInput) if (!Number.isFinite(scale) || scale <= 0) { @@ -46,8 +51,12 @@ export async function preparePrintExport( onlyVisible, download: false, printScale: scale, + printScope: scope, }) - if (!artifact || !isPrintExportReport(artifact.metadata)) { + if ( + !artifact || + (!isPrintExportReport(artifact.metadata) && !isPrintLevelBundleReport(artifact.metadata)) + ) { throw new Error('The print exporter did not return a preflight report.') } return { artifact, report: artifact.metadata } @@ -58,6 +67,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { const nodes = useScene((state) => state.nodes) const exportScene = useViewer((state) => state.exportScene) const [printScale, setPrintScale] = useState('100') + const [scope, setScope] = useState<'whole' | 'levels'>('levels') const [isPreparing, setIsPreparing] = useState(false) const [prepared, setPrepared] = useState(null) const [error, setError] = useState(null) @@ -65,7 +75,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { useEffect(() => { setPrepared(null) setError(null) - }, [nodes, onlyVisible, printScale]) + }, [nodes, onlyVisible, printScale, scope]) const handlePrepare = async () => { if (!exportScene) { @@ -77,7 +87,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { setPrepared(null) setError(null) try { - setPrepared(await preparePrintExport(exportScene, onlyVisible, printScale)) + setPrepared(await preparePrintExport(exportScene, onlyVisible, printScale, scope)) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Print export failed.') } finally { @@ -90,7 +100,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) {
-
Print STL
+
Print files
Experimental millimeter, Z-up export centered on the print bed
@@ -113,6 +123,18 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { + + {error && ( @@ -157,28 +183,55 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) {
-
-
Physical size
-
- {prepared.report.bounds - ? `${formatMillimeters(prepared.report.bounds.width)} × ${formatMillimeters( - prepared.report.bounds.depth, - )} × ${formatMillimeters(prepared.report.bounds.height)} mm` - : '—'} -
-
Triangles
-
- {prepared.report.triangleCount.toLocaleString()} -
-
Boundary edges
-
- {prepared.report.boundaryEdgeCount?.toLocaleString() ?? 'Not checked'} -
-
Non-manifold edges
-
- {prepared.report.nonManifoldEdgeCount?.toLocaleString() ?? 'Not checked'} -
-
+ {isPrintLevelBundleReport(prepared.report) ? ( +
+
+ Level parts + {prepared.report.partCount} +
+ {prepared.report.parts.map((part) => ( +
+
+ {part.label} + + {part.report.bounds + ? `${formatMillimeters(part.report.bounds.width)} × ${formatMillimeters( + part.report.bounds.depth, + )} × ${formatMillimeters(part.report.bounds.height)} mm` + : 'No geometry'} + +
+
+ {part.report.triangleCount.toLocaleString()} triangles ·{' '} + {part.report.status === 'pass' ? 'basic checks passed' : part.report.status} +
+
+ ))} +
+ ) : ( +
+
Physical size
+
+ {prepared.report.bounds + ? `${formatMillimeters(prepared.report.bounds.width)} × ${formatMillimeters( + prepared.report.bounds.depth, + )} × ${formatMillimeters(prepared.report.bounds.height)} mm` + : '—'} +
+
Triangles
+
+ {prepared.report.triangleCount.toLocaleString()} +
+
Boundary edges
+
+ {prepared.report.boundaryEdgeCount?.toLocaleString() ?? 'Not checked'} +
+
Non-manifold edges
+
+ {prepared.report.nonManifoldEdgeCount?.toLocaleString() ?? 'Not checked'} +
+
+ )}
    {prepared.report.diagnostics.map((diagnostic) => ( @@ -192,7 +245,9 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { onClick={() => downloadArtifact(prepared.artifact)} > - Download print STL + {isPrintLevelBundleReport(prepared.report) + ? 'Download level STLs (.zip)' + : 'Download print STL'}
)} diff --git a/packages/editor/src/lib/level-print-export.test.ts b/packages/editor/src/lib/level-print-export.test.ts new file mode 100644 index 0000000000..361e3dcb4f --- /dev/null +++ b/packages/editor/src/lib/level-print-export.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNode, sceneRegistry } from '@pascal-app/core' +import { unzipSync } from 'fflate' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { exportSceneLevelsToPrintStl } from './level-print-export' + +function binaryStlBounds(buffer: Uint8Array): { triangles: number; size: THREE.Vector3 } { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const triangles = view.getUint32(80, true) + const bounds = new THREE.Box3() + const point = new THREE.Vector3() + let offset = 84 + + for (let triangle = 0; triangle < triangles; triangle += 1) { + offset += 12 + for (let vertex = 0; vertex < 3; vertex += 1) { + point.set( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ) + bounds.expandByPoint(point) + offset += 12 + } + offset += 2 + } + + return { triangles, size: bounds.getSize(new THREE.Vector3()) } +} + +function twoLevelFixture() { + const root = new THREE.Group() + const building = new THREE.Group() + const ground = new THREE.Group() + const upper = new THREE.Group() + ground.add(new THREE.Mesh(new THREE.BoxGeometry(10, 3, 8))) + upper.add(new THREE.Mesh(new THREE.BoxGeometry(8, 2, 6))) + ground.position.y = 1.5 + upper.position.y = 4 + root.add(building) + building.add(ground, upper) + + sceneRegistry.nodes.set('building_main', building) + sceneRegistry.nodes.set('level_ground', ground) + sceneRegistry.nodes.set('level_upper', upper) + + const nodes: Record = { + building_main: { + object: 'node', + id: 'building_main', + type: 'building', + parentId: null, + children: ['level_ground', 'level_upper'], + } as unknown as AnyNode, + level_ground: { + object: 'node', + id: 'level_ground', + type: 'level', + name: 'Ground', + level: 0, + parentId: 'building_main', + children: [], + visible: true, + } as unknown as AnyNode, + level_upper: { + object: 'node', + id: 'level_upper', + type: 'level', + name: 'Upper', + level: 1, + parentId: 'building_main', + children: [], + visible: true, + } as unknown as AnyNode, + } + + return { root, building, ground, upper, nodes } +} + +describe('per-level print STL export', () => { + afterEach(() => { + sceneRegistry.nodes.clear() + }) + + test('packages one bed-normalized, scale-correct STL per visible level', () => { + const fixture = twoLevelFixture() + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const bundle = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) + const files = unzipSync(bundle.archive) + const ground = binaryStlBounds(files['01_ground.stl']!) + const upper = binaryStlBounds(files['02_upper.stl']!) + + expect(Object.keys(files)).toEqual(['01_ground.stl', '02_upper.stl']) + expect(bundle.report.status).toBe('pass') + expect(bundle.report.partCount).toBe(2) + expect(ground.triangles).toBe(12) + expect(ground.size.x).toBeCloseTo(100, 4) + expect(ground.size.y).toBeCloseTo(80, 4) + expect(ground.size.z).toBeCloseTo(30, 4) + expect(upper.triangles).toBe(12) + expect(upper.size.x).toBeCloseTo(80, 4) + expect(upper.size.y).toBeCloseTo(60, 4) + expect(upper.size.z).toBeCloseTo(20, 4) + }) + + test('omits and blocks an unsplit stair that spans two levels', () => { + const fixture = twoLevelFixture() + const stair = new THREE.Group() + stair.add(new THREE.Mesh(new THREE.BoxGeometry(1, 3, 2))) + fixture.ground.add(stair) + sceneRegistry.nodes.set('stair_main', stair) + fixture.nodes.stair_main = { + object: 'node', + id: 'stair_main', + type: 'stair', + parentId: 'level_ground', + fromLevelId: 'level_ground', + toLevelId: 'level_upper', + children: [], + visible: true, + } as unknown as AnyNode + + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + const bundle = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) + + expect(bundle.report.status).toBe('blocked') + expect(bundle.report.excludedNodeIds).toEqual(['stair_main']) + expect(bundle.report.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + 'unsplit_spanning_node', + ) + expect(bundle.report.parts.map((part) => part.report.triangleCount)).toEqual([12, 12]) + }) + + test('does not create a part for a semantically hidden level', () => { + const fixture = twoLevelFixture() + fixture.nodes.level_upper = { + ...fixture.nodes.level_upper!, + visible: false, + } as AnyNode + + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + const bundle = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) + const files = unzipSync(bundle.archive) + + expect(Object.keys(files)).toEqual(['01_ground.stl']) + expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground']) + }) + + test('produces deterministic archive bytes for the same level parts', () => { + const fixture = twoLevelFixture() + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const first = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 50 }) + const second = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 50 }) + + expect(first.archive).toEqual(second.archive) + }) +}) diff --git a/packages/editor/src/lib/level-print-export.ts b/packages/editor/src/lib/level-print-export.ts new file mode 100644 index 0000000000..61eb0d2505 --- /dev/null +++ b/packages/editor/src/lib/level-print-export.ts @@ -0,0 +1,241 @@ +import { type AnyNode, getLevelDisplayName, type LevelNode } from '@pascal-app/core' +import { type Zippable, zipSync } from 'fflate' +import type * as THREE from 'three' +import { + exportSceneToPrintStl, + type PrintExportDiagnostic, + type PrintExportReport, +} from './print-export' + +const ZIP_MTIME = new Date(2000, 0, 1, 0, 0, 0) + +export type PrintLevelPartReport = { + levelId: string + label: string + filename: string + report: PrintExportReport +} + +export type PrintLevelBundleReport = { + kind: 'print-level-stl-report' + version: 1 + scale: number + units: 'millimeter' + orientation: 'z-up' + status: 'pass' | 'warning' | 'blocked' + partCount: number + parts: PrintLevelPartReport[] + excludedNodeIds: string[] + diagnostics: PrintExportDiagnostic[] +} + +export type PrintLevelStlBundle = { + archive: Uint8Array + report: PrintLevelBundleReport +} + +function exportedIdentityIds(root: THREE.Object3D): Set { + const ids = new Set() + root.traverse((object) => { + const id = object.userData.pascalId + if (typeof id === 'string') ids.add(id) + }) + return ids +} + +function owningLevelId( + id: string, + nodes: Record, + memo: Map, + path = new Set(), +): string | null { + if (memo.has(id)) return memo.get(id) ?? null + const node = nodes[id] + if (!node || path.has(id)) return null + if (node.type === 'level') { + memo.set(id, id) + return id + } + if (!node.parentId) { + memo.set(id, null) + return null + } + + path.add(id) + const levelId = owningLevelId(node.parentId, nodes, memo, path) + path.delete(id) + memo.set(id, levelId) + return levelId +} + +function levelAncestors(levelId: string, nodes: Record): Set { + const ancestors = new Set() + const visited = new Set() + let parentId = nodes[levelId]?.parentId ?? null + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + ancestors.add(parentId) + parentId = nodes[parentId]?.parentId ?? null + } + return ancestors +} + +function isSpanningNode(node: AnyNode, ownerLevelId: string | null): boolean { + if (node.type === 'elevator') return true + if (node.type !== 'stair') return false + + const fromLevelId = node.fromLevelId ?? ownerLevelId + const toLevelId = node.toLevelId + return Boolean(fromLevelId && toLevelId && fromLevelId !== toLevelId) +} + +function hasExcludedAncestor( + id: string, + excludedIds: ReadonlySet, + nodes: Record, +): boolean { + const visited = new Set() + let parentId = nodes[id]?.parentId ?? null + while (parentId && !visited.has(parentId)) { + if (excludedIds.has(parentId)) return true + visited.add(parentId) + parentId = nodes[parentId]?.parentId ?? null + } + return false +} + +function pruneSceneToLevel( + source: THREE.Object3D, + levelId: string, + nodes: Record, + excludedIds: ReadonlySet, + ownerByNodeId: Map, +): THREE.Object3D { + const scene = source.clone(true) + const ancestors = levelAncestors(levelId, nodes) + const removals: THREE.Object3D[] = [] + + scene.traverse((object) => { + const id = object.userData.pascalId + if (typeof id !== 'string') return + const belongsToLevel = + ownerByNodeId.get(id) === levelId && + !excludedIds.has(id) && + !hasExcludedAncestor(id, excludedIds, nodes) + if (!belongsToLevel && !ancestors.has(id)) removals.push(object) + }) + + for (const object of removals) object.removeFromParent() + scene.name = `print-level-${levelId}` + return scene +} + +function safeFilenamePart(value: string): string { + return ( + value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'level' + ) +} + +function bundleStatus( + diagnostics: PrintExportDiagnostic[], + parts: PrintLevelPartReport[], +): PrintLevelBundleReport['status'] { + if ( + diagnostics.some((diagnostic) => diagnostic.severity === 'error') || + parts.some((part) => part.report.status === 'blocked') + ) { + return 'blocked' + } + if ( + diagnostics.some((diagnostic) => diagnostic.severity === 'warning') || + parts.some((part) => part.report.status === 'warning') + ) { + return 'warning' + } + return 'pass' +} + +export function exportSceneLevelsToPrintStl( + source: THREE.Object3D, + nodes: Record, + options: { scale: number }, +): PrintLevelStlBundle { + const exportedIds = exportedIdentityIds(source) + const ownerByNodeId = new Map() + for (const id of Object.keys(nodes)) owningLevelId(id, nodes, ownerByNodeId) + + const levels = Object.values(nodes) + .filter((node): node is LevelNode => node.type === 'level' && exportedIds.has(node.id)) + .sort( + (a, b) => + (a.parentId ?? '').localeCompare(b.parentId ?? '') || + a.level - b.level || + a.id.localeCompare(b.id), + ) + + const excludedIds = new Set() + const diagnostics: PrintExportDiagnostic[] = [] + for (const id of exportedIds) { + const node = nodes[id] + if (!node || !isSpanningNode(node, ownerByNodeId.get(id) ?? null)) continue + excludedIds.add(id) + diagnostics.push({ + severity: 'error', + code: 'unsplit_spanning_node', + message: `${node.type} ${id} spans levels and was omitted. Hide it or define a deterministic split before downloading level parts.`, + }) + } + + if (levels.length === 0) { + diagnostics.push({ + severity: 'error', + code: 'no_visible_levels', + message: 'No visible level nodes remain in the print scope.', + }) + } + + const files: Zippable = {} + const parts: PrintLevelPartReport[] = [] + for (const [index, level] of levels.entries()) { + const label = getLevelDisplayName(level) + const filename = `${String(index + 1).padStart(2, '0')}_${safeFilenamePart(label)}.stl` + const levelScene = pruneSceneToLevel(source, level.id, nodes, excludedIds, ownerByNodeId) + const output = exportSceneToPrintStl(levelScene, options) + files[filename] = [new Uint8Array(output.buffer), { level: 0, mtime: ZIP_MTIME }] + parts.push({ levelId: level.id, label, filename, report: output.report }) + } + + diagnostics.push({ + severity: 'info', + code: 'level_parts_experimental', + message: + 'Level parts are separated semantically but are not boolean-unioned printable shells yet.', + }) + + return { + archive: zipSync(files, { level: 0 }), + report: { + kind: 'print-level-stl-report', + version: 1, + scale: options.scale, + units: 'millimeter', + orientation: 'z-up', + status: bundleStatus(diagnostics, parts), + partCount: parts.length, + parts, + excludedNodeIds: Array.from(excludedIds).sort(), + diagnostics, + }, + } +} + +export function isPrintLevelBundleReport(value: unknown): value is PrintLevelBundleReport { + if (!value || typeof value !== 'object') return false + const report = value as Partial + return report.kind === 'print-level-stl-report' && report.version === 1 +} diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 254519cf82..16db547525 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -6,6 +6,7 @@ export type SceneExportOptions = { onlyVisible?: boolean download?: boolean printScale?: number + printScope?: 'whole' | 'levels' } export type SceneExportArtifact = { blob: Blob diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 75e351af7a..16a2f16006 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -17,6 +17,7 @@ export type SceneExportOptions = { onlyVisible?: boolean download?: boolean printScale?: number + printScope?: 'whole' | 'levels' } export type SceneExportArtifact = { blob: Blob From ec37c690c5a60be493204747952bd918b9c4e970 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 11:20:12 -0400 Subject: [PATCH 04/19] feat: default print exports to structure --- .../src/components/editor/export-manager.tsx | 10 +- .../settings-panel/print-export-card.test.ts | 15 ++- .../settings-panel/print-export-card.tsx | 23 +++- .../editor/src/lib/level-print-export.test.ts | 70 +++++++++++- .../src/lib/print-content-scope.test.ts | 107 ++++++++++++++++++ .../editor/src/lib/print-content-scope.ts | 90 +++++++++++++++ packages/viewer/src/store/use-viewer.d.ts | 1 + packages/viewer/src/store/use-viewer.ts | 1 + 8 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 packages/editor/src/lib/print-content-scope.test.ts create mode 100644 packages/editor/src/lib/print-content-scope.ts diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 441b308cd4..d7bc2c0309 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -14,6 +14,7 @@ import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export' import { exportSceneLevelsToPrintStl } from '../../lib/level-print-export' +import { filterPreparedSceneForPrintContent } from '../../lib/print-content-scope' import { exportSceneToPrintStl } from '../../lib/print-export' // prepareSceneForExport neutralises container meshes (door/window hitbox roots, @@ -81,7 +82,14 @@ export function ExportManager() { restoreLevels() emitter.emit('thumbnail:after-capture', undefined) } - const { scene: exportScene } = prepared + let { scene: exportScene } = prepared + if (format === 'print-stl') { + exportScene = filterPreparedSceneForPrintContent( + exportScene, + nodes, + options.printContent ?? 'structure', + ) + } ensurePositionAttributes(exportScene) if (format === 'print-stl') { diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts index b1d88572aa..3c521e85ab 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts @@ -49,7 +49,7 @@ describe('print export card contract', () => { return artifact } - const prepared = await preparePrintExport(exportScene, true, '50', 'whole') + const prepared = await preparePrintExport(exportScene, true, '50', 'whole', 'structure') expect(calls).toEqual([ { @@ -59,6 +59,7 @@ describe('print export card contract', () => { download: false, printScale: 50, printScope: 'whole', + printContent: 'structure', }, }, ]) @@ -72,7 +73,9 @@ describe('print export card contract', () => { return null } - await expect(preparePrintExport(exportScene, true, '0', 'levels')).rejects.toThrow( + await expect( + preparePrintExport(exportScene, true, '0', 'levels', 'structure'), + ).rejects.toThrow( 'Enter a positive scale denominator', ) expect(invoked).toBe(false) @@ -82,7 +85,7 @@ describe('print export card contract', () => { const artifact = { blob: new Blob(['zip']), filename: 'levels.zip', metadata: levelReport } const exportScene: SceneExport = async () => artifact - const prepared = await preparePrintExport(exportScene, true, '50', 'levels') + const prepared = await preparePrintExport(exportScene, true, '50', 'levels', 'everything') expect(prepared).toEqual({ artifact, report: levelReport }) }) @@ -93,8 +96,8 @@ describe('print export card contract', () => { filename: 'house.stl', }) - await expect(preparePrintExport(exportScene, false, '100', 'whole')).rejects.toThrow( - 'did not return a preflight report', - ) + await expect( + preparePrintExport(exportScene, false, '100', 'whole', 'structure'), + ).rejects.toThrow('did not return a preflight report') }) }) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx index c0b65cc693..3095de239d 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx @@ -11,6 +11,7 @@ import { isPrintLevelBundleReport, type PrintLevelBundleReport, } from '../../../../../lib/level-print-export' +import type { PrintContentScope } from '../../../../../lib/print-content-scope' import { isPrintExportReport, type PrintExportReport, @@ -41,6 +42,7 @@ export async function preparePrintExport( onlyVisible: boolean, scaleInput: string, scope: 'whole' | 'levels', + content: PrintContentScope, ): Promise { const scale = Number(scaleInput) if (!Number.isFinite(scale) || scale <= 0) { @@ -52,6 +54,7 @@ export async function preparePrintExport( download: false, printScale: scale, printScope: scope, + printContent: content, }) if ( !artifact || @@ -68,6 +71,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { const exportScene = useViewer((state) => state.exportScene) const [printScale, setPrintScale] = useState('100') const [scope, setScope] = useState<'whole' | 'levels'>('levels') + const [content, setContent] = useState('structure') const [isPreparing, setIsPreparing] = useState(false) const [prepared, setPrepared] = useState(null) const [error, setError] = useState(null) @@ -75,7 +79,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { useEffect(() => { setPrepared(null) setError(null) - }, [nodes, onlyVisible, printScale, scope]) + }, [nodes, onlyVisible, printScale, scope, content]) const handlePrepare = async () => { if (!exportScene) { @@ -87,7 +91,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { setPrepared(null) setError(null) try { - setPrepared(await preparePrintExport(exportScene, onlyVisible, printScale, scope)) + setPrepared(await preparePrintExport(exportScene, onlyVisible, printScale, scope, content)) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Print export failed.') } finally { @@ -123,6 +127,21 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { + + + + {scope === 'levels' && base === 'plinth' && ( +
+ + +
+ )} + diff --git a/packages/editor/src/lib/level-print-export.test.ts b/packages/editor/src/lib/level-print-export.test.ts index cfa2b260f6..32a296b0be 100644 --- a/packages/editor/src/lib/level-print-export.test.ts +++ b/packages/editor/src/lib/level-print-export.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { type AnyNode, registerNode, sceneRegistry } from '@pascal-app/core' +import { type AnyNode, RoofSegmentNode, registerNode, sceneRegistry } from '@pascal-app/core' +import { generateRoofSegmentGeometry } from '@pascal-app/viewer' import { unzipSync } from 'fflate' import * as THREE from 'three' import { prepareSceneForExport } from './glb-export' @@ -205,6 +206,76 @@ describe('per-level print STL export', () => { expect(bundle.report.status).toBe('pass') }) + test('substitutes a canonical roof shell before compiling a level part', () => { + const root = new THREE.Group() + const building = new THREE.Group() + building.userData = { pascalId: 'building_roof-print' } + const level = new THREE.Group() + level.userData = { pascalId: 'level_roof-print' } + const roof = RoofSegmentNode.parse({ + id: 'rseg_level-print', + parentId: 'level_roof-print', + roofType: 'gable', + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + }) + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + root.add(building) + building.add(level) + level.add(roofRoot) + + const nodes: Record = { + 'building_roof-print': { + object: 'node', + id: 'building_roof-print', + type: 'building', + parentId: null, + children: ['level_roof-print'], + } as unknown as AnyNode, + 'level_roof-print': { + object: 'node', + id: 'level_roof-print', + type: 'level', + name: 'Roof', + level: 0, + parentId: 'building_roof-print', + children: [roof.id], + visible: true, + } as unknown as AnyNode, + [roof.id]: roof, + } + + const raw = exportSceneLevelsToPrintStl(root, nodes, { scale: 100 }) + const compiled = exportSceneLevelsToPrintStl(root, nodes, { + scale: 100, + compileShells: true, + }) + const part = compiled.report.parts[0]! + + expect(raw.report.parts[0]?.report.status).toBe('blocked') + expect(compiled.report.status).toBe('pass') + expect(part.report.status).toBe('pass') + expect(part.report.bounds?.width).toBeCloseTo(46.6962, 3) + expect(part.report.bounds?.depth).toBeCloseTo(37.1962, 3) + expect(part.report.bounds?.height).toBeCloseTo(15.3923, 3) + expect(part.report.boundaryEdgeCount).toBe(0) + expect(part.report.nonManifoldEdgeCount).toBe(0) + expect(part.report.diagnostics.map((diagnostic) => diagnostic.code)).toEqual( + expect.arrayContaining(['baseline_compiler', 'compiler_limits']), + ) + expect(part.report.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain( + 'compiler_pending', + ) + }) + test('produces deterministic archive bytes for the same level parts', () => { const fixture = twoLevelFixture() const prepared = prepareSceneForExport(fixture.root, fixture.nodes) diff --git a/packages/editor/src/lib/level-print-export.ts b/packages/editor/src/lib/level-print-export.ts index 9348b6c498..aa19f15ad6 100644 --- a/packages/editor/src/lib/level-print-export.ts +++ b/packages/editor/src/lib/level-print-export.ts @@ -3,9 +3,11 @@ import { type Zippable, zipSync } from 'fflate' import * as THREE from 'three' import { exportSceneToPrintStl, + mergePrintExportDiagnostics, type PrintExportDiagnostic, type PrintExportReport, } from './print-export' +import { compileSemanticPrintShell } from './print-shell-compiler' const ZIP_MTIME = new Date(2000, 0, 1, 0, 0, 0) const MILLIMETERS_PER_METER = 1000 @@ -43,6 +45,12 @@ export type PrintLevelStlBundle = { report: PrintLevelBundleReport } +export type PrintLevelExportOptions = { + scale: number + plinth?: PrintPlinthOptions + compileShells?: boolean +} + function exportedIdentityIds(root: THREE.Object3D): Set { const ids = new Set() root.traverse((object) => { @@ -172,7 +180,7 @@ function bundleStatus( export function exportSceneLevelsToPrintStl( source: THREE.Object3D, nodes: Record, - options: { scale: number; plinth?: PrintPlinthOptions }, + options: PrintLevelExportOptions, ): PrintLevelStlBundle { const exportedIds = exportedIdentityIds(source) const ownerByNodeId = new Map() @@ -214,9 +222,26 @@ export function exportSceneLevelsToPrintStl( const label = getLevelDisplayName(level) const filename = `${String(index + 1).padStart(2, '0')}_${safeFilenamePart(label)}.stl` const levelScene = pruneSceneToLevel(source, level.id, nodes, excludedIds, ownerByNodeId) - const output = exportSceneToPrintStl(levelScene, options) + const compiled = options.compileShells ? compileSemanticPrintShell(levelScene, nodes) : null + const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : levelScene + const output = exportSceneToPrintStl(printSource, { + scale: options.scale, + compiled: compiled?.status === 'compiled', + }) + const report = compiled + ? mergePrintExportDiagnostics( + output.report, + compiled.diagnostics, + new Set(['compiler_pending']), + ) + : output.report + if (compiled) { + diagnostics.push( + ...compiled.diagnostics.filter((diagnostic) => diagnostic.severity !== 'info'), + ) + } levelFiles.push({ filename, bytes: new Uint8Array(output.buffer) }) - levelParts.push({ kind: 'level', levelId: level.id, label, filename, report: output.report }) + levelParts.push({ kind: 'level', levelId: level.id, label, filename, report }) } let plinthFile: { filename: string; bytes: Uint8Array } | null = null @@ -281,8 +306,9 @@ export function exportSceneLevelsToPrintStl( diagnostics.push({ severity: 'info', code: 'level_parts_experimental', - message: - 'Level parts are separated semantically but are not boolean-unioned printable shells yet.', + message: options.compileShells + ? 'Level parts use the experimental synchronous semantic shell compiler; worker execution, self-intersection checks, and minimum wall thickness remain pending.' + : 'Level parts are separated semantically but are not boolean-unioned printable shells yet.', }) const files: Zippable = {} diff --git a/packages/editor/src/lib/print-export.test.ts b/packages/editor/src/lib/print-export.test.ts index 69b3dbb8a9..934bb294fb 100644 --- a/packages/editor/src/lib/print-export.test.ts +++ b/packages/editor/src/lib/print-export.test.ts @@ -2,7 +2,11 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, sceneRegistry } from '@pascal-app/core' import * as THREE from 'three' import { prepareSceneForExport } from './glb-export' -import { exportSceneToPrintStl, prepareSceneForPrint } from './print-export' +import { + exportSceneToPrintStl, + mergePrintExportDiagnostics, + prepareSceneForPrint, +} from './print-export' function binaryStlBounds(buffer: ArrayBuffer): { triangles: number; bounds: THREE.Box3 } { const view = new DataView(buffer) @@ -117,6 +121,64 @@ describe('print STL export', () => { ) }) + test('does not conflate distinct closed edges inside the boundary matching tolerance', () => { + const source = new THREE.Group() + const first = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + const second = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + second.position.x = 1.000002 + source.add(first, second) + + const { report } = prepareSceneForPrint(source, { scale: 100 }) + + expect(report.status).toBe('pass') + expect(report.boundaryEdgeCount).toBe(0) + expect(report.nonManifoldEdgeCount).toBe(0) + }) + + test('uses authoritative indexed incidence for a compiled mesh', () => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 1], 3), + ) + geometry.setIndex([0, 1, 2, 1, 0, 3, 0, 1, 4]) + + const { report } = prepareSceneForPrint(new THREE.Mesh(geometry), { + scale: 100, + indexedTopology: true, + }) + + expect(report.status).toBe('blocked') + expect(report.nonManifoldEdgeCount).toBe(1) + }) + + test('merges located compiler diagnostics into a compiled preflight report', () => { + const { report } = prepareSceneForPrint(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)), { + scale: 100, + compiled: true, + }) + const merged = mergePrintExportDiagnostics(report, [ + { + severity: 'error', + code: 'unsupported_roof_print_trim', + message: 'The roof trim has no manifold fixture.', + nodeIds: ['rseg_print-trimmed'], + }, + ]) + + expect(report.diagnostics.map((diagnostic) => diagnostic.code)).toContain('compiler_limits') + expect(report.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain( + 'compiler_pending', + ) + expect(merged.status).toBe('blocked') + expect(merged.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'unsupported_roof_print_trim', + nodeIds: ['rseg_print-trimmed'], + }), + ) + }) + test('omits semantically hidden meshes from the parsed print artifact', () => { const root = new THREE.Group() const visibleGroup = new THREE.Group() diff --git a/packages/editor/src/lib/print-export.ts b/packages/editor/src/lib/print-export.ts index f2897b7419..d8d9d4eb3c 100644 --- a/packages/editor/src/lib/print-export.ts +++ b/packages/editor/src/lib/print-export.ts @@ -4,6 +4,7 @@ import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' const MILLIMETERS_PER_METER = 1000 const EDGE_CONNECTIVITY_EPSILON_METERS = 1e-5 +const EDGE_INCIDENCE_EPSILON_METERS = 1e-7 const MAX_EDGE_CHECK_TRIANGLES = 500_000 const DEGENERATE_CROSS_LENGTH_SQ = 1e-12 @@ -17,6 +18,13 @@ export type PrintExportDiagnostic = { severity: 'error' | 'warning' | 'info' code: string message: string + nodeIds?: string[] +} + +export type PrintExportOptions = { + scale: number + compiled?: boolean + indexedTopology?: boolean } export type PrintExportBounds = { @@ -142,9 +150,9 @@ function measureBounds(root: THREE.Object3D): BoundsMeasurement { } function pointKey(point: THREE.Vector3): string { - return `${Math.round(point.x / EDGE_CONNECTIVITY_EPSILON_METERS)},${Math.round( - point.y / EDGE_CONNECTIVITY_EPSILON_METERS, - )},${Math.round(point.z / EDGE_CONNECTIVITY_EPSILON_METERS)}` + return `${Math.round(point.x / EDGE_INCIDENCE_EPSILON_METERS)},${Math.round( + point.y / EDGE_INCIDENCE_EPSILON_METERS, + )},${Math.round(point.z / EDGE_INCIDENCE_EPSILON_METERS)}` } function addEdge(edges: Map, a: THREE.Vector3, b: THREE.Vector3) { @@ -154,7 +162,47 @@ function addEdge(edges: Map, a: THREE.Vector3, b: THREE.Vector3) edges.set(key, (edges.get(key) ?? 0) + 1) } -function analyzeEdgeTopology(root: THREE.Object3D): EdgeTopologyMeasurement { +function indexedNonManifoldEdgeCount(root: THREE.Object3D): number | null { + let hasGeometry = false + let nonManifoldEdgeCount = 0 + for (const object of root.children) object.updateMatrixWorld(true) + + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const position = mesh.geometry.getAttribute('position') + if (!position || position.count === 0) return + const index = mesh.geometry.getIndex() + if (!index) { + nonManifoldEdgeCount = Number.NaN + return + } + hasGeometry = true + const edges = new Map() + const add = (a: number, b: number) => { + const key = a < b ? `${a}|${b}` : `${b}|${a}` + edges.set(key, (edges.get(key) ?? 0) + 1) + } + for (let offset = 0; offset + 2 < index.count; offset += 3) { + const a = index.getX(offset) + const b = index.getX(offset + 1) + const c = index.getX(offset + 2) + add(a, b) + add(b, c) + add(c, a) + } + for (const uses of edges.values()) { + if (uses > 2) nonManifoldEdgeCount += 1 + } + }) + + return hasGeometry && Number.isFinite(nonManifoldEdgeCount) ? nonManifoldEdgeCount : null +} + +function analyzeEdgeTopology( + root: THREE.Object3D, + useIndexedIncidence: boolean, +): EdgeTopologyMeasurement { const edges = new Map() const halfEdgePositions: number[] = [] let edgeCheckComplete = true @@ -196,9 +244,12 @@ function analyzeEdgeTopology(root: THREE.Object3D): EdgeTopologyMeasurement { const boundaryEdgeCount = halfEdges.unmatchedEdges connectivityGeometry.dispose() - let nonManifoldEdgeCount = 0 - for (const count of edges.values()) { - if (count > 2) nonManifoldEdgeCount += 1 + let nonManifoldEdgeCount = useIndexedIncidence ? indexedNonManifoldEdgeCount(root) : null + if (nonManifoldEdgeCount === null) { + nonManifoldEdgeCount = 0 + for (const count of edges.values()) { + if (count > 2) nonManifoldEdgeCount += 1 + } } return { boundaryEdgeCount, nonManifoldEdgeCount, edgeCheckComplete } @@ -208,6 +259,7 @@ function analyzePrintScene( root: THREE.Object3D, scale: number, edgeTopology: EdgeTopologyMeasurement, + compiled: boolean, ): PrintExportReport { const min = new THREE.Vector3( Number.POSITIVE_INFINITY, @@ -321,11 +373,21 @@ function analyzePrintScene( message: 'The exported surfaces enclose no measurable signed volume.', }) } - diagnostics.push({ - severity: 'info', - code: 'compiler_pending', - message: 'Boolean union, shell intersections, and minimum wall thickness are not checked yet.', - }) + diagnostics.push( + compiled + ? { + severity: 'info', + code: 'compiler_limits', + message: + 'The shell was boolean-unioned, but self-intersections and minimum wall thickness are not checked yet.', + } + : { + severity: 'info', + code: 'compiler_pending', + message: + 'Boolean union, shell intersections, and minimum wall thickness are not checked yet.', + }, + ) const status = diagnostics.some((diagnostic) => diagnostic.severity === 'error') ? 'blocked' @@ -353,7 +415,7 @@ function analyzePrintScene( export function prepareSceneForPrint( source: THREE.Object3D, - options: { scale: number }, + options: PrintExportOptions, ): { scene: THREE.Object3D; report: PrintExportReport } { if (!Number.isFinite(options.scale) || options.scale <= 0) { throw new RangeError('Print scale must be a positive finite denominator') @@ -364,7 +426,7 @@ export function prepareSceneForPrint( const physicalScale = MILLIMETERS_PER_METER / options.scale // Connectivity is invariant under print scale and orientation. Checking it // in model-space meters avoids scale-dependent ray tolerances and π/2 drift. - const edgeTopology = analyzeEdgeTopology(source) + const edgeTopology = analyzeEdgeTopology(source, options.indexedTopology ?? false) const scene = new THREE.Group() scene.name = 'print-export' @@ -383,12 +445,15 @@ export function prepareSceneForPrint( scene.updateMatrixWorld(true) } - return { scene, report: analyzePrintScene(scene, options.scale, edgeTopology) } + return { + scene, + report: analyzePrintScene(scene, options.scale, edgeTopology, options.compiled ?? false), + } } export function exportSceneToPrintStl( source: THREE.Object3D, - options: { scale: number }, + options: PrintExportOptions, ): PrintStlExport { const { scene, report } = prepareSceneForPrint(source, options) const exporter = new STLExporter() @@ -403,6 +468,23 @@ export function exportSceneToPrintStl( return { buffer, report } } +export function mergePrintExportDiagnostics( + report: PrintExportReport, + diagnostics: PrintExportDiagnostic[], + omitCodes: ReadonlySet = new Set(), +): PrintExportReport { + const merged = [ + ...report.diagnostics.filter((diagnostic) => !omitCodes.has(diagnostic.code)), + ...diagnostics, + ] + const status = merged.some((diagnostic) => diagnostic.severity === 'error') + ? 'blocked' + : merged.some((diagnostic) => diagnostic.severity === 'warning') + ? 'warning' + : 'pass' + return { ...report, status, diagnostics: merged } +} + export function isPrintExportReport(value: unknown): value is PrintExportReport { if (!value || typeof value !== 'object') return false const report = value as Partial diff --git a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts index d2b7cb2f85..4b5881ec40 100644 --- a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts +++ b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts @@ -17,7 +17,9 @@ import { } from '@pascal-app/viewer' import * as THREE from 'three' import { exportSceneToPrintStl } from './print-export' +import { compileSemanticPrintShell } from './print-shell-compiler' import { compilePrintShellBaseline } from './print-shell-compiler-baseline' +import { compilePrintShellWithManifold } from './print-shell-compiler-manifold' const EMPTY_SLAB_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] } const ROOF_TYPES: RoofType[] = ['gable', 'hip', 'shed', 'gambrel', 'mansard', 'flat', 'dutch'] @@ -30,9 +32,15 @@ function structuralBox(id: string, x: number): THREE.Group { return group } -function rayIntersectionCount(root: THREE.Object3D, x: number, y: number): number { +function rayIntersectionCount( + root: THREE.Object3D, + x: number, + y: number, + far = Number.POSITIVE_INFINITY, +): number { root.updateMatrixWorld(true) const raycaster = new THREE.Raycaster(new THREE.Vector3(x, y, -2), new THREE.Vector3(0, 0, 1)) + raycaster.far = far const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }) let count = 0 @@ -49,6 +57,30 @@ function rayIntersectionCount(root: THREE.Object3D, x: number, y: number): numbe return count } +function indexedNonManifoldEdgeCount(root: THREE.Object3D): number { + let count = 0 + root.traverse((object) => { + const mesh = object as THREE.Mesh + const index = mesh.isMesh ? mesh.geometry.getIndex() : null + if (!index) return + const edges = new Map() + const add = (a: number, b: number) => { + const key = a < b ? `${a}|${b}` : `${b}|${a}` + edges.set(key, (edges.get(key) ?? 0) + 1) + } + for (let offset = 0; offset + 2 < index.count; offset += 3) { + const a = index.getX(offset) + const b = index.getX(offset + 1) + const c = index.getX(offset + 2) + add(a, b) + add(b, c) + add(c, a) + } + count += Array.from(edges.values()).filter((uses) => uses > 2).length + }) + return count +} + describe('print shell compiler baseline', () => { test('unions overlapping world-space structural meshes into a closed shell', () => { const source = new THREE.Group() @@ -61,7 +93,7 @@ describe('print shell compiler baseline', () => { expect(compiled.sourceNodeIds).toEqual(['wall_left', 'wall_right']) expect(compiled.scene).not.toBeNull() - const print = exportSceneToPrintStl(compiled.scene!, { scale: 100 }) + const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) expect(print.report.status).toBe('pass') expect(print.report.bounds?.width).toBeCloseTo(30, 4) expect(print.report.bounds?.depth).toBeCloseTo(20, 4) @@ -132,7 +164,7 @@ describe('print shell compiler baseline', () => { expect(rayIntersectionCount(compiled.scene!, door.position[0], 1)).toBe(0) expect(rayIntersectionCount(compiled.scene!, 0.5, 1)).toBeGreaterThanOrEqual(2) - const print = exportSceneToPrintStl(compiled.scene!, { scale: 100 }) + const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) expect(print.report.status).toBe('pass') expect(print.report.bounds?.width).toBeCloseTo(50, 4) expect(print.report.bounds?.depth).toBeCloseTo(30, 4) @@ -146,6 +178,180 @@ describe('print shell compiler baseline', () => { } }) + test('compares the full-house baseline union with the Manifold candidate', async () => { + const walls = [ + WallNode.parse({ + id: 'wall_print-house-front', + start: [-2, -1.5], + end: [2, -1.5], + height: 2.5, + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_print-house-right', + start: [2, -1.5], + end: [2, 1.5], + height: 2.5, + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_print-house-back', + start: [2, 1.5], + end: [-2, 1.5], + height: 2.5, + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_print-house-left', + start: [-2, 1.5], + end: [-2, -1.5], + height: 2.5, + thickness: 0.2, + }), + ] + const door = DoorNode.parse({ + id: 'door_print-house-front', + wallId: walls[0]!.id, + position: [2, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const slab = SlabNode.parse({ + id: 'slab_print-house', + elevation: 0, + thickness: 0.2, + polygon: [ + [-2.1, -1.6], + [2.1, -1.6], + [2.1, 1.6], + [-2.1, 1.6], + ], + }) + const roof = RoofSegmentNode.parse({ + id: 'rseg_print-house', + roofType: 'gable', + position: [0, 2.5, 0], + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + }) + const source = new THREE.Group() + const miters = calculateLevelMiters(walls) + const nodes = Object.fromEntries( + [...walls, slab, roof].map((node) => [node.id, node]), + ) as Record + + try { + for (const wall of walls) { + const root = new THREE.Group() + root.userData = { pascalId: wall.id } + sceneRegistry.nodes.set(wall.id, root) + root.add( + new THREE.Mesh(generateExtrudedWall(wall, wall.id === door.wallId ? [door] : [], miters)), + ) + source.add(root) + } + const slabRoot = new THREE.Group() + slabRoot.userData = { pascalId: slab.id } + slabRoot.add(new THREE.Mesh(generateSlabGeometry(slab, EMPTY_SLAB_CONTEXT))) + source.add(slabRoot) + + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.position.set(...roof.position) + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + source.add(roofRoot) + + const compiled = compileSemanticPrintShell(source, nodes) + + expect(compiled.status).toBe('compiled') + expect(compiled.inputMeshCount).toBe(6) + expect(compiled.sourceNodeIds).toEqual(Object.keys(nodes).sort()) + expect(compiled.scene).not.toBeNull() + + const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) + expect(print.report.status).toBe('blocked') + expect(print.report.degenerateTriangleCount).toBeGreaterThan(0) + expect(print.report.boundaryEdgeCount).toBeGreaterThan(0) + expect(print.report.nonManifoldEdgeCount).toBeGreaterThan(0) + expect(print.report.volumeMm3).toBeGreaterThan(0) + + const candidateSource = new THREE.Group() + for (const wall of walls) { + const root = new THREE.Group() + root.userData = { pascalId: wall.id } + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + const wallHeight = wall.height + if (wallHeight === undefined) throw new Error(`Missing height for ${wall.id}`) + root.position.set((wall.start[0] + wall.end[0]) / 2, 0, (wall.start[1] + wall.end[1]) / 2) + root.rotation.y = Math.atan2(-dz, dx) + + if (wall.id === door.wallId) { + const doorStart = door.position[0] - door.width / 2 + const doorEnd = door.position[0] + door.width / 2 + const leftLength = doorStart + const rightLength = length - doorEnd + const left = new THREE.Mesh(new THREE.BoxGeometry(leftLength, wallHeight, wall.thickness)) + left.position.set(-length / 2 + leftLength / 2, wallHeight / 2, 0) + const right = new THREE.Mesh( + new THREE.BoxGeometry(rightLength, wallHeight, wall.thickness), + ) + right.position.set(length / 2 - rightLength / 2, wallHeight / 2, 0) + const headerHeight = wallHeight - door.height + const header = new THREE.Mesh( + new THREE.BoxGeometry(door.width, headerHeight, wall.thickness), + ) + header.position.set(0, door.height + headerHeight / 2, 0) + root.add(left, right, header) + } else { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(length, wallHeight, wall.thickness)) + mesh.position.y = wallHeight / 2 + root.add(mesh) + } + candidateSource.add(root) + } + const manufacturingSlabRoot = new THREE.Group() + manufacturingSlabRoot.userData = { pascalId: slab.id } + manufacturingSlabRoot.add(new THREE.Mesh(generateSlabGeometry(slab, EMPTY_SLAB_CONTEXT))) + candidateSource.add(manufacturingSlabRoot) + const canonicalRoof = buildPrintableRoofSegmentSolids(roof) + expect(canonicalRoof.status).toBe('ready') + candidateSource.add(canonicalRoof.object!) + + const candidate = await compilePrintShellWithManifold(candidateSource) + expect(candidate.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual( + [], + ) + expect(candidate.backend).toBe('manifold-3d') + expect(candidate.scene).not.toBeNull() + expect(indexedNonManifoldEdgeCount(candidate.scene!)).toBe(0) + expect(rayIntersectionCount(candidate.scene!, 0, 1, 0.8)).toBe(0) + expect(rayIntersectionCount(candidate.scene!, 1.5, 1, 0.8)).toBeGreaterThanOrEqual(2) + + const candidatePrint = exportSceneToPrintStl(candidate.scene!, { + scale: 100, + compiled: true, + indexedTopology: true, + }) + expect( + candidatePrint.report.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'), + ).toEqual([]) + expect(candidatePrint.report.degenerateTriangleCount).toBe(0) + expect(candidatePrint.report.boundaryEdgeCount).toBe(0) + expect(candidatePrint.report.nonManifoldEdgeCount).toBe(0) + expect(candidatePrint.report.volumeMm3).toBeGreaterThan(0) + } finally { + for (const wall of walls) sceneRegistry.nodes.delete(wall.id) + } + }) + test('blocks the generated display gable roof from print export', () => { const roof = RoofSegmentNode.parse({ id: 'rseg_print-shell-fixture', @@ -188,6 +394,40 @@ describe('print shell compiler baseline', () => { expect(new Uint8Array(firstPrint.buffer)).toEqual(new Uint8Array(secondPrint.buffer)) }) + test('blocks unsupported semantic roof cuts without falling back to display geometry', () => { + const roof = RoofSegmentNode.parse({ + id: 'rseg_print-shell-trimmed', + roofType: 'gable', + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + trim: { left: 0.25 }, + }) + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + const source = new THREE.Group() + source.add(roofRoot) + + const compiled = compileSemanticPrintShell(source, { [roof.id]: roof }) + + expect(compiled.status).toBe('blocked') + expect(compiled.scene).toBeNull() + expect(compiled.sourceNodeIds).toEqual([roof.id]) + expect(compiled.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'unsupported_roof_print_trim', + severity: 'error', + nodeIds: [roof.id], + }), + ) + }) + test('compiles canonical roof modules into deterministic manifold print shells', () => { for (const roofType of ROOF_TYPES) { const roof = RoofSegmentNode.parse({ @@ -202,18 +442,19 @@ describe('print shell compiler baseline', () => { overhang: 0.3, shingleThickness: 0.05, }) - const built = buildPrintableRoofSegmentSolids(roof) - - expect(built.status).toBe('ready') - expect(built.object).not.toBeNull() + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + const source = new THREE.Group() + source.add(roofRoot) - const compiled = compilePrintShellBaseline(built.object!) + const compiled = compileSemanticPrintShell(source, { [roof.id]: roof }) expect(compiled.status).toBe('compiled') expect(compiled.inputMeshCount).toBe(1) expect(compiled.sourceNodeIds).toEqual([roof.id]) expect(compiled.scene).not.toBeNull() - const print = exportSceneToPrintStl(compiled.scene!, { scale: 100 }) + const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) expect(print.report.status).toBe('pass') expect(print.report.degenerateTriangleCount).toBe(0) expect(print.report.boundaryEdgeCount).toBe(0) @@ -233,12 +474,22 @@ describe('print shell compiler baseline', () => { overhang: 0.3, shingleThickness: 0.05, }) - const first = buildPrintableRoofSegmentSolids(roof) - const second = buildPrintableRoofSegmentSolids(roof) - const firstCompiled = compilePrintShellBaseline(first.object!) - const secondCompiled = compilePrintShellBaseline(second.object!) - const firstPrint = exportSceneToPrintStl(firstCompiled.scene!, { scale: 100 }) - const secondPrint = exportSceneToPrintStl(secondCompiled.scene!, { scale: 100 }) + const firstSource = new THREE.Group() + const firstRoof = new THREE.Group() + firstRoof.userData = { pascalId: roof.id } + firstRoof.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + firstSource.add(firstRoof) + const secondSource = firstSource.clone(true) + const firstCompiled = compileSemanticPrintShell(firstSource, { [roof.id]: roof }) + const secondCompiled = compileSemanticPrintShell(secondSource, { [roof.id]: roof }) + const firstPrint = exportSceneToPrintStl(firstCompiled.scene!, { + scale: 100, + compiled: true, + }) + const secondPrint = exportSceneToPrintStl(secondCompiled.scene!, { + scale: 100, + compiled: true, + }) expect(firstPrint.report.bounds?.width).toBeCloseTo(46.6962, 3) expect(firstPrint.report.bounds?.depth).toBeCloseTo(37.1962, 3) diff --git a/packages/editor/src/lib/print-shell-compiler-baseline.ts b/packages/editor/src/lib/print-shell-compiler-baseline.ts index c320d45160..0c78f9ab90 100644 --- a/packages/editor/src/lib/print-shell-compiler-baseline.ts +++ b/packages/editor/src/lib/print-shell-compiler-baseline.ts @@ -10,7 +10,7 @@ export type PrintShellCompileDiagnostic = { } export type PrintShellCompileResult = { - backend: 'pascal-three-bvh-csg' + backend: 'pascal-three-bvh-csg' | 'manifold-3d' status: 'compiled' | 'blocked' scene: THREE.Object3D | null inputMeshCount: number @@ -18,6 +18,14 @@ export type PrintShellCompileResult = { diagnostics: PrintShellCompileDiagnostic[] } +export type PrintShellInput = { + inputMeshCount: number + sourceNodeIds: Set + geometries: THREE.BufferGeometry[] + geometryNodeIds: string[] + diagnostics: PrintShellCompileDiagnostic[] +} + function nearestPascalId(object: THREE.Object3D): string | null { let current: THREE.Object3D | null = object while (current) { @@ -60,33 +68,13 @@ function worldGeometry(mesh: THREE.Mesh): THREE.BufferGeometry { return indexed } -function blockedResult( - inputMeshCount: number, - sourceNodeIds: Set, - diagnostics: PrintShellCompileDiagnostic[], -): PrintShellCompileResult { - return { - backend: 'pascal-three-bvh-csg', - status: 'blocked', - scene: null, - inputMeshCount, - sourceNodeIds: Array.from(sourceNodeIds).sort(), - diagnostics, - } -} - -/** - * Synchronous baseline used only by print fixtures while backend correctness - * is evaluated. It unions world-space static meshes and preserves source node - * IDs at result level; it does not yet run in a worker or provide face-level - * provenance. - */ -export function compilePrintShellBaseline(source: THREE.Object3D): PrintShellCompileResult { +export function collectPrintShellInput(source: THREE.Object3D): PrintShellInput { source.updateMatrixWorld(true) const diagnostics: PrintShellCompileDiagnostic[] = [] const sourceNodeIds = new Set() const geometries: THREE.BufferGeometry[] = [] + const geometryNodeIds: string[] = [] let inputMeshCount = 0 source.traverse((object) => { @@ -130,6 +118,7 @@ export function compilePrintShellBaseline(source: THREE.Object3D): PrintShellCom } geometries.push(worldGeometry(mesh)) + geometryNodeIds.push(nodeId) }) if (inputMeshCount === 0) { @@ -140,6 +129,33 @@ export function compilePrintShellBaseline(source: THREE.Object3D): PrintShellCom nodeIds: [], }) } + + return { inputMeshCount, sourceNodeIds, geometries, geometryNodeIds, diagnostics } +} + +function blockedResult( + inputMeshCount: number, + sourceNodeIds: Set, + diagnostics: PrintShellCompileDiagnostic[], +): PrintShellCompileResult { + return { + backend: 'pascal-three-bvh-csg', + status: 'blocked', + scene: null, + inputMeshCount, + sourceNodeIds: Array.from(sourceNodeIds).sort(), + diagnostics, + } +} + +/** + * Synchronous baseline used only by print fixtures while backend correctness + * is evaluated. It unions world-space static meshes and preserves source node + * IDs at result level; it does not yet run in a worker or provide face-level + * provenance. + */ +export function compilePrintShellBaseline(source: THREE.Object3D): PrintShellCompileResult { + const { diagnostics, geometries, inputMeshCount, sourceNodeIds } = collectPrintShellInput(source) if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { for (const geometry of geometries) geometry.dispose() return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) diff --git a/packages/editor/src/lib/print-shell-compiler-manifold.ts b/packages/editor/src/lib/print-shell-compiler-manifold.ts new file mode 100644 index 0000000000..673a56dfad --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-manifold.ts @@ -0,0 +1,193 @@ +import ManifoldModule, { type Manifold as ManifoldSolid, type ManifoldToplevel } from 'manifold-3d' +import * as THREE from 'three' +import { + collectPrintShellInput, + type PrintShellCompileDiagnostic, + type PrintShellCompileResult, +} from './print-shell-compiler-baseline' + +let modulePromise: Promise | null = null + +async function getManifoldModule(): Promise { + modulePromise ??= ManifoldModule().then((module) => { + module.setup() + return module + }) + return modulePromise +} + +function manifoldMesh( + module: ManifoldToplevel, + geometry: THREE.BufferGeometry, +): InstanceType { + const position = geometry.getAttribute('position') + const vertProperties = new Float32Array(position.count * 3) + for (let index = 0; index < position.count; index += 1) { + vertProperties[index * 3] = position.getX(index) + vertProperties[index * 3 + 1] = position.getY(index) + vertProperties[index * 3 + 2] = position.getZ(index) + } + + const geometryIndex = geometry.getIndex() + const triVerts = new Uint32Array(geometryIndex?.count ?? position.count) + for (let index = 0; index < triVerts.length; index += 1) { + triVerts[index] = geometryIndex?.getX(index) ?? index + } + return new module.Mesh({ numProp: 3, vertProperties, triVerts }) +} + +function threeGeometry(solid: ManifoldSolid): THREE.BufferGeometry { + const mesh = solid.getMesh() + const positions = new Float32Array(mesh.numVert * 3) + for (let index = 0; index < mesh.numVert; index += 1) { + const sourceOffset = index * mesh.numProp + positions[index * 3] = mesh.vertProperties[sourceOffset]! + positions[index * 3 + 1] = mesh.vertProperties[sourceOffset + 1]! + positions[index * 3 + 2] = mesh.vertProperties[sourceOffset + 2]! + } + + const parents = new Uint32Array(mesh.numVert) + for (let index = 0; index < parents.length; index += 1) parents[index] = index + const find = (index: number): number => { + let root = index + while (parents[root] !== root) root = parents[root]! + while (parents[index] !== index) { + const next = parents[index]! + parents[index] = root + index = next + } + return root + } + for (let index = 0; index < mesh.mergeFromVert.length; index += 1) { + parents[find(mesh.mergeFromVert[index]!)] = find(mesh.mergeToVert[index]!) + } + + const triVerts: number[] = [] + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + for (let index = 0; index + 2 < mesh.triVerts.length; index += 3) { + const a = find(mesh.triVerts[index]!) + const b = find(mesh.triVerts[index + 1]!) + const c = find(mesh.triVerts[index + 2]!) + if (a === b || b === c || c === a) continue + ab.set( + positions[b * 3]! - positions[a * 3]!, + positions[b * 3 + 1]! - positions[a * 3 + 1]!, + positions[b * 3 + 2]! - positions[a * 3 + 2]!, + ) + ac.set( + positions[c * 3]! - positions[a * 3]!, + positions[c * 3 + 1]! - positions[a * 3 + 1]!, + positions[c * 3 + 2]! - positions[a * 3 + 2]!, + ) + if (ab.cross(ac).lengthSq() <= 1e-12) continue + triVerts.push(a, b, c) + } + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setIndex(triVerts) + geometry.computeVertexNormals() + return geometry +} + +function blockedResult( + inputMeshCount: number, + sourceNodeIds: Set, + diagnostics: PrintShellCompileDiagnostic[], +): PrintShellCompileResult { + return { + backend: 'manifold-3d', + status: 'blocked', + scene: null, + inputMeshCount, + sourceNodeIds: Array.from(sourceNodeIds).sort(), + diagnostics, + } +} + +export async function compilePrintShellWithManifold( + source: THREE.Object3D, +): Promise { + const { diagnostics, geometries, geometryNodeIds, inputMeshCount, sourceNodeIds } = + collectPrintShellInput(source) + if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + for (const geometry of geometries) geometry.dispose() + return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) + } + + const solids: ManifoldSolid[] = [] + let result: ManifoldSolid | null = null + try { + const module = await getManifoldModule() + for (const [index, geometry] of geometries.entries()) { + const nodeId = geometryNodeIds[index]! + try { + solids.push(new module.Manifold(manifoldMesh(module, geometry))) + } catch (error) { + diagnostics.push({ + severity: 'error', + code: 'manifold_input_failed', + message: `Node ${nodeId}: ${ + error instanceof Error ? error.message : 'Manifold rejected the shell input.' + }`, + nodeIds: [nodeId], + }) + } + } + if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) + } + const union = module.Manifold.union(solids) + result = union.asOriginal() + union.delete() + const status = result.status() + if (status !== 'NoError') { + diagnostics.push({ + severity: 'error', + code: 'manifold_union_failed', + message: `Manifold union failed with ${status}.`, + nodeIds: Array.from(sourceNodeIds).sort(), + }) + return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) + } + + const geometry = threeGeometry(result) + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()) + mesh.name = 'print-shell-manifold' + mesh.userData = { + printCompiler: 'manifold-3d', + sourceNodeIds: Array.from(sourceNodeIds).sort(), + } + const scene = new THREE.Group() + scene.name = 'compiled-print-shell' + scene.add(mesh) + diagnostics.push({ + severity: 'info', + code: 'manifold_compiler_candidate', + message: + 'Compiled with the test-only Manifold WASM candidate; worker packaging and production bundle impact remain unapproved.', + nodeIds: Array.from(sourceNodeIds).sort(), + }) + return { + backend: 'manifold-3d', + status: 'compiled', + scene, + inputMeshCount, + sourceNodeIds: Array.from(sourceNodeIds).sort(), + diagnostics, + } + } catch (error) { + diagnostics.push({ + severity: 'error', + code: 'manifold_input_failed', + message: error instanceof Error ? error.message : 'Manifold rejected the shell input.', + nodeIds: Array.from(sourceNodeIds).sort(), + }) + return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) + } finally { + for (const geometry of geometries) geometry.dispose() + for (const solid of solids) solid.delete() + result?.delete() + } +} diff --git a/packages/editor/src/lib/print-shell-compiler.ts b/packages/editor/src/lib/print-shell-compiler.ts new file mode 100644 index 0000000000..d44adb339d --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler.ts @@ -0,0 +1,103 @@ +import type { AnyNode, RoofSegmentNode } from '@pascal-app/core' +import { buildPrintableRoofSegmentSolids } from '@pascal-app/viewer' +import * as THREE from 'three' +import { + compilePrintShellBaseline, + type PrintShellCompileDiagnostic, + type PrintShellCompileResult, +} from './print-shell-compiler-baseline' + +function meshCount(root: THREE.Object3D): number { + let count = 0 + root.traverse((object) => { + const mesh = object as THREE.Mesh + const position = mesh.isMesh ? mesh.geometry?.getAttribute('position') : null + if (position && position.count > 0) count += 1 + }) + return count +} + +function replaceChild(parent: THREE.Object3D, target: THREE.Object3D, replacement: THREE.Object3D) { + const targetIndex = parent.children.indexOf(target) + parent.remove(target) + parent.add(replacement) + const appendedIndex = parent.children.indexOf(replacement) + parent.children.splice(appendedIndex, 1) + parent.children.splice(targetIndex, 0, replacement) +} + +function copyPreparedTransform(source: THREE.Object3D, target: THREE.Object3D) { + target.name = source.name + target.position.copy(source.position) + target.quaternion.copy(source.quaternion) + target.scale.copy(source.scale) + target.matrix.copy(source.matrix) + target.matrixAutoUpdate = source.matrixAutoUpdate + target.visible = source.visible + target.layers.mask = source.layers.mask + target.userData = { ...source.userData, printSource: 'canonical-roof' } +} + +function disposeGenerated(root: THREE.Object3D) { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh) mesh.geometry.dispose() + }) +} + +/** + * Compiles a semantic structural source instead of trusting display aggregates. + * Roof segments are replaced as complete identity subtrees so their hosted + * display CSG and accessory meshes cannot leak into the manufacturing shell. + */ +export function compileSemanticPrintShell( + source: THREE.Object3D, + nodes: Record, +): PrintShellCompileResult { + const scene = new THREE.Group() + scene.name = 'semantic-print-source' + scene.add(source.clone(true)) + + const roofTargets: { node: RoofSegmentNode; object: THREE.Object3D }[] = [] + scene.traverse((object) => { + const id = object.userData.pascalId + const node = typeof id === 'string' ? nodes[id] : undefined + if (node?.type === 'roof-segment') roofTargets.push({ node, object }) + }) + + const diagnostics: PrintShellCompileDiagnostic[] = [] + const replacements: { target: THREE.Object3D; replacement: THREE.Group }[] = [] + for (const { node, object } of roofTargets) { + const result = buildPrintableRoofSegmentSolids(node, nodes) + if (result.status === 'blocked') { + diagnostics.push(...result.diagnostics) + continue + } + copyPreparedTransform(object, result.object) + replacements.push({ target: object, replacement: result.object }) + } + + if (diagnostics.length > 0) { + for (const { replacement } of replacements) disposeGenerated(replacement) + return { + backend: 'pascal-three-bvh-csg', + status: 'blocked', + scene: null, + inputMeshCount: meshCount(scene), + sourceNodeIds: Array.from( + new Set(diagnostics.flatMap((diagnostic) => diagnostic.nodeIds)), + ).sort(), + diagnostics, + } + } + + for (const { target, replacement } of replacements) { + if (target.parent) replaceChild(target.parent, target, replacement) + } + + try { + return compilePrintShellBaseline(scene) + } finally { + for (const { replacement } of replacements) disposeGenerated(replacement) + } +} From 1f0575415cfa7f070b3703bf2569b3f6c6876b4d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 14:34:45 -0400 Subject: [PATCH 11/19] feat: add canonical printable wall solids --- .../lib/print-shell-compiler-baseline.test.ts | 104 +++--- .../editor/src/lib/print-shell-compiler.ts | 111 +++++- packages/viewer/src/index.ts | 8 + .../systems/wall/wall-print-solids.test.ts | 194 +++++++++++ .../src/systems/wall/wall-print-solids.ts | 327 ++++++++++++++++++ 5 files changed, 696 insertions(+), 48 deletions(-) create mode 100644 packages/viewer/src/systems/wall/wall-print-solids.test.ts create mode 100644 packages/viewer/src/systems/wall/wall-print-solids.ts diff --git a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts index 4b5881ec40..7e570db3cd 100644 --- a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts +++ b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts @@ -8,9 +8,11 @@ import { type SlabPolygonContext, sceneRegistry, WallNode, + WindowNode, } from '@pascal-app/core' import { buildPrintableRoofSegmentSolids, + buildPrintableWallSolids, generateExtrudedWall, generateRoofSegmentGeometry, generateSlabGeometry, @@ -178,7 +180,7 @@ describe('print shell compiler baseline', () => { } }) - test('compares the full-house baseline union with the Manifold candidate', async () => { + test('compares the semantic full-house baseline union with the Manifold candidate', async () => { const walls = [ WallNode.parse({ id: 'wall_print-house-front', @@ -186,6 +188,7 @@ describe('print shell compiler baseline', () => { end: [2, -1.5], height: 2.5, thickness: 0.2, + children: ['door_print-house-front'], }), WallNode.parse({ id: 'wall_print-house-right', @@ -200,6 +203,7 @@ describe('print shell compiler baseline', () => { end: [-2, 1.5], height: 2.5, thickness: 0.2, + children: ['window_print-house-back'], }), WallNode.parse({ id: 'wall_print-house-left', @@ -216,6 +220,13 @@ describe('print shell compiler baseline', () => { width: 0.9, height: 2.1, }) + const window = WindowNode.parse({ + id: 'window_print-house-back', + wallId: walls[2]!.id, + position: [2, 1.4, 0], + width: 1.2, + height: 1, + }) const slab = SlabNode.parse({ id: 'slab_print-house', elevation: 0, @@ -243,17 +254,21 @@ describe('print shell compiler baseline', () => { const source = new THREE.Group() const miters = calculateLevelMiters(walls) const nodes = Object.fromEntries( - [...walls, slab, roof].map((node) => [node.id, node]), - ) as Record + [...walls, door, window, slab, roof].map((node) => [node.id, node]), + ) as Record try { for (const wall of walls) { const root = new THREE.Group() root.userData = { pascalId: wall.id } sceneRegistry.nodes.set(wall.id, root) - root.add( - new THREE.Mesh(generateExtrudedWall(wall, wall.id === door.wallId ? [door] : [], miters)), - ) + const openings = [door, window].filter((opening) => opening.wallId === wall.id) + root.add(new THREE.Mesh(generateExtrudedWall(wall, openings, miters))) + for (const opening of openings) { + const openingRoot = new THREE.Group() + openingRoot.userData = { pascalId: opening.id } + root.add(openingRoot) + } source.add(root) } const slabRoot = new THREE.Group() @@ -267,55 +282,27 @@ describe('print shell compiler baseline', () => { roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) source.add(roofRoot) - const compiled = compileSemanticPrintShell(source, nodes) + const compiled = compileSemanticPrintShell(source, nodes, { wallSolids: true }) expect(compiled.status).toBe('compiled') - expect(compiled.inputMeshCount).toBe(6) - expect(compiled.sourceNodeIds).toEqual(Object.keys(nodes).sort()) + expect(compiled.inputMeshCount).toBe(11) + expect(compiled.sourceNodeIds).toEqual([...walls, slab, roof].map((node) => node.id).sort()) expect(compiled.scene).not.toBeNull() const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) expect(print.report.status).toBe('blocked') - expect(print.report.degenerateTriangleCount).toBeGreaterThan(0) - expect(print.report.boundaryEdgeCount).toBeGreaterThan(0) - expect(print.report.nonManifoldEdgeCount).toBeGreaterThan(0) + expect(print.report.degenerateTriangleCount).toBe(1_760) + expect(print.report.boundaryEdgeCount).toBe(1_286) + expect(print.report.nonManifoldEdgeCount).toBe(10) expect(print.report.volumeMm3).toBeGreaterThan(0) const candidateSource = new THREE.Group() for (const wall of walls) { - const root = new THREE.Group() - root.userData = { pascalId: wall.id } - const dx = wall.end[0] - wall.start[0] - const dz = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dz) const wallHeight = wall.height if (wallHeight === undefined) throw new Error(`Missing height for ${wall.id}`) - root.position.set((wall.start[0] + wall.end[0]) / 2, 0, (wall.start[1] + wall.end[1]) / 2) - root.rotation.y = Math.atan2(-dz, dx) - - if (wall.id === door.wallId) { - const doorStart = door.position[0] - door.width / 2 - const doorEnd = door.position[0] + door.width / 2 - const leftLength = doorStart - const rightLength = length - doorEnd - const left = new THREE.Mesh(new THREE.BoxGeometry(leftLength, wallHeight, wall.thickness)) - left.position.set(-length / 2 + leftLength / 2, wallHeight / 2, 0) - const right = new THREE.Mesh( - new THREE.BoxGeometry(rightLength, wallHeight, wall.thickness), - ) - right.position.set(length / 2 - rightLength / 2, wallHeight / 2, 0) - const headerHeight = wallHeight - door.height - const header = new THREE.Mesh( - new THREE.BoxGeometry(door.width, headerHeight, wall.thickness), - ) - header.position.set(0, door.height + headerHeight / 2, 0) - root.add(left, right, header) - } else { - const mesh = new THREE.Mesh(new THREE.BoxGeometry(length, wallHeight, wall.thickness)) - mesh.position.y = wallHeight / 2 - root.add(mesh) - } - candidateSource.add(root) + const result = buildPrintableWallSolids(wall, { effectiveHeight: wallHeight }, nodes) + expect(result.status).toBe('ready') + candidateSource.add(result.object!) } const manufacturingSlabRoot = new THREE.Group() manufacturingSlabRoot.userData = { pascalId: slab.id } @@ -334,6 +321,7 @@ describe('print shell compiler baseline', () => { expect(indexedNonManifoldEdgeCount(candidate.scene!)).toBe(0) expect(rayIntersectionCount(candidate.scene!, 0, 1, 0.8)).toBe(0) expect(rayIntersectionCount(candidate.scene!, 1.5, 1, 0.8)).toBeGreaterThanOrEqual(2) + expect(rayIntersectionCount(candidate.scene!, 0, 1.4, 4)).toBe(0) const candidatePrint = exportSceneToPrintStl(candidate.scene!, { scale: 100, @@ -350,6 +338,36 @@ describe('print shell compiler baseline', () => { } finally { for (const wall of walls) sceneRegistry.nodes.delete(wall.id) } + }, 15_000) + + test('blocks unsupported semantic wall forms without falling back to display geometry', () => { + const wall = WallNode.parse({ + id: 'wall_print-shell-curved', + start: [0, 0], + end: [4, 0], + height: 2.5, + thickness: 0.2, + curveOffset: 0.5, + }) + const wallRoot = new THREE.Group() + wallRoot.userData = { pascalId: wall.id } + const displayMesh = new THREE.Mesh(new THREE.BoxGeometry(4, 2.5, 0.2)) + displayMesh.position.set(2, 1.25, 0) + wallRoot.add(displayMesh) + const source = new THREE.Group() + source.add(wallRoot) + + const compiled = compileSemanticPrintShell(source, { [wall.id]: wall }, { wallSolids: true }) + + expect(compiled.status).toBe('blocked') + expect(compiled.scene).toBeNull() + expect(compiled.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'unsupported_wall_print_curve', + severity: 'error', + nodeIds: [wall.id], + }), + ) }) test('blocks the generated display gable roof from print export', () => { diff --git a/packages/editor/src/lib/print-shell-compiler.ts b/packages/editor/src/lib/print-shell-compiler.ts index d44adb339d..b14dc4096d 100644 --- a/packages/editor/src/lib/print-shell-compiler.ts +++ b/packages/editor/src/lib/print-shell-compiler.ts @@ -1,5 +1,5 @@ -import type { AnyNode, RoofSegmentNode } from '@pascal-app/core' -import { buildPrintableRoofSegmentSolids } from '@pascal-app/viewer' +import type { AnyNode, RoofSegmentNode, WallNode } from '@pascal-app/core' +import { buildPrintableRoofSegmentSolids, buildPrintableWallSolids } from '@pascal-app/viewer' import * as THREE from 'three' import { compilePrintShellBaseline, @@ -7,6 +7,10 @@ import { type PrintShellCompileResult, } from './print-shell-compiler-baseline' +export type SemanticPrintCompileOptions = { + wallSolids?: boolean +} + function meshCount(root: THREE.Object3D): number { let count = 0 root.traverse((object) => { @@ -26,7 +30,11 @@ function replaceChild(parent: THREE.Object3D, target: THREE.Object3D, replacemen parent.children.splice(targetIndex, 0, replacement) } -function copyPreparedTransform(source: THREE.Object3D, target: THREE.Object3D) { +function copyPreparedTransform( + source: THREE.Object3D, + target: THREE.Object3D, + printSource: 'canonical-roof' | 'canonical-wall', +) { target.name = source.name target.position.copy(source.position) target.quaternion.copy(source.quaternion) @@ -35,7 +43,7 @@ function copyPreparedTransform(source: THREE.Object3D, target: THREE.Object3D) { target.matrixAutoUpdate = source.matrixAutoUpdate target.visible = source.visible target.layers.mask = source.layers.mask - target.userData = { ...source.userData, printSource: 'canonical-roof' } + target.userData = { ...source.userData, printSource } } function disposeGenerated(root: THREE.Object3D) { @@ -45,6 +53,77 @@ function disposeGenerated(root: THREE.Object3D) { }) } +function exportedIdentityIds(root: THREE.Object3D): Set { + const ids = new Set() + root.traverse((object) => { + if (typeof object.userData.pascalId === 'string') ids.add(object.userData.pascalId) + }) + return ids +} + +function ownedLocalYBounds(root: THREE.Object3D): { min: number; max: number } | null { + root.updateMatrixWorld(true) + const inverseRoot = root.matrixWorld.clone().invert() + const point = new THREE.Vector3() + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + + const visit = (object: THREE.Object3D) => { + if ( + object !== root && + typeof object.userData.pascalId === 'string' && + object.userData.pascalId !== root.userData.pascalId + ) { + return + } + const mesh = object as THREE.Mesh + const position = mesh.isMesh ? mesh.geometry.getAttribute('position') : null + if (position) { + const toRoot = inverseRoot.clone().multiply(object.matrixWorld) + for (let index = 0; index < position.count; index += 1) { + point.fromBufferAttribute(position, index).applyMatrix4(toRoot) + min = Math.min(min, point.y) + max = Math.max(max, point.y) + } + } + for (const child of object.children) visit(child) + } + visit(root) + return Number.isFinite(min) && Number.isFinite(max) ? { min, max } : null +} + +function preparedWallHeight( + node: WallNode, + object: THREE.Object3D, +): + | { height: number; diagnostic: null } + | { height: null; diagnostic: PrintShellCompileDiagnostic } { + const bounds = ownedLocalYBounds(object) + if (!bounds || bounds.max <= 1e-7) { + return { + height: null, + diagnostic: { + severity: 'error', + code: 'invalid_wall_print_dimensions', + message: `Wall ${node.id} has no finite prepared height for print compilation.`, + nodeIds: [node.id], + }, + } + } + if (bounds.min < -1e-5 || bounds.min > 1e-5) { + return { + height: null, + diagnostic: { + severity: 'error', + code: 'unsupported_wall_print_base', + message: `Wall ${node.id} has a stepped or displaced local base that does not yet have a canonical printable solid.`, + nodeIds: [node.id], + }, + } + } + return { height: bounds.max, diagnostic: null } +} + /** * Compiles a semantic structural source instead of trusting display aggregates. * Roof segments are replaced as complete identity subtrees so their hosted @@ -53,16 +132,20 @@ function disposeGenerated(root: THREE.Object3D) { export function compileSemanticPrintShell( source: THREE.Object3D, nodes: Record, + options: SemanticPrintCompileOptions = {}, ): PrintShellCompileResult { const scene = new THREE.Group() scene.name = 'semantic-print-source' scene.add(source.clone(true)) + const includedNodeIds = exportedIdentityIds(scene) const roofTargets: { node: RoofSegmentNode; object: THREE.Object3D }[] = [] + const wallTargets: { node: WallNode; object: THREE.Object3D }[] = [] scene.traverse((object) => { const id = object.userData.pascalId const node = typeof id === 'string' ? nodes[id] : undefined if (node?.type === 'roof-segment') roofTargets.push({ node, object }) + if (options.wallSolids && node?.type === 'wall') wallTargets.push({ node, object }) }) const diagnostics: PrintShellCompileDiagnostic[] = [] @@ -73,7 +156,25 @@ export function compileSemanticPrintShell( diagnostics.push(...result.diagnostics) continue } - copyPreparedTransform(object, result.object) + copyPreparedTransform(object, result.object, 'canonical-roof') + replacements.push({ target: object, replacement: result.object }) + } + for (const { node, object } of wallTargets) { + const prepared = preparedWallHeight(node, object) + if (prepared.diagnostic) { + diagnostics.push(prepared.diagnostic) + continue + } + const result = buildPrintableWallSolids( + node, + { effectiveHeight: prepared.height, includedNodeIds }, + nodes, + ) + if (result.status === 'blocked') { + diagnostics.push(...result.diagnostics) + continue + } + copyPreparedTransform(object, result.object, 'canonical-wall') replacements.push({ target: object, replacement: result.object }) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index f301808f94..17366c6caf 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -257,6 +257,14 @@ export { } from './systems/wall/opening-cutout-geometry' export { getWallHideState, WallCutout } from './systems/wall/wall-cutout' export { getVisibleWallMaterials } from './systems/wall/wall-materials' +// Canonical wall manufacturing solids stay separate from display CSG so +// print compilation never has to repair the render mesh. +export { + buildPrintableWallSolids, + type PrintWallSolidDiagnostic, + type PrintWallSolidOptions, + type PrintWallSolidResult, +} from './systems/wall/wall-print-solids' // Wall internals re-exported so `@pascal-app/nodes`' registry-driven wall // definition can compose them into `def.system` without duplicating the // 800+ lines of CSG / mitering logic during Phase 3. These exports are diff --git a/packages/viewer/src/systems/wall/wall-print-solids.test.ts b/packages/viewer/src/systems/wall/wall-print-solids.test.ts new file mode 100644 index 0000000000..550a78cbdc --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-print-solids.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'bun:test' +import { DoorNode, WallNode, WindowNode } from '@pascal-app/core' +import * as THREE from 'three' +import { buildPrintableWallSolids } from './wall-print-solids' + +function rayIntersectionCount(root: THREE.Object3D, x: number, y: number): number { + root.updateMatrixWorld(true) + const raycaster = new THREE.Raycaster(new THREE.Vector3(x, y, -1), new THREE.Vector3(0, 0, 1)) + const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }) + let count = 0 + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const originalMaterial = mesh.material + mesh.material = material + count += raycaster.intersectObject(mesh, false).length + mesh.material = originalMaterial + }) + material.dispose() + return count +} + +function expectClosedBoxMeshes(root: THREE.Group) { + for (const object of root.children) { + const mesh = object as THREE.Mesh + expect(mesh.isMesh).toBe(true) + const index = mesh.geometry.getIndex() + expect(index).not.toBeNull() + const edges = new Map() + const add = (a: number, b: number) => { + const key = a < b ? `${a}|${b}` : `${b}|${a}` + edges.set(key, (edges.get(key) ?? 0) + 1) + } + for (let offset = 0; offset + 2 < index!.count; offset += 3) { + const a = index!.getX(offset) + const b = index!.getX(offset + 1) + const c = index!.getX(offset + 2) + add(a, b) + add(b, c) + add(c, a) + } + expect(Array.from(edges.values()).every((uses) => uses === 2)).toBe(true) + } +} + +function dispose(root: THREE.Group) { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh) mesh.geometry.dispose() + }) +} + +describe('buildPrintableWallSolids', () => { + test('builds deterministic closed solids around rectangular door and window voids', () => { + const door = DoorNode.parse({ + id: 'door_print-wall', + wallId: 'wall_print-openings', + position: [1.5, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const window = WindowNode.parse({ + id: 'window_print-wall', + wallId: 'wall_print-openings', + position: [4.5, 1.4, 0], + width: 1.2, + height: 1.0, + }) + const wall = WallNode.parse({ + id: 'wall_print-openings', + start: [0, 0], + end: [6, 0], + height: 2.5, + thickness: 0.2, + children: [door.id, window.id], + }) + const nodes = { [door.id]: door, [window.id]: window } + const first = buildPrintableWallSolids(wall, { effectiveHeight: 2.5 }, nodes) + const second = buildPrintableWallSolids(wall, { effectiveHeight: 2.5 }, nodes) + + expect(first.status).toBe('ready') + expect(second.status).toBe('ready') + expect(first.object).not.toBeNull() + expect(second.object).not.toBeNull() + expect(first.object!.userData.pascalId).toBe(wall.id) + expectClosedBoxMeshes(first.object!) + + const bounds = new THREE.Box3().setFromObject(first.object!) + expect(bounds.min.x).toBeCloseTo(0, 6) + expect(bounds.min.y).toBeCloseTo(0, 6) + expect(bounds.min.z).toBeCloseTo(-0.1, 6) + expect(bounds.max.x).toBeCloseTo(6, 6) + expect(bounds.max.y).toBeCloseTo(2.5, 6) + expect(bounds.max.z).toBeCloseTo(0.1, 6) + expect(rayIntersectionCount(first.object!, 1.5, 1)).toBe(0) + expect(rayIntersectionCount(first.object!, 4.5, 1.4)).toBe(0) + expect(rayIntersectionCount(first.object!, 3, 1)).toBeGreaterThanOrEqual(2) + + expect(first.object!.children.map((child) => child.position.toArray())).toEqual( + second.object!.children.map((child) => child.position.toArray()), + ) + expect( + first.object!.children.map((child) => + Array.from((child as THREE.Mesh).geometry.getAttribute('position').array), + ), + ).toEqual( + second.object!.children.map((child) => + Array.from((child as THREE.Mesh).geometry.getAttribute('position').array), + ), + ) + + const hiddenOpenings = buildPrintableWallSolids( + wall, + { effectiveHeight: 2.5, includedNodeIds: new Set() }, + nodes, + ) + expect(hiddenOpenings.status).toBe('ready') + expect(rayIntersectionCount(hiddenOpenings.object!, 1.5, 1)).toBeGreaterThanOrEqual(2) + + dispose(first.object!) + dispose(second.object!) + dispose(hiddenOpenings.object!) + }) + + test('preserves the authored wall-local transform', () => { + const wall = WallNode.parse({ + id: 'wall_print-transform', + start: [1, 2], + end: [1, 6], + thickness: 0.2, + }) + const result = buildPrintableWallSolids(wall, { effectiveHeight: 3 }) + + expect(result.status).toBe('ready') + expect(result.object?.position.toArray()).toEqual([1, 0, 2]) + expect(result.object?.rotation.y).toBeCloseTo(-Math.PI / 2) + dispose(result.object!) + }) + + test('blocks unsupported wall forms and invalid opening contracts', () => { + const shaped = DoorNode.parse({ + id: 'door_print-wall-arch', + wallId: 'wall_print-blocked', + position: [2, 1.05, 0], + openingShape: 'arch', + }) + const wall = WallNode.parse({ + id: 'wall_print-blocked', + start: [0, 0], + end: [4, 0], + children: [shaped.id], + }) + const curved = buildPrintableWallSolids( + { ...wall, curveOffset: 0.5 }, + { effectiveHeight: 2.5 }, + { [shaped.id]: shaped }, + ) + const terrain = buildPrintableWallSolids( + { ...wall, children: [], fillToTerrain: true }, + { effectiveHeight: 2.5 }, + ) + const shapedResult = buildPrintableWallSolids( + wall, + { effectiveHeight: 2.5 }, + { [shaped.id]: shaped }, + ) + const unresolved = buildPrintableWallSolids(wall, { effectiveHeight: 2.5 }, {}) + + expect(curved).toEqual( + expect.objectContaining({ + status: 'blocked', + diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_curve' })], + }), + ) + expect(terrain).toEqual( + expect.objectContaining({ + status: 'blocked', + diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_terrain' })], + }), + ) + expect(shapedResult).toEqual( + expect.objectContaining({ + status: 'blocked', + diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_opening_shape' })], + }), + ) + expect(unresolved).toEqual( + expect.objectContaining({ + status: 'blocked', + diagnostics: [expect.objectContaining({ code: 'unresolved_wall_print_child' })], + }), + ) + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-print-solids.ts b/packages/viewer/src/systems/wall/wall-print-solids.ts new file mode 100644 index 0000000000..25efe38831 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-print-solids.ts @@ -0,0 +1,327 @@ +import { + type AnyNode, + type DoorNode, + getWallThickness, + type WallNode, + type WindowNode, +} from '@pascal-app/core' +import * as THREE from 'three' +import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + +const DIMENSION_EPSILON = 1e-7 +const SOLID_JOIN_OVERLAP = 1e-5 + +type PrintWallOpening = DoorNode | WindowNode + +type OpeningInterval = { + node: PrintWallOpening + left: number + right: number + bottom: number + top: number +} + +export type PrintWallSolidDiagnostic = { + severity: 'error' + code: + | 'invalid_wall_print_dimensions' + | 'unsupported_wall_print_curve' + | 'unsupported_wall_print_terrain' + | 'unsupported_wall_print_opening_shape' + | 'invalid_wall_print_opening' + | 'unresolved_wall_print_child' + message: string + nodeIds: string[] +} + +export type PrintWallSolidOptions = { + effectiveHeight: number + includedNodeIds?: ReadonlySet +} + +export type PrintWallSolidResult = + | { status: 'ready'; object: THREE.Group; diagnostics: [] } + | { status: 'blocked'; object: null; diagnostics: PrintWallSolidDiagnostic[] } + +function finite(values: number[]): boolean { + return values.every(Number.isFinite) +} + +function openingInterval( + wall: WallNode, + opening: PrintWallOpening, + length: number, + height: number, +): { interval: OpeningInterval | null; diagnostic: PrintWallSolidDiagnostic | null } { + if (opening.wallId && opening.wallId !== wall.id) { + return { + interval: null, + diagnostic: { + severity: 'error', + code: 'invalid_wall_print_opening', + message: `Opening ${opening.id} is listed by wall ${wall.id} but references ${opening.wallId}.`, + nodeIds: [wall.id, opening.id].sort(), + }, + } + } + if (opening.openingShape !== 'rectangle') { + return { + interval: null, + diagnostic: { + severity: 'error', + code: 'unsupported_wall_print_opening_shape', + message: `Opening ${opening.id} uses a ${opening.openingShape} profile that does not yet have a printable wall fixture.`, + nodeIds: [wall.id, opening.id].sort(), + }, + } + } + + const [centerX, centerY] = opening.position + const { width, height: openingHeight } = opening + if ( + !finite([centerX, centerY, width, openingHeight]) || + width <= DIMENSION_EPSILON || + openingHeight <= DIMENSION_EPSILON + ) { + return { + interval: null, + diagnostic: { + severity: 'error', + code: 'invalid_wall_print_opening', + message: `Opening ${opening.id} has invalid printable dimensions.`, + nodeIds: [wall.id, opening.id].sort(), + }, + } + } + + const interval = { + node: opening, + left: centerX - width / 2, + right: centerX + width / 2, + bottom: centerY - openingHeight / 2, + top: centerY + openingHeight / 2, + } + if ( + interval.left < -DIMENSION_EPSILON || + interval.right > length + DIMENSION_EPSILON || + interval.bottom < -DIMENSION_EPSILON || + interval.top > height + DIMENSION_EPSILON || + interval.left >= interval.right - DIMENSION_EPSILON || + interval.bottom >= interval.top - DIMENSION_EPSILON + ) { + return { + interval: null, + diagnostic: { + severity: 'error', + code: 'invalid_wall_print_opening', + message: `Opening ${opening.id} extends outside printable wall ${wall.id}.`, + nodeIds: [wall.id, opening.id].sort(), + }, + } + } + + interval.left = THREE.MathUtils.clamp(interval.left, 0, length) + interval.right = THREE.MathUtils.clamp(interval.right, 0, length) + interval.bottom = THREE.MathUtils.clamp(interval.bottom, 0, height) + interval.top = THREE.MathUtils.clamp(interval.top, 0, height) + return { interval, diagnostic: null } +} + +function collectOpenings( + wall: WallNode, + nodes: Record | undefined, + options: PrintWallSolidOptions, + length: number, +): { openings: OpeningInterval[]; diagnostics: PrintWallSolidDiagnostic[] } { + const openings: OpeningInterval[] = [] + const diagnostics: PrintWallSolidDiagnostic[] = [] + + for (const childId of wall.children) { + const child = nodes?.[childId] + if (!child) { + diagnostics.push({ + severity: 'error', + code: 'unresolved_wall_print_child', + message: `Wall ${wall.id} references unresolved child ${childId}.`, + nodeIds: [wall.id, childId].sort(), + }) + continue + } + if (options.includedNodeIds && !options.includedNodeIds.has(child.id)) continue + if (child.type !== 'door' && child.type !== 'window') continue + + const result = openingInterval(wall, child, length, options.effectiveHeight) + if (result.diagnostic) diagnostics.push(result.diagnostic) + if (result.interval) openings.push(result.interval) + } + + return { openings, diagnostics } +} + +function uniqueBreakpoints(values: number[]): number[] { + const sorted = [...values].sort((a, b) => a - b) + const result: number[] = [] + for (const value of sorted) { + if (result.length === 0 || value - result[result.length - 1]! > DIMENSION_EPSILON) { + result.push(value) + } + } + return result +} + +function mergedVerticalCuts(openings: OpeningInterval[], x: number): [number, number][] { + const intervals = openings + .filter((opening) => opening.left < x && opening.right > x) + .map((opening) => [opening.bottom, opening.top] as [number, number]) + .sort((a, b) => a[0] - b[0]) + const merged: [number, number][] = [] + + for (const interval of intervals) { + const previous = merged[merged.length - 1] + if (!previous || interval[0] > previous[1] + DIMENSION_EPSILON) { + merged.push([...interval]) + } else { + previous[1] = Math.max(previous[1], interval[1]) + } + } + return merged +} + +function addSolid( + root: THREE.Group, + wallId: string, + index: number, + left: number, + right: number, + bottom: number, + top: number, + thickness: number, + wallLength: number, +) { + if (right - left <= DIMENSION_EPSILON || top - bottom <= DIMENSION_EPSILON) return + const joinedLeft = Math.max(0, left - (left > DIMENSION_EPSILON ? SOLID_JOIN_OVERLAP : 0)) + const joinedRight = Math.min( + wallLength, + right + (right < wallLength - DIMENSION_EPSILON ? SOLID_JOIN_OVERLAP : 0), + ) + const box = new THREE.BoxGeometry(joinedRight - joinedLeft, top - bottom, thickness) + box.deleteAttribute('normal') + box.deleteAttribute('uv') + const geometry = mergeVertices(box, DIMENSION_EPSILON) + box.dispose() + geometry.computeVertexNormals() + const mesh = new THREE.Mesh(geometry) + mesh.name = `print-wall-solid-${index}` + mesh.position.set((joinedLeft + joinedRight) / 2, (bottom + top) / 2, 0) + mesh.userData = { pascalId: wallId } + root.add(mesh) +} + +export function buildPrintableWallSolids( + node: WallNode, + options: PrintWallSolidOptions, + nodes?: Record, +): PrintWallSolidResult { + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const length = Math.hypot(dx, dz) + const thickness = getWallThickness(node) + const diagnostics: PrintWallSolidDiagnostic[] = [] + + if ( + !finite([ + node.start[0], + node.start[1], + node.end[0], + node.end[1], + length, + thickness, + options.effectiveHeight, + ]) || + length <= DIMENSION_EPSILON || + thickness <= DIMENSION_EPSILON || + options.effectiveHeight <= DIMENSION_EPSILON + ) { + diagnostics.push({ + severity: 'error', + code: 'invalid_wall_print_dimensions', + message: `Wall ${node.id} has invalid printable length, thickness, or height.`, + nodeIds: [node.id], + }) + } + if (Math.abs(node.curveOffset ?? 0) > DIMENSION_EPSILON) { + diagnostics.push({ + severity: 'error', + code: 'unsupported_wall_print_curve', + message: `Curved wall ${node.id} does not yet have a canonical printable solid.`, + nodeIds: [node.id], + }) + } + if (node.fillToTerrain) { + diagnostics.push({ + severity: 'error', + code: 'unsupported_wall_print_terrain', + message: `Terrain-filled wall ${node.id} requires a terrain-aware printable base fixture.`, + nodeIds: [node.id], + }) + } + if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics } + + const collected = collectOpenings(node, nodes, options, length) + diagnostics.push(...collected.diagnostics) + if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics } + + const root = new THREE.Group() + root.name = 'print-wall-solids' + root.userData = { pascalId: node.id } + root.position.set(node.start[0], 0, node.start[1]) + root.rotation.y = -Math.atan2(dz, dx) + + const breakpoints = uniqueBreakpoints([ + 0, + length, + ...collected.openings.flatMap((opening) => [opening.left, opening.right]), + ]) + let solidIndex = 0 + for (let index = 0; index < breakpoints.length - 1; index += 1) { + const left = breakpoints[index]! + const right = breakpoints[index + 1]! + if (right - left <= DIMENSION_EPSILON) continue + const cuts = mergedVerticalCuts(collected.openings, (left + right) / 2) + let bottom = 0 + for (const [cutBottom, cutTop] of cuts) { + addSolid(root, node.id, solidIndex, left, right, bottom, cutBottom, thickness, length) + if (cutBottom - bottom > DIMENSION_EPSILON) solidIndex += 1 + bottom = Math.max(bottom, cutTop) + } + addSolid( + root, + node.id, + solidIndex, + left, + right, + bottom, + options.effectiveHeight, + thickness, + length, + ) + if (options.effectiveHeight - bottom > DIMENSION_EPSILON) solidIndex += 1 + } + + if (root.children.length === 0) { + return { + status: 'blocked', + object: null, + diagnostics: [ + { + severity: 'error', + code: 'invalid_wall_print_opening', + message: `Openings remove all printable material from wall ${node.id}.`, + nodeIds: [node.id, ...collected.openings.map((opening) => opening.node.id)].sort(), + }, + ], + } + } + + return { status: 'ready', object: root, diagnostics: [] } +} From 35c098f9f99b19a1f52c957b62d0c122fa7d5391 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 15:08:06 -0400 Subject: [PATCH 12/19] feat: compile print shells in a Manifold worker --- apps/editor/next.config.ts | 7 + bun.lock | 2 +- packages/editor/package.json | 2 +- .../src/components/editor/export-manager.tsx | 10 +- .../settings-panel/print-export-card.tsx | 2 +- .../editor/src/lib/level-print-export.test.ts | 41 ++-- packages/editor/src/lib/level-print-export.ts | 20 +- .../lib/print-shell-compiler-baseline.test.ts | 106 ++++++++-- .../lib/print-shell-compiler-manifold-core.ts | 199 ++++++++++++++++++ .../print-shell-compiler-manifold-worker.ts | 187 ++++++++++++++++ .../src/lib/print-shell-compiler-manifold.ts | 195 ++++------------- .../print-shell-compiler-manifold.worker.ts | 21 ++ .../src/lib/print-shell-compiler-mesh-data.ts | 32 +++ .../src/lib/print-shell-compiler-protocol.ts | 30 +++ .../editor/src/lib/print-shell-compiler.ts | 108 ++++++++-- packages/editor/src/manifold-wasm.d.ts | 4 + 16 files changed, 739 insertions(+), 227 deletions(-) create mode 100644 packages/editor/src/lib/print-shell-compiler-manifold-core.ts create mode 100644 packages/editor/src/lib/print-shell-compiler-manifold-worker.ts create mode 100644 packages/editor/src/lib/print-shell-compiler-manifold.worker.ts create mode 100644 packages/editor/src/lib/print-shell-compiler-mesh-data.ts create mode 100644 packages/editor/src/lib/print-shell-compiler-protocol.ts create mode 100644 packages/editor/src/manifold-wasm.d.ts diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 48578416b9..f25bae4022 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -38,7 +38,14 @@ const nextConfig: NextConfig = { '@pascal-app/plugin-bones', '@dgreenheck/ez-tree', ], + webpack(config) { + config.module.rules.push({ test: /\.wasm$/, type: 'asset/resource' }) + return config + }, turbopack: { + rules: { + '*.wasm': { type: 'asset' }, + }, resolveAlias: { react: './node_modules/react', three: './node_modules/three', diff --git a/bun.lock b/bun.lock index 257ff087ef..c212da186b 100644 --- a/bun.lock +++ b/bun.lock @@ -170,6 +170,7 @@ "fflate": "^0.8.3", "howler": "^2.2.4", "lucide-react": "^1.7.0", + "manifold-3d": "3.5.1", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", @@ -190,7 +191,6 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", - "manifold-3d": "3.5.1", "typescript": "6.0.3", }, "peerDependencies": { diff --git a/packages/editor/package.json b/packages/editor/package.json index 2ea12365c9..4a05d63e78 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -48,6 +48,7 @@ "fflate": "^0.8.3", "howler": "^2.2.4", "lucide-react": "^1.7.0", + "manifold-3d": "3.5.1", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", @@ -68,7 +69,6 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", - "manifold-3d": "3.5.1", "typescript": "6.0.3" } } diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 9357251646..9e861c3c28 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -16,7 +16,7 @@ import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/g import { exportSceneLevelsToPrintStl } from '../../lib/level-print-export' import { filterPreparedSceneForPrintContent } from '../../lib/print-content-scope' import { exportSceneToPrintStl, mergePrintExportDiagnostics } from '../../lib/print-export' -import { compileSemanticPrintShell } from '../../lib/print-shell-compiler' +import { compileSemanticPrintShellWithManifold } from '../../lib/print-shell-compiler-manifold-worker' // prepareSceneForExport neutralises container meshes (door/window hitbox roots, // material-less renderables) with an attribute-less geometry — GLTFExporter @@ -101,10 +101,11 @@ export function ExportManager() { thicknessMm: options.printPlinthThicknessMm ?? 2, } : undefined - const { archive, report } = exportSceneLevelsToPrintStl(exportScene, nodes, { + const { archive, report } = await exportSceneLevelsToPrintStl(exportScene, nodes, { scale, plinth, compileShells, + compileShell: compileShells ? compileSemanticPrintShellWithManifold : undefined, }) const blob = new Blob([archive], { type: 'application/zip' }) return finishArtifact( @@ -117,11 +118,14 @@ export function ExportManager() { if (options.printBase === 'plinth') { throw new Error('Plinth generation is available only for per-level print packages.') } - const compiled = compileShells ? compileSemanticPrintShell(exportScene, nodes) : null + const compiled = compileShells + ? await compileSemanticPrintShellWithManifold(exportScene, nodes) + : null const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : exportScene const output = exportSceneToPrintStl(printSource, { scale, compiled: compiled?.status === 'compiled', + indexedTopology: compiled?.backend === 'manifold-3d', }) const report = compiled ? mergePrintExportDiagnostics( diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx index cf29cf6643..603033441c 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx @@ -175,7 +175,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { - Structure compiles registered solids into one experimental shell per output part. + Structure compiles canonical solids with Manifold in a worker before preflight. diff --git a/packages/editor/src/lib/level-print-export.test.ts b/packages/editor/src/lib/level-print-export.test.ts index 32a296b0be..c34b6a33a6 100644 --- a/packages/editor/src/lib/level-print-export.test.ts +++ b/packages/editor/src/lib/level-print-export.test.ts @@ -6,6 +6,7 @@ import * as THREE from 'three' import { prepareSceneForExport } from './glb-export' import { exportSceneLevelsToPrintStl } from './level-print-export' import { filterPreparedSceneForPrintContent } from './print-content-scope' +import { compileSemanticPrintShell } from './print-shell-compiler' function registerFixtureKind(category: 'structure' | 'furnish'): string { const kind = `print-level-${category}-${crypto.randomUUID()}` @@ -118,11 +119,11 @@ describe('per-level print STL export', () => { sceneRegistry.nodes.clear() }) - test('packages one bed-normalized, scale-correct STL per visible level', () => { + test('packages one bed-normalized, scale-correct STL per visible level', async () => { const fixture = twoLevelFixture() const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const bundle = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) + const bundle = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) const files = unzipSync(bundle.archive) const ground = binaryStlBounds(files['01_ground.stl']!) const upper = binaryStlBounds(files['02_upper.stl']!) @@ -141,7 +142,7 @@ describe('per-level print STL export', () => { expect(upper.size.z).toBeCloseTo(20, 4) }) - test('omits and blocks an unsplit stair that spans two levels', () => { + test('omits and blocks an unsplit stair that spans two levels', async () => { const fixture = twoLevelFixture() const stair = new THREE.Group() stair.add(new THREE.Mesh(new THREE.BoxGeometry(1, 3, 2))) @@ -159,7 +160,7 @@ describe('per-level print STL export', () => { } as unknown as AnyNode const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const bundle = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) + const bundle = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) expect(bundle.report.status).toBe('blocked') expect(bundle.report.excludedNodeIds).toEqual(['stair_main']) @@ -169,7 +170,7 @@ describe('per-level print STL export', () => { expect(bundle.report.parts.map((part) => part.report.triangleCount)).toEqual([12, 12]) }) - test('does not create a part for a semantically hidden level', () => { + test('does not create a part for a semantically hidden level', async () => { const fixture = twoLevelFixture() fixture.nodes.level_upper = { ...fixture.nodes.level_upper!, @@ -177,14 +178,14 @@ describe('per-level print STL export', () => { } as AnyNode const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const bundle = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) + const bundle = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) const files = unzipSync(bundle.archive) expect(Object.keys(files)).toEqual(['01_ground.stl']) expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground']) }) - test('applies structure scope before partitioning level files', () => { + test('applies structure scope before partitioning level files', async () => { const fixture = twoLevelFixture() const furniture = new THREE.Group() furniture.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))) @@ -200,13 +201,13 @@ describe('per-level print STL export', () => { const prepared = prepareSceneForExport(fixture.root, fixture.nodes) const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure') - const bundle = exportSceneLevelsToPrintStl(structure, fixture.nodes, { scale: 100 }) + const bundle = await exportSceneLevelsToPrintStl(structure, fixture.nodes, { scale: 100 }) expect(bundle.report.parts.map((part) => part.report.triangleCount)).toEqual([12, 12]) expect(bundle.report.status).toBe('pass') }) - test('substitutes a canonical roof shell before compiling a level part', () => { + test('uses the asynchronous shell compiler before exporting a level part', async () => { const root = new THREE.Group() const building = new THREE.Group() building.userData = { pascalId: 'building_roof-print' } @@ -253,14 +254,20 @@ describe('per-level print STL export', () => { [roof.id]: roof, } - const raw = exportSceneLevelsToPrintStl(root, nodes, { scale: 100 }) - const compiled = exportSceneLevelsToPrintStl(root, nodes, { + let compileCalls = 0 + const raw = await exportSceneLevelsToPrintStl(root, nodes, { scale: 100 }) + const compiled = await exportSceneLevelsToPrintStl(root, nodes, { scale: 100, compileShells: true, + compileShell: async (source, compilerNodes) => { + compileCalls += 1 + return compileSemanticPrintShell(source, compilerNodes) + }, }) const part = compiled.report.parts[0]! expect(raw.report.parts[0]?.report.status).toBe('blocked') + expect(compileCalls).toBe(1) expect(compiled.report.status).toBe('pass') expect(part.report.status).toBe('pass') expect(part.report.bounds?.width).toBeCloseTo(46.6962, 3) @@ -276,25 +283,25 @@ describe('per-level print STL export', () => { ) }) - test('produces deterministic archive bytes for the same level parts', () => { + test('produces deterministic archive bytes for the same level parts', async () => { const fixture = twoLevelFixture() const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const first = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 50 }) - const second = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 50 }) + const first = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 50 }) + const second = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 50 }) expect(first.archive).toEqual(second.archive) }) - test('prepends an optional physical-size plinth derived from the lowest level bounds', () => { + test('prepends an optional physical-size plinth derived from the lowest level bounds', async () => { const fixture = twoLevelFixture() const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const bundle = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { + const bundle = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100, plinth: { marginMm: 2, thicknessMm: 3 }, }) - const repeated = exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { + const repeated = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100, plinth: { marginMm: 2, thicknessMm: 3 }, }) diff --git a/packages/editor/src/lib/level-print-export.ts b/packages/editor/src/lib/level-print-export.ts index aa19f15ad6..41419b94ec 100644 --- a/packages/editor/src/lib/level-print-export.ts +++ b/packages/editor/src/lib/level-print-export.ts @@ -8,6 +8,7 @@ import { type PrintExportReport, } from './print-export' import { compileSemanticPrintShell } from './print-shell-compiler' +import type { PrintShellCompileResult } from './print-shell-compiler-baseline' const ZIP_MTIME = new Date(2000, 0, 1, 0, 0, 0) const MILLIMETERS_PER_METER = 1000 @@ -49,6 +50,10 @@ export type PrintLevelExportOptions = { scale: number plinth?: PrintPlinthOptions compileShells?: boolean + compileShell?: ( + source: THREE.Object3D, + nodes: Record, + ) => Promise } function exportedIdentityIds(root: THREE.Object3D): Set { @@ -177,11 +182,11 @@ function bundleStatus( return 'pass' } -export function exportSceneLevelsToPrintStl( +export async function exportSceneLevelsToPrintStl( source: THREE.Object3D, nodes: Record, options: PrintLevelExportOptions, -): PrintLevelStlBundle { +): Promise { const exportedIds = exportedIdentityIds(source) const ownerByNodeId = new Map() for (const id of Object.keys(nodes)) owningLevelId(id, nodes, ownerByNodeId) @@ -222,11 +227,16 @@ export function exportSceneLevelsToPrintStl( const label = getLevelDisplayName(level) const filename = `${String(index + 1).padStart(2, '0')}_${safeFilenamePart(label)}.stl` const levelScene = pruneSceneToLevel(source, level.id, nodes, excludedIds, ownerByNodeId) - const compiled = options.compileShells ? compileSemanticPrintShell(levelScene, nodes) : null + const compiled = options.compileShells + ? options.compileShell + ? await options.compileShell(levelScene, nodes) + : compileSemanticPrintShell(levelScene, nodes) + : null const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : levelScene const output = exportSceneToPrintStl(printSource, { scale: options.scale, compiled: compiled?.status === 'compiled', + indexedTopology: compiled?.backend === 'manifold-3d', }) const report = compiled ? mergePrintExportDiagnostics( @@ -307,7 +317,9 @@ export function exportSceneLevelsToPrintStl( severity: 'info', code: 'level_parts_experimental', message: options.compileShells - ? 'Level parts use the experimental synchronous semantic shell compiler; worker execution, self-intersection checks, and minimum wall thickness remain pending.' + ? options.compileShell + ? 'Level parts use worker-backed Manifold semantic shell compilation; self-intersection checks and minimum wall thickness remain pending.' + : 'Level parts use the experimental synchronous semantic shell compiler; worker execution, self-intersection checks, and minimum wall thickness remain pending.' : 'Level parts are separated semantically but are not boolean-unioned printable shells yet.', }) diff --git a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts index 7e570db3cd..56045f2ac5 100644 --- a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts +++ b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + type AnyNode, calculateLevelMiters, DoorNode, RoofSegmentNode, @@ -11,8 +12,6 @@ import { WindowNode, } from '@pascal-app/core' import { - buildPrintableRoofSegmentSolids, - buildPrintableWallSolids, generateExtrudedWall, generateRoofSegmentGeometry, generateSlabGeometry, @@ -21,7 +20,8 @@ import * as THREE from 'three' import { exportSceneToPrintStl } from './print-export' import { compileSemanticPrintShell } from './print-shell-compiler' import { compilePrintShellBaseline } from './print-shell-compiler-baseline' -import { compilePrintShellWithManifold } from './print-shell-compiler-manifold' +import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' +import { compileSemanticPrintShellWithManifold } from './print-shell-compiler-manifold-worker' const EMPTY_SLAB_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] } const ROOF_TYPES: RoofType[] = ['gable', 'hip', 'shed', 'gambrel', 'mansard', 'flat', 'dutch'] @@ -261,6 +261,8 @@ describe('print shell compiler baseline', () => { for (const wall of walls) { const root = new THREE.Group() root.userData = { pascalId: wall.id } + root.position.set(wall.start[0], 0, wall.start[1]) + root.rotation.y = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) sceneRegistry.nodes.set(wall.id, root) const openings = [door, window].filter((opening) => opening.wallId === wall.id) root.add(new THREE.Mesh(generateExtrudedWall(wall, openings, miters))) @@ -291,28 +293,14 @@ describe('print shell compiler baseline', () => { const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) expect(print.report.status).toBe('blocked') - expect(print.report.degenerateTriangleCount).toBe(1_760) - expect(print.report.boundaryEdgeCount).toBe(1_286) - expect(print.report.nonManifoldEdgeCount).toBe(10) + expect(print.report.degenerateTriangleCount).toBe(52) + expect(print.report.boundaryEdgeCount).toBe(59) + expect(print.report.nonManifoldEdgeCount).toBe(1) expect(print.report.volumeMm3).toBeGreaterThan(0) - const candidateSource = new THREE.Group() - for (const wall of walls) { - const wallHeight = wall.height - if (wallHeight === undefined) throw new Error(`Missing height for ${wall.id}`) - const result = buildPrintableWallSolids(wall, { effectiveHeight: wallHeight }, nodes) - expect(result.status).toBe('ready') - candidateSource.add(result.object!) - } - const manufacturingSlabRoot = new THREE.Group() - manufacturingSlabRoot.userData = { pascalId: slab.id } - manufacturingSlabRoot.add(new THREE.Mesh(generateSlabGeometry(slab, EMPTY_SLAB_CONTEXT))) - candidateSource.add(manufacturingSlabRoot) - const canonicalRoof = buildPrintableRoofSegmentSolids(roof) - expect(canonicalRoof.status).toBe('ready') - candidateSource.add(canonicalRoof.object!) - - const candidate = await compilePrintShellWithManifold(candidateSource) + const candidate = await compileSemanticPrintShellWithManifold(source, nodes, { + runner: compileManifoldMeshData, + }) expect(candidate.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual( [], ) @@ -340,6 +328,78 @@ describe('print shell compiler baseline', () => { } }, 15_000) + test('blocks a Manifold worker failure without exporting display geometry', async () => { + const source = structuralBox('wall_worker-failure', 0) + const compiled = await compileSemanticPrintShellWithManifold( + source, + {}, + { + runner: async () => { + throw new Error('Worker unavailable') + }, + }, + ) + + expect(compiled.status).toBe('blocked') + expect(compiled.scene).toBeNull() + expect(compiled.sourceNodeIds).toEqual(['wall_worker-failure']) + expect(compiled.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'manifold_worker_failed', + message: 'Worker unavailable', + severity: 'error', + }), + ) + }) + + test('compiles a plane-bound wall independently of its flat 2D display geometry', async () => { + const levelId = 'level_print-shell-2d' + const wall = WallNode.parse({ + id: 'wall_print-shell-2d', + parentId: levelId, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const wallRoot = new THREE.Group() + wallRoot.userData = { pascalId: wall.id } + const flatDisplay = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.2)) + flatDisplay.rotation.x = -Math.PI / 2 + wallRoot.add(flatDisplay) + const source = new THREE.Group() + source.add(wallRoot) + const nodes = { + [levelId]: { + object: 'node', + id: levelId, + type: 'level', + parentId: null, + children: [wall.id], + height: 2.5, + level: 0, + visible: true, + } as unknown as AnyNode, + [wall.id]: wall, + } + + const compiled = await compileSemanticPrintShellWithManifold(source, nodes, { + runner: compileManifoldMeshData, + }) + expect(compiled.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]) + expect(compiled.status).toBe('compiled') + expect(compiled.scene).not.toBeNull() + + const print = exportSceneToPrintStl(compiled.scene!, { + scale: 100, + compiled: true, + indexedTopology: true, + }) + expect(print.report.status).toBe('pass') + expect(print.report.bounds?.height).toBeCloseTo(25, 4) + expect(print.report.boundaryEdgeCount).toBe(0) + expect(print.report.nonManifoldEdgeCount).toBe(0) + }) + test('blocks unsupported semantic wall forms without falling back to display geometry', () => { const wall = WallNode.parse({ id: 'wall_print-shell-curved', diff --git a/packages/editor/src/lib/print-shell-compiler-manifold-core.ts b/packages/editor/src/lib/print-shell-compiler-manifold-core.ts new file mode 100644 index 0000000000..7a1b8148c7 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-manifold-core.ts @@ -0,0 +1,199 @@ +import ManifoldModule, { type Manifold as ManifoldSolid, type ManifoldToplevel } from 'manifold-3d' +import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baseline' +import type { ManifoldCompileOutput, ManifoldMeshData } from './print-shell-compiler-protocol' + +let modulePromise: Promise | null = null + +async function getManifoldModule(wasmUrl?: string): Promise { + modulePromise ??= ManifoldModule(wasmUrl ? { locateFile: () => wasmUrl } : undefined).then( + (module) => { + module.setup() + return module + }, + ) + return modulePromise +} + +function manifoldMesh( + module: ManifoldToplevel, + mesh: ManifoldMeshData, +): InstanceType { + return new module.Mesh({ + numProp: 3, + vertProperties: mesh.positions, + triVerts: mesh.indices, + }) +} + +function manifoldOutput(solid: ManifoldSolid): { positions: Float32Array; indices: Uint32Array } { + const mesh = solid.getMesh() + const positions = new Float32Array(mesh.numVert * 3) + for (let index = 0; index < mesh.numVert; index += 1) { + const sourceOffset = index * mesh.numProp + positions[index * 3] = mesh.vertProperties[sourceOffset]! + positions[index * 3 + 1] = mesh.vertProperties[sourceOffset + 1]! + positions[index * 3 + 2] = mesh.vertProperties[sourceOffset + 2]! + } + + const parents = new Uint32Array(mesh.numVert) + for (let index = 0; index < parents.length; index += 1) parents[index] = index + const find = (index: number): number => { + let root = index + while (parents[root] !== root) root = parents[root]! + while (parents[index] !== index) { + const next = parents[index]! + parents[index] = root + index = next + } + return root + } + for (let index = 0; index < mesh.mergeFromVert.length; index += 1) { + parents[find(mesh.mergeFromVert[index]!)] = find(mesh.mergeToVert[index]!) + } + + const indices: number[] = [] + for (let index = 0; index + 2 < mesh.triVerts.length; index += 3) { + const a = find(mesh.triVerts[index]!) + const b = find(mesh.triVerts[index + 1]!) + const c = find(mesh.triVerts[index + 2]!) + if (a === b || b === c || c === a) continue + + const abX = positions[b * 3]! - positions[a * 3]! + const abY = positions[b * 3 + 1]! - positions[a * 3 + 1]! + const abZ = positions[b * 3 + 2]! - positions[a * 3 + 2]! + const acX = positions[c * 3]! - positions[a * 3]! + const acY = positions[c * 3 + 1]! - positions[a * 3 + 1]! + const acZ = positions[c * 3 + 2]! - positions[a * 3 + 2]! + const crossX = abY * acZ - abZ * acY + const crossY = abZ * acX - abX * acZ + const crossZ = abX * acY - abY * acX + if (crossX * crossX + crossY * crossY + crossZ * crossZ <= 1e-12) continue + indices.push(a, b, c) + } + + return { positions, indices: new Uint32Array(indices) } +} + +function elapsed(startedAt: number): number { + return Math.max(0, performance.now() - startedAt) +} + +export async function compileManifoldMeshData( + meshes: ManifoldMeshData[], + wasmUrl?: string, +): Promise { + const startedAt = performance.now() + const sourceNodeIds = Array.from(new Set(meshes.map((mesh) => mesh.nodeId))).sort() + const diagnostics: PrintShellCompileDiagnostic[] = [] + const solids: ManifoldSolid[] = [] + let union: ManifoldSolid | null = null + let result: ManifoldSolid | null = null + + if (meshes.length === 0) { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics: [ + { + severity: 'error', + code: 'no_shell_meshes', + message: 'No structural meshes are available for Manifold compilation.', + nodeIds: [], + }, + ], + durationMs: elapsed(startedAt), + } + } + + try { + const module = await getManifoldModule(wasmUrl) + for (const mesh of meshes) { + try { + solids.push(new module.Manifold(manifoldMesh(module, mesh))) + } catch (error) { + diagnostics.push({ + severity: 'error', + code: 'manifold_input_failed', + message: `Node ${mesh.nodeId}: ${ + error instanceof Error ? error.message : 'Manifold rejected the shell input.' + }`, + nodeIds: [mesh.nodeId], + }) + } + } + if (diagnostics.length > 0) { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics, + durationMs: elapsed(startedAt), + } + } + + union = module.Manifold.union(solids) + result = union.asOriginal() + const status = result.status() + if (status !== 'NoError') { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics: [ + { + severity: 'error', + code: 'manifold_union_failed', + message: `Manifold union failed with ${status}.`, + nodeIds: sourceNodeIds, + }, + ], + durationMs: elapsed(startedAt), + } + } + + const output = manifoldOutput(result) + if (output.indices.length === 0) { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics: [ + { + severity: 'error', + code: 'manifold_union_failed', + message: 'Manifold produced no printable triangles.', + nodeIds: sourceNodeIds, + }, + ], + durationMs: elapsed(startedAt), + } + } + return { + status: 'compiled', + positions: output.positions, + indices: output.indices, + diagnostics: [], + durationMs: elapsed(startedAt), + } + } catch (error) { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics: [ + { + severity: 'error', + code: 'manifold_worker_failed', + message: error instanceof Error ? error.message : 'Manifold compilation failed.', + nodeIds: sourceNodeIds, + }, + ], + durationMs: elapsed(startedAt), + } + } finally { + for (const solid of solids) solid.delete() + union?.delete() + result?.delete() + } +} diff --git a/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts b/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts new file mode 100644 index 0000000000..f4ddb4c855 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts @@ -0,0 +1,187 @@ +import type { AnyNode } from '@pascal-app/core' +import * as THREE from 'three' +import { + prepareSemanticPrintShellSource, + type SemanticPrintCompileOptions, +} from './print-shell-compiler' +import { + collectPrintShellInput, + type PrintShellCompileDiagnostic, + type PrintShellCompileResult, +} from './print-shell-compiler-baseline' +import { + geometryFromManifoldMeshData, + geometryToManifoldMeshData, +} from './print-shell-compiler-mesh-data' +import type { + ManifoldCompileOutput, + ManifoldMeshData, + ManifoldWorkerRequest, + ManifoldWorkerResponse, +} from './print-shell-compiler-protocol' + +const WORKER_TIMEOUT_MS = 60_000 + +export type ManifoldCompileRunner = (meshes: ManifoldMeshData[]) => Promise + +export type SemanticManifoldCompileOptions = SemanticPrintCompileOptions & { + runner?: ManifoldCompileRunner +} + +type PendingRequest = { + resolve: (output: ManifoldCompileOutput) => void + reject: (error: Error) => void + timeout: ReturnType +} + +let worker: Worker | null = null +let nextRequestId = 1 +const pendingRequests = new Map() + +function resetWorker(error: Error) { + worker?.terminate() + worker = null + for (const pending of pendingRequests.values()) { + clearTimeout(pending.timeout) + pending.reject(error) + } + pendingRequests.clear() +} + +function getWorker(): Worker { + if (worker) return worker + if (typeof Worker === 'undefined') { + throw new Error('Web Workers are unavailable in this environment.') + } + worker = new Worker(new URL('./print-shell-compiler-manifold.worker.ts', import.meta.url), { + type: 'module', + }) + worker.addEventListener('message', (event: MessageEvent) => { + const pending = pendingRequests.get(event.data.id) + if (!pending) return + pendingRequests.delete(event.data.id) + clearTimeout(pending.timeout) + pending.resolve(event.data) + }) + worker.addEventListener('error', (event) => { + resetWorker(new Error(event.message || 'The Manifold worker failed.')) + }) + worker.addEventListener('messageerror', () => { + resetWorker(new Error('The Manifold worker returned an unreadable response.')) + }) + return worker +} + +export const runManifoldWorker: ManifoldCompileRunner = (meshes) => { + const activeWorker = getWorker() + const id = nextRequestId + nextRequestId += 1 + const request: ManifoldWorkerRequest = { id, meshes } + const transfer = meshes.flatMap((mesh) => [ + mesh.positions.buffer as ArrayBuffer, + mesh.indices.buffer as ArrayBuffer, + ]) + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resetWorker(new Error(`The Manifold worker exceeded ${WORKER_TIMEOUT_MS / 1000} seconds.`)) + }, WORKER_TIMEOUT_MS) + pendingRequests.set(id, { resolve, reject, timeout }) + try { + activeWorker.postMessage(request, transfer) + } catch (error) { + resetWorker( + error instanceof Error ? error : new Error('Failed to start the Manifold worker.'), + ) + } + }) +} + +function blockedResult( + inputMeshCount: number, + sourceNodeIds: Iterable, + diagnostics: PrintShellCompileDiagnostic[], +): PrintShellCompileResult { + return { + backend: 'manifold-3d', + status: 'blocked', + scene: null, + inputMeshCount, + sourceNodeIds: Array.from(sourceNodeIds).sort(), + diagnostics, + } +} + +export async function compileSemanticPrintShellWithManifold( + source: THREE.Object3D, + nodes: Record, + options: SemanticManifoldCompileOptions = {}, +): Promise { + const { runner = runManifoldWorker, ...semanticOptions } = options + const prepared = prepareSemanticPrintShellSource(source, nodes, { + ...semanticOptions, + wallSolids: semanticOptions.wallSolids ?? true, + }) + if (prepared.status === 'blocked') { + return blockedResult(prepared.inputMeshCount, prepared.sourceNodeIds, prepared.diagnostics) + } + + const input = collectPrintShellInput(prepared.scene) + prepared.dispose() + if (input.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + for (const geometry of input.geometries) geometry.dispose() + return blockedResult(input.inputMeshCount, input.sourceNodeIds, input.diagnostics) + } + + const meshes = input.geometries.map((geometry, index) => + geometryToManifoldMeshData(geometry, input.geometryNodeIds[index]!), + ) + for (const geometry of input.geometries) geometry.dispose() + + let output: ManifoldCompileOutput + try { + output = await runner(meshes) + } catch (error) { + return blockedResult(input.inputMeshCount, input.sourceNodeIds, [ + ...input.diagnostics, + { + severity: 'error', + code: 'manifold_worker_failed', + message: error instanceof Error ? error.message : 'The Manifold worker failed.', + nodeIds: Array.from(input.sourceNodeIds).sort(), + }, + ]) + } + + const diagnostics = [...input.diagnostics, ...output.diagnostics] + if (output.status === 'blocked') { + return blockedResult(input.inputMeshCount, input.sourceNodeIds, diagnostics) + } + + const geometry = geometryFromManifoldMeshData(output.positions, output.indices) + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()) + mesh.name = 'print-shell-manifold' + mesh.userData = { + printCompiler: 'manifold-3d', + sourceNodeIds: Array.from(input.sourceNodeIds).sort(), + } + const scene = new THREE.Group() + scene.name = 'compiled-print-shell' + scene.add(mesh) + diagnostics.push({ + severity: 'info', + code: runner === runManifoldWorker ? 'manifold_worker_compiler' : 'manifold_compiler_candidate', + message: `Compiled with Manifold in ${output.durationMs.toFixed(1)} ms${ + runner === runManifoldWorker ? ' off the main thread' : ' through the in-process test runner' + }.`, + nodeIds: Array.from(input.sourceNodeIds).sort(), + }) + return { + backend: 'manifold-3d', + status: 'compiled', + scene, + inputMeshCount: input.inputMeshCount, + sourceNodeIds: Array.from(input.sourceNodeIds).sort(), + diagnostics, + } +} diff --git a/packages/editor/src/lib/print-shell-compiler-manifold.ts b/packages/editor/src/lib/print-shell-compiler-manifold.ts index 673a56dfad..d27c7f6c89 100644 --- a/packages/editor/src/lib/print-shell-compiler-manifold.ts +++ b/packages/editor/src/lib/print-shell-compiler-manifold.ts @@ -1,95 +1,14 @@ -import ManifoldModule, { type Manifold as ManifoldSolid, type ManifoldToplevel } from 'manifold-3d' import * as THREE from 'three' import { collectPrintShellInput, type PrintShellCompileDiagnostic, type PrintShellCompileResult, } from './print-shell-compiler-baseline' - -let modulePromise: Promise | null = null - -async function getManifoldModule(): Promise { - modulePromise ??= ManifoldModule().then((module) => { - module.setup() - return module - }) - return modulePromise -} - -function manifoldMesh( - module: ManifoldToplevel, - geometry: THREE.BufferGeometry, -): InstanceType { - const position = geometry.getAttribute('position') - const vertProperties = new Float32Array(position.count * 3) - for (let index = 0; index < position.count; index += 1) { - vertProperties[index * 3] = position.getX(index) - vertProperties[index * 3 + 1] = position.getY(index) - vertProperties[index * 3 + 2] = position.getZ(index) - } - - const geometryIndex = geometry.getIndex() - const triVerts = new Uint32Array(geometryIndex?.count ?? position.count) - for (let index = 0; index < triVerts.length; index += 1) { - triVerts[index] = geometryIndex?.getX(index) ?? index - } - return new module.Mesh({ numProp: 3, vertProperties, triVerts }) -} - -function threeGeometry(solid: ManifoldSolid): THREE.BufferGeometry { - const mesh = solid.getMesh() - const positions = new Float32Array(mesh.numVert * 3) - for (let index = 0; index < mesh.numVert; index += 1) { - const sourceOffset = index * mesh.numProp - positions[index * 3] = mesh.vertProperties[sourceOffset]! - positions[index * 3 + 1] = mesh.vertProperties[sourceOffset + 1]! - positions[index * 3 + 2] = mesh.vertProperties[sourceOffset + 2]! - } - - const parents = new Uint32Array(mesh.numVert) - for (let index = 0; index < parents.length; index += 1) parents[index] = index - const find = (index: number): number => { - let root = index - while (parents[root] !== root) root = parents[root]! - while (parents[index] !== index) { - const next = parents[index]! - parents[index] = root - index = next - } - return root - } - for (let index = 0; index < mesh.mergeFromVert.length; index += 1) { - parents[find(mesh.mergeFromVert[index]!)] = find(mesh.mergeToVert[index]!) - } - - const triVerts: number[] = [] - const ab = new THREE.Vector3() - const ac = new THREE.Vector3() - for (let index = 0; index + 2 < mesh.triVerts.length; index += 3) { - const a = find(mesh.triVerts[index]!) - const b = find(mesh.triVerts[index + 1]!) - const c = find(mesh.triVerts[index + 2]!) - if (a === b || b === c || c === a) continue - ab.set( - positions[b * 3]! - positions[a * 3]!, - positions[b * 3 + 1]! - positions[a * 3 + 1]!, - positions[b * 3 + 2]! - positions[a * 3 + 2]!, - ) - ac.set( - positions[c * 3]! - positions[a * 3]!, - positions[c * 3 + 1]! - positions[a * 3 + 1]!, - positions[c * 3 + 2]! - positions[a * 3 + 2]!, - ) - if (ab.cross(ac).lengthSq() <= 1e-12) continue - triVerts.push(a, b, c) - } - - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) - geometry.setIndex(triVerts) - geometry.computeVertexNormals() - return geometry -} +import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' +import { + geometryFromManifoldMeshData, + geometryToManifoldMeshData, +} from './print-shell-compiler-mesh-data' function blockedResult( inputMeshCount: number, @@ -116,78 +35,38 @@ export async function compilePrintShellWithManifold( return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) } - const solids: ManifoldSolid[] = [] - let result: ManifoldSolid | null = null - try { - const module = await getManifoldModule() - for (const [index, geometry] of geometries.entries()) { - const nodeId = geometryNodeIds[index]! - try { - solids.push(new module.Manifold(manifoldMesh(module, geometry))) - } catch (error) { - diagnostics.push({ - severity: 'error', - code: 'manifold_input_failed', - message: `Node ${nodeId}: ${ - error instanceof Error ? error.message : 'Manifold rejected the shell input.' - }`, - nodeIds: [nodeId], - }) - } - } - if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { - return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) - } - const union = module.Manifold.union(solids) - result = union.asOriginal() - union.delete() - const status = result.status() - if (status !== 'NoError') { - diagnostics.push({ - severity: 'error', - code: 'manifold_union_failed', - message: `Manifold union failed with ${status}.`, - nodeIds: Array.from(sourceNodeIds).sort(), - }) - return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) - } - - const geometry = threeGeometry(result) - const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()) - mesh.name = 'print-shell-manifold' - mesh.userData = { - printCompiler: 'manifold-3d', - sourceNodeIds: Array.from(sourceNodeIds).sort(), - } - const scene = new THREE.Group() - scene.name = 'compiled-print-shell' - scene.add(mesh) - diagnostics.push({ - severity: 'info', - code: 'manifold_compiler_candidate', - message: - 'Compiled with the test-only Manifold WASM candidate; worker packaging and production bundle impact remain unapproved.', - nodeIds: Array.from(sourceNodeIds).sort(), - }) - return { - backend: 'manifold-3d', - status: 'compiled', - scene, - inputMeshCount, - sourceNodeIds: Array.from(sourceNodeIds).sort(), - diagnostics, - } - } catch (error) { - diagnostics.push({ - severity: 'error', - code: 'manifold_input_failed', - message: error instanceof Error ? error.message : 'Manifold rejected the shell input.', - nodeIds: Array.from(sourceNodeIds).sort(), - }) + const meshes = geometries.map((geometry, index) => + geometryToManifoldMeshData(geometry, geometryNodeIds[index]!), + ) + for (const geometry of geometries) geometry.dispose() + const output = await compileManifoldMeshData(meshes) + diagnostics.push(...output.diagnostics) + if (output.status === 'blocked') { return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) - } finally { - for (const geometry of geometries) geometry.dispose() - for (const solid of solids) solid.delete() - result?.delete() + } + + const geometry = geometryFromManifoldMeshData(output.positions, output.indices) + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()) + mesh.name = 'print-shell-manifold' + mesh.userData = { + printCompiler: 'manifold-3d', + sourceNodeIds: Array.from(sourceNodeIds).sort(), + } + const scene = new THREE.Group() + scene.name = 'compiled-print-shell' + scene.add(mesh) + diagnostics.push({ + severity: 'info', + code: 'manifold_compiler_candidate', + message: `Compiled with the in-process Manifold candidate in ${output.durationMs.toFixed(1)} ms.`, + nodeIds: Array.from(sourceNodeIds).sort(), + }) + return { + backend: 'manifold-3d', + status: 'compiled', + scene, + inputMeshCount, + sourceNodeIds: Array.from(sourceNodeIds).sort(), + diagnostics, } } diff --git a/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts b/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts new file mode 100644 index 0000000000..a93fb0b655 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts @@ -0,0 +1,21 @@ +import manifoldWasmUrl from 'manifold-3d/manifold.wasm' +import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' +import type { ManifoldWorkerRequest, ManifoldWorkerResponse } from './print-shell-compiler-protocol' + +const workerScope = self as unknown as { + addEventListener: ( + type: 'message', + listener: (event: MessageEvent) => void, + ) => void + postMessage: (response: ManifoldWorkerResponse, transfer: Transferable[]) => void +} + +workerScope.addEventListener('message', async (event) => { + const output = await compileManifoldMeshData(event.data.meshes, manifoldWasmUrl) + const response: ManifoldWorkerResponse = { id: event.data.id, ...output } + const transfer: Transferable[] = [] + if (response.status === 'compiled') { + transfer.push(response.positions.buffer as ArrayBuffer, response.indices.buffer as ArrayBuffer) + } + workerScope.postMessage(response, transfer) +}) diff --git a/packages/editor/src/lib/print-shell-compiler-mesh-data.ts b/packages/editor/src/lib/print-shell-compiler-mesh-data.ts new file mode 100644 index 0000000000..317edb3c1a --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-mesh-data.ts @@ -0,0 +1,32 @@ +import * as THREE from 'three' +import type { ManifoldMeshData } from './print-shell-compiler-protocol' + +export function geometryToManifoldMeshData( + geometry: THREE.BufferGeometry, + nodeId: string, +): ManifoldMeshData { + const position = geometry.getAttribute('position') + const positions = new Float32Array(position.count * 3) + for (let index = 0; index < position.count; index += 1) { + positions[index * 3] = position.getX(index) + positions[index * 3 + 1] = position.getY(index) + positions[index * 3 + 2] = position.getZ(index) + } + const geometryIndex = geometry.getIndex() + const indices = new Uint32Array(geometryIndex?.count ?? position.count) + for (let index = 0; index < indices.length; index += 1) { + indices[index] = geometryIndex?.getX(index) ?? index + } + return { nodeId, positions, indices } +} + +export function geometryFromManifoldMeshData( + positions: Float32Array, + indices: Uint32Array, +): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setIndex(new THREE.BufferAttribute(indices, 1)) + geometry.computeVertexNormals() + return geometry +} diff --git a/packages/editor/src/lib/print-shell-compiler-protocol.ts b/packages/editor/src/lib/print-shell-compiler-protocol.ts new file mode 100644 index 0000000000..8554f1e2e8 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-protocol.ts @@ -0,0 +1,30 @@ +import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baseline' + +export type ManifoldMeshData = { + nodeId: string + positions: Float32Array + indices: Uint32Array +} + +export type ManifoldCompileOutput = + | { + status: 'compiled' + positions: Float32Array + indices: Uint32Array + diagnostics: PrintShellCompileDiagnostic[] + durationMs: number + } + | { + status: 'blocked' + positions: null + indices: null + diagnostics: PrintShellCompileDiagnostic[] + durationMs: number + } + +export type ManifoldWorkerRequest = { + id: number + meshes: ManifoldMeshData[] +} + +export type ManifoldWorkerResponse = ManifoldCompileOutput & { id: number } diff --git a/packages/editor/src/lib/print-shell-compiler.ts b/packages/editor/src/lib/print-shell-compiler.ts index b14dc4096d..5051c55ab9 100644 --- a/packages/editor/src/lib/print-shell-compiler.ts +++ b/packages/editor/src/lib/print-shell-compiler.ts @@ -1,4 +1,11 @@ -import type { AnyNode, RoofSegmentNode, WallNode } from '@pascal-app/core' +import { + type AnyNode, + getWallEffectiveHeightForNodes, + type RoofSegmentNode, + resolveLevelId, + spatialGridManager, + type WallNode, +} from '@pascal-app/core' import { buildPrintableRoofSegmentSolids, buildPrintableWallSolids } from '@pascal-app/viewer' import * as THREE from 'three' import { @@ -11,6 +18,22 @@ export type SemanticPrintCompileOptions = { wallSolids?: boolean } +export type SemanticPrintSourceResult = + | { + status: 'ready' + scene: THREE.Object3D + diagnostics: [] + dispose: () => void + } + | { + status: 'blocked' + scene: null + inputMeshCount: number + sourceNodeIds: string[] + diagnostics: PrintShellCompileDiagnostic[] + dispose: () => void + } + function meshCount(root: THREE.Object3D): number { let count = 0 root.traverse((object) => { @@ -95,45 +118,57 @@ function ownedLocalYBounds(root: THREE.Object3D): { min: number; max: number } | function preparedWallHeight( node: WallNode, object: THREE.Object3D, + nodes: Record, ): | { height: number; diagnostic: null } | { height: null; diagnostic: PrintShellCompileDiagnostic } { + const levelId = resolveLevelId(node, nodes) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + node.start, + node.end, + node.curveOffset ?? 0, + node.thickness, + node.supportSlabId ?? null, + undefined, + node.supportOffset, + ) + const hasDisplacedBase = + Math.abs(support.baseElevation - support.elevation) > 1e-5 || + support.baseSegments.some((segment) => Math.abs(segment.elevation - support.elevation) > 1e-5) const bounds = ownedLocalYBounds(object) - if (!bounds || bounds.max <= 1e-7) { + if (hasDisplacedBase || (bounds && bounds.max > 1e-7 && Math.abs(bounds.min) > 1e-5)) { return { height: null, diagnostic: { severity: 'error', - code: 'invalid_wall_print_dimensions', - message: `Wall ${node.id} has no finite prepared height for print compilation.`, + code: 'unsupported_wall_print_base', + message: `Wall ${node.id} has a stepped or displaced local base that does not yet have a canonical printable solid.`, nodeIds: [node.id], }, } } - if (bounds.min < -1e-5 || bounds.min > 1e-5) { + + const height = getWallEffectiveHeightForNodes(node, nodes) + if (!Number.isFinite(height) || height <= 1e-7) { return { height: null, diagnostic: { severity: 'error', - code: 'unsupported_wall_print_base', - message: `Wall ${node.id} has a stepped or displaced local base that does not yet have a canonical printable solid.`, + code: 'invalid_wall_print_dimensions', + message: `Wall ${node.id} has no finite semantic height for print compilation.`, nodeIds: [node.id], }, } } - return { height: bounds.max, diagnostic: null } + return { height, diagnostic: null } } -/** - * Compiles a semantic structural source instead of trusting display aggregates. - * Roof segments are replaced as complete identity subtrees so their hosted - * display CSG and accessory meshes cannot leak into the manufacturing shell. - */ -export function compileSemanticPrintShell( +export function prepareSemanticPrintShellSource( source: THREE.Object3D, nodes: Record, options: SemanticPrintCompileOptions = {}, -): PrintShellCompileResult { +): SemanticPrintSourceResult { const scene = new THREE.Group() scene.name = 'semantic-print-source' scene.add(source.clone(true)) @@ -160,7 +195,7 @@ export function compileSemanticPrintShell( replacements.push({ target: object, replacement: result.object }) } for (const { node, object } of wallTargets) { - const prepared = preparedWallHeight(node, object) + const prepared = preparedWallHeight(node, object, nodes) if (prepared.diagnostic) { diagnostics.push(prepared.diagnostic) continue @@ -181,7 +216,6 @@ export function compileSemanticPrintShell( if (diagnostics.length > 0) { for (const { replacement } of replacements) disposeGenerated(replacement) return { - backend: 'pascal-three-bvh-csg', status: 'blocked', scene: null, inputMeshCount: meshCount(scene), @@ -189,6 +223,7 @@ export function compileSemanticPrintShell( new Set(diagnostics.flatMap((diagnostic) => diagnostic.nodeIds)), ).sort(), diagnostics, + dispose: () => {}, } } @@ -196,9 +231,44 @@ export function compileSemanticPrintShell( if (target.parent) replaceChild(target.parent, target, replacement) } + let disposed = false + return { + status: 'ready', + scene, + diagnostics: [], + dispose: () => { + if (disposed) return + disposed = true + for (const { replacement } of replacements) disposeGenerated(replacement) + }, + } +} + +/** + * Compiles a semantic structural source instead of trusting display aggregates. + * Roof segments are replaced as complete identity subtrees so their hosted + * display CSG and accessory meshes cannot leak into the manufacturing shell. + */ +export function compileSemanticPrintShell( + source: THREE.Object3D, + nodes: Record, + options: SemanticPrintCompileOptions = {}, +): PrintShellCompileResult { + const prepared = prepareSemanticPrintShellSource(source, nodes, options) + if (prepared.status === 'blocked') { + return { + backend: 'pascal-three-bvh-csg', + status: 'blocked', + scene: null, + inputMeshCount: prepared.inputMeshCount, + sourceNodeIds: prepared.sourceNodeIds, + diagnostics: prepared.diagnostics, + } + } + try { - return compilePrintShellBaseline(scene) + return compilePrintShellBaseline(prepared.scene) } finally { - for (const { replacement } of replacements) disposeGenerated(replacement) + prepared.dispose() } } diff --git a/packages/editor/src/manifold-wasm.d.ts b/packages/editor/src/manifold-wasm.d.ts new file mode 100644 index 0000000000..22afb28511 --- /dev/null +++ b/packages/editor/src/manifold-wasm.d.ts @@ -0,0 +1,4 @@ +declare module 'manifold-3d/manifold.wasm' { + const url: string + export default url +} From d70e9436b17c732cf37bd236753388534371eb83 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 15:34:42 -0400 Subject: [PATCH 13/19] feat: add deterministic 3MF print packages --- bun.lock | 1 + packages/editor/package.json | 1 + .../src/components/editor/export-manager.tsx | 32 +++-- .../settings-panel/print-export-card.test.ts | 47 +++++-- .../settings-panel/print-export-card.tsx | 47 +++++-- .../editor/src/lib/level-print-export.test.ts | 114 ++++++++++++--- packages/editor/src/lib/level-print-export.ts | 122 ++++++++++++---- packages/editor/src/lib/print-3mf.test.ts | 69 ++++++++++ packages/editor/src/lib/print-3mf.ts | 130 ++++++++++++++++++ packages/editor/src/lib/print-export.ts | 110 ++++++++++++--- packages/viewer/src/store/use-viewer.d.ts | 2 +- packages/viewer/src/store/use-viewer.ts | 2 +- 12 files changed, 583 insertions(+), 94 deletions(-) create mode 100644 packages/editor/src/lib/print-3mf.test.ts create mode 100644 packages/editor/src/lib/print-3mf.ts diff --git a/bun.lock b/bun.lock index c212da186b..97d04acaab 100644 --- a/bun.lock +++ b/bun.lock @@ -191,6 +191,7 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", + "fast-xml-parser": "^5.4.2", "typescript": "6.0.3", }, "peerDependencies": { diff --git a/packages/editor/package.json b/packages/editor/package.json index 4a05d63e78..9ef2fbab95 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -69,6 +69,7 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", + "fast-xml-parser": "^5.4.2", "typescript": "6.0.3" } } diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 9e861c3c28..934aef90a6 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -13,7 +13,8 @@ import * as THREE from 'three' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export' -import { exportSceneLevelsToPrintStl } from '../../lib/level-print-export' +import { exportSceneLevelsForPrint } from '../../lib/level-print-export' +import { exportSceneToPrint3mf } from '../../lib/print-3mf' import { filterPreparedSceneForPrintContent } from '../../lib/print-content-scope' import { exportSceneToPrintStl, mergePrintExportDiagnostics } from '../../lib/print-export' import { compileSemanticPrintShellWithManifold } from '../../lib/print-shell-compiler-manifold-worker' @@ -85,12 +86,14 @@ export function ExportManager() { } let { scene: exportScene } = prepared const printContent = options.printContent ?? 'structure' - if (format === 'print-stl') { + const isPrintFormat = format === 'print-stl' || format === 'print-3mf' + if (isPrintFormat) { exportScene = filterPreparedSceneForPrintContent(exportScene, nodes, printContent) } ensurePositionAttributes(exportScene) - if (format === 'print-stl') { + if (isPrintFormat) { + const printFormat = format === 'print-3mf' ? '3mf' : 'stl' const scale = options.printScale ?? 100 const compileShells = printContent === 'structure' if (options.printScope === 'levels') { @@ -101,16 +104,19 @@ export function ExportManager() { thicknessMm: options.printPlinthThicknessMm ?? 2, } : undefined - const { archive, report } = await exportSceneLevelsToPrintStl(exportScene, nodes, { + const { data, report } = await exportSceneLevelsForPrint(exportScene, nodes, { scale, + format: printFormat, plinth, compileShells, compileShell: compileShells ? compileSemanticPrintShellWithManifold : undefined, }) - const blob = new Blob([archive], { type: 'application/zip' }) + const blob = new Blob([data], { + type: printFormat === '3mf' ? 'model/3mf' : 'application/zip', + }) return finishArtifact( blob, - `print_levels_1-${scale}_${date}.zip`, + `print_levels_1-${scale}_${date}.${printFormat === '3mf' ? '3mf' : 'zip'}`, options.download, report, ) @@ -122,11 +128,15 @@ export function ExportManager() { ? await compileSemanticPrintShellWithManifold(exportScene, nodes) : null const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : exportScene - const output = exportSceneToPrintStl(printSource, { + const printOptions = { scale, compiled: compiled?.status === 'compiled', indexedTopology: compiled?.backend === 'manifold-3d', - }) + } + const output = + printFormat === '3mf' + ? exportSceneToPrint3mf(printSource, printOptions) + : exportSceneToPrintStl(printSource, printOptions) const report = compiled ? mergePrintExportDiagnostics( output.report, @@ -135,10 +145,12 @@ export function ExportManager() { ) : output.report const { buffer } = output - const blob = new Blob([buffer], { type: 'model/stl' }) + const blob = new Blob([buffer], { + type: printFormat === '3mf' ? 'model/3mf' : 'model/stl', + }) return finishArtifact( blob, - `print_model_1-${scale}_${date}.stl`, + `print_model_1-${scale}_${date}.${printFormat}`, options.download, report, ) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts index c657182476..2149220e36 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts @@ -5,8 +5,9 @@ import type { PrintExportReport } from '../../../../../lib/print-export' import { preparePrintExport } from './print-export-card' const report: PrintExportReport = { - kind: 'print-stl-report', - version: 1, + kind: 'print-export-report', + version: 2, + format: '3mf', scale: 50, units: 'millimeter', orientation: 'z-up', @@ -27,9 +28,12 @@ const report: PrintExportReport = { diagnostics: [], } +const stlReport: PrintExportReport = { ...report, format: 'stl' } + const levelReport: PrintLevelBundleReport = { - kind: 'print-level-stl-report', - version: 1, + kind: 'print-level-export-report', + version: 2, + format: 'stl', scale: 50, units: 'millimeter', orientation: 'z-up', @@ -40,8 +44,9 @@ const levelReport: PrintLevelBundleReport = { kind: 'level', levelId: 'level_ground', label: 'Ground', + objectName: '01 Ground', filename: '01_ground.stl', - report, + report: stlReport, }, ], excludedNodeIds: [], @@ -51,7 +56,7 @@ const levelReport: PrintLevelBundleReport = { describe('print export card contract', () => { test('prepares a visible-only scaled artifact without downloading immediately', async () => { const calls: { format?: string; options?: SceneExportOptions }[] = [] - const artifact = { blob: new Blob(['stl']), filename: 'house.stl', metadata: report } + const artifact = { blob: new Blob(['3mf']), filename: 'house.3mf', metadata: report } const exportScene: SceneExport = async (format, options) => { calls.push({ format, options }) return artifact @@ -62,6 +67,7 @@ describe('print export card contract', () => { true, '50', 'whole', + '3mf', 'structure', 'none', '2', @@ -70,7 +76,7 @@ describe('print export card contract', () => { expect(calls).toEqual([ { - format: 'print-stl', + format: 'print-3mf', options: { onlyVisible: true, download: false, @@ -92,7 +98,7 @@ describe('print export card contract', () => { } await expect( - preparePrintExport(exportScene, true, '0', 'levels', 'structure', 'none', '2', '2'), + preparePrintExport(exportScene, true, '0', 'levels', '3mf', 'structure', 'none', '2', '2'), ).rejects.toThrow( 'Enter a positive scale denominator', ) @@ -112,6 +118,7 @@ describe('print export card contract', () => { true, '50', 'levels', + 'stl', 'everything', 'plinth', '3', @@ -144,7 +151,17 @@ describe('print export card contract', () => { } await expect( - preparePrintExport(exportScene, true, '50', 'levels', 'structure', 'plinth', '-1', '2'), + preparePrintExport( + exportScene, + true, + '50', + 'levels', + '3mf', + 'structure', + 'plinth', + '-1', + '2', + ), ).rejects.toThrow('non-negative plinth margin') expect(invoked).toBe(false) }) @@ -156,7 +173,17 @@ describe('print export card contract', () => { }) await expect( - preparePrintExport(exportScene, false, '100', 'whole', 'structure', 'none', '2', '2'), + preparePrintExport( + exportScene, + false, + '100', + 'whole', + '3mf', + 'structure', + 'none', + '2', + '2', + ), ).rejects.toThrow('did not return a preflight report') }) }) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx index 603033441c..401829c485 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx @@ -15,6 +15,7 @@ import { import type { PrintContentScope } from '../../../../../lib/print-content-scope' import { isPrintExportReport, + type PrintArtifactFormat, type PrintExportReport, } from '../../../../../lib/print-export' @@ -43,6 +44,7 @@ export async function preparePrintExport( onlyVisible: boolean, scaleInput: string, scope: 'whole' | 'levels', + format: PrintArtifactFormat, content: PrintContentScope, base: PrintBaseMode, plinthMarginInput: string, @@ -69,7 +71,7 @@ export async function preparePrintExport( } } - const artifact = await exportScene('print-stl', { + const artifact = await exportScene(format === '3mf' ? 'print-3mf' : 'print-stl', { onlyVisible, download: false, printScale: scale, @@ -94,6 +96,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { const exportScene = useViewer((state) => state.exportScene) const [printScale, setPrintScale] = useState('100') const [scope, setScope] = useState<'whole' | 'levels'>('levels') + const [format, setFormat] = useState('3mf') const [content, setContent] = useState('structure') const [base, setBase] = useState('none') const [plinthMargin, setPlinthMargin] = useState('2') @@ -105,7 +108,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { useEffect(() => { setPrepared(null) setError(null) - }, [nodes, onlyVisible, printScale, scope, content, base, plinthMargin, plinthThickness]) + }, [nodes, onlyVisible, printScale, scope, format, content, base, plinthMargin, plinthThickness]) const handlePrepare = async () => { if (!exportScene) { @@ -123,6 +126,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { onlyVisible, printScale, scope, + format, content, scope === 'levels' ? base : 'none', plinthMargin, @@ -143,7 +147,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) {
Print files
- Experimental millimeter, Z-up export centered on the print bed + Experimental millimeter, Z-up export normalized to the print bed
@@ -164,6 +168,21 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { + + @@ -244,8 +265,10 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { {isPreparing ? 'Preparing print files...' : scope === 'levels' - ? 'Prepare level STLs' - : 'Prepare print STL'} + ? format === '3mf' + ? 'Prepare level 3MF' + : 'Prepare level STLs' + : `Prepare print ${format.toUpperCase()}`} {error && ( @@ -289,7 +312,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { {prepared.report.partCount} {prepared.report.parts.map((part) => ( -
+
{part.label} @@ -344,9 +367,11 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { onClick={() => downloadArtifact(prepared.artifact)} > - {isPrintLevelBundleReport(prepared.report) - ? 'Download level STLs (.zip)' - : 'Download print STL'} + {prepared.report.format === '3mf' + ? 'Download print 3MF' + : isPrintLevelBundleReport(prepared.report) + ? 'Download level STLs (.zip)' + : 'Download print STL'}
)} diff --git a/packages/editor/src/lib/level-print-export.test.ts b/packages/editor/src/lib/level-print-export.test.ts index c34b6a33a6..6ea07d4647 100644 --- a/packages/editor/src/lib/level-print-export.test.ts +++ b/packages/editor/src/lib/level-print-export.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, RoofSegmentNode, registerNode, sceneRegistry } from '@pascal-app/core' import { generateRoofSegmentGeometry } from '@pascal-app/viewer' -import { unzipSync } from 'fflate' +import { XMLParser } from 'fast-xml-parser' +import { strFromU8, unzipSync } from 'fflate' import * as THREE from 'three' import { prepareSceneForExport } from './glb-export' -import { exportSceneLevelsToPrintStl } from './level-print-export' +import { exportSceneLevelsForPrint } from './level-print-export' import { filterPreparedSceneForPrintContent } from './print-content-scope' import { compileSemanticPrintShell } from './print-shell-compiler' @@ -44,6 +45,10 @@ function binaryStlBounds(buffer: Uint8Array): { triangles: number; size: THREE.V return { triangles, size: bounds.getSize(new THREE.Vector3()) } } +function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + function twoLevelFixture() { const root = new THREE.Group() const building = new THREE.Group() @@ -123,8 +128,8 @@ describe('per-level print STL export', () => { const fixture = twoLevelFixture() const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const bundle = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) - const files = unzipSync(bundle.archive) + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + const files = unzipSync(bundle.data) const ground = binaryStlBounds(files['01_ground.stl']!) const upper = binaryStlBounds(files['02_upper.stl']!) @@ -160,7 +165,7 @@ describe('per-level print STL export', () => { } as unknown as AnyNode const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const bundle = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) expect(bundle.report.status).toBe('blocked') expect(bundle.report.excludedNodeIds).toEqual(['stair_main']) @@ -178,8 +183,8 @@ describe('per-level print STL export', () => { } as AnyNode const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const bundle = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 100 }) - const files = unzipSync(bundle.archive) + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + const files = unzipSync(bundle.data) expect(Object.keys(files)).toEqual(['01_ground.stl']) expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground']) @@ -201,7 +206,7 @@ describe('per-level print STL export', () => { const prepared = prepareSceneForExport(fixture.root, fixture.nodes) const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure') - const bundle = await exportSceneLevelsToPrintStl(structure, fixture.nodes, { scale: 100 }) + const bundle = await exportSceneLevelsForPrint(structure, fixture.nodes, { scale: 100 }) expect(bundle.report.parts.map((part) => part.report.triangleCount)).toEqual([12, 12]) expect(bundle.report.status).toBe('pass') @@ -255,8 +260,8 @@ describe('per-level print STL export', () => { } let compileCalls = 0 - const raw = await exportSceneLevelsToPrintStl(root, nodes, { scale: 100 }) - const compiled = await exportSceneLevelsToPrintStl(root, nodes, { + const raw = await exportSceneLevelsForPrint(root, nodes, { scale: 100 }) + const compiled = await exportSceneLevelsForPrint(root, nodes, { scale: 100, compileShells: true, compileShell: async (source, compilerNodes) => { @@ -287,25 +292,25 @@ describe('per-level print STL export', () => { const fixture = twoLevelFixture() const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const first = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 50 }) - const second = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { scale: 50 }) + const first = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 50 }) + const second = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 50 }) - expect(first.archive).toEqual(second.archive) + expect(first.data).toEqual(second.data) }) test('prepends an optional physical-size plinth derived from the lowest level bounds', async () => { const fixture = twoLevelFixture() const prepared = prepareSceneForExport(fixture.root, fixture.nodes) - const bundle = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100, plinth: { marginMm: 2, thicknessMm: 3 }, }) - const repeated = await exportSceneLevelsToPrintStl(prepared.scene, fixture.nodes, { + const repeated = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100, plinth: { marginMm: 2, thicknessMm: 3 }, }) - const files = unzipSync(bundle.archive) + const files = unzipSync(bundle.data) const plinth = binaryStlBounds(files['00_plinth.stl']!) expect(Object.keys(files)).toEqual(['00_plinth.stl', '01_ground.stl', '02_upper.stl']) @@ -315,6 +320,81 @@ describe('per-level print STL export', () => { expect(plinth.size.x).toBeCloseTo(104, 4) expect(plinth.size.y).toBeCloseTo(84, 4) expect(plinth.size.z).toBeCloseTo(3, 4) - expect(bundle.archive).toEqual(repeated.archive) + expect(bundle.data).toEqual(repeated.data) + }) + + test('packages named, millimeter-unit 3MF objects in a non-overlapping bed layout', async () => { + const fixture = twoLevelFixture() + fixture.nodes.level_ground = { + ...fixture.nodes.level_ground!, + name: 'Ground & Entry', + } as AnyNode + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + const options = { + scale: 100, + format: '3mf' as const, + plinth: { marginMm: 2, thicknessMm: 3 }, + } + + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, options) + const repeated = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, options) + const files = unzipSync(bundle.data) + const xml = strFromU8(files['3D/3dmodel.model']!) + const model = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }).parse( + xml, + ).model + const objects = asArray>(model.resources.object) + const items = asArray>(model.build.item) + + expect(Object.keys(files)).toEqual(['[Content_Types].xml', '_rels/.rels', '3D/3dmodel.model']) + expect(model.unit).toBe('millimeter') + expect(objects.map((object) => object.name)).toEqual([ + '00 Plinth', + '01 Ground & Entry', + '02 Upper', + ]) + expect(items.map((item) => item.objectid)).toEqual(['1', '2', '3']) + expect(bundle.report.format).toBe('3mf') + expect(bundle.report.parts.map((part) => part.filename)).toEqual([null, null, null]) + expect(bundle.report.parts.map((part) => part.objectName)).toEqual([ + '00 Plinth', + '01 Ground & Entry', + '02 Upper', + ]) + + const expectedSizes = [ + [104, 84, 3], + [100, 80, 30], + [80, 60, 20], + ] + let previousMaxX = Number.NEGATIVE_INFINITY + for (const [index, object] of objects.entries()) { + const mesh = object.mesh as { + vertices: { vertex: Record | Record[] } + triangles: { triangle: Record | Record[] } + } + const vertices = asArray(mesh.vertices.vertex) + const triangles = asArray(mesh.triangles.triangle) + const transformValue = items[index]?.transform + expect(transformValue).toBeDefined() + const transform = transformValue!.split(' ').map(Number) + const translation = new THREE.Vector3(transform[9]!, transform[10]!, transform[11]!) + const bounds = new THREE.Box3() + for (const vertex of vertices) { + bounds.expandByPoint( + new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z)).add(translation), + ) + } + const size = bounds.getSize(new THREE.Vector3()) + + expect(triangles).toHaveLength(12) + expect(size.x).toBeCloseTo(expectedSizes[index]![0]!, 5) + expect(size.y).toBeCloseTo(expectedSizes[index]![1]!, 5) + expect(size.z).toBeCloseTo(expectedSizes[index]![2]!, 5) + expect(bounds.min.z).toBeCloseTo(0, 9) + if (index > 0) expect(bounds.min.x - previousMaxX).toBeCloseTo(5, 5) + previousMaxX = bounds.max.x + } + expect(bundle.data).toEqual(repeated.data) }) }) diff --git a/packages/editor/src/lib/level-print-export.ts b/packages/editor/src/lib/level-print-export.ts index 41419b94ec..7575bb4c01 100644 --- a/packages/editor/src/lib/level-print-export.ts +++ b/packages/editor/src/lib/level-print-export.ts @@ -1,11 +1,17 @@ import { type AnyNode, getLevelDisplayName, type LevelNode } from '@pascal-app/core' import { type Zippable, zipSync } from 'fflate' import * as THREE from 'three' +import { createPrint3mf, type Print3mfPart } from './print-3mf' import { - exportSceneToPrintStl, + encodePreparedPrintSceneToStl, + extractPreparedPrintMesh, mergePrintExportDiagnostics, + type PrintArtifactFormat, + type PrintExportBounds, type PrintExportDiagnostic, type PrintExportReport, + type PrintMeshData, + prepareSceneForPrint, } from './print-export' import { compileSemanticPrintShell } from './print-shell-compiler' import type { PrintShellCompileResult } from './print-shell-compiler-baseline' @@ -24,13 +30,15 @@ export type PrintLevelPartReport = { kind: 'level' | 'plinth' levelId: string label: string - filename: string + objectName: string + filename: string | null report: PrintExportReport } export type PrintLevelBundleReport = { - kind: 'print-level-stl-report' - version: 1 + kind: 'print-level-export-report' + version: 2 + format: PrintArtifactFormat scale: number units: 'millimeter' orientation: 'z-up' @@ -41,13 +49,14 @@ export type PrintLevelBundleReport = { diagnostics: PrintExportDiagnostic[] } -export type PrintLevelStlBundle = { - archive: Uint8Array +export type PrintLevelPackage = { + data: Uint8Array report: PrintLevelBundleReport } export type PrintLevelExportOptions = { scale: number + format?: PrintArtifactFormat plinth?: PrintPlinthOptions compileShells?: boolean compileShell?: ( @@ -182,11 +191,20 @@ function bundleStatus( return 'pass' } -export async function exportSceneLevelsToPrintStl( +type PreparedLevelArtifact = { + filename: string | null + objectName: string + bytes: Uint8Array | null + mesh: PrintMeshData | null + bounds: PrintExportBounds | null +} + +export async function exportSceneLevelsForPrint( source: THREE.Object3D, nodes: Record, options: PrintLevelExportOptions, -): Promise { +): Promise { + const format = options.format ?? 'stl' const exportedIds = exportedIdentityIds(source) const ownerByNodeId = new Map() for (const id of Object.keys(nodes)) owningLevelId(id, nodes, ownerByNodeId) @@ -221,11 +239,13 @@ export async function exportSceneLevelsToPrintStl( }) } - const levelFiles: { filename: string; bytes: Uint8Array }[] = [] + const levelArtifacts: PreparedLevelArtifact[] = [] const levelParts: PrintLevelPartReport[] = [] for (const [index, level] of levels.entries()) { const label = getLevelDisplayName(level) - const filename = `${String(index + 1).padStart(2, '0')}_${safeFilenamePart(label)}.stl` + const prefix = String(index + 1).padStart(2, '0') + const objectName = `${prefix} ${label}` + const filename = format === 'stl' ? `${prefix}_${safeFilenamePart(label)}.stl` : null const levelScene = pruneSceneToLevel(source, level.id, nodes, excludedIds, ownerByNodeId) const compiled = options.compileShells ? options.compileShell @@ -233,28 +253,39 @@ export async function exportSceneLevelsToPrintStl( : compileSemanticPrintShell(levelScene, nodes) : null const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : levelScene - const output = exportSceneToPrintStl(printSource, { + const prepared = prepareSceneForPrint(printSource, { scale: options.scale, compiled: compiled?.status === 'compiled', indexedTopology: compiled?.backend === 'manifold-3d', + format, }) const report = compiled ? mergePrintExportDiagnostics( - output.report, + prepared.report, compiled.diagnostics, new Set(['compiler_pending']), ) - : output.report + : prepared.report if (compiled) { diagnostics.push( ...compiled.diagnostics.filter((diagnostic) => diagnostic.severity !== 'info'), ) } - levelFiles.push({ filename, bytes: new Uint8Array(output.buffer) }) - levelParts.push({ kind: 'level', levelId: level.id, label, filename, report }) + levelArtifacts.push({ + filename, + objectName, + bytes: + format === 'stl' ? new Uint8Array(encodePreparedPrintSceneToStl(prepared.scene)) : null, + mesh: + format === '3mf' && report.bounds && report.invalidTriangleCount === 0 + ? extractPreparedPrintMesh(prepared.scene) + : null, + bounds: report.bounds, + }) + levelParts.push({ kind: 'level', levelId: level.id, label, objectName, filename, report }) } - let plinthFile: { filename: string; bytes: Uint8Array } | null = null + let plinthArtifact: PreparedLevelArtifact | null = null let plinthPart: PrintLevelPartReport | null = null if (options.plinth) { const { marginMm, thicknessMm } = options.plinth @@ -293,15 +324,27 @@ export async function exportSceneLevelsToPrintStl( const mesh = new THREE.Mesh( new THREE.BoxGeometry(widthMeters, thicknessMeters, depthMeters), ) - const output = exportSceneToPrintStl(mesh, options) - const filename = '00_plinth.stl' - plinthFile = { filename, bytes: new Uint8Array(output.buffer) } + const prepared = prepareSceneForPrint(mesh, { ...options, format }) + const filename = format === 'stl' ? '00_plinth.stl' : null + const objectName = '00 Plinth' + plinthArtifact = { + filename, + objectName, + bytes: + format === 'stl' ? new Uint8Array(encodePreparedPrintSceneToStl(prepared.scene)) : null, + mesh: + format === '3mf' && prepared.report.bounds && prepared.report.invalidTriangleCount === 0 + ? extractPreparedPrintMesh(prepared.scene) + : null, + bounds: prepared.report.bounds, + } plinthPart = { kind: 'plinth', levelId: lowestLevel.id, label: 'Plinth', + objectName, filename, - report: output.report, + report: prepared.report, } diagnostics.push({ severity: 'info', @@ -325,21 +368,44 @@ export async function exportSceneLevelsToPrintStl( const files: Zippable = {} const parts: PrintLevelPartReport[] = [] - if (plinthFile && plinthPart) { - files[plinthFile.filename] = [plinthFile.bytes, { level: 0, mtime: ZIP_MTIME }] + const packageParts: Print3mfPart[] = [] + if (plinthArtifact && plinthPart) { + if (plinthArtifact.filename && plinthArtifact.bytes) { + files[plinthArtifact.filename] = [plinthArtifact.bytes, { level: 0, mtime: ZIP_MTIME }] + } + if (plinthArtifact.mesh && plinthArtifact.bounds) { + packageParts.push({ + name: plinthArtifact.objectName, + mesh: plinthArtifact.mesh, + bounds: plinthArtifact.bounds, + }) + } parts.push(plinthPart) } - for (const [index, file] of levelFiles.entries()) { - files[file.filename] = [file.bytes, { level: 0, mtime: ZIP_MTIME }] + for (const [index, artifact] of levelArtifacts.entries()) { + if (artifact.filename && artifact.bytes) { + files[artifact.filename] = [artifact.bytes, { level: 0, mtime: ZIP_MTIME }] + } + if (artifact.mesh && artifact.bounds) { + packageParts.push({ + name: artifact.objectName, + mesh: artifact.mesh, + bounds: artifact.bounds, + }) + } const part = levelParts[index] if (part) parts.push(part) } return { - archive: zipSync(files, { level: 0 }), + data: + format === '3mf' + ? createPrint3mf(packageParts, 'Pascal level parts') + : zipSync(files, { level: 0 }), report: { - kind: 'print-level-stl-report', - version: 1, + kind: 'print-level-export-report', + version: 2, + format, scale: options.scale, units: 'millimeter', orientation: 'z-up', @@ -355,5 +421,5 @@ export async function exportSceneLevelsToPrintStl( export function isPrintLevelBundleReport(value: unknown): value is PrintLevelBundleReport { if (!value || typeof value !== 'object') return false const report = value as Partial - return report.kind === 'print-level-stl-report' && report.version === 1 + return report.kind === 'print-level-export-report' && report.version === 2 } diff --git a/packages/editor/src/lib/print-3mf.test.ts b/packages/editor/src/lib/print-3mf.test.ts new file mode 100644 index 0000000000..14e97d7bb5 --- /dev/null +++ b/packages/editor/src/lib/print-3mf.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test' +import { XMLParser } from 'fast-xml-parser' +import { strFromU8, unzipSync } from 'fflate' +import * as THREE from 'three' +import { exportSceneToPrint3mf } from './print-3mf' + +function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + +describe('print 3MF export', () => { + test('writes a deterministic standards package with explicit millimeter units', () => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(10, 4, 6)) + mesh.position.set(5, 2, -7) + + const first = exportSceneToPrint3mf(mesh, { scale: 100 }) + const second = exportSceneToPrint3mf(mesh, { scale: 100 }) + const files = unzipSync(first.buffer) + const xml = strFromU8(files['3D/3dmodel.model']!) + const model = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }).parse( + xml, + ).model + const object = asArray>(model.resources.object)[0]! + const item = asArray>(model.build.item)[0]! + const objectMesh = object.mesh as { + vertices: { vertex: Record | Record[] } + triangles: { triangle: Record | Record[] } + } + const vertices = asArray(objectMesh.vertices.vertex) + const triangles = asArray(objectMesh.triangles.triangle) + expect(item.transform).toBeDefined() + const transform = item.transform!.split(' ').map(Number) + + expect(Object.keys(files)).toEqual(['[Content_Types].xml', '_rels/.rels', '3D/3dmodel.model']) + expect(strFromU8(files['_rels/.rels']!)).toContain('Target="/3D/3dmodel.model"') + expect(model.unit).toBe('millimeter') + expect(object.name).toBe('Pascal print model') + expect(vertices).toHaveLength(8) + expect(triangles).toHaveLength(12) + expect(item.objectid).toBe('1') + expect(transform.slice(9)).toEqual([50, 30, 0]) + expect(first.report.format).toBe('3mf') + expect(first.report.bounds?.width).toBeCloseTo(100, 6) + expect(first.report.bounds?.depth).toBeCloseTo(60, 6) + expect(first.report.bounds?.height).toBeCloseTo(40, 6) + expect(first.buffer).toEqual(second.buffer) + + for (const triangle of triangles) { + expect(Number(triangle.v1)).toBeLessThan(vertices.length) + expect(Number(triangle.v2)).toBeLessThan(vertices.length) + expect(Number(triangle.v3)).toBeLessThan(vertices.length) + } + }) + + test('returns a blocking report instead of serializing non-finite coordinates', () => { + const geometry = new THREE.BoxGeometry(1, 1, 1) + geometry.getAttribute('position').setX(0, Number.NaN) + + const output = exportSceneToPrint3mf(new THREE.Mesh(geometry), { scale: 100 }) + const model = strFromU8(unzipSync(output.buffer)['3D/3dmodel.model']!) + + expect(output.report.status).toBe('blocked') + expect(output.report.invalidTriangleCount).toBeGreaterThan(0) + expect(output.report.diagnostics).toContainEqual( + expect.objectContaining({ code: 'non_finite_geometry', severity: 'error' }), + ) + expect(model).not.toContain(' + + + + +` + +const ROOT_RELATIONSHIPS = ` + + + +` + +export type Print3mfPart = { + name: string + mesh: PrintMeshData + bounds: PrintExportBounds +} + +export type Print3mfExport = { + buffer: Uint8Array + report: PrintExportReport +} + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function decimal(value: number): string { + if (!Number.isFinite(value)) throw new RangeError('3MF coordinates must be finite.') + const rounded = Math.abs(value) < 5e-10 ? 0 : value + return rounded.toFixed(9).replace(/\.?0+$/, '') +} + +function appendMeshObject(lines: string[], part: Print3mfPart, objectId: number) { + lines.push(` `) + lines.push(' ') + lines.push(' ') + for (let offset = 0; offset < part.mesh.positions.length; offset += 3) { + lines.push( + ` `, + ) + } + lines.push(' ') + lines.push(' ') + for (let offset = 0; offset < part.mesh.indices.length; offset += 3) { + lines.push( + ` `, + ) + } + lines.push(' ') + lines.push(' ') + lines.push(' ') +} + +export function createPrint3mf( + parts: Print3mfPart[], + title = 'Pascal print export', +): Uint8Array { + const lines = [ + '', + '', + ` ${escapeXml(title)}`, + ' Pascal', + ' ', + ] + + for (const [index, part] of parts.entries()) appendMeshObject(lines, part, index + 1) + lines.push(' ') + lines.push(' ') + + let cursorX = 0 + for (const [index, part] of parts.entries()) { + const translateX = cursorX - part.bounds.min.x + const translateY = -part.bounds.min.y + lines.push( + ` `, + ) + cursorX += part.bounds.width + PART_GAP_MM + } + lines.push(' ') + lines.push('') + lines.push('') + + const files: Zippable = { + '[Content_Types].xml': [strToU8(CONTENT_TYPES), { level: 0, mtime: ZIP_MTIME }], + '_rels/.rels': [strToU8(ROOT_RELATIONSHIPS), { level: 0, mtime: ZIP_MTIME }], + '3D/3dmodel.model': [strToU8(lines.join('\n')), { level: 0, mtime: ZIP_MTIME }], + } + return zipSync(files, { level: 0 }) +} + +export function exportSceneToPrint3mf( + source: THREE.Object3D, + options: PrintExportOptions, +): Print3mfExport { + const prepared = prepareSceneForPrint(source, { ...options, format: '3mf' }) + const parts = + prepared.report.bounds && prepared.report.invalidTriangleCount === 0 + ? [ + { + name: 'Pascal print model', + mesh: extractPreparedPrintMesh(prepared.scene), + bounds: prepared.report.bounds, + }, + ] + : [] + return { + buffer: createPrint3mf(parts), + report: prepared.report, + } +} diff --git a/packages/editor/src/lib/print-export.ts b/packages/editor/src/lib/print-export.ts index d8d9d4eb3c..79bdea46c1 100644 --- a/packages/editor/src/lib/print-export.ts +++ b/packages/editor/src/lib/print-export.ts @@ -21,10 +21,13 @@ export type PrintExportDiagnostic = { nodeIds?: string[] } +export type PrintArtifactFormat = 'stl' | '3mf' + export type PrintExportOptions = { scale: number compiled?: boolean indexedTopology?: boolean + format?: PrintArtifactFormat } export type PrintExportBounds = { @@ -36,8 +39,9 @@ export type PrintExportBounds = { } export type PrintExportReport = { - kind: 'print-stl-report' - version: 1 + kind: 'print-export-report' + version: 2 + format: PrintArtifactFormat scale: number units: 'millimeter' orientation: 'z-up' @@ -57,6 +61,11 @@ export type PrintStlExport = { report: PrintExportReport } +export type PrintMeshData = { + positions: Float64Array + indices: Uint32Array +} + type BoundsMeasurement = { min: THREE.Vector3 max: THREE.Vector3 @@ -260,6 +269,7 @@ function analyzePrintScene( scale: number, edgeTopology: EdgeTopologyMeasurement, compiled: boolean, + format: PrintArtifactFormat, ): PrintExportReport { const min = new THREE.Vector3( Number.POSITIVE_INFINITY, @@ -396,8 +406,9 @@ function analyzePrintScene( : 'pass' return { - kind: 'print-stl-report', - version: 1, + kind: 'print-export-report', + version: 2, + format, scale, units: 'millimeter', orientation: 'z-up', @@ -447,25 +458,92 @@ export function prepareSceneForPrint( return { scene, - report: analyzePrintScene(scene, options.scale, edgeTopology, options.compiled ?? false), + report: analyzePrintScene( + scene, + options.scale, + edgeTopology, + options.compiled ?? false, + options.format ?? 'stl', + ), + } +} + +export function extractPreparedPrintMesh(root: THREE.Object3D): PrintMeshData { + root.updateMatrixWorld(true) + + const positions: number[] = [] + const indices: number[] = [] + const point = new THREE.Vector3() + + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + + const position = mesh.geometry.getAttribute('position') + if (!position) return + const index = mesh.geometry.getIndex() + const skinnedMesh = mesh as THREE.SkinnedMesh + const outputIndexByPosition = new Map() + + const outputIndexFor = (vertexIndex: number): number => { + point.fromBufferAttribute(position, vertexIndex) + if (skinnedMesh.isSkinnedMesh) skinnedMesh.applyBoneTransform(vertexIndex, point) + point.applyMatrix4(mesh.matrixWorld) + if (!isFiniteVector(point)) { + throw new RangeError( + 'Print geometry contains non-finite coordinates and cannot be encoded.', + ) + } + + const x = Object.is(point.x, -0) ? 0 : point.x + const y = Object.is(point.y, -0) ? 0 : point.y + const z = Object.is(point.z, -0) ? 0 : point.z + const key = `${x},${y},${z}` + const existing = outputIndexByPosition.get(key) + if (existing !== undefined) return existing + + const next = positions.length / 3 + positions.push(x, y, z) + outputIndexByPosition.set(key, next) + return next + } + + const appendTriangle = (a: number, b: number, c: number) => { + indices.push(outputIndexFor(a), outputIndexFor(b), outputIndexFor(c)) + } + + if (index) { + for (let offset = 0; offset + 2 < index.count; offset += 3) { + appendTriangle(index.getX(offset), index.getX(offset + 1), index.getX(offset + 2)) + } + return + } + + for (let offset = 0; offset + 2 < position.count; offset += 3) { + appendTriangle(offset, offset + 1, offset + 2) + } + }) + + return { + positions: new Float64Array(positions), + indices: new Uint32Array(indices), } } +export function encodePreparedPrintSceneToStl(scene: THREE.Object3D): ArrayBuffer { + const exporter = new STLExporter() + const output = exporter.parse(scene, { binary: true }) as ArrayBuffer | DataView + return output instanceof DataView + ? (output.buffer.slice(output.byteOffset, output.byteOffset + output.byteLength) as ArrayBuffer) + : output +} + export function exportSceneToPrintStl( source: THREE.Object3D, options: PrintExportOptions, ): PrintStlExport { const { scene, report } = prepareSceneForPrint(source, options) - const exporter = new STLExporter() - const output = exporter.parse(scene, { binary: true }) as ArrayBuffer | DataView - const buffer = - output instanceof DataView - ? (output.buffer.slice( - output.byteOffset, - output.byteOffset + output.byteLength, - ) as ArrayBuffer) - : output - return { buffer, report } + return { buffer: encodePreparedPrintSceneToStl(scene), report } } export function mergePrintExportDiagnostics( @@ -488,5 +566,5 @@ export function mergePrintExportDiagnostics( export function isPrintExportReport(value: unknown): value is PrintExportReport { if (!value || typeof value !== 'object') return false const report = value as Partial - return report.kind === 'print-stl-report' && report.version === 1 + return report.kind === 'print-export-report' && report.version === 2 } diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 8d7fa142c4..0e8f225bb9 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -1,7 +1,7 @@ import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' import type { Object3D } from 'three' -export type SceneExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' +export type SceneExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' | 'print-3mf' export type SceneExportOptions = { onlyVisible?: boolean download?: boolean diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index a99290ef82..db1cebd61c 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -12,7 +12,7 @@ import { SCENE_THEME_IDS } from '../lib/scene-themes' export type RenderContext = 'editor' | 'viewer' export type MetricNotation = 'meters' | 'millimeters' export type WallMode = 'up' | 'cutaway' | 'down' | 'translucent' -export type SceneExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' +export type SceneExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' | 'print-3mf' export type SceneExportOptions = { onlyVisible?: boolean download?: boolean From d19319d40bf1049557bd6694db7210a96d37329d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 15:43:59 -0400 Subject: [PATCH 14/19] feat: anchor print levels to stored bases --- .../settings-panel/print-export-card.test.ts | 1 + .../editor/src/lib/level-print-export.test.ts | 87 +++++++++++++++++-- packages/editor/src/lib/level-print-export.ts | 76 ++++++++++++++-- packages/editor/src/lib/print-export.ts | 14 ++- 4 files changed, 164 insertions(+), 14 deletions(-) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts index 2149220e36..5664ab5dd5 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts @@ -46,6 +46,7 @@ const levelReport: PrintLevelBundleReport = { label: 'Ground', objectName: '01 Ground', filename: '01_ground.stl', + sourceBaseMeters: 0, report: stlReport, }, ], diff --git a/packages/editor/src/lib/level-print-export.test.ts b/packages/editor/src/lib/level-print-export.test.ts index 6ea07d4647..851cccefa6 100644 --- a/packages/editor/src/lib/level-print-export.test.ts +++ b/packages/editor/src/lib/level-print-export.test.ts @@ -21,7 +21,12 @@ function registerFixtureKind(category: 'structure' | 'furnish'): string { return kind } -function binaryStlBounds(buffer: Uint8Array): { triangles: number; size: THREE.Vector3 } { +function binaryStlBounds(buffer: Uint8Array): { + triangles: number + min: THREE.Vector3 + max: THREE.Vector3 + size: THREE.Vector3 +} { const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) const triangles = view.getUint32(80, true) const bounds = new THREE.Box3() @@ -42,7 +47,12 @@ function binaryStlBounds(buffer: Uint8Array): { triangles: number; size: THREE.V offset += 2 } - return { triangles, size: bounds.getSize(new THREE.Vector3()) } + return { + triangles, + min: bounds.min.clone(), + max: bounds.max.clone(), + size: bounds.getSize(new THREE.Vector3()), + } } function asArray(value: T | T[]): T[] { @@ -56,12 +66,15 @@ function twoLevelFixture() { const upper = new THREE.Group() const groundStructure = new THREE.Group() const upperStructure = new THREE.Group() - groundStructure.add(new THREE.Mesh(new THREE.BoxGeometry(10, 3, 8))) - upperStructure.add(new THREE.Mesh(new THREE.BoxGeometry(8, 2, 6))) + const groundSolid = new THREE.Mesh(new THREE.BoxGeometry(10, 3, 8)) + const upperSolid = new THREE.Mesh(new THREE.BoxGeometry(8, 2, 6)) + groundSolid.position.y = 1.5 + upperSolid.position.y = 1 + groundStructure.add(groundSolid) + upperStructure.add(upperSolid) ground.add(groundStructure) upper.add(upperStructure) - ground.position.y = 1.5 - upper.position.y = 4 + upper.position.y = 3 root.add(building) building.add(ground, upper) @@ -86,6 +99,7 @@ function twoLevelFixture() { type: 'level', name: 'Ground', level: 0, + height: 3, parentId: 'building_main', children: ['structure_ground'], visible: true, @@ -96,6 +110,7 @@ function twoLevelFixture() { type: 'level', name: 'Upper', level: 1, + height: 2, parentId: 'building_main', children: ['structure_upper'], visible: true, @@ -116,7 +131,7 @@ function twoLevelFixture() { } as unknown as AnyNode, } - return { root, building, ground, upper, nodes } + return { root, building, ground, upper, groundStructure, upperStructure, nodes } } describe('per-level print STL export', () => { @@ -137,16 +152,74 @@ describe('per-level print STL export', () => { expect(bundle.report.status).toBe('pass') expect(bundle.report.partCount).toBe(2) expect(bundle.report.parts.map((part) => part.kind)).toEqual(['level', 'level']) + expect(bundle.report.parts.map((part) => part.sourceBaseMeters)).toEqual([0, 3]) expect(ground.triangles).toBe(12) + expect(ground.min.z).toBeCloseTo(0, 6) expect(ground.size.x).toBeCloseTo(100, 4) expect(ground.size.y).toBeCloseTo(80, 4) expect(ground.size.z).toBeCloseTo(30, 4) expect(upper.triangles).toBe(12) + expect(upper.min.z).toBeCloseTo(0, 6) expect(upper.size.x).toBeCloseTo(80, 4) expect(upper.size.y).toBeCloseTo(60, 4) expect(upper.size.z).toBeCloseTo(20, 4) }) + test('blocks geometry that crosses or floats above its stored level base', async () => { + const fixture = twoLevelFixture() + fixture.groundStructure.position.y = -0.25 + fixture.upperStructure.position.y = 0.5 + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + const [ground, upper] = bundle.report.parts + + expect(bundle.report.status).toBe('blocked') + expect(ground?.sourceBaseMeters).toBe(0) + expect(ground?.report.bounds?.min.z).toBeCloseTo(-2.5, 5) + expect(ground?.report.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'level_geometry_below_base', + nodeIds: ['level_ground'], + }), + ) + expect(upper?.sourceBaseMeters).toBe(3) + expect(upper?.report.bounds?.min.z).toBeCloseTo(5, 5) + expect(upper?.report.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'level_geometry_detached_from_base', + nodeIds: ['level_upper'], + }), + ) + }) + + test('orders a basement first and honors additive stored base elevation', async () => { + const fixture = twoLevelFixture() + fixture.nodes.level_ground = { + ...fixture.nodes.level_ground!, + name: 'Basement', + level: -1, + baseElevation: -0.4, + } as AnyNode + fixture.nodes.level_upper = { + ...fixture.nodes.level_upper!, + name: 'Ground', + level: 0, + } as AnyNode + fixture.ground.position.y = -0.4 + fixture.upper.position.y = 2.6 + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + const files = unzipSync(bundle.data) + + expect(Object.keys(files)).toEqual(['01_basement.stl', '02_ground.stl']) + expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground', 'level_upper']) + expect(bundle.report.parts.map((part) => part.sourceBaseMeters)).toEqual([-0.4, 2.6]) + expect(binaryStlBounds(files['01_basement.stl']!).min.z).toBeCloseTo(0, 6) + expect(binaryStlBounds(files['02_ground.stl']!).min.z).toBeCloseTo(0, 6) + }) + test('omits and blocks an unsplit stair that spans two levels', async () => { const fixture = twoLevelFixture() const stair = new THREE.Group() diff --git a/packages/editor/src/lib/level-print-export.ts b/packages/editor/src/lib/level-print-export.ts index 7575bb4c01..5ebdc8cedb 100644 --- a/packages/editor/src/lib/level-print-export.ts +++ b/packages/editor/src/lib/level-print-export.ts @@ -1,4 +1,9 @@ -import { type AnyNode, getLevelDisplayName, type LevelNode } from '@pascal-app/core' +import { + type AnyNode, + getLevelDisplayName, + getLevelElevations, + type LevelNode, +} from '@pascal-app/core' import { type Zippable, zipSync } from 'fflate' import * as THREE from 'three' import { createPrint3mf, type Print3mfPart } from './print-3mf' @@ -18,6 +23,7 @@ import type { PrintShellCompileResult } from './print-shell-compiler-baseline' const ZIP_MTIME = new Date(2000, 0, 1, 0, 0, 0) const MILLIMETERS_PER_METER = 1000 +const LEVEL_BASE_TOLERANCE_MM = 0.01 export type PrintBaseMode = 'none' | 'plinth' @@ -32,6 +38,7 @@ export type PrintLevelPartReport = { label: string objectName: string filename: string | null + sourceBaseMeters: number | null report: PrintExportReport } @@ -199,6 +206,46 @@ type PreparedLevelArtifact = { bounds: PrintExportBounds | null } +function levelBaseDiagnostics( + level: LevelNode, + label: string, + sourceBaseMeters: number | null, + report: PrintExportReport, +): PrintExportDiagnostic[] { + if (sourceBaseMeters === null) { + return [ + { + severity: 'error', + code: 'missing_level_base', + message: `${label} has no finite stored level base and cannot be normalized reliably.`, + nodeIds: [level.id], + }, + ] + } + + const minZ = report.bounds?.min.z + if (minZ === undefined || Math.abs(minZ) <= LEVEL_BASE_TOLERANCE_MM) return [] + if (minZ < 0) { + return [ + { + severity: 'error', + code: 'level_geometry_below_base', + message: `${label} extends ${Math.abs(minZ).toFixed(3)} mm below its stored level base. Correct the level ownership or supporting slab before printing.`, + nodeIds: [level.id], + }, + ] + } + + return [ + { + severity: 'error', + code: 'level_geometry_detached_from_base', + message: `${label} begins ${minZ.toFixed(3)} mm above its stored level base, leaving the printable part detached from the bed. Add or assign a floor solid before printing.`, + nodeIds: [level.id], + }, + ] +} + export async function exportSceneLevelsForPrint( source: THREE.Object3D, nodes: Record, @@ -206,6 +253,7 @@ export async function exportSceneLevelsForPrint( ): Promise { const format = options.format ?? 'stl' const exportedIds = exportedIdentityIds(source) + const levelElevations = getLevelElevations(nodes) const ownerByNodeId = new Map() for (const id of Object.keys(nodes)) owningLevelId(id, nodes, ownerByNodeId) @@ -247,6 +295,9 @@ export async function exportSceneLevelsForPrint( const objectName = `${prefix} ${label}` const filename = format === 'stl' ? `${prefix}_${safeFilenamePart(label)}.stl` : null const levelScene = pruneSceneToLevel(source, level.id, nodes, excludedIds, ownerByNodeId) + const sourceBase = levelElevations.get(level.id)?.baseY + const sourceBaseMeters = + typeof sourceBase === 'number' && Number.isFinite(sourceBase) ? sourceBase : null const compiled = options.compileShells ? options.compileShell ? await options.compileShell(levelScene, nodes) @@ -258,19 +309,23 @@ export async function exportSceneLevelsForPrint( compiled: compiled?.status === 'compiled', indexedTopology: compiled?.backend === 'manifold-3d', format, + ...(sourceBaseMeters === null ? {} : { sourceBedElevationMeters: sourceBaseMeters }), }) - const report = compiled + let report = compiled ? mergePrintExportDiagnostics( prepared.report, compiled.diagnostics, new Set(['compiler_pending']), ) : prepared.report + const baseDiagnostics = levelBaseDiagnostics(level, label, sourceBaseMeters, report) + report = mergePrintExportDiagnostics(report, baseDiagnostics) if (compiled) { diagnostics.push( ...compiled.diagnostics.filter((diagnostic) => diagnostic.severity !== 'info'), ) } + diagnostics.push(...baseDiagnostics) levelArtifacts.push({ filename, objectName, @@ -282,7 +337,15 @@ export async function exportSceneLevelsForPrint( : null, bounds: report.bounds, }) - levelParts.push({ kind: 'level', levelId: level.id, label, objectName, filename, report }) + levelParts.push({ + kind: 'level', + levelId: level.id, + label, + objectName, + filename, + sourceBaseMeters, + report, + }) } let plinthArtifact: PreparedLevelArtifact | null = null @@ -344,6 +407,7 @@ export async function exportSceneLevelsForPrint( label: 'Plinth', objectName, filename, + sourceBaseMeters: null, report: prepared.report, } diagnostics.push({ @@ -361,9 +425,9 @@ export async function exportSceneLevelsForPrint( code: 'level_parts_experimental', message: options.compileShells ? options.compileShell - ? 'Level parts use worker-backed Manifold semantic shell compilation; self-intersection checks and minimum wall thickness remain pending.' - : 'Level parts use the experimental synchronous semantic shell compiler; worker execution, self-intersection checks, and minimum wall thickness remain pending.' - : 'Level parts are separated semantically but are not boolean-unioned printable shells yet.', + ? 'Level parts use stored level bases and worker-backed Manifold semantic shell compilation; self-intersection checks and minimum wall thickness remain pending.' + : 'Level parts use stored level bases and the experimental synchronous semantic shell compiler; worker execution, self-intersection checks, and minimum wall thickness remain pending.' + : 'Level parts use stored level bases and semantic separation but are not boolean-unioned printable shells yet.', }) const files: Zippable = {} diff --git a/packages/editor/src/lib/print-export.ts b/packages/editor/src/lib/print-export.ts index 79bdea46c1..b1d153b20b 100644 --- a/packages/editor/src/lib/print-export.ts +++ b/packages/editor/src/lib/print-export.ts @@ -28,6 +28,8 @@ export type PrintExportOptions = { compiled?: boolean indexedTopology?: boolean format?: PrintArtifactFormat + /** Original Y-up world elevation that becomes print Z=0. Omit to use geometry minimum. */ + sourceBedElevationMeters?: number } export type PrintExportBounds = { @@ -431,6 +433,12 @@ export function prepareSceneForPrint( if (!Number.isFinite(options.scale) || options.scale <= 0) { throw new RangeError('Print scale must be a positive finite denominator') } + if ( + options.sourceBedElevationMeters !== undefined && + !Number.isFinite(options.sourceBedElevationMeters) + ) { + throw new RangeError('Print bed elevation must be finite') + } ensureMeshPositions(source) @@ -448,10 +456,14 @@ export function prepareSceneForPrint( const initialBounds = measureBounds(scene) if (initialBounds) { + const bedElevation = + options.sourceBedElevationMeters === undefined + ? initialBounds.min.z + : options.sourceBedElevationMeters * physicalScale scene.position.set( -(initialBounds.min.x + initialBounds.max.x) / 2, -(initialBounds.min.y + initialBounds.max.y) / 2, - -initialBounds.min.z, + -bedElevation, ) scene.updateMatrixWorld(true) } From a8f96afca7895e17ffa3c2f0f24ce7fa73a54440 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 16:08:10 -0400 Subject: [PATCH 15/19] feat: add a two-level print golden house --- .../scripts/generate-print-golden-house.ts | 85 +++++ .../lib/print-golden-house.test-fixture.ts | 348 ++++++++++++++++++ .../editor/src/lib/print-golden-house.test.ts | 231 ++++++++++++ .../lib/print-shell-compiler-baseline.test.ts | 137 ++----- .../lib/print-shell-compiler-manifold-core.ts | 157 +++++++- 5 files changed, 831 insertions(+), 127 deletions(-) create mode 100644 packages/editor/scripts/generate-print-golden-house.ts create mode 100644 packages/editor/src/lib/print-golden-house.test-fixture.ts create mode 100644 packages/editor/src/lib/print-golden-house.test.ts diff --git a/packages/editor/scripts/generate-print-golden-house.ts b/packages/editor/scripts/generate-print-golden-house.ts new file mode 100644 index 0000000000..d987c88546 --- /dev/null +++ b/packages/editor/scripts/generate-print-golden-house.ts @@ -0,0 +1,85 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { prepareSceneForExport } from '../src/lib/glb-export' +import { exportSceneLevelsForPrint } from '../src/lib/level-print-export' +import { filterPreparedSceneForPrintContent } from '../src/lib/print-content-scope' +import { createPrintGoldenHouseFixture } from '../src/lib/print-golden-house.test-fixture' +import { compileManifoldMeshData } from '../src/lib/print-shell-compiler-manifold-core' +import { compileSemanticPrintShellWithManifold } from '../src/lib/print-shell-compiler-manifold-worker' + +const outputArgument = process.argv[2] +if (!outputArgument) { + throw new Error( + 'Usage: bun packages/editor/scripts/generate-print-golden-house.ts ', + ) +} + +const outputDirectory = resolve(outputArgument) +const fixture = createPrintGoldenHouseFixture() + +async function sha256(data: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure') + const compileShell = (source: Parameters[0]) => + compileSemanticPrintShellWithManifold(source, fixture.nodes, { + runner: compileManifoldMeshData, + }) + const common = { + scale: 100, + plinth: { marginMm: 2, thicknessMm: 3 }, + compileShells: true, + compileShell, + } + const threeMf = await exportSceneLevelsForPrint(structure, fixture.nodes, { + ...common, + format: '3mf', + }) + const stl = await exportSceneLevelsForPrint(structure, fixture.nodes, { + ...common, + format: 'stl', + }) + if (threeMf.report.status === 'blocked' || stl.report.status === 'blocked') { + throw new Error('The golden house failed print preflight and was not written.') + } + + await mkdir(outputDirectory, { recursive: true }) + const files = [ + { name: 'pascal-golden-house-levels.3mf', data: threeMf.data }, + { name: 'pascal-golden-house-levels-stl.zip', data: stl.data }, + ] + for (const file of files) await writeFile(resolve(outputDirectory, file.name), file.data) + + const manifest = { + kind: 'pascal-print-golden-house', + version: 1, + scale: 100, + units: 'millimeter', + files: await Promise.all( + files.map(async (file) => ({ + name: file.name, + bytes: file.data.byteLength, + sha256: await sha256(file.data), + })), + ), + parts: threeMf.report.parts.map((part) => ({ + kind: part.kind, + label: part.label, + sourceBaseMeters: part.sourceBaseMeters, + bounds: part.report.bounds, + triangles: part.report.triangleCount, + volumeMm3: part.report.volumeMm3, + })), + } + await writeFile( + resolve(outputDirectory, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ) + process.stdout.write(`${JSON.stringify({ outputDirectory, ...manifest }, null, 2)}\n`) +} finally { + fixture.dispose() +} diff --git a/packages/editor/src/lib/print-golden-house.test-fixture.ts b/packages/editor/src/lib/print-golden-house.test-fixture.ts new file mode 100644 index 0000000000..4ccd52d152 --- /dev/null +++ b/packages/editor/src/lib/print-golden-house.test-fixture.ts @@ -0,0 +1,348 @@ +import { + type AnyNode, + BuildingNode, + calculateLevelMiters, + DoorNode, + getLevelElevations, + LevelNode, + nodeRegistry, + RoofSegmentNode, + registerNode, + SlabNode, + type SlabPolygonContext, + sceneRegistry, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { + generateExtrudedWall, + generateRoofSegmentGeometry, + generateSlabGeometry, +} from '@pascal-app/viewer' +import * as THREE from 'three' + +const EMPTY_SLAB_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] } +const PRINT_GOLDEN_FURNITURE_KIND = 'print-golden-furniture' + +export const PRINT_GOLDEN_HOUSE_IDS = { + building: 'building_print-golden-house', + groundLevel: 'level_print-golden-ground', + upperLevel: 'level_print-golden-upper', + groundWalls: [ + 'wall_print-golden-ground-front', + 'wall_print-golden-ground-right', + 'wall_print-golden-ground-back', + 'wall_print-golden-ground-left', + ], + upperWalls: [ + 'wall_print-golden-upper-front', + 'wall_print-golden-upper-right', + 'wall_print-golden-upper-back', + 'wall_print-golden-upper-left', + ], + door: 'door_print-golden-ground-front', + window: 'window_print-golden-upper-back', + groundSlab: 'slab_print-golden-ground', + upperSlab: 'slab_print-golden-upper', + roof: 'rseg_print-golden-upper', + visibleFurniture: 'furniture_print-golden-visible', + hiddenFurnitureParent: 'furniture_print-golden-hidden-parent', + hiddenFurnitureChild: 'furniture_print-golden-hidden-child', +} as const + +export type PrintGoldenHouseFixture = { + root: THREE.Group + nodes: Record + structuralNodeIds: string[] + groundStructuralNodeIds: string[] + upperStructuralNodeIds: string[] + dispose: () => void +} + +function wall( + id: string, + parentId: string, + start: [number, number], + end: [number, number], + children: string[] = [], +) { + return WallNode.parse({ + id, + parentId, + start, + end, + height: 2.5, + thickness: 0.2, + children, + }) +} + +function slab(id: string, parentId: string) { + return SlabNode.parse({ + id, + parentId, + elevation: 0.2, + thickness: 0.2, + polygon: [ + [-2.1, -1.6], + [2.1, -1.6], + [2.1, 1.6], + [-2.1, 1.6], + ], + }) +} + +function disposeObject(root: THREE.Object3D) { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + mesh.geometry.dispose() + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + for (const material of materials) material.dispose() + }) +} + +export function createPrintGoldenHouseFixture(): PrintGoldenHouseFixture { + const ids = PRINT_GOLDEN_HOUSE_IDS + for (const kind of ['wall', 'door', 'window', 'slab', 'roof-segment']) { + if (nodeRegistry.has(kind)) continue + registerNode({ + kind, + schemaVersion: 1, + category: 'structure', + defaults: () => ({}), + capabilities: {}, + } as never) + } + const groundWalls = [ + wall(ids.groundWalls[0], ids.groundLevel, [-2, -1.5], [2, -1.5], [ids.door]), + wall(ids.groundWalls[1], ids.groundLevel, [2, -1.5], [2, 1.5]), + wall(ids.groundWalls[2], ids.groundLevel, [2, 1.5], [-2, 1.5]), + wall(ids.groundWalls[3], ids.groundLevel, [-2, 1.5], [-2, -1.5]), + ] + const upperWalls = [ + wall(ids.upperWalls[0], ids.upperLevel, [-2, -1.5], [2, -1.5]), + wall(ids.upperWalls[1], ids.upperLevel, [2, -1.5], [2, 1.5]), + wall(ids.upperWalls[2], ids.upperLevel, [2, 1.5], [-2, 1.5], [ids.window]), + wall(ids.upperWalls[3], ids.upperLevel, [-2, 1.5], [-2, -1.5]), + ] + const door = DoorNode.parse({ + id: ids.door, + parentId: groundWalls[0]!.id, + wallId: groundWalls[0]!.id, + position: [2, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const window = WindowNode.parse({ + id: ids.window, + parentId: upperWalls[2]!.id, + wallId: upperWalls[2]!.id, + position: [2, 1.4, 0], + width: 1.2, + height: 1, + }) + const groundSlab = slab(ids.groundSlab, ids.groundLevel) + const upperSlab = slab(ids.upperSlab, ids.upperLevel) + const roof = RoofSegmentNode.parse({ + id: ids.roof, + parentId: ids.upperLevel, + roofType: 'gable', + position: [0, 2.5, 0], + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + }) + + if (!nodeRegistry.has(PRINT_GOLDEN_FURNITURE_KIND)) { + registerNode({ + kind: PRINT_GOLDEN_FURNITURE_KIND, + schemaVersion: 1, + category: 'furnish', + defaults: () => ({}), + capabilities: {}, + } as never) + } + const visibleFurniture = { + object: 'node', + id: ids.visibleFurniture, + type: PRINT_GOLDEN_FURNITURE_KIND, + parentId: ids.groundLevel, + children: [], + visible: true, + } as unknown as AnyNode + const hiddenFurnitureParent = { + object: 'node', + id: ids.hiddenFurnitureParent, + type: PRINT_GOLDEN_FURNITURE_KIND, + parentId: ids.groundLevel, + children: [ids.hiddenFurnitureChild], + visible: false, + } as unknown as AnyNode + const hiddenFurnitureChild = { + object: 'node', + id: ids.hiddenFurnitureChild, + type: PRINT_GOLDEN_FURNITURE_KIND, + parentId: ids.hiddenFurnitureParent, + children: [], + visible: true, + } as unknown as AnyNode + + const groundLevel = LevelNode.parse({ + id: ids.groundLevel, + parentId: ids.building, + name: 'Ground', + level: 0, + height: 2.5, + children: [ + ...groundWalls.map((node) => node.id), + groundSlab.id, + visibleFurniture.id, + hiddenFurnitureParent.id, + ], + }) + const upperLevel = LevelNode.parse({ + id: ids.upperLevel, + parentId: ids.building, + name: 'Upper', + level: 1, + height: 2.5, + children: [...upperWalls.map((node) => node.id), upperSlab.id, roof.id], + }) + const building = BuildingNode.parse({ + id: ids.building, + children: [groundLevel.id, upperLevel.id], + }) + const nodes = Object.fromEntries( + [ + building, + groundLevel, + upperLevel, + ...groundWalls, + ...upperWalls, + door, + window, + groundSlab, + upperSlab, + roof, + visibleFurniture, + hiddenFurnitureParent, + hiddenFurnitureChild, + ].map((node) => [node.id, node]), + ) as Record + + const root = new THREE.Group() + root.name = 'print-golden-house' + const buildingRoot = new THREE.Group() + buildingRoot.userData = { pascalId: building.id } + const groundRoot = new THREE.Group() + groundRoot.userData = { pascalId: groundLevel.id } + const upperRoot = new THREE.Group() + upperRoot.userData = { pascalId: upperLevel.id } + const elevations = getLevelElevations(nodes) + groundRoot.position.y = elevations.get(groundLevel.id)?.baseY ?? 0 + upperRoot.position.y = elevations.get(upperLevel.id)?.baseY ?? 0 + root.add(buildingRoot) + buildingRoot.add(groundRoot, upperRoot) + + const registered = new Map() + const registerObject = (id: string, object: THREE.Object3D) => { + sceneRegistry.nodes.set(id, object) + registered.set(id, object) + } + registerObject(building.id, buildingRoot) + registerObject(groundLevel.id, groundRoot) + registerObject(upperLevel.id, upperRoot) + + const mountWalls = ( + levelRoot: THREE.Group, + walls: WallNode[], + openings: Array, + ) => { + const miters = calculateLevelMiters(walls) + for (const wallNode of walls) { + const wallRoot = new THREE.Group() + wallRoot.userData = { pascalId: wallNode.id } + wallRoot.position.set(wallNode.start[0], 0, wallNode.start[1]) + wallRoot.rotation.y = -Math.atan2( + wallNode.end[1] - wallNode.start[1], + wallNode.end[0] - wallNode.start[0], + ) + const wallOpenings = openings.filter((opening) => opening.wallId === wallNode.id) + wallRoot.add(new THREE.Mesh(generateExtrudedWall(wallNode, wallOpenings, miters))) + for (const opening of wallOpenings) { + const openingRoot = new THREE.Group() + openingRoot.userData = { pascalId: opening.id } + wallRoot.add(openingRoot) + registerObject(opening.id, openingRoot) + } + levelRoot.add(wallRoot) + registerObject(wallNode.id, wallRoot) + } + } + mountWalls(groundRoot, groundWalls, [door]) + mountWalls(upperRoot, upperWalls, [window]) + + const mountSlab = (levelRoot: THREE.Group, node: SlabNode) => { + const slabRoot = new THREE.Group() + slabRoot.userData = { pascalId: node.id } + slabRoot.add(new THREE.Mesh(generateSlabGeometry(node, EMPTY_SLAB_CONTEXT))) + levelRoot.add(slabRoot) + registerObject(node.id, slabRoot) + } + mountSlab(groundRoot, groundSlab) + mountSlab(upperRoot, upperSlab) + + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.position.set(...roof.position) + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + upperRoot.add(roofRoot) + registerObject(roof.id, roofRoot) + + const visibleFurnitureRoot = new THREE.Group() + visibleFurnitureRoot.userData = { pascalId: visibleFurniture.id } + visibleFurnitureRoot.position.set(1, 0.5, 0) + visibleFurnitureRoot.add(new THREE.Mesh(new THREE.BoxGeometry(0.8, 1, 0.8))) + groundRoot.add(visibleFurnitureRoot) + registerObject(visibleFurniture.id, visibleFurnitureRoot) + + const hiddenFurnitureParentRoot = new THREE.Group() + hiddenFurnitureParentRoot.userData = { pascalId: hiddenFurnitureParent.id } + hiddenFurnitureParentRoot.position.set(-1, 0, 0) + const hiddenFurnitureChildRoot = new THREE.Group() + hiddenFurnitureChildRoot.userData = { pascalId: hiddenFurnitureChild.id } + hiddenFurnitureChildRoot.position.y = 0.5 + hiddenFurnitureChildRoot.add(new THREE.Mesh(new THREE.BoxGeometry(0.8, 1, 0.8))) + hiddenFurnitureParentRoot.add(hiddenFurnitureChildRoot) + groundRoot.add(hiddenFurnitureParentRoot) + registerObject(hiddenFurnitureParent.id, hiddenFurnitureParentRoot) + registerObject(hiddenFurnitureChild.id, hiddenFurnitureChildRoot) + + const groundStructuralNodeIds = [...groundWalls.map((node) => node.id), groundSlab.id].sort() + const upperStructuralNodeIds = [ + ...upperWalls.map((node) => node.id), + upperSlab.id, + roof.id, + ].sort() + + return { + root, + nodes, + structuralNodeIds: [...groundStructuralNodeIds, ...upperStructuralNodeIds].sort(), + groundStructuralNodeIds, + upperStructuralNodeIds, + dispose: () => { + for (const [id, object] of registered) { + if (sceneRegistry.nodes.get(id) === object) sceneRegistry.nodes.delete(id) + } + disposeObject(root) + root.clear() + }, + } +} diff --git a/packages/editor/src/lib/print-golden-house.test.ts b/packages/editor/src/lib/print-golden-house.test.ts new file mode 100644 index 0000000000..dce521dc05 --- /dev/null +++ b/packages/editor/src/lib/print-golden-house.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from 'bun:test' +import { XMLParser } from 'fast-xml-parser' +import { strFromU8, unzipSync } from 'fflate' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { exportSceneLevelsForPrint } from './level-print-export' +import { filterPreparedSceneForPrintContent } from './print-content-scope' +import { + createPrintGoldenHouseFixture, + PRINT_GOLDEN_HOUSE_IDS, +} from './print-golden-house.test-fixture' +import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' +import { compileSemanticPrintShellWithManifold } from './print-shell-compiler-manifold-worker' + +function identityIds(root: THREE.Object3D): string[] { + const ids: string[] = [] + root.traverse((object) => { + if (typeof object.userData.pascalId === 'string') ids.push(object.userData.pascalId) + }) + return ids.sort() +} + +function objectByIdentity(root: THREE.Object3D, id: string): THREE.Object3D { + let match: THREE.Object3D | null = null + root.traverse((object) => { + if (object.userData.pascalId === id) match = object + }) + if (!match) throw new Error(`Missing prepared object ${id}`) + return match +} + +function rayIntersectionCount( + root: THREE.Object3D, + origin: THREE.Vector3, + direction: THREE.Vector3, + far: number, +): number { + root.updateMatrixWorld(true) + const raycaster = new THREE.Raycaster(origin, direction.normalize(), 0, far) + const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }) + let count = 0 + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const originalMaterial = mesh.material + mesh.material = material + count += raycaster.intersectObject(mesh, false).length + mesh.material = originalMaterial + }) + material.dispose() + return count +} + +function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + +function packageObjectSizes(data: Uint8Array): Array<{ name: string; size: THREE.Vector3 }> { + const files = unzipSync(data) + const xml = strFromU8(files['3D/3dmodel.model']!) + const model = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }).parse(xml).model + const objects = asArray>(model.resources.object) + const items = asArray>(model.build.item) + return objects.map((object, index) => { + const mesh = object.mesh as { vertices: { vertex: Record[] } } + const vertices = asArray(mesh.vertices.vertex) + const transformValue = items[index]?.transform + if (!transformValue) throw new Error(`Missing build transform for ${String(object.name)}`) + const transform = transformValue.split(' ').map(Number) + const translation = new THREE.Vector3(transform[9]!, transform[10]!, transform[11]!) + const bounds = new THREE.Box3() + for (const vertex of vertices) { + bounds.expandByPoint( + new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z)).add(translation), + ) + } + return { name: String(object.name), size: bounds.getSize(new THREE.Vector3()) } + }) +} + +const compileGoldenShell = ( + source: THREE.Object3D, + nodes: Parameters[1], +) => compileSemanticPrintShellWithManifold(source, nodes, { runner: compileManifoldMeshData }) + +describe('print golden house', () => { + test('consolidates hidden-ancestor visibility and structure-only scope', () => { + const fixture = createPrintGoldenHouseFixture() + try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const preparedIds = identityIds(prepared.scene) + expect(preparedIds).toContain(PRINT_GOLDEN_HOUSE_IDS.visibleFurniture) + expect(preparedIds).not.toContain(PRINT_GOLDEN_HOUSE_IDS.hiddenFurnitureParent) + expect(preparedIds).not.toContain(PRINT_GOLDEN_HOUSE_IDS.hiddenFurnitureChild) + + const structure = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'structure', + ) + const structureIds = identityIds(structure) + expect(structureIds).not.toContain(PRINT_GOLDEN_HOUSE_IDS.visibleFurniture) + expect(structureIds).toEqual( + expect.arrayContaining([ + PRINT_GOLDEN_HOUSE_IDS.groundLevel, + PRINT_GOLDEN_HOUSE_IDS.upperLevel, + ...fixture.structuralNodeIds, + ]), + ) + } finally { + fixture.dispose() + } + }) + + test('preserves door and window voids in the final Manifold level shells', async () => { + const fixture = createPrintGoldenHouseFixture() + try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const structure = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'structure', + ) + const ground = await compileGoldenShell( + objectByIdentity(structure, PRINT_GOLDEN_HOUSE_IDS.groundLevel), + fixture.nodes, + ) + const upper = await compileGoldenShell( + objectByIdentity(structure, PRINT_GOLDEN_HOUSE_IDS.upperLevel), + fixture.nodes, + ) + + expect(ground.status).toBe('compiled') + expect(upper.status).toBe('compiled') + expect(ground.sourceNodeIds).toEqual(fixture.groundStructuralNodeIds) + expect(upper.sourceNodeIds).toEqual(fixture.upperStructuralNodeIds) + expect( + rayIntersectionCount( + ground.scene!, + new THREE.Vector3(0, 1.05, -2), + new THREE.Vector3(0, 0, 1), + 0.8, + ), + ).toBe(0) + expect( + rayIntersectionCount( + ground.scene!, + new THREE.Vector3(1.5, 1.05, -2), + new THREE.Vector3(0, 0, 1), + 0.8, + ), + ).toBeGreaterThanOrEqual(2) + expect( + rayIntersectionCount( + upper.scene!, + new THREE.Vector3(0, 3.9, 1), + new THREE.Vector3(0, 0, 1), + 1, + ), + ).toBe(0) + expect( + rayIntersectionCount( + upper.scene!, + new THREE.Vector3(1.5, 3.9, 1), + new THREE.Vector3(0, 0, 1), + 1, + ), + ).toBeGreaterThanOrEqual(2) + } finally { + fixture.dispose() + } + }, 15_000) + + test('emits deterministic two-level 3MF parts and plinth from the same semantic house', async () => { + const fixture = createPrintGoldenHouseFixture() + try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const structure = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'structure', + ) + const options = { + scale: 100, + format: '3mf' as const, + plinth: { marginMm: 2, thicknessMm: 3 }, + compileShells: true, + compileShell: compileGoldenShell, + } + const first = await exportSceneLevelsForPrint(structure, fixture.nodes, options) + const second = await exportSceneLevelsForPrint(structure, fixture.nodes, options) + + expect(first.report.status).toBe('pass') + expect(first.report.parts.map((part) => part.objectName)).toEqual([ + '00 Plinth', + '01 Ground', + '02 Upper', + ]) + expect(first.report.parts.map((part) => part.sourceBaseMeters)).toEqual([null, 0, 2.5]) + for (const part of first.report.parts) { + expect(part.report.status).toBe('pass') + expect(part.report.degenerateTriangleCount).toBe(0) + expect(part.report.boundaryEdgeCount).toBe(0) + expect(part.report.nonManifoldEdgeCount).toBe(0) + expect(part.report.volumeMm3).toBeGreaterThan(0) + expect(part.report.bounds?.min.z).toBeCloseTo(0, 5) + } + expect(first.report.parts[1]?.report.diagnostics).toContainEqual( + expect.objectContaining({ nodeIds: fixture.groundStructuralNodeIds }), + ) + expect(first.report.parts[2]?.report.diagnostics).toContainEqual( + expect.objectContaining({ nodeIds: fixture.upperStructuralNodeIds }), + ) + + const objects = packageObjectSizes(first.data) + expect(objects.map((object) => object.name)).toEqual(['00 Plinth', '01 Ground', '02 Upper']) + expect(objects[0]?.size.x).toBeCloseTo(46, 4) + expect(objects[0]?.size.y).toBeCloseTo(36, 4) + expect(objects[0]?.size.z).toBeCloseTo(3, 4) + expect(objects[1]?.size.x).toBeCloseTo(42, 4) + expect(objects[1]?.size.y).toBeCloseTo(32, 4) + expect(objects[1]?.size.z).toBeCloseTo(25, 4) + expect(objects[2]?.size.x).toBeCloseTo(46.6962, 3) + expect(objects[2]?.size.y).toBeCloseTo(37.1962, 3) + expect(objects[2]?.size.z).toBeCloseTo(40.3923, 3) + expect(first.data).toEqual(second.data) + } finally { + fixture.dispose() + } + }, 30_000) +}) diff --git a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts index 56045f2ac5..a4ca053f10 100644 --- a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts +++ b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts @@ -9,7 +9,6 @@ import { type SlabPolygonContext, sceneRegistry, WallNode, - WindowNode, } from '@pascal-app/core' import { generateExtrudedWall, @@ -17,7 +16,10 @@ import { generateSlabGeometry, } from '@pascal-app/viewer' import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { filterPreparedSceneForPrintContent } from './print-content-scope' import { exportSceneToPrintStl } from './print-export' +import { createPrintGoldenHouseFixture } from './print-golden-house.test-fixture' import { compileSemanticPrintShell } from './print-shell-compiler' import { compilePrintShellBaseline } from './print-shell-compiler-baseline' import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' @@ -39,9 +41,10 @@ function rayIntersectionCount( x: number, y: number, far = Number.POSITIVE_INFINITY, + startZ = -2, ): number { root.updateMatrixWorld(true) - const raycaster = new THREE.Raycaster(new THREE.Vector3(x, y, -2), new THREE.Vector3(0, 0, 1)) + const raycaster = new THREE.Raycaster(new THREE.Vector3(x, y, startZ), new THREE.Vector3(0, 0, 1)) raycaster.far = far const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }) let count = 0 @@ -181,124 +184,29 @@ describe('print shell compiler baseline', () => { }) test('compares the semantic full-house baseline union with the Manifold candidate', async () => { - const walls = [ - WallNode.parse({ - id: 'wall_print-house-front', - start: [-2, -1.5], - end: [2, -1.5], - height: 2.5, - thickness: 0.2, - children: ['door_print-house-front'], - }), - WallNode.parse({ - id: 'wall_print-house-right', - start: [2, -1.5], - end: [2, 1.5], - height: 2.5, - thickness: 0.2, - }), - WallNode.parse({ - id: 'wall_print-house-back', - start: [2, 1.5], - end: [-2, 1.5], - height: 2.5, - thickness: 0.2, - children: ['window_print-house-back'], - }), - WallNode.parse({ - id: 'wall_print-house-left', - start: [-2, 1.5], - end: [-2, -1.5], - height: 2.5, - thickness: 0.2, - }), - ] - const door = DoorNode.parse({ - id: 'door_print-house-front', - wallId: walls[0]!.id, - position: [2, 1.05, 0], - width: 0.9, - height: 2.1, - }) - const window = WindowNode.parse({ - id: 'window_print-house-back', - wallId: walls[2]!.id, - position: [2, 1.4, 0], - width: 1.2, - height: 1, - }) - const slab = SlabNode.parse({ - id: 'slab_print-house', - elevation: 0, - thickness: 0.2, - polygon: [ - [-2.1, -1.6], - [2.1, -1.6], - [2.1, 1.6], - [-2.1, 1.6], - ], - }) - const roof = RoofSegmentNode.parse({ - id: 'rseg_print-house', - roofType: 'gable', - position: [0, 2.5, 0], - width: 4, - depth: 3, - wallHeight: 0.5, - pitch: 30, - wallThickness: 0.15, - deckThickness: 0.1, - overhang: 0.3, - shingleThickness: 0.05, - }) - const source = new THREE.Group() - const miters = calculateLevelMiters(walls) - const nodes = Object.fromEntries( - [...walls, door, window, slab, roof].map((node) => [node.id, node]), - ) as Record + const fixture = createPrintGoldenHouseFixture() try { - for (const wall of walls) { - const root = new THREE.Group() - root.userData = { pascalId: wall.id } - root.position.set(wall.start[0], 0, wall.start[1]) - root.rotation.y = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) - sceneRegistry.nodes.set(wall.id, root) - const openings = [door, window].filter((opening) => opening.wallId === wall.id) - root.add(new THREE.Mesh(generateExtrudedWall(wall, openings, miters))) - for (const opening of openings) { - const openingRoot = new THREE.Group() - openingRoot.userData = { pascalId: opening.id } - root.add(openingRoot) - } - source.add(root) - } - const slabRoot = new THREE.Group() - slabRoot.userData = { pascalId: slab.id } - slabRoot.add(new THREE.Mesh(generateSlabGeometry(slab, EMPTY_SLAB_CONTEXT))) - source.add(slabRoot) - - const roofRoot = new THREE.Group() - roofRoot.userData = { pascalId: roof.id } - roofRoot.position.set(...roof.position) - roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) - source.add(roofRoot) - - const compiled = compileSemanticPrintShell(source, nodes, { wallSolids: true }) + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const structure = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'structure', + ) + const compiled = compileSemanticPrintShell(structure, fixture.nodes, { wallSolids: true }) expect(compiled.status).toBe('compiled') - expect(compiled.inputMeshCount).toBe(11) - expect(compiled.sourceNodeIds).toEqual([...walls, slab, roof].map((node) => node.id).sort()) + expect(compiled.inputMeshCount).toBeGreaterThan(10) + expect(compiled.sourceNodeIds).toEqual(fixture.structuralNodeIds) expect(compiled.scene).not.toBeNull() const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) expect(print.report.status).toBe('blocked') - expect(print.report.degenerateTriangleCount).toBe(52) - expect(print.report.boundaryEdgeCount).toBe(59) - expect(print.report.nonManifoldEdgeCount).toBe(1) + expect(print.report.degenerateTriangleCount).toBeGreaterThan(0) + expect(print.report.boundaryEdgeCount).toBeGreaterThan(0) expect(print.report.volumeMm3).toBeGreaterThan(0) - const candidate = await compileSemanticPrintShellWithManifold(source, nodes, { + const candidate = await compileSemanticPrintShellWithManifold(structure, fixture.nodes, { runner: compileManifoldMeshData, }) expect(candidate.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual( @@ -307,9 +215,10 @@ describe('print shell compiler baseline', () => { expect(candidate.backend).toBe('manifold-3d') expect(candidate.scene).not.toBeNull() expect(indexedNonManifoldEdgeCount(candidate.scene!)).toBe(0) - expect(rayIntersectionCount(candidate.scene!, 0, 1, 0.8)).toBe(0) - expect(rayIntersectionCount(candidate.scene!, 1.5, 1, 0.8)).toBeGreaterThanOrEqual(2) - expect(rayIntersectionCount(candidate.scene!, 0, 1.4, 4)).toBe(0) + expect(rayIntersectionCount(candidate.scene!, 0, 1.05, 0.8)).toBe(0) + expect(rayIntersectionCount(candidate.scene!, 1.5, 1.05, 0.8)).toBeGreaterThanOrEqual(2) + expect(rayIntersectionCount(candidate.scene!, 0, 3.9, 1, 1)).toBe(0) + expect(rayIntersectionCount(candidate.scene!, 1.5, 3.9, 1, 1)).toBeGreaterThanOrEqual(2) const candidatePrint = exportSceneToPrintStl(candidate.scene!, { scale: 100, @@ -324,7 +233,7 @@ describe('print shell compiler baseline', () => { expect(candidatePrint.report.nonManifoldEdgeCount).toBe(0) expect(candidatePrint.report.volumeMm3).toBeGreaterThan(0) } finally { - for (const wall of walls) sceneRegistry.nodes.delete(wall.id) + fixture.dispose() } }, 15_000) diff --git a/packages/editor/src/lib/print-shell-compiler-manifold-core.ts b/packages/editor/src/lib/print-shell-compiler-manifold-core.ts index 7a1b8148c7..082dcb835d 100644 --- a/packages/editor/src/lib/print-shell-compiler-manifold-core.ts +++ b/packages/editor/src/lib/print-shell-compiler-manifold-core.ts @@ -3,6 +3,10 @@ import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baselin import type { ManifoldCompileOutput, ManifoldMeshData } from './print-shell-compiler-protocol' let modulePromise: Promise | null = null +const MANIFOLD_OUTPUT_WELD_EPSILON_METERS = 2e-5 +const COLLINEAR_SEAM_CROSS_LENGTH_SQ = 1e-20 + +type Triangle = [number, number, number] async function getManifoldModule(wasmUrl?: string): Promise { modulePromise ??= ManifoldModule(wasmUrl ? { locateFile: () => wasmUrl } : undefined).then( @@ -25,6 +29,103 @@ function manifoldMesh( }) } +function distanceSquared(positions: Float32Array, left: number, right: number): number { + const dx = positions[left * 3]! - positions[right * 3]! + const dy = positions[left * 3 + 1]! - positions[right * 3 + 1]! + const dz = positions[left * 3 + 2]! - positions[right * 3 + 2]! + return dx * dx + dy * dy + dz * dz +} + +function triangleCrossLengthSquared(positions: Float32Array, [a, b, c]: Triangle): number { + const abX = positions[b * 3]! - positions[a * 3]! + const abY = positions[b * 3 + 1]! - positions[a * 3 + 1]! + const abZ = positions[b * 3 + 2]! - positions[a * 3 + 2]! + const acX = positions[c * 3]! - positions[a * 3]! + const acY = positions[c * 3 + 1]! - positions[a * 3 + 1]! + const acZ = positions[c * 3 + 2]! - positions[a * 3 + 2]! + const crossX = abY * acZ - abZ * acY + const crossY = abZ * acX - abX * acZ + const crossZ = abX * acY - abY * acX + return crossX * crossX + crossY * crossY + crossZ * crossZ +} + +function collinearSeam( + positions: Float32Array, + triangle: Triangle, +): { start: number; middle: number; end: number } | null { + if (triangleCrossLengthSquared(positions, triangle) > COLLINEAR_SEAM_CROSS_LENGTH_SQ) { + return null + } + + const [a, b, c] = triangle + const edges = [ + { start: a, middle: c, end: b, lengthSquared: distanceSquared(positions, a, b) }, + { start: b, middle: a, end: c, lengthSquared: distanceSquared(positions, b, c) }, + { start: c, middle: b, end: a, lengthSquared: distanceSquared(positions, c, a) }, + ].sort((left, right) => right.lengthSquared - left.lengthSquared) + const longest = edges[0]! + if (longest.lengthSquared === 0) return null + + const startOffset = longest.start * 3 + const middleOffset = longest.middle * 3 + const endOffset = longest.end * 3 + const edgeX = positions[endOffset]! - positions[startOffset]! + const edgeY = positions[endOffset + 1]! - positions[startOffset + 1]! + const edgeZ = positions[endOffset + 2]! - positions[startOffset + 2]! + const middleX = positions[middleOffset]! - positions[startOffset]! + const middleY = positions[middleOffset + 1]! - positions[startOffset + 1]! + const middleZ = positions[middleOffset + 2]! - positions[startOffset + 2]! + const projection = middleX * edgeX + middleY * edgeY + middleZ * edgeZ + if (projection <= 0 || projection >= longest.lengthSquared) return null + + return longest +} + +function stitchCollinearSeams(positions: Float32Array, input: Triangle[]): Triangle[] { + const triangles: Array = [...input] + + // Manifold can encode a T-junction as one zero-area triangle: one surface owns the long + // edge while the other owns its two segments. Split the neighboring face at the middle + // vertex before removing the collapsed face so indexed edge incidence remains closed. + for (let triangleIndex = 0; triangleIndex < triangles.length; triangleIndex += 1) { + const triangle = triangles[triangleIndex] + if (!triangle) continue + const seam = collinearSeam(positions, triangle) + if (!seam) continue + + const matches: Array<{ index: number; edgeOffset: number }> = [] + for (let candidateIndex = 0; candidateIndex < triangles.length; candidateIndex += 1) { + if (candidateIndex === triangleIndex) continue + const candidate = triangles[candidateIndex] + if ( + !candidate || + triangleCrossLengthSquared(positions, candidate) <= COLLINEAR_SEAM_CROSS_LENGTH_SQ + ) { + continue + } + for (let edgeOffset = 0; edgeOffset < 3; edgeOffset += 1) { + const from = candidate[edgeOffset]! + const to = candidate[(edgeOffset + 1) % 3]! + if ((from === seam.start && to === seam.end) || (from === seam.end && to === seam.start)) { + matches.push({ index: candidateIndex, edgeOffset }) + } + } + } + if (matches.length !== 1) continue + + const match = matches[0]! + const neighbor = triangles[match.index]! + const from = neighbor[match.edgeOffset]! + const to = neighbor[(match.edgeOffset + 1) % 3]! + const opposite = neighbor[(match.edgeOffset + 2) % 3]! + triangles[match.index] = [from, seam.middle, opposite] + triangles.push([seam.middle, to, opposite]) + triangles[triangleIndex] = null + } + + return triangles.filter((triangle): triangle is Triangle => triangle !== null) +} + function manifoldOutput(solid: ManifoldSolid): { positions: Float32Array; indices: Uint32Array } { const mesh = solid.getMesh() const positions = new Float32Array(mesh.numVert * 3) @@ -51,25 +152,55 @@ function manifoldOutput(solid: ManifoldSolid): { positions: Float32Array; indice parents[find(mesh.mergeFromVert[index]!)] = find(mesh.mergeToVert[index]!) } - const indices: number[] = [] + // Float32 boolean output can leave seam vertices just over 10 microns apart. Dropping the + // resulting sliver triangle by area opens the shell; weld the vertices first so adjacent + // faces inherit one indexed edge, then remove only triangles collapsed by that topology. + const cellSize = MANIFOLD_OUTPUT_WELD_EPSILON_METERS + const cellRoots = new Map() + const cellCoordinate = (value: number) => Math.floor(value / cellSize) + const cellKey = (x: number, y: number, z: number) => `${x},${y},${z}` + const weldDistanceSquared = cellSize * cellSize + for (let index = 0; index < mesh.numVert; index += 1) { + const root = find(index) + if (root !== index) continue + const cellX = cellCoordinate(positions[root * 3]!) + const cellY = cellCoordinate(positions[root * 3 + 1]!) + const cellZ = cellCoordinate(positions[root * 3 + 2]!) + let weldedTo: number | null = null + for (let xOffset = -1; xOffset <= 1 && weldedTo === null; xOffset += 1) { + for (let yOffset = -1; yOffset <= 1 && weldedTo === null; yOffset += 1) { + for (let zOffset = -1; zOffset <= 1 && weldedTo === null; zOffset += 1) { + const candidates = cellRoots.get( + cellKey(cellX + xOffset, cellY + yOffset, cellZ + zOffset), + ) + for (const candidate of candidates ?? []) { + if (distanceSquared(positions, root, candidate) <= weldDistanceSquared) { + weldedTo = candidate + break + } + } + } + } + } + if (weldedTo === null) { + const key = cellKey(cellX, cellY, cellZ) + const roots = cellRoots.get(key) ?? [] + roots.push(root) + cellRoots.set(key, roots) + } else { + parents[root] = find(weldedTo) + } + } + + const triangles: Triangle[] = [] for (let index = 0; index + 2 < mesh.triVerts.length; index += 3) { const a = find(mesh.triVerts[index]!) const b = find(mesh.triVerts[index + 1]!) const c = find(mesh.triVerts[index + 2]!) if (a === b || b === c || c === a) continue - - const abX = positions[b * 3]! - positions[a * 3]! - const abY = positions[b * 3 + 1]! - positions[a * 3 + 1]! - const abZ = positions[b * 3 + 2]! - positions[a * 3 + 2]! - const acX = positions[c * 3]! - positions[a * 3]! - const acY = positions[c * 3 + 1]! - positions[a * 3 + 1]! - const acZ = positions[c * 3 + 2]! - positions[a * 3 + 2]! - const crossX = abY * acZ - abZ * acY - const crossY = abZ * acX - abX * acZ - const crossZ = abX * acY - abY * acX - if (crossX * crossX + crossY * crossY + crossZ * crossZ <= 1e-12) continue - indices.push(a, b, c) + triangles.push([a, b, c]) } + const indices = stitchCollinearSeams(positions, triangles).flat() return { positions, indices: new Uint32Array(indices) } } From 936d56b6f858598bde81f60e1e7776d06ee09e90 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 20 Aug 2026 16:25:29 -0400 Subject: [PATCH 16/19] feat: report semantic print feature thickness --- .../scripts/generate-print-golden-house.ts | 1 + .../src/components/editor/export-manager.tsx | 13 +- .../settings-panel/print-export-card.test.ts | 42 +++- .../settings-panel/print-export-card.tsx | 67 ++++++- packages/editor/src/lib/level-print-export.ts | 32 +++- packages/editor/src/lib/print-export.ts | 1 + .../src/lib/print-feature-thickness.test.ts | 144 ++++++++++++++ .../editor/src/lib/print-feature-thickness.ts | 180 ++++++++++++++++++ .../editor/src/lib/print-golden-house.test.ts | 20 +- packages/viewer/src/store/use-viewer.d.ts | 1 + packages/viewer/src/store/use-viewer.ts | 1 + 11 files changed, 492 insertions(+), 10 deletions(-) create mode 100644 packages/editor/src/lib/print-feature-thickness.test.ts create mode 100644 packages/editor/src/lib/print-feature-thickness.ts diff --git a/packages/editor/scripts/generate-print-golden-house.ts b/packages/editor/scripts/generate-print-golden-house.ts index d987c88546..f5fb6f6e9a 100644 --- a/packages/editor/scripts/generate-print-golden-house.ts +++ b/packages/editor/scripts/generate-print-golden-house.ts @@ -73,6 +73,7 @@ try { bounds: part.report.bounds, triangles: part.report.triangleCount, volumeMm3: part.report.volumeMm3, + minimumFeatureThicknessMm: part.report.minimumFeatureThicknessMm, })), } await writeFile( diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 934aef90a6..5651a35380 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -17,6 +17,7 @@ import { exportSceneLevelsForPrint } from '../../lib/level-print-export' import { exportSceneToPrint3mf } from '../../lib/print-3mf' import { filterPreparedSceneForPrintContent } from '../../lib/print-content-scope' import { exportSceneToPrintStl, mergePrintExportDiagnostics } from '../../lib/print-export' +import { applySemanticPrintFeatureThickness } from '../../lib/print-feature-thickness' import { compileSemanticPrintShellWithManifold } from '../../lib/print-shell-compiler-manifold-worker' // prepareSceneForExport neutralises container meshes (door/window hitbox roots, @@ -96,6 +97,7 @@ export function ExportManager() { const printFormat = format === 'print-3mf' ? '3mf' : 'stl' const scale = options.printScale ?? 100 const compileShells = printContent === 'structure' + const minimumFeatureMm = compileShells ? options.printMinimumFeatureMm : undefined if (options.printScope === 'levels') { const plinth = options.printBase === 'plinth' @@ -108,6 +110,7 @@ export function ExportManager() { scale, format: printFormat, plinth, + minimumFeatureMm, compileShells, compileShell: compileShells ? compileSemanticPrintShellWithManifold : undefined, }) @@ -137,13 +140,21 @@ export function ExportManager() { printFormat === '3mf' ? exportSceneToPrint3mf(printSource, printOptions) : exportSceneToPrintStl(printSource, printOptions) - const report = compiled + let report = compiled ? mergePrintExportDiagnostics( output.report, compiled.diagnostics, new Set(['compiler_pending']), ) : output.report + if (compiled) { + report = applySemanticPrintFeatureThickness( + report, + nodes, + compiled.sourceNodeIds, + minimumFeatureMm, + ) + } const { buffer } = output const blob = new Blob([buffer], { type: printFormat === '3mf' ? 'model/3mf' : 'model/stl', diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts index 5664ab5dd5..be4dad9600 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts @@ -73,6 +73,7 @@ describe('print export card contract', () => { 'none', '2', '2', + '1.8', ) expect(calls).toEqual([ @@ -85,6 +86,7 @@ describe('print export card contract', () => { printScope: 'whole', printContent: 'structure', printBase: 'none', + printMinimumFeatureMm: 1.8, }, }, ]) @@ -99,7 +101,18 @@ describe('print export card contract', () => { } await expect( - preparePrintExport(exportScene, true, '0', 'levels', '3mf', 'structure', 'none', '2', '2'), + preparePrintExport( + exportScene, + true, + '0', + 'levels', + '3mf', + 'structure', + 'none', + '2', + '2', + '', + ), ).rejects.toThrow( 'Enter a positive scale denominator', ) @@ -124,6 +137,7 @@ describe('print export card contract', () => { 'plinth', '3', '2.5', + '', ) expect(calls).toEqual([ @@ -162,11 +176,36 @@ describe('print export card contract', () => { 'plinth', '-1', '2', + '', ), ).rejects.toThrow('non-negative plinth margin') expect(invoked).toBe(false) }) + test('rejects an invalid custom minimum feature target before invoking the exporter', async () => { + let invoked = false + const exportScene: SceneExport = async () => { + invoked = true + return null + } + + await expect( + preparePrintExport( + exportScene, + true, + '50', + 'levels', + '3mf', + 'structure', + 'none', + '2', + '2', + '0', + ), + ).rejects.toThrow('positive minimum feature target') + expect(invoked).toBe(false) + }) + test('rejects an artifact without the print preflight contract', async () => { const exportScene: SceneExport = async () => ({ blob: new Blob(['stl']), @@ -184,6 +223,7 @@ describe('print export card contract', () => { 'none', '2', '2', + '', ), ).rejects.toThrow('did not return a preflight report') }) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx index 401829c485..016dcfa8c5 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx @@ -49,12 +49,21 @@ export async function preparePrintExport( base: PrintBaseMode, plinthMarginInput: string, plinthThicknessInput: string, + minimumFeatureInput: string, ): Promise { const scale = Number(scaleInput) if (!Number.isFinite(scale) || scale <= 0) { throw new RangeError('Enter a positive scale denominator, such as 25, 50, or 100.') } + let minimumFeatureMm: number | undefined + if (content === 'structure' && minimumFeatureInput.trim() !== '') { + minimumFeatureMm = Number(minimumFeatureInput) + if (!Number.isFinite(minimumFeatureMm) || minimumFeatureMm <= 0) { + throw new RangeError('Enter a positive minimum feature target in millimeters.') + } + } + let plinthMarginMm: number | undefined let plinthThicknessMm: number | undefined if (base === 'plinth') { @@ -78,6 +87,7 @@ export async function preparePrintExport( printScope: scope, printContent: content, printBase: base, + ...(minimumFeatureMm === undefined ? {} : { printMinimumFeatureMm: minimumFeatureMm }), ...(plinthMarginMm === undefined ? {} : { printPlinthMarginMm: plinthMarginMm }), ...(plinthThicknessMm === undefined ? {} : { printPlinthThicknessMm: plinthThicknessMm }), }) @@ -92,6 +102,7 @@ export async function preparePrintExport( export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { const scaleInputId = useId() + const minimumFeatureInputId = useId() const nodes = useScene((state) => state.nodes) const exportScene = useViewer((state) => state.exportScene) const [printScale, setPrintScale] = useState('100') @@ -101,6 +112,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { const [base, setBase] = useState('none') const [plinthMargin, setPlinthMargin] = useState('2') const [plinthThickness, setPlinthThickness] = useState('2') + const [minimumFeature, setMinimumFeature] = useState('') const [isPreparing, setIsPreparing] = useState(false) const [prepared, setPrepared] = useState(null) const [error, setError] = useState(null) @@ -108,7 +120,18 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { useEffect(() => { setPrepared(null) setError(null) - }, [nodes, onlyVisible, printScale, scope, format, content, base, plinthMargin, plinthThickness]) + }, [ + nodes, + onlyVisible, + printScale, + scope, + format, + content, + base, + plinthMargin, + plinthThickness, + minimumFeature, + ]) const handlePrepare = async () => { if (!exportScene) { @@ -131,6 +154,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { scope === 'levels' ? base : 'none', plinthMargin, plinthThickness, + minimumFeature, ), ) } catch (reason) { @@ -168,6 +192,25 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { + + @@ -292,7 +303,9 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { value={scope} > @@ -300,7 +313,7 @@ export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) {