Skip to content

feat(editor): floorplan export (multi-page PDF, per level)#449

Merged
wass08 merged 1 commit into
mainfrom
feat/floorplan-export
Jun 29, 2026
Merged

feat(editor): floorplan export (multi-page PDF, per level)#449
wass08 merged 1 commit into
mainfrom
feat/floorplan-export

Conversation

@wass08

@wass08 wass08 commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds a Floorplan export to the settings Export panel, next to the 3D model exports. Two buttons:

  • Full floorplan — every visible node that has a floorplan builder
  • Structure onlycategory === 'structure' nodes (walls, slabs, ceilings, doors, windows, stairs, columns, roofs…)

Both produce a landscape A4 PDF with one page per level of the active building, each page titled with the level label (node.name, falling back to Level <n>).

How it works

  • Re-runs the live registry-driven floorplan pipeline (def.floorplan(node, ctx)FloorplanGeometryRenderer) headlessly, with a neutral viewState so geometry renders in its clean, unselected form. The plan is measured with getBBox() and fit to the page (independent of live pan/zoom).
  • jsPDF + svg2pdf.js are dynamically imported, so they're code-split and only load when an export actually runs.
  • Five pure helpers are exported from floorplan-registry-layer for reuse (buildContext, getFloorplanLevelData, floorplanLayerRank, splitFloorplanOverlay, isFloorplanNodeVisible) — no behaviour change.

Notable fix

svg2pdf ignores vector-effect: non-scaling-stroke (used by door/window/stair/fence builders with pixel-sized stroke widths). Left as-is it drew those as metre-wide strokes (huge grey blobs). Before handing each page to svg2pdf we now bake those widths into the real-unit value that lands at the intended point weight once the plan is scaled onto the page.

How to test

  1. bun dev, open a project with a building that has one or more levels (walls, doors, windows, furniture).
  2. Settings panel → ExportFloorplanFull floorplan. A multi-page PDF downloads — one page per level, each titled, plan fit to the page.
  3. Try Structure only — same, but furniture/items are dropped.
  4. Zoom into the PDF: linework stays crisp (vector); door/window/stair strokes render as thin lines, not blobs.

Screenshots / screen recording

Checklist

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

🤖 Generated with Claude Code


Note

Low Risk
Client-side export only; no auth or server changes. Main risk is bundle size from new PDF deps (mitigated by dynamic import) and edge cases in headless SVG/PDF rendering.

Overview
Adds floorplan PDF export from Settings → Export: Full floorplan (all visible floorplan nodes) and Structure only (category === 'structure'). Each run downloads a landscape A4 PDF with one page per level of the active building, titled from the level name, with the plan fit to the page (not the live 2D viewport).

Export reuses the same registry floorplan pipeline headlessly (FloorplanGeometryRenderer + neutral view state), with dynamic jspdf / svg2pdf.js loads. Several helpers are exported from floorplan-registry-layer for reuse only.

Before svg2pdf, vector-effect: non-scaling-stroke widths are converted to real units so door/window/stair lines don’t render as metre-wide blobs in the PDF.

Reviewed by Cursor Bugbot for commit b87b1f9. Bugbot is set up for automated code reviews on this repo. Configure here.

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>
@wass08 wass08 merged commit 9084396 into main Jun 29, 2026
4 checks passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 5 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b87b1f9. Configure here.

const childIds = (node as { children?: AnyNodeId[] }).children
if (Array.isArray(childIds)) for (const cid of childIds) visit(cid)
}
visit(levelId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building elevators omitted from PDF

High Severity

The collectFloorplanGeometry function's node collection logic only traverses nodes under a specific level. This means building-scoped nodes, such as elevators, which are typically parented to the building itself, are not included in the floorplan export. As a result, exported PDFs may be missing these elements, leading to incomplete floorplans or blank pages if a level only contains such nodes. The live floorplan renderer correctly includes these.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b87b1f9. Configure here.

isFloorplanNodeVisible(node) &&
(scope === 'full' || def.category === 'structure')
) {
entries.push({ id, node })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hidden parent visibility not honored

Medium Severity

The floorplan export uses isFloorplanNodeVisible to determine node inclusion, unlike the live editor's isFloorplanHierarchyVisible which considers ancestor visibility. This can lead to nodes appearing in the PDF that are hidden in the live editor due to a hidden parent.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b87b1f9. Configure here.

if (!svg || !bbox || bbox.width === 0 || bbox.height === 0) {
cleanup()
return null
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Item icons may miss PDF

Medium Severity

In mountFloorplanSvg, the SVG bounds are measured after a fixed two-frame delay. This can be insufficient for asynchronously loaded assets (like item icons from FloorplanImage) to render, potentially causing those icons to be missing from the PDF and the resulting viewBox to be incorrectly sized.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b87b1f9. Configure here.

const geometry = builder(node, ctx)
if (!geometry) continue
const { base } = splitFloorplanOverlay(geometry)
if (base) out.push({ id, base })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zone labels stripped from export

Medium Severity

After building geometry, export keeps only the base half of splitFloorplanOverlay. Zone names are emitted as kind: 'text' in buildZoneFloorplan, which OVERLAY_KINDS routes to the overlay tree. The live floorplan draws overlay in a second pass, so full PDFs show colored zones without centered name labels.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b87b1f9. Configure here.

for (const node of Object.values(nodes)) {
if (node.type === 'level') return node.id as AnyNodeId
}
return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-deterministic building fallback

Low Severity

When selection.levelId is missing or invalid, resolveExportLevels uses firstLevelId, which scans Object.values(nodes) and returns the first level encountered. That order is not stable, so multi-building scenes can export the wrong building.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b87b1f9. Configure here.

@mintlify

mintlify Bot commented Jun 29, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
pascal 🔴 Failed Jun 29, 2026, 1:01 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant