feat(editor): floorplan export (multi-page PDF, per level)
Add a Floorplan group to the settings Export panel alongside the 3D model exports, with "Full floorplan" and "Structure only" buttons. Export re-runs the live registry-driven floorplan pipeline (def.floorplan -> FloorplanGeometryRenderer) headlessly with a neutral viewState, fits each level to its own page, and titles each page with the level label. Every level of the active building becomes a page in one landscape A4 PDF. "Structure only" keeps category === 'structure' nodes; "Full" keeps every visible node with a floorplan builder. - new lib/floorplan/floorplan-export.tsx; jsPDF + svg2pdf dynamically imported so they only load on export - export five pure helpers from floorplan-registry-layer for reuse (buildContext, getFloorplanLevelData, floorplanLayerRank, splitFloorplanOverlay, isFloorplanNodeVisible) — no behaviour change - bake vector-effect:non-scaling-stroke widths into real units before svg2pdf (which ignores the hint and would otherwise draw door/window/ stair linework as metre-wide strokes) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a6023356b9
commit
b87b1f999d
@@ -43,14 +43,16 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"svg2pdf.js": "^2.7.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three-mesh-bvh": "~0.9.8",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11",
|
||||
"three-mesh-bvh": "~0.9.8"
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
|
||||
@@ -1488,7 +1488,7 @@ function buildFloorplanEntryGeometry({
|
||||
return entry
|
||||
}
|
||||
|
||||
function getFloorplanLevelData(
|
||||
export function getFloorplanLevelData(
|
||||
type: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
liveOverrides: Map<string, LiveNodeOverrides>,
|
||||
@@ -2426,7 +2426,7 @@ function applyPositionLiveTransform(
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
|
||||
export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
|
||||
const overrideVisible = liveOverride?.visible
|
||||
if (typeof overrideVisible === 'boolean') return overrideVisible
|
||||
return (node as { visible?: boolean }).visible !== false
|
||||
@@ -2452,7 +2452,7 @@ function isFloorplanHierarchyVisible(
|
||||
return true
|
||||
}
|
||||
|
||||
function buildContext(
|
||||
export function buildContext(
|
||||
node: AnyNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
viewState: {
|
||||
@@ -2560,7 +2560,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
||||
* / translations apply in both passes. Empty groups collapse to `null`
|
||||
* so the caller can skip emitting an `<g>` when there's nothing to draw.
|
||||
*/
|
||||
function splitFloorplanOverlay(g: FloorplanGeometry): {
|
||||
export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
||||
base: FloorplanGeometry | null
|
||||
overlay: FloorplanGeometry | null
|
||||
} {
|
||||
@@ -2726,7 +2726,7 @@ function depsValueEqual(a: unknown, b: unknown): boolean {
|
||||
* Sort is stable in modern JS engines, so siblings within the same
|
||||
* bucket keep their DFS order (= scene tree order).
|
||||
*/
|
||||
function floorplanLayerRank(type: string): number {
|
||||
export function floorplanLayerRank(type: string): number {
|
||||
switch (type) {
|
||||
case 'zone':
|
||||
return 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { emitter, useScene, validateBuildJson } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { TreeView, VisualJson } from '@visual-json/react'
|
||||
import { Camera, Download, Save, Trash2, Upload } from 'lucide-react'
|
||||
import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type SyntheticEvent,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { exportFloorplanPdf } from '../../../../../lib/floorplan/floorplan-export'
|
||||
import { Button } from './../../../../../components/ui/primitives/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -344,32 +345,56 @@ export function SettingsPanel({
|
||||
)}
|
||||
|
||||
{/* Export Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('glb')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export GLB
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('stl')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export STL
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('obj')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export OBJ
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">3D model</div>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('glb')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export GLB
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('stl')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export STL
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('obj')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export OBJ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">Floorplan</div>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportFloorplanPdf('full')}
|
||||
variant="outline"
|
||||
>
|
||||
<MapIcon className="size-4" />
|
||||
Full floorplan
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportFloorplanPdf('structure')}
|
||||
variant="outline"
|
||||
>
|
||||
<MapIcon className="size-4" />
|
||||
Structure only
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Section (only for cloud projects) */}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type FloorplanGeometry,
|
||||
type LiveNodeOverrides,
|
||||
nodeRegistry,
|
||||
resolveBuildingForLevel,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createElement } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
|
||||
import {
|
||||
buildContext,
|
||||
floorplanLayerRank,
|
||||
getFloorplanLevelData,
|
||||
isFloorplanNodeVisible,
|
||||
splitFloorplanOverlay,
|
||||
} from '../../components/editor-2d/renderers/floorplan-registry-layer'
|
||||
|
||||
/**
|
||||
* Floorplan PDF export.
|
||||
*
|
||||
* Re-runs the same registry-driven geometry pipeline the live 2D layer uses
|
||||
* (`def.floorplan(node, ctx)` → `FloorplanGeometryRenderer`) headlessly, with
|
||||
* a neutral `viewState` so nodes render in their default, unselected form.
|
||||
* Every level of the active building becomes its own page, titled with the
|
||||
* level's label, with the plan fit to the page (independent of the live
|
||||
* pan/zoom). jsPDF + svg2pdf are dynamically imported so they only load when
|
||||
* an export actually runs.
|
||||
*
|
||||
* `scope: 'structure'` keeps only `category === 'structure'` nodes (walls,
|
||||
* slabs, ceilings, doors, windows, stairs, columns, roofs…); `'full'` keeps
|
||||
* every node that has a floorplan builder and is visible.
|
||||
*/
|
||||
export type FloorplanExportScope = 'full' | 'structure'
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
/** Meters of margin around the plan bounds. */
|
||||
const PADDING_M = 1
|
||||
/** PDF page margin + title band, in pt. */
|
||||
const PAGE_MARGIN_PT = 36
|
||||
const TITLE_BAND_PT = 28
|
||||
|
||||
// Neutral view state — no selection / hover / palette, so builders emit their
|
||||
// default appearance (the core palette only carries selection/handle colors).
|
||||
const NEUTRAL_VIEW_STATE = {
|
||||
selected: false,
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
palette: undefined,
|
||||
} as const
|
||||
|
||||
type ExportLevel = { id: AnyNodeId; label: string }
|
||||
|
||||
export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
|
||||
const nodes = useScene.getState().nodes
|
||||
const levels = resolveExportLevels(nodes)
|
||||
if (levels.length === 0) {
|
||||
console.warn('[floorplan-export] no level to export')
|
||||
return
|
||||
}
|
||||
|
||||
const [{ jsPDF }, { svg2pdf }] = await Promise.all([import('jspdf'), import('svg2pdf.js')])
|
||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' })
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const pageH = doc.internal.pageSize.getHeight()
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.style.cssText =
|
||||
'position:fixed;left:-10000px;top:0;width:1px;height:1px;overflow:hidden;pointer-events:none;'
|
||||
document.body.appendChild(host)
|
||||
|
||||
let pageCount = 0
|
||||
try {
|
||||
for (const level of levels) {
|
||||
const geometries = collectFloorplanGeometry(nodes, level.id, scope)
|
||||
if (geometries.length === 0) continue
|
||||
|
||||
const mounted = await mountFloorplanSvg(host, geometries)
|
||||
if (!mounted) continue
|
||||
|
||||
try {
|
||||
if (pageCount > 0) doc.addPage()
|
||||
pageCount++
|
||||
|
||||
doc.setFontSize(14)
|
||||
doc.text(level.label, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12)
|
||||
|
||||
// Fit the plan into the page below the title band, preserving aspect.
|
||||
const boxX = PAGE_MARGIN_PT
|
||||
const boxY = PAGE_MARGIN_PT + TITLE_BAND_PT
|
||||
const boxW = pageW - PAGE_MARGIN_PT * 2
|
||||
const boxH = pageH - PAGE_MARGIN_PT * 2 - TITLE_BAND_PT
|
||||
const aspect = mounted.width / mounted.height
|
||||
let w = boxW
|
||||
let h = w / aspect
|
||||
if (h > boxH) {
|
||||
h = boxH
|
||||
w = h * aspect
|
||||
}
|
||||
const x = boxX + (boxW - w) / 2
|
||||
const y = boxY + (boxH - h) / 2
|
||||
|
||||
// svg2pdf doesn't honour `vector-effect: non-scaling-stroke` (which
|
||||
// many builders use to keep door/window/stair line weights constant
|
||||
// on screen). Left as-is, those pixel-sized widths render as
|
||||
// metre-wide strokes — huge grey blobs. Convert them to the real-unit
|
||||
// width that lands at the intended point weight once svg2pdf scales
|
||||
// the plan onto the page.
|
||||
inlineNonScalingStrokes(mounted.svg, w / mounted.width)
|
||||
|
||||
await svg2pdf(mounted.svg, doc, { x, y, width: w, height: h })
|
||||
} finally {
|
||||
mounted.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
if (pageCount === 0) {
|
||||
console.warn(`[floorplan-export] nothing to export for scope "${scope}"`)
|
||||
return
|
||||
}
|
||||
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
doc.save(`floorplan_${scope}_${date}.pdf`)
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
}
|
||||
|
||||
type MountedFloorplan = {
|
||||
svg: SVGSVGElement
|
||||
/** Padded viewBox dimensions, in meters — used for aspect-preserving fit. */
|
||||
width: number
|
||||
height: number
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
async function mountFloorplanSvg(
|
||||
parent: HTMLElement,
|
||||
geometries: { id: AnyNodeId; base: FloorplanGeometry }[],
|
||||
): Promise<MountedFloorplan | null> {
|
||||
const container = document.createElement('div')
|
||||
parent.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const cleanup = () => {
|
||||
root.unmount()
|
||||
container.remove()
|
||||
}
|
||||
|
||||
// Render a full `<svg>` as the React root child so React enters the SVG
|
||||
// namespace at the `<svg>` tag, then mutate the DOM node afterwards —
|
||||
// viewBox/background depend on the post-mount measured bounds.
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
'svg',
|
||||
{ xmlns: SVG_NS },
|
||||
createElement(
|
||||
'g',
|
||||
{ 'data-floorplan-content': '' },
|
||||
geometries.map(({ id, base }) =>
|
||||
createElement(FloorplanGeometryRenderer, { key: id, geometry: base }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
// Give async asset images (item icons) a couple of frames to resolve so
|
||||
// they're included in the measured bounds and the rendered output.
|
||||
await nextFrames(2)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
const content = svg?.querySelector('[data-floorplan-content]') as SVGGraphicsElement | null
|
||||
const bbox = content?.getBBox()
|
||||
if (!svg || !bbox || bbox.width === 0 || bbox.height === 0) {
|
||||
cleanup()
|
||||
return null
|
||||
}
|
||||
|
||||
const minX = bbox.x - PADDING_M
|
||||
const minY = bbox.y - PADDING_M
|
||||
const width = bbox.width + PADDING_M * 2
|
||||
const height = bbox.height + PADDING_M * 2
|
||||
svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`)
|
||||
svg.setAttribute('width', `${width}`)
|
||||
svg.setAttribute('height', `${height}`)
|
||||
|
||||
const background = document.createElementNS(SVG_NS, 'rect')
|
||||
background.setAttribute('x', `${minX}`)
|
||||
background.setAttribute('y', `${minY}`)
|
||||
background.setAttribute('width', `${width}`)
|
||||
background.setAttribute('height', `${height}`)
|
||||
background.setAttribute('fill', '#ffffff')
|
||||
svg.insertBefore(background, svg.firstChild)
|
||||
|
||||
return { svg, width, height, cleanup }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake `vector-effect: non-scaling-stroke` widths into real user units.
|
||||
*
|
||||
* svg2pdf ignores the non-scaling hint, so a `stroke-width="1.25"` meant as
|
||||
* "1.25 screen px" would otherwise render as 1.25 metres on the page. We
|
||||
* rewrite each such width (and any dash pattern) to `px / ptPerUnit` so it
|
||||
* lands at ~`px` points once svg2pdf scales the plan by `ptPerUnit`, then drop
|
||||
* the now-misleading attribute.
|
||||
*/
|
||||
function inlineNonScalingStrokes(svg: SVGSVGElement, ptPerUnit: number) {
|
||||
if (!Number.isFinite(ptPerUnit) || ptPerUnit <= 0) return
|
||||
for (const el of svg.querySelectorAll('[vector-effect="non-scaling-stroke"]')) {
|
||||
const sw = el.getAttribute('stroke-width')
|
||||
if (sw) {
|
||||
const px = Number.parseFloat(sw)
|
||||
if (Number.isFinite(px)) el.setAttribute('stroke-width', `${px / ptPerUnit}`)
|
||||
}
|
||||
const dash = el.getAttribute('stroke-dasharray')
|
||||
if (dash) {
|
||||
const scaled = dash
|
||||
.split(/[\s,]+/)
|
||||
.map((v) => {
|
||||
const n = Number.parseFloat(v)
|
||||
return Number.isFinite(n) ? `${n / ptPerUnit}` : v
|
||||
})
|
||||
.join(' ')
|
||||
el.setAttribute('stroke-dasharray', scaled)
|
||||
}
|
||||
el.removeAttribute('vector-effect')
|
||||
}
|
||||
}
|
||||
|
||||
function collectFloorplanGeometry(
|
||||
nodes: Record<string, AnyNode>,
|
||||
levelId: AnyNodeId,
|
||||
scope: FloorplanExportScope,
|
||||
): { id: AnyNodeId; base: FloorplanGeometry }[] {
|
||||
const noLiveOverrides = new Map<string, LiveNodeOverrides>()
|
||||
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
||||
const entries: { id: AnyNodeId; node: AnyNode }[] = []
|
||||
|
||||
const visit = (id: AnyNodeId) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (def?.computeFloorplanLevelData) {
|
||||
const ids = levelNodeIdsByType.get(node.type)
|
||||
if (ids) ids.push(id)
|
||||
else levelNodeIdsByType.set(node.type, [id])
|
||||
}
|
||||
if (
|
||||
def?.floorplan &&
|
||||
isFloorplanNodeVisible(node) &&
|
||||
(scope === 'full' || def.category === 'structure')
|
||||
) {
|
||||
entries.push({ id, node })
|
||||
}
|
||||
const childIds = (node as { children?: AnyNodeId[] }).children
|
||||
if (Array.isArray(childIds)) for (const cid of childIds) visit(cid)
|
||||
}
|
||||
visit(levelId)
|
||||
|
||||
// Document order is paint order — sort the same way the live layer does so
|
||||
// zones sit under walls/slabs/furniture rather than on top of them.
|
||||
entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
||||
|
||||
// One-shot per-type cache for `computeFloorplanLevelData`; value type is
|
||||
// module-private to the registry layer, so let it infer.
|
||||
const levelDataCache = new Map()
|
||||
const out: { id: AnyNodeId; base: FloorplanGeometry }[] = []
|
||||
for (const { id, node } of entries) {
|
||||
const builder = nodeRegistry.get(node.type)?.floorplan
|
||||
if (!builder) continue
|
||||
const levelData = getFloorplanLevelData(
|
||||
node.type,
|
||||
nodes,
|
||||
noLiveOverrides,
|
||||
levelNodeIdsByType,
|
||||
levelDataCache,
|
||||
)
|
||||
const ctx = buildContext(node, nodes, NEUTRAL_VIEW_STATE, levelData)
|
||||
const geometry = builder(node, ctx)
|
||||
if (!geometry) continue
|
||||
const { base } = splitFloorplanOverlay(geometry)
|
||||
if (base) out.push({ id, base })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Levels to export, ordered bottom-to-top. The active building (the building
|
||||
* owning the selected level, or the first one found) contributes all of its
|
||||
* level children; if there is no building wrapper we fall back to the single
|
||||
* resolved level.
|
||||
*/
|
||||
function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
|
||||
const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
|
||||
const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
|
||||
if (!activeLevelId) return []
|
||||
|
||||
const buildingId = resolveBuildingForLevel(activeLevelId, nodes as Record<AnyNodeId, AnyNode>)
|
||||
let levelNodes: AnyNode[]
|
||||
if (buildingId) {
|
||||
const childIds = (nodes[buildingId] as { children?: AnyNodeId[] }).children ?? []
|
||||
levelNodes = childIds.map((id) => nodes[id]).filter((n): n is AnyNode => n?.type === 'level')
|
||||
} else {
|
||||
const node = nodes[activeLevelId]
|
||||
levelNodes = node ? [node] : []
|
||||
}
|
||||
|
||||
levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
|
||||
return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
|
||||
}
|
||||
|
||||
function firstLevelId(nodes: Record<string, AnyNode>): AnyNodeId | null {
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type === 'level') return node.id as AnyNodeId
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function levelIndexOf(node: AnyNode): number {
|
||||
return (node as { level?: number }).level ?? 0
|
||||
}
|
||||
|
||||
function levelLabelOf(node: AnyNode): string {
|
||||
const name = node.name?.trim()
|
||||
if (name) return name
|
||||
return `Level ${levelIndexOf(node)}`
|
||||
}
|
||||
|
||||
function nextFrames(count: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const tick = (remaining: number) => {
|
||||
if (remaining <= 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => tick(remaining - 1))
|
||||
}
|
||||
tick(count)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user