Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/editor/components/scene-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -66,6 +66,22 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
/>
),
},
{
id: 'settings',
label: 'Settings',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Settings className="h-5 w-5" />,
icon: (
<Image
alt=""
className="h-8 w-8 object-contain"
height={32}
src="/icons/settings.webp"
width={32}
/>
),
},
]

interface SceneLoaderProps {
Expand Down
64 changes: 60 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@
"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",
"manifold-3d": "3.5.1",
"mitt": "^3.0.1",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "~0.9.8",
"zod": "^4.3.6",
"zustand": "^5.0.11"
Expand All @@ -67,6 +70,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"
}
}
89 changes: 89 additions & 0 deletions packages/editor/scripts/generate-print-golden-house.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 <output-directory>',
)
}

const outputDirectory = resolve(outputArgument)
const fixture = createPrintGoldenHouseFixture()

async function sha256(data: Uint8Array): Promise<string> {
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<typeof exportSceneLevelsForPrint>[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,
connectedComponentCount: part.report.connectedComponentCount,
solidComponentCount: part.report.solidComponentCount,
invertedWinding: part.report.invertedWinding,
volumeMm3: part.report.volumeMm3,
minimumFeatureThicknessMm: part.report.minimumFeatureThicknessMm,
})),
}
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()
}
136 changes: 122 additions & 14 deletions packages/editor/src/components/editor/export-manager.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
'use client'

import { emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { disposeObject3DResources, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
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 { exportSceneLevelsForPrint } from '../../lib/level-print-export'
import type { ModelExport, ModelExportArtifact } from '../../lib/model-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'
import useEditor from '../../store/use-editor'

// prepareSceneForExport neutralises container meshes (door/window hitbox roots,
// material-less renderables) with an attribute-less geometry — GLTFExporter
Expand All @@ -34,14 +42,15 @@ function ensurePositionAttributes(root: THREE.Object3D) {
export function ExportManager() {
const scene = useThree((state) => state.scene)
const setExportScene = useViewer((state) => state.setExportScene)
const setModelExport = useEditor((state) => state.setModelExport)

useEffect(() => {
const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
const exportFn: ModelExport = async (format = 'glb', options = {}) => {
// Find the scene renderer group by name
const sceneGroup = scene.getObjectByName('scene-renderer')
if (!sceneGroup) {
console.error('scene-renderer group not found')
return
return null
}

const date = new Date().toISOString().split('T')[0]
Expand All @@ -55,56 +64,155 @@ 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
return finishArtifact(blob, `model_${date}.glb`, options.download)
}

// Hide editor affordances that live on the scene layer (selection handles,
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
// synchronous capture path thumbnails use. We clone the scene inside the
// 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<typeof prepareSceneForExport>
try {
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
prepared = prepareSceneForExport(sceneGroup, nodes, options)
} finally {
restoreLevels()
emitter.emit('thumbnail:after-capture', undefined)
}
const { scene: exportScene } = prepared
let { scene: exportScene } = prepared
const printContent = options.printContent ?? 'structure'
const isPrintFormat = format === 'print-stl' || format === 'print-3mf'
if (isPrintFormat) {
exportScene = filterPreparedSceneForPrintContent(exportScene, nodes, printContent)
}
ensurePositionAttributes(exportScene)

if (isPrintFormat) {
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'
? {
marginMm: options.printPlinthMarginMm ?? 2,
thicknessMm: options.printPlinthThicknessMm ?? 2,
}
: undefined
const { data, report } = await exportSceneLevelsForPrint(exportScene, nodes, {
scale,
format: printFormat,
plinth,
minimumFeatureMm,
compileShells,
compileShell: compileShells ? compileSemanticPrintShellWithManifold : undefined,
})
const blob = new Blob([data], {
type: printFormat === '3mf' ? 'model/3mf' : 'application/zip',
})
return finishArtifact(
blob,
`print_levels_1-${scale}_${date}.${printFormat === '3mf' ? '3mf' : 'zip'}`,
options.download,
report,
)
}
if (options.printBase === 'plinth') {
throw new Error('Plinth generation is available only for per-level print packages.')
}
const compiled = compileShells
? await compileSemanticPrintShellWithManifold(exportScene, nodes)
: null
try {
const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : exportScene
const printOptions = {
scale,
compiled: compiled?.status === 'compiled',
indexedTopology: compiled?.backend === 'manifold-3d',
}
const output =
printFormat === '3mf'
? exportSceneToPrint3mf(printSource, printOptions)
: exportSceneToPrintStl(printSource, printOptions)
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',
})
return finishArtifact(
blob,
`print_model_1-${scale}_${date}.${printFormat}`,
options.download,
report,
)
} finally {
if (compiled?.scene) disposeObject3DResources(compiled.scene)
}
}

if (format === 'stl') {
const exporter = new STLExporter()
const result = exporter.parse(exportScene, { binary: true })
const blob = new Blob([result], { type: 'model/stl' })
downloadBlob(blob, `model_${date}.stl`)
return
return finishArtifact(blob, `model_${date}.stl`, options.download)
}

if (format === 'obj') {
const exporter = new OBJExporter()
const result = exporter.parse(exportScene)
const blob = new Blob([result], { type: 'model/obj' })
downloadBlob(blob, `model_${date}.obj`)
return
return finishArtifact(blob, `model_${date}.obj`, options.download)
}

return null
} finally {
useViewer.getState().setExporting(false)
}
}

setExportScene(exportFn)
setModelExport(exportFn)
setExportScene(async (format = 'glb') => {
await exportFn(format, { onlyVisible: true })
})

return () => {
setModelExport(null)
setExportScene(null)
}
}, [scene, setExportScene])
}, [scene, setExportScene, setModelExport])

return null
}

function finishArtifact(
blob: Blob,
filename: string,
download: boolean | undefined,
metadata?: unknown,
): ModelExportArtifact {
if (download !== false) downloadBlob(blob, filename)
return { blob, filename, metadata }
}

function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
Expand Down
Loading
Loading