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..9998c2309f
--- /dev/null
+++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts
@@ -0,0 +1,233 @@
+import { describe, expect, test } from 'bun:test'
+import type { PrintLevelBundleReport } from '../../../../../lib/level-print-export'
+import type { ModelExport, ModelExportOptions } from '../../../../../lib/model-export'
+import type { PrintExportReport } from '../../../../../lib/print-export'
+import { preparePrintExport } from './print-export-card'
+
+const report: PrintExportReport = {
+ kind: 'print-export-report',
+ version: 2,
+ format: '3mf',
+ 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,
+ connectedComponentCount: 1,
+ solidComponentCount: 1,
+ invertedWinding: false,
+ volumeMm3: 240_000,
+ diagnostics: [],
+}
+
+const stlReport: PrintExportReport = { ...report, format: 'stl' }
+
+const levelReport: PrintLevelBundleReport = {
+ kind: 'print-level-export-report',
+ version: 2,
+ format: 'stl',
+ scale: 50,
+ units: 'millimeter',
+ orientation: 'z-up',
+ status: 'pass',
+ partCount: 1,
+ parts: [
+ {
+ kind: 'level',
+ levelId: 'level_ground',
+ label: 'Ground',
+ objectName: '01 Ground',
+ filename: '01_ground.stl',
+ sourceBaseMeters: 0,
+ report: stlReport,
+ },
+ ],
+ excludedNodeIds: [],
+ diagnostics: [],
+}
+
+describe('print export card contract', () => {
+ test('prepares a visible-only scaled artifact without downloading immediately', async () => {
+ const calls: { format?: string; options?: ModelExportOptions }[] = []
+ const artifact = { blob: new Blob(['3mf']), filename: 'house.3mf', metadata: report }
+ const modelExport: ModelExport = async (format, options) => {
+ calls.push({ format, options })
+ return artifact
+ }
+
+ const prepared = await preparePrintExport(
+ modelExport,
+ true,
+ '50',
+ 'whole',
+ '3mf',
+ 'structure',
+ 'none',
+ '2',
+ '2',
+ '1.8',
+ )
+
+ expect(calls).toEqual([
+ {
+ format: 'print-3mf',
+ options: {
+ onlyVisible: true,
+ download: false,
+ printScale: 50,
+ printScope: 'whole',
+ printContent: 'structure',
+ printBase: 'none',
+ printMinimumFeatureMm: 1.8,
+ },
+ },
+ ])
+ expect(prepared).toEqual({ artifact, report })
+ })
+
+ test('rejects invalid scale input before invoking the exporter', async () => {
+ let invoked = false
+ const modelExport: ModelExport = async () => {
+ invoked = true
+ return null
+ }
+
+ await expect(
+ preparePrintExport(
+ modelExport,
+ true,
+ '0',
+ 'levels',
+ '3mf',
+ 'structure',
+ 'none',
+ '2',
+ '2',
+ '',
+ ),
+ ).rejects.toThrow(
+ 'Enter a positive scale denominator',
+ )
+ expect(invoked).toBe(false)
+ })
+
+ test('accepts the per-level archive report contract', async () => {
+ const calls: { format?: string; options?: ModelExportOptions }[] = []
+ const artifact = { blob: new Blob(['zip']), filename: 'levels.zip', metadata: levelReport }
+ const modelExport: ModelExport = async (format, options) => {
+ calls.push({ format, options })
+ return artifact
+ }
+
+ const prepared = await preparePrintExport(
+ modelExport,
+ true,
+ '50',
+ 'levels',
+ 'stl',
+ 'everything',
+ 'plinth',
+ '3',
+ '2.5',
+ '',
+ )
+
+ expect(calls).toEqual([
+ {
+ format: 'print-stl',
+ options: {
+ onlyVisible: true,
+ download: false,
+ printScale: 50,
+ printScope: 'levels',
+ printContent: 'everything',
+ printBase: 'plinth',
+ printPlinthMarginMm: 3,
+ printPlinthThicknessMm: 2.5,
+ },
+ },
+ ])
+ expect(prepared).toEqual({ artifact, report: levelReport })
+ })
+
+ test('rejects invalid plinth dimensions before invoking the exporter', async () => {
+ let invoked = false
+ const modelExport: ModelExport = async () => {
+ invoked = true
+ return null
+ }
+
+ await expect(
+ preparePrintExport(
+ modelExport,
+ true,
+ '50',
+ 'levels',
+ '3mf',
+ 'structure',
+ '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 modelExport: ModelExport = async () => {
+ invoked = true
+ return null
+ }
+
+ await expect(
+ preparePrintExport(
+ modelExport,
+ 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 modelExport: ModelExport = async () => ({
+ blob: new Blob(['stl']),
+ filename: 'house.stl',
+ })
+
+ await expect(
+ preparePrintExport(
+ modelExport,
+ 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
new file mode 100644
index 0000000000..38cac03376
--- /dev/null
+++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx
@@ -0,0 +1,475 @@
+import { useScene } from '@pascal-app/core'
+import { AlertTriangle, CheckCircle2, Download, Printer, XCircle } from 'lucide-react'
+import { useEffect, useId, useRef, useState } from 'react'
+import { Button } from '../../../../../components/ui/primitives/button'
+import {
+ isPrintLevelBundleReport,
+ type PrintBaseMode,
+ type PrintLevelBundleReport,
+} from '../../../../../lib/level-print-export'
+import type { PrintContentScope } from '../../../../../lib/print-content-scope'
+import type { ModelExport, ModelExportArtifact } from '../../../../../lib/model-export'
+import {
+ isPrintExportReport,
+ type PrintArtifactFormat,
+ type PrintExportReport,
+} from '../../../../../lib/print-export'
+import useEditor from '../../../../../store/use-editor'
+
+export type PreparedPrintExport = {
+ artifact: ModelExportArtifact
+ report: PrintExportReport | PrintLevelBundleReport
+}
+
+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: ModelExportArtifact) {
+ 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(
+ modelExport: ModelExport,
+ onlyVisible: boolean,
+ scaleInput: string,
+ scope: 'whole' | 'levels',
+ format: PrintArtifactFormat,
+ content: PrintContentScope,
+ 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') {
+ if (scope !== 'levels') {
+ throw new RangeError('A plinth is available only for per-level print packages.')
+ }
+ plinthMarginMm = Number(plinthMarginInput)
+ plinthThicknessMm = Number(plinthThicknessInput)
+ if (!Number.isFinite(plinthMarginMm) || plinthMarginMm < 0) {
+ throw new RangeError('Enter a non-negative plinth margin in millimeters.')
+ }
+ if (!Number.isFinite(plinthThicknessMm) || plinthThicknessMm <= 0) {
+ throw new RangeError('Enter a positive plinth thickness in millimeters.')
+ }
+ }
+
+ const artifact = await modelExport(format === '3mf' ? 'print-3mf' : 'print-stl', {
+ onlyVisible,
+ download: false,
+ printScale: scale,
+ printScope: scope,
+ printContent: content,
+ printBase: base,
+ ...(minimumFeatureMm === undefined ? {} : { printMinimumFeatureMm: minimumFeatureMm }),
+ ...(plinthMarginMm === undefined ? {} : { printPlinthMarginMm: plinthMarginMm }),
+ ...(plinthThicknessMm === undefined ? {} : { printPlinthThicknessMm: plinthThicknessMm }),
+ })
+ 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 }
+}
+
+export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) {
+ const scaleInputId = useId()
+ const minimumFeatureInputId = useId()
+ const nodes = useScene((state) => state.nodes)
+ const modelExport = useEditor((state) => state.modelExport)
+ const generationRef = useRef(0)
+ 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')
+ const [plinthThickness, setPlinthThickness] = useState('2')
+ const [minimumFeature, setMinimumFeature] = useState('')
+ const [isPreparing, setIsPreparing] = useState(false)
+ const [prepared, setPrepared] = useState(null)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ generationRef.current += 1
+ setIsPreparing(false)
+ setPrepared(null)
+ setError(null)
+ }, [
+ nodes,
+ onlyVisible,
+ printScale,
+ scope,
+ format,
+ content,
+ base,
+ plinthMargin,
+ plinthThickness,
+ minimumFeature,
+ modelExport,
+ ])
+
+ useEffect(
+ () => () => {
+ generationRef.current += 1
+ },
+ [],
+ )
+
+ const handlePrepare = async () => {
+ if (!modelExport) {
+ setError('The 3D exporter is still loading.')
+ return
+ }
+
+ const generation = generationRef.current + 1
+ generationRef.current = generation
+ setIsPreparing(true)
+ setPrepared(null)
+ setError(null)
+ try {
+ const next = await preparePrintExport(
+ modelExport,
+ onlyVisible,
+ printScale,
+ scope,
+ format,
+ content,
+ scope === 'levels' ? base : 'none',
+ plinthMargin,
+ plinthThickness,
+ minimumFeature,
+ )
+ if (generation === generationRef.current) setPrepared(next)
+ } catch (reason) {
+ if (generation === generationRef.current) {
+ setError(reason instanceof Error ? reason.message : 'Print export failed.')
+ }
+ } finally {
+ if (generation === generationRef.current) setIsPreparing(false)
+ }
+ }
+
+ return (
+
+
+
+
+
Print files
+
+ Experimental millimeter, Z-up export normalized to the print bed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {scope === 'levels' && base === 'plinth' && (
+
+
+
+
+ )}
+
+
+
+
+
+ {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.'}
+
+
+
+ {isPrintLevelBundleReport(prepared.report) ? (
+
+
+ Print 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}
+
+
+ Solid components ·{' '}
+ {part.report.solidComponentCount?.toLocaleString() ?? 'Not checked'} · Surface
+ shells · {part.report.connectedComponentCount?.toLocaleString() ?? 'Not checked'}
+
+ {part.report.minimumFeatureThicknessMm !== undefined && (
+
+ Minimum known feature ·{' '}
+ {part.report.minimumFeatureThicknessMm === null
+ ? 'Not measured'
+ : `${formatMillimeters(part.report.minimumFeatureThicknessMm)} mm`}
+
+ )}
+
+ ))}
+
+ ) : (
+
+ - 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'}
+
+ - Solid components
+ -
+ {prepared.report.solidComponentCount?.toLocaleString() ?? 'Not checked'}
+
+ - Surface shells
+ -
+ {prepared.report.connectedComponentCount?.toLocaleString() ?? 'Not checked'}
+
+ - Face winding
+ -
+ {prepared.report.invertedWinding === null
+ ? 'Not checked'
+ : prepared.report.invertedWinding
+ ? 'Inverted'
+ : 'Outward'}
+
+ {prepared.report.minimumFeatureThicknessMm !== undefined && (
+ <>
+ - Minimum known feature
+ -
+ {prepared.report.minimumFeatureThicknessMm === null
+ ? 'Not measured'
+ : `${formatMillimeters(prepared.report.minimumFeatureThicknessMm)} mm`}
+
+ >
+ )}
+
+ )}
+
+
+ {prepared.report.diagnostics.map((diagnostic, index) => (
+ - · {diagnostic.message}
+ ))}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts
index c9557bf9e3..5afc8d4c76 100644
--- a/packages/editor/src/lib/glb-export.test.ts
+++ b/packages/editor/src/lib/glb-export.test.ts
@@ -3,6 +3,8 @@ import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app
import { buildDoorPreviewMesh } from '@pascal-app/viewer'
import * as THREE from 'three'
import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js'
+import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
+import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export'
// The reference module reads the storage origin lazily on first use, so
@@ -40,6 +42,35 @@ function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
return new THREE.Mesh(geometry, material)
}
+function sceneWithVisibleAndHiddenBoxes(): {
+ root: THREE.Group
+ nodes: Record
+} {
+ const root = new THREE.Group()
+ const nodes: Record = {}
+
+ for (const [id, visible, x] of [
+ ['item_visible', true, 0],
+ ['item_hidden', false, 2],
+ ] as const) {
+ const group = new THREE.Group()
+ const mesh = meshWithNodeMaterial(nodeMaterial())
+ mesh.position.x = x
+ group.add(mesh)
+ root.add(group)
+ sceneRegistry.nodes.set(id, group)
+ nodes[id] = {
+ object: 'node',
+ id,
+ type: 'item',
+ parentId: null,
+ visible,
+ } as unknown as AnyNode
+ }
+
+ return { root, nodes }
+}
+
describe('prepareSceneForExport', () => {
test('converts NodeMaterials to classic glTF-standard materials', () => {
const root = new THREE.Group()
@@ -173,6 +204,93 @@ describe('prepareSceneForExport', () => {
expect(meshes).toHaveLength(1)
})
+ test('excludes hidden scene nodes and descendants by default', () => {
+ const root = new THREE.Group()
+ const levelGroup = new THREE.Group()
+ const itemGroup = new THREE.Group()
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
+ levelGroup.add(itemGroup)
+ root.add(levelGroup)
+
+ const levelId = 'level_hidden'
+ const itemId = 'item_visible_child'
+ sceneRegistry.nodes.set(levelId, levelGroup)
+ sceneRegistry.nodes.set(itemId, itemGroup)
+ const nodes: Record = {
+ [levelId]: {
+ object: 'node',
+ id: levelId,
+ type: 'level',
+ parentId: null,
+ visible: false,
+ } as unknown as AnyNode,
+ [itemId]: {
+ object: 'node',
+ id: itemId,
+ type: 'item',
+ parentId: levelId,
+ visible: true,
+ } as unknown as AnyNode,
+ }
+
+ const { scene, animations } = prepareSceneForExport(root, nodes)
+
+ expect(scene.getObjectByName(levelId)).toBeUndefined()
+ expect(scene.getObjectByName(itemId)).toBeUndefined()
+ expect(animations).toHaveLength(0)
+ })
+
+ test('can include hidden scene nodes when visible-only export is disabled', () => {
+ const root = new THREE.Group()
+ const itemGroup = new THREE.Group()
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
+ root.add(itemGroup)
+
+ const itemId = 'item_hidden'
+ sceneRegistry.nodes.set(itemId, itemGroup)
+ const nodes: Record = {
+ [itemId]: {
+ object: 'node',
+ id: itemId,
+ type: 'item',
+ parentId: null,
+ visible: false,
+ } as unknown as AnyNode,
+ }
+
+ const { scene } = prepareSceneForExport(root, nodes, { onlyVisible: false })
+
+ expect(scene.getObjectByName(itemId)?.userData).toMatchObject({
+ pascalId: itemId,
+ kind: 'item',
+ })
+ })
+
+ test('traditional binary STL excludes hidden nodes by default and can include them', () => {
+ const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
+
+ const visibleScene = prepareSceneForExport(root, nodes).scene
+ const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene
+ const visibleStl = new STLExporter().parse(visibleScene, { binary: true })
+ const completeStl = new STLExporter().parse(completeScene, { binary: true })
+
+ expect(visibleStl.getUint32(80, true)).toBe(12)
+ expect(completeStl.getUint32(80, true)).toBe(24)
+ })
+
+ test('traditional OBJ excludes hidden nodes by default and can include them', () => {
+ const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
+
+ const visibleScene = prepareSceneForExport(root, nodes).scene
+ const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene
+ const visibleObj = new OBJExporter().parse(visibleScene)
+ const completeObj = new OBJExporter().parse(completeScene)
+ const vertexCount = (obj: string) => obj.match(/^v /gm)?.length ?? 0
+
+ expect(vertexCount(visibleObj)).toBe(24)
+ expect(vertexCount(completeObj)).toBe(48)
+ })
+
test('neutralises an invisible hitbox root but keeps its visible children', () => {
// Door/window roots are selection hitboxes: a box geometry with an invisible
// material (object stays visible). Left intact it would plug the wall opening.
diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts
index ed0e406314..72461f3e5c 100644
--- a/packages/editor/src/lib/glb-export.ts
+++ b/packages/editor/src/lib/glb-export.ts
@@ -48,6 +48,7 @@ export type GlbExport = {
export type GlbExportOptions = {
textures?: 'embed' | 'reference'
+ onlyVisible?: boolean
}
/** Resolve after the next couple of animation frames, giving React/R3F time to
@@ -125,10 +126,7 @@ export async function exportSceneToGlb(
const restoreLevels = snapLevelsToTruePositions()
let prepared: ReturnType
try {
- prepared =
- textureMode === 'reference'
- ? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' })
- : prepareSceneForExport(sceneGroup, nodes)
+ prepared = prepareSceneForExport(sceneGroup, nodes, options)
} finally {
restoreLevels()
emitter.emit('thumbnail:after-capture', undefined)
@@ -152,7 +150,7 @@ export async function exportSceneToGlb(
(error) => {
reject(error)
},
- { binary: true, animations },
+ { binary: true, animations, onlyVisible: options.onlyVisible ?? true },
)
})
}
@@ -197,6 +195,10 @@ export function prepareSceneForExport(
}
}
+ if (options.onlyVisible ?? true) {
+ pruneHiddenSceneNodes(cloneByOriginal, nodes)
+ }
+
// Object3Ds that carry node identity — never strip these even when they sit on
// a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes
// are stripped, but its identity node stays to carry the polygon that /viewer
@@ -211,13 +213,57 @@ export function prepareSceneForExport(
sanitizeMaterialGroups(scene, identityNodes)
convertMaterials(scene, options.textures ?? 'embed')
- const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
+ const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
+ const { clips, clipNamesByNode } = bakeAnimationClips(retainedCloneByOriginal, nodes)
- stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode)
+ stampIdentity(scene, retainedCloneByOriginal, nodes, clipNamesByNode)
return { scene, animations: clips }
}
+function pruneHiddenSceneNodes(
+ cloneByOriginal: Map,
+ nodes: Record,
+) {
+ const visibility = new Map()
+
+ const isVisible = (id: string, path: Set): boolean => {
+ const cached = visibility.get(id)
+ if (cached !== undefined) return cached
+
+ const node = nodes[id]
+ if (!node) return true
+ if (node.visible === false) {
+ visibility.set(id, false)
+ return false
+ }
+ if (!node.parentId || path.has(id)) {
+ visibility.set(id, true)
+ return true
+ }
+
+ path.add(id)
+ const visible = isVisible(node.parentId, path)
+ path.delete(id)
+ visibility.set(id, visible)
+ return visible
+ }
+
+ for (const [id, original] of sceneRegistry.nodes) {
+ if (isVisible(id, new Set())) continue
+ cloneByOriginal.get(original)?.removeFromParent()
+ }
+}
+
+function retainedClones(
+ root: THREE.Object3D,
+ cloneByOriginal: Map,
+): Map {
+ const retained = new Set()
+ root.traverse((object) => retained.add(object))
+ return new Map(Array.from(cloneByOriginal.entries()).filter(([, clone]) => retained.has(clone)))
+}
+
/**
* Pair each original Object3D with its clone. `clone(true)` builds children in
* source order, so parallel pre-order traversals line up 1:1 — this is how we
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..767a9263cd
--- /dev/null
+++ b/packages/editor/src/lib/level-print-export.test.ts
@@ -0,0 +1,484 @@
+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 { 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 { compileSemanticPrintShell } from './print-shell-compiler'
+
+function registerFixtureKind(category: 'structure' | 'furnish'): string {
+ const kind = `print-level-${category}-${crypto.randomUUID()}`
+ registerNode({
+ kind,
+ schemaVersion: 1,
+ category,
+ defaults: () => ({}),
+ capabilities: {},
+ } as never)
+ return kind
+}
+
+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()
+ 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,
+ min: bounds.min.clone(),
+ max: bounds.max.clone(),
+ 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()
+ const ground = new THREE.Group()
+ const upper = new THREE.Group()
+ const groundStructure = new THREE.Group()
+ const upperStructure = new THREE.Group()
+ 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)
+ upper.position.y = 3
+ root.add(building)
+ building.add(ground, upper)
+
+ const structureKind = registerFixtureKind('structure')
+ sceneRegistry.nodes.set('building_main', building)
+ sceneRegistry.nodes.set('level_ground', ground)
+ sceneRegistry.nodes.set('level_upper', upper)
+ sceneRegistry.nodes.set('structure_ground', groundStructure)
+ sceneRegistry.nodes.set('structure_upper', upperStructure)
+
+ 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,
+ height: 3,
+ parentId: 'building_main',
+ children: ['structure_ground'],
+ visible: true,
+ } as unknown as AnyNode,
+ level_upper: {
+ object: 'node',
+ id: 'level_upper',
+ type: 'level',
+ name: 'Upper',
+ level: 1,
+ height: 2,
+ parentId: 'building_main',
+ children: ['structure_upper'],
+ visible: true,
+ } as unknown as AnyNode,
+ structure_ground: {
+ object: 'node',
+ id: 'structure_ground',
+ type: structureKind,
+ parentId: 'level_ground',
+ visible: true,
+ } as unknown as AnyNode,
+ structure_upper: {
+ object: 'node',
+ id: 'structure_upper',
+ type: structureKind,
+ parentId: 'level_upper',
+ visible: true,
+ } as unknown as AnyNode,
+ }
+
+ return { root, building, ground, upper, groundStructure, upperStructure, nodes }
+}
+
+describe('per-level print STL export', () => {
+ afterEach(() => {
+ sceneRegistry.nodes.clear()
+ })
+
+ test('packages one bed-normalized, scale-correct STL per visible level', async () => {
+ const fixture = twoLevelFixture()
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
+
+ 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']!)
+
+ expect(Object.keys(files)).toEqual(['01_ground.stl', '02_upper.stl'])
+ 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()
+ 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 = await exportSceneLevelsForPrint(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', async () => {
+ const fixture = twoLevelFixture()
+ fixture.nodes.level_upper = {
+ ...fixture.nodes.level_upper!,
+ visible: false,
+ } as AnyNode
+
+ 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_ground.stl'])
+ expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground'])
+ })
+
+ 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)))
+ fixture.ground.add(furniture)
+ sceneRegistry.nodes.set('chair_ground', furniture)
+ fixture.nodes.chair_ground = {
+ object: 'node',
+ id: 'chair_ground',
+ type: registerFixtureKind('furnish'),
+ parentId: 'level_ground',
+ visible: true,
+ } as unknown as AnyNode
+
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
+ const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure')
+ 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')
+ })
+
+ 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' }
+ 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,
+ }
+
+ let compileCalls = 0
+ const raw = await exportSceneLevelsForPrint(root, nodes, { scale: 100 })
+ const compiled = await exportSceneLevelsForPrint(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)
+ 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', async () => {
+ const fixture = twoLevelFixture()
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
+
+ const first = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 50 })
+ const second = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 50 })
+
+ 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 exportSceneLevelsForPrint(prepared.scene, fixture.nodes, {
+ scale: 100,
+ plinth: { marginMm: 2, thicknessMm: 3 },
+ })
+ const repeated = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, {
+ scale: 100,
+ plinth: { marginMm: 2, thicknessMm: 3 },
+ })
+ 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'])
+ expect(bundle.report.parts.map((part) => part.kind)).toEqual(['plinth', 'level', 'level'])
+ expect(bundle.report.parts[0]?.levelId).toBe('level_ground')
+ expect(plinth.triangles).toBe(12)
+ expect(plinth.size.x).toBeCloseTo(104, 4)
+ expect(plinth.size.y).toBeCloseTo(84, 4)
+ expect(plinth.size.z).toBeCloseTo(3, 4)
+ expect(bundle.data).toEqual(repeated.data)
+ })
+
+ test('packages named parts in one non-overlapping millimeter-unit 3MF plate mesh', 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 object = asArray>(model.resources.object)[0]!
+ const items = asArray>(model.build.item)
+ const metadata = asArray>(model.metadata)
+ const partManifest = JSON.parse(
+ metadata.find((entry) => entry.name === 'Pascal.PartManifest')!['#text']!,
+ ) as Array<{
+ name: string
+ vertexStart: number
+ vertexCount: number
+ triangleStart: number
+ triangleCount: number
+ }>
+ 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)
+
+ expect(Object.keys(files)).toEqual(['[Content_Types].xml', '_rels/.rels', '3D/3dmodel.model'])
+ expect(model.unit).toBe('millimeter')
+ expect(object.name).toBe('Pascal level parts')
+ expect(partManifest.map((part) => part.name)).toEqual([
+ '00 Plinth',
+ '01 Ground & Entry',
+ '02 Upper',
+ ])
+ expect(items.map((item) => item.objectid)).toEqual(['1'])
+ 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, part] of partManifest.entries()) {
+ const bounds = new THREE.Box3()
+ for (const vertex of vertices.slice(part.vertexStart, part.vertexStart + part.vertexCount)) {
+ bounds.expandByPoint(
+ new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z)),
+ )
+ }
+ const size = bounds.getSize(new THREE.Vector3())
+
+ expect(part.triangleCount).toBe(12)
+ expect(
+ triangles.slice(part.triangleStart, part.triangleStart + part.triangleCount),
+ ).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(items[0]?.transform).toBeUndefined()
+ 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
new file mode 100644
index 0000000000..75a9fb7dc8
--- /dev/null
+++ b/packages/editor/src/lib/level-print-export.ts
@@ -0,0 +1,526 @@
+import {
+ type AnyNode,
+ getLevelDisplayName,
+ getLevelElevations,
+ type LevelNode,
+} from '@pascal-app/core'
+import { disposeObject3DResources } from '@pascal-app/viewer'
+import { type Zippable, zipSync } from 'fflate'
+import * as THREE from 'three'
+import { createPrint3mf, type Print3mfPart } from './print-3mf'
+import {
+ encodePreparedPrintSceneToStl,
+ extractPreparedPrintMesh,
+ mergePrintExportDiagnostics,
+ type PrintArtifactFormat,
+ type PrintExportBounds,
+ type PrintExportDiagnostic,
+ type PrintExportReport,
+ type PrintMeshData,
+ prepareSceneForPrint,
+} from './print-export'
+import {
+ applyPrintFeatureThickness,
+ applySemanticPrintFeatureThickness,
+ isPrintFeatureThicknessDiagnostic,
+} from './print-feature-thickness'
+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
+const LEVEL_BASE_TOLERANCE_MM = 0.01
+
+export type PrintBaseMode = 'none' | 'plinth'
+
+export type PrintPlinthOptions = {
+ marginMm: number
+ thicknessMm: number
+}
+
+export type PrintLevelPartReport = {
+ kind: 'level' | 'plinth'
+ levelId: string
+ label: string
+ objectName: string
+ filename: string | null
+ sourceBaseMeters: number | null
+ report: PrintExportReport
+}
+
+export type PrintLevelBundleReport = {
+ kind: 'print-level-export-report'
+ version: 2
+ format: PrintArtifactFormat
+ scale: number
+ units: 'millimeter'
+ orientation: 'z-up'
+ status: 'pass' | 'warning' | 'blocked'
+ partCount: number
+ parts: PrintLevelPartReport[]
+ excludedNodeIds: string[]
+ diagnostics: PrintExportDiagnostic[]
+}
+
+export type PrintLevelPackage = {
+ data: Uint8Array
+ report: PrintLevelBundleReport
+}
+
+export type PrintLevelExportOptions = {
+ scale: number
+ format?: PrintArtifactFormat
+ plinth?: PrintPlinthOptions
+ minimumFeatureMm?: number
+ compileShells?: boolean
+ compileShell?: (
+ source: THREE.Object3D,
+ nodes: Record,
+ ) => Promise
+}
+
+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'
+}
+
+type PreparedLevelArtifact = {
+ filename: string | null
+ objectName: string
+ bytes: Uint8Array | null
+ mesh: PrintMeshData | null
+ 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,
+ options: PrintLevelExportOptions,
+): 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)
+
+ 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 levelArtifacts: PreparedLevelArtifact[] = []
+ const levelParts: PrintLevelPartReport[] = []
+ for (const [index, level] of levels.entries()) {
+ const label = getLevelDisplayName(level)
+ 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 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)
+ : compileSemanticPrintShell(levelScene, nodes)
+ : null
+ try {
+ const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : levelScene
+ const prepared = prepareSceneForPrint(printSource, {
+ scale: options.scale,
+ compiled: compiled?.status === 'compiled',
+ indexedTopology: compiled?.backend === 'manifold-3d',
+ format,
+ ...(sourceBaseMeters === null ? {} : { sourceBedElevationMeters: sourceBaseMeters }),
+ })
+ let report = compiled
+ ? mergePrintExportDiagnostics(
+ prepared.report,
+ compiled.diagnostics,
+ new Set(['compiler_pending']),
+ )
+ : prepared.report
+ if (compiled) {
+ report = applySemanticPrintFeatureThickness(
+ report,
+ nodes,
+ compiled.sourceNodeIds,
+ options.minimumFeatureMm,
+ )
+ }
+ const baseDiagnostics = levelBaseDiagnostics(level, label, sourceBaseMeters, report)
+ report = mergePrintExportDiagnostics(report, baseDiagnostics)
+ if (compiled) {
+ diagnostics.push(
+ ...compiled.diagnostics.filter((diagnostic) => diagnostic.severity !== 'info'),
+ )
+ }
+ diagnostics.push(...report.diagnostics.filter(isPrintFeatureThicknessDiagnostic))
+ diagnostics.push(...baseDiagnostics)
+ 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,
+ sourceBaseMeters,
+ report,
+ })
+ } finally {
+ if (compiled?.scene) disposeObject3DResources(compiled.scene)
+ }
+ }
+
+ let plinthArtifact: PreparedLevelArtifact | null = null
+ let plinthPart: PrintLevelPartReport | null = null
+ if (options.plinth) {
+ const { marginMm, thicknessMm } = options.plinth
+ if (
+ !Number.isFinite(marginMm) ||
+ marginMm < 0 ||
+ !Number.isFinite(thicknessMm) ||
+ thicknessMm <= 0
+ ) {
+ diagnostics.push({
+ severity: 'error',
+ code: 'invalid_plinth_dimensions',
+ message: 'Plinth margin must be non-negative and thickness must be positive.',
+ })
+ } else {
+ const buildingIds = new Set(levels.map((level) => level.parentId ?? 'unparented-building'))
+ const lowestLevel = levels[0]
+ const lowestPart = levelParts[0]
+ const bounds = lowestPart?.report.bounds
+ if (buildingIds.size > 1) {
+ diagnostics.push({
+ severity: 'error',
+ code: 'multiple_building_plinth',
+ message: 'A plinth currently requires the print scope to contain exactly one building.',
+ })
+ } else if (!lowestLevel || !lowestPart || !bounds) {
+ diagnostics.push({
+ severity: 'error',
+ code: 'plinth_missing_footprint',
+ message: 'The lowest visible level has no structural bounds for plinth generation.',
+ })
+ } else {
+ const widthMeters = ((bounds.width + marginMm * 2) * options.scale) / MILLIMETERS_PER_METER
+ const depthMeters = ((bounds.depth + marginMm * 2) * options.scale) / MILLIMETERS_PER_METER
+ const thicknessMeters = (thicknessMm * options.scale) / MILLIMETERS_PER_METER
+ const mesh = new THREE.Mesh(
+ new THREE.BoxGeometry(widthMeters, thicknessMeters, depthMeters),
+ )
+ try {
+ const prepared = prepareSceneForPrint(mesh, { ...options, format })
+ const report = applyPrintFeatureThickness(
+ prepared.report,
+ {
+ features: [{ nodeId: lowestLevel.id, thicknessMm }],
+ unmeasuredNodeIds: [],
+ },
+ options.minimumFeatureMm,
+ )
+ 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: report.bounds,
+ }
+ plinthPart = {
+ kind: 'plinth',
+ levelId: lowestLevel.id,
+ label: 'Plinth',
+ objectName,
+ filename,
+ sourceBaseMeters: null,
+ report,
+ }
+ diagnostics.push(...report.diagnostics.filter(isPrintFeatureThicknessDiagnostic))
+ diagnostics.push({
+ severity: 'info',
+ code: 'rectangular_plinth_experimental',
+ message:
+ 'The plinth is a separate rectangular part derived from the lowest level bounds; footprint shaping and connectors are not implemented yet.',
+ })
+ } finally {
+ disposeObject3DResources(mesh)
+ }
+ }
+ }
+ }
+
+ diagnostics.push({
+ severity: 'info',
+ code: 'level_parts_experimental',
+ message: options.compileShells
+ ? options.compileShell
+ ? 'Level parts use stored level bases and worker-backed Manifold semantic shell compilation; known wall, slab, roof, and plinth dimensions are measured, while mesh-observed thin features and self-intersections remain pending.'
+ : 'Level parts use stored level bases and the experimental synchronous semantic shell compiler; known wall, slab, roof, and plinth dimensions are measured, while worker execution, mesh-observed thin features, and self-intersections remain pending.'
+ : 'Level parts use stored level bases and semantic separation but are not boolean-unioned printable shells yet.',
+ })
+
+ const files: Zippable = {}
+ const parts: PrintLevelPartReport[] = []
+ 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, 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 {
+ data:
+ format === '3mf'
+ ? createPrint3mf(packageParts, 'Pascal level parts')
+ : zipSync(files, { level: 0 }),
+ report: {
+ kind: 'print-level-export-report',
+ version: 2,
+ format,
+ 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-export-report' && report.version === 2
+}
diff --git a/packages/editor/src/lib/model-export.ts b/packages/editor/src/lib/model-export.ts
new file mode 100644
index 0000000000..efdf19ffde
--- /dev/null
+++ b/packages/editor/src/lib/model-export.ts
@@ -0,0 +1,24 @@
+export type ModelExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' | 'print-3mf'
+
+export type ModelExportOptions = {
+ onlyVisible?: boolean
+ download?: boolean
+ printScale?: number
+ printScope?: 'whole' | 'levels'
+ printContent?: 'structure' | 'everything'
+ printBase?: 'none' | 'plinth'
+ printMinimumFeatureMm?: number
+ printPlinthMarginMm?: number
+ printPlinthThicknessMm?: number
+}
+
+export type ModelExportArtifact = {
+ blob: Blob
+ filename: string
+ metadata?: unknown
+}
+
+export type ModelExport = (
+ format?: ModelExportFormat,
+ options?: ModelExportOptions,
+) => Promise
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..d263fa7b29
--- /dev/null
+++ b/packages/editor/src/lib/print-3mf.test.ts
@@ -0,0 +1,86 @@
+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 metadata = asArray>(model.metadata)
+ const partManifest = JSON.parse(
+ metadata.find((entry) => entry.name === 'Pascal.PartManifest')!['#text']!,
+ )
+ 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)
+ const bounds = new THREE.Box3()
+ for (const vertex of vertices) {
+ bounds.expandByPoint(new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z)))
+ }
+
+ 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(item.transform).toBeUndefined()
+ expect(partManifest).toEqual([
+ {
+ name: 'Pascal print model',
+ vertexStart: 0,
+ vertexCount: 8,
+ triangleStart: 0,
+ triangleCount: 12,
+ },
+ ])
+ expect(bounds.min.toArray()).toEqual([0, 0, 0])
+ expect(bounds.max.toArray()).toEqual([100, 60, 40])
+ 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('