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
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import useFloorplanMode from './../../../../../store/use-floorplan-mode'
import { AudioSettingsDialog } from './audio-settings-dialog'
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
import { PrintExportCard } from './print-export-card'
import { PrintExportButton } from './print-export-button'

type SceneNode = Record<string, unknown> & {
id?: unknown
Expand Down Expand Up @@ -410,7 +410,7 @@ export function SettingsPanel({
Export OBJ
</Button>

<PrintExportCard onlyVisible={exportOnlyVisible} />
<PrintExportButton onlyVisible={exportOnlyVisible} />
</div>

<div className="space-y-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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-button'

const report: PrintExportReport = {
kind: 'print-export-report',
version: 2,
format: '3mf',
scale: 100,
units: 'millimeter',
orientation: 'z-up',
status: 'pass',
bounds: {
min: { x: -25, y: -15, z: 0 },
max: { x: 25, y: 15, z: 20 },
width: 50,
depth: 30,
height: 20,
},
triangleCount: 12,
invalidTriangleCount: 0,
degenerateTriangleCount: 0,
boundaryEdgeCount: 0,
nonManifoldEdgeCount: 0,
connectedComponentCount: 1,
solidComponentCount: 1,
invertedWinding: false,
volumeMm3: 30_000,
diagnostics: [],
}

describe('simple 3D print export', () => {
test('uses one fixed safe print profile', 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)

expect(calls).toEqual([
{
format: 'print-3mf',
options: {
onlyVisible: true,
download: false,
printScale: 100,
printScope: 'levels',
printContent: 'structure',
printBase: 'none',
},
},
])
expect(prepared).toEqual({ artifact, report })
})

test('blocks the download when preflight finds invalid geometry', async () => {
const blockedReport: PrintExportReport = {
...report,
status: 'blocked',
diagnostics: [
{
severity: 'error',
code: 'open_boundary',
message: 'One wall has an open edge.',
},
],
}
const modelExport: ModelExport = async () => ({
blob: new Blob(['3mf']),
filename: 'house.3mf',
metadata: blockedReport,
})

await expect(preparePrintExport(modelExport, true)).rejects.toThrow(
'One wall has an open edge.',
)
})

test('shows a per-level preflight error when the bundle has no top-level error', async () => {
const blockedPartReport: PrintExportReport = {
...report,
status: 'blocked',
diagnostics: [
{
severity: 'error',
code: 'open_boundary',
message: 'The upper level has an open edge.',
},
],
}
const blockedBundleReport: PrintLevelBundleReport = {
kind: 'print-level-export-report',
version: 2,
format: '3mf',
scale: 100,
units: 'millimeter',
orientation: 'z-up',
status: 'blocked',
partCount: 1,
parts: [
{
kind: 'level',
levelId: 'upper-level',
label: 'Upper level',
objectName: 'Upper level',
filename: null,
sourceBaseMeters: 3,
report: blockedPartReport,
},
],
excludedNodeIds: [],
diagnostics: [],
}
const modelExport: ModelExport = async () => ({
blob: new Blob(['3mf']),
filename: 'house.zip',
metadata: blockedBundleReport,
})

await expect(preparePrintExport(modelExport, true)).rejects.toThrow(
'The upper level has an open edge.',
)
})

test('rejects an exporter response without print metadata', async () => {
const modelExport: ModelExport = async () => ({
blob: new Blob(['3mf']),
filename: 'house.3mf',
})

await expect(preparePrintExport(modelExport, false)).rejects.toThrow(
'did not return a valid file',
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { AlertTriangle, Printer } from 'lucide-react'
import { useState } from 'react'
import { Button } from '../../../../../components/ui/primitives/button'
import {
isPrintLevelBundleReport,
type PrintLevelBundleReport,
} from '../../../../../lib/level-print-export'
import type { ModelExport, ModelExportArtifact } from '../../../../../lib/model-export'
import {
isPrintExportReport,
type PrintExportReport,
} from '../../../../../lib/print-export'
import useEditor from '../../../../../store/use-editor'

type PreparedPrintExport = {
artifact: ModelExportArtifact
report: PrintExportReport | PrintLevelBundleReport
}

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)
}

function firstBlockingMessage(report: PrintExportReport | PrintLevelBundleReport) {
const bundleDiagnostic = report.diagnostics.find((item) => item.severity === 'error')
if (bundleDiagnostic || !isPrintLevelBundleReport(report)) return bundleDiagnostic?.message

for (const part of report.parts) {
const partDiagnostic = part.report.diagnostics.find((item) => item.severity === 'error')
if (partDiagnostic) return partDiagnostic.message
}
}

export async function preparePrintExport(
modelExport: ModelExport,
onlyVisible: boolean,
): Promise<PreparedPrintExport> {
const artifact = await modelExport('print-3mf', {
onlyVisible,
download: false,
printScale: 100,
printScope: 'levels',
printContent: 'structure',
printBase: 'none',
})

if (
!artifact ||
(!isPrintExportReport(artifact.metadata) && !isPrintLevelBundleReport(artifact.metadata))
) {
throw new Error('The 3D print exporter did not return a valid file.')
}

if (artifact.metadata.status === 'blocked') {
throw new Error(
firstBlockingMessage(artifact.metadata) ??
'This project cannot be exported as printable parts.',
)
}
Comment thread
cursor[bot] marked this conversation as resolved.

return { artifact, report: artifact.metadata }
}

export function PrintExportButton({ onlyVisible }: { onlyVisible: boolean }) {
const modelExport = useEditor((state) => state.modelExport)
const [isExporting, setIsExporting] = useState(false)
const [error, setError] = useState<string | null>(null)

const handleExport = async () => {
if (!modelExport) return

setIsExporting(true)
setError(null)
try {
const prepared = await preparePrintExport(modelExport, onlyVisible)
downloadArtifact(prepared.artifact)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '3D print export failed.')
} finally {
setIsExporting(false)
}
}

return (
<>
<Button
aria-busy={isExporting}
className="w-full justify-start gap-2"
disabled={isExporting || !modelExport}
onClick={handleExport}
variant="outline"
>
<Printer className="size-4" />
Export 3D print files
</Button>
{error && (
<div className="flex gap-2 text-destructive text-xs">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
</>
)
}
Loading
Loading