From bf89b5bcf2e98d06ce4a731a5e97c6abb883c189 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 09:57:30 -0400 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20vertical=20building=20model=20?= =?UTF-8?q?=E2=80=94=20stored=20level=20heights,=20wall=20inversion,=20dec?= =?UTF-8?q?ks=20(#526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): stored storey heights groundwork — pure slab-support module + level height schema Extract pointInPolygon/computeWallSlabSupport and friends into a cycle-free packages/core/src/systems/slab/slab-support.ts (severs level-height -> spatial-grid-manager -> use-scene), add deriveLegacyLevelHeight as the pure mesh-free equivalent of the viewer's stacked level height, and add the optional LevelNode.height field plus the storey service (getStoredLevelHeight, getLevelElevations per-building prefix sums). No behavior change. Co-Authored-By: Claude Fable 5 * feat(editor): storey height badge + edit popover on level rows Each floating-level-selector row shows its storey height; clicking opens a popover with 2.5/3.0/3.5 presets and a free slider writing level.height. Co-Authored-By: Claude Fable 5 * feat(core): vertical-model load migration — stored heights, ordinal compaction, wall-top classification Pass 3 in migrateNodes: derive and store each legacy level's exact stacked height (never snapped), compact ordinals per building anchored at zero so basements stay basements, classify wall tops against the derived plane (|plane - top| < 0.20 strictly -> plane-bound, else explicit height materialized), and drop the blind totalRise 2.5 stair default on legacy scenes only. Epsilon and strictness validated by a prod census. Co-Authored-By: Claude Fable 5 * feat: pin wall tops to the storey plane; stored heights become the only vertical truth Wall-top inversion: a wall without an explicit height now tops out at its storey plane (resolveWallTop); slabs lift only the base. Window/door caps resolve the real top through the same slab election instead of Infinity. All level stacking (viewer, elevator, first-person, stair openings, MCP scene queries) reads stored LevelNode.height; the four divergent live derivations and level.metadata.height are deleted. Stair totalRise becomes optional and derives from the storey height when absent. MCP create-level stops writing its elevation param into the ordinal. Level creation sites write explicit heights; templates carry their true derived heights. Co-Authored-By: Claude Fable 5 * feat: clamp slabs/ceilings under the storey plane; sweep wall-height fallbacks; wall Top control Slab elevation writes clamp to plane − MIN_WALL_HEIGHT when plane-bound walls elect the slab (pure clampSlabElevationForWalls + registry handle bounds + shared panels for 2D/3D parity); ceiling heights clamp under the plane and auto-ceilings derive from resolved wall tops. Every remaining wall.height ?? 2.5 fallback resolves through resolveWallTop / resolveWallEffectiveHeight (panels, overlays, measurements, quantities, spatial grid, MCP reports); template walls matching their storey become plane-bound. Wall panel gains a Top control (Follows storey / Custom height) derived purely from height presence; the store update path now deletes keys passed as explicit undefined so plane-binding round-trips. Co-Authored-By: Claude Fable 5 * feat(core): persisted support hosts — schema, host-preferring election, rendered-polygon unification Floor-placed nodes and walls gain a nullable supportSlabId. Elections surface the winning slab (getSlabSupportForItem, candidates query) and prefer a still-valid persisted host, falling back silently when the host is gone or reshaped away; deleting the host strips references in the same undo commit. Item-side support now tests the rendered slab polygon (like walls) through a per-level cache invalidated by the spatial-grid sync. Also adds the resolveStairTotalRise unit tests from the stage-1 gates. Co-Authored-By: Claude Fable 5 * feat: persist support hosts at commit; thread wall host preference everywhere Floor-placed commits (draft pipeline, per-kind creation tools, registry move tool) and wall create/move/endpoint commits persist supportSlabId via shared resolveSupportSlabPatch helpers — only when overlapping supports disagree on elevation, clearing it otherwise or when the node leaves the floor. WallSlabSupport surfaces electedSlabId; every wall support read site passes wall.supportSlabId as the preferred host. Co-Authored-By: Claude Fable 5 * feat: split slab into placement + thickness; pools become explicit recess intent slab.elevation stays the walking surface; new thickness grows downward so the solid occupies [elevation − thickness, elevation]. Migration writes thickness := elevation for solids (byte-identical intervals, including degenerate zero) and recessed: true for legacy negative pools. Geometry branches on recessed instead of the elevation sign; presets keep today's intervals; free elevation edits move the body without coupling thickness (the deck semantic). Dead viewer SlabSystem component deleted. Co-Authored-By: Claude Fable 5 * feat: clamp ceilings to covering-slab undersides across levels getLevelAbove + getCoveringSlabUndersideAt give the first cross-level query; ceiling writes clamp to min(storey plane, lowest covering underside) − 0.01, and the space-detection reconcile now clamps manual ceilings down (never up) when a deck above intrudes — a flush deck reactively lowers the ceiling below it. Co-Authored-By: Claude Fable 5 * feat: mezzanine and balcony build-tab tools One-gesture composites over the kernel: draw a deck footprint and commit deck slab + railings + stair (mezzanine) or deck + railings (balcony) in a single undo step. Fences gain supportSlabId and lift onto their host deck; railing runs split around the stair mouth; edges near wall centerlines are treated as closed. Stairs target the deck via explicit totalRise with no level-to-level opening sync. Co-Authored-By: Claude Fable 5 * feat: adaptive slab vertical editing; level vocabulary in UI copy Dragging a grounded slab's top stretches it (elevation and thickness move together — gaps impossible); floating decks move with thickness preserved and land grounded at zero; pools keep the drag-through-zero gesture. User-facing copy says level, not storey (Follows level, Level height). Co-Authored-By: Claude Fable 5 * feat: clamp plane-bound wall tops to covering-slab undersides; fix vertical reactivity getWallPlaneTop samples the wall span against the level above's slabs, so a thick or flush upper floor shortens the walls below it instead of colliding (automatic attach, no dialog). Level-height edits now dirty the level's walls, stairs, ceilings, and fences; covering-slab changes dirty the level below. The effective-height helper triplicated across editor overlays moved to core. Co-Authored-By: Claude Fable 5 * feat: ceilings follow the level top by default ceiling.height becomes optional — absent means the ceiling tracks min(level top, covering slab underside) − 0.01 live, so level-height edits no longer require ceiling fixes. Ceiling panel gains the same Follows level / Custom height control as walls; auto-from-walls ceilings are created height-less and their height-derivation machinery is deleted; migration drops stored heights within 0.20 of the bound on legacy scenes. Co-Authored-By: Claude Fable 5 * fix: wall plane clamp missed max-side boundary walls Auto slabs derive their polygon from wall centerlines, so covering-clamp samples sat exactly on the boundary where ray-cast point-in-polygon is side-dependent (min edges in, max edges out) — walls clamped or not by orientation. getWallPlaneTop now clips the wall's thickness band against the covering polygon (boundary-inclusive, arc-aware) and the ceiling bound's point sampling gained an explicit on-boundary test. Verified against the reported repro scene. Co-Authored-By: Claude Fable 5 * feat: stairs attach to decks; pointer-decided placement surface Stairs gain deckSlabId — rise follows the deck's elevation live (straight flights re-converge via a write-sync mirroring auto-openings), the panel shows a unified To destination with Follows deck / Custom rise, and the mezzanine tool attaches instead of baking a stale rise. Item placement under an elevated deck no longer flickers: grid events fed a feedback loop (the grid plane rode the ghost's elected height), so the support election is now capped at the surface the pointer ray actually hits, with a ground sentinel keeping under-deck commits deterministic. Co-Authored-By: Claude Fable 5 * chore: remove the mezzanine/balcony composite tools Decks ship as catalog presets instead; the kernel the presets rely on (fence deck-hosting, stair deck attachment, clone remaps, pointer-decided placement) all stays. The tool code lives at e30042db for reference. Co-Authored-By: Claude Fable 5 * feat: gate wall adoption to grounded slabs; panel moves, drag stretches then unsticks Floating decks keep their drawn polygon (and stop being seam candidates for grounded neighbors) instead of growing into nearby walls. Panel elevation edits are pure placement; the viewport drag stretches a grounded slab up to 0.4 m then unsticks it into a thin deck. Co-Authored-By: Claude Fable 5 * fix: deck-attached stairs land flush with the deck surface The rise now subtracts the stair's own elected base (same election the visual lift uses), so base + rise always equals the deck walking surface; the auto-sync defers a microtask so it reads a settled spatial grid and re-converges on both deck and base-slab moves. Co-Authored-By: Claude Fable 5 * feat: draw walls and fences on elevated decks Wall and fence draw tools now publish the pointed surface, so the draw plane rides the deck top (no more perspective-skewed floor hits) and previews sit on the deck. Fences gain real support election: a pure resolveFenceSupportSlabPatch persists the deck host at draw and reshape commits; wall commits thread the pointer cap so aiming under a deck elects the floor. Co-Authored-By: Claude Fable 5 * feat: unit-aware height presets Level, ceiling, and slab preset buttons show clean values per display system (8/9/10 ft storeys, 8'-9' ceilings, whole-inch slab steps) instead of converted metric labels; metric presets unchanged. Co-Authored-By: Claude Fable 5 * feat: stairs converge to their resolved rise; deck attachment disables the cutout syncStairRises now converges every follows-mode straight stair (level or deck) plus deck-attached custom rises — detaching a stair from a deck re-derives its height, and ordinary stairs finally track level-height changes. Attaching via the panel writes slabOpeningMode none and hides the cutout controls; detaching restores the destination cutout and clears the stale explicit rise. Co-Authored-By: Claude Fable 5 * fix: stacked-slab move hopping — one ray, one surface, one XZ The hop was hysteresis: the election consumed the riding grid plane's perspective-skewed hit, giving two self-consistent fixed points for one pointer ray. getPointedSupportSurface now returns the ray's crossing of the pointed surface and both the support cap and the cursor XZ derive from that single computation, so items stay on the surface the pointer aims at and sit exactly under the cursor across storeys. Co-Authored-By: Claude Fable 5 * fix: single stair per click; tools restore select mode on exit The stair tool subscribed to both node clicks (synthesized on pointerup) and the native-click grid event with none of the guards sibling tools carry — one physical click over any node surface committed twice. A commit gate + follow-up click swallow fix the double dispatch, and the stair and column tools now restore select mode on exit instead of leaving the dead build-mode-without-tool state that ignored every click. Co-Authored-By: Claude Fable 5 * feat: compact multi-selection panel with host footer slot Selecting multiple nodes now docks the collapsed-by-default panel on the right: N selected header, kind breakdown, and Duplicate/Delete mirroring the floating pill. A new multiSelectionFooter slot lets the host app dock actions below it, exactly like inspectorFooter. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../spatial-grid/fence-support-patch.test.ts | 180 ++++ .../floor-placed-elevation.test.ts | 2 + .../spatial-grid/floor-placed-elevation.ts | 52 +- .../spatial-grid/pointer-support-cap.test.ts | 487 ++++++++++ .../spatial-grid/spatial-grid-manager.ts | 840 ++++++------------ .../spatial-grid/spatial-grid-sync.test.ts | 289 ++++++ .../hooks/spatial-grid/spatial-grid-sync.ts | 111 ++- .../hooks/spatial-grid/support-host-patch.ts | 217 +++++ .../hooks/spatial-grid/support-host.test.ts | 628 +++++++++++++ .../spatial-grid/wall-slab-overlap.test.ts | 45 +- packages/core/src/index.ts | 26 +- packages/core/src/lib/slab-polygon.test.ts | 99 ++- packages/core/src/lib/slab-polygon.ts | 33 +- packages/core/src/lib/space-detection.test.ts | 284 +++++- packages/core/src/lib/space-detection.ts | 224 ++--- packages/core/src/lib/zone-quantities.test.ts | 7 +- packages/core/src/lib/zone-quantities.ts | 17 +- packages/core/src/schema/index.ts | 2 +- packages/core/src/schema/nodes/cabinet.ts | 2 + packages/core/src/schema/nodes/ceiling.ts | 8 +- packages/core/src/schema/nodes/column.ts | 2 + .../core/src/schema/nodes/duct-terminal.ts | 2 + packages/core/src/schema/nodes/fence.ts | 4 + .../core/src/schema/nodes/hvac-equipment.ts | 2 + packages/core/src/schema/nodes/item.ts | 14 + packages/core/src/schema/nodes/level.test.ts | 5 + packages/core/src/schema/nodes/level.ts | 7 + packages/core/src/schema/nodes/shelf.ts | 2 + packages/core/src/schema/nodes/slab.ts | 13 +- packages/core/src/schema/nodes/spawn.ts | 2 + packages/core/src/schema/nodes/stair.ts | 9 +- packages/core/src/schema/nodes/wall.test.ts | 42 +- packages/core/src/schema/nodes/wall.ts | 12 +- .../wall-plane-top-boundary-repro.ts | 89 ++ packages/core/src/services/index.ts | 15 +- .../core/src/services/level-height.test.ts | 228 +++++ packages/core/src/services/level-height.ts | 78 +- packages/core/src/services/storey.test.ts | 527 +++++++++++ packages/core/src/services/storey.ts | 358 ++++++++ .../core/src/store/actions/node-actions.ts | 49 +- .../actions/node-mutation-sanitize.test.ts | 58 ++ .../core/src/store/use-live-transforms.ts | 8 + .../core/src/store/use-scene-commits.test.ts | 6 +- .../use-scene-vertical-migration.test.ts | 376 ++++++++ packages/core/src/store/use-scene.ts | 188 +++- .../src/systems/elevator/elevator-service.ts | 36 +- .../src/systems/slab/slab-support.test.ts | 140 +++ .../core/src/systems/slab/slab-support.ts | 688 ++++++++++++++ .../src/systems/stair/stair-opening-sync.ts | 38 +- .../systems/stair/stair-opening-system.tsx | 40 +- .../core/src/systems/stair/stair-rise.test.ts | 465 ++++++++++ packages/core/src/systems/stair/stair-rise.ts | 90 ++ .../core/src/systems/wall/wall-top.test.ts | 45 + packages/core/src/systems/wall/wall-top.ts | 53 ++ .../core/src/utils/clone-scene-graph.test.ts | 54 +- packages/core/src/utils/clone-scene-graph.ts | 31 + .../editor/first-person-controls.tsx | 32 +- .../editor/floating-action-menu.tsx | 9 +- .../editor/src/components/editor/index.tsx | 12 +- .../editor/wall-measurement-label.tsx | 24 +- .../editor/wall-move-side-handles.tsx | 14 +- .../editor/wall-snap-beacon-layer.tsx | 10 +- .../ceiling-selection-affordance-system.tsx | 19 +- .../components/tools/fence/fence-drafting.ts | 27 + .../components/tools/item/use-draft-node.ts | 193 ++-- .../tools/item/use-placement-coordinator.tsx | 56 +- .../registry/move-registry-node-tool.tsx | 79 +- .../tools/shared/floor-stack-preview.ts | 4 + .../tools/shared/pointer-support-cap.ts | 99 +++ .../tools/stair/stair-click-guard.test.ts | 58 ++ .../tools/stair/stair-click-guard.ts | 72 ++ .../src/components/tools/stair/stair-tool.tsx | 21 +- .../components/tools/wall/wall-drafting.ts | 24 +- .../ui/command-palette/editor-commands.tsx | 3 +- .../components/ui/floating-level-selector.tsx | 70 ++ .../ui/panels/multi-selection-panel.tsx | 57 ++ .../components/ui/panels/panel-manager.tsx | 15 +- .../ui/panels/selection-breakdown.test.ts | 24 + .../ui/panels/selection-breakdown.ts | 20 + .../panels/site-panel/building-tree-node.tsx | 3 +- .../ui/sidebar/panels/site-panel/index.tsx | 2 + packages/editor/src/index.tsx | 7 + .../editor/src/lib/level-duplication.test.ts | 3 +- packages/editor/src/lib/stair-levels.ts | 2 + packages/mcp/src/templates/empty-studio.ts | 6 +- packages/mcp/src/templates/garden-house.ts | 2 +- packages/mcp/src/templates/two-bedroom.ts | 2 +- packages/mcp/src/tools/construction-tools.ts | 56 +- packages/mcp/src/tools/create-level.test.ts | 8 +- packages/mcp/src/tools/create-level.ts | 21 +- packages/mcp/src/tools/describe-node.ts | 20 +- .../tools/photo-to-scene/photo-to-scene.ts | 8 +- packages/mcp/src/tools/scene-query.ts | 46 +- packages/nodes/src/cabinet/tool.tsx | 33 +- .../nodes/src/ceiling/boundary-editor.tsx | 10 +- packages/nodes/src/ceiling/definition.ts | 43 +- packages/nodes/src/ceiling/floorplan-move.ts | 10 +- packages/nodes/src/ceiling/hole-editor.tsx | 10 +- packages/nodes/src/ceiling/move-tool.tsx | 6 +- packages/nodes/src/ceiling/panel.tsx | 125 ++- packages/nodes/src/ceiling/renderer.tsx | 7 +- packages/nodes/src/column/tool.tsx | 17 +- packages/nodes/src/door/definition.ts | 9 +- packages/nodes/src/duct-segment/tool.tsx | 3 +- packages/nodes/src/duct-terminal/tool.tsx | 22 +- .../nodes/src/fence/__tests__/lift.test.ts | 65 ++ .../nodes/src/fence/actions/move-endpoint.ts | 32 + packages/nodes/src/fence/definition.ts | 7 +- .../nodes/src/fence/floorplan-affordances.ts | 16 + packages/nodes/src/fence/floorplan-move.ts | 48 +- packages/nodes/src/fence/geometry.ts | 13 +- packages/nodes/src/fence/lift.ts | 25 + packages/nodes/src/fence/move-tool.tsx | 33 +- packages/nodes/src/fence/system.tsx | 50 ++ packages/nodes/src/fence/tool.tsx | 84 +- packages/nodes/src/hvac-equipment/tool.tsx | 17 +- packages/nodes/src/level/definition.ts | 12 +- packages/nodes/src/shared/move-roof-tool.tsx | 20 +- .../src/shared/opening-guides-runtime.ts | 3 +- .../shared/opening-placement-dimensions.ts | 7 +- .../nodes/src/shared/wall-opening-ceiling.ts | 64 ++ packages/nodes/src/shelf/tool.tsx | 10 +- .../site/recessed-slab-ground-holes.test.ts | 18 + .../src/site/recessed-slab-ground-holes.ts | 5 +- .../src/slab/__tests__/definition.test.ts | 22 +- .../slab/__tests__/elevation-limit.test.ts | 160 ++++ .../nodes/src/slab/__tests__/geometry.test.ts | 27 + packages/nodes/src/slab/definition.ts | 19 +- packages/nodes/src/slab/elevation-limit.ts | 119 +++ packages/nodes/src/slab/geometry.ts | 5 +- packages/nodes/src/slab/panel.tsx | 74 +- packages/nodes/src/slab/parametrics.ts | 15 +- packages/nodes/src/spawn/tool.tsx | 18 +- packages/nodes/src/stair/definition.ts | 16 +- packages/nodes/src/stair/destination.test.ts | 70 ++ packages/nodes/src/stair/destination.ts | 30 + packages/nodes/src/stair/panel.tsx | 132 ++- packages/nodes/src/stair/renderer.tsx | 6 +- packages/nodes/src/wall/measurement.ts | 15 +- .../nodes/src/wall/move-endpoint-tool.tsx | 15 +- packages/nodes/src/wall/move-shared.ts | 4 +- packages/nodes/src/wall/move-tool.tsx | 24 +- packages/nodes/src/wall/paint.ts | 25 +- packages/nodes/src/wall/panel.tsx | 79 +- packages/nodes/src/wall/parametrics.ts | 2 + packages/nodes/src/wall/quick-measurement.ts | 5 +- packages/nodes/src/wall/system.tsx | 2 +- packages/nodes/src/wall/tool.tsx | 50 +- packages/nodes/src/wall/treatments.tsx | 4 +- packages/nodes/src/window/definition.ts | 13 +- packages/nodes/src/window/floorplan-move.ts | 1 + packages/nodes/src/window/move-tool.tsx | 1 + packages/nodes/src/window/tool.tsx | 9 +- packages/nodes/src/window/window-math.ts | 12 +- packages/viewer/src/index.ts | 7 +- .../src/systems/ceiling/ceiling-system.tsx | 10 +- .../floor-elevation-system.tsx | 11 +- .../src/systems/level/level-stacking.test.ts | 67 -- .../src/systems/level/level-stacking.ts | 32 - .../viewer/src/systems/level/level-system.tsx | 26 +- .../viewer/src/systems/level/level-utils.ts | 26 +- .../src/systems/slab/slab-system.test.ts | 74 +- .../viewer/src/systems/slab/slab-system.tsx | 131 +-- .../viewer/src/systems/wall/wall-cutout.tsx | 20 +- .../wall/wall-support-extension.test.ts | 98 +- .../viewer/src/systems/wall/wall-system.tsx | 49 +- 166 files changed, 9280 insertions(+), 1526 deletions(-) create mode 100644 packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts create mode 100644 packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts create mode 100644 packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts create mode 100644 packages/core/src/hooks/spatial-grid/support-host-patch.ts create mode 100644 packages/core/src/hooks/spatial-grid/support-host.test.ts create mode 100644 packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts create mode 100644 packages/core/src/services/level-height.test.ts create mode 100644 packages/core/src/services/storey.test.ts create mode 100644 packages/core/src/services/storey.ts create mode 100644 packages/core/src/store/use-scene-vertical-migration.test.ts create mode 100644 packages/core/src/systems/slab/slab-support.test.ts create mode 100644 packages/core/src/systems/slab/slab-support.ts create mode 100644 packages/core/src/systems/stair/stair-rise.test.ts create mode 100644 packages/core/src/systems/stair/stair-rise.ts create mode 100644 packages/core/src/systems/wall/wall-top.test.ts create mode 100644 packages/core/src/systems/wall/wall-top.ts create mode 100644 packages/editor/src/components/tools/shared/pointer-support-cap.ts create mode 100644 packages/editor/src/components/tools/stair/stair-click-guard.test.ts create mode 100644 packages/editor/src/components/tools/stair/stair-click-guard.ts create mode 100644 packages/editor/src/components/ui/panels/multi-selection-panel.tsx create mode 100644 packages/editor/src/components/ui/panels/selection-breakdown.test.ts create mode 100644 packages/editor/src/components/ui/panels/selection-breakdown.ts create mode 100644 packages/nodes/src/fence/__tests__/lift.test.ts create mode 100644 packages/nodes/src/fence/lift.ts create mode 100644 packages/nodes/src/fence/system.tsx create mode 100644 packages/nodes/src/shared/wall-opening-ceiling.ts create mode 100644 packages/nodes/src/slab/__tests__/elevation-limit.test.ts create mode 100644 packages/nodes/src/slab/elevation-limit.ts create mode 100644 packages/nodes/src/stair/destination.test.ts create mode 100644 packages/nodes/src/stair/destination.ts delete mode 100644 packages/viewer/src/systems/level/level-stacking.test.ts delete mode 100644 packages/viewer/src/systems/level/level-stacking.ts diff --git a/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts new file mode 100644 index 00000000..9ad31a19 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' +import { type FenceSupportInput, resolveFenceSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +/** Deck footprint in plan: x/z ∈ [0, 4] × [0, 3]. */ +const DECK_POLYGON: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +/** Ground floor slab under (and far beyond) the deck. */ +const GROUND_POLYGON: Array<[number, number]> = [ + [-6, -6], + [6, -6], + [6, 6], + [-6, 6], +] + +const DECK_ELEVATION = 0.9 +const FLOOR_ELEVATION = 0.05 + +function makeLevel(): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +/** Straight fence fully over the deck footprint. */ +function fenceOnDeck(overrides: Partial = {}): FenceSupportInput { + return { + start: [0.5, 1.5], + end: [3.5, 1.5], + thickness: 0.08, + parentId: LEVEL_ID, + ...overrides, + } +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +function sceneWith(...slabs: SlabNode[]): Record { + const nodes = nodesFor(makeLevel(), ...(slabs as AnyNode[])) + useScene.setState({ nodes }) + for (const slab of slabs) addSlab(slab) + return nodes +} + +beforeEach(() => { + spatialGridManager.clear() + useScene.setState({ nodes: {} }) +}) + +describe('resolveFenceSupportSlabPatch', () => { + test('a fence drawn over a deck stacked on the floor persists the deck (uncapped max election)', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: 'slab_deck', + }) + }) + + test('the pointer cap decides between stacked surfaces', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + // Aiming at the floor under the deck elects (and persists) the floor. + expect( + resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: FLOOR_ELEVATION }), + ).toEqual({ supportSlabId: 'slab_ground' }) + // Aiming at the deck top keeps the deck. + expect( + resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: DECK_ELEVATION }), + ).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a lone elevated deck (balcony, nothing underneath) still persists its host', () => { + // Unambiguous single candidate — but fences resolve an absent host to + // the level floor, so an elevated winner must be written or the fence + // renders buried under the deck. + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: 'slab_deck', + }) + }) + + test('a plain default ground slab stays unpersisted (fence keeps sitting at the level base)', () => { + const nodes = sceneWith(makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: undefined, + }) + }) + + test('capped at bare ground under a deck-only overlap resolves to the floor default', () => { + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: 0 })).toEqual({ + supportSlabId: undefined, + }) + }) + + test('no slabs / off-slab fence persists nothing', () => { + const nodes = sceneWith() + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: undefined, + }) + + const withDeck = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect( + resolveFenceSupportSlabPatch(fenceOnDeck({ start: [10, 10], end: [13, 10] }), withDeck), + ).toEqual({ supportSlabId: undefined }) + }) + + test('a spline fence elects through its path band segments', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + const spline = fenceOnDeck({ + start: [0.5, 0.5], + end: [3.5, 2.5], + path: [ + [0.5, 0.5], + [2, 1.5], + [3.5, 2.5], + ], + }) + expect(resolveFenceSupportSlabPatch(spline, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a fence not parented to a level persists nothing', () => { + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck({ parentId: 'not_a_level' }), nodes)).toEqual({ + supportSlabId: undefined, + }) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index f0dfe76f..ebdb24c3 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -74,6 +74,8 @@ function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab holes: [], holeMetadata: [], elevation, + thickness: Math.max(elevation, 0), + recessed: elevation < 0, autoFromWalls: false, } as SlabNode spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts index b0b8315d..ca66b3cc 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts @@ -8,12 +8,29 @@ import type { import type { AnyNode, AnyNodeId } from '../../schema' import { spatialGridManager } from './spatial-grid-manager' +/** + * Sentinel `supportSlabId` meaning "hosted by the level base (ground)". + * Persisted when a pointer-capped commit elects the ground while one or + * more slabs (e.g. an elevated deck) still overlap the footprint above the + * cap — without it, the uncapped per-frame election would lift the + * committed node back onto the deck. + */ +export const GROUND_SUPPORT_ID = 'ground' + export type FloorPlacedElevationArgs = { node: AnyNode nodes: Record position: [number, number, number] rotation?: unknown levelId?: string | null + /** + * Pointer-decided support cap (level-local Y): only slabs whose walking + * surface sits at or below `maxElevation + SUPPORT_ELEVATION_EPSILON` + * may be elected, and the persisted `supportSlabId` is bypassed — during + * a drag the pointer, not the stored host, decides the target surface. + * Omit (or pass null) for the uncapped committed-read behavior. + */ + maxElevation?: number | null } function finiteSlabElevation(elevation: number): number { @@ -50,6 +67,7 @@ export function getFloorPlacedElevation({ position, rotation, levelId, + maxElevation, }: FloorPlacedElevationArgs): number { const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced if (!floorPlaced) return 0 @@ -66,8 +84,31 @@ export function getFloorPlacedElevation({ const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId if (!resolvedLevelId) return 0 - let maxElevation = Number.NEGATIVE_INFINITY - for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) { + const footprints = getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes }) + + // A persisted support host pins the elevation while it still exists and + // overlaps a footprint — deterministic across stacked slabs. A stale + // host (deleted or reshaped away) silently falls through to the + // election below; this per-frame read path never writes the field. + // Skipped entirely under a pointer cap: the cursor, not the stored + // host, decides the target surface during a drag. + const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId + if (maxElevation == null && supportSlabId) { + if (supportSlabId === GROUND_SUPPORT_ID) return 0 + for (const footprint of footprints) { + const hosted = spatialGridManager.getHostSlabElevationForFootprint( + resolvedLevelId, + supportSlabId, + footprint.position ?? position, + footprint.dimensions, + footprint.rotation, + ) + if (hosted !== null) return finiteSlabElevation(hosted) + } + } + + let elected = Number.NEGATIVE_INFINITY + for (const footprint of footprints) { const footprintPosition = footprint.position ?? position const elevation = finiteSlabElevation( spatialGridManager.getSlabElevationForItem( @@ -75,14 +116,15 @@ export function getFloorPlacedElevation({ footprintPosition, footprint.dimensions, footprint.rotation, + maxElevation, ), ) - if (elevation > maxElevation) { - maxElevation = elevation + if (elevation > elected) { + elected = elevation } } - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + return elected === Number.NEGATIVE_INFINITY ? 0 : elected } export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] { diff --git a/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts new file mode 100644 index 00000000..1846afa6 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts @@ -0,0 +1,487 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' +import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' +import { resolveSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +/** Deck footprint in plan: x/z ∈ [-1, 1]. */ +const DECK_POLYGON: Array<[number, number]> = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], +] + +/** Ground floor slab under (and far beyond) the deck: x/z ∈ [-5, 5]. */ +const GROUND_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], +] + +const DECK_ELEVATION = 0.9 +const FLOOR_ELEVATION = 0.05 + +function makeDefinition( + kind: AnyNode['type'], + capabilities: AnyNodeDefinition['capabilities'] = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities, + } +} + +function registerFloorPlacedItem() { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) +} + +function makeLevel(): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + } as AnyNode +} + +function makeFloorNode(overrides: Partial = {}): AnyNode { + return { + id: 'item_test', + type: 'item', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + asset: { + id: 'asset_test', + category: 'test', + name: 'Test', + thumbnail: '', + src: 'asset:test', + dimensions: [1, 1, 1], + source: 'library', + }, + ...overrides, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +function addDeckAndFloor() { + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) +}) + +describe('pointer-capped slab support election', () => { + test('hit at the floor under the deck elects the floor, not the deck above', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + FLOOR_ELEVATION, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) + + test('hit on the deck top still elects the deck', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + DECK_ELEVATION, + ), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + }) + + test('no cap keeps the historical max election', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + }) + + test('epsilon boundary: a slab within EPS above the cap is elected, beyond EPS is not', () => { + // Cap 0.05 with EPS 0.05: a slab at 0.10 is still electable, 0.11 is not. + addSlab(makeSlab('slab_within', DECK_POLYGON, 0.1)) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05), + ).toEqual({ elevation: 0.1, slabId: 'slab_within' }) + + spatialGridManager.clear() + addSlab(makeSlab('slab_beyond', DECK_POLYGON, 0.11)) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05), + ).toEqual({ elevation: 0, slabId: null }) + }) +}) + +describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => { + test('ray aimed at the floor under the deck resolves the floor, aimed at the deck resolves the deck', () => { + addDeckAndFloor() + + // Camera in front of the deck (negative z), high up. Aiming at the + // floor point (0, FLOOR, 0) — a point that lies UNDER the deck in + // plan — crosses the deck's elevation plane before reaching the deck + // polygon, so only the floor is hit. + const origin: [number, number, number] = [0, 5, -10] + const toFloorUnderDeck: [number, number, number] = [ + 0 - origin[0], + FLOOR_ELEVATION - origin[1], + 0 - origin[2], + ] + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toFloorUnderDeck)).toEqual( + { elevation: FLOOR_ELEVATION, slabId: 'slab_floor', point: [0, 0] }, + ) + + // Aiming at the deck's top surface: the deck plane crossing lands + // inside the deck polygon and is nearer along the ray than the floor. + const toDeckTop: [number, number, number] = [ + 0 - origin[0], + DECK_ELEVATION - origin[1], + 0.5 - origin[2], + ] + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toDeckTop)).toEqual({ + elevation: DECK_ELEVATION, + slabId: 'slab_deck', + point: [0, 0.5], + }) + }) + + test('a ray through a deck hole falls through to the surface below', () => { + addSlab( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION, { + holes: [ + [ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ], + ], + }), + ) + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) + + // Straight down through the hole center. + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, -1, 0])).toEqual({ + elevation: FLOOR_ELEVATION, + slabId: 'slab_floor', + point: [0, 0], + }) + }) + + test('no slab crossing resolves the level base (with the base-plane point)', () => { + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [3, 5, 3], [0, -1, 0])).toEqual({ + elevation: 0, + slabId: null, + point: [3, 3], + }) + }) + + test('a ray that cannot reach any surface has no point', () => { + addDeckAndFloor() + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, 1, 0])).toEqual({ + elevation: 0, + slabId: null, + point: null, + }) + }) +}) + +describe('pointed point — stacked-deck hop repro (ray ∩ pointed-surface plane)', () => { + // Manual repro this pins down: deck slab stacked above a floor slab, + // move an item over the deck near its far edge with an angled camera. + // The grid event plane rides at the ghost's LAST surface height, so the + // same screen ray produces hit points whose XZ differ by metres + // depending on which storey the plane rode at. The cap (ray → pointed + // surface) is plane-height independent, but electing at the RAW hit XZ + // is not: the floor-height hit is perspective-skewed past the deck, its + // footprint misses the deck polygon, and the capped election falls to + // the floor — dropping the ghost, which drops the plane, which keeps + // the hit skewed (a second self-consistent state). Transitions between + // the two states are the hop. Electing at the ray-derived `point` + // leaves a single fixed point per pointer ray. + const origin: [number, number, number] = [0, 5, -10] + /** Aimed at the deck top near its far edge: (0, DECK_ELEVATION, 0.8). */ + const aimAtDeck: [number, number, number] = [ + 0 - origin[0], + DECK_ELEVATION - origin[1], + 0.8 - origin[2], + ] + + test('same ray reconstructed from either plane-height hit: pointed point elects the deck every time', () => { + addDeckAndFloor() + + // The two grid hits the SAME screen ray produces — one per event-plane + // height (plane riding at the deck vs at the floor slab). + const tDeck = (DECK_ELEVATION - origin[1]) / aimAtDeck[1] + const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1] + const planeHits = [tDeck, tFloor].map((t): [number, number, number] => [ + origin[0] + aimAtDeck[0] * t, + origin[1] + aimAtDeck[1] * t, + origin[2] + aimAtDeck[2] * t, + ]) + + for (const hit of planeHits) { + const direction: [number, number, number] = [ + hit[0] - origin[0], + hit[1] - origin[1], + hit[2] - origin[2], + ] + const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, direction) + expect(pointed.slabId).toBe('slab_deck') + expect(pointed.elevation).toBe(DECK_ELEVATION) + expect(pointed.point?.[0]).toBeCloseTo(0, 10) + expect(pointed.point?.[1]).toBeCloseTo(0.8, 10) + + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [pointed.point![0], 0, pointed.point![1]], + [1, 1, 1], + [0, 0, 0], + pointed.elevation, + ), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + } + }) + + test('electing at the raw floor-height hit flips to the floor — the hop mechanism, kept as documentation', () => { + addDeckAndFloor() + + const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1] + const floorPlaneHit: [number, number, number] = [ + origin[0] + aimAtDeck[0] * tFloor, + 0, + origin[2] + aimAtDeck[2] * tFloor, + ] + // The skew carries the hit metres past the deck's far edge (z = 1)… + expect(floorPlaneHit[2]).toBeGreaterThan(2) + // …so the same pointer ray, elected at the raw hit XZ, picks the + // FLOOR while the cap says the pointer is on the deck. + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + floorPlaneHit, + [1, 1, 1], + [0, 0, 0], + DECK_ELEVATION, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) + + test('pointer past the deck edge: pointed point lands on the floor and elects it', () => { + addDeckAndFloor() + + // Aimed at a floor point far enough out that the deck-plane crossing + // falls outside the deck polygon (the floor there is actually visible). + const aimPastDeck: [number, number, number] = [ + 0 - origin[0], + FLOOR_ELEVATION - origin[1], + 4 - origin[2], + ] + const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, aimPastDeck) + expect(pointed).toEqual({ + elevation: FLOOR_ELEVATION, + slabId: 'slab_floor', + point: [0, 4], + }) + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 4], + [1, 1, 1], + [0, 0, 0], + pointed.elevation, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) +}) + +describe('getFloorPlacedElevation under a pointer cap', () => { + test('cap at the floor keeps the item on the floor even though the deck overlaps in plan', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: FLOOR_ELEVATION, + }), + ).toBeCloseTo(FLOOR_ELEVATION) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: DECK_ELEVATION, + }), + ).toBeCloseTo(DECK_ELEVATION) + + // Uncapped read keeps the historical max election. + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(DECK_ELEVATION) + }) + + test('the pointer cap bypasses a persisted host — the cursor decides during a drag', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: 'slab_deck' } as Partial) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: FLOOR_ELEVATION, + }), + ).toBeCloseTo(FLOOR_ELEVATION) + }) + + test('the ground sentinel pins a committed node to the level base under an overlapping deck', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: GROUND_SUPPORT_ID } as Partial) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + }) +}) + +describe('resolveSupportSlabPatch under a pointer cap (commit determinism)', () => { + test('a commit under the deck persists the elected lower slab', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: 'slab_floor', + }) + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: 'slab_deck', + }) + // Uncapped commits keep the historical rule (max winner on ambiguity). + expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a commit on bare ground under the deck persists the ground sentinel', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: 0 })).toEqual({ + supportSlabId: GROUND_SUPPORT_ID, + }) + // Aiming at the deck top with only the deck overlapping stays + // unambiguous — no host persisted, same as the uncapped rule. + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: undefined, + }) + }) + + test('a single floor slab under the cap stays unpersisted (unambiguous)', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: undefined, + }) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 81c322e1..fa4b27bc 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -2,36 +2,35 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' +import { getWallPlaneTop } from '../../services/storey' import useScene from '../../store/use-scene' -import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' +import { + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallSlabSupport, +} from '../../systems/slab/slab-support' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' +import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' +export { + computeWallSlabElevation, + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallOverlapInput, + type WallSlabSupport, + type WallSlabSupportSegment, + wallOverlapsPolygon, +} from '../../systems/slab/slab-support' + // ============================================================================ // GEOMETRY HELPERS // ============================================================================ -/** - * Point-in-polygon test using ray casting algorithm. - */ -export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean { - let inside = false - const n = polygon.length - for (let i = 0, j = n - 1; i < n; j = i++) { - const xi = polygon[i]![0], - zi = polygon[i]![1] - const xj = polygon[j]![0], - zj = polygon[j]![1] - - if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) { - inside = !inside - } - } - return inside -} - /** * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. */ @@ -295,512 +294,29 @@ export function itemOverlapsPolygon( return false } -function pointSegmentDistance( - px: number, - pz: number, - ax: number, - az: number, - bx: number, - bz: number, -): number { - const dx = bx - ax - const dz = bz - az - const lengthSquared = dx * dx + dz * dz - if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az) - const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared)) - return Math.hypot(px - (ax + dx * t), pz - (az + dz * t)) -} - -// Ray-cast pointInPolygon is unreliable for points exactly on the polygon -// boundary: the answer flips depending on which side of the polygon the edge -// is on. Interval classification below therefore treats "within this distance -// of the boundary" as inside explicitly, so walls sitting exactly on a slab -// edge (the common case — auto-slab polygons derive from wall centerlines) -// classify identically on every side of the slab. -const ON_BOUNDARY_EPSILON = 1e-4 - -function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, number]>): boolean { - const n = polygon.length - for (let i = 0; i < n; i++) { - const [ax, az] = polygon[i]! - const [bx, bz] = polygon[(i + 1) % n]! - if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true - } - return false -} - -/** Sub-interval along a segment or polyline: [start, end] in length units. */ -type LengthInterval = [number, number] - -function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] { - if (intervals.length <= 1) return intervals - const sorted = [...intervals].sort((a, b) => a[0] - b[0]) - const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]] - for (let i = 1; i < sorted.length; i++) { - const [intervalStart, intervalEnd] = sorted[i]! - const last = merged[merged.length - 1]! - if (intervalStart <= last[1] + 1e-9) { - last[1] = Math.max(last[1], intervalEnd) - } else { - merged.push([intervalStart, intervalEnd]) - } - } - return merged -} - -/** Total length of a merged (sorted, disjoint) interval list. */ -function intervalsLength(intervals: readonly LengthInterval[]): number { - let total = 0 - for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart - return total -} - -/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */ -function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] { - if (base.length === 0 || cut.length === 0) return mergeIntervals(base) - const cuts = mergeIntervals(cut) - const result: LengthInterval[] = [] - for (const [baseStart, baseEnd] of mergeIntervals(base)) { - let cursor = baseStart - for (const [cutStart, cutEnd] of cuts) { - if (cutEnd <= cursor) continue - if (cutStart >= baseEnd) break - if (cutStart > cursor) result.push([cursor, cutStart]) - cursor = cutEnd - if (cursor >= baseEnd) break - } - if (cursor < baseEnd) result.push([cursor, baseEnd]) - } - return result -} - -/** - * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and, - * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the - * segment. The segment is split at every crossing with a polygon edge and - * each sub-interval is classified by its midpoint, so no test point ever - * sits on a crossing. - */ -function segmentInsideIntervals( - ax: number, - az: number, - bx: number, - bz: number, - polygon: Array<[number, number]>, - includeBoundary: boolean, -): LengthInterval[] { - const dx = bx - ax - const dz = bz - az - const length = Math.hypot(dx, dz) - if (length < 1e-9) return [] - - const ts = [0, 1] - const n = polygon.length - for (let i = 0; i < n; i++) { - const [px, pz] = polygon[i]! - const [qx, qz] = polygon[(i + 1) % n]! - const ex = qx - px - const ez = qz - pz - const denom = dx * ez - dz * ex - if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at - const t = ((px - ax) * ez - (pz - az) * ex) / denom - const s = ((px - ax) * dz - (pz - az) * dx) / denom - if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t) - } - ts.sort((a, b) => a - b) - - const inside: LengthInterval[] = [] - for (let i = 1; i < ts.length; i++) { - const t0 = ts[i - 1]! - const t1 = ts[i]! - if (t1 - t0 < 1e-9) continue - const tm = (t0 + t1) / 2 - const mx = ax + dx * tm - const mz = az + dz * tm - const midpointInside = pointOnPolygonBoundary(mx, mz, polygon) - ? includeBoundary - : pointInPolygon(mx, mz, polygon) - if (midpointInside) inside.push([t0, t1]) - } - return inside -} - -function polylineLength(points: Array<{ x: number; y: number }>): number { - let total = 0 - for (let i = 1; i < points.length; i++) { - total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y) - } - return total -} - -/** - * Inside sub-intervals of a polyline against a polygon, in cumulative - * arc-length units from the polyline start (merged, disjoint). Boundary - * contact counts as inside for slab support (walls sit exactly on slab - * edges — see ON_BOUNDARY_EPSILON above); hole callers pass - * `includeBoundary: false` so a wall running along a stairwell hole's - * rim keeps the rim's support. - */ -function polylineInsideIntervals( - points: Array<{ x: number; y: number }>, - polygon: Array<[number, number]>, - includeBoundary = true, -): LengthInterval[] { - const intervals: LengthInterval[] = [] - let offset = 0 - for (let i = 1; i < points.length; i++) { - const a = points[i - 1]! - const b = points[i]! - const segmentLength = Math.hypot(b.x - a.x, b.y - a.y) - if (segmentLength < 1e-9) continue - for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) { - intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength]) - } - offset += segmentLength - } - return mergeIntervals(intervals) -} - -function polylineInsideLength( - points: Array<{ x: number; y: number }>, - polygon: Array<[number, number]>, -): number { - return intervalsLength(polylineInsideIntervals(points, polygon)) -} - -export type WallOverlapInput = { - start: [number, number] - end: [number, number] - curveOffset?: number - thickness?: number -} - -// Minimum length of wall that must lie on/inside a slab polygon before the -// wall counts as overlapping it. Point contact (a perpendicular wall butting -// into a room's edge) clips to ~zero length and never reaches this, so such -// walls don't follow the slab's elevation. -const WALL_SLAB_MIN_OVERLAP = 0.05 - -/** - * Centerline of the wall plus its two face lines (centerline offset by - * ±halfThickness). The face lines catch walls whose centerline sits on or - * just outside the slab boundary but whose body reaches onto the slab — - * e.g. slab polygons drawn to the room's interior faces. - */ -function wallTestPolylines( - start: [number, number], - end: [number, number], - curveOffset: number, - halfThickness: number, -): Array> { - const wallLike = { start, end, curveOffset } - if (curveOffset !== 0 && isCurvedWall(wallLike)) { - const count = 16 - const center: Array<{ x: number; y: number }> = [] - const left: Array<{ x: number; y: number }> = [] - const right: Array<{ x: number; y: number }> = [] - for (let i = 0; i <= count; i++) { - const frame = getWallCurveFrameAt(wallLike, i / count) - center.push(frame.point) - left.push({ - x: frame.point.x + frame.normal.x * halfThickness, - y: frame.point.y + frame.normal.y * halfThickness, - }) - right.push({ - x: frame.point.x - frame.normal.x * halfThickness, - y: frame.point.y - frame.normal.y * halfThickness, - }) - } - return halfThickness > 0 ? [center, left, right] : [center] - } - - const center = [ - { x: start[0], y: start[1] }, - { x: end[0], y: end[1] }, - ] - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const len = Math.hypot(dx, dz) - if (len < 1e-10 || halfThickness <= 0) return [center] - const nx = (-dz / len) * halfThickness - const nz = (dx / len) * halfThickness - return [ - center, - [ - { x: start[0] + nx, y: start[1] + nz }, - { x: end[0] + nx, y: end[1] + nz }, - ], - [ - { x: start[0] - nx, y: start[1] - nz }, - { x: end[0] - nx, y: end[1] - nz }, - ], - ] -} - -/** - * Test whether a wall overlaps a slab polygon along a segment of its length. - * - * The wall's centerline and both face lines are clipped against the polygon; - * the wall overlaps when the longest clipped inside-or-on-boundary length - * exceeds a threshold (5cm, halved for very short walls). Because interval - * midpoints classify "on the boundary" as inside explicitly (never by - * ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves - * identically on every side of the slab. - * - * A wall that only touches the polygon at a point — a perpendicular wall - * butting into a room's edge, or a corner-to-corner touch — clips to ~zero - * length and does NOT overlap. - */ -export function wallOverlapsPolygon( - startOrWall: [number, number] | WallOverlapInput, - endOrPolygon: [number, number] | Array<[number, number]>, - polygonArg?: Array<[number, number]>, -): boolean { - // Two call shapes: - // wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware - // wallOverlapsPolygon(start, end, polygon) — legacy chord-only - let start: [number, number] - let end: [number, number] - let polygon: Array<[number, number]> - let curveOffset = 0 - let thickness = DEFAULT_WALL_THICKNESS - if (Array.isArray(startOrWall)) { - start = startOrWall as [number, number] - end = endOrPolygon as [number, number] - polygon = polygonArg as Array<[number, number]> - } else { - start = startOrWall.start - end = startOrWall.end - curveOffset = startOrWall.curveOffset ?? 0 - thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS - polygon = endOrPolygon as Array<[number, number]> - } - const halfThickness = Math.max(thickness / 2, 0) - - const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) - const centerLength = polylineLength(polylines[0]!) - if (centerLength < 1e-9) return false - - let overlap = 0 - for (const line of polylines) { - overlap = Math.max(overlap, polylineInsideLength(line, polygon)) - } - const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5)) - return overlap >= threshold -} - -// A slab elevation must support at least this fraction of the wall's -// length before it can dictate the wall's base. Below majority, a raised -// slab reaching one endpoint would hoist the whole wall off the floor -// that actually carries it. -const WALL_SLAB_SUPPORT_MAJORITY = 0.5 - -// Slabs whose elevations differ by less than this pool their support: -// a wall shared between two rooms' slabs is covered roughly half by -// each, and must still follow their common elevation. -const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 - -/** - * Base elevation for a wall, decided by which slabs actually SUPPORT it. - * - * Support is measured as covered length: the wall's centerline and face - * lines are clipped against each slab's RENDERED footprint - * (`getRenderableSlabPolygon` with the level walls + siblings, not the - * stored polygon — legacy polygons stored at wall faces or with old - * baked offsets fall short of the wall body, but their band-adopted - * rendered edge reaches the wall's outer face) minus the slab's stored - * holes (holes are data, never render-offset). A slab supporting less - * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point - * contact, endpoint grazes). - * - * Same-elevation slabs pool their coverage. `elevation` preserves the - * existing wall-relative origin: the highest elevation covering at - * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered - * elevation when none reaches majority. `baseElevation` only fills down - * where a lower support remains exposed on a wall face after higher, - * overlapping support is accounted for. Coincident floor/platform slabs - * therefore keep the wall on the platform, while slabs on opposite wall - * sides bridge correctly. A slab touching only one endpoint never enters - * either result. Pure; - * exported for tests. - */ -export type WallSlabSupport = { - /** Existing wall-relative floor elevation used by hosted children and wall height. */ - elevation: number - /** Lowest exposed adjacent support; wall geometry fills down to this elevation. */ - baseElevation: number - /** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */ - baseSegments: WallSlabSupportSegment[] -} - -export type WallSlabSupportSegment = { - start: number - end: number +/** One slab overlapping a queried footprint, as seen by support election. */ +export type SlabSupportCandidate = { + slabId: string elevation: number } -export function computeWallSlabSupport( - wallLike: WallOverlapInput, - slabs: readonly SlabNode[], - levelWalls: WallNode[], -): WallSlabSupport { - const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike - const halfThickness = Math.max(thickness / 2, 0) - const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) - const polylineLengths = polylines.map(polylineLength) - const wallLength = polylineLengths[0]! - if (wallLength < 1e-9) { - return { elevation: 0, baseElevation: 0, baseSegments: [] } - } - - const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5)) - - type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] } - const groups: ElevationGroup[] = [] - - for (const slab of slabs) { - if (slab.polygon.length < 3) continue - const renderedPolygon = getRenderableSlabPolygon(slab, { - walls: levelWalls, - siblingSlabs: slabs.filter((other) => other.id !== slab.id), - }) - - let supported = 0 - const perPolyline = polylines.map((line) => { - let intervals = polylineInsideIntervals(line, renderedPolygon) - for (const hole of slab.holes || []) { - if (intervals.length === 0) break - if (hole.length < 3) continue - intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) - } - supported = Math.max(supported, intervalsLength(intervals)) - return intervals - }) - if (supported < minSupport) continue - - const elevation = slab.elevation ?? 0.05 - let group = groups.find( - (candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, - ) - if (!group) { - group = { elevation, perPolyline: polylines.map(() => []) } - groups.push(group) - } - for (let i = 0; i < perPolyline.length; i++) { - group.perPolyline[i]!.push(...perPolyline[i]!) - } - } - - type EvaluatedGroup = ElevationGroup & { - coverage: number - mergedPerPolyline: LengthInterval[][] - } - const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { - let coverage = 0 - const mergedPerPolyline = group.perPolyline.map(mergeIntervals) - for (let i = 0; i < group.perPolyline.length; i++) { - const lineLength = polylineLengths[i]! - if (lineLength < 1e-9) continue - coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) - } - return { ...group, coverage, mergedPerPolyline } - }) - - let majorityElevation = Number.NEGATIVE_INFINITY - let bestElevation = Number.NEGATIVE_INFINITY - let bestCoverage = -1 - for (const group of evaluatedGroups) { - if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { - majorityElevation = Math.max(majorityElevation, group.elevation) - } - if ( - group.coverage > bestCoverage + 1e-6 || - (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) - ) { - bestCoverage = group.coverage - bestElevation = group.elevation - } - } - - const elevation = - majorityElevation !== Number.NEGATIVE_INFINITY - ? majorityElevation - : bestElevation === Number.NEGATIVE_INFINITY - ? 0 - : bestElevation - const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { - const lineLength = polylineLengths[polylineIndex]! - if (lineLength < 1e-9) return [] - return group.mergedPerPolyline[polylineIndex]!.map( - ([intervalStart, intervalEnd]) => - [intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval, - ) - } - - const normalizedByGroup = evaluatedGroups.map((group) => ({ - elevation: group.elevation, - perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)), - })) - const breakpoints = [0, 1] - for (const group of normalizedByGroup) { - for (const intervals of group.perPolyline) { - for (const [intervalStart, intervalEnd] of intervals) { - breakpoints.push(intervalStart, intervalEnd) - } - } - } - breakpoints.sort((left, right) => left - right) - const uniqueBreakpoints = breakpoints.filter( - (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, - ) - - const highestAt = (polylineIndex: number, t: number) => { - let highest = Number.NEGATIVE_INFINITY - for (const group of normalizedByGroup) { - if ( - group.perPolyline[polylineIndex]?.some( - ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, - ) - ) { - highest = Math.max(highest, group.elevation) - } - } - return highest - } - - const baseSegments: WallSlabSupportSegment[] = [] - for (let index = 1; index < uniqueBreakpoints.length; index++) { - const start = uniqueBreakpoints[index - 1]! - const end = uniqueBreakpoints[index]! - if (end - start < 1e-7) continue - const midpoint = (start + end) / 2 - const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY - const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY - const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) - const segmentElevation = - faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) - const previous = baseSegments[baseSegments.length - 1] - if ( - previous && - Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON - ) { - previous.end = end - } else { - baseSegments.push({ start, end, elevation: segmentElevation }) - } - } - - if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) - const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) - return { elevation, baseElevation, baseSegments } +export type ItemSlabSupport = { + elevation: number + /** The winning slab, or null when no slab overlaps the footprint. */ + slabId: string | null } -export function computeWallSlabElevation( - wallLike: WallOverlapInput, - slabs: readonly SlabNode[], - levelWalls: WallNode[], -): number { - return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation +export type PointedSupportSurface = ItemSlabSupport & { + /** + * Level-local XZ where the ray meets the pointed surface's plane, or + * null when the ray never reaches it (grazing / aimed above the base). + * This is the plan point the pointer actually indicates: unlike a grid + * event-plane hit — whose XZ shifts with whatever height the event + * plane currently rides at — it depends only on the ray and the + * aimed-at surface, so election/preview at this point cannot flip when + * the event plane changes storey. + */ + point: [number, number] | null } export class SpatialGridManager { @@ -842,7 +358,24 @@ export class SpatialGridManager { private getWallHeight(wallId: string): number { const wall = this.walls.get(wallId) - return wall?.height ?? 2.5 // Default wall height + if (!wall) return 0 + if (wall.height != null) return wall.height + + const nodes = useScene.getState().nodes + const levelId = resolveNodeLevelId(wall, nodes) + const support = this.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + ) + return resolveWallEffectiveHeight( + wall, + getWallPlaneTop(wall, levelId, nodes), + support.elevation, + ) } private getCeilingGrid(ceilingId: string): SpatialGrid { @@ -859,15 +392,74 @@ export class SpatialGridManager { return this.slabsByLevel.get(levelId)! } + /** + * Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item + * support queries run per frame and the projection scans the level's + * walls + sibling slabs, so the result is cached per slab id and + * dropped for the whole level whenever a slab or wall on that level + * flows through the manager's create/update/delete handlers. + */ + private readonly renderedSlabPolygons = new Map>() + + private invalidateRenderedSlabPolygons(levelId: string) { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return + for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) + } + + private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { + const cached = this.renderedSlabPolygons.get(slab.id) + if (cached) return cached + + const siblingSlabs: SlabNode[] = [] + for (const other of this.getSlabMap(levelId).values()) { + if (other.id !== slab.id) siblingSlabs.push(other) + } + const polygon = getRenderableSlabPolygon(slab, { + walls: this.getLevelWallNodes(levelId), + siblingSlabs, + }) + this.renderedSlabPolygons.set(slab.id, polygon) + return polygon + } + + /** + * Support test shared by election, candidate listing, and persisted-host + * validation: the footprint overlaps the slab's RENDERED polygon (what + * users see — matching the wall election in `computeWallSlabSupport`), + * with the center-point hole veto kept against the stored holes (holes + * are data, never render-offset). + */ + private slabSupportsFootprint( + levelId: string, + slab: SlabNode, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): boolean { + if (slab.polygon.length < 3) return false + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false + + const [cx, , cz] = position + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false + } + return true + } + // Called when nodes change handleNodeCreated(node: AnyNode, levelId: string) { if (node.type === 'slab') { this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'ceiling') { this.ceilings.set(node.id, node as CeilingNode) } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) + // Rendered slab polygons adopt wall bands — a new wall can extend them. + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'item') { const item = node as ItemNode if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { @@ -920,11 +512,13 @@ export class SpatialGridManager { handleNodeUpdated(node: AnyNode, levelId: string) { if (node.type === 'slab') { this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'ceiling') { this.ceilings.set(node.id, node as CeilingNode) } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'item') { const item = node as ItemNode if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { @@ -982,12 +576,16 @@ export class SpatialGridManager { handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { if (nodeType === 'slab') { + // Invalidate before removal so the deleted slab's own cache entry + // (still keyed in the level map here) is dropped with its siblings'. + this.invalidateRenderedSlabPolygons(levelId) this.getSlabMap(levelId).delete(nodeId) } else if (nodeType === 'ceiling') { this.ceilings.delete(nodeId) this.ceilingGrids.delete(nodeId) } else if (nodeType === 'wall') { this.walls.delete(nodeId) + this.invalidateRenderedSlabPolygons(levelId) // Remove all items attached to this wall from the spatial grid const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) return removedItemIds // Caller can use this to delete the items from scene @@ -1201,45 +799,162 @@ export class SpatialGridManager { /** * Get the slab elevation for an item using its full footprint (bounding box). - * Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes). - * Returns the highest overlapping slab elevation, or 0 if none. + * Thin wrapper over {@link getSlabSupportForItem} for callers (and tests) + * that only need the number. */ getSlabElevationForItem( levelId: string, position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number], + maxElevation?: number | null, ): number { - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return 0 + return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation) + .elevation + } - let maxElevation = Number.NEGATIVE_INFINITY + /** + * Elect the supporting slab for a footprint: the highest-elevation slab + * whose RENDERED polygon the footprint overlaps (center-point hole veto + * applies). Returns `{ elevation: 0, slabId: null }` when nothing + * overlaps. + * + * `maxElevation` is the pointer-decided cap: when set, only slabs whose + * walking surface sits at or below `maxElevation + + * SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface + * the cursor ray actually hit never captures the election. + */ + getSlabSupportForItem( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + maxElevation?: number | null, + ): ItemSlabSupport { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return { elevation: 0, slabId: null } + + let winningElevation = Number.NEGATIVE_INFINITY + let winnerId: string | null = null for (const slab of slabMap.values()) { - if ( - slab.polygon.length >= 3 && - itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01) - ) { - // Check if item is entirely within a hole (if so, ignore this slab) - // We consider it entirely in a hole if the item center is in the hole + const elevation = slab.elevation ?? 0.05 + if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + if (elevation > winningElevation) { + winningElevation = elevation + winnerId = slab.id + } + } + return winnerId === null + ? { elevation: 0, slabId: null } + : { elevation: winningElevation, slabId: winnerId } + } + + /** + * The walking surface the pointer actually points at: the nearest slab + * plane the ray crosses INSIDE that slab's rendered polygon (hole veto + * applies), or the level base (`elevation: 0, slabId: null`) when it + * crosses none. Ray origin/direction are level-local. Deliberately a + * point test, not a footprint test — it answers "which surface is under + * the cursor", which then caps the footprint election so a deck hanging + * above the aimed-at floor never lifts the placement. `point` is the + * ray's crossing of that surface's plane — the stable plan point + * callers should elect/preview at (see {@link PointedSupportSurface}). + */ + getPointedSupportSurface( + levelId: string, + rayOrigin: [number, number, number], + rayDirection: [number, number, number], + ): PointedSupportSurface { + const slabMap = this.slabsByLevel.get(levelId) + const [ox, oy, oz] = rayOrigin + const [dx, dy, dz] = rayDirection + if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null } + + let best: { t: number; elevation: number; slabId: string } | null = null + if (slabMap) { + for (const slab of slabMap.values()) { + if (slab.polygon.length < 3) continue + const elevation = slab.elevation ?? 0.05 + const t = (elevation - oy) / dy + if (t <= 0) continue + if (best && t >= best.t) continue + const x = ox + dx * t + const z = oz + dz * t + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue let inHole = false - const [cx, , cz] = position - const holes = slab.holes || [] - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) { + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(x, z, hole)) { inHole = true break } } - - if (!inHole) { - const elevation = slab.elevation ?? 0.05 - if (elevation > maxElevation) { - maxElevation = elevation - } - } + if (inHole) continue + best = { t, elevation, slabId: slab.id } } } - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + if (best) { + return { + elevation: best.elevation, + slabId: best.slabId, + point: [ox + dx * best.t, oz + dz * best.t], + } + } + const tBase = -oy / dy + return { + elevation: 0, + slabId: null, + point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null, + } + } + + /** + * All slabs supporting a footprint, one entry per overlapping slab + * (highest elevation first; slab id breaks ties deterministically). + * Commit-side ambiguity check: persist a `supportSlabId` only when the + * candidates carry ≥ 2 distinct elevations. + */ + getSupportCandidatesForFootprint( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): SlabSupportCandidate[] { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return [] + + const candidates: SlabSupportCandidate[] = [] + for (const slab of slabMap.values()) { + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) + } + candidates.sort( + (a, b) => + b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0), + ) + return candidates + } + + /** + * Elevation of a persisted support host for a footprint, or null when + * the slab no longer exists on the level or no longer overlaps the + * footprint (same overlap test as election). Deliberately read-only: a + * host reshaped away is NOT cleared — callers fall back to election and + * the stale reference resumes hosting if the slab's polygon returns. + * Slab deletion is the only writer (`deleteNodesAction` strips it). + */ + getHostSlabElevationForFootprint( + levelId: string, + slabId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): number | null { + const slab = this.slabsByLevel.get(levelId)?.get(slabId) + if (!slab) return null + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null + return slab.elevation ?? 0.05 } /** @@ -1255,8 +970,10 @@ export class SpatialGridManager { end: [number, number], curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, ): number { - return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation + return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId) + .elevation } getSlabSupportForWall( @@ -1265,11 +982,14 @@ export class SpatialGridManager { end: [number, number], curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, + maxElevation?: number | null, ): WallSlabSupport { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) { return { elevation: 0, + electedSlabId: null, baseElevation: 0, baseSegments: [{ start: 0, end: 1, elevation: 0 }], } @@ -1279,6 +999,8 @@ export class SpatialGridManager { { start, end, curveOffset, thickness }, [...slabMap.values()], this.getLevelWallNodes(levelId), + preferredSlabId, + maxElevation, ) } @@ -1387,6 +1109,7 @@ export class SpatialGridManager { } clearLevel(levelId: string) { + this.invalidateRenderedSlabPolygons(levelId) this.floorGrids.delete(levelId) this.wallGrids.delete(levelId) this.slabsByLevel.delete(levelId) @@ -1400,8 +1123,33 @@ export class SpatialGridManager { this.ceilingGrids.clear() this.ceilings.clear() this.itemCeilingMap.clear() + this.renderedSlabPolygons.clear() } } // Singleton instance export const spatialGridManager = new SpatialGridManager() + +/** + * Effective (extruded) height of a wall resolved from a nodes record: + * {@link resolveWallEffectiveHeight} over the covering-clamped plane top + * (`getWallPlaneTop`) and the singleton manager's slab election — so the + * value always agrees with the rendered wall. One shared resolver for the + * editor overlays (measurement label, action menu, side handles) that used + * to copy this derivation locally. + */ +export function getWallEffectiveHeightForNodes( + wall: WallNode, + nodes: Record, +): number { + const levelId = resolveNodeLevelId(wall, nodes) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + ) + return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), support.elevation) +} diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts new file mode 100644 index 00000000..280db599 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../../schema' +import useScene, { clearSceneHistory } from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' +import { + initSpatialGridSync, + markCoveringDependentsBelow, + markLevelHeightDependents, +} from './spatial-grid-sync' + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +function makeLevel(id: string, ordinal: number, height: number, children: string[]): AnyNode { + return { + id, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: ordinal, + height, + } as AnyNode +} + +function makeChild(id: string, type: string, parentId: string): AnyNode { + return { + id, + type, + object: 'node', + parentId, + visible: true, + metadata: {}, + children: [], + start: [0, 1], + end: [4, 1], + thickness: 0.1, + polygon: SQUARE, + holes: [], + } as unknown as AnyNode +} + +function makeSlab(id: string, parentId: string, overrides: Partial = {}): AnyNode { + return { + id, + type: 'slab', + object: 'node', + parentId, + visible: true, + metadata: {}, + children: [], + polygon: SQUARE, + holes: [], + holeMetadata: [], + elevation: 0.05, + thickness: 0.05, + autoFromWalls: false, + ...overrides, + } as AnyNode +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +function dirtyIds(): string[] { + return [...useScene.getState().dirtyNodes].sort() +} + +describe('spatial-grid sync dirty rules (vertical model)', () => { + let stopSync = () => {} + + // Two orphan levels sharing the legacy stack: level_0 (below) carries a + // wall, ceiling, stair, fence, and zone; level_1 (above) carries a slab. + const wall = makeChild('wall_a', 'wall', 'level_0') + const ceiling = makeChild('ceiling_a', 'ceiling', 'level_0') + const stair = makeChild('stair_a', 'stair', 'level_0') + const fence = makeChild('fence_a', 'fence', 'level_0') + const zone = makeChild('zone_a', 'zone', 'level_0') + const upperSlab = makeSlab('slab_up', 'level_1', { elevation: 0, thickness: 0.3 }) + const level0 = makeLevel('level_0', 0, 2.5, [ + 'wall_a', + 'ceiling_a', + 'stair_a', + 'fence_a', + 'zone_a', + ]) + const level1 = makeLevel('level_1', 1, 2.5, ['slab_up']) + + function setScene(nodes: Record) { + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes, + readOnly: false, + rootNodeIds: ['level_0', 'level_1'] as AnyNodeId[], + } as never) + clearSceneHistory() + } + + beforeEach(() => { + spatialGridManager.clear() + setScene(nodesFor(level0, level1, wall, ceiling, stair, fence, zone, upperSlab)) + stopSync = initSpatialGridSync() + useScene.setState({ dirtyNodes: new Set() }) + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + test('changing a level height marks its wall/stair/ceiling/fence children dirty', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + level_0: { ...level0, height: 3 } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a']) + }) + + test('a slab thickness change marks the walls and ceilings of the level below', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_up: { ...upperSlab, thickness: 0.5 } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('a slab recessed toggle marks the walls and ceilings of the level below', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_up: { ...upperSlab, recessed: true } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('creating a slab on the level above marks the level below, deleting it too', () => { + const added = makeSlab('slab_new', 'level_1', { elevation: 0, thickness: 0.2 }) + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_new: added, + level_1: { ...level1, children: ['slab_up', 'slab_new'] } as AnyNode, + } as never, + }) + expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true) + + useScene.setState({ dirtyNodes: new Set() }) + const { slab_new: _gone, ...rest } = useScene.getState().nodes as Record + useScene.setState({ + nodes: { ...rest, level_1: { ...level1, children: ['slab_up'] } as AnyNode } as never, + }) + expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true) + }) +}) + +describe('spatial-grid sync dirty rules (deck-attached stairs)', () => { + let stopSync = () => {} + + const deck = makeSlab('slab_deck', 'level_0', { elevation: 1.25, thickness: 0.05 }) + const attachedStair = { + ...makeChild('stair_deck', 'stair', 'level_0'), + deckSlabId: 'slab_deck', + } as AnyNode + const otherStair = makeChild('stair_other', 'stair', 'level_0') + const deckLevel = makeLevel('level_0', 0, 2.5, ['slab_deck', 'stair_deck', 'stair_other']) + + beforeEach(() => { + spatialGridManager.clear() + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes: nodesFor(deckLevel, deck, attachedStair, otherStair), + readOnly: false, + rootNodeIds: ['level_0'] as AnyNodeId[], + } as never) + clearSceneHistory() + stopSync = initSpatialGridSync() + useScene.setState({ dirtyNodes: new Set() }) + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + test('changing a deck elevation marks its attached stair dirty, not other stairs', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_deck: { ...deck, elevation: 1.6 } as AnyNode, + } as never, + }) + + expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('stair_other' as AnyNodeId)).toBe(false) + }) + + test('a deck polygon-only change leaves the attached stair alone', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_deck: { + ...deck, + polygon: [ + [0, 0], + [5, 0], + [5, 5], + [0, 5], + ], + } as AnyNode, + } as never, + }) + + expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(false) + }) +}) + +describe('sync dirty helpers (pure)', () => { + const collect = () => { + const marked: string[] = [] + return { marked, markDirty: (id: AnyNodeId) => marked.push(id) } + } + + test('markLevelHeightDependents marks only wall/stair/ceiling/fence children', () => { + const level = makeLevel('level_0', 0, 2.5, [ + 'wall_a', + 'stair_a', + 'ceiling_a', + 'fence_a', + 'zone_a', + 'missing', + ]) + const nodes = nodesFor( + level, + makeChild('wall_a', 'wall', 'level_0'), + makeChild('stair_a', 'stair', 'level_0'), + makeChild('ceiling_a', 'ceiling', 'level_0'), + makeChild('fence_a', 'fence', 'level_0'), + makeChild('zone_a', 'zone', 'level_0'), + ) + + const { marked, markDirty } = collect() + markLevelHeightDependents(level as never, nodes, markDirty) + expect(marked.sort()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a']) + }) + + test('markCoveringDependentsBelow marks walls and ceilings of the level below only', () => { + const nodes = nodesFor( + makeLevel('level_0', 0, 2.5, ['wall_a', 'ceiling_a', 'zone_a']), + makeLevel('level_1', 1, 2.5, []), + makeChild('wall_a', 'wall', 'level_0'), + makeChild('ceiling_a', 'ceiling', 'level_0'), + makeChild('zone_a', 'zone', 'level_0'), + ) + + const { marked, markDirty } = collect() + markCoveringDependentsBelow('level_1', nodes, markDirty) + expect(marked.sort()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('markCoveringDependentsBelow is a no-op for the lowest level', () => { + const nodes = nodesFor( + makeLevel('level_0', 0, 2.5, ['wall_a']), + makeChild('wall_a', 'wall', 'level_0'), + ) + + const { marked, markDirty } = collect() + markCoveringDependentsBelow('level_0', nodes, markDirty) + expect(marked).toEqual([]) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 8f37156e..886d5970 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,6 +1,7 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' -import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' +import type { AnyNode, AnyNodeId, LevelNode, SlabNode, WallNode } from '../../schema' +import { getLevelBelow } from '../../services/storey' import useScene from '../../store/use-scene' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { @@ -116,6 +117,7 @@ export function initSpatialGridSync(): () => void { // When a slab is added, mark overlapping items/walls dirty if (node.type === 'slab') { markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) } } } @@ -129,6 +131,7 @@ export function initSpatialGridSync(): () => void { // When a slab is removed, mark items/walls that were on it dirty (using current state) if (node.type === 'slab') { markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) } } } @@ -156,11 +159,11 @@ export function initSpatialGridSync(): () => void { } } } else if (node.type === 'slab' && prev.type === 'slab') { - if ( + const supportChanged = node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes - ) { + if (supportChanged) { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeUpdated(node, levelId) @@ -168,6 +171,35 @@ export function initSpatialGridSync(): () => void { markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty) markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) } + if (node.elevation !== prev.elevation) { + markDeckAttachedStairs(node.id, state.nodes, markDirty) + } + // The covering bound over the level below also moves with thickness + // (underside = elevation − thickness) and recessed (pools never + // cover), which same-level support ignores. + if ( + supportChanged || + node.thickness !== prev.thickness || + node.recessed !== prev.recessed + ) { + markCoveringDependentsBelow(resolveLevelId(node, state.nodes), state.nodes, markDirty) + } + } else if (node.type === 'level' && prev.type === 'level') { + if (node.height !== prev.height) { + markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) + } + } else if (node.type === 'wall' && prev.type === 'wall') { + if ( + node.start !== prev.start || + node.end !== prev.end || + node.curveOffset !== prev.curveOffset || + node.thickness !== prev.thickness + ) { + // Rendered slab polygons adopt wall bands, so a wall reshape + // must reach the manager to refresh its wall map and drop the + // level's rendered-polygon cache. + spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes)) + } } } }) @@ -179,6 +211,68 @@ function arraysEqual(a: number[], b: number[]): boolean { return a.length === b.length && a.every((v, i) => v === b[i]) } +/** + * A level's stored height moved: plane-bound walls follow the new plane, + * stair rise re-derives, and ceilings/fences re-resolve their clamp — mark + * them all so their systems rebuild. Restacking the level containers alone + * leaves their geometry stale. + */ +export function markLevelHeightDependents( + level: LevelNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const childId of level.children) { + const child = nodes[childId] + if (!child) continue + if ( + child.type === 'wall' || + child.type === 'stair' || + child.type === 'ceiling' || + child.type === 'fence' + ) { + markDirty(child.id) + } + } +} + +/** + * A deck slab's walking surface moved: stairs attached to it via + * `deckSlabId` derive their rise from that elevation, so their geometry + * (and rise-derived affordances) must rebuild. + */ +export function markDeckAttachedStairs( + slabId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const node of Object.values(nodes)) { + if (node.type === 'stair' && node.deckSlabId === slabId) { + markDirty(node.id) + } + } +} + +/** + * A slab on `slabLevelId` was created/deleted or changed shape/placement: + * the covering bound (slab underside) over the level BELOW moved, so that + * level's plane-bound walls and clamped ceilings must rebuild. + */ +export function markCoveringDependentsBelow( + slabLevelId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const below = getLevelBelow(slabLevelId, nodes) + if (!below) return + for (const childId of below.children) { + const child = nodes[childId] + if (child?.type === 'wall' || child?.type === 'ceiling') { + markDirty(child.id) + } + } +} + /** * Mark all floor items and walls that may be affected by a slab change as dirty. */ @@ -190,10 +284,11 @@ function markNodesOverlappingSlab( if (slab.polygon.length < 3) return const slabLevelId = resolveLevelId(slab, nodes) - // Walls follow the slab's RENDERED footprint (band-adopted edges reach - // the wall's outer face), so the dirty gate must test the same polygon - // `getSlabElevationForWall` will re-evaluate — a stored polygon that - // stops short of the wall body would otherwise never re-elevate it. + // Walls AND floor-placed nodes follow the slab's RENDERED footprint + // (band-adopted edges reach the wall's outer face), so the dirty gate + // must test the same polygon the support queries re-evaluate — a stored + // polygon that stops short of the wall body would otherwise never + // re-elevate nodes sitting over the adopted band. const levelWalls: WallNode[] = [] const siblingSlabs: SlabNode[] = [] for (const node of Object.values(nodes)) { @@ -249,7 +344,7 @@ function markNodesOverlappingSlab( footprint.position ?? position, footprint.dimensions, footprint.rotation, - slab.polygon, + renderedPolygon, 0.01, ) ) { diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts new file mode 100644 index 00000000..588ca7ee --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -0,0 +1,217 @@ +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' +import { GROUND_SUPPORT_ID, getFloorPlacedFootprints } from './floor-placed-elevation' +import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager' + +export type SupportSlabPatch = { supportSlabId: string | undefined } + +export type SupportSlabPatchOptions = { + /** + * Pointer-decided support cap (level-local Y) — see + * `FloorPlacedElevationArgs.maxElevation`. When set, the persisted host + * reproduces the CAPPED election: the elected lower slab wins over a + * deck hanging above the cap, and `GROUND_SUPPORT_ID` is stored when the + * ground is elected while capped-out slabs still overlap the footprint. + */ + maxElevation?: number | null +} + +export function resolveSupportSlabPatch( + node: AnyNode, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) { + return { supportSlabId: undefined } + } + + const parentId = (node as { parentId?: AnyNodeId | null }).parentId ?? null + const parent = parentId ? nodes[parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + const maxElevation = options?.maxElevation + const footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes }) + const candidateElevations = new Set() + let winner: { slabId: string; elevation: number } | null = null + let cappedOut = false + + for (const footprint of footprints) { + const position = footprint.position ?? (node as { position?: unknown }).position + if (!Array.isArray(position) || position.length !== 3) continue + const candidates = spatialGridManager.getSupportCandidatesForFootprint( + parent.id, + position as [number, number, number], + footprint.dimensions, + footprint.rotation, + ) + for (const candidate of candidates) candidateElevations.add(candidate.elevation) + + const support = spatialGridManager.getSlabSupportForItem( + parent.id, + position as [number, number, number], + footprint.dimensions, + footprint.rotation, + maxElevation, + ) + if (support.slabId && (!winner || support.elevation > winner.elevation)) { + winner = { slabId: support.slabId, elevation: support.elevation } + } + if (maxElevation != null && support.slabId === null && candidates.length > 0) { + cappedOut = true + } + } + + if (winner !== null) { + return { supportSlabId: candidateElevations.size >= 2 ? winner.slabId : undefined } + } + // Capped election chose the ground while overlapping slabs sit above the + // cap: persist the ground host, or the uncapped per-frame election would + // lift the committed node back onto the deck. + return { supportSlabId: cappedOut ? GROUND_SUPPORT_ID : undefined } +} + +export function resolveWallSupportSlabPatch( + wall: WallNode, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const parent = wall.parentId ? nodes[wall.parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + // Winner under the pointer cap (when given): a deck hanging above the + // aimed-at surface can't capture the elected base, so a wall drawn at the + // floor underneath it persists the floor slab the user actually targeted. + const support = spatialGridManager.getSlabSupportForWall( + parent.id, + wall.start, + wall.end, + wall.curveOffset, + wall.thickness, + null, + options?.maxElevation, + ) + const candidateElevations = new Set() + for (const node of Object.values(nodes)) { + if (node.type !== 'slab' || node.parentId !== parent.id) continue + const candidate = node as SlabNode + const preferred = spatialGridManager.getSlabSupportForWall( + parent.id, + wall.start, + wall.end, + wall.curveOffset, + wall.thickness, + candidate.id, + ) + if (preferred.electedSlabId === candidate.id) { + candidateElevations.add(candidate.elevation) + } + } + + return { + supportSlabId: candidateElevations.size >= 2 ? (support.electedSlabId ?? undefined) : undefined, + } +} + +/** Fence-like shape the fence host election needs — plain segment, arc, or spline. */ +export type FenceSupportInput = Pick< + FenceNode, + 'start' | 'end' | 'curveOffset' | 'path' | 'thickness' | 'parentId' +> + +/** Sample count for a curved (sagitta) fence centerline, matching the wall band test. */ +const FENCE_CURVE_SUPPORT_SAMPLES = 16 +/** Fallback fence thickness (schema default) when the node carries none. */ +const DEFAULT_FENCE_THICKNESS = 0.08 +/** Minimum band depth so the footprint survives the election's polygon inset. */ +const MIN_FENCE_SUPPORT_BAND = 0.05 + +function fenceCenterlinePoints(fence: FenceSupportInput): Array<[number, number]> { + if (fence.path && fence.path.length >= 2) { + return fence.path.map((point) => [point[0], point[1]]) + } + const wallLike = { start: fence.start, end: fence.end, curveOffset: fence.curveOffset ?? 0 } + if ((fence.curveOffset ?? 0) !== 0 && isCurvedWall(wallLike)) { + const points: Array<[number, number]> = [] + for (let i = 0; i <= FENCE_CURVE_SUPPORT_SAMPLES; i++) { + const frame = getWallCurveFrameAt(wallLike, i / FENCE_CURVE_SUPPORT_SAMPLES) + points.push([frame.point.x, frame.point.y]) + } + return points + } + return [ + [fence.start[0], fence.start[1]], + [fence.end[0], fence.end[1]], + ] +} + +/** + * Support-host patch for a fence: elect the slab the fence line stands on + * and persist it as `supportSlabId` (the fence lift resolves absent = + * level floor — see `packages/nodes/src/fence/lift.ts`). + * + * The centerline (chord, sampled arc, or spline path) is turned into thin + * band footprints and run through the same candidate machinery items use. + * `options.maxElevation` is the pointer-decided cap: aiming at the floor + * under a deck elects the floor, aiming at the deck top elects the deck. + * + * Persist rule: the items ambiguity rule (stacked candidates disagree) + * PLUS the elevated-host case — a winner sitting meaningfully above the + * level floor must be persisted even when unambiguous (a balcony deck with + * nothing underneath), or the commit loses the election entirely since + * fences run no per-frame election. A single default ground slab (its top + * within `SUPPORT_ELEVATION_EPSILON` of the floor) stays unpersisted so + * plain fences keep sitting at the level base. A capped-out election (all + * overlapping slabs above the aimed-at ground) also resolves to the floor + * via the same absent-host default. Pure; exported for tests. + */ +export function resolveFenceSupportSlabPatch( + fence: FenceSupportInput, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const parent = fence.parentId ? nodes[fence.parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + const maxElevation = options?.maxElevation + const band = Math.max(fence.thickness ?? DEFAULT_FENCE_THICKNESS, MIN_FENCE_SUPPORT_BAND) + const points = fenceCenterlinePoints(fence) + const candidateElevations = new Set() + let winner: { slabId: string; elevation: number } | null = null + + for (let i = 1; i < points.length; i++) { + const [ax, az] = points[i - 1]! + const [bx, bz] = points[i]! + const length = Math.hypot(bx - ax, bz - az) + if (length < 1e-6) continue + const position: [number, number, number] = [(ax + bx) / 2, 0, (az + bz) / 2] + const dimensions: [number, number, number] = [length, 1, band] + // getItemFootprint's rotation convention: local +X maps to + // (cos yRot, sin yRot) in XZ, so the segment angle aligns the band. + const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0] + + const candidates = spatialGridManager.getSupportCandidatesForFootprint( + parent.id, + position, + dimensions, + rotation, + ) + for (const candidate of candidates) candidateElevations.add(candidate.elevation) + + const support = spatialGridManager.getSlabSupportForItem( + parent.id, + position, + dimensions, + rotation, + maxElevation, + ) + if (support.slabId && (!winner || support.elevation > winner.elevation)) { + winner = { slabId: support.slabId, elevation: support.elevation } + } + } + + if (winner === null) return { supportSlabId: undefined } + const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON + return { supportSlabId: persist ? winner.slabId : undefined } +} diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts new file mode 100644 index 00000000..88ed9453 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts @@ -0,0 +1,628 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, AnyNodeId, SlabNode } from '../../schema' +import { WallNode } from '../../schema' +import useScene, { clearSceneHistory } from '../../store/use-scene' +import { resolveWallEffectiveHeight, resolveWallTop } from '../../systems/wall/wall-top' +import { getFloorPlacedElevation } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' +import { initSpatialGridSync } from './spatial-grid-sync' +import { resolveSupportSlabPatch, resolveWallSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +const SQUARE: Array<[number, number]> = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], +] + +function makeDefinition( + kind: AnyNode['type'], + capabilities: AnyNodeDefinition['capabilities'] = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities, + } +} + +function registerFloorPlacedItem() { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) +} + +function makeLevel(children: string[] = []): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: 0, + } as AnyNode +} + +function makeFloorNode(overrides: Partial = {}): AnyNode { + return { + id: 'item_test', + type: 'item', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + asset: { + id: 'asset_test', + category: 'test', + name: 'Test', + thumbnail: '', + src: 'asset:test', + dimensions: [1, 1, 1], + source: 'library', + }, + ...overrides, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +describe('persisted support hosts (items)', () => { + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) + }) + + test('no-host election over stacked slabs keeps returning the highest elevation', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.8) + }) + + test('a persisted host wins over the election, whichever slab it names', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const hostedLow = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const hostedHigh = makeFloorNode({ supportSlabId: 'slab_high' } as Partial) + + expect( + getFloorPlacedElevation({ + node: hostedLow, + nodes: nodesFor(level, hostedLow), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.2) + expect( + getFloorPlacedElevation({ + node: hostedHigh, + nodes: nodesFor(level, hostedHigh), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.8) + }) + + test('a host reshaped away falls back without clearing the field, and resumes on return', () => { + registerFloorPlacedItem() + const host = makeSlab('slab_low', SQUARE, 0.2) + addSlab(host) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const args = { + node, + nodes: nodesFor(level, node), + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + } + + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2) + + // Reshape the host away from the item's footprint. + const movedAway: Array<[number, number]> = [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ] + spatialGridManager.handleNodeUpdated(makeSlab('slab_low', movedAway, 0.2) as AnyNode, LEVEL_ID) + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.8) + expect((node as { supportSlabId?: string }).supportSlabId).toBe('slab_low') + + // Reshape it back — the stale reference resumes hosting. + spatialGridManager.handleNodeUpdated(host as AnyNode, LEVEL_ID) + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2) + }) + + test('getSlabSupportForItem surfaces the winning slab id', () => { + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: 0.8, slabId: 'slab_high' }) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [20, 0, 20], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: 0, slabId: null }) + }) + + test('getSupportCandidatesForFootprint lists distinct overlapping slabs, highest first', () => { + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + addSlab( + makeSlab( + 'slab_far', + [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + 0.5, + ), + ) + + expect( + spatialGridManager.getSupportCandidatesForFootprint( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + ), + ).toEqual([ + { slabId: 'slab_high', elevation: 0.8 }, + { slabId: 'slab_low', elevation: 0.2 }, + ]) + expect( + spatialGridManager.getSupportCandidatesForFootprint( + LEVEL_ID, + [20, 0, 20], + [1, 1, 1], + [0, 0, 0], + ), + ).toEqual([]) + }) + + test('resolveSupportSlabPatch persists only an ambiguous stacked-slab winner', () => { + registerFloorPlacedItem() + const low = makeSlab('slab_low', SQUARE, 0.2) + const high = makeSlab('slab_high', SQUARE, 0.8) + addSlab(low) + addSlab(high) + + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node, low as AnyNode, high as AnyNode) + expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_high' }) + + spatialGridManager.handleNodeDeleted(high.id, 'slab', LEVEL_ID) + expect(resolveSupportSlabPatch(node, nodesFor(level, node, low as AnyNode))).toEqual({ + supportSlabId: undefined, + }) + }) + + test('item support follows the RENDERED slab polygon (wall band adoption)', () => { + registerFloorPlacedItem() + + // Room slab drawn on the wall centerlines; the rendered polygon + // extends to the walls' outer faces (x/z ± 0.05 for 0.1-thick walls). + const roomPolygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [4, 0], end: [4, 3], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [4, 3], end: [0, 3], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [0, 3], end: [0, 0], thickness: 0.1, parentId: LEVEL_ID }), + ] + const level = makeLevel(walls.map((wall) => wall.id)) + const node = makeFloorNode() + useScene.setState({ nodes: nodesFor(level, node, ...(walls as AnyNode[])) }) + // Grounded raised floor (thickness = elevation): band adoption only + // applies to grounded slabs — a floating deck keeps its drawn polygon. + addSlab(makeSlab('slab_room', roomPolygon, 0.4, { thickness: 0.4 })) + + // Footprint fully outside the STORED polygon (x from 4.0 to 4.6 with a + // 0.01 overlap inset) but inside the rendered band edge at x = 4.05. + const elevation = spatialGridManager.getSlabElevationForItem( + LEVEL_ID, + [4.3, 0, 1.5], + [0.6, 1, 0.6], + [0, 0, 0], + ) + expect(elevation).toBeCloseTo(0.4) + + // The manager sees wall changes: removing the walls drops the adopted + // band, so the same footprint stops electing the slab. + for (const wall of walls) { + spatialGridManager.handleNodeDeleted(wall.id, 'wall', LEVEL_ID) + } + useScene.setState({ nodes: nodesFor(makeLevel(), node) }) + expect( + spatialGridManager.getSlabElevationForItem(LEVEL_ID, [4.3, 0, 1.5], [0.6, 1, 0.6], [0, 0, 0]), + ).toBe(0) + }) +}) + +describe('persisted support hosts (walls, via the manager)', () => { + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) + }) + + test('preferred slab pins the elected elevation; invalid preference falls back', () => { + const polygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + addSlab(makeSlab('slab_low', polygon, 0.1)) + addSlab(makeSlab('slab_high', polygon, 0.6)) + + const start: [number, number] = [0, 1.5] + const end: [number, number] = [4, 1.5] + + const elected = spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end) + expect(elected.elevation).toBeCloseTo(0.6) + expect(elected.electedSlabId).toBe('slab_high') + + const preferred = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + start, + end, + 0, + 0.1, + 'slab_low', + ) + expect(preferred.elevation).toBeCloseTo(0.1) + expect(preferred.electedSlabId).toBe('slab_low') + + const fallback = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + start, + end, + 0, + 0.1, + 'slab_missing', + ) + expect(fallback.elevation).toBeCloseTo(0.6) + expect(fallback.electedSlabId).toBe('slab_high') + }) + + test('resolveWallSupportSlabPatch persists the winner over two elevations', () => { + const low = makeSlab( + 'slab_low', + [ + [-2, -1], + [0, -1], + [0, 1], + [-2, 1], + ], + 0.2, + ) + const high = makeSlab( + 'slab_high', + [ + [0, -1], + [2, -1], + [2, 1], + [0, 1], + ], + 0.8, + ) + const wall = WallNode.parse({ + id: 'wall_test', + parentId: LEVEL_ID, + start: [-2, 0], + end: [2, 0], + thickness: 0.1, + }) + const level = makeLevel([low.id, high.id, wall.id]) + const nodes = nodesFor(level, low as AnyNode, high as AnyNode, wall as AnyNode) + useScene.setState({ nodes }) + addSlab(low) + addSlab(high) + + expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ + supportSlabId: 'slab_high', + }) + }) + + // Elevated deck stacked over a ground floor slab — the "wall on a deck" + // fixture (both slabs cover the wall band; the deck sits above). + const DECK_ELEVATION = 0.9 + const FLOOR_ELEVATION = 0.05 + function makeDeckOverFloorFixture() { + const deck = makeSlab( + 'slab_deck', + [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + DECK_ELEVATION, + ) + const ground = makeSlab( + 'slab_ground', + [ + [-6, -6], + [6, -6], + [6, 6], + [-6, 6], + ], + FLOOR_ELEVATION, + ) + const wall = WallNode.parse({ + id: 'wall_on_deck', + parentId: LEVEL_ID, + start: [0.5, 1.5], + end: [3.5, 1.5], + thickness: 0.1, + }) + const level = makeLevel([deck.id, ground.id, wall.id]) + const nodes = nodesFor(level, deck as AnyNode, ground as AnyNode, wall as AnyNode) + useScene.setState({ nodes }) + addSlab(deck) + addSlab(ground) + return { wall, nodes } + } + + test('a wall whose band lies over an elevated deck bases on the deck with a plane-bound top', () => { + const { wall, nodes } = makeDeckOverFloorFixture() + + const support = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + wall.start, + wall.end, + 0, + wall.thickness, + ) + expect(support.electedSlabId).toBe('slab_deck') + expect(support.elevation).toBeCloseTo(DECK_ELEVATION) + + // Wall-top inversion: no stored height → the top stays at the storey + // plane, so the extruded body is the plane minus the deck base. + const storeyHeight = 2.7 + expect(resolveWallTop(wall, storeyHeight, support.elevation)).toBeCloseTo(storeyHeight) + expect(resolveWallEffectiveHeight(wall, storeyHeight, support.elevation)).toBeCloseTo( + storeyHeight - DECK_ELEVATION, + ) + + // Commit persists the deck deterministically (two candidate elevations). + expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('pointer cap: aiming at the floor under the deck elects and persists the floor', () => { + const { wall, nodes } = makeDeckOverFloorFixture() + + const capped = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + wall.start, + wall.end, + 0, + wall.thickness, + null, + FLOOR_ELEVATION, + ) + expect(capped.electedSlabId).toBe('slab_ground') + expect(capped.elevation).toBeCloseTo(FLOOR_ELEVATION) + + expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: 'slab_ground', + }) + // Aiming at the deck top keeps the deck. + expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: 'slab_deck', + }) + }) +}) + +describe('deleteNodesAction strips supportSlabId references', () => { + let stopSync = () => {} + + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + registerFloorPlacedItem() + + const slabLow = makeSlab('slab_low', SQUARE, 0.2) + const slabHigh = makeSlab('slab_high', SQUARE, 0.8) + const item = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const level = makeLevel(['slab_low', 'slab_high', item.id]) + + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes: nodesFor(level, slabLow as AnyNode, slabHigh as AnyNode, item), + readOnly: false, + rootNodeIds: [LEVEL_ID as AnyNodeId], + } as never) + clearSceneHistory() + stopSync = initSpatialGridSync() + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + function itemElevation(): number { + const nodes = useScene.getState().nodes + const item = nodes['item_test' as AnyNodeId]! + return getFloorPlacedElevation({ + node: item, + nodes, + position: [0, 0, 0], + rotation: [0, 0, 0], + }) + } + + test('deleting the host slab clears the reference and re-elects; undo restores both', () => { + expect(itemElevation()).toBeCloseTo(0.2) + + useScene.getState().deleteNodes(['slab_low' as AnyNodeId]) + + const afterDelete = useScene.getState().nodes + expect(afterDelete['slab_low' as AnyNodeId]).toBeUndefined() + expect( + (afterDelete['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId, + ).toBeUndefined() + expect(itemElevation()).toBeCloseTo(0.8) + + useScene.temporal.getState().undo() + + const afterUndo = useScene.getState().nodes + expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined() + expect((afterUndo['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId).toBe( + 'slab_low', + ) + expect(itemElevation()).toBeCloseTo(0.2) + }) + + test('deleting a non-host slab leaves the reference alone', () => { + useScene.getState().deleteNodes(['slab_high' as AnyNodeId]) + + expect( + (useScene.getState().nodes['item_test' as AnyNodeId] as { supportSlabId?: string }) + .supportSlabId, + ).toBe('slab_low') + expect(itemElevation()).toBeCloseTo(0.2) + }) + + test('deleting the destination deck strips deckSlabId from stairs; undo restores it', () => { + const stair = { + id: 'stair_test', + type: 'stair', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + deckSlabId: 'slab_low', + } as unknown as AnyNode + + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + stair_test: stair, + [LEVEL_ID]: { + ...useScene.getState().nodes[LEVEL_ID as AnyNodeId]!, + children: ['slab_low', 'slab_high', 'item_test', 'stair_test'], + } as AnyNode, + } as never, + }) + clearSceneHistory() + + useScene.getState().deleteNodes(['slab_low' as AnyNodeId]) + + const afterDelete = useScene.getState().nodes + expect( + (afterDelete['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId, + ).toBeUndefined() + + useScene.temporal.getState().undo() + + const afterUndo = useScene.getState().nodes + expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined() + expect((afterUndo['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId).toBe( + 'slab_low', + ) + }) + + test('deleting a slab that is not the destination deck leaves deckSlabId alone', () => { + const stair = { + id: 'stair_test', + type: 'stair', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + deckSlabId: 'slab_low', + } as unknown as AnyNode + + useScene.setState({ + nodes: { ...useScene.getState().nodes, stair_test: stair } as never, + }) + + useScene.getState().deleteNodes(['slab_high' as AnyNodeId]) + + expect( + (useScene.getState().nodes['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId, + ).toBe('slab_low') + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts index b1da3b33..3a1b79fe 100644 --- a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts +++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts @@ -90,7 +90,7 @@ describe('computeWallSlabElevation', () => { parseWall([4, 4], [0, 4]), parseWall([0, 4], [0, 0]), ] - const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 }) + const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 }) const bottom = walls[0]! expect( @@ -102,6 +102,37 @@ describe('computeWallSlabElevation', () => { ).toBeCloseTo(0.1) }) + it('elects a floating deck for a wall standing on its drawn footprint', () => { + // Wall ON a deck: no band adoption needed — the wall body lies inside + // the deck's drawn polygon, which is exactly what a floating slab + // renders. + const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 }) + const wallOnDeck = parseWall([1, 2], [3, 2]) + + expect( + computeWallSlabElevation( + { start: [1, 2], end: [3, 2], thickness: 0.1 }, + [deck], + [wallOnDeck], + ), + ).toBeCloseTo(1.5) + }) + + it('a wall in the adoption band beside a floating deck does not stand on it', () => { + // Centerline 6cm below the deck's bottom edge — inside the adoption + // band (half-thickness + 0.06) but the body never reaches the drawn + // footprint. A grounded slab adopts the band and carries the wall; the + // deck keeps its drawn polygon and offers no support. + const bandWall = parseWall([0, -0.06], [4, -0.06]) + const wallLike = { start: bandWall.start, end: bandWall.end, thickness: bandWall.thickness } + + const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 }) + expect(computeWallSlabElevation(wallLike, [deck], [bandWall])).toBe(0) + + const grounded = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 }) + expect(computeWallSlabElevation(wallLike, [grounded], [bandWall])).toBeCloseTo(0.1) + }) + it('lifts a wall whose body a legacy stored polygon falls short of', () => { // Legacy hand-adjusted slab: edges 6cm inside the wall centerlines — // 1cm short of even the inner faces, so the STORED polygon never @@ -114,6 +145,8 @@ describe('computeWallSlabElevation', () => { parseWall([4, 4], [0, 4]), parseWall([0, 4], [0, 0]), ] + // Grounded (thickness = elevation): band adoption only applies to + // grounded room floors under the vertical model. const slab = SlabNode.parse({ polygon: [ [0.06, 0.06], @@ -122,6 +155,7 @@ describe('computeWallSlabElevation', () => { [0.06, 3.94], ], elevation: 0.1, + thickness: 0.1, }) const bottom = walls[0]! @@ -304,6 +338,7 @@ describe('computeWallSlabElevation', () => { computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []), ).toEqual({ elevation: 0.6, + electedSlabId: platform.id, baseElevation: 0.6, baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], }) @@ -329,6 +364,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: platform.id, baseElevation: 0.05, baseSegments: [ { start: 0, end: 2 / 3, elevation: 0.6 }, @@ -340,6 +376,8 @@ describe('computeWallSlabElevation', () => { it('keeps a shared wall on the higher slab that carries the full wall band', () => { const sharedWall = parseWall([4, 0], [4, 4]) const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) + // Raised room floor: grounded (thickness = elevation) so the band-carry + // rule applies — a floating deck would keep its drawn polygon instead. const high = SlabNode.parse({ polygon: [ [4, 0], @@ -348,6 +386,7 @@ describe('computeWallSlabElevation', () => { [4, 4], ], elevation: 0.6, + thickness: 0.6, }) expect( @@ -358,6 +397,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: high.id, baseElevation: 0.6, baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], }) @@ -374,6 +414,7 @@ describe('computeWallSlabElevation', () => { parseWall([8, 1.5], [8, 4.5]), parseWall([8, 4.5], [4, 4.5]), ] + // Grounded raised room floor (see the shared-wall test above). const high = SlabNode.parse({ polygon: [ [0, 0], @@ -382,6 +423,7 @@ describe('computeWallSlabElevation', () => { [0, 3], ], elevation: 0.6, + thickness: 0.6, }) const low = SlabNode.parse({ polygon: [ @@ -401,6 +443,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: high.id, baseElevation: 0.05, baseSegments: [ { start: 0, end: 3.05 / 4.5, elevation: 0.6 }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1147e04c..d48f43a9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,12 +46,16 @@ export { } from './hooks/scene-registry/scene-registry' export { type FloorPlacedElevationArgs, + GROUND_SUPPORT_ID, getFloorPlacedElevation, getFloorPlacedFootprints, getFloorStackedPosition, } from './hooks/spatial-grid/floor-placed-elevation' export { + getWallEffectiveHeightForNodes, + type PointedSupportSurface, pointInPolygon, + SUPPORT_ELEVATION_EPSILON, spatialGridManager, type WallSlabSupportSegment, } from './hooks/spatial-grid/spatial-grid-manager' @@ -61,6 +65,14 @@ export { resolveBuildingForLevel, resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' +export { + type FenceSupportInput, + resolveFenceSupportSlabPatch, + resolveSupportSlabPatch, + resolveWallSupportSlabPatch, + type SupportSlabPatch, + type SupportSlabPatchOptions, +} from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' export { @@ -123,7 +135,6 @@ export { planAutoCeilingsForLevel, planAutoSlabsForLevel, planAutoZonesForLevel, - projectAutoSlabsForPlan, resolveAutoZonePolygon, resumeSpaceDetection, type Space, @@ -245,9 +256,7 @@ export { } from './systems/elevator/elevator-runtime' export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' export { - DEFAULT_ELEVATOR_LEVEL_HEIGHT, type ElevatorLevelEntry, - getElevatorLevelHeight, resolveElevatorBuildingLevels, resolveElevatorLevels, resolveElevatorServiceLevelIds, @@ -266,10 +275,16 @@ export { isSplineFence, sampleFenceSpline, } from './systems/fence/fence-spline' +export { + clampSlabElevationForWalls, + getSlabElevationUpperBound, + type SlabElevationClamp, +} from './systems/slab/slab-support' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { StairOpeningSystem } from './systems/stair/stair-opening-system' +export { resolveStairTotalRise } from './systems/stair/stair-rise' export { getClampedWallCurveOffset, getMaxWallCurveOffset, @@ -310,6 +325,11 @@ export { type WallMoveLinkedWallTargetPlan, type WallPlanPoint, } from './systems/wall/wall-move' +export { + MIN_WALL_HEIGHT, + resolveWallEffectiveHeight, + resolveWallTop, +} from './systems/wall/wall-top' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/lib/slab-polygon.test.ts b/packages/core/src/lib/slab-polygon.test.ts index 45490c1e..ef89df43 100644 --- a/packages/core/src/lib/slab-polygon.test.ts +++ b/packages/core/src/lib/slab-polygon.test.ts @@ -7,10 +7,22 @@ function wallOf(start: [number, number], end: [number, number], thickness = 0.1) return WallNode.parse({ start, end, thickness }) } -function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) { - return SlabNode.parse( - elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation }, - ) +function slabOf( + polygon: Array<[number, number]>, + autoFromWalls = true, + elevation?: number, + thickness?: number, +) { + return SlabNode.parse({ + polygon, + autoFromWalls, + ...(elevation === undefined ? {} : { elevation }), + // Raised ROOM FLOOR fixtures pass thickness = elevation so the slab + // stays grounded (underside 0) — adoption/seam rules only apply to + // grounded slabs; the schema-default 0.05 thickness would make an + // elevated fixture a floating deck. + ...(thickness === undefined ? {} : { thickness }), + }) } function xs(polygon: Array<[number, number]>) { @@ -398,6 +410,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.34, + 0.34, ) const low = slabOf( [ @@ -450,6 +463,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.4, + 0.4, ) const legacyLow = slabOf( [ @@ -471,8 +485,11 @@ describe('getRenderableSlabPolygon', () => { }) test('stacked slabs are not mistaken for rooms across a wall', () => { + // The platform is a grounded raised floor (thickness = elevation); the + // floating-deck variant of this shape is covered by the adoption-gate + // tests below. const floor = slabOf(roomA, false, 0.05) - const platform = slabOf(roomA, false, 0.4) + const platform = slabOf(roomA, false, 0.4, 0.4) const walls = [ wallOf([0, 0], [4, 0]), wallOf([4, 0], [4, 3]), @@ -527,6 +544,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.3, + 0.3, ) const stepLow = slabOf( [ @@ -635,6 +653,7 @@ describe('getRenderableSlabPolygon', () => { ], true, 0.4, + 0.4, ) const low = slabOf( [ @@ -784,6 +803,76 @@ describe('getRenderableSlabPolygon', () => { }) }) +describe('grounded adoption gate', () => { + // Owner rule: wall adoption / per-edge extension exists so ROOM FLOORS + // tile with the walls standing on them. It applies only to grounded + // slabs (underside ≈ 0) and recessed pools; a floating deck keeps its + // drawn polygon exactly. + + test('a floating deck near walls keeps its drawn polygon exactly', () => { + // Same footprint as roomA — every edge inside a wall adoption band — + // but floating at 1.5m: no edge may extend to a wall face. + const deck = slabOf(roomA, false, 1.5, 0.05) + + const poly = getRenderableSlabPolygon(deck, { walls: twoRoomWalls, siblingSlabs: [] }) + + expect(poly).toEqual(roomA) + }) + + test('boundary case: underside 0.005 still counts as grounded and adopts', () => { + const nearlyGrounded = slabOf(roomA, false, 0.055, 0.05) + + const poly = getRenderableSlabPolygon(nearlyGrounded, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(-0.05) + }) + + test('a slab floated just past the epsilon stops adopting', () => { + // Underside 0.02 > 0.01 epsilon — already a deck. + const justFloating = slabOf(roomA, false, 0.07, 0.05) + + const poly = getRenderableSlabPolygon(justFloating, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(0) + }) + + test('a recessed pool keeps band adoption (unchanged)', () => { + // Recessed slabs are sunk into the ground, never floating — their + // negative elevation encodes depth, so the gate must not strip the + // wall-face extension a sunken room floor relies on. + const pool = SlabNode.parse({ polygon: roomA, elevation: -0.15, recessed: true }) + + const poly = getRenderableSlabPolygon(pool, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(-0.05) + }) + + test('a grounded floor ignores a floating deck sibling as a seam target', () => { + // Deck butted across the x=4 wall band: were it a room floor, the + // grounded (lower) floor would terminate at its own wall face (3.95). + // As a deck it is no seam partner — the floor adopts the wall's outer + // face (4.05) as if alone, and the deck itself stays as drawn. + const floor = slabOf(roomA, false, 0.05) + const deck = slabOf(roomB, false, 1.5, 0.05) + const walls = [wallOf([4, 0], [4, 3])] + + const floorPoly = getRenderableSlabPolygon(floor, { walls, siblingSlabs: [deck] }) + const deckPoly = getRenderableSlabPolygon(deck, { walls, siblingSlabs: [floor] }) + + expect(Math.max(...xs(floorPoly))).toBeCloseTo(4.05) + expect(deckPoly).toEqual(roomB) + }) +}) + describe('snapSlabEdgeToWallBand', () => { test('an edge inside the band snaps onto the wall centerline', () => { const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])]) diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts index 5b121adb..e8fded0d 100644 --- a/packages/core/src/lib/slab-polygon.ts +++ b/packages/core/src/lib/slab-polygon.ts @@ -38,6 +38,13 @@ import { getWallThickness } from '../systems/wall/wall-footprint' * render offsets. * - FREE — no neighbour, no wall. Rendered exactly as drawn. * + * The whole machinery exists to make ROOM FLOORS tile with the walls + * standing on them, so it only applies to GROUNDED slabs (underside on + * the level plane) and recessed pools. A floating deck keeps its drawn + * polygon exactly — it must not grow into a wall it happens to float + * beside — and is symmetrically ignored as a seam target by its + * grounded siblings. + * * Sub-edges of one edge with different projections are joined by a * perpendicular STEP connector at the breakpoint. Breakpoints sit on * candidate span boundaries — wall junctions — so the step's vertical @@ -74,9 +81,28 @@ const WALL_LATERAL_TIE_EPSILON = 0.02 const CURVED_WALL_SAMPLE_SEGMENTS = 32 const SLAB_SEAM_ELEVATION_EPSILON = 1e-4 const DEFAULT_SLAB_ELEVATION = 0.05 +const DEFAULT_SLAB_THICKNESS = 0.05 +/** + * A non-recessed slab whose underside (`elevation − thickness`) rises + * above the level plane by more than this is a floating deck: it keeps + * its drawn polygon (no wall adoption, no seam projection) and grounded + * siblings don't seam toward it. + */ +const GROUNDED_SLAB_UNDERSIDE_EPSILON = 0.01 /** Prevent near-parallel offset lines from producing unbounded corner spikes. */ const MAX_CORNER_MITER_RATIO = 10 +/** + * Floating deck test — see the module header. Recessed pools are never + * floating: their negative elevation encodes depth, not placement. + */ +function isFloatingSlab(slab: SlabNode): boolean { + if (slab.recessed) return false + const elevation = slab.elevation ?? DEFAULT_SLAB_ELEVATION + const thickness = slab.thickness ?? DEFAULT_SLAB_THICKNESS + return elevation - thickness > GROUNDED_SLAB_UNDERSIDE_EPSILON +} + export type SlabPolygonContext = { /** Walls on the slab's level. */ walls: WallNode[] @@ -138,7 +164,7 @@ export function getRenderableSlabPolygon( context: SlabPolygonContext, ): Array<[number, number]> { const polygon = slabNode.polygon - if (polygon.length < 3) { + if (polygon.length < 3 || isFloatingSlab(slabNode)) { return polygon.map(([x, z]) => [x, z] as [number, number]) } @@ -368,6 +394,11 @@ function computeEdgeSubSpans( const neighborSegments: NeighborSegment[] = [] for (const sibling of context.siblingSlabs) { + // A floating deck keeps its drawn polygon, so it can't be a seam + // partner: projecting toward it would move this slab's edge while the + // deck's stays put (asymmetric seam), and the higher/lower band rules + // only describe room floors meeting under a wall. + if (isFloatingSlab(sibling)) continue const siblingPolygon = sibling.polygon if (siblingPolygon.length < 2) continue const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 708f7153..c7eb3949 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'bun:test' -import { CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' +import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { resolveCeilingHeight } from '../services/level-height' +import { getCeilingClampBound } from '../services/storey' import { detectSpacesForLevel, + initSpaceDetectionSync, planAutoCeilingsForLevel, planAutoSlabsForLevel, planAutoZonesForLevel, @@ -38,16 +42,35 @@ function slab(elevation: number) { } describe('planAutoCeilingsForLevel', () => { - test('creates auto ceilings at the top of the room walls', () => { + test('creates auto ceilings height-less so they follow the level top', () => { const created = planAutoCeilingsForLevel([roomPolygon()], [], { - walls: squareWalls(), - slabs: [slab(0.05)], + storeyHeight: 2.7, }).create[0] - expect(created?.height).toBeCloseTo(2.55) + expect(created).toBeDefined() + // Follows-mode: no stored height — the effective height derives from + // the clamp bound at read time via resolveCeilingHeight. + expect('height' in created!).toBe(false) + expect(created?.autoFromWalls).toBe(true) }) - test('updates existing auto ceiling height when the slab elevation changes', () => { + test('never writes a height onto a matched auto ceiling', () => { + const ceiling = CeilingNode.parse({ + polygon: square, + autoFromWalls: true, + }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { + storeyHeight: 3, + }) + + // Same polygon, follows-mode height — nothing to update. + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + }) + + test('a leftover explicit height on a matched auto ceiling is not rewritten', () => { const ceiling = CeilingNode.parse({ polygon: square, height: 2.55, @@ -55,30 +78,12 @@ describe('planAutoCeilingsForLevel', () => { }) const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { - walls: squareWalls(), - slabs: [slab(0.4)], + storeyHeight: 3, }) - expect(plan.update).toHaveLength(1) - expect(plan.update[0]?.id).toBe(ceiling.id) - expect(plan.update[0]?.data.polygon).toBeUndefined() - expect(plan.update[0]?.data.height).toBeCloseTo(2.9) - }) - - test('updates existing auto ceiling height when wall height changes', () => { - const ceiling = CeilingNode.parse({ - polygon: square, - height: 2.55, - autoFromWalls: true, - }) - - const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { - walls: squareWalls(3), - slabs: [slab(0.05)], - }) - - expect(plan.update).toHaveLength(1) - expect(plan.update[0]?.data.height).toBeCloseTo(3.05) + // The sync no longer re-derives auto heights; a user-set explicit + // height survives (still under the bound, so no clamp either). + expect(plan.update).toHaveLength(0) }) test('does not replace a manual ceiling with an auto ceiling', () => { @@ -88,9 +93,10 @@ describe('planAutoCeilingsForLevel', () => { autoFromWalls: false, }) + // Storey plane above the stored 2.5 so the stage 3-B manual re-clamp + // stays out of this test's scope (suppression only). const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], { - walls: squareWalls(), - slabs: [slab(0.4)], + storeyHeight: 2.7, }) expect(plan.create).toHaveLength(0) @@ -160,9 +166,10 @@ describe('planAutoCeilingsForLevel', () => { const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data }) expect(demoted.autoFromWalls).toBe(false) + // Storey plane above the stored 2.55 so the stage 3-B manual re-clamp + // stays out of this test's scope (suppression only). const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], { - walls: squareWalls(), - slabs: [slab(0.05)], + storeyHeight: 2.7, }) expect(plan.create).toHaveLength(0) @@ -171,6 +178,219 @@ describe('planAutoCeilingsForLevel', () => { }) }) +// Two stacked levels; the deck slab (occupying [-0.3, 0] over the upper +// level's plane) covers the queried level below, so the clamp bound is +// 2.5 - 0.3 - 0.01 = 2.19 (scenario gate 11's flush deck). +function stackedDeckNodes(): Record { + const deck = SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_1', + polygon: square, + elevation: 0, + thickness: 0.3, + }) + const list: AnyNode[] = [ + BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }), + LevelNode.parse({ id: 'level_0', level: 0, height: 2.5, parentId: 'building_a' }), + LevelNode.parse({ + id: 'level_1', + level: 1, + height: 2.5, + parentId: 'building_a', + children: ['slab_deck'], + }), + deck, + ] + return Object.fromEntries(list.map((node) => [node.id, node])) as Record +} + +describe('stage 3-B ceiling clamp bound', () => { + test('height-less auto ceilings resolve under the covering-slab bound at read time', () => { + const nodes = stackedDeckNodes() + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }).create[0] + + expect(created).toBeDefined() + expect('height' in created!).toBe(false) + // Follows-mode: the effective height is the deck-limited bound. + expect(resolveCeilingHeight({ ...created!, parentId: 'level_0' }, nodes)).toBeCloseTo(2.19) + }) + + test('clamps a manual ceiling above the bound down to it (plane-only degradation)', () => { + const manual = CeilingNode.parse({ polygon: square, height: 2.6, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 }) + + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(manual.id) + expect(plan.update[0]?.data.polygon).toBeUndefined() + expect(plan.update[0]?.data.height).toBeCloseTo(2.49) + }) + + test('never raises a manual ceiling sitting below the bound', () => { + const manual = CeilingNode.parse({ polygon: square, height: 2.0, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 }) + + expect(plan.update).toHaveLength(0) + }) + + test('skips follows-mode manual ceilings (never converts them to explicit)', () => { + const nodes = stackedDeckNodes() + const manual = CeilingNode.parse({ polygon: square, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }) + + expect(plan.update).toHaveLength(0) + }) + + test('a flush deck above clamps a manual ceiling at the plane margin to its underside', () => { + // Scenario gate 11: manual ceiling at storeyHeight - 0.01 (the no-deck + // bound) → deck occupying [-0.3, 0] above → clamps to 2.5 - 0.3 - 0.01. + const nodes = stackedDeckNodes() + const manual = CeilingNode.parse({ polygon: square, height: 2.49, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }) + + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(manual.id) + expect(plan.update[0]?.data.height).toBeCloseTo(2.19) + }) +}) + +// Minimal store stand-ins for initSpaceDetectionSync: a zustand-shaped +// scene store (getState/subscribe/temporal) whose write methods mutate the +// nodes record and re-notify, and an editor store carrying `spaces`. +function createSceneStoreStub(initialNodes: Record) { + const listeners = new Set<(state: unknown) => void>() + const state: Record & { nodes: Record } = { + nodes: initialNodes, + } + const notify = () => { + for (const listener of [...listeners]) listener(state) + } + state.updateNodes = (updates: Array<{ id: string; data: Record }>) => { + const next: Record = { ...state.nodes } + for (const { id, data } of updates) { + const existing = next[id] + if (existing) next[id] = { ...existing, ...data } as AnyNode + } + state.nodes = next + notify() + } + state.deleteNodes = (ids: string[]) => { + const next: Record = { ...state.nodes } + for (const id of ids) delete next[id] + state.nodes = next + notify() + } + state.createNodes = (entries: Array<{ node: AnyNode; parentId: string }>) => { + const next: Record = { ...state.nodes } + for (const { node, parentId } of entries) { + next[node.id] = { ...node, parentId } as AnyNode + const parent = next[parentId] as (AnyNode & { children?: string[] }) | undefined + if (parent) { + next[parentId] = { ...parent, children: [...(parent.children ?? []), node.id] } as AnyNode + } + } + state.nodes = next + notify() + } + return { + getState: () => state, + subscribe: (listener: (state: unknown) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + temporal: { getState: () => ({ pause() {}, resume() {} }) }, + setNodes(next: Record) { + state.nodes = next + notify() + }, + } +} + +function createEditorStoreStub() { + const state = { + spaces: {} as Record, + setSpaces(next: Record) { + state.spaces = next + }, + } + return { getState: () => state } +} + +describe('reactive ceiling re-clamp through the detection sync', () => { + test('a flush deck created on the level above clamps the existing manual ceiling below', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0], parentId: 'level_0' }), + WallNode.parse({ start: [4, 0], end: [4, 3], parentId: 'level_0' }), + WallNode.parse({ start: [4, 3], end: [0, 3], parentId: 'level_0' }), + WallNode.parse({ start: [0, 3], end: [0, 0], parentId: 'level_0' }), + ] + const manualCeiling = CeilingNode.parse({ + id: 'ceiling_main', + parentId: 'level_0', + polygon: square, + height: 2.49, + autoFromWalls: false, + }) + const initialNodes = Object.fromEntries( + [ + BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }), + LevelNode.parse({ + id: 'level_0', + level: 0, + height: 2.5, + parentId: 'building_a', + children: [...walls.map((wall) => wall.id), 'ceiling_main'], + }), + LevelNode.parse({ id: 'level_1', level: 1, height: 2.5, parentId: 'building_a' }), + ...walls, + manualCeiling, + ].map((node) => [node.id, node]), + ) as Record + + const sceneStore = createSceneStoreStub(initialNodes) + const editorStore = createEditorStoreStub() + const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore) + + try { + // Scenario gate 11's reactive half: the deck lands on the level + // ABOVE, so only the covering-underside part of level_0's structure + // snapshot changes — the sync must still re-run and clamp down. + const deck = SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_1', + polygon: square, + elevation: 0, + thickness: 0.3, + }) + const current = sceneStore.getState().nodes + const levelAbove = current.level_1 as AnyNode + sceneStore.setNodes({ + ...current, + slab_deck: deck, + level_1: { ...levelAbove, children: ['slab_deck'] } as AnyNode, + }) + + const ceiling = sceneStore.getState().nodes.ceiling_main as CeilingNode + expect(ceiling.height).toBeCloseTo(2.5 - 0.3 - 0.01) + } finally { + unsubscribe() + } + }) +}) + describe('detectSpacesForLevel', () => { const areaOf = (polygon: Array<{ x: number; y: number }>) => { let area = 0 diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index c125e275..41302a74 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -2,12 +2,21 @@ import { type AnyNodeId, CeilingNode, type CeilingNode as CeilingNodeType, + type LevelNode, SlabNode, type SlabNode as SlabNodeType, type WallNode, ZoneNode, type ZoneNode as ZoneNodeType, } from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + getCeilingClampBound, + getLevelElevations, + getStoredLevelHeight, +} from '../services/storey' import { getSceneHistoryPauseDepth, pauseSceneHistory, @@ -56,10 +65,6 @@ type DetectedRoom = { bbox: ReturnType } -type DetectedCeilingRoom = DetectedRoom & { - ceilingHeight: number -} - export type AutoSlabSyncPlan = { create: SlabNodeType[] update: Array<{ id: SlabNodeType['id']; data: Partial }> @@ -77,7 +82,6 @@ export type AutoZoneSyncPlan = { } const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 -const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const CEILING_HEIGHT_EPSILON = 1e-6 const ROOM_CURVE_TOLERANCE = 0.04 const MAX_CURVE_SUBDIVISION_DEPTH = 6 @@ -94,9 +98,21 @@ const WALL_JUNCTION_TOLERANCE = 0.08 const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 const COVERAGE_SAMPLE_STEPS = 12 +// Auto ceilings are created height-less (follows-mode: they track the +// clamp bound live through `resolveCeilingHeight`), so the planner needs +// no wall/slab inputs anymore — only the bound for the explicit-height +// reactive re-clamp below. export type AutoCeilingPlanningContext = { - walls?: WallNode[] - slabs?: SlabNodeType[] + /** Stored storey height of the level being planned (floor-to-floor). */ + storeyHeight?: number + /** + * Stage 3-B clamp-bound resolver for a polygon on the planned level: + * `min(storey plane, lowest covering-slab underside from the level + * above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent + * (pure-planner callers without a nodes record), the bound degrades to + * the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`. + */ + ceilingClampBound?: (polygon: Array<[number, number]>) => number } function pointFromTuple(point: [number, number]): Point2D { @@ -306,61 +322,17 @@ function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) { return matchingPoints.length >= 2 } -function pointIsOnSlab(point: Point2D, slab: SlabNodeType) { - if (slab.polygon.length < 3) return false - const slabPolygon = slab.polygon.map(pointFromTuple) - if (!pointInPolygon(point, slabPolygon)) return false - - for (const hole of slab.holes ?? []) { - if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) { - return false - } - } - - return true -} - -function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) { - if (slab.polygon.length < 3) return false - if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) { - return true - } - return pointIsOnSlab(polygonCentroid(roomPolygon), slab) -} - -function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) { - let maxElevation = 0 - - for (const slab of slabs) { - if (!slabSupportsRoom(roomPolygon, slab)) continue - maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION) - } - - return maxElevation -} - -function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) { - let maxHeight = 0 - - for (const wall of walls) { - if (!wallBoundsRoom(wall, roomPolygon)) continue - const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT - if (Number.isFinite(height)) { - maxHeight = Math.max(maxHeight, height) - } - } - - return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT -} - -function resolveAutoCeilingHeight( - roomPolygon: Point2D[], - context: AutoCeilingPlanningContext = {}, +/** + * The clamp bound for a ceiling polygon under this planning context — + * the context's cross-level resolver when provided, else the plane-only + * `storeyHeight - CEILING_CLAMP_MARGIN` degradation. + */ +function resolveCeilingClampBound( + polygon: Array<[number, number]>, + context: AutoCeilingPlanningContext, ) { - return ( - resolveRoomSlabElevation(roomPolygon, context.slabs) + - resolveRoomWallHeight(roomPolygon, context.walls) - ) + if (context.ceilingClampBound) return context.ceilingClampBound(polygon) + return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN } function getWallDirection(wall: Pick) { @@ -809,7 +781,10 @@ function wallGeometrySignature(wall: WallNode) { wall.end[0].toFixed(4), wall.end[1].toFixed(4), (wall.thickness ?? 0.2).toFixed(4), - (wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4), + // Plane-bound (no stored height) is a distinct state, not a default + // value: it resolves to the storey plane, so it must not alias an + // explicit height of the same magnitude in the trigger signature. + wall.height == null ? 'plane' : wall.height.toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), ].join('|') } @@ -827,13 +802,24 @@ function zoneGeometrySignature(zone: ZoneNodeType) { ].join('|') } -// Slabs and ceilings stay out of the trigger signature: including generated -// surfaces caused delete/recreate feedback. Zones are included only so a newly -// traced room footprint can adopt its enclosing walls without waiting for the -// next remodel. +// Slab/ceiling POLYGONS stay out of the trigger signature: including +// generated footprints caused delete/recreate feedback. Zones are included +// only so a newly traced room footprint can adopt its enclosing walls +// without waiting for the next remodel. Slab ELEVATIONS and the level's +// stored storey height ARE included — both feed the explicit-ceiling +// re-clamp bound (the storey plane), and neither is rewritten by +// the sync, so regeneration triggers when they change without feedback. +// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation − +// thickness, recessed pools excluded): a deck created, lowered, or +// thickened above must re-run the sync below so ceilings re-clamp under +// it. Same polygon exclusion applies — the level-above's own auto sync +// rewrites its slab footprints, and hashing them here would re-trigger +// this level on every remodel above. function levelStructureSnapshots(nodes: Record) { const wallsByLevel = new Map() const zonesByLevel = new Map() + const slabElevationsByLevel = new Map() + const coveringUndersidesByLevel = new Map() for (const node of Object.values(nodes)) { if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue @@ -846,17 +832,39 @@ function levelStructureSnapshots(nodes: Record) { const zones = zonesByLevel.get(levelId) ?? [] zones.push(ZoneNode.parse(node)) zonesByLevel.set(levelId, zones) + } else if ((node as any).type === 'slab') { + const elevations = slabElevationsByLevel.get(levelId) ?? [] + elevations.push( + `${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`, + ) + slabElevationsByLevel.set(levelId, elevations) + if ((node as any).recessed !== true) { + const undersides = coveringUndersidesByLevel.get(levelId) ?? [] + const elevation = ((node as any).elevation as number | undefined) ?? 0.05 + const thickness = ((node as any).thickness as number | undefined) ?? 0.05 + undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`) + coveringUndersidesByLevel.set(levelId, undersides) + } } } + const levelElevations = getLevelElevations(nodes as Record) const snapshots = new Map() const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]) for (const levelId of levelIds) { const walls = wallsByLevel.get(levelId) ?? [] const zones = zonesByLevel.get(levelId) ?? [] + const level = nodes[levelId] + const storeyKey = + level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : '' + const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';') + const aboveId = findLevelAboveId(levelId, levelElevations) + const aboveSlabKey = aboveId + ? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';') + : '' snapshots.set( levelId, - `${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`, + `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, ) } @@ -1111,29 +1119,6 @@ function syncAutoSlabsForLevel( return plan } -export function projectAutoSlabsForPlan( - existingSlabs: SlabNodeType[], - plan: AutoSlabSyncPlan, -): SlabNodeType[] { - const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab])) - - for (const id of plan.delete) { - slabsById.delete(id) - } - - for (const update of plan.update) { - const slab = slabsById.get(update.id) - if (!slab) continue - slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data })) - } - - for (const slab of plan.create) { - slabsById.set(slab.id, slab) - } - - return [...slabsById.values()] -} - export function planAutoCeilingsForLevel( roomPolygons: Point2D[][], existingCeilings: CeilingNodeType[], @@ -1145,7 +1130,7 @@ export function planAutoCeilingsForLevel( ) const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple)) - const detectedAll: DetectedCeilingRoom[] = roomPolygons + const detectedAll: DetectedRoom[] = roomPolygons .map((poly) => ({ poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( pointFromTuple, @@ -1161,7 +1146,6 @@ export function planAutoCeilingsForLevel( centroid: polygonCentroid(room.poly), area: Math.abs(polygonArea(room.poly)), bbox: bboxOf(room.poly), - ceilingHeight: resolveAutoCeilingHeight(room.poly, context), })) const detected = detectedAll.filter( @@ -1182,7 +1166,7 @@ export function planAutoCeilingsForLevel( const matchedCeilingIds = new Set() const matchedDetectedIdx = new Set() - const updatesById = new Map() + const updatesById = new Map() const autoBySignature = new Map>() for (const entry of existingAutoMeta) { @@ -1199,7 +1183,6 @@ export function planAutoCeilingsForLevel( matchedCeilingIds.add(existing.ceiling.id) updatesById.set(existing.ceiling.id, { polygon: room.poly.map(pointToTuple), - height: room.ceilingHeight, }) }) @@ -1237,7 +1220,6 @@ export function planAutoCeilingsForLevel( matchedCeilingIds.add(bestMatch.entry.ceiling.id) updatesById.set(bestMatch.entry.ceiling.id, { polygon: room.poly.map(pointToTuple), - height: room.ceilingHeight, }) } @@ -1255,27 +1237,36 @@ export function planAutoCeilingsForLevel( } } + // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab + // created, moved, or thickened on the level above can leave an EXISTING + // manual explicit-height ceiling poking into its solid. Clamp explicit + // heights down to the bound; never raise them — a user-lowered ceiling + // is intent, only an over-bound one is a conflict. Follows-mode + // ceilings (absent height) derive under the bound by construction and + // are skipped, so the clamp can never convert one to an explicit + // height. + const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => { + if (ceiling.height == null) return [] + const bound = resolveCeilingClampBound(ceiling.polygon, context) + if (!Number.isFinite(bound)) return [] + return ceiling.height > bound + CEILING_HEIGHT_EPSILON + ? [{ id: ceiling.id, data: { height: bound } }] + : [] + }) + const ceilingsToUpdate = [ + // Auto ceilings only track their room's POLYGON here — their height is + // follows-mode (absent) and derives from the level top at read time. ...existingAuto .filter((ceiling) => updatesById.has(ceiling.id)) .flatMap((ceiling) => { const update = updatesById.get(ceiling.id) if (!update) return [] - - const data: Partial = {} - if (!sameTuplePolygon(ceiling.polygon, update.polygon)) { - data.polygon = update.polygon - } - if ( - Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) > - CEILING_HEIGHT_EPSILON - ) { - data.height = update.height - } - - return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] + if (sameTuplePolygon(ceiling.polygon, update.polygon)) return [] + return [{ id: ceiling.id, data: { polygon: update.polygon } }] }), ...ceilingDemotions, + ...manualClamps, ] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] @@ -1289,12 +1280,14 @@ export function planAutoCeilingsForLevel( const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') plannedCeilingsForNaming.push({ name }) + // Height-less on purpose: auto ceilings follow the level top (the + // clamp bound) through `resolveCeilingHeight` instead of baking a + // derived height that would go stale on level-height edits. ceilingsToCreate.push( CeilingNode.parse({ name, polygon: room.poly.map(pointToTuple), holes: [], - height: room.ceilingHeight, autoFromWalls: true, }), ) @@ -1402,14 +1395,21 @@ function runSpaceDetection( } const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) - const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) - const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan) + syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) + const levelNode = nodes[levelId] + const storeyHeight = + levelNode?.type === 'level' + ? getStoredLevelHeight(levelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT syncAutoCeilingsForLevel( levelId, roomPolygons, ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), sceneStore, - { walls, slabs: projectedSlabs }, + { + storeyHeight, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + }, ) const zonePlan = planAutoZonesForLevel( spaces, diff --git a/packages/core/src/lib/zone-quantities.test.ts b/packages/core/src/lib/zone-quantities.test.ts index c8e529ce..4bdeb23a 100644 --- a/packages/core/src/lib/zone-quantities.test.ts +++ b/packages/core/src/lib/zone-quantities.test.ts @@ -17,7 +17,12 @@ function sceneRecord(nodes: AnyNode[]): Record { function roomNodes() { const zone = ZoneNode.parse({ id: 'zone_room', name: 'Studio', parentId: 'level_main', polygon }) const slab = SlabNode.parse({ id: 'slab_room', parentId: 'level_main', polygon }) - const ceiling = CeilingNode.parse({ id: 'ceiling_room', parentId: 'level_main', polygon }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_room', + parentId: 'level_main', + polygon, + height: 2.5, + }) const walls = polygon.map((start, index) => WallNode.parse({ id: `wall_${index}`, diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index f120ff02..a2ad983c 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -1,6 +1,11 @@ import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { AnyNodeId } from '../schema/types' +import { DEFAULT_LEVEL_HEIGHT, resolveCeilingHeight } from '../services/level-height' +import { getWallPlaneTop } from '../services/storey' +import { computeWallSlabSupport } from '../systems/slab/slab-support' import { sampleWallCenterline } from '../systems/wall/wall-curve' -import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' +import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' +import { resolveWallEffectiveHeight } from '../systems/wall/wall-top' import { detectSpacesForLevel, type Space } from './space-detection' type Point2D = readonly [number, number] @@ -473,6 +478,12 @@ export function deriveZoneQuantityReport( ? Object.values(sceneNodes).filter((node) => node.parentId === levelId) : [] const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall') + const slabs = levelNodes.filter((node): node is SlabNode => node.type === 'slab') + const wallEffectiveHeight = (wall: WallNode) => { + const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) + const planeTop = levelId ? getWallPlaneTop(wall, levelId, sceneNodes) : DEFAULT_LEVEL_HEIGHT + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) + } const edgeLengths = zone.polygon.map((start, index) => { const end = zone.polygon[(index + 1) % zone.polygon.length] return end ? pointDistance(start, end) : 0 @@ -488,7 +499,7 @@ export function deriveZoneQuantityReport( const ceilingCoverage = proveSurfaceCoverage( zone, levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'), - (node) => node.height, + (node) => resolveCeilingHeight(node, sceneNodes as Record), { singular: 'ceiling', plural: 'Ceilings', datum: 'heights' }, ) @@ -517,7 +528,7 @@ export function deriveZoneQuantityReport( ? { status: 'available' as const, value: wallSpans!.reduce( - (sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT), + (sum, span) => sum + span.length * wallEffectiveHeight(span.wall), 0, ), note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.', diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 7727c986..e58db315 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -184,7 +184,7 @@ export { SkylightType, type SkylightTypePreset, } from './nodes/skylight' -export { SlabNode } from './nodes/slab' +export { MIN_SLAB_THICKNESS, SlabNode } from './nodes/slab' export { SolarPanelMaterialRole, SolarPanelNode, diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index a84e3248..a0949a49 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -80,6 +80,8 @@ export type CabinetCompartmentSchema = z.infer const cabinetBoxFields = { position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), width: z.number().min(0.05).max(3).default(0.5), depth: z.number().min(0.3).max(1.2).default(0.5), carcassHeight: z.number().min(0.4).max(2.4).default(0.72), diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index b94aaaa2..cb673d2c 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -18,7 +18,12 @@ export const CeilingNode = BaseNode.extend({ polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), - height: z.number().default(2.5), // Height in meters + // Height in meters. Absent = the ceiling follows the level top: its + // effective height is the same bound its write-clamp uses — + // min(storey plane, lowest covering-slab underside over the polygon) + // − CEILING_CLAMP_MARGIN (see `resolveCeilingHeight`). Present = an + // explicit custom height, still write-clamped under that bound. + height: z.number().optional(), autoFromWalls: z.boolean().default(false), }).describe( dedent` @@ -26,6 +31,7 @@ export const CeilingNode = BaseNode.extend({ - polygon: array of [x, z] points defining the ceiling boundary - holes: array of polygons representing holes in the ceiling - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts + - height: explicit height in meters; absent = follows the level top automatically - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index de411604..1dc704cf 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -86,6 +86,8 @@ export const ColumnNode = BaseNode.extend({ type: nodeType('column'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), style: ColumnStyle.default('plain'), crossSection: ColumnCrossSection.default('round'), height: z.number().positive().default(2.5), diff --git a/packages/core/src/schema/nodes/duct-terminal.ts b/packages/core/src/schema/nodes/duct-terminal.ts index 5eefff31..6c5f058a 100644 --- a/packages/core/src/schema/nodes/duct-terminal.ts +++ b/packages/core/src/schema/nodes/duct-terminal.ts @@ -21,6 +21,8 @@ export const DuctTerminalNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Yaw in radians. rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'), // Which surface the terminal mounts on. Drives face orientation and // which way the collar (and its port) points. diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index e8e76874..26ebff77 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -32,6 +32,9 @@ export const FenceNode = BaseNode.extend({ tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(), height: z.number().default(1.8), thickness: z.number().default(0.08), + // Persisted slab-support host — the fence sits on that slab's walking + // surface (see ItemNode.supportSlabId for the host rules). + supportSlabId: z.string().optional(), curveOffset: z.number().optional(), baseHeight: z.number().default(0.22), postSpacing: z.number().default(2), @@ -54,6 +57,7 @@ export const FenceNode = BaseNode.extend({ - path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them - tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent - height/thickness: overall fence dimensions in meters + - supportSlabId: optional slab host; the fence stands on that slab's walking surface (elevation) - curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set) - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - groundClearance/edgeInset/baseStyle: fence support and inset configuration diff --git a/packages/core/src/schema/nodes/hvac-equipment.ts b/packages/core/src/schema/nodes/hvac-equipment.ts index a0e00a13..6bde1e2c 100644 --- a/packages/core/src/schema/nodes/hvac-equipment.ts +++ b/packages/core/src/schema/nodes/hvac-equipment.ts @@ -23,6 +23,8 @@ export const HvacEquipmentNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Yaw in radians. rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'), // Cabinet dimensions in meters. Defaults match a typical upflow // furnace cabinet (~22" × 28" footprint, ~43" tall). diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index f8732482..00c5a998 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -143,6 +143,20 @@ export const ItemNode = BaseNode.extend({ roofSegmentId: z.string().optional(), roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), + // Persisted floor-support host (canonical doc — the same field on other + // floor-placed kinds and walls follows these rules). Written at + // placement/commit ONLY when overlapping slabs disagree on elevation + // (ambiguity); absent/null means "elect the support fresh on every + // read", which is the historical behavior. Read paths PREFER this slab + // while it still exists and still overlaps the node's footprint, and + // silently fall back to election otherwise. Deleting the host slab + // strips the field (deleteNodesAction); a host merely reshaped away is + // deliberately kept so hosting resumes if the slab's polygon returns. + // The sentinel value 'ground' (GROUND_SUPPORT_ID) pins the node to the + // level base — written when a pointer-capped commit elected the ground + // while a slab (e.g. an elevated deck) still overlapped the footprint. + supportSlabId: z.string().optional(), + // Denormalized references to collections this node belongs to collectionIds: z.array(z.custom()).optional(), diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts index 839be780..ae44915d 100644 --- a/packages/core/src/schema/nodes/level.test.ts +++ b/packages/core/src/schema/nodes/level.test.ts @@ -48,4 +48,9 @@ describe('LevelNode', () => { nodes.map((node) => node.id), ) }) + + test('does not materialize height on parse — absence marks unmigrated legacy data', () => { + expect('height' in LevelNode.parse({})).toBe(false) + expect(LevelNode.parse({ height: 3 }).height).toBe(3) + }) }) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index ef4de43a..a566f593 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -59,11 +59,18 @@ export const LevelNode = BaseNode.extend({ .default([]), // Specific props level: z.number().default(0), + /** + * Stored storey height in meters (floor-to-floor). No zod default on + * purpose: absence marks unmigrated legacy data and gates the load-time + * migration; a schema default would materialize silently through .parse(). + */ + height: z.number().optional(), }).describe( dedent` Level node - used to represent a level in the building - children: array of architectural, equipment, and MEP distribution nodes - level: level number + - height: storey height in meters (floor-to-floor); absent only on unmigrated legacy data `, ) diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts index 3e9b74b2..89faaedd 100644 --- a/packages/core/src/schema/nodes/shelf.ts +++ b/packages/core/src/schema/nodes/shelf.ts @@ -43,6 +43,8 @@ export const ShelfNode = BaseNode.extend({ children: z.array(ItemNode.shape.id).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), // Dimensions (meters). Schema-level defaults intentionally reproduce // the v1 wall-shelf so existing v1 scenes that omit the v2-introduced diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index fe3c47c1..ec8e99b7 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -4,6 +4,11 @@ import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' import { SurfaceHoleMetadata } from './surface-hole-metadata' +// Edit-time floor for `thickness` — a thinner slab z-fights the ceiling's +// −0.01 underside offset. Applies to edits only; migration writes legacy +// intervals verbatim (including degenerate zero-thickness slabs). +export const MIN_SLAB_THICKNESS = 0.02 + export const SlabNode = BaseNode.extend({ id: objectId('slab'), type: nodeType('slab'), @@ -16,7 +21,9 @@ export const SlabNode = BaseNode.extend({ polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), - elevation: z.number().default(0.05), // Elevation in meters + elevation: z.number().default(0.05), // Walking surface (slab top), meters above the level plane + thickness: z.number().default(0.05), // Grows downward from the surface + recessed: z.boolean().default(false), autoFromWalls: z.boolean().default(false), }).describe( dedent` @@ -24,7 +31,9 @@ export const SlabNode = BaseNode.extend({ - polygon: array of [x, z] points defining the slab boundary - holes: array of [x, z] polygons representing cutouts in the slab - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - - elevation: elevation in meters + - elevation: the walking surface (slab top), in meters above the level plane + - thickness: grows downward from the surface; the solid occupies [elevation - thickness, elevation] + - recessed: open recess (pool) whose floor sits at elevation (< 0); the shell walls rise to the level plane - autoFromWalls: whether the slab is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/spawn.ts b/packages/core/src/schema/nodes/spawn.ts index 521d3f81..b3a620eb 100644 --- a/packages/core/src/schema/nodes/spawn.ts +++ b/packages/core/src/schema/nodes/spawn.ts @@ -6,6 +6,8 @@ export const SpawnNode = BaseNode.extend({ type: nodeType('spawn'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), }) export type SpawnNode = z.infer diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index abc1fe54..cf9d0664 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -37,13 +37,19 @@ export const StairNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), stairType: StairType.default('straight'), fromLevelId: z.string().nullable().default(null), toLevelId: z.string().nullable().default(null), + // Destination deck (a slab id). When set, the stair's rise follows that + // slab's elevation live. An explicit `totalRise` still wins when BOTH are + // set (edge case — the panel clears the custom rise when attaching). + deckSlabId: z.string().optional(), slabOpeningMode: StairSlabOpeningMode.default('none'), openingOffset: z.number().default(0), width: z.number().default(1.0), - totalRise: z.number().default(2.5), + totalRise: z.number().optional(), stepCount: z.number().default(10), thickness: z.number().default(0.25), fillToFloor: z.boolean().default(true), @@ -66,6 +72,7 @@ export const StairNode = BaseNode.extend({ - rotation: rotation around Y axis - stairType: straight (segment-based), curved (arc-based), or spiral - fromLevelId / toLevelId: source and destination levels used for auto slab cutouts + - deckSlabId: destination deck (slab) — the rise derives from its elevation while set - slabOpeningMode: whether a destination-level slab opening is generated for this stair - openingOffset: extra opening expansion applied after the cutout polygon is computed - width: stair width diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index abc2f5e8..5364e083 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -41,16 +41,19 @@ describe('wall face bands', () => { }) expect( - getWallFaceBandConfig({ - height: 2.5, - faceBands: { - enabled: true, - count: 3, - lowerHeight: 0.84, - middleHeight: 0.61, - upperHeight: 0.61, + getWallFaceBandConfig( + { + height: 2.5, + faceBands: { + enabled: true, + count: 3, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, + }, }, - }), + 2.5, + ), ).toMatchObject({ count: 3, lowerTop: 0.84, @@ -60,16 +63,19 @@ describe('wall face bands', () => { test('four bands adds an upper split below the final top band', () => { expect( - getWallFaceBandConfig({ - height: 2.5, - faceBands: { - enabled: true, - count: 4, - lowerHeight: 0.5, - middleHeight: 0.6, - upperHeight: 0.7, + getWallFaceBandConfig( + { + height: 2.5, + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.5, + middleHeight: 0.6, + upperHeight: 0.7, + }, }, - }), + 2.5, + ), ).toMatchObject({ count: 4, lowerTop: 0.5, diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 4bfaa063..a01a98ad 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -151,6 +151,8 @@ export const WallNode = BaseNode.extend({ thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), faceBands: WallFaceBandConfig.optional(), skirting: WallTrimConfig.optional(), crown: WallTrimConfig.optional(), @@ -198,8 +200,11 @@ export const WALL_SLOT_DEFAULT: Record = { exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior, } -export function getWallFaceBandConfig(wall: Pick) { - const wallHeight = wall.height ?? 2.5 +export function getWallFaceBandConfig( + wall: Pick, + effectiveWallHeight: number, +) { + const wallHeight = Math.max(0, effectiveWallHeight) const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) } const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1 const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0 @@ -223,8 +228,9 @@ export function getWallFaceBandConfig(wall: Pick, y: number, + effectiveWallHeight: number, ): WallFaceBand { - const bands = getWallFaceBandConfig(wall) + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return 'upper' if (y < bands.lowerTop) return 'lower' if (y < bands.middleTop) return 'middle' diff --git a/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts b/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts new file mode 100644 index 00000000..75841f1a --- /dev/null +++ b/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts @@ -0,0 +1,89 @@ +// Real-scene repro for the max-side boundary clamp miss (project_O1z9NLOylyb5kFX4). +// Auto slab polygon derives from wall centerlines, putting wall samples exactly on +// the polygon boundary — the shape that exposed ray-cast side dependence. +export const wallPlaneTopBoundaryRepro = { + building_rr4rx7weux2fpdbh: { + id: 'building_rr4rx7weux2fpdbh', + type: 'building', + object: 'node', + visible: true, + children: ['level_pomuk0sbwec15mf3', 'level_5msog1z8hy2lyvxr'], + metadata: {}, + parentId: null, + position: [0, 0, 0], + rotation: [0, 0, 0], + }, + level_pomuk0sbwec15mf3: { + id: 'level_pomuk0sbwec15mf3', + type: 'level', + level: 0, + height: 2.7, + object: 'node', + visible: true, + children: ['wall_39bnnq29h824ryy0', 'wall_on4rj410n69n3rzf'], + metadata: {}, + parentId: null, + }, + level_5msog1z8hy2lyvxr: { + id: 'level_5msog1z8hy2lyvxr', + type: 'level', + level: 1, + height: 2.5, + object: 'node', + visible: true, + children: ['slab_j3i4ebjg4nsu8xk7'], + metadata: {}, + parentId: 'building_rr4rx7weux2fpdbh', + }, + wall_39bnnq29h824ryy0: { + id: 'wall_39bnnq29h824ryy0', + end: [-1, 4], + name: 'Wall 1', + type: 'wall', + start: [3, 4], + object: 'node', + visible: true, + backSide: 'exterior', + children: [], + metadata: {}, + parentId: 'level_pomuk0sbwec15mf3', + frontSide: 'interior', + }, + wall_on4rj410n69n3rzf: { + id: 'wall_on4rj410n69n3rzf', + end: [-1, -1], + name: 'Wall 2', + type: 'wall', + start: [-1, 4], + object: 'node', + visible: true, + backSide: 'exterior', + children: [], + metadata: {}, + parentId: 'level_pomuk0sbwec15mf3', + frontSide: 'interior', + }, + slab_j3i4ebjg4nsu8xk7: { + id: 'slab_j3i4ebjg4nsu8xk7', + name: 'Room 1 Slab', + type: 'slab', + holes: [], + object: 'node', + polygon: [ + [-1, 4], + [-1, -1], + [4, -1], + [4, 1], + [3, 1], + [3, 4], + ], + visible: true, + metadata: {}, + parentId: 'level_5msog1z8hy2lyvxr', + recessed: false, + elevation: 0.19757210573188194, + thickness: 0.5, + holeMetadata: [], + autoFromWalls: true, + }, +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 85517d5c..245793f6 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -46,7 +46,7 @@ export { DEFAULT_LEVEL_HEIGHT, getCeilingAt, getCeilingHeightAt, - getLevelHeight, + resolveCeilingHeight, } from './level-height' export { type AxisLock, @@ -102,6 +102,19 @@ export { snapVec3ToGrid, snapWorldXZToBuildingLocal, } from './snap' +export { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + findLevelBelowId, + getCeilingClampBound, + getCoveringSlabUndersideAt, + getLevelAbove, + getLevelBelow, + getLevelElevations, + getStoredLevelHeight, + getWallPlaneTop, + type LevelElevation, +} from './storey' export { buildPortComponents, type SystemSummary, diff --git a/packages/core/src/services/level-height.test.ts b/packages/core/src/services/level-height.test.ts new file mode 100644 index 00000000..81698a26 --- /dev/null +++ b/packages/core/src/services/level-height.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'bun:test' +import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { deriveLegacyLevelHeight, getCeilingAt, resolveCeilingHeight } from './level-height' + +function createFixture(): Record { + const nodes: AnyNode[] = [ + LevelNode.parse({ id: 'level_empty', children: [] }), + LevelNode.parse({ id: 'level_no_slab', children: ['wall_no_slab'] }), + WallNode.parse({ + id: 'wall_no_slab', + parentId: 'level_no_slab', + start: [10, 0], + end: [12, 0], + }), + LevelNode.parse({ id: 'level_standard_slab', children: ['slab_standard', 'wall_standard'] }), + SlabNode.parse({ + id: 'slab_standard', + parentId: 'level_standard_slab', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + elevation: 0.05, + }), + WallNode.parse({ + id: 'wall_standard', + parentId: 'level_standard_slab', + start: [1, 2], + end: [3, 2], + }), + LevelNode.parse({ id: 'level_tall_wall', children: ['slab_raised', 'wall_tall'] }), + SlabNode.parse({ + id: 'slab_raised', + parentId: 'level_tall_wall', + polygon: [ + [20, 0], + [24, 0], + [24, 4], + [20, 4], + ], + elevation: 0.35, + }), + WallNode.parse({ + id: 'wall_tall', + parentId: 'level_tall_wall', + start: [21, 2], + end: [23, 2], + height: 3.2, + }), + LevelNode.parse({ id: 'level_ceiling', children: ['wall_below_ceiling', 'ceiling_tall'] }), + WallNode.parse({ + id: 'wall_below_ceiling', + parentId: 'level_ceiling', + start: [40, 0], + end: [42, 0], + }), + CeilingNode.parse({ + id: 'ceiling_tall', + parentId: 'level_ceiling', + polygon: [ + [40, 0], + [42, 0], + [42, 2], + [40, 2], + ], + height: 3.4, + }), + LevelNode.parse({ id: 'level_negative_slab', children: ['slab_negative', 'wall_negative'] }), + SlabNode.parse({ + id: 'slab_negative', + parentId: 'level_negative_slab', + polygon: [ + [30, 0], + [34, 0], + [34, 4], + [30, 4], + ], + elevation: -0.4, + }), + WallNode.parse({ + id: 'wall_negative', + parentId: 'level_negative_slab', + start: [31, 2], + end: [33, 2], + height: 2.8, + }), + ] + + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +describe('deriveLegacyLevelHeight', () => { + const nodes = createFixture() + const cases = [ + ['level_no_slab', 2.5], + ['level_standard_slab', 2.5], + ['level_tall_wall', 3.55], + ['level_ceiling', 3.4], + ['level_negative_slab', 2.8], + ['level_empty', 2.5], + ] as const + + for (const [levelId, expected] of cases) { + it(`derives ${expected} for ${levelId}`, () => { + expect(deriveLegacyLevelHeight(levelId, nodes)).toBeCloseTo(expected) + }) + } +}) + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +// Post-migration stack: two stored-height levels; the upper level carries a +// deck slab occupying [-0.3, 0] over the lower level's plane, so the lower +// level's ceiling clamp bound is 2.5 − 0.3 − 0.01 = 2.19 under the deck. +function createResolverFixture(options: { deck?: boolean } = {}): Record { + const list: AnyNode[] = [ + BuildingNode.parse({ + id: 'building_a', + children: ['level_low', 'level_high'], + }), + LevelNode.parse({ id: 'level_low', level: 0, height: 2.5, parentId: 'building_a' }), + LevelNode.parse({ + id: 'level_high', + level: 1, + height: 2.5, + parentId: 'building_a', + children: options.deck ? ['slab_deck'] : [], + }), + ] + if (options.deck) { + list.push( + SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_high', + polygon: SQUARE, + elevation: 0, + thickness: 0.3, + }), + ) + } + return Object.fromEntries(list.map((node) => [node.id, node])) as Record +} + +describe('resolveCeilingHeight', () => { + it('returns the explicit height verbatim when stored', () => { + const nodes = createResolverFixture({ deck: true }) + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE, height: 2.0 }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBe(2.0) + }) + + it('resolves an absent height to the level-top clamp bound', () => { + const nodes = createResolverFixture() + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49) + }) + + it('tracks a level height change without any ceiling write', () => { + const nodes = createResolverFixture() + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49) + + const level = nodes['level_low' as AnyNodeId] as AnyNode & { height?: number } + const raised = { + ...nodes, + level_low: { ...level, height: 3.2 } as AnyNode, + } as Record + + expect(resolveCeilingHeight(ceiling, raised)).toBeCloseTo(3.19) + }) + + it('resolves under a covering deck from the level above', () => { + const nodes = createResolverFixture({ deck: true }) + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.19) + }) + + it('falls back to the default plane when the level is unresolvable', () => { + const ceiling = CeilingNode.parse({ parentId: null, polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, {} as Record)).toBeCloseTo(2.49) + }) +}) + +describe('getCeilingAt lowest-wins with mixed follows/explicit', () => { + it('picks the explicit low ceiling under a follows-mode one, and vice versa', () => { + const base = createResolverFixture() + const follows = CeilingNode.parse({ + id: 'ceiling_follows', + parentId: 'level_low', + polygon: SQUARE, + }) + const explicitLow = CeilingNode.parse({ + id: 'ceiling_low', + parentId: 'level_low', + polygon: SQUARE, + height: 2.0, + }) + const level = base['level_low' as AnyNodeId] as AnyNode & { children: string[] } + const nodes = { + ...base, + level_low: { ...level, children: ['ceiling_follows', 'ceiling_low'] } as AnyNode, + ceiling_follows: follows, + ceiling_low: explicitLow, + } as Record + + // Explicit 2.0 undercuts the 2.49 follows bound. + expect(getCeilingAt('level_low', nodes, 2, 2)?.id).toBe(explicitLow.id) + + // Raise the explicit one above the bound comparison: 2.6 stored — the + // follows ceiling (2.49) is now the lowest surface over the point. + const nodesHighExplicit = { + ...nodes, + ceiling_low: { ...explicitLow, height: 2.6 } as AnyNode, + } as Record + expect(getCeilingAt('level_low', nodesHighExplicit, 2, 2)?.id).toBe(follows.id) + }) +}) diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 016711ac..871303c4 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,40 +1,68 @@ -import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' -import type { CeilingNode, LevelNode, WallNode } from '../schema' +import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' +import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' +import { resolveWallTop } from '../systems/wall/wall-top' +// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: +// both sides only reference the other inside function bodies. +import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' export const DEFAULT_LEVEL_HEIGHT = 2.5 /** - * Optional resolver for a wall's rendered base Y (mesh elevation). - * - * `packages/core` is pure domain logic and must not read viewer/Three.js - * state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have - * registry access (viewer systems, node tools) may pass a resolver so the - * mesh elevation is factored in; pure/headless callers (MCP, tests, server) - * omit it and get a deterministic result from serialized node data alone. + * Effective ceiling height in level-local meters. An explicit stored + * `height` wins; absent height means the ceiling follows the level top — + * the same bound its write-clamp uses: min(storey plane, lowest + * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see + * {@link getCeilingClampBound}). Falls back to the default plane minus + * the same margin when the owning level is unresolvable. */ -export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined +export function resolveCeilingHeight( + ceiling: Pick, + nodes: Record, +): number { + if (ceiling.height != null) return ceiling.height + const bound = + typeof ceiling.parentId === 'string' + ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) + : Number.POSITIVE_INFINITY + return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN +} -export function getLevelHeight( +export function deriveLegacyLevelHeight( levelId: string, nodes: Record, - resolveWallBaseY?: WallBaseYResolver, ): number { const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (!level) return DEFAULT_LEVEL_HEIGHT + const levelChildren = level.children + .map((childId) => nodes[childId as keyof typeof nodes]) + .filter((child): child is AnyNode => child !== undefined) + const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') + const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') + let maxTop = 0 - for (const childId of level.children) { - const child = nodes[childId as keyof typeof nodes] - if (!child) continue + for (const child of levelChildren) { if (child.type === 'ceiling') { - const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT - if (ch > maxTop) maxTop = ch + // Absence here is the PRE-migration legacy schema default (2.5), not + // follows-mode — this derivation runs before the level has a height + // for a follows-mode bound to track. + const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (height > maxTop) maxTop = height } else if (child.type === 'wall') { - let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0 - if (baseY < 0) baseY = 0 - const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT) + const wall = child as WallNode + const electedElevation = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation) if (top > maxTop) maxTop = top } } @@ -58,14 +86,18 @@ export function getCeilingAt( if (!level) return null let best: CeilingNode | null = null + let bestHeight = Number.POSITIVE_INFINITY for (const childId of level.children) { const child = nodes[childId as keyof typeof nodes] if (child?.type !== 'ceiling') continue const ceiling = child as CeilingNode if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue - const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT - if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling + const h = resolveCeilingHeight(ceiling, nodes) + if (best === null || h < bestHeight) { + best = ceiling + bestHeight = h + } } return best } @@ -82,5 +114,5 @@ export function getCeilingHeightAt( z: number, ): number | null { const ceiling = getCeilingAt(levelId, nodes, x, z) - return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null + return ceiling ? resolveCeilingHeight(ceiling, nodes) : null } diff --git a/packages/core/src/services/storey.test.ts b/packages/core/src/services/storey.test.ts new file mode 100644 index 00000000..2cce58ef --- /dev/null +++ b/packages/core/src/services/storey.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, test } from 'bun:test' +import { BuildingNode, LevelNode, SlabNode, type WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { wallPlaneTopBoundaryRepro as reproFixture } from './__fixtures__/wall-plane-top-boundary-repro' +import { DEFAULT_LEVEL_HEIGHT } from './level-height' +import { + CEILING_CLAMP_MARGIN, + getCeilingClampBound, + getCoveringSlabUndersideAt, + getLevelAbove, + getLevelBelow, + getLevelElevations, + getStoredLevelHeight, + getWallPlaneTop, +} from './storey' + +const buildNodes = (list: AnyNode[]): Record => + Object.fromEntries(list.map((node) => [node.id, node])) as Record + +const level = ( + id: string, + ordinal: number, + opts: { height?: number; parentId?: string | null; children?: string[] } = {}, +): LevelNode => + LevelNode.parse({ + id, + level: ordinal, + parentId: opts.parentId ?? null, + children: opts.children ?? [], + ...(opts.height === undefined ? {} : { height: opts.height }), + }) + +const building = (id: string, children: string[]): BuildingNode => + BuildingNode.parse({ id, children }) + +const slabNode = ( + id: string, + opts: { + polygon?: Array<[number, number]> + holes?: Array> + elevation?: number + thickness?: number + recessed?: boolean + }, +): SlabNode => + SlabNode.parse({ + id, + polygon: + opts.polygon ?? + ([ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] as Array<[number, number]>), + holes: opts.holes ?? [], + ...(opts.elevation === undefined ? {} : { elevation: opts.elevation }), + ...(opts.thickness === undefined ? {} : { thickness: opts.thickness }), + ...(opts.recessed === undefined ? {} : { recessed: opts.recessed }), + }) + +describe('getStoredLevelHeight', () => { + test('returns the stored height when present', () => { + expect(getStoredLevelHeight(level('level_a', 0, { height: 3.25 }))).toBe(3.25) + }) + + test('falls back to the default for unmigrated legacy levels', () => { + expect(getStoredLevelHeight(level('level_a', 0))).toBe(DEFAULT_LEVEL_HEIGHT) + expect(getStoredLevelHeight(level('level_a', 0))).toBe(2.5) + }) +}) + +describe('getLevelElevations', () => { + test('single building matches a hand-computed prefix sum', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_1', 'level_2', 'level_3']), + level('level_0', 0, { height: 3, parentId: 'building_a' }), + level('level_1', 1, { height: 2.5, parentId: 'building_a' }), + level('level_2', 2, { height: 2.75, parentId: 'building_a' }), + level('level_3', 3, { height: 4, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_0')).toEqual({ + baseY: 0, + height: 3, + buildingId: 'building_a', + ordinal: 0, + }) + expect(elevations.get('level_1')?.baseY).toBe(3) + expect(elevations.get('level_2')?.baseY).toBe(5.5) + expect(elevations.get('level_3')?.baseY).toBe(8.25) + }) + + test('stacks two buildings independently with interleaved, unsorted ordinals', () => { + const nodes = buildNodes([ + level('level_b1', 1, { height: 2.5, parentId: 'building_b' }), + level('level_a2', 2, { height: 3, parentId: 'building_a' }), + building('building_a', ['level_a0', 'level_a1', 'level_a2']), + level('level_a0', 0, { height: 3.5, parentId: 'building_a' }), + building('building_b', ['level_b0', 'level_b1']), + level('level_b0', 0, { height: 4, parentId: 'building_b' }), + level('level_a1', 1, { height: 3.25, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_a0')?.baseY).toBe(0) + expect(elevations.get('level_a1')?.baseY).toBe(3.5) + expect(elevations.get('level_a2')?.baseY).toBe(6.75) + expect(elevations.get('level_b0')?.baseY).toBe(0) + expect(elevations.get('level_b1')?.baseY).toBe(4) + expect(elevations.get('level_a2')?.buildingId).toBe('building_a') + expect(elevations.get('level_b1')?.buildingId).toBe('building_b') + }) + + test('negative ordinals stack from the lowest level up', () => { + const nodes = buildNodes([ + building('building_a', ['level_basement', 'level_ground', 'level_upper']), + level('level_upper', 1, { height: 3, parentId: 'building_a' }), + level('level_basement', -1, { height: 2.25, parentId: 'building_a' }), + level('level_ground', 0, { height: 2.5, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_basement')?.baseY).toBe(0) + expect(elevations.get('level_ground')?.baseY).toBe(2.25) + expect(elevations.get('level_upper')?.baseY).toBe(4.75) + }) + + test('duplicate and fractional ordinals stack stably without NaN', () => { + const nodes = buildNodes([ + building('building_a', ['level_ground', 'level_mezz', 'level_dup_b', 'level_dup_a']), + level('level_dup_b', 1, { height: 3, parentId: 'building_a' }), + level('level_dup_a', 1, { height: 2.5, parentId: 'building_a' }), + level('level_mezz', 0.5, { height: 1.5, parentId: 'building_a' }), + level('level_ground', 0, { height: 2.5, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_ground')?.baseY).toBe(0) + expect(elevations.get('level_mezz')?.baseY).toBe(2.5) + // Stable sort: equal ordinals keep nodes-record insertion order. + expect(elevations.get('level_dup_b')?.baseY).toBe(4) + expect(elevations.get('level_dup_a')?.baseY).toBe(7) + for (const elevation of elevations.values()) { + expect(Number.isFinite(elevation.baseY)).toBe(true) + expect(Number.isFinite(elevation.height)).toBe(true) + } + }) + + test('levels missing height fall back to 2.5 for both height and stacking', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_1', 'level_2']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_1', 1, { height: 3, parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_0')?.height).toBe(2.5) + expect(elevations.get('level_1')?.baseY).toBe(2.5) + expect(elevations.get('level_2')?.baseY).toBe(5.5) + expect(elevations.get('level_2')?.height).toBe(2.5) + }) + + test('resolves buildings via parentId, legacy children membership, and non-building parents', () => { + const nodes = buildNodes([ + // level_direct is not in children; level_site has a non-building parentId. + building('building_x', ['level_legacy', 'level_site']), + level('level_direct', 0, { height: 3, parentId: 'building_x' }), + level('level_legacy', 1, { height: 2.5, parentId: null }), + level('level_site', 2, { height: 2.75, parentId: 'site_main' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_direct')?.buildingId).toBe('building_x') + expect(elevations.get('level_legacy')?.buildingId).toBe('building_x') + expect(elevations.get('level_site')?.buildingId).toBe('building_x') + expect(elevations.get('level_direct')?.baseY).toBe(0) + expect(elevations.get('level_legacy')?.baseY).toBe(3) + expect(elevations.get('level_site')?.baseY).toBe(5.5) + }) + + test('levels with no resolvable building share one legacy stack from 0', () => { + const nodes = buildNodes([ + level('level_orphan_1', 1, { height: 3 }), + level('level_orphan_0', 0, { height: 2.75 }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_orphan_0')).toEqual({ + baseY: 0, + height: 2.75, + buildingId: null, + ordinal: 0, + }) + expect(elevations.get('level_orphan_1')?.baseY).toBe(2.75) + }) +}) + +describe('getLevelAbove', () => { + test('returns the next-higher ordinal in the same building, skipping ordinal gaps', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_2', 'level_5']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_5', 5, { parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + expect(getLevelAbove('level_0', nodes)?.id).toBe('level_2') + expect(getLevelAbove('level_2', nodes)?.id).toBe('level_5') + expect(getLevelAbove('level_5', nodes)).toBeNull() + }) + + test('never crosses into another building', () => { + const nodes = buildNodes([ + building('building_a', ['level_a0']), + building('building_b', ['level_b0', 'level_b1']), + level('level_a0', 0, { parentId: 'building_a' }), + level('level_b0', 0, { parentId: 'building_b' }), + level('level_b1', 1, { parentId: 'building_b' }), + ]) + + expect(getLevelAbove('level_a0', nodes)).toBeNull() + expect(getLevelAbove('level_b0', nodes)?.id).toBe('level_b1') + }) + + test('orphan levels resolve within the shared legacy stack', () => { + const nodes = buildNodes([ + level('level_orphan_0', 0, { height: 2.75 }), + level('level_orphan_1', 1, { height: 3 }), + ]) + + expect(getLevelAbove('level_orphan_0', nodes)?.id).toBe('level_orphan_1') + expect(getLevelAbove('level_orphan_1', nodes)).toBeNull() + }) + + test('returns null for an unknown level id', () => { + const nodes = buildNodes([level('level_0', 0)]) + expect(getLevelAbove('level_missing', nodes)).toBeNull() + }) +}) + +describe('getLevelBelow', () => { + test('returns the next-lower ordinal in the same building, skipping ordinal gaps', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_2', 'level_5']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_5', 5, { parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + expect(getLevelBelow('level_5', nodes)?.id).toBe('level_2') + expect(getLevelBelow('level_2', nodes)?.id).toBe('level_0') + expect(getLevelBelow('level_0', nodes)).toBeNull() + }) + + test('never crosses into another building', () => { + const nodes = buildNodes([ + building('building_a', ['level_a0']), + building('building_b', ['level_b0', 'level_b1']), + level('level_a0', 0, { parentId: 'building_a' }), + level('level_b0', 0, { parentId: 'building_b' }), + level('level_b1', 1, { parentId: 'building_b' }), + ]) + + expect(getLevelBelow('level_a0', nodes)).toBeNull() + expect(getLevelBelow('level_b1', nodes)?.id).toBe('level_b0') + }) + + test('returns null for an unknown level id', () => { + const nodes = buildNodes([level('level_0', 0)]) + expect(getLevelBelow('level_missing', nodes)).toBeNull() + }) +}) + +// Two stacked levels in one building; `slabs` become children of the level +// above the queried one. +const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) => + buildNodes([ + building('building_a', ['level_0', 'level_1']), + level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }), + level('level_1', 1, { + height: 2.5, + parentId: 'building_a', + children: slabs.map((node) => node.id), + }), + ...slabs, + ]) + +describe('getCoveringSlabUndersideAt', () => { + test('expresses a flush deck underside in the queried level local Y', () => { + // Flush deck occupying [-0.3, 0] above the plane: underside sits at + // storeyHeight + (0 - 0.3) = 2.2 over the queried level's floor. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2) + }) + + test('returns null outside the slab polygon', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 10, 10)).toBeNull() + }) + + test('a hole in the slab vetoes coverage', () => { + const nodes = stackedNodes([ + slabNode('slab_deck', { + elevation: 0, + thickness: 0.3, + holes: [ + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ], + ], + }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull() + expect(getCoveringSlabUndersideAt('level_0', nodes, 0.5, 0.5)).toBeCloseTo(2.2) + }) + + test('recessed pools never cover', () => { + const nodes = stackedNodes([ + slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull() + }) + + test('the lowest underside wins among overlapping covering slabs', () => { + const nodes = stackedNodes([ + // Default floor slab occupying [0, 0.05]: underside at the plane (2.5). + slabNode('slab_floor', {}), + slabNode('slab_deck', { elevation: 0, thickness: 0.3 }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2) + }) + + test('returns null when there is no level above', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_1', nodes, 2, 2)).toBeNull() + }) +}) + +describe('getWallPlaneTop', () => { + const wallAt = ( + start: [number, number], + end: [number, number], + ): { start: [number, number]; end: [number, number] } => ({ start, end }) + + test('no covering slab → the stored level height', () => { + const nodes = stackedNodes([], 3) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(3) + }) + + test('a flush thick deck above clamps the plane to its underside', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a slab covering only part of the span clamps via the min of the samples', () => { + // Deck over x ∈ [3.5, 6]: start (0,2) and chord midpoint (2,2) miss it, + // only the end sample (4,2) lands inside — the min still clamps. + const nodes = stackedNodes([ + slabNode('slab_deck', { + polygon: [ + [3.5, 0], + [6, 0], + [6, 4], + [3.5, 4], + ], + elevation: 0, + thickness: 0.3, + }), + ]) + expect(getWallPlaneTop(wallAt([0, 2], [4, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a recessed slab above is ignored', () => { + const nodes = stackedNodes([ + slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }), + ]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(2.5) + }) + + test('falls back to the default height when the level does not resolve', () => { + const nodes = stackedNodes([]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_missing', nodes)).toBe( + DEFAULT_LEVEL_HEIGHT, + ) + }) + + test('repro project: both boundary walls clamp to the covering slab underside', () => { + // Real scene subset (project_O1z9NLOylyb5kFX4): the level-1 auto slab's + // polygon derives from the level-0 wall CENTERLINES, so every perimeter + // wall's samples sit exactly ON the polygon boundary. Wall 2 (min-x edge) + // clamped while Wall 1 (max-z edge) ran full height — ray-cast + // pointInPolygon includes min-side boundaries and excludes max-side ones. + const nodes = reproFixture as unknown as Record + const levelId = 'level_pomuk0sbwec15mf3' + const wall1 = nodes['wall_39bnnq29h824ryy0' as AnyNodeId] as WallNode + const wall2 = nodes['wall_on4rj410n69n3rzf' as AnyNodeId] as WallNode + // storeyHeight 2.7 + (slab elevation 0.19757… - thickness 0.5) + const underside = 2.7 + (0.19757210573188194 - 0.5) + expect(getWallPlaneTop(wall1, levelId, nodes)).toBeCloseTo(underside) + expect(getWallPlaneTop(wall2, levelId, nodes)).toBeCloseTo(underside) + }) + + test('all four rectangle walls under a same-footprint covering slab clamp', () => { + // The repro shape distilled: wall centerlines lie exactly on the covering + // slab's polygon edges. Every orientation must clamp identically. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + const walls: Array<[[number, number], [number, number]]> = [ + [ + [0, 0], + [4, 0], + ], + [ + [4, 0], + [4, 4], + ], + [ + [4, 4], + [0, 4], + ], + [ + [0, 4], + [0, 0], + ], + ] + for (const [start, end] of walls) { + expect(getWallPlaneTop(wallAt(start, end), 'level_0', nodes)).toBeCloseTo(2.2) + } + }) + + test('a diagonal wall under the covering slab clamps', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([0.5, 0.5], [3.5, 3.5]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a wall fully outside the covering slab keeps the storey height', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([6, 0], [6, 4]), 'level_0', nodes)).toBe(2.5) + }) + + test('a wall partially overlapping the covering slab clamps', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([2, 2], [8, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) +}) + +describe('getCeilingClampBound', () => { + const ceilingPolygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] + + test('with no covering slab the bound is the storey plane minus the margin', () => { + const nodes = stackedNodes([]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.5 - CEILING_CLAMP_MARGIN, + ) + }) + + test('a covering deck lowers the bound to its underside minus the margin', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) + + test('a slab covering only the interior is caught by the centroid sample', () => { + // Deck hovers over the middle of the ceiling — every vertex sample + // misses, only the centroid (2, 2) lands inside it. + const nodes = stackedNodes([ + slabNode('slab_deck', { + polygon: [ + [1.5, 1.5], + [2.5, 1.5], + [2.5, 2.5], + [1.5, 2.5], + ], + elevation: 0, + thickness: 0.3, + }), + ]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) + + test('returns Infinity for an unresolvable level', () => { + const nodes = stackedNodes([]) + expect(getCeilingClampBound('level_missing', nodes, ceilingPolygon)).toBe( + Number.POSITIVE_INFINITY, + ) + }) + + test('vertices on the covering slab boundary clamp identically on every side', () => { + // Two mirrored strips share an edge with the 4x4 deck: one along its + // min-z edge, one along its max-z edge. Their interiors and centroids sit + // outside the deck, so only the shared-edge vertices can register — + // ray-cast pointInPolygon used to admit the min-side vertices and reject + // the max-side ones, giving orientation-dependent clamps. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + const minSideStrip: Array<[number, number]> = [ + [0, -1], + [4, -1], + [4, 0], + [0, 0], + ] + const maxSideStrip: Array<[number, number]> = [ + [0, 4], + [4, 4], + [4, 5], + [0, 5], + ] + expect(getCeilingClampBound('level_0', nodes, minSideStrip)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + expect(getCeilingClampBound('level_0', nodes, maxSideStrip)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) +}) diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts new file mode 100644 index 00000000..4fd6563f --- /dev/null +++ b/packages/core/src/services/storey.ts @@ -0,0 +1,358 @@ +import type { BuildingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { + pointInPolygon, + pointOnPolygonBoundary, + wallOverlapsSlabFootprint, +} from '../systems/slab/slab-support' +import { DEFAULT_LEVEL_HEIGHT } from './level-height' + +/** + * Gap kept between a ceiling's stored height and its clamp bound (storey + * plane or covering-slab underside), so the ceiling surface never + * coincides with the solid above it. + */ +export const CEILING_CLAMP_MARGIN = 0.01 + +/** + * Stored storey height in meters (floor-to-floor). Falls back to + * {@link DEFAULT_LEVEL_HEIGHT} for unmigrated legacy levels whose `height` + * field is absent. + */ +export function getStoredLevelHeight(level: Pick): number { + return level.height ?? DEFAULT_LEVEL_HEIGHT +} + +export type LevelElevation = { + /** World Y of the level's floor: prefix sum of the storey heights below it. */ + baseY: number + /** Stored storey height of this level (fallback applied). */ + height: number + buildingId: string | null + ordinal: number +} + +/** + * Resolves the owning building: explicit `parentId` pointing at a building + * wins; legacy levels that only appear in a building's `children` array + * resolve through that membership. + */ +function resolveLevelBuildingId( + levelId: LevelNode['id'], + parentId: string | null, + buildings: readonly BuildingNode[], +): string | null { + const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined + if (directParent) return directParent.id + + return buildings.find((building) => building.children.includes(levelId))?.id ?? null +} + +/** + * Per-building stacked elevations from stored storey heights: levels are + * sorted by ordinal ascending within each building, the lowest level's floor + * sits at 0, and each next floor sits on top of the previous storey height. + * Levels with no resolvable building share one legacy stack from 0. + * + * Pure — operates on the serialized nodes record only. + */ +export function getLevelElevations(nodes: Record): Map { + const buildings = Object.values(nodes).filter( + (node): node is BuildingNode => node?.type === 'building', + ) + + const entries: Array<{ levelId: string } & LevelElevation> = [] + for (const node of Object.values(nodes)) { + if (node?.type !== 'level') continue + const level = node as LevelNode + entries.push({ + levelId: level.id, + baseY: 0, + height: getStoredLevelHeight(level), + buildingId: resolveLevelBuildingId(level.id, level.parentId, buildings), + ordinal: level.level, + }) + } + + const elevations = new Map() + const cumulativeYByBuilding = new Map() + for (const entry of entries.sort((a, b) => a.ordinal - b.ordinal)) { + const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0 + elevations.set(entry.levelId, { + baseY, + height: entry.height, + buildingId: entry.buildingId, + ordinal: entry.ordinal, + }) + cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height) + } + + return elevations +} + +/** + * The id of the level directly above `levelId` in its own stack (same + * resolved building, or the shared legacy stack for building-less levels): + * the level with the lowest ordinal strictly greater than the queried + * level's. `null` when the level is topmost or unresolvable. + */ +export function findLevelAboveId( + levelId: string, + elevations: Map, +): string | null { + const entry = elevations.get(levelId) + if (!entry) return null + + let aboveId: string | null = null + let aboveOrdinal = Number.POSITIVE_INFINITY + for (const [candidateId, candidate] of elevations) { + if (candidateId === levelId) continue + if (candidate.buildingId !== entry.buildingId) continue + if (candidate.ordinal > entry.ordinal && candidate.ordinal < aboveOrdinal) { + aboveOrdinal = candidate.ordinal + aboveId = candidateId + } + } + return aboveId +} + +/** + * The level directly above `levelId` — see {@link findLevelAboveId}. + * `null` when topmost or unresolvable. Pure. + */ +export function getLevelAbove( + levelId: string, + nodes: Record, +): LevelNode | null { + const aboveId = findLevelAboveId(levelId, getLevelElevations(nodes)) + if (!aboveId) return null + const above = nodes[aboveId as LevelNode['id']] + return above?.type === 'level' ? (above as LevelNode) : null +} + +/** + * The id of the level directly below `levelId` in its own stack — mirror of + * {@link findLevelAboveId}: the level with the highest ordinal strictly less + * than the queried level's. `null` when the level is lowest or unresolvable. + */ +export function findLevelBelowId( + levelId: string, + elevations: Map, +): string | null { + const entry = elevations.get(levelId) + if (!entry) return null + + let belowId: string | null = null + let belowOrdinal = Number.NEGATIVE_INFINITY + for (const [candidateId, candidate] of elevations) { + if (candidateId === levelId) continue + if (candidate.buildingId !== entry.buildingId) continue + if (candidate.ordinal < entry.ordinal && candidate.ordinal > belowOrdinal) { + belowOrdinal = candidate.ordinal + belowId = candidateId + } + } + return belowId +} + +/** + * The level directly below `levelId` — see {@link findLevelBelowId}. + * `null` when lowest or unresolvable. Pure. + */ +export function getLevelBelow( + levelId: string, + nodes: Record, +): LevelNode | null { + const belowId = findLevelBelowId(levelId, getLevelElevations(nodes)) + if (!belowId) return null + const below = nodes[belowId as LevelNode['id']] + return below?.type === 'level' ? (below as LevelNode) : null +} + +type CoveringSlabContext = { + /** Stored storey height of the QUERIED level. */ + storeyHeight: number + /** Non-recessed slab children of the level above. */ + slabs: SlabNode[] +} + +/** + * Storey height of the queried level plus the level-above's covering + * (non-recessed) slabs. `null` when `levelId` doesn't resolve to a level. + * A missing level above yields an empty slab list, not `null` — the + * storey height is still meaningful for the clamp bound. + */ +function resolveCoveringSlabContext( + levelId: string, + nodes: Record, +): CoveringSlabContext | null { + const level = nodes[levelId as LevelNode['id']] + if (level?.type !== 'level') return null + + const above = getLevelAbove(levelId, nodes) + const slabs: SlabNode[] = [] + for (const childId of above?.children ?? []) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'slab') continue + const slab = child as SlabNode + // Recessed slabs (pools) are open shells, not covering solids. + if (slab.recessed === true) continue + if (slab.polygon.length < 3) continue + slabs.push(slab) + } + + return { storeyHeight: getStoredLevelHeight(level as LevelNode), slabs } +} + +/** + * Underside of `slab`'s solid in the QUERIED level's local Y. The solid + * occupies `[elevation - thickness, elevation]` in ITS level's local Y, + * which sits `storeyHeight` above the queried level's floor. + */ +function coveringUndersideY(storeyHeight: number, slab: SlabNode): number { + return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05)) +} + +/** + * Whether `slab`'s stored footprint (polygon minus holes) covers `[x, z]`. + * Ray-cast pointInPolygon flips arbitrarily for points exactly ON the + * boundary (min-side edges read inside, max-side edges outside), so + * boundary contact counts as covered explicitly — the same convention as + * the slab-support interval classification. A point on a hole's rim keeps + * coverage (mirrors the support election's hole handling). + * + * Raw stored polygon + holes on purpose (mirrors getCeilingAt): the + * clamp bound doesn't need the rendered footprint's junction trims, + * and staying off the render path keeps this query cheap and pure. + */ +function slabCoversPoint(slab: SlabNode, x: number, z: number): boolean { + if (!pointInPolygon(x, z, slab.polygon) && !pointOnPolygonBoundary(x, z, slab.polygon)) { + return false + } + for (const hole of slab.holes ?? []) { + if (hole.length < 3) continue + if (pointInPolygon(x, z, hole) && !pointOnPolygonBoundary(x, z, hole)) return false + } + return true +} + +/** + * Lowest underside among `slabs` covering `[x, z]`, in the queried + * level's local Y, or `null` when none covers the point. + */ +function lowestCoveringUndersideAt( + context: CoveringSlabContext, + x: number, + z: number, +): number | null { + let lowest: number | null = null + for (const slab of context.slabs) { + if (!slabCoversPoint(slab, x, z)) continue + const underside = coveringUndersideY(context.storeyHeight, slab) + if (lowest === null || underside < lowest) lowest = underside + } + return lowest +} + +/** + * Underside of the LOWEST slab from the level above that covers + * level-local point `[x, z]`, expressed in the queried level's local Y: + * `storeyHeight + (slab.elevation - slab.thickness)`. `recessed` slabs + * (pools) never cover. `null` when no covering slab (or no level above). + * + * Coordinate spaces: levels stack in Y only (`LevelNode` carries no XZ + * transform and the viewer's LevelSystem writes only `position.y`), so a + * level-local `[x, z]` is valid in every level of the stack unchanged. + */ +export function getCoveringSlabUndersideAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return null + return lowestCoveringUndersideAt(context, x, z) +} + +/** + * Top plane for a plane-bound wall on `levelId`, in level-local Y: + * `min(stored storey height, lowest covering-slab underside over the wall's + * span)` — a thick or flush slab on the level above SHORTENS the walls below + * instead of colliding with them (Revit-style automatic attach). + * + * Coverage: the wall's thickness band (centerline + face lines, arc-aware) + * is clipped against each covering slab's stored polygon minus holes via + * {@link wallOverlapsSlabFootprint} — the same overlap machinery as the + * support election. Point sampling is deliberately avoided: auto-slab + * polygons derive from wall CENTERLINES, so perimeter walls sit exactly ON + * the polygon boundary, where ray-cast point-in-polygon flips with the + * edge's orientation (one wall clamped, its neighbor didn't). Boundary + * contact counts as covered on every side of the slab. + * + * This is THE plane for a plane-bound wall (`height` absent). Explicit-height + * walls ignore the value (`resolveWallTop` returns their stored height), so + * passing it wherever a raw storey height feeds `resolveWallTop` / + * `resolveWallEffectiveHeight` is always safe. Falls back to + * {@link DEFAULT_LEVEL_HEIGHT} when `levelId` doesn't resolve to a level. + */ +export function getWallPlaneTop( + wall: Pick & Partial>, + levelId: string, + nodes: Record, +): number { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return DEFAULT_LEVEL_HEIGHT + + let plane = context.storeyHeight + for (const slab of context.slabs) { + const underside = coveringUndersideY(context.storeyHeight, slab) + if (underside >= plane) continue + if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue + plane = underside + } + return plane +} + +/** + * Upper bound for a ceiling's stored height over `polygon` on `levelId`: + * `min(storey plane, lowest covering-slab underside) - CEILING_CLAMP_MARGIN`. + * The covering underside is sampled at every polygon vertex plus the + * centroid — cheap, and a slab overlapping a convex-ish ceiling almost + * always covers one of those points; exact polygon-vs-polygon overlap is + * not worth its cost for a clamp bound. Ceiling outlines share footprint + * edges with the slabs above them the same way walls do, so vertices + * sitting exactly on a slab's boundary count as covered on every side + * (see `slabCoversPoint`) instead of flipping with the edge orientation. + * + * Returns `Infinity` when `levelId` doesn't resolve, so callers clamp + * against nothing rather than a garbage plane. + */ +export function getCeilingClampBound( + levelId: string, + nodes: Record, + polygon: ReadonlyArray<[number, number]>, +): number { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return Number.POSITIVE_INFINITY + + let bound = context.storeyHeight + if (polygon.length > 0) { + let cx = 0 + let cz = 0 + for (const [x, z] of polygon) { + cx += x + cz += z + } + const samples: Array<[number, number]> = [ + ...polygon, + [cx / polygon.length, cz / polygon.length], + ] + for (const [x, z] of samples) { + const underside = lowestCoveringUndersideAt(context, x, z) + if (underside !== null && underside < bound) bound = underside + } + } + + return bound - CEILING_CLAMP_MARGIN +} diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 3b0d09b5..e56623ec 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -498,8 +498,21 @@ function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode { return sanitized.value as AnyNode } +// An explicit `key: undefined` in update data REMOVES the key: optional +// fields like wall.height encode a mode by their absence (absent = +// plane-bound top), and zod's safeParse echoes explicit-undefined keys, so +// a plain spread would leave a lingering own key that breaks `'height' in +// node` checks. +function mergeNodeUpdate(currentNode: AnyNode, patch: Partial): AnyNode { + const merged: Record = { ...currentNode, ...patch } + for (const key of Object.keys(patch)) { + if ((patch as Record)[key] === undefined) delete merged[key] + } + return merged as AnyNode +} + function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode { - const candidate = { ...currentNode, ...data } + const candidate = mergeNodeUpdate(currentNode, data) const parsed = AnyNodeSchema.safeParse(candidate) if (parsed.success) return parsed.data @@ -507,12 +520,12 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode const sanitized = sanitizeNumericValue(schema, data, currentNode, []) if (sanitized.issues.length === 0) { - return candidate as AnyNode + return candidate } warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues) - return { ...currentNode, ...(sanitized.value as Partial) } as AnyNode + return mergeNodeUpdate(currentNode, sanitized.value as Partial) } function shouldRefreshDefaultRidgeVents(data: Partial) { @@ -590,7 +603,10 @@ function areWallStylesCompatible(a: WallNode, b: WallNode) { (a.parentId ?? null) === (b.parentId ?? null) && Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && - Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && + // Absent height means plane-bound (follows the storey), which must never + // merge with an explicit height — even one that currently matches the plane. + (a.height == null) === (b.height == null) && + Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 && aInterior === bInterior && aExterior === bExterior && a.frontSide === b.frontSide && @@ -1129,6 +1145,31 @@ export const deleteNodesAction = ( } } + // Deleting a slab strips `supportSlabId` / `deckSlabId` references from + // surviving nodes in the same undo commit (mirrors the collectionIds + // cleanup below), so those nodes re-elect their support / re-derive + // their rise. Deletion is the ONLY writer — a host merely reshaped away + // keeps the field and the read path falls back, letting hosting resume + // if the slab returns. + const deletedSlabIds = new Set() + for (const id of allIds) { + if (nextNodes[id]?.type === 'slab') deletedSlabIds.add(id) + } + if (deletedSlabIds.size > 0) { + for (const [nodeId, node] of Object.entries(nextNodes)) { + if (allIds.has(nodeId as AnyNodeId)) continue + const patch: { supportSlabId?: undefined; deckSlabId?: undefined } = {} + const hostId = (node as { supportSlabId?: string }).supportSlabId + if (hostId && deletedSlabIds.has(hostId)) patch.supportSlabId = undefined + const deckId = (node as { deckSlabId?: string }).deckSlabId + if (deckId && deletedSlabIds.has(deckId)) patch.deckSlabId = undefined + if (Object.keys(patch).length > 0) { + nextNodes[nodeId as AnyNodeId] = { ...node, ...patch } as AnyNode + nodesToMarkDirty.add(nodeId as AnyNodeId) + } + } + } + for (const id of allIds) { const node = nextNodes[id] if (!node) continue diff --git a/packages/core/src/store/actions/node-mutation-sanitize.test.ts b/packages/core/src/store/actions/node-mutation-sanitize.test.ts index 1863b538..3e54d6d4 100644 --- a/packages/core/src/store/actions/node-mutation-sanitize.test.ts +++ b/packages/core/src/store/actions/node-mutation-sanitize.test.ts @@ -14,6 +14,22 @@ type RafFn = (cb: (t: number) => void) => number const SHELF_ID = 'shelf_sanitize' as AnyNodeId const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId +const WALL_ID = 'wall_keyremoval' as AnyNodeId + +function makeWall(): AnyNode { + return { + id: WALL_ID, + type: 'wall', + parentId: null, + object: 'node', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + height: 2.5, + } as unknown as AnyNode +} function makeShelf(overrides: Partial = {}): AnyNode { return { @@ -173,3 +189,45 @@ describe('node mutation numeric sanitization', () => { expect(Number.isFinite(created.thickness)).toBe(true) }) }) + +describe('node update explicit-undefined key removal', () => { + beforeEach(() => { + useScene.setState({ + nodes: { [WALL_ID]: makeWall() }, + rootNodeIds: [WALL_ID], + dirtyNodes: new Set(), + collections: {}, + readOnly: false, + } as never) + useScene.temporal.getState().clear() + }) + + test('an undefined value in update data removes the key from the stored node', () => { + useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial) + + const wall = useScene.getState().nodes[WALL_ID] as Record + expect('height' in wall).toBe(false) + }) + + test('undo restores a key removed via an undefined update value', () => { + useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial) + expect('height' in (useScene.getState().nodes[WALL_ID] as Record)).toBe(false) + + useScene.temporal.getState().undo() + + const wall = useScene.getState().nodes[WALL_ID] as { height?: number } + expect('height' in wall).toBe(true) + expect(wall.height).toBe(2.5) + }) + + test('other keys in the same patch still apply when one is removed', () => { + useScene.getState().updateNode(WALL_ID, { + height: undefined, + name: 'Plane-bound wall', + } as Partial) + + const wall = useScene.getState().nodes[WALL_ID] as Record + expect('height' in wall).toBe(false) + expect(wall.name).toBe('Plane-bound wall') + }) +}) diff --git a/packages/core/src/store/use-live-transforms.ts b/packages/core/src/store/use-live-transforms.ts index b2aef7dd..6c235583 100644 --- a/packages/core/src/store/use-live-transforms.ts +++ b/packages/core/src/store/use-live-transforms.ts @@ -7,6 +7,14 @@ import { create } from 'zustand' export type LiveTransform = { position: [number, number, number] rotation: number // Y-axis rotation (plan-view rotation) + /** + * Pointer-decided support cap (level-local Y) published by 3D drags: + * the elevation of the surface the cursor ray actually points at. The + * floor-elevation system passes it to the slab-support election so a + * deck above the aimed-at floor never lifts the dragged node. Absent + * for 2D floorplan drags (no camera ray) — election stays uncapped. + */ + supportElevationCap?: number } type LiveTransformState = { diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index dd837e8c..1d35ed7d 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -342,7 +342,9 @@ describe('scene commit boundary', () => { const snapshot = currentSnapshot() snapshot.nodes = { ...snapshot.nodes, - [LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], level: 8 } as AnyNode, + // Marker must survive the load migration: level ordinals renumber on + // load, so the stored storey height marks the applied snapshot instead. + [LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], height: 8 } as AnyNode, } snapshot.installedPlugins = ['pascal:trees'] const commits: SceneCommit[] = [] @@ -350,7 +352,7 @@ describe('scene commit boundary', () => { useScene.getState().dirtyNodes.clear() expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true) - expect(levelNumber()).toBe(8) + expect((useScene.getState().nodes[LEVEL_ID] as { height?: number }).height).toBe(8) expect(useScene.getState().installedPlugins).toEqual(['pascal:trees']) expect(commits.map((commit) => commit.origin)).toEqual(['host']) expect(useScene.temporal.getState().pastStates).toHaveLength(0) diff --git a/packages/core/src/store/use-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts new file mode 100644 index 00000000..a95e7839 --- /dev/null +++ b/packages/core/src/store/use-scene-vertical-migration.test.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +type RawNode = Record + +function baseNode(id: string, type: string, parentId: string | null, extra: RawNode = {}): RawNode { + return { object: 'node', id, type, parentId, visible: true, metadata: {}, ...extra } +} + +function site(children: string[]): RawNode { + return baseNode('site_test', 'site', null, { children }) +} + +function building(id: string, children: string[]): RawNode { + return baseNode(id, 'building', 'site_test', { children }) +} + +function level( + id: string, + buildingId: string, + ordinal: number, + children: string[], + extra: RawNode = {}, +): RawNode { + return baseNode(id, 'level', buildingId, { level: ordinal, children, ...extra }) +} + +function wall( + id: string, + levelId: string, + start: [number, number], + end: [number, number], + height?: number, +): RawNode { + return baseNode(id, 'wall', levelId, { + start, + end, + children: [], + ...(height !== undefined ? { height } : {}), + }) +} + +function slab( + id: string, + levelId: string, + polygon: Array<[number, number]>, + elevation = 0.05, +): RawNode { + return baseNode(id, 'slab', levelId, { polygon, holes: [], elevation }) +} + +function ceiling( + id: string, + levelId: string, + polygon: Array<[number, number]>, + height: number, + extra: RawNode = {}, +): RawNode { + return baseNode(id, 'ceiling', levelId, { polygon, holes: [], height, ...extra }) +} + +function stair(id: string, levelId: string, extra: RawNode = {}): RawNode { + return baseNode(id, 'stair', levelId, { position: [1, 0, 1], children: [], ...extra }) +} + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +function loadScene(nodes: Record): Record { + useScene.getState().setScene(nodes as unknown as Record, ['site_test'] as never) + return useScene.getState().nodes as Record +} + +type LevelResult = Extract +type WallResult = Extract +type StairResult = Extract +type SlabResult = Extract +type CeilingResult = Extract + +describe('scene vertical model migration', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('default legacy storey derives height 2.5 and keeps walls plane-bound', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4]), + }) + + expect((nodes.level_a as LevelResult).height).toBe(2.5) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect('height' in (nodes.wall_b as WallResult)).toBe(false) + }) + + test('hole pattern: walls within 0.20 of the plane become plane-bound', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_tall', 'wall_a', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65), + wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]), + wall_b: wall('wall_b', 'level_a', [0, 4], [4, 4]), + }) + + // Plane 0.05 + 2.65 = 2.7; absent walls top out at 2.55, 0.15 short. + expect((nodes.level_a as LevelResult).height).toBe(0.05 + 2.65) + expect('height' in (nodes.wall_tall as WallResult)).toBe(false) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect('height' in (nodes.wall_b as WallResult)).toBe(false) + }) + + test('intentional short walls at or beyond 0.20 keep their explicit height', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a', 'wall_b']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0], 2.3), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.1), + }) + + expect((nodes.level_a as LevelResult).height).toBe(2.5) + expect((nodes.wall_a as WallResult).height).toBe(2.3) + expect((nodes.wall_b as WallResult).height).toBe(2.1) + }) + + test('absent-height wall well short of the plane materializes the 2.5 default', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 3.0), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + }) + + expect((nodes.level_a as LevelResult).height).toBe(3.0) + expect((nodes.wall_a as WallResult).height).toBe(2.5) + }) + + test('ordinal renumber compacts per building, anchored at zero', () => { + const nodes = loadScene({ + site_test: site(['building_a', 'building_b']), + building_a: building('building_a', ['level_a1', 'level_a2', 'level_a3']), + building_b: building('building_b', ['level_b1', 'level_b2', 'level_b3', 'level_b4']), + // Duplicate fractional ordinals (MCP wrote elevation params here). + level_a1: level('level_a1', 'building_a', 2.5, []), + level_a2: level('level_a2', 'building_a', 2.5, []), + level_a3: level('level_a3', 'building_a', 5, []), + // Basements compact upward toward -1, non-negatives down to 0. + level_b1: level('level_b1', 'building_b', -3, []), + level_b2: level('level_b2', 'building_b', -1, []), + level_b3: level('level_b3', 'building_b', 0, []), + level_b4: level('level_b4', 'building_b', 4, []), + }) + + expect((nodes.level_a1 as LevelResult).level).toBe(0) + expect((nodes.level_a2 as LevelResult).level).toBe(1) + expect((nodes.level_a3 as LevelResult).level).toBe(2) + + expect((nodes.level_b1 as LevelResult).level).toBe(-2) + expect((nodes.level_b2 as LevelResult).level).toBe(-1) + expect((nodes.level_b3 as LevelResult).level).toBe(0) + expect((nodes.level_b4 as LevelResult).level).toBe(1) + }) + + test('near-bound ceiling heights become follows-mode', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a', 'level_b']), + // Legacy default: ceiling 2.5 drives the derived level height 2.5, + // so the clamp bound is 2.49 and |2.5 − 2.49| < 0.20 → follows. + level_a: level('level_a', 'building_a', 0, ['ceiling_a']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5), + // Already write-clamped default: 2.49 under a derived 2.49 level + // (bound 2.48) → follows too. + level_b: level('level_b', 'building_a', 1, ['ceiling_b']), + ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 2.49), + }) + + expect('height' in (nodes.ceiling_a as CeilingResult)).toBe(false) + expect('height' in (nodes.ceiling_b as CeilingResult)).toBe(false) + }) + + test('an intentional low ceiling keeps its explicit height', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // The 3.0 wall drives the plane; the 2.0 ceiling sits 0.99 under + // the 2.99 bound — a deliberate dropped ceiling, kept explicit. + level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_low']), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0), + ceiling_low: ceiling('ceiling_low', 'level_a', SQUARE, 2.0), + }) + + expect((nodes.level_a as LevelResult).height).toBe(3.0) + expect((nodes.ceiling_low as CeilingResult).height).toBe(2.0) + }) + + test('autoFromWalls ceilings always convert to follows-mode', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // 2.2 is far from the 2.99 bound, but auto heights were always + // derived by the sync — never user intent — so it drops anyway. + level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_auto']), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0), + ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.2, { autoFromWalls: true }), + }) + + expect('height' in (nodes.ceiling_auto as CeilingResult)).toBe(false) + }) + + test('migrated scene keeps a near-bound ceiling height (gate respected)', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // Post-migration scene (level carries height): a stored 2.49 IS a + // deliberately typed value and must survive reloads. + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'ceiling_auto'], { height: 2.5 }), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.49), + ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.49, { autoFromWalls: true }), + }) + + expect((nodes.ceiling_a as CeilingResult).height).toBe(2.49) + expect((nodes.ceiling_auto as CeilingResult).height).toBe(2.49) + }) + + test('legacy scene drops totalRise 2.5 but keeps other rises', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['stair_a', 'stair_b']), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }), + }) + + expect('totalRise' in (nodes.stair_a as StairResult)).toBe(false) + expect((nodes.stair_b as StairResult).totalRise).toBe(3.1) + }) + + test('migrated scene keeps a deliberately typed totalRise 2.5', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['stair_a'], { height: 2.5 }), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + }) + + expect((nodes.stair_a as StairResult).totalRise).toBe(2.5) + }) + + test('already-migrated level and its walls are untouched', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b'], { height: 4.0 }), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.5), + }) + + expect((nodes.level_a as LevelResult).height).toBe(4.0) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect((nodes.wall_b as WallResult).height).toBe(2.5) + }) + + test('slab split writes thickness = elevation exactly for legacy solids', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'slab_b']), + slab_a: slab('slab_a', 'level_a', SQUARE, 0.3), + slab_b: slab('slab_b', 'level_a', SQUARE, 0), + }) + + const raised = nodes.slab_a as SlabResult + expect(raised.elevation).toBe(0.3) + expect(raised.thickness).toBe(0.3) + expect(raised.recessed).not.toBe(true) + + // Degenerate zero-elevation slab keeps its zero occupied interval — + // migration never clamps to MIN_SLAB_THICKNESS. + const flush = nodes.slab_b as SlabResult + expect(flush.elevation).toBe(0) + expect(flush.thickness).toBe(0) + }) + + test('slab split defaults an absent elevation to the effective 0.05 thickness', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + slab_a: baseNode('slab_a', 'slab', 'level_a', { polygon: SQUARE, holes: [] }), + }) + + expect((nodes.slab_a as SlabResult).thickness).toBe(0.05) + }) + + test('legacy pool becomes recessed with its elevation unchanged', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + slab_a: slab('slab_a', 'level_a', SQUARE, -0.15), + }) + + const pool = nodes.slab_a as SlabResult + expect(pool.elevation).toBe(-0.15) + expect(pool.recessed).toBe(true) + expect(pool.thickness).toBe(0.05) + }) + + test('slab with thickness already present is untouched', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + // A below-plane SOLID (already-split scene): the gate must not + // reinterpret its negative elevation as a pool. + slab_a: baseNode('slab_a', 'slab', 'level_a', { + polygon: SQUARE, + holes: [], + elevation: -0.15, + thickness: 0.3, + }), + }) + + const deck = nodes.slab_a as SlabResult + expect(deck.elevation).toBe(-0.15) + expect(deck.thickness).toBe(0.3) + expect('recessed' in deck).toBe(false) + }) + + test('migration is idempotent', () => { + const first = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a', 'level_b']), + level_a: level('level_a', 'building_a', 2.5, [ + 'slab_a', + 'wall_tall', + 'wall_a', + 'stair_a', + 'stair_b', + ]), + level_b: level('level_b', 'building_a', 5, ['ceiling_b', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65), + wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }), + ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 3.0), + wall_b: wall('wall_b', 'level_b', [0, 0], [4, 0]), + }) + + const second = loadScene(structuredClone(first) as unknown as Record) + + expect(second).toEqual(first) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 36848e84..be23e8e5 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -32,6 +32,10 @@ import { type SceneMaterialId, } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' +import { deriveLegacyLevelHeight } from '../services/level-height' +import { getCeilingClampBound } from '../services/storey' +import { computeWallSlabSupport } from '../systems/slab/slab-support' +import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' import { @@ -91,6 +95,7 @@ function getVector3(value: unknown, fallback: [number, number, number]): [number } function normalizeStairNode(node: Record) { + const hasTotalRise = 'totalRise' in node const sanitized = { ...node, position: getVector3(node.position, [0, 0, 0]), @@ -101,7 +106,7 @@ function normalizeStairNode(node: Record) { slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'), openingOffset: getFiniteNumber(node.openingOffset, 0), width: getFiniteNumber(node.width, 1), - totalRise: getFiniteNumber(node.totalRise, 2.5), + totalRise: hasTotalRise ? getFiniteNumber(node.totalRise, 2.5) : undefined, stepCount: getFiniteNumber(node.stepCount, 10), thickness: getFiniteNumber(node.thickness, 0.25), fillToFloor: getBoolean(node.fillToFloor, true), @@ -117,7 +122,13 @@ function normalizeStairNode(node: Record) { } const parsed = StairNodeSchema.safeParse(sanitized) - return parsed.success ? parsed.data : null + if (!parsed.success) return null + if (hasTotalRise) return parsed.data + // Absent `totalRise` means "rise derives from the storey height" and must + // survive the load: safeParse echoes the sanitized explicit-undefined key, + // which would flip `'totalRise' in node` checks — strip it back off. + const { totalRise: _totalRise, ...rest } = parsed.data + return rest } function normalizeStairSegmentNode(node: Record) { @@ -559,6 +570,15 @@ function migrateRoofSurfaceMaterials(node: Record) { return next } +// Walls whose top lands within this of the storey plane become plane-bound; +// ceilings whose stored height lands within this of their clamp bound become +// follows-mode (step 3f) — same census-backed threshold for both. +// From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to +// a taller wall) must snap to the plane, while intentional 0.20-short walls +// (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit +// height — hence 0.20 with a strictly-less-than comparison. +const PLANE_BOUND_EPSILON = 0.2 + function migrateNodes(nodes: Record): { nodes: Record mintedMaterials: Record @@ -886,6 +906,169 @@ function migrateNodes(nodes: Record): { } } + // Pass 3: vertical building model. + // A level without `height` marks a scene saved before the vertical model + // landed. Computed before this pass mutates anything: the stair-rise + // cleanup below must never run on already-migrated scenes. + const isLegacyScene = Object.values(patchedNodes).some( + (node) => node?.type === 'level' && !('height' in node), + ) + + // 3a. Ordinal renumber — always runs, per building (idempotent + // self-healing; MCP's create-level historically wrote its elevation PARAM + // into the ordinal, so fractional/duplicate ordinals exist in the wild). + const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building') + const levelsByBuilding = new Map>() + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'level') continue + // Mirrors the building resolution in services/storey.ts: an explicit + // parentId pointing at a building wins, membership in a building's + // children array is the legacy fallback, and unresolvable levels share + // one orphan bucket. + const buildingId = + buildingNodes.find((building) => building.id === node.parentId)?.id ?? + buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ?? + null + const bucket = levelsByBuilding.get(buildingId) ?? [] + bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) }) + levelsByBuilding.set(buildingId, bucket) + } + for (const bucket of levelsByBuilding.values()) { + // Anchored at zero on purpose: ordinals are semantic — `level < 0` + // renders "Basement N" and `level === 0` is the ground-floor default — + // so negatives compact upward toward −1 and non-negatives compact down + // to 0. A blind 0..n renumber would rename basements. + const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal) + const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length + sorted.forEach((entry, index) => { + const nextOrdinal = index - negativeCount + const current = patchedNodes[entry.id] + if (current.level !== nextOrdinal) { + patchedNodes[entry.id] = { ...current, level: nextOrdinal } + } + }) + } + + // 3b. Stored storey heights: materialize the legacy stacked height verbatim + // (never rounded or snapped — snapping would move existing buildings). + // All planes derive before any wall height below mutates. + const legacyLevelIds = Object.entries(patchedNodes) + .filter(([, node]) => node?.type === 'level' && !('height' in node)) + .map(([id]) => id) + const derivedHeights = new Map() + for (const levelId of legacyLevelIds) { + derivedHeights.set( + levelId, + deriveLegacyLevelHeight(levelId, patchedNodes as Record), + ) + } + + for (const levelId of legacyLevelIds) { + const plane = derivedHeights.get(levelId)! + const level = patchedNodes[levelId] + patchedNodes[levelId] = { ...level, height: plane } + + // 3c. Wall-top classification against the just-written plane, using the + // same slab-support election as deriveLegacyLevelHeight (call shape + // mirrored from services/level-height.ts). Walls whose top meets the + // plane drop their explicit height and follow the level from now on; + // walls ending short (or tall) keep an explicit height — materializing + // the 2.5 default onto absent-height walls that end short of the plane. + const children = getStringArray(level.children) + .map((childId) => patchedNodes[childId]) + .filter((child) => child !== undefined) + const slabs = children.filter((child) => child.type === 'slab') + const walls = children.filter((child) => child.type === 'wall') + for (const wall of walls) { + const electedBase = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const top = Math.max(0, electedBase) + effectiveHeight + if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) { + if ('height' in wall) { + const { height: _height, ...planeBound } = wall + patchedNodes[wall.id] = planeBound + } + } else { + patchedNodes[wall.id] = { ...wall, height: effectiveHeight } + } + } + } + + // 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old + // schema default, not a user choice — drop it so the rise derives from the + // storey height. Gated on isLegacyScene because on a post-migration scene + // a stored 2.5 IS a deliberately typed value and must survive reloads. + if (isLegacyScene) { + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'stair') continue + if (node.totalRise !== 2.5) continue + const { totalRise: _totalRise, ...derivedRise } = node + patchedNodes[id] = derivedRise + } + } + + // 3e. Slab placement/thickness split. `elevation` stays the walking surface; + // the new `thickness` grows downward so the solid occupies + // [elevation − thickness, elevation]. Legacy solids extruded [0, elevation], + // so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS + // applies to edits only, never here) keeps the occupied interval identical. + // Legacy pools (elevation < 0) become explicit `recessed` intent with + // elevation unchanged. Gated per slab on a missing `thickness` — the + // migration output is cast, so schema defaults never materialize on load. + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'slab' || 'thickness' in node) continue + const elevation = getFiniteNumber(node.elevation, 0.05) + patchedNodes[id] = + elevation < 0 + ? { ...node, thickness: 0.05, recessed: true } + : { ...node, thickness: elevation } + } + + // 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs + // after 3b/3e so the clamp bound sees stored level heights and split slab + // thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its + // clamp bound (min(storey plane, covering-slab underside) − margin, via + // getCeilingClampBound) is the legacy default tracking the level top, not + // a choice — drop it so the ceiling follows the level from now on. + // autoFromWalls ceilings always convert: their height was derived by the + // space-detection sync, never user intent. Gated on isLegacyScene, which + // is exact — nothing shipped between the level-height migration and this + // one — and makes the step idempotent. Known accepted edge: a + // post-migration user typing a custom height exactly equal to the bound + // keeps it (the gate prevents re-classification on later loads). + if (isLegacyScene) { + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'ceiling' || !('height' in node)) continue + const dropHeight = () => { + const { height: _height, ...follows } = node + patchedNodes[id] = follows + } + if (node.autoFromWalls === true) { + dropHeight() + continue + } + if (typeof node.parentId !== 'string') continue + const bound = getCeilingClampBound( + node.parentId, + patchedNodes as Record, + Array.isArray(node.polygon) ? node.polygon : [], + ) + const stored = getFiniteNumber(node.height, Number.NaN) + if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) { + dropHeight() + } + } + } + return { nodes: patchedNodes as Record, mintedMaterials } } @@ -1163,6 +1346,7 @@ const useScene: UseSceneStore = create()( const level0 = LevelNode.parse({ level: 0, children: [], + height: 2.5, }) const building = BuildingNode.parse({ diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index 736ffe1d..124746e9 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -1,13 +1,5 @@ -import type { - AnyNode, - AnyNodeId, - CeilingNode, - ElevatorNode, - LevelNode, - WallNode, -} from '../../schema' - -export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 +import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema' +import { getStoredLevelHeight } from '../../services/storey' export type ElevatorLevelEntry = { id: LevelNode['id'] @@ -81,28 +73,6 @@ export function resolveElevatorServiceLevels( return levels.slice(minIndex, maxIndex + 1) } -export function getElevatorLevelHeight(levelId: string, nodes: Record): number { - const level = nodes[levelId as AnyNodeId] as LevelNode | undefined - if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT - - let maxTop = 0 - - for (const childId of level.children) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - - if (child.type === 'ceiling') { - const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT - if (height > maxTop) maxTop = height - } else if (child.type === 'wall') { - const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT - if (height > maxTop) maxTop = height - } - } - - return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT -} - export function resolveElevatorLevels( elevator: ElevatorNode, nodes: Record, @@ -119,7 +89,7 @@ export function resolveElevatorLevels( let cumulativeY = 0 for (const level of allLevels) { baseYByLevelId.set(level.id, cumulativeY) - cumulativeY += getElevatorLevelHeight(level.id, nodes) + cumulativeY += getStoredLevelHeight(level) } const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) diff --git a/packages/core/src/systems/slab/slab-support.test.ts b/packages/core/src/systems/slab/slab-support.test.ts new file mode 100644 index 00000000..758d31ab --- /dev/null +++ b/packages/core/src/systems/slab/slab-support.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'bun:test' +import { SlabNode, WallNode } from '../../schema' +import { MIN_WALL_HEIGHT } from '../wall/wall-top' +import { + clampSlabElevationForWalls, + computeWallSlabSupport, + getSlabElevationUpperBound, +} from './slab-support' + +// 4×3 room slab drawn on the wall centerlines, like an auto-slab. +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +const STOREY_HEIGHT = 2.7 +const BOUND = STOREY_HEIGHT - MIN_WALL_HEIGHT + +function roomSlab(elevation: number) { + return SlabNode.parse({ polygon: SQUARE, elevation, autoFromWalls: true }) +} + +function roomWalls(height?: number) { + return [ + WallNode.parse({ start: [0, 0], end: [4, 0], height }), + WallNode.parse({ start: [4, 0], end: [4, 3], height }), + WallNode.parse({ start: [4, 3], end: [0, 3], height }), + WallNode.parse({ start: [0, 3], end: [0, 0], height }), + ] +} + +describe('clampSlabElevationForWalls', () => { + it('clamps a slab under plane-bound walls at the plane minus MIN_WALL_HEIGHT', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(2.5, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(true) + expect(result.elevation).toBeCloseTo(BOUND) + }) + + it('leaves proposals at or below the bound untouched', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(BOUND, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(BOUND) + }) + + it('passes negative (recessed-committing) proposals through untouched', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(-0.6, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(-0.6) + }) + + it('does not clamp when the walls all carry explicit heights', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(2.5, slab, roomWalls(2.5), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(2.5) + }) + + it('does not clamp a slab covering no walls', () => { + const island = SlabNode.parse({ + polygon: [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + elevation: 0.05, + }) + const result = clampSlabElevationForWalls(2.5, island, roomWalls(), [island], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(2.5) + }) +}) + +describe('getSlabElevationUpperBound', () => { + it('bounds a slab electable by plane-bound walls', () => { + const slab = roomSlab(0.05) + expect(getSlabElevationUpperBound(slab, roomWalls(), [slab], STOREY_HEIGHT)).toBeCloseTo(BOUND) + }) + + it('is unbounded under explicit-height walls', () => { + const slab = roomSlab(0.05) + expect(getSlabElevationUpperBound(slab, roomWalls(2.5), [slab], STOREY_HEIGHT)).toBe( + Number.POSITIVE_INFINITY, + ) + }) +}) + +describe('computeWallSlabSupport preferred host', () => { + const wallLike = { start: [0, 1.5] as [number, number], end: [4, 1.5] as [number, number] } + const low = SlabNode.parse({ + id: 'slab_low', + polygon: SQUARE, + elevation: 0.1, + autoFromWalls: true, + }) + const high = SlabNode.parse({ + id: 'slab_high', + polygon: SQUARE, + elevation: 0.6, + autoFromWalls: true, + }) + + it('elects the highest supporting elevation without a preference', () => { + const support = computeWallSlabSupport(wallLike, [low, high], []) + expect(support.elevation).toBeCloseTo(0.6) + }) + + it('pins the elected elevation to a still-supporting preferred slab', () => { + const support = computeWallSlabSupport(wallLike, [low, high], [], 'slab_low') + expect(support.elevation).toBeCloseTo(0.1) + // Fill-down machinery still derives from ALL supporting slabs. + expect(support.baseSegments).toHaveLength(1) + expect(support.baseSegments[0]!.elevation).toBeCloseTo(0.6) + }) + + it('ignores a preferred slab that no longer supports the wall', () => { + const island = SlabNode.parse({ + id: 'slab_island', + polygon: [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + elevation: 0.9, + }) + const support = computeWallSlabSupport(wallLike, [low, high, island], [], 'slab_island') + expect(support.elevation).toBeCloseTo(0.6) + }) +}) diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts new file mode 100644 index 00000000..fb308d57 --- /dev/null +++ b/packages/core/src/systems/slab/slab-support.ts @@ -0,0 +1,688 @@ +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' +import type { SlabNode, WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve' +import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint' +import { MIN_WALL_HEIGHT } from '../wall/wall-top' + +export type SlabElevationClamp = { + elevation: number + clamped: boolean +} + +/** + * Clamp-never-ask upper bound for a slab's elevation. A plane-bound wall + * (no stored `height`) keeps its top at the storey plane, so a slab that + * rises past `storeyHeight - MIN_WALL_HEIGHT` while electing as that + * wall's base would squeeze the wall body below its minimum (and at the + * plane, to nothing). Walls with explicit heights don't constrain — their + * top rides the elected base, not the plane. Negative proposals (the + * drag-through-zero path that commits the `recessed` intent) pass + * through untouched: this is a purely numeric upper bound. + * + * The election runs against `levelSlabs` with `proposedElevation` + * substituted into `slab`, so a slab that would only WIN the election at + * the proposed elevation still clamps, and a slab out-elected by a + * sibling doesn't. Pure. + */ +export function clampSlabElevationForWalls( + proposedElevation: number, + slab: SlabNode, + levelWalls: WallNode[], + levelSlabs: readonly SlabNode[], + storeyHeight: number, +): SlabElevationClamp { + const bound = storeyHeight - MIN_WALL_HEIGHT + if (proposedElevation <= bound) return { elevation: proposedElevation, clamped: false } + if (slab.polygon.length < 3) return { elevation: proposedElevation, clamped: false } + + const substituted = levelSlabs.some((candidate) => candidate.id === slab.id) + ? levelSlabs.map((candidate) => + candidate.id === slab.id ? { ...candidate, elevation: proposedElevation } : candidate, + ) + : [...levelSlabs, { ...slab, elevation: proposedElevation }] + + for (const wall of levelWalls) { + if (wall.height != null) continue + const wallLike: WallOverlapInput = { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + } + // Cheap pre-filter: a wall that never reaches the slab's footprint + // can't elect it, whatever the election says about sibling slabs. + if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue + const support = computeWallSlabSupport(wallLike, substituted, levelWalls) + if (Math.abs(support.elevation - proposedElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) { + return { elevation: bound, clamped: true } + } + } + + return { elevation: proposedElevation, clamped: false } +} + +/** + * Static upper bound for a slab-elevation drag: probe the election with + * the slab raised above every sibling and the storey plane. If any + * plane-bound wall would elect it there, the drag may not pass + * `storeyHeight - MIN_WALL_HEIGHT`; otherwise it is unbounded above. + */ +export function getSlabElevationUpperBound( + slab: SlabNode, + levelWalls: WallNode[], + levelSlabs: readonly SlabNode[], + storeyHeight: number, +): number { + const probe = + Math.max(storeyHeight, ...levelSlabs.map((candidate) => candidate.elevation ?? 0.05)) + 1 + return clampSlabElevationForWalls(probe, slab, levelWalls, levelSlabs, storeyHeight).clamped + ? storeyHeight - MIN_WALL_HEIGHT + : Number.POSITIVE_INFINITY +} + +/** + * Point-in-polygon test using ray casting algorithm. + */ +export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean { + let inside = false + const n = polygon.length + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = polygon[i]![0], + zi = polygon[i]![1] + const xj = polygon[j]![0], + zj = polygon[j]![1] + + if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) { + inside = !inside + } + } + return inside +} + +function pointSegmentDistance( + px: number, + pz: number, + ax: number, + az: number, + bx: number, + bz: number, +): number { + const dx = bx - ax + const dz = bz - az + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az) + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared)) + return Math.hypot(px - (ax + dx * t), pz - (az + dz * t)) +} + +// Ray-cast pointInPolygon is unreliable for points exactly on the polygon +// boundary: the answer flips depending on which side of the polygon the edge +// is on. Interval classification below therefore treats "within this distance +// of the boundary" as inside explicitly, so walls sitting exactly on a slab +// edge (the common case — auto-slab polygons derive from wall centerlines) +// classify identically on every side of the slab. +const ON_BOUNDARY_EPSILON = 1e-4 + +export function pointOnPolygonBoundary( + px: number, + pz: number, + polygon: Array<[number, number]>, +): boolean { + const n = polygon.length + for (let i = 0; i < n; i++) { + const [ax, az] = polygon[i]! + const [bx, bz] = polygon[(i + 1) % n]! + if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true + } + return false +} + +/** Sub-interval along a segment or polyline: [start, end] in length units. */ +type LengthInterval = [number, number] + +function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] { + if (intervals.length <= 1) return intervals + const sorted = [...intervals].sort((a, b) => a[0] - b[0]) + const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]] + for (let i = 1; i < sorted.length; i++) { + const [intervalStart, intervalEnd] = sorted[i]! + const last = merged[merged.length - 1]! + if (intervalStart <= last[1] + 1e-9) { + last[1] = Math.max(last[1], intervalEnd) + } else { + merged.push([intervalStart, intervalEnd]) + } + } + return merged +} + +/** Total length of a merged (sorted, disjoint) interval list. */ +function intervalsLength(intervals: readonly LengthInterval[]): number { + let total = 0 + for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart + return total +} + +/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */ +function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] { + if (base.length === 0 || cut.length === 0) return mergeIntervals(base) + const cuts = mergeIntervals(cut) + const result: LengthInterval[] = [] + for (const [baseStart, baseEnd] of mergeIntervals(base)) { + let cursor = baseStart + for (const [cutStart, cutEnd] of cuts) { + if (cutEnd <= cursor) continue + if (cutStart >= baseEnd) break + if (cutStart > cursor) result.push([cursor, cutStart]) + cursor = cutEnd + if (cursor >= baseEnd) break + } + if (cursor < baseEnd) result.push([cursor, baseEnd]) + } + return result +} + +/** + * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and, + * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the + * segment. The segment is split at every crossing with a polygon edge and + * each sub-interval is classified by its midpoint, so no test point ever + * sits on a crossing. + */ +function segmentInsideIntervals( + ax: number, + az: number, + bx: number, + bz: number, + polygon: Array<[number, number]>, + includeBoundary: boolean, +): LengthInterval[] { + const dx = bx - ax + const dz = bz - az + const length = Math.hypot(dx, dz) + if (length < 1e-9) return [] + + const ts = [0, 1] + const n = polygon.length + for (let i = 0; i < n; i++) { + const [px, pz] = polygon[i]! + const [qx, qz] = polygon[(i + 1) % n]! + const ex = qx - px + const ez = qz - pz + const denom = dx * ez - dz * ex + if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at + const t = ((px - ax) * ez - (pz - az) * ex) / denom + const s = ((px - ax) * dz - (pz - az) * dx) / denom + if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t) + } + ts.sort((a, b) => a - b) + + const inside: LengthInterval[] = [] + for (let i = 1; i < ts.length; i++) { + const t0 = ts[i - 1]! + const t1 = ts[i]! + if (t1 - t0 < 1e-9) continue + const tm = (t0 + t1) / 2 + const mx = ax + dx * tm + const mz = az + dz * tm + const midpointInside = pointOnPolygonBoundary(mx, mz, polygon) + ? includeBoundary + : pointInPolygon(mx, mz, polygon) + if (midpointInside) inside.push([t0, t1]) + } + return inside +} + +function polylineLength(points: Array<{ x: number; y: number }>): number { + let total = 0 + for (let i = 1; i < points.length; i++) { + total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y) + } + return total +} + +/** + * Inside sub-intervals of a polyline against a polygon, in cumulative + * arc-length units from the polyline start (merged, disjoint). Boundary + * contact counts as inside for slab support (walls sit exactly on slab + * edges — see ON_BOUNDARY_EPSILON above); hole callers pass + * `includeBoundary: false` so a wall running along a stairwell hole's + * rim keeps the rim's support. + */ +function polylineInsideIntervals( + points: Array<{ x: number; y: number }>, + polygon: Array<[number, number]>, + includeBoundary = true, +): LengthInterval[] { + const intervals: LengthInterval[] = [] + let offset = 0 + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]! + const b = points[i]! + const segmentLength = Math.hypot(b.x - a.x, b.y - a.y) + if (segmentLength < 1e-9) continue + for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) { + intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength]) + } + offset += segmentLength + } + return mergeIntervals(intervals) +} + +export type WallOverlapInput = { + start: [number, number] + end: [number, number] + curveOffset?: number + thickness?: number +} + +// Minimum length of wall that must lie on/inside a slab polygon before the +// wall counts as overlapping it. Point contact (a perpendicular wall butting +// into a room's edge) clips to ~zero length and never reaches this, so such +// walls don't follow the slab's elevation. +const WALL_SLAB_MIN_OVERLAP = 0.05 + +/** + * Centerline of the wall plus its two face lines (centerline offset by + * ±halfThickness). The face lines catch walls whose centerline sits on or + * just outside the slab boundary but whose body reaches onto the slab — + * e.g. slab polygons drawn to the room's interior faces. + */ +function wallTestPolylines( + start: [number, number], + end: [number, number], + curveOffset: number, + halfThickness: number, +): Array> { + const wallLike = { start, end, curveOffset } + if (curveOffset !== 0 && isCurvedWall(wallLike)) { + const count = 16 + const center: Array<{ x: number; y: number }> = [] + const left: Array<{ x: number; y: number }> = [] + const right: Array<{ x: number; y: number }> = [] + for (let i = 0; i <= count; i++) { + const frame = getWallCurveFrameAt(wallLike, i / count) + center.push(frame.point) + left.push({ + x: frame.point.x + frame.normal.x * halfThickness, + y: frame.point.y + frame.normal.y * halfThickness, + }) + right.push({ + x: frame.point.x - frame.normal.x * halfThickness, + y: frame.point.y - frame.normal.y * halfThickness, + }) + } + return halfThickness > 0 ? [center, left, right] : [center] + } + + const center = [ + { x: start[0], y: start[1] }, + { x: end[0], y: end[1] }, + ] + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const len = Math.hypot(dx, dz) + if (len < 1e-10 || halfThickness <= 0) return [center] + const nx = (-dz / len) * halfThickness + const nz = (dx / len) * halfThickness + return [ + center, + [ + { x: start[0] + nx, y: start[1] + nz }, + { x: end[0] + nx, y: end[1] + nz }, + ], + [ + { x: start[0] - nx, y: start[1] - nz }, + { x: end[0] - nx, y: end[1] - nz }, + ], + ] +} + +/** + * Test whether a wall overlaps a slab polygon along a segment of its length. + * + * The wall's centerline and both face lines are clipped against the polygon; + * the wall overlaps when the longest clipped inside-or-on-boundary length + * exceeds a threshold (5cm, halved for very short walls). Because interval + * midpoints classify "on the boundary" as inside explicitly (never by + * ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves + * identically on every side of the slab. + * + * A wall that only touches the polygon at a point — a perpendicular wall + * butting into a room's edge, or a corner-to-corner touch — clips to ~zero + * length and does NOT overlap. + */ +export function wallOverlapsPolygon( + startOrWall: [number, number] | WallOverlapInput, + endOrPolygon: [number, number] | Array<[number, number]>, + polygonArg?: Array<[number, number]>, +): boolean { + // Two call shapes: + // wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware + // wallOverlapsPolygon(start, end, polygon) — legacy chord-only + let start: [number, number] + let end: [number, number] + let polygon: Array<[number, number]> + let curveOffset = 0 + let thickness = DEFAULT_WALL_THICKNESS + if (Array.isArray(startOrWall)) { + start = startOrWall as [number, number] + end = endOrPolygon as [number, number] + polygon = polygonArg as Array<[number, number]> + } else { + start = startOrWall.start + end = startOrWall.end + curveOffset = startOrWall.curveOffset ?? 0 + thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS + polygon = endOrPolygon as Array<[number, number]> + } + return wallOverlapsSlabFootprint({ start, end, curveOffset, thickness }, polygon) +} + +/** + * {@link wallOverlapsPolygon} with the slab's stored holes subtracted from + * the covered length: a wall whose band only reaches the polygon inside a + * hole does not overlap. Hole boundaries keep coverage (rim convention — + * see {@link computeWallSlabSupport}). Polygon boundary contact counts as + * covered, so a wall sitting exactly on a slab edge resolves identically + * on every side of the slab. Pure. + */ +export function wallOverlapsSlabFootprint( + wallLike: WallOverlapInput, + polygon: Array<[number, number]>, + holes?: ReadonlyArray>, +): boolean { + const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike + const halfThickness = Math.max(thickness / 2, 0) + + const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) + const centerLength = polylineLength(polylines[0]!) + if (centerLength < 1e-9) return false + + let overlap = 0 + for (const line of polylines) { + let intervals = polylineInsideIntervals(line, polygon) + for (const hole of holes ?? []) { + if (intervals.length === 0) break + if (hole.length < 3) continue + intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) + } + overlap = Math.max(overlap, intervalsLength(intervals)) + } + const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5)) + return overlap >= threshold +} + +/** + * Tolerance for the pointer-decided support cap: a slab still counts as + * "the surface you're pointing at (or below)" when its walking surface is + * within this many meters ABOVE the pointed elevation. Absorbs elevation + * noise between the ray hit and slab tops without letting a deck hanging + * clearly above the hit point capture the election. Defined here (rather + * than in the spatial-grid manager, which re-exports it) so the wall + * election below can honour the same cap without an import cycle. + */ +export const SUPPORT_ELEVATION_EPSILON = 0.05 + +// A slab elevation must support at least this fraction of the wall's +// length before it can dictate the wall's base. Below majority, a raised +// slab reaching one endpoint would hoist the whole wall off the floor +// that actually carries it. +const WALL_SLAB_SUPPORT_MAJORITY = 0.5 + +// Slabs whose elevations differ by less than this pool their support: +// a wall shared between two rooms' slabs is covered roughly half by +// each, and must still follow their common elevation. +const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 + +/** + * Base elevation for a wall, decided by which slabs actually SUPPORT it. + * + * Support is measured as covered length: the wall's centerline and face + * lines are clipped against each slab's RENDERED footprint + * (`getRenderableSlabPolygon` with the level walls + siblings, not the + * stored polygon — legacy polygons stored at wall faces or with old + * baked offsets fall short of the wall body, but their band-adopted + * rendered edge reaches the wall's outer face) minus the slab's stored + * holes (holes are data, never render-offset). A slab supporting less + * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point + * contact, endpoint grazes). + * + * Same-elevation slabs pool their coverage. `elevation` preserves the + * existing wall-relative origin: the highest elevation covering at + * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered + * elevation when none reaches majority. `baseElevation` only fills down + * where a lower support remains exposed on a wall face after higher, + * overlapping support is accounted for. Coincident floor/platform slabs + * therefore keep the wall on the platform, while slabs on opposite wall + * sides bridge correctly. A slab touching only one endpoint never enters + * either result. Pure; + * exported for tests. + */ +export type WallSlabSupport = { + /** Existing wall-relative floor elevation used by hosted children and wall height. */ + elevation: number + /** Slab whose elevation won the election, or null when the wall has no support. */ + electedSlabId: string | null + /** Lowest exposed adjacent support; wall geometry fills down to this elevation. */ + baseElevation: number + /** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */ + baseSegments: WallSlabSupportSegment[] +} + +export type WallSlabSupportSegment = { + start: number + end: number + elevation: number +} + +/** + * `preferredSlabId` is a persisted support host (`wall.supportSlabId`): + * while that slab is still in the candidate set (still overlaps the wall + * band with enough covered length), the elected `elevation` is pinned to + * it instead of the majority/best-coverage election. `baseSegments` / + * `baseElevation` (fill-down) still derive from ALL supporting slabs + * unchanged. A preferred slab that no longer qualifies is silently + * ignored — deliberately never cleared here, so the host resumes if the + * slab's polygon returns (only slab deletion strips the stored field). + * + * `maxElevation` is the pointer-decided support cap (level-local Y, same + * semantics as the item election): when set, elevation groups whose + * walking surface sits above `maxElevation + SUPPORT_ELEVATION_EPSILON` + * are excluded from the majority/best election — a deck hanging above the + * surface the cursor ray actually hit never captures the elected base. + * `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and + * an explicit `preferredSlabId` still wins over the cap. + */ +export function computeWallSlabSupport( + wallLike: WallOverlapInput, + slabs: readonly SlabNode[], + levelWalls: WallNode[], + preferredSlabId?: string | null, + maxElevation?: number | null, +): WallSlabSupport { + const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike + const halfThickness = Math.max(thickness / 2, 0) + const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) + const polylineLengths = polylines.map(polylineLength) + const wallLength = polylineLengths[0]! + if (wallLength < 1e-9) { + return { elevation: 0, electedSlabId: null, baseElevation: 0, baseSegments: [] } + } + + const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5)) + + type ElevationGroup = { + elevation: number + slabIds: string[] + perPolyline: LengthInterval[][] + } + const groups: ElevationGroup[] = [] + let preferredElevation: number | null = null + let preferredElectedSlabId: string | null = null + + for (const slab of slabs) { + if (slab.polygon.length < 3) continue + const renderedPolygon = getRenderableSlabPolygon(slab, { + walls: levelWalls, + siblingSlabs: slabs.filter((other) => other.id !== slab.id), + }) + + let supported = 0 + const perPolyline = polylines.map((line) => { + let intervals = polylineInsideIntervals(line, renderedPolygon) + for (const hole of slab.holes || []) { + if (intervals.length === 0) break + if (hole.length < 3) continue + intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) + } + supported = Math.max(supported, intervalsLength(intervals)) + return intervals + }) + if (supported < minSupport) continue + + const elevation = slab.elevation ?? 0.05 + if (preferredSlabId != null && slab.id === preferredSlabId) { + preferredElevation = elevation + preferredElectedSlabId = slab.id + } + let group = groups.find( + (candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, + ) + if (!group) { + group = { elevation, slabIds: [], perPolyline: polylines.map(() => []) } + groups.push(group) + } + group.slabIds.push(slab.id) + for (let i = 0; i < perPolyline.length; i++) { + group.perPolyline[i]!.push(...perPolyline[i]!) + } + } + + type EvaluatedGroup = ElevationGroup & { + coverage: number + mergedPerPolyline: LengthInterval[][] + } + const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { + let coverage = 0 + const mergedPerPolyline = group.perPolyline.map(mergeIntervals) + for (let i = 0; i < group.perPolyline.length; i++) { + const lineLength = polylineLengths[i]! + if (lineLength < 1e-9) continue + coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) + } + return { ...group, coverage, mergedPerPolyline } + }) + + const electableGroups = + maxElevation == null + ? evaluatedGroups + : evaluatedGroups.filter( + (group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, + ) + + let majorityElevation = Number.NEGATIVE_INFINITY + let bestElevation = Number.NEGATIVE_INFINITY + let bestCoverage = -1 + for (const group of electableGroups) { + if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { + majorityElevation = Math.max(majorityElevation, group.elevation) + } + if ( + group.coverage > bestCoverage + 1e-6 || + (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) + ) { + bestCoverage = group.coverage + bestElevation = group.elevation + } + } + + const elevation = + preferredElevation !== null + ? preferredElevation + : majorityElevation !== Number.NEGATIVE_INFINITY + ? majorityElevation + : bestElevation === Number.NEGATIVE_INFINITY + ? 0 + : bestElevation + const electedSlabId = + preferredElectedSlabId ?? + electableGroups + .find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) + ?.slabIds.slice() + .sort()[0] ?? + null + const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { + const lineLength = polylineLengths[polylineIndex]! + if (lineLength < 1e-9) return [] + return group.mergedPerPolyline[polylineIndex]!.map( + ([intervalStart, intervalEnd]) => + [intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval, + ) + } + + const normalizedByGroup = evaluatedGroups.map((group) => ({ + elevation: group.elevation, + perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)), + })) + const breakpoints = [0, 1] + for (const group of normalizedByGroup) { + for (const intervals of group.perPolyline) { + for (const [intervalStart, intervalEnd] of intervals) { + breakpoints.push(intervalStart, intervalEnd) + } + } + } + breakpoints.sort((left, right) => left - right) + const uniqueBreakpoints = breakpoints.filter( + (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, + ) + + const highestAt = (polylineIndex: number, t: number) => { + let highest = Number.NEGATIVE_INFINITY + for (const group of normalizedByGroup) { + if ( + group.perPolyline[polylineIndex]?.some( + ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, + ) + ) { + highest = Math.max(highest, group.elevation) + } + } + return highest + } + + const baseSegments: WallSlabSupportSegment[] = [] + for (let index = 1; index < uniqueBreakpoints.length; index++) { + const start = uniqueBreakpoints[index - 1]! + const end = uniqueBreakpoints[index]! + if (end - start < 1e-7) continue + const midpoint = (start + end) / 2 + const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY + const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY + const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) + const segmentElevation = + faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) + const previous = baseSegments[baseSegments.length - 1] + if ( + previous && + Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON + ) { + previous.end = end + } else { + baseSegments.push({ start, end, elevation: segmentElevation }) + } + } + + if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) + const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) + return { elevation, electedSlabId, baseElevation, baseSegments } +} + +export function computeWallSlabElevation( + wallLike: WallOverlapInput, + slabs: readonly SlabNode[], + levelWalls: WallNode[], +): number { + return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation +} diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 36b1c2ff..0cd760cf 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -9,8 +9,10 @@ import type { StairSegmentNode, SurfaceHoleMetadata, } from '../../schema' -import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' +import { resolveCeilingHeight } from '../../services/level-height' +import { getLevelElevations } from '../../services/storey' import { computeSegmentTransforms, rotateXZ } from './stair-footprint' +import { resolveStairTotalRise } from './stair-rise' type SegmentTransform = { position: [number, number, number] @@ -463,7 +465,7 @@ function getStraightOpeningPolygonsForSurface( const layouts = getStraightStairLayouts(stair, nodes) if (layouts.length === 0) return [] - const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) + const riserHeight = resolveStairTotalRise(stair, nodes) / Math.max(stair.stepCount ?? 10, 1) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) const openingRects: AxisAlignedRect[] = [] @@ -605,17 +607,16 @@ function getTargetSlabElevationForStair( nodes: Record, ) { const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) - const fromLevel = getLevelNumber(fromLevelId, nodes) - const slabLevel = getLevelNumber(slabLevelId, nodes) + const elevations = getLevelElevations(nodes as Record) + const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined + const slabElevation = elevations.get(slabLevelId) - if (fromLevel === undefined || slabLevel === undefined) { + if (!fromElevation || !slabElevation || fromElevation.buildingId !== slabElevation.buildingId) { return slab.elevation ?? 0.05 } return ( - (slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT + - (slab.elevation ?? 0.05) - - (stair.position[1] ?? 0) + slabElevation.baseY - fromElevation.baseY + (slab.elevation ?? 0.05) - (stair.position[1] ?? 0) ) } @@ -626,18 +627,21 @@ function getTargetCeilingElevationForStair( nodes: Record, ) { const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) - const fromLevel = getLevelNumber(fromLevelId, nodes) - const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + const elevations = getLevelElevations(nodes as Record) + const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined + const ceilingElevation = elevations.get(ceilingLevelId) - if (fromLevel === undefined || ceilingLevel === undefined) { - return ceiling.height ?? DEFAULT_WALL_HEIGHT + const ceilingHeight = resolveCeilingHeight(ceiling, nodes as Record) + + if ( + !fromElevation || + !ceilingElevation || + fromElevation.buildingId !== ceilingElevation.buildingId + ) { + return ceilingHeight } - return ( - (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + - (ceiling.height ?? DEFAULT_WALL_HEIGHT) - - (stair.position[1] ?? 0) - ) + return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0) } function shouldApplyStairToSlab( diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index b78224cf..357134e6 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useRef } from 'react' -import type { AnyNode } from '../../schema' +import type { AnyNode, AnyNodeId } from '../../schema' import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' import useLiveNodeOverrides from '../../store/use-live-node-overrides' import useLiveTransforms from '../../store/use-live-transforms' @@ -12,6 +12,7 @@ import { hasLiveStairOpeningInputs, } from './stair-opening-preview' import { syncAutoStairOpenings } from './stair-opening-sync' +import { syncStairRises } from './stair-rise' function isOpeningRelevantNode(node: AnyNode | undefined) { return ( @@ -47,7 +48,7 @@ export const StairOpeningSystem = () => { const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) useEffect(() => { - const applyUpdates = (updates: ReturnType) => { + const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial }>) => { if (updates.length === 0) return syncingAutoOpeningsRef.current = true pauseSceneHistory(useScene) @@ -103,14 +104,40 @@ export const StairOpeningSystem = () => { ) } - applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) - refreshLivePreview() + const runAutoSync = () => { + // Rise first: straight stairs converge their flight heights to the + // resolved rise (level height or deck elevation), and the opening pass + // reads those segment heights — so it must run against the post-rise + // nodes. + applyUpdates(syncStairRises(useScene.getState().nodes)) + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + } + + let disposed = false + let autoSyncQueued = false + const scheduleAutoSync = () => { + if (autoSyncQueued) return + autoSyncQueued = true + // One microtask later so every other scene-store listener for the + // triggering transition (and, at mount, the editor's spatial-grid + // init) runs first — the spatial-grid sync in particular. The + // deck-attached rise elects the stair's floor-stack base elevation + // through the spatial grid; syncing before the grid listener would + // rescale flights against the pre-transition slab state. + queueMicrotask(() => { + autoSyncQueued = false + if (disposed) return + runAutoSync() + refreshLivePreview() + }) + } + + scheduleAutoSync() const unsubscribeScene = useScene.subscribe((state, prevState) => { if (syncingAutoOpeningsRef.current) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return - applyUpdates(syncAutoStairOpenings(state.nodes)) - refreshLivePreview() + scheduleAutoSync() }) const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { @@ -122,6 +149,7 @@ export const StairOpeningSystem = () => { }) return () => { + disposed = true unsubscribeScene() unsubscribeLiveTransforms() unsubscribeLiveOverrides() diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts new file mode 100644 index 00000000..00309cbf --- /dev/null +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -0,0 +1,465 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { z } from 'zod' +import { + GROUND_SUPPORT_ID, + getFloorPlacedElevation, +} from '../../hooks/spatial-grid/floor-placed-elevation' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, StairNode as StairNodeType } from '../../schema' +import { LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { resolveStairTotalRise, syncStairRises } from './stair-rise' + +// The deck branch elects the stair's floor-stack base through the node +// registry + spatial grid singletons — reset them so tests are hermetic +// (base elects 0 unless a test registers a stair footprint and slabs). +beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() +}) + +function buildScene(levelHeight: number | undefined, totalRise: number | undefined) { + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + ...(totalRise !== undefined ? { totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + children: ['stair_1'], + ...(levelHeight !== undefined ? { height: levelHeight } : {}), + }) + return { stair, nodes: { level_1: level, stair_1: stair } } +} + +function makeDeck(elevation: number, polygon?: Array<[number, number]>) { + return SlabNode.parse({ + id: 'slab_deck', + type: 'slab', + polygon: polygon ?? [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + elevation, + thickness: 0.05, + }) +} + +function buildDeckScene(options: { + deckElevation: number + deckPolygon?: Array<[number, number]> + totalRise?: number + deckSlabId?: string + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> +}) { + const deck = makeDeck(options.deckElevation, options.deckPolygon) + const segments = (options.segments ?? []).map((segment) => + StairSegmentNode.parse({ + id: segment.id, + type: 'stair-segment', + segmentType: segment.segmentType, + width: 1, + length: 2, + height: segment.height, + stepCount: 8, + parentId: 'stair_1', + }), + ) + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + deckSlabId: options.deckSlabId ?? deck.id, + children: segments.map((segment) => segment.id), + ...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + height: 2.5, + children: ['stair_1', deck.id], + }) + const nodes: Record = { + level_1: level, + stair_1: stair, + [deck.id]: deck, + } + for (const segment of segments) nodes[segment.id] = segment + return { deck, stair, nodes } +} + +function buildLevelSceneWithSegments(options: { + levelHeight: number + totalRise?: number + segments: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> +}) { + const segments = options.segments.map((segment) => + StairSegmentNode.parse({ + id: segment.id, + type: 'stair-segment', + segmentType: segment.segmentType, + width: 1, + length: 2, + height: segment.height, + stepCount: 8, + parentId: 'stair_1', + }), + ) + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + children: segments.map((segment) => segment.id), + ...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + height: options.levelHeight, + children: ['stair_1'], + }) + const nodes: Record = { level_1: level, stair_1: stair } + for (const segment of segments) nodes[segment.id] = segment + return { level, stair, nodes } +} + +describe('resolveStairTotalRise', () => { + it('derives the rise from the containing level stored height when absent', () => { + const { stair, nodes } = buildScene(3.2, undefined) + expect(resolveStairTotalRise(stair, nodes)).toBe(3.2) + }) + + it('tracks a storey height change without any stair write', () => { + const { stair, nodes } = buildScene(2.55, undefined) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.55) + const level = nodes.level_1 + if (level.type !== 'level') throw new Error('expected level') + const updated = { ...nodes, level_1: { ...level, height: 3.0 } } + expect(resolveStairTotalRise(stair, updated)).toBe(3.0) + }) + + it('prefers an explicit totalRise over the storey height', () => { + const { stair, nodes } = buildScene(3.2, 2.5) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.5) + }) + + it('falls back to the default when the stair has no containing level', () => { + const { stair } = buildScene(3.2, undefined) + expect(resolveStairTotalRise(stair, {})).toBe(2.5) + }) + + it('derives the rise from the attached deck elevation', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(1.25) + }) + + it('tracks a deck elevation change without any stair write', () => { + const { deck, stair, nodes } = buildDeckScene({ deckElevation: 1.25 }) + const updated = { ...nodes, [deck.id]: { ...deck, elevation: 1.6 } } + expect(resolveStairTotalRise(stair, updated)).toBe(1.6) + }) + + it('prefers an explicit totalRise over the attached deck', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, totalRise: 2.0 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.0) + }) + + it('falls through a stale deckSlabId to the storey height silently', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, deckSlabId: 'slab_gone' }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.5) + }) +}) + +describe('syncStairRises', () => { + it('writes the deck elevation into a single flight segment', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 1.6 } }]) + }) + + it('is a no-op when the flights already match the deck elevation', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) + + it('scales multiple flights proportionally and leaves landings alone', () => { + const { nodes } = buildDeckScene({ + deckElevation: 2.1, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } }) + expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } }) + }) + + it('distributes an explicit custom rise instead of the deck elevation', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.25, + totalRise: 2.0, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.0 } }]) + }) + + it('falls a stale deckSlabId back to the storey height', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + deckSlabId: 'slab_gone', + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('leaves a stale-deck stair with an explicit rise untouched', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + deckSlabId: 'slab_gone', + totalRise: 2.0, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) + + it('converges a level-following straight stair to the storey height', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.5, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.0 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('converges a level-following stair after a storey height change', () => { + const scene = buildLevelSceneWithSegments({ + levelHeight: 2.5, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const nodes = { ...scene.nodes, level_1: { ...scene.level, height: 3.0 } as AnyNode } + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 3.0 } }]) + }) + + it('rescales level-following flights proportionally, landings untouched', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.1, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } }) + expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } }) + }) + + it('converges back to the storey height after a deck detach', () => { + const scene = buildDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const { deckSlabId: _deckSlabId, ...detached } = scene.stair + const nodes = { ...scene.nodes, stair_1: detached as AnyNode } + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('leaves a detached explicit-rise stair with hand-set segments untouched', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.5, + totalRise: 2.0, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.9 }, + { id: 'sseg_2', segmentType: 'stair', height: 0.6 }, + ], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) +}) + +// The stair stands on a floor slab (the default 0.05 one, or whatever the +// floor-stack elects) — the deck-derived rise must be measured from that +// lifted base so the last step lands flush with the deck's walking surface. +describe('deck-attached rise with a floor-lifted base', () => { + const FLOOR_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ] + // Away from the stair footprint at the origin so the base election never + // sees the deck itself. + const AWAY_DECK_POLYGON: Array<[number, number]> = [ + [8, 8], + [10, 8], + [10, 10], + [8, 10], + ] + + beforeEach(() => { + registerNode({ + kind: 'stair', + schemaVersion: 1, + schema: z.object({ type: z.literal('stair') }) as never, + category: 'structure', + defaults: () => ({}) as never, + capabilities: { + floorPlaced: { + footprints: (node) => [ + { + position: (node as StairNodeType).position, + dimensions: [1, 1, 2] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + ], + }, + }, + } as AnyNodeDefinition) + }) + + function makeFloorSlab(elevation: number) { + return SlabNode.parse({ + id: 'slab_floor', + type: 'slab', + polygon: FLOOR_POLYGON, + elevation, + thickness: 0.05, + }) + } + + function buildLiftedDeckScene(options: { + deckElevation: number + floorElevation?: number + totalRise?: number + supportSlabId?: string + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> + }) { + const floor = makeFloorSlab(options.floorElevation ?? 0.05) + const scene = buildDeckScene({ + deckElevation: options.deckElevation, + deckPolygon: AWAY_DECK_POLYGON, + totalRise: options.totalRise, + segments: options.segments, + }) + const stair = options.supportSlabId + ? ({ ...scene.stair, supportSlabId: options.supportSlabId } as typeof scene.stair) + : scene.stair + const nodes: Record = { + ...scene.nodes, + stair_1: stair, + [floor.id]: floor, + } + spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1') + spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1') + return { deck: scene.deck, floor, stair, nodes } + } + + it('lands the last step flush: rise = deck elevation − elected base', () => { + const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25 }) + const base = getFloorPlacedElevation({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: 'level_1', + }) + expect(base).toBeCloseTo(0.05) + const rise = resolveStairTotalRise(stair, nodes) + expect(rise).toBeCloseTo(1.2) + // Top surface = visual base + rise = the deck's walking surface, not 1.30. + expect(base + rise).toBeCloseTo(1.25) + }) + + it('rescales a flight converged under the old rule down to the flush rise', () => { + const { nodes } = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.2) + }) + + it('keeps the full deck elevation when the stair stands on bare ground', () => { + const scene = buildDeckScene({ deckElevation: 1.25, deckPolygon: AWAY_DECK_POLYGON }) + spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1') + expect(resolveStairTotalRise(scene.stair, scene.nodes)).toBeCloseTo(1.25) + }) + + it('lets an explicit totalRise win over the base-adjusted deck rise', () => { + const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25, totalRise: 2.0 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.0) + }) + + it('re-converges to flush after a deck elevation change', () => { + const scene = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const movedDeck = { ...scene.deck, elevation: 1.6 } + const nodes = { ...scene.nodes, [scene.deck.id]: movedDeck as AnyNode } + spatialGridManager.handleNodeUpdated(movedDeck as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.55) + }) + + it('re-converges to flush after the base slab elevation changes', () => { + const scene = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }], + }) + const movedFloor = { ...scene.floor, elevation: 0.3 } + const nodes = { ...scene.nodes, [scene.floor.id]: movedFloor as AnyNode } + spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(0.95) + }) + + it('rescales flights proportionally from the lifted base, landings untouched', () => { + const { nodes } = buildLiftedDeckScene({ + deckElevation: 2.15, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + // Target flight rise = 2.15 − 0.05 (base) − 0.1 (landing) = 2.0 → 1.0 each. + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.0) + expect(updates[1]?.id).toBe('sseg_3' as never) + expect((updates[1]?.data as { height?: number }).height).toBeCloseTo(1.0) + }) + + it('honors a persisted ground host over the floor slab election', () => { + const { stair, nodes } = buildLiftedDeckScene({ + deckElevation: 1.25, + supportSlabId: GROUND_SUPPORT_ID, + }) + expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25) + }) +}) diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts new file mode 100644 index 00000000..12054831 --- /dev/null +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -0,0 +1,90 @@ +import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation' +import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' +import { getStoredLevelHeight } from '../../services/storey' + +export function resolveStairTotalRise(stair: StairNode, nodes: Record): number { + if (stair.totalRise !== undefined) return stair.totalRise + + const level = Object.values(nodes).find( + (node) => node.type === 'level' && node.children.includes(stair.id), + ) + + if (stair.deckSlabId) { + const deck = nodes[stair.deckSlabId] + // The deck's `elevation` IS its walking surface (level-local), but the + // stair's own base may be lifted onto a floor slab by the floor-stack + // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at + // `position[1] + elected slab elevation`). The rise is measured from + // that base, so subtract it — electing the base exactly the way the + // visual systems do (persisted `supportSlabId` honored, uncapped + // election otherwise) keeps base + rise landing precisely on the deck's + // walking surface. A stale reference (deck gone) falls through to the + // level-derived rise. + if (deck?.type === 'slab') { + const baseElevation = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: level?.id ?? null, + })[1] + return (deck.elevation ?? 0.05) - baseElevation + } + } + + return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT +} + +const RISE_SYNC_EPSILON = 1e-4 + +/** + * Keeps straight stairs' flight segments in step with the resolved rise. + * Straight-stair geometry derives from per-segment heights (not from + * `resolveStairTotalRise`), so level-height and deck-elevation changes must + * write through to the flight segments — curved/spiral stairs read the + * resolved rise directly and need no sync. + * + * Scope: stairs whose total the system owns — follows-mode stairs (absent + * `totalRise`, tracking their level or their deck) and deck-attached stairs + * (an explicit rise converges to the typed value). A detached stair with an + * explicit `totalRise` is the one place hand-edited segment chains are + * legitimate, so it is never touched. Flight heights scale proportionally + * (landings keep theirs); returns `updateNodes` patches, empty when every + * stair is already in step. + */ +export function syncStairRises( + nodes: Record, +): Array<{ id: AnyNodeId; data: Partial }> { + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + + for (const node of Object.values(nodes)) { + if (node.type !== 'stair' || node.stairType !== 'straight') continue + const deck = node.deckSlabId ? nodes[node.deckSlabId] : undefined + if (node.totalRise !== undefined && deck?.type !== 'slab') continue + + const segments = (node.children ?? []) + .map((childId) => nodes[childId]) + .filter((child): child is StairSegmentNode => child?.type === 'stair-segment') + const flights = segments.filter((segment) => segment.segmentType === 'stair') + if (flights.length === 0) continue + + const landingRise = segments + .filter((segment) => segment.segmentType !== 'stair') + .reduce((sum, segment) => sum + segment.height, 0) + const flightRise = flights.reduce((sum, segment) => sum + segment.height, 0) + const targetFlightRise = resolveStairTotalRise(node, nodes) - landingRise + if (targetFlightRise <= 0) continue + if (Math.abs(flightRise - targetFlightRise) <= RISE_SYNC_EPSILON) continue + + for (const flight of flights) { + const height = + flightRise > RISE_SYNC_EPSILON + ? flight.height * (targetFlightRise / flightRise) + : targetFlightRise / flights.length + updates.push({ id: flight.id as AnyNodeId, data: { height } }) + } + } + + return updates +} diff --git a/packages/core/src/systems/wall/wall-top.test.ts b/packages/core/src/systems/wall/wall-top.test.ts new file mode 100644 index 00000000..e452dbfe --- /dev/null +++ b/packages/core/src/systems/wall/wall-top.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { resolveWallEffectiveHeight, resolveWallTop } from './wall-top' + +describe('resolveWallTop', () => { + test('explicit height on zero base keeps the stored top', () => { + expect(resolveWallTop({ height: 2.5 }, 3, 0)).toBe(2.5) + }) + + test('explicit height on raised base rides the base', () => { + expect(resolveWallTop({ height: 2.5 }, 3, 0.6)).toBeCloseTo(3.1) + }) + + test('explicit height on sunken base keeps the absolute top', () => { + expect(resolveWallTop({ height: 2.5 }, 3, -0.4)).toBe(2.5) + }) + + test('plane-bound wall tops out at the storey plane regardless of base', () => { + expect(resolveWallTop({}, 3, 0)).toBe(3) + expect(resolveWallTop({}, 3, 0.6)).toBe(3) + expect(resolveWallTop({}, 3, -0.4)).toBe(3) + }) +}) + +describe('resolveWallEffectiveHeight', () => { + test('explicit on raised base extrudes the stored height', () => { + expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0.6)).toBeCloseTo(2.5) + }) + + test('explicit on zero base extrudes the stored height', () => { + expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0)).toBe(2.5) + }) + + test('plane-bound on raised base gets shorter, never taller', () => { + expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeCloseTo(2.4) + expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeLessThan(3) + }) + + test('plane-bound on zero base spans the full storey', () => { + expect(resolveWallEffectiveHeight({}, 3, 0)).toBe(3) + }) + + test('plane-bound on sunken base fills down while the top stays at the plane', () => { + expect(resolveWallEffectiveHeight({}, 3, -0.4)).toBeCloseTo(3.4) + }) +}) diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts new file mode 100644 index 00000000..208ed4bd --- /dev/null +++ b/packages/core/src/systems/wall/wall-top.ts @@ -0,0 +1,53 @@ +import type { WallNode } from '../../schema/nodes/wall' + +/** + * Minimum wall body height in meters. Governs both the wall height + * arrow's lower drag bound and the slab-elevation clamp: a slab may not + * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall + * elects it as its base, or the wall's extrusion (plane minus base) + * would collapse below this minimum. + */ +export const MIN_WALL_HEIGHT = 0.5 + +/** + * Wall-top inversion (vertical building model): a wall with no stored + * `height` is plane-bound — its top sits at the storey plane (level-local + * Y = the level's stored height), so a slab lifting the wall's base makes + * the wall shorter, never taller, and no gap can open at the top of a + * level. A wall WITH `height` is an explicit exception (half wall, + * parapet) and keeps the legacy semantics: the top rides a raised elected + * base (`electedBase + height`), while a zero or sunken base leaves the + * top at `height` (the legacy negative-slab constraint). + * + * Returns the top in level-local Y (same frame as `electedBase`). + */ +export function resolveWallTop( + wall: Pick, + storeyHeight: number, + electedBase: number, +): number { + if (wall.height == null) return storeyHeight + return electedBase > 0 ? electedBase + wall.height : wall.height +} + +/** + * Extruded height of the wall body: {@link resolveWallTop} minus the + * elected base. Base convention: the elected slab-support elevation itself + * — the viewer computes `effectiveBaseElevation = min(baseElevation, + * slabElevation)` and defaults `baseElevation` to the elected elevation, + * so with only the election in hand the two coincide. Fill-down below the + * elected base (`baseSegments`) is a geometry detail the extruder handles + * separately and never changes where the top sits. + * + * Equivalently: the wall-local Y of the wall's top, measured from the wall + * mesh origin (which sits at `electedBase`). May be non-positive when a + * slab reaches the storey plane; callers own the degenerate-geometry + * policy. + */ +export function resolveWallEffectiveHeight( + wall: Pick, + storeyHeight: number, + electedBase: number, +): number { + return resolveWallTop(wall, storeyHeight, electedBase) - electedBase +} diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index 8b0b04a9..93fd3c69 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'bun:test' import type { CollectionId } from '../schema/collections' import type { AnyNode, AnyNodeId } from '../schema/types' -import { forkSceneGraph, type SceneGraph } from './clone-scene-graph' +import { + cloneLevelSubtree, + cloneSceneGraph, + forkSceneGraph, + type SceneGraph, +} from './clone-scene-graph' function makeNode(id: string, type: string, extra: Record = {}): AnyNode { return { @@ -71,3 +76,50 @@ describe('forkSceneGraph', () => { expect(forked.installedPlugins).toEqual(['pascal:trees']) }) }) + +describe('supportSlabId remap', () => { + test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => { + const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] }) + const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' }) + const item = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' }) + + const cloned = cloneSceneGraph({ + nodes: { + ['level_1' as AnyNodeId]: level, + ['slab_1' as AnyNodeId]: slab, + ['item_1' as AnyNodeId]: item, + }, + rootNodeIds: ['level_1' as AnyNodeId], + }) + + const clonedSlab = Object.values(cloned.nodes).find((node) => node.type === 'slab')! + const clonedItem = Object.values(cloned.nodes).find((node) => node.type === 'item')! + expect(clonedSlab.id).not.toBe('slab_1') + expect((clonedItem as { supportSlabId?: string }).supportSlabId).toBe(clonedSlab.id) + }) + + test('cloneLevelSubtree remaps in-subtree hosts and preserves external references', () => { + const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1', 'item_2'] }) + const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' }) + const hosted = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' }) + const external = makeNode('item_2', 'item', { + parentId: 'level_1', + supportSlabId: 'slab_external', + }) + + const { clonedNodes, idMap } = cloneLevelSubtree( + { + ['level_1' as AnyNodeId]: level, + ['slab_1' as AnyNodeId]: slab, + ['item_1' as AnyNodeId]: hosted, + ['item_2' as AnyNodeId]: external, + }, + 'level_1' as AnyNodeId, + ) + + const clonedHosted = clonedNodes.find((node) => node.id === idMap.get('item_1'))! + const clonedExternal = clonedNodes.find((node) => node.id === idMap.get('item_2'))! + expect((clonedHosted as { supportSlabId?: string }).supportSlabId).toBe(idMap.get('slab_1')!) + expect((clonedExternal as { supportSlabId?: string }).supportSlabId).toBe('slab_external') + }) +}) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index ad100cbe..8235c012 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -1,3 +1,4 @@ +import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation' import { remapMeasurementReferences } from '../lib/measurement-geometry' import type { AnyNode, AnyNodeId } from '../schema' import { generateId } from '../schema/base' @@ -85,6 +86,24 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { ) as string | undefined } + // Remap supportSlabId (persisted slab-support hosts). The 'ground' + // sentinel is not a node id — keep it as-is. + if ( + 'supportSlabId' in clonedNode && + typeof clonedNode.supportSlabId === 'string' && + clonedNode.supportSlabId !== GROUND_SUPPORT_ID + ) { + ;(clonedNode as Record).supportSlabId = idMap.get( + clonedNode.supportSlabId, + ) as string | undefined + } + + if ('deckSlabId' in clonedNode && typeof clonedNode.deckSlabId === 'string') { + ;(clonedNode as Record).deckSlabId = idMap.get(clonedNode.deckSlabId) as + | string + | undefined + } + if (clonedNode.type === 'measurement') { clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap) } @@ -240,6 +259,18 @@ export function cloneLevelSubtree( idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId } + // Remap supportSlabId when the host slab is inside the cloned subtree; + // preserve it otherwise (like wallId, the reference may point outside). + if ('supportSlabId' in cloned && typeof cloned.supportSlabId === 'string') { + ;(cloned as Record).supportSlabId = + idMap.get(cloned.supportSlabId) ?? cloned.supportSlabId + } + + if ('deckSlabId' in cloned && typeof cloned.deckSlabId === 'string') { + ;(cloned as Record).deckSlabId = + idMap.get(cloned.deckSlabId) ?? cloned.deckSlabId + } + if (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 88820d37..d57968c2 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -15,6 +15,7 @@ import { getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, + getLevelElevations, getResolvedElevatorDoorStyle, openElevatorDoor, requestElevatorLevel, @@ -76,7 +77,6 @@ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 -const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 const VOID_FALL_RESPAWN_DEPTH = 12 type MovementKeyName = Exclude @@ -281,37 +281,17 @@ function isInsideElevatorCab( ) } -function getFirstPersonLevelHeight(levelId: string, nodes: Record) { - const level = nodes[levelId as AnyNodeId] - if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT - - let maxTop = 0 - for (const childId of level.children) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - - if (child.type === 'ceiling') { - maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT) - continue - } - - if (child.type === 'wall') { - const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0) - maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)) - } - } - - return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT -} - function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record) { const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + const levelElevations = getLevelElevations(nodes as Record) const baseYByLevelId = new Map() let cumulativeY = 0 for (const level of allLevels) { - baseYByLevelId.set(level.id, cumulativeY) - cumulativeY += getFirstPersonLevelHeight(level.id, nodes) + const elevation = levelElevations.get(level.id) + const baseY = elevation?.baseY ?? 0 + baseYByLevelId.set(level.id, baseY) + cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0)) } const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fa526574..5d2f9a62 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -6,7 +6,6 @@ import { type CeilingNode, ColumnNode, createSceneApi, - DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, emitter, @@ -15,6 +14,7 @@ import { getActiveRoofHeight, getEffectiveNode, getWallCurveLength, + getWallEffectiveHeightForNodes, getWallThickness, ItemNode, isCurvedWall, @@ -271,7 +271,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): { } { if (node.type === 'wall') { return { - height: node.height ?? DEFAULT_WALL_HEIGHT, + height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes), length: getWallCurveLength(node), thickness: getWallThickness(node), } @@ -413,7 +413,10 @@ export function FloatingActionMenu() { const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as | { height?: number } | undefined - const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT + const fallbackHeight = + node.type === 'wall' + ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes) + : FENCE_DEFAULT_HEIGHT const liveHeight = override?.height ?? node.height ?? fallbackHeight pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}` } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 531f9f82..78c0f25d 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -153,6 +153,12 @@ export interface EditorProps { * only while a node is selected. */ inspectorFooter?: ReactNode + /** + * Docked below the multi-selection panel (v2). Hosts mount whole-selection + * affordances here (e.g. "Save to my catalog"); shows only while more than + * one node is selected. + */ + multiSelectionFooter?: ReactNode /** Host-owned content mounted inside the editor's React Three Fiber scene. */ viewerSceneSlot?: ReactNode @@ -1108,6 +1114,7 @@ export default function Editor({ viewerToolbarRight, stageOverlay, inspectorFooter, + multiSelectionFooter, viewerSceneSlot, floorplanSceneSlot, projectId, @@ -1412,7 +1419,10 @@ export default function Editor({ )} {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
- +
)} {!isCaptureMode && ( diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index 285b9629..92851a09 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -1,10 +1,11 @@ 'use client' import { + type AnyNode, type AnyNodeId, calculateLevelMiters, - DEFAULT_WALL_HEIGHT, getWallCurveLength, + getWallEffectiveHeightForNodes, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, @@ -91,10 +92,7 @@ export function WallMeasurementLabel() { return createPortal(, selectedObject) } -function getLevelWalls( - wall: WallNode, - nodes: Record, -): WallNode[] { +function getLevelWalls(wall: WallNode, nodes: Record): WallNode[] { if (!wall.parentId) return [wall] const levelNode = nodes[wall.parentId as AnyNodeId] @@ -308,7 +306,7 @@ function getCurvedWallMeasurementPath( function buildMeasurementGuide( wall: WallNode, - nodes: Record, + nodes: Record, ): MeasurementGuide | null { const levelWalls = getLevelWalls(wall, nodes) const miterData = calculateLevelMiters(levelWalls) @@ -317,7 +315,7 @@ function buildMeasurementGuide( const measurementPoints = measurementLine ?? fallbackMiddlePoints if (!measurementPoints) return null - const height = wall.height ?? DEFAULT_WALL_HEIGHT + const height = getWallEffectiveHeightForNodes(wall, nodes) const startLocal = worldPointToWallLocal(wall, measurementPoints.start) const endLocal = worldPointToWallLocal(wall, measurementPoints.end) const curvedMeasurementPath = isCurvedWall(wall) @@ -516,14 +514,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { const color = isNight ? '#ffffff' : '#111111' const shadowColor = isNight ? '#111111' : '#ffffff' - const guide = useMemo( - () => - buildMeasurementGuide( - wall, - nodes as Record, - ), - [nodes, wall], - ) + const guide = useMemo(() => buildMeasurementGuide(wall, nodes), [nodes, wall]) const length = useMemo(() => { if (!guide?.guidePath?.length || guide.guidePath.length < 2) { return getWallCurveLength(wall) @@ -538,7 +529,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { return total }, [guide, wall]) const label = formatLinearMeasurement(length, unit) - const heightLabel = `H ${formatLinearMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}` + const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall]) + const heightLabel = `H ${formatLinearMeasurement(height, unit)}` if (!(guide && Number.isFinite(length) && length >= 0.01)) return null diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 735f1763..3ae9b8a1 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -2,11 +2,12 @@ import { type AnyNodeId, - DEFAULT_WALL_HEIGHT, type FenceNode, getWallCurveFrameAt, + getWallEffectiveHeightForNodes, getWallThickness, isCurvedWall, + MIN_WALL_HEIGHT, sceneRegistry, useLiveNodeOverrides, useScene, @@ -55,7 +56,6 @@ const HANDLE_MIN_OFFSET = 0.33 const HANDLE_MIN_HEIGHT = 0.4 const HANDLE_TOP_INSET = 0.08 const HEIGHT_HANDLE_OFFSET = 0.26 -const MIN_WALL_HEIGHT = 0.5 const ARROW_COLOR = '#8381ed' const ARROW_HOVER_COLOR = '#a5b4fc' // Match the door arrows: scale the rendered chevron down to ~two-thirds @@ -244,7 +244,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: const corner = endpoint === 'start' ? wall.start : wall.end const x = corner[0] const z = corner[1] - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) @@ -433,7 +433,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const wallAngle = Math.atan2(-dirZ, dirX) // `wall` is the override-merged effective wall (see // WallMoveSideHandlesForWall), so this height is already live during a drag. - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const handleY = wallHeight + HEIGHT_HANDLE_OFFSET const activateHeightResize = (event: ThreeEvent) => { @@ -466,7 +466,9 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const hit = new Vector3() if (!raycaster.ray.intersectPlane(plane, hit)) return - const initialHeight = wall.height ?? DEFAULT_WALL_HEIGHT + // Dragging the top makes the wall custom-height; seed from the resolved + // effective height so a plane-bound wall's drag starts at its real top. + const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const initialY = hit.y const wallId = wall.id as AnyNodeId let pendingHeight = initialHeight @@ -774,7 +776,7 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { const midpoint: [number, number] = frame ? [frame.point.x, frame.point.y] : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index e3fa0855..7ef0ee9b 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -3,12 +3,13 @@ import { type AnyNode, type AnyNodeId, - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallCurveLength, + getWallPlaneTop, getWallThickness, isCurvedWall, resolveLevelId, + resolveWallTop, sceneRegistry, spatialGridManager, useScene, @@ -147,15 +148,16 @@ type WallTopHighlightSegment = { function getWallTopY(wall: WallNode, nodes: Readonly>) { const levelId = resolveLevelId(wall, nodes as Record) - const slabElevation = spatialGridManager.getSlabElevationForWall( + const support = spatialGridManager.getSlabSupportForWall( levelId, wall.start, wall.end, wall.curveOffset ?? 0, wall.thickness, + wall.supportSlabId, ) - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT - return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT + const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) + return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT } function buildHighlightSegment(start: [number, number], end: [number, number]) { diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 71e787ab..e60c88e9 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, emitter, + resolveCeilingHeight, resolveLevelId, sceneRegistry, snapPointToGrid, @@ -151,6 +152,9 @@ const CeilingSelectionAffordance = ({ () => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), [ceiling, liveOverride], ) + // Explicit height when stored, else the live level-top bound the ceiling + // follows (primitive selector — re-render-safe). + const resolvedHeight = useScene((s) => resolveCeilingHeight(effectiveCeiling, s.nodes)) const [levelObject, setLevelObject] = useState( () => sceneRegistry.nodes.get(levelId) ?? null, ) @@ -221,7 +225,7 @@ const CeilingSelectionAffordance = ({ ) raycasterRef.current.setFromCamera(ndcRef.current, camera) - planePointRef.current.set(0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0) + planePointRef.current.set(0, resolvedHeight + BRACKET_Y_OFFSET, 0) levelObject.localToWorld(planePointRef.current) planeOriginRef.current.set(0, 0, 0) @@ -238,7 +242,7 @@ const CeilingSelectionAffordance = ({ levelObject.worldToLocal(localIntersectionRef.current) return [localIntersectionRef.current.x, localIntersectionRef.current.z] }, - [camera, effectiveCeiling.height, gl.domElement, levelObject], + [camera, resolvedHeight, gl.domElement, levelObject], ) const handleCornerPointerDown = useCallback( @@ -438,10 +442,7 @@ const CeilingSelectionAffordance = ({ if (!levelObject || corners.length === 0) return null return createPortal( - + {corners.map((corner, index) => ( e.stopPropagation(), viaHandle: true, }) diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 0d76ef27..fec5ab6a 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -5,6 +5,7 @@ import { getWallCurveFrameAt, getWallCurveLength, isCurvedWall, + resolveFenceSupportSlabPatch, snapPointAlongAngleRay, useScene, type WallNode, @@ -187,9 +188,22 @@ export function snapFenceDraftPoint(args: { return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint } +export type FenceCommitOptions = { + /** + * Pointer-decided support cap (level-local Y) from + * `resolvePointerSupportSurface` — the 3D tool passes the elevation of + * the surface the commit click actually aimed at, so a fence drawn on a + * deck top persists the deck as its lift host while one drawn at the + * floor underneath stays grounded. Omitted by 2D floor-plan commits (no + * camera ray): those keep the uncapped max election. + */ + supportCap?: number | null +} + export function createFenceOnCurrentLevel( start: FencePlanPoint, end: FencePlanPoint, + options?: FenceCommitOptions, ): FenceNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, nodes } = useScene.getState() @@ -209,6 +223,13 @@ export function createFenceOnCurrentLevel( start, end, }) + // Fences run no per-frame support election — the persisted host IS the + // lift (absent = level floor), so elect it at commit, pointer-capped. + fence.supportSlabId = resolveFenceSupportSlabPatch( + { ...fence, parentId: currentLevelId }, + nodes, + { maxElevation: options?.supportCap ?? null }, + ).supportSlabId createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') @@ -225,6 +246,7 @@ export function createFenceOnCurrentLevel( export function createSplineFenceOnCurrentLevel( path: FencePlanPoint[], tangents = getTwoPointFenceCurveTangents(path), + options?: FenceCommitOptions, ): FenceNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, nodes } = useScene.getState() @@ -250,6 +272,11 @@ export function createSplineFenceOnCurrentLevel( path, tangents, }) + fence.supportSlabId = resolveFenceSupportSlabPatch( + { ...fence, parentId: currentLevelId }, + nodes, + { maxElevation: options?.supportCap ?? null }, + ).supportSlabId createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index f402148c..297d6ebf 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -2,6 +2,7 @@ import { type AnyNodeId, type AssetInput, ItemNode, + resolveSupportSlabPatch, sceneRegistry, useScene, } from '@pascal-app/core' @@ -39,8 +40,14 @@ export interface DraftNodeHandle { ) => ItemNode | null /** Take ownership of an existing scene node as the draft (for move mode). */ adopt: (node: ItemNode) => void - /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */ - commit: (finalUpdate: Partial) => string | null + /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. + * `supportElevationCap` (floor commits) is the pointer-decided surface + * elevation — it caps the persisted `supportSlabId` election so the + * commit lands on the surface the cursor pointed at. */ + commit: ( + finalUpdate: Partial, + options?: { supportElevationCap?: number | null }, + ) => string | null /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */ destroy: () => void } @@ -124,100 +131,128 @@ export function useDraftNode(): DraftNodeHandle { ) }, []) - const commit = useCallback((finalUpdate: Partial): string | null => { - const draft = draftRef.current - if (!draft) return null + const commit = useCallback( + ( + finalUpdate: Partial, + options?: { supportElevationCap?: number | null }, + ): string | null => { + const draft = draftRef.current + if (!draft) return null - if (adoptedRef.current) { - // Move mode: update in place (single undoable action) + if (adoptedRef.current) { + // Move mode: update in place (single undoable action) + const { parentId: newParentId, ...updateProps } = finalUpdate + const parentId = + newParentId ?? + originalStateRef.current?.parentId ?? + useViewer.getState().selection.levelId + const original = originalStateRef.current! + + // Restore original state while paused — so the undo baseline is clean + useScene.getState().updateNode(draft.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, + metadata: original.metadata, + }) + + // Resume → tracked update (undo reverts to original) + useScene.temporal.getState().resume() + + const effectiveNode = ItemNode.parse({ + ...draft, + ...updateProps, + parentId, + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + }) + + useScene.getState().updateNode(draft.id, { + position: updateProps.position ?? draft.position, + rotation: updateProps.rotation ?? draft.rotation, + side: updateProps.side ?? draft.side, + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + parentId: parentId as string, + // Forward the roof host explicitly: strategies set it on every + // commit (segment id on a roof face, undefined elsewhere), and + // dropping it here strands the item in the roof frame without + // the segment transform. + roofSegmentId: updateProps.roofSegmentId, + roofFace: updateProps.roofFace, + // Only when the strategy decided about wallId (roof commits clear + // it) — floor/ceiling commits never managed the field. + ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), + ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, { + maxElevation: options?.supportElevationCap, + }), + }) + + useScene.temporal.getState().pause() + + const id = draft.id + if (usePlacementPreview.getState().node?.id === id) { + usePlacementPreview.getState().clear() + } + draftRef.current = null + adoptedRef.current = false + originalStateRef.current = null + return id + } + + // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause const { parentId: newParentId, ...updateProps } = finalUpdate - const parentId = - newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId - const original = originalStateRef.current! + const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId + if (!parentId) return null - // Restore original state while paused — so the undo baseline is clean - useScene.getState().updateNode(draft.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - metadata: original.metadata, - }) + // Delete draft while paused (invisible to undo) + useScene.getState().deleteNode(draft.id) + draftRef.current = null - // Resume → tracked update (undo reverts to original) + // Briefly resume → create fresh node (the single undoable action) useScene.temporal.getState().resume() - useScene.getState().updateNode(draft.id, { + const finalNode = ItemNode.parse({ + name: draft.name, + asset: draft.asset, position: updateProps.position ?? draft.position, rotation: updateProps.rotation ?? draft.rotation, + scale: updateProps.scale ?? draft.scale, side: updateProps.side ?? draft.side, - metadata: updateProps.metadata ?? stripTransient(draft.metadata), - parentId: parentId as string, - // Forward the roof host explicitly: strategies set it on every - // commit (segment id on a roof face, undefined elsewhere), and - // dropping it here strands the item in the roof frame without - // the segment transform. + // Carry painted slot overrides so a duplicated item keeps its materials. + ...(draft.slots ? { slots: draft.slots } : {}), + // Roof host — see the move-mode commit above for why this must be + // forwarded explicitly. roofSegmentId: updateProps.roofSegmentId, roofFace: updateProps.roofFace, - // Only when the strategy decided about wallId (roof commits clear - // it) — floor/ceiling commits never managed the field. ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + parentId, }) - - useScene.temporal.getState().pause() - - const id = draft.id - if (usePlacementPreview.getState().node?.id === id) { + const nodes = useScene.getState().nodes + const committedNode = ItemNode.parse({ + ...finalNode, + ...resolveSupportSlabPatch( + finalNode, + { ...nodes, [finalNode.id]: finalNode }, + { maxElevation: options?.supportElevationCap }, + ), + }) + useScene.getState().createNode(committedNode, parentId) + if (usePlacementPreview.getState().node?.id === draft.id) { usePlacementPreview.getState().clear() } - draftRef.current = null + + // Re-pause for next draft cycle + useScene.temporal.getState().pause() + adoptedRef.current = false originalStateRef.current = null - return id - } - - // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause - const { parentId: newParentId, ...updateProps } = finalUpdate - const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId - if (!parentId) return null - - // Delete draft while paused (invisible to undo) - useScene.getState().deleteNode(draft.id) - draftRef.current = null - - // Briefly resume → create fresh node (the single undoable action) - useScene.temporal.getState().resume() - - const finalNode = ItemNode.parse({ - name: draft.name, - asset: draft.asset, - position: updateProps.position ?? draft.position, - rotation: updateProps.rotation ?? draft.rotation, - scale: updateProps.scale ?? draft.scale, - side: updateProps.side ?? draft.side, - // Carry painted slot overrides so a duplicated item keeps its materials. - ...(draft.slots ? { slots: draft.slots } : {}), - // Roof host — see the move-mode commit above for why this must be - // forwarded explicitly. - roofSegmentId: updateProps.roofSegmentId, - roofFace: updateProps.roofFace, - ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), - metadata: updateProps.metadata ?? stripTransient(draft.metadata), - }) - useScene.getState().createNode(finalNode, parentId) - if (usePlacementPreview.getState().node?.id === draft.id) { - usePlacementPreview.getState().clear() - } - - // Re-pause for next draft cycle - useScene.temporal.getState().pause() - - adoptedRef.current = false - originalStateRef.current = null - return finalNode.id - }, []) + return committedNode.id + }, + [], + ) const destroy = useCallback(() => { if (!draftRef.current) return diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 8e542340..26624958 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -62,6 +62,10 @@ import { type PreviewBounds, updateLineGeometry, } from '../shared/placement-box-geometry' +import { + resolvePointerSupportElevation, + resolvePointerSupportSurface, +} from '../shared/pointer-support-cap' import { getDetachedAttachmentPreviewLift, getGridAlignedDimensions, @@ -269,6 +273,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const raycastDisabledMeshRef = useRef(null) const restoreRaycastsRef = useRef void>>([]) const raycastDisabledChildrenRef = useRef(new WeakSet()) + // Level-local elevation of the surface the pointer ray actually points + // at (deck top, floor slab, or ground), refreshed on every grid move. + // Threaded into the floor-support election as its cap so the POINTER + // decides the target surface — without it the election lifts the ghost + // by the MAX overlapping slab and a deck above the aimed-at floor + // captures the item (and the grid-plane feedback makes it blink). + const pointerSupportCapRef = useRef(null) const [dimensionBounds, setDimensionBounds] = useState(null) // Live camera ref — the shelf-stickiness test reconstructs the cursor world @@ -420,6 +431,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea node: previewNode, position, rotation: previewNode.rotation, + maxElevation: pointerSupportCapRef.current, }) }, [asset?.attachTo, draftNode], @@ -449,6 +461,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, } + // No pointer surface known yet — fall back to the uncapped election + // (an adopted move draft keeps its persisted host until the first move). + pointerSupportCapRef.current = null if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 if (cursorGroupRef.current) { @@ -848,6 +863,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea has3DPointerDrivenMoveRef.current = true + // The pointer decides the target surface AND the floor point: cap + // the floor-support election at the elevation of the surface the + // camera ray actually hits, and re-aim the event at the ray's + // crossing of that surface's plane. The grid event's own hit can't + // be used directly — its plane rides at the ghost's last height, so + // its Y is a feedback loop (the under-deck blink) and its XZ is + // perspective-skewed along the ray whenever the plane sits on a + // different storey than the pointed surface (the skew is what made + // a drag over a deck-above-a-floor hop between the two surfaces). + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + pointerSupportCapRef.current = pointed?.elevation ?? null + const surfaceEvent: GridEvent = + pointed?.worldPoint && pointed.localPoint + ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint } + : event + // Shelf stickiness: while hosting on a shelf, ignore floor events while // the cursor ray still points at the shelf volume (the ray merely slipped // off a board / through a gap and hit the floor behind). Detach to the @@ -855,10 +886,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // item oscillates between the shelf row and the floor on every micro-move. if (placementState.current.surface === 'shelf-surface') { if (cursorRayIntersectsActiveShelf(event.position)) return - detachItemSurfaceToFloor(event as unknown as ItemEvent) + // Land at the pointed surface's plan point — the raw grid hit is + // still skewed by the plane riding at the shelf-surface height. + detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent) } - const floorEvent = applyFloorGrabOffset(event) + const floorEvent = applyFloorGrabOffset(surfaceEvent) lastRawPos.current.set( floorEvent.localPosition[0], @@ -941,11 +974,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (draft) draft.position = gridPos - // Publish live transform for 2D floorplan + // Publish live transform for 2D floorplan (and the pointer surface + // cap, so FloorElevationSystem's per-frame Y agrees with the ghost). if (draft) { useLiveTransforms.getState().set(draft.id, { position: gridPos, rotation: cursorGroupRef.current.rotation.y, + supportElevationCap: pointerSupportCapRef.current ?? undefined, }) } @@ -973,7 +1008,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const committedId = draftNode.current?.id ?? null const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) + // Carry the pointer surface cap into the commit so the persisted + // supportSlabId reproduces the capped election (elects the aimed-at + // lower slab — or the ground — instead of a deck hanging above). + const finalId = draftNode.commit(result.nodeUpdate, { + supportElevationCap: pointerSupportCapRef.current, + }) finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { draftNode.create( gridPosition.current, @@ -1392,6 +1432,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const detachItemSurfaceToFloor = (event: ItemEvent) => { hostSurfaceDragAnchor = null + // Landing back on the floor: refresh the pointer surface cap from + // this event's world hit so the first floor position already targets + // the aimed-at surface (not a deck above it). + pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [ + event.position[0], + event.position[1], + event.position[2], + ]) // Coming back from a host: forget the floor grab too, so the item // centers under the cursor instead of restoring the pre-drag offset — // and landing on the floor is "anchoring elsewhere", so a later return diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 0d0360f9..ccd37b35 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -23,6 +23,7 @@ import { resolveAlignment, resolveConnectivityUpdates, resolveFacingIndicator, + resolveSupportSlabPatch, sceneRegistry, spatialGridManager, useLiveNodeOverrides, @@ -30,6 +31,7 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' @@ -54,6 +56,7 @@ import { DragBoundingBox } from '../shared/drag-bounding-box' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility' import { PlacementBox } from '../shared/placement-box' +import { resolvePointerSupportSurface } from '../shared/pointer-support-cap' /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */ @@ -231,6 +234,15 @@ const CLICK_TRIGGER_KINDS = [ ] as const export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + // Live camera ref — the pointer-surface cap reconstructs the cursor world + // ray (camera → grid hit) to find which walking surface is aimed at. + const camera = useThree((s) => s.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + // Level-local elevation of the surface the pointer ray points at, + // refreshed per grid move. Caps the floor-support election so a deck + // hanging above the aimed-at floor never lifts the dragged node. + const supportCapRef = useRef(null) // Kinds whose `position` lives in a host parent's local frame declare // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the // plan-frame cursor through the capability's hooks and previews via @@ -356,6 +368,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0] : rotationY })(), + maxElevation: supportCapRef.current, }) }, [parentFrame, frameParent, node], @@ -404,6 +417,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { rotationRef.current = originalRotationY altRef.current = false validRef.current = true + // No pointer surface known yet — uncapped election (the node keeps its + // persisted host / committed elevation until the first grid move). + supportCapRef.current = null // Re-sync the box transform to the (possibly new) node. `node` changes // without this component remounting whenever a positioned preset re-arms a // fresh clone after a drop, or the user picks a different catalog tile — @@ -591,8 +607,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } const onGridMove = (event: GridEvent) => { - const rawX = event.localPosition[0] - const rawZ = event.localPosition[2] + // The pointer decides the target surface AND the cursor plan point, + // both resolved from the true camera ray in one place. The event's + // own hit can't be used directly: its plane rides at the ghost's + // last height, so whenever that plane sits on a different storey + // than the aimed-at surface the hit XZ is perspective-skewed along + // the ray — electing at that skewed point is what made a drag over + // a deck-above-a-floor hop between the two surfaces (each hop moved + // the plane, which re-skewed the next hit, which flipped the + // election back). Cap and XZ from the same ray ∩ pointed-surface + // test are plane-height independent, so the elected surface is a + // single fixed point per pointer ray. + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + supportCapRef.current = pointed?.elevation ?? null + const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0] + const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2] revealFreshPlacement() const resolved = resolvePlanarCursorPosition({ @@ -721,9 +750,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // the absolute world plan position here. Polygon-based kinds // (slab / ceiling / fence) follow a different delta contract — // their floor-plan move-targets handle the override themselves. + // The pointer surface cap rides along so FloorElevationSystem's + // per-frame Y agrees with this tool's preview. useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, + supportElevationCap: supportCapRef.current ?? undefined, }) syncParentFramePreview(position) markMovedNodeDirty() @@ -781,9 +813,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { let committedId = node.id as AnyNodeId if (useScene.getState().nodes[node.id]) { + const effectiveNode = { + ...(node as Record), + position, + rotation, + } as AnyNode const data = { position, rotation, + // The pointer cap makes the persisted host reproduce the capped + // election — a drop under a deck stores the aimed-at lower slab + // (or the ground), not the deck hanging above. + ...resolveSupportSlabPatch( + effectiveNode, + { + ...useScene.getState().nodes, + [node.id]: effectiveNode, + }, + { maxElevation: supportCapRef.current }, + ), ...(isNew ? { metadata: stripPlacementMetadataFlags(node.metadata), @@ -819,6 +867,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId] if (liveNode && liveParent) { parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene)) + const committedNodes = useScene.getState().nodes + const committedParent = committedNodes[frameParent.id as AnyNodeId] + if (committedParent) { + useScene + .getState() + .updateNode( + committedParent.id, + resolveSupportSlabPatch(committedParent, committedNodes), + ) + } } } useScene.temporal.getState().pause() @@ -834,9 +892,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { metadata: {}, position, rotation, - }) + }) as AnyNode + const committedNode = def.schema.parse({ + ...reparsed, + parentId: node.parentId, + ...resolveSupportSlabPatch( + { ...reparsed, parentId: node.parentId } as AnyNode, + { + ...useScene.getState().nodes, + [reparsed.id]: reparsed, + }, + { maxElevation: supportCapRef.current }, + ), + }) as AnyNode useScene.temporal.getState().resume() - useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId) + useScene.getState().createNode(committedNode, node.parentId as AnyNodeId) useScene.temporal.getState().pause() committed = true } @@ -901,6 +971,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, + supportElevationCap: supportCapRef.current ?? undefined, }) syncParentFramePreview(position) markMovedNodeDirty() diff --git a/packages/editor/src/components/tools/shared/floor-stack-preview.ts b/packages/editor/src/components/tools/shared/floor-stack-preview.ts index f29b608b..08ecfb6d 100644 --- a/packages/editor/src/components/tools/shared/floor-stack-preview.ts +++ b/packages/editor/src/components/tools/shared/floor-stack-preview.ts @@ -6,6 +6,8 @@ type FloorStackPreviewArgs = { rotation?: unknown levelId?: string | null nodes?: Record + /** Pointer-decided support cap — see `FloorPlacedElevationArgs.maxElevation`. */ + maxElevation?: number | null } export function getFloorStackPreviewPosition({ @@ -14,6 +16,7 @@ export function getFloorStackPreviewPosition({ rotation, levelId, nodes, + maxElevation, }: FloorStackPreviewArgs): [number, number, number] { return getFloorStackedPosition({ node, @@ -21,5 +24,6 @@ export function getFloorStackPreviewPosition({ position, rotation, levelId, + maxElevation, }) } diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts new file mode 100644 index 00000000..b0bc8398 --- /dev/null +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -0,0 +1,99 @@ +import { type AnyNodeId, sceneRegistry, spatialGridManager } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { type Camera, Vector3 } from 'three' + +const originScratch = new Vector3() +const hitScratch = new Vector3() +const worldScratch = new Vector3() +const pointScratch = new Vector3() + +export type PointerSupportSurface = { + /** Level-local elevation of the pointed surface — the election cap. */ + elevation: number + /** World-space Y of the same surface, for grid-plane / preview placement. */ + worldY: number + /** + * World-space point where the pointer ray meets the pointed surface's + * plane, or null when the ray never reaches it. Unlike the grid event's + * own hit — whose XZ is perspective-skewed whenever the event plane + * rides at a different storey than the aimed-at surface — this point + * depends only on the ray and the pointed surface, so a preview / + * election fed from it cannot flip with the event plane's height. + */ + worldPoint: [number, number, number] | null + /** {@link PointerSupportSurface.worldPoint} in the grid event's + * `localPosition` (building-local) frame — a drop-in replacement for + * the event's plane-hit XZ. */ + localPoint: [number, number, number] | null +} + +/** + * The walking surface the pointer actually points at: its level-local + * elevation (for use as the slab-support election cap, `maxElevation`), + * its world-space Y (for riding the grid event plane / draw preview on + * it), and the ray's crossing of that surface's plane (the plan point the + * cursor indicates). + * + * The grid event plane rides at the ghost's last height, so its hit point + * alone can't be trusted (that feedback loop is what made a ghost under an + * elevated deck blink between the deck top and the ground). But camera → + * hit reconstructs the true pointer ray regardless of the plane height, + * and the nearest slab plane that ray crosses inside its rendered polygon + * IS the surface under the cursor — the deck top when aiming at the deck, + * the floor/ground when aiming underneath it. The same reasoning applies + * to the cursor XZ: the event plane's hit is skewed along the ray whenever + * the plane sits on a different storey than the pointed surface, so + * callers should place at `localPoint` / `worldPoint`, not the event hit. + * + * Returns null when no level is active (callers fall back to the + * uncapped max election and the raw event hit). + */ +export function resolvePointerSupportSurface( + camera: Camera, + worldHit: readonly [number, number, number], +): PointerSupportSurface | null { + const levelId = useViewer.getState().selection.levelId + if (!levelId) return null + + camera.getWorldPosition(originScratch) + hitScratch.set(worldHit[0], worldHit[1], worldHit[2]) + // Slab polygons/elevations live in the level frame; the level mesh + // carries the storey Y offset and any building rotation. + const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId) + if (levelMesh) { + levelMesh.worldToLocal(originScratch) + levelMesh.worldToLocal(hitScratch) + } + hitScratch.sub(originScratch) + if (hitScratch.lengthSq() < 1e-12) return null + + const { elevation, point } = spatialGridManager.getPointedSupportSurface( + levelId, + [originScratch.x, originScratch.y, originScratch.z], + [hitScratch.x, hitScratch.y, hitScratch.z], + ) + const worldY = levelMesh ? levelMesh.localToWorld(worldScratch.set(0, elevation, 0)).y : elevation + + let worldPoint: [number, number, number] | null = null + let localPoint: [number, number, number] | null = null + if (point) { + pointScratch.set(point[0], elevation, point[1]) + if (levelMesh) levelMesh.localToWorld(pointScratch) + worldPoint = [pointScratch.x, pointScratch.y, pointScratch.z] + // Same frame the grid events report `localPosition` in (use-grid-events). + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (buildingMesh) buildingMesh.worldToLocal(pointScratch) + localPoint = [pointScratch.x, pointScratch.y, pointScratch.z] + } + + return { elevation, worldY, worldPoint, localPoint } +} + +/** {@link resolvePointerSupportSurface}, elevation only — the election cap. */ +export function resolvePointerSupportElevation( + camera: Camera, + worldHit: readonly [number, number, number], +): number | null { + return resolvePointerSupportSurface(camera, worldHit)?.elevation ?? null +} diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.test.ts b/packages/editor/src/components/tools/stair/stair-click-guard.test.ts new file mode 100644 index 00000000..9339860f --- /dev/null +++ b/packages/editor/src/components/tools/stair/stair-click-guard.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' + +describe('createStairCommitGate', () => { + test('allows commits until the session exits', () => { + const gate = createStairCommitGate() + expect(gate.shouldCommit()).toBe(true) + // Repeat continuation: consecutive commits stay allowed. + expect(gate.shouldCommit()).toBe(true) + }) + + test('refuses every trigger after a single-continuation exit', () => { + const gate = createStairCommitGate() + expect(gate.shouldCommit()).toBe(true) + gate.markExited() + // The native follow-up click / stray node click of the same gesture. + expect(gate.shouldCommit()).toBe(false) + expect(gate.shouldCommit()).toBe(false) + }) +}) + +describe('swallowFollowUpBrowserClick', () => { + test('stops exactly one follow-up click', () => { + const target = new EventTarget() + swallowFollowUpBrowserClick(target) + + let firstStopped = 0 + const first = new Event('click', { cancelable: true }) + Object.defineProperty(first, 'stopPropagation', { value: () => firstStopped++ }) + target.dispatchEvent(first) + expect(firstStopped).toBe(1) + expect(first.defaultPrevented).toBe(true) + + // `once: true` — the next click of the next gesture passes through. + let secondStopped = 0 + const second = new Event('click', { cancelable: true }) + Object.defineProperty(second, 'stopPropagation', { value: () => secondStopped++ }) + target.dispatchEvent(second) + expect(secondStopped).toBe(0) + expect(second.defaultPrevented).toBe(false) + }) + + test('disarms after the timeout when no click follows', async () => { + const target = new EventTarget() + swallowFollowUpBrowserClick(target, 5) + await new Promise((resolve) => setTimeout(resolve, 15)) + + let stopped = 0 + const late = new Event('click', { cancelable: true }) + Object.defineProperty(late, 'stopPropagation', { value: () => stopped++ }) + target.dispatchEvent(late) + expect(stopped).toBe(0) + }) + + test('no-ops without a window-like target', () => { + expect(() => swallowFollowUpBrowserClick(undefined)).not.toThrow() + }) +}) diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.ts b/packages/editor/src/components/tools/stair/stair-click-guard.ts new file mode 100644 index 00000000..59100848 --- /dev/null +++ b/packages/editor/src/components/tools/stair/stair-click-guard.ts @@ -0,0 +1,72 @@ +/** + * Guards for the stair tool's commit triggers. + * + * One physical validation click can reach the stair tool's commit handler + * twice: node-surface clicks (`slab:click`, `wall:click`, …) are synthesized + * on *pointerup* by the viewer (`use-node-events`), while `grid:click` rides + * the browser's native *click* event from a canvas-level DOM listener + * (`use-grid-events`) that deliberately ignores R3F stopPropagation — and + * after a single-continuation commit the tool's emitter subscriptions survive + * until React unmounts it, which lands only after that native click. Without + * these guards a validation click over any node surface (a deck or floor + * slab, a wall, another stair) created TWO stairs from one click. + * + * Same hazard and same countermeasures as + * `packages/nodes/src/shared/floor-placement.ts` (`stopPlacementCommitPropagation`) + * and the `committed` flag in `move-registry-node-tool.tsx` — reimplemented + * here because `@pascal-app/editor` cannot depend on `@pascal-app/nodes`. + */ + +export type StairCommitGate = { + /** True while the armed session may still commit. */ + shouldCommit: () => boolean + /** + * Mark the session exited (single continuation): every further click + * trigger reaching the still-subscribed handler — the native follow-up + * click, a stray second node click — is refused. + */ + markExited: () => void +} + +export function createStairCommitGate(): StairCommitGate { + let exited = false + return { + shouldCommit: () => !exited, + markExited: () => { + exited = true + }, + } +} + +type ClickSwallowTarget = { + addEventListener: ( + type: string, + listener: (event: Event) => void, + options?: AddEventListenerOptions, + ) => void + removeEventListener: ( + type: string, + listener: (event: Event) => void, + options?: EventListenerOptions, + ) => void +} + +/** + * Eat the one native browser `click` that follows a pointerup-synthesized + * node click, before it reaches the canvas `grid:click` listener (capture + * phase on window runs first). Needed in repeat continuation too, where the + * tool stays armed and the gate above must keep allowing one commit per + * gesture. Self-disarms after the click or `timeoutMs`, whichever first. + */ +export function swallowFollowUpBrowserClick( + target: ClickSwallowTarget | undefined = typeof window === 'undefined' ? undefined : window, + timeoutMs = 300, +): void { + if (!target) return + const swallow = (event: Event) => { + event.stopPropagation() + event.preventDefault() + } + target.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => target.removeEventListener('click', swallow, { capture: true }), timeoutMs) +} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 8711909e..3b1b8068 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -34,6 +34,7 @@ import useFacingPose from '../../../store/use-facing-pose' import { useStairBuildPreview } from '../../../store/use-stair-build-preview' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' +import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' import { DEFAULT_CURVED_STAIR_INNER_RADIUS, DEFAULT_CURVED_STAIR_SWEEP_ANGLE, @@ -150,7 +151,6 @@ function createDefaultStairNode({ slabOpeningMode: 'destination', openingOffset: DEFAULT_STAIR_OPENING_OFFSET, width: DEFAULT_STAIR_WIDTH, - totalRise: DEFAULT_STAIR_HEIGHT, stepCount: DEFAULT_STAIR_STEP_COUNT, thickness: DEFAULT_STAIR_THICKNESS, fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, @@ -231,6 +231,9 @@ export const StairTool: React.FC = () => { if (!currentLevelId) return const openingPreview = createSurfaceOpeningPreviewController() + // Refuses the duplicate commit triggers a single physical click produces + // — see `stair-click-guard.ts`. Fresh per armed session. + const commitGate = createStairCommitGate() // Reset rotation when tool activates rotationRef.current = 0 @@ -441,10 +444,20 @@ export const StairTool: React.FC = () => { const commitAtCursor = (event: ClickTriggerEvent) => { if (!currentLevelId) return + // One physical click can reach here twice (node click synthesized on + // pointerup + the native browser click driving `grid:click`) — see + // `stair-click-guard.ts`. The gate refuses anything after a single- + // continuation commit; the swallow below eats the same gesture's + // follow-up click while the tool stays armed (repeat continuation). + if (!commitGate.shouldCommit()) return const nodeEvent = 'node' in event ? (event as NodeEvent) : null if (nodeEvent) { nodeEvent.stopPropagation() nodeEvent.nativeEvent.stopPropagation() + // The canvas-level `grid:click` listener is out of stopPropagation's + // reach — without this, the browser click that follows this + // pointerup-synthesized node click commits a second stair. + swallowFollowUpBrowserClick() } const position = nodeEvent @@ -465,8 +478,14 @@ export const StairTool: React.FC = () => { if (useEditor.getState().getContinuation('point') === 'repeat') { alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) } else { + commitGate.markExited() useFacingPose.getState().clear() useEditor.getState().setTool(null) + // Return to select mode explicitly (matches the spawn tool's exit). + // The selection managers route node clicks only while + // `mode === 'select'`; exiting with `mode: 'build'` + a null tool + // left every click dead until the user pressed Escape. + useEditor.getState().setMode('select') } } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 20edaeed..e588ce5a 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -5,6 +5,7 @@ import { type DoorNode, getScaledDimensions, type ItemNode, + resolveWallSupportSlabPatch, runAsSingleSceneHistoryStep, snapPointAlongAngleRay, useScene, @@ -456,6 +457,17 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b export function createWallOnCurrentLevel( start: WallPlanPoint, end: WallPlanPoint, + options?: { + /** + * Pointer-decided support cap (level-local Y) from + * `resolvePointerSupportSurface` — the 3D tool passes the elevation of + * the surface the commit click actually aimed at, so the persisted + * host reproduces what the preview showed (floor under a deck vs deck + * top). Omitted by 2D floor-plan commits (no camera ray): those keep + * the uncapped max election. + */ + supportCap?: number | null + }, ): WallNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, createNodes, deleteNode, nodes } = useScene.getState() @@ -541,8 +553,18 @@ export function createWallOnCurrentLevel( }) createNode(wall, currentLevelId) + const createdWall = useScene.getState().nodes[wall.id] + if (createdWall?.type === 'wall') { + useScene.getState().updateNode( + createdWall.id, + resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, { + maxElevation: options?.supportCap ?? null, + }), + ) + } sfxEmitter.emit('sfx:structure-build') - return wall + const committedWall = useScene.getState().nodes[wall.id] + return committedWall?.type === 'wall' ? committedWall : wall }) } diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 967602bf..300cb429 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -1,7 +1,7 @@ 'use client' import type { AnyNodeId } from '@pascal-app/core' -import { LevelNode, useScene } from '@pascal-app/core' +import { DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { AppWindow, @@ -196,6 +196,7 @@ export function EditorCommands() { ).length const newLevel = LevelNode.parse({ level: levelCount, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: building.id, }) diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index ef922dfa..591902c4 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -22,6 +22,8 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + DEFAULT_LEVEL_HEIGHT, + getStoredLevelHeight, LevelNode, useScene, } from '@pascal-app/core' @@ -49,7 +51,10 @@ import { subscribeEditorClipboard, } from '../../lib/scene-clipboard' import { sfxEmitter } from '../../lib/sfx-bus' +import { useLinearDisplay } from '../../lib/use-linear-display' import { cn } from '../../lib/utils' +import { ActionButton } from './controls/action-button' +import { SliderControl } from './controls/slider-control' import { LevelDuplicateDialog } from './level-duplicate-dialog' import { Dialog, @@ -145,6 +150,26 @@ function LevelRow({ }) { const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) + const updateNode = useScene((s) => s.updateNode) + const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2) + + const storeyHeight = getStoredLevelHeight(level) + // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays. + const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}` + + // Clean preset values per display system; imperial stores exact meters + // for whole-foot storey heights. + const heightPresets = isImperial + ? [ + { label: '8 ft', height: 2.4384 }, + { label: '9 ft', height: 2.7432 }, + { label: '10 ft', height: 3.048 }, + ] + : [ + { label: '2.5 m', height: 2.5 }, + { label: '3.0 m', height: 3.0 }, + { label: '3.5 m', height: 3.5 }, + ] return (
@@ -195,6 +220,48 @@ function LevelRow({ {getLevelDisplayName(level)} + {/* Storey height badge — opens the height popover */} + + + + + e.stopPropagation()} + side="right" + sideOffset={8} + > + updateNode(level.id, { height: v })} + precision={3} + step={0.1} + unit="m" + value={Math.round(storeyHeight * 1000) / 1000} + /> +
+ {heightPresets.map((preset) => ( + updateNode(level.id, { height: preset.height })} + /> + ))} +
+
+
+ {/* Vertical three-dot menu — inside the pill */} @@ -371,6 +438,7 @@ export function FloatingLevelSelector() { const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1 const newLevel = LevelNode.parse({ level: maxLevel + 1, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) @@ -383,6 +451,7 @@ export function FloatingLevelSelector() { const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1 const newLevel = LevelNode.parse({ level: minLevel - 1, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) @@ -409,6 +478,7 @@ export function FloatingLevelSelector() { const newLevel = LevelNode.parse({ level: newLevelNumber, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) diff --git a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx new file mode 100644 index 00000000..09cac7ff --- /dev/null +++ b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx @@ -0,0 +1,57 @@ +'use client' + +import { type AnyNodeId, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Copy, Trash2 } from 'lucide-react' +import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions' +import { ActionButton, ActionGroup } from '../controls/action-button' +import { PanelWrapper } from './panel-wrapper' +import { formatSelectionBreakdown } from './selection-breakdown' + +/** + * Docked right-side panel for a MULTI-selection — the compact sibling of the + * single-node inspector, rendered by `PanelManager` when more than one node + * is selected. Same collapsed-by-default `PanelWrapper` shell (header always + * visible; the shared desktop collapse state carries across single ↔ multi + * swaps). Actions mirror the floating group pill: Duplicate clones the + * selection and picks the clones up, Delete removes the whole selection + * (including its bulk-delete confirm). Unlike the pill, the docked panel + * stays visible during interactions. `footer` is the host-injected slot + * (e.g. community's "Save to my catalog"). + */ +export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const setSelection = useViewer((s) => s.setSelection) + // String selector — recomputed on scene ticks, but the === compare keeps + // unrelated mutations from re-rendering the panel. + const breakdown = useScene((s) => + formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)), + ) + + return ( + setSelection({ selectedIds: [] })} + title={`${selectedIds.length} selected`} + width={320} + > + {breakdown &&
{breakdown}
} +
+ + } + label="Duplicate" + onClick={() => duplicateSelectionAndPickUp()} + /> + } + label="Delete" + onClick={() => deleteSelection()} + /> + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 9752022a..34d36718 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -28,6 +28,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { MobilePanelSheet } from './mobile-panel-sheet' import { MobileSelectionBar } from './mobile-selection-bar' +import { MultiSelectionPanel } from './multi-selection-panel' import { getNodeDisplay } from './node-display' import { resetDesktopInspectorCollapsed } from './panel-wrapper' import { ParametricInspector } from './parametric-inspector' @@ -168,7 +169,13 @@ function MobilePanelLayer({ ) } -export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) { +export function PanelManager({ + inspectorFooter, + multiSelectionFooter, +}: { + inspectorFooter?: React.ReactNode + multiSelectionFooter?: React.ReactNode +}) { const isMobile = useIsMobile() const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedZoneId = useViewer((s) => s.selection.zoneId) @@ -237,5 +244,11 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac ) } + // Multi-selection: compact docked panel (desktop only — the mobile branch + // above keeps today's behavior and renders nothing for multi-selections). + if (selectedIds.length > 1) { + return + } + return panelForType(selectedNodeType, inspectorFooter) } diff --git a/packages/editor/src/components/ui/panels/selection-breakdown.test.ts b/packages/editor/src/components/ui/panels/selection-breakdown.test.ts new file mode 100644 index 00000000..5a3df38f --- /dev/null +++ b/packages/editor/src/components/ui/panels/selection-breakdown.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { formatSelectionBreakdown } from './selection-breakdown' + +describe('formatSelectionBreakdown', () => { + test('counts per type in first-appearance order, pluralizing with +s', () => { + expect(formatSelectionBreakdown(['slab', 'stair', 'fence', 'fence'])).toBe( + '1 slab · 1 stair · 2 fences', + ) + }) + + test('humanizes hyphenated kinds', () => { + expect(formatSelectionBreakdown(['roof-segment', 'roof-segment', 'wall'])).toBe( + '2 roof segments · 1 wall', + ) + }) + + test('skips missing nodes', () => { + expect(formatSelectionBreakdown(['wall', undefined, null])).toBe('1 wall') + }) + + test('empty selection formats to an empty string', () => { + expect(formatSelectionBreakdown([])).toBe('') + }) +}) diff --git a/packages/editor/src/components/ui/panels/selection-breakdown.ts b/packages/editor/src/components/ui/panels/selection-breakdown.ts new file mode 100644 index 00000000..b8c5c913 --- /dev/null +++ b/packages/editor/src/components/ui/panels/selection-breakdown.ts @@ -0,0 +1,20 @@ +/** + * "1 slab · 1 stair · 2 fences" — one entry per node type in first-appearance + * order so the line stays stable while shift-clicking. Labels derive from the + * type id ('roof-segment' → 'roof segment'); pluralization is a simple +s + * (the codebase has no pluralize helper and no current kind needs one). + * Missing nodes (stale ids) are skipped. + */ +export function formatSelectionBreakdown(types: Array): string { + const counts = new Map() + for (const type of types) { + if (!type) continue + counts.set(type, (counts.get(type) ?? 0) + 1) + } + const parts: string[] = [] + for (const [type, count] of counts) { + const label = type.replace(/-/g, ' ') + parts.push(`${count} ${count === 1 ? label : `${label}s`}`) + } + return parts.join(' · ') +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index 5407b929..f0303eec 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -1,4 +1,4 @@ -import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core' +import { type BuildingNode, DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Building2, Plus } from 'lucide-react' import { memo, useState } from 'react' @@ -43,6 +43,7 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({ const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length const newLevel = LevelNode.parse({ level: levelCount, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: nodeId, }) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index af7fe82d..87a6352b 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -2,6 +2,7 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + DEFAULT_LEVEL_HEIGHT, emitter, type GuideNode, LevelNode, @@ -926,6 +927,7 @@ const LevelsSection = memo(function LevelsSection({ const handleAddLevel = () => { const newLevel = LevelNode.parse({ level: levels.length, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: building.id, }) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5db96e10..ba2dfedb 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -132,6 +132,13 @@ export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' export { PlacementBox } from './components/tools/shared/placement-box' +// Pointer-decided support surface (deck top vs floor underneath) — the +// draw tools (wall / fence) ride their grid plane and commit cap on it. +export { + type PointerSupportSurface, + resolvePointerSupportElevation, + resolvePointerSupportSurface, +} from './components/tools/shared/pointer-support-cap' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, diff --git a/packages/editor/src/lib/level-duplication.test.ts b/packages/editor/src/lib/level-duplication.test.ts index 5f850071..b4f2434c 100644 --- a/packages/editor/src/lib/level-duplication.test.ts +++ b/packages/editor/src/lib/level-duplication.test.ts @@ -11,7 +11,7 @@ import { buildLevelDuplicateCreateOps } from './level-duplication' describe('buildLevelDuplicateCreateOps', () => { test('parents a duplicated bootstrap level back to its building', () => { - const level = LevelNode.parse({ level: 0, children: [] }) + const level = LevelNode.parse({ level: 0, height: 3.25, children: [] }) const building = BuildingNode.parse({ children: [level.id] }) const wall = WallNode.parse({ parentId: level.id, @@ -36,6 +36,7 @@ describe('buildLevelDuplicateCreateOps', () => { expect(sourceLevel.parentId).toBeNull() expect(levelCreateOp?.parentId).toBe(building.id) + expect(levelCreateOp?.node.type === 'level' ? levelCreateOp.node.height : undefined).toBe(3.25) }) test('does not copy spawn points from the source level', () => { diff --git a/packages/editor/src/lib/stair-levels.ts b/packages/editor/src/lib/stair-levels.ts index d0f1841a..e1410134 100644 --- a/packages/editor/src/lib/stair-levels.ts +++ b/packages/editor/src/lib/stair-levels.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + DEFAULT_LEVEL_HEIGHT, LevelNode, type LevelNode as LevelNodeType, resolveBuildingForLevel, @@ -154,6 +155,7 @@ export function resolveStairDestinationLevel({ if (createMissing && buildingId) { const createdLevel = LevelNode.parse({ children: [], + height: DEFAULT_LEVEL_HEIGHT, level: fromLevel.level + 1, parentId: buildingId, }) diff --git a/packages/mcp/src/templates/empty-studio.ts b/packages/mcp/src/templates/empty-studio.ts index 10a4b7c0..75c9de4c 100644 --- a/packages/mcp/src/templates/empty-studio.ts +++ b/packages/mcp/src/templates/empty-studio.ts @@ -1,3 +1,4 @@ +import { DEFAULT_LEVEL_HEIGHT } from '@pascal-app/core' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' @@ -137,7 +138,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: [], thickness: 0.1, - height: 2.5, start: [-W, -D], end: [W, -D], frontSide: 'unknown', @@ -153,7 +153,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: [], thickness: 0.1, - height: 2.5, start: [W, -D], end: [W, D], frontSide: 'unknown', @@ -169,7 +168,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: ['door_front'], thickness: 0.1, - height: 2.5, start: [W, D], end: [-W, D], frontSide: 'unknown', @@ -185,7 +183,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: ['window_w'], thickness: 0.1, - height: 2.5, start: [-W, D], end: [-W, -D], frontSide: 'unknown', @@ -217,6 +214,7 @@ function buildNodes(): StudioNodes { visible: true, metadata: {}, level: 0, + height: DEFAULT_LEVEL_HEIGHT, children: [...wallIds, 'zone_living'] as AnyNodeId[], } as unknown as AnyNode diff --git a/packages/mcp/src/templates/garden-house.ts b/packages/mcp/src/templates/garden-house.ts index 5dd8dcd7..4e695ac3 100644 --- a/packages/mcp/src/templates/garden-house.ts +++ b/packages/mcp/src/templates/garden-house.ts @@ -41,7 +41,6 @@ function wall( metadata: {}, children, thickness: WALL_THICKNESS, - height: WALL_HEIGHT, start, end, frontSide: 'unknown', @@ -250,6 +249,7 @@ function buildTemplate(): SceneGraph { visible: true, metadata: {}, level: 0, + height: WALL_HEIGHT, children: [ 'wall_n', 'wall_e', diff --git a/packages/mcp/src/templates/two-bedroom.ts b/packages/mcp/src/templates/two-bedroom.ts index d41a14b1..dcd8dd1e 100644 --- a/packages/mcp/src/templates/two-bedroom.ts +++ b/packages/mcp/src/templates/two-bedroom.ts @@ -45,7 +45,6 @@ function wall( metadata: {}, children, thickness: WALL_THICKNESS, - height: WALL_HEIGHT, start, end, frontSide: 'unknown', @@ -274,6 +273,7 @@ function buildTemplate(): SceneGraph { visible: true, metadata: {}, level: 0, + height: WALL_HEIGHT, children: [ 'wall_n', 'wall_e', diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index a8e43e92..307d6f88 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -1,4 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { resolveStairTotalRise } from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { CeilingNode, @@ -23,9 +24,10 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), - wallHeight: measurement('length', 'm', { positive: true, description: 'Wall height.' }).default( - 2.8, - ), + wallHeight: measurement('length', 'm', { + positive: true, + description: 'Explicit wall height override. Omit for level-plane-bound walls.', + }).optional(), wallThickness: measurement('length', 'm', { positive: true, description: 'Wall thickness.', @@ -100,7 +102,7 @@ export const createStairBetweenLevelsInput = { totalRise: measurement('length', 'm', { positive: true, description: 'Total vertical rise.', - }).default(2.8), + }).optional(), stepCount: z.number().int().positive().default(14), railingMode: z.enum(RAILING_MODES).default('both'), destinationSlabId: NodeIdSchema.optional(), @@ -276,7 +278,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat start: points[i], end: points[(i + 1) % points.length], thickness: wallThickness, - height: wallHeight, + ...(wallHeight !== undefined ? { height: wallHeight } : {}), frontSide: 'exterior', backSide: 'interior', ...(wallMaterialPreset ? { materialPreset: wallMaterialPreset } : {}), @@ -292,6 +294,11 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat name: namePrefix ? `${namePrefix} Slab` : undefined, polygon: points, elevation: slabElevation, + // Grounded solid: underside on the level plane, so the created + // story slab occupies [0, slabElevation] like the legacy + // extrude-from-zero model. + thickness: Math.max(slabElevation, 0), + recessed: slabElevation < 0, ...(slabMaterialPreset ? { materialPreset: slabMaterialPreset } : {}), metadata: { role: 'story-slab' }, }) @@ -301,10 +308,13 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat let ceilingId: string | null = null if (createCeiling) { + // Height-less unless the caller pinned one: a new story ceiling + // follows the level top automatically. + const explicitCeilingHeight = ceilingHeight ?? wallHeight const ceiling = CeilingNode.parse({ name: namePrefix ? `${namePrefix} Ceiling` : undefined, polygon: points, - height: ceilingHeight ?? wallHeight, + ...(explicitCeilingHeight !== undefined ? { height: explicitCeilingHeight } : {}), ...(ceilingMaterialPreset ? { materialPreset: ceilingMaterialPreset } : {}), metadata: { role: 'story-ceiling' }, }) @@ -372,12 +382,12 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat const roofLevel = LevelNode.parse({ name: roofLevelLabel, level: roofLevelElevation ?? nextLevelIndex(bridge, buildingId, referenceLevel), + height: roofLevelHeight ?? Math.max(wallHeight + peakHeight, 0.2), children: [], metadata: { role: 'roof', label: roofLevelLabel, referenceLevelId: levelId, - height: roofLevelHeight ?? Math.max(wallHeight + peakHeight, 0.2), }, }) targetRoofLevelId = roofLevel.id as AnyNodeId @@ -460,15 +470,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat ) } - const segment = StairSegmentNode.parse({ - segmentType: 'stair', - width, - length: runLength, - height: totalRise, - stepCount, - ...(materialPreset ? { materialPreset } : {}), - }) - const stair = StairNode.parse({ + const stairDraft = StairNode.parse({ name: name ?? 'Stair', position: position as [number, number, number], rotation, @@ -478,15 +480,33 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat slabOpeningMode: 'none', openingOffset, width, - totalRise, + ...(totalRise !== undefined ? { totalRise } : {}), stepCount, railingMode, - children: [segment.id], + children: [], ...(materialPreset ? { materialPreset } : {}), metadata: { openingManaged: 'manual-rectangular', }, }) + const riseNodes = { + ...bridge.getNodes(), + [fromLevel.id]: { + ...fromLevel, + children: [...(fromLevel as Extract).children, stairDraft.id], + }, + [stairDraft.id]: stairDraft, + } as Record + const resolvedTotalRise = resolveStairTotalRise(stairDraft, riseNodes) + const segment = StairSegmentNode.parse({ + segmentType: 'stair', + width, + length: runLength, + height: resolvedTotalRise, + stepCount, + ...(materialPreset ? { materialPreset } : {}), + }) + const stair = { ...stairDraft, children: [segment.id] } const openingPolygon = rectangularOpening({ position: position as [number, number, number], diff --git a/packages/mcp/src/tools/create-level.test.ts b/packages/mcp/src/tools/create-level.test.ts index cf548daa..268876b6 100644 --- a/packages/mcp/src/tools/create-level.test.ts +++ b/packages/mcp/src/tools/create-level.test.ts @@ -20,11 +20,11 @@ describe('create_level', () => { await Promise.all([server.connect(srvT), client.connect(cliT)]) }) - test('creates a level on a building', async () => { + test('appends a level on a building with stored height', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const result = await client.callTool({ name: 'create_level', - arguments: { buildingId: building.id, elevation: 3, label: 'Second' }, + arguments: { buildingId: building.id, elevation: 99, height: 3.1, label: 'Second' }, }) expect(result.isError).toBeFalsy() const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) @@ -32,7 +32,9 @@ describe('create_level', () => { const created = bridge.getNode(parsed.levelId) expect(created).not.toBeNull() expect(created!.type).toBe('level') - expect((created as { level: number }).level).toBe(3) + expect((created as { height: number; level: number }).level).toBe(1) + expect((created as { height: number; level: number }).height).toBe(3.1) + expect(created?.metadata.height).toBeUndefined() }) test('rejects unknown building id', async () => { diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts index 784297c4..06ce61bd 100644 --- a/packages/mcp/src/tools/create-level.ts +++ b/packages/mcp/src/tools/create-level.ts @@ -1,4 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { DEFAULT_LEVEL_HEIGHT } from '@pascal-app/core' import type { AnyNodeId } from '@pascal-app/core/schema' import { LevelNode } from '@pascal-app/core/schema' import { z } from 'zod' @@ -10,10 +11,13 @@ import { NodeIdSchema } from './schemas' export const createLevelInput = { buildingId: NodeIdSchema, - elevation: z.number().optional(), + elevation: z + .number() + .optional() + .describe("Legacy parameter; new levels are appended above the building's current top level."), height: measurement('length', 'm', { min: 0, - description: 'Level height (stored in metadata).', + description: 'Stored floor-to-floor storey height.', }).optional(), label: z.string().optional(), } @@ -28,11 +32,11 @@ export function registerCreateLevel(server: McpServer, bridge: SceneOperations): { title: 'Create level', description: - 'Create a new level node attached to the given building. height and label are stored in metadata.', + "Append a new level above the given building's current top level. height is stored as the level's floor-to-floor storey height.", inputSchema: createLevelInput, outputSchema: createLevelOutput, }, - async ({ buildingId, elevation, height, label }) => { + async ({ buildingId, height, label }) => { const parent = bridge.getNode(buildingId as AnyNodeId) if (!parent) { throwMcpError(ErrorCode.InvalidParams, `Building not found: ${buildingId}`) @@ -45,11 +49,16 @@ export function registerCreateLevel(server: McpServer, bridge: SceneOperations): } const metadata: Record = {} - if (height !== undefined) metadata.height = height if (label !== undefined) metadata.label = label + const existingOrdinals = bridge + .getChildren(buildingId as AnyNodeId) + .filter((node) => node.type === 'level') + .map((node) => node.level) + const nextOrdinal = Math.max(-1, ...existingOrdinals) + 1 const levelNode = LevelNode.parse({ - level: elevation ?? 0, + level: nextOrdinal, + height: height ?? DEFAULT_LEVEL_HEIGHT, children: [], ...(Object.keys(metadata).length > 0 ? { metadata } : {}), ...(label !== undefined ? { name: label } : {}), diff --git a/packages/mcp/src/tools/describe-node.ts b/packages/mcp/src/tools/describe-node.ts index 8be93177..62832351 100644 --- a/packages/mcp/src/tools/describe-node.ts +++ b/packages/mcp/src/tools/describe-node.ts @@ -1,8 +1,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { resolveCeilingHeight } from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' +import { resolveReportedWallHeight } from './scene-query' import { NodeIdSchema } from './schemas' export const describeNodeInput = { @@ -23,13 +25,13 @@ export const describeNodeOutput = { * Build a short, human-readable one-liner describing the node. * Covers the common shapes; falls back to a generic sentence otherwise. */ -function describe(node: AnyNode): string { +function describe(node: AnyNode, bridge: SceneOperations): string { switch (node.type) { case 'wall': { const [x1, z1] = node.start const [x2, z2] = node.end const t = node.thickness ?? 0.1 - const h = node.height ?? 2.5 + const h = resolveReportedWallHeight(bridge, node) return `Wall from (${x1},${z1}) to (${x2},${z2}), thickness ${t.toFixed(2)}m, height ${h.toFixed(2)}m` } case 'level': @@ -45,7 +47,7 @@ function describe(node: AnyNode): string { case 'slab': return `Slab with ${node.polygon.length} vertices` case 'ceiling': - return `Ceiling with ${node.polygon.length} vertices, height ${node.height.toFixed(2)}m` + return `Ceiling with ${node.polygon.length} vertices, height ${resolveCeilingHeight(node, bridge.getNodes()).toFixed(2)}m` case 'door': return `Door (${node.width.toFixed(2)}m x ${node.height.toFixed(2)}m)` case 'window': @@ -83,14 +85,22 @@ export function registerDescribeNode(server: McpServer, bridge: SceneOperations) const childrenIds = children.map((n) => n.id as string) const n = node as AnyNode + const properties = + n.type === 'wall' + ? { + ...n, + resolvedHeight: resolveReportedWallHeight(bridge, n), + heightIsExplicit: n.height !== undefined, + } + : n const payload = { id: n.id as string, type: n.type as string, parentId: (n.parentId ?? null) as string | null, ancestryIds, childrenIds, - properties: n as unknown as Record, - description: describe(n), + properties: properties as unknown as Record, + description: describe(n, bridge), } return { diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index 96d2d2b1..3679a384 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -55,6 +55,7 @@ const VisionResponseSchema = z.object({ start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), thickness: z.number().optional(), + height: z.number().positive().optional(), }), ), rooms: z.array( @@ -82,13 +83,14 @@ const SYSTEM_PROMPT = `You are a vision assistant that extracts structured floor Your ONLY job: return a JSON object that exactly matches this schema — no prose, no markdown fences. { - "walls": [{ "start": [x, z], "end": [x, z], "thickness": number? }, ...], + "walls": [{ "start": [x, z], "end": [x, z], "thickness": number?, "height": number? }, ...], "rooms": [{ "name": string, "polygon": [[x,z], ...], "approximateAreaSqM": number? }, ...], "approximateDimensions": { "widthM": number, "depthM": number }, "confidence": number 0..1 } Coordinates are in metres. Origin can be the floor plan's centre or bottom-left — be consistent. +Only include a wall height when it is visibly measured or annotated in the image. If the image is unclear, lower the confidence score but still produce your best attempt. DO NOT wrap the JSON in markdown. DO NOT explain. Just output the raw JSON.` @@ -235,7 +237,7 @@ function buildSceneGraphFromVision( // Build the skeleton: site → building → level. const building = BuildingNode.parse({}) - const level = LevelNode.parse({ level: 0 }) + const level = LevelNode.parse({ level: 0, height: defaultWallHeight }) const site = SiteNode.parse({ children: [building.id] }) // Link parent ids so downstream traversal works. @@ -283,7 +285,7 @@ function buildSceneGraphFromVision( start: w.start, end: w.end, thickness: w.thickness ?? defaultWallThickness, - height: defaultWallHeight, + ...(w.height !== undefined ? { height: w.height } : {}), }) const linkedWall: AnyNodeT = { ...(wall as AnyNodeT), diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 22f7cc2d..b3eac2f4 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -1,5 +1,13 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + DEFAULT_LEVEL_HEIGHT, + getStoredLevelHeight, + getWallPlaneTop, + resolveStairTotalRise, + resolveWallEffectiveHeight, +} from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' +import { computeWallSlabSupport } from '@pascal-app/core/spatial-grid' import { z } from 'zod' import type { SceneOperations } from '../operations' import { @@ -111,6 +119,27 @@ function nodesOnLevel(bridge: SceneOperations, levelId: AnyNodeId): AnyNode[] { ) } +export function resolveReportedWallHeight( + bridge: SceneOperations, + wall: Extract, +): number { + const levelId = bridge.resolveLevelId(wall.id as AnyNodeId) + // Covering-clamped plane for plane-bound walls; explicit heights pass + // through resolveWallEffectiveHeight untouched. + const planeTop = levelId + ? getWallPlaneTop(wall, levelId, bridge.getNodes()) + : DEFAULT_LEVEL_HEIGHT + const levelNodes = levelId ? nodesOnLevel(bridge, levelId) : [] + const slabs = levelNodes.filter( + (node): node is Extract => node.type === 'slab', + ) + const walls = levelNodes.filter( + (node): node is Extract => node.type === 'wall', + ) + const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) +} + function metadataRecord(node: AnyNode): Record | null { return typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) @@ -159,6 +188,7 @@ function openingSummaries(bridge: SceneOperations, wallId: AnyNodeId) { function wallSummary(bridge: SceneOperations, wall: AnyNode) { if (wall.type !== 'wall') return null const length = distance2D(wall.start, wall.end) + const resolvedHeight = resolveReportedWallHeight(bridge, wall) return { id: wall.id, name: wall.name, @@ -166,6 +196,8 @@ function wallSummary(bridge: SceneOperations, wall: AnyNode) { end: wall.end, length: Math.round(length * 100) / 100, height: wall.height, + resolvedHeight, + heightIsExplicit: wall.height !== undefined, thickness: wall.thickness, openings: openingSummaries(bridge, wall.id as AnyNodeId), } @@ -308,7 +340,7 @@ function stairFootprintPolygons( { width: stair.width ?? 1, length: 3, - height: stair.totalRise ?? 2.5, + height: resolveStairTotalRise(stair, nodes), stepCount: stair.stepCount ?? 10, attachmentSide: 'front' as const, }, @@ -657,17 +689,11 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): if (level.type !== 'level') continue const summary = levels.find((entry) => entry.levelId === level.id) if (!summary?.isOccupiedStory) continue - const expectedHeight = - typeof level.metadata === 'object' && - level.metadata !== null && - 'height' in level.metadata && - typeof level.metadata.height === 'number' - ? level.metadata.height - : 3.2 + const expectedHeight = getStoredLevelHeight(level) for (const wall of nodesOnLevel(bridge, level.id as AnyNodeId).filter( (node): node is AnyNode & { type: 'wall' } => node.type === 'wall', )) { - const wallHeight = wall.height ?? 2.5 + const wallHeight = resolveReportedWallHeight(bridge, wall) if (wallHeight > expectedHeight + 0.25) { issues.push( `Wall ${wall.name ?? wall.id} on ${level.name ?? level.id} is ${wallHeight}m high; multi-story exterior walls should be split into level-owned story walls`, @@ -699,7 +725,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) { issues.push(`${node.type} ${node.id} extends outside wall ${parent.id}`) } - const wallHeight = parent.height ?? 2.5 + const wallHeight = resolveReportedWallHeight(bridge, parent) const bottom = node.position[1] - height / 2 const top = node.position[1] + height / 2 if (bottom < -0.01 || top > wallHeight + 0.01) { diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index a07a4b59..56672a18 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -15,6 +15,7 @@ import { movingFootprintAnchors, nodeRegistry, resolveAlignment, + resolveSupportSlabPatch, spatialGridManager, useScene, type WallEvent, @@ -797,6 +798,7 @@ const CabinetTool = () => { name: island ? 'Kitchen Island' : 'Modular Cabinet', position, rotation: yaw, + parentId: activeLevelId, depth: patch.depth ?? cabinetDefinition.defaults().depth, carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, ...(island && { @@ -841,11 +843,16 @@ const CabinetTool = () => { buildModule(m.x, m.width, index), ) for (const module of modules) sceneApi.upsert(module, cabinet.id as AnyNodeId) + const liveRun = sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet + sceneApi.update( + liveRun.id as AnyNodeId, + resolveSupportSlabPatch(liveRun, sceneApi.nodes()), + ) bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) sceneApi.resumeHistory() return { endModule: modules[modules.length - 1]!, - run: sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet, + run: sceneApi.get(cabinet.id as AnyNodeId) ?? liveRun, } } @@ -889,6 +896,19 @@ const CabinetTool = () => { } bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId) + const liveNextRun = sceneApi.get(nextRun.id as AnyNodeId) ?? nextRun + sceneApi.update( + liveNextRun.id as AnyNodeId, + resolveSupportSlabPatch(liveNextRun, sceneApi.nodes()), + ) + const rootRun = chainRootRunRef.current + if (rootRun) { + const liveRoot = sceneApi.get(rootRun.id as AnyNodeId) ?? rootRun + sceneApi.update( + liveRoot.id as AnyNodeId, + resolveSupportSlabPatch(liveRoot, sceneApi.nodes()), + ) + } sceneApi.resumeHistory() return { endModule: anchorModule, @@ -996,11 +1016,16 @@ const CabinetTool = () => { } const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw) const module = buildModule(0, previewNode.width, 0) + const nodes = { ...useScene.getState().nodes, [cabinet.id]: cabinet, [module.id]: module } + const committedCabinet = CabinetNode.parse({ + ...cabinet, + ...resolveSupportSlabPatch(cabinet, nodes), + }) useScene.getState().createNodes([ - { node: cabinet, parentId: activeLevelId }, - { node: module, parentId: cabinet.id }, + { node: committedCabinet, parentId: activeLevelId }, + { node: module, parentId: committedCabinet.id }, ]) - bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + bumpCabinetRunsNearNewRun(committedCabinet.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [module.id] }) useEditor.getState().setMode('select') triggerSFX('sfx:item-place') diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index f0e96317..3ed66c0c 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -1,6 +1,12 @@ 'use client' -import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + type CeilingNode, + resolveCeilingHeight, + resolveLevelId, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' import { boundaryReshapeScope, clearCeilingSnapFeedback, @@ -173,7 +179,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = onVertexHoverChange={handleHandleHoverChange} polygon={effectiveCeiling.polygon} resolvePlanPoint={resolvePolygonEditorPlanPoint} - surfaceHeight={effectiveCeiling.height ?? 2.5} + surfaceHeight={resolveCeilingHeight(effectiveCeiling, useScene.getState().nodes)} /> ) } diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 6dbc39eb..fc8b8635 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -1,7 +1,12 @@ -import type { - CeilingNode as CeilingNodeType, - HandleDescriptor, - NodeDefinition, +import { + type AnyNodeId, + type CeilingNode as CeilingNodeType, + getCeilingClampBound, + type HandleDescriptor, + type NodeDefinition, + resolveCeilingHeight, + type SceneApi, + useScene, } from '@pascal-app/core' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' import { buildCeilingFloorplan } from './floorplan' @@ -20,6 +25,18 @@ import { ceilingSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_CEILING_HEIGHT = 0.5 +// Ceilings no longer drive the storey height; the stored level height +// does. Height writes clamp under min(storey plane, lowest underside of +// any covering slab from the level above) − CEILING_CLAMP_MARGIN, so a +// ceiling can poke into neither the level above nor a deck hanging from +// it (clamp, never ask). +function ceilingHeightBound(n: CeilingNodeType, sceneApi: SceneApi): number { + const parent = n.parentId ? sceneApi.get(n.parentId as AnyNodeId) : undefined + return parent?.type === 'level' + ? getCeilingClampBound(parent.id, sceneApi.nodes(), n.polygon ?? []) + : Number.POSITIVE_INFINITY +} + function ceilingPolygonCenter(n: CeilingNodeType): [number, number] { const polygon = n.polygon ?? [] if (polygon.length === 0) return [0, 0] @@ -38,8 +55,12 @@ function ceilingPolygonCenter(n: CeilingNodeType): [number, number] { // the cursor upward grows the value directly. Live override + commit // flow comes from the shared registry arrow pipeline. // +// `currentValue` resolves through `resolveCeilingHeight`, and `apply` +// always writes `height` — so dragging a follows-mode ceiling converts +// it to an explicit custom height, mirroring the wall top-drag. +// // The placement Y is in *mesh-local* coords. CeilingSystem already -// parks `mesh.position.y = ceiling.height - 0.01`, so the local Y is +// parks `mesh.position.y = resolved height - 0.01`, so the local Y is // just the offset above that plane (NOT `height + offset` — that // would double-add the height and push the arrow off-screen). function ceilingHeightHandle(): HandleDescriptor { @@ -48,7 +69,8 @@ function ceilingHeightHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_CEILING_HEIGHT, - currentValue: (n) => n.height ?? 2.5, + max: ceilingHeightBound, + currentValue: (n) => resolveCeilingHeight(n, useScene.getState().nodes), apply: (_n, newValue) => ({ height: newValue }), placement: { position: (n) => { @@ -87,6 +109,8 @@ export const ceilingDefinition: NodeDefinition = { category: 'structure', surfaceRole: 'ceiling', + // Height-less on purpose: a new ceiling follows the level top until the + // user gives it an explicit custom height. defaults: () => ({ object: 'node', parentId: null, @@ -96,14 +120,15 @@ export const ceilingDefinition: NodeDefinition = { polygon: [], holes: [], holeMetadata: [], - height: 2.5, autoFromWalls: false, }), capabilities: { selectable: { hitVolume: 'bbox' }, surfaces: { - top: { height: (n) => (n as CeilingNode).height }, + top: { + height: (n) => resolveCeilingHeight(n as CeilingNodeType, useScene.getState().nodes), + }, }, duplicable: true, deletable: true, @@ -124,7 +149,7 @@ export const ceilingDefinition: NodeDefinition = { features: (node) => polygonMeasurementFeatures({ featurePrefix: 'ceiling', - height: node.height, + height: resolveCeilingHeight(node, useScene.getState().nodes), label: 'Ceiling', polygon: node.polygon, }), diff --git a/packages/nodes/src/ceiling/floorplan-move.ts b/packages/nodes/src/ceiling/floorplan-move.ts index 42c0e2ef..6eb715e3 100644 --- a/packages/nodes/src/ceiling/floorplan-move.ts +++ b/packages/nodes/src/ceiling/floorplan-move.ts @@ -1,4 +1,4 @@ -import type { CeilingNode, FloorplanMoveTarget } from '@pascal-app/core' +import { type CeilingNode, type FloorplanMoveTarget, resolveCeilingHeight } from '@pascal-app/core' import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move' /** @@ -6,14 +6,14 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move * centroid-pivot mover (same pivot semantics as slab / items). See * `shared/polygon-centroid-move.ts` for the rationale. * - * `meshY = height − 0.01`: `CeilingSystem` parks the ceiling group at that Y - * on rebuild, so mirroring it during the drag avoids a vertical teleport in - * split view. + * `meshY = resolved height − 0.01`: `CeilingSystem` parks the ceiling group + * at that Y on rebuild, so mirroring it during the drag avoids a vertical + * teleport in split view. */ export const ceilingFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => createPolygonCentroidMoveTarget({ node, nodes, - meshY: (node.height ?? 2.5) - 0.01, + meshY: resolveCeilingHeight(node, nodes) - 0.01, extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined, }) diff --git a/packages/nodes/src/ceiling/hole-editor.tsx b/packages/nodes/src/ceiling/hole-editor.tsx index 1b0ac28c..b418112d 100644 --- a/packages/nodes/src/ceiling/hole-editor.tsx +++ b/packages/nodes/src/ceiling/hole-editor.tsx @@ -1,6 +1,12 @@ 'use client' -import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + type CeilingNode, + resolveCeilingHeight, + resolveLevelId, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' import { PolygonEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect } from 'react' @@ -64,7 +70,7 @@ export const CeilingHoleEditor: React.FC<{ onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} polygon={hole} - surfaceHeight={ceiling.height ?? 2.5} + surfaceHeight={resolveCeilingHeight(ceiling, useScene.getState().nodes)} /> ) } diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index ac52e65e..43387a80 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -8,6 +8,7 @@ import { type GridEvent, polygonAnchors, resolveAlignment, + resolveCeilingHeight, sceneRegistry, snapScalar, useLiveTransforms, @@ -99,7 +100,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), ) const originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current)) - const heightRef = useRef(node.height ?? 2.5) + // Resolved once at drag start — the ceiling plane can't change mid-move. + const heightRef = useRef(resolveCeilingHeight(node, useScene.getState().nodes)) const dragAnchorRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) const deltaRef = useRef<[number, number]>([0, 0]) @@ -254,7 +256,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { diff --git a/packages/nodes/src/ceiling/panel.tsx b/packages/nodes/src/ceiling/panel.tsx index 219b695f..07754c03 100644 --- a/packages/nodes/src/ceiling/panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -1,12 +1,20 @@ 'use client' -import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' +import { + type AnyNode, + type CeilingNode, + getCeilingClampBound, + resolveCeilingHeight, + useScene, +} from '@pascal-app/core' import { ActionButton, ActionGroup, + formatLinearMeasurement, holeEditScope, PanelSection, PanelWrapper, + SegmentedControl, SliderControl, triggerSFX, useEditingHole, @@ -27,6 +35,7 @@ import { useCallback, useEffect, useRef } from 'react' */ export function CeilingPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) @@ -35,11 +44,40 @@ export function CeilingPanel() { selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined, ) + // Ceilings no longer drive the storey height — the stored level height + // does — so height writes clamp under min(storey plane, lowest covering + // slab underside from the level above) − margin instead of poking into + // the level above or a deck hanging from it (clamp, never ask). + // Selector returns a primitive, so recomputing per store update is + // re-render-safe. + const maxHeight = useScene((s) => { + const parent = node?.parentId ? s.nodes[node.parentId as AnyNode['id']] : undefined + return parent?.type === 'level' + ? getCeilingClampBound(parent.id, s.nodes, node?.polygon ?? []) + : 6 + }) + + // Effective height: the stored custom height, or — for follows-mode + // ceilings (absent `height`) — the live level-top bound. Primitive + // selector so it tracks level-height / covering-slab edits. + const resolvedHeight = useScene((s) => { + const ceiling = selectedId + ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) + : undefined + return ceiling?.type === 'ceiling' ? resolveCeilingHeight(ceiling, s.nodes) : 2.5 + }) + // Panel slider-drag fix recipe (plans/editor-node-registry.md): stable // handler refs so slider drags don't trigger Maximum update depth. const nodeRef = useRef(node) nodeRef.current = node + const maxHeightRef = useRef(maxHeight) + maxHeightRef.current = maxHeight + + const resolvedHeightRef = useRef(resolvedHeight) + resolvedHeightRef.current = resolvedHeight + const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return @@ -48,6 +86,31 @@ export function CeilingPanel() { [selectedId], ) + const handleHeightChange = useCallback( + (proposed: number) => { + handleUpdate({ height: Math.min(proposed, maxHeightRef.current) }) + }, + [handleUpdate], + ) + + const handleTopModeChange = useCallback( + (mode: 'storey' | 'custom') => { + const n = nodeRef.current + if (!n) return + const isCustom = n.height != null + if (mode === 'custom' && !isCustom) { + // Seed from the current resolved height so the surface doesn't + // jump at the moment of detaching from the level top. + handleUpdate({ height: Math.min(resolvedHeightRef.current, maxHeightRef.current) }) + } else if (mode === 'storey' && isCustom) { + // Absent `height` = follows the level top; the store strips + // undefined keys. + handleUpdate({ height: undefined }) + } + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope @@ -156,6 +219,22 @@ export function CeilingPanel() { } const area = calculateArea(node.polygon) + const isFollows = node.height == null + + // Clean preset values per display system; imperial stores exact meters + // for 8'0" / 8'6" / 9'0" ceilings. + const heightPresets = + unit === 'imperial' + ? [ + { label: 'Low (8\'0")', height: 2.4384 }, + { label: 'Standard (8\'6")', height: 2.5908 }, + { label: 'High (9\'0")', height: 2.7432 }, + ] + : [ + { label: 'Low (2.4m)', height: 2.4 }, + { label: 'Standard (2.5m)', height: 2.5 }, + { label: 'High (3.0m)', height: 3.0 }, + ] return ( - handleUpdate({ height: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.height * 1000) / 1000} + + {isFollows ? ( +
+ Currently {formatLinearMeasurement(resolvedHeight, unit)} +
+ ) : ( + + )} + {/* Presets write an explicit height (clamped to the bound), so + clicking one on a follows-mode ceiling switches it to custom. */}
- handleUpdate({ height: 2.4 })} /> - handleUpdate({ height: 2.5 })} /> - handleUpdate({ height: 3.0 })} /> + {heightPresets.map((preset) => ( + handleHeightChange(preset.height)} + /> + ))}
diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 9f49086c..781c4c5a 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, getMaterialPresetByRef, + resolveCeilingHeight, resolveMaterial, useLiveTransforms, useRegistry, @@ -46,7 +47,11 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { // ceiling slot references re-tints it live. const sceneMaterials = useScene((s) => s.materials) const liveTransform = useLiveTransforms((s) => s.get(node.id)) - const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) + // Resolved height: explicit when stored, else the live level-top bound + // (primitive selector, so follows-mode ceilings track level-height edits + // and covering-slab changes without a node write). + const resolvedHeight = useScene((s) => resolveCeilingHeight(node, s.nodes)) + const ceilingY = resolvedHeight - 0.01 + (liveTransform?.position[1] ?? 0) const position: [number, number, number] = [ liveTransform?.position[0] ?? 0, ceilingY, diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 31befe79..c8af70c5 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -7,6 +7,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, useScene, } from '@pascal-app/core' import { @@ -142,9 +143,16 @@ const ColumnTool = () => { !isGridSnapActive(), ) - const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) - useScene.getState().createNode(column, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [column.id] }) + const column = ColumnNode.parse({ + ...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position), + parentId: activeLevelId, + }) + const committedColumn = ColumnNode.parse({ + ...column, + ...resolveSupportSlabPatch(column, useScene.getState().nodes), + }) + useScene.getState().createNode(committedColumn, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedColumn.id] }) triggerSFX('sfx:structure-build') useAlignmentGuides.getState().clear() usePlacementPreview.getState().clear() @@ -155,7 +163,10 @@ const ColumnTool = () => { cursorVisibleRef.current = false setCursorVisible(false) useFacingPose.getState().clear() + // Restore select mode with the tool — `mode: 'build'` with no tool is + // a dead state where the selection manager ignores every click. useEditor.getState().setTool(null) + useEditor.getState().setMode('select') } stopPlacementCommitPropagation(event) } diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 4a6f6cc6..195c141b 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -9,6 +9,7 @@ import type { import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' +import { readHostWallCeiling } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' @@ -34,12 +35,6 @@ function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unk return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } -function readWallHeight(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!door.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined - return wall?.height ?? Number.POSITIVE_INFINITY -} - // Width arrow on the door-local +X (right) or -X (left) side. Drag grows // the door from the anchored OPPOSITE edge; the door's wall-local center // re-centers so the anchored edge stays put. @@ -100,7 +95,7 @@ function doorHeightHandle(): HandleDescriptor { const roofMax = readRoofFaceHeightMax(n, scene, 1) if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax) const bottom = n.position[1] - n.height / 2 - return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom) + return Math.max(MIN_DOOR_HEIGHT, readHostWallCeiling(n.wallId, scene) - bottom) }, currentValue: (n) => n.height, onDrag: (node) => publishOpeningResizeGuides(node, false), diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index e176fa7e..92d523d1 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -9,6 +9,7 @@ import { type GridEvent, getCeilingAt, getCeilingHeightAt, + resolveCeilingHeight, useScene, } from '@pascal-app/core' import { @@ -1132,7 +1133,7 @@ function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) { return pts }, [ceiling.polygon]) if (!geometry) return null - const y = ceiling.height ?? 2.5 + const y = resolveCeilingHeight(ceiling, useScene.getState().nodes) return ( diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index b1b9cafc..c8ef3063 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -2,10 +2,13 @@ import { type AnyNodeId, + type CeilingNode, DuctTerminalNode, emitter, pointInPolygon, + resolveCeilingHeight, resolveLevelId, + resolveSupportSlabPatch, sceneRegistry, useScene, type WallEvent, @@ -33,8 +36,6 @@ import { COLLAR_LENGTH, mountQuaternion } from './ports' const PREVIEW_OPACITY = 0.55 /** R/T yaw step — 45°. */ const ROTATE_STEP_RAD = Math.PI / 4 -/** Fallback height (meters) for a ceiling node that carries no `height`. */ -const DEFAULT_CEILING_HEIGHT = 2.5 /** Snap radius (meters) for mating the collar onto a nearby duct port. */ const PORT_SNAP_RADIUS_M = 0.5 @@ -227,12 +228,8 @@ const DuctTerminalTool = () => { for (const node of Object.values(nodes)) { if (node?.type !== 'ceiling') continue if (resolveLevelId(node, nodes) !== activeLevelId) continue - const ceiling = node as { - height?: number - polygon: Array<[number, number]> - holes?: Array> - } - const height = ceiling.height ?? DEFAULT_CEILING_HEIGHT + const ceiling = node as CeilingNode + const height = resolveCeilingHeight(ceiling, nodes) const hit = hitLocalPlane(nativeEvent, height) if (!hit) continue if (!pointInPolygon(hit.x, hit.z, ceiling.polygon)) continue @@ -289,9 +286,14 @@ const DuctTerminalTool = () => { mount: p.mount, position: p.position, rotation: p.yaw, + parentId: activeLevelId, }) - useScene.getState().createNode(terminal, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [terminal.id] }) + const committedTerminal = DuctTerminalNode.parse({ + ...terminal, + ...resolveSupportSlabPatch(terminal, useScene.getState().nodes), + }) + useScene.getState().createNode(committedTerminal, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedTerminal.id] }) triggerSFX('sfx:item-place') } diff --git a/packages/nodes/src/fence/__tests__/lift.test.ts b/packages/nodes/src/fence/__tests__/lift.test.ts new file mode 100644 index 00000000..53d6f935 --- /dev/null +++ b/packages/nodes/src/fence/__tests__/lift.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, FenceNode, SlabNode } from '@pascal-app/core' +import { resolveFenceLiftElevation } from '../lift' + +const LEVEL_ID = 'level-1' + +function makeDeck(elevation: number, parentId: string | null = LEVEL_ID): SlabNode { + return SlabNode.parse({ + parentId, + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + elevation, + thickness: 0.05, + }) +} + +function makeRailing(supportSlabId: string | undefined, parentId: string | null = LEVEL_ID) { + return FenceNode.parse({ + parentId, + start: [0, 0], + end: [4, 0], + supportSlabId, + }) +} + +function resolverFor(...nodes: AnyNode[]) { + const byId = new Map(nodes.map((node) => [node.id as string, node])) + return (id: string) => byId.get(id) +} + +describe('resolveFenceLiftElevation', () => { + test('lifts onto the host slab walking surface', () => { + const deck = makeDeck(1.25) + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck))).toBe(1.25) + }) + + test('unhosted fence stays on the level floor', () => { + const railing = makeRailing(undefined) + expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0) + }) + + test('stale host (slab gone) falls back to the floor', () => { + const deck = makeDeck(1.25) + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0) + }) + + test('host on another level does not lift the fence', () => { + const deck = makeDeck(1.25, 'level-2') + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck))).toBe(0) + }) + + test('host id resolving to a non-slab node is ignored', () => { + const deck = makeDeck(1.25) + const impostor = makeRailing(undefined) + const railing = makeRailing(impostor.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck, impostor))).toBe(0) + }) +}) diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index d7d8a9f1..12a6bcde 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -6,6 +6,7 @@ import { type DragAction, type FenceNode, resolveAlignment, + resolveFenceSupportSlabPatch, useScene, type WallNode, } from '@pascal-app/core' @@ -111,6 +112,26 @@ function snapshotLinked( return out } +/** + * Re-elect the slab lift host for the given fences from their CURRENT + * store state (call after the endpoint writes). Only writes when the host + * actually changes, so unaffected drags stay patch-free. + */ +function applyFenceSupportPatches( + ids: readonly AnyNodeId[], + scene: { update(id: AnyNodeId, data: Partial): void }, +) { + const nodes = useScene.getState().nodes + for (const id of ids) { + const fence = nodes[id] + if (fence?.type !== 'fence') continue + const patch = resolveFenceSupportSlabPatch(fence as FenceNode, nodes) + if (patch.supportSlabId !== (fence as FenceNode).supportSlabId) { + scene.update(id, patch as Partial) + } + } +} + function linkedCascade( linked: LinkedFenceSnapshot[], origin: FencePlanPoint, @@ -230,6 +251,10 @@ export const moveFenceEndpointDragAction: DragAction) + const patched: AnyNodeId[] = [ctx.fenceId] if (!draft.detached) { for (const linked of draft.linkedUpdates) { scene.update( @@ -259,8 +285,14 @@ export const moveFenceEndpointDragAction: DragAction, ) + patched.push(linked.id as AnyNodeId) } } + // The restoreAll above reverted any live host patch — re-run the + // election against the final endpoints so the committed fence stands + // on (or leaves) its deck. Uncapped: an endpoint drag has no commit + // pointer ray worth trusting, matching the wall move commits. + applyFenceSupportPatches(patched, scene) return true }, diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index b283a2a0..f75baeab 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -261,8 +261,13 @@ export const fenceDefinition: NodeDefinition = { // Stage B: pure geometry function. Generic rebuilds // on dirtyNodes; mounts the empty group. - // `renderer` + `system` fields dropped along with their files. geometry: buildFenceGeometry, + // Dependency tracker only — a hosted railing (`supportSlabId`) renders at + // its slab's elevation, so host elevation edits must re-dirty the fence. + system: { + module: () => import('./system'), + priority: 4, + }, // Stage C: floor-plan rendering. FloorplanRegistryLayer iterates kinds // with `floorplan` set and renders via FloorplanGeometryRenderer. // Legacy `floorplanFenceEntries` short-circuits to [] when fence is diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index bc0b33dc..4482dcc4 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -7,6 +7,7 @@ import { getMaxWallCurveOffset, getWallChordFrame, normalizeWallCurveOffset, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, type WallNode, @@ -320,6 +321,21 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = { data: { start: u.start, end: u.end }, })), ]) + // Re-elect the slab lift host as the endpoint drags (uncapped max + // election — 2D has no camera ray). This legacy write path commits + // via the dispatcher's snapshot diff, so patching per tick both + // previews the lift and lands it in the committed diff. Fences run + // no per-frame election: `supportSlabId` IS the lift. + const patchedNodes = useScene.getState().nodes + const supportPatches = [node.id, ...linkedUpdates.map((u) => u.id)].flatMap((id) => { + const fence = patchedNodes[id] + if (fence?.type !== 'fence') return [] + const patch = resolveFenceSupportSlabPatch(fence as FenceNode, patchedNodes) + return patch.supportSlabId === (fence as FenceNode).supportSlabId + ? [] + : [{ id, data: patch }] + }) + if (supportPatches.length > 0) useScene.getState().updateNodes(supportPatches) }, canCommit() { // Pointer-up always runs canCommit — drop the alignment guide here diff --git a/packages/nodes/src/fence/floorplan-move.ts b/packages/nodes/src/fence/floorplan-move.ts index 5a3db015..9dabf89a 100644 --- a/packages/nodes/src/fence/floorplan-move.ts +++ b/packages/nodes/src/fence/floorplan-move.ts @@ -3,6 +3,7 @@ import { type FenceNode, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -179,6 +180,35 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node // tracked change. Drop the override AFTER the scene write so // mid-commit reads still see the new position (override wins until // cleared; scene wins after). + // The re-elected slab lift host rides in the same write (uncapped max + // election — 2D has no camera ray): a fence moved onto / off an + // elevated deck must land on the right surface, since fences run no + // per-frame election (`supportSlabId` IS the lift). One updateNodes + // keeps the whole move a single tracked change. Election runs on the + // committed endpoints against the pre-write store (the patch only + // reads the parent level + the slab grid). + const baselineNodes = useScene.getState().nodes + const supportFor = ( + start: PlanPoint, + end: PlanPoint, + path: PlanPoint[] | undefined, + id: AnyNodeId, + ) => { + const fence = baselineNodes[id] + return fence?.type === 'fence' + ? resolveFenceSupportSlabPatch( + { + start, + end, + path, + curveOffset: (fence as FenceNode).curveOffset, + thickness: (fence as FenceNode).thickness, + parentId: fence.parentId, + }, + baselineNodes, + ) + : {} + } const fenceUpdate: { id: AnyNodeId; data: Partial } = isNew ? { id: fenceId, @@ -187,9 +217,18 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node end: lastNextEnd, path: lastNextPath, metadata: { ...originalMetadata, isNew: false }, + ...supportFor(lastNextStart, lastNextEnd, lastNextPath, fenceId), } as Partial, } - : { id: fenceId, data: { start: lastNextStart, end: lastNextEnd, path: lastNextPath } } + : { + id: fenceId, + data: { + start: lastNextStart, + end: lastNextEnd, + path: lastNextPath, + ...supportFor(lastNextStart, lastNextEnd, lastNextPath, fenceId), + }, + } const linkedUpdates = linkedOriginals.map((l) => ({ id: l.id, ...projectLinked(l, lastNextStart, lastNextEnd, lastDelta[0], lastDelta[1]), @@ -198,7 +237,12 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node fenceUpdate, ...linkedUpdates.map((u) => ({ id: u.id, - data: { start: u.start, end: u.end, path: u.path }, + data: { + start: u.start, + end: u.end, + path: u.path, + ...supportFor(u.start, u.end, u.path, u.id), + }, })), ]) const overrides = useLiveNodeOverrides.getState() diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts index e48f6355..b8bebb8b 100644 --- a/packages/nodes/src/fence/geometry.ts +++ b/packages/nodes/src/fence/geometry.ts @@ -1,4 +1,4 @@ -import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' +import { type AnyNodeId, type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' import { applyMaterialPresetToMaterials, type ColorPreset, @@ -11,6 +11,7 @@ import { resolveSlotDefaultMaterial, } from '@pascal-app/viewer' import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' +import { resolveFenceLiftElevation } from './lift' import type { FenceNode } from './schema' import { FENCE_SLOT_DEFAULTS, type FenceSlotId } from './slots' @@ -110,6 +111,14 @@ export function buildFenceGeometry( const group = new Group() const geometries = generateFenceSlotGeometries(node) + // A hosted railing (`supportSlabId`) stands on its slab's walking surface. + // The builder emits local-space children, so the lift lives on an inner + // group rather than the registered (React-transformed) root. + const lift = ctx ? resolveFenceLiftElevation(node, (id) => ctx.resolve(id as AnyNodeId)) : 0 + const meshParent = new Group() + meshParent.position.y = lift + group.add(meshParent) + for (const slotId of FENCE_SLOT_ORDER) { const geometry = geometries[slotId] if (geometry.getAttribute('position') === undefined) continue @@ -126,7 +135,7 @@ export function buildFenceGeometry( mesh.castShadow = true mesh.receiveShadow = true mesh.userData.slotId = slotId - group.add(mesh) + meshParent.add(mesh) } return group diff --git a/packages/nodes/src/fence/lift.ts b/packages/nodes/src/fence/lift.ts new file mode 100644 index 00000000..01397d32 --- /dev/null +++ b/packages/nodes/src/fence/lift.ts @@ -0,0 +1,25 @@ +import type { AnyNode, SlabNode } from '@pascal-app/core' +import type { FenceNode } from './schema' + +/** + * Elevation (meters above the level plane) a hosted fence stands at. + * + * A fence carrying `supportSlabId` is a railing on that slab's walking + * surface (drawn onto a deck, or placed by a deck preset). The + * host pins the lift only while it still exists as a slab on the fence's + * own level — a stale host (deleted slab, reparented fence) silently + * falls back to the level floor, mirroring the read-path rules of the + * other `supportSlabId` carriers. Pure so it is unit-testable and + * callable from the geometry builder with `ctx.resolve`. + */ +export function resolveFenceLiftElevation( + node: Pick, + resolve: (id: string) => AnyNode | undefined, +): number { + if (!node.supportSlabId) return 0 + const host = resolve(node.supportSlabId) + if (host?.type !== 'slab') return 0 + if ((host.parentId ?? null) !== (node.parentId ?? null)) return 0 + const elevation = (host as SlabNode).elevation + return Number.isFinite(elevation) ? elevation : 0 +} diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index fcc1f279..4be27ece 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -10,6 +10,7 @@ import { isCurvedWall, isSplineFence, type LevelNode, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, type WallMoveAxis, @@ -224,11 +225,35 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { path?: [number, number][] }>, ) => { + // Fold the re-elected slab lift host into the same write — a fence + // moved onto / off an elevated deck must land on the right surface + // (fences run no per-frame election; `supportSlabId` IS the lift), + // and one updateNodes keeps the whole move a single undo step. + // Election runs on the committed endpoints against the pre-write + // store (the patch only reads the parent level + the slab grid). + const baselineNodes = useScene.getState().nodes useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end, path: entry.path }, - })), + updates.map((entry) => { + const fence = baselineNodes[entry.id as AnyNodeId] + const support = + fence?.type === 'fence' + ? resolveFenceSupportSlabPatch( + { + start: entry.start, + end: entry.end, + path: entry.path, + curveOffset: (fence as FenceNode).curveOffset, + thickness: (fence as FenceNode).thickness, + parentId: fence.parentId, + }, + baselineNodes, + ) + : {} + return { + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end, path: entry.path, ...support }, + } + }), ) for (const entry of updates) { useScene.getState().markDirty(entry.id as AnyNodeId) diff --git a/packages/nodes/src/fence/system.tsx b/packages/nodes/src/fence/system.tsx new file mode 100644 index 00000000..dedfc96e --- /dev/null +++ b/packages/nodes/src/fence/system.tsx @@ -0,0 +1,50 @@ +'use client' + +import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import { useEffect } from 'react' +import { resolveFenceLiftElevation } from './lift' +import type { FenceNode } from './schema' + +/** + * Hosted-railing dependency tracker. A fence with `supportSlabId` renders at + * its host slab's elevation, but a store update only dirties the node that + * changed — editing the slab's elevation (or restoring a deleted host) would + * leave the railing floating at the stale height. Watch each hosted fence's + * resolved lift and dirty the fence when it moves; `GeometrySystem` rebuilds + * through `def.geometry` as usual. (Deleting the host needs no help here: + * `deleteNodesAction` strips `supportSlabId` and dirties the fence itself.) + */ + +function fenceLiftSignatures(nodes: Record): Map { + const signatures = new Map() + for (const node of Object.values(nodes)) { + if (node.type !== 'fence') continue + const fence = node as FenceNode + if (!fence.supportSlabId) continue + signatures.set( + fence.id, + resolveFenceLiftElevation(fence, (id) => nodes[id]), + ) + } + return signatures +} + +const FenceSystems = () => { + useEffect(() => { + let previous = fenceLiftSignatures(useScene.getState().nodes) + + return useScene.subscribe((state) => { + const current = fenceLiftSignatures(state.nodes) + for (const [fenceId, lift] of current.entries()) { + if (previous.get(fenceId) !== lift) { + state.markDirty(fenceId as AnyNodeId) + } + } + previous = current + }) + }, []) + + return null +} + +export default FenceSystems diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index c8f3d9e4..682b9cd3 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -18,6 +18,7 @@ import { } from '@pascal-app/core' import { CursorSphere, + clearPlacementSurface, createFenceOnCurrentLevel, createSplineFenceOnCurrentLevel, EDITOR_LAYER, @@ -33,6 +34,8 @@ import { isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, + publishPlacementSurface, + resolvePointerSupportSurface, type SegmentAngleReference, snapFenceDraftPoint, snapScalarToGrid, @@ -46,11 +49,34 @@ import { import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { + BoxGeometry, + BufferGeometry, + type Camera, + DoubleSide, + type Group, + type Mesh, + Vector3, +} from 'three' const FENCE_PREVIEW_HEIGHT = 1.8 const FENCE_PREVIEW_THICKNESS = 0.08 +// Grid-plane surface publish (pointer-decided): scratch + constant normal so +// per-move publishes don't allocate. +const SURFACE_UP = new Vector3(0, 1, 0) +const surfacePointScratch = new Vector3() + +// The walking surface the pointer actually aims at (deck top when over the +// deck, floor/ground underneath it) — only for genuine 3D pointer events. +// The 2D floor plan emits synthetic grid events with no camera ray behind +// them; those keep the uncapped max election and leave the grid plane alone. +function pointedSurfaceFor(camera: Camera, event: GridEvent) { + return event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(camera, event.position) + : null +} /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 // HUD label heights are measured from the top of the preview bar, so they @@ -460,6 +486,11 @@ const StraightFenceTool: React.FC = () => { previewHeightRef.current = previewHeight const previewThicknessRef = useRef(previewThickness) previewThicknessRef.current = previewThickness + // Camera for the pointer-support resolution (deck top vs floor) — read + // through a ref so the live event handlers see the current camera. + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera const cursorRef = useRef(null) const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) @@ -521,6 +552,18 @@ const StraightFenceTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && previewRef.current)) return + // Ride the grid event plane on the pointed surface: aiming at an + // elevated deck lifts the plane to the deck top, so the draft's XZ + // lands where the cursor points and the preview/cursor Y + // (`event.localPosition[1]`) sits at the lift the committed fence + // will get. Aiming past the deck edge drops it back to the floor. + const pointed = pointedSurfaceFor(cameraRef.current, event) + if (pointed) { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + } const { walls, fences } = getCurrentLevelElements() const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] // While drafting, the segment locks to 15° rays from its start. @@ -630,9 +673,11 @@ const StraightFenceTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return + const pointed = pointedSurfaceFor(cameraRef.current, event) const createdFence = createFenceOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, + { supportCap: pointed ? pointed.elevation : null }, ) if (!createdFence) return @@ -681,6 +726,7 @@ const StraightFenceTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() useSegmentDraftChain.getState().clear('fence') useAlignmentGuides.getState().clear() const draftPreview = useFloorplanDraftPreview.getState() @@ -738,6 +784,16 @@ const SplineFenceDraft: React.FC = () => { : FENCE_PREVIEW_HEIGHT const [draftPoints, setDraftPoints] = useState([]) const [cursor, setCursor] = useState(null) + // Building-local Y of the grid plane (rides the pointed surface — see + // `pointedSurfaceFor`), so the spline preview draws on the deck top when + // the curve is being laid out on one. + const [liftY, setLiftY] = useState(0) + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + // Pointer cap for the commit (Enter / double-click carry no useful grid + // event of their own) — last resolved on move/click. + const supportCapRef = useRef(null) const draftRef = useRef(draftPoints) draftRef.current = draftPoints @@ -761,7 +817,9 @@ const SplineFenceDraft: React.FC = () => { const commit = () => { const points = draftRef.current if (points.length >= 2) { - const created = createSplineFenceOnCurrentLevel(points) + const created = createSplineFenceOnCurrentLevel(points, undefined, { + supportCap: supportCapRef.current, + }) if (created) { triggerSFX('sfx:item-place') // Once the new curve fence is selected for direct editing, leave @@ -775,11 +833,24 @@ const SplineFenceDraft: React.FC = () => { setCursor(null) } + const trackPointedSurface = (event: GridEvent) => { + const pointed = pointedSurfaceFor(cameraRef.current, event) + if (!pointed) return + supportCapRef.current = pointed.elevation + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + setLiftY(event.localPosition[1]) + } + const onMove = (event: GridEvent) => { + trackPointedSurface(event) setCursor(snapPoint([event.localPosition[0], event.localPosition[2]])) } const onClick = (event: GridEvent) => { + trackPointedSurface(event) if (event.nativeEvent.detail >= 2) { commit() return @@ -807,6 +878,7 @@ const SplineFenceDraft: React.FC = () => { emitter.off('grid:move', onMove) emitter.off('grid:click', onClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() window.removeEventListener('keydown', onKeyDown) } }, []) @@ -820,18 +892,18 @@ const SplineFenceDraft: React.FC = () => { SPLINE_PREVIEW_SEGMENTS, ) return new BufferGeometry().setFromPoints( - sampled.map((point) => new Vector3(point.x, previewHeight, point.y)), + sampled.map((point) => new Vector3(point.x, liftY + previewHeight, point.y)), ) - }, [previewHeight, previewPoints]) + }, [liftY, previewHeight, previewPoints]) return ( - {cursor && } + {cursor && } {draftPoints.map((point, index) => ( diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx index 4aa874db..678c09a4 100644 --- a/packages/nodes/src/hvac-equipment/tool.tsx +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -1,6 +1,12 @@ 'use client' -import { emitter, type GridEvent, HvacEquipmentNode, useScene } from '@pascal-app/core' +import { + emitter, + type GridEvent, + HvacEquipmentNode, + resolveSupportSlabPatch, + useScene, +} from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -76,9 +82,14 @@ const HvacEquipmentTool = () => { name: 'Furnace', position, rotation: yawRef.current, + parentId: activeLevelId, }) - useScene.getState().createNode(unit, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [unit.id] }) + const committedUnit = HvacEquipmentNode.parse({ + ...unit, + ...resolveSupportSlabPatch(unit, useScene.getState().nodes), + }) + useScene.getState().createNode(committedUnit, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedUnit.id] }) triggerSFX('sfx:item-place') } diff --git a/packages/nodes/src/level/definition.ts b/packages/nodes/src/level/definition.ts index 3dc516c3..c3e6fcea 100644 --- a/packages/nodes/src/level/definition.ts +++ b/packages/nodes/src/level/definition.ts @@ -1,4 +1,8 @@ -import { LevelNode as LevelNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { + DEFAULT_LEVEL_HEIGHT, + LevelNode as LevelNodeSchema, + type NodeDefinition, +} from '@pascal-app/core' import { levelParametrics } from './parametrics' import { LevelNode } from './schema' @@ -14,7 +18,11 @@ export const levelDefinition: NodeDefinition = { category: 'site', defaults: () => { - const stub = LevelNodeSchema.parse({ id: 'level_default' as never, type: 'level' }) + const stub = LevelNodeSchema.parse({ + id: 'level_default' as never, + type: 'level', + height: DEFAULT_LEVEL_HEIGHT, + }) const { id: _id, type: _type, ...rest } = stub return rest }, diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 388cc85d..c03f17c2 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -10,6 +10,7 @@ import { type RoofNode, type RoofSegmentNode, resolveAlignment, + resolveSupportSlabPatch, type StairNode, type StairSegmentNode, sceneRegistry, @@ -404,23 +405,38 @@ export const MoveRoofTool: React.FC<{ useAlignmentGuides.getState().clear() wasCommitted = true + const position: [number, number, number] = [localX, movingNode.position[1], localZ] + const effectiveNode = { + ...movingNode, + position, + rotation: pendingRotation, + } as typeof movingNode + const supportPatch = isFloorPlaced + ? resolveSupportSlabPatch(effectiveNode, { + ...useScene.getState().nodes, + [movingNode.id]: effectiveNode, + }) + : {} + let committedId = movingNode.id as AnyNodeId if (isNew) { committedId = commitFreshPlacementSubtree(movingNode.id as AnyNodeId, { - position: [localX, movingNode.position[1], localZ], + position, rotation: pendingRotation, metadata: committedMeta, visible: true, + ...supportPatch, }) ?? committedId } else { // The store still holds the original values (we didn't update during drag). // Resume temporal and apply the final state as a single undoable step. useScene.temporal.getState().resume() useScene.getState().updateNode(movingNode.id, { - position: [localX, movingNode.position[1], localZ], + position, rotation: pendingRotation, metadata: committedMeta, + ...supportPatch, }) useScene.temporal.getState().pause() } diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index 1729e923..8d73eca7 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -15,6 +15,7 @@ import { type WallNode, } from '@pascal-app/core' import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor' +import { resolveWallOpeningCeiling } from './wall-opening-ceiling' // Parity with `snapLocalXToNeighbors`' along-wall threshold. const SILL_SNAP_THRESHOLD_M = 0.08 @@ -94,7 +95,7 @@ export function publishOpeningGuides3D(args: { }): void { const { wall, centerS, centerY, width, toWorld } = args const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) - const wallHeight = wall.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(wall, args.nodes) const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes) const guides = computeOpeningGuides({ moving: { id: args.movingId, centerS, width, centerY, height: args.height }, diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts index 08fedd62..032ca2d0 100644 --- a/packages/nodes/src/shared/opening-placement-dimensions.ts +++ b/packages/nodes/src/shared/opening-placement-dimensions.ts @@ -8,9 +8,11 @@ import { type GeometryContext, isCurvedWall, type OpeningSpan, + useScene, type WallNode, type WindowNode, } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from './wall-opening-ceiling' /** * Build placement-measurement dimension lines for a door / window @@ -94,7 +96,10 @@ export function buildOpeningPlacementDimensions( height: opening.height, }, siblings, - wall: { length: wallLength, height: wall.height ?? 2.5 }, + wall: { + length: wallLength, + height: resolveWallOpeningCeiling(wall, useScene.getState().nodes), + }, // The 2D plan is top-down: sill/head height and vertical alignment aren't // representable here — those belong to the 3D viewport. includeVertical: false, diff --git a/packages/nodes/src/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts new file mode 100644 index 00000000..e6465b96 --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -0,0 +1,64 @@ +import { + type AnyNode, + type AnyNodeId, + getWallPlaneTop, + resolveWallEffectiveHeight, + spatialGridManager, + type WallNode, +} from '@pascal-app/core' + +/** + * Structural subset of `SceneApi` the opening-cap readers need — matches + * both handle-descriptor callbacks (which receive the full SceneApi) and + * tools holding a nodes snapshot. + */ +export type WallCeilingSceneReader = { + get: (id: AnyNodeId) => unknown + nodes: () => Readonly> +} + +/** + * Available wall-local Y span for an opening hosted on `wall`: the wall's + * resolved top (storey plane for plane-bound walls, stored height for + * explicit ones) minus the wall's elected slab base. Wall-local Y = 0 sits + * at the elected base (where the viewer positions the wall mesh), so this + * is the ceiling an opening's top edge must stay under. + * + * Uses the same slab election as the viewer's WallSystem + * (`spatialGridManager.getSlabSupportForWall`) so the cap agrees with the + * rendered wall; headless callers with an empty spatial grid elect base 0 + * and fall back to the full storey height. + */ +export function resolveWallOpeningCeiling( + wall: WallNode, + nodes: Readonly>, +): number { + const levelId = wall.parentId ?? 'default' + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) + // Covering-clamped plane: openings cap under a flush/thick slab from the + // level above, matching the shortened wall body. + const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) +} + +/** + * Height cap for a wall-hosted opening's resize handles. Infinity only when + * the opening is unhosted (no wallId, or the wall is gone) — roof-hosted + * openings clamp elsewhere. + */ +export function readHostWallCeiling( + wallId: string | null | undefined, + scene: WallCeilingSceneReader, +): number { + if (!wallId) return Number.POSITIVE_INFINITY + const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined + if (!wall) return Number.POSITIVE_INFINITY + return resolveWallOpeningCeiling(wall, scene.nodes()) +} diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index de4dc94d..11300729 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -4,6 +4,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, ShelfNode, useScene, } from '@pascal-app/core' @@ -132,9 +133,14 @@ const ShelfTool = () => { name: 'Shelf', position, rotation: [0, 0, 0], + parentId: activeLevelId, }) - useScene.getState().createNode(shelf, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [shelf.id] }) + const committedShelf = ShelfNode.parse({ + ...shelf, + ...resolveSupportSlabPatch(shelf, useScene.getState().nodes), + }) + useScene.getState().createNode(committedShelf, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedShelf.id] }) triggerSFX('sfx:item-place') useAlignmentGuides.getState().clear() if (useEditor.getState().getContinuation('point') === 'repeat') { diff --git a/packages/nodes/src/site/recessed-slab-ground-holes.test.ts b/packages/nodes/src/site/recessed-slab-ground-holes.test.ts index 92f28a7d..92bff797 100644 --- a/packages/nodes/src/site/recessed-slab-ground-holes.test.ts +++ b/packages/nodes/src/site/recessed-slab-ground-holes.test.ts @@ -9,6 +9,7 @@ describe('getRecessedSlabGroundHoles', () => { id: 'slab_ground-holes', parentId, elevation: -0.15, + recessed: true, polygon: [ [0, 0], [2, 0], @@ -51,4 +52,21 @@ describe('getRecessedSlabGroundHoles', () => { expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([]) }) + + test('keys on the recessed flag, not the elevation sign', () => { + // A below-plane SOLID (deck underside) must not punch a ground hole. + const slab = SlabNode.parse({ + id: 'slab_ground-holes-below-plane', + elevation: -0.15, + thickness: 0.3, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + + expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([]) + }) }) diff --git a/packages/nodes/src/site/recessed-slab-ground-holes.ts b/packages/nodes/src/site/recessed-slab-ground-holes.ts index e14a38f5..77f5e6c3 100644 --- a/packages/nodes/src/site/recessed-slab-ground-holes.ts +++ b/packages/nodes/src/site/recessed-slab-ground-holes.ts @@ -36,10 +36,7 @@ export function getRecessedSlabGroundHoles( return nodeList .filter( (node): node is SlabNode => - node.type === 'slab' && - node.visible && - node.polygon.length >= 3 && - (node.elevation ?? 0.05) < 0, + node.type === 'slab' && node.visible && node.polygon.length >= 3 && node.recessed === true, ) .filter((slab) => { if (!Number.isFinite(lowestLevelIndex)) return true diff --git a/packages/nodes/src/slab/__tests__/definition.test.ts b/packages/nodes/src/slab/__tests__/definition.test.ts index e66434f0..b625c187 100644 --- a/packages/nodes/src/slab/__tests__/definition.test.ts +++ b/packages/nodes/src/slab/__tests__/definition.test.ts @@ -45,7 +45,7 @@ describe('slabDefinition handles', () => { expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false) }) - test('allows the elevation arrow to cross zero into a recessed slab', () => { + test('routes the elevation arrow through adaptive slab top changes', () => { const slab = SlabNode.parse({ elevation: 0.05, polygon: [ @@ -58,6 +58,24 @@ describe('slabDefinition handles', () => { const heightHandle = getHeightHandle(slab) expect(heightHandle.min).toBe(-1) - expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ elevation: -0.15 }) + // Crossing zero flips the recessed intent in the same patch; coming back + // above the plane clears it. + expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ + elevation: -0.15, + recessed: true, + }) + expect(heightHandle.apply(slab, 0.1, {} as never)).toEqual({ + elevation: 0.1, + thickness: 0.1, + recessed: false, + }) + // The arrow is the drag surface: past SLAB_UNSTICK_THRESHOLD a + // grounded slab pops to the default deck thickness instead of + // stretching further. + expect(heightHandle.apply(slab, 0.6, {} as never)).toEqual({ + elevation: 0.6, + thickness: 0.05, + recessed: false, + }) }) }) diff --git a/packages/nodes/src/slab/__tests__/elevation-limit.test.ts b/packages/nodes/src/slab/__tests__/elevation-limit.test.ts new file mode 100644 index 00000000..7a829488 --- /dev/null +++ b/packages/nodes/src/slab/__tests__/elevation-limit.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'bun:test' +import { SlabNode } from '@pascal-app/core' +import { + applySlabElevationPreset, + applySlabTopChange, + SLAB_UNSTICK_THRESHOLD, +} from '../elevation-limit' + +function slab(overrides: Partial = {}): SlabNode { + return SlabNode.parse({ polygon: [], ...overrides }) +} + +const drag = (node: SlabNode, newTop: number) => applySlabTopChange(node, newTop, { mode: 'drag' }) +const panel = (node: SlabNode, newTop: number) => + applySlabTopChange(node, newTop, { mode: 'panel' }) + +describe('applySlabTopChange — drag (viewport arrow)', () => { + test('stretches a grounded slab up to the unstick threshold', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(drag(grounded, 0.25)).toEqual({ + elevation: 0.25, + thickness: 0.25, + recessed: false, + }) + expect(drag(grounded, 0.04)).toEqual({ + elevation: 0.04, + thickness: 0.04, + recessed: false, + }) + // The threshold itself still stretches — unstick starts strictly past it. + expect(drag(grounded, SLAB_UNSTICK_THRESHOLD)).toEqual({ + elevation: SLAB_UNSTICK_THRESHOLD, + thickness: SLAB_UNSTICK_THRESHOLD, + recessed: false, + }) + }) + + test('unsticks past the threshold: pops to the default deck thickness', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(drag(grounded, 0.55)).toEqual({ + elevation: 0.55, + thickness: 0.05, + recessed: false, + }) + }) + + test('crosses a grounded slab into a pool and back out', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + const intoPool = drag(grounded, -0.15) + + expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) + + const pool = { ...grounded, ...intoPool } + expect(drag(pool, 0.08)).toEqual({ + elevation: 0.08, + recessed: false, + }) + }) + + test('moves a floating deck and clamps its underside to ground', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) + + expect(drag(floating, 0.4)).toEqual({ + elevation: 0.4, + recessed: false, + }) + + const landedChange = drag(floating, 0.1) + expect(landedChange).toEqual({ elevation: 0.2, recessed: false }) + + // Landed (underside 0) → grounded again: below the threshold the way + // back up stretches, past it the slab unsticks to the default deck. + const landed = { ...floating, ...landedChange } + expect(drag(landed, 0.3)).toEqual({ + elevation: 0.3, + thickness: 0.3, + recessed: false, + }) + expect(drag(landed, 0.5)).toEqual({ + elevation: 0.5, + thickness: 0.05, + recessed: false, + }) + }) + + test('keeps a recessed pool thickness unchanged', () => { + const pool = slab({ elevation: -0.15, thickness: 0.08, recessed: true }) + + expect(drag(pool, -0.3)).toEqual({ + elevation: -0.3, + recessed: true, + }) + }) + + test('allows a grounded stretch below the edit-time minimum thickness', () => { + const grounded = slab({ elevation: 0.01, thickness: 0.01 }) + + expect(drag(grounded, 0.015)).toEqual({ + elevation: 0.015, + thickness: 0.015, + recessed: false, + }) + }) +}) + +describe('applySlabTopChange — panel (pure placement)', () => { + test('moves a grounded slab without coupling thickness', () => { + // The panel never stretches: raising a grounded slab lifts the body + // (thickness preserved by omission) instead of thickening it. + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(panel(grounded, 0.3)).toEqual({ elevation: 0.3, recessed: false }) + expect(panel(grounded, 0.55)).toEqual({ elevation: 0.55, recessed: false }) + }) + + test('clamps a grounded slab at underside 0 instead of shrinking it', () => { + const grounded = slab({ elevation: 0.2, thickness: 0.2 }) + + expect(panel(grounded, 0.1)).toEqual({ elevation: 0.2, recessed: false }) + }) + + test('moves a floating deck preserving thickness and clamps its underside', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) + + expect(panel(floating, 0.4)).toEqual({ elevation: 0.4, recessed: false }) + expect(panel(floating, 0.1)).toEqual({ elevation: 0.2, recessed: false }) + }) + + test('keeps the pool cross-zero gesture', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + const intoPool = panel(grounded, -0.15) + + expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) + + const pool = { ...grounded, ...intoPool } + expect(panel(pool, -0.3)).toEqual({ elevation: -0.3, recessed: true }) + expect(panel(pool, 0.08)).toEqual({ elevation: 0.08, recessed: false }) + }) +}) + +test('slab elevation presets keep their explicit writes', () => { + expect(applySlabElevationPreset(-0.15)).toEqual({ elevation: -0.15, recessed: true }) + expect(applySlabElevationPreset(0)).toEqual({ + elevation: 0, + thickness: 0, + recessed: false, + }) + expect(applySlabElevationPreset(0.05)).toEqual({ + elevation: 0.05, + thickness: 0.05, + recessed: false, + }) + expect(applySlabElevationPreset(0.15)).toEqual({ + elevation: 0.15, + thickness: 0.15, + recessed: false, + }) +}) diff --git a/packages/nodes/src/slab/__tests__/geometry.test.ts b/packages/nodes/src/slab/__tests__/geometry.test.ts index 411d051f..82ca0814 100644 --- a/packages/nodes/src/slab/__tests__/geometry.test.ts +++ b/packages/nodes/src/slab/__tests__/geometry.test.ts @@ -28,4 +28,31 @@ describe('buildSlabGeometry', () => { expect(Array.from(uv2.array)).toEqual(Array.from(uv.array)) } }) + + test('solid slab meshes stay at the level plane; recessed meshes sink to the elevation', () => { + const polygon: Array<[number, number]> = [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ] + + const solid = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon }) + const solidGroup = buildSlabGeometry(solid, undefined, 'solid', false) + for (const mesh of solidGroup.children.filter( + (child): child is Mesh => child instanceof Mesh, + )) { + expect(mesh.position.y).toBe(0) + } + + const recessed = SlabNode.parse({ elevation: -0.2, recessed: true, polygon }) + const recessedGroup = buildSlabGeometry(recessed, undefined, 'solid', false) + const recessedMeshes = recessedGroup.children.filter( + (child): child is Mesh => child instanceof Mesh, + ) + expect(recessedMeshes.length).toBeGreaterThan(0) + for (const mesh of recessedMeshes) { + expect(mesh.position.y).toBeCloseTo(-0.2) + } + }) }) diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 73684664..d44d2b07 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -5,6 +5,7 @@ import { type SlabNode as SlabNodeType, } from '@pascal-app/core' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' +import { applySlabTopChange, slabElevationUpperBound } from './elevation-limit' import { buildSlabFloorplan } from './floorplan' import { slabAddVertexAffordance, @@ -91,19 +92,23 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] { return best ?? fallback } -// Slab height arrow — vertical chevron on solid slab surface near the -// polygon center. Drags elevation through zero: positive values extrude -// upward from ground while negative values create a recessed floor whose -// depth follows the pointer. Same registry-handle pipeline as the column -// height arrow, so live override + commit-on-release come for free. +// Slab elevation arrow — vertical chevron on solid slab surface near the +// polygon center. The shared top-change policy stretches grounded slabs up +// to SLAB_UNSTICK_THRESHOLD (past it the slab pops to a thin floating +// deck), moves floating slabs, and preserves the drag-through-zero pool +// gesture. Same registry-handle pipeline as the column height arrow, so +// live override + commit-on-release come for free. `max` clamps the drag +// under the storey plane while plane-bound walls elect this slab as their +// base. function slabHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', axis: 'y', anchor: 'min', min: MIN_SLAB_ELEVATION, + max: (n, sceneApi) => slabElevationUpperBound(sceneApi.nodes(), n), currentValue: (n) => n.elevation ?? 0.05, - apply: (_n, newValue) => ({ elevation: newValue }), + apply: (n, newValue) => applySlabTopChange(n, newValue, { mode: 'drag' }), placement: { position: (n) => { const [cx, cz] = slabHandleAnchor(n) @@ -151,6 +156,8 @@ export const slabDefinition: NodeDefinition = { holes: [], holeMetadata: [], elevation: 0.05, + thickness: 0.05, + recessed: false, autoFromWalls: false, }), diff --git a/packages/nodes/src/slab/elevation-limit.ts b/packages/nodes/src/slab/elevation-limit.ts new file mode 100644 index 00000000..bc335b68 --- /dev/null +++ b/packages/nodes/src/slab/elevation-limit.ts @@ -0,0 +1,119 @@ +import { + type AnyNode, + type AnyNodeId, + clampSlabElevationForWalls, + getSlabElevationUpperBound, + getStoredLevelHeight, + type LevelNode, + type SlabElevationClamp, + type SlabNode, + type WallNode, +} from '@pascal-app/core' + +type SlabLevelContext = { + storeyHeight: number + walls: WallNode[] + slabs: SlabNode[] +} + +const GROUNDED_SLAB_EPSILON = 1e-3 +/** Deck thickness an unsticking slab pops to — the schema default. */ +const UNSTUCK_DECK_THICKNESS = 0.05 +/** + * Grounded-stretch ceiling for the 3D elevation arrow (m) — above any + * plausible step/platform height. While grounded, dragging the top up to + * here stretches the body; dragging past it unsticks the slab into a + * thin floating deck and the drag continues as pure placement. + */ +export const SLAB_UNSTICK_THRESHOLD = 0.4 + +export type SlabTopChangeMode = 'drag' | 'panel' + +/** + * The one owner of the slab vertical-editing rules. Both edit surfaces + * route through it: the viewport arrow as `mode: 'drag'`, the panel + * elevation input as `mode: 'panel'`. + * + * Hysteresis-free state machine (pure in current state + newTop): + * - recessed → move the pool floor; rising to ≥ 0 un-recesses. + * - grounded, newTop ≤ 0 → pool gesture (both modes). + * - grounded drag, newTop ≤ {@link SLAB_UNSTICK_THRESHOLD} → stretch + * (elevation and thickness move together, underside stays at 0). + * - grounded drag past the threshold → unstick: pop to the default deck + * thickness and continue as placement. + * - otherwise (floating, or any panel edit) → placement: move the body + * preserving thickness, clamping the underside to the level plane — + * landing re-grounds the slab, so the way back up stretches again + * below the threshold. + */ +export function applySlabTopChange( + slab: SlabNode, + newTop: number, + options: { mode: SlabTopChangeMode }, +): Partial { + if (slab.recessed) return { elevation: newTop, recessed: newTop < 0 } + + const grounded = Math.abs(slab.elevation - slab.thickness) < GROUNDED_SLAB_EPSILON + if (grounded && newTop <= 0) return { elevation: newTop, recessed: true } + + if (grounded && options.mode === 'drag') { + return newTop <= SLAB_UNSTICK_THRESHOLD + ? { elevation: newTop, thickness: newTop, recessed: false } + : { elevation: newTop, thickness: UNSTUCK_DECK_THICKNESS, recessed: false } + } + + return { elevation: Math.max(newTop, slab.thickness), recessed: false } +} + +export function applySlabElevationPreset(newTop: number): Partial { + return newTop < 0 + ? { elevation: newTop, recessed: true } + : { elevation: newTop, thickness: Math.max(newTop, 0), recessed: false } +} + +function resolveSlabLevelContext( + nodes: Readonly>, + slab: SlabNode, +): SlabLevelContext | null { + const parent = slab.parentId ? nodes[slab.parentId as AnyNodeId] : undefined + if (parent?.type !== 'level') return null + const level = parent as LevelNode + const children = level.children.map((childId) => nodes[childId as AnyNodeId]) + return { + storeyHeight: getStoredLevelHeight(level), + walls: children.filter((child): child is WallNode => child?.type === 'wall'), + slabs: children.filter((child): child is SlabNode => child?.type === 'slab'), + } +} + +/** + * Level-context wrapper over the pure core clamp: a slab under + * plane-bound walls may not rise past the storey plane minus the + * minimum wall height. Slabs outside a level (no parent) are + * unconstrained. + */ +export function clampSlabElevation( + nodes: Readonly>, + slab: SlabNode, + proposedElevation: number, +): SlabElevationClamp { + const context = resolveSlabLevelContext(nodes, slab) + if (!context) return { elevation: proposedElevation, clamped: false } + return clampSlabElevationForWalls( + proposedElevation, + slab, + context.walls, + context.slabs, + context.storeyHeight, + ) +} + +/** Drag-time upper bound for the slab height arrow; +Infinity when unconstrained. */ +export function slabElevationUpperBound( + nodes: Readonly>, + slab: SlabNode, +): number { + const context = resolveSlabLevelContext(nodes, slab) + if (!context) return Number.POSITIVE_INFINITY + return getSlabElevationUpperBound(slab, context.walls, context.slabs, context.storeyHeight) +} diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 69d8e1fa..59195788 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -209,7 +209,10 @@ export function buildSlabGeometry( mesh.castShadow = true mesh.receiveShadow = true mesh.userData.slotId = slotId - if (elevation < 0) mesh.position.y = elevation + // Solid slabs bake [elevation − thickness, elevation] into the geometry; + // recessed shells are authored at local Y=0 and sink so the recess floor + // sits at `elevation` (< 0) with the shell rim on the level plane. + if (node.recessed) mesh.position.y = elevation group.add(mesh) } return group diff --git a/packages/nodes/src/slab/panel.tsx b/packages/nodes/src/slab/panel.tsx index 6587a589..cb73a8be 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' +import { type AnyNode, MIN_SLAB_THICKNESS, type SlabNode, useScene } from '@pascal-app/core' import { ActionButton, ActionGroup, @@ -16,6 +16,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect, useRef } from 'react' +import { applySlabElevationPreset, applySlabTopChange, clampSlabElevation } from './elevation-limit' /** * Phase 5 Stage E — slab inspector (kind-owned). @@ -30,6 +31,7 @@ import { useCallback, useEffect, useRef } from 'react' */ export function SlabPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) @@ -52,6 +54,33 @@ export function SlabPanel() { [selectedId], ) + const handleElevationChange = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current) return + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) + handleUpdate(applySlabTopChange(current, elevation, { mode: 'panel' })) + }, + [handleUpdate], + ) + + const handleThicknessChange = useCallback( + (proposed: number) => { + handleUpdate({ thickness: Math.max(MIN_SLAB_THICKNESS, proposed) }) + }, + [handleUpdate], + ) + + const handleElevationPreset = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current) return + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) + handleUpdate(applySlabElevationPreset(elevation)) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope @@ -162,6 +191,23 @@ export function SlabPanel() { const area = calculateArea(node.polygon) + // Clean preset values per display system; imperial stores exact meters + // for whole-inch offsets. + const elevationPresets = + unit === 'imperial' + ? [ + { label: 'Sunken (-6")', elevation: -0.1524 }, + { label: 'Ground (0")', elevation: 0 }, + { label: 'Raised (+2")', elevation: 0.0508 }, + { label: 'Step (+6")', elevation: 0.1524 }, + ] + : [ + { label: 'Sunken (-15cm)', elevation: -0.15 }, + { label: 'Ground (0m)', elevation: 0 }, + { label: 'Raised (+5cm)', elevation: 0.05 }, + { label: 'Step (+15cm)', elevation: 0.15 }, + ] + return ( handleUpdate({ elevation: v })} + onChange={handleElevationChange} precision={3} step={0.01} unit="m" value={Math.round(node.elevation * 1000) / 1000} /> + {!node.recessed && ( + + )} +
- handleUpdate({ elevation: -0.15 })} /> - handleUpdate({ elevation: 0 })} /> - handleUpdate({ elevation: 0.05 })} /> - handleUpdate({ elevation: 0.15 })} /> + {elevationPresets.map((preset) => ( + handleElevationPreset(preset.elevation)} + /> + ))}
diff --git a/packages/nodes/src/slab/parametrics.ts b/packages/nodes/src/slab/parametrics.ts index 292e0073..622a7098 100644 --- a/packages/nodes/src/slab/parametrics.ts +++ b/packages/nodes/src/slab/parametrics.ts @@ -1,4 +1,4 @@ -import type { ParametricDescriptor } from '@pascal-app/core' +import { MIN_SLAB_THICKNESS, type ParametricDescriptor } from '@pascal-app/core' import type { SlabNode } from './schema' /** @@ -15,7 +15,18 @@ export const slabParametrics: ParametricDescriptor = { groups: [ { label: 'Elevation', - fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }], + fields: [ + { key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }, + { + key: 'thickness', + kind: 'number', + unit: 'm', + min: MIN_SLAB_THICKNESS, + max: 0.5, + step: 0.01, + visibleIf: (n) => !n.recessed, + }, + ], }, ], customPanel: () => import('./panel'), diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index c68a5dbc..ce196c29 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -4,6 +4,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, SpawnNode, useScene, } from '@pascal-app/core' @@ -105,10 +106,18 @@ const SpawnTool = () => { let placedId: SpawnNode['id'] if (existingSpawnId) { + const live = useScene.getState().nodes[existingSpawnId] + const effectiveSpawn = SpawnNode.parse({ + ...live, + parentId: activeLevelId, + position: next, + rotation: 0, + }) useScene.getState().updateNode(existingSpawnId, { parentId: activeLevelId, position: next, rotation: 0, + ...resolveSupportSlabPatch(effectiveSpawn, useScene.getState().nodes), }) if (duplicates.length > 0) { useScene.getState().deleteNodes(duplicates) @@ -119,9 +128,14 @@ const SpawnTool = () => { name: 'Spawn Point', position: next, rotation: 0, + parentId: activeLevelId, }) - useScene.getState().createNode(spawn, activeLevelId) - placedId = spawn.id + const committedSpawn = SpawnNode.parse({ + ...spawn, + ...resolveSupportSlabPatch(spawn, useScene.getState().nodes), + }) + useScene.getState().createNode(committedSpawn, activeLevelId) + placedId = committedSpawn.id } useViewer.getState().setSelection({ selectedIds: [placedId] }) diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index d5431ceb..11510ac6 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -1,11 +1,13 @@ import { type HandleDescriptor, type NodeDefinition, + resolveStairTotalRise, type SceneApi, StairNode as StairNodeSchema, type StairNode as StairNodeType, type StairSegmentNode, stairFootprintAABB, + useScene, } from '@pascal-app/core' const MIN_CURVED_RISE = 0.3 @@ -53,10 +55,14 @@ type StairMoveBounds = { height: number } +function readTotalRise(node: StairNodeType): number { + return Math.max(resolveStairTotalRise(node, useScene.getState().nodes), 0.1) +} + function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom { const isSpiral = node.stairType === 'spiral' const stepCount = Math.max(2, Math.round(node.stepCount ?? 10)) - const totalRise = Math.max(node.totalRise ?? 2.5, 0.1) + const totalRise = readTotalRise(node) const width = Math.max(node.width ?? 1, MIN_CURVED_WIDTH) const minInnerRadius = isSpiral ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED const innerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9) @@ -96,7 +102,7 @@ function fallbackStraightStairMoveBounds(node: StairNodeType): StairMoveBounds { maxX: width / 2, minZ: 0, maxZ: depth, - height: Math.max(node.totalRise ?? 2.5, 0.1), + height: readTotalRise(node), } } @@ -161,7 +167,7 @@ function curvedRiseHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_CURVED_RISE, - currentValue: (n) => Math.max(n.totalRise ?? 2.5, 0.1), + currentValue: readTotalRise, apply: (_n, newRise) => ({ totalRise: newRise }), placement: { position: (n) => { @@ -307,7 +313,7 @@ function stairRotateGizmoPosition(n: StairNodeType): [number, number, number] { return [radius * Math.cos(angle), g.totalRise / 2, radius * Math.sin(angle)] } const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH) - const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2 + const yMid = readTotalRise(n) / 2 return [width / 2 + STAIR_ROTATE_CORNER_OFFSET, yMid, -STAIR_ROTATE_CORNER_OFFSET] } @@ -345,7 +351,7 @@ function stairRotateHandle(): HandleDescriptor { STAIR_ROTATE_RING_OFFSET ) }, - y: (n) => Math.max(n.totalRise ?? 2.5, 0.1) / 2, + y: (n) => readTotalRise(n) / 2, }, } } diff --git a/packages/nodes/src/stair/destination.test.ts b/packages/nodes/src/stair/destination.test.ts new file mode 100644 index 00000000..331a7daf --- /dev/null +++ b/packages/nodes/src/stair/destination.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'bun:test' +import { LevelNode, SlabNode, StairNode } from '@pascal-app/core' +import { getStairDestinationUpdates } from './destination' + +function makeStair(overrides: Record = {}) { + return StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + slabOpeningMode: 'destination', + ...overrides, + }) +} + +const deck = SlabNode.parse({ + id: 'slab_deck', + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + elevation: 1.25, + thickness: 0.05, +}) + +const level = LevelNode.parse({ + id: 'level_2', + type: 'level', + level: 1, + children: [], +}) + +describe('getStairDestinationUpdates', () => { + it('attaching to a deck disables the auto cutout and clears the custom rise', () => { + const stair = makeStair({ totalRise: 2.0 }) + const updates = getStairDestinationUpdates(stair, deck, deck.id) + expect(updates.deckSlabId).toBe(deck.id) + expect(updates.slabOpeningMode).toBe('none') + // The key must be PRESENT with an undefined value so the store merge + // clears the field. + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) + + it('detaching back to a level restores the placement-default cutout and follows mode', () => { + const stair = makeStair({ deckSlabId: deck.id, slabOpeningMode: 'none' }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.toLevelId).toBe(level.id) + expect(updates.slabOpeningMode).toBe('destination') + expect('deckSlabId' in updates && updates.deckSlabId === undefined).toBe(true) + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) + + it('a plain level-to-level switch leaves rise and opening mode alone', () => { + const stair = makeStair({ totalRise: 2.0 }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.toLevelId).toBe(level.id) + expect('deckSlabId' in updates && updates.deckSlabId === undefined).toBe(true) + expect('totalRise' in updates).toBe(false) + expect('slabOpeningMode' in updates).toBe(false) + }) + + it('detaching a stale deck reference still resets to follows mode', () => { + const stair = makeStair({ deckSlabId: 'slab_gone', totalRise: 1.4 }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.slabOpeningMode).toBe('destination') + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) +}) diff --git a/packages/nodes/src/stair/destination.ts b/packages/nodes/src/stair/destination.ts new file mode 100644 index 00000000..41b6148e --- /dev/null +++ b/packages/nodes/src/stair/destination.ts @@ -0,0 +1,30 @@ +import type { AnyNode, StairNode } from '@pascal-app/core' + +/** + * Computes the stair patch for a destination ("To") switch. + * + * Attaching to a deck clears any explicit custom rise (the rise follows the + * deck's elevation from now on) and disables the auto cutout — a deck stair + * lands ON its destination slab, not through it. Switching a deck-attached + * stair back to a level clears both again so the rise re-derives from the + * storey height, and restores `slabOpeningMode: 'destination'` — the + * placement default (the schema default is 'none', but the stair tool places + * ordinary stairs with 'destination', so that is what a level-destination + * stair regains). Plain level-to-level switches leave rise and opening mode + * alone. + */ +export function getStairDestinationUpdates( + stair: StairNode, + target: AnyNode | undefined, + targetId: string, +): Partial { + if (target?.type === 'slab') { + return { deckSlabId: targetId, totalRise: undefined, slabOpeningMode: 'none' } + } + const updates: Partial = { toLevelId: targetId, deckSlabId: undefined } + if (stair.deckSlabId) { + updates.totalRise = undefined + updates.slabOpeningMode = 'destination' + } + return updates +} diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 00297893..fae2b5ac 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -4,6 +4,8 @@ import { type AnyNode, type AnyNodeId, type LevelNode, + resolveStairTotalRise, + type SlabNode, type StairNode, type StairRailingMode, type StairSegmentNode, @@ -35,6 +37,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' +import { getStairDestinationUpdates } from './destination' const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ { label: 'None', value: 'none' }, @@ -59,6 +62,10 @@ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode } { label: 'Destination', value: 'destination' }, ] +// Slabs at least this high off the storey floor read as decks (mezzanines) — +// lower slabs are floor coverings, never useful stair destinations. +const DECK_DESTINATION_MIN_ELEVATION = 0.5 + export default function StairPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) @@ -75,6 +82,20 @@ export default function StairPanel() { () => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []), [node, nodes], ) + const candidateDecks = useMemo(() => { + if (node?.type !== 'stair') return [] + const level = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (level?.type !== 'level') return [] + const decks: SlabNode[] = [] + for (const childId of level.children) { + const child = nodes[childId as AnyNodeId] + if (child?.type !== 'slab') continue + if (child.id === node.deckSlabId || child.elevation >= DECK_DESTINATION_MIN_ELEVATION) { + decks.push(child) + } + } + return decks + }, [node, nodes]) const segments = useScene( useShallow((s) => { if (!selectedId) return [] @@ -133,6 +154,15 @@ export default function StairPanel() { [handleUpdate], ) + const handleDestinationChange = useCallback( + (value: string) => { + if (!node) return + const target = useScene.getState().nodes[value as AnyNodeId] + handleUpdate(getStairDestinationUpdates(node, target, value)) + }, + [node, handleUpdate], + ) + const getLastSegmentFillDefaults = useCallback(() => { if (!node) return { fillToFloor: true } const children = node.children ?? [] @@ -223,6 +253,9 @@ export default function StairPanel() { const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels) const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels) + const deckNode = node.deckSlabId ? nodes[node.deckSlabId as AnyNodeId] : undefined + const attachedDeck = deckNode?.type === 'slab' ? deckNode : undefined + const resolvedRise = Math.round(resolveStairTotalRise(node, nodes) * 100) / 100 return (
- + {attachedDeck ? null : ( + + )}
@@ -276,40 +311,85 @@ export default function StairPanel() {
- To Level + To
- handleAutoCutoutChange(value === 'destination')} - options={STAIR_SLAB_OPENING_OPTIONS} - value={node.slabOpeningMode ?? 'none'} - /> - - {(node.slabOpeningMode ?? 'none') === 'destination' ? ( - handleUpdate({ openingOffset: value })} - precision={2} - step={0.01} - unit="m" - value={Math.round((node.openingOffset ?? 0) * 100) / 100} - /> + {attachedDeck ? ( +
+
+ Rise +
+ + handleUpdate( + value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, + ) + } + options={[ + { label: 'Follows deck', value: 'follows' }, + { label: 'Custom rise', value: 'custom' }, + ]} + value={node.totalRise == null ? 'follows' : 'custom'} + /> + {node.totalRise == null ? ( +
+ Currently {resolvedRise} m +
+ ) : ( + handleUpdate({ totalRise: value })} + precision={2} + step={0.05} + unit="m" + value={resolvedRise} + /> + )} +
) : null} + {attachedDeck ? null : ( + <> + handleAutoCutoutChange(value === 'destination')} + options={STAIR_SLAB_OPENING_OPTIONS} + value={node.slabOpeningMode ?? 'none'} + /> + + {(node.slabOpeningMode ?? 'none') === 'destination' ? ( + handleUpdate({ openingOffset: value })} + precision={2} + step={0.01} + unit="m" + value={Math.round((node.openingOffset ?? 0) * 100) / 100} + /> + ) : null} + + )} + {node.stairType === 'spiral' && ( <>
@@ -389,7 +469,7 @@ export default function StairPanel() { precision={2} step={0.05} unit="m" - value={Math.round((node.totalRise ?? 2.5) * 100) / 100} + value={Math.round(resolveStairTotalRise(node, nodes) * 100) / 100} /> state.nodes) const sideMaterial = bodyMaterials[1] const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) - const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) + const totalRise = Math.max(resolveStairTotalRise(stair, nodes), 0.1) const stepHeight = totalRise / stepCount const isSpiral = stair.stairType === 'spiral' const innerRadius = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? 0.9) diff --git a/packages/nodes/src/wall/measurement.ts b/packages/nodes/src/wall/measurement.ts index d9d9645f..ac06b43d 100644 --- a/packages/nodes/src/wall/measurement.ts +++ b/packages/nodes/src/wall/measurement.ts @@ -1,18 +1,19 @@ import { - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallThickness, type MeasurementFeature, type MeasurementFeatureBinding, type MeasurementFeatureReference, sampleWallCenterline, + useScene, type WallNode, } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' const point = (x: number, y: number, z: number) => [x, y, z] as [number, number, number] export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] { - const height = wall.height ?? DEFAULT_WALL_HEIGHT + const height = resolveWallOpeningCeiling(wall, useScene.getState().nodes) const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y)) const midpoint = getWallCurveFrameAt(wall, 0.5).point const halfThickness = getWallThickness(wall) / 2 @@ -168,7 +169,10 @@ export function matchWallMeasurementFeature( const faceDistance = Math.hypot(hit[0] - faceX, hit[2] - faceZ) const threshold = Math.max(maxDistance, halfThickness + 0.03) if (faceDistance <= threshold && (!best || faceDistance < best.distance)) { - const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, hit[1])) + const height = Math.max( + 0, + Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), hit[1]), + ) best = { featureId: side > 0 ? 'wall:face:left' : 'wall:face:right', point: point(faceX, height, faceZ), @@ -204,7 +208,10 @@ export function resolveWallMeasurementFeature( if (typeof heightValue !== 'number' || feature.geometry.kind !== 'path') { return normal ? { ...feature, normal } : feature } - const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, heightValue)) + const height = Math.max( + 0, + Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), heightValue), + ) return { ...feature, ...(normal ? { normal } : {}), diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index fcb7bfd4..f8bcc86b 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -3,13 +3,13 @@ import { type AnyNodeId, collectAlignmentAnchors, - DEFAULT_WALL_HEIGHT, emitter, type GridEvent, getWallCurveLength, getWallThickness, pauseSceneHistory, resolveAlignment, + resolveWallSupportSlabPatch, resumeSceneHistory, runAsSingleSceneHistoryStep, useLiveNodeOverrides, @@ -39,6 +39,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useCallback, useEffect, useRef, useState } from 'react' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Wall endpoint move tool (kind-owned). @@ -521,6 +522,16 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ }, })), ]) + const affectedIds = [nodeId as AnyNodeId, ...linkedUpdates.map((u) => u.id as AnyNodeId)] + const committedNodes = useScene.getState().nodes + useScene.getState().updateNodes( + affectedIds.flatMap((id) => { + const wall = committedNodes[id] + return wall?.type === 'wall' + ? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }] + : [] + }), + ) useScene.getState().markDirty(nodeId as AnyNodeId) for (const u of linkedUpdates) { useScene.getState().markDirty(u.id as AnyNodeId) @@ -613,7 +624,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ end: previewEnd, curveOffset: target.wall.curveOffset, }) - const wallHeight = target.wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = resolveWallOpeningCeiling(target.wall, useScene.getState().nodes) const dimMidX = (previewStart[0] + previewEnd[0]) / 2 const dimMidZ = (previewStart[1] + previewEnd[1]) / 2 diff --git a/packages/nodes/src/wall/move-shared.ts b/packages/nodes/src/wall/move-shared.ts index aebea59f..3ae07a32 100644 --- a/packages/nodes/src/wall/move-shared.ts +++ b/packages/nodes/src/wall/move-shared.ts @@ -1,6 +1,5 @@ import { type AnyNodeId, - DEFAULT_WALL_HEIGHT, getMaterialPresetByRef, parseMaterialRef, resolveMaterial, @@ -12,6 +11,7 @@ import { WallNode as WallSchema, } from '@pascal-app/core' import { isSegmentLongEnough } from '@pascal-app/editor' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Pure helpers shared by the 3D `MoveWallTool` and the 2D @@ -241,7 +241,7 @@ export function buildBridgeWallPreviews(args: { start: [...plan.originalPoint] as WallPlanPoint, end: [...nextPoint] as WallPlanPoint, color: getWallGhostColor(plan.wall), - height: plan.wall.height ?? DEFAULT_WALL_HEIGHT, + height: resolveWallOpeningCeiling(plan.wall, useScene.getState().nodes), } previews.push({ ghost, wall }) wallsForDuplicateCheck.push(wall) diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 0a3b71ec..95744fa7 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -8,13 +8,16 @@ import { detectSpacesForLevel, emitter, type GridEvent, + getCeilingClampBound, getPerpendicularWallMoveAxis, getPlannedLinkedWallUpdates, + getStoredLevelHeight, + type LevelNode, pauseSceneHistory, planAutoCeilingsForLevel, planAutoSlabsForLevel, planWallMoveJunctions, - projectAutoSlabsForPlan, + resolveWallSupportSlabPatch, resumeSceneHistory, type SlabNode, useLiveNodeOverrides, @@ -284,12 +287,14 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // re-flowed through the planner. const existingSlabs = getLevelSlabs(levelId, sceneState.nodes) const slabPlan = planAutoSlabsForLevel(roomPolygons, existingSlabs) + const levelNode = sceneState.nodes[levelId as AnyNodeId] const ceilingPlan = planAutoCeilingsForLevel( roomPolygons, getLevelCeilings(levelId, sceneState.nodes), { - walls: levelWalls, - slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan), + storeyHeight: + levelNode?.type === 'level' ? getStoredLevelHeight(levelNode as LevelNode) : undefined, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, sceneState.nodes, polygon), }, ) @@ -594,6 +599,19 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // undoable step. Then drop the live overrides — the renderer // now reads the committed walls + polygons directly. commitSurfacesToStore() + const affectedWallIds = [ + ...commitUpdates.map((entry) => entry.id), + ...bridgeCreates.map((entry) => entry.node.id as AnyNodeId), + ] + const committedNodes = useScene.getState().nodes + useScene.getState().updateNodes( + affectedWallIds.flatMap((id) => { + const wall = committedNodes[id] + return wall?.type === 'wall' + ? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }] + : [] + }), + ) clearSurfaceOverrides() clearWallOverrides() diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index b914561f..9c41e857 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -23,6 +23,7 @@ import { createSlotPaintCapability, previewSlotByUserData, } from '../shared/slot-paint' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' const WALL_SLOT_IDS = new Set(Object.keys(WALL_SURFACE_SLOT_DEFAULTS)) const WALL_ARRAY_SLOT_INDEX: Partial> = { @@ -104,9 +105,13 @@ export function resolveWallRole(args: { } if (sideFromIndex && localPosition) { - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return sideFromIndex - return getWallBandSlotId(sideFromIndex, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + sideFromIndex, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } if (sideFromIndex) return sideFromIndex @@ -128,15 +133,23 @@ export function resolveWallRole(args: { const semantic = hitFace === 'front' ? node.frontSide : node.backSide if (semantic === 'interior' || semantic === 'exterior') { - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return semantic - return getWallBandSlotId(semantic, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + semantic, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } const side = hitFace === 'front' ? 'interior' : 'exterior' - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return side - return getWallBandSlotId(side, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + side, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } /** diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 72342657..2fd4729e 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -22,6 +22,7 @@ import { ActionButton, ActionGroup, curveReshapeScope, + formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, metersToLinearUnit, @@ -35,6 +36,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Spline } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' type WallTrimKey = 'skirting' | 'crown' | 'chairRail' @@ -104,6 +106,15 @@ export default function WallPanel() { }) }) + // Effective height while the wall is plane-bound (`height` absent): the + // storey plane minus the elected slab base — what the wall currently + // renders at. `undefined` for walls with an explicit custom height. + const planeBoundHeightMeters = useScene((s) => { + const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined + if (!wall || wall.type !== 'wall' || wall.height != null) return undefined + return resolveWallOpeningCeiling(wall, s.nodes) + }) + // Mirror the latest node into a ref so the slider handlers below have // stable identities across re-renders. Without this, every store tick // (one per pointermove during a slider drag) rebuilt the handler @@ -145,6 +156,24 @@ export default function WallPanel() { [handleUpdate], ) + const handleTopModeChange = useCallback( + (mode: 'storey' | 'custom') => { + const n = nodeRef.current + if (!n) return + const isCustom = n.height != null + if (mode === 'custom' && !isCustom) { + // Seed from the current effective height so the geometry doesn't + // jump at the moment of detaching from the storey plane. + const seeded = resolveWallOpeningCeiling(n, useScene.getState().nodes) + handleUpdate({ height: Math.max(0.1, seeded) }) + } else if (mode === 'storey' && isCustom) { + // Absent `height` = plane-bound; the store strips undefined keys. + handleUpdate({ height: undefined }) + } + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -160,7 +189,8 @@ export default function WallPanel() { const length = getWallCurveLength(node) - const height = node.height ?? 2.5 + const isPlaneBound = node.height == null + const height = node.height ?? planeBoundHeightMeters ?? 2.5 const thickness = node.thickness ?? 0.1 const curveOffset = getClampedWallCurveOffset(node) const maxCurveOffset = getMaxWallCurveOffset(node) @@ -171,7 +201,7 @@ export default function WallPanel() { const displayCurveOffset = metersToLinearUnit(curveOffset, unit) const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) const curveOffsetLimit = Math.max(0.01, maxCurveOffset) - const wallHeightMeters = node.height ?? 2.5 + const wallHeightMeters = height const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) } const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) } @@ -199,20 +229,37 @@ export default function WallPanel() { unit={unitLabel} value={displayLength} /> - - handleUpdate({ - height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), - }) - } - precision={2} - step={0.1} - unit={unitLabel} - value={Math.round(displayHeight * 100) / 100} +
+ Top +
+ + {isPlaneBound ? ( +
+ Currently {formatLinearMeasurement(height, unit)} +
+ ) : ( + + handleUpdate({ + height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), + }) + } + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayHeight * 100) / 100} + /> + )} = { label: 'Dimensions', fields: [ { key: 'thickness', kind: 'number', unit: 'm', min: 0.05, max: 0.6, step: 0.01 }, + // `height` may be absent (plane-bound top); the custom panel owns the + // Follows storey / Custom height mode switch, so this is metadata only. { key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 }, { key: 'curveOffset', kind: 'number', unit: 'm', min: -3, max: 3, step: 0.05 }, ], diff --git a/packages/nodes/src/wall/quick-measurement.ts b/packages/nodes/src/wall/quick-measurement.ts index cbe9b1ee..c7f97f03 100644 --- a/packages/nodes/src/wall/quick-measurement.ts +++ b/packages/nodes/src/wall/quick-measurement.ts @@ -1,15 +1,16 @@ import { - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallCurveLength, getWallThickness, type QuickMeasurementReport, + useScene, type WallNode, } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' export function wallQuickMeasurement(node: WallNode): QuickMeasurementReport { const length = getWallCurveLength(node) - const height = node.height ?? DEFAULT_WALL_HEIGHT + const height = resolveWallOpeningCeiling(node, useScene.getState().nodes) const frame = getWallCurveFrameAt(node, 0.5) return { diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx index d5ce8243..7f880f34 100644 --- a/packages/nodes/src/wall/system.tsx +++ b/packages/nodes/src/wall/system.tsx @@ -44,7 +44,7 @@ const WallTreatmentMiterSystem = () => { * * - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs * `calculateLevelMiters(levelWalls)`, rebuilds geometry via - * `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments)`, + * `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments, storeyHeight)`, * and cascades to adjacent walls that share a junction. This is the * bulk of the wall runtime (~820 lines in viewer). * - **`WallCutout`** — cutaway-mode hide/show logic based on camera diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index f5acb490..8f7b9cbc 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -2,6 +2,7 @@ import { type AnyNode, calculateLevelMiters, collectAlignmentAnchors, + DEFAULT_LEVEL_HEIGHT, emitter, type GridEvent, getWallMiterBoundaryPoints, @@ -17,6 +18,7 @@ import { import { CursorSphere, chainEndJoinsExistingWall, + clearPlacementSurface, createWallOnCurrentLevel, EDITOR_LAYER, formatAngleRadians, @@ -28,6 +30,8 @@ import { isAngleSnapActive, isMagneticSnapActive, markToolCancelConsumed, + publishPlacementSurface, + resolvePointerSupportSurface, type SegmentAngleReference, snapWallDraftPointDetailed, triggerSFX, @@ -42,6 +46,7 @@ import { } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' @@ -58,7 +63,6 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 * * Mounted via `def.tool` from `wall/definition.ts`. */ -const WALL_HEIGHT = 2.5 const DRAFT_WALL_THICKNESS = 0.1 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 @@ -83,6 +87,11 @@ const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [ { vector: [0, 1], orientation: 'axis' }, ] +// Grid-plane surface publish (pointer-decided): scratch + constant normal so +// per-move publishes don't allocate. +const SURFACE_UP = new Vector3(0, 1, 0) +const surfacePointScratch = new Vector3() + type DraftAngleLabel = { id: string label: string @@ -513,13 +522,24 @@ function getBelowLevelWalls(): WallNode[] { export const WallTool: React.FC = () => { const unit = useViewer((state) => state.unit) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const activeLevelId = useViewer((state) => state.selection.levelId) + const activeLevelHeight = useScene((state) => { + const level = activeLevelId ? state.nodes[activeLevelId] : undefined + return level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + }) // A placed wall preset seeds `toolDefaults.wall` (height / thickness …) // before the tool mounts, so the draft preview is drawn at the preset's // dimensions rather than the generic fallbacks — matching the wall that // will be created. Read through refs so the live event handlers below see // the latest values without re-subscribing. const wallDefaults = useEditor((s) => s.toolDefaults.wall) - const previewHeight = typeof wallDefaults?.height === 'number' ? wallDefaults.height : WALL_HEIGHT + // Camera for the pointer-support resolution (deck top vs floor) — read + // through a ref so the event handlers below see the live camera. + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + const previewHeight = + typeof wallDefaults?.height === 'number' ? wallDefaults.height : activeLevelHeight const previewThickness = typeof wallDefaults?.thickness === 'number' ? wallDefaults.thickness : DRAFT_WALL_THICKNESS const previewHeightRef = useRef(previewHeight) @@ -591,6 +611,16 @@ export const WallTool: React.FC = () => { : point } + // The walking surface the pointer actually aims at (deck top when over + // the deck, floor/ground underneath it) — only for genuine 3D pointer + // events. The 2D floor plan emits synthetic grid events with no camera + // ray behind them; those keep the uncapped max election and leave the + // grid plane alone. + const pointedSurfaceFor = (event: GridEvent) => + event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(cameraRef.current, event.position) + : null + const stopDrafting = () => { buildingState.current = 0 chainFirstVertex.current = null @@ -611,6 +641,19 @@ export const WallTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && wallPreviewRef.current)) return + // Ride the grid event plane on the pointed surface: aiming at an + // elevated deck lifts the plane to the deck top, so the draft's XZ + // lands where the cursor points and the preview/cursor Y + // (`event.localPosition[1]`) sits at the base the committed wall + // will elect. Aiming past the deck edge drops it back to the floor. + const pointed = pointedSurfaceFor(event) + if (pointed) { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + } + const walls = getCurrentLevelWalls() // Add walls on the floor below as extra snap references so the new wall // can align with the level beneath it. Kept separate from `walls` so the @@ -745,10 +788,12 @@ export const WallTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return + const pointed = pointedSurfaceFor(event) // Both start and end are building-local ✓ const createdWall = createWallOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, + { supportCap: pointed ? pointed.elevation : null }, ) if (!createdWall) return chainWallIds.current.push(createdWall.id) @@ -831,6 +876,7 @@ export const WallTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() useSegmentDraftChain.getState().clear('wall') diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index c092875b..6fe6856b 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -7,6 +7,7 @@ import { isCurvedWall, type SceneMaterial, type SceneMaterialId, + useScene, WALL_CHAIR_RAIL_DEFAULT, WALL_CROWN_DEFAULT, WALL_SKIRTING_DEFAULT, @@ -25,6 +26,7 @@ import { import { memo, useEffect, useMemo } from 'react' import * as THREE from 'three' import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data' const CURVE_SEGMENTS = 24 @@ -502,7 +504,7 @@ export function buildTrimGeometry( childrenNodes: OpeningLike[], levelData: WallTreatmentLevelData, ) { - const wallHeight = node.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) const height = trim.height const yBottom = kind === 'crown' diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index a61ea3cb..d2fa4467 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -9,6 +9,7 @@ import type { import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' +import { readHostWallCeiling } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' @@ -33,12 +34,6 @@ function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unkn return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } -function readWallHeight(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!w.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(w.wallId as AnyNodeId) as WallNode | undefined - return wall?.height ?? Number.POSITIVE_INFINITY -} - function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor { const sign = side === 'right' ? 1 : -1 return { @@ -96,9 +91,9 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor = ({ nod startLocalY, node.width, node.height, + nodes, ) // One click per real position step, keyed on the SNAPPED along-wall value diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 885560a1..475a5c44 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -356,6 +356,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode targetLocalY, movingWindowNode.width, movingWindowNode.height, + useScene.getState().nodes, ) const valid = !hasWallChildOverlap( diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index ede4907b..d7d63a76 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -339,7 +339,14 @@ const WindowTool: React.FC = () => { width, height, }) - const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height) + const { clampedX, clampedY } = clampToWall( + wall, + localX, + localY, + width, + height, + useScene.getState().nodes, + ) const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) return { clampedX, clampedY, valid } } diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index eb3547df..08ff4cb3 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,4 +1,5 @@ -import type { WallNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Default sill height (metres from the floor to the BOTTOM of a window) for a @@ -35,7 +36,11 @@ export function wallLocalToWorld( } /** - * Clamps window center position so it stays fully within wall bounds. + * Clamps window center position so it stays fully within wall bounds. The Y + * ceiling is the wall's RESOLVED top (storey plane for plane-bound walls, + * stored height for explicit ones, minus the elected slab base) — `nodes` is + * required because a plane-bound wall's top lives on its level, not on the + * wall record. */ export function clampToWall( wallNode: WallNode, @@ -43,11 +48,12 @@ export function clampToWall( localY: number, width: number, height: number, + nodes: Readonly>, ): { clampedX: number; clampedY: number } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] const wallLength = Math.sqrt(dx * dx + dz * dz) - const wallHeight = wallNode.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(wallNode, nodes) const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 9a7e7165..8408cd88 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -161,10 +161,9 @@ export { type SurfaceFrame, } from './systems/roof/roof-system' export { ScanSystem } from './systems/scan/scan-system' -// Slab system follows the wall + fence re-export pattern — composed into -// the registry-driven slab definition's `def.system`. Removed in Phase 6 -// alongside the legacy slab mount point. -export { generateSlabGeometry, SlabSystem } from './systems/slab/slab-system' +// Pure slab geometry generator — composed into the registry-driven slab +// definition's `def.geometry` in `@pascal-app/nodes`. +export { generateSlabGeometry } from './systems/slab/slab-system' export { getStairBodyMaterials, getStairRailingMaterial, diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index b1fc6623..e490ecf6 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, getEffectiveNode, nodeRegistry, + resolveCeilingHeight, sceneRegistry, useLiveTransforms, useScene, @@ -44,7 +45,7 @@ export const CeilingSystem = () => { // the final value on commit. Mirrors WallSystem / GeometrySystem. const effective = getEffectiveNode(node as CeilingNode) const itemHoles = collectCeilingHoles(effective, nodes) - updateCeilingGeometry(effective, mesh, itemHoles) + updateCeilingGeometry(effective, mesh, itemHoles, nodes) clearDirty(id as AnyNodeId) } // If mesh not found, keep it dirty for next frame @@ -88,6 +89,7 @@ function updateCeilingGeometry( node: CeilingNode, mesh: THREE.Mesh, extraHoles: Array> = [], + nodes: SceneNodes = useScene.getState().nodes, ) { const newGeo = generateCeilingGeometry(node, extraHoles) @@ -108,7 +110,11 @@ function updateCeilingGeometry( const liveTransform = useLiveTransforms.getState().get(node.id) mesh.position.x = liveTransform?.position[0] ?? 0 mesh.position.z = liveTransform?.position[2] ?? 0 - mesh.position.y = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) // Slight offset to avoid z-fighting with upper-level slabs + // Resolved height: explicit when stored, else the level-top bound — so a + // follows-mode ceiling re-parks under the current plane on every rebuild + // (level-height edits / covering-slab changes dirty-mark ceilings). + // Slight offset to avoid z-fighting with upper-level slabs. + mesh.position.y = resolveCeilingHeight(node, nodes) - 0.01 + (liveTransform?.position[1] ?? 0) } /** diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index 2098875e..33976fae 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, getEffectiveNode, getFloorStackedPosition, + type LiveTransform, nodeRegistry, sceneRegistry, useLiveTransforms, @@ -16,8 +17,7 @@ type PositionedNode = AnyNode & { rotation?: [number, number, number] | number } -function withLiveTransform(node: AnyNode, id: string): AnyNode { - const liveTransform = useLiveTransforms.getState().get(id) +function withLiveTransform(node: AnyNode, liveTransform: LiveTransform | undefined): AnyNode { if (!liveTransform) return node const currentRotation = (node as PositionedNode).rotation @@ -75,7 +75,8 @@ export const FloorElevationSystem = () => { const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined if (!mesh) return - const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), id) + const liveTransform = useLiveTransforms.getState().get(id) + const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), liveTransform) const position = (effectiveNode as PositionedNode).position if (!position) return @@ -91,6 +92,10 @@ export const FloorElevationSystem = () => { node: effectiveNode, nodes: resolverNodes, position, + // 3D drags publish the pointer-decided surface cap with their live + // transform; honoring it here keeps this system's per-frame Y in + // agreement with the tool's preview (no deck/floor flicker). + maxElevation: liveTransform?.supportElevationCap, }) mesh.position.y = visualPosition[1] diff --git a/packages/viewer/src/systems/level/level-stacking.test.ts b/packages/viewer/src/systems/level/level-stacking.test.ts deleted file mode 100644 index c71d7668..00000000 --- a/packages/viewer/src/systems/level/level-stacking.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { getLevelBuildingId, getLevelStackPositions, type LevelStackEntry } from './level-stacking' - -describe('getLevelBuildingId', () => { - const buildings = [ - { id: 'building_a', children: ['level_a0'] }, - { id: 'building_b', children: ['level_b0'] }, - ] - - test('uses an explicit building parent', () => { - expect(getLevelBuildingId('level_a0', 'building_a', buildings)).toBe('building_a') - }) - - test('falls back to building children for legacy levels without a parentId', () => { - expect(getLevelBuildingId('level_b0', null, buildings)).toBe('building_b') - }) - - test('ignores a non-building parent before checking building children', () => { - expect(getLevelBuildingId('level_a0', 'site_main', buildings)).toBe('building_a') - }) -}) - -describe('getLevelStackPositions', () => { - test('stacks levels within one building by level index', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_second', buildingId: 'building_a', index: 2, height: 3.4 }, - { levelId: 'level_ground', buildingId: 'building_a', index: 0, height: 2.5 }, - { levelId: 'level_first', buildingId: 'building_a', index: 1, height: 3.1 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_ground: 0, - level_first: 2.5, - level_second: 5.6, - }) - }) - - test('starts each building on its own ground plane', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_a0', buildingId: 'building_a', index: 0, height: 2.5 }, - { levelId: 'level_b0', buildingId: 'building_b', index: 0, height: 3 }, - { levelId: 'level_a1', buildingId: 'building_a', index: 1, height: 2.8 }, - { levelId: 'level_b1', buildingId: 'building_b', index: 1, height: 3.2 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_a0: 0, - level_b0: 0, - level_a1: 2.5, - level_b1: 3, - }) - }) - - test('keeps orphan levels in one legacy stack', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_0', buildingId: null, index: 0, height: 2.7 }, - { levelId: 'level_1', buildingId: null, index: 1, height: 3 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_0: 0, - level_1: 2.7, - }) - }) -}) diff --git a/packages/viewer/src/systems/level/level-stacking.ts b/packages/viewer/src/systems/level/level-stacking.ts deleted file mode 100644 index b966e383..00000000 --- a/packages/viewer/src/systems/level/level-stacking.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type LevelStackEntry = { - levelId: string - buildingId: string | null - index: number - height: number -} - -type BuildingOwnership = { id: string; children: readonly string[] } - -export function getLevelBuildingId( - levelId: string, - parentId: string | null, - buildings: readonly BuildingOwnership[], -): string | null { - const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined - if (directParent) return directParent.id - - return buildings.find((building) => building.children.includes(levelId))?.id ?? null -} - -export function getLevelStackPositions(entries: readonly LevelStackEntry[]): Map { - const positions = new Map() - const cumulativeYByBuilding = new Map() - - for (const entry of [...entries].sort((a, b) => a.index - b.index)) { - const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0 - positions.set(entry.levelId, baseY) - cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height) - } - - return positions -} diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index 80af5af3..3a20f6cb 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,16 +1,9 @@ -import { - type BuildingNode, - getLevelHeight, - type LevelNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' +import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import type { Object3D } from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' -import { getLevelBuildingId, getLevelStackPositions } from './level-stacking' const EXPLODED_GAP = 5 @@ -26,44 +19,31 @@ export const LevelSystem = () => { const levelMode = useViewer.getState().levelMode const selectedLevel = useViewer.getState().selection.levelId - // Collect level heights so each building can compute its own cumulative offsets. - // Level 0 → Y=0, Level 1 → Y=height(0), Level 2 → Y=height(0)+height(1), etc. + const levelElevations = getLevelElevations(nodes) type LevelEntry = { levelId: string - buildingId: string | null index: number - height: number obj: NonNullable> } const entries: LevelEntry[] = [] - const buildings = Object.values(nodes).filter( - (node): node is BuildingNode => node?.type === 'building', - ) sceneRegistry.byType.level!.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (obj && level) { entries.push({ levelId, - buildingId: getLevelBuildingId(levelId, level.parentId, buildings), index: level.level, - height: getLevelHeight( - levelId, - nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ), obj, }) } }) - const stackPositions = getLevelStackPositions(entries) const selectedIndex = selectedLevel ? entries.find((e) => e.levelId === selectedLevel)?.index : undefined for (const { levelId, index, obj } of entries) { const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - const baseY = stackPositions.get(levelId) ?? 0 + const baseY = levelElevations.get(levelId)?.baseY ?? 0 const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0 const targetY = baseY + explodedExtra diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts index 25c03ba9..cfa29b3b 100644 --- a/packages/viewer/src/systems/level/level-utils.ts +++ b/packages/viewer/src/systems/level/level-utils.ts @@ -1,11 +1,4 @@ -import { - type BuildingNode, - getLevelHeight, - type LevelNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { getLevelBuildingId, getLevelStackPositions } from './level-stacking' +import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' /** * Instantly snaps all level Objects3D to their true stacked Y positions @@ -25,33 +18,20 @@ export function snapLevelsToTruePositions(): () => void { type LevelEntry = { obj: NonNullable> levelId: string - buildingId: string | null - index: number - height: number } const entries: LevelEntry[] = [] - const buildings = Object.values(nodes).filter( - (node): node is BuildingNode => node?.type === 'building', - ) sceneRegistry.byType.level!.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (obj && level) { entries.push({ levelId, - buildingId: getLevelBuildingId(levelId, level.parentId, buildings), - index: level.level, - height: getLevelHeight( - levelId, - nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ), obj, }) } }) - const stackPositions = getLevelStackPositions(entries) + const levelElevations = getLevelElevations(nodes) // Snapshot current Y and visibility so we can restore them after the render const snapshot = new Map( @@ -60,7 +40,7 @@ export function snapLevelsToTruePositions(): () => void { // Snap to true stacked positions and make all levels visible for (const { levelId, obj } of entries) { - obj.position.y = stackPositions.get(levelId) ?? 0 + obj.position.y = levelElevations.get(levelId)?.baseY ?? 0 obj.visible = true } diff --git a/packages/viewer/src/systems/slab/slab-system.test.ts b/packages/viewer/src/systems/slab/slab-system.test.ts index a6e58aa2..910c1dcc 100644 --- a/packages/viewer/src/systems/slab/slab-system.test.ts +++ b/packages/viewer/src/systems/slab/slab-system.test.ts @@ -7,6 +7,13 @@ import { generateSlabGeometry } from './slab-system' const EMPTY_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] } +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) { const positions = geometry.getAttribute('position') for (let index = 0; index < positions.count; index += 1) { @@ -17,16 +24,20 @@ function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) { return false } +function uniqueSortedYs(geometry: THREE.BufferGeometry): number[] { + const positions = geometry.getAttribute('position') + const ys = new Set() + for (let index = 0; index < positions.count; index += 1) { + ys.add(Math.round(positions.getY(index) * 1e4) / 1e4) + } + return [...ys].sort((a, b) => a - b) +} + describe('generateSlabGeometry', () => { test('renders a boundary-overlapping hole as an open indentation', () => { const slab = SlabNode.parse({ elevation: 0.05, - polygon: [ - [0, 0], - [4, 0], - [4, 3], - [0, 3], - ], + polygon: SQUARE, holes: [ [ [1, -0.5], @@ -47,12 +58,8 @@ describe('generateSlabGeometry', () => { test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => { const slab = SlabNode.parse({ elevation: -0.2, - polygon: [ - [0, 0], - [4, 0], - [4, 3], - [0, 3], - ], + recessed: true, + polygon: SQUARE, holes: [ [ [1, -0.5], @@ -69,4 +76,47 @@ describe('generateSlabGeometry', () => { expect(hasVertexAt(geometry, 1, 1)).toBe(true) expect(hasVertexAt(geometry, 3, 1)).toBe(true) }) + + test('solid slab occupies [elevation − thickness, elevation]', () => { + const slab = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon: SQUARE }) + + const ys = uniqueSortedYs(generateSlabGeometry(slab, EMPTY_CONTEXT)) + + expect(ys).toEqual([0.2, 0.3]) + }) + + test('migrated legacy slab (thickness = elevation) reproduces the [0, elevation] extrusion', () => { + const slab = SlabNode.parse({ elevation: 0.05, thickness: 0.05, polygon: SQUARE }) + + const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT) + + // Old-style expectations: extrude-from-zero put the bottom cap at 0 and + // the top cap at `elevation`, with 8 cap verts + 4 side quads (16 verts) + // and 12 triangles for a plain quad slab. + expect(uniqueSortedYs(geometry)).toEqual([0, 0.05]) + expect(geometry.getAttribute('position').count).toBe(24) + expect((geometry.index?.count ?? 0) / 3).toBe(12) + for (const [x, z] of SQUARE) { + expect(hasVertexAt(geometry, x, z)).toBe(true) + } + }) + + test('recess is keyed by the flag, not the elevation sign', () => { + const belowPlaneSolid = SlabNode.parse({ elevation: -0.2, thickness: 0.05, polygon: SQUARE }) + + // Without `recessed`, a negative elevation is just a solid placed below + // the level plane: closed body at [-0.25, -0.2], world-space Y baked in. + expect(uniqueSortedYs(generateSlabGeometry(belowPlaneSolid, EMPTY_CONTEXT))).toEqual([ + -0.25, -0.2, + ]) + + // The recessed shell is authored at local Y=0 (floor) up to |elevation| + // (rim), with no top cap: 4 floor verts + 4 wall quads = 20 verts, + // 2 floor + 8 wall triangles. + const pool = SlabNode.parse({ elevation: -0.2, recessed: true, polygon: SQUARE }) + const poolGeometry = generateSlabGeometry(pool, EMPTY_CONTEXT) + expect(uniqueSortedYs(poolGeometry)).toEqual([0, 0.2]) + expect(poolGeometry.getAttribute('position').count).toBe(20) + expect((poolGeometry.index?.count ?? 0) / 3).toBe(10) + }) }) diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index f1d220e2..00ba8c88 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -1,129 +1,33 @@ import { - type AnyNode, - type AnyNodeId, - getEffectiveNode, getRenderableSlabPolygon, type PolygonPoint2D, pointInPolygon2D, polygonsIntersect, type SlabNode, type SlabPolygonContext, - sceneRegistry, - useScene, - type WallNode, } from '@pascal-app/core' -import { useFrame } from '@react-three/fiber' -import { useEffect } from 'react' import * as THREE from 'three' import { subtractPolygonsFromPolygon } from '../../lib/polygon-union' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' -function ensureUv2Attribute(geometry: THREE.BufferGeometry) { - const uv = geometry.getAttribute('uv') - if (!uv) return - - geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) -} - // ============================================================================ -// SLAB SYSTEM +// SLAB GEOMETRY GENERATORS // ============================================================================ -export const SlabSystem = () => { - const dirtyNodes = useScene((state) => state.dirtyNodes) - const clearDirty = useScene((state) => state.clearDirty) - const markDirty = useScene((state) => state.markDirty) - - useEffect(() => { - const nodes = useScene.getState().nodes - for (const node of Object.values(nodes)) { - if (node.type === 'slab') { - markDirty(node.id) - } - } - }, [markDirty]) - - useFrame(() => { - if (dirtyNodes.size === 0) return - - const nodes = useScene.getState().nodes - const contextByLevel = new Map() - - // Process dirty slabs - dirtyNodes.forEach((id) => { - const node = nodes[id] - if (node?.type !== 'slab') return - - const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh - if (mesh) { - const slab = node as SlabNode - const levelContext = - contextByLevel.get(slab.parentId) ?? buildLevelSlabContext(slab.parentId, nodes) - contextByLevel.set(slab.parentId, levelContext) - updateSlabGeometry( - getEffectiveNode(slab), - excludeSlabFromContext(levelContext, slab.id), - mesh, - ) - clearDirty(id as AnyNodeId) - } - // If mesh not found, keep it dirty for next frame - }) - }, 1) - - return null -} - -function buildLevelSlabContext( - levelId: string | null, - nodes: Record, -): SlabPolygonContext { - const walls: WallNode[] = [] - const siblingSlabs: SlabNode[] = [] - for (const node of Object.values(nodes)) { - if (node.parentId !== levelId) continue - if (node.type === 'wall') walls.push(node as WallNode) - else if (node.type === 'slab') siblingSlabs.push(node as SlabNode) - } - return { walls, siblingSlabs } -} - -function excludeSlabFromContext(context: SlabPolygonContext, slabId: string): SlabPolygonContext { - return { - walls: context.walls, - siblingSlabs: context.siblingSlabs.filter((slab) => slab.id !== slabId), - } -} - /** - * Updates the geometry for a single slab - */ -function updateSlabGeometry(node: SlabNode, context: SlabPolygonContext, mesh: THREE.Mesh) { - const newGeo = generateSlabGeometry(node, context) - ensureUv2Attribute(newGeo) - - mesh.geometry.dispose() - mesh.geometry = newGeo - - // For negative elevation, shift the mesh down so the top face sits at Y=elevation - // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level). - const elevation = node.elevation ?? 0.05 - mesh.position.y = elevation < 0 ? elevation : 0 -} - -/** - * Generates extruded slab geometry from polygon. `context` carries the - * slab's level neighbourhood (walls + sibling slabs) driving the per-edge - * render offsets — see `getRenderableSlabPolygon`. + * Generates slab geometry from polygon. `context` carries the slab's level + * neighbourhood (walls + sibling slabs) driving the per-edge render offsets — + * see `getRenderableSlabPolygon`. Branches on the explicit `recessed` intent: + * a recessed slab is an open shell (pool), everything else a solid occupying + * `[elevation − thickness, elevation]`. */ export function generateSlabGeometry( slabNode: SlabNode, context: SlabPolygonContext, ): THREE.BufferGeometry { - const elevation = slabNode.elevation ?? 0.05 - return elevation < 0 + return slabNode.recessed ? generatePoolGeometry(slabNode, context) - : generatePositiveSlabGeometry(slabNode, context) + : generateSolidSlabGeometry(slabNode, context) } // Earcut normalizes cap triangulation regardless of input winding, but the side @@ -175,7 +79,8 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) } /** - * Standard slab: flat extrusion upward from Y=0 by elevation thickness. + * Solid slab occupying `[elevation − thickness, elevation]`: the top cap is + * the walking surface at `elevation`, the body grows downward by `thickness`. * * Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side * walls can be emitted double-sided. The slab material is forced to FrontSide @@ -186,12 +91,14 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) * thickness visible from any angle: the two coincident triangles never z-fight * because exactly one faces the camera under FrontSide culling. */ -function generatePositiveSlabGeometry( +function generateSolidSlabGeometry( slabNode: SlabNode, context: SlabPolygonContext, ): THREE.BufferGeometry { const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context)) const elevation = slabNode.elevation ?? 0.05 + const thickness = slabNode.thickness ?? 0.05 + const bottom = elevation - thickness const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) if (polygon.length < 3) return new THREE.BufferGeometry() @@ -207,14 +114,14 @@ function generatePositiveSlabGeometry( const addWall = (a: THREE.Vector2, b: THREE.Vector2, flipped: boolean) => { const base = positions.length / 3 const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001) - positions.push(a.x, 0, a.y) + positions.push(a.x, bottom, a.y) uvs.push(0, 0) - positions.push(b.x, 0, b.y) + positions.push(b.x, bottom, b.y) uvs.push(len, 0) positions.push(b.x, elevation, b.y) - uvs.push(len, elevation) + uvs.push(len, thickness) positions.push(a.x, elevation, a.y) - uvs.push(0, elevation) + uvs.push(0, thickness) // Standard winding on a CCW polygon gives inward-facing normals (see pool // path), so the unflipped quad faces outward; flipped is its back face. if (!flipped) { @@ -244,7 +151,7 @@ function generatePositiveSlabGeometry( } const bottomBase = positions.length / 3 for (const p of capPoints) { - positions.push(p.x, 0, p.y) + positions.push(p.x, bottom, p.y) uvs.push(p.x, -p.y) } @@ -303,7 +210,7 @@ function generatePoolGeometry( const pushFloorVertex = (x: number, y: number, z: number) => { positions.push(x, y, z) - // Floor UVs in metres (shape-space x, -z), matching generatePositiveSlabGeometry's + // Floor UVs in metres (shape-space x, -z), matching generateSolidSlabGeometry's // cap mapping so a finish tiles at the same world scale on every surface. uvs.push(x, -z) } diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index ebee5f3b..929accfe 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -2,7 +2,11 @@ import { type AnyNodeId, emitter, getWallFaceBandConfig, + getWallPlaneTop, + resolveLevelId, + resolveWallEffectiveHeight, sceneRegistry, + spatialGridManager, useScene, type WallNode, } from '@pascal-app/core' @@ -130,8 +134,22 @@ export const WallCutout = () => { const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) + const levelId = resolveLevelId(wallNode, sceneState.nodes) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wallNode.start, + wallNode.end, + wallNode.curveOffset ?? 0, + wallNode.thickness, + wallNode.supportSlabId, + ) + const effectiveWallHeight = resolveWallEffectiveHeight( + wallNode, + getWallPlaneTop(wallNode, levelId, sceneState.nodes), + support.elevation, + ) const shouldSelectionHighlight = - isSelectionHighlighted && !getWallFaceBandConfig(wallNode).enabled + isSelectionHighlighted && !getWallFaceBandConfig(wallNode, effectiveWallHeight).enabled const materials = getMaterialsForWall( wallNode, shading, diff --git a/packages/viewer/src/systems/wall/wall-support-extension.test.ts b/packages/viewer/src/systems/wall/wall-support-extension.test.ts index 881fb29e..3c8516b0 100644 --- a/packages/viewer/src/systems/wall/wall-support-extension.test.ts +++ b/packages/viewer/src/systems/wall/wall-support-extension.test.ts @@ -1,7 +1,13 @@ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' -import { calculateLevelMiters, WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + calculateLevelMiters, + getWallPlaneTop, + WallNode, +} from '@pascal-app/core' import { generateExtrudedWall } from './wall-system' describe('wall support extension', () => { @@ -31,6 +37,96 @@ describe('wall support extension', () => { geometry.dispose() }) + test('plane-bound wall tops out at the storey plane regardless of slab elevation', () => { + const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1 }) + + const flat = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0, 0, undefined, 3) + flat.computeBoundingBox() + expect(flat.boundingBox?.max.y).toBeCloseTo(3) + expect(flat.boundingBox?.min.y).toBeCloseTo(0) + flat.dispose() + + const raised = generateExtrudedWall( + wall, + [], + calculateLevelMiters([wall]), + 0.6, + 0.6, + undefined, + 3, + ) + raised.computeBoundingBox() + // Mesh sits at Y=0.6, so a 2.4 local top keeps the world top at the 3m + // plane — the raised slab shortens the wall instead of lifting its top. + expect(raised.boundingBox?.max.y).toBeCloseTo(2.4) + expect(raised.boundingBox?.min.y).toBeCloseTo(0) + raised.dispose() + }) + + test('plane-bound wall under a flush thick deck tops out at the deck underside', () => { + // level_1 carries a flush deck occupying [-0.3, 0] above the storey + // plane: the covering-clamped plane for level_0 is 2.5 − 0.3 = 2.2. + const wall = WallNode.parse({ + start: [0.5, 2], + end: [3.5, 2], + thickness: 0.1, + parentId: 'level_0', + }) + const base = { object: 'node', parentId: null, visible: true, metadata: {}, children: [] } + const nodes = { + level_0: { + ...base, + id: 'level_0', + type: 'level', + level: 0, + height: 2.5, + children: [wall.id], + }, + level_1: { + ...base, + id: 'level_1', + type: 'level', + level: 1, + height: 2.5, + children: ['slab_deck'], + }, + slab_deck: { + ...base, + id: 'slab_deck', + type: 'slab', + parentId: 'level_1', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + holes: [], + elevation: 0, + thickness: 0.3, + }, + } as unknown as Record + + const planeTop = getWallPlaneTop(wall, 'level_0', nodes) + expect(planeTop).toBeCloseTo(2.2) + + const geometry = generateExtrudedWall( + wall, + [], + calculateLevelMiters([wall]), + 0, + 0, + undefined, + planeTop, + ) + geometry.computeBoundingBox() + // Mesh sits at Y=0, so the world top lands at the deck underside instead + // of colliding with the slab solid above. + expect(geometry.boundingBox?.max.y).toBeCloseTo(2.2) + expect(geometry.boundingBox?.min.y).toBeCloseTo(0) + geometry.dispose() + }) + test('raises only the high-supported part of a mixed wall run', () => { const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 }) const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.05, [ diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 20b0eaa8..754b02ef 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -2,7 +2,7 @@ import { type AnyNode, type AnyNodeId, calculateLevelMiters, - DEFAULT_WALL_HEIGHT, + DEFAULT_LEVEL_HEIGHT, type DoorNode, getAdjacentWallIds, getEffectiveNode, @@ -11,6 +11,7 @@ import { getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, + getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, @@ -18,6 +19,7 @@ import { type Point2D, pointToKey, resolveLevelId, + resolveWallTop, sceneRegistry, spatialGridManager, useLiveNodeOverrides, @@ -222,15 +224,16 @@ function getWallFaceMaterialIndex( wall: Pick, face: 'front' | 'back', y: number, + effectiveWallHeight: number, ): number { const semantic = face === 'front' ? wall.frontSide : wall.backSide const fallback: WallSurfaceSide = face === 'front' ? 'interior' : 'exterior' const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback - const bands = getWallFaceBandConfig(wall) + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return WALL_BAND_SLOT_MATERIAL_INDEX[side] - const band = getWallFaceBandForHeight(wall, y) + const band = getWallFaceBandForHeight(wall, y, effectiveWallHeight) return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)] } @@ -238,6 +241,7 @@ function assignWallMaterialGroups( geometry: THREE.BufferGeometry, wall: WallNode, boundaryEdges: TaggedWallBoundaryEdge[], + effectiveWallHeight: number, ) { const position = geometry.getAttribute('position') if (!position) return @@ -317,7 +321,12 @@ function assignWallMaterialGroups( continue } - triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y) + triangleMaterials[triangleIndex] = getWallFaceMaterialIndex( + wall, + nearestTag, + centroid.y, + effectiveWallHeight, + ) } geometry.clearGroups() @@ -445,15 +454,15 @@ function splitGeometryAtHorizontalPlanes( return split } -function getWallBandSplitPlanes(wall: WallNode): number[] { - const bands = getWallFaceBandConfig(wall) +function getWallBandSplitPlanes(wall: WallNode, effectiveWallHeight: number): number[] { + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return [] const planes = [bands.lowerTop] if (bands.count >= 3) planes.push(bands.middleTop) if (bands.count >= 4) planes.push(bands.upperTop) return planes.filter( (plane) => - plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON, + plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON, ) } @@ -695,12 +704,16 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { if (!mesh) return const levelId = resolveLevelId(node, nodes) + // Covering-clamped plane: a flush/thick slab on the level above shortens + // the plane-bound walls below it (explicit-height walls ignore the value). + const planeTop = getWallPlaneTop(node, levelId, nodes) const slabSupport = spatialGridManager.getSlabSupportForWall( levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, + node.supportSlabId, ) const slabElevation = slabSupport.elevation @@ -731,6 +744,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, + planeTop, ) const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) // World transform the render mesh will apply (position + Y-rotation below). @@ -755,6 +769,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, + planeTop, ) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo @@ -845,14 +860,20 @@ export function generateExtrudedWall( baseSegments: readonly WallSlabSupportSegment[] = [ { start: 0, end: 1, elevation: baseElevation }, ], + storeyHeight = DEFAULT_LEVEL_HEIGHT, ): THREE.BufferGeometry { const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } - const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT - const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight + const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation) + const effectiveWallHeight = topElevation - slabElevation const effectiveBaseElevation = Math.min(baseElevation, slabElevation) const localBottom = effectiveBaseElevation - slabElevation const height = topElevation - effectiveBaseElevation + // A slab at or above the storey plane leaves a plane-bound wall with no + // body — bail before ExtrudeGeometry sees a non-positive depth. + if (height <= 1e-9) { + return new THREE.BufferGeometry() + } const thickness = getWallThickness(wallNode) @@ -913,7 +934,7 @@ export function generateExtrudedWall( geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) geometry.computeVertexNormals() - assignWallMaterialGroups(geometry, wallNode, boundaryEdges) + assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry) // Start with the lowest required wall prism, then remove the volume below @@ -1015,10 +1036,10 @@ export function generateExtrudedWall( if (cutoutBrushes.length === 0) { const splitGeometry = splitGeometryAtHorizontalPlanes( geometry, - getWallBandSplitPlanes(wallNode), + getWallBandSplitPlanes(wallNode, effectiveWallHeight), ) splitGeometry.computeVertexNormals() - assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges) + assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitGeometry) return splitGeometry } @@ -1052,10 +1073,10 @@ export function generateExtrudedWall( const resultGeometry = csgGeometry(resultBrush) const splitResultGeometry = splitGeometryAtHorizontalPlanes( resultGeometry, - getWallBandSplitPlanes(wallNode), + getWallBandSplitPlanes(wallNode, effectiveWallHeight), ) splitResultGeometry.computeVertexNormals() - assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges) + assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitResultGeometry) return splitResultGeometry From 603f5d2242e11521692b4299fc724d2a389c9f6e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 10:00:48 -0400 Subject: [PATCH 02/11] feat(materials): dynamic library registry, picker source tabs, Other category (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(materials): dynamic library registry, picker source tabs, create-material entry point Core gains a runtime material registry (registerLibraryMaterials/ unregisterLibraryMaterials/subscribeLibraryMaterials) so embedders can feed user/community materials; library: refs to registered materials resolve in the viewer unchanged. MaterialCatalogItem carries an optional source (pascal|community|mine|workspace). MaterialPicker gets a source filter row and an optional onCreateMaterialRequest '+ New material' tile, threaded through MaterialPaintPanel. Co-Authored-By: Claude Fable 5 * fix(materials): underline source tabs in picker, matching catalog browse surfaces Co-Authored-By: Claude Fable 5 * feat(materials): add 'other' category for uncategorized library materials Co-Authored-By: Claude Fable 5 * fix(viewer): rebuild node material when a texture slot is cleared Reused cached WebGPU materials keep a compiled TextureNode per slot; nulling the slot for a cold texture load (or a preset without the map) without needsUpdate leaves the node's per-frame material reference pulling null, crashing the render pass in TextureNode.update. Cold loads are the norm for freshly generated library materials, whose maps aren't in the texture cache. Co-Authored-By: Claude Fable 5 * fix(viewer): standard materials always carry a displacement texture three's WebGPU shadow pass copies each object's displacementMap onto one shared per-light shadow material while caching per-mesh shadow node graphs against that shared override. A mesh whose shadow graph was built with a displacement TextureNode (painted with a generated material — the only presets carrying height maps) crashes the render pass with "null (reading 'matrix')" the moment a material without a displacement texture is swapped onto it, which is exactly what the paint hover preview does. Give every MeshStandardNodeMaterial a shared 1x1 black displacement texel (zero offset) when it has no real height map, so the copied slot is never null in either direction. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- packages/core/src/index.ts | 6 ++ packages/core/src/material-library.ts | 60 +++++++++++- .../ui/controls/material-paint-panel.tsx | 8 +- .../ui/controls/material-picker.tsx | 93 +++++++++++++++++-- packages/editor/src/index.tsx | 11 ++- packages/viewer/src/lib/materials.ts | 82 +++++++++++++--- 6 files changed, 234 insertions(+), 26 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d48f43a9..1b8b3a47 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,7 +157,9 @@ export { } from './lib/zone-quantities' export { getCatalogMaterialById, + getDynamicLibraryMaterials, getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, getMaterialPresetByRef, getMaterialsForCategory, getSceneMaterialIdFromRef, @@ -168,12 +170,16 @@ export { type MaterialCatalogItem, type MaterialCategory, type MaterialRef, + type MaterialSource, type MaterialSurface, type ParsedMaterialRef, parseMaterialRef, + registerLibraryMaterials, SCENE_MATERIAL_REF_PREFIX, + subscribeLibraryMaterials, toLibraryMaterialRef, toSceneMaterialRef, + unregisterLibraryMaterials, } from './material-library' export type { FloorPlacedFootprint, diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index e45a4887..7bf2b786 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -4,10 +4,14 @@ import { MaterialTarget as MaterialTargetSchema, } from './schema/material' +export type MaterialSource = 'pascal' | 'community' | 'mine' | 'workspace' + export type MaterialCatalogItem = { id: string label: string category: MaterialCategory + /** Origin of the entry. Absent = 'pascal' (all static catalog entries). */ + source?: MaterialSource /** * Where this finish is appropriate. Absent = universal (e.g. flat colors). * The paint picker may filter by the slot being painted; v1 shows everything. @@ -69,6 +73,7 @@ export const MATERIAL_CATEGORIES = [ 'roofing', 'ground', 'glass', + 'other', ] as const export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] @@ -4149,13 +4154,64 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, ] +const STATIC_CATALOG_IDS = new Set(MATERIAL_CATALOG.map((item) => item.id)) + +// Embedder-registered library materials (user/community/workspace). Core stays +// passive: hosts push entries in; nothing here fetches. Static catalog entries +// win on id collision so a registration can never shadow a built-in. +const dynamicLibraryMaterials = new Map() +const dynamicLibraryListeners = new Set<() => void>() +let dynamicLibraryVersion = 0 + +function notifyDynamicLibraryChange(): void { + dynamicLibraryVersion += 1 + for (const listener of [...dynamicLibraryListeners]) { + listener() + } +} + +export function registerLibraryMaterials(items: MaterialCatalogItem[]): void { + if (items.length === 0) return + for (const item of items) { + dynamicLibraryMaterials.set(item.id, item) + } + notifyDynamicLibraryChange() +} + +export function unregisterLibraryMaterials(ids: string[]): void { + let changed = false + for (const id of ids) { + changed = dynamicLibraryMaterials.delete(id) || changed + } + if (changed) notifyDynamicLibraryChange() +} + +export function getDynamicLibraryMaterials(): MaterialCatalogItem[] { + return [...dynamicLibraryMaterials.values()] +} + +export function subscribeLibraryMaterials(listener: () => void): () => void { + dynamicLibraryListeners.add(listener) + return () => { + dynamicLibraryListeners.delete(listener) + } +} + +export function getLibraryMaterialsVersion(): number { + return dynamicLibraryVersion +} + export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { - return MATERIAL_CATALOG.filter((item) => item.category === category) + const items = MATERIAL_CATALOG.filter((item) => item.category === category) + for (const item of dynamicLibraryMaterials.values()) { + if (item.category === category && !STATIC_CATALOG_IDS.has(item.id)) items.push(item) + } + return items } export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { if (!id) return undefined - return MATERIAL_CATALOG.find((item) => item.id === id) + return MATERIAL_CATALOG.find((item) => item.id === id) ?? dynamicLibraryMaterials.get(id) } export const LIBRARY_MATERIAL_REF_PREFIX = 'library:' diff --git a/packages/editor/src/components/ui/controls/material-paint-panel.tsx b/packages/editor/src/components/ui/controls/material-paint-panel.tsx index 4abae9b6..922a69a8 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -27,7 +27,12 @@ import { SceneMaterialList } from './scene-material-list' * fixed control/category header, a single scrolling catalog grid, and a fixed * scene-material footer (always visible, with a `+` to add a custom material). */ -export function MaterialPaintPanel() { +export type MaterialPaintPanelProps = { + /** When provided, the catalog grid leads with a "New material" tile that invokes it. */ + onCreateMaterialRequest?: () => void +} + +export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) { const activePaintMaterial = useEditor((state) => state.activePaintMaterial) const activePaintTarget = useEditor((state) => state.activePaintTarget) const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) @@ -109,6 +114,7 @@ export function MaterialPaintPanel() { {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
{ setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) }} diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index e0284a1a..328a63d2 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -2,44 +2,82 @@ import { getCatalogMaterialById, + getDynamicLibraryMaterials, getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, getMaterialsForCategory, MATERIAL_CATEGORIES, + type MaterialCatalogItem, + type MaterialSource, type MaterialTarget, + subscribeLibraryMaterials, toLibraryMaterialRef, } from '@pascal-app/core' -import { useEffect, useState } from 'react' +import { Plus } from 'lucide-react' +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { triggerSFX } from '../../../lib/sfx-bus' -type MaterialPickerProps = { +export type MaterialSourceFilter = 'all' | MaterialSource + +export type MaterialPickerProps = { selectedMaterialPreset?: string onSelectMaterialPreset?: (materialPreset: string) => void disabled?: boolean nodeType?: MaterialTarget hideSideControl?: boolean + onCreateMaterialRequest?: () => void } +const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'pascal', label: 'Pascal' }, + { id: 'mine', label: 'Mine' }, + { id: 'workspace', label: 'Workspace' }, + { id: 'community', label: 'Community' }, +] + function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) { return category.charAt(0).toUpperCase() + category.slice(1) } +function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) { + if (filter === 'all') return items + return items.filter((item) => (item.source ?? 'pascal') === filter) +} + /** - * Catalog material picker: a fixed row of category tabs over a scrollable grid - * of swatches. Custom-material creation lives in the scene-material section - * (the host's `+` action), not here, so it's available from any category. + * Catalog material picker: a fixed row of category tabs and a source filter row + * over a scrollable grid of swatches. Scene-material creation lives in the + * scene-material section (the host's `+` action); `onCreateMaterialRequest` is + * the host's entry point for authoring a new *library* material. */ export function MaterialPicker({ selectedMaterialPreset, onSelectMaterialPreset, disabled = false, + onCreateMaterialRequest, }: MaterialPickerProps) { const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( MATERIAL_CATEGORIES[0], ) + const [sourceFilter, setSourceFilter] = useState('all') + // Version counter so host registrations/unregistrations re-render the picker. + const libraryVersion = useSyncExternalStore( + subscribeLibraryMaterials, + getLibraryMaterialsVersion, + getLibraryMaterialsVersion, + ) + const hasWorkspaceMaterials = useMemo( + () => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'), + [libraryVersion], + ) + const visibleSourceFilters = SOURCE_FILTERS.filter( + (filter) => filter.id !== 'workspace' || hasWorkspaceMaterials, + ) const availableCategories = MATERIAL_CATEGORIES.filter( (category) => getMaterialsForCategory(category).length > 0, ) - const catalogItems = getMaterialsForCategory(selectedCategory) + const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter) // Keep the visible category in sync with the externally-selected catalog // material (a `scene:` ref matches no catalog entry, so the tab stays put). @@ -72,7 +110,7 @@ export function MaterialPicker({ setSelectedCategory(category) // Auto-select the first material in the category so the brush is // immediately ready (and the swatch shows as selected). - const first = getMaterialsForCategory(category)[0] + const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0] if (first) handleCatalogSelect(first.id) }} type="button" @@ -81,11 +119,52 @@ export function MaterialPicker({ ))}
+ {/* Fixed source filter tabs — underline style, matching the catalog + browse surfaces (Items / Rooms / Build / Search) rather than the + pill-button category row above. */} +
+ {visibleSourceFilters.map((filter) => ( + + ))} +
{/* The only scrolling region. */}
+ {onCreateMaterialRequest ? ( + + ) : null} {catalogItems.map((item) => { const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id) return ( diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index ba2dfedb..25544bfb 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -207,8 +207,15 @@ export { } from './components/ui/action-menu/view-toggles' export { useCommandPalette } from './components/ui/command-palette' export { ActionButton, ActionGroup } from './components/ui/controls/action-button' -export { MaterialPaintPanel } from './components/ui/controls/material-paint-panel' -export { MaterialPicker } from './components/ui/controls/material-picker' +export { + MaterialPaintPanel, + type MaterialPaintPanelProps, +} from './components/ui/controls/material-paint-panel' +export { + MaterialPicker, + type MaterialPickerProps, + type MaterialSourceFilter, +} from './components/ui/controls/material-picker' export { MetricControl } from './components/ui/controls/metric-control' export { PanelSection } from './components/ui/controls/panel-section' export { SegmentedControl } from './components/ui/controls/segmented-control' diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 504e3034..aa109649 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -286,6 +286,40 @@ function getPresetTexture( return texture } +// three's WebGPU shadow pass copies each object's base-material +// `displacementMap` onto one shared per-light shadow material, while the +// per-mesh shadow node graph is cached against that shared override. A mesh +// whose graph was built with a displacement TextureNode crashes the render +// pass ("Cannot read properties of null (reading 'matrix')") as soon as a +// material *without* a displacement texture is swapped onto it — the paint +// hover preview does exactly that. Standard node materials therefore always +// carry a displacement texture: a shared 1×1 black texel (zero offset, so +// shading is unchanged) whenever there is no real height map. +let neutralDisplacement: THREE.Texture | null = null + +function getNeutralDisplacementTexture(): THREE.Texture { + if (!neutralDisplacement) { + neutralDisplacement = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1) + neutralDisplacement.needsUpdate = true + } + return neutralDisplacement +} + +function ensureDisplacementFallback(material: T): T { + const textureMaterial = material as THREE.Material as TextureMaterial + if (material instanceof MeshStandardNodeMaterial && !textureMaterial.displacementMap) { + textureMaterial.displacementMap = getNeutralDisplacementTexture() + } + return material +} + +/** The value a cleared slot falls back to (never null for displacement). */ +function clearedSlotValue(material: CommonMaterial, slot: TextureSlot): THREE.Texture | null { + return slot === 'displacementMap' && material instanceof MeshStandardNodeMaterial + ? getNeutralDisplacementTexture() + : null +} + function createAssignedTexture( source: THREE.Texture, props: MaterialMapProperties, @@ -360,7 +394,14 @@ function queueTextureAssignment( const textureMaterial = material as TextureMaterial if (!path) { - textureMaterial[slot] = null + const cleared = clearedSlotValue(material, slot) + if (textureMaterial[slot] !== cleared) { + // Rebuild the node graph: a cached WebGPU material keeps a TextureNode + // for the slot, whose per-frame material reference would pull the null + // and crash in TextureNode.update ("null (reading 'matrix')"). + textureMaterial[slot] = cleared + material.needsUpdate = true + } return } @@ -379,7 +420,15 @@ function queueTextureAssignment( return } - textureMaterial[slot] = null + // Cold load: clear the slot for the fetch window, and rebuild the node + // graph if it previously held a texture — reused cached materials otherwise + // keep a TextureNode whose reference pulls the null and crashes the render + // pass. Cold loads are the norm for freshly generated library materials. + const placeholder = clearedSlotValue(material, slot) + if (textureMaterial[slot] !== placeholder) { + textureMaterial[slot] = placeholder + material.needsUpdate = true + } loadPresetTexture(path, props, slot).then((texture) => { if (!texture) return @@ -496,8 +545,9 @@ export function createMaterialFromPreset( return materialCache.get(cacheKey)! } - const material = - shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() + const material = ensureDisplacementFallback( + shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial(), + ) applyMaterialPresetToMaterials(material, preset) maybeApplyGlassFresnel(material) material.userData.__pascalCachedMaterial = true @@ -541,14 +591,15 @@ export function createMaterial( if (map) materialParams.map = map - const threeMaterial = + const threeMaterial = ensureDisplacementFallback( shading === 'solid' ? new MeshLambertNodeMaterial(materialParams) : new MeshStandardNodeMaterial({ ...materialParams, roughness: props.roughness, metalness: props.metalness, - }) + }), + ) maybeApplyGlassFresnel(threeMaterial) threeMaterial.userData.__pascalCachedMaterial = true @@ -608,12 +659,14 @@ export function createDefaultMaterial( }) } - return new MeshStandardNodeMaterial({ - color, - roughness, - metalness: 0, - side: resolvedSide, - }) + return ensureDisplacementFallback( + new MeshStandardNodeMaterial({ + color, + roughness, + metalness: 0, + side: resolvedSide, + }), + ) } function cachedDefaultMaterial( @@ -704,14 +757,15 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH transparent: true, side: THREE.FrontSide, } - const material = + const material = ensureDisplacementFallback( shading === 'solid' ? new MeshLambertNodeMaterial(params) : new MeshStandardNodeMaterial({ ...params, roughness: 0.1, metalness: 0.1, - }) + }), + ) maybeApplyGlassFresnel(material) defaultMaterialCache.set(cacheKey, material) return material From adc1ec89f439d5464d114eddd62f5912e6a40892 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 11:34:07 -0400 Subject: [PATCH 03/11] fix(viewer): guard TextureNode updates against null textures (#527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(viewer): guard TextureNode updates against null textures three's override-material passes (shadow, prepasses) copy per-object texture slots onto shared materials whose cached per-mesh node graphs can disagree about a slot's presence; when a graph with a TextureNode pulls a null slot, the exception kills the whole render pass and the scene goes black. Patch TextureNode.prototype.update to skip null values (with a throttled warning) so the slot just renders textureless for a frame and recovers. Co-Authored-By: Claude Fable 5 * revert(viewer): drop the always-on neutral displacement texture Giving every standard material a displacement texture put displacement TextureNodes into the shared shadow/prepass override graphs; in scenes mixing preset materials with GLB item materials (no displacementMap), an item's shadow render can hit a cached graph that expects the texture and pull null — build-order dependent, which is why it broke many-but-not-all projects at open right after the release. The TextureNode fallback guard covers the original paint-hover crash (black texel = zero displacement), so the broad neutral-texture approach is retired. Co-Authored-By: Claude Fable 5 * style: sort guard imports Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../viewer/src/components/viewer/index.tsx | 5 ++ packages/viewer/src/lib/materials.ts | 73 ++++--------------- packages/viewer/src/lib/texture-node-guard.ts | 44 +++++++++++ 3 files changed, 65 insertions(+), 57 deletions(-) create mode 100644 packages/viewer/src/lib/texture-node-guard.ts diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index c1754da6..2de9cade 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -23,6 +23,7 @@ import { applyIsolation, clearIsolation } from '../../lib/isolation' import { ensureKtx2Support } from '../../lib/ktx2-loader' import type { ColorPreset, RenderShading } from '../../lib/materials' import { getSceneTheme } from '../../lib/scene-themes' +import { installTextureNodeNullGuard } from '../../lib/texture-node-guard' import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { GeometrySystem } from '../../systems/geometry/geometry-system' @@ -37,6 +38,10 @@ import { SceneBvh } from './scene-bvh' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' +// Must be in place before any node material builds — a null texture pulled by +// a shared override-material pass otherwise kills the render pass outright. +installTextureNodeNullGuard() + declare module '@react-three/fiber' { // The TS 7 native compiler (tsgo) rejects mapping the entire `three/webgpu` // namespace into JSX — `ThreeToJSXElements` triggers a TS2320 diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index aa109649..5504153d 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -286,40 +286,6 @@ function getPresetTexture( return texture } -// three's WebGPU shadow pass copies each object's base-material -// `displacementMap` onto one shared per-light shadow material, while the -// per-mesh shadow node graph is cached against that shared override. A mesh -// whose graph was built with a displacement TextureNode crashes the render -// pass ("Cannot read properties of null (reading 'matrix')") as soon as a -// material *without* a displacement texture is swapped onto it — the paint -// hover preview does exactly that. Standard node materials therefore always -// carry a displacement texture: a shared 1×1 black texel (zero offset, so -// shading is unchanged) whenever there is no real height map. -let neutralDisplacement: THREE.Texture | null = null - -function getNeutralDisplacementTexture(): THREE.Texture { - if (!neutralDisplacement) { - neutralDisplacement = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1) - neutralDisplacement.needsUpdate = true - } - return neutralDisplacement -} - -function ensureDisplacementFallback(material: T): T { - const textureMaterial = material as THREE.Material as TextureMaterial - if (material instanceof MeshStandardNodeMaterial && !textureMaterial.displacementMap) { - textureMaterial.displacementMap = getNeutralDisplacementTexture() - } - return material -} - -/** The value a cleared slot falls back to (never null for displacement). */ -function clearedSlotValue(material: CommonMaterial, slot: TextureSlot): THREE.Texture | null { - return slot === 'displacementMap' && material instanceof MeshStandardNodeMaterial - ? getNeutralDisplacementTexture() - : null -} - function createAssignedTexture( source: THREE.Texture, props: MaterialMapProperties, @@ -394,12 +360,11 @@ function queueTextureAssignment( const textureMaterial = material as TextureMaterial if (!path) { - const cleared = clearedSlotValue(material, slot) - if (textureMaterial[slot] !== cleared) { + if (textureMaterial[slot] != null) { // Rebuild the node graph: a cached WebGPU material keeps a TextureNode // for the slot, whose per-frame material reference would pull the null // and crash in TextureNode.update ("null (reading 'matrix')"). - textureMaterial[slot] = cleared + textureMaterial[slot] = null material.needsUpdate = true } return @@ -424,9 +389,8 @@ function queueTextureAssignment( // graph if it previously held a texture — reused cached materials otherwise // keep a TextureNode whose reference pulls the null and crashes the render // pass. Cold loads are the norm for freshly generated library materials. - const placeholder = clearedSlotValue(material, slot) - if (textureMaterial[slot] !== placeholder) { - textureMaterial[slot] = placeholder + if (textureMaterial[slot] != null) { + textureMaterial[slot] = null material.needsUpdate = true } @@ -545,9 +509,8 @@ export function createMaterialFromPreset( return materialCache.get(cacheKey)! } - const material = ensureDisplacementFallback( - shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial(), - ) + const material = + shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() applyMaterialPresetToMaterials(material, preset) maybeApplyGlassFresnel(material) material.userData.__pascalCachedMaterial = true @@ -591,15 +554,14 @@ export function createMaterial( if (map) materialParams.map = map - const threeMaterial = ensureDisplacementFallback( + const threeMaterial = shading === 'solid' ? new MeshLambertNodeMaterial(materialParams) : new MeshStandardNodeMaterial({ ...materialParams, roughness: props.roughness, metalness: props.metalness, - }), - ) + }) maybeApplyGlassFresnel(threeMaterial) threeMaterial.userData.__pascalCachedMaterial = true @@ -659,14 +621,12 @@ export function createDefaultMaterial( }) } - return ensureDisplacementFallback( - new MeshStandardNodeMaterial({ - color, - roughness, - metalness: 0, - side: resolvedSide, - }), - ) + return new MeshStandardNodeMaterial({ + color, + roughness, + metalness: 0, + side: resolvedSide, + }) } function cachedDefaultMaterial( @@ -757,15 +717,14 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH transparent: true, side: THREE.FrontSide, } - const material = ensureDisplacementFallback( + const material = shading === 'solid' ? new MeshLambertNodeMaterial(params) : new MeshStandardNodeMaterial({ ...params, roughness: 0.1, metalness: 0.1, - }), - ) + }) maybeApplyGlassFresnel(material) defaultMaterialCache.set(cacheKey, material) return material diff --git a/packages/viewer/src/lib/texture-node-guard.ts b/packages/viewer/src/lib/texture-node-guard.ts new file mode 100644 index 00000000..d607674a --- /dev/null +++ b/packages/viewer/src/lib/texture-node-guard.ts @@ -0,0 +1,44 @@ +import type * as THREE from 'three/webgpu' +import { DataTexture, TextureNode } from 'three/webgpu' + +let installed = false +let reported = 0 +let fallbackTexture: THREE.Texture | null = null + +function getFallbackTexture(): THREE.Texture { + if (!fallbackTexture) { + fallbackTexture = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1) + fallbackTexture.needsUpdate = true + } + return fallbackTexture +} + +/** + * three's node system pulls texture uniforms from materials each frame via + * reference nodes, and several override-material passes (shadow, depth/normal + * prepasses) copy per-object texture slots onto shared materials whose cached + * per-mesh node graphs can disagree about a slot's presence. When they do, + * `TextureNode.update` dereferences a null texture and the exception kills the + * whole render pass — the scene goes black. Substitute a 1×1 black fallback + * instead (skipping is not enough: the null would still reach the backend's + * texture-binding WeakMap). The slot renders black for a frame and recovers + * as soon as the reference pulls a real value again. + */ +export function installTextureNodeNullGuard(): void { + if (installed) return + installed = true + + const prototype = TextureNode.prototype as { update: () => void } + const originalUpdate = prototype.update + + prototype.update = function update(this: { value: unknown; uuid: string }) { + if (this.value == null) { + if (reported < 5) { + reported += 1 + console.warn(`[viewer] TextureNode ${this.uuid} has no texture — using fallback`) + } + this.value = getFallbackTexture() + } + originalUpdate.call(this) + } +} From de13119f86f857e23d4c1c2b76c73bf620089b9a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 14:44:03 -0400 Subject: [PATCH 04/11] docs(wiki): vertical-model architecture page; drop stale SlabSystem rows (#528) Documents the shipped vertical building model (#526): stored truth table, resolution helpers, clamp rules, pointer-decided placement, the load migration that lives in migrateNodes indefinitely, and the gotchas. The systems tables stop listing the deleted SlabSystem and point at the registry geometry path instead. Co-authored-by: Claude Fable 5 --- apps/editor/README.md | 1 - wiki/architecture/README.md | 1 + wiki/architecture/systems.md | 3 +- wiki/architecture/vertical-model.md | 81 +++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 wiki/architecture/vertical-model.md diff --git a/apps/editor/README.md b/apps/editor/README.md index ea6e6066..1329ad66 100644 --- a/apps/editor/README.md +++ b/apps/editor/README.md @@ -183,7 +183,6 @@ Systems are React components that run in the render loop (`useFrame`) to update | System | Responsibility | |--------|---------------| | `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | -| `SlabSystem` | Generates floor geometry from polygons | | `CeilingSystem` | Generates ceiling geometry | | `RoofSystem` | Generates roof geometry | | `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) | diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 8680b786..4b9bb1ce 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -21,6 +21,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | | [spatial-queries](spatial-queries.md) | Placement validation (`canPlaceOnFloor`/`Wall`/`Ceiling`) for tools | | [node-schemas](node-schemas.md) | Zod schema pattern for node types, `createNode`, `updateNode` | +| [vertical-model](vertical-model.md) | Stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, clamp rules, and the load migration | | [events](events.md) | Typed event bus — emitting and listening to node and grid events | | [creating-rules](creating-rules.md) | How to add or update a page in this folder | diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index c90500bb..87ae82c8 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -17,13 +17,14 @@ Pure logic: no rendering, no Three.js objects. They read nodes from `useScene`, | System | Responsibility | |---|---| | `WallSystem` | Wall mitering, corner joints | -| `SlabSystem` | Polygon-based floor/roof generation | | `CeilingSystem` | Polygon-based ceiling generation | | `RoofSystem` | Pitched roof shape | | `DoorSystem` | Placement constraints on walls | | `WindowSystem` | Placement constraints on walls | | `ItemSystem` | Item transforms, collision | +Slab geometry has no dedicated system: it renders through the registry `def.geometry` (`packages/nodes/src/slab/geometry.ts`, calling the pure generators in `packages/viewer/src/systems/slab/slab-system.tsx`) with a small `def.system` for dirty tracking. + ### Viewer Systems — `packages/viewer/src/systems/` Access Three.js objects (via `useRegistry`) and manage rendering side-effects. diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md new file mode 100644 index 00000000..757aa776 --- /dev/null +++ b/wiki/architecture/vertical-model.md @@ -0,0 +1,81 @@ +# Vertical Model + +*How buildings stack: stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, and the clamp rules that keep it all coherent.* + +Applies to: anything that reads or writes vertical geometry — levels, walls, slabs, ceilings, stairs, fences, floor-placed items. + +The invariant, in one sentence: + +> Wall tops are pinned to the level plane; floors and platforms move what stands on +> them, never the walls or the storey above; anything that doesn't fit is clamped, +> never asked. + +**Sources**: `packages/core/src/services/storey.ts`, `packages/core/src/systems/wall/wall-top.ts`, `packages/core/src/systems/slab/slab-support.ts`, `packages/core/src/systems/stair/stair-rise.ts`, `packages/core/src/store/use-scene.ts` (migration Pass 3) + +## Stored truth + +| Field | Meaning | Absent means | +|---|---|---| +| `level.height` | Storey height in meters, floor-to-floor. Level world Y = per-building prefix sum of stored heights, ordered by the `level` ordinal (`getLevelElevations`). | Unmigrated legacy data (never seen post-load; the migration writes it). Consumers fall back to `DEFAULT_LEVEL_HEIGHT` (2.5). | +| `wall.height` | Explicit custom height (half wall, parapet). Top = elected base + height. | **Plane-bound** (the default): the top follows `getWallPlaneTop` — `min(level height, lowest covering-slab underside over the span)`. Slabs lift only the base. | +| `ceiling.height` | Explicit custom height, write-clamped to the bound. | **Follows the level**: resolves live to `getCeilingClampBound` = `min(level height, covering underside) − 0.01`. | +| `slab.elevation` | The walking surface (top), level-local. | Default 0.05. | +| `slab.thickness` | Grows **downward**: the solid occupies `[elevation − thickness, elevation]`. | Default 0.05. | +| `slab.recessed` | Pool intent: open shell, floor at (negative) `elevation`, inner walls up to the plane. Excluded from "covering" queries and wall-face adoption. | Solid slab. | +| `supportSlabId` | Persisted support host on walls and all floor-placed kinds. Written at commit **only when overlapping supports disagree on elevation**; `'ground'` sentinel pins bare ground under a deck. | Support is elected per query (coverage election for walls, footprint max for items). | +| `stair.deckSlabId` | Destination deck: rise follows `deck.elevation − the stair's own elected base` live; cutout sync disabled while attached. | Destination is a level. | +| `stair.totalRise` | Explicit custom rise (wins over everything). | Follows: derived from the deck or the containing level; `syncStairRises` converges straight-stair segments to the resolved rise. | + +Two schema rules protect these semantics: + +- **No Zod defaults on meaning-bearing fields.** `level.height`, `wall.height`, `ceiling.height`, `stair.totalRise` are `.optional()` with no `.default()` — absence is data. Creation sites write values explicitly; `migrateNodes` output is cast, not parsed, so a schema default would never materialize on legacy load anyway. +- **The store deletes explicit-`undefined` keys.** `updateNode(id, { height: undefined })` removes the key (see `mergeNodeUpdate` in `node-actions.ts`); that is how "Follows level/deck" mode switches work. UI mode controls derive state from field presence — no persisted mode enums. + +## Resolution helpers (use these, never `?? 2.5`) + +| Helper | Home | Resolves | +|---|---|---| +| `getStoredLevelHeight`, `getLevelElevations`, `getLevelAbove/Below` | `services/storey.ts` | Level heights, per-building stacking, neighbors | +| `getWallPlaneTop` | `services/storey.ts` | A plane-bound wall's top: level height clamped to covering-slab undersides, span-sampled with boundary-inclusive band overlap | +| `resolveWallTop`, `resolveWallEffectiveHeight`, `MIN_WALL_HEIGHT` | `systems/wall/wall-top.ts` | A wall's top / effective height given plane + elected base | +| `getWallEffectiveHeightForNodes` | spatial-grid manager | The above with the real slab election, for UI overlays | +| `getCeilingClampBound`, `getCoveringSlabUndersideAt` | `services/storey.ts` | Ceiling bound; the cross-level covering query (level above, non-recessed slabs) | +| `resolveCeilingHeight` | `services/level-height.ts` | A ceiling's effective height (explicit or follows) | +| `resolveStairTotalRise`, `syncStairRises` | `systems/stair/stair-rise.ts` | Stair rise precedence + straight-flight convergence | +| `computeWallSlabSupport`, `getSlabSupportForItem`, `getSupportCandidatesForFootprint` | `systems/slab/slab-support.ts` + spatial-grid manager | Support election (rendered polygons, host-preferring, optional `maxElevation` cap) | +| `clampSlabElevationForWalls`, `applySlabTopChange`, `SLAB_UNSTICK_THRESHOLD` | slab-support + `nodes/slab/elevation-limit.ts` | Slab edit clamps and the adaptive drag/panel rules | + +## Clamp rules (clamp, never ask) + +- A slab under plane-bound walls clamps its elevation to `level height − MIN_WALL_HEIGHT` (0.5). +- Ceilings clamp (at write time, and reactively downward via space-detection) to `min(level top, covering-slab underside) − 0.01`. +- Plane-bound wall tops clamp to covering-slab undersides — a thick or flush upper-level slab shortens the walls below it (Revit's attach-to-floor-bottom, automatic). Explicit-height walls are exempt. +- Slab vertical editing is adaptive: the panel moves placement (thickness untouched); the viewport drag stretches a grounded slab (elevation and thickness together) up to `SLAB_UNSTICK_THRESHOLD` (0.4), then unsticks it into a 0.05-thick deck; floating decks move with thickness preserved and re-ground at underside 0; pools keep the drag-through-zero gesture. +- Wall-face adoption in `getRenderableSlabPolygon` applies only to grounded slabs (`elevation − thickness ≤ 0.01`, not recessed) — floating decks keep their drawn polygon and are skipped as seam candidates. + +## Pointer-decided placement + +Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest slab plane the ray crosses inside its rendered polygon plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck; commits persist the capped winner (or `'ground'`). 2D floorplan placement has no camera ray and keeps max-election. + +## Load migration (lives in `migrateNodes` Pass 3, indefinitely) + +Because community autosave only persists after the first post-load edit, the migration must remain in `migrateNodes`: + +- Writes each legacy level's **exact** derived height (a default legacy storey stores 2.55 = 0.05 slab + 2.5 wall) — never snapped to presets. +- Compacts `level` ordinals per building, anchored at zero (non-negatives → 0,1,2…; negatives → −1,−2… — basements stay basements). Runs every load; idempotent. +- Classifies wall tops against the derived plane: `|plane − top| < 0.20` **strict** → plane-bound (height key removed); else explicit (materializing 2.5 on absent-height short walls). ε calibrated by a prod census: intentional 0.20-short walls exist and must not snap. +- Ceilings within ε of the bound (and all `autoFromWalls` ceilings) drop their height → follows mode; stairs drop the legacy blind `totalRise: 2.5`. Both gated on the scene being legacy (some level lacked `height`). +- Slabs get `thickness := elevation` (byte-identical occupied interval, including degenerate zero); negative-elevation pools become `recessed: true` with elevation unchanged. + +## Gotchas + +- **Ordinals are semantic.** `level < 0` renders "Basement N"; `level === 0` is the ground-floor lookup. Never renumber without the zero anchor. +- **Boundary geometry.** Auto slabs derive polygons from wall centerlines, so wall/ceiling clamp samples sit exactly on polygon edges — always use the boundary-inclusive band-overlap helpers (`wallOverlapsSlabFootprint`, `slabCoversPoint`), never raw ray-cast point-in-polygon on those paths. +- **Straight stairs build from stored segment heights**, not the resolved rise — any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled). +- **Reactivity is explicit.** A `level.height` change dirties that level's walls/stairs/ceilings/fences; a slab change dirties the level below's walls/ceilings and deck-attached stairs (`spatial-grid-sync.ts`). If a new consumer reads these bounds, wire its dirty rule there. +- **Host lifecycle.** Deleting a slab strips `supportSlabId`/`deckSlabId` from survivors in the same undo commit; a host merely reshaped away falls back silently and resumes if the slab returns. +- **Clone paths differ.** `clone-scene-graph.ts` remaps `supportSlabId`/`deckSlabId`; the editor clipboard (`scene-clipboard.ts`) intentionally does not (it re-elects); room placement remaps them (fixed in the private repo's `room-placement.ts`). When adding a new clone/instantiation path, remap both fields. + +## Deferred by decision (see the private repo's plan archive) + +Persistent Room identity, partial-storey navigation, slab reference-face enums, suspended ceilings, and a site datum for sloped terrain all have named gates in `plans/` — none block this model. Decks ship as catalog rooms/presets; the one-gesture mezzanine/balcony tools were removed (code preserved at editor `e30042db`). From cb6fadbc288f9daa627e866f31eb86a39b2d93e5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 17:04:00 -0400 Subject: [PATCH 05/11] editor: unify walkthrough + viewer UI, crouch, screenshot pause (#529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(walkthrough): unify first-person and baked walkthrough UI into a shared HUD The builder first-person overlay (crosshair, Exit Street View button, hints card) and the baked-GLB walkthrough HUD were two divergent UIs. Extract the GLB-style HUD (reticle, floor/room labels, Esc pill, interact prompt) into a shared WalkthroughHud in packages/editor, feed it from FirstPersonControls via a small useFirstPersonHud store (interact target each frame, floor/zone labels sampled from the camera), and align FOV/projection handling with the baked controller. The now-unused WalkthroughControls glide controller is removed from packages/viewer (WALKTHROUGH_FOV moves to the GLB controller module). Co-Authored-By: Claude Fable 5 * feat(viewer-ui): shared ViewerControlsBar + ViewerSceneHeader for preview and embedders Preview mode's ViewerOverlay was an older copy of the community viewer UI (separate scan/guide/camera buttons, own render/theme/edges menus, 4-state wall mode). Extract the community design into shared prop-driven components: ViewerControlsBar (visibility, level/wall modes, display menu, walkthrough, orbit/top view) and ViewerSceneHeader (back, project info, optional stats slot, breadcrumb, levels card). ViewerOverlay is now a thin composition of them. The display menu gains the edges submenu everywhere; the vestigial translucent wall mode is dropped (a stale value renders as cutaway). Co-Authored-By: Claude Fable 5 * feat(walkthrough): hold-Ctrl crouch + P screenshot pause in both controllers Crouch swaps the capsule for a short one (shrinks around the centre, so a mid-jump crouch lowers the head and raises the feet — enough to thread window openings), lowers the eye with a short lerp, and slows movement; standing back up is gated on headroom via an upward raycast against the collider world. Tuning constants live in the GLB controller module and are shared with the editor first-person controller. P releases the pointer lock without leaving the walkthrough so the cursor is free for an OS screenshot (macOS region capture needs a movable pointer); clicking the canvas re-locks and resumes. Co-Authored-By: Claude Fable 5 * fix(walkthrough): crouched profile fits ~1 m openings The float gap counts toward the effective obstacle height — the capsule rides floatHeight (0.5 m) above the ground, so the old crouch spanned 0.5–1.3 m and a 1.14 m opening still blocked it. Crouching now also lowers the float gap (0.25 m) and uses a shorter capsule (0.7 m), for an effective 0.25–0.95 m span; the stand-up headroom check grows to match. Co-Authored-By: Claude Fable 5 * feat(walkthrough): seamless screenshot pause — auto-release pointer lock on ⌘ Replace the P shortcut: macOS swallows the full ⇧⌘4 but the ⌘-down keystroke still reaches the page, so the moment ⌘ (or PrintScreen) goes down while locked the cursor is released without leaving the walkthrough — the native screenshot flow just works, no user education. The HUD pill flips to "Click to resume" (click-through, so the resuming click lands on the canvas) via a new walkthroughSuspended flag on the viewer store, reset on lock/exit/unmount. Co-Authored-By: Claude Fable 5 * fix(walkthrough): screenshot pause is P again, advertised in the HUD The ⌘ auto-release fired on every command combo and felt broken. Back to an explicit P toggle, now discoverable: the HUD bottom shows a "P free cursor" pill next to "Esc to exit", and while paused it flips to "Click or P to resume · Esc to exit" (click-through so the resuming click lands on the canvas). P re-locks as well as releasing. Co-Authored-By: Claude Fable 5 * fix(walkthrough): freeze crouch during cursor pause; no editor hints in first person While the P pause is active, Ctrl no longer toggles crouch — ⌃⇧⌘4 (clipboard screenshot) was crouching the player mid-capture; the held state stays frozen until resume. HelperManager now renders nothing in first-person mode, so the Ctrl multi-select hint no longer pops over the walkthrough HUD (Ctrl is crouch there). Co-Authored-By: Claude Fable 5 * chore: biome formatting + pre-existing useOptionalChain fix in wall panel The wall-panel lint error predates this branch (#526); fixed here to unblock the quality gate. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../editor/first-person-controls.tsx | 377 +++++++--- .../components/ui/helpers/helper-manager.tsx | 5 + .../editor/src/components/viewer-overlay.tsx | 669 +----------------- .../components/viewer/viewer-controls-bar.tsx | 466 ++++++++++++ .../components/viewer/viewer-scene-header.tsx | 235 ++++++ .../editor/src/components/walkthrough-hud.tsx | 111 +++ packages/editor/src/index.tsx | 15 + packages/editor/src/lib/door-interaction.ts | 2 +- packages/editor/src/lib/window-interaction.ts | 2 +- .../editor/src/store/use-first-person-hud.ts | 40 ++ packages/nodes/src/wall/panel.tsx | 2 +- .../src/components/viewer/glb-scene.tsx | 2 +- .../viewer/glb-walkthrough-controller.tsx | 108 ++- .../viewer/walkthrough-controls.tsx | 156 ---- packages/viewer/src/index.ts | 15 +- packages/viewer/src/store/use-viewer.ts | 10 +- 16 files changed, 1284 insertions(+), 931 deletions(-) create mode 100644 packages/editor/src/components/viewer/viewer-controls-bar.tsx create mode 100644 packages/editor/src/components/viewer/viewer-scene-header.tsx create mode 100644 packages/editor/src/components/walkthrough-hud.tsx create mode 100644 packages/editor/src/store/use-first-person-hud.ts delete mode 100644 packages/viewer/src/components/viewer/walkthrough-controls.tsx diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index d57968c2..9f4e43e8 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -15,9 +15,11 @@ import { getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, + getLevelDisplayName, getLevelElevations, getResolvedElevatorDoorStyle, openElevatorDoor, + pointInPolygon2D, requestElevatorLevel, resolveElevatorBuildingLevels, resolveElevatorDispatchTarget, @@ -26,7 +28,22 @@ import { useInteractive, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { + BVHEcctrl, + type BVHEcctrlApi, + CROUCH_CAPSULE, + CROUCH_EYE_OFFSET, + CROUCH_FLOAT_HEIGHT, + CROUCH_RUN_SPEED, + CROUCH_WALK_SPEED, + EYE_LERP_SPEED, + type MovementInput, + STAND_CAPSULE, + STAND_CLEARANCE, + STAND_FLOAT_HEIGHT, + useViewer, + WALKTHROUGH_FOV, +} from '@pascal-app/viewer' import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -39,6 +56,7 @@ import { Mesh, MeshBasicMaterial, type Object3D, + type PerspectiveCamera, Ray, Raycaster, Vector2, @@ -46,19 +64,22 @@ import { } from 'three' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import '../../three-types' -import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer' import { closeDoorOpenState, DOOR_SWING_OPEN_ANGLE, + getDisplayedDoorValue, isOperationDoorType, toggleDoorOpenState, } from '../../lib/door-interaction' import { closeWindowOpenState, + getDisplayedWindowValue, isOperableWindowType, toggleWindowOpenState, } from '../../lib/window-interaction' import useEditor from '../../store/use-editor' +import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud' +import { WalkthroughHud } from '../walkthrough-hud' import { buildFirstPersonColliderWorldFromRegistry, deriveFirstPersonSpawn, @@ -78,6 +99,7 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const VOID_FALL_RESPAWN_DEPTH = 12 +const HUD_LABEL_SAMPLE_FRAMES = 10 type MovementKeyName = Exclude @@ -120,8 +142,10 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) { canvas.focus({ preventScroll: true }) } -const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraOffset = new Vector3() const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const standClearanceRaycaster = new Raycaster() +const standClearanceUp = new Vector3(0, 1, 0) const centerScreenPoint = new Vector2(0, 0) const doorInteractionRaycaster = new Raycaster() const doorLeafBox = new Box3() @@ -146,6 +170,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false }) const spawnWorldPosition = new Vector3() const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const windowInteractionRaycaster = new Raycaster() +const hudBuildingLocalEyePosition = new Vector3() +const hudWorldEyePosition = new Vector3() +const hudLevelBounds = new Box3() type ElevatorColliderKind = | 'cab-back' @@ -202,6 +229,128 @@ type ElevatorButtonTarget = { levelId?: AnyNodeId } +function getLevelChildren( + level: Extract, + nodes: Record, +) { + const childIds = new Set(level.children) + return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id)) +} + +function pointIsInLevelFootprint( + point: [number, number], + worldPoint: Vector3, + level: Extract, + nodes: Record, +) { + const children = getLevelChildren(level, nodes) + const slabs = children.filter( + (node): node is Extract => + node.type === 'slab' && node.polygon.length >= 3, + ) + const zones = children.filter( + (node): node is Extract => + node.type === 'zone' && node.polygon.length >= 3, + ) + + if (slabs.length > 0) { + return slabs.some( + (slab) => + pointInPolygon2D(point, slab.polygon) && + !slab.holes.some((hole) => pointInPolygon2D(point, hole)), + ) + } + + if (zones.length > 0) { + if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true + } + + const levelObject = sceneRegistry.nodes.get(level.id) + if (!levelObject) return false + hudLevelBounds.setFromObject(levelObject) + return ( + !hudLevelBounds.isEmpty() && + worldPoint.x >= hudLevelBounds.min.x && + worldPoint.x <= hudLevelBounds.max.x && + worldPoint.z >= hudLevelBounds.min.z && + worldPoint.z <= hudLevelBounds.max.z + ) +} + +function resolveFirstPersonHudLabels(worldPoint: Vector3) { + const nodes = useScene.getState().nodes + const levelElevations = getLevelElevations(nodes as Record) + + for (const building of Object.values(nodes)) { + if (building.type !== 'building') continue + const buildingObject = sceneRegistry.nodes.get(building.id) + if (!buildingObject) continue + + buildingObject.updateWorldMatrix(true, true) + hudBuildingLocalEyePosition.copy(worldPoint) + buildingObject.worldToLocal(hudBuildingLocalEyePosition) + + const levels = Object.values(nodes) + .filter((node) => node.type === 'level') + .filter((level) => levelElevations.get(level.id)?.buildingId === building.id) + .sort( + (left, right) => + (levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0), + ) + + let activeLevel: (typeof levels)[number] | null = null + for (const level of levels) { + const elevation = levelElevations.get(level.id) + if (!elevation) continue + if ( + hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 && + hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5 + ) { + activeLevel = level + } + } + if (!activeLevel) continue + + const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z] + if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue + + const zone = getLevelChildren(activeLevel, nodes).find( + (node) => + node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon), + ) + + return { + floorLabel: getLevelDisplayName(activeLevel), + zoneLabel: zone?.type === 'zone' ? zone.name : null, + } + } + + return { floorLabel: null, zoneLabel: null } +} + +function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract { + if (!target) return null + if (target.type === 'elevator') { + return { + label: target.action === 'open-door' ? 'door button' : 'elevator button', + verb: 'press', + } + } + + const node = useScene.getState().nodes[target.id] + if (target.type === 'window') { + if (node?.type !== 'window') return null + const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0 + return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' } + } + + if (node?.type !== 'door') return null + const isOpen = isOperationDoorType(node.doorType) + ? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0 + : getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0 + return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' } +} + function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null { let current: Object3D | null = object @@ -553,6 +702,11 @@ export const FirstPersonControls = () => { const yawRef = useRef(0) const pitchRef = useRef(0) const interactableTargetRef = useRef(null) + const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1) + const crouchKeyRef = useRef(false) + const suspendRef = useRef(false) + const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET) + const [crouched, setCrouched] = useState(false) const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false) const ridingElevatorRef = useRef<{ elevatorId: AnyNodeId @@ -569,6 +723,35 @@ export const FirstPersonControls = () => { yaw: number } | null>(null) + useEffect(() => { + const previousCameraMode = useViewer.getState().cameraMode + if (previousCameraMode === 'orthographic') { + useViewer.getState().setCameraMode('perspective') + } + return () => { + if (previousCameraMode === 'orthographic') { + useViewer.getState().setCameraMode('orthographic') + } + } + }, []) + + useEffect(() => { + const perspectiveCamera = camera as PerspectiveCamera + if (!perspectiveCamera.isPerspectiveCamera) return + const previousFov = perspectiveCamera.fov + perspectiveCamera.fov = WALKTHROUGH_FOV + perspectiveCamera.updateProjectionMatrix() + return () => { + perspectiveCamera.fov = previousFov + perspectiveCamera.updateProjectionMatrix() + } + }, [camera]) + + useEffect(() => { + useFirstPersonHud.getState().reset() + return () => useFirstPersonHud.getState().reset() + }, []) + const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => { worldRef.current?.dispose() worldRef.current = nextWorld @@ -979,9 +1162,15 @@ export const FirstPersonControls = () => { const isLocked = document.pointerLockElement === canvas if (isLocked) { hadPointerLockRef.current = true + suspendRef.current = false + useViewer.getState().setWalkthroughSuspended(false) return } + // Deliberately released (screenshot pause) — stay in first person; + // clicking the canvas re-locks. + if (suspendRef.current) return + if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) { useEditor.getState().setFirstPersonMode(false) } @@ -998,6 +1187,7 @@ export const FirstPersonControls = () => { document.removeEventListener('click', handleClick) document.removeEventListener('mousedown', handleMouseDown, true) document.removeEventListener('pointerlockchange', handlePointerLockChange) + useViewer.getState().setWalkthroughSuspended(false) if (document.pointerLockElement === canvas) { document.exitPointerLock() } @@ -1029,7 +1219,11 @@ export const FirstPersonControls = () => { return } - if (event.code === 'Escape') { + if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard + // screenshot) must not toggle it under the user. + if (!suspendRef.current) crouchKeyRef.current = true + } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() if (document.pointerLockElement === canvas) { @@ -1044,18 +1238,41 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() closeInteractableTarget() + } else if (event.code === 'KeyP') { + // P toggles a cursor pause (advertised in the HUD): frees the pointer + // without leaving first person — e.g. for an OS screenshot, which + // needs a movable cursor — and click or P resumes. + event.preventDefault() + event.stopPropagation() + if (document.pointerLockElement === canvas) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + document.exitPointerLock() + } else if (suspendRef.current) { + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } } } const handleKeyUp = (event: KeyboardEvent) => { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = false + } applyMovementKey(event, false) } + const handleBlur = () => { + if (!suspendRef.current) crouchKeyRef.current = false + } + document.addEventListener('keydown', handleKeyDown, true) document.addEventListener('keyup', handleKeyUp, true) + window.addEventListener('blur', handleBlur) return () => { document.removeEventListener('keydown', handleKeyDown, true) document.removeEventListener('keyup', handleKeyUp, true) + window.removeEventListener('blur', handleBlur) } }, [closeInteractableTarget, gl, toggleInteractableTarget]) @@ -1281,11 +1498,33 @@ export const FirstPersonControls = () => { [camera, setElevatorRideLocked], ) - useFrame(() => { + const hasStandingClearance = useCallback((position: Vector3) => { + standClearanceRaycaster.set(position, standClearanceUp) + standClearanceRaycaster.far = STAND_CLEARANCE + const meshes: Mesh[] = [] + if (worldRef.current) meshes.push(worldRef.current.mesh) + for (const mesh of elevatorColliderMeshesRef.current) { + if (mesh.visible) meshes.push(mesh) + } + return standClearanceRaycaster.intersectObjects(meshes, false).length === 0 + }, []) + + useFrame((_, delta) => { if (!controllerRef.current?.group) return const group = controllerRef.current.group + // Crouch follows the held key; standing back up waits for headroom. + // Frozen while the cursor pause is active. + if (!suspendRef.current && crouchKeyRef.current !== crouched) { + if (crouchKeyRef.current) setCrouched(true) + else if (hasStandingClearance(group.position)) setCrouched(false) + } + const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET + eyeOffsetRef.current += + (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED) + cameraOffset.set(0, eyeOffsetRef.current, 0) + // The site ground collider is effectively unbounded, but scenes without a // site node only have finite fallback floors — if the controller still ends // up below every collider it can never land, so put it back at the spawn. @@ -1326,6 +1565,17 @@ export const FirstPersonControls = () => { interactableTargetRef.current = nextInteractableTarget useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null) } + + useFirstPersonHud.getState().setHud({ + interact: resolveHudInteract(nextInteractableTarget), + }) + + hudLabelFrameRef.current += 1 + if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) { + hudLabelFrameRef.current = 0 + camera.getWorldPosition(hudWorldEyePosition) + useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition)) + } }, 2.5) useEffect(() => { @@ -1352,7 +1602,7 @@ export const FirstPersonControls = () => { { fallGravityFactor={4} floatCheckType="BOTH" floatDampingC={36} - floatHeight={0.5} + floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT} floatPullBackHeight={0.35} floatSensorRadius={0.15} floatSpringK={1200} gravity={9.81} jumpVel={5} key="first-person-controller" - maxRunSpeed={5} + maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5} maxSlope={1.2} - maxWalkSpeed={2} + maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2} paused={isElevatorRideLocked} position={controllerStart.position} ref={setControllerApi} @@ -1383,27 +1633,14 @@ export const FirstPersonControls = () => { ) } -/** - * Overlay UI for first-person mode: crosshair, controls hint, exit button. - * Rendered as a regular DOM overlay (not inside the Canvas). - */ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { - const [isLocked, setIsLocked] = useState(false) const hasPlacedSpawn = useScene((state) => Object.values(state.nodes).some((node) => node.type === 'spawn'), ) - - useEffect(() => { - const handlePointerLockChange = () => { - setIsLocked(document.pointerLockElement != null) - } - - handlePointerLockChange() - document.addEventListener('pointerlockchange', handlePointerLockChange) - return () => { - document.removeEventListener('pointerlockchange', handlePointerLockChange) - } - }, []) + const floorLabel = useFirstPersonHud((state) => state.floorLabel) + const zoneLabel = useFirstPersonHud((state) => state.zoneLabel) + const interact = useFirstPersonHud((state) => state.interact) + const suspended = useViewer((state) => state.walkthroughSuspended) const handleExit = useCallback(() => { if (document.pointerLockElement) { @@ -1413,86 +1650,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { }, [onExit]) return ( - <> - {isLocked && ( -
-
-
-
-
-
- )} - -
- -
- + {!hasPlacedSpawn && ( -
-
- Place a Spawn Point from the Build tab to control where walkthrough starts. -
+
+ Place a spawn point from the Build tab to control where walkthrough starts.
)} - - {isLocked && ( -
-
- -
- - - - -
- - Click to look around - -
-
- )} - - ) -} - -function ControlHint({ label, keys }: { label: string; keys: string[] }) { - return ( -
- - {label} - -
- {keys.map((key) => ( - - {key} - - ))} -
-
- ) -} - -function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) { - return ( -
- - {label} - - - {keyLabel} - -
+ ) } diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 4ea8f6fd..83b1c2e3 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -95,6 +95,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { export function HelperManager() { const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) + const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind) const workspaceMode = useEditor((s) => s.workspaceMode) const scope = useInteractionScope((s) => s.scope) @@ -148,6 +149,10 @@ export function HelperManager() { // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. if (isMobile) return null + // First-person walkthrough has its own HUD; editor shortcut hints (e.g. the + // Ctrl multi-select hint — Ctrl is crouch there) don't apply while walking. + if (isFirstPersonMode) return null + // The studio workspace (compose panel / gallery) has no scene selection or // tools — editor shortcut hints would only mislead there. if (workspaceMode === 'studio') return null diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index e60c5d66..7918f9f5 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -1,51 +1,9 @@ 'use client' -import { Icon } from '@iconify/react' -import { - type AnyNode, - type AnyNodeId, - type BuildingNode, - emitter, - getLevelDisplayName, - type LevelNode, - useScene, - type ZoneNode, -} from '@pascal-app/core' -import { - CLAY_PALETTE, - type EdgeMode, - getSceneTheme, - SCENE_THEMES, - useViewer, -} from '@pascal-app/viewer' -import { - ArrowLeft, - Box, - Camera, - Check, - ChevronRight, - Diamond, - Footprints, - Layers, - Palette, - PenLine, - Sparkles, - Square, -} from 'lucide-react' -import Link from 'next/link' import { flushSync } from 'react-dom' -import { useShallow } from 'zustand/react/shallow' -import { cn } from '../lib/utils' import useEditor from '../store/use-editor' -import { ActionButton } from './ui/action-menu/action-button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from './ui/primitives/dropdown-menu' -import { TooltipProvider } from './ui/primitives/tooltip' +import { ViewerControlsBar } from './viewer/viewer-controls-bar' +import { ViewerSceneHeader } from './viewer/viewer-scene-header' type ProjectOwner = { id: string @@ -72,218 +30,6 @@ function requestWalkthroughPointerLock() { } } -const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { - stacked: 'Stacked', - exploded: 'Exploded', - solo: 'Solo', -} - -const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { - manual: 'Stack', - stacked: 'Stack', - exploded: 'Exploded', - solo: 'Solo', -} - -const wallModeConfig = { - up: { - icon: (props: any) => ( - Full Height - ), - label: 'Full Height', - }, - cutaway: { - icon: (props: any) => ( - Cutaway - ), - label: 'Cutaway', - }, - down: { - icon: (props: any) => ( - Low - ), - label: 'Low', - }, - translucent: { - icon: (props: any) => ( - Translucent - ), - label: 'Translucent', - }, -} - -const SHADING_OPTIONS = [ - { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box }, - { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, -] as const - -const TEXTURE_OPTIONS = [ - { id: true, name: 'Colored', detail: 'Show materials, textures & colors', icon: Palette }, - { id: false, name: 'Monochrome', detail: 'Flat clay surfaces by role', icon: Square }, -] as const - -function RenderModeMenu() { - const shading = useViewer((s) => s.shading) - const textures = useViewer((s) => s.textures) - const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0] - const ActiveIcon = active.icon - return ( - - - - - - - - {SHADING_OPTIONS.map((option) => { - const OptionIcon = option.icon - return ( - useViewer.getState().setShading(option.id)} - > - -
- {option.name} - {option.detail} -
- {shading === option.id ? : null} -
- ) - })} - - {TEXTURE_OPTIONS.map((option) => { - const OptionIcon = option.icon - return ( - useViewer.getState().setTextures(option.id)} - > - -
- {option.name} - {option.detail} -
- {textures === option.id ? : null} -
- ) - })} -
-
- ) -} - -function SceneThemeMenu() { - const sceneTheme = useViewer((s) => s.sceneTheme) - const active = getSceneTheme(sceneTheme) - return ( - - - - - - - - {SCENE_THEMES.map((sceneThemeOption) => { - const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( - (role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role], - ) - return ( - useViewer.getState().setSceneTheme(sceneThemeOption.id)} - > - - {swatches.map((color, index) => ( - - ))} - - {sceneThemeOption.name} - {sceneTheme === sceneThemeOption.id ? ( - - ) : null} - - ) - })} - - - ) -} - -const EDGE_OPTIONS = [ - { id: 'off', name: 'Off', detail: 'No edge lines' }, - { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' }, - { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' }, -] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[] - -function EdgesMenu() { - const edges = useViewer((s) => s.edges) - const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0] - return ( - - - - - - - - {EDGE_OPTIONS.map((option) => ( - useViewer.getState().setEdges(option.id)} - > -
- {option.name} - {option.detail} -
- {edges === option.id ? : null} -
- ))} -
-
- ) -} - -const getNodeName = (node: AnyNode): string => { - if ('name' in node && node.name) return node.name - if (node.type === 'wall') return 'Wall' - if (node.type === 'fence') return 'Fence' - if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' - if (node.type === 'slab') return 'Slab' - if (node.type === 'ceiling') return 'Ceiling' - if (node.type === 'roof') return 'Roof' - if (node.type === 'roof-segment') return 'Roof Segment' - return node.type -} - interface ViewerOverlayProps { projectName?: string | null owner?: ProjectOwner | null @@ -298,401 +44,16 @@ export const ViewerOverlay = ({ canShowScans = true, canShowGuides = true, onBack, -}: ViewerOverlayProps) => { - const selection = useViewer((s) => s.selection) - const showScans = useViewer((s) => s.showScans) - const showGuides = useViewer((s) => s.showGuides) - const cameraMode = useViewer((s) => s.cameraMode) - const levelMode = useViewer((s) => s.levelMode) - const wallMode = useViewer((s) => s.wallMode) - - // Subscribe only to the specific nodes we read so that creating an unrelated - // node elsewhere in the scene doesn't re-render this overlay. - const firstSelectedId = selection.selectedIds[0] ?? null - const building = useScene((s) => - selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null, - ) - const level = useScene((s) => - selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null, - ) - const zone = useScene((s) => - selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null, - ) - const selectedNode = useScene((s) => - firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null, - ) - const levels = useScene( - useShallow((s) => { - if (!building) return [] - return building.children - .map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined) - .filter((n): n is LevelNode => n?.type === 'level') - .sort((a, b) => a.level - b.level) - }), - ) - - const handleLevelClick = (levelId: LevelNode['id']) => { - // When switching levels, deselect zone and items - useViewer.getState().setSelection({ levelId }) - } - - const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => { - switch (depth) { - case 'root': - useViewer.getState().resetSelection() - break - case 'building': - useViewer.getState().setSelection({ levelId: null }) - break - case 'level': - useViewer.getState().setSelection({ zoneId: null }) - break - } - } - - return ( - <> - {/* Unified top-left card */} -
-
- {/* Project info + back */} -
- {onBack ? ( - - ) : ( - - - - )} -
-
- {projectName || 'Untitled'} -
- {owner?.username && ( - - @{owner.username} - - )} -
-
- - {/* Breadcrumb — only shown when navigated into a building */} - {building && ( -
-
- - - {building && ( - <> - - - - )} - - {level && ( - <> - - - - )} - - {zone && ( - <> - - - {zone.name} - - - )} - - {selectedNode && zone && ( - <> - - - {getNodeName(selectedNode)} - - - )} -
-
- )} -
- - {/* Level List (only when building is selected) */} - {building && levels.length > 0 && ( -
- - Levels - -
- {levels.map((lvl) => { - const isSelected = lvl.id === selection.levelId - return ( - - ) - })} -
-
- )} -
- - {/* Controls Panel - Bottom Center */} -
- -
- {/* Scans and Guides Visibility */} - {canShowScans && ( - useViewer.getState().setShowScans(!showScans)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Scans - - )} - - {canShowGuides && ( - useViewer.getState().setShowGuides(!showGuides)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Guides - - )} - - {(canShowScans || canShowGuides) &&
} - - {/* Camera Mode */} - - useViewer - .getState() - .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective') - } - size="icon" - tooltipSide="top" - variant="ghost" - > - - - - - - - - - - {/* Level Mode */} - { - if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') - const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] - const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length - useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - - {levelMode === 'solo' && } - {levelMode === 'exploded' && ( - - )} - {(levelMode === 'stacked' || levelMode === 'manual') && ( - - )} - - - - - {/* Wall Mode */} - { - const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [ - 'cutaway', - 'up', - 'down', - 'translucent', - ] - const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length - useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - {(() => { - const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon - return - })()} - - -
- - {/* Camera Actions */} - emitter.emit('camera-controls:orbit-ccw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Left - - - emitter.emit('camera-controls:orbit-cw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Right - - - emitter.emit('camera-controls:top-view')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Top View - - -
- - {/* First-person walkthrough */} - { - flushSync(() => useEditor.getState().setFirstPersonMode(true)) - requestWalkthroughPointerLock() - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - - -
- -
- - ) -} +}: ViewerOverlayProps) => ( + <> + + { + flushSync(() => useEditor.getState().setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }} + /> + +) diff --git a/packages/editor/src/components/viewer/viewer-controls-bar.tsx b/packages/editor/src/components/viewer/viewer-controls-bar.tsx new file mode 100644 index 00000000..929dbd4a --- /dev/null +++ b/packages/editor/src/components/viewer/viewer-controls-bar.tsx @@ -0,0 +1,466 @@ +'use client' + +import { emitter } from '@pascal-app/core' +import { + CLAY_PALETTE, + type EdgeMode, + getSceneTheme, + SCENE_THEMES, + useViewer, +} from '@pascal-app/viewer' +import { + Box, + Camera, + Check, + Contrast, + Diamond, + Eye, + EyeOff, + Footprints, + Layers, + Layers2, + Palette, + PenLine, + SlidersHorizontal, + Sparkles, + Square, + SwatchBook, +} from 'lucide-react' +import { cn } from '../../lib/utils' +import { ActionButton } from '../ui/action-menu/action-button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '../ui/primitives/dropdown-menu' +import { TooltipProvider } from '../ui/primitives/tooltip' + +const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { + stacked: 'Stacked', + exploded: 'Exploded', + solo: 'Solo', +} + +const wallModeConfig = { + up: { + icon: (props: any) => ( + Full height + ), + label: 'Full height', + }, + cutaway: { + icon: (props: any) => ( + Cutaway + ), + label: 'Cutaway', + }, + down: { + icon: (props: any) => ( + Low + ), + label: 'Low', + }, +} + +const SHADING_OPTIONS = [ + { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box }, + { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, +] as const + +const EDGE_OPTIONS = [ + { id: 'off', name: 'Off', detail: 'No edge lines' }, + { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' }, + { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' }, +] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[] + +// Keep the dropdown open when flipping an in-place toggle row. +const keepOpen = (event: Event, fn: () => void) => { + event.preventDefault() + fn() +} + +// Scans + guides folded into one control. A baked GLB carries none of its own, +// but the GLB viewer re-adds them from scene data when the privacy flags allow, +// so the toggle shows for whichever exist. +function VisibilityMenu({ + canShowScans, + canShowGuides, +}: { + canShowScans: boolean + canShowGuides: boolean +}) { + const showScans = useViewer((s) => s.showScans) + const showGuides = useViewer((s) => s.showGuides) + return ( + + + + + + + + {canShowScans && ( + keepOpen(e, () => useViewer.getState().setShowScans(!showScans))} + > + + Scans + {showScans ? ( + + ) : ( + + )} + + )} + {canShowGuides && ( + keepOpen(e, () => useViewer.getState().setShowGuides(!showGuides))} + > + + Guides + {showGuides ? ( + + ) : ( + + )} + + )} + + + ) +} + +// One "Display" button gathering shadows, camera projection, colors, render +// mode, scene theme and edges. +function DisplayMenu() { + const cameraMode = useViewer((s) => s.cameraMode) + const shading = useViewer((s) => s.shading) + const textures = useViewer((s) => s.textures) + const shadows = useViewer((s) => s.shadows) + const sceneTheme = useViewer((s) => s.sceneTheme) + const edges = useViewer((s) => s.edges) + const activeShading = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0] + const activeTheme = getSceneTheme(sceneTheme) + const activeEdges = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0] + return ( + + + + + + + + keepOpen(e, () => useViewer.getState().setShadows(!shadows))} + > + + Shadows + {shadows ? 'On' : 'Off'} + + + keepOpen(e, () => + useViewer + .getState() + .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'), + ) + } + > + + Camera + + {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} + + + keepOpen(e, () => useViewer.getState().setTextures(!textures))} + > + {textures ? : } + Colors + + {textures ? 'Colored' : 'Monochrome'} + + + + + + + + + Render + {activeShading.name} + + + {SHADING_OPTIONS.map((option) => { + const OptionIcon = option.icon + return ( + useViewer.getState().setShading(option.id)} + > + +
+ {option.name} + {option.detail} +
+ {shading === option.id ? ( + + ) : null} +
+ ) + })} +
+
+ + + + + Theme + + {activeTheme.name} + + + + {SCENE_THEMES.map((t) => { + const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( + (role) => t.clayTints?.[role] ?? CLAY_PALETTE[role], + ) + return ( + useViewer.getState().setSceneTheme(t.id)} + > + + {swatches.map((color, index) => ( + + ))} + + {t.name} + {sceneTheme === t.id ? : null} + + ) + })} + + + + + + + Edges + {activeEdges.name} + + + {EDGE_OPTIONS.map((option) => ( + useViewer.getState().setEdges(option.id)} + > +
+ {option.name} + {option.detail} +
+ {edges === option.id ? : null} +
+ ))} +
+
+
+
+ ) +} + +export type ViewerControlsBarProps = { + canShowScans?: boolean + canShowGuides?: boolean + /** A baked GLB is the active artifact: hide controls it can't honor (wall + * modes aren't baked into the GLB). */ + glbActive?: boolean + /** In GLB mode, whether scans/guides were re-added from scene data — so the + * visibility control surfaces the matching toggle even though the artifact + * itself carries none. */ + glbHasScans?: boolean + glbHasGuides?: boolean + walkthroughActive?: boolean + onWalkthroughToggle: () => void + className?: string +} + +export const ViewerControlsBar = ({ + canShowScans = true, + canShowGuides = true, + glbActive = false, + glbHasScans = false, + glbHasGuides = false, + walkthroughActive = false, + onWalkthroughToggle, + className, +}: ViewerControlsBarProps) => { + const levelMode = useViewer((s) => s.levelMode) + const wallMode = useViewer((s) => s.wallMode) + // Sessions may carry a stale mode outside the cycle (e.g. the retired + // 'translucent'); render and cycle it as cutaway instead of crashing. + const safeWallMode = ( + wallMode in wallModeConfig ? wallMode : 'cutaway' + ) as keyof typeof wallModeConfig + const WallModeIcon = wallModeConfig[safeWallMode].icon + + return ( +
+ +
+ {((canShowScans && (!glbActive || glbHasScans)) || + (canShowGuides && (!glbActive || glbHasGuides))) && ( + <> + +
+ + )} + + {/* Level mode */} + { + if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') + const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] + const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length + useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + {levelMode === 'solo' && } + {levelMode === 'exploded' && } + {(levelMode === 'stacked' || levelMode === 'manual') && } + + + {/* Wall mode — parametric only; baked GLB walls are fixed-height. */} + {!glbActive && ( + { + const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const nextIndex = (modes.indexOf(safeWallMode) + 1) % modes.length + useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + + + )} + +
+ + + +
+ + {/* Walkthrough */} + + + + +
+ + {/* Camera actions */} + emitter.emit('camera-controls:orbit-ccw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit left + + + emitter.emit('camera-controls:orbit-cw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit right + + + emitter.emit('camera-controls:top-view')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Top view + +
+ +
+ ) +} diff --git a/packages/editor/src/components/viewer/viewer-scene-header.tsx b/packages/editor/src/components/viewer/viewer-scene-header.tsx new file mode 100644 index 00000000..1d44a9be --- /dev/null +++ b/packages/editor/src/components/viewer/viewer-scene-header.tsx @@ -0,0 +1,235 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + getLevelDisplayName, + type LevelNode, + useScene, + type ZoneNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { ArrowLeft, ChevronRight, Layers } from 'lucide-react' +import Link from 'next/link' +import type { ReactNode } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { cn } from '../../lib/utils' + +const getNodeName = (node: AnyNode): string => { + if ('name' in node && node.name) return node.name + if (node.type === 'wall') return 'Wall' + if (node.type === 'fence') return 'Fence' + if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' + if (node.type === 'slab') return 'Slab' + if (node.type === 'ceiling') return 'Ceiling' + if (node.type === 'roof') return 'Roof' + if (node.type === 'roof-segment') return 'Roof Segment' + return node.type +} + +export type ViewerSceneHeaderProps = { + projectName?: string | null + owner?: { username?: string | null } | null + onBack?: () => void + /** Fallback destination when no `onBack` handler is supplied. Must already be + * sanitized by the caller. */ + backHref?: string + /** Extra row under the project info (e.g. likes/fork actions). */ + stats?: ReactNode +} + +export const ViewerSceneHeader = ({ + projectName, + owner, + onBack, + backHref = '/', + stats, +}: ViewerSceneHeaderProps) => { + const selection = useViewer((s) => s.selection) + + // Subscribe only to the specific nodes we read so that creating an unrelated + // node elsewhere in the scene doesn't re-render this overlay. + const firstSelectedId = selection.selectedIds[0] ?? null + const building = useScene((s) => + selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null, + ) + const level = useScene((s) => + selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null, + ) + const zone = useScene((s) => + selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null, + ) + const selectedNode = useScene((s) => + firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null, + ) + // Highest first so the list reads top-down like a building section. + const levels = useScene( + useShallow((s) => { + if (!building) return [] + return building.children + .map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined) + .filter((n): n is LevelNode => n?.type === 'level') + .sort((a, b) => b.level - a.level) + }), + ) + + const handleLevelClick = (levelId: LevelNode['id']) => { + // When switching levels, deselect zone and items + useViewer.getState().setSelection({ levelId }) + } + + const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level') => { + switch (depth) { + case 'root': + useViewer.getState().resetSelection() + break + case 'building': + useViewer.getState().setSelection({ levelId: null }) + break + case 'level': + useViewer.getState().setSelection({ zoneId: null }) + break + } + } + + return ( +
+
+ {/* Project info + back */} +
+ {onBack ? ( + + ) : ( + + + + )} +
+
+ {projectName || 'Untitled'} +
+ {owner?.username && ( + + @{owner.username} + + )} +
+
+ + {stats && ( +
{stats}
+ )} + + {/* Breadcrumb — only shown when navigated into a building */} + {building && ( +
+
+ + + + + + {level && ( + <> + + + + )} + + {zone && ( + <> + + + {zone.name} + + + )} + + {selectedNode && zone && ( + <> + + + {getNodeName(selectedNode)} + + + )} +
+
+ )} +
+ + {/* Level list (only when a building is selected) */} + {building && levels.length > 0 && ( +
+ + Levels + +
+ {levels.map((lvl) => { + const isSelected = lvl.id === selection.levelId + return ( + + ) + })} +
+
+ )} +
+ ) +} diff --git a/packages/editor/src/components/walkthrough-hud.tsx b/packages/editor/src/components/walkthrough-hud.tsx new file mode 100644 index 00000000..8af196ae --- /dev/null +++ b/packages/editor/src/components/walkthrough-hud.tsx @@ -0,0 +1,111 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '../lib/utils' +import type { WalkthroughInteract } from '../store/use-first-person-hud' + +export type { WalkthroughInteract } from '../store/use-first-person-hud' + +export type WalkthroughHudProps = { + floorLabel?: string | null + zoneLabel?: string | null + interact?: WalkthroughInteract + /** Pointer lock temporarily released (OS screenshot) — the pill flips to + * "Click to resume" and lets clicks fall through to the canvas. */ + suspended?: boolean + onExit?: () => void + children?: ReactNode +} + +export function WalkthroughHud({ + floorLabel, + zoneLabel, + interact = null, + suspended = false, + onExit, + children, +}: WalkthroughHudProps) { + const kbdClass = 'rounded border border-border/60 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]' + const pillClass = + 'flex items-center gap-1.5 rounded-full border border-border/40 bg-background/70 px-3 py-1 text-muted-foreground text-xs backdrop-blur-xl' + const exitContent = ( + <> + Esc + to exit + + ) + + return ( +
+
+ {floorLabel && ( +
+ {floorLabel} +
+ )} + {zoneLabel && ( +
+ {zoneLabel} +
+ )} + {children} +
+ +
+
+
+ +
+ {suspended ? ( +
+ Click + or + P + to resume + · + {exitContent} +
+ ) : ( + <> +
+ P + free cursor +
+ {onExit ? ( + + ) : ( +
{exitContent}
+ )} + + )} +
+ + {interact && ( +
+
+ + E + + or click to + + {interact.verb} {interact.label} + +
+
+ )} +
+ ) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 25544bfb..f7b6a46d 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -26,6 +26,7 @@ export { default as Editor } from './components/editor' // surface uses the shorter, shell-friendly names from the unified // preset-system spec. export { BakeExporter } from './components/editor/bake-exporter' +export { FirstPersonControls } from './components/editor/first-person-controls' export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' // Embed surface — the editor's real in-canvas affordances, so a host can mount // authentic selection handles, interactive build tools, and the mover on top @@ -261,6 +262,19 @@ export { SnapTargetBadge, SnapTargetIcon, } from './components/ui/snap-target-badge' +export { + ViewerControlsBar, + type ViewerControlsBarProps, +} from './components/viewer/viewer-controls-bar' +export { + ViewerSceneHeader, + type ViewerSceneHeaderProps, +} from './components/viewer/viewer-scene-header' +export { + WalkthroughHud, + type WalkthroughHudProps, + type WalkthroughInteract, +} from './components/walkthrough-hud' export type { SaveStatus } from './hooks/use-auto-save' // useDragAction is the React-side glue for the registry's DragAction // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) @@ -470,6 +484,7 @@ export { } from './store/use-editor' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' +export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud' export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview' export { default as useInteractionScope, diff --git a/packages/editor/src/lib/door-interaction.ts b/packages/editor/src/lib/door-interaction.ts index 7d9efc48..1ad72088 100644 --- a/packages/editor/src/lib/door-interaction.ts +++ b/packages/editor/src/lib/door-interaction.ts @@ -15,7 +15,7 @@ type DoorOpenAnimationOptions = { persist?: boolean } -function getDisplayedDoorValue( +export function getDisplayedDoorValue( doorId: AnyNodeId, field: keyof DoorInteractiveState, nodeValue: number | undefined, diff --git a/packages/editor/src/lib/window-interaction.ts b/packages/editor/src/lib/window-interaction.ts index 64c4ce99..2ed3ec4d 100644 --- a/packages/editor/src/lib/window-interaction.ts +++ b/packages/editor/src/lib/window-interaction.ts @@ -23,7 +23,7 @@ export function isOperableWindowType(windowType: string | undefined) { ) } -function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) { +export function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) { const interactive = useInteractive.getState() const runtimeValue = interactive.windows[windowId]?.operationState if (runtimeValue !== undefined) return runtimeValue diff --git a/packages/editor/src/store/use-first-person-hud.ts b/packages/editor/src/store/use-first-person-hud.ts new file mode 100644 index 00000000..b78a0fd9 --- /dev/null +++ b/packages/editor/src/store/use-first-person-hud.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand' + +export type WalkthroughInteract = { label: string; verb: string } | null + +export type FirstPersonHudState = { + floorLabel: string | null + zoneLabel: string | null + interact: WalkthroughInteract + setHud: (hud: Partial>) => void + reset: () => void +} + +export const useFirstPersonHud = create((set) => ({ + floorLabel: null, + zoneLabel: null, + interact: null, + setHud: (hud) => + set((state) => { + const floorLabel = hud.floorLabel === undefined ? state.floorLabel : hud.floorLabel + const zoneLabel = hud.zoneLabel === undefined ? state.zoneLabel : hud.zoneLabel + const interact = hud.interact === undefined ? state.interact : hud.interact + + if ( + floorLabel === state.floorLabel && + zoneLabel === state.zoneLabel && + interact?.label === state.interact?.label && + interact?.verb === state.interact?.verb + ) { + return state + } + + return { floorLabel, zoneLabel, interact } + }), + reset: () => + set((state) => + state.floorLabel === null && state.zoneLabel === null && state.interact === null + ? state + : { floorLabel: null, zoneLabel: null, interact: null }, + ), +})) diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 2fd4729e..22b47ae8 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -111,7 +111,7 @@ export default function WallPanel() { // renders at. `undefined` for walls with an explicit custom height. const planeBoundHeightMeters = useScene((s) => { const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined - if (!wall || wall.type !== 'wall' || wall.height != null) return undefined + if (wall?.type !== 'wall' || wall.height != null) return undefined return resolveWallOpeningCeiling(wall, s.nodes) }) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index d3b26f9f..e2e604dc 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -923,7 +923,7 @@ export function GlbScene({ }) // E or click activates the openable in view. The click also re-locks the - // pointer via WalkthroughControls — harmless overlap; no selection happens. + // pointer through the walkthrough controller; no selection happens. const activateWalkDoor = useCallback(() => { if (walkDoorRef.current) toggleOpenable(walkDoorRef.current) }, [toggleOpenable]) diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index 56d52163..0814ba7e 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -17,6 +17,7 @@ import { type Object3D, type PerspectiveCamera, Quaternion, + Raycaster, Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' @@ -25,7 +26,12 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { SCENE_LAYER } from '../../lib/layers' import useViewer from '../../store/use-viewer' import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl' -import { WALKTHROUGH_FOV } from './walkthrough-controls' + +// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels +// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral +// awareness without wide-angle distortion. Applied only while walking — both +// walkthrough controllers read this and restore the orbit FOV on exit. +export const WALKTHROUGH_FOV = 60 // Eye/capsule geometry mirrors the editor's first-person controller so the // baked walkthrough feels identical. The capsule centre sits below the eye; the @@ -37,6 +43,28 @@ const SPAWN_EYE_HEIGHT = 1.65 const LOOK_SENSITIVITY = 0.002 const VOID_FALL_RESPAWN_DEPTH = 12 +// Crouch (hold Ctrl): swap to a short capsule — it shrinks around the centre, +// so a crouch mid-jump also lowers the head AND raises the feet, letting the +// player thread window openings. Standing back up is gated on headroom. +// The float gap counts toward the effective obstacle height (the capsule rides +// floatHeight above the ground), so crouching also lowers it: crouched span is +// CROUCH_FLOAT_HEIGHT + capsule = 0.25 + 0.7 = 0.95 m — fits a 1 m opening. +export const STAND_CAPSULE: [number, number, number, number] = [0.25, 0.8, 4, 8] +export const CROUCH_CAPSULE: [number, number, number, number] = [0.25, 0.2, 4, 8] +export const STAND_FLOAT_HEIGHT = 0.5 +export const CROUCH_FLOAT_HEIGHT = 0.25 +export const CROUCH_EYE_OFFSET = 0.1 +export const CROUCH_WALK_SPEED = 1 +export const CROUCH_RUN_SPEED = 1.4 +// Headroom (from the capsule centre, upward) required before uncrouching: +// standing raises the centre by half the length delta plus the float delta, +// and the standing capsule top sits standLength/2 + radius above the centre. +export const STAND_CLEARANCE = 1.25 +export const EYE_LERP_SPEED = 12 + +const standClearanceRaycaster = new Raycaster() +const UP = new Vector3(0, 1, 0) + // Kinds that must not block the player: room helpers, the spawn marker, the // ceiling/roof shell (you walk under them), and door/window leaves — excluding // the latter lets you pass any doorway whether the leaf is open or shut (the @@ -54,7 +82,7 @@ const keyboardMap: Array<{ name: Exclude; keys: { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, ] -const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraOffset = new Vector3() const cameraEuler = new Euler(0, 0, 0, 'YXZ') const spawnQuat = new Quaternion() const spawnEuler = new Euler(0, 0, 0, 'YXZ') @@ -238,6 +266,10 @@ export function GlbWalkthroughController({ url }: { url: string }) { const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) + const crouchKeyRef = useRef(false) + const suspendRef = useRef(false) + const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET) + const [crouched, setCrouched] = useState(false) const [start, setStart] = useState<{ position: [number, number, number] } | null>(null) const [world, setWorld] = useState(null) @@ -333,20 +365,58 @@ export function GlbWalkthroughController({ url }: { url: string }) { if (event.code === 'Escape' && document.pointerLockElement !== canvas) { useViewer.getState().setWalkthroughMode(false) } + // P toggles a cursor pause (advertised in the HUD): frees the pointer + // without leaving the walkthrough — e.g. for an OS screenshot, which + // needs a movable cursor — and click or P resumes. + if (event.code === 'KeyP') { + if (document.pointerLockElement === canvas) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + document.exitPointerLock() + } else if (suspendRef.current) { + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } + } + // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard screenshot) + // must not toggle it under the user. + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = true + } + } + const onKeyUp = (event: KeyboardEvent) => { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = false + } + } + const onBlur = () => { + if (!suspendRef.current) crouchKeyRef.current = false } const onPointerLockChange = () => { - if (document.pointerLockElement === canvas) wasLocked = true - else if (wasLocked) useViewer.getState().setWalkthroughMode(false) + if (document.pointerLockElement === canvas) { + wasLocked = true + suspendRef.current = false + useViewer.getState().setWalkthroughSuspended(false) + } else if (suspendRef.current) { + // Deliberately released (screenshot pause) — stay in walkthrough. + } else if (wasLocked) { + useViewer.getState().setWalkthroughMode(false) + } } document.addEventListener('mousemove', onMouseMove) canvas.addEventListener('click', onClick) document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) document.addEventListener('pointerlockchange', onPointerLockChange) return () => { document.removeEventListener('mousemove', onMouseMove) canvas.removeEventListener('click', onClick) document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) document.removeEventListener('pointerlockchange', onPointerLockChange) + useViewer.getState().setWalkthroughSuspended(false) if (document.pointerLockElement === canvas) document.exitPointerLock() } }, [gl]) @@ -367,8 +437,16 @@ export function GlbWalkthroughController({ url }: { url: string }) { controllerRef.current = api }, []) + const hasStandingClearance = useCallback((position: Vector3) => { + const mesh = worldRef.current?.mesh + if (!mesh) return true + standClearanceRaycaster.set(position, UP) + standClearanceRaycaster.far = STAND_CLEARANCE + return standClearanceRaycaster.intersectObject(mesh, false).length === 0 + }, []) + // Drive the camera from the capsule each frame + respawn if it falls into void. - useFrame(() => { + useFrame((_, delta) => { const group = controllerRef.current?.group if (!group) return @@ -377,8 +455,18 @@ export function GlbWalkthroughController({ url }: { url: string }) { controllerRef.current?.resetLinVel() } + // Crouch follows the held key; standing back up waits for headroom. + // Frozen while the cursor pause is active. + if (!suspendRef.current && crouchKeyRef.current !== crouched) { + if (crouchKeyRef.current) setCrouched(true) + else if (hasStandingClearance(group.position)) setCrouched(false) + } + const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET + eyeOffsetRef.current += + (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED) + group.rotation.y = 0 - camera.position.copy(group.position).add(cameraOffset) + camera.position.copy(group.position).add(cameraOffset.set(0, eyeOffsetRef.current, 0)) cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') camera.quaternion.setFromEuler(cameraEuler) camera.updateMatrixWorld(true) @@ -391,7 +479,7 @@ export function GlbWalkthroughController({ url }: { url: string }) { diff --git a/packages/viewer/src/components/viewer/walkthrough-controls.tsx b/packages/viewer/src/components/viewer/walkthrough-controls.tsx deleted file mode 100644 index 65c6b69f..00000000 --- a/packages/viewer/src/components/viewer/walkthrough-controls.tsx +++ /dev/null @@ -1,156 +0,0 @@ -'use client' - -import { PointerLockControls } from '@react-three/drei' -import { useFrame, useThree } from '@react-three/fiber' -import { useCallback, useEffect, useRef } from 'react' -import { type PerspectiveCamera, Vector3 } from 'three' -import useViewer from '../../store/use-viewer' - -const MOVE_SPEED = 5 -const EYE_HEIGHT = 1.6 - -// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels -// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral -// awareness without wide-angle distortion. Applied only while walking — both -// walkthrough controllers read this and restore the orbit FOV on exit. -export const WALKTHROUGH_FOV = 60 - -const _direction = new Vector3() -const _forward = new Vector3() -const _right = new Vector3() - -export const WalkthroughControls = () => { - const controlsRef = useRef(null!) - const walkthroughMode = useViewer((s: any) => s.walkthroughMode) - const keys = useRef({ w: false, a: false, s: false, d: false }) - const camera = useThree((s) => s.camera) - - // Set initial eye height - useEffect(() => { - if (walkthroughMode) { - camera.position.y = EYE_HEIGHT - } - }, [walkthroughMode, camera]) - - // Widen FOV while walking; restore the orbit FOV on exit. - useEffect(() => { - if (!walkthroughMode) return - const cam = camera as PerspectiveCamera - if (!cam.isPerspectiveCamera) return - const prevFov = cam.fov - cam.fov = WALKTHROUGH_FOV - cam.updateProjectionMatrix() - return () => { - cam.fov = prevFov - cam.updateProjectionMatrix() - } - }, [walkthroughMode, camera]) - - // Keyboard handlers - useEffect(() => { - if (!walkthroughMode) return - - const onKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return - const key = e.key.toLowerCase() - - // ESC exits walkthrough mode completely - if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - useViewer.getState().setWalkthroughMode(false) - return - } - - if (key === 'w' || key === 'arrowup') keys.current.w = true - if (key === 'a' || key === 'arrowleft') keys.current.a = true - if (key === 's' || key === 'arrowdown') keys.current.s = true - if (key === 'd' || key === 'arrowright') keys.current.d = true - } - - const onKeyUp = (e: KeyboardEvent) => { - const key = e.key.toLowerCase() - if (key === 'w' || key === 'arrowup') keys.current.w = false - if (key === 'a' || key === 'arrowleft') keys.current.a = false - if (key === 's' || key === 'arrowdown') keys.current.s = false - if (key === 'd' || key === 'arrowright') keys.current.d = false - } - - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - - return () => { - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - // Reset keys on cleanup - keys.current = { w: false, a: false, s: false, d: false } - } - }, [walkthroughMode]) - - // Release pointer lock when walkthrough mode is turned off - useEffect(() => { - if (!walkthroughMode && document.pointerLockElement) { - document.exitPointerLock() - } - }, [walkthroughMode]) - - // Movement loop - useFrame((_, delta) => { - if (!(walkthroughMode && controlsRef.current)) return - - _direction.set(0, 0, 0) - - // Get camera forward and right vectors (XZ plane only) - camera.getWorldDirection(_forward) - _forward.y = 0 - _forward.normalize() - - _right.crossVectors(_forward, camera.up).normalize() - - if (keys.current.w) _direction.add(_forward) - if (keys.current.s) _direction.sub(_forward) - if (keys.current.d) _direction.add(_right) - if (keys.current.a) _direction.sub(_right) - - if (_direction.lengthSq() > 0) { - _direction.normalize().multiplyScalar(MOVE_SPEED * delta) - camera.position.add(_direction) - // Keep eye height constant - camera.position.y = EYE_HEIGHT - } - }) - - const handleClick = useCallback(() => { - if (walkthroughMode && controlsRef.current) { - // Feature detection: some browsers (Facebook/Instagram in-app, older Safari) - // don't support pointer lock on the canvas element - if (typeof controlsRef.current.lock === 'function') { - try { - controlsRef.current.lock() - } catch { - // Silently ignore — pointer lock unavailable in this browser context - } - } - } - }, [walkthroughMode]) - - // Click to lock - useEffect(() => { - if (!walkthroughMode) return - const canvas = document.querySelector('canvas') - if (!canvas) return - - canvas.addEventListener('click', handleClick) - return () => canvas.removeEventListener('click', handleClick) - }, [walkthroughMode, handleClick]) - - if (!walkthroughMode) return null - - // Skip PointerLockControls on browsers that don't support pointer lock - // (Facebook/Instagram in-app browsers, some iOS WebViews) - if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) { - return null - } - - return -} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8408cd88..ba44676d 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -34,14 +34,25 @@ export { GlbScene, type GlbWalkthrough, } from './components/viewer/glb-scene' -export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller' +export { + CROUCH_CAPSULE, + CROUCH_EYE_OFFSET, + CROUCH_FLOAT_HEIGHT, + CROUCH_RUN_SPEED, + CROUCH_WALK_SPEED, + EYE_LERP_SPEED, + GlbWalkthroughController, + STAND_CAPSULE, + STAND_CLEARANCE, + STAND_FLOAT_HEIGHT, + WALKTHROUGH_FOV, +} from './components/viewer/glb-walkthrough-controller' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, SSGI_PARAMS, } from './components/viewer/post-processing' export { SceneEnvironment } from './components/viewer/scene-environment' -export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 085efb59..1fd8bc20 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -153,6 +153,11 @@ type ViewerState = { walkthroughMode: boolean setWalkthroughMode: (mode: boolean) => void + /** Pointer lock temporarily released mid-walkthrough (⌘/PrintScreen — OS + * screenshot needs a movable cursor); clicking the canvas re-locks. */ + walkthroughSuspended: boolean + setWalkthroughSuspended: (suspended: boolean) => void + cameraDragging: boolean setCameraDragging: (dragging: boolean) => void @@ -515,7 +520,10 @@ const useViewer = create()( setDebugColors: (enabled) => set({ debugColors: enabled }), walkthroughMode: false, - setWalkthroughMode: (mode) => set({ walkthroughMode: mode }), + setWalkthroughMode: (mode) => set({ walkthroughMode: mode, walkthroughSuspended: false }), + + walkthroughSuspended: false, + setWalkthroughSuspended: (suspended) => set({ walkthroughSuspended: suspended }), cameraDragging: false, setCameraDragging: (dragging) => set({ cameraDragging: dragging }), From 0fba611a05015550a2a537021cb5d39251f711b8 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 22 Jul 2026 08:33:38 -0400 Subject: [PATCH 06/11] =?UTF-8?q?feat(export):=20texture=20references=20?= =?UTF-8?q?=E2=80=94=20stamp=20sources=20at=20load,=20reference=20mode=20i?= =?UTF-8?q?n=20GLB=20export=20(#532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bake v2 exporter side: library/preset, legacy scene-material, and item-GLB textures get a validated pascalTextureRef stamped at load (origin-checked, env-derived). Material-ish sources resolve to 'library-material' (storage bucket) or 'app-material' (static catalog on the assets CDN) by URL shape. exportSceneToGlb grows a textures: 'embed' | 'reference' option — reference mode swaps stamped textures for 1x1 canvas-backed placeholders (skipping the WebGPU blit) and writes the ref to both texture and image extras via a GLTFExporter writeTexture plugin. Download GLB stays embed; the bake page passes reference. Co-authored-by: Claude Fable 5 --- .../src/components/editor/bake-exporter.tsx | 4 +- packages/editor/src/lib/glb-export.test.ts | 81 +++++++- packages/editor/src/lib/glb-export.ts | 189 +++++++++++++++++- packages/nodes/src/item/renderer.tsx | 79 +++++++- packages/viewer/src/index.ts | 8 + packages/viewer/src/lib/materials.ts | 21 +- .../viewer/src/lib/texture-reference.test.ts | 102 ++++++++++ packages/viewer/src/lib/texture-reference.ts | 185 +++++++++++++++++ 8 files changed, 657 insertions(+), 12 deletions(-) create mode 100644 packages/viewer/src/lib/texture-reference.test.ts create mode 100644 packages/viewer/src/lib/texture-reference.ts diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx index d58c4cbe..e84302cf 100644 --- a/packages/editor/src/components/editor/bake-exporter.tsx +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -29,7 +29,9 @@ export function BakeExporter({ await nextFrames() const sceneGroup = scene.getObjectByName('scene-renderer') if (!sceneGroup) throw new Error('scene-renderer group not found') - const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, { + textures: 'reference', + }) onComplete(buffer) } catch (err) { // The bake worker relays page console output into the job's error diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 94c0e7de..c9557bf9 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -2,7 +2,13 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core' import { buildDoorPreviewMesh } from '@pascal-app/viewer' import * as THREE from 'three' -import { prepareSceneForExport } from './glb-export' +import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export' + +// The reference module reads the storage origin lazily on first use, so +// setting the env here (before any validation call) pins it for the file. +process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co' +const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin afterEach(() => { sceneRegistry.clear() @@ -62,6 +68,79 @@ describe('prepareSceneForExport', () => { expect(meshes[0]!.material).toBe(meshes[1]!.material) }) + test('reference mode swaps stamped compressed textures and leaves unstamped textures embedded', () => { + const root = new THREE.Group() + const stamped = new THREE.CompressedTexture([], 4, 4) + stamped.wrapS = THREE.MirroredRepeatWrapping + stamped.wrapT = THREE.ClampToEdgeWrapping + stamped.repeat.set(2, 3) + stamped.offset.set(0.25, 0.5) + stamped.center.set(0.5, 0.5) + stamped.rotation = 0.75 + stamped.flipY = false + stamped.colorSpace = THREE.SRGBColorSpace + stamped.updateMatrix() + stamped.userData.pascalTextureRef = { + v: 1, + kind: 'library-material', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_basecolor_512.ktx2`, + map: 'basecolor', + colorSpace: 'srgb', + } + const unstamped = new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1) + root.add( + meshWithNodeMaterial(nodeMaterial({ map: stamped, normalMap: unstamped })), + meshWithNodeMaterial(nodeMaterial({ map: stamped })), + ) + + const { scene } = prepareSceneForExport(root, {}, { textures: 'reference' }) + + const material = (scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial + const placeholder = material.map as THREE.DataTexture + expect(placeholder).not.toBe(stamped) + expect(placeholder.isDataTexture).toBe(true) + expect( + (placeholder as THREE.DataTexture & { isCompressedTexture?: boolean }).isCompressedTexture, + ).toBeUndefined() + expect(placeholder.image.width).toBe(1) + expect(placeholder.image.height).toBe(1) + expect(Array.from(placeholder.image.data!)).toEqual([255, 255, 255, 255]) + expect(placeholder.wrapS).toBe(stamped.wrapS) + expect(placeholder.wrapT).toBe(stamped.wrapT) + expect(placeholder.repeat.toArray()).toEqual(stamped.repeat.toArray()) + expect(placeholder.offset.toArray()).toEqual(stamped.offset.toArray()) + expect(placeholder.center.toArray()).toEqual(stamped.center.toArray()) + expect(placeholder.rotation).toBe(stamped.rotation) + expect(placeholder.flipY).toBe(stamped.flipY) + expect(placeholder.colorSpace).toBe(stamped.colorSpace) + expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef) + expect(material.normalMap).toBe(unstamped) + const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial + expect(sharedMaterial.map).toBe(placeholder) + }) + + test('writes identical texture-reference extras to the texture and image definitions', () => { + const texture = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1) + const ref = { + v: 1, + kind: 'item-glb', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/models/chair.glb`, + imageIndex: 3, + map: 'normal', + colorSpace: 'linear', + } + texture.userData.pascalTextureRef = ref + const imageDef: { extras?: Record } = {} + const textureDef: { source: number; extras?: Record } = { source: 0 } + const writer = { json: { images: [imageDef] } } as unknown as GLTFWriter + + writeTextureReferenceExtras(writer, texture, textureDef) + + expect(textureDef.extras?.pascalTextureRef).toEqual(ref) + expect(imageDef.extras?.pascalTextureRef).toEqual(ref) + expect(textureDef.extras?.pascalTextureRef).toEqual(imageDef.extras?.pascalTextureRef) + }) + test('strips editor overlays that live off the scene layer', () => { const root = new THREE.Group() const realMesh = meshWithNodeMaterial(nodeMaterial()) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index e6b67b5b..ed0e4063 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -13,6 +13,7 @@ import { type ZoneNode, } from '@pascal-app/core' import { + getPascalTextureRef, poseDoorMovingParts, poseWindowMovingParts, SCENE_LAYER, @@ -20,7 +21,11 @@ import { } from '@pascal-app/viewer' import type { Object3D } from 'three' import * as THREE from 'three' -import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import { + GLTFExporter, + type GLTFExporterPlugin, + type GLTFWriter, +} from 'three/examples/jsm/exporters/GLTFExporter.js' import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' /** @@ -41,6 +46,10 @@ export type GlbExport = { animations: THREE.AnimationClip[] } +export type GlbExportOptions = { + textures?: 'embed' | 'reference' +} + /** Resolve after the next couple of animation frames, giving React/R3F time to * commit and mount export-only geometry (e.g. instanced kinds' real meshes) * before the exporter clones the scene graph. Callers must set @@ -51,10 +60,63 @@ export function nextFrames(): Promise { }) } +type GltfExtrasDef = { + extras?: Record +} + +type TextureReferenceWriter = GLTFWriter & { + json: { + images?: GltfExtrasDef[] + } +} + +function getExportedImageIndex(textureDef: Record): number | null { + if (Number.isInteger(textureDef.source)) return textureDef.source as number + const extensions = textureDef.extensions as Record | undefined + const source = extensions?.EXT_texture_webp?.source ?? extensions?.EXT_texture_avif?.source + return Number.isInteger(source) ? (source as number) : null +} + +export function writeTextureReferenceExtras( + writer: GLTFWriter, + texture: THREE.Texture, + textureDef: Record, +) { + const ref = getPascalTextureRef(texture) + if (!ref) return + + const imageIndex = getExportedImageIndex(textureDef) + const imageDef = + imageIndex === null ? undefined : (writer as TextureReferenceWriter).json.images?.[imageIndex] + if (!imageDef) { + throw new Error('GLTFExporter did not expose an image for a referenced Pascal texture') + } + + const textureWithExtras = textureDef as GltfExtrasDef + textureWithExtras.extras = { + ...textureWithExtras.extras, + pascalTextureRef: ref, + } + imageDef.extras = { + ...imageDef.extras, + pascalTextureRef: ref, + } +} + +function textureReferencePlugin(writer: GLTFWriter): GLTFExporterPlugin { + return { + writeTexture: (texture, textureDef) => { + writeTextureReferenceExtras(writer, texture, textureDef) + }, + } +} + export async function exportSceneToGlb( sceneGroup: Object3D, nodes: Record, + options: GlbExportOptions = {}, ): Promise { + const textureMode = options.textures ?? 'embed' emitter.emit('thumbnail:before-capture', undefined) // Snap levels to their true stacked positions (like thumbnail capture) so the // export always reflects the clean stacked building, regardless of the live @@ -63,7 +125,10 @@ export async function exportSceneToGlb( const restoreLevels = snapLevelsToTruePositions() let prepared: ReturnType try { - prepared = prepareSceneForExport(sceneGroup, nodes) + prepared = + textureMode === 'reference' + ? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' }) + : prepareSceneForExport(sceneGroup, nodes) } finally { restoreLevels() emitter.emit('thumbnail:after-capture', undefined) @@ -71,6 +136,7 @@ export async function exportSceneToGlb( const { scene: exportScene, animations } = prepared const exporter = new GLTFExporter() + if (textureMode === 'reference') exporter.register(textureReferencePlugin) // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read // those directly. WebGPUTextureUtils blits each one to RGBA on its own // offscreen renderer (passing the live renderer would resize/draw over the @@ -112,6 +178,7 @@ export async function exportSceneToGlb( export function prepareSceneForExport( source: THREE.Object3D, nodes: Record, + options: GlbExportOptions = {}, ): GlbExport { const scene = source.clone(true) const cloneByOriginal = pairClones(source, scene) @@ -142,7 +209,7 @@ export function prepareSceneForExport( pruneNonRenderableMeshes(scene, identityNodes) sanitizeMaterialGroups(scene, identityNodes) - convertMaterials(scene) + convertMaterials(scene, options.textures ?? 'embed') const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes) @@ -352,14 +419,31 @@ const STANDARD_MAP_SLOTS = [ 'bumpMap', ] as const -function convertMaterials(root: THREE.Object3D) { +const REFERENCE_MAP_SLOTS = [ + ...STANDARD_MAP_SLOTS, + 'clearcoatMap', + 'clearcoatNormalMap', + 'clearcoatRoughnessMap', + 'iridescenceMap', + 'iridescenceThicknessMap', + 'transmissionMap', + 'thicknessMap', + 'specularIntensityMap', + 'specularColorMap', + 'sheenRoughnessMap', + 'sheenColorMap', + 'anisotropyMap', +] as const + +function convertMaterials(root: THREE.Object3D, textureMode: 'embed' | 'reference') { const cache = new Map() + const placeholderCache = new Map() root.traverse((object) => { const mesh = object as THREE.Mesh if (!mesh.isMesh) return const material = mesh.material if (Array.isArray(material)) { - mesh.material = material.map((m) => convertMaterial(m, cache)) + mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache)) return } // glTF has no BackSide — GLTFExporter renders the *front* face for any @@ -373,7 +457,7 @@ function convertMaterials(root: THREE.Object3D) { ) { mesh.geometry = flipGeometryWinding(mesh.geometry) } - mesh.material = convertMaterial(material, cache) + mesh.material = convertMaterial(material, cache, textureMode, placeholderCache) }) } @@ -423,8 +507,19 @@ function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeomet function convertMaterial( material: THREE.Material, cache: Map, + textureMode: 'embed' | 'reference', + placeholderCache: Map, ): THREE.Material { - if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material + const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true + if (!isNodeMaterial) { + if (textureMode === 'embed') return material + const cached = cache.get(material) + if (cached) return cached + const target = material.clone() + replaceReferencedTextures(target, placeholderCache) + cache.set(material, target) + return target + } const cached = cache.get(material) if (cached) return cached @@ -465,10 +560,90 @@ function convertMaterial( } } + if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache) + cache.set(material, target) return target } +function replaceReferencedTextures( + material: THREE.Material, + placeholderCache: Map, +) { + const textureMaterial = material as THREE.Material & Record + for (const slot of REFERENCE_MAP_SLOTS) { + const texture = textureMaterial[slot] + if (!(texture instanceof THREE.Texture) || !getPascalTextureRef(texture)) continue + + let placeholder = placeholderCache.get(texture) + if (!placeholder) { + placeholder = createReferencePlaceholder(texture) + placeholderCache.set(texture, placeholder) + } + textureMaterial[slot] = placeholder + } +} + +/** GLTFExporter serializes images via canvas drawImage/createImageBitmap, + * which reject a DataTexture's raw `{data,width,height}` image — so in DOM + * environments the placeholder must be canvas-backed. The DataTexture branch + * covers non-DOM runs (bun tests), where the exporter itself never runs. */ +function createPlaceholderCanvas(): OffscreenCanvas | HTMLCanvasElement | null { + const canvas = + typeof OffscreenCanvas !== 'undefined' + ? new OffscreenCanvas(1, 1) + : typeof document !== 'undefined' + ? Object.assign(document.createElement('canvas'), { width: 1, height: 1 }) + : null + if (!canvas) return null + const ctx = canvas.getContext('2d') as + | OffscreenCanvasRenderingContext2D + | CanvasRenderingContext2D + | null + if (!ctx) return null + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, 1, 1) + return canvas +} + +function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture { + const ref = getPascalTextureRef(texture) + if (!ref) throw new Error('Cannot create a placeholder for an invalid Pascal texture reference') + + const canvas = createPlaceholderCanvas() + const placeholder = canvas + ? new THREE.Texture(canvas) + : new THREE.DataTexture( + new Uint8Array([255, 255, 255, 255]), + 1, + 1, + THREE.RGBAFormat, + THREE.UnsignedByteType, + ) + placeholder.name = texture.name + placeholder.mapping = texture.mapping + placeholder.channel = texture.channel + placeholder.wrapS = texture.wrapS + placeholder.wrapT = texture.wrapT + placeholder.magFilter = texture.magFilter + placeholder.minFilter = texture.minFilter + placeholder.anisotropy = texture.anisotropy + placeholder.offset.copy(texture.offset) + placeholder.repeat.copy(texture.repeat) + placeholder.center.copy(texture.center) + placeholder.rotation = texture.rotation + placeholder.matrixAutoUpdate = texture.matrixAutoUpdate + placeholder.matrix.copy(texture.matrix) + placeholder.generateMipmaps = texture.generateMipmaps + placeholder.premultiplyAlpha = texture.premultiplyAlpha + placeholder.flipY = texture.flipY + placeholder.unpackAlignment = texture.unpackAlignment + placeholder.colorSpace = texture.colorSpace + placeholder.userData = { pascalTextureRef: ref } + placeholder.needsUpdate = true + return placeholder +} + // --- Animation clip baking ---------------------------------------------- function bakeAnimationClips( diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 53b65455..b3e71846 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -29,6 +29,7 @@ import { type RenderShading, resolveCdnUrl, resolveMaterialRef, + stampPascalTextureRef, useItemLightPool, useNodeEvents, useViewer, @@ -38,7 +39,7 @@ import { Clone } from '@react-three/drei/core/Clone' import { useFrame, useLoader, useThree } from '@react-three/fiber' import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three' -import { MathUtils } from 'three' +import { MathUtils, Texture } from 'three' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js' import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' @@ -225,11 +226,85 @@ type LoadedItemGltf = GLTF & { nodes: Record } +const ITEM_TEXTURE_SLOTS = [ + 'map', + 'normalMap', + 'roughnessMap', + 'metalnessMap', + 'emissiveMap', + 'aoMap', + 'alphaMap', + 'lightMap', + 'bumpMap', + 'displacementMap', + 'clearcoatMap', + 'clearcoatNormalMap', + 'clearcoatRoughnessMap', + 'iridescenceMap', + 'iridescenceThicknessMap', + 'transmissionMap', + 'thicknessMap', + 'specularIntensityMap', + 'specularColorMap', + 'sheenRoughnessMap', + 'sheenColorMap', + 'anisotropyMap', +] as const + +function getItemTextureImageIndex(gltf: LoadedItemGltf, texture: Texture): number | null { + const association = gltf.parser?.associations.get(texture) + const textureIndex = association?.textures + if (!Number.isInteger(textureIndex)) return null + + const textureDef = gltf.parser.json.textures?.[textureIndex as number] + const imageIndex = + textureDef?.extensions?.KHR_texture_basisu?.source ?? + textureDef?.extensions?.EXT_texture_webp?.source ?? + textureDef?.extensions?.EXT_texture_avif?.source ?? + textureDef?.source + return Number.isInteger(imageIndex) && imageIndex >= 0 ? imageIndex : null +} + +function stampItemTextureReferences(gltf: LoadedItemGltf, src: string) { + if (!gltf.parser?.associations) return + + const stamped = new Set() + gltf.scene.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + for (const material of materials) { + const textureMaterial = material as Material & Record + for (const slot of ITEM_TEXTURE_SLOTS) { + const texture = textureMaterial[slot] + if (!(texture instanceof Texture)) continue + if (stamped.has(texture)) continue + const imageIndex = getItemTextureImageIndex(gltf, texture) + if (imageIndex === null) continue + if ( + stampPascalTextureRef(texture, { + kind: 'item-glb', + src, + slot, + imageIndex, + }) + ) { + stamped.add(texture) + } + } + } + }) +} + const useItemGltf = (url: string): LoadedItemGltf => { const renderer = useThree((state) => state.gl) - return useLoader(ItemGLTFLoader, url, (loader) => + const gltf = useLoader(ItemGLTFLoader, url, (loader) => configureItemModelLoader(loader, renderer), ) as LoadedItemGltf + return useMemo(() => { + stampItemTextureReferences(gltf, url) + return gltf + }, [gltf, url]) } type DeferredUnavailableCleanup = { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index ba44676d..89472dcb 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -119,6 +119,14 @@ export { SCENE_THEMES, type SceneTheme, } from './lib/scene-themes' +export { + getPascalTextureRef, + type PascalTextureColorSpace, + type PascalTextureMap, + type PascalTextureRef, + stampPascalTextureRef, + textureMapForSlot, +} from './lib/texture-reference' export { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat' export { useItemLightPool } from './store/use-item-light-pool' export { applyCountryUnitDefault, default as useViewer } from './store/use-viewer' diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 5504153d..b4ecb334 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -17,6 +17,7 @@ import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { resolveCdnUrl } from './asset-url' import { isKtx2Url, ktx2Loader, whenKtx2Ready } from './ktx2-loader' import { getSceneTheme } from './scene-themes' +import { stampPascalTextureRef } from './texture-reference' export type RenderShading = 'solid' | 'rendered' export type ColorPreset = 'clay' | 'white' | 'mono' | 'blueprint' @@ -212,7 +213,10 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined { const cached = textureCache.get(cacheKey) if (cached) return cached - const texture = pickTextureLoader(textureConfig.url).load(textureConfig.url) + const resolvedUrl = /^(?:asset|blob|data):/.test(textureConfig.url) + ? textureConfig.url + : (resolveCdnUrl(textureConfig.url) ?? textureConfig.url) + const texture = pickTextureLoader(resolvedUrl).load(resolvedUrl) texture.wrapS = THREE.RepeatWrapping texture.wrapT = THREE.RepeatWrapping @@ -220,6 +224,11 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined { texture.repeat.set(repeatX, repeatY) texture.updateMatrix() texture.colorSpace = THREE.SRGBColorSpace + stampPascalTextureRef(texture, { + kind: 'project-asset', + src: resolvedUrl, + slot: 'map', + }) textureCache.set(cacheKey, texture) return texture @@ -281,6 +290,11 @@ function getPresetTexture( const texture = pickTextureLoader(resolvedPath).load(resolvedPath) applyTextureProperties(texture, props, slot) + stampPascalTextureRef(texture, { + kind: 'material', + src: resolvedPath, + slot: slot ?? 'map', + }) setTextureCacheKey(texture, cacheKey) textureCache.set(cacheKey, texture) return texture @@ -336,6 +350,11 @@ async function loadPresetTexture( const promise = load .then((texture) => { applyTextureProperties(texture, props, slot) + stampPascalTextureRef(texture, { + kind: 'material', + src: resolvedPath, + slot: slot ?? 'map', + }) setTextureCacheKey(texture, cacheKey) textureCache.set(cacheKey, texture) textureLoadPromises.delete(cacheKey) diff --git a/packages/viewer/src/lib/texture-reference.test.ts b/packages/viewer/src/lib/texture-reference.test.ts new file mode 100644 index 00000000..bb45b612 --- /dev/null +++ b/packages/viewer/src/lib/texture-reference.test.ts @@ -0,0 +1,102 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import * as THREE from 'three' +import { getPascalTextureRef, stampPascalTextureRef } from './texture-reference' + +// The module reads the storage origin lazily on first use, so setting the env +// here (before any stamp call) pins it for the whole test file. +process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co' +const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin + +describe('Pascal texture references', () => { + test("resolves 'material' input to library-material for storage-bucket URLs", () => { + const texture = new THREE.Texture() + texture.colorSpace = THREE.SRGBColorSpace + const src = `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/mtl_1/oak_basecolor_512.ktx2` + + const ref = stampPascalTextureRef(texture, { kind: 'material', src, slot: 'map' }) + + expect(ref).toEqual({ + v: 1, + kind: 'library-material', + src, + map: 'basecolor', + colorSpace: 'srgb', + }) + expect(getPascalTextureRef(texture)).toEqual(ref) + }) + + test("resolves 'material' input to app-material for assets-CDN catalog URLs", () => { + const cdnOrigin = new URL(process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app') + .origin + const texture = new THREE.Texture() + const src = `${cdnOrigin}/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2` + + const ref = stampPascalTextureRef(texture, { kind: 'material', src, slot: 'normalMap' }) + + expect(ref).toEqual({ v: 1, kind: 'app-material', src, map: 'normal', colorSpace: 'linear' }) + expect(getPascalTextureRef(texture)).toEqual(ref) + + const foreign = new THREE.Texture() + expect( + stampPascalTextureRef(foreign, { + kind: 'material', + src: 'https://example.com/material/concrete/x/x_basecolor_512.ktx2', + slot: 'map', + }), + ).toBeNull() + }) + + test('stamps the exact project-asset payload for Pascal storage URLs', () => { + const texture = new THREE.Texture() + texture.colorSpace = THREE.SRGBColorSpace + + const ref = stampPascalTextureRef(texture, { + kind: 'project-asset', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/project-assets/project/asset.png`, + slot: 'map', + }) + + expect(ref).toEqual({ + v: 1, + kind: 'project-asset', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/project-assets/project/asset.png`, + map: 'basecolor', + colorSpace: 'srgb', + }) + expect(getPascalTextureRef(texture)).toEqual(ref) + }) + + test('keeps local and non-Pascal URLs unstamped', () => { + for (const src of [ + 'asset://project/asset', + 'blob:https://editor.pascal.app/asset', + 'data:image/png;base64,AAAA', + 'https://example.com/storage/v1/object/public/project-assets/project/asset.png', + ]) { + const texture = new THREE.Texture() + expect(stampPascalTextureRef(texture, { kind: 'project-asset', src, slot: 'map' })).toBeNull() + expect(texture.userData.pascalTextureRef).toBeUndefined() + } + }) + + test('includes imageIndex only for item GLB references', () => { + const texture = new THREE.Texture() + const ref = stampPascalTextureRef(texture, { + kind: 'item-glb', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/model.glb`, + slot: 'normalMap', + imageIndex: 2, + }) + + expect(ref).toEqual({ + v: 1, + kind: 'item-glb', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/model.glb`, + imageIndex: 2, + map: 'normal', + colorSpace: 'linear', + }) + }) +}) diff --git a/packages/viewer/src/lib/texture-reference.ts b/packages/viewer/src/lib/texture-reference.ts new file mode 100644 index 00000000..a11cbbda --- /dev/null +++ b/packages/viewer/src/lib/texture-reference.ts @@ -0,0 +1,185 @@ +import type * as THREE from 'three' +import { ASSETS_CDN_URL } from './asset-url' + +export type PascalTextureMap = + | 'basecolor' + | 'normal' + | 'roughness' + | 'metalness' + | 'height' + | 'other' + +export type PascalTextureColorSpace = 'srgb' | 'linear' + +type PascalTextureRefBase = { + v: 1 + src: string + map: PascalTextureMap + colorSpace: PascalTextureColorSpace +} + +export type PascalTextureRef = + | (PascalTextureRefBase & { + kind: 'library-material' | 'app-material' | 'project-asset' + }) + | (PascalTextureRefBase & { + kind: 'item-glb' + imageIndex: number + }) + +const STORAGE_BUCKET_BY_KIND = { + 'library-material': 'materials', + 'item-glb': 'items', + 'project-asset': 'project-assets', +} as const + +let cachedStorageOrigin: string | null | undefined +function pascalStorageOrigin(): string | null { + if (cachedStorageOrigin !== undefined) return cachedStorageOrigin + try { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL + cachedStorageOrigin = url ? new URL(url).origin : null + } catch { + cachedStorageOrigin = null + } + return cachedStorageOrigin +} + +/** Static catalog materials ship in the app's public dir and resolve through + * the assets CDN (`/material/{category}/{slug}/{slug}_{map}_{size}.ktx2`) — + * a server-known KTX2 source like the storage buckets, just app-hosted. */ +function isAppMaterialUrl(src: string): boolean { + try { + const url = new URL(src) + return url.origin === new URL(ASSETS_CDN_URL).origin && url.pathname.startsWith('/material/') + } catch { + return false + } +} + +const TEXTURE_MAPS = new Set([ + 'basecolor', + 'normal', + 'roughness', + 'metalness', + 'height', + 'other', +]) + +function isPascalStorageUrl(src: string, kind: keyof typeof STORAGE_BUCKET_BY_KIND): boolean { + const origin = pascalStorageOrigin() + if (!origin) return false + try { + const url = new URL(src) + const bucket = STORAGE_BUCKET_BY_KIND[kind] + return url.origin === origin && url.pathname.startsWith(`/storage/v1/object/public/${bucket}/`) + } catch { + return false + } +} + +export function textureMapForSlot(slot: string): PascalTextureMap { + switch (slot) { + case 'map': + return 'basecolor' + case 'normalMap': + return 'normal' + case 'roughnessMap': + return 'roughness' + case 'metalnessMap': + return 'metalness' + case 'displacementMap': + case 'bumpMap': + return 'height' + default: + return 'other' + } +} + +function textureColorSpace(texture: THREE.Texture): PascalTextureColorSpace { + return texture.colorSpace === 'srgb' ? 'srgb' : 'linear' +} + +export function stampPascalTextureRef( + texture: THREE.Texture, + input: + | { + /** 'material' resolves to library-material (storage bucket) or + * app-material (static catalog on the assets CDN) by URL shape. */ + kind: 'material' | 'project-asset' + src: string + slot: string + } + | { + kind: 'item-glb' + src: string + slot: string + imageIndex: number + }, +): PascalTextureRef | null { + const base = { + v: 1 as const, + src: input.src, + map: textureMapForSlot(input.slot), + colorSpace: textureColorSpace(texture), + } + + let ref: PascalTextureRef + if (input.kind === 'item-glb') { + if (!isPascalStorageUrl(input.src, 'item-glb')) return null + if (!Number.isInteger(input.imageIndex) || input.imageIndex < 0) return null + ref = { ...base, kind: 'item-glb', imageIndex: input.imageIndex } + } else { + const kind = + input.kind === 'material' + ? isPascalStorageUrl(input.src, 'library-material') + ? 'library-material' + : isAppMaterialUrl(input.src) + ? 'app-material' + : null + : isPascalStorageUrl(input.src, 'project-asset') + ? 'project-asset' + : null + if (!kind) return null + ref = { ...base, kind } + } + texture.userData.pascalTextureRef = ref + return ref +} + +export function getPascalTextureRef(texture: THREE.Texture): PascalTextureRef | null { + const raw = texture.userData.pascalTextureRef + if (!raw || typeof raw !== 'object') return null + + const candidate = raw as Record + const kind = candidate.kind + if ( + candidate.v !== 1 || + (kind !== 'library-material' && + kind !== 'app-material' && + kind !== 'item-glb' && + kind !== 'project-asset') || + typeof candidate.src !== 'string' || + !(kind === 'app-material' + ? isAppMaterialUrl(candidate.src) + : isPascalStorageUrl(candidate.src, kind)) || + typeof candidate.map !== 'string' || + !TEXTURE_MAPS.has(candidate.map as PascalTextureMap) || + (candidate.colorSpace !== 'srgb' && candidate.colorSpace !== 'linear') + ) { + return null + } + + const base: PascalTextureRefBase = { + v: 1, + src: candidate.src, + map: candidate.map as PascalTextureMap, + colorSpace: candidate.colorSpace, + } + if (kind === 'item-glb') { + if (!Number.isInteger(candidate.imageIndex) || (candidate.imageIndex as number) < 0) return null + return { ...base, kind, imageIndex: candidate.imageIndex as number } + } + if (candidate.imageIndex !== undefined) return null + return { ...base, kind } +} From 2adb50a340d32aa47a63e94590bef3c545f6beab Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 22 Jul 2026 09:46:06 -0400 Subject: [PATCH 07/11] editor: stop wall endpoint drag snapping back to the stale junction corner (#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(wall): stop endpoint drag snapping back to the stale junction corner Walls attached to the moving corner cascade with the drag, but the snap pipeline reads the scene store, which keeps their pre-drag coordinates until commit. Their stale corners recreated the old junction as a snap / alignment target, so inside the connect radius (5cm, 70cm magnetic) the endpoint could never land closer than that to where it started — sub-5cm corrections (e.g. squaring a scan-imported 91° junction) were impossible in every snapping mode. Both endpoint-move paths (3D tool + 2D floorplan affordance) now exclude the walls linked at the moving corner from snap candidates and alignment anchors while attached; Alt-detach keeps them (they stay put, so they're live geometry). The 2D path previously over-excluded: walls linked at the FIXED corner don't move, and their anchors are exactly what lets the dragged corner align back onto a true axis. Co-Authored-By: Claude Fable 5 * fix(wall): re-run full snap pipeline on Alt toggle during endpoint drag The snap/alignment candidate set now depends on Alt (stale-junction exclusion), so a keyboard detach/re-attach must re-resolve from the raw cursor point instead of re-applying the previously snapped one — matching what the 2D dispatcher already does by re-invoking apply() with the raw planPoint on modifier changes. Flagged by Bugbot on #533. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../tools/wall/wall-drafting.test.ts | 31 +++++++ .../nodes/src/wall/floorplan-affordances.ts | 31 +++++-- .../nodes/src/wall/move-endpoint-tool.tsx | 81 ++++++++++++++----- 3 files changed, 113 insertions(+), 30 deletions(-) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index b9a22d71..611d3c8f 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -327,4 +327,35 @@ describe('snapWallDraftPointDetailed', () => { expect(result.point).toEqual([3.99, 0.03]) expect(result.snap).toBeNull() }) + + // Endpoint-move regression: walls attached to the moving corner keep their + // pre-drag coordinates in the scene during the drag, so their stale corner + // recreates the old junction inside the connect radius. The move tools must + // pass those walls in `ignoreWallIds` (attached mode) or a sub-5cm corner + // correction — e.g. squaring a scan-imported 91° junction — can never land. + test('a stale linked-wall corner swallows a sub-connect-radius correction unless ignored', () => { + // `wall_d` shares the dragged corner of `wall_c` at [2, 0.03]; the user + // drops 3cm away at [2, 0] to square the junction. + const linked = makeWall([2, 0.03], [2, 2], 'wall_d') + + const captured = snapWallDraftPointDetailed({ + point: [2, 0], + walls: [linked], + ignoreWallIds: ['wall_c'], + magnetic: false, + step: 0, + }) + expect(captured.point).toEqual([2, 0.03]) + expect(captured.snap).toBe('endpoint') + + const freed = snapWallDraftPointDetailed({ + point: [2, 0], + walls: [linked], + ignoreWallIds: ['wall_c', 'wall_d'], + magnetic: false, + step: 0, + }) + expect(freed.point).toEqual([2, 0]) + expect(freed.snap).toBeNull() + }) }) diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index 4bb0c18d..86dd79df 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -169,6 +169,18 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { const originalEnd: WallPlanPoint = [...node.end] as WallPlanPoint const linkedWalls = collectLinkedWalls(nodes, node.id, originalStart, originalEnd) const affectedIds: AnyNodeId[] = [node.id, ...linkedWalls.map((w) => w.id)] + const movingOriginal: WallPlanPoint = endpoint === 'start' ? originalStart : originalEnd + // Walls attached to the MOVING corner cascade with the drag, but the snap + // pipeline reads the scene store, which keeps their pre-drag coordinates + // until commit. Their stale corners would recreate the old junction as a + // snap/alignment target: inside the connect radius the endpoint could + // never land closer than ~5cm to where it started, making sub-5cm + // corrections (e.g. squaring a scan-imported 91° corner) impossible. + // Excluded while attached; under Alt-detach they stay put and remain + // legitimate targets. Mirrors the 3D move-endpoint tool. + const movingLinkedWallIds = linkedWalls + .filter((w) => pointsEqual(w.start, movingOriginal) || pointsEqual(w.end, movingOriginal)) + .map((w) => w.id) // Remember the latest preview so `commit()` can write it tracked. let lastPrimaryStart: WallPlanPoint = originalStart @@ -181,11 +193,12 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { // Re-collect walls every tick so the snap pipeline sees fresh // positions (matters when the user releases + re-grabs without // unmounting the layer). Snap reads from scene — which holds - // the pre-drag positions throughout — so the linked-wall snap - // targets stay anchored to where corners *were*, exactly like - // the legacy flow. + // the pre-drag positions throughout — so walls that cascade with + // the moving corner are excluded (stale coordinates); under + // Alt-detach they stay put, so they rejoin the candidate pool. const sceneNodes = useScene.getState().nodes const walls = collectLevelWalls(sceneNodes, node.id) + const staleWallIds = modifiers.altKey ? [node.id] : [node.id, ...movingLinkedWallIds] // The grid step follows the active snapping mode (`getSegmentGridStep()` // is 0 outside grid mode), so `'lines' / 'angles' / 'off'` no longer // force a grid snap the mode chip says is inactive. In `'angles'` mode @@ -195,7 +208,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { const snapped = snapWallDraftPoint({ point: planPoint as WallPlanPoint, walls, - ignoreWallIds: [node.id], + ignoreWallIds: staleWallIds, start: angleLocked ? fixedPoint : undefined, angleSnap: angleLocked, magnetic: isMagneticSnapActive(), @@ -205,13 +218,15 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { // object's edge / wall face and publishes a guide. The guide is // DISPLAYED in every mode except Off (isAlignmentGuideActive); the // magnetic pull onto it is applied only in 'lines' mode - // (isMagneticSnapActive), like the draft tool does. The dragged wall and - // its linked siblings (which cascade with the corner) are excluded from - // the candidate pool. Alt is detach, NOT bypass. + // (isMagneticSnapActive), like the draft tool does. Only the dragged + // wall and the siblings cascading with the moving corner are excluded + // from the candidate pool — walls linked at the FIXED corner don't + // move, and their anchors are what let the dragged corner align back + // onto a true axis. Alt is detach, NOT bypass. const aligned = alignFloorplanDraftPoint(snapped, { applySnap: isMagneticSnapActive(), bypass: !isAlignmentGuideActive(), - excludeIds: [node.id, ...linkedWalls.map((w) => w.id)], + excludeIds: staleWallIds, }) as WallPlanPoint const primaryStart: WallPlanPoint = endpoint === 'start' ? aligned : fixedPoint diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index f8bcc86b..d81d8d8d 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -229,6 +229,21 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const originalStart = originalStartRef.current const originalEnd = originalEndRef.current const fixedPoint = fixedPointRef.current + const movingOriginalPoint = target.endpoint === 'start' ? originalStart : originalEnd + // Walls attached to the MOVING corner cascade with the drag, but the snap + // pipeline reads the scene store, which keeps their pre-drag coordinates + // until commit. Their stale corners would recreate the old junction as a + // snap/alignment target: inside the connect radius the endpoint could + // never land closer than ~5cm to where it started, making sub-5cm + // corrections (e.g. squaring a scan-imported 91° corner) impossible. + // Excluded while attached; under Alt-detach they stay put and remain + // legitimate targets. + const movingLinkedWallIds = linkedOriginalsRef.current + .filter( + (wall) => + samePoint(wall.start, movingOriginalPoint) || samePoint(wall.end, movingOriginalPoint), + ) + .map((wall) => wall.id) const levelWalls = Object.values(useScene.getState().nodes).filter( (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), @@ -238,14 +253,24 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // fences, items, slabs, ceilings, columns), gathered once (the set is // stable during the drag). Coords are building-local, the same frame as // the cursor and the 3D guide layer, so the published guide lines up. + // The attached variant additionally drops anchors owned by walls that + // follow the moving corner (see `movingLinkedWallIds` above) — their + // scene coordinates are stale during the drag. const wallAlignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, nodeId) + const movingLinkedIdSet = new Set(movingLinkedWallIds) + const attachedAlignmentCandidates = wallAlignmentCandidates.filter( + (anchor) => !movingLinkedIdSet.has(anchor.nodeId), + ) pauseSceneHistory(useScene) let wasCommitted = false - // Last point handed to `applyPreview` — lets the Alt keydown/keyup - // handlers re-run the preview immediately on a modifier change instead of - // waiting for the next mousemove. - let lastMovedPoint: WallPlanPoint | null = null + // Last RAW cursor point from `grid:move` — lets the Alt keydown/keyup + // handlers re-run the FULL snap pipeline immediately on a modifier change + // instead of waiting for the next mousemove. The raw point (not the + // snapped one) matters: the snap/alignment candidate set depends on Alt + // (stale-junction exclusion above), so a point snapped under the previous + // modifier state must not be reused as-is. + let lastRawPoint: WallPlanPoint | null = null // The first pointer-up is the *grab* of a click-to-move; later ones are // drops. See the `!hasChanged` branch in `onPointerUp`. let hasReleasedOnce = false @@ -289,7 +314,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => { - lastMovedPoint = movingPoint const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint const linkedUpdates = detachLinkedWalls @@ -353,16 +377,18 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300) } - const onGridMove = (event: GridEvent) => { - const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - // Endpoint move honours the active snapping mode (the HUD chip): grid → - // lattice; lines → magnetic corner/alignment snap; angles → lock the - // segment to 15° rays from the FIXED corner; off → raw. No Shift bypass — - // Shift cycles the mode now, and Off is the bypass. + // Full snap pipeline from a RAW cursor point to the applied endpoint — + // shared by `grid:move` and the Alt keydown/keyup handlers, since the + // candidate set (stale-junction exclusion) flips with the modifier. + // Endpoint move honours the active snapping mode (the HUD chip): grid → + // lattice; lines → magnetic corner/alignment snap; angles → lock the + // segment to 15° rays from the FIXED corner; off → raw. No Shift bypass — + // Shift cycles the mode now, and Off is the bypass. + const resolveDragPoint = (planPoint: WallPlanPoint): WallPlanPoint => { const snapResult = snapWallDraftPointDetailed({ point: planPoint, walls: levelWalls, - ignoreWallIds: [nodeId], + ignoreWallIds: altPressedRef.current ? [nodeId] : [nodeId, ...movingLinkedWallIds], start: fixedPoint, angleSnap: isAngleSnapActive(), magnetic: isMagneticSnapActive(), @@ -379,10 +405,13 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // (isAlignmentGuideActive); the magnetic pull onto them is applied only in // 'lines' mode (isMagneticSnapActive). let alignedPoint = snappedPoint - if (isAlignmentGuideActive() && wallAlignmentCandidates.length > 0) { + const alignmentCandidates = altPressedRef.current + ? wallAlignmentCandidates + : attachedAlignmentCandidates + if (isAlignmentGuideActive() && alignmentCandidates.length > 0) { const ar = resolveAlignment({ moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }], - candidates: wallAlignmentCandidates, + candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) const magnetic = isMagneticSnapActive() @@ -428,14 +457,21 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ : null, ) + return alignedPoint + } + + const onGridMove = (event: GridEvent) => { + const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] + lastRawPoint = planPoint // The keydown listener can't observe an Alt press that predates the // tool mounting; the pointer event can. Sync the shared ref (single Alt - // source for preview, HUD badge, and commit) before applying. + // source for snap targets, preview, HUD badge, and commit) before the + // snap pipeline reads it. if (event.nativeEvent.altKey !== altPressedRef.current) { altPressedRef.current = event.nativeEvent.altKey setAltPressed(event.nativeEvent.altKey) } - applyPreview(alignedPoint, altPressedRef.current) + applyPreview(resolveDragPoint(planPoint), altPressedRef.current) } const onPointerUp = () => { @@ -557,16 +593,17 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ exitMoveMode() } - // Single Alt writer for keyboard transitions. Re-running the preview on - // the flip keeps geometry and the HUD badge in lockstep — detach reverts - // the linked walls instantly, re-attach snaps them onto the dragged point - // — without waiting for the next mousemove. + // Single Alt writer for keyboard transitions. Re-running the FULL snap + // pipeline from the raw cursor on the flip keeps geometry and the HUD + // badge in lockstep — detach reverts the linked walls instantly and + // re-snaps against their (now live) corners, re-attach drops them from + // the candidate set again — without waiting for the next mousemove. const setAltState = (pressed: boolean) => { if (altPressedRef.current === pressed) return altPressedRef.current = pressed setAltPressed(pressed) - if (lastMovedPoint) { - applyPreview(lastMovedPoint, pressed) + if (lastRawPoint) { + applyPreview(resolveDragPoint(lastRawPoint), pressed) } } From 77442861d9a7e44de64bb1e5fa4c690d8a3865b5 Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Wed, 22 Jul 2026 23:32:17 +0530 Subject: [PATCH 08/11] editor: complete floorplan construction documentation (#531) * Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 * fixed conflict * feat(floorplan): add construction dimension strings * feat(floorplan): coordinate opening dimensions * feat(floorplan): add opening documentation * feat(floorplan): add construction dimensions and notes * feat(floorplan): add interior dimensions and curved note leaders * feat(floorplan): improve construction dimensions and document plan * feat(floorplan): harden construction document output * feat(floorplan): add annotation collision diagnostics * feat(floorplan): size export annotations in paper space * feat(floorplan): automatically separate overlapping labels * fix(floorplan): resolve dense label overlaps * fix(floorplan): remove stale collision warning overlays * fix(floorplan): treat mark pills as collision obstacles * feat(floorplan): place short dimension values outside * fix(floorplan): preserve dimension string order * fix(floorplan): avoid architectural geometry in label layout * feat(floorplan): add dimension side fallback leaders * docs(floorplan): update chapter 17 implementation status * fix(floorplan): dimension subdivided interior walls * feat: add associative floor plan dimensions * feat: add continuous construction dimension strings * feat: add structural floor plan grids * feat: coordinate columns with structural grids Snap column placement and movement to structural axes and intersections, derive associative grid references, and preserve floor-plan rotation by allowing secondary-button pointer moves through the grid drafting layer. * feat: add architectural room documentation Add room-role metadata, editable documentation fields, centered room labels, and persisted live/PDF visibility while preserving generic zone behavior. * feat: generate architectural room schedules Add registry-driven room schedule rows with unit-aware areas and heights, natural room ordering, enclosure resolution, and document-quality warnings. * feat: add reliable room clear dimensions Derive unit-aware clear dimensions from proven modeled inside wall faces for straight rectangular rooms, including rotated and split-wall enclosures, while suppressing unproven datums. * feat: add architectural stair documentation Add level-aware UP/DN graphics, derived flight and rail notes, plan break and overhead conventions, linked destination-level projection, and persisted live/PDF visibility. * feat: add typed specialty construction notes Add schema-validated specialty payloads, standardized plan notation, contract-scope metadata, configurable overhead outlines, and editor authoring controls. * feat: add curved and circular dimensions Add associative radius, diameter, center, chord, arc-length, angular, and coordinate modes with unit-aware notation, repeated-feature labels, 2D authoring, and document controls. * feat: coordinate floor plan drawing types Add persistent floor, foundation, reflected-ceiling, roof, and site plan views with per-dimension show, omit, reference, and foundation-controller behavior across live and PDF output. * feat: add associative curved wall dimensions Bind radius, center, chord, arc-length, and angular construction dimensions directly to curved wall geometry so annotations update when the host curve changes. * fix: render automatic curved wall dimensions The wall floor-plan builder explicitly skipped curved walls, leaving the associative authoring workflow as the only dimension path. Render a concentric arc-length dimension automatically and keep it governed by automatic-dimension visibility. * fix: use radius callout for curved walls Replace the automatic arc-length annotation with the source-standard radius method: computed center mark, radial leader, curve arrow, and R value. Keep adjacent linear strings responsible for locating the curve tangencies and depth. * Implement construction dimension string editing * Add construction dimension standards controls * Apply drawing standards to automatic dimensions * Add floorplan overhead and reference visibility controls * Add view-specific dimension segment suppression * Add persistent drawing sheet model * Plot floorplan exports at fixed scale * Apply paper-space annotation profiles * Compose floorplan PDF sheets * Support sheet paper sizes and preflight * Persist pinned annotation layout overrides * Expand annotation collision obstacles * Add floorplan annotation preflight surface * Add reusable drawing sheet general notes * Add drawing sheet keyed note instances * Add drawing sheet document markers * Expand construction note leader terminators * Add wall assembly layer model * Resolve wall assembly datum references * Add wall assembly floorplan graphics * Add opening documentation dimension policies * Add finish-face room clear dimensions * Extend room clear dimensions to rectilinear rooms * Add construction module advisories * Add clearance advisory profiles * Add dimension completeness audit * Expand dimension completeness audit * Include preflight issues in completeness audit * feat: complete floorplan construction documentation * refactor: remove construction note node * feat: refine floorplan documentation and unit display * fix(editor): improve floorplan PDF dimensions * fix(floorplan): refresh annotation collision layout * fix(floorplan): keep annotations clear and restore registry boundaries * feat(floorplan): refine construction dimension references * fix(floorplan): align documentation tools with architecture --------- Co-authored-by: Claude Opus 4.6 --- apps/editor/app/page.tsx | 2 + apps/editor/components/build-tab.tsx | 74 +- .../floorplan-construction-preflight.tsx | 70 + apps/editor/components/viewer-toolbar.tsx | 218 ++- apps/editor/public/icons/structural-grid.webp | Bin 0 -> 72538 bytes apps/ifc-converter/next-env.d.ts | 2 +- bun.lock | 132 +- design-qa.md | 64 + packages/core/src/events/bus.ts | 9 + packages/core/src/index.ts | 6 + .../core/src/lib/measurement-geometry.test.ts | 33 +- packages/core/src/lib/measurement-geometry.ts | 50 +- packages/core/src/lib/zone-quantities.ts | 2 +- packages/core/src/registry/index.ts | 3 + packages/core/src/registry/subtree.test.ts | 111 +- packages/core/src/registry/subtree.ts | 14 +- packages/core/src/registry/types.ts | 63 +- packages/core/src/schema/index.ts | 66 +- packages/core/src/schema/nodes/building.ts | 7 +- .../nodes/construction-dimension.test.ts | 176 ++ .../schema/nodes/construction-dimension.ts | 205 +++ packages/core/src/schema/nodes/door.ts | 23 + .../src/schema/nodes/drawing-sheet.test.ts | 222 +++ .../core/src/schema/nodes/drawing-sheet.ts | 263 +++ packages/core/src/schema/nodes/level.ts | 4 + .../src/schema/nodes/structural-grid.test.ts | 33 + .../core/src/schema/nodes/structural-grid.ts | 22 + packages/core/src/schema/nodes/wall.test.ts | 222 ++- packages/core/src/schema/nodes/wall.ts | 260 +++ packages/core/src/schema/nodes/window.ts | 22 + packages/core/src/schema/nodes/zone.test.ts | 53 + packages/core/src/schema/nodes/zone.ts | 15 + packages/core/src/schema/types.ts | 6 + ...e-construction-dimension-migration.test.ts | 70 + packages/core/src/store/use-scene.ts | 25 + packages/core/src/systems/wall/wall-curve.ts | 2 +- .../src/systems/wall/wall-mitering.test.ts | 1 + .../core/src/utils/clone-scene-graph.test.ts | 113 ++ packages/core/src/utils/clone-scene-graph.ts | 22 +- packages/editor/package.json | 6 +- .../floorplan-alignment-guide-layer.tsx | 3 +- .../floorplan-measurement-tool-layer.tsx | 26 +- .../floorplan-registered-tool-layer.tsx | 61 + .../floorplan-registry-action-menu.tsx | 39 +- .../floorplan-annotation-layout.test.ts | 408 +++++ .../renderers/floorplan-annotation-layout.ts | 688 +++++++ .../floorplan-dimension-renderer.test.tsx | 236 +++ .../floorplan-dimension-renderer.tsx | 608 ++++++ .../floorplan-geometry-renderer.test.tsx | 369 ++++ .../renderers/floorplan-geometry-renderer.tsx | 457 ++++- .../floorplan-registry-layer.test.ts | 122 ++ .../renderers/floorplan-registry-layer.tsx | 425 +++-- .../renderers/floorplan-stair-layer.tsx | 30 +- .../src/components/editor/floorplan-panel.tsx | 25 +- .../components/editor/measurement-pill.tsx | 7 +- .../editor/quick-measurement-card.tsx | 12 +- .../editor/quick-measurement-hud.tsx | 8 +- .../ui/action-menu/measurement-control.tsx | 123 +- .../sidebar/panels/settings-panel/index.tsx | 11 +- packages/editor/src/index.tsx | 35 + .../floorplan/annotation-visibility.test.ts | 249 +++ .../lib/floorplan/annotation-visibility.ts | 110 ++ .../floorplan/drawing-coordination.test.ts | 46 + .../src/lib/floorplan/drawing-coordination.ts | 13 + .../lib/floorplan/floorplan-export.test.ts | 591 ++++++ .../src/lib/floorplan/floorplan-export.tsx | 1593 ++++++++++++++-- .../lib/floorplan/floorplan-extension.test.ts | 37 + .../src/lib/floorplan/floorplan-extension.ts | 177 ++ .../floorplan/floorplan-pdfkit-document.ts | 194 ++ .../floorplan-pdfkit-renderer.test.ts | 107 ++ .../floorplan/floorplan-pdfkit-renderer.ts | 680 +++++++ packages/editor/src/lib/floorplan/geometry.ts | 5 +- packages/editor/src/lib/measurements.test.ts | 5 + packages/editor/src/lib/measurements.ts | 13 +- .../editor/src/store/use-drawing-view.test.ts | 31 + packages/editor/src/store/use-drawing-view.ts | 130 ++ packages/editor/src/store/use-editor.tsx | 6 +- .../use-floorplan-annotation-visibility.ts | 60 + .../src/store/use-floorplan-preflight.ts | 54 + .../nodes/src/building/definition.test.ts | 17 + packages/nodes/src/building/definition.ts | 2 +- packages/nodes/src/column/definition.ts | 27 +- packages/nodes/src/column/floorplan-move.ts | 13 +- packages/nodes/src/column/floorplan.test.ts | 96 + packages/nodes/src/column/floorplan.ts | 88 +- packages/nodes/src/column/tool.tsx | 31 +- .../construction-dimension/definition.test.ts | 34 + .../src/construction-dimension/definition.ts | 91 + .../drawing-coordination.test.ts | 83 + .../drawing-coordination.ts | 76 + .../floorplan-affordances.test.ts | 126 ++ .../floorplan-affordances.ts | 154 ++ .../floorplan-tool.test.ts | 180 ++ .../construction-dimension/floorplan-tool.tsx | 726 ++++++++ .../construction-dimension/floorplan.test.ts | 518 ++++++ .../src/construction-dimension/floorplan.ts | 660 +++++++ .../src/construction-dimension/geometry.ts | 154 ++ .../nodes/src/construction-dimension/index.ts | 6 + .../src/construction-dimension/panel.tsx | 416 +++++ .../src/construction-dimension/parametrics.ts | 6 + .../src/construction-dimension/schema.ts | 6 + packages/nodes/src/door/definition.ts | 13 +- packages/nodes/src/door/floorplan.ts | 38 +- packages/nodes/src/door/panel.tsx | 17 + packages/nodes/src/door/schema.ts | 6 +- .../src/drawing-sheet/definition.test.ts | 52 + .../nodes/src/drawing-sheet/definition.ts | 51 + packages/nodes/src/drawing-sheet/index.ts | 1 + packages/nodes/src/drawing-sheet/schema.ts | 18 + packages/nodes/src/fence/tool.tsx | 1 + packages/nodes/src/index.ts | 38 + .../nodes/src/measurement/floorplan.test.ts | 30 +- packages/nodes/src/measurement/floorplan.ts | 195 +- packages/nodes/src/measurement/index.ts | 1 + packages/nodes/src/measurement/resolve.ts | 4 +- .../src/shared/clearance-advisories.test.ts | 209 +++ .../nodes/src/shared/clearance-advisories.ts | 513 ++++++ .../construction-dimension-standards.ts | 43 + .../src/shared/construction-length.test.ts | 29 + .../nodes/src/shared/construction-length.ts | 77 + .../construction-module-advisories.test.ts | 181 ++ .../shared/construction-module-advisories.ts | 326 ++++ .../dimension-completeness-audit.test.ts | 340 ++++ .../shared/dimension-completeness-audit.ts | 495 +++++ .../nodes/src/shared/dimension-string.test.ts | 61 + packages/nodes/src/shared/dimension-string.ts | 47 + .../shared/opening-documentation-fields.tsx | 199 ++ .../src/shared/opening-documentation.test.ts | 205 +++ .../nodes/src/shared/opening-documentation.ts | 413 +++++ .../opening-placement-dimensions.test.ts | 65 + .../shared/opening-placement-dimensions.ts | 11 +- .../src/slab/placement-ownership.test.ts | 19 + .../nodes/src/slab/placement-ownership.ts | 8 + packages/nodes/src/slab/tool.tsx | 19 +- packages/nodes/src/stair/definition.ts | 8 + .../nodes/src/stair/documentation.test.ts | 141 ++ packages/nodes/src/stair/documentation.ts | 349 ++++ packages/nodes/src/stair/floorplan.test.ts | 62 + packages/nodes/src/stair/floorplan.ts | 52 +- .../src/structural-grid/coordination.test.ts | 66 + .../nodes/src/structural-grid/coordination.ts | 149 ++ .../src/structural-grid/definition.test.ts | 14 + .../nodes/src/structural-grid/definition.ts | 59 + .../structural-grid/floorplan-tool.test.ts | 59 + .../src/structural-grid/floorplan-tool.tsx | 313 ++++ .../src/structural-grid/floorplan.test.ts | 55 + .../nodes/src/structural-grid/floorplan.ts | 76 + packages/nodes/src/structural-grid/index.ts | 1 + packages/nodes/src/structural-grid/schema.ts | 1 + ...ruction-dimension-reference-policy.test.ts | 335 ++++ .../src/wall/construction-dimensions.test.ts | 1326 ++++++++++++++ .../nodes/src/wall/construction-dimensions.ts | 1622 +++++++++++++++++ packages/nodes/src/wall/definition.test.ts | 30 + packages/nodes/src/wall/definition.ts | 20 +- .../src/wall/floorplan-overrides.test.ts | 48 + .../nodes/src/wall/floorplan-overrides.ts | 36 +- packages/nodes/src/wall/floorplan.test.ts | 284 +++ packages/nodes/src/wall/floorplan.ts | 611 ++++++- packages/nodes/src/wall/measurement.test.ts | 32 +- packages/nodes/src/wall/measurement.ts | 13 + packages/nodes/src/wall/panel.tsx | 166 +- packages/nodes/src/wall/schema.ts | 19 +- packages/nodes/src/wall/tool.tsx | 1 + packages/nodes/src/window/definition.ts | 13 +- packages/nodes/src/window/floorplan.ts | 14 + packages/nodes/src/window/panel.tsx | 18 + packages/nodes/src/window/schema.ts | 6 +- packages/nodes/src/zone/definition.ts | 9 +- packages/nodes/src/zone/floorplan.test.ts | 67 + packages/nodes/src/zone/floorplan.ts | 82 +- packages/nodes/src/zone/quantities-panel.tsx | 252 ++- .../src/zone/room-clear-dimensions.test.ts | 293 +++ .../nodes/src/zone/room-clear-dimensions.ts | 609 +++++++ .../nodes/src/zone/room-documentation.test.ts | 144 ++ packages/nodes/src/zone/room-documentation.ts | 125 ++ packages/viewer/src/index.ts | 6 +- packages/viewer/src/store/use-viewer.test.ts | 12 + packages/viewer/src/store/use-viewer.ts | 10 + wiki/architecture/measurements.md | 2 +- wiki/floorplan-chapter-17-assessment.md | 203 +++ wiki/floorplan-pdf-export-library-research.md | 269 +++ 181 files changed, 25002 insertions(+), 800 deletions(-) create mode 100644 apps/editor/components/floorplan-construction-preflight.tsx create mode 100644 apps/editor/public/icons/structural-grid.webp create mode 100644 design-qa.md create mode 100644 packages/core/src/schema/nodes/construction-dimension.test.ts create mode 100644 packages/core/src/schema/nodes/construction-dimension.ts create mode 100644 packages/core/src/schema/nodes/drawing-sheet.test.ts create mode 100644 packages/core/src/schema/nodes/drawing-sheet.ts create mode 100644 packages/core/src/schema/nodes/structural-grid.test.ts create mode 100644 packages/core/src/schema/nodes/structural-grid.ts create mode 100644 packages/core/src/schema/nodes/zone.test.ts create mode 100644 packages/core/src/store/use-scene-construction-dimension-migration.test.ts create mode 100644 packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx create mode 100644 packages/editor/src/lib/floorplan/annotation-visibility.test.ts create mode 100644 packages/editor/src/lib/floorplan/annotation-visibility.ts create mode 100644 packages/editor/src/lib/floorplan/drawing-coordination.test.ts create mode 100644 packages/editor/src/lib/floorplan/drawing-coordination.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-export.test.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-extension.test.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-extension.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-pdfkit-document.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts create mode 100644 packages/editor/src/store/use-drawing-view.test.ts create mode 100644 packages/editor/src/store/use-drawing-view.ts create mode 100644 packages/editor/src/store/use-floorplan-annotation-visibility.ts create mode 100644 packages/editor/src/store/use-floorplan-preflight.ts create mode 100644 packages/nodes/src/building/definition.test.ts create mode 100644 packages/nodes/src/column/floorplan.test.ts create mode 100644 packages/nodes/src/construction-dimension/definition.test.ts create mode 100644 packages/nodes/src/construction-dimension/definition.ts create mode 100644 packages/nodes/src/construction-dimension/drawing-coordination.test.ts create mode 100644 packages/nodes/src/construction-dimension/drawing-coordination.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan-affordances.test.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan-affordances.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan-tool.test.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan-tool.tsx create mode 100644 packages/nodes/src/construction-dimension/floorplan.test.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan.ts create mode 100644 packages/nodes/src/construction-dimension/geometry.ts create mode 100644 packages/nodes/src/construction-dimension/index.ts create mode 100644 packages/nodes/src/construction-dimension/panel.tsx create mode 100644 packages/nodes/src/construction-dimension/parametrics.ts create mode 100644 packages/nodes/src/construction-dimension/schema.ts create mode 100644 packages/nodes/src/drawing-sheet/definition.test.ts create mode 100644 packages/nodes/src/drawing-sheet/definition.ts create mode 100644 packages/nodes/src/drawing-sheet/index.ts create mode 100644 packages/nodes/src/drawing-sheet/schema.ts create mode 100644 packages/nodes/src/shared/clearance-advisories.test.ts create mode 100644 packages/nodes/src/shared/clearance-advisories.ts create mode 100644 packages/nodes/src/shared/construction-dimension-standards.ts create mode 100644 packages/nodes/src/shared/construction-length.test.ts create mode 100644 packages/nodes/src/shared/construction-length.ts create mode 100644 packages/nodes/src/shared/construction-module-advisories.test.ts create mode 100644 packages/nodes/src/shared/construction-module-advisories.ts create mode 100644 packages/nodes/src/shared/dimension-completeness-audit.test.ts create mode 100644 packages/nodes/src/shared/dimension-completeness-audit.ts create mode 100644 packages/nodes/src/shared/dimension-string.test.ts create mode 100644 packages/nodes/src/shared/dimension-string.ts create mode 100644 packages/nodes/src/shared/opening-documentation-fields.tsx create mode 100644 packages/nodes/src/shared/opening-documentation.test.ts create mode 100644 packages/nodes/src/shared/opening-documentation.ts create mode 100644 packages/nodes/src/shared/opening-placement-dimensions.test.ts create mode 100644 packages/nodes/src/slab/placement-ownership.test.ts create mode 100644 packages/nodes/src/slab/placement-ownership.ts create mode 100644 packages/nodes/src/stair/documentation.test.ts create mode 100644 packages/nodes/src/stair/documentation.ts create mode 100644 packages/nodes/src/stair/floorplan.test.ts create mode 100644 packages/nodes/src/structural-grid/coordination.test.ts create mode 100644 packages/nodes/src/structural-grid/coordination.ts create mode 100644 packages/nodes/src/structural-grid/definition.test.ts create mode 100644 packages/nodes/src/structural-grid/definition.ts create mode 100644 packages/nodes/src/structural-grid/floorplan-tool.test.ts create mode 100644 packages/nodes/src/structural-grid/floorplan-tool.tsx create mode 100644 packages/nodes/src/structural-grid/floorplan.test.ts create mode 100644 packages/nodes/src/structural-grid/floorplan.ts create mode 100644 packages/nodes/src/structural-grid/index.ts create mode 100644 packages/nodes/src/structural-grid/schema.ts create mode 100644 packages/nodes/src/wall/construction-dimension-reference-policy.test.ts create mode 100644 packages/nodes/src/wall/construction-dimensions.test.ts create mode 100644 packages/nodes/src/wall/construction-dimensions.ts create mode 100644 packages/nodes/src/wall/definition.test.ts create mode 100644 packages/nodes/src/wall/floorplan-overrides.test.ts create mode 100644 packages/nodes/src/wall/floorplan.test.ts create mode 100644 packages/nodes/src/zone/floorplan.test.ts create mode 100644 packages/nodes/src/zone/room-clear-dimensions.test.ts create mode 100644 packages/nodes/src/zone/room-clear-dimensions.ts create mode 100644 packages/nodes/src/zone/room-documentation.test.ts create mode 100644 packages/nodes/src/zone/room-documentation.ts create mode 100644 wiki/floorplan-chapter-17-assessment.md create mode 100644 wiki/floorplan-pdf-export-library-research.md diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index e4ccf7ed..7119d312 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -5,6 +5,7 @@ import { Hammer, Layers, Package, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { BuildTab } from '@/components/build-tab' +import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight' import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight, @@ -89,6 +90,7 @@ const PROJECT_ID = 'local-editor' export default function Home() { return (
+ {PROJECT_ID === 'local-editor' && (
diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index bb476542..1ec86d04 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,7 +1,12 @@ 'use client' import { nodeRegistry } from '@pascal-app/core' -import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' +import { + getFloorplanNodeExtension, + MaterialPaintPanel, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -13,24 +18,6 @@ import { } from '@/components/toolbar-tooltip' import { cn } from '@/lib/utils' -/** - * Raw structure-tool kinds the Build tab can activate. These map 1:1 to the - * editor's `StructureTool` ids. - */ -type BuildToolKind = - | 'wall' - | 'fence' - | 'slab' - | 'ceiling' - | 'roof' - | 'stair' - | 'elevator' - | 'door' - | 'window' - | 'column' - | 'shelf' - | 'spawn' - /** * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP" * group tile — its own sub-grid, like Roof's "Features". @@ -53,7 +40,8 @@ type BuildType = { /** Raster asset tile (legacy Build sidebar artwork). */ iconSrc: string /** Present for structure-tool types (absent for paint mode and the MEP group). */ - kind?: BuildToolKind + kind?: string + paletteOrder?: number /** Non-placement special mode. */ mode?: 'material-paint' } @@ -67,7 +55,7 @@ type MepItem = { } // Same icons + ordering as the community Build sidebar, minus presets. -const BUILD_TYPES: BuildType[] = [ +const BASE_BUILD_TYPES: BuildType[] = [ { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, @@ -85,6 +73,37 @@ const BUILD_TYPES: BuildType[] = [ { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, ] +function collectBuildTypes(): BuildType[] { + const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) + const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ + ...type, + paletteOrder: + nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, + })) + for (const [kind, definition] of nodeRegistry.entries()) { + const presentation = definition.presentation + const extension = getFloorplanNodeExtension(definition) + if ( + baseKinds.has(kind) || + !extension?.tool || + !presentation || + presentation.hidden || + presentation.paletteSection !== 'structure' + ) { + continue + } + tools.push({ + id: kind, + kind, + label: presentation.label, + iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', + paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, + }) + } + tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) + return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] +} + // MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP // tools had in the community Build sidebar. const MEP_ITEMS: MepItem[] = [ @@ -105,8 +124,10 @@ const MEP_ITEMS: MepItem[] = [ * Activate a raw structure draw/cursor tool. Mirrors the editor's own * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). */ -function activateBuildTool(kind: BuildToolKind | MepToolKind): void { +function activateBuildTool(kind: string): void { const ed = useEditor.getState() + const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView + if (preferredView) ed.setViewMode(preferredView) ed.setPhase('structure') ed.setStructureLayer('elements') ed.setCatalogCategory(null) @@ -142,7 +163,7 @@ function activateRoofFeatureTool(kind: string): void { ed.setStructureLayer('elements') ed.setCatalogCategory(null) ed.setMode('build') - ed.setTool(kind as Parameters[0]) + ed.setTool(kind) } /** @@ -165,6 +186,7 @@ export function BuildTab() { const mode = useEditor((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) + const buildTypes = useMemo(collectBuildTypes, []) // The fitting / follow tools are armed from a segment's panel, not a grid // tile — keep the segment tile lit so the panel (and the way back) stays @@ -245,9 +267,9 @@ export function BuildTab() { didInitRef.current = true const ed = useEditor.getState() if (ed.mode === 'build' && ed.tool) return - const firstType = BUILD_TYPES.find((t) => t.kind) + const firstType = buildTypes.find((t) => t.kind) if (firstType) handleTypeClick(firstType) - }, [handleTypeClick]) + }, [buildTypes, handleTypeClick]) return (
@@ -256,7 +278,7 @@ export function BuildTab() { className="grid gap-1.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }} > - {BUILD_TYPES.map((type) => { + {buildTypes.map((type) => { const active = isTypeActive(type) return ( diff --git a/apps/editor/components/floorplan-construction-preflight.tsx b/apps/editor/components/floorplan-construction-preflight.tsx new file mode 100644 index 00000000..19d30b85 --- /dev/null +++ b/apps/editor/components/floorplan-construction-preflight.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useFloorplanPreflight } from '@pascal-app/editor' +import { + buildClearanceAdvisories, + buildConstructionModuleAdvisories, + buildDimensionCompletenessAudit, +} from '@pascal-app/nodes' +import { useEffect, useMemo, useState } from 'react' + +export function FloorplanConstructionPreflight() { + const nodes = useDebouncedSceneNodes() + const clearanceChecksEnabled = useFloorplanPreflight((state) => state.clearanceChecksEnabled) + const moduleChecksEnabled = useFloorplanPreflight((state) => state.moduleChecksEnabled) + const setAuditIssues = useFloorplanPreflight((state) => state.setAuditIssues) + + const issues = useMemo(() => { + const completeness = buildDimensionCompletenessAudit(nodes, { + includeAutomaticDimensions: true, + }).map((issue) => ({ + id: issue.id, + kind: 'dimension-completeness' as const, + severity: issue.severity, + message: issue.message, + })) + const clearance = clearanceChecksEnabled + ? buildClearanceAdvisories(nodes, { includeDisabled: true }).map((issue) => ({ + id: issue.id, + kind: 'clearance-advisory' as const, + severity: issue.severity, + message: issue.message, + })) + : [] + const modules = moduleChecksEnabled + ? buildConstructionModuleAdvisories(nodes, { includeDisabled: true }).map((issue) => ({ + id: issue.id, + kind: 'module-advisory' as const, + severity: issue.severity, + message: issue.message, + })) + : [] + return [...completeness, ...clearance, ...modules] + }, [clearanceChecksEnabled, moduleChecksEnabled, nodes]) + + useEffect(() => { + setAuditIssues(issues) + return () => setAuditIssues([]) + }, [issues, setAuditIssues]) + + return null +} + +function useDebouncedSceneNodes() { + const [nodes, setNodes] = useState(() => useScene.getState().nodes) + + useEffect(() => { + let pending: ReturnType | undefined + const unsubscribe = useScene.subscribe((state) => { + if (pending) clearTimeout(pending) + pending = setTimeout(() => setNodes(state.nodes), 100) + }) + return () => { + if (pending) clearTimeout(pending) + unsubscribe() + } + }, []) + + return nodes +} diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index d4443a32..77809e10 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -2,6 +2,7 @@ import { Icon as IconifyIcon } from '@iconify/react' import { + DRAWING_TYPE_OPTIONS, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -10,7 +11,9 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + useDrawingView, useEditor, + useFloorplanAnnotationVisibility, useSidebarStore, type ViewMode, } from '@pascal-app/editor' @@ -32,12 +35,16 @@ import { EyeOff, Footprints, Grid2X2, + Layers3, Magnet, PenLine, Ruler, + ScanLine, SlidersHorizontal, Sparkles, + SquareUserRound, SwatchBook, + Tag, } from 'lucide-react' import Image from 'next/image' import { type ReactNode, useCallback } from 'react' @@ -133,6 +140,22 @@ const SHADING_OPTIONS = [ { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, ] as const +const FLOORPLAN_ANNOTATION_OPTIONS = [ + { id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler }, + { id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler }, + { id: 'measurements', name: 'Measurements', icon: ScanLine }, + { id: 'openingMarks', name: 'Door/window marks', icon: Tag }, + { id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 }, + { id: 'roomLabels', name: 'Room labels', icon: SquareUserRound }, + { id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints }, +] as const + +const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [ + { id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' }, + { id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' }, + { id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' }, +] as const + function ViewModeControl() { const viewMode = useEditor((state) => state.viewMode) const setViewMode = useEditor((state) => state.setViewMode) @@ -165,6 +188,49 @@ function ViewModeControl() { ) } +function DrawingTypeControl() { + const viewMode = useEditor((state) => state.viewMode) + const drawingType = useDrawingView((state) => state.drawingType) + const setDrawingType = useDrawingView((state) => state.setDrawingType) + if (viewMode === '3d') return null + + const active = + DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType) ?? DRAWING_TYPE_OPTIONS[0] + + return ( +
+ + + + + + + + {DRAWING_TYPE_OPTIONS.map((option) => ( + setDrawingType(option.id)}> + + {option.label} + {drawingType === option.id ? : null} + + ))} + + +
+ ) +} + function CollapseSidebarButton() { const isCollapsed = useSidebarStore((state) => state.isCollapsed) const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) @@ -278,12 +344,15 @@ const EDGE_OPTIONS = [ const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl' function DisplayMenu() { + const viewMode = useEditor((state) => state.viewMode) const showGrid = useViewer((state) => state.showGrid) const setShowGrid = useViewer((state) => state.setShowGrid) const showMeasurements = useViewer((state) => state.showMeasurements) const setShowMeasurements = useViewer((state) => state.setShowMeasurements) const unit = useViewer((state) => state.unit) const setUnit = useViewer((state) => state.setUnit) + const metricNotation = useViewer((state) => state.metricNotation) + const setMetricNotation = useViewer((state) => state.setMetricNotation) const cameraMode = useViewer((state) => state.cameraMode) const setCameraMode = useViewer((state) => state.setCameraMode) const shading = useViewer((state) => state.shading) @@ -296,6 +365,14 @@ function DisplayMenu() { const setShadows = useViewer((state) => state.setShadows) const magneticSnap = useEditor((state) => state.magneticSnap) const setMagneticSnap = useEditor((state) => state.setMagneticSnap) + const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility) + const setAnnotationCategory = useFloorplanAnnotationVisibility((state) => state.setCategory) + const wallDimensionReference = useFloorplanAnnotationVisibility( + (state) => state.wallDimensionReference, + ) + const setWallDimensionReference = useFloorplanAnnotationVisibility( + (state) => state.setWallDimensionReference, + ) const activeShading = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] @@ -337,17 +414,82 @@ function DisplayMenu() { )} - keepOpen(e, () => setShowMeasurements(!showMeasurements))} - > - - Measurements - {showMeasurements ? ( - - ) : ( - - )} - + {viewMode !== '2d' ? ( + keepOpen(e, () => setShowMeasurements(!showMeasurements))} + > + + {viewMode === 'split' ? '3D measurements' : 'Measurements'} + {showMeasurements ? ( + + ) : ( + + )} + + ) : null} + {viewMode !== '3d' ? ( + <> + + + + Floor plan annotations + + + {FLOORPLAN_ANNOTATION_OPTIONS.map((option) => { + const OptionIcon = option.icon + const visible = annotationVisibility[option.id] + return ( + + keepOpen(e, () => setAnnotationCategory(option.id, !visible)) + } + > + + {option.name} + {visible ? ( + + ) : ( + + )} + + ) + })} + + + + + + Wall dimensions + + { + FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( + (option) => option.id === wallDimensionReference, + )?.name + } + + + + {FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => ( + + keepOpen(event, () => setWallDimensionReference(option.id)) + } + > +
+ {option.name} + {option.detail} +
+ {wallDimensionReference === option.id ? ( + + ) : null} +
+ ))} +
+
+ + ) : null} keepOpen(e, () => setMagneticSnap(!magneticSnap))}> Magnetic snap @@ -377,17 +519,48 @@ function DisplayMenu() { {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} - keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))} - > - - {unit === 'metric' ? 'm' : 'ft'} - - Units - - {unit === 'metric' ? 'Metric' : 'Imperial'} - - + + + + {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'} + + Units + + {unit === 'imperial' + ? 'Feet & inches' + : metricNotation === 'millimeters' + ? 'Millimeters' + : 'Meters'} + + + + setMetricNotation('meters')}> + + m + + Meters + {unit === 'metric' && metricNotation === 'meters' ? ( + + ) : null} + + setMetricNotation('millimeters')}> + + mm + + Millimeters + {unit === 'metric' && metricNotation === 'millimeters' ? ( + + ) : null} + + setUnit('imperial')}> + + ft + + Feet & inches + {unit === 'imperial' ? : null} + + + @@ -521,6 +694,7 @@ export function CommunityViewerToolbarLeft() { <> + ) } diff --git a/apps/editor/public/icons/structural-grid.webp b/apps/editor/public/icons/structural-grid.webp new file mode 100644 index 0000000000000000000000000000000000000000..bc5ec5053ef9a1c14cf35bc8568c0db851af41b8 GIT binary patch literal 72538 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 literal 0 HcmV?d00001 diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/bun.lock b/bun.lock index 2a57c1e6..5cbd6c7a 100644 --- a/bun.lock +++ b/bun.lock @@ -98,7 +98,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -124,7 +124,7 @@ }, "packages/editor": { "name": "@pascal-app/editor", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -145,35 +145,37 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@visual-json/react": "^0.4.0", + "blob-stream": "^0.1.3", "class-variance-authority": "^0.7.1", "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", + "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^0.9.1", - "@pascal-app/viewer": "^0.9.1", + "@pascal-app/core": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@pascal/typescript-config": "*", + "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", "@types/howler": "^2.2.12", + "@types/pdfkit": "^0.17.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", - "@pascal-app/viewer": "^0.9.1", + "@pascal-app/core": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -201,7 +203,7 @@ }, "packages/ifc-converter": { "name": "@pascal-app/ifc-converter", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@pascal-app/core": "*", "nanoid": "^5.1.6", @@ -215,7 +217,7 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "0.3.1", + "version": "0.3.2", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, @@ -225,22 +227,22 @@ "zod": "^4.3.5", }, "devDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", "@pascal/typescript-config": "*", "@types/node": "^22.19.20", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", }, }, "packages/nodes": { "name": "@pascal-app/nodes", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { - "@pascal-app/core": "^0.9.0", - "@pascal-app/editor": "^0.9.0", - "@pascal-app/viewer": "^0.9.0", + "@pascal-app/core": "^0.9.2", + "@pascal-app/editor": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", @@ -249,9 +251,9 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.0", - "@pascal-app/editor": "^0.9.0", - "@pascal-app/viewer": "^0.9.0", + "@pascal-app/core": "^0.9.2", + "@pascal-app/editor": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "lucide-react": "^1", @@ -283,7 +285,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", @@ -297,7 +299,7 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", @@ -538,6 +540,10 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -852,6 +858,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/blob-stream": ["@types/blob-stream@0.1.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HNHZ1S6W7F8PhxdyAastunpUC8cAZim78UIfqbL79gLzylp8EZep68yxAh11hTRoEvsqHAg/MECgmKF8+V0HzQ=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], @@ -868,9 +876,7 @@ "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], - "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], - - "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -882,8 +888,6 @@ "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -978,8 +982,6 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], @@ -988,12 +990,20 @@ "bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], + "blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="], + + "blob-stream": ["blob-stream@0.1.3", "", { "dependencies": { "blob": "0.0.4" } }, "sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + + "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -1014,8 +1024,6 @@ "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], - "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -1030,6 +1038,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -1056,18 +1066,12 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], - - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -1100,9 +1104,9 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], - "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], @@ -1202,8 +1206,6 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1232,7 +1234,7 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -1302,8 +1304,6 @@ "howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="], - "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1330,8 +1330,6 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1404,6 +1402,8 @@ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], @@ -1422,8 +1422,6 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="], - "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -1460,6 +1458,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -1580,7 +1580,7 @@ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -1598,7 +1598,7 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + "pdfkit": ["pdfkit@0.19.1", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.1.0" } }, "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1606,6 +1606,8 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -1632,8 +1634,6 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1660,8 +1660,6 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], - "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1674,9 +1672,9 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], - "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -1726,10 +1724,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="], - - "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], - "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], @@ -1768,12 +1762,6 @@ "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], - "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="], - - "svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="], - - "svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -1782,8 +1770,6 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], - "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], @@ -1792,6 +1778,8 @@ "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1844,6 +1832,10 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], @@ -1860,8 +1852,6 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], - "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1962,6 +1952,8 @@ "agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "browserify-zlib/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="], "deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -1978,6 +1970,8 @@ "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..8ed33c4f --- /dev/null +++ b/design-qa.md @@ -0,0 +1,64 @@ +**Comparison Target** + +- Source visual truth: `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-18484c1f-bc43-43a1-a329-791cb5605ff4.png` and `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-1ff72f24-c84b-4b6a-94eb-4ced9a04bd4d.png`. +- Implementation screenshot: `/private/tmp/internal-dimension-lines-preview.png`. +- Viewport: 1280 × 720. +- State: local scene route after clicking 2D and waiting 2.5 seconds; 3D remained selected and the scene remained on its loading indicator. +- Intended state: internal dimension baselines, witness lines, ticks, and values render clear of the wall, and enclosed perimeter doors receive room-side width dimensions. + +**Full-view Comparison Evidence** + +- The source screenshots show internal values while their linework is collapsed onto the host walls, making the strings read as detached text. +- The larger left perimeter door has no room-side width dimension in the source state. +- The implementation screenshot could not be compared at the same scene state because the local scene did not finish loading. + +**Focused-region Comparison Evidence** + +- Source: the horizontal internal strings contain values such as `0.5m`, `0.9m`, `4.9m`, and `3.5m`, but the intended parallel baseline and extension linework is not visibly separated from the wall. +- Generated implementation geometry now places automatic internal baselines at `0.55 m` from their witness origins rather than explicitly pinning them at `0 m`. +- Generated plans now include room-side opening chains for enclosed perimeter walls in all four orientations, including a left-side door. +- A same-state rendered focused comparison is blocked by the local loading state. + +**Findings** + +- [P0] Browser-rendered implementation evidence unavailable. + Location: local editor preview. + Evidence: the captured implementation contains only the loading indicator; clicking 2D leaves 3D selected. + Impact: final screen-space line visibility, collisions, and door-width placement cannot be visually accepted. + Fix: restore a working local scene preview and recapture the reported room in 2D. + +**Required Fidelity Surfaces** + +- Fonts and typography: source labels remain unchanged by this fix; post-fix rendered typography is blocked from inspection. +- Spacing and layout rhythm: geometry tests verify the internal baseline clearance is restored to `0.55 m`; pixel-level rhythm is blocked from inspection. +- Colors and visual tokens: no color or token changes were made; rendered contrast is blocked from inspection. +- Image quality and asset fidelity: no image assets are involved. +- Copy and content: dimension values remain generated from the same measurements; the missing perimeter-door width is now included. + +**Comparison History** + +- Earlier P0: internal baseline coordinates were explicitly equal to witness coordinates, overriding `offsetDistance` and collapsing lines onto walls. +- Fix: preserve omitted automatic baselines so the renderer applies the configured offset; add enclosed room-side opening chains for perimeter walls. +- Post-fix evidence: SVG renderer regression asserts a `0.55 m` automatic baseline, and planner regressions cover top, right, bottom, and left perimeter doors. Browser-rendered evidence remains blocked. + +**Implementation Evidence** + +- Focused dimension, wall, floor-plan, and registry tests: 61 passed, 0 failed. +- Nodes package build: passed. +- Editor package type-check: blocked by the unrelated missing `resolveFloorplanExportViewport` export referenced by `floorplan-export.test.ts`. +- Biome check: passed. +- Git diff whitespace check: passed. +- Browser console warnings/errors: none reported. + +**Implementation Checklist** + +- Restore the local editor preview. +- Reopen the reported room in 2D. +- Confirm each internal string has a visible parallel baseline, witness lines, and ticks. +- Confirm the large left-side door displays its room-side width dimension. + +**Follow-up Polish** + +- Reassess internal line contrast only after the corrected geometry can be seen in the target scene. + +final result: blocked diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 78bc2619..25aed8e7 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -9,10 +9,12 @@ import type { CeilingNode, ChimneyNode, ColumnNode, + ConstructionDimensionNode, CupolaNode, DoorNode, DormerNode, DownspoutNode, + DrawingSheetNode, DuctFittingNode, DuctSegmentNode, DuctTerminalNode, @@ -42,6 +44,7 @@ import type { SpawnNode, StairNode, StairSegmentNode, + StructuralGridNode, TurbineVentNode, WallNode, WindowNode, @@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent export type ColumnEvent = NodeEvent +export type ConstructionDimensionEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent export type StairEvent = NodeEvent export type StairSegmentEvent = NodeEvent +export type StructuralGridEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent export type ElevatorEvent = NodeEvent @@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent export type SkylightEvent = NodeEvent export type DormerEvent = NodeEvent export type DownspoutEvent = NodeEvent +export type DrawingSheetEvent = NodeEvent export type DuctSegmentEvent = NodeEvent export type DuctFittingEvent = NodeEvent export type DuctTerminalEvent = NodeEvent @@ -295,10 +301,12 @@ type EditorEvents = GridEvents & NodeEvents<'spawn', SpawnEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'column', ColumnEvent> & + NodeEvents<'construction-dimension', ConstructionDimensionEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'stair', StairEvent> & NodeEvents<'stair-segment', StairSegmentEvent> & + NodeEvents<'structural-grid', StructuralGridEvent> & NodeEvents<'window', WindowEvent> & NodeEvents<'door', DoorEvent> & NodeEvents<'scan', ScanEvent> & @@ -314,6 +322,7 @@ type EditorEvents = GridEvents & NodeEvents<'skylight', SkylightEvent> & NodeEvents<'dormer', DormerEvent> & NodeEvents<'downspout', DownspoutEvent> & + NodeEvents<'drawing-sheet', DrawingSheetEvent> & NodeEvents<'duct-segment', DuctSegmentEvent> & NodeEvents<'duct-fitting', DuctFittingEvent> & NodeEvents<'duct-terminal', DuctTerminalEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1b8b3a47..b76a9c69 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,8 +9,10 @@ export type { CeilingEvent, ChimneyEvent, ColumnEvent, + ConstructionDimensionEvent, DoorEvent, DormerEvent, + DrawingSheetEvent, ElevatorEvent, EventSuffix, FenceEvent, @@ -34,6 +36,7 @@ export type { SpawnEvent, StairEvent, StairSegmentEvent, + StructuralGridEvent, WallEvent, WindowEvent, ZoneEvent, @@ -88,6 +91,7 @@ export { closestMeasurementFeatureBinding, MEASUREMENT_PLANAR_TOLERANCE, measurementAnchorFallback, + measurementAnchorReferenceNodeIds, measurementAngle, measurementArea, measurementAreaVector, @@ -98,6 +102,7 @@ export { measurementPerimeter, measurementPrismVolume, measurementReferenceNodeIds, + remapMeasurementAnchors, remapMeasurementReferences, } from './lib/measurement-geometry' export { @@ -294,6 +299,7 @@ export { resolveStairTotalRise } from './systems/stair/stair-rise' export { getClampedWallCurveOffset, getMaxWallCurveOffset, + getWallArcData, getWallChordFrame, getWallCurveFrameAt, getWallCurveLength, diff --git a/packages/core/src/lib/measurement-geometry.test.ts b/packages/core/src/lib/measurement-geometry.test.ts index 842f184d..48a8264b 100644 --- a/packages/core/src/lib/measurement-geometry.test.ts +++ b/packages/core/src/lib/measurement-geometry.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from 'bun:test' import type { MeasurementFeature } from '../registry/types' -import type { MeasurementPoint } from '../schema/nodes/measurement' +import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement' import { areMeasurementPointsCoplanar, closestMeasurementFeatureBinding, + measurementAnchorReferenceNodeIds, measurementAngle, measurementArea, measurementAreaVector, @@ -12,6 +13,7 @@ import { measurementNormal, measurementPerimeter, measurementPrismVolume, + remapMeasurementAnchors, } from './measurement-geometry' const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => { @@ -134,4 +136,33 @@ describe('measurement geometry', () => { expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24) expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24) }) + + test('remaps and collects references for arbitrary anchor strings', () => { + const anchors: MeasurementAnchor[] = [ + { + kind: 'feature', + reference: { nodeId: 'wall_a', featureId: 'wall:start' }, + fallback: [0, 0, 0], + }, + [2, 0, 0], + { + kind: 'feature', + reference: { nodeId: 'wall_b', featureId: 'wall:end' }, + fallback: [4, 0, 0], + }, + ] + + expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b']) + const remapped = remapMeasurementAnchors( + anchors, + new Map([ + ['wall_a', 'wall_a_copy'], + ['wall_b', 'wall_b_copy'], + ]), + ) + const first = remapped[0]! + const last = remapped[2]! + expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy') + expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy') + }) }) diff --git a/packages/core/src/lib/measurement-geometry.ts b/packages/core/src/lib/measurement-geometry.ts index f2184ad1..9dd7b7f9 100644 --- a/packages/core/src/lib/measurement-geometry.ts +++ b/packages/core/src/lib/measurement-geometry.ts @@ -1,4 +1,5 @@ import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types' +import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension' import type { MeasurementAnchor, MeasurementPayload, @@ -176,11 +177,8 @@ export function remapMeasurementReferences( measurement: MeasurementPayload, idMap: ReadonlyMap, ): MeasurementPayload { - const remap = (anchor: MeasurementAnchor): MeasurementAnchor => { - if (Array.isArray(anchor)) return anchor - const nodeId = idMap.get(anchor.reference.nodeId) - return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor - } + const remap = (anchor: MeasurementAnchor): MeasurementAnchor => + remapMeasurementAnchors([anchor], idMap)[0]! switch (measurement.kind) { case 'distance': @@ -205,11 +203,35 @@ export function remapMeasurementReferences( } } -export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] { - const anchors = - measurement.kind === 'distance' || measurement.kind === 'angle' - ? measurement.points - : measurement.base +export function remapMeasurementAnchors( + anchors: readonly MeasurementAnchor[], + idMap: ReadonlyMap, +): MeasurementAnchor[] { + return anchors.map((anchor) => { + if (Array.isArray(anchor)) return anchor + const nodeId = idMap.get(anchor.reference.nodeId) + return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor + }) +} + +export function remapConstructionDimensionReferences( + dimension: ConstructionDimensionNode, + idMap: ReadonlyMap, +): ConstructionDimensionNode { + const controllingDimensionId = dimension.controllingDimensionId + ? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ?? + dimension.controllingDimensionId) + : null + return { + ...dimension, + anchors: remapMeasurementAnchors(dimension.anchors, idMap), + controllingDimensionId, + } +} + +export function measurementAnchorReferenceNodeIds( + anchors: readonly MeasurementAnchor[], +): AnyNodeId[] { const ids = new Set() for (const anchor of anchors) { if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId) @@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An return [...ids] as AnyNodeId[] } +export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] { + const anchors = + measurement.kind === 'distance' || measurement.kind === 'angle' + ? measurement.points + : measurement.base + return measurementAnchorReferenceNodeIds(anchors) +} + export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint { if (points.length < 3) return [0, 0, 0] diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index a2ad983c..b1830696 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -471,7 +471,7 @@ function unavailable(reason: string): ZoneQuantityValue { export function deriveZoneQuantityReport( zone: ZoneNode, - sceneNodes: Record, + sceneNodes: Readonly>, ): ZoneQuantityReport { const levelId = zone.parentId const levelNodes = levelId diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 437ee091..08d459be 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -65,6 +65,8 @@ export type { Capabilities, CapabilityCtx, CuttableConfig, + DimensionTerminator, + DimensionTextPosition, DistributionRole, DragAction, DuplicableConfig, @@ -88,6 +90,7 @@ export type { FloorplanPoint, FloorplanStyle, GeometryContext, + GroupMoveSnapArgs, HostableConfig, IconRef, Issue, diff --git a/packages/core/src/registry/subtree.test.ts b/packages/core/src/registry/subtree.test.ts index ae7b8605..4f14671f 100644 --- a/packages/core/src/registry/subtree.test.ts +++ b/packages/core/src/registry/subtree.test.ts @@ -143,12 +143,121 @@ describe('cloneNodesInto', () => { ) { const anchor = clonedMeasurement.measurement.points[0] expect(Array.isArray(anchor)).toBe(false) - if (!Array.isArray(anchor)) { + if (anchor && !Array.isArray(anchor)) { expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) } } }) + test('remaps associative construction-dimension anchors inside the cloned subtree', () => { + const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' }) + const dimension = makeNode('construction-dimension_1', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + { + kind: 'feature', + reference: { nodeId: 'wall_1', featureId: 'wall:start' }, + fallback: [0, 0, 0], + }, + [1, 0, 0], + { + kind: 'feature', + reference: { nodeId: 'wall_1', featureId: 'wall:end' }, + fallback: [2, 0, 0], + }, + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + chainMode: 'continuous', + }) + const result = cloneNodesInto([wall, dimension], { + rootId: 'wall_1' as AnyNodeId, + }) + const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension') + + expect(clonedDimension?.type).toBe('construction-dimension') + if (clonedDimension?.type === 'construction-dimension') { + const anchor = clonedDimension.anchors[0] + expect(Array.isArray(anchor)).toBe(false) + if (anchor && !Array.isArray(anchor)) { + expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) + } + const lastAnchor = clonedDimension.anchors[2] + expect(Array.isArray(lastAnchor)).toBe(false) + if (lastAnchor && !Array.isArray(lastAnchor)) { + expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) + } + } + }) + + test('remaps a construction dimension foundation controller when both are cloned', () => { + const controller = makeNode('construction-dimension_foundation', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + drawingType: 'foundation-plan', + }) + const dependent = makeNode('construction-dimension_floor', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [1, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + controllingDimensionId: controller.id, + }) + + const result = cloneNodesInto([controller, dependent], { + rootId: controller.id as AnyNodeId, + }) + const clonedDependent = result.nodes.find( + (node) => node.id === result.idMap.get(dependent.id as AnyNodeId), + ) + + expect(clonedDependent?.type).toBe('construction-dimension') + if (clonedDependent?.type === 'construction-dimension') { + expect(clonedDependent.controllingDimensionId).toBe( + result.idMap.get( + controller.id as AnyNodeId, + ) as typeof clonedDependent.controllingDimensionId, + ) + } + }) + + test('regenerates drawing-sheet identities while preserving external level references', () => { + const original = makeNode('drawing-sheet_a101', 'drawing-sheet', { + placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + position: [1, 1], + }, + ], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + }) + + const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId }) + const cloned = nodes[0] + + expect(cloned?.type).toBe('drawing-sheet') + if (cloned?.type === 'drawing-sheet') { + expect(cloned.placedViews[0]?.levelId).toBe('level_existing') + expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main') + expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id) + expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id) + } + }) + test('parents the cloned root under opts.parentId when supplied', () => { const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' }) const { nodes } = cloneNodesInto([orig], { diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts index 894669c7..f3e3f10d 100644 --- a/packages/core/src/registry/subtree.ts +++ b/packages/core/src/registry/subtree.ts @@ -1,5 +1,9 @@ -import { remapMeasurementReferences } from '../lib/measurement-geometry' +import { + remapConstructionDimensionReferences, + remapMeasurementReferences, +} from '../lib/measurement-geometry' import { generateId } from '../schema/base' +import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet' import type { AnyNode, AnyNodeId } from '../schema/types' // Generic, opinion-free primitives the host app composes to implement @@ -141,7 +145,7 @@ export function cloneNodesInto( const out: AnyNode[] = [] let root: AnyNode | null = null for (const original of nodes) { - const cloned = JSON.parse(JSON.stringify(original)) as AnyNode + let cloned = JSON.parse(JSON.stringify(original)) as AnyNode const freshId = idMap.get(original.id)! ;(cloned as { id: AnyNodeId }).id = freshId // parentId: root's parentId becomes opts.parentId (or preserved @@ -169,6 +173,12 @@ export function cloneNodesInto( if (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } + if (cloned.type === 'construction-dimension') { + cloned = remapConstructionDimensionReferences(cloned, idMap) + } + if (cloned.type === 'drawing-sheet') { + cloned = remapDrawingSheetReferences(cloned, idMap) + } if (original.id === opts.rootId) { if (opts.position) { diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index de9e4089..22150bcb 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -51,6 +51,8 @@ export type GeometryContext = { * `scene:` refs. */ materials?: Record + /** Opaque host/plugin context. Core never interprets extension values. */ + extensions?: Readonly> /** * Optional view state — only populated for `def.floorplan` builders. The * 2D floor-plan layer surfaces selection / hover here so kinds can vary @@ -212,12 +214,18 @@ export type FloorplanPalette = { export type FloorplanPoint = readonly [x: number, y: number] +export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot' + +export type DimensionTextPosition = 'above' | 'centered' + export type FloorplanStyle = { stroke?: string fill?: string strokeWidth?: number strokeDasharray?: string opacity?: number + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> /** * When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth` * as a constant screen-pixel width regardless of viewport zoom. Maps @@ -398,6 +406,8 @@ export type FloorplanGeometry = * of the floor-plan's scene rotation (default 90°). */ upright?: boolean + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> } /** * Bitmap overlay — captured top-down asset thumbnail, AI-generated @@ -426,6 +436,8 @@ export type FloorplanGeometry = children: FloorplanGeometry[] /** Optional transform applied to all children. Rotation in radians. */ transform?: { translate?: FloorplanPoint; rotate?: number } + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> } /** * Hatched fill overlay — same polygon shape as the kind's main fill but @@ -629,16 +641,64 @@ export type FloorplanGeometry = kind: 'dimension' start: FloorplanPoint end: FloorplanPoint + /** + * Optional explicit dimension-line endpoints. Use these when the + * measured origins sit at different depths, such as stepped facades or + * an exterior column row. Extension lines still originate at + * `start`/`end`, while the measurement is drawn between these aligned + * baseline points. + */ + dimensionStart?: FloorplanPoint + dimensionEnd?: FloorplanPoint /** Outward-pointing unit normal — the dimension line offsets along this. */ offsetNormal: FloorplanPoint /** Distance (plan units) from the edge to the dimension line. */ offsetDistance: number /** How far past the offset point the extension line continues. */ extensionOvershoot: number + /** Optional gap before each extension line starts. Defaults to the project/document profile. */ + extensionStartGap?: number + /** Dimension-line terminator. Defaults to an architectural tick. */ + terminator?: DimensionTerminator + /** Dimension text position relative to the baseline. Defaults above the line. */ + textPosition?: DimensionTextPosition text: string /** Optional override for the line/text colour. Defaults to the palette accent. */ stroke?: string } + | { + kind: 'dimension-string' + segments: readonly { + start: FloorplanPoint + end: FloorplanPoint + /** + * Optional explicit dimension-line endpoints. Use these when the + * measured origins sit at different depths, such as stepped facades or + * an exterior column row. Extension lines still originate at + * `start`/`end`, while the measurement is drawn between these aligned + * baseline points. + */ + dimensionStart?: FloorplanPoint + dimensionEnd?: FloorplanPoint + text: string + }[] + /** Outward-pointing unit normal shared by every segment in the string. */ + offsetNormal: FloorplanPoint + /** Distance (plan units) from each measured origin to its dimension line. */ + offsetDistance: number + /** How far past each offset point the extension line continues. */ + extensionOvershoot: number + /** Optional gap before each extension line starts. Defaults to the project/document profile. */ + extensionStartGap?: number + /** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */ + terminator?: DimensionTerminator + /** Dimension text position shared by every segment. Defaults above the line. */ + textPosition?: DimensionTextPosition + /** Optional override for the line/text colour. Defaults to the palette accent. */ + stroke?: string + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> + } // ─── FloorplanAffordance ───────────────────────────────────────────── // @@ -853,6 +913,8 @@ export type NodeDefinition> = { schemaVersion: number schema: S category: NodeCategory + /** Opaque host/plugin contributions. Core stores but never interprets them. */ + extensions?: Readonly> surfaceRole?: SurfaceRole /** * Show a floor direction-triangle while placing/moving — the kind has a @@ -889,7 +951,6 @@ export type NodeDefinition> = { portConnectivityFollow?: boolean defaults: () => Omit, 'id' | 'type'> - migrate?: Record unknown> capabilities: Capabilities relations?: Relations diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index e58db315..fd0e8f7f 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -51,8 +51,33 @@ export { ColumnStyle, ColumnSupportStyle, } from './nodes/column' +export { + CONSTRUCTION_DRAWING_TYPES, + ConstructionDimensionBaseline, + ConstructionDimensionChainMode, + ConstructionDimensionDatumPolicy, + ConstructionDimensionDrawingOverride, + ConstructionDimensionDrawingPresentation, + ConstructionDimensionImperialPrecision, + ConstructionDimensionMetricNotation, + ConstructionDimensionMode, + ConstructionDimensionNode, + ConstructionDimensionTerminator, + ConstructionDimensionTextPosition, + ConstructionDrawingType, + constructionDimensionRequiredAnchorCount, + resolveConstructionDimensionDrawingOverride, + resolveConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingSuppressedSegments, +} from './nodes/construction-dimension' export { CupolaNode } from './nodes/cupola' -export { DoorNode, DoorSegment } from './nodes/door' +export { + DoorNode, + DoorSegment, + OpeningConstructionType, + OpeningDimensionReference, +} from './nodes/door' export { DormerNode, type DormerSurfaceMaterialRole, @@ -60,6 +85,25 @@ export { getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' export { DownspoutNode } from './nodes/downspout' +export { + DrawingSheetAnnotationProfile, + DrawingSheetDocumentMarker, + DrawingSheetDocumentMarkerKind, + DrawingSheetGeneralNote, + DrawingSheetGeneralNoteSet, + DrawingSheetKeyedNote, + DrawingSheetKeyedNoteDefinition, + DrawingSheetKeyedNoteInstance, + DrawingSheetNode, + DrawingSheetOrientation, + DrawingSheetPaperSize, + DrawingSheetPlacedView, + DrawingSheetRect, + DrawingSheetScale, + DrawingSheetSchedulePlacement, + DrawingSheetTitleBlock, + remapDrawingSheetReferences, +} from './nodes/drawing-sheet' export { DuctFittingNode } from './nodes/duct-fitting' export { DuctSegmentNode } from './nodes/duct-segment' export { DuctTerminalNode } from './nodes/duct-terminal' @@ -200,9 +244,13 @@ export { StairType, } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' +export { StructuralGridNode } from './nodes/structural-grid' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { TurbineVentNode } from './nodes/turbine-vent' export type { + WallAssemblyDatumReference, + WallAssemblyDatumSide, + WallAssemblyLayer, WallBandSurfaceSlotId, WallFaceBand, WallFaceBandConfig, @@ -215,11 +263,18 @@ export { buildEnabledWallFaceBandPatch, buildWallFaceBandCountPatch, getEffectiveWallSurfaceMaterial, + getWallAssemblyDatumReferenceId, + getWallAssemblyFaceOffsets, + getWallAssemblyLayers, + getWallAssemblyThickness, getWallBandSlotId, + getWallDatumEligibleLayers, getWallFaceBandConfig, getWallFaceBandForHeight, getWallSurfaceMaterialSignature, getWallSurfaceSideFromBandSlot, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, @@ -230,11 +285,18 @@ export { WALL_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WALL_TRIM_DEFAULTS, + WallAssemblyLayerRole, + WallDimensionDatum, WallNode, WallTreatmentSide, WallTrimProfile, } from './nodes/wall' -export { WindowNode, WindowType } from './nodes/window' +export { + WindowConstructionType, + WindowDimensionReference, + WindowNode, + WindowType, +} from './nodes/window' export { ZoneNode } from './nodes/zone' export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' export type { AnyNodeId, AnyNodeType } from './types' diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index e4a4e470..c1acb785 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -1,13 +1,16 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { DrawingSheetNode } from './drawing-sheet' import { ElevatorNode } from './elevator' import { LevelNode } from './level' export const BuildingNode = BaseNode.extend({ id: objectId('building'), type: nodeType('building'), - children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]), + children: z + .array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id])) + .default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), }).describe( @@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({ Building node - used to represent a building - position: position in site coordinate system - rotation: rotation in site coordinate system - - children: array of level nodes and building-level systems such as elevators + - children: array of level nodes, building-level systems such as elevators, and drawing sheets `, ) diff --git a/packages/core/src/schema/nodes/construction-dimension.test.ts b/packages/core/src/schema/nodes/construction-dimension.test.ts new file mode 100644 index 00000000..33cc7e56 --- /dev/null +++ b/packages/core/src/schema/nodes/construction-dimension.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test' +import { + ConstructionDimensionNode, + resolveConstructionDimensionDrawingOverride, + resolveConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingSuppressedSegments, +} from './construction-dimension' + +describe('ConstructionDimensionNode', () => { + test('creates valid free-anchor defaults', () => { + const node = ConstructionDimensionNode.parse({}) + + expect(node.type).toBe('construction-dimension') + expect(node.id).toMatch(/^construction-dimension_/) + expect(node.anchors).toEqual([ + [0, 0, 0], + [1, 0, 0], + ]) + expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] }) + expect(node.chainMode).toBe('point-to-point') + expect(node).toMatchObject({ + mode: 'linear', + featureCount: 1, + showCenterMark: true, + prefix: '', + suffix: '', + textOverride: null, + datumPolicy: 'centerline', + terminator: 'architectural-tick', + textPosition: 'above', + imperialPrecision: '1/16', + metricNotation: 'meters', + extensionStartGap: 0.075, + extensionOvershoot: 0.12, + drawingType: 'floor-plan', + drawingOverrides: [], + controllingDimensionId: null, + }) + }) + + test('accepts semantic anchors and rejects a collapsed baseline direction', () => { + expect( + ConstructionDimensionNode.safeParse({ + anchors: [ + { + kind: 'feature', + reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } }, + fallback: [1, 0, 0], + }, + [3, 0, 0], + ], + }).success, + ).toBe(true) + expect( + ConstructionDimensionNode.safeParse({ + baseline: { origin: [0, 0], direction: [0, 0] }, + }).success, + ).toBe(false) + }) + + test('accepts continuous strings with three or more anchors', () => { + expect( + ConstructionDimensionNode.safeParse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + [5, 0, 0], + ], + chainMode: 'continuous', + }).success, + ).toBe(true) + expect( + ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' }) + .success, + ).toBe(false) + }) + + test('accepts curved and circular notation settings', () => { + expect( + ConstructionDimensionNode.safeParse({ + mode: 'diameter', + featureCount: 6, + prefix: 'TYP · ', + suffix: ' CLR', + }).success, + ).toBe(true) + expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true) + expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true) + expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false) + expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false) + }) + + test('accepts dimension-standard overrides and rejects invalid drafting distances', () => { + const node = ConstructionDimensionNode.parse({ + datumPolicy: 'finish-face', + terminator: 'filled-arrow', + textPosition: 'centered', + imperialPrecision: '1/8', + metricNotation: 'millimeters', + extensionStartGap: 0.025, + extensionOvershoot: 0.08, + }) + + expect(node).toMatchObject({ + datumPolicy: 'finish-face', + terminator: 'filled-arrow', + textPosition: 'centered', + imperialPrecision: '1/8', + metricNotation: 'millimeters', + extensionStartGap: 0.025, + extensionOvershoot: 0.08, + }) + expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false) + expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false) + }) + + test('coordinates one associative dimension across persistent drawing types', () => { + const node = ConstructionDimensionNode.parse({ + drawingType: 'foundation-plan', + drawingOverrides: [ + { drawingType: 'floor-plan', presentation: 'controlled' }, + { drawingType: 'roof-plan', presentation: 'shown' }, + ], + controllingDimensionId: 'construction-dimension_foundation', + }) + + expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown') + expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled') + expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown') + expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit') + }) + + test('stores only drawing presentations that differ from the primary defaults', () => { + const node = ConstructionDimensionNode.parse({}) + const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown') + expect(shown).toEqual([ + { drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] }, + ]) + expect( + setConstructionDimensionDrawingPresentation( + { ...node, drawingOverrides: shown }, + 'roof-plan', + 'omit', + ), + ).toEqual([]) + }) + + test('stores view-specific suppressed segment indexes without changing default presentation', () => { + const node = ConstructionDimensionNode.parse({}) + const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments( + node, + 'floor-plan', + [3, 1, 1, -1], + ) + + expect(drawingOverrides).toEqual([ + { + drawingType: 'floor-plan', + presentation: 'shown', + suppressedSegmentIndexes: [1, 3], + }, + ]) + expect( + resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan') + ?.suppressedSegmentIndexes, + ).toEqual([1, 3]) + expect( + setConstructionDimensionDrawingSuppressedSegments( + { ...node, drawingOverrides }, + 'floor-plan', + [], + ), + ).toEqual([]) + }) +}) diff --git a/packages/core/src/schema/nodes/construction-dimension.ts b/packages/core/src/schema/nodes/construction-dimension.ts new file mode 100644 index 00000000..c831b116 --- /dev/null +++ b/packages/core/src/schema/nodes/construction-dimension.ts @@ -0,0 +1,205 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MeasurementAnchor } from './measurement' + +const FiniteCoordinate = z.number().finite() + +export const ConstructionDimensionBaseline = z + .object({ + origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]), + direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]), + }) + .superRefine((baseline, ctx) => { + if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) { + ctx.addIssue({ + code: 'custom', + path: ['direction'], + message: 'Construction dimension baseline direction must be non-zero', + }) + } + }) + +export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous']) +export const ConstructionDimensionMode = z.enum([ + 'linear', + 'radius', + 'diameter', + 'center-mark', + 'chord', + 'arc-length', + 'angular', + 'coordinate', +]) +export const ConstructionDrawingType = z.enum([ + 'floor-plan', + 'foundation-plan', + 'reflected-ceiling-plan', + 'roof-plan', + 'site-plan', +]) +export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled']) +export const ConstructionDimensionDrawingOverride = z.object({ + drawingType: ConstructionDrawingType, + presentation: ConstructionDimensionDrawingPresentation, + suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]), +}) +export const ConstructionDimensionDatumPolicy = z.enum([ + 'centerline', + 'wall-face', + 'structural-face', + 'finish-face', +]) +export const ConstructionDimensionTerminator = z.enum([ + 'architectural-tick', + 'filled-arrow', + 'open-arrow', + 'dot', +]) +export const ConstructionDimensionTextPosition = z.enum(['above', 'centered']) +export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16']) +export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters']) + +export const ConstructionDimensionNode = BaseNode.extend({ + id: objectId('construction-dimension'), + type: nodeType('construction-dimension'), + anchors: z + .array(MeasurementAnchor) + .min(2) + .default([ + [0, 0, 0], + [1, 0, 0], + ]), + baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }), + chainMode: ConstructionDimensionChainMode.default('point-to-point'), + mode: ConstructionDimensionMode.default('linear'), + featureCount: z.number().int().min(1).max(999).default(1), + showCenterMark: z.boolean().default(true), + prefix: z.string().max(40).default(''), + suffix: z.string().max(40).default(''), + textOverride: z.string().trim().min(1).max(120).nullable().default(null), + datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'), + terminator: ConstructionDimensionTerminator.default('architectural-tick'), + textPosition: ConstructionDimensionTextPosition.default('above'), + imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'), + metricNotation: ConstructionDimensionMetricNotation.default('meters'), + extensionStartGap: z.number().finite().min(0).max(1).default(0.075), + extensionOvershoot: z.number().finite().min(0).max(1).default(0.12), + drawingType: ConstructionDrawingType.default('floor-plan'), + drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]), + controllingDimensionId: objectId('construction-dimension').nullable().default(null), +}).describe( + dedent` + Construction dimension node - an associative floor-plan construction dimension + - anchors: two or more free or semantic feature anchors that supply the witness origins + - baseline.origin: a point on the independently placed dimension line + - baseline.direction: the fixed plan direction used to project the witness origins + - chainMode: point-to-point for one segment or continuous for adjacent dimension strings + - mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate + - featureCount: repeated-feature multiplier used by diameter/radius and other notation + - showCenterMark: displays the resolved circle/angle center where applicable + - prefix/suffix/textOverride: document notation overrides without changing geometry + - datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides + - drawingType: the primary persistent drawing that owns the dimension + - drawingOverrides: omit, show, or foundation-control presentation per drawing type + - controllingDimensionId: foundation dimension whose associative geometry controls this dimension + `, +) + +export type ConstructionDimensionBaseline = z.infer +export type ConstructionDimensionChainMode = z.infer +export type ConstructionDimensionMode = z.infer +export type ConstructionDrawingType = z.infer +export type ConstructionDimensionDrawingPresentation = z.infer< + typeof ConstructionDimensionDrawingPresentation +> +export type ConstructionDimensionDrawingOverride = z.infer< + typeof ConstructionDimensionDrawingOverride +> +export type ConstructionDimensionDatumPolicy = z.infer +export type ConstructionDimensionTerminator = z.infer +export type ConstructionDimensionTextPosition = z.infer +export type ConstructionDimensionImperialPrecision = z.infer< + typeof ConstructionDimensionImperialPrecision +> +export type ConstructionDimensionMetricNotation = z.infer< + typeof ConstructionDimensionMetricNotation +> +export type ConstructionDimensionNode = z.infer + +export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options + +export function resolveConstructionDimensionDrawingPresentation( + node: Pick, + drawingType: ConstructionDrawingType, +): ConstructionDimensionDrawingPresentation { + let override: ConstructionDimensionDrawingOverride | undefined + for (const entry of node.drawingOverrides) { + if (entry.drawingType === drawingType) override = entry + } + return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit') +} + +export function resolveConstructionDimensionDrawingOverride( + node: Pick, + drawingType: ConstructionDrawingType, +): ConstructionDimensionDrawingOverride | null { + let override: ConstructionDimensionDrawingOverride | undefined + for (const entry of node.drawingOverrides) { + if (entry.drawingType === drawingType) override = entry + } + return override ?? null +} + +export function setConstructionDimensionDrawingPresentation( + node: Pick, + drawingType: ConstructionDrawingType, + presentation: ConstructionDimensionDrawingPresentation, +): ConstructionDimensionDrawingOverride[] { + const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit' + const existing = resolveConstructionDimensionDrawingOverride(node, drawingType) + const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType) + const next = { + drawingType, + presentation, + suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [], + } + return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation) + ? withoutDrawing + : [...withoutDrawing, next] +} + +export function setConstructionDimensionDrawingSuppressedSegments( + node: Pick, + drawingType: ConstructionDrawingType, + suppressedSegmentIndexes: readonly number[], +): ConstructionDimensionDrawingOverride[] { + const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit' + const existing = resolveConstructionDimensionDrawingOverride(node, drawingType) + const presentation = existing?.presentation ?? defaultPresentation + const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes) + const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType) + const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed } + return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation) + ? withoutDrawing + : [...withoutDrawing, next] +} + +export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number { + return mode === 'arc-length' || mode === 'angular' ? 3 : 2 +} + +function isDefaultConstructionDimensionDrawingOverride( + override: ConstructionDimensionDrawingOverride, + defaultPresentation: ConstructionDimensionDrawingPresentation, +): boolean { + return ( + override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0 + ) +} + +function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] { + return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort( + (left, right) => left - right, + ) +} diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 26b4170e..ab0aaab9 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -19,6 +19,13 @@ export const DoorSegment = z.object({ export type DoorSegment = z.infer export const DoorCategory = z.enum(['interior', 'garage']) +export const OpeningConstructionType = z.enum(['framed', 'masonry']) +export const OpeningDimensionReference = z.enum([ + 'nominal', + 'rough-opening', + 'masonry-opening', + 'finish-opening', +]) export const DoorType = z.enum([ 'hinged', 'double', @@ -34,6 +41,8 @@ export const DoorType = z.enum([ export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead']) export type DoorCategory = z.infer +export type OpeningConstructionType = z.infer +export type OpeningDimensionReference = z.infer export type DoorType = z.infer export type DoorTrackStyle = z.infer @@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({ width: z.number().default(0.9), height: z.number().default(2.1), + // Construction-document identity. `mark` overrides the deterministic + // level fallback (101, 102, ...). Rough-opening dimensions stay optional + // because they are manufacturer/framing inputs, not safe derivations from + // the nominal modeled size. + mark: z.string().trim().max(16).optional(), + constructionType: OpeningConstructionType.default('framed'), + dimensionReference: OpeningDimensionReference.default('nominal'), + roughOpeningWidth: z.number().positive().optional(), + roughOpeningHeight: z.number().positive().optional(), + masonryOpeningWidth: z.number().positive().optional(), + masonryOpeningHeight: z.number().positive().optional(), + finishOpeningWidth: z.number().positive().optional(), + finishOpeningHeight: z.number().positive().optional(), + // Door family doorCategory: DoorCategory.default('interior'), doorType: DoorType.default('hinged'), diff --git a/packages/core/src/schema/nodes/drawing-sheet.test.ts b/packages/core/src/schema/nodes/drawing-sheet.test.ts new file mode 100644 index 00000000..216295e1 --- /dev/null +++ b/packages/core/src/schema/nodes/drawing-sheet.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from 'bun:test' +import { BuildingNode } from './building' +import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet' + +describe('DrawingSheetNode', () => { + test('creates persistent sheet defaults', () => { + const sheet = DrawingSheetNode.parse({}) + + expect(sheet.type).toBe('drawing-sheet') + expect(sheet.id).toMatch(/^drawing-sheet_/) + expect(sheet).toMatchObject({ + sheetNumber: 'A1.0', + sheetTitle: 'Floor Plan', + paperSize: 'arch-b', + orientation: 'landscape', + customPaperWidth: null, + customPaperHeight: null, + annotationProfile: 'architectural-default', + placedViews: [], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [], + keyedNoteInstances: [], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + titleBlock: { + projectName: '', + projectNumber: '', + clientName: '', + drawnBy: '', + checkedBy: '', + issueDate: '', + revision: '', + }, + }) + }) + + test('stores placed views, notes, schedules, and title-block metadata', () => { + const sheet = DrawingSheetNode.parse({ + sheetNumber: 'A2.1', + sheetTitle: 'Enlarged Plans', + paperSize: 'custom', + customPaperWidth: 24, + customPaperHeight: 36, + placedViews: [ + { + id: 'drawing-view_main', + drawingType: 'floor-plan', + drawingNumber: '2', + title: 'Main Floor Plan', + levelId: 'level_main', + scale: '1/4"=1\'-0"', + viewport: { x: 1, y: 1, width: 12, height: 8 }, + }, + ], + generalNoteSetIds: ['sheet-note-set_project'], + generalNoteSets: [ + { + id: 'sheet-note-set_project', + name: 'Project Notes', + notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }], + }, + ], + generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }], + keyedNoteDefinitions: [ + { id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' }, + ], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_patch-slab-1', + definitionId: 'keyed-note_patch-slab', + placedViewId: 'drawing-view_main', + position: [3.25, 2.5], + }, + { + id: 'keyed-note-instance_patch-slab-2', + definitionId: 'keyed-note_patch-slab', + position: [5, 4], + }, + ], + keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }], + documentMarkers: [ + { + id: 'sheet-marker_wall-a', + kind: 'wall-tag', + label: 'W1', + placedViewId: 'drawing-view_main', + position: [2, 3], + }, + { + id: 'sheet-marker_revision-a', + kind: 'revision-cloud', + label: '1', + revisionId: 'A', + points: [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + }, + ], + schedules: [ + { + id: 'sheet-schedule_room', + scheduleType: 'room', + title: 'Room Schedule', + region: { x: 15, y: 1, width: 6, height: 5 }, + }, + ], + titleBlock: { + projectName: 'House', + projectNumber: '2401', + clientName: 'Owner', + }, + }) + + expect(sheet.placedViews[0]).toMatchObject({ + drawingType: 'floor-plan', + levelId: 'level_main', + annotationProfile: 'architectural-default', + showNorthArrow: true, + showGraphicScale: true, + }) + expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.') + expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project']) + expect(sheet.generalNoteSets[0]).toMatchObject({ + id: 'sheet-note-set_project', + name: 'Project Notes', + notes: [{ text: 'COORDINATE WITH OWNER.' }], + }) + expect(sheet.keyedNoteLegend[0]).toEqual({ + key: 'A', + text: 'ALIGN WITH EXISTING WALL.', + }) + expect(sheet.keyedNoteDefinitions[0]).toEqual({ + id: 'keyed-note_patch-slab', + key: 'A', + text: 'PATCH EXISTING SLAB.', + }) + expect(sheet.keyedNoteInstances).toHaveLength(2) + expect(sheet.keyedNoteInstances[0]).toMatchObject({ + definitionId: 'keyed-note_patch-slab', + placedViewId: 'drawing-view_main', + position: [3.25, 2.5], + }) + expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull() + expect(sheet.documentMarkers).toHaveLength(2) + expect(sheet.documentMarkers[0]).toMatchObject({ + kind: 'wall-tag', + label: 'W1', + position: [2, 3], + }) + expect(sheet.documentMarkers[1]).toMatchObject({ + kind: 'revision-cloud', + revisionId: 'A', + points: [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + }) + expect(sheet.schedules[0]?.title).toBe('Room Schedule') + expect(sheet.titleBlock).toMatchObject({ + projectName: 'House', + projectNumber: '2401', + clientName: 'Owner', + drawnBy: '', + }) + }) + + test('can live under a building instead of a level', () => { + const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' }) + + expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([ + 'level_main', + sheet.id, + ]) + }) + + test('remaps sheet-local identities and their references together', () => { + const sheet = DrawingSheetNode.parse({ + placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }], + generalNoteSetIds: ['sheet-note-set_project'], + generalNoteSets: [ + { + id: 'sheet-note-set_project', + notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }], + }, + ], + generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a1', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + }, + ], + documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }], + schedules: [{ id: 'sheet-schedule_a' }], + }) + const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']])) + + expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id) + expect(remapped.placedViews[0]?.levelId).toBe('level_cloned') + expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id) + expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe( + sheet.generalNoteSets[0]?.notes[0]?.id, + ) + expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id) + expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id) + expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id) + expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id) + expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id) + expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id) + expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id) + }) +}) diff --git a/packages/core/src/schema/nodes/drawing-sheet.ts b/packages/core/src/schema/nodes/drawing-sheet.ts new file mode 100644 index 00000000..372addf0 --- /dev/null +++ b/packages/core/src/schema/nodes/drawing-sheet.ts @@ -0,0 +1,263 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, generateId, nodeType, objectId } from '../base' +import { ConstructionDrawingType } from './construction-dimension' + +const PositiveFinite = z.number().finite().positive() +const SheetCoordinate = z.number().finite().min(0) + +export const DrawingSheetPaperSize = z.enum([ + 'letter', + 'tabloid', + 'arch-a', + 'arch-b', + 'arch-c', + 'a4', + 'a3', + 'custom', +]) +export const DrawingSheetOrientation = z.enum(['portrait', 'landscape']) +export const DrawingSheetScale = z.enum([ + '1:20', + '1:25', + '1:50', + '1:75', + '1:100', + '1/8"=1\'-0"', + '1/4"=1\'-0"', + '1/2"=1\'-0"', + '1"=1\'-0"', +]) +export const DrawingSheetAnnotationProfile = z.enum([ + 'architectural-default', + 'presentation', + 'permit', +]) + +export const DrawingSheetRect = z.object({ + x: SheetCoordinate.default(0), + y: SheetCoordinate.default(0), + width: PositiveFinite.default(1), + height: PositiveFinite.default(1), +}) + +export const DrawingSheetPlacedView = z.object({ + id: objectId('drawing-view'), + drawingType: ConstructionDrawingType.default('floor-plan'), + drawingNumber: z.string().trim().min(1).max(24).default('1'), + title: z.string().trim().min(1).max(80).default('Floor Plan'), + levelId: objectId('level').nullable().default(null), + scale: DrawingSheetScale.default('1/4"=1\'-0"'), + viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }), + annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'), + showNorthArrow: z.boolean().default(true), + showGraphicScale: z.boolean().default(true), +}) + +export const DrawingSheetGeneralNote = z.object({ + id: objectId('sheet-note'), + number: z.number().int().positive().default(1), + text: z.string().trim().min(1).max(500).default('GENERAL NOTE'), +}) + +export const DrawingSheetGeneralNoteSet = z.object({ + id: objectId('sheet-note-set'), + name: z.string().trim().min(1).max(80).default('General Notes'), + notes: z.array(DrawingSheetGeneralNote).max(200).default([]), +}) + +export const DrawingSheetKeyedNote = z.object({ + key: z.string().trim().min(1).max(16).default('1'), + text: z.string().trim().min(1).max(500).default('KEYED NOTE'), +}) + +export const DrawingSheetKeyedNoteDefinition = z.object({ + id: objectId('keyed-note'), + key: z.string().trim().min(1).max(16).default('1'), + text: z.string().trim().min(1).max(500).default('KEYED NOTE'), +}) + +export const DrawingSheetKeyedNoteInstance = z.object({ + id: objectId('keyed-note-instance'), + definitionId: DrawingSheetKeyedNoteDefinition.shape.id, + placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null), + position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]), +}) + +export const DrawingSheetDocumentMarkerKind = z.enum([ + 'wall-tag', + 'glazing-tag', + 'assembly-tag', + 'section-callout', + 'elevation-callout', + 'detail-reference', + 'delta-marker', + 'revision-cloud', +]) + +export const DrawingSheetDocumentMarker = z.object({ + id: objectId('sheet-marker'), + kind: DrawingSheetDocumentMarkerKind.default('detail-reference'), + placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null), + label: z.string().trim().min(1).max(32).default('1'), + title: z.string().trim().max(120).default(''), + sheetReference: z.string().trim().max(24).default(''), + drawingReference: z.string().trim().max(24).default(''), + revisionId: z.string().trim().max(16).default(''), + position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]), + endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null), + points: z + .array(z.tuple([SheetCoordinate, SheetCoordinate])) + .max(64) + .default([]), +}) + +export const DrawingSheetSchedulePlacement = z.object({ + id: objectId('sheet-schedule'), + scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'), + title: z.string().trim().min(1).max(80).default('Room Schedule'), + region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }), +}) + +export const DrawingSheetTitleBlock = z.object({ + projectName: z.string().trim().max(120).default(''), + projectNumber: z.string().trim().max(40).default(''), + clientName: z.string().trim().max(120).default(''), + drawnBy: z.string().trim().max(40).default(''), + checkedBy: z.string().trim().max(40).default(''), + issueDate: z.string().trim().max(40).default(''), + revision: z.string().trim().max(20).default(''), +}) + +const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = { + projectName: '', + projectNumber: '', + clientName: '', + drawnBy: '', + checkedBy: '', + issueDate: '', + revision: '', +} + +export const DrawingSheetNode = BaseNode.extend({ + id: objectId('drawing-sheet'), + type: nodeType('drawing-sheet'), + sheetNumber: z.string().trim().min(1).max(24).default('A1.0'), + sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'), + paperSize: DrawingSheetPaperSize.default('arch-b'), + orientation: DrawingSheetOrientation.default('landscape'), + customPaperWidth: PositiveFinite.nullable().default(null), + customPaperHeight: PositiveFinite.nullable().default(null), + placedViews: z.array(DrawingSheetPlacedView).max(32).default([]), + annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'), + generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]), + generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]), + generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]), + keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]), + keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]), + keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]), + documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]), + schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]), + titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK), +}).describe( + dedent` + Drawing sheet node - persistent construction-document sheet metadata + - sheetNumber/sheetTitle: sheet identity in the drawing set + - paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition + - placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles + - generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes + - keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries + - documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds + - schedules/titleBlock: sheet-level documentation content and title-block metadata + `, +) + +export type DrawingSheetPaperSize = z.infer +export type DrawingSheetOrientation = z.infer +export type DrawingSheetScale = z.infer +export type DrawingSheetAnnotationProfile = z.infer +export type DrawingSheetRect = z.infer +export type DrawingSheetPlacedView = z.infer +export type DrawingSheetGeneralNote = z.infer +export type DrawingSheetGeneralNoteSet = z.infer +export type DrawingSheetKeyedNote = z.infer +export type DrawingSheetKeyedNoteDefinition = z.infer +export type DrawingSheetKeyedNoteInstance = z.infer +export type DrawingSheetDocumentMarker = z.infer +export type DrawingSheetDocumentMarkerKind = z.infer +export type DrawingSheetSchedulePlacement = z.infer +export type DrawingSheetTitleBlock = z.infer +export type DrawingSheetNode = z.infer + +/** + * Rewrites every scene and sheet-local identity carried by a drawing sheet. + * External scene references are preserved when they are not present in + * `sceneIdMap`, which keeps a duplicated sheet attached to its existing level. + */ +export function remapDrawingSheetReferences( + sheet: DrawingSheetNode, + sceneIdMap: ReadonlyMap, +): DrawingSheetNode { + const placedViewIds = new Map( + sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const), + ) + const noteSetIds = new Map( + sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const), + ) + const noteIds = new Map( + [...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map( + (note) => [note.id, generateId('sheet-note')] as const, + ), + ) + const keyedDefinitionIds = new Map( + sheet.keyedNoteDefinitions.map( + (definition) => [definition.id, generateId('keyed-note')] as const, + ), + ) + + return { + ...sheet, + placedViews: sheet.placedViews.map((view) => ({ + ...view, + id: placedViewIds.get(view.id)!, + levelId: view.levelId + ? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId) + : null, + })), + generalNoteSetIds: sheet.generalNoteSetIds.map( + (id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number], + ), + generalNoteSets: sheet.generalNoteSets.map((set) => ({ + ...set, + id: noteSetIds.get(set.id)!, + notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })), + })), + generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })), + keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({ + ...definition, + id: keyedDefinitionIds.get(definition.id)!, + })), + keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({ + ...instance, + id: generateId('keyed-note-instance'), + definitionId: (keyedDefinitionIds.get(instance.definitionId) ?? + instance.definitionId) as typeof instance.definitionId, + placedViewId: instance.placedViewId + ? ((placedViewIds.get(instance.placedViewId) ?? + instance.placedViewId) as typeof instance.placedViewId) + : null, + })), + documentMarkers: sheet.documentMarkers.map((marker) => ({ + ...marker, + id: generateId('sheet-marker'), + placedViewId: marker.placedViewId + ? ((placedViewIds.get(marker.placedViewId) ?? + marker.placedViewId) as typeof marker.placedViewId) + : null, + })), + schedules: sheet.schedules.map((schedule) => ({ + ...schedule, + id: generateId('sheet-schedule'), + })), + } +} diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index a566f593..114c9514 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' import { ColumnNode } from './column' +import { ConstructionDimensionNode } from './construction-dimension' import { DuctFittingNode } from './duct-fitting' import { DuctSegmentNode } from './duct-segment' import { DuctTerminalNode } from './duct-terminal' @@ -22,6 +23,7 @@ import { ShelfNode } from './shelf' import { SlabNode } from './slab' import { SpawnNode } from './spawn' import { StairNode } from './stair' +import { StructuralGridNode } from './structural-grid' import { WallNode } from './wall' import { ZoneNode } from './zone' @@ -34,6 +36,8 @@ export const LevelNode = BaseNode.extend({ WallNode.shape.id, FenceNode.shape.id, ColumnNode.shape.id, + ConstructionDimensionNode.shape.id, + StructuralGridNode.shape.id, ItemNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, diff --git a/packages/core/src/schema/nodes/structural-grid.test.ts b/packages/core/src/schema/nodes/structural-grid.test.ts new file mode 100644 index 00000000..1b6afcfc --- /dev/null +++ b/packages/core/src/schema/nodes/structural-grid.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test' +import { LevelNode } from './level' +import { StructuralGridNode } from './structural-grid' + +describe('StructuralGridNode', () => { + test('fills stable construction-document defaults', () => { + const grid = StructuralGridNode.parse({}) + + expect(grid.id).toStartWith('structural-grid_') + expect(grid).toMatchObject({ + type: 'structural-grid', + start: [0, 0], + end: [0, 5], + label: '1', + showStartBubble: true, + showEndBubble: true, + }) + }) + + test('is accepted as a level child', () => { + expect(LevelNode.parse({ children: ['structural-grid_axis-1'] }).children).toEqual([ + 'structural-grid_axis-1', + ]) + }) + + test('rejects empty labels and zero-length concerns stay in authoring', () => { + expect(() => StructuralGridNode.parse({ label: ' ' })).toThrow() + expect(StructuralGridNode.parse({ start: [1, 1], end: [1, 1], label: 'A' })).toMatchObject({ + start: [1, 1], + end: [1, 1], + }) + }) +}) diff --git a/packages/core/src/schema/nodes/structural-grid.ts b/packages/core/src/schema/nodes/structural-grid.ts new file mode 100644 index 00000000..09f071a6 --- /dev/null +++ b/packages/core/src/schema/nodes/structural-grid.ts @@ -0,0 +1,22 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const StructuralGridNode = BaseNode.extend({ + id: objectId('structural-grid'), + type: nodeType('structural-grid'), + start: z.tuple([z.number(), z.number()]).default([0, 0]), + end: z.tuple([z.number(), z.number()]).default([0, 5]), + label: z.string().trim().min(1).max(12).default('1'), + showStartBubble: z.boolean().default(true), + showEndBubble: z.boolean().default(true), +}).describe( + dedent` + Structural grid node - a persistent floor-plan datum axis with identification bubbles + - start/end: level-local plan coordinates defining the grid axis extent + - label: axis identifier, commonly numeric in one direction and alphabetic in the other + - showStartBubble/showEndBubble: independently control the two endpoint identifiers + `, +) + +export type StructuralGridNode = z.infer diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 5364e083..164a5c37 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test' import { buildEnabledWallFaceBandPatch, buildWallFaceBandCountPatch, + getWallAssemblyDatumReferenceId, + getWallAssemblyFaceOffsets, + getWallAssemblyThickness, + getWallDatumEligibleLayers, getWallFaceBandConfig, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, @@ -13,7 +19,8 @@ import { WALL_SKIRTING_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WallFaceBandConfig, - type WallNode, + WallNode, + type WallNode as WallNodeType, WallTrimConfig, } from './wall' @@ -99,7 +106,7 @@ describe('wall face bands', () => { lowerInterior: 'library:stale-lower', middleExterior: 'library:stale-middle', }, - } as Pick) + } as Pick) expect(patch.faceBands).toEqual({ enabled: true, @@ -135,7 +142,7 @@ describe('wall face bands', () => { exterior: 'scene:exterior-finish', topInterior: 'library:stale-top', }, - } as Pick, + } as Pick, 3, ) @@ -159,7 +166,7 @@ describe('wall face bands', () => { middleInterior: 'library:stale-middle', upperExterior: 'library:stale-upper', }, - } as Pick) + } as Pick) expect(patch.slots).toEqual({ lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, @@ -187,7 +194,7 @@ describe('wall face bands', () => { lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, }, - } as Pick, + } as Pick, 3, ) @@ -219,7 +226,7 @@ describe('wall face bands', () => { middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, upperExterior: 'library:painted-top-exterior', }, - } as Pick, + } as Pick, 4, ) @@ -260,3 +267,206 @@ describe('wall trim profiles', () => { expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) }) }) + +describe('wall assembly layers', () => { + test('defaults to legacy thickness when no assembly layers are modeled', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + thickness: 0.14, + }) + + expect(wall.assemblyLayers).toEqual([]) + expect(getWallAssemblyThickness(wall)).toBe(0.14) + }) + + test('stores role, side, thickness, material reference, and datum eligibility', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.09, + materialRef: 'library:wood-framing', + datumEligible: ['centerline', 'structural-face'], + }, + { + id: 'interior-gwb', + role: 'interior-finish', + side: 'interior', + thickness: 0.016, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'brick-veneer', + role: 'masonry-veneer', + side: 'exterior', + thickness: 0.09, + materialRef: 'library:brick', + datumEligible: ['veneer-face', 'finish-face'], + }, + ], + }) + + expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196) + expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([ + 'interior-gwb', + 'brick-veneer', + ]) + expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([ + { id: 'stud-core', role: 'structure', side: 'core' }, + ]) + expect(getWallAssemblyFaceOffsets(wall)).toEqual({ + interior: -0.061, + exterior: 0.135, + }) + }) + + test('resolves stable datum references for legacy single-thickness walls', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + thickness: 0.14, + }) + + expect(resolveWallAssemblyDatumReferences(wall)).toEqual([ + { id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 }, + { + id: 'wall:structural-face:interior', + datum: 'structural-face', + side: 'interior', + offset: -0.07, + }, + { + id: 'wall:structural-face:exterior', + datum: 'structural-face', + side: 'exterior', + offset: 0.07, + }, + { + id: 'wall:finish-face:interior', + datum: 'finish-face', + side: 'interior', + offset: -0.07, + }, + { + id: 'wall:finish-face:exterior', + datum: 'finish-face', + side: 'exterior', + offset: 0.07, + }, + ]) + }) + + test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.09, + materialRef: 'library:wood-framing', + datumEligible: ['centerline', 'structural-face'], + }, + { + id: 'interior-gwb', + role: 'interior-finish', + side: 'interior', + thickness: 0.016, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'exterior-sheathing', + role: 'exterior-sheathing', + side: 'exterior', + thickness: 0.012, + materialRef: 'library:sheathing', + datumEligible: ['finish-face'], + }, + { + id: 'brick-veneer', + role: 'masonry-veneer', + side: 'exterior', + thickness: 0.09, + materialRef: 'library:brick', + datumEligible: ['veneer-face'], + }, + ], + }) + + const references = resolveWallAssemblyDatumReferences(wall) + + expect(references).toContainEqual({ + id: 'wall:centerline:center', + datum: 'centerline', + side: 'center', + offset: 0, + }) + expect(references).toContainEqual({ + id: 'wall:structural-face:interior:stud-core', + datum: 'structural-face', + side: 'interior', + layerId: 'stud-core', + offset: -0.045, + }) + expect(references).toContainEqual({ + id: 'wall:structural-face:exterior:stud-core', + datum: 'structural-face', + side: 'exterior', + layerId: 'stud-core', + offset: 0.045, + }) + expect(references).toContainEqual({ + id: 'wall:finish-face:interior:interior-gwb', + datum: 'finish-face', + side: 'interior', + layerId: 'interior-gwb', + offset: -0.061, + }) + expect( + references.find( + (reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing', + ), + ).toMatchObject({ + datum: 'finish-face', + side: 'exterior', + layerId: 'exterior-sheathing', + }) + expect( + references.find( + (reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing', + )?.offset, + ).toBeCloseTo(0.057) + + expect( + references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'), + ).toMatchObject({ + datum: 'veneer-face', + side: 'exterior', + layerId: 'brick-veneer', + }) + expect( + references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer') + ?.offset, + ).toBeCloseTo(0.147) + expect( + resolveWallAssemblyDatumReference( + wall, + getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'), + ), + ).toMatchObject({ + datum: 'veneer-face', + side: 'exterior', + layerId: 'brick-veneer', + offset: 0.147, + }) + }) +}) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index a01a98ad..82a81429 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = { export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS +export const WallAssemblyLayerRole = z.enum([ + 'structure', + 'interior-finish', + 'exterior-sheathing', + 'exterior-finish', + 'masonry-veneer', + 'air-space', + 'concrete-block', + 'structural-masonry', + 'solid-concrete', + 'furring', +]) +export type WallAssemblyLayerRole = z.infer + +export const WallDimensionDatum = z.enum([ + 'centerline', + 'structural-face', + 'finish-face', + 'veneer-face', +]) +export type WallDimensionDatum = z.infer + +export const WallAssemblyLayer = z.object({ + id: z.string().trim().min(1).max(80).default('structure'), + role: WallAssemblyLayerRole.default('structure'), + side: z.enum(['core', 'interior', 'exterior']).default('core'), + thickness: z.number().finite().positive().default(0.1), + materialRef: z.string().trim().max(120).default(''), + datumEligible: z.array(WallDimensionDatum).max(8).default([]), +}) +export type WallAssemblyLayer = z.infer + +export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior' + +export type WallAssemblyDatumReference = { + id: string + datum: WallDimensionDatum + side: WallAssemblyDatumSide + layerId?: string + offset: number +} + export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), @@ -149,6 +191,7 @@ export const WallNode = BaseNode.extend({ // in a follow-up once migrated scenes are the norm. slots: z.record(z.string(), z.string()).optional(), thickness: z.number().optional(), + assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]), height: z.number().optional(), curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. @@ -167,6 +210,7 @@ export const WallNode = BaseNode.extend({ dedent` Wall node - used to represent a wall in the building - thickness: thickness in meters + - assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility - height: height in meters - curveOffset: midpoint sagitta offset used to bend the wall into an arc - start: start point of the wall in level coordinate system @@ -190,6 +234,222 @@ export type WallBandSurfaceSlotId = | 'upperExterior' | 'topExterior' +export function getWallAssemblyLayers(wall: Pick): WallAssemblyLayer[] { + return wall.assemblyLayers ?? [] +} + +export function getWallAssemblyThickness( + wall: Pick, +): number { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) return wall.thickness ?? 0.1 + return layers.reduce((sum, layer) => sum + layer.thickness, 0) +} + +export function getWallAssemblyFaceOffsets(wall: Pick): { + interior: number + exterior: number +} { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + return { interior: -halfThickness, exterior: halfThickness } + } + + const coreLayers = layers.filter((layer) => layer.side === 'core') + const coreThickness = + coreLayers.length > 0 + ? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0) + : (wall.thickness ?? 0.1) + const interiorFinishThickness = layers + .filter((layer) => layer.side === 'interior') + .reduce((sum, layer) => sum + layer.thickness, 0) + const exteriorFinishThickness = layers + .filter((layer) => layer.side === 'exterior') + .reduce((sum, layer) => sum + layer.thickness, 0) + + return { + interior: -coreThickness / 2 - interiorFinishThickness, + exterior: coreThickness / 2 + exteriorFinishThickness, + } +} + +export function getWallDatumEligibleLayers( + wall: Pick, + datum: WallDimensionDatum, +): WallAssemblyLayer[] { + return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum)) +} + +export function getWallAssemblyDatumReferenceId( + datum: WallDimensionDatum, + side: WallAssemblyDatumSide, + layerId?: string, +): string { + return ['wall', datum, side, layerId].filter(Boolean).join(':') +} + +type WallAssemblyLayerSpan = { + layer: WallAssemblyLayer + interiorOffset: number + exteriorOffset: number +} + +function getWallAssemblyLayerSpans( + wall: Pick, +): WallAssemblyLayerSpan[] { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) return [] + + const coreLayers = layers.filter((layer) => layer.side === 'core') + const coreThickness = + coreLayers.length > 0 + ? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0) + : (wall.thickness ?? 0.1) + const coreInteriorFace = -coreThickness / 2 + const coreExteriorFace = coreThickness / 2 + const spans: WallAssemblyLayerSpan[] = [] + + let coreOffset = coreInteriorFace + for (const layer of coreLayers) { + const interiorOffset = coreOffset + const exteriorOffset = coreOffset + layer.thickness + spans.push({ layer, interiorOffset, exteriorOffset }) + coreOffset = exteriorOffset + } + + let interiorOffset = coreInteriorFace + for (const layer of layers.filter((candidate) => candidate.side === 'interior')) { + const exteriorOffset = interiorOffset + const nextInteriorOffset = exteriorOffset - layer.thickness + spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset }) + interiorOffset = nextInteriorOffset + } + + let exteriorOffset = coreExteriorFace + for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) { + const interiorFaceOffset = exteriorOffset + const nextExteriorOffset = interiorFaceOffset + layer.thickness + spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset }) + exteriorOffset = nextExteriorOffset + } + + return spans +} + +function createWallAssemblyDatumReference( + datum: WallDimensionDatum, + side: WallAssemblyDatumSide, + offset: number, + layerId?: string, +): WallAssemblyDatumReference { + return { + id: getWallAssemblyDatumReferenceId(datum, side, layerId), + datum, + side, + ...(layerId ? { layerId } : {}), + offset, + } +} + +export function resolveWallAssemblyDatumReferences( + wall: Pick, +): WallAssemblyDatumReference[] { + const layers = wall.assemblyLayers ?? [] + const references: WallAssemblyDatumReference[] = [ + createWallAssemblyDatumReference('centerline', 'center', 0), + ] + + if (layers.length === 0) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + return [ + ...references, + createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness), + createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness), + ] + } + + const spans = getWallAssemblyLayerSpans(wall) + + for (const span of spans) { + if (span.layer.datumEligible.includes('structural-face')) { + if (span.layer.side === 'core') { + references.push( + createWallAssemblyDatumReference( + 'structural-face', + 'interior', + span.interiorOffset, + span.layer.id, + ), + createWallAssemblyDatumReference( + 'structural-face', + 'exterior', + span.exteriorOffset, + span.layer.id, + ), + ) + } else { + const side = span.layer.side + references.push( + createWallAssemblyDatumReference( + 'structural-face', + side, + side === 'interior' ? span.interiorOffset : span.exteriorOffset, + span.layer.id, + ), + ) + } + } + + if (span.layer.datumEligible.includes('finish-face')) { + const side = span.layer.side === 'core' ? 'center' : span.layer.side + const offset = + span.layer.side === 'interior' + ? span.interiorOffset + : span.layer.side === 'exterior' + ? span.exteriorOffset + : (span.interiorOffset + span.exteriorOffset) / 2 + references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id)) + } + + if (span.layer.datumEligible.includes('veneer-face')) { + const side = span.layer.side === 'interior' ? 'interior' : 'exterior' + const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset + references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id)) + } + } + + if (!references.some((reference) => reference.datum === 'structural-face')) { + const halfThickness = getWallAssemblyThickness(wall) / 2 + references.push( + createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness), + ) + } + + if (!references.some((reference) => reference.datum === 'finish-face')) { + const halfThickness = getWallAssemblyThickness(wall) / 2 + references.push( + createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness), + ) + } + + return references +} + +export function resolveWallAssemblyDatumReference( + wall: Pick, + referenceId: string, +): WallAssemblyDatumReference | null { + return ( + resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ?? + null + ) +} + // Declared default appearance for an unpainted wall face in colored mode — // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the // slot declaration (nodes) and the material resolver (viewer) share one value. diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 08c5ce4e..53b7fec5 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -17,6 +17,16 @@ export const WindowType = z.enum([ ]) export type WindowType = z.infer +export const WindowConstructionType = z.enum(['framed', 'masonry']) +export const WindowDimensionReference = z.enum([ + 'nominal', + 'rough-opening', + 'masonry-opening', + 'finish-opening', +]) +export type WindowConstructionType = z.infer +export type WindowDimensionReference = z.infer + export const WindowNode = BaseNode.extend({ id: objectId('window'), type: nodeType('window'), @@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({ width: z.number().default(1.5), height: z.number().default(1.5), + // Construction-document identity and optional manufacturer rough opening. + // Legacy scenes omit these fields and continue to parse unchanged. + mark: z.string().trim().max(16).optional(), + constructionType: WindowConstructionType.default('framed'), + dimensionReference: WindowDimensionReference.default('nominal'), + roughOpeningWidth: z.number().positive().optional(), + roughOpeningHeight: z.number().positive().optional(), + masonryOpeningWidth: z.number().positive().optional(), + masonryOpeningHeight: z.number().positive().optional(), + finishOpeningWidth: z.number().positive().optional(), + finishOpeningHeight: z.number().positive().optional(), + // Opening mode - when set to "opening", the window is only a shaped cutout openingKind: z.enum(['window', 'opening']).default('window'), diff --git a/packages/core/src/schema/nodes/zone.test.ts b/packages/core/src/schema/nodes/zone.test.ts new file mode 100644 index 00000000..95a80512 --- /dev/null +++ b/packages/core/src/schema/nodes/zone.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { ZoneNode } from './zone' + +describe('ZoneNode architectural room data', () => { + test('keeps legacy zones generic while supplying room-safe defaults', () => { + const zone = ZoneNode.parse({ + id: 'zone_legacy', + name: 'Landscape area', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + ], + }) + + expect(zone).toMatchObject({ + spaceRole: 'generic', + roomNumber: '', + enclosureStatus: 'auto', + floorFinish: '', + wallFinish: '', + ceilingFinish: '', + ceilingHeight: 2.7, + occupancy: '', + clearDimensionPolicy: 'none', + }) + }) + + test('persists a complete architectural room profile', () => { + const room = ZoneNode.parse({ + id: 'zone_office', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + ], + spaceRole: 'room', + roomNumber: '101', + enclosureStatus: 'enclosed', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 3, + occupancy: 'Business', + clearDimensionPolicy: 'inside-faces', + }) + + expect(room.spaceRole).toBe('room') + expect(room.roomNumber).toBe('101') + expect(room.clearDimensionPolicy).toBe('inside-faces') + }) +}) diff --git a/packages/core/src/schema/nodes/zone.ts b/packages/core/src/schema/nodes/zone.ts index 55a17957..227f5a4d 100644 --- a/packages/core/src/schema/nodes/zone.ts +++ b/packages/core/src/schema/nodes/zone.ts @@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({ // stored polygon remains a fallback for missing or temporarily open walls. autoFromWalls: z.boolean().default(false), boundaryWallIds: z.array(objectId('wall')).default([]), + // Generic zones remain available for sites and analysis. Architectural + // room documentation is opt-in so legacy zone behavior is unchanged. + spaceRole: z.enum(['generic', 'room']).default('generic'), + roomNumber: z.string().trim().max(32).default(''), + enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'), + floorFinish: z.string().trim().max(120).default(''), + wallFinish: z.string().trim().max(120).default(''), + ceilingFinish: z.string().trim().max(120).default(''), + ceilingHeight: z.number().min(0.1).default(2.7), + occupancy: z.string().trim().max(80).default(''), + clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'), // Visual styling color: z.string().default('#3b82f6'), // Default blue metadata: z.json().optional().default({}), @@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({ - polygon: array of [x, z] points defining the zone boundary - autoFromWalls: whether the boundary follows an enclosed wall loop - boundaryWallIds: wall ids that prove the procedural enclosure + - spaceRole: generic site/analysis zone or architectural room + - roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata + - enclosureStatus: auto-detected, explicitly enclosed, or open + - clearDimensionPolicy: optional room clear-dimension datum preference - color: hex color for visual styling - metadata: zone metadata (optional) `, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index da37c6f2..2c980bfc 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet' import { CeilingNode } from './nodes/ceiling' import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' +import { ConstructionDimensionNode } from './nodes/construction-dimension' import { CupolaNode } from './nodes/cupola' import { DoorNode } from './nodes/door' import { DormerNode } from './nodes/dormer' import { DownspoutNode } from './nodes/downspout' +import { DrawingSheetNode } from './nodes/drawing-sheet' import { DuctFittingNode } from './nodes/duct-fitting' import { DuctSegmentNode } from './nodes/duct-segment' import { DuctTerminalNode } from './nodes/duct-terminal' @@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel' import { SpawnNode } from './nodes/spawn' import { StairNode } from './nodes/stair' import { StairSegmentNode } from './nodes/stair-segment' +import { StructuralGridNode } from './nodes/structural-grid' import { TurbineVentNode } from './nodes/turbine-vent' import { WallNode } from './nodes/wall' import { WindowNode } from './nodes/window' @@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [ ElevatorNode, LevelNode, ColumnNode, + ConstructionDimensionNode, + StructuralGridNode, WallNode, FenceNode, CabinetNode, @@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [ SkylightNode, DormerNode, DownspoutNode, + DrawingSheetNode, DuctSegmentNode, DuctFittingNode, DuctTerminalNode, diff --git a/packages/core/src/store/use-scene-construction-dimension-migration.test.ts b/packages/core/src/store/use-scene-construction-dimension-migration.test.ts new file mode 100644 index 00000000..6becb6d1 --- /dev/null +++ b/packages/core/src/store/use-scene-construction-dimension-migration.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +describe('scene construction-dimension migrations', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('normalizes the legacy reference presentation before parsing', () => { + useScene.getState().setScene( + { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'building_test', + visible: true, + metadata: {}, + children: ['construction-dimension_test'], + level: 0, + }, + 'construction-dimension_test': { + object: 'node', + id: 'construction-dimension_test', + type: 'construction-dimension', + parentId: 'level_test', + visible: true, + metadata: {}, + reference: true, + referenceStyle: 'suffix', + drawingOverrides: [{ drawingType: 'roof-plan', presentation: 'reference' }], + }, + } as unknown as Record, + ['site_test'] as never, + ) + + const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode & + Record + expect(dimension.reference).toBeUndefined() + expect(dimension.referenceStyle).toBeUndefined() + expect(dimension.drawingOverrides).toEqual([ + { drawingType: 'roof-plan', presentation: 'shown' }, + ]) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index be23e8e5..6036dff8 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -570,6 +570,27 @@ function migrateRoofSurfaceMaterials(node: Record) { return next } +function migrateConstructionDimension(node: Record) { + const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : [] + const hasLegacyDrawingOverride = drawingOverrides.some( + (entry) => + entry && + typeof entry === 'object' && + !Array.isArray(entry) && + entry.presentation === 'reference', + ) + if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node + + const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node + return { + ...dimension, + drawingOverrides: drawingOverrides.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry + return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry + }), + } +} + // Walls whose top lands within this of the storey plane become plane-bound; // ceilings whose stored height lands within this of their clamp bound become // follows-mode (step 3f) — same census-backed threshold for both. @@ -687,6 +708,10 @@ function migrateNodes(nodes: Record): { } } + if (node.type === 'construction-dimension') { + patchedNodes[id] = migrateConstructionDimension(node) + } + if (node.type === 'stair') { const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) if (normalized) { diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts index 743f14ca..5107922c 100644 --- a/packages/core/src/systems/wall/wall-curve.ts +++ b/packages/core/src/systems/wall/wall-curve.ts @@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) { } } -function getWallArcData(wall: WallCurveLike) { +export function getWallArcData(wall: WallCurveLike) { const chord = getWallChordFrame(wall) const sagitta = getClampedWallCurveOffset(wall) diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts index 637b1840..d73bd8d4 100644 --- a/packages/core/src/systems/wall/wall-mitering.test.ts +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN visible: true, parentId: 'level_test', children: [], + assemblyLayers: [], start, end, thickness: 0.1, diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index 93fd3c69..02aea9c5 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -77,6 +77,119 @@ describe('forkSceneGraph', () => { }) }) +describe('construction-dimension clone references', () => { + function sceneWithControlledDimensions(): SceneGraph { + const site = makeNode('site_1', 'site', { children: ['level_1'] }) + const level = makeNode('level_1', 'level', { + parentId: 'site_1', + children: ['construction-dimension_foundation', 'construction-dimension_floor'], + }) + const controller = makeNode('construction-dimension_foundation', 'construction-dimension', { + name: 'Foundation controller', + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + controllingDimensionId: null, + }) + const dependent = makeNode('construction-dimension_floor', 'construction-dimension', { + name: 'Floor dependent', + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + controllingDimensionId: controller.id, + }) + return { + nodes: { + [site.id]: site, + [level.id]: level, + [controller.id]: controller, + [dependent.id]: dependent, + }, + rootNodeIds: [site.id], + } + } + + test('remaps controller IDs in whole-scene clones', () => { + const cloned = cloneSceneGraph(sceneWithControlledDimensions()) + const dimensions = Object.values(cloned.nodes).filter( + (node) => node.type === 'construction-dimension', + ) + const controller = dimensions.find((node) => node.name === 'Foundation controller') + const dependent = dimensions.find((node) => node.name === 'Floor dependent') + + expect(controller?.type).toBe('construction-dimension') + expect(dependent?.type).toBe('construction-dimension') + if ( + controller?.type === 'construction-dimension' && + dependent?.type === 'construction-dimension' + ) { + expect(dependent.controllingDimensionId).toBe(controller.id) + } + }) + + test('remaps controller IDs in level-subtree clones', () => { + const scene = sceneWithControlledDimensions() + const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId) + const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension') + const controller = dimensions.find((node) => node.name === 'Foundation controller') + const dependent = dimensions.find((node) => node.name === 'Floor dependent') + + expect(controller?.type).toBe('construction-dimension') + expect(dependent?.type).toBe('construction-dimension') + if ( + controller?.type === 'construction-dimension' && + dependent?.type === 'construction-dimension' + ) { + expect(dependent.controllingDimensionId).toBe(controller.id) + } + }) +}) + +describe('drawing-sheet clone references', () => { + test('remaps placed levels and nested sheet identities in whole-scene clones', () => { + const level = makeNode('level_main', 'level') + const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', { + placedViews: [{ id: 'drawing-view_main', levelId: level.id }], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + position: [1, 1], + }, + ], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + }) + const cloned = cloneSceneGraph({ + nodes: { [level.id]: level, [sheet.id]: sheet }, + rootNodeIds: [level.id, sheet.id] as AnyNodeId[], + }) + const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level') + const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet') + + expect(clonedLevel).toBeDefined() + expect(clonedSheet?.type).toBe('drawing-sheet') + if (clonedLevel && clonedSheet?.type === 'drawing-sheet') { + expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id) + expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main') + expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe( + clonedSheet.keyedNoteDefinitions[0]?.id, + ) + expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id) + } + }) +}) + describe('supportSlabId remap', () => { test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => { const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] }) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 8235c012..5c6ec8bc 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -1,8 +1,12 @@ import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation' -import { remapMeasurementReferences } from '../lib/measurement-geometry' +import { + remapConstructionDimensionReferences, + remapMeasurementReferences, +} from '../lib/measurement-geometry' import type { AnyNode, AnyNodeId } from '../schema' import { generateId } from '../schema/base' import type { Collection, CollectionId } from '../schema/collections' +import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet' export type SceneGraph = { nodes: Record @@ -45,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { for (const [oldId, node] of Object.entries(nodes)) { const newId = idMap.get(oldId)! as AnyNodeId - const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode + let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode // Remap parentId if (clonedNode.parentId && typeof clonedNode.parentId === 'string') { @@ -107,6 +111,12 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { if (clonedNode.type === 'measurement') { clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap) } + if (clonedNode.type === 'construction-dimension') { + clonedNode = remapConstructionDimensionReferences(clonedNode, idMap) + } + if (clonedNode.type === 'drawing-sheet') { + clonedNode = remapDrawingSheetReferences(clonedNode, idMap) + } clonedNodes[newId] = clonedNode } @@ -221,7 +231,7 @@ export function cloneLevelSubtree( const newId = idMap.get(oldId)! as AnyNodeId // JSON roundtrip: safely strips functions, Object3D, circular refs, etc. - const cloned = JSON.parse(JSON.stringify(node)) as AnyNode + let cloned = JSON.parse(JSON.stringify(node)) as AnyNode ;(cloned as Record).id = newId // Remap parentId — but only for descendants, not the level node itself @@ -274,6 +284,12 @@ export function cloneLevelSubtree( if (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } + if (cloned.type === 'construction-dimension') { + cloned = remapConstructionDimensionReferences(cloned, idMap) + } + if (cloned.type === 'drawing-sheet') { + cloned = remapDrawingSheetReferences(cloned, idMap) + } clonedNodes.push(cloned) } diff --git a/packages/editor/package.json b/packages/editor/package.json index bec9357d..73d5899f 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -40,16 +40,16 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@visual-json/react": "^0.4.0", + "blob-stream": "^0.1.3", "class-variance-authority": "^0.7.1", "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", + "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", @@ -59,8 +59,10 @@ "@pascal-app/core": "^0.9.2", "@pascal-app/viewer": "^0.9.2", "@pascal/typescript-config": "*", + "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", "@types/howler": "^2.2.12", + "@types/pdfkit": "^0.17.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx index b98d2394..8478747b 100644 --- a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx @@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context' export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { const guides = useAlignmentGuides((s) => s.guides) const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) const ctx = useFloorplanRender() if (guides.length === 0) return null @@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid // offset along X. const pillX = axis === 'x' ? midX + pillOffset : midX const pillZ = axis === 'z' ? midZ + pillOffset : midZ - const distLabel = formatMeasurement(distMeters, unit) + const distLabel = formatMeasurement(distMeters, unit, metricNotation) const charWidth = pillFontSize * 0.55 const pillWidth = distLabel.length * charWidth + pillPadX * 2 const pillHeight = pillFontSize + pillPadY * 2 diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx index 7e690b82..70840020 100644 --- a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx @@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() { const draftLevelId = useMeasurementDraft((state) => state.levelId) const activeLevelId = useViewer((state) => state.selection.levelId) const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) useEffect(() => { if (active) { @@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() { angle: Math.atan2(end[2] - start[2], end[0] - start[0]), point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], screenUpright: false, - text: formatLinearMeasurement(measurementDistance(start, end), unit), + text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation), } } else if (kind === 'angle' && livePoints.length >= 3) { const anglePoints = livePoints.slice(0, 3) as [ @@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() { text: kind === 'area' ? `A ${formatAreaLabel(measurementArea(livePoints), unit)}` - : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`, + : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`, } } } else if (kind === 'volume' && center && baseNormal) { @@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() { (segmentStart[1] + segmentEnd[1]) / 2, (segmentStart[2] + segmentEnd[2]) / 2, ], - text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit), + text: formatLinearMeasurement( + measurementDistance(segmentStart, segmentEnd), + unit, + metricNotation, + ), } } } @@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() { polygonPoints, segmentLabel, } - }, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag]) + }, [ + axisGuide, + baseNormal, + extrusionHeight, + hover, + kind, + metricNotation, + points, + stage, + unit, + vertexDrag, + ]) if (smartActive) return if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null @@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() { text={`${hover.semantic.label}${ hover.semantic.length === null ? '' - : ` · ${formatLinearMeasurement(hover.semantic.length, unit)}` + : ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}` }`} textColor={labelText} unitsPerPixel={unitsPerPixel} diff --git a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx new file mode 100644 index 00000000..54b240cc --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx @@ -0,0 +1,61 @@ +'use client' + +import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react' +import { + type FloorplanToolContext, + getFloorplanNodeExtension, +} from '../../lib/floorplan/floorplan-extension' +import useEditor from '../../store/use-editor' + +const lazyToolCache = new WeakMap<() => Promise, ComponentType>() + +function registeredFloorplanTool(tool: string | null): ComponentType | null { + if (!tool) return null + const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool + if (!loader) return null + const cached = lazyToolCache.get(loader) + if (cached) return cached + const component = lazy(loader) + lazyToolCache.set(loader, component) + return component +} + +export function FloorplanRegisteredToolLayer() { + const mode = useEditor((state) => state.mode) + const tool = useEditor((state) => state.tool) + const gridSnapStep = useEditor((state) => state.gridSnapStep) + const toolDefaults = useEditor((state) => + state.tool ? (state.toolDefaults[state.tool] ?? null) : null, + ) + const activeLevelId = useViewer((state) => state.selection.levelId) + const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) + const sceneApi = useMemo(() => createSceneApi(useScene), []) + const selectNode = useCallback( + (id: Parameters[0]) => + useViewer.getState().setSelection({ selectedIds: [id] }), + [], + ) + const finishTool = useCallback(() => { + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + }, []) + if (mode !== 'build') return null + const Tool = registeredFloorplanTool(tool) + return Tool ? ( + + + + ) : null +} diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index bef46dcd..315834d1 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -20,16 +20,21 @@ import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' import { useReducedMotion } from '../../hooks/use-reduced-motion' import { resolveMoveActionNode } from '../../lib/direct-manipulation' +import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension' import { createFreshPlacementSubtree, duplicatesAsFreshSubtree, } from '../../lib/fresh-planar-placement' +import { curveReshapeScope } from '../../lib/interaction/scope' import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback' import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes' import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' import useEditor from '../../store/use-editor' -import { useMovingNode } from '../../store/use-interaction-scope' +import useInteractionScope, { + useIsCurveReshape, + useMovingNode, +} from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' import { IconRefGlyph } from '../ui/icon-ref' @@ -106,6 +111,8 @@ function collectQuickActionNodes( * `` / dispatcher picks the right path. * Walls are excluded — their move is reached via the side-arrow * handles emitted from `def.floorplan`, not via a menu button. + * - Curve (wall only): enters curve reshape mode. The selected wall's + * midpoint curve handle remains visible so it can be dragged in plan. * - Add hole (slab + ceiling only): inserts a small default-square * hole at the polygon centroid via `updateNode`. Mirrors the legacy * `handleAddHole` in `floating-action-menu.tsx`. @@ -114,7 +121,7 @@ function collectQuickActionNodes( * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's * `relations.cascadeDelete` if declared on the def. * - * Hidden while in a move state (so we don't show buttons over a ghost). + * Hidden while moving or curving so the menu does not compete with the active affordance. */ export function FloorplanRegistryActionMenu() { const reducedMotion = useReducedMotion() @@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() { s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined, ) as AnyNodeId | undefined const movingNode = useMovingNode() + const isCurveReshape = useIsCurveReshape() const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) // Gate on floorplan hover so this 2D menu never coexists with the 3D @@ -139,10 +147,28 @@ export function FloorplanRegistryActionMenu() { // Only show for registered kinds (skip legacy kinds — they have their // own FloorplanActionMenuLayer entries). const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null)) + const canCurve = useScene((s) => { + if (!selectedId) return false + const selectedNode = s.nodes[selectedId] + if (!selectedNode) return false + const definition = nodeRegistry.get(selectedNode.type) + const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve + return ( + !!definition?.floorplanAffordances?.curve && + !!canCurveNode?.({ + node: selectedNode as never, + nodes: s.nodes, + }) + ) + }) const def = selectedKind ? nodeRegistry.get(selectedKind) : null const isRegistryKind = !!def const isVisible = - isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered + isRegistryKind && + def?.presentation?.actionMenu !== false && + !movingNode && + !isCurveReshape && + isFloorplanHovered const isWall = selectedKind === 'wall' const quickActionNodes = useScene( useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)), @@ -284,6 +310,12 @@ export function FloorplanRegistryActionMenu() { ) } + const handleCurve = () => { + if (!canCurve) return + sfxEmitter.emit('sfx:item-pick') + useInteractionScope.getState().begin(curveReshapeScope(node.id)) + } + const handleDuplicate = () => { if (!node.parentId) return sfxEmitter.emit('sfx:item-pick') @@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() { > { + test('treats fixed annotation categories as layout obstacles', () => { + expect( + floorplanAnnotationObstacleMode({ + kind: 'text', + x: 0, + y: 0, + text: 'BEDROOM', + fontSize: 0.18, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + }), + ).toBe('bounds') + expect( + floorplanAnnotationObstacleMode({ + kind: 'line', + x1: 0, + y1: 0, + x2: 1, + y2: 0, + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + }), + ).toBe('bounds') + expect( + floorplanAnnotationObstacleMode({ + kind: 'polyline', + points: [ + [0, 0], + [1, 0], + ], + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + }), + ).toBe('outline') + }) +}) + +describe('collectAnnotationLayoutPreflightIssues', () => { + test('reports unresolved collisions, short labels, and plan geometry conflicts separately', () => { + const issues = collectAnnotationLayoutPreflightIssues( + [ + { + id: 'short', + x: 0, + y: 0, + width: 40, + height: 10, + priority: 10, + text: '1"', + labelPlacement: 'outside-end', + }, + { + id: 'blocked', + x: 100, + y: 0, + width: 40, + height: 10, + priority: 10, + text: 'Blocked', + }, + { + id: 'overlap-a', + x: 200, + y: 0, + width: 40, + height: 10, + priority: 10, + text: 'A', + }, + { + id: 'overlap-b', + x: 205, + y: 0, + width: 40, + height: 10, + priority: 10, + text: 'B', + }, + ], + [ + { id: 'short', dx: 0, dy: 0, resolved: true }, + { id: 'blocked', dx: 0, dy: 0, resolved: true }, + { id: 'overlap-a', dx: 0, dy: 0, resolved: false }, + { id: 'overlap-b', dx: 0, dy: 0, resolved: true }, + ], + [{ x: 96, y: -2, width: 48, height: 14 }], + ) + + expect(issues.map((issue) => issue.kind)).toEqual([ + 'short-unreadable-segment', + 'plan-geometry-conflict', + 'unresolved-collision', + ]) + expect(issues.every((issue) => issue.severity === 'warning')).toBe(true) + }) +}) + +describe('resolveAnnotationLabelRectangles', () => { + test('keeps the higher-priority label and moves the conflicting label', () => { + const shifts = resolveAnnotationLabelRectangles([ + { id: 'overall', x: 0, y: 0, width: 80, height: 12, priority: 100 }, + { id: 'opening', x: 20, y: 0, width: 50, height: 12, priority: 50 }, + ]) + + expect(shifts.find((entry) => entry.id === 'overall')).toMatchObject({ dx: 0, dy: 0 }) + expect(shifts.find((entry) => entry.id === 'opening')).not.toMatchObject({ dx: 0, dy: 0 }) + expect(shifts.every((entry) => entry.resolved)).toBe(true) + }) + + test('keeps pinned labels at their drawing-view override and routes other labels around them', () => { + const shifts = resolveAnnotationLabelRectangles([ + { + id: 'pinned', + x: 0, + y: 0, + width: 80, + height: 12, + priority: 1, + pinnedShift: { dx: 30, dy: 0 }, + }, + { id: 'automatic', x: 30, y: 0, width: 80, height: 12, priority: 100 }, + ]) + + expect(shifts.find((entry) => entry.id === 'pinned')).toEqual({ + id: 'pinned', + dx: 30, + dy: 0, + resolved: true, + }) + expect(shifts.find((entry) => entry.id === 'automatic')).not.toMatchObject({ + dx: 0, + dy: 0, + }) + }) + + test('does not move labels that are already clear', () => { + expect( + resolveAnnotationLabelRectangles([ + { id: 'left', x: 0, y: 0, width: 40, height: 12, priority: 10 }, + { id: 'right', x: 100, y: 0, width: 40, height: 12, priority: 10 }, + ]), + ).toEqual([ + { id: 'left', dx: 0, dy: 0, resolved: true }, + { id: 'right', dx: 0, dy: 0, resolved: true }, + ]) + }) + + test('preserves drawing order for labels on the same dimension string', () => { + const shifts = resolveAnnotationLabelRectangles([ + { id: 'first', x: 0, y: 0, width: 20, height: 12, priority: 10 }, + { id: 'second', x: 0, y: 0, width: 80, height: 12, priority: 10 }, + ]) + + expect(shifts.find((entry) => entry.id === 'first')).toMatchObject({ dx: 0, dy: 0 }) + expect(shifts.find((entry) => entry.id === 'second')).not.toMatchObject({ dx: 0, dy: 0 }) + }) + + test('slides a colliding label along its dimension string before crossing tiers', () => { + const shifts = resolveAnnotationLabelRectangles([ + { id: 'datum', x: 0, y: 0, width: 40, height: 12, priority: 20 }, + { + id: 'adjacent', + x: 0, + y: 0, + width: 40, + height: 12, + priority: 10, + tangentX: 1, + tangentY: 0, + }, + ]) + + expect(shifts.find((entry) => entry.id === 'adjacent')).toMatchObject({ dy: 0, resolved: true }) + expect(shifts.find((entry) => entry.id === 'adjacent')?.dx).not.toBe(0) + }) + + test('tries a short dimension alternative before generic relocation', () => { + const shifts = resolveAnnotationLabelRectangles( + [ + { + id: 'short-dimension', + x: 0, + y: 0, + width: 40, + height: 12, + priority: 10, + preferredShifts: [{ dx: 100, dy: 0 }], + }, + ], + [{ x: 0, y: 0, width: 40, height: 12 }], + ) + + expect(shifts).toEqual([{ id: 'short-dimension', dx: 100, dy: 0, resolved: true }]) + }) + + test('falls back to a third position when both short-dimension sides are blocked', () => { + const shifts = resolveAnnotationLabelRectangles( + [ + { + id: 'short-dimension', + x: 0, + y: 0, + width: 40, + height: 12, + priority: 10, + preferredShifts: [{ dx: 100, dy: 0 }], + }, + ], + [ + { x: 0, y: 0, width: 40, height: 12 }, + { x: 100, y: 0, width: 40, height: 12 }, + ], + ) + + expect(shifts[0]).toMatchObject({ id: 'short-dimension', resolved: true }) + expect(shifts[0]).not.toMatchObject({ dx: 0, dy: 0 }) + expect(shifts[0]).not.toMatchObject({ dx: 100, dy: 0 }) + }) + + test('approximates diagonal outlines without blocking their full bounding box', () => { + expect( + polylineObstacleRectangles([ + { x: 0, y: 0 }, + { x: 4, y: 4 }, + { x: 8, y: 8 }, + ]), + ).toEqual([ + { x: -1, y: -1, width: 6, height: 6 }, + { x: 3, y: 3, width: 6, height: 6 }, + ]) + }) + + test('moves a dimension value clear of a fixed door-mark pill', () => { + const shifts = resolveAnnotationLabelRectangles( + [{ id: 'door-width', x: 94, y: 88, width: 42, height: 16, priority: 100 }], + [{ x: 100, y: 82, width: 48, height: 32 }], + ) + + expect(shifts).toEqual([expect.objectContaining({ id: 'door-width', resolved: true })]) + const shift = shifts[0] + expect(shift).not.toMatchObject({ dx: 0, dy: 0 }) + expect( + 94 + (shift?.dx ?? 0) + 42 + 6 <= 100 || + 148 + 6 <= 94 + (shift?.dx ?? 0) || + 88 + (shift?.dy ?? 0) + 16 + 6 <= 82 || + 114 + 6 <= 88 + (shift?.dy ?? 0), + ).toBe(true) + }) + + test('finds separate nearby positions for a dense label cluster', () => { + const shifts = resolveAnnotationLabelRectangles( + Array.from({ length: 4 }, (_, index) => ({ + id: `label-${index}`, + x: 0, + y: 0, + width: 40, + height: 12, + priority: 10 - index, + })), + ) + + expect(new Set(shifts.map(({ dx, dy }) => `${dx},${dy}`))).toHaveLength(4) + expect(shifts.every((entry) => entry.resolved)).toBe(true) + }) + + test('keeps a large dense label cluster readable', () => { + const rectangles = Array.from({ length: 12 }, (_, index) => ({ + id: `label-${index}`, + x: 100 + (index % 3) * 4, + y: 100 + (index % 2) * 3, + width: 48 + (index % 4) * 8, + height: 14, + priority: 20 - index, + })) + const shifts = resolveAnnotationLabelRectangles(rectangles) + const placed = rectangles.map((rectangle) => { + const shift = shifts.find(({ id }) => id === rectangle.id) + return { + ...rectangle, + x: rectangle.x + (shift?.dx ?? 0), + y: rectangle.y + (shift?.dy ?? 0), + } + }) + + expect(shifts.every((entry) => entry.resolved)).toBe(true) + for (let index = 0; index < placed.length; index += 1) { + for (let otherIndex = index + 1; otherIndex < placed.length; otherIndex += 1) { + const left = placed[index] + const right = placed[otherIndex] + if (!left || !right) continue + const overlaps = !( + left.x + left.width + 6 <= right.x || + right.x + right.width + 6 <= left.x || + left.y + left.height + 6 <= right.y || + right.y + right.height + 6 <= left.y + ) + expect(overlaps).toBe(false) + } + } + }) + + test('resolves a construction-plan label set without blocking the view transition', () => { + const labels = Array.from({ length: 25 }, (_, index) => ({ + id: `label-${index}`, + x: index % 5, + y: index % 7, + width: 80, + height: 20, + priority: 25 - index, + })) + const obstacles = Array.from({ length: 100 }, (_, index) => ({ + x: (index % 10) * 2, + y: (index % 13) * 2, + width: 100, + height: 40, + })) + + const startedAt = performance.now() + const shifts = resolveAnnotationLabelRectangles(labels, obstacles) + const elapsedMs = performance.now() - startedAt + + expect(shifts).toHaveLength(labels.length) + expect(shifts.every((entry) => entry.resolved)).toBe(true) + expect(elapsedMs).toBeLessThan(500) + }) +}) + +describe('observeSvgAnnotationLayoutChanges', () => { + test('requests a fresh collision pass when floor-plan geometry changes after mount', () => { + const OriginalMutationObserver = globalThis.MutationObserver + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + let notify: MutationCallback | undefined + let animationFrame: FrameRequestCallback | undefined + let disconnected = false + let observedOptions: MutationObserverInit | undefined + + class FakeMutationObserver { + constructor(callback: MutationCallback) { + notify = callback + } + + observe(_target: Node, options?: MutationObserverInit): void { + observedOptions = options + } + + disconnect(): void { + disconnected = true + } + + takeRecords(): MutationRecord[] { + return [] + } + } + + globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + animationFrame = callback + return 1 + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + try { + let layoutPasses = 0 + const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { + layoutPasses += 1 + }) + + notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) + + expect(layoutPasses).toBe(0) + animationFrame?.(0) + expect(layoutPasses).toBe(1) + expect(observedOptions).toMatchObject({ + attributes: true, + childList: true, + subtree: true, + attributeFilter: expect.any(Array), + }) + + notify?.( + [ + { + attributeName: 'style', + target: { closest: () => ({}) }, + type: 'attributes', + } as unknown as MutationRecord, + ], + {} as MutationObserver, + ) + expect(layoutPasses).toBe(1) + + stop() + expect(disconnected).toBe(true) + } finally { + globalThis.MutationObserver = OriginalMutationObserver + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts new file mode 100644 index 00000000..934a806e --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts @@ -0,0 +1,688 @@ +import type { FloorplanGeometry } from '@pascal-app/core' +import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension' + +export type AnnotationLabelRectangle = { + id: string + x: number + y: number + width: number + height: number + priority: number + text?: string + labelPlacement?: 'inside' | 'outside-end' + pinnedShift?: { dx: number; dy: number } + tangentX?: number + tangentY?: number + preferredShifts?: readonly { dx: number; dy: number }[] +} + +export type AnnotationObstacleRectangle = Pick< + AnnotationLabelRectangle, + 'x' | 'y' | 'width' | 'height' +> + +export type AnnotationLabelShift = { + id: string + dx: number + dy: number + resolved: boolean +} + +const LABEL_GAP_PX = 6 +const LABEL_PLACEMENT_GAP_PX = LABEL_GAP_PX + 0.5 +const OUTLINE_SAMPLE_SPACING_PX = 6 +const OUTLINE_OBSTACLE_PADDING_PX = 1 +const MAX_LABEL_SHIFT_CANDIDATES = 512 +const PREFERRED_SHIFT_COST_STEP = 1_000_000 +const COLLISION_GRID_CELL_SIZE_PX = 64 + +class AnnotationObstacleIndex { + private readonly cells = new Map>() + + add(rectangle: AnnotationObstacleRectangle): void { + this.forEachCell(rectangle, 0, (key) => { + let cell = this.cells.get(key) + if (!cell) { + cell = new Set() + this.cells.set(key, cell) + } + cell.add(rectangle) + }) + } + + findOverlaps(rectangle: AnnotationObstacleRectangle): AnnotationObstacleRectangle[] { + const candidates = new Set() + this.forEachCell(rectangle, LABEL_GAP_PX, (key) => { + for (const candidate of this.cells.get(key) ?? []) candidates.add(candidate) + }) + return [...candidates].filter((candidate) => rectanglesOverlap(rectangle, candidate)) + } + + private forEachCell( + rectangle: AnnotationObstacleRectangle, + padding: number, + visit: (key: string) => void, + ): void { + const minCellX = Math.floor((rectangle.x - padding) / COLLISION_GRID_CELL_SIZE_PX) + const maxCellX = Math.floor( + (rectangle.x + rectangle.width + padding) / COLLISION_GRID_CELL_SIZE_PX, + ) + const minCellY = Math.floor((rectangle.y - padding) / COLLISION_GRID_CELL_SIZE_PX) + const maxCellY = Math.floor( + (rectangle.y + rectangle.height + padding) / COLLISION_GRID_CELL_SIZE_PX, + ) + for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { + for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) visit(`${cellX}:${cellY}`) + } + } +} + +export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean } +export type AnnotationLayoutOverrides = Readonly> +export type AnnotationPreflightIssueKind = + | 'unresolved-collision' + | 'short-unreadable-segment' + | 'plan-geometry-conflict' +export type AnnotationPreflightIssue = { + id: string + kind: AnnotationPreflightIssueKind + severity: 'warning' + message: string +} + +export function resolveAnnotationLabelRectangles( + rectangles: readonly AnnotationLabelRectangle[], + obstacles: readonly AnnotationObstacleRectangle[] = [], +): AnnotationLabelShift[] { + const occupied = new AnnotationObstacleIndex() + for (const obstacle of obstacles) occupied.add(obstacle) + const shifts = new Map() + const ordered = rectangles + .map((rectangle, order) => ({ order, rectangle })) + .sort( + (left, right) => + Number(Boolean(right.rectangle.pinnedShift)) - + Number(Boolean(left.rectangle.pinnedShift)) || + right.rectangle.priority - left.rectangle.priority || + left.order - right.order, + ) + + for (const { rectangle } of ordered) { + const selected = rectangle.pinnedShift ?? resolveLabelShift(rectangle, occupied) + const shift = selected ?? { dx: 0, dy: 0 } + const resolved = rectangle.pinnedShift !== undefined || selected !== undefined + occupied.add({ + x: rectangle.x + shift.dx, + y: rectangle.y + shift.dy, + width: rectangle.width, + height: rectangle.height, + }) + shifts.set(rectangle.id, { id: rectangle.id, ...shift, resolved }) + } + + return rectangles.map( + (rectangle) => shifts.get(rectangle.id) ?? { id: rectangle.id, dx: 0, dy: 0, resolved: false }, + ) +} + +export function resolveSvgAnnotationCollisions( + svg: SVGSVGElement, + options: { layoutOverrides?: AnnotationLayoutOverrides } = {}, +): AnnotationPreflightIssue[] { + const labels = Array.from(svg.querySelectorAll('[data-floorplan-annotation-label]')) + if (labels.length === 0) return [] + + for (const label of labels) { + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform + if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform) + label.removeAttribute('data-floorplan-layout-unresolved') + delete label.dataset.floorplanAnnotationLayoutDx + delete label.dataset.floorplanAnnotationLayoutDy + } + resetDimensionConnectors(svg) + + const pinnedLocalById = new Map() + const rectangles: AnnotationLabelRectangle[] = labels.map((label, index) => { + const bounds = label.getBoundingClientRect() + const matrix = label.getScreenCTM() + const id = svgAnnotationLabelId(label, index) + label.dataset.floorplanAnnotationId = id + const override = options.layoutOverrides?.[id] + const pinnedLocal = + override?.pinned === true && Number.isFinite(override.dx) && Number.isFinite(override.dy) + ? { dx: override.dx, dy: override.dy } + : undefined + if (pinnedLocal) pinnedLocalById.set(id, { x: pinnedLocal.dx, y: pinnedLocal.dy }) + const pinnedShift = + pinnedLocal && matrix + ? { + dx: matrix.a * pinnedLocal.dx + matrix.c * pinnedLocal.dy, + dy: matrix.b * pinnedLocal.dx + matrix.d * pinnedLocal.dy, + } + : undefined + const tangentLength = matrix ? Math.hypot(matrix.a, matrix.b) : 0 + const outsideStartLocalX = Number( + label.dataset.floorplanDimensionOutsideStartLocalX ?? Number.NaN, + ) + const outsideStartLocalY = Number( + label.dataset.floorplanDimensionOutsideStartLocalY ?? Number.NaN, + ) + const preferredShifts = + matrix && Number.isFinite(outsideStartLocalX) && Number.isFinite(outsideStartLocalY) + ? [ + { + dx: matrix.a * outsideStartLocalX + matrix.c * outsideStartLocalY, + dy: matrix.b * outsideStartLocalX + matrix.d * outsideStartLocalY, + }, + ] + : undefined + return { + id, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + priority: Number(label.dataset.floorplanAnnotationPriority ?? 0), + text: label.textContent?.trim() ?? '', + labelPlacement: + label.dataset.floorplanDimensionLabelPlacement === 'outside-end' ? 'outside-end' : 'inside', + pinnedShift, + tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined, + tangentY: tangentLength > 1e-9 && matrix ? matrix.b / tangentLength : undefined, + preferredShifts, + } + }) + const obstacles = Array.from( + svg.querySelectorAll('[data-floorplan-annotation-obstacle]'), + ).flatMap(svgAnnotationObstacleRectangles) + const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles) + const preflightIssues = collectAnnotationLayoutPreflightIssues(rectangles, shifts, obstacles) + + labels.forEach((label, index) => { + const rectangle = rectangles[index] + const shift = rectangle && shifts.find((candidate) => candidate.id === rectangle.id) + if (!shift || (shift.dx === 0 && shift.dy === 0)) { + label.dataset.floorplanAnnotationLayoutDx = '0' + label.dataset.floorplanAnnotationLayoutDy = '0' + if (shift && !shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true' + return + } + const matrix = label.getScreenCTM() + if (!matrix) return + const local = pinnedLocalById.get(shift.id) ?? screenVectorToLocal(matrix, shift.dx, shift.dy) + label.dataset.floorplanAnnotationLayoutDx = String(local.x) + label.dataset.floorplanAnnotationLayoutDy = String(local.y) + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute('transform', `${defaultTransform} translate(${local.x} ${local.y})`.trim()) + const preferredShift = rectangle.preferredShifts?.[0] + const usedOutsideStart = + preferredShift !== undefined && + Math.hypot(shift.dx - preferredShift.dx, shift.dy - preferredShift.dy) < 0.5 + if (usedOutsideStart) applyOutsideStartDimensionLine(label) + else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') { + showDimensionLeader(label, matrix, shift.dx, shift.dy) + } + if (!shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true' + }) + return preflightIssues +} + +export function observeSvgAnnotationLayoutChanges( + svg: SVGSVGElement, + onChange: () => void, +): () => void { + let scheduledFrame: number | null = null + const schedule = () => { + if (scheduledFrame !== null) return + const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) + scheduledFrame = requestFrame(() => { + scheduledFrame = null + onChange() + }) + } + const observer = new MutationObserver((mutations) => { + if (mutations.some(isAnnotationLayoutMutation)) schedule() + }) + observer.observe(svg, { + attributes: true, + attributeFilter: [ + 'cx', + 'cy', + 'd', + 'dominant-baseline', + 'font-family', + 'font-size', + 'font-weight', + 'height', + 'points', + 'r', + 'rx', + 'ry', + 'stroke-width', + 'text-anchor', + 'transform', + 'visibility', + 'width', + 'x', + 'x1', + 'x2', + 'y', + 'y1', + 'y2', + ], + characterData: true, + childList: true, + subtree: true, + }) + return () => { + observer.disconnect() + if (scheduledFrame === null) return + if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame) + else clearTimeout(scheduledFrame) + } +} + +function isAnnotationLayoutMutation(mutation: MutationRecord): boolean { + if (mutation.type !== 'attributes') return true + const attribute = mutation.attributeName ?? '' + const target = mutation.target as Element + const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null + + if ( + attribute === 'data-floorplan-annotation-id' || + attribute === 'data-floorplan-annotation-layout-dx' || + attribute === 'data-floorplan-annotation-layout-dy' || + attribute === 'data-floorplan-layout-unresolved' + ) { + return false + } + if ( + closest?.('[data-floorplan-annotation-label]') && + (attribute === 'style' || attribute === 'transform') + ) { + return false + } + if ( + closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') && + (attribute === 'x1' || + attribute === 'x2' || + attribute === 'y1' || + attribute === 'y2' || + attribute === 'visibility') + ) { + return false + } + return true +} + +export function collectAnnotationLayoutPreflightIssues( + rectangles: readonly AnnotationLabelRectangle[], + shifts: readonly AnnotationLabelShift[], + obstacles: readonly AnnotationObstacleRectangle[] = [], +): AnnotationPreflightIssue[] { + const shiftsById = new Map(shifts.map((shift) => [shift.id, shift])) + const finalRectangles = rectangles.map((rectangle) => { + const shift = shiftsById.get(rectangle.id) ?? { + id: rectangle.id, + dx: 0, + dy: 0, + resolved: false, + } + return { + source: rectangle, + shift, + bounds: { + x: rectangle.x + shift.dx, + y: rectangle.y + shift.dy, + width: rectangle.width, + height: rectangle.height, + }, + } + }) + const issues: AnnotationPreflightIssue[] = [] + const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => { + if (issues.some((issue) => issue.id === id && issue.kind === kind)) return + issues.push({ id, kind, severity: 'warning', message }) + } + + for (const entry of finalRectangles) { + const label = preflightLabel(entry.source) + if (entry.source.labelPlacement === 'outside-end') { + addIssue( + entry.source.id, + 'short-unreadable-segment', + `${label} is too short for inline text and uses an outside label or leader.`, + ) + } + if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) { + addIssue( + entry.source.id, + 'plan-geometry-conflict', + `${label} still conflicts with fixed plan geometry after automatic layout.`, + ) + } + if (!entry.shift.resolved) { + const collidesWithLabel = finalRectangles.some( + (candidate) => + candidate.source.id !== entry.source.id && + rectanglesOverlap(entry.bounds, candidate.bounds), + ) + if (collidesWithLabel) { + addIssue( + entry.source.id, + 'unresolved-collision', + `${label} still overlaps another annotation after automatic layout.`, + ) + } + } + } + return issues +} + +function preflightLabel(rectangle: AnnotationLabelRectangle): string { + const text = rectangle.text?.trim() + return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}` +} + +export function svgAnnotationLabelId(label: SVGGElement, index: number): string { + const explicit = label.dataset.floorplanAnnotationId?.trim() + if (explicit) return explicit + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + const text = label.textContent?.trim() ?? '' + return `annotation:${index}:${text}:${defaultTransform}` +} + +function resetDimensionConnectors(svg: SVGSVGElement): void { + for (const line of svg.querySelectorAll('[data-floorplan-dimension-line]')) { + line.setAttribute( + 'x1', + line.dataset.floorplanDimensionDefaultX1 ?? line.getAttribute('x1') ?? '0', + ) + line.setAttribute( + 'y1', + line.dataset.floorplanDimensionDefaultY1 ?? line.getAttribute('y1') ?? '0', + ) + line.setAttribute( + 'x2', + line.dataset.floorplanDimensionDefaultX2 ?? line.getAttribute('x2') ?? '0', + ) + line.setAttribute( + 'y2', + line.dataset.floorplanDimensionDefaultY2 ?? line.getAttribute('y2') ?? '0', + ) + } + for (const leader of svg.querySelectorAll('[data-floorplan-dimension-leader]')) { + leader.setAttribute('visibility', 'hidden') + } +} + +function applyOutsideStartDimensionLine(label: SVGGElement): void { + const dimension = label.closest('[data-floorplan-dimension]') + const line = dimension?.querySelector('[data-floorplan-dimension-line]') + if (!line) return + const { dataset } = line + if ( + dataset.floorplanDimensionOutsideStartX1 === undefined || + dataset.floorplanDimensionOutsideStartY1 === undefined || + dataset.floorplanDimensionOutsideStartX2 === undefined || + dataset.floorplanDimensionOutsideStartY2 === undefined + ) { + return + } + line.setAttribute('x1', dataset.floorplanDimensionOutsideStartX1) + line.setAttribute('y1', dataset.floorplanDimensionOutsideStartY1) + line.setAttribute('x2', dataset.floorplanDimensionOutsideStartX2) + line.setAttribute('y2', dataset.floorplanDimensionOutsideStartY2) +} + +function showDimensionLeader( + label: SVGGElement, + labelMatrix: DOMMatrix, + dx: number, + dy: number, +): void { + const dimension = label.closest('[data-floorplan-dimension]') as SVGGElement | null + const leader = dimension?.querySelector('[data-floorplan-dimension-leader]') + const dimensionLine = dimension?.querySelector('[data-floorplan-dimension-line]') + const dimensionMatrix = dimension?.getScreenCTM() + if (!leader || !dimensionLine || !dimensionMatrix) return + + const start = dimensionEndpoint(label, 'start') + const end = dimensionEndpoint(label, 'end') + if (!start || !end) return + dimensionLine.setAttribute('x1', String(start.x)) + dimensionLine.setAttribute('y1', String(start.y)) + dimensionLine.setAttribute('x2', String(end.x)) + dimensionLine.setAttribute('y2', String(end.y)) + const labelScreen = { x: labelMatrix.e + dx, y: labelMatrix.f + dy } + const startScreen = localPointToScreen(dimensionMatrix, start.x, start.y) + const endScreen = localPointToScreen(dimensionMatrix, end.x, end.y) + const anchor = + Math.hypot(labelScreen.x - startScreen.x, labelScreen.y - startScreen.y) < + Math.hypot(labelScreen.x - endScreen.x, labelScreen.y - endScreen.y) + ? start + : end + const labelPoint = screenPointToLocal(dimensionMatrix, labelScreen.x, labelScreen.y) + + leader.setAttribute('x1', String(anchor.x)) + leader.setAttribute('y1', String(anchor.y)) + leader.setAttribute('x2', String(labelPoint.x)) + leader.setAttribute('y2', String(labelPoint.y)) + leader.setAttribute('visibility', 'visible') +} + +function dimensionEndpoint( + label: SVGGElement, + endpoint: 'start' | 'end', +): { x: number; y: number } | null { + const x = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}X`]) + const y = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}Y`]) + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null +} + +function localPointToScreen(matrix: DOMMatrix, x: number, y: number) { + return { + x: matrix.a * x + matrix.c * y + matrix.e, + y: matrix.b * x + matrix.d * y + matrix.f, + } +} + +function screenPointToLocal(matrix: DOMMatrix, x: number, y: number) { + const local = screenVectorToLocal(matrix, x - matrix.e, y - matrix.f) + return { x: local.x, y: local.y } +} + +function svgAnnotationObstacleRectangles( + obstacle: SVGGraphicsElement, +): AnnotationObstacleRectangle[] { + const bounds = obstacle.getBoundingClientRect() + const fallback = [{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }] + if (obstacle.getAttribute('data-floorplan-annotation-obstacle') !== 'outline') return fallback + + const geometry = obstacle as SVGGeometryElement + const matrix = geometry.getScreenCTM() + if (!matrix || typeof geometry.getTotalLength !== 'function') return fallback + + try { + const length = geometry.getTotalLength() + const screenScale = Math.max(Math.hypot(matrix.a, matrix.b), Math.hypot(matrix.c, matrix.d)) + const sampleCount = Math.max(1, Math.ceil((length * screenScale) / OUTLINE_SAMPLE_SPACING_PX)) + const points = Array.from({ length: sampleCount + 1 }, (_, index) => { + const point = geometry.getPointAtLength((length * index) / sampleCount) + return { + x: matrix.a * point.x + matrix.c * point.y + matrix.e, + y: matrix.b * point.x + matrix.d * point.y + matrix.f, + } + }) + return polylineObstacleRectangles(points) + } catch { + return fallback + } +} + +export function polylineObstacleRectangles( + points: readonly { x: number; y: number }[], +): AnnotationObstacleRectangle[] { + if (points.length === 1) { + const point = points[0]! + return [ + { + x: point.x - OUTLINE_OBSTACLE_PADDING_PX, + y: point.y - OUTLINE_OBSTACLE_PADDING_PX, + width: OUTLINE_OBSTACLE_PADDING_PX * 2, + height: OUTLINE_OBSTACLE_PADDING_PX * 2, + }, + ] + } + + const rectangles: AnnotationObstacleRectangle[] = [] + for (let index = 1; index < points.length; index += 1) { + const start = points[index - 1]! + const end = points[index]! + rectangles.push({ + x: Math.min(start.x, end.x) - OUTLINE_OBSTACLE_PADDING_PX, + y: Math.min(start.y, end.y) - OUTLINE_OBSTACLE_PADDING_PX, + width: Math.abs(end.x - start.x) + OUTLINE_OBSTACLE_PADDING_PX * 2, + height: Math.abs(end.y - start.y) + OUTLINE_OBSTACLE_PADDING_PX * 2, + }) + } + return rectangles +} + +function resolveLabelShift( + rectangle: AnnotationLabelRectangle, + occupied: AnnotationObstacleIndex, +): { dx: number; dy: number } | undefined { + const candidates = new Map() + const visited = new Set() + const addCandidate = (dx: number, dy: number, preference = 0) => { + if (!Number.isFinite(dx) || !Number.isFinite(dy)) return + const key = `${dx}:${dy}` + if (visited.has(key)) return + const existing = candidates.get(key) + if (!existing || preference < existing.preference) { + candidates.set(key, { + dx, + dy, + preference, + cost: candidateCost({ dx, dy, preference }, rectangle), + }) + } + } + addCandidate(0, 0, -2) + for (const preferred of rectangle.preferredShifts ?? []) { + addCandidate(preferred.dx, preferred.dy, -1) + } + + while (candidates.size > 0 && visited.size < MAX_LABEL_SHIFT_CANDIDATES) { + let candidate: { dx: number; dy: number; preference: number; cost: number } | undefined + for (const queued of candidates.values()) { + if (!candidate || queued.cost < candidate.cost) candidate = queued + } + if (!candidate) return undefined + const key = `${candidate.dx}:${candidate.dy}` + candidates.delete(key) + visited.add(key) + + const shifted = { + ...rectangle, + x: rectangle.x + candidate.dx, + y: rectangle.y + candidate.dy, + } + const blockers = occupied.findOverlaps(shifted) + if (blockers.length === 0) return { dx: candidate.dx, dy: candidate.dy } + + const blockerBounds = blockers.reduce( + (bounds, blocker) => ({ + minX: Math.min(bounds.minX, blocker.x), + minY: Math.min(bounds.minY, blocker.y), + maxX: Math.max(bounds.maxX, blocker.x + blocker.width), + maxY: Math.max(bounds.maxY, blocker.y + blocker.height), + }), + { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + }, + ) + const left = blockerBounds.minX - LABEL_PLACEMENT_GAP_PX - rectangle.width - rectangle.x + const right = blockerBounds.maxX + LABEL_PLACEMENT_GAP_PX - rectangle.x + const above = blockerBounds.minY - LABEL_PLACEMENT_GAP_PX - rectangle.height - rectangle.y + const below = blockerBounds.maxY + LABEL_PLACEMENT_GAP_PX - rectangle.y + + addCandidate(candidate.dx, above) + addCandidate(candidate.dx, below) + addCandidate(left, candidate.dy) + addCandidate(right, candidate.dy) + addCandidate(left, above) + addCandidate(right, above) + addCandidate(left, below) + addCandidate(right, below) + } + return undefined +} + +function candidateCost( + candidate: { dx: number; dy: number; preference?: number }, + rectangle: AnnotationLabelRectangle, +): number { + const distance = Math.hypot(candidate.dx, candidate.dy) + const preference = (candidate.preference ?? 0) * PREFERRED_SHIFT_COST_STEP + if (rectangle.tangentX === undefined || rectangle.tangentY === undefined) { + return preference + distance + } + + const perpendicularMovement = Math.abs( + candidate.dx * -rectangle.tangentY + candidate.dy * rectangle.tangentX, + ) + return preference + distance + perpendicularMovement * 4 +} + +export function isFloorplanAnnotationObstacleGeometry(geometry: FloorplanGeometry): boolean { + if (floorplanAnnotationObstacleMode(geometry)) return true + if (geometry.kind !== 'group') return false + const hasPlate = geometry.children.some( + (child) => child.kind === 'rect' || child.kind === 'circle', + ) + const hasUprightText = geometry.children.some((child) => child.kind === 'text' && child.upright) + return hasPlate && hasUprightText +} + +export function floorplanAnnotationObstacleMode( + geometry: FloorplanGeometry, +): 'bounds' | 'outline' | '' | undefined { + const metadata = readFloorplanGeometryMetadata(geometry) + if (metadata.annotationObstacle) return metadata.annotationObstacle + switch (metadata.annotationRole) { + case 'room-label': + return geometry.kind === 'text' ? 'bounds' : undefined + case 'column-center': + return geometry.kind === 'line' || geometry.kind === 'text' ? 'bounds' : undefined + case 'stair-annotation': + return geometry.kind === 'polyline' || geometry.kind === 'line' ? 'outline' : 'bounds' + default: + return undefined + } +} + +function rectanglesOverlap( + left: Pick, + right: Pick, +): boolean { + return !( + left.x + left.width + LABEL_GAP_PX <= right.x || + right.x + right.width + LABEL_GAP_PX <= left.x || + left.y + left.height + LABEL_GAP_PX <= right.y || + right.y + right.height + LABEL_GAP_PX <= left.y + ) +} + +function screenVectorToLocal(matrix: DOMMatrix, dx: number, dy: number) { + const determinant = matrix.a * matrix.d - matrix.b * matrix.c + if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 } + return { + x: (matrix.d * dx - matrix.c * dy) / determinant, + y: (-matrix.b * dx + matrix.a * dy) / determinant, + } +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx new file mode 100644 index 00000000..c48ef972 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx @@ -0,0 +1,236 @@ +import { describe, expect, test } from 'bun:test' +import type { FloorplanGeometry } from '@pascal-app/core' +import { renderToStaticMarkup } from 'react-dom/server' +import { + computeArchitecturalDimensionLayout, + FloorplanDimensionRenderer, + FloorplanDimensionStringRenderer, + floorplanDimensionAnnotationPriority, +} from './floorplan-dimension-renderer' + +const dimension = { + kind: 'dimension', + start: [0, 0], + end: [4, 0], + offsetNormal: [0, 1], + offsetDistance: 0.45, + extensionOvershoot: 0.12, + text: `13'-1 1/2"`, +} satisfies Extract + +describe('architectural floor-plan dimensions', () => { + test('leaves a paper-space gap before solid extension lines', () => { + const layout = computeArchitecturalDimensionLayout(dimension, 0) + + expect(layout).not.toBeNull() + expect(layout?.extensionStart).toEqual([0, 0.075]) + expect(layout?.extensionEnd).toEqual([4, 0.075]) + expect(layout?.extensionStartTip[0]).toBe(0) + expect(layout?.extensionStartTip[1]).toBeCloseTo(0.57) + expect(layout?.extensionEndTip[0]).toBe(4) + expect(layout?.extensionEndTip[1]).toBeCloseTo(0.57) + expect(layout?.dimensionStart).toEqual([0, 0.45]) + expect(layout?.dimensionEnd).toEqual([4, 0.45]) + expect(layout?.dimensionLineEnd).toEqual([4, 0.45]) + expect(layout?.labelPlacement).toBe('inside') + }) + + test('builds consistent 45-degree architectural slash terminators', () => { + const layout = computeArchitecturalDimensionLayout(dimension, 0) + + expect(layout?.tickHalfVector[0]).toBeCloseTo(0.06364, 5) + expect(layout?.tickHalfVector[1]).toBeCloseTo(-0.06364, 5) + }) + + test('honors dimension standard overrides for gaps, terminators, and text placement', () => { + const customDimension = { + ...dimension, + extensionStartGap: 0.2, + terminator: 'dot', + textPosition: 'centered', + } satisfies Extract + const layout = computeArchitecturalDimensionLayout(customDimension, 0) + const markup = renderToStaticMarkup( + + + , + ) + + expect(layout?.extensionStart).toEqual([0, 0.2]) + expect(markup).toContain(' { + const shortDimension = { + ...dimension, + end: [0.23, 0] as [number, number], + text: '0.23m', + } + const layout = computeArchitecturalDimensionLayout(shortDimension, 0) + + expect(layout?.labelPlacement).toBe('outside-end') + expect(layout?.labelPoint[0]).toBeGreaterThan(0.23) + expect(layout?.outsideStartLabelPoint?.[0]).toBeLessThan(0) + expect(layout?.outsideStartDimensionLineStart?.[0]).toBeLessThan( + layout?.outsideStartLabelPoint?.[0] ?? 0, + ) + expect(layout?.dimensionLineEnd[0]).toBeGreaterThan(layout?.labelPoint[0] ?? 0) + expect(layout?.dimensionEnd).toEqual([0.23, 0.45]) + + const documentLayout = computeArchitecturalDimensionLayout(shortDimension, 0, 0.01) + expect(documentLayout?.labelPlacement).toBe('outside-end') + expect(documentLayout?.dimensionLineEnd[0]).toBeGreaterThan(documentLayout?.labelPoint[0] ?? 0) + + const markup = renderToStaticMarkup( + + + , + ) + expect(markup).toContain('data-floorplan-dimension-outside-start-local-x=') + expect(markup).not.toContain('data-floorplan-dimension-leader=""') + + const documentMarkup = renderToStaticMarkup( + + + , + ) + expect(documentMarkup).toContain('data-floorplan-dimension-leader=""') + expect(documentMarkup).toContain('visibility="hidden"') + }) + + test('aligns stepped feature origins to an explicit exterior baseline', () => { + const layout = computeArchitecturalDimensionLayout( + { + ...dimension, + start: [0, 0], + end: [4, 1], + dimensionStart: [0, 2], + dimensionEnd: [4, 2], + offsetDistance: 2, + }, + 0, + ) + + expect(layout?.dimensionStart).toEqual([0, 2]) + expect(layout?.dimensionEnd).toEqual([4, 2]) + expect(layout?.extensionStart).toEqual([0, 0.075]) + expect(layout?.extensionEnd).toEqual([4, 1.075]) + expect(layout?.extensionStartTip).toEqual([0, 2.12]) + expect(layout?.extensionEndTip).toEqual([4, 2.12]) + }) + + test('renders one uninterrupted line with the label above it', () => { + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup.match(/ { + expect(floorplanDimensionAnnotationPriority(1.67)).toBeGreaterThan( + floorplanDimensionAnnotationPriority(0.55), + ) + }) + + test('keeps labels readable after the scene rotates', () => { + expect(computeArchitecturalDimensionLayout(dimension, 180)?.labelAngleDeg).toBe(-180) + }) + + test('resolves document annotation sizes from paper points', () => { + const layout = computeArchitecturalDimensionLayout(dimension, 0, 0.01) + const markup = renderToStaticMarkup( + + + , + ) + + expect(layout?.extensionStart[1]).toBeCloseTo(0.03) + expect(layout?.extensionStartTip[1]).toBeCloseTo(0.49) + expect(markup).toContain('font-size="0.08"') + expect(markup).toContain('y="-0.05"') + }) + + test('uses PDF-safe label plates and hairline measurement strokes for export', () => { + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-dimension-label-plate=""') + expect(markup).toContain('stroke-width="0.5"') + expect(markup).not.toContain('paint-order="stroke"') + expect(markup).not.toContain('stroke="#ffffff"') + }) + + test('renders a dimension string with shared witness extension lines and ticks', () => { + const stringGeometry = { + kind: 'dimension-string', + segments: [ + { + start: [0, 0], + end: [2, 0], + dimensionStart: [0, 1], + dimensionEnd: [2, 1], + text: '2m', + }, + { + start: [2, 0], + end: [5, 0], + dimensionStart: [2, 1], + dimensionEnd: [5, 1], + text: '3m', + }, + ], + offsetNormal: [0, 1], + offsetDistance: 1, + extensionOvershoot: 0.12, + textPosition: 'above', + } satisfies Extract + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-dimension-string=""') + expect(markup.match(/ { + const automaticString = { + kind: 'dimension-string', + segments: [{ start: [0, 0], end: [2, 0], text: '2m' }], + offsetNormal: [0, 1], + offsetDistance: 0.55, + extensionOvershoot: 0.12, + textPosition: 'above', + } satisfies Extract + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-dimension-default-y1="0.55"') + expect(markup).toContain('data-floorplan-dimension-default-y2="0.55"') + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx new file mode 100644 index 00000000..e917a273 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx @@ -0,0 +1,608 @@ +import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core' +import { resolveFloorplanLabelAngle } from './floorplan-label-angle' + +const EXTENSION_START_GAP = 0.075 +const TICK_HALF_LENGTH = 0.09 +const LABEL_FONT_SIZE = 0.15 +const LABEL_BASELINE_OFFSET = 0.12 +const LABEL_CHARACTER_WIDTH_RATIO = 0.62 +const LABEL_END_GAP = 0.075 +const DOCUMENT_EXTENSION_START_GAP_PT = 3 +const DOCUMENT_EXTENSION_OVERSHOOT_PT = 4 +const DOCUMENT_TICK_HALF_LENGTH_PT = 4.5 +const DOCUMENT_LABEL_FONT_SIZE_PT = 8 +const DOCUMENT_LABEL_BASELINE_OFFSET_PT = 5 +const DOCUMENT_LABEL_END_GAP_PT = 3 +const LINE_STROKE_WIDTH_PX = 0.9 +const TICK_STROKE_WIDTH_PX = 1.35 +const PDF_LINE_STROKE_WIDTH_PT = 0.5 +const PDF_TICK_STROKE_WIDTH_PT = 0.75 +const SQRT_ONE_HALF = Math.SQRT1_2 + +type FloorplanDimensionRenderMode = 'screen' | 'pdf' + +type DimensionGeometry = Extract +type DimensionStringGeometry = Extract +type DimensionTerminator = NonNullable + +export type ArchitecturalDimensionLayout = { + dimensionStart: FloorplanPoint + dimensionEnd: FloorplanPoint + dimensionLineStart: FloorplanPoint + dimensionLineEnd: FloorplanPoint + extensionStart: FloorplanPoint + extensionEnd: FloorplanPoint + extensionStartTip: FloorplanPoint + extensionEndTip: FloorplanPoint + tickHalfVector: FloorplanPoint + labelPoint: FloorplanPoint + labelAngleDeg: number + labelPlacement: 'inside' | 'outside-end' + outsideStartLabelPoint?: FloorplanPoint + outsideStartDimensionLineStart?: FloorplanPoint +} + +export function floorplanDimensionAnnotationPriority(offsetDistance: number): number { + return 100 + Math.round(Math.abs(offsetDistance) * 100) +} + +export function computeArchitecturalDimensionLayout( + geometry: DimensionGeometry, + sceneRotationDeg: number, + annotationUnitsPerPoint?: number, +): ArchitecturalDimensionLayout | null { + const extensionStartGap = annotationUnitsPerPoint + ? DOCUMENT_EXTENSION_START_GAP_PT * annotationUnitsPerPoint + : (geometry.extensionStartGap ?? EXTENSION_START_GAP) + const extensionOvershoot = annotationUnitsPerPoint + ? DOCUMENT_EXTENSION_OVERSHOOT_PT * annotationUnitsPerPoint + : geometry.extensionOvershoot + const tickHalfLength = annotationUnitsPerPoint + ? DOCUMENT_TICK_HALF_LENGTH_PT * annotationUnitsPerPoint + : TICK_HALF_LENGTH + const labelFontSize = annotationUnitsPerPoint + ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint + : LABEL_FONT_SIZE + const labelEndGap = annotationUnitsPerPoint + ? DOCUMENT_LABEL_END_GAP_PT * annotationUnitsPerPoint + : LABEL_END_GAP + const offsetX = geometry.offsetNormal[0] * geometry.offsetDistance + const offsetY = geometry.offsetNormal[1] * geometry.offsetDistance + const dimensionStart: FloorplanPoint = geometry.dimensionStart ?? [ + geometry.start[0] + offsetX, + geometry.start[1] + offsetY, + ] + const dimensionEnd: FloorplanPoint = geometry.dimensionEnd ?? [ + geometry.end[0] + offsetX, + geometry.end[1] + offsetY, + ] + const dx = dimensionEnd[0] - dimensionStart[0] + const dy = dimensionEnd[1] - dimensionStart[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return null + + const dirX = dx / length + const dirY = dy / length + const startOffsetDistance = dot(subtract(dimensionStart, geometry.start), geometry.offsetNormal) + const endOffsetDistance = dot(subtract(dimensionEnd, geometry.end), geometry.offsetNormal) + const startExtensionGap = Math.min(extensionStartGap, Math.max(0, startOffsetDistance - 0.01)) + const endExtensionGap = Math.min(extensionStartGap, Math.max(0, endOffsetDistance - 0.01)) + + // A 45-degree architectural slash. Every terminator within one string uses + // this same vector instead of rotating independently around its endpoint. + const tickHalfVector: FloorplanPoint = [ + (dirX + dirY) * tickHalfLength * SQRT_ONE_HALF, + (dirY - dirX) * tickHalfLength * SQRT_ONE_HALF, + ] + const labelWidth = Math.max( + labelFontSize, + geometry.text.length * labelFontSize * LABEL_CHARACTER_WIDTH_RATIO, + ) + const labelPlacement = + length >= labelWidth + labelEndGap * 2 ? ('inside' as const) : ('outside-end' as const) + const direction: FloorplanPoint = [dirX, dirY] + const labelPoint = + labelPlacement === 'inside' + ? ([ + (dimensionStart[0] + dimensionEnd[0]) / 2, + (dimensionStart[1] + dimensionEnd[1]) / 2, + ] as FloorplanPoint) + : addScaled(dimensionEnd, direction, labelEndGap + labelWidth / 2) + const dimensionLineEnd = + labelPlacement === 'inside' + ? dimensionEnd + : addScaled(dimensionEnd, direction, labelEndGap * 2 + labelWidth) + const outsideStartLabelPoint = + labelPlacement === 'outside-end' + ? addScaled(dimensionStart, direction, -(labelEndGap + labelWidth / 2)) + : undefined + const outsideStartDimensionLineStart = + labelPlacement === 'outside-end' + ? addScaled(dimensionStart, direction, -(labelEndGap * 2 + labelWidth)) + : undefined + + return { + dimensionStart, + dimensionEnd, + dimensionLineStart: dimensionStart, + dimensionLineEnd, + extensionStart: addScaled(geometry.start, geometry.offsetNormal, startExtensionGap), + extensionEnd: addScaled(geometry.end, geometry.offsetNormal, endExtensionGap), + extensionStartTip: addScaled(dimensionStart, geometry.offsetNormal, extensionOvershoot), + extensionEndTip: addScaled(dimensionEnd, geometry.offsetNormal, extensionOvershoot), + tickHalfVector, + labelPoint, + labelAngleDeg: resolveFloorplanLabelAngle(Math.atan2(dy, dx), sceneRotationDeg), + labelPlacement, + outsideStartLabelPoint, + outsideStartDimensionLineStart, + } +} + +function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint { + return [left[0] - right[0], left[1] - right[1]] +} + +function dot(left: FloorplanPoint, right: FloorplanPoint): number { + return left[0] * right[0] + left[1] * right[1] +} + +function addScaled( + point: FloorplanPoint, + direction: FloorplanPoint, + distance: number, +): FloorplanPoint { + return [point[0] + direction[0] * distance, point[1] + direction[1] * distance] +} + +export function FloorplanDimensionRenderer({ + geometry, + sceneRotationDeg = 0, + stroke = geometry.stroke ?? '#334155', + annotationUnitsPerPoint, + renderMode = 'screen', +}: { + geometry: DimensionGeometry + sceneRotationDeg?: number + stroke?: string + annotationUnitsPerPoint?: number + renderMode?: FloorplanDimensionRenderMode +}): React.ReactElement | null { + const layout = computeArchitecturalDimensionLayout( + geometry, + sceneRotationDeg, + annotationUnitsPerPoint, + ) + if (!layout) return null + + const labelFontSize = annotationUnitsPerPoint + ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint + : LABEL_FONT_SIZE + const labelBaselineOffset = annotationUnitsPerPoint + ? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint + : LABEL_BASELINE_OFFSET + const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset + + const lineProps = { + stroke, + strokeLinecap: 'butt' as const, + strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX, + vectorEffect: 'non-scaling-stroke' as const, + } + const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX + const terminator = geometry.terminator ?? 'architectural-tick' + const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})` + const outsideStartLocalShift = layout.outsideStartLabelPoint + ? rotateVector( + subtract(layout.outsideStartLabelPoint, layout.labelPoint), + (-layout.labelAngleDeg * Math.PI) / 180, + ) + : undefined + + return ( + + + + + {renderTerminator( + terminator, + layout.dimensionStart, + layout.dimensionEnd, + layout, + lineProps, + tickStrokeWidth, + )} + {renderTerminator( + terminator, + layout.dimensionEnd, + layout.dimensionStart, + layout, + lineProps, + tickStrokeWidth, + )} + {layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? ( + + ) : null} + + + + + ) +} + +export function FloorplanDimensionStringRenderer({ + geometry, + sceneRotationDeg = 0, + stroke = geometry.stroke ?? '#334155', + annotationUnitsPerPoint, + renderMode = 'screen', +}: { + geometry: DimensionStringGeometry + sceneRotationDeg?: number + stroke?: string + annotationUnitsPerPoint?: number + renderMode?: FloorplanDimensionRenderMode +}): React.ReactElement | null { + const segmentLayouts = geometry.segments.flatMap((segment, index) => { + const segmentGeometry: DimensionGeometry = { + kind: 'dimension', + start: segment.start, + end: segment.end, + dimensionStart: segment.dimensionStart, + dimensionEnd: segment.dimensionEnd, + offsetNormal: geometry.offsetNormal, + offsetDistance: geometry.offsetDistance, + extensionOvershoot: geometry.extensionOvershoot, + extensionStartGap: geometry.extensionStartGap, + terminator: geometry.terminator, + textPosition: geometry.textPosition, + text: segment.text, + stroke: geometry.stroke, + } + const layout = computeArchitecturalDimensionLayout( + segmentGeometry, + sceneRotationDeg, + annotationUnitsPerPoint, + ) + return layout ? [{ index, layout, segment: segmentGeometry }] : [] + }) + if (segmentLayouts.length === 0) return null + + const labelFontSize = annotationUnitsPerPoint + ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint + : LABEL_FONT_SIZE + const labelBaselineOffset = annotationUnitsPerPoint + ? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint + : LABEL_BASELINE_OFFSET + const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset + const lineProps = { + stroke, + strokeLinecap: 'butt' as const, + strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX, + vectorEffect: 'non-scaling-stroke' as const, + } + const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX + + const extensionLines = new Map() + const ticks = new Map< + string, + { point: FloorplanPoint; toward: FloorplanPoint; tickHalfVector: FloorplanPoint } + >() + for (const { layout } of segmentLayouts) { + extensionLines.set(pointKey(layout.dimensionStart), { + start: layout.extensionStart, + tip: layout.extensionStartTip, + }) + extensionLines.set(pointKey(layout.dimensionEnd), { + start: layout.extensionEnd, + tip: layout.extensionEndTip, + }) + ticks.set(pointKey(layout.dimensionStart), { + point: layout.dimensionStart, + toward: layout.dimensionEnd, + tickHalfVector: layout.tickHalfVector, + }) + ticks.set(pointKey(layout.dimensionEnd), { + point: layout.dimensionEnd, + toward: layout.dimensionStart, + tickHalfVector: layout.tickHalfVector, + }) + } + const terminator = geometry.terminator ?? 'architectural-tick' + + return ( + + {[...extensionLines.values()].map((line, index) => ( + + ))} + {segmentLayouts.map(({ index, layout }) => ( + + ))} + {[...ticks.values()].map(({ point, toward, tickHalfVector }, index) => + renderTerminator( + terminator, + point, + toward, + { tickHalfVector }, + lineProps, + tickStrokeWidth, + `tick-${index}`, + ), + )} + {segmentLayouts.map(({ index, layout, segment }) => { + const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})` + const outsideStartLocalShift = layout.outsideStartLabelPoint + ? rotateVector( + subtract(layout.outsideStartLabelPoint, layout.labelPoint), + (-layout.labelAngleDeg * Math.PI) / 180, + ) + : undefined + return ( + + {layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? ( + + ) : null} + + + + + ) + })} + + ) +} + +function rotateVector(vector: FloorplanPoint, radians: number): FloorplanPoint { + const cosine = Math.cos(radians) + const sine = Math.sin(radians) + return [vector[0] * cosine - vector[1] * sine, vector[0] * sine + vector[1] * cosine] +} + +function DimensionLabel({ + text, + y, + fontSize, + stroke, + renderMode, +}: { + text: string + y: number + fontSize: number + stroke: string + renderMode: FloorplanDimensionRenderMode +}) { + const width = Math.max(fontSize, text.length * fontSize * LABEL_CHARACTER_WIDTH_RATIO) + const plateWidth = width + fontSize * 0.5 + const plateHeight = fontSize * 1.2 + const plateY = y - fontSize * 0.82 + + return ( + <> + {renderMode === 'pdf' ? ( + + ) : null} + + {text} + + + ) +} + +function renderTerminator( + terminator: DimensionTerminator, + point: FloorplanPoint, + toward: FloorplanPoint, + layout: Pick, + lineProps: { + stroke: string + strokeLinecap: 'butt' + strokeWidth: number + vectorEffect: 'non-scaling-stroke' + }, + tickStrokeWidth: number, + key?: string, +): React.ReactElement | null { + const direction = normalized(point, toward) + if (!direction) return null + const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1]) + if (terminator === 'dot') { + return ( + + ) + } + if (terminator === 'filled-arrow' || terminator === 'open-arrow') { + const base = addScaled(point, direction, tickHalfLength * 1.7) + const normal: FloorplanPoint = [-direction[1], direction[0]] + const wing = tickHalfLength * 0.65 + const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing] + const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing] + if (terminator === 'filled-arrow') { + return ( + + ) + } + return ( + + + + + ) + } + const [tickX, tickY] = layout.tickHalfVector + return ( + + ) +} + +function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null { + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const magnitude = Math.hypot(dx, dy) + return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude] +} + +function pointKey(point: FloorplanPoint): string { + return `${point[0].toFixed(6)},${point[1].toFixed(6)}` +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx new file mode 100644 index 00000000..10672fd5 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx @@ -0,0 +1,369 @@ +import { describe, expect, test } from 'bun:test' +import type { FloorplanGeometry } from '@pascal-app/core' +import { renderToStaticMarkup } from 'react-dom/server' +import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension' +import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' + +describe('FloorplanGeometryRenderer static labels', () => { + test('renders a measurement value together with its geometry', () => { + const geometry = { + kind: 'group', + children: [ + { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0, stroke: '#334155' }, + { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 0, + text: '2.00m', + angle: 0, + offsetPx: 14, + }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain(' { + const geometry = { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 2, + text: 'A 6.0m²', + angle: Math.PI / 3, + screenUpright: true, + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('A 6.0m²') + expect(markup).toContain('rotate(-90)') + }) + + test('uses paper-point sizing when document scale is provided', () => { + const geometry = { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 2, + text: '2.00m', + angle: 0, + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('font-size="0.08"') + }) + + test('uses live screen sizing without enabling document annotation styles', () => { + const geometry = { + kind: 'group', + children: [ + { + kind: 'text', + x: 0, + y: 0, + text: 'LIVE TEXT', + fontSize: 0.16, + upright: true, + }, + { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 2, + text: '2.00m', + angle: 0, + offsetPx: 14, + }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('font-size="0.16"') + expect(markup).toContain('font-size="0.24"') + expect(markup).toContain('translate(0 -0.28)') + }) + + test('renders outlined measurement labels as PDF-safe dark text on a white plate', () => { + const geometry = { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 2, + text: '2.00m', + angle: 0, + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-dimension-label-plate=""') + expect(markup).toContain('fill="#111827"') + expect(markup).not.toContain('paint-order="stroke"') + expect(markup).not.toContain('fill="#ffffff" font-family=') + }) + + test('caps PDF annotation linework without changing live stroke widths', () => { + const geometry = { + kind: 'line', + x1: 0, + y1: 0, + x2: 2, + y2: 0, + stroke: '#334155', + strokeWidth: 2, + vectorEffect: 'non-scaling-stroke', + } satisfies FloorplanGeometry + + const liveMarkup = renderToStaticMarkup( + + + , + ) + const pdfMarkup = renderToStaticMarkup( + + + , + ) + + expect(liveMarkup).toContain('stroke-width="2"') + expect(pdfMarkup).toContain('stroke-width="0.5"') + }) + + test('removes unsupported paint-order outlines from generic PDF text', () => { + const geometry = { + kind: 'text', + x: 1, + y: 2, + text: '101', + fontSize: 0.15, + fill: '#ffffff', + stroke: '#334155', + strokeWidth: 0.04, + paintOrder: 'stroke', + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('fill="#334155"') + expect(markup).not.toContain('paint-order') + expect(markup).not.toContain('stroke=') + }) + + test('resolves generic annotation text from paper points only in document mode', () => { + const geometry = { + kind: 'text', + x: 2, + y: 3, + text: 'VERIFY DIMENSIONS', + fontSize: 0.16, + upright: true, + } satisfies FloorplanGeometry + + const liveMarkup = renderToStaticMarkup( + + + , + ) + const documentMarkup = renderToStaticMarkup( + + + , + ) + + expect(liveMarkup).toContain('font-size="0.16"') + expect(documentMarkup).toContain('font-size="0.08"') + }) + + test('uses a room-label paper profile while preserving room label hierarchy', () => { + const geometry = { + kind: 'group', + children: [ + { + kind: 'text', + x: 0, + y: 0, + text: 'KITCHEN', + fontSize: 0.2, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + upright: true, + }, + { + kind: 'text', + x: 0, + y: 0.18, + text: '101', + fontSize: 0.16, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + upright: true, + }, + { + kind: 'text', + x: 0, + y: 0.36, + text: 'CH: 2700', + fontSize: 0.11, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + upright: true, + }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('font-size="0.08"') + expect(markup).toContain('font-size="0.07"') + expect(markup).toContain('font-size="0.055"') + expect(markup).toMatch(/translate\(0 0\.08625/) + expect(markup).toMatch(/translate\(0 0\.18625/) + expect(markup).toMatch(/translate\(0 0\.27375/) + }) + + test('uses paper stroke profiles for leaders and opening marks in document mode', () => { + const geometry = { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }), + children: [ + { + kind: 'polyline', + points: [ + [0, 0], + [1, 0], + [1.4, 0], + ], + stroke: '#334155', + strokeWidth: 0.9, + vectorEffect: 'non-scaling-stroke', + }, + { + kind: 'rect', + x: 2, + y: 2, + width: 0.42, + height: 0.32, + fill: '#ffffff', + stroke: '#334155', + strokeWidth: 0.02, + }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('stroke-width="0.9"') + expect(markup).toContain('vector-effect="non-scaling-stroke"') + expect(markup).toContain('height="0.14"') + expect(markup).toContain('rx="0.07"') + }) + + test('registers fixed mark pills as annotation obstacles', () => { + const geometry = { + kind: 'group', + children: [ + { kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 }, + { kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 }, + { kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-annotation-obstacle=""') + }) + + test('registers semantic plan primitives as annotation obstacles', () => { + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-annotation-obstacle="outline"') + }) + + test('registers fixed annotation categories as obstacles', () => { + const roomLabel = { + kind: 'text', + x: 0, + y: 0, + text: 'KITCHEN', + fontSize: 0.18, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + } satisfies FloorplanGeometry + const stairArrow = { + kind: 'polyline', + points: [ + [0, 0], + [0.5, 0.5], + ], + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + + , + ) + + expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"') + expect(markup).toContain('data-floorplan-annotation-obstacle="outline"') + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx index 316c78ee..70c95b64 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx @@ -2,6 +2,29 @@ import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core' import { memo, useEffect, useState } from 'react' +import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension' +import { + floorplanAnnotationObstacleMode, + isFloorplanAnnotationObstacleGeometry, +} from './floorplan-annotation-layout' +import { + FloorplanDimensionRenderer, + FloorplanDimensionStringRenderer, +} from './floorplan-dimension-renderer' +import { resolveFloorplanLabelAngle } from './floorplan-label-angle' + +const STATIC_LABEL_UNITS_PER_PIXEL = 0.01 +const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8 +const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8 +const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7 +const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5 +const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7 +const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5 +const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75 +const DOCUMENT_MARK_HEIGHT_PT = 14 +const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5 + +type FloorplanRenderMode = 'screen' | 'pdf' /** * Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by @@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react' export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({ geometry, pointerEventsOverride, + sceneRotationDeg = 0, + annotationUnitsPerPoint, + screenUnitsPerPixel, + renderMode = 'screen', }: { geometry: FloorplanGeometry pointerEventsOverride?: string + sceneRotationDeg?: number + annotationUnitsPerPoint?: number + screenUnitsPerPixel?: number + renderMode?: FloorplanRenderMode }) { - return renderNode(geometry, 0, pointerEventsOverride) + return renderNode( + geometry, + 0, + pointerEventsOverride, + sceneRotationDeg, + annotationUnitsPerPoint, + screenUnitsPerPixel, + renderMode, + ) }) function styleAttrs( g: FloorplanGeometry & { kind: Exclude }, pointerEventsOverride?: string, + annotationUnitsPerPoint?: number, + renderMode: FloorplanRenderMode = 'screen', ) { // Shared SVG attribute mapping for any styled primitive. Keeps the per- // primitive switch arms terse and ensures new style fields land @@ -54,37 +95,241 @@ function styleAttrs( pointerEvents?: string cursor?: string } + const annotationMetadata = readFloorplanGeometryMetadata(g) + const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint) + const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect + const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth + const strokeWidth = + renderMode === 'pdf' && + vectorEffect === 'non-scaling-stroke' && + resolvedStrokeWidth !== undefined + ? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth) + : resolvedStrokeWidth return { - fill: s.fill ?? 'none', + 'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g), + 'data-floorplan-annotation-role': annotationMetadata.annotationRole, + fill: documentStyle.fill ?? s.fill ?? 'none', fillOpacity: s.fillOpacity, - stroke: s.stroke, - strokeWidth: s.strokeWidth, + stroke: documentStyle.stroke ?? s.stroke, + strokeWidth, strokeDasharray: s.strokeDasharray, strokeLinecap: s.strokeLinecap, strokeLinejoin: s.strokeLinejoin, strokeOpacity: s.strokeOpacity, opacity: s.opacity, - vectorEffect: s.vectorEffect, + vectorEffect, pointerEvents: pointerEventsOverride ?? s.pointerEvents, style: s.cursor ? { cursor: s.cursor } : undefined, } } +export function resolveDocumentFloorplanAnnotationStyle( + geometry: FloorplanGeometry & { kind: Exclude }, + annotationUnitsPerPoint?: number, +): { + fill?: string + stroke?: string + strokeWidth?: number + vectorEffect?: 'non-scaling-stroke' +} { + if (annotationUnitsPerPoint === undefined) return {} + const styled = geometry as FloorplanGeometry & { + stroke?: string + strokeWidth?: number + vectorEffect?: 'non-scaling-stroke' + } + if (!styled.stroke && geometry.kind !== 'text') return {} + + if (geometry.kind === 'text') { + const sourceFontSize = Math.max(geometry.fontSize, 1e-6) + const sourceStrokeWidth = geometry.strokeWidth ?? 0 + const outlineRatio = sourceStrokeWidth > 0 ? sourceStrokeWidth / sourceFontSize : 0 + const fontSize = documentTextFontSize(geometry, annotationUnitsPerPoint) + return { + strokeWidth: + geometry.stroke && outlineRatio > 0 + ? Math.max( + DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT * annotationUnitsPerPoint, + fontSize * outlineRatio, + ) + : undefined, + } + } + + return { + strokeWidth: documentStrokeWidth(styled, annotationUnitsPerPoint), + vectorEffect: 'non-scaling-stroke', + } +} + +function documentTextFontSize( + geometry: Extract, + annotationUnitsPerPoint: number, +): number { + return documentTextSizePt(geometry) * annotationUnitsPerPoint +} + +function documentTextSizePt(geometry: Extract): number { + switch (readFloorplanGeometryMetadata(geometry).annotationRole) { + case 'room-label': + if (geometry.fontSize >= 0.18) return DOCUMENT_ROOM_NAME_TEXT_SIZE_PT + if (geometry.fontSize >= 0.145) return DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT + return DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT + case 'column-center': + case 'stair-annotation': + return DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT + default: + return DOCUMENT_DEFAULT_TEXT_SIZE_PT + } +} + +function documentStrokeWidth( + geometry: { strokeWidth?: number }, + annotationUnitsPerPoint: number, +): number { + return Math.max(DOCUMENT_DEFAULT_STROKE_WIDTH_PT, Math.min(1.2, geometry.strokeWidth ?? 0.5)) +} + +export function documentRectGeometryAttrs( + geometry: Extract, + annotationUnitsPerPoint?: number, +) { + if (annotationUnitsPerPoint === undefined || !isAnnotationMarkRect(geometry)) { + return { + x: geometry.x, + y: geometry.y, + width: geometry.width, + height: geometry.height, + rx: geometry.rx, + ry: geometry.ry, + } + } + + const centerX = geometry.x + geometry.width / 2 + const centerY = geometry.y + geometry.height / 2 + const height = DOCUMENT_MARK_HEIGHT_PT * annotationUnitsPerPoint + const width = Math.max(height * 1.6, (geometry.width / Math.max(geometry.height, 1e-6)) * height) + return { + x: centerX - width / 2, + y: centerY - height / 2, + width, + height, + rx: height / 2, + ry: height / 2, + } +} + +function isAnnotationMarkRect(geometry: Extract): boolean { + return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5 +} + +export function documentCircleGeometryAttrs( + geometry: Extract, + annotationUnitsPerPoint?: number, +) { + if (annotationUnitsPerPoint === undefined || !isAnnotationMarkCircle(geometry)) { + return { r: geometry.r } + } + return { r: Math.max(geometry.r, (DOCUMENT_MARK_HEIGHT_PT / 2) * annotationUnitsPerPoint) } +} + +function isAnnotationMarkCircle(geometry: Extract): boolean { + return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.r <= 0.25 +} + +export function resolveDocumentAnnotationGroupChildren( + children: FloorplanGeometry[], + annotationUnitsPerPoint?: number, +): FloorplanGeometry[] { + if (annotationUnitsPerPoint === undefined) return children + + const next = [...children] + let start = 0 + while (start < next.length) { + const first = next[start] + if (!isDocumentTextLine(first)) { + start++ + continue + } + + let end = start + 1 + while (end < next.length && isSameDocumentTextRun(first, next[end])) end++ + if (end - start > 1) { + const run = next.slice(start, end) as Extract[] + const adjusted = positionDocumentTextRun(run, annotationUnitsPerPoint) + for (let index = 0; index < adjusted.length; index++) { + const line = adjusted[index] + if (line) next[start + index] = line + } + } + start = end + } + + return next +} + +function isDocumentTextLine( + geometry: FloorplanGeometry | undefined, +): geometry is Extract { + return geometry?.kind === 'text' && geometry.upright === true +} + +function isSameDocumentTextRun( + first: Extract, + candidate: FloorplanGeometry | undefined, +): candidate is Extract { + return ( + isDocumentTextLine(candidate) && + Math.abs(candidate.x - first.x) < 1e-6 && + candidate.textAnchor === first.textAnchor && + readFloorplanGeometryMetadata(candidate).annotationRole === + readFloorplanGeometryMetadata(first).annotationRole + ) +} + +function positionDocumentTextRun( + run: Extract[], + annotationUnitsPerPoint: number, +): Extract[] { + const centerY = run.reduce((sum, line) => sum + line.y, 0) / run.length + const steps = run.slice(0, -1).map((line, index) => { + const next = run[index + 1] ?? line + const largerFontPt = Math.max(documentTextSizePt(line), documentTextSizePt(next)) + return largerFontPt * 1.25 * annotationUnitsPerPoint + }) + const totalHeight = steps.reduce((sum, step) => sum + step, 0) + let y = centerY - totalHeight / 2 + return run.map((line, index) => { + if (index > 0) y += steps[index - 1] ?? 0 + return { ...line, y } + }) +} + function renderNode( g: FloorplanGeometry, keyHint: number, pointerEventsOverride?: string, + sceneRotationDeg = 0, + annotationUnitsPerPoint?: number, + screenUnitsPerPixel?: number, + renderMode: FloorplanRenderMode = 'screen', ): React.ReactElement | null { switch (g.kind) { case 'path': - return + return ( + + ) case 'polygon': return ( ) @@ -93,34 +338,38 @@ function renderNode( ) - case 'rect': + case 'rect': { + const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint) return ( ) + } - case 'circle': + case 'circle': { + const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint) return ( ) + } case 'line': return ( @@ -130,25 +379,65 @@ function renderNode( x2={g.x2} y1={g.y1} y2={g.y2} - {...styleAttrs(g, pointerEventsOverride)} + {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)} /> ) - case 'text': + case 'text': { + const fontSize = + annotationUnitsPerPoint !== undefined + ? documentTextFontSize(g, annotationUnitsPerPoint) + : g.fontSize + const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint) + const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke + const fill = + pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff' + ? g.stroke + : (g.fill ?? '#171717') + if (g.upright) { + return ( + + + {g.text} + + + ) + } return ( ) + } + + case 'dimension': + return ( + + ) + + case 'dimension-string': + return ( + + ) + + case 'dimension-label': { + const unitsPerPixel = + annotationUnitsPerPoint ?? screenUnitsPerPixel ?? STATIC_LABEL_UNITS_PER_PIXEL + const documentMode = annotationUnitsPerPoint !== undefined + const outlined = g.appearance === 'outlined' + const pdfOutlined = outlined && renderMode === 'pdf' + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = unitsPerPixel * (documentMode ? 8 : outlined ? 12 : 10) + const textWidth = g.text.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright) + const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * unitsPerPixel})` + + return ( + + {outlined && !pdfOutlined ? null : ( + + )} + + {g.text} + + + ) + } case 'image': return ( @@ -174,15 +550,32 @@ function renderNode( case 'group': { const transform = formatTransform(g.transform) + const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint) return ( - - {g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))} + + {children.map((child, i) => + renderNode( + child, + i, + pointerEventsOverride, + sceneRotationDeg, + annotationUnitsPerPoint, + screenUnitsPerPixel, + renderMode, + ), + )} ) } - // The interactive primitives (hatch / hit-line / endpoint-handle / - // dimension-label) need the SVG context + theme palette + units-per- + // The remaining interactive primitives (hatch / hit-line / endpoint-handle) + // need the SVG context + theme palette + units-per- // pixel that only the registry layer has access to. They're rendered // by `floorplan-registry-layer.tsx`'s interactive walker instead. If // a caller routes one of these through this pure renderer it diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index e2e208a8..c8ef7df1 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -3,15 +3,25 @@ import type { AnyNode, AnyNodeId, FloorplanAffordanceSession, + FloorplanGeometry, LiveNodeOverrides, } from '@pascal-app/core' import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' import { z } from 'zod' +import { + FLOORPLAN_NODE_EXTENSION_KEY, + floorplanGeometryMetadata, +} from '../../../lib/floorplan/floorplan-extension' import { cancelFloorplanAffordanceDrag, collectFloorplanDependencyNodes, + collectFloorplanLinkedLevelNodes, computeAffectedSiblingIds, floorplanHandleDoubleClickAffordance, + InteractiveGeometry, + splitFloorplanOverlay, subscribeFloorplanAffordanceToolCancel, } from './floorplan-registry-layer' @@ -209,6 +219,76 @@ describe('floorplan vertex double-click routing', () => { }) }) +describe('floorplan annotation overlay routing', () => { + test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => { + const noop = () => {} + const renderAt180Degrees = (geometry: FloorplanGeometry) => + renderToStaticMarkup( + createElement( + 'svg', + null, + createElement(InteractiveGeometry, { + activeDragId: null, + activeRotateNodeId: null, + geometry, + hatchPatternId: undefined, + hoveredHandleId: null, + isMarqueeSelectionActive: false, + nodeId: 'wall_test' as AnyNodeId, + onHandleDoubleClick: noop, + onHandleHoverChange: noop, + onHandlePointerDown: noop, + onMoveHandlePointerDown: noop, + palette: undefined, + sceneRotationDeg: 180, + unitsPerPixel: 0.01, + }), + ), + ) + const dimensionString = ( + end: readonly [number, number], + offsetNormal: readonly [number, number], + ): FloorplanGeometry => ({ + kind: 'dimension-string', + segments: [{ start: [0, 0], end, text: '2m' }], + offsetNormal, + offsetDistance: 0.55, + extensionOvershoot: 0.12, + textPosition: 'above', + }) + + expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)') + expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)') + }) + + test('keeps a fixed mark pill together in the overlay pass', () => { + const mark = { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }), + children: [ + { kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 }, + { kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 }, + { kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true }, + ], + } satisfies FloorplanGeometry + + expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark }) + }) + + test('keeps fixed annotation symbols in the overlay pass for collision layout', () => { + const columnCenter = { + kind: 'line', + x1: 0, + y1: 0, + x2: 1, + y2: 0, + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + } satisfies FloorplanGeometry + + expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter }) + }) +}) + describe('computeAffectedSiblingIds', () => { beforeEach(() => { nodeRegistry._reset() @@ -306,3 +386,45 @@ describe('collectFloorplanDependencyNodes', () => { ]) }) }) + +describe('collectFloorplanLinkedLevelNodes', () => { + test('projects a node onto a linked destination level with its real children', () => { + nodeRegistry._reset() + registerNode({ + kind: 'linked-floorplan-test', + schemaVersion: 1, + schema: z.object({ type: z.literal('linked-floorplan-test') }) as never, + category: 'structure', + defaults: () => ({}) as never, + floorplan: () => null, + extensions: { + [FLOORPLAN_NODE_EXTENSION_KEY]: { + linkedLevelIds: () => ['level_upper' as AnyNodeId], + }, + }, + } as unknown as AnyNodeDefinition) + const child = { + id: 'linked_child', + type: 'linked-child', + parentId: 'linked_parent', + } as unknown as AnyNode + const parent = { + id: 'linked_parent', + type: 'linked-floorplan-test', + parentId: 'level_lower', + children: [child.id], + } as unknown as AnyNode + const nodes = { [parent.id]: parent, [child.id]: child } + + expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([ + { id: parent.id, node: parent, children: [child] }, + ]) + expect( + collectFloorplanLinkedLevelNodes( + nodes, + 'level_upper' as AnyNodeId, + new Set([parent.id as AnyNodeId]), + ), + ).toEqual([]) + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index a94b7b48..3959bb0f 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -33,6 +33,7 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -47,6 +48,16 @@ import { snapDirectRotationDelta, } from '../../../lib/direct-manipulation' import { createEditorApi } from '../../../lib/editor-api' +import { + type FloorplanAnnotationVisibility, + filterFloorplanAnnotationGeometry, +} from '../../../lib/floorplan/annotation-visibility' +import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination' +import { + createFloorplanContextExtensions, + type FloorplanWallDimensionReference, + getFloorplanNodeExtension, +} from '../../../lib/floorplan/floorplan-extension' import { clientToPlan } from '../../../lib/floorplan/plan-coords' import { type ActiveInteractionScope, @@ -60,7 +71,10 @@ import { import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' +import useDrawingView from '../../../store/use-drawing-view' import useEditor from '../../../store/use-editor' +import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility' +import useFloorplanPreflight from '../../../store/use-floorplan-preflight' import useInteractionScope, { useEndpointReshape, useMovingNode, @@ -74,6 +88,14 @@ import { startFloorplanGroupRotate, } from '../floorplan-group-move' import { useFloorplanRender } from '../floorplan-render-context' +import { + floorplanAnnotationObstacleMode, + isFloorplanAnnotationObstacleGeometry, + observeSvgAnnotationLayoutChanges, + resolveSvgAnnotationCollisions, + svgAnnotationLabelId, +} from './floorplan-annotation-layout' +import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { resolveFloorplanLabelAngle } from './floorplan-label-angle' @@ -276,6 +298,8 @@ type NodeDeps = { node: AnyNode live: LiveTransform | undefined unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference selected: boolean highlighted: boolean hovered: boolean @@ -343,7 +367,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const selectedLevelId = useViewer((s) => s.selection.levelId) const selectedBuildingId = useViewer((s) => s.selection.buildingId) const unit = useViewer((s) => s.unit) - const showMeasurements = useViewer((s) => s.showMeasurements) + const metricNotation = useViewer((s) => s.metricNotation) const selectedIds = useViewer((s) => s.selection.selectedIds) const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) @@ -423,6 +447,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // selectors freeze to `undefined` so drag publishes do not re-render the // hidden floor-plan tree. const floorplanVisible = useEditor((s) => s.viewMode !== '3d') + const drawingType = useDrawingView((s) => s.drawingType) + const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility) + const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference) // Elevator builders read runtime state imperatively, so entries include this // rare-changing ref in their cache deps. const interactiveElevators = useInteractive((s) => s.elevators) @@ -838,15 +865,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => { if (!isNodeKindEnabled(node.type, installedPlugins)) return - const def = nodeRegistry.get(node.type) + const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType) + if (!drawingNode) return + const def = nodeRegistry.get(drawingNode.type) if (!def?.floorplan) return - if (node.type === 'measurement' && !showMeasurements) return const dependsOnSiblingInputs = !!( def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides || def.floorplanAffectedIds ) - const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs } + const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs } if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides out.push(descriptor) } @@ -863,6 +891,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { visit(levelId as AnyNodeId) + const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined + if (activeLevelNode) { + const collectedIds = new Set(out.map((entry) => entry.id)) + for (const linked of collectFloorplanLinkedLevelNodes( + nodes, + levelId as AnyNodeId, + collectedIds, + )) { + pushEntry(linked.id, linked.node, { + children: linked.children, + siblings: [], + parent: activeLevelNode, + }) + } + } + // Building-scoped kinds (`def.floorplanScope === 'building'`) live // as siblings of the level, not under it — the `visit(levelId)` DFS // above doesn't reach them. Walk every node of those kinds whose @@ -871,7 +915,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // builders that gate on the current floor — e.g. elevator service // range — keep working). Pure registry-driven dispatch: no kind // name appears in this file. - const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined const activeBuildingId = activeLevelNode ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null @@ -907,7 +950,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type) } return { entries: out, levelNodeIdsByType } - }, [installedPlugins, levelId, nodes, showMeasurements]) + }, [drawingType, installedPlugins, levelId, nodes]) // ── Generic 2D affordance dispatch ───────────────────────────────── // @@ -1290,6 +1333,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ))} + {/* Dashed group bbox — shows what a group drag carries along while a multi-selection exists, rides the live delta mid-drag, and doubles as the group's whole-area drag handle. */} @@ -1403,9 +1453,136 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) }) +function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { + const markerRef = useRef(null) + const [layoutEpoch, setLayoutEpoch] = useState(0) + const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) + const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride) + const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues) + const resetPreflightIssues = useFloorplanPreflight((state) => state.reset) + useLayoutEffect(() => { + if (!active) return + const svg = markerRef.current?.ownerSVGElement + if (!svg) return + return observeSvgAnnotationLayoutChanges(svg, () => { + setLayoutEpoch((epoch) => epoch + 1) + }) + }, [active]) + useLayoutEffect(() => { + // The epoch is only a trigger; collision inputs are measured from the live SVG below. + void layoutEpoch + if (!active) { + resetPreflightIssues() + return + } + const svg = markerRef.current?.ownerSVGElement + if (!svg) return + const preflightIssues = resolveSvgAnnotationCollisions(svg, { + layoutOverrides: annotationLayoutOverrides, + }) + setPreflightIssues(preflightIssues) + + const labels = Array.from( + svg.querySelectorAll('[data-floorplan-annotation-label]'), + ) + const cleanup: Array<() => void> = [] + for (const [index, label] of labels.entries()) { + const id = svgAnnotationLabelId(label, index) + label.dataset.floorplanAnnotationId = id + label.style.pointerEvents = 'all' + label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + label.style.cursor = 'grabbing' + const matrix = label.getScreenCTM() + if (!matrix) return + const start = { x: event.clientX, y: event.clientY } + const existing = annotationLayoutOverrides[id] ?? { + ...readFloorplanAnnotationLayoutOffset(label), + pinned: true, + } + let latest = existing + let moved = false + const onPointerMove = (moveEvent: PointerEvent) => { + moved = true + const local = screenVectorToFloorplanAnnotationLocal( + matrix, + moveEvent.clientX - start.x, + moveEvent.clientY - start.y, + ) + latest = { + dx: existing.dx + local.x, + dy: existing.dy + local.y, + pinned: true, + } + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute( + 'transform', + `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(), + ) + } + const onPointerUp = () => { + label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + if (moved) setAnnotationLayoutOverride(id, latest) + } + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + } + const onDoubleClick = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + setAnnotationLayoutOverride(id, null) + } + label.addEventListener('pointerdown', onPointerDown) + label.addEventListener('dblclick', onDoubleClick) + cleanup.push(() => { + label.removeEventListener('pointerdown', onPointerDown) + label.removeEventListener('dblclick', onDoubleClick) + label.style.pointerEvents = '' + label.style.cursor = '' + }) + } + return () => { + for (const fn of cleanup) fn() + } + }, [ + active, + annotationLayoutOverrides, + layoutEpoch, + resetPreflightIssues, + setAnnotationLayoutOverride, + setPreflightIssues, + ]) + return +} + +function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) { + const determinant = matrix.a * matrix.d - matrix.b * matrix.c + if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 } + return { + x: (matrix.d * dx - matrix.c * dy) / determinant, + y: (-matrix.b * dx + matrix.a * dy) / determinant, + } +} + +function readFloorplanAnnotationLayoutOffset(label: SVGGElement) { + const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0) + const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0) + return { + dx: Number.isFinite(dx) ? dx : 0, + dy: Number.isFinite(dy) ? dy : 0, + } +} + type FloorplanRegistryEntryProps = { activeDragId: string | null activeRotateNodeId: AnyNodeId | null + annotationVisibility: FloorplanAnnotationVisibility ctxOverrides: FloorplanContextOverrides | undefined floorplanVisible: boolean geometryCacheRef: { current: Map } @@ -1453,6 +1630,8 @@ type FloorplanRegistryEntryProps = { setMovingNodeOrigin: ReturnType['setMovingNodeOrigin'] siblingEpoch: number unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference unitsPerPixel: number visibilityRootId: AnyNodeId | undefined } @@ -1460,6 +1639,7 @@ type FloorplanRegistryEntryProps = { const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ activeDragId, activeRotateNodeId, + annotationVisibility, ctxOverrides, floorplanVisible, geometryCacheRef, @@ -1493,6 +1673,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ setMovingNodeOrigin, siblingEpoch, unit, + metricNotation, + wallDimensionReference, unitsPerPixel, visibilityRootId, }: FloorplanRegistryEntryProps): React.ReactElement | null { @@ -1599,16 +1781,21 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ selected, siblingEpoch, unit, + metricNotation, + wallDimensionReference, visibilityRootId, }) const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null + const visibleGeometry = rawGeometry + ? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility) + : null // Multi-selection shows highlight only: strip this member's edit handles / // dimension chrome (all of which live in the overlay pass) while keeping // its highlighted body geometry. const geometry = - rawGeometry && suppressHandles && pass === 'overlay' - ? stripHandleChrome(rawGeometry) - : rawGeometry + visibleGeometry && suppressHandles && pass === 'overlay' + ? stripHandleChrome(visibleGeometry) + : visibleGeometry if (!geometry) return null const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop @@ -1664,6 +1851,8 @@ type BuildFloorplanEntryGeometryArgs = { selected: boolean siblingEpoch: number unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference visibilityRootId: AnyNodeId | undefined } @@ -1707,6 +1896,8 @@ function buildFloorplanEntryGeometry({ selected, siblingEpoch, unit, + metricNotation, + wallDimensionReference, visibilityRootId, }: BuildFloorplanEntryGeometryArgs): CacheEntry | null { const def = nodeRegistry.get(node.type) @@ -1731,6 +1922,8 @@ function buildFloorplanEntryGeometry({ node, live, unit, + metricNotation, + wallDimensionReference, selected, highlighted, hovered, @@ -1808,6 +2001,8 @@ function buildFloorplanEntryGeometry({ const viewState = { selected, unit, + metricNotation, + wallDimensionReference, highlighted, hovered, moving, @@ -1826,6 +2021,11 @@ function buildFloorplanEntryGeometry({ siblings: ctxOverrides.siblings, parent: ctxOverrides.parent, levelData, + extensions: createFloorplanContextExtensions({ + metricNotation, + purpose: 'edit', + wallDimensionReference, + }), viewState: palette ? { selected, @@ -1925,7 +2125,7 @@ type InteractiveGeometryProps = { onMoveHandlePointerDown: (event: ReactPointerEvent) => void } -const InteractiveGeometry = memo(function InteractiveGeometry({ +export const InteractiveGeometry = memo(function InteractiveGeometry({ geometry, unitsPerPixel, palette, @@ -1948,7 +2148,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ case 'group': { const transform = formatGroupTransform(g.transform) return ( - + {g.children.map((child, i) => renderInteractive(child, i))} ) @@ -2499,11 +2706,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ const textWidth = g.text.length * labelUnitsPerPixel * 6.2 const plateW = textWidth + padX * 2 const plateH = fontSize + padY * 2 + const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})` return ( {outlined ? null : ( - const stroke = g.stroke ?? palette.measurementStroke - // Offset endpoints along the outward normal — this is where the - // dimension line sits, parallel to the edge. - const ox = g.offsetNormal[0] * g.offsetDistance - const oy = g.offsetNormal[1] * g.offsetDistance - const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy] - const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy] - - // Extension line endpoints — extend past the dimension line by - // `extensionOvershoot` so the tip clears the dimension stroke. - const eOvershoot = g.extensionOvershoot - const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot) - const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot) - const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy] - const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy] - - const dx = dEnd[0] - dStart[0] - const dy = dEnd[1] - dStart[1] - const length = Math.hypot(dx, dy) - if (length < 1e-6) return <> - const dirX = dx / length - const dirY = dy / length - - // Plan-unit constants matching the legacy `floorplan- - // measurements-layer.tsx`. `strokeWidth` is intentionally a - // raw value (not multiplied by `unitsPerPixel`) because every - // stroke here uses `vectorEffect: non-scaling-stroke` — the - // browser interprets it as screen-pixel-stable. Multiplying - // by `unitsPerPixel` would shrink the strokes by ~100× and - // make them invisible. Tick length, dash pattern, font size, - // and the label gap stay in plan units (they're geometry, - // not stroke width). - const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2 - const perpX = -dirY * tickHalf - const perpY = dirX * tickHalf - - const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE - const labelGap = 0.5 // plan units — gap in the dimension line for the label - const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04) - - const midX = (dStart[0] + dEnd[0]) / 2 - const midY = (dStart[1] + dEnd[1]) / 2 - const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf] - const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf] - - // Keep the label parallel to the dimension line, but decide the - // 180° flip from the on-SCREEN angle, not the local one. The parent - // `` is rotated by `sceneRotationDeg` (default 90° in the floor - // plan), so a label kept upright in local coords still renders - // upside down for half of the wall orientations. Same fix as the - // `dimension-label` case above. - let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI - let screenDeg = labelDeg + sceneRotationDeg - screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180 - if (screenDeg > 90) labelDeg -= 180 - else if (screenDeg <= -90) labelDeg += 180 - return ( - - {/* Extension lines (dashed). */} - - - {/* Dimension line: two halves with the label in between. */} - - - {/* End ticks. */} - - - {/* Rotated label centered in the gap. */} - - {g.text} - - + ) } case 'text': { @@ -2747,7 +2824,11 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ // horizontally on screen even when the floor-plan view is // rotated (default `sceneRotationDeg` is 90°). return ( - + ) } @@ -2853,6 +2935,9 @@ export function buildContext( viewState: { selected: boolean unit: 'metric' | 'imperial' + metricNotation?: 'meters' | 'millimeters' + purpose?: 'edit' | 'document' + wallDimensionReference?: FloorplanWallDimensionReference highlighted: boolean hovered: boolean moving: boolean @@ -2892,6 +2977,11 @@ export function buildContext( siblings, parent, levelData, + extensions: createFloorplanContextExtensions({ + metricNotation: viewState.metricNotation ?? 'meters', + purpose: viewState.purpose ?? 'edit', + wallDimensionReference: viewState.wallDimensionReference, + }), viewState: viewState.palette ? { selected: viewState.selected, @@ -2905,6 +2995,29 @@ export function buildContext( } } +export function collectFloorplanLinkedLevelNodes( + nodes: Record, + levelId: AnyNodeId, + excludedIds: ReadonlySet = new Set(), +): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> { + const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = [] + for (const [rawId, node] of Object.entries(nodes)) { + if (!node) continue + const definition = nodeRegistry.get(node.type) + const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds + if (!definition?.floorplan || !linkedLevelIds) continue + const id = rawId as AnyNodeId + if (excludedIds.has(id)) continue + if (!linkedLevelIds(node).includes(levelId)) continue + const childIds = (node as { children?: AnyNodeId[] }).children + const children = Array.isArray(childIds) + ? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child) + : [] + linked.push({ id, node, children }) + } + return linked +} + /** * Stable id for a handle on a node, derived from the node id + opaque * payload. Used to track hover / active visual state when multiple @@ -2951,6 +3064,7 @@ const OVERLAY_KINDS = new Set([ 'move-arrow', 'rotate-arrow', 'dimension', + 'dimension-string', 'dimension-label', 'equal-spacing-badge', ]) @@ -2969,6 +3083,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): { base: FloorplanGeometry | null overlay: FloorplanGeometry | null } { + if (isFloorplanAnnotationObstacleGeometry(g)) { + return { base: null, overlay: g } + } if (OVERLAY_KINDS.has(g.kind)) { return { base: null, overlay: g } } @@ -3130,6 +3247,8 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean { 'node', 'live', 'unit', + 'metricNotation', + 'wallDimensionReference', 'selected', 'highlighted', 'hovered', diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx index deb71ffe..b1cef1bb 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx @@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) { return Math.max(minimum, Math.round(stair.stepCount ?? 10)) } +function getFloorplanStairBreakStep(stepCount: number) { + return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * 0.68)) +} + function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') { return 0 @@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ const angle = sectorStartAngle + stepSweep * index const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle) - const dashedFromIndex = Math.floor(stepCount * 0.68) + if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) { + return null + } return ( = dashedFromIndex ? '0.1 0.08' : undefined} strokeWidth={index === stepCount ? '1.8' : '1.15'} vectorEffect="non-scaling-stroke" x1={toSvgX(innerPoint.x)} @@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ const angle = sectorStartAngle + stepSweep * index const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle) + if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) { + return null + } return ( - {treadBars.map((treadBar, treadIndex) => ( - - ))} + {treadBars + .slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1)) + .map((treadBar, treadIndex) => ( + + ))} ))} {arrow?.polyline && arrow.polyline.length >= 2 ? ( diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9baf8848..b3d8b128 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -118,6 +118,7 @@ import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOver import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu' import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer' +import { FloorplanRegisteredToolLayer } from '../editor-2d/floorplan-registered-tool-layer' import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu' import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay' import { @@ -2416,6 +2417,7 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin visible: true, metadata: {}, children: [], + assemblyLayers: [], start, end, frontSide: 'unknown', @@ -2719,9 +2721,10 @@ function formatMeasurement( value: number, unit: 'metric' | 'imperial', metersPerUnit: number | null = null, + metricNotation: 'meters' | 'millimeters' = 'meters', ) { const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value - return formatLinearMeasurement(measuredValue, unit) + return formatLinearMeasurement(measuredValue, unit, metricNotation) } function formatNumber(value: number, fractionDigits = 2) { @@ -3614,6 +3617,7 @@ function FloorplanReferenceScaleDraftLine({ unitsPerPixel: number }) { const cursor = useFloorplanDraftPreview((s) => s.cursorPoint) + const metricNotation = useViewer((state) => state.metricNotation) if (!cursor) { return null } @@ -3625,6 +3629,8 @@ function FloorplanReferenceScaleDraftLine({ label={`Ref ${formatMeasurement( Math.hypot(cursor[0] - start[0], cursor[1] - start[1]), unit, + null, + metricNotation, )}`} palette={palette} start={start} @@ -4006,6 +4012,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({ unit: 'metric' | 'imperial' unitsPerPixel: number }) { + const metricNotation = useViewer((state) => state.metricNotation) if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) { return null } @@ -4054,7 +4061,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({ labelAngleDeg += 180 } - const label = formatMeasurement(length, unit) + const label = formatMeasurement(length, unit, null, metricNotation) const width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth) const height = fontSize + padY * 2 @@ -5029,6 +5036,7 @@ function FloorplanLinearDraftLayer({ unitsPerPixel: number sceneRotationDeg: number }) { + const metricNotation = useViewer((state) => state.metricNotation) const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd) const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd) const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd) @@ -5147,7 +5155,7 @@ function FloorplanLinearDraftLayer({ } return { - lengthLabel: formatMeasurement(length, unit), + lengthLabel: formatMeasurement(length, unit, null, metricNotation), midpoint: [ (wallDraftStart[0] + wallDraftEnd[0]) / 2, (wallDraftStart[1] + wallDraftEnd[1]) / 2, @@ -5155,7 +5163,7 @@ function FloorplanLinearDraftLayer({ direction: [dx / length, dy / length] as WallPlanPoint, angleLabels, } - }, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls]) + }, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls]) return ( <> @@ -5244,6 +5252,7 @@ export function FloorplanPanel({ const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) const showGrid = useViewer((state) => state.showGrid) const showGuides = useViewer((state) => state.showGuides) const setShowGuides = useViewer((state) => state.setShowGuides) @@ -11169,7 +11178,12 @@ export function FloorplanPanel({ Drawn line
- {formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)} + {formatMeasurement( + pendingReferenceScale.measuredLengthUnits, + unit, + null, + metricNotation, + )}
@@ -11432,6 +11446,7 @@ export function FloorplanPanel({ + {floorplanSceneSlot} {/* Cursor-driven placement ghost for movingNode when the diff --git a/packages/editor/src/components/editor/measurement-pill.tsx b/packages/editor/src/components/editor/measurement-pill.tsx index 793f9d2b..11d608d2 100644 --- a/packages/editor/src/components/editor/measurement-pill.tsx +++ b/packages/editor/src/components/editor/measurement-pill.tsx @@ -4,7 +4,11 @@ import { type ForwardedRef, Fragment, forwardRef } from 'react' // Canonical in-world dimension formatter — metric metres or imperial // feet/inches. Shared by every measurement readout so they read the same. -export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string { +export function formatMeasurement( + value: number, + unit: 'metric' | 'imperial', + metricNotation: 'meters' | 'millimeters' = 'meters', +): string { if (unit === 'imperial') { const feet = value * 3.280_84 const wholeFeet = Math.floor(feet) @@ -12,6 +16,7 @@ export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): s if (inches === 12) return `${wholeFeet + 1}'0"` return `${wholeFeet}'${inches}"` } + if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm` return `${Number.parseFloat(value.toFixed(2))}m` } diff --git a/packages/editor/src/components/editor/quick-measurement-card.tsx b/packages/editor/src/components/editor/quick-measurement-card.tsx index 127d3093..420887e5 100644 --- a/packages/editor/src/components/editor/quick-measurement-card.tsx +++ b/packages/editor/src/components/editor/quick-measurement-card.tsx @@ -5,19 +5,25 @@ import { Crosshair, MapPin } from 'lucide-react' import { memo } from 'react' import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements' -function formatMetric(metric: QuickMeasurementMetric, unit: 'metric' | 'imperial'): string { +function formatMetric( + metric: QuickMeasurementMetric, + unit: 'metric' | 'imperial', + metricNotation: 'meters' | 'millimeters', +): string { if (metric.quantity === 'area') return formatAreaLabel(metric.value, unit, 2) if (metric.quantity === 'volume') return formatVolumeLabel(metric.value, unit, 2) - return formatLinearMeasurement(metric.value, unit) + return formatLinearMeasurement(metric.value, unit, metricNotation) } export const QuickMeasurementCard = memo(function QuickMeasurementCard({ report, unit, + metricNotation, lensState, }: { report: QuickMeasurementReport unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' lensState: 'live' | 'pinned' }) { const pinned = lensState === 'pinned' @@ -53,7 +59,7 @@ export const QuickMeasurementCard = memo(function QuickMeasurementCard({ {metric.label}
- {formatMetric(metric, unit)} + {formatMetric(metric, unit, metricNotation)}
))} diff --git a/packages/editor/src/components/editor/quick-measurement-hud.tsx b/packages/editor/src/components/editor/quick-measurement-hud.tsx index dbd96fbb..df49068d 100644 --- a/packages/editor/src/components/editor/quick-measurement-hud.tsx +++ b/packages/editor/src/components/editor/quick-measurement-hud.tsx @@ -12,6 +12,7 @@ export function QuickMeasurementHud() { const viewMode = useEditor((state) => state.viewMode) const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode)) const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) if (!entry) return null @@ -21,7 +22,12 @@ export function QuickMeasurementHud() { data-quick-measure-hud >
- +
) diff --git a/packages/editor/src/components/ui/action-menu/measurement-control.tsx b/packages/editor/src/components/ui/action-menu/measurement-control.tsx index 0a203d4f..d383868e 100644 --- a/packages/editor/src/components/ui/action-menu/measurement-control.tsx +++ b/packages/editor/src/components/ui/action-menu/measurement-control.tsx @@ -1,12 +1,17 @@ 'use client' -import { useViewer } from '@pascal-app/viewer' +import type { + ConstructionDimensionChainMode, + ConstructionDimensionMode, +} from '@pascal-app/core' import { Box, Check, ChevronDown, - Eye, - EyeOff, + CircleIcon, + Crosshair, + Grid2X2, + Minus, Ruler, ScanSearch, Square, @@ -37,27 +42,64 @@ const measurementMenuOptions = [ ...measurementOptions, ] as const +const constructionDimensionOptions = [ + { mode: 'linear', chainMode: 'point-to-point', label: 'Linear dimension', icon: Ruler }, + { mode: 'linear', chainMode: 'continuous', label: 'Continuous dimension', icon: Waypoints }, + { mode: 'radius', chainMode: 'point-to-point', label: 'Radius dimension', icon: CircleIcon }, + { mode: 'diameter', chainMode: 'point-to-point', label: 'Diameter dimension', icon: CircleIcon }, + { mode: 'center-mark', chainMode: 'point-to-point', label: 'Center mark', icon: Crosshair }, + { mode: 'chord', chainMode: 'point-to-point', label: 'Chord dimension', icon: Minus }, + { mode: 'arc-length', chainMode: 'point-to-point', label: 'Arc length', icon: CircleIcon }, + { mode: 'angular', chainMode: 'point-to-point', label: 'Angular dimension', icon: Triangle }, + { mode: 'coordinate', chainMode: 'continuous', label: 'Coordinate dimensions', icon: Grid2X2 }, +] as const satisfies readonly { + mode: ConstructionDimensionMode + chainMode: ConstructionDimensionChainMode + label: string + icon: typeof Ruler +}[] + export function MeasurementControl() { const [isOpen, setIsOpen] = useState(false) const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) const selectedKind = useEditor((state) => state.lastMeasurementKind) const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind) + const constructionDimensionChainMode = useEditor( + (state) => state.toolDefaults['construction-dimension']?.chainMode, + ) + const constructionDimensionMode = useEditor( + (state) => state.toolDefaults['construction-dimension']?.mode, + ) const setMode = useEditor((state) => state.setMode) const setPhase = useEditor((state) => state.setPhase) const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind) const setStructureLayer = useEditor((state) => state.setStructureLayer) const setTool = useEditor((state) => state.setTool) const setToolDefaults = useEditor((state) => state.setToolDefaults) - const showMeasurements = useViewer((state) => state.showMeasurements) - const setShowMeasurements = useViewer((state) => state.setShowMeasurements) + const setViewMode = useEditor((state) => state.setViewMode) const selectedOption = measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0] const isActive = mode === 'build' && tool === 'measurement' + const isConstructionDimensionActive = mode === 'build' && tool === 'construction-dimension' + const activeConstructionDimensionOption = constructionDimensionOptions.find( + (option) => + option.mode === (constructionDimensionMode ?? 'linear') && + option.chainMode === (constructionDimensionChainMode ?? 'point-to-point'), + ) + const isControlActive = isActive || isConstructionDimensionActive const isSmartActive = isActive && activeToolKind === 'smart' - const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon - const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label + const SelectedIcon = isConstructionDimensionActive + ? (activeConstructionDimensionOption?.icon ?? Ruler) + : isSmartActive + ? ScanSearch + : selectedOption.icon + const selectedLabel = isConstructionDimensionActive + ? (activeConstructionDimensionOption?.label ?? 'Linear dimension') + : isSmartActive + ? 'Smart' + : selectedOption.label const activateMeasurement = (kind: CreatableMeasurementKind) => { setPhase('structure') @@ -69,7 +111,7 @@ export function MeasurementControl() { } const handlePrimaryClick = () => { - if (isActive) { + if (isControlActive) { setMode('select') return } @@ -84,15 +126,27 @@ export function MeasurementControl() { setTool('measurement') } + const activateConstructionDimension = ( + dimensionMode: ConstructionDimensionMode, + chainMode: ConstructionDimensionChainMode, + ) => { + setPhase('structure') + setStructureLayer('elements') + setViewMode('2d') + setToolDefaults('construction-dimension', { chainMode, mode: dimensionMode }) + setMode('build') + setTool('construction-dimension') + } + return (
@@ -138,7 +192,7 @@ export function MeasurementControl() { const isSmart = option.kind === 'smart' const isSelected = isSmart ? isSmartActive - : !isSmartActive && option.kind === selectedKind + : !isConstructionDimensionActive && !isSmartActive && option.kind === selectedKind return ( + {constructionDimensionOptions.map((option) => { + const OptionIcon = option.icon + const isSelected = + isConstructionDimensionActive && activeConstructionDimensionOption === option + return ( + + ) + })}
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 8c54a739..49ef0bdd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -1,4 +1,9 @@ -import { clearSceneHistory, emitter, useScene, validateBuildJson } from '@pascal-app/core' +import { + clearSceneHistory, + emitter, + useScene, + validateBuildJson, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { TreeView, VisualJson } from '@visual-json/react' import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react' @@ -394,14 +399,14 @@ export function SettingsPanel({
-
Floorplan
+
Floor plan
+
+
+ + +
+ +
+ {WALL_DATUM_OPTIONS.map((option) => ( + + ))} +
+
+ ))} + +
+ + ) +} + function WallFaceBandSection({ node, onUpdate, diff --git a/packages/nodes/src/wall/schema.ts b/packages/nodes/src/wall/schema.ts index fdd964f0..e96cea2e 100644 --- a/packages/nodes/src/wall/schema.ts +++ b/packages/nodes/src/wall/schema.ts @@ -8,5 +8,20 @@ * imports a single canonical type. */ -export type { WallNode as WallNodeType } from '@pascal-app/core' -export { WallNode } from '@pascal-app/core' +export type { + WallAssemblyDatumReference, + WallAssemblyDatumSide, + WallAssemblyLayer, + WallNode as WallNodeType, +} from '@pascal-app/core' +export { + getWallAssemblyDatumReferenceId, + getWallAssemblyLayers, + getWallAssemblyThickness, + getWallDatumEligibleLayers, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, + WallAssemblyLayerRole, + WallDimensionDatum, + WallNode, +} from '@pascal-app/core' diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 8f7b9cbc..98ef3220 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -234,6 +234,7 @@ function buildDraftWall(start: WallPlanPoint, end: WallPlanPoint): WallNode { visible: true, metadata: {}, children: [], + assemblyLayers: [], start, end, thickness: DRAFT_WALL_THICKNESS, diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index d2fa4467..4370cb3c 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -6,6 +6,11 @@ import type { WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { + buildWindowFloorplanSchedule, + computeWindowFloorplanLevelData, +} from '../shared/opening-documentation' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' @@ -161,9 +166,14 @@ export const windowDefinition: NodeDefinition = { kind: 'window', snapProfile: 'item', facingIndicator: true, - schemaVersion: 1, + schemaVersion: 2, schema: WindowNode, category: 'structure', + extensions: { + 'pascal:editor/floorplan': { + schedule: buildWindowFloorplanSchedule, + } satisfies FloorplanNodeExtension, + }, // Same schema-driven defaults trick as door: parse a stub, strip // id/type. Window also has many fields with zod `.default()` set. @@ -211,6 +221,7 @@ export const windowDefinition: NodeDefinition = { // Stage C: floor-plan polygon. ctx.parent gives the wall for direction // + thickness — same shape as door. floorplan: buildWindowFloorplan, + computeFloorplanLevelData: computeWindowFloorplanLevelData, floorplanDependsOnSiblings: true, // Opening symbols position from `ctx.parent` (the host wall); merge the // walls' live drag overrides so the symbol tracks a wall / group drag in diff --git a/packages/nodes/src/window/floorplan.ts b/packages/nodes/src/window/floorplan.ts index cb37e717..ddf3e670 100644 --- a/packages/nodes/src/window/floorplan.ts +++ b/packages/nodes/src/window/floorplan.ts @@ -5,6 +5,11 @@ import type { WallNode, WindowNode, } from '@pascal-app/core' +import { floorplanGeometryMetadata } from '@pascal-app/editor' +import { + buildOpeningMarkAnnotation, + type OpeningFloorplanLevelData, +} from '../shared/opening-documentation' import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions' /** @@ -102,6 +107,7 @@ export function buildWindowFloorplan( strokeWidth: showSelectedChrome ? 1.9 : 1.25, vectorEffect: 'non-scaling-stroke', strokeLinejoin: 'round', + metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }), }, // Inset glass-pane outline. { @@ -169,5 +175,13 @@ export function buildWindowFloorplan( } } + const markAnnotation = buildOpeningMarkAnnotation( + node, + wall, + ctx.levelData as OpeningFloorplanLevelData | undefined, + { stroke: showSelectedChrome ? '#f97316' : '#334155' }, + ) + if (markAnnotation) children.push(markAnnotation) + return { kind: 'group', children } } diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index a1abde0f..1636f691 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -22,6 +22,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' +import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' function isSameWindowValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { @@ -220,6 +221,8 @@ export default function WindowPanel() { parentId: node.parentId, width: node.width, height: node.height, + roughOpeningWidth: node.roughOpeningWidth, + roughOpeningHeight: node.roughOpeningHeight, windowType: node.windowType, operationState: node.operationState, awningDirection: node.awningDirection, @@ -413,6 +416,21 @@ export default function WindowPanel() { /> + + + + {showWindowTypeSection && (
diff --git a/packages/nodes/src/window/schema.ts b/packages/nodes/src/window/schema.ts index f1f47eaa..addb4039 100644 --- a/packages/nodes/src/window/schema.ts +++ b/packages/nodes/src/window/schema.ts @@ -1 +1,5 @@ -export { WindowNode } from '@pascal-app/core' +export { + WindowConstructionType, + WindowDimensionReference, + WindowNode, +} from '@pascal-app/core' diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index c527825c..bebd7fa9 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -3,6 +3,7 @@ import { resolveAutoZonePolygon, ZoneNode as ZoneNodeSchema, } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' import { buildZoneFloorplan } from './floorplan' import { @@ -14,6 +15,7 @@ import { import { zoneFloorplanMoveTarget } from './floorplan-move' import { zoneParametrics } from './parametrics' import { zoneQuickMeasurement } from './quick-measurement' +import { buildRoomFloorplanSchedule } from './room-documentation' import { ZoneNode } from './schema' /** @@ -25,9 +27,14 @@ import { ZoneNode } from './schema' export const zoneDefinition: NodeDefinition = { kind: 'zone', snapProfile: 'structural', - schemaVersion: 1, + schemaVersion: 2, schema: ZoneNode, category: 'site', + extensions: { + 'pascal:editor/floorplan': { + schedule: buildRoomFloorplanSchedule, + } satisfies FloorplanNodeExtension, + }, defaults: () => { const stub = ZoneNodeSchema.parse({ id: 'zone_default' as never, type: 'zone' }) diff --git a/packages/nodes/src/zone/floorplan.test.ts b/packages/nodes/src/zone/floorplan.test.ts new file mode 100644 index 00000000..ab666c7c --- /dev/null +++ b/packages/nodes/src/zone/floorplan.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import { type FloorplanGeometry, type GeometryContext, ZoneNode } from '@pascal-app/core' +import { readFloorplanGeometryMetadata } from '@pascal-app/editor' +import { buildZoneFloorplan } from './floorplan' + +const context = { + resolve: () => undefined, + children: [], + siblings: [], + parent: null, +} satisfies GeometryContext + +function textChildren(geometry: FloorplanGeometry | null) { + if (geometry?.kind !== 'group') return [] + return geometry.children.filter((child) => child.kind === 'text') +} + +describe('buildZoneFloorplan room documentation', () => { + test('keeps a generic zone label unchanged', () => { + const zone = ZoneNode.parse({ + id: 'zone_landscape', + name: 'Courtyard', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + + expect(textChildren(buildZoneFloorplan(zone, context))).toEqual([ + expect.objectContaining({ kind: 'text', text: 'Courtyard', upright: true }), + ]) + }) + + test('centers room name, number, finish, and height information as room annotations', () => { + const room = ZoneNode.parse({ + id: 'zone_office', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + spaceRole: 'room', + roomNumber: '101', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 2.7, + occupancy: 'Business', + }) + + const labels = textChildren(buildZoneFloorplan(room, context)) + expect(labels.map((label) => ('text' in label ? label.text : ''))).toEqual([ + 'Office', + '101', + 'FL: Timber · WL: Paint · CL: ACT', + 'CH: 2.7m · Business', + ]) + expect(labels.every((label) => label.kind === 'text' && label.upright)).toBe(true) + expect( + labels.every((label) => readFloorplanGeometryMetadata(label).annotationRole === 'room-label'), + ).toBe(true) + }) +}) diff --git a/packages/nodes/src/zone/floorplan.ts b/packages/nodes/src/zone/floorplan.ts index b8333dbb..8806f6fe 100644 --- a/packages/nodes/src/zone/floorplan.ts +++ b/packages/nodes/src/zone/floorplan.ts @@ -5,6 +5,12 @@ import { resolveAutoZonePolygon, type ZoneNode, } from '@pascal-app/core' +import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + formatConstructionLength, +} from '../shared/construction-length' +import { buildRoomClearDimensions } from './room-clear-dimensions' /** * Stage C floor-plan builder for zone. Zones are colored polygons — @@ -21,6 +27,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp if (!ring || ring.length < 3) return null const view = ctx.viewState + const floorplanContext = readFloorplanContext(ctx) const palette = view?.palette const isSelected = view?.selected ?? false const isHighlighted = view?.highlighted ?? false @@ -28,7 +35,8 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp const points: FloorplanPoint[] = ring.map(([x, z]) => [x, z] as FloorplanPoint) const stroke = showSelectedChrome && palette ? palette.selectedStroke : node.color - const fillOpacity = isSelected ? 0.28 : 0.16 + const isRoom = node.spaceRole === 'room' + const fillOpacity = isRoom ? (isSelected ? 0.12 : 0.04) : isSelected ? 0.28 : 0.16 const children: FloorplanGeometry[] = [ { @@ -91,9 +99,22 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp // it). Mirrors the legacy `FloorplanZoneLabel` so the look is // consistent. Centered on the polygon's area-weighted centroid; the // bbox-center fallback handles degenerate rings without throwing. + const [cx, cy] = polygonCentroid(ring) const name = node.name?.trim() - if (name) { - const [cx, cy] = polygonCentroid(ring) + if (isRoom) { + children.push( + ...buildRoomLabels( + node, + cx, + cy, + view?.unit ?? 'metric', + floorplanContext.purpose === 'document' ? 'document' : 'editor', + floorplanContext.metricNotation, + stroke, + ), + ) + children.push(...buildRoomClearDimensions(node, ctx)) + } else if (name) { children.push({ kind: 'text', x: cx, @@ -119,6 +140,61 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp } const ZONE_LABEL_FONT_SIZE = 0.2 +const ROOM_NAME_FONT_SIZE = 0.2 +const ROOM_NUMBER_FONT_SIZE = 0.16 +const ROOM_DETAIL_FONT_SIZE = 0.11 +const ROOM_LABEL_LINE_SPACING = 0.18 + +function buildRoomLabels( + node: ZoneNode, + x: number, + y: number, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: 'meters' | 'millimeters', + color: string, +): FloorplanGeometry[] { + const lines: Array<{ text: string; fontSize: number; fontWeight: number }> = [] + const name = node.name.trim() + if (name) lines.push({ text: name, fontSize: ROOM_NAME_FONT_SIZE, fontWeight: 700 }) + if (node.roomNumber) { + lines.push({ text: node.roomNumber, fontSize: ROOM_NUMBER_FONT_SIZE, fontWeight: 600 }) + } + + const finishes = [ + node.floorFinish ? `FL: ${node.floorFinish}` : '', + node.wallFinish ? `WL: ${node.wallFinish}` : '', + node.ceilingFinish ? `CL: ${node.ceilingFinish}` : '', + ].filter(Boolean) + if (finishes.length > 0) { + lines.push({ text: finishes.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 }) + } + + const roomDetails = [ + `CH: ${formatConstructionLength(node.ceilingHeight, unit, profile, { metricNotation })}`, + ] + if (node.occupancy) roomDetails.push(node.occupancy) + lines.push({ text: roomDetails.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 }) + + const startY = y - ((lines.length - 1) * ROOM_LABEL_LINE_SPACING) / 2 + return lines.map((line, index) => ({ + kind: 'text', + x, + y: startY + index * ROOM_LABEL_LINE_SPACING, + text: line.text, + fontSize: line.fontSize, + fill: color, + stroke: '#ffffff', + strokeWidth: line.fontSize * 0.18, + paintOrder: 'stroke', + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: line.fontWeight, + textAnchor: 'middle', + dominantBaseline: 'central', + upright: true, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + })) +} /** * Area-weighted centroid of a simple polygon (Shoelace formula). Falls diff --git a/packages/nodes/src/zone/quantities-panel.tsx b/packages/nodes/src/zone/quantities-panel.tsx index f07837fd..60668030 100644 --- a/packages/nodes/src/zone/quantities-panel.tsx +++ b/packages/nodes/src/zone/quantities-panel.tsx @@ -12,10 +12,12 @@ import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel, + MetricControl, PanelSection, + ToggleControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useMemo } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' type Point2D = readonly [number, number] @@ -151,6 +153,167 @@ function QuantityRow({ ) } +function RoomTextField({ + label, + onCommit, + value, +}: { + label: string + onCommit: (value: string) => void + value: string +}) { + const [draft, setDraft] = useState(value) + const cancelRef = useRef(false) + + useEffect(() => setDraft(value), [value]) + + const commit = () => { + if (cancelRef.current) { + cancelRef.current = false + setDraft(value) + return + } + const next = draft.trim() + if (next !== value) onCommit(next) + else setDraft(value) + } + + return ( + + ) +} + +function RoomSelect({ + label, + onChange, + options, + value, +}: { + label: string + onChange: (value: string) => void + options: ReadonlyArray<{ label: string; value: string }> + value: string +}) { + return ( + + ) +} + +function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) { + const updateNode = useScene((state) => state.updateNode) + const update = (patch: Partial) => updateNode(zone.id, patch) + const isRoom = zone.spaceRole === 'room' + + return ( + + update({ spaceRole: checked ? 'room' : 'generic' })} + /> + {isRoom ? ( + <> + update({ name })} + value={zone.name} + /> + update({ roomNumber })} + value={zone.roomNumber} + /> + + update({ enclosureStatus: enclosureStatus as ZoneNode['enclosureStatus'] }) + } + options={[ + { label: 'Auto-detect', value: 'auto' }, + { label: 'Enclosed', value: 'enclosed' }, + { label: 'Open', value: 'open' }, + ]} + value={zone.enclosureStatus} + /> + update({ occupancy })} + value={zone.occupancy} + /> + update({ floorFinish })} + value={zone.floorFinish} + /> + update({ wallFinish })} + value={zone.wallFinish} + /> + update({ ceilingFinish })} + value={zone.ceilingFinish} + /> + update({ ceilingHeight })} + precision={2} + step={0.05} + unit="m" + value={zone.ceilingHeight} + /> + + update({ + clearDimensionPolicy: clearDimensionPolicy as ZoneNode['clearDimensionPolicy'], + }) + } + options={[ + { label: 'None', value: 'none' }, + { label: 'Inside faces', value: 'inside-faces' }, + { label: 'Finish faces', value: 'finish-faces' }, + ]} + value={zone.clearDimensionPolicy} + /> + + ) : null} + + ) +} + export default function ZoneQuantitiesPanel() { const selectedZoneId = useViewer((state) => state.selection.zoneId) const unit = useViewer((state) => state.unit) @@ -193,48 +356,53 @@ export default function ZoneQuantitiesPanel() { if (!effectiveZone || !report) return null return ( - -
-
- {effectiveZone.name} - - {report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'} - + <> + + +
+
+ {effectiveZone.name} + + {report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'} + +
+
+ A + {formatAreaLabel(report.footprintArea, unit, 2)} + P + {formatLinearMeasurement(report.perimeter, unit)} +
-
- A - {formatAreaLabel(report.footprintArea, unit, 2)} - P - {formatLinearMeasurement(report.perimeter, unit)} + + + +
+ formatAreaLabel(value, unit, 2)} + label="Wall surface" + quantity={report.wallSurface} + /> + formatAreaLabel(value, unit, 2)} + label="Floor surface" + quantity={report.floorSurface} + /> + formatVolumeLabel(value, unit, 2)} + label="Volume" + quantity={report.volume} + />
-
- - - -
- formatAreaLabel(value, unit, 2)} - label="Wall surface" - quantity={report.wallSurface} - /> - formatAreaLabel(value, unit, 2)} - label="Floor surface" - quantity={report.floorSurface} - /> - formatVolumeLabel(value, unit, 2)} - label="Volume" - quantity={report.volume} - /> -
-
+ + ) } diff --git a/packages/nodes/src/zone/room-clear-dimensions.test.ts b/packages/nodes/src/zone/room-clear-dimensions.test.ts new file mode 100644 index 00000000..14f1afcd --- /dev/null +++ b/packages/nodes/src/zone/room-clear-dimensions.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type FloorplanGeometry, + type GeometryContext, + WallNode, + ZoneNode, +} from '@pascal-app/core' +import { createFloorplanContextExtensions } from '@pascal-app/editor' +import { buildRoomClearDimensions } from './room-clear-dimensions' + +function enclosure(points: Array<[number, number]>) { + const walls = points.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: 'level_main', + start, + end: points[(index + 1) % points.length], + thickness: 0.2, + }), + ) + const zone = ZoneNode.parse({ + id: 'zone_room', + parentId: 'level_main', + name: 'Office', + polygon: points, + autoFromWalls: true, + boundaryWallIds: walls.map((wall) => wall.id), + spaceRole: 'room', + clearDimensionPolicy: 'inside-faces', + }) + const nodes = Object.fromEntries([...walls, zone].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + const context = { + resolve: (id) => nodes[id], + children: [], + siblings: [], + parent: null, + viewState: { + selected: false, + highlighted: false, + hovered: false, + moving: false, + unit: 'metric', + palette: { + measurementStroke: '#123456', + } as NonNullable['palette'], + }, + extensions: createFloorplanContextExtensions({ purpose: 'edit' }), + } satisfies GeometryContext + return { context, nodes, walls, zone } +} + +function withFinishAssembly(wall: WallNode): WallNode { + return WallNode.parse({ + ...wall, + thickness: undefined, + assemblyLayers: [ + { + id: `${wall.id}_core`, + role: 'structure', + side: 'core', + thickness: 0.2, + datumEligible: ['structural-face'], + }, + { + id: `${wall.id}_interior-finish`, + role: 'interior-finish', + side: 'interior', + thickness: 0.02, + datumEligible: ['finish-face'], + }, + { + id: `${wall.id}_exterior-finish`, + role: 'exterior-finish', + side: 'exterior', + thickness: 0.02, + datumEligible: ['finish-face'], + }, + ], + }) +} + +function dimensions(geometry: FloorplanGeometry[]) { + return geometry.filter( + (entry): entry is Extract => + entry.kind === 'dimension', + ) +} + +describe('buildRoomClearDimensions', () => { + test('dimensions the proven inside faces of a rectangular room', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + const result = dimensions(buildRoomClearDimensions(zone, context)) + + expect(result).toHaveLength(2) + expect(result.map((entry) => entry.text).sort()).toEqual(['2.8m', '3.8m']) + expect(result.every((entry) => entry.stroke === '#123456')).toBe(true) + expect(result[0]?.start[0]).toBeCloseTo(1.316) + expect(result[0]?.start[1]).toBeCloseTo(0.1) + expect(result[0]?.end[0]).toBeCloseTo(1.316) + expect(result[0]?.end[1]).toBeCloseTo(2.9) + }) + + test('preserves clear spans when the room is rotated', () => { + const angle = Math.PI / 6 + const rotate = ([x, y]: [number, number]): [number, number] => [ + x * Math.cos(angle) - y * Math.sin(angle), + x * Math.sin(angle) + y * Math.cos(angle), + ] + const { context, zone } = enclosure( + [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ].map(rotate), + ) + + expect( + dimensions(buildRoomClearDimensions(zone, context)) + .map((entry) => entry.text) + .sort(), + ).toEqual(['2.8m', '3.8m']) + }) + + test('consolidates collinear wall segments before proving the clear rectangle', () => { + const { context, zone } = enclosure([ + [0, 0], + [2, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + + expect( + dimensions(buildRoomClearDimensions(zone, context)) + .map((entry) => entry.text) + .sort(), + ).toEqual(['2.8m', '3.8m']) + }) + + test('dimensions finish faces when every boundary wall has assembly finish datums', () => { + const { context, nodes, walls, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + const assembledWalls = walls.map(withFinishAssembly) + const assembledNodes = { ...nodes } + for (const wall of assembledWalls) assembledNodes[wall.id] = wall + + const result = dimensions( + buildRoomClearDimensions( + { ...zone, clearDimensionPolicy: 'finish-faces' }, + { + ...context, + resolve: (id) => assembledNodes[id], + }, + ), + ) + + expect(result).toHaveLength(2) + expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m']) + }) + + test('adds a room-to-room finish-face dimension for adjacent rectangular rooms', () => { + const walls = [ + WallNode.parse({ id: 'wall_a_bottom', parentId: 'level_main', start: [0, 0], end: [4, 0] }), + WallNode.parse({ id: 'wall_shared', parentId: 'level_main', start: [4, 0], end: [4, 3] }), + WallNode.parse({ id: 'wall_a_top', parentId: 'level_main', start: [4, 3], end: [0, 3] }), + WallNode.parse({ id: 'wall_a_left', parentId: 'level_main', start: [0, 3], end: [0, 0] }), + WallNode.parse({ id: 'wall_b_bottom', parentId: 'level_main', start: [4, 0], end: [8, 0] }), + WallNode.parse({ id: 'wall_b_right', parentId: 'level_main', start: [8, 0], end: [8, 3] }), + WallNode.parse({ id: 'wall_b_top', parentId: 'level_main', start: [8, 3], end: [4, 3] }), + ].map(withFinishAssembly) + const zoneA = ZoneNode.parse({ + id: 'zone_a', + parentId: 'level_main', + name: 'A', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + autoFromWalls: true, + boundaryWallIds: ['wall_a_bottom', 'wall_shared', 'wall_a_top', 'wall_a_left'], + spaceRole: 'room', + clearDimensionPolicy: 'finish-faces', + }) + const zoneB = ZoneNode.parse({ + id: 'zone_b', + parentId: 'level_main', + name: 'B', + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + autoFromWalls: true, + boundaryWallIds: ['wall_b_bottom', 'wall_b_right', 'wall_b_top', 'wall_shared'], + spaceRole: 'room', + clearDimensionPolicy: 'finish-faces', + }) + const nodes = Object.fromEntries( + [...walls, zoneA, zoneB].map((node) => [node.id, node]), + ) as Record + const context = { + resolve: (id) => nodes[id], + children: [], + siblings: [zoneB], + parent: null, + viewState: { + selected: false, + highlighted: false, + hovered: false, + moving: false, + unit: 'metric', + palette: { + measurementStroke: '#123456', + } as NonNullable['palette'], + }, + extensions: createFloorplanContextExtensions({ purpose: 'edit' }), + } satisfies GeometryContext + + const result = dimensions(buildRoomClearDimensions(zoneA, context)) + + expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m', 'R-R 0.24m']) + expect(result.find((entry) => entry.text.startsWith('R-R'))?.text).toBe('R-R 0.24m') + }) + + test('dimensions proven rectilinear room bays beyond simple rectangles', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 2], + [2, 2], + [2, 4], + [0, 4], + ]) + + const result = dimensions(buildRoomClearDimensions(zone, context)) + + expect(result.map((entry) => entry.text).sort()).toEqual(['1.8m', '1.8m', '3.8m', '3.8m']) + }) + + test('suppresses dimensions when the requested datum cannot be proven', () => { + const { context, nodes, walls, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + + expect(buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'none' }, context)).toEqual([]) + expect( + buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context), + ).toEqual([]) + expect(buildRoomClearDimensions({ ...zone, enclosureStatus: 'open' }, context)).toEqual([]) + expect(buildRoomClearDimensions({ ...zone, autoFromWalls: false }, context)).toEqual([]) + + const missingWallNodes = { ...nodes } + delete missingWallNodes[walls[0]!.id] + expect( + buildRoomClearDimensions(zone, { + ...context, + resolve: (id) => missingWallNodes[id], + }), + ).toEqual([]) + }) + + test('suppresses dimensions for a proven enclosure that is not rectangular', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 2], + [2, 3], + [0, 2], + ]) + + expect(buildRoomClearDimensions(zone, context)).toEqual([]) + }) +}) diff --git a/packages/nodes/src/zone/room-clear-dimensions.ts b/packages/nodes/src/zone/room-clear-dimensions.ts new file mode 100644 index 00000000..18e2576c --- /dev/null +++ b/packages/nodes/src/zone/room-clear-dimensions.ts @@ -0,0 +1,609 @@ +import { + detectSpacesForLevel, + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + getWallAssemblyFaceOffsets, + resolveWallAssemblyDatumReferences, + type SpaceBoundaryFace, + type WallNode, + type ZoneNode, +} from '@pascal-app/core' +import { readFloorplanContext } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + type ConstructionMetricNotation, + formatConstructionLength, +} from '../shared/construction-length' + +const LINE_TOLERANCE = 1e-4 +const ANGLE_TOLERANCE = 1e-3 +const MIN_CLEAR_SPAN = 0.3 +const MIN_ROOM_TO_ROOM_SPAN = 0.03 +const FIRST_DIMENSION_POSITION = 0.32 +const SECOND_DIMENSION_POSITION = 0.68 +const EXTENSION_OVERSHOOT = 0.08 + +type FaceLine = { + start: FloorplanPoint + end: FloorplanPoint +} + +type DimensionGeometry = Extract + +type ClearDimensionPolicy = Extract< + ZoneNode['clearDimensionPolicy'], + 'inside-faces' | 'finish-faces' +> + +export function buildRoomClearDimensions( + node: ZoneNode, + ctx: GeometryContext, +): FloorplanGeometry[] { + if ( + node.spaceRole !== 'room' || + (node.clearDimensionPolicy !== 'inside-faces' && + node.clearDimensionPolicy !== 'finish-faces') || + node.enclosureStatus === 'open' || + !node.autoFromWalls || + !node.parentId || + node.boundaryWallIds.length < 3 + ) { + return [] + } + + const walls = node.boundaryWallIds.flatMap((id) => { + const resolved = ctx.resolve(id) + return resolved && + typeof resolved === 'object' && + 'type' in resolved && + resolved.type === 'wall' + ? [resolved as WallNode] + : [] + }) + if (walls.length !== node.boundaryWallIds.length) return [] + + const boundaryIds = new Set(node.boundaryWallIds) + const space = detectSpacesForLevel(node.parentId, walls).spaces.find( + (candidate) => + candidate.wallIds.length === boundaryIds.size && + candidate.wallIds.every((id) => boundaryIds.has(id)), + ) + if (!space) return [] + + const wallsById = new Map(walls.map((wall) => [wall.id, wall])) + const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById, node.clearDimensionPolicy) + if (!faceLines) return [] + + const unit = ctx.viewState?.unit ?? 'metric' + const floorplanContext = readFloorplanContext(ctx) + const profile: ConstructionLengthProfile = + floorplanContext.purpose === 'document' ? 'document' : 'editor' + const metricNotation = floorplanContext.metricNotation + const stroke = ctx.viewState?.palette.measurementStroke ?? '#475569' + const rectangle = resolveClearFaceRectangle(faceLines) + const dimensions = rectangle + ? buildRectangleClearDimensions(rectangle, unit, profile, metricNotation, stroke) + : buildRectilinearClearDimensions(faceLines, unit, profile, metricNotation, stroke) + if (dimensions.length === 0) return [] + return [ + ...dimensions, + ...buildRoomToRoomClearDimensions( + node, + ctx, + space.boundaryFaces, + wallsById, + unit, + profile, + metricNotation, + stroke, + ), + ] +} + +function resolveClearFaceLines( + boundaryFaces: readonly SpaceBoundaryFace[], + wallsById: ReadonlyMap, + policy: ClearDimensionPolicy, +): FaceLine[] | null { + const faceLines: FaceLine[] = [] + for (const boundary of boundaryFaces) { + const wall = wallsById.get(boundary.wallId) + if (!wall || Math.abs(wall.curveOffset ?? 0) > LINE_TOLERANCE) return null + const line = offsetBoundaryFace(boundary, wall, policy) + if (!line) return null + faceLines.push(line) + } + + const merged = mergeCollinearFaces(faceLines) + return merged.length >= 4 ? merged : null +} + +function resolveClearFaceRectangle( + merged: readonly FaceLine[], +): [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint] | null { + if (merged.length !== 4) return null + + const vertices = merged.map((line, index) => { + const previous = merged[(index + merged.length - 1) % merged.length]! + return intersectLines(previous, line) + }) + if (vertices.some((vertex) => vertex === null)) return null + const rectangle = vertices as [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint] + const directions = rectangle.map((start, index) => + normalizedDirection(start, rectangle[(index + 1) % rectangle.length]!), + ) + if (directions.some((direction) => direction === null)) return null + const [first, second, third, fourth] = directions as [ + FloorplanPoint, + FloorplanPoint, + FloorplanPoint, + FloorplanPoint, + ] + if ( + Math.abs(dot(first, second)) > ANGLE_TOLERANCE || + Math.abs(dot(second, third)) > ANGLE_TOLERANCE || + Math.abs(dot(third, fourth)) > ANGLE_TOLERANCE || + Math.abs(dot(fourth, first)) > ANGLE_TOLERANCE || + dot(first, third) > -1 + ANGLE_TOLERANCE || + dot(second, fourth) > -1 + ANGLE_TOLERANCE + ) { + return null + } + return rectangle +} + +function buildRectangleClearDimensions( + rectangle: [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + const first = dimensionAcrossOppositeFaces( + rectangle[0], + rectangle[1], + rectangle[3], + rectangle[2], + FIRST_DIMENSION_POSITION, + unit, + profile, + metricNotation, + stroke, + ) + const second = dimensionAcrossOppositeFaces( + rectangle[1], + rectangle[2], + rectangle[0], + rectangle[3], + SECOND_DIMENSION_POSITION, + unit, + profile, + metricNotation, + stroke, + ) + return first && second ? [first, second] : [] +} + +function buildRectilinearClearDimensions( + faceLines: readonly FaceLine[], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + const vertices = clearFacePolygon(faceLines) + if (!vertices || !isRectilinearPolygon(vertices)) return [] + + const dimensions: FloorplanGeometry[] = [] + const seen = new Set() + for (let firstIndex = 0; firstIndex < faceLines.length; firstIndex++) { + const first = faceLines[firstIndex]! + const firstDirection = normalizedDirection(first.start, first.end) + if (!firstDirection) return [] + + for (let secondIndex = firstIndex + 1; secondIndex < faceLines.length; secondIndex++) { + const second = faceLines[secondIndex]! + const secondDirection = normalizedDirection(second.start, second.end) + if (!secondDirection) return [] + if (Math.abs(dot(firstDirection, secondDirection)) < 1 - ANGLE_TOLERANCE) continue + + const dimension = dimensionBetweenOverlappingParallelFaces( + first, + second, + firstDirection, + vertices, + unit, + profile, + metricNotation, + stroke, + ) + if (!dimension) continue + const key = dimensionKey(dimension) + if (seen.has(key)) continue + seen.add(key) + dimensions.push(dimension) + } + } + return dimensions +} + +function offsetBoundaryFace( + boundary: SpaceBoundaryFace, + wall: WallNode, + policy: ClearDimensionPolicy, +): FaceLine | null { + const first = boundary.points[0] + const last = boundary.points[boundary.points.length - 1] + if (!(first && last)) return null + + const wallDirection = normalizedDirection(wall.start, wall.end) + if (!wallDirection) return null + const normal: FloorplanPoint = [-wallDirection[1], wallDirection[0]] + const side = boundary.face === 'front' ? 1 : -1 + const faces = getWallAssemblyFaceOffsets(wall) + const offset = + policy === 'finish-faces' + ? resolveFinishFaceOffset(wall, side) + : side > 0 + ? faces.exterior + : faces.interior + if (offset === null) return null + return { + start: [first[0] + normal[0] * offset, first[1] + normal[1] * offset], + end: [last[0] + normal[0] * offset, last[1] + normal[1] * offset], + } +} + +function resolveFinishFaceOffset(wall: WallNode, side: 1 | -1): number | null { + if ((wall.assemblyLayers ?? []).length === 0) return null + const references = resolveWallAssemblyDatumReferences(wall).filter( + (reference) => reference.datum === 'finish-face', + ) + const matching = references + .filter((reference) => Math.sign(reference.offset) === side) + .map((reference) => reference.offset) + if (matching.length === 0) return null + return side > 0 ? Math.max(...matching) : Math.min(...matching) +} + +function clearFacePolygon(faceLines: readonly FaceLine[]): FloorplanPoint[] | null { + const vertices = faceLines.map((line, index) => { + const previous = faceLines[(index + faceLines.length - 1) % faceLines.length]! + return intersectLines(previous, line) + }) + return vertices.some((vertex) => vertex === null) ? null : (vertices as FloorplanPoint[]) +} + +function isRectilinearPolygon(vertices: readonly FloorplanPoint[]): boolean { + if (vertices.length < 4) return false + const directions = vertices.map((start, index) => + normalizedDirection(start, vertices[(index + 1) % vertices.length]!), + ) + if (directions.some((direction) => direction === null)) return false + for (let index = 0; index < directions.length; index++) { + const current = directions[index]! + const next = directions[(index + 1) % directions.length]! + if (Math.abs(dot(current, next)) > ANGLE_TOLERANCE) return false + } + return true +} + +function dimensionBetweenOverlappingParallelFaces( + first: FaceLine, + second: FaceLine, + direction: FloorplanPoint, + polygon: readonly FloorplanPoint[], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): DimensionGeometry | null { + const firstStart = dot(first.start, direction) + const firstEnd = dot(first.end, direction) + const secondStart = dot(second.start, direction) + const secondEnd = dot(second.end, direction) + const overlapStart = Math.max(Math.min(firstStart, firstEnd), Math.min(secondStart, secondEnd)) + const overlapEnd = Math.min(Math.max(firstStart, firstEnd), Math.max(secondStart, secondEnd)) + if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null + + const projection = (overlapStart + overlapEnd) / 2 + const start = projectPointToLineProjection(first, direction, projection) + const end = projectPointToLineProjection(second, direction, projection) + const midpoint: FloorplanPoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + if (!pointInPolygon(midpoint, polygon)) return null + + const axis = normalizedDirection(start, end) + if (!axis) return null + const length = distance(start, end) + if (length < MIN_CLEAR_SPAN) return null + + return { + kind: 'dimension', + start, + end, + offsetNormal: [-axis[1], axis[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: formatConstructionLength(length, unit, profile, { metricNotation }), + stroke, + } +} + +function pointInPolygon(point: FloorplanPoint, polygon: readonly FloorplanPoint[]): boolean { + let inside = false + for ( + let index = 0, previousIndex = polygon.length - 1; + index < polygon.length; + previousIndex = index++ + ) { + const current = polygon[index]! + const previous = polygon[previousIndex]! + const intersects = + current[1] > point[1] !== previous[1] > point[1] && + point[0] < + ((previous[0] - current[0]) * (point[1] - current[1])) / (previous[1] - current[1]) + + current[0] + if (intersects) inside = !inside + } + return inside +} + +function dimensionKey(dimension: DimensionGeometry): string { + const first = `${roundKey(dimension.start[0])},${roundKey(dimension.start[1])}` + const second = `${roundKey(dimension.end[0])},${roundKey(dimension.end[1])}` + return first < second ? `${first}|${second}` : `${second}|${first}` +} + +function roundKey(value: number): number { + return Math.round(value / LINE_TOLERANCE) +} + +function buildRoomToRoomClearDimensions( + node: ZoneNode, + ctx: GeometryContext, + boundaryFaces: readonly SpaceBoundaryFace[], + wallsById: ReadonlyMap, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + if (node.clearDimensionPolicy !== 'finish-faces') return [] + + const neighboringRooms = ctx.siblings.filter( + (sibling): sibling is ZoneNode => + sibling.type === 'zone' && + sibling.id !== node.id && + String(node.id) < String(sibling.id) && + sibling.spaceRole === 'room' && + sibling.clearDimensionPolicy === 'finish-faces' && + sibling.enclosureStatus !== 'open' && + sibling.autoFromWalls && + sibling.parentId === node.parentId, + ) + if (neighboringRooms.length === 0) return [] + + const dimensions: FloorplanGeometry[] = [] + const currentBoundaryByWallId = new Map( + boundaryFaces.map((boundary) => [boundary.wallId, boundary]), + ) + + for (const neighbor of neighboringRooms) { + const sharedWallIds = neighbor.boundaryWallIds.filter((wallId) => + currentBoundaryByWallId.has(wallId), + ) + if (sharedWallIds.length === 0) continue + + const neighborWalls = neighbor.boundaryWallIds.flatMap((id) => { + const resolved = ctx.resolve(id) + return resolved && + typeof resolved === 'object' && + 'type' in resolved && + resolved.type === 'wall' + ? [resolved as WallNode] + : [] + }) + if (neighborWalls.length !== neighbor.boundaryWallIds.length) continue + const neighborWallIds = new Set(neighbor.boundaryWallIds) + const neighborSpace = detectSpacesForLevel(neighbor.parentId ?? '', neighborWalls).spaces.find( + (candidate) => + candidate.wallIds.length === neighborWallIds.size && + candidate.wallIds.every((id) => neighborWallIds.has(id)), + ) + if (!neighborSpace) continue + const neighborBoundaryByWallId = new Map( + neighborSpace.boundaryFaces.map((boundary) => [boundary.wallId, boundary]), + ) + + for (const wallId of sharedWallIds) { + const wall = wallsById.get(wallId) + const currentBoundary = currentBoundaryByWallId.get(wallId) + const neighborBoundary = neighborBoundaryByWallId.get(wallId) + if (!(wall && currentBoundary && neighborBoundary)) continue + const currentLine = offsetBoundaryFace(currentBoundary, wall, 'finish-faces') + const neighborLine = offsetBoundaryFace(neighborBoundary, wall, 'finish-faces') + if (!(currentLine && neighborLine)) continue + const dimension = dimensionAcrossSharedRoomWall( + currentLine, + neighborLine, + unit, + profile, + metricNotation, + stroke, + ) + if (dimension) dimensions.push(dimension) + } + } + + return dimensions +} + +function dimensionAcrossSharedRoomWall( + currentLine: FaceLine, + neighborLine: FaceLine, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): DimensionGeometry | null { + const direction = normalizedDirection(currentLine.start, currentLine.end) + if (!direction) return null + const neighborDirection = normalizedDirection(neighborLine.start, neighborLine.end) + if (!neighborDirection || Math.abs(dot(direction, neighborDirection)) < 1 - ANGLE_TOLERANCE) { + return null + } + + const currentStart = dot(currentLine.start, direction) + const currentEnd = dot(currentLine.end, direction) + const neighborStart = dot(neighborLine.start, direction) + const neighborEnd = dot(neighborLine.end, direction) + const overlapStart = Math.max( + Math.min(currentStart, currentEnd), + Math.min(neighborStart, neighborEnd), + ) + const overlapEnd = Math.min( + Math.max(currentStart, currentEnd), + Math.max(neighborStart, neighborEnd), + ) + if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null + + const projection = (overlapStart + overlapEnd) / 2 + const start = projectPointToLineProjection(currentLine, direction, projection) + const end = projectPointToLineProjection(neighborLine, direction, projection) + const clear = distance(start, end) + if (clear < MIN_ROOM_TO_ROOM_SPAN) return null + const axis = normalizedDirection(start, end) + if (!axis) return null + + return { + kind: 'dimension', + start, + end, + offsetNormal: [-axis[1], axis[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: `R-R ${formatConstructionLength(clear, unit, profile, { metricNotation })}`, + stroke, + } +} + +function projectPointToLineProjection( + line: FaceLine, + direction: FloorplanPoint, + projection: number, +): FloorplanPoint { + const originProjection = dot(line.start, direction) + return [ + line.start[0] + direction[0] * (projection - originProjection), + line.start[1] + direction[1] * (projection - originProjection), + ] +} + +function mergeCollinearFaces(lines: readonly FaceLine[]): FaceLine[] { + const merged: FaceLine[] = [] + for (const line of lines) { + const previous = merged[merged.length - 1] + if (previous && canMerge(previous, line)) previous.end = line.end + else merged.push({ ...line }) + } + + while (merged.length > 1) { + const first = merged[0]! + const last = merged[merged.length - 1]! + if (!canMerge(last, first)) break + first.start = last.start + merged.pop() + } + return merged +} + +function canMerge(first: FaceLine, second: FaceLine): boolean { + const firstDirection = normalizedDirection(first.start, first.end) + const secondDirection = normalizedDirection(second.start, second.end) + if (!(firstDirection && secondDirection)) return false + return ( + dot(firstDirection, secondDirection) > 1 - ANGLE_TOLERANCE && + pointLineDistance(second.start, first) <= LINE_TOLERANCE + ) +} + +function intersectLines(first: FaceLine, second: FaceLine): FloorplanPoint | null { + const firstDirection: FloorplanPoint = [ + first.end[0] - first.start[0], + first.end[1] - first.start[1], + ] + const secondDirection: FloorplanPoint = [ + second.end[0] - second.start[0], + second.end[1] - second.start[1], + ] + const denominator = cross(firstDirection, secondDirection) + if (Math.abs(denominator) <= LINE_TOLERANCE) return null + const delta: FloorplanPoint = [second.start[0] - first.start[0], second.start[1] - first.start[1]] + const parameter = cross(delta, secondDirection) / denominator + return [ + first.start[0] + firstDirection[0] * parameter, + first.start[1] + firstDirection[1] * parameter, + ] +} + +function dimensionAcrossOppositeFaces( + firstStart: FloorplanPoint, + firstEnd: FloorplanPoint, + oppositeStart: FloorplanPoint, + oppositeEnd: FloorplanPoint, + position: number, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry | null { + const start = interpolate(firstStart, firstEnd, position) + const end = interpolate(oppositeStart, oppositeEnd, position) + const direction = normalizedDirection(start, end) + if (!direction) return null + const length = distance(start, end) + if (length < MIN_CLEAR_SPAN) return null + return { + kind: 'dimension', + start, + end, + offsetNormal: [-direction[1], direction[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: formatConstructionLength(length, unit, profile, { metricNotation }), + stroke, + } +} + +function normalizedDirection( + start: readonly [number, number], + end: readonly [number, number], +): FloorplanPoint | null { + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const length = Math.hypot(dx, dy) + return length <= LINE_TOLERANCE ? null : [dx / length, dy / length] +} + +function pointLineDistance(point: FloorplanPoint, line: FaceLine): number { + const direction = normalizedDirection(line.start, line.end) + if (!direction) return Number.POSITIVE_INFINITY + return Math.abs(cross(direction, [point[0] - line.start[0], point[1] - line.start[1]])) +} + +function interpolate(start: FloorplanPoint, end: FloorplanPoint, t: number): FloorplanPoint { + return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t] +} + +function distance(first: FloorplanPoint, second: FloorplanPoint): number { + return Math.hypot(second[0] - first[0], second[1] - first[1]) +} + +function dot(first: FloorplanPoint, second: FloorplanPoint): number { + return first[0] * second[0] + first[1] * second[1] +} + +function cross(first: FloorplanPoint, second: FloorplanPoint): number { + return first[0] * second[1] - first[1] * second[0] +} diff --git a/packages/nodes/src/zone/room-documentation.test.ts b/packages/nodes/src/zone/room-documentation.test.ts new file mode 100644 index 00000000..8d5bf2ab --- /dev/null +++ b/packages/nodes/src/zone/room-documentation.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode, ZoneNode } from '@pascal-app/core' +import { buildRoomFloorplanSchedule } from './room-documentation' + +function room(overrides: Partial = {}) { + return ZoneNode.parse({ + id: 'zone_room', + parentId: 'level_main', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + spaceRole: 'room', + roomNumber: '101', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 2.7, + occupancy: 'Business', + ...overrides, + }) +} + +function nodesFor(zones: ZoneNode[]) { + const level = LevelNode.parse({ + id: 'level_main', + children: zones.map((zone) => zone.id), + }) + return Object.fromEntries([level, ...zones].map((node) => [node.id, node])) as Record< + string, + AnyNode + > +} + +describe('buildRoomFloorplanSchedule', () => { + test('includes only architectural rooms and formats their documented values', () => { + const office = room({ id: 'zone_office', roomNumber: '102' }) + const lobby = room({ + id: 'zone_lobby', + name: 'Lobby', + roomNumber: '101', + polygon: [ + [0, 0], + [5, 0], + [5, 2], + [0, 2], + ], + floorFinish: '', + }) + const courtyard = room({ + id: 'zone_courtyard', + name: 'Courtyard', + roomNumber: '100', + spaceRole: 'generic', + }) + const zones = [office, lobby, courtyard] + + const schedule = buildRoomFloorplanSchedule({ + siblings: zones, + nodes: nodesFor(zones), + levelId: 'level_main', + unit: 'metric', + }) + + expect(schedule?.title).toBe('ROOM SCHEDULE') + expect(schedule?.rows.map((row) => row.id)).toEqual(['zone_lobby', 'zone_office']) + expect(schedule?.rows[0]?.cells).toMatchObject({ + number: '101', + name: 'Lobby', + area: '10.00 m²', + floorFinish: '—', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: '2700', + occupancy: 'Business', + enclosure: 'Open', + }) + }) + + test('formats imperial schedule values', () => { + const office = room({ + polygon: [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ], + }) + const schedule = buildRoomFloorplanSchedule({ + siblings: [office], + nodes: nodesFor([office]), + levelId: 'level_main', + unit: 'imperial', + }) + + expect(schedule?.rows[0]?.cells).toMatchObject({ + area: '10.8 ft²', + ceilingHeight: `8'-10 5/16"`, + }) + }) + + test('reports missing and duplicate room numbers plus unproven enclosure claims', () => { + const unnumbered = room({ + id: 'zone_unnumbered', + name: 'Storage', + roomNumber: '', + }) + const duplicateA = room({ id: 'zone_a', roomNumber: 'A01' }) + const duplicateB = room({ + id: 'zone_b', + name: 'Meeting', + roomNumber: 'a01', + enclosureStatus: 'enclosed', + }) + const zones = [unnumbered, duplicateA, duplicateB] + const schedule = buildRoomFloorplanSchedule({ + siblings: zones, + nodes: nodesFor(zones), + levelId: 'level_main', + unit: 'metric', + }) + + expect(schedule?.issues).toEqual([ + 'Room Storage has no room number', + 'Room a01 is marked enclosed but not proven', + 'Duplicate room number A01 (2 rooms)', + ]) + }) + + test('returns no schedule when the level has no architectural rooms', () => { + const zone = room({ spaceRole: 'generic' }) + expect( + buildRoomFloorplanSchedule({ + siblings: [zone], + nodes: nodesFor([zone]), + levelId: 'level_main', + unit: 'metric', + }), + ).toBeNull() + }) +}) diff --git a/packages/nodes/src/zone/room-documentation.ts b/packages/nodes/src/zone/room-documentation.ts new file mode 100644 index 00000000..7032cca5 --- /dev/null +++ b/packages/nodes/src/zone/room-documentation.ts @@ -0,0 +1,125 @@ +import { + type AnyNode, + deriveZoneQuantityReport, + resolveAutoZonePolygon, + type ZoneNode, +} from '@pascal-app/core' +import type { FloorplanSchedule } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + type ConstructionLinearUnit, + formatConstructionLength, +} from '../shared/construction-length' + +const SQUARE_FEET_PER_SQUARE_METER = 10.76391041671 +const ROOM_NUMBER_COLLATOR = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }) + +export function buildRoomFloorplanSchedule(args: { + siblings: ReadonlyArray + nodes: Readonly> + levelId: string + unit: ConstructionLinearUnit + profile?: ConstructionLengthProfile +}): FloorplanSchedule | null { + const rooms = args.siblings + .filter((zone) => zone.spaceRole === 'room') + .map((zone) => { + const polygon = resolveAutoZonePolygon(zone, (id) => args.nodes[id]) + const resolvedZone = polygon === zone.polygon ? zone : { ...zone, polygon } + return { zone: resolvedZone, report: deriveZoneQuantityReport(resolvedZone, args.nodes) } + }) + .sort((a, b) => compareRooms(a.zone, b.zone)) + + if (rooms.length === 0) return null + + return { + id: 'rooms', + title: 'ROOM SCHEDULE', + columns: [ + { key: 'number', label: 'NO.', weight: 0.7 }, + { key: 'name', label: 'ROOM NAME', weight: 1.35 }, + { key: 'area', label: 'AREA', weight: 0.9 }, + { key: 'floorFinish', label: 'FLOOR FINISH', weight: 1.15 }, + { key: 'wallFinish', label: 'WALL FINISH', weight: 1.15 }, + { key: 'ceilingFinish', label: 'CEILING FINISH', weight: 1.15 }, + { key: 'ceilingHeight', label: 'CLG. HT.', weight: 0.9 }, + { key: 'occupancy', label: 'OCCUPANCY / USE', weight: 1.25 }, + { key: 'enclosure', label: 'ENCLOSURE', weight: 0.9 }, + ], + rows: rooms.map(({ zone, report }) => ({ + id: zone.id, + cells: { + number: valueOrDash(zone.roomNumber), + name: valueOrDash(zone.name), + area: formatRoomArea(report.footprintArea, args.unit), + floorFinish: valueOrDash(zone.floorFinish), + wallFinish: valueOrDash(zone.wallFinish), + ceilingFinish: valueOrDash(zone.ceilingFinish), + ceilingHeight: formatConstructionLength( + zone.ceilingHeight, + args.unit, + args.profile ?? 'document', + ), + occupancy: valueOrDash(zone.occupancy), + enclosure: resolveEnclosure(zone, report.classification), + }, + })), + issues: collectRoomScheduleIssues(rooms), + } +} + +function compareRooms(a: ZoneNode, b: ZoneNode): number { + const numberComparison = ROOM_NUMBER_COLLATOR.compare(a.roomNumber.trim(), b.roomNumber.trim()) + if (numberComparison !== 0) return numberComparison + const nameComparison = a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }) + return nameComparison !== 0 ? nameComparison : a.id.localeCompare(b.id) +} + +function valueOrDash(value: string): string { + return value.trim() || '—' +} + +function formatRoomArea(squareMeters: number, unit: ConstructionLinearUnit): string { + if (!Number.isFinite(squareMeters)) return '—' + if (unit === 'metric') return `${squareMeters.toFixed(2)} m²` + return `${(squareMeters * SQUARE_FEET_PER_SQUARE_METER).toFixed(1)} ft²` +} + +function resolveEnclosure(zone: ZoneNode, classification: 'footprint' | 'enclosed-room'): string { + if (zone.enclosureStatus === 'enclosed') return 'Enclosed' + if (zone.enclosureStatus === 'open') return 'Open' + return classification === 'enclosed-room' ? 'Enclosed' : 'Open' +} + +function collectRoomScheduleIssues( + rooms: ReadonlyArray<{ + zone: ZoneNode + report: { classification: 'footprint' | 'enclosed-room' } + }>, +): string[] { + const issues: string[] = [] + const numberedRooms = new Map() + + for (const { zone, report } of rooms) { + const number = zone.roomNumber.trim() + if (!number) { + issues.push(`Room ${zone.name.trim() || zone.id} has no room number`) + } else { + const normalized = number.toLocaleUpperCase() + const duplicates = numberedRooms.get(normalized) + if (duplicates) duplicates.push(zone) + else numberedRooms.set(normalized, [zone]) + } + + if (zone.enclosureStatus === 'enclosed' && report.classification !== 'enclosed-room') { + issues.push(`Room ${number || zone.name.trim() || zone.id} is marked enclosed but not proven`) + } + } + + for (const [normalizedNumber, duplicateRooms] of numberedRooms) { + if (duplicateRooms.length < 2) continue + issues.push(`Duplicate room number ${normalizedNumber} (${duplicateRooms.length} rooms)`) + } + + return issues +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 89472dcb..8aba75c9 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -129,7 +129,11 @@ export { } from './lib/texture-reference' export { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat' export { useItemLightPool } from './store/use-item-light-pool' -export { applyCountryUnitDefault, default as useViewer } from './store/use-viewer' +export { + applyCountryUnitDefault, + default as useViewer, + type MetricNotation, +} from './store/use-viewer' export { CeilingSystem } from './systems/ceiling/ceiling-system' export { createColumnBoxGeometry, diff --git a/packages/viewer/src/store/use-viewer.test.ts b/packages/viewer/src/store/use-viewer.test.ts index 817b4045..4218d004 100644 --- a/packages/viewer/src/store/use-viewer.test.ts +++ b/packages/viewer/src/store/use-viewer.test.ts @@ -10,6 +10,7 @@ const resetMeasurementPreferences = () => { projectPreferences: {}, showMeasurements: true, unit: 'metric', + metricNotation: 'meters', }) } @@ -49,6 +50,17 @@ describe('measurement display preferences', () => { expect(useViewer.getState().projectPreferences).toEqual(preferences) expect(useViewer.getState().showMeasurements).toBe(false) }) + + test('selects millimeters as a metric display notation', () => { + useViewer.getState().setUnit('imperial') + useViewer.getState().setMetricNotation('millimeters') + + expect(useViewer.getState()).toMatchObject({ + unit: 'metric', + metricNotation: 'millimeters', + unitExplicit: true, + }) + }) }) describe('external selection highlights', () => { diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 1fd8bc20..7190c178 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -10,6 +10,7 @@ import type { ColorPreset, RenderShading } from '../lib/materials' import { SCENE_THEME_IDS } from '../lib/scene-themes' export type RenderContext = 'editor' | 'viewer' +export type MetricNotation = 'meters' | 'millimeters' type SelectionPath = { buildingId: BuildingNode['id'] | null @@ -83,6 +84,8 @@ type ViewerState = { unit: 'metric' | 'imperial' setUnit: (unit: 'metric' | 'imperial') => void + metricNotation: MetricNotation + setMetricNotation: (notation: MetricNotation) => void /** True once the user explicitly picked a unit. Until then `unit` is a * locale-derived default and is not persisted, so the default can keep * tracking the browser locale across sessions. */ @@ -186,6 +189,7 @@ type PersistedViewerState = Partial< | 'edges' | 'shadows' | 'unit' + | 'metricNotation' | 'unitExplicit' | 'levelMode' | 'wallMode' @@ -198,6 +202,7 @@ const RENDER_SHADINGS = ['solid', 'rendered'] as const const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'] as const const EDGE_MODES = ['off', 'soft', 'strong'] as const const UNITS = ['metric', 'imperial'] as const +const METRIC_NOTATIONS = ['meters', 'millimeters'] as const const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const @@ -309,6 +314,7 @@ function normalizePersistedViewerState(value: unknown): PersistedViewerState { edges: pickString(state.edges, EDGE_MODES, 'soft'), shadows: typeof state.shadows === 'boolean' ? state.shadows : true, unit: pickString(state.unit, UNITS, detectDefaultUnit()), + metricNotation: pickString(state.metricNotation, METRIC_NOTATIONS, 'meters'), unitExplicit: typeof state.unit === 'string' && UNITS.includes(state.unit as ViewerState['unit']), levelMode: pickString(state.levelMode, LEVEL_MODES, 'stacked'), @@ -391,8 +397,11 @@ const useViewer = create()( setShadows: (shadows) => set({ shadows }), unit: detectDefaultUnit(), + metricNotation: 'meters', unitExplicit: false, setUnit: (unit) => set({ unit, unitExplicit: true }), + setMetricNotation: (metricNotation) => + set({ unit: 'metric', metricNotation, unitExplicit: true }), levelMode: 'stacked', setLevelMode: (mode) => set({ levelMode: mode }), @@ -545,6 +554,7 @@ const useViewer = create()( edges: state.edges, shadows: state.shadows, ...(state.unitExplicit ? { unit: state.unit } : {}), + metricNotation: state.metricNotation, levelMode: state.levelMode, wallMode: state.wallMode, projectPreferences: state.projectPreferences, diff --git a/wiki/architecture/measurements.md b/wiki/architecture/measurements.md index 29f81085..f9b7d935 100644 --- a/wiki/architecture/measurements.md +++ b/wiki/architecture/measurements.md @@ -60,7 +60,7 @@ Measurement nodes must remain in `AnyNode`, `LevelNode.children`, the built-in n These functions are pure and receive the same read-only `GeometryContext` used by registered geometry. They must not import Three.js, editor state, or `useScene`. Feature IDs describe semantic roles (`wall:face:left`, `wall:height`, `roof:ridge:0`); labels never act as identifiers. -The wall contribution samples the existing curved-wall centerline and resolves face hits with normalized `t` plus clamped height. Exact plan-level wall corners bind to `wall:start` or `wall:end` before the thickness-aware face matcher runs. The roof-segment contribution reuses `getRoofSegmentPlanLinework` and the existing roof surface-height calculation, then applies segment and parent-roof transforms. Slab, ceiling, zone, and site use one shared polygon contribution with stable `vertex:`, `boundary`, and `center` roles. An exact corner uses the point feature so it remains a corner when the polygon changes shape; continuous boundary anchors store normalized perimeter position. Do not duplicate any of those topology implementations in measurement code. +The wall contribution samples the existing curved-wall centerline and resolves face hits with normalized `t` plus clamped height. Exact plan-level wall corners bind to `wall:start` or `wall:end` before the thickness-aware face matcher runs. Curved walls additionally publish `wall:curve:center`, allowing radius, center-mark, chord, arc-length, and angular construction dimensions to bind their complete defining geometry and follow later curve edits. Arc-length and angular drafting use four explicit clicks: first arc/ray point, center/vertex, second arc/ray point, then label-line position; their anchors persist in point-center-point order. The roof-segment contribution reuses `getRoofSegmentPlanLinework` and the existing roof surface-height calculation, then applies segment and parent-roof transforms. Slab, ceiling, zone, and site use one shared polygon contribution with stable `vertex:`, `boundary`, and `center` roles. An exact corner uses the point feature so it remains a corner when the polygon changes shape; continuous boundary anchors store normalized perimeter position. Do not duplicate any of those topology implementations in measurement code. `resolveMeasurementNode` derives current free-point geometry from the scene snapshot. Renderers subscribe to referenced nodes, their parents, and ephemeral node overrides; the floor-plan cache uses `def.floorplanDependencies` and the same override-merged resolver. A host edit therefore changes measurement geometry and value during the drag and after commit without writing the measurement node or adding history entries. diff --git a/wiki/floorplan-chapter-17-assessment.md b/wiki/floorplan-chapter-17-assessment.md new file mode 100644 index 00000000..6dfb85cd --- /dev/null +++ b/wiki/floorplan-chapter-17-assessment.md @@ -0,0 +1,203 @@ +# Floor Plan Chapter 17 Assessment + +## Purpose + +This document compares the guidance in `Chapter_17_Floor_Plan_Dimensions_and_Notes.pdf` with Pascal's current floor-plan implementation. It records what the chapter teaches, what the editor already supports, and the remaining construction-document gaps. + +The review covered the full 19-page chapter and the floor-plan stack across: + +- Core floor-plan, wall, opening, and measurement schemas. +- The registry-owned `FloorplanGeometry` contract. +- Editor 2D rendering and interaction layers. +- Node-specific floor-plan builders. +- Automatic wall and opening dimension planning. +- Persistent measurements and smart measurement. +- Door/window documentation and schedules. +- Per-level PDF export. + +## What the chapter is teaching + +The chapter is primarily about construction communication, not merely measuring geometry. Its main principles are: + +1. A drawing must locate and size every construction-critical feature without requiring field workers to guess, scale the drawing, or perform unnecessary arithmetic. +2. Dimensions must be organized into consistent strings that remain readable and uncrowded. +3. The selected datum must match the construction method: centerline, face of stud, face of finish, masonry opening, rough opening, or another explicit reference. +4. Dimension graphics must follow a consistent standard: thin lines, extension-line gaps, extension-line overshoot, uniform terminators, readable aligned text, and predictable spacing. +5. Exterior strings normally progress from detailed opening/partition information to the overall building dimension. +6. Local or specific notes identify individual features through leaders. General notes apply to the whole drawing and are normally numbered in a dedicated sheet area. +7. Door/window schedules and feature notes may replace repeated dimensions when they communicate the information more clearly. +8. Drawing scale, paper-space text size, line weight, and sheet composition are part of the construction-document contract. +9. Curved, circular, masonry, concrete, and foundation-related construction require different dimension semantics from ordinary wood-frame walls. + +## Current implementation + +### Automatic construction dimensions + +`packages/nodes/src/wall/construction-dimensions.ts` already produces coordinated level-wide construction dimensions. The exterior hierarchy includes: + +1. Opening widths. +2. Door and window center locations. +3. Intersecting partition references. +4. Structural columns. +5. Facade jogs, projections, and recesses. +6. Overall facade dimensions. +7. A structural overall dimension when an exterior column row extends beyond the wall envelope. + +The planner also supports: + +- Collinear wall runs that form one facade. +- Disconnected facade runs. +- Angled exterior walls. +- Exterior-side classification. +- Wall-thickness-aware partition references. +- Interior partition strings, including geometrically enclosed partitions whose side metadata remains stale after wall splitting. +- Subdivision chains on every exterior orientation when internal walls divide a facade into multiple runs. +- Hosted door and window widths. +- Interior clear spans bounded by adjacent wall faces. +- Suppression of very short accidental segments. +- Associative updates when the contributing model geometry changes. + +`packages/nodes/src/wall/floorplan.ts` integrates these dimensions into the registry-driven wall floor-plan builder. + +### Dimension graphics + +`packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx` implements several conventions from the chapter: + +- Aligned dimension lines. +- A gap between the feature and extension line. +- Extension lines that pass beyond the dimension line. +- Consistent 45-degree architectural slash terminators. +- Thin dimension and extension lines. +- Text above the dimension line. +- Text that remains readable when the plan is rotated. +- Explicit aligned baselines for stepped facade dimensions. +- Separate edit and document presentation profiles. +- True modeled wall thickness in document output while retaining interactive legibility in edit mode. +- Paper-space dimension text, tick, extension-gap, overshoot, and label-offset sizing in PDF output. +- Whole-millimetre document notation without an `mm` suffix, while retaining metre notation in the interactive editor. +- Short-segment values outside the dimension ticks when the value cannot fit inside. + +### Automatic annotation layout + +`packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts` now resolves automatic dimension-value collisions in both the live floor plan and PDF composition. It supports: + +- Label-to-label separation, including dense clusters. +- Stable same-string drawing order and priority for farther-out architectural strings. +- Movement along the dimension string before crossing into an adjacent tier. +- Fixed door/window mark pills as obstacles. +- Semantic architectural obstacles for walls, wall corners, door symbols and swing envelopes, windows, and columns. +- Sampled diagonal wall outlines, avoiding the oversized screen-aligned bounds produced by rotated walls. +- Outside-end placement for short values, followed by outside-start when the end side is blocked. +- Matching baseline extensions when a short value changes sides. +- A leader and true tick-to-tick baseline when both outside positions require further relocation. + +The former orange/red dashed collision overlay was removed because it displayed stale pre-layout conflicts on top of labels that the automatic resolver had already made readable. Any future unresolved-collision reporting should live in a separate preflight surface rather than being painted over the drawing. + +`packages/nodes/src/shared/construction-length.ts` formats imperial construction dimensions using feet, inches, and reduced fractions rounded to the nearest sixteenth. + +### Persistent measurements + +The existing measurement system is broader than the chapter's drafting examples. It supports: + +- Distance. +- Angle. +- Area. +- Perimeter. +- Prism volume. +- Free and associative semantic anchors. +- Wall, roof, slab, ceiling, zone, and site features. +- Live updates when referenced geometry changes. +- Dangling-reference presentation and explicit detach behavior. +- 2D and 3D drafting and editing. +- Smart transient measurement reports. + +The architecture is documented in `wiki/architecture/measurements.md`. These measurements are analysis annotations; they are not yet a complete replacement for architectural construction-dimension strings. + +### Door and window documentation + +`packages/nodes/src/shared/opening-documentation.ts` provides: + +- Deterministic automatic door and window marks. +- Explicit mark overrides. +- Duplicate explicit-mark warnings. +- Mark bubbles and leaders. +- Door schedules. +- Window schedules. +- Nominal dimensions. +- Optional verified rough-opening dimensions. +- Window sill and head heights. +- Door operation, frame, and hardware fields. + +The rough-opening fields intentionally remain optional rather than being invented from the nominal modeled opening size. + +### Rooms, stairs, and other plan graphics + +- Zones render a centered name but currently represent generic colored polygons rather than a complete architectural room model. +- Stairs render footprints, treads, and direction arrows, but do not yet emit a complete construction stair note. +- Columns can contribute structural center references to automatic exterior strings. +- The generic floor-plan registry already renders walls, doors, windows, slabs, ceilings, zones, roofs, stairs, columns, furniture, MEP nodes, and annotation nodes through a common geometry contract. + +### PDF export + +`packages/editor/src/lib/floorplan/floorplan-export.tsx` currently provides: + +- Per-level PDF plan pages. +- North-up orientation that accounts for building rotation. +- Full and structure-only export scopes. +- Door and window schedule pages. +- Registry-driven geometry matching the live floor-plan builders. +- Conversion of non-scaling SVG strokes for PDF output. +- Preservation of persistent measurement value labels in full export. +- Respect for the existing measurement-visibility preference. +- Document-purpose wall rendering at modeled thickness. +- Document metric notation and initial paper-space sizing for construction dimensions and measurement labels. +- The same automatic annotation collision layout used by the live floor plan. + +The plan is fitted to an A4 landscape page. It is not yet plotted at a fixed architectural scale. + +## Important current limitations + +### Interactive measurement and construction dimension are different concepts + +The measurement system stores geometric analysis annotations. The wall planner creates automatic construction strings. There is no dedicated manual construction-dimension object that lets a drafter pick references, place a baseline, add points to a continuous string, and later reposition or suppress individual segments. + +### The current datum is not truly face of stud + +`WallNode` stores total thickness and finish materials but does not describe studs, sheathing, finish layers, veneer, air space, concrete block, or furring. Automatic dimensions can reference a generic wall face, but the model cannot yet prove that this face is a structural stud face or finish face. + +### Paper-space control is only partially implemented + +Exported construction dimensions and measurement labels now resolve their main text, tick, extension-gap, overshoot, and label-offset sizes from paper points. Note text, mark bubbles, room labels, remaining line-weight categories, and fixed user-selectable drawing scales still require the drawing-sheet work. + +### Construction dimensions have no independent visibility layer + +The live floor plan exposes independent visibility controls for automatic dimensions, manual dimensions, measurements, opening marks, structural grids, room labels, and stair annotations. Full export intentionally includes every supported annotation category regardless of the live-view toggles. + +### Automatic collision layout has no persistent manual override + +Automatic placement now handles adjacent labels, short values, opening marks, and the first set of architectural obstacles. It does not yet let a drafter pin a chosen label position, suppress a segment, or persist a view-specific layout override. Broader fixed-symbol coverage and a separate unresolved-collision preflight also remain. + +### Curved and circular construction dimensions + +Curved walls emit an automatic radius leader and center mark in live plans and document output, matching the chapter's curved-wall callout method. Manual associative construction dimensions cover radius, diameter, center, chord, arc-length, coordinate-pattern, and angular-pattern workflows, with curved-wall defining geometry resolved from stable semantic host features. + +### Construction systems are not semantically modeled + +The editor cannot yet apply different documentation rules for wood framing, masonry veneer, concrete block, structural masonry, or solid concrete because those assembly semantics do not exist in the wall model. + +### The floor plan has no drawing-sheet model + +The export layer produces plan and schedule pages, but there is no persistent drawing sheet with view identity, scale, title block, drawing number, note blocks, graphic scale, north arrow, or per-view annotation visibility. + +## Features that should not be copied blindly + +The chapter was published in 2012. Its example sizes and clearances are useful drafting and design references, but they should not be treated as current building-code requirements. + +Any implementation of hallway, fixture, door, stair, appliance, or room-clearance checks should: + +- Be configurable by jurisdiction and standard profile. +- Be presented as an advisory or verification result unless code provenance is known. +- Avoid embedding manufacturer-dependent rough openings or product sizes as universal facts. +- Avoid silently omitting dimensions merely because a feature is commonly considered standard. + +The product should prefer explicit model data, verified manufacturer data, and user-controlled documentation policies. diff --git a/wiki/floorplan-pdf-export-library-research.md b/wiki/floorplan-pdf-export-library-research.md new file mode 100644 index 00000000..69de739f --- /dev/null +++ b/wiki/floorplan-pdf-export-library-research.md @@ -0,0 +1,269 @@ +# Floor-plan PDF export library research + +Date: 2026-07-21 + +## Decision summary + +The missing dimension values are a conversion-boundary problem, not a limitation of PDF text. +The current export builds an SVG in the DOM and asks `svg2pdf.js` to reinterpret that SVG as PDF. +That makes the result depend on how the converter handles nested transforms, inherited SVG styles, +font discovery, text baselines, paint order, and non-scaling strokes. Replacing `svg2pdf.js` with a +second automatic SVG converter leaves those same risks in place. + +The reliable design is to render the existing semantic `FloorplanGeometry` directly into PDF +primitives. Dimension values must be emitted with the PDF library's native text API, and dimension +lines/ticks must be emitted with explicit point widths. That preserves selectable vector text and +removes SVG/CSS interpretation from the critical path. + +Recommended choices: + +1. **Smallest and lowest-risk:** keep jsPDF but stop sending dimension annotations through + `svg2pdf.js`. Draw dimensions as a native jsPDF overlay with `doc.text`, `doc.line`, and + `doc.rect`. This is the best implementation choice even though it is not a library replacement. +2. **If a different library is required:** use **PDFKit directly**, rendering from + `FloorplanGeometry`. It has the strongest current combination of browser support, native vector + drawing, transformation support, font embedding, and active maintenance. +3. **Do not choose another automatic SVG converter** as the primary fix. In particular, + `SVG-to-PDFKit` has been inactive since 2022 and documents unsupported features and browser font + loading caveats. + +No production code was changed as part of this research. + +## Current architecture and why it matters + +The current code already has the right source model for a direct PDF backend: + +- Node builders return semantic `FloorplanGeometry`, including `dimension`, `dimension-string`, + `dimension-label`, lines, paths, polygons, circles, and groups. +- [`floorplan-dimension-renderer.tsx`](../packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx) + resolves each dimension's line endpoints, ticks, label point, label angle, font size, and label + placement. +- [`floorplan-export.tsx`](../packages/editor/src/lib/floorplan/floorplan-export.tsx) currently mounts + a React SVG off-screen and converts it with jsPDF + `svg2pdf.js`. + +That means a new export backend does not need to infer measurements from DOM nodes. It can traverse +the same geometry tree and emit native PDF operations deterministically. + +The current converter itself says that custom fonts must be registered before conversion, calls +itself "by no means perfect," and notes that its visual tests can vary because of text measurement. +Those are material warnings for small, rotated architectural labels. +[Official `svg2pdf.js` repository](https://github.com/yWorks/svg2pdf.js) + +## Requirements + +The selected approach should provide: + +- visible dimension values at every rotation; +- selectable/searchable PDF text; +- embedded or otherwise deterministic fonts; +- explicit thin vector strokes in PDF points; +- lines, curves, polygons, circles, fills, clips, and nested transforms; +- browser-side generation and Blob/download support; +- compatibility with React and TypeScript in this monorepo; +- an API that can be tested without visual browser automation. + +## Comparison + +| Rank | Approach | Native/selectable text | Fonts | Transforms and thin vectors | Browser/TypeScript fit | Maintenance | Integration cost | +|---:|---|---|---|---|---|---|---| +| 1 | Direct jsPDF drawing, optionally as a hybrid overlay | Yes; `text()` emits PDF text and supports an angle or matrix | Custom TTF through VFS + `addFont` | Explicit `setLineWidth`; advanced mode exposes transformation matrices | Already installed and browser-first; official typings | Active; current 4.x docs and releases | Low for annotation overlay, medium for full renderer | +| 2 | Direct PDFKit renderer | Yes; native PDF text | TTF, OTF, WOFF, WOFF2, TTC, dfont; subsetting | Canvas-like vectors, SVG path data, save/restore, translate/rotate/scale/transform, explicit line width | Browser supported, but Blob stream/bundling and separate TS types add work | Active; current 0.19.x releases | Medium-high | +| 3 | Chromium print-to-PDF | Browser's own text/SVG renderer; generally preserves vector text | Uses loaded web fonts; Puppeteer waits for fonts by default | Browser-native SVG/CSS transforms and strokes | Not a pure browser-side library: needs print UI or a headless-browser service | Very active | Low rendering rewrite, high operational cost | +| 4 | `@react-pdf/renderer` | Native `` and SVG `` | `Font.register`; TTF and WOFF | SVG primitives, group transforms, explicit strokes; `Canvas` wraps PDFKit operations | Browser + server React APIs, Blob provider, bundled typings | Active releases and commits | High because DOM SVG is not reusable as-is | +| 5 | `pdf-lib` direct renderer | Native `drawText` with rotation | Standard fonts; custom fonts through `@pdf-lib/fontkit` | Lines, shapes, individual SVG path data, explicit thickness; lower-level transform work | Browser-compatible and TypeScript-native | Stable but inactive upstream since November 2021 | High | +| 6 | `SVG-to-PDFKit` automatic conversion | Supports SVG text/tspan/textPath | Requires pre-registration or a callback; does not wait for async browser font loading | Supports common transforms, but documents unsupported `vector-effect` | Browser possible through PDFKit; has a declaration file | Last commit August 2022; no published GitHub releases | Medium | +| 7 | Canvg raster fallback | No; text becomes pixels | Whatever the canvas resolved at rasterization time | Visually faithful at sufficient resolution, but all output is raster | Browser-friendly and TypeScript-based | Maintained; 4.0.3 released in 2025 | Low-medium | + +## Approach details + +### 1. Direct jsPDF primitives — recommended incremental implementation + +jsPDF already exposes all operations needed for dimension annotations: + +- `text(text, x, y, { angle, align, baseline })` for actual PDF text; +- transformation matrices in advanced mode; +- `setLineWidth(width)` in the document's declared units; +- custom font registration with `addFileToVFS`, `addFont`, and `setFont`. + +The official source documentation shows that `text()` writes a PDF text object (`BT`, font +selection, text position, `Tj`, `ET`) rather than rasterizing the label. It also documents angle and +matrix transforms. [jsPDF text and line-width documentation](https://parallax.github.io/jsPDF/docs/jspdf.js.html), +[font and advanced-mode guide](https://parallax.github.io/jsPDF/docs/index.html) + +Practical design: + +1. Keep the current `svg2pdf.js` pass temporarily for non-annotation geometry. +2. Exclude all `dimension`, `dimension-string`, and `dimension-label` geometry from that SVG pass. +3. Resolve them into a small `PdfDimensionAnnotation` display list containing witness lines, + dimension line, tick segments, label text, label anchor, angle, font size, and background box. +4. Transform model coordinates into page points once. +5. Draw the background plate, lines, ticks, then `doc.text()` with explicit fill color and embedded + font. +6. Later, move walls and other geometry to the same native backend if desired. + +Why this is ranked first: it eliminates the observed failure path while retaining the installed PDF +engine, page setup, headers, schedules, and save flow. It also provides a narrow regression-test +surface: generated PDF content can be inspected for each expected label string. + +### 2. Direct PDFKit — recommended full replacement library + +PDFKit runs in both Node and the browser. Its official documentation includes: + +- selectable text and embedded font support for TTF, OTF, WOFF, WOFF2, TTC, and dfont; +- vector `moveTo`, `lineTo`, Bézier and quadratic curves; +- parsing of SVG **path data** (not an entire SVG DOM); +- save/restore, translate, rotate, scale, and arbitrary transform operations; +- explicit stroke widths and Blob output in the browser. + +[PDFKit browser setup](https://pdfkit.org/docs/getting_started.html), +[vector and transform APIs](https://pdfkit.org/docs/vector.html), +[text and font APIs](https://pdfkit.org/docs/text.html) + +PDFKit 0.19 raised its documented browser floor to Firefox 115 and Safari/iOS 16, and its current +release line continues to include text, font, SVG-path, and browser fixes. +[Official PDFKit releases](https://github.com/foliojs/pdfkit/releases) + +Practical design: + +1. Add an exhaustive `renderFloorplanGeometryToPdfKit` visitor. +2. Give the visitor a coordinate transform from model metres to PDF points, including the page's + Y-axis inversion and plan rotation. +3. Draw every dimension label using `doc.text()` after `save/translate/rotate`. +4. Register an exact project font before rendering and use explicit point sizes. +5. Pipe to a browser Blob stream and keep the existing download UX. + +Tradeoffs: this is a cleaner long-term backend but a larger initial migration. PDFKit's npm package +does not currently advertise bundled declarations in its package manifest, so the TypeScript package +would normally also use `@types/pdfkit`. Browser output uses a Node-style stream, commonly adapted +with `blob-stream`. Both add integration weight compared with the existing jsPDF save flow. + +### 3. Chromium/browser printing + +Printing a dedicated page lets the browser render the same SVG, CSS, transforms, and fonts that it +renders on screen. `window.print()` is widely available, and print-specific CSS can control the +page. [MDN `window.print`](https://developer.mozilla.org/en-US/docs/Web/API/Window/print), +[MDN printing guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Printing) + +For automatic downloads, Puppeteer's `Page.pdf()` uses Chromium print output. Its documented +options include CSS page-size preference, background graphics, and waiting for +`document.fonts.ready` (enabled by default). +[Puppeteer PDF guide](https://pptr.dev/guides/pdf-generation), +[Puppeteer PDF options](https://pptr.dev/api/puppeteer.pdfoptions) + +This is the best path when exact browser-rendering parity outweighs infrastructure cost. It is not +a pure client library: either the user must use the print dialog, or the application needs a trusted +server/desktop process running Chromium. That is a substantial architectural change for the current +browser-side download. + +### 4. `@react-pdf/renderer` + +React-pdf produces PDFs in the browser and server and offers browser Blob/download components. It +has its own PDF primitives, including SVG `Line`, `Path`, `Text`, `Tspan`, and `G`. Its documented +presentation attributes include `strokeWidth`, `transform`, `textAnchor`, and +`dominantBaseline`. Group transforms apply to children. Its `Canvas` painter wraps PDFKit methods, +including `text`, `path`, `rotate`, `lineWidth`, `translate`, and `scale`. +[React-pdf SVG APIs](https://react-pdf.org/svg), +[components and Canvas API](https://react-pdf.org/components), +[font registration](https://react-pdf.org/fonts) + +This is viable and actively maintained. It is not a drop-in renderer for the existing React DOM SVG: +the floor plan must be rebuilt with React-pdf's component types or drawn through its Canvas painter. +For a CAD-like plan, that gives no decisive rendering advantage over direct PDFKit while adding a +second React renderer and layout engine. It is more attractive if the broader drawing-sheet document +will be rebuilt declaratively. + +### 5. `pdf-lib` + +`pdf-lib` runs in browsers and is written in TypeScript. It provides native `drawText` with rotation, +explicit line thickness, rectangles/circles/ellipses, and individual SVG path drawing. Custom fonts +are embedded through `@pdf-lib/fontkit`. +[Official examples](https://pdf-lib.js.org/), +[`PDFPage` drawing API](https://pdf-lib.js.org/docs/api/classes/pdfpage), +[`DrawTextOptions`](https://pdf-lib.js.org/docs/api/interfaces/drawtextoptions) + +It does not parse a complete SVG document; its SVG support is for one path-data string at a time. +Consequently, it requires the same complete `FloorplanGeometry` visitor as PDFKit, with a less +convenient graphics-state/transform API for this use case. The upstream repository's latest commit +and release are from November 2021, so it is not the preferred new dependency for a renderer being +introduced in 2026. [Official commit history](https://github.com/Hopding/pdf-lib/commits/master/), +[official releases](https://github.com/Hopding/pdf-lib/releases) + +### 6. `SVG-to-PDFKit` + +`SVG-to-PDFKit` is the only credible alternate JavaScript full-SVG converter found. Its documented +coverage includes SVG text/tspan/textPath, transforms, paths, clips, masks, fonts, gradients, and +patterns. However, it explicitly does not support `vector-effect`, warns that browser fonts must be +registered before conversion because it does not wait for asynchronous loading, warns that bugs +remain, and has not received a commit since August 2022. +[Official repository and support table](https://github.com/alafr/SVG-to-PDFKit), +[official commit history](https://github.com/alafr/SVG-to-PDFKit/commits/master/) + +It is therefore not a sensible replacement for `svg2pdf.js`. It changes the converter without +removing the converter boundary. + +### 7. Canvg raster fallback + +Canvg parses SVG and renders it to Canvas; its stated purpose includes SVG rasterization. +[Official repository](https://github.com/canvg/canvg), +[official API](https://canvg.js.org/api) + +Rendering the plan at high device-pixel density and embedding the canvas as PNG would make missing +text unlikely after `document.fonts.ready`, because the browser/canvas has already converted the +glyphs to pixels. It is an acceptable emergency fallback or diagnostic control. It does not meet the +core deliverable: dimension text is not selectable, all geometry becomes raster, thin lines depend +on export resolution, and large plans produce larger PDFs. + +## Proposed implementation sequence + +If implementation is approved, use this order: + +1. Add a library-independent PDF display list or visitor over `FloorplanGeometry`. +2. Implement dimensions first: lines, ticks, background plates, and native text. +3. Embed one exact non-variable TTF font and wait for/load it explicitly. +4. Preserve all document sizes in PDF points; do not use CSS pixels for line weights. +5. Keep the existing SVG conversion only for unported geometry during the transition. +6. Add structural tests that inspect the generated PDF for expected text strings and page count. +7. Add fixture coverage for horizontal, vertical, diagonal, rotated-plan, short/outside-label, + metric, and imperial dimensions. +8. Only after the annotation path is proven, decide whether to port the remaining geometry and + remove `svg2pdf.js`. + +For a mandated new library, substitute a direct PDFKit backend at steps 2–5 and port geometry kinds +incrementally. Do not introduce `SVG-to-PDFKit` as an intermediate layer. + +## Acceptance criteria for the eventual implementation + +- Every dimension value in the source geometry is present as extractable text in the generated PDF. +- Horizontal, vertical, and diagonal values remain readable at plan rotations of 0°, 45°, 90°, and + arbitrary building rotations. +- Dimension lines render at an explicit target such as 0.5 pt and ticks at 0.75 pt regardless of + plan scale. +- The chosen font is embedded or a deliberate standard PDF font is used. +- Label backing plates are drawn before text and do not obscure glyphs. +- Text extraction verifies representative metric and imperial labels. +- Geometry remains vector except for explicitly documented raster-only assets. + +## Primary sources + +- [jsPDF repository](https://github.com/parallax/jsPDF) +- [jsPDF documentation](https://parallax.github.io/jsPDF/docs/index.html) +- [jsPDF source/API documentation](https://parallax.github.io/jsPDF/docs/jspdf.js.html) +- [`svg2pdf.js` repository](https://github.com/yWorks/svg2pdf.js) +- [`svg2pdf.js` releases](https://github.com/yWorks/svg2pdf.js/releases) +- [PDFKit repository](https://github.com/foliojs/pdfkit) +- [PDFKit browser setup](https://pdfkit.org/docs/getting_started.html) +- [PDFKit vector graphics](https://pdfkit.org/docs/vector.html) +- [PDFKit text and fonts](https://pdfkit.org/docs/text.html) +- [PDFKit releases](https://github.com/foliojs/pdfkit/releases) +- [React-pdf components](https://react-pdf.org/components) +- [React-pdf SVG primitives](https://react-pdf.org/svg) +- [React-pdf fonts](https://react-pdf.org/fonts) +- [React-pdf releases](https://github.com/diegomura/react-pdf/releases) +- [`pdf-lib` documentation](https://pdf-lib.js.org/) +- [`pdf-lib` `PDFPage` API](https://pdf-lib.js.org/docs/api/classes/pdfpage) +- [`pdf-lib` repository](https://github.com/Hopding/pdf-lib) +- [`SVG-to-PDFKit` repository](https://github.com/alafr/SVG-to-PDFKit) +- [Canvg repository](https://github.com/canvg/canvg) +- [Puppeteer PDF generation](https://pptr.dev/guides/pdf-generation) +- [Puppeteer PDF options](https://pptr.dev/api/puppeteer.pdfoptions) +- [MDN printing guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Printing) From 26c9e17fed2b2c8416c90036f1f5d8636792a523 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 22 Jul 2026 14:51:34 -0400 Subject: [PATCH 09/11] feat(nodes): draft axis guides at the moving endpoint + 2d parity (#534) Wall/fence drafting already drew an X/Z axis cross at the segment start. Now the moving endpoint gets a single long guide line perpendicular to the draft segment (a second cross would collide with the start cross on axis-aligned segments), fences gain the same guides they lacked entirely, and the 2D floor plan renders the equivalent SVG guides (cross at start, perpendicular line at end) fed by the floorplan draft preview store. The guide components are extracted from wall/tool.tsx into nodes/shared/draft-axis-guides.tsx so both tools (and the fence's formerly duplicated arc/label helpers) share one implementation. --- .../src/components/editor/floorplan-panel.tsx | 76 ++++++ packages/nodes/src/fence/tool.tsx | 120 +++------ .../nodes/src/shared/draft-axis-guides.tsx | 253 ++++++++++++++++++ packages/nodes/src/wall/tool.tsx | 229 +--------------- 4 files changed, 381 insertions(+), 297 deletions(-) create mode 100644 packages/nodes/src/shared/draft-axis-guides.tsx diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index b3d8b128..d26ce3ca 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -5165,8 +5165,82 @@ function FloorplanLinearDraftLayer({ } }, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls]) + // Axis guides for wall and fence drafts — parity with the 3D tools' + // `DraftAxisGuides`: an X/Z cross through the draft start, and a single + // long line PERPENDICULAR to the segment through the moving endpoint (a + // second cross would collide with the start cross on axis-aligned + // segments). Long solid lines in world space — cheap for the SVG renderer, + // unlike dashed strokes which tessellate per dash over 2000 m. Stroke + // width is budgeted in screen pixels via `unitsPerPixel`, matching the + // alignment-guide layer. + const draftAxisGuideLines = useMemo(() => { + const lines: Array<{ x1: number; y1: number; x2: number; y2: number }> = [] + const pushCross = (point: WallPlanPoint) => { + lines.push( + { + x1: point[0] - DRAFT_AXIS_GUIDE_EXTENT, + y1: point[1], + x2: point[0] + DRAFT_AXIS_GUIDE_EXTENT, + y2: point[1], + }, + { + x1: point[0], + y1: point[1] - DRAFT_AXIS_GUIDE_EXTENT, + x2: point[0], + y2: point[1] + DRAFT_AXIS_GUIDE_EXTENT, + }, + ) + } + const pushDraft = (start: WallPlanPoint, end: WallPlanPoint) => { + pushCross(start) + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return + const nx = -dy / length + const ny = dx / length + lines.push({ + x1: end[0] - nx * DRAFT_AXIS_GUIDE_EXTENT, + y1: end[1] - ny * DRAFT_AXIS_GUIDE_EXTENT, + x2: end[0] + nx * DRAFT_AXIS_GUIDE_EXTENT, + y2: end[1] + ny * DRAFT_AXIS_GUIDE_EXTENT, + }) + } + if (isWallBuildActive && wallDraftStart && wallDraftEnd) { + pushDraft(wallDraftStart, wallDraftEnd) + } + if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) { + pushDraft(fenceDraftStart, fenceDraftEnd) + } + return lines.length > 0 ? lines : null + }, [ + isFenceBuildActive, + isWallBuildActive, + fenceDraftEnd, + fenceDraftStart, + wallDraftEnd, + wallDraftStart, + ]) + return ( <> + {draftAxisGuideLines && ( + + {draftAxisGuideLines.map((line, index) => ( + + ))} + + )} + = [] +/** World-space half-length of the 2D draft axis guide lines (matches the 3D tools' 2000 m guides). */ +const DRAFT_AXIS_GUIDE_EXTENT = 1000 export function FloorplanPanel({ /** diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 25dee57a..763e787d 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -48,7 +48,6 @@ import { } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { @@ -60,6 +59,14 @@ import { type Mesh, Vector3, } from 'three' +import { + DraftAngleArc, + type DraftAngleLabel, + type DraftAxisGuideState, + DraftAxisGuides, + DraftMeasurementLabel, + getNearestAxisAngleLabel, +} from '../shared/draft-axis-guides' const FENCE_PREVIEW_HEIGHT = 1.8 const FENCE_PREVIEW_THICKNESS = 0.08 @@ -86,20 +93,6 @@ const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08 const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012 const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 -const DRAFT_ANGLE_ARC_SEGMENTS = 24 - -type DraftAngleLabel = { - id: string - label: string - position: [number, number, number] - arc: { - center: FencePlanPoint - radius: number - startAngle: number - endAngle: number - y: number - } -} type DraftMeasurementState = { lengthLabel: string @@ -498,6 +491,7 @@ const StraightFenceTool: React.FC = () => { const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) + const [axisGuide, setAxisGuide] = useState(null) const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' @@ -544,6 +538,7 @@ const StraightFenceTool: React.FC = () => { buildingState.current = 0 previewRef.current.visible = false setDraftMeasurement(null) + setAxisGuide(null) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setFenceDraftStart(null) draftPreview.setFenceDraftEnd(null) @@ -590,6 +585,16 @@ const StraightFenceTool: React.FC = () => { draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z]) draftPreview.setFenceDraftEnd(snappedLocal) cursorRef.current.position.copy(endingPoint.current) + setAxisGuide({ + origin: [startingPoint.current.x, startingPoint.current.z], + endOrigin: snappedLocal, + y: startingPoint.current.y, + angleLabel: getNearestAxisAngleLabel( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + startingPoint.current.y, + ), + }) const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] if ( previousFenceEnd && @@ -627,6 +632,7 @@ const StraightFenceTool: React.FC = () => { ) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) setDraftMeasurement(null) + setAxisGuide(null) } } @@ -658,6 +664,12 @@ const StraightFenceTool: React.FC = () => { triggerSFX('sfx:structure-build-start') previewRef.current.visible = true setDraftMeasurement(null) + setAxisGuide({ + origin: snappedStart, + endOrigin: null, + y: event.localPosition[1], + angleLabel: null, + }) } else { const angleLocked = isAngleSnapActive() const snappedEnd = alignPoint( @@ -709,6 +721,12 @@ const StraightFenceTool: React.FC = () => { previewRef.current.visible = false buildingState.current = 1 setDraftMeasurement(null) + setAxisGuide({ + origin: nextStart, + endOrigin: null, + y: event.localPosition[1], + angleLabel: null, + }) } } @@ -738,6 +756,11 @@ const StraightFenceTool: React.FC = () => { return ( + @@ -927,71 +950,4 @@ const SplineFenceDraft: React.FC = () => { ) } -function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { - const geometry = useMemo(() => { - const segmentCount = Math.max( - 8, - Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), - ) - - const points = Array.from({ length: segmentCount + 1 }, (_, index) => { - const t = index / segmentCount - const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t - - return new Vector3( - arc.center[0] + Math.cos(angle) * arc.radius, - arc.y, - arc.center[1] + Math.sin(angle) * arc.radius, - ) - }) - - return new BufferGeometry().setFromPoints(points) - }, [arc]) - - return ( - // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. - - - - ) -} - -function DraftMeasurementLabel({ - color, - label, - position, - shadowColor, -}: { - color: string - label: string - position: [number, number, number] - shadowColor: string -}) { - return ( - -
- {label} -
- - ) -} - export default FenceTool diff --git a/packages/nodes/src/shared/draft-axis-guides.tsx b/packages/nodes/src/shared/draft-axis-guides.tsx new file mode 100644 index 00000000..50b52f84 --- /dev/null +++ b/packages/nodes/src/shared/draft-axis-guides.tsx @@ -0,0 +1,253 @@ +import { + EDITOR_LAYER, + formatAngleRadians, + getAngleArcToSegmentReference, + getAngleToSegmentReference, + type SegmentAngleReference, + type WallPlanPoint, +} from '@pascal-app/editor' +import { Html } from '@react-three/drei' +import { useMemo } from 'react' +import { BufferGeometry, Vector3 } from 'three' + +/** + * Axis guide lines + axis-angle readout shown while drafting linear segments + * (walls, fences). An X/Z cross of long thin boxes is drawn through the + * segment start so it can be aligned against the world axes; the moving + * endpoint gets a single long line PERPENDICULAR to the draft segment (a + * second cross there would overlap the start cross whenever the segment is + * axis-aligned). The angle to the nearest axis is shown as an arc + label + * anchored at the start point only (duplicating it at the endpoint is + * visual clutter). + */ +const DRAFT_AXIS_GUIDE_LENGTH = 2000 +const DRAFT_AXIS_GUIDE_WIDTH = 0.035 +const DRAFT_AXIS_GUIDE_HEIGHT = 0.004 +const DRAFT_AXIS_GUIDE_Y_OFFSET = 0.026 +const DRAFT_AXIS_ANGLE_ARC_Y_OFFSET = 0.05 +const DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET = 0.16 +const DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS = 0.36 +const DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS = 0.82 +const DRAFT_ANGLE_ARC_SEGMENTS = 24 +const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [ + { vector: [1, 0], orientation: 'axis' }, + { vector: [0, 1], orientation: 'axis' }, +] + +export type DraftAngleLabel = { + id: string + label: string + position: [number, number, number] + arc: { + center: WallPlanPoint + radius: number + startAngle: number + endAngle: number + y: number + } +} + +export type DraftAxisGuideState = { + origin: WallPlanPoint + endOrigin: WallPlanPoint | null + y: number + angleLabel: DraftAngleLabel | null +} | null + +type AxisAngleCandidate = { + angle: number + arc: { + startAngle: number + endAngle: number + midAngle: number + } +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +export function getNearestAxisAngleLabel( + start: WallPlanPoint, + end: WallPlanPoint, + y: number, +): DraftAngleLabel | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < 0.01) return null + + const draftVector: WallPlanPoint = [dx, dz] + const axisCandidates: AxisAngleCandidate[] = [] + for (const reference of AXIS_ANGLE_REFERENCES) { + const angle = getAngleToSegmentReference(draftVector, reference) + const arc = getAngleArcToSegmentReference(draftVector, reference) + if (!(angle === null || arc === null)) { + axisCandidates.push({ angle, arc }) + } + } + const nearestAxisAngle = axisCandidates.sort((a, b) => a.angle - b.angle)[0] + if (!nearestAxisAngle) return null + + const radius = clamp( + length * 0.22, + DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS, + DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS, + ) + const { angle, arc } = nearestAxisAngle + + return { + id: 'axis', + label: formatAngleRadians(angle), + position: [ + start[0] + Math.cos(arc.midAngle) * (radius + 0.16), + y + DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET, + start[1] + Math.sin(arc.midAngle) * (radius + 0.16), + ], + arc: { + center: start, + radius, + startAngle: arc.startAngle, + endAngle: arc.endAngle, + y: y + DRAFT_AXIS_ANGLE_ARC_Y_OFFSET, + }, + } +} + +export function DraftAxisGuides({ + guide, + labelColor, + labelShadowColor, +}: { + guide: DraftAxisGuideState + labelColor: string + labelShadowColor: string +}) { + if (!guide) return null + + const [x, z] = guide.origin + + // Single long line through the endpoint, perpendicular to the draft + // segment (a full axis cross there would collide with the start cross + // whenever the segment is axis-aligned). + let endRotationY: number | null = null + if (guide.endOrigin) { + const dx = guide.endOrigin[0] - x + const dz = guide.endOrigin[1] - z + if (dx * dx + dz * dz >= 0.01 * 0.01) { + endRotationY = Math.atan2(-dx, -dz) + } + } + + return ( + <> + + + + + {guide.endOrigin && endRotationY !== null && ( + + + + )} + {guide.angleLabel && ( + <> + + + + )} + + ) +} + +function DraftAxisGuideLine({ axis, rotationY }: { axis?: 'x' | 'z'; rotationY?: number }) { + const y = rotationY ?? (axis === 'z' ? Math.PI / 2 : 0) + return ( + + + + + ) +} + +export function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { + const geometry = useMemo(() => { + const segmentCount = Math.max( + 8, + Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), + ) + + const points = Array.from({ length: segmentCount + 1 }, (_, index) => { + const t = index / segmentCount + const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t + + return new Vector3( + arc.center[0] + Math.cos(angle) * arc.radius, + arc.y, + arc.center[1] + Math.sin(angle) * arc.radius, + ) + }) + + return new BufferGeometry().setFromPoints(points) + }, [arc]) + + return ( + // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. + + + + ) +} + +export function DraftMeasurementLabel({ + color, + label, + position, + shadowColor, +}: { + color: string + label: string + position: [number, number, number] + shadowColor: string +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 98ef3220..80070590 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -45,10 +45,17 @@ import { type WallPlanPoint, } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useThree } from '@react-three/fiber' -import { useEffect, useMemo, useRef, useState } from 'react' -import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { useEffect, useRef, useState } from 'react' +import { BoxGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { + DraftAngleArc, + type DraftAngleLabel, + type DraftAxisGuideState, + DraftAxisGuides, + DraftMeasurementLabel, + getNearestAxisAngleLabel, +} from '../shared/draft-axis-guides' /** * Phase 5 Stage D — wall placement tool (kind-owned). @@ -73,59 +80,18 @@ const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08 const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012 const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 -const DRAFT_ANGLE_ARC_SEGMENTS = 24 -const DRAFT_AXIS_GUIDE_LENGTH = 2000 -const DRAFT_AXIS_GUIDE_WIDTH = 0.035 -const DRAFT_AXIS_GUIDE_HEIGHT = 0.004 -const DRAFT_AXIS_GUIDE_Y_OFFSET = 0.026 -const DRAFT_AXIS_ANGLE_ARC_Y_OFFSET = 0.05 -const DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET = 0.16 -const DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS = 0.36 -const DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS = 0.82 -const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [ - { vector: [1, 0], orientation: 'axis' }, - { vector: [0, 1], orientation: 'axis' }, -] // Grid-plane surface publish (pointer-decided): scratch + constant normal so // per-move publishes don't allocate. const SURFACE_UP = new Vector3(0, 1, 0) const surfacePointScratch = new Vector3() -type DraftAngleLabel = { - id: string - label: string - position: [number, number, number] - arc: { - center: WallPlanPoint - radius: number - startAngle: number - endAngle: number - y: number - } -} - type DraftMeasurementState = { lengthLabel: string lengthPosition: [number, number, number] angleLabels: DraftAngleLabel[] } | null -type DraftAxisGuideState = { - origin: WallPlanPoint - y: number - angleLabel: DraftAngleLabel | null -} | null - -type AxisAngleCandidate = { - angle: number - arc: { - startAngle: number - endAngle: number - midAngle: number - } -} - type FaceAngleCandidate = { index: number point: WallPlanPoint @@ -166,53 +132,6 @@ function isWithinWallJoinSnapRadius(point: WallPlanPoint, vertex: Vector3) { return dx * dx + dz * dz <= WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS } -function getNearestAxisAngleLabel( - start: WallPlanPoint, - end: WallPlanPoint, - y: number, -): DraftAngleLabel | null { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - if (length < 0.01) return null - - const draftVector: WallPlanPoint = [dx, dz] - const axisCandidates: AxisAngleCandidate[] = [] - for (const reference of AXIS_ANGLE_REFERENCES) { - const angle = getAngleToSegmentReference(draftVector, reference) - const arc = getAngleArcToSegmentReference(draftVector, reference) - if (!(angle === null || arc === null)) { - axisCandidates.push({ angle, arc }) - } - } - const nearestAxisAngle = axisCandidates.sort((a, b) => a.angle - b.angle)[0] - if (!nearestAxisAngle) return null - - const radius = clamp( - length * 0.22, - DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS, - DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS, - ) - const { angle, arc } = nearestAxisAngle - - return { - id: 'axis', - label: formatAngleRadians(angle), - position: [ - start[0] + Math.cos(arc.midAngle) * (radius + 0.16), - y + DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET, - start[1] + Math.sin(arc.midAngle) * (radius + 0.16), - ], - arc: { - center: start, - radius, - startAngle: arc.startAngle, - endAngle: arc.endAngle, - y: y + DRAFT_AXIS_ANGLE_ARC_Y_OFFSET, - }, - } -} - function toWallPlanPoint(point: Point2D): WallPlanPoint { return [point.x, point.y] } @@ -691,6 +610,7 @@ export const WallTool: React.FC = () => { cursorRef.current.position.copy(endingPoint.current) setAxisGuide({ origin: [startingPoint.current.x, startingPoint.current.z], + endOrigin: snappedLocal, y: startingPoint.current.y, angleLabel: getNearestAxisAngleLabel( [startingPoint.current.x, startingPoint.current.z], @@ -762,6 +682,7 @@ export const WallTool: React.FC = () => { draftPreview.setWallDraftEnd(snappedStart) setAxisGuide({ origin: snappedStart, + endOrigin: null, y: event.localPosition[1], angleLabel: null, }) @@ -847,6 +768,7 @@ export const WallTool: React.FC = () => { buildingState.current = 1 setAxisGuide({ origin: nextStart, + endOrigin: null, y: event.localPosition[1], angleLabel: null, }) @@ -889,7 +811,7 @@ export const WallTool: React.FC = () => { return ( - { ) } -function WallAxisGuides({ - guide, - labelColor, - labelShadowColor, -}: { - guide: DraftAxisGuideState - labelColor: string - labelShadowColor: string -}) { - if (!guide) return null - - const [x, z] = guide.origin - - return ( - <> - - - - - {guide.angleLabel && ( - <> - - - - )} - - ) -} - -function WallAxisGuideLine({ axis }: { axis: 'x' | 'z' }) { - return ( - - - - - ) -} - -function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { - const geometry = useMemo(() => { - const segmentCount = Math.max( - 8, - Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), - ) - - const points = Array.from({ length: segmentCount + 1 }, (_, index) => { - const t = index / segmentCount - const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t - - return new Vector3( - arc.center[0] + Math.cos(angle) * arc.radius, - arc.y, - arc.center[1] + Math.sin(angle) * arc.radius, - ) - }) - - return new BufferGeometry().setFromPoints(points) - }, [arc]) - - return ( - // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. - - - - ) -} - -function DraftMeasurementLabel({ - color, - label, - position, - shadowColor, -}: { - color: string - label: string - position: [number, number, number] - shadowColor: string -}) { - return ( - -
- {label} -
- - ) -} - export default WallTool From fcac55ca30869bb5cffd7f042c29b068928af759 Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Thu, 23 Jul 2026 17:23:01 +0530 Subject: [PATCH 10/11] editor: improve floorplan navigation performance (#535) * Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 * fixed conflict * fix(editor): defer floorplan annotation collision layout * fix(viewer): suspend outlines during camera movement * fix(editor): keep floorplan zoom off the render hot path * fix(editor): keep floorplan pan off render hot path * fix(editor): keep floorplan rotation off render hot path * fix(editor): keep rotation backdrop white * fix(editor): finalize floorplan navigation safely * fix(editor): keep navigation interaction state current * fix(editor): stop floorplan animation before pan --------- Co-authored-by: Claude Opus 4.6 --- .../floorplan-annotation-layout.test.ts | 67 +- .../renderers/floorplan-annotation-layout.ts | 27 +- .../renderers/floorplan-registry-layer.tsx | 197 ++++-- .../editor/camera-dragging-lifecycle.test.ts | 49 ++ .../editor/camera-dragging-lifecycle.ts | 41 ++ .../editor/custom-camera-controls.tsx | 31 +- .../floorplan-navigation-presentation.test.ts | 47 ++ .../floorplan-navigation-presentation.ts | 42 ++ .../src/components/editor/floorplan-panel.tsx | 597 ++++++++++++++---- .../editor/use-floorplan-hit-testing.ts | 16 +- .../src/store/use-floorplan-preflight.test.ts | 49 ++ .../src/store/use-floorplan-preflight.ts | 29 +- .../src/construction-dimension/panel.tsx | 76 ++- .../src/components/viewer/post-processing.tsx | 1 + .../src/lib/merged-outline-node.test.ts | 22 + .../viewer/src/lib/merged-outline-node.ts | 9 +- 16 files changed, 1042 insertions(+), 258 deletions(-) create mode 100644 packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts create mode 100644 packages/editor/src/components/editor/camera-dragging-lifecycle.ts create mode 100644 packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts create mode 100644 packages/editor/src/components/editor/floorplan-navigation-presentation.ts create mode 100644 packages/editor/src/store/use-floorplan-preflight.test.ts create mode 100644 packages/viewer/src/lib/merged-outline-node.test.ts diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts index bc3be802..a1108ee9 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts @@ -339,7 +339,7 @@ describe('observeSvgAnnotationLayoutChanges', () => { const originalRequestAnimationFrame = globalThis.requestAnimationFrame const originalCancelAnimationFrame = globalThis.cancelAnimationFrame let notify: MutationCallback | undefined - let animationFrame: FrameRequestCallback | undefined + let animationFrames: FrameRequestCallback[] = [] let disconnected = false let observedOptions: MutationObserverInit | undefined @@ -363,11 +363,16 @@ describe('observeSvgAnnotationLayoutChanges', () => { globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { - animationFrame = callback - return 1 + animationFrames.push(callback) + return animationFrames.length }) as typeof requestAnimationFrame globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame try { + const flushAnimationFrame = () => { + const callbacks = animationFrames + animationFrames = [] + for (const callback of callbacks) callback(0) + } let layoutPasses = 0 const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { layoutPasses += 1 @@ -376,7 +381,9 @@ describe('observeSvgAnnotationLayoutChanges', () => { notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) expect(layoutPasses).toBe(0) - animationFrame?.(0) + flushAnimationFrame() + expect(layoutPasses).toBe(0) + flushAnimationFrame() expect(layoutPasses).toBe(1) expect(observedOptions).toMatchObject({ attributes: true, @@ -405,4 +412,56 @@ describe('observeSvgAnnotationLayoutChanges', () => { globalThis.cancelAnimationFrame = originalCancelAnimationFrame } }) + + test('waits for a quiet frame instead of resolving on every mutation frame', () => { + const OriginalMutationObserver = globalThis.MutationObserver + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + let notify: MutationCallback | undefined + let animationFrames: FrameRequestCallback[] = [] + + class FakeMutationObserver { + constructor(callback: MutationCallback) { + notify = callback + } + + observe(): void {} + disconnect(): void {} + takeRecords(): MutationRecord[] { + return [] + } + } + + globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + animationFrames.push(callback) + return animationFrames.length + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + try { + const flushAnimationFrame = () => { + const callbacks = animationFrames + animationFrames = [] + for (const callback of callbacks) callback(0) + } + let layoutPasses = 0 + const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { + layoutPasses += 1 + }) + + for (let frame = 0; frame < 30; frame += 1) { + notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) + flushAnimationFrame() + } + + expect(layoutPasses).toBe(0) + flushAnimationFrame() + expect(layoutPasses).toBe(1) + stop() + } finally { + globalThis.MutationObserver = OriginalMutationObserver + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + }) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts index 934a806e..c6ab37ef 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts @@ -227,23 +227,30 @@ export function resolveSvgAnnotationCollisions( return preflightIssues } -export function observeSvgAnnotationLayoutChanges( - svg: SVGSVGElement, - onChange: () => void, -): () => void { +export function observeSvgAnnotationLayoutChanges(target: Node, onChange: () => void): () => void { let scheduledFrame: number | null = null + let mutationVersion = 0 + let observedVersion = 0 + const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) + const flushWhenSettled = () => { + if (observedVersion !== mutationVersion) { + observedVersion = mutationVersion + scheduledFrame = requestFrame(flushWhenSettled) + return + } + scheduledFrame = null + onChange() + } const schedule = () => { + mutationVersion += 1 if (scheduledFrame !== null) return - const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) - scheduledFrame = requestFrame(() => { - scheduledFrame = null - onChange() - }) + observedVersion = mutationVersion - 1 + scheduledFrame = requestFrame(flushWhenSettled) } const observer = new MutationObserver((mutations) => { if (mutations.some(isAnnotationLayoutMutation)) schedule() }) - observer.observe(svg, { + observer.observe(target, { attributes: true, attributeFilter: [ 'cx', diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 3959bb0f..0bb25899 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -66,6 +66,7 @@ import { curveReshapeScope, endpointReshapeScope, holeEditScope, + isIdle, tangentReshapeScope, } from '../../../lib/interaction/scope' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -1456,18 +1457,20 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const markerRef = useRef(null) const [layoutEpoch, setLayoutEpoch] = useState(0) + const interactionIdle = useInteractionScope((state) => isIdle(state.scope)) const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride) const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues) const resetPreflightIssues = useFloorplanPreflight((state) => state.reset) + const layoutEnabled = active && interactionIdle useLayoutEffect(() => { - if (!active) return - const svg = markerRef.current?.ownerSVGElement - if (!svg) return - return observeSvgAnnotationLayoutChanges(svg, () => { + if (!layoutEnabled) return + const registryLayer = markerRef.current?.parentElement + if (!registryLayer) return + return observeSvgAnnotationLayoutChanges(registryLayer, () => { setLayoutEpoch((epoch) => epoch + 1) }) - }, [active]) + }, [layoutEnabled]) useLayoutEffect(() => { // The epoch is only a trigger; collision inputs are measured from the live SVG below. void layoutEpoch @@ -1475,89 +1478,149 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { resetPreflightIssues() return } + if (!interactionIdle) return const svg = markerRef.current?.ownerSVGElement - if (!svg) return + const registryLayer = markerRef.current?.parentElement + if (!(svg && registryLayer)) return const preflightIssues = resolveSvgAnnotationCollisions(svg, { layoutOverrides: annotationLayoutOverrides, }) setPreflightIssues(preflightIssues) const labels = Array.from( - svg.querySelectorAll('[data-floorplan-annotation-label]'), + registryLayer.querySelectorAll('[data-floorplan-annotation-label]'), ) - const cleanup: Array<() => void> = [] for (const [index, label] of labels.entries()) { const id = svgAnnotationLabelId(label, index) label.dataset.floorplanAnnotationId = id label.style.pointerEvents = 'all' label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' - - const onPointerDown = (event: PointerEvent) => { - if (event.button !== 0) return - event.preventDefault() - event.stopPropagation() - label.style.cursor = 'grabbing' - const matrix = label.getScreenCTM() - if (!matrix) return - const start = { x: event.clientX, y: event.clientY } - const existing = annotationLayoutOverrides[id] ?? { - ...readFloorplanAnnotationLayoutOffset(label), - pinned: true, - } - let latest = existing - let moved = false - const onPointerMove = (moveEvent: PointerEvent) => { - moved = true - const local = screenVectorToFloorplanAnnotationLocal( - matrix, - moveEvent.clientX - start.x, - moveEvent.clientY - start.y, - ) - latest = { - dx: existing.dx + local.x, - dy: existing.dy + local.y, - pinned: true, - } - const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' - label.setAttribute( - 'transform', - `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(), - ) - } - const onPointerUp = () => { - label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' - window.removeEventListener('pointermove', onPointerMove) - window.removeEventListener('pointerup', onPointerUp) - if (moved) setAnnotationLayoutOverride(id, latest) - } - window.addEventListener('pointermove', onPointerMove) - window.addEventListener('pointerup', onPointerUp) - } - const onDoubleClick = (event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - setAnnotationLayoutOverride(id, null) - } - label.addEventListener('pointerdown', onPointerDown) - label.addEventListener('dblclick', onDoubleClick) - cleanup.push(() => { - label.removeEventListener('pointerdown', onPointerDown) - label.removeEventListener('dblclick', onDoubleClick) - label.style.pointerEvents = '' - label.style.cursor = '' - }) - } - return () => { - for (const fn of cleanup) fn() } }, [ active, annotationLayoutOverrides, + interactionIdle, layoutEpoch, resetPreflightIssues, - setAnnotationLayoutOverride, setPreflightIssues, ]) + + useEffect(() => { + if (!layoutEnabled) return + const registryLayer = markerRef.current?.parentElement + if (!registryLayer) return + let cleanupPointerDrag: (() => void) | null = null + let cancelActivePointerDrag: (() => void) | null = null + + const findLabel = (target: EventTarget | null): SVGGElement | null => { + if (!(target instanceof Element)) return null + const label = target.closest('[data-floorplan-annotation-label]') + return label && registryLayer.contains(label) ? label : null + } + + const labelId = (label: SVGGElement): string => { + const labels = Array.from( + registryLayer.querySelectorAll('[data-floorplan-annotation-label]'), + ) + return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label))) + } + + const onPointerDown = (event: PointerEvent) => { + const label = findLabel(event.target) + if (!(label && event.button === 0)) return + const matrix = label.getScreenCTM() + if (!matrix) return + event.preventDefault() + event.stopPropagation() + cancelActivePointerDrag?.() + label.style.cursor = 'grabbing' + const id = labelId(label) + const start = { x: event.clientX, y: event.clientY } + const existing = useDrawingView.getState().annotationLayoutOverrides[id] ?? { + ...readFloorplanAnnotationLayoutOffset(label), + pinned: true, + } + const wasPinned = useDrawingView.getState().annotationLayoutOverrides[id]?.pinned === true + let latest = existing + let moved = false + + const onPointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== event.pointerId) return + moved = true + const local = screenVectorToFloorplanAnnotationLocal( + matrix, + moveEvent.clientX - start.x, + moveEvent.clientY - start.y, + ) + latest = { + dx: existing.dx + local.x, + dy: existing.dy + local.y, + pinned: true, + } + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute( + 'transform', + `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(), + ) + } + + const finishPointerDrag = (endEvent: PointerEvent) => { + if (endEvent.pointerId !== event.pointerId) return + cleanupPointerDrag?.() + label.style.cursor = moved || wasPinned ? 'grab' : 'move' + if (moved) setAnnotationLayoutOverride(id, latest) + } + + const cancelPointerDrag = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId !== event.pointerId) return + cancelActivePointerDrag?.() + } + + cancelActivePointerDrag = () => { + cleanupPointerDrag?.() + label.style.cursor = wasPinned ? 'grab' : 'move' + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute( + 'transform', + `${defaultTransform} translate(${existing.dx} ${existing.dy})`.trim(), + ) + } + + cleanupPointerDrag = () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', finishPointerDrag) + window.removeEventListener('pointercancel', cancelPointerDrag) + cleanupPointerDrag = null + cancelActivePointerDrag = null + } + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', finishPointerDrag) + window.addEventListener('pointercancel', cancelPointerDrag) + } + + const onDoubleClick = (event: MouseEvent) => { + const label = findLabel(event.target) + if (!label) return + event.preventDefault() + event.stopPropagation() + label.style.cursor = 'move' + setAnnotationLayoutOverride(labelId(label), null) + } + + registryLayer.addEventListener('pointerdown', onPointerDown) + registryLayer.addEventListener('dblclick', onDoubleClick) + return () => { + cancelActivePointerDrag?.() + registryLayer.removeEventListener('pointerdown', onPointerDown) + registryLayer.removeEventListener('dblclick', onDoubleClick) + for (const label of registryLayer.querySelectorAll( + '[data-floorplan-annotation-label]', + )) { + label.style.pointerEvents = '' + label.style.cursor = '' + } + } + }, [layoutEnabled, setAnnotationLayoutOverride]) return } diff --git a/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts b/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts new file mode 100644 index 00000000..da60a433 --- /dev/null +++ b/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle' + +describe('camera dragging lifecycle', () => { + test('releases wheel interactions even when camera controls never report rest', () => { + const dragging: boolean[] = [] + let scheduled: (() => void) | null = null + const lifecycle = createCameraDraggingLifecycle({ + setDragging: (value) => dragging.push(value), + schedule: (callback) => { + scheduled = callback + return 1 as unknown as ReturnType + }, + cancel: () => { + scheduled = null + }, + }) + + lifecycle.begin() + lifecycle.scheduleEnd() + expect(dragging).toEqual([true]) + + const release = scheduled as (() => void) | null + release?.() + expect(dragging).toEqual([true, false]) + }) + + test('cancels a pending wheel release when another interaction begins', () => { + const dragging: boolean[] = [] + let scheduled: (() => void) | null = null + const lifecycle = createCameraDraggingLifecycle({ + setDragging: (value) => dragging.push(value), + schedule: (callback) => { + scheduled = callback + return 1 as unknown as ReturnType + }, + cancel: () => { + scheduled = null + }, + }) + + lifecycle.begin() + lifecycle.scheduleEnd() + lifecycle.begin() + + expect(scheduled).toBeNull() + expect(dragging).toEqual([true, true]) + }) +}) diff --git a/packages/editor/src/components/editor/camera-dragging-lifecycle.ts b/packages/editor/src/components/editor/camera-dragging-lifecycle.ts new file mode 100644 index 00000000..1eb371fa --- /dev/null +++ b/packages/editor/src/components/editor/camera-dragging-lifecycle.ts @@ -0,0 +1,41 @@ +type TimerHandle = ReturnType + +export function createCameraDraggingLifecycle({ + setDragging, + fallbackMs = 500, + schedule = globalThis.setTimeout, + cancel = globalThis.clearTimeout, +}: { + setDragging: (dragging: boolean) => void + fallbackMs?: number + schedule?: (callback: () => void, delay: number) => TimerHandle + cancel?: (timer: TimerHandle) => void +}) { + let releaseTimer: TimerHandle | null = null + + const clearScheduledEnd = () => { + if (releaseTimer === null) return + cancel(releaseTimer) + releaseTimer = null + } + + const begin = () => { + clearScheduledEnd() + setDragging(true) + } + + const end = () => { + clearScheduledEnd() + setDragging(false) + } + + const scheduleEnd = () => { + clearScheduledEnd() + releaseTimer = schedule(() => { + releaseTimer = null + setDragging(false) + }, fallbackMs) + } + + return { begin, end, scheduleEnd } +} diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 1772e199..d415fb97 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -35,6 +35,7 @@ import { useEndpointReshape, useMovingNode, } from '../../store/use-interaction-scope' +import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle' const currentTarget = new Vector3() const tempBox = new Box3() @@ -166,6 +167,10 @@ function isKeyboardPanKey(code: string): boolean { return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' } +function hasKeyboardPanInput(state: KeyboardPanState): boolean { + return state.forward || state.backward || state.left || state.right +} + type CameraViewportSize = { width: number height: number @@ -420,6 +425,14 @@ export const CustomCameraControls = () => { const gl = useThree((state) => state.gl) const raycaster = useThree((state) => state.raycaster) const viewportSize = useThree((state) => state.size) + const cameraDraggingLifecycle = useMemo( + () => + createCameraDraggingLifecycle({ + setDragging: (dragging) => useViewer.getState().setCameraDragging(dragging), + }), + [], + ) + useEffect(() => () => cameraDraggingLifecycle.end(), [cameraDraggingLifecycle]) useEffect(() => { camera.layers.enable(EDITOR_LAYER) camera.layers.enable(GRID_LAYER) @@ -446,8 +459,9 @@ export const CustomCameraControls = () => { const beginLocalCameraInteraction = useCallback(() => { cancelPoseApplication() + cameraDraggingLifecycle.begin() emitter.emit('camera-controls:interaction-start', undefined) - }, [cancelPoseApplication]) + }, [cameraDraggingLifecycle, cancelPoseApplication]) const applyPendingPose = useCallback(() => { if (isFirstPersonMode) { @@ -1007,6 +1021,9 @@ export const CustomCameraControls = () => { if (isKeyboardPanKey(event.code)) { const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false) if (changed) { + if (!hasKeyboardPanInput(keyboardPanKeys.current)) { + cameraDraggingLifecycle.end() + } event.preventDefault() event.stopPropagation() } @@ -1048,6 +1065,7 @@ export const CustomCameraControls = () => { const onWheel = () => { beginLocalCameraInteraction() + cameraDraggingLifecycle.scheduleEnd() clearPendingFloorplanNavigationPose() } @@ -1067,6 +1085,7 @@ export const CustomCameraControls = () => { panPointerId = null panPointerButton = null clearNavigationCursor() + cameraDraggingLifecycle.end() updateConfig() } @@ -1089,9 +1108,11 @@ export const CustomCameraControls = () => { gl.domElement.removeEventListener('wheel', onWheel, true) clearKeyboardPanKeys() clearNavigationCursor() + cameraDraggingLifecycle.end() } }, [ beginLocalCameraInteraction, + cameraDraggingLifecycle, cameraMode, gl, isPreviewMode, @@ -1407,12 +1428,12 @@ export const CustomCameraControls = () => { }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode]) const onTransitionStart = useCallback(() => { - useViewer.getState().setCameraDragging(true) - }, []) + cameraDraggingLifecycle.begin() + }, [cameraDraggingLifecycle]) const onRest = useCallback(() => { - useViewer.getState().setCameraDragging(false) - }, []) + cameraDraggingLifecycle.end() + }, [cameraDraggingLifecycle]) // Preset capture mode frames a single subtree (often a 0.3–2m preset), // so the default 2m minDistance prevents the user from getting close diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts new file mode 100644 index 00000000..b114d9ea --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' +import { + canApplyFloorplanNavigationSync, + canZoomFloorplanDuringNavigation, + finalizeFloorplanNavigation, + resolveFloorplanPresentationViewBox, +} from './floorplan-navigation-presentation' + +describe('floorplan navigation presentation', () => { + test('keeps the imperative viewBox authoritative during navigation', () => { + const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 } + const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 } + + expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, true)).toBe( + imperativeViewBox, + ) + expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, false)).toBe( + reactViewBox, + ) + }) + + test('does not mix wheel zoom with a compositor rotation preview', () => { + expect(canZoomFloorplanDuringNavigation(true)).toBe(false) + expect(canZoomFloorplanDuringNavigation(false)).toBe(true) + }) + + test('does not apply synchronized camera poses over local navigation', () => { + expect(canApplyFloorplanNavigationSync(true)).toBe(false) + expect(canApplyFloorplanNavigationSync(false)).toBe(true) + }) + + test('commits every active navigation channel before teardown', () => { + const calls: string[] = [] + const rotationState = { angle: 42 } + + finalizeFloorplanNavigation({ + zoomPending: true, + panActive: true, + rotationState, + commitZoom: () => calls.push('zoom'), + commitPan: () => calls.push('pan'), + commitRotation: (state) => calls.push(`rotation:${state.angle}`), + }) + + expect(calls).toEqual(['zoom', 'pan', 'rotation:42']) + }) +}) diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts new file mode 100644 index 00000000..30295809 --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts @@ -0,0 +1,42 @@ +export type FloorplanPresentationViewBox = { + minX: number + minY: number + width: number + height: number +} + +export function resolveFloorplanPresentationViewBox( + reactViewBox: FloorplanPresentationViewBox, + imperativeViewBox: FloorplanPresentationViewBox | null, + interactionInProgress: boolean, +): FloorplanPresentationViewBox { + return interactionInProgress && imperativeViewBox ? imperativeViewBox : reactViewBox +} + +export function canZoomFloorplanDuringNavigation(rotationInProgress: boolean): boolean { + return !rotationInProgress +} + +export function canApplyFloorplanNavigationSync(interactionInProgress: boolean): boolean { + return !interactionInProgress +} + +export function finalizeFloorplanNavigation({ + zoomPending, + panActive, + rotationState, + commitZoom, + commitPan, + commitRotation, +}: { + zoomPending: boolean + panActive: boolean + rotationState: RotationState | null + commitZoom: () => void + commitPan: () => void + commitRotation: (rotationState: RotationState) => void +}): void { + if (zoomPending) commitZoom() + if (panActive) commitPan() + if (rotationState) commitRotation(rotationState) +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d26ce3ca..fae830cc 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -191,6 +191,13 @@ import { import { PALETTE_COLORS } from '../ui/primitives/color-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' +import { + canApplyFloorplanNavigationSync, + canZoomFloorplanDuringNavigation, + type FloorplanPresentationViewBox, + finalizeFloorplanNavigation, + resolveFloorplanPresentationViewBox, +} from './floorplan-navigation-presentation' import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' import { useFloorplanHitTesting } from './use-floorplan-hit-testing' import { useFloorplanSceneData } from './use-floorplan-scene-data' @@ -320,6 +327,20 @@ type FloorplanRotationState = { startClientX: number initialUserRotationDeg: number viewportCenterLocal: SvgPoint + svg: SVGSVGElement + svgStyle: { + transform: string + transformOrigin: string + willChange: string + } + latestUserRotationDeg: number + latestViewport: FloorplanViewport +} + +function restoreFloorplanRotationPresentation(rotationState: FloorplanRotationState) { + rotationState.svg.style.transform = rotationState.svgStyle.transform + rotationState.svg.style.transformOrigin = rotationState.svgStyle.transformOrigin + rotationState.svg.style.willChange = rotationState.svgStyle.willChange } type FloorplanScreenSelectionState = { @@ -5287,6 +5308,7 @@ export function FloorplanPanel({ }) { const viewportHostRef = useRef(null) const svgRef = useRef(null) + const floorplanBackgroundRef = useRef(null) const floorplanSceneRef = useRef(null) const floorplanContentRef = useRef(null) const panStateRef = useRef(null) @@ -5314,6 +5336,21 @@ export function FloorplanPanel({ const latestFittedViewportRef = useRef(null) const floorplanViewAnimationFrameRef = useRef(null) const floorplanViewAnimationTargetRef = useRef(null) + const floorplanZoomCommitTimerRef = useRef(null) + const floorplanRenderScaleCommitTimerRef = useRef(null) + const floorplanViewportInteractionInProgressRef = useRef(false) + const floorplanImperativeViewBoxRef = useRef(null) + const latestFloorplanRenderUnitsPerPixelRef = useRef(1) + const floorplanZoomPoseRef = useRef<{ + localCenter: SvgPoint + userRotationDeg: number + viewWidth: number + } | null>(null) + const floorplanPanPoseRef = useRef<{ + localCenter: SvgPoint + userRotationDeg: number + viewWidth: number + } | null>(null) const latestNavigationSyncPoseRef = useRef( useEditor.getState().navigationSyncPose, ) @@ -5421,7 +5458,7 @@ export function FloorplanPanel({ FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg // Only sync ref from state when floorplan is open (state is source of truth). // When hidden, the imperative 3D path owns the ref and must not be clobbered. - if (isFloorplanOpenRef.current) { + if (isFloorplanOpenRef.current && !floorplanViewportInteractionInProgressRef.current) { latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg } @@ -5630,9 +5667,14 @@ export function FloorplanPanel({ const [isPanelReady, setIsPanelReady] = useState(false) const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 }) const [viewport, setViewport] = useState(null) - latestViewportRef.current = viewport + const [floorplanRenderUnitsPerPixel, setFloorplanRenderUnitsPerPixel] = useState( + null, + ) + if (!floorplanViewportInteractionInProgressRef.current) { + latestViewportRef.current = viewport + } // Tight bbox of the painted floor-plan scene (the rotation ``'s - // children), read via SVG `getBBox()` after each render. The legacy + // children), read via SVG `getBBox()` after content changes settle. The legacy // polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now // empty stubs because rendering moved to the registry layer, so // measuring the DOM is how `fittedViewport` learns where content lives. @@ -6610,42 +6652,71 @@ export function FloorplanPanel({ ]) latestFittedViewportRef.current = fittedViewport - // Measure the painted floor-plan scene after each render. `getBBox()` - // gives us the tight bounds of whatever the registry layer emitted, - // even for kinds whose legacy entry arrays are empty stubs. Bail out - // when nothing has painted (empty group throws in some browsers). - // We measure the content-only sub-group (not the full scene group) to - // exclude the grid layer, whose extent tracks the viewBox and would - // otherwise create a measure→fit→measure update loop. + // Measure the content-only subtree after its geometry settles. ViewBox-only + // navigation does not change these bounds and must not force `getBBox()` on + // every animation frame. + // biome-ignore lint/correctness/useExhaustiveDependencies: visibility remounts the observed SVG subtree. useLayoutEffect(() => { const el = floorplanContentRef.current if (!el) return - let bbox: { x: number; y: number; width: number; height: number } - try { - const measured = el.getBBox() - bbox = { - x: measured.x, - y: measured.y, - width: measured.width, - height: measured.height, + let scheduledFrame: number | null = null + let mutationVersion = 0 + let observedVersion = 0 + const measure = () => { + let bbox: { x: number; y: number; width: number; height: number } + try { + const measured = el.getBBox() + bbox = { + x: measured.x, + y: measured.y, + width: measured.width, + height: measured.height, + } + } catch { + return } - } catch { - return + if (bbox.width <= 0 && bbox.height <= 0) return + setMeasuredSceneBBox((prev) => { + if ( + prev && + prev.x === bbox.x && + prev.y === bbox.y && + prev.width === bbox.width && + prev.height === bbox.height + ) { + return prev + } + return bbox + }) } - if (bbox.width <= 0 && bbox.height <= 0) return - setMeasuredSceneBBox((prev) => { - if ( - prev && - prev.x === bbox.x && - prev.y === bbox.y && - prev.width === bbox.width && - prev.height === bbox.height - ) { - return prev + const flushWhenSettled = () => { + if (observedVersion !== mutationVersion) { + observedVersion = mutationVersion + scheduledFrame = requestAnimationFrame(flushWhenSettled) + return } - return bbox + scheduledFrame = null + measure() + } + const observer = new MutationObserver(() => { + mutationVersion += 1 + if (scheduledFrame !== null) return + observedVersion = mutationVersion - 1 + scheduledFrame = requestAnimationFrame(flushWhenSettled) }) - }) + + measure() + observer.observe(el, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) + return () => { + observer.disconnect() + if (scheduledFrame !== null) cancelAnimationFrame(scheduledFrame) + } + }, [isFloorplanOpen]) const applyFloorplanNavigationState = useCallback( (nextViewport: FloorplanViewport, userRotationDeg: number) => { @@ -6798,7 +6869,7 @@ export function FloorplanPanel({ if (!isFloorplanOpenRef.current) { return } - if (floorplanRotationStateRef.current) { + if (!canApplyFloorplanNavigationSync(floorplanViewportInteractionInProgressRef.current)) { return } @@ -6977,35 +7048,6 @@ export function FloorplanPanel({ } }, []) - // Reset to auto-fit each time the 2D editor re-opens. The panel stays - // mounted across close/open (hidden via `display: none`), so without - // this the user's last pan/zoom — and any stale `measuredSceneBBox` - // captured before they closed it — would survive and the reopened - // editor would show the same off-screen viewport instead of fitting - // to the current scene. - useEffect(() => { - if (!isFloorplanOpen) { - stopFloorplanViewAnimation() - floorplanSpacePanPressedRef.current = false - panStateRef.current = null - floorplanRotationStateRef.current = null - setIsSpacePanPressed(false) - setIsPanning(false) - setIsRotatingFloorplan(false) - return - } - setMeasuredSceneBBox(null) - - if (!latestNavigationSyncPoseRef.current) { - stopFloorplanViewAnimation() - hasUserAdjustedViewportRef.current = false - latestFloorplanUserRotationDegRef.current = 0 - latestViewportRef.current = null - setFloorplanUserRotationDeg(0) - setViewport(null) - } - }, [isFloorplanOpen, stopFloorplanViewAnimation]) - useEffect(() => { const levelChanged = previousLevelIdRef.current !== (levelId ?? null) @@ -7064,19 +7106,31 @@ export function FloorplanPanel({ height, } }, [fittedViewport, svgAspectRatio, viewport]) + const presentationViewBox = resolveFloorplanPresentationViewBox( + viewBox, + floorplanImperativeViewBoxRef.current, + floorplanViewportInteractionInProgressRef.current, + ) const floorplanWorldUnitsPerPixel = useMemo(() => { const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1) return (widthUnitsPerPixel + heightUnitsPerPixel) / 2 }, [surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width]) - const floorplanWallHitTolerance = useMemo( - () => floorplanWorldUnitsPerPixel * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2), - [floorplanWorldUnitsPerPixel], + const getLiveFloorplanWorldUnitsPerPixel = useCallback(() => { + const width = latestViewportRef.current?.width ?? viewBox.width + const height = width / svgAspectRatio + const widthUnitsPerPixel = width / Math.max(surfaceSize.width, 1) + const heightUnitsPerPixel = height / Math.max(surfaceSize.height, 1) + return (widthUnitsPerPixel + heightUnitsPerPixel) / 2 + }, [surfaceSize.height, surfaceSize.width, svgAspectRatio, viewBox.width]) + const getFloorplanWallHitTolerance = useCallback( + () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2), + [getLiveFloorplanWorldUnitsPerPixel], ) - const floorplanOpeningHitTolerance = useMemo( - () => floorplanWorldUnitsPerPixel * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2), - [floorplanWorldUnitsPerPixel], + const getFloorplanOpeningHitTolerance = useCallback( + () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2), + [getLiveFloorplanWorldUnitsPerPixel], ) const wallSelectionHatchSpacing = useMemo( () => Math.max(floorplanWorldUnitsPerPixel * 12, 0.0001), @@ -7361,7 +7415,9 @@ export function FloorplanPanel({ ), [gridBounds, gridSteps.majorStep], ) - const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + const liveFloorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + const floorplanUnitsPerPixel = floorplanRenderUnitsPerPixel ?? liveFloorplanUnitsPerPixel + latestFloorplanRenderUnitsPerPixelRef.current = floorplanUnitsPerPixel useEffect(() => { setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') @@ -7889,8 +7945,104 @@ export function FloorplanPanel({ [beginPanelInteraction, panelRect], ) + const applyFloorplanViewportImperatively = useCallback( + (nextViewport: FloorplanViewport) => { + const nextHeight = nextViewport.width / svgAspectRatio + const nextMinX = nextViewport.centerX - nextViewport.width / 2 + const nextMinY = nextViewport.centerY - nextHeight / 2 + floorplanImperativeViewBoxRef.current = { + minX: nextMinX, + minY: nextMinY, + width: nextViewport.width, + height: nextHeight, + } + hasUserAdjustedViewportRef.current = true + latestViewportRef.current = nextViewport + svgRef.current?.setAttribute( + 'viewBox', + `${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`, + ) + const background = floorplanBackgroundRef.current + if (background) { + background.setAttribute('x', String(nextMinX)) + background.setAttribute('y', String(nextMinY)) + background.setAttribute('width', String(nextViewport.width)) + background.setAttribute('height', String(nextHeight)) + } + }, + [svgAspectRatio], + ) + + const applyFloorplanRotationImperatively = useCallback( + (rotationState: FloorplanRotationState, nextUserRotationDeg: number) => { + const currentViewport = latestViewportRef.current ?? rotationState.latestViewport + const nextSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg + const nextCenterSvg = rotateSvgPoint(rotationState.viewportCenterLocal, nextSceneRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: currentViewport.width, + } + + hasUserAdjustedViewportRef.current = true + latestFloorplanUserRotationDegRef.current = nextUserRotationDeg + latestViewportRef.current = nextViewport + // Transform the already-painted SVG as one compositor layer. Mutating the + // scene rotation/viewBox here forces the heavy vector plan to rerasterize. + rotationState.svg.style.transform = `rotate(${nextUserRotationDeg - rotationState.initialUserRotationDeg}deg)` + + rotationState.latestUserRotationDeg = nextUserRotationDeg + rotationState.latestViewport = nextViewport + }, + [buildingRotationDeg], + ) + + const commitFloorplanZoom = useCallback(() => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + floorplanZoomCommitTimerRef.current = null + } + const nextViewport = latestViewportRef.current + const pendingPose = floorplanZoomPoseRef.current + floorplanZoomPoseRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + if (!nextViewport) return + setFloorplanRenderUnitsPerPixel( + (current) => current ?? latestFloorplanRenderUnitsPerPixelRef.current, + ) + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + } + floorplanRenderScaleCommitTimerRef.current = window.setTimeout(() => { + floorplanRenderScaleCommitTimerRef.current = null + setFloorplanRenderUnitsPerPixel(null) + }, 350) + if (pendingPose) { + publishFloorplanNavigationPose( + pendingPose.localCenter, + pendingPose.userRotationDeg, + pendingPose.viewWidth, + ) + } + }, [publishFloorplanNavigationPose]) + + const scheduleFloorplanZoomCommit = useCallback(() => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + } + floorplanZoomCommitTimerRef.current = window.setTimeout(commitFloorplanZoom, 300) + }, [commitFloorplanZoom]) + const zoomViewportAtClientPoint = useCallback( (clientX: number, clientY: number, widthFactor: number) => { + if (!canZoomFloorplanDuringNavigation(floorplanRotationStateRef.current !== null)) { + return + } if (!Number.isFinite(widthFactor) || widthFactor <= 0) { return } @@ -7906,12 +8058,21 @@ export function FloorplanPanel({ } const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg) - const currentViewport = viewport ?? fittedViewport - const currentViewBox = viewBox + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) { + return + } + const currentViewBox = { + minX: currentViewport.centerX - currentViewport.width / 2, + minY: currentViewport.centerY - currentViewport.width / svgAspectRatio / 2, + width: currentViewport.width, + height: currentViewport.width / svgAspectRatio, + } + const fitted = latestFittedViewportRef.current const nextWidth = resolveFloorplanViewWidth( currentViewport.width * widthFactor, currentViewport.width, - fittedViewport, + fitted, true, ) const nextHeight = nextWidth / svgAspectRatio @@ -7925,30 +8086,140 @@ export function FloorplanPanel({ y: nextMinY + nextHeight / 2, } const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: nextWidth, + } - smoothFloorplanNavigationView( - localCenter, - latestFloorplanUserRotationDegRef.current, - nextWidth, - ) - publishFloorplanNavigationPose( - localCenter, - latestFloorplanUserRotationDegRef.current, - nextWidth, - ) + stopFloorplanViewAnimation() + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + floorplanRenderScaleCommitTimerRef.current = null + } + floorplanViewportInteractionInProgressRef.current = true + applyFloorplanViewportImperatively(nextViewport) + scheduleFloorplanZoomCommit() + const userRotationDeg = latestFloorplanUserRotationDegRef.current + floorplanZoomPoseRef.current = { localCenter, userRotationDeg, viewWidth: nextWidth } + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose(localCenter, userRotationDeg, nextWidth) + } }, [ - fittedViewport, + applyFloorplanViewportImperatively, floorplanSceneRotationDeg, getSvgPointFromClientPoint, publishFloorplanNavigationPose, - smoothFloorplanNavigationView, + scheduleFloorplanZoomCommit, + stopFloorplanViewAnimation, svgAspectRatio, - viewBox, - viewport, ], ) + useEffect( + () => () => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + } + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + } + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + }, + [], + ) + + const commitFloorplanPan = useCallback(() => { + const nextViewport = latestViewportRef.current + const pendingPose = floorplanPanPoseRef.current + floorplanPanPoseRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + if (nextViewport) { + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + } + if (pendingPose) { + publishFloorplanNavigationPose( + pendingPose.localCenter, + pendingPose.userRotationDeg, + pendingPose.viewWidth, + ) + } + }, [publishFloorplanNavigationPose]) + + const commitFloorplanRotation = useCallback( + (rotationState: FloorplanRotationState) => { + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + restoreFloorplanRotationPresentation(rotationState) + setFloorplanUserRotationDeg((current) => + current === rotationState.latestUserRotationDeg + ? current + : rotationState.latestUserRotationDeg, + ) + setViewport((current) => + floorplanViewportEquals(current, rotationState.latestViewport) + ? current + : rotationState.latestViewport, + ) + publishFloorplanNavigationPose( + rotationState.viewportCenterLocal, + rotationState.latestUserRotationDeg, + rotationState.latestViewport.width, + ) + }, + [publishFloorplanNavigationPose], + ) + + // Finalize imperative navigation when the floorplan closes so reopening + // restores the last visible pose instead of stale React state. + useEffect(() => { + if (isFloorplanOpen) return + stopFloorplanViewAnimation() + const rotationState = floorplanRotationStateRef.current + finalizeFloorplanNavigation({ + zoomPending: + floorplanZoomCommitTimerRef.current !== null || floorplanZoomPoseRef.current !== null, + panActive: panStateRef.current !== null, + rotationState, + commitZoom: commitFloorplanZoom, + commitPan: commitFloorplanPan, + commitRotation: commitFloorplanRotation, + }) + floorplanSpacePanPressedRef.current = false + panStateRef.current = null + floorplanRotationStateRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + setIsSpacePanPressed(false) + setIsPanning(false) + setIsRotatingFloorplan(false) + }, [ + commitFloorplanPan, + commitFloorplanRotation, + commitFloorplanZoom, + isFloorplanOpen, + stopFloorplanViewAnimation, + ]) + + useEffect(() => { + if (!isFloorplanOpen) return + setMeasuredSceneBBox(null) + + if (!latestNavigationSyncPoseRef.current) { + stopFloorplanViewAnimation() + hasUserAdjustedViewportRef.current = false + latestFloorplanUserRotationDegRef.current = 0 + latestViewportRef.current = null + setFloorplanUserRotationDeg(0) + setViewport(null) + } + }, [isFloorplanOpen, stopFloorplanViewAnimation]) + const clearWallPlacementDraft = useCallback(() => { setDraftStart(null) setWallChainFirstVertex(null) @@ -8906,8 +9177,12 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() + if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom() + stopFloorplanViewAnimation() floorplanNavigationClickSuppressedRef.current = true - const currentViewport = viewport ?? fittedViewport + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) return + floorplanViewportInteractionInProgressRef.current = true panStateRef.current = { pointerId: event.pointerId, clientX: event.clientX, @@ -8932,17 +9207,35 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() - const currentViewport = viewport ?? fittedViewport + if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom() + stopFloorplanViewAnimation() + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + const svg = svgRef.current + if (!(currentViewport && svg)) return + const currentUserRotationDeg = latestFloorplanUserRotationDegRef.current + const currentSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg const viewportCenterLocal = rotateSvgPoint( { x: currentViewport.centerX, y: currentViewport.centerY }, - -floorplanSceneRotationDeg, + -currentSceneRotationDeg, ) - + const svgStyle = { + transform: svg.style.transform, + transformOrigin: svg.style.transformOrigin, + willChange: svg.style.willChange, + } + floorplanViewportInteractionInProgressRef.current = true + svg.style.transformOrigin = 'center' + svg.style.willChange = 'transform' floorplanRotationStateRef.current = { pointerId: event.pointerId, startClientX: event.clientX, - initialUserRotationDeg: floorplanUserRotationDeg, + initialUserRotationDeg: currentUserRotationDeg, viewportCenterLocal, + svg, + svgStyle, + latestUserRotationDeg: currentUserRotationDeg, + latestViewport: currentViewport, } setIsRotatingFloorplan(true) setCursorPoint(null) @@ -8951,12 +9244,11 @@ export function FloorplanPanel({ event.currentTarget.setPointerCapture(event.pointerId) }, [ - fittedViewport, - floorplanSceneRotationDeg, - floorplanUserRotationDeg, - viewport, + commitFloorplanZoom, + buildingRotationDeg, setFloorplanCursorPosition, setCursorPoint, + stopFloorplanViewAnimation, ], ) @@ -8996,24 +9288,31 @@ export function FloorplanPanel({ [isScreenSelectionToolActive, setPreviewSelectedIds], ) - const endFloorplanNavigation = useCallback((event?: ReactPointerEvent) => { - if ( - event && - (panStateRef.current || floorplanRotationStateRef.current) && - event.currentTarget.hasPointerCapture(event.pointerId) - ) { - event.currentTarget.releasePointerCapture(event.pointerId) - } + const endFloorplanNavigation = useCallback( + (event?: ReactPointerEvent) => { + const wasPanning = panStateRef.current !== null + const rotationState = floorplanRotationStateRef.current + if ( + event && + (panStateRef.current || floorplanRotationStateRef.current) && + event.currentTarget.hasPointerCapture(event.pointerId) + ) { + event.currentTarget.releasePointerCapture(event.pointerId) + } - panStateRef.current = null - floorplanRotationStateRef.current = null - setIsPanning(false) - setIsRotatingFloorplan(false) + panStateRef.current = null + floorplanRotationStateRef.current = null + if (wasPanning) commitFloorplanPan() + if (rotationState) commitFloorplanRotation(rotationState) + setIsPanning(false) + setIsRotatingFloorplan(false) - window.setTimeout(() => { - floorplanNavigationClickSuppressedRef.current = false - }, 0) - }, []) + window.setTimeout(() => { + floorplanNavigationClickSuppressedRef.current = false + }, 0) + }, + [commitFloorplanPan, commitFloorplanRotation], + ) const hoveredWallIdRef = useRef(null) const hoveredCeilingIdRef = useRef(null) @@ -9213,9 +9512,14 @@ export function FloorplanPanel({ (rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg - smoothFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg) - publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg) - setCursorPoint(null) + applyFloorplanRotationImperatively(rotationState, nextUserRotationDeg) + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose( + rotationState.viewportCenterLocal, + nextUserRotationDeg, + rotationState.latestViewport.width, + ) + } return } @@ -9225,8 +9529,11 @@ export function FloorplanPanel({ const deltaX = event.clientX - panStateRef.current.clientX const deltaY = event.clientY - panStateRef.current.clientY - const worldPerPixelX = viewBox.width / surfaceSize.width - const worldPerPixelY = viewBox.height / surfaceSize.height + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) return + const currentHeight = currentViewport.width / svgAspectRatio + const worldPerPixelX = currentViewport.width / surfaceSize.width + const worldPerPixelY = currentHeight / surfaceSize.height const nextCenterSvg = { x: panStateRef.current.centerSvg.x - deltaX * worldPerPixelX, @@ -9237,8 +9544,20 @@ export function FloorplanPanel({ FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg) - smoothFloorplanNavigationView(localCenter, currentUserRotationDeg) - publishFloorplanNavigationPose(localCenter, currentUserRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: currentViewport.width, + } + applyFloorplanViewportImperatively(nextViewport) + floorplanPanPoseRef.current = { + localCenter, + userRotationDeg: currentUserRotationDeg, + viewWidth: currentViewport.width, + } + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose(localCenter, currentUserRotationDeg, currentViewport.width) + } panStateRef.current = { pointerId: event.pointerId, @@ -9578,6 +9897,8 @@ export function FloorplanPanel({ }, [ buildingRotationDeg, + applyFloorplanViewportImperatively, + applyFloorplanRotationImperatively, draftStart, ceilingDraftPoints, emitFloorplanWallLeave, @@ -9606,15 +9927,13 @@ export function FloorplanPanel({ isWallBuildActive, levelId, publishFloorplanNavigationPose, - smoothFloorplanNavigationView, referenceScaleDraft, roofDraftStart, elevatorResizeDragState, siteVertexDragState, surfaceSize.height, surfaceSize.width, - viewBox.height, - viewBox.width, + svgAspectRatio, walls, setCursorPoint, setDraftEnd, @@ -9879,10 +10198,10 @@ export function FloorplanPanel({ displayWallPolygons, floorplanElevatorEntries, floorplanItemEntries, - floorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanOpeningHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, @@ -11030,6 +11349,7 @@ export function FloorplanPanel({ const handleGestureEnd = (event: Event) => { gestureScaleRef.current = 1 + commitFloorplanZoom() event.preventDefault() event.stopPropagation() } @@ -11049,7 +11369,7 @@ export function FloorplanPanel({ svg.removeEventListener('gesturechange', handleGestureChange) svg.removeEventListener('gestureend', handleGestureEnd) } - }, [zoomViewportAtClientPoint]) + }, [commitFloorplanZoom, zoomViewportAtClientPoint]) const restoreGroundLevelStructureSelection = useCallback(() => { const sceneNodes = useScene.getState().nodes @@ -11167,7 +11487,7 @@ export function FloorplanPanel({ ref={containerRef} > -
+
{ event.preventDefault() event.stopPropagation() @@ -11477,9 +11798,9 @@ export function FloorplanPanel({ onPointerMove={handleMarqueePointerMove} onPointerUp={handleMarqueePointerUp} style={{ cursor: EDITOR_CURSOR }} - width={viewBox.width} - x={viewBox.minX} - y={viewBox.minY} + width={presentationViewBox.width} + x={presentationViewBox.minX} + y={presentationViewBox.minY} /> )} @@ -11692,12 +12013,12 @@ export function FloorplanPanel({ {isFloorplanNavigationOverlayVisible && ( )} diff --git a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts index 6894e7f4..4e71512f 100644 --- a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts +++ b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts @@ -89,10 +89,10 @@ type UseFloorplanHitTestingArgs = { displayWallPolygons: WallPolygonEntry[] floorplanElevatorEntries: ElevatorPolygonEntry[] floorplanItemEntries: FloorplanItemEntry[] - floorplanOpeningHitTolerance: number + getFloorplanOpeningHitTolerance: () => number floorplanRoofEntries: FloorplanRoofEntry[] floorplanStairEntries: FloorplanStairEntry[] - floorplanWallHitTolerance: number + getFloorplanWallHitTolerance: () => number getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null isFloorplanItemContextActive: boolean openingsPolygons: OpeningPolygonEntry[] @@ -107,10 +107,10 @@ export function useFloorplanHitTesting({ displayWallPolygons, floorplanElevatorEntries, floorplanItemEntries, - floorplanOpeningHitTolerance, + getFloorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, @@ -132,8 +132,8 @@ export function useFloorplanHitTesting({ elevators: floorplanElevatorEntries, walls: displayWallPolygons, slabs: displaySlabPolygons, - openingHitTolerance: floorplanOpeningHitTolerance, - wallHitTolerance: floorplanWallHitTolerance, + openingHitTolerance: getFloorplanOpeningHitTolerance(), + wallHitTolerance: getFloorplanWallHitTolerance(), columns: columnPolygons, getOpeningCenterLine, }) @@ -145,10 +145,10 @@ export function useFloorplanHitTesting({ displayWallPolygons, floorplanItemEntries, floorplanElevatorEntries, - floorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanOpeningHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, diff --git a/packages/editor/src/store/use-floorplan-preflight.test.ts b/packages/editor/src/store/use-floorplan-preflight.test.ts new file mode 100644 index 00000000..8dffa10d --- /dev/null +++ b/packages/editor/src/store/use-floorplan-preflight.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import useFloorplanPreflight, { type FloorplanPreflightIssue } from './use-floorplan-preflight' + +const COLLISION_ISSUE: FloorplanPreflightIssue = { + id: 'dimension-1', + kind: 'unresolved-collision', + severity: 'warning', + message: 'The same collision remains unresolved.', +} + +afterEach(() => { + useFloorplanPreflight.getState().setIssues([]) + useFloorplanPreflight.getState().setAuditIssues([]) +}) + +describe('useFloorplanPreflight', () => { + test('does not notify subscribers when layout issues are unchanged', () => { + const state = useFloorplanPreflight.getState() + state.setIssues([COLLISION_ISSUE]) + let notifications = 0 + const unsubscribe = useFloorplanPreflight.subscribe(() => { + notifications += 1 + }) + + useFloorplanPreflight.getState().setIssues([{ ...COLLISION_ISSUE }]) + + unsubscribe() + expect(notifications).toBe(0) + }) + + test('still publishes changed layout issues alongside audit issues', () => { + const state = useFloorplanPreflight.getState() + state.setAuditIssues([ + { + id: 'audit-1', + kind: 'dimension-completeness', + severity: 'info', + message: 'Audit issue', + }, + ]) + + useFloorplanPreflight.getState().setIssues([COLLISION_ISSUE]) + + expect(useFloorplanPreflight.getState().issues).toEqual([ + COLLISION_ISSUE, + expect.objectContaining({ id: 'audit-1' }), + ]) + }) +}) diff --git a/packages/editor/src/store/use-floorplan-preflight.ts b/packages/editor/src/store/use-floorplan-preflight.ts index e4f10b2e..d00c7bd7 100644 --- a/packages/editor/src/store/use-floorplan-preflight.ts +++ b/packages/editor/src/store/use-floorplan-preflight.ts @@ -18,6 +18,23 @@ export type FloorplanPreflightIssue = { message: string } +function preflightIssuesEqual( + left: readonly FloorplanPreflightIssue[], + right: readonly FloorplanPreflightIssue[], +): boolean { + if (left.length !== right.length) return false + return left.every((issue, index) => { + const candidate = right[index] + return ( + candidate !== undefined && + issue.id === candidate.id && + issue.kind === candidate.kind && + issue.severity === candidate.severity && + issue.message === candidate.message + ) + }) +} + type FloorplanPreflightState = { issues: FloorplanPreflightIssue[] layoutIssues: FloorplanPreflightIssue[] @@ -38,9 +55,17 @@ export const useFloorplanPreflight = create((set) => ({ clearanceChecksEnabled: false, moduleChecksEnabled: false, setIssues: (issues) => - set((state) => ({ layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] })), + set((state) => + preflightIssuesEqual(state.layoutIssues, issues) + ? state + : { layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] }, + ), setAuditIssues: (issues) => - set((state) => ({ auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] })), + set((state) => + preflightIssuesEqual(state.auditIssues, issues) + ? state + : { auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] }, + ), setClearanceChecksEnabled: (clearanceChecksEnabled) => set({ clearanceChecksEnabled }), setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }), reset: () => diff --git a/packages/nodes/src/construction-dimension/panel.tsx b/packages/nodes/src/construction-dimension/panel.tsx index c1018fb2..3a1ef55a 100644 --- a/packages/nodes/src/construction-dimension/panel.tsx +++ b/packages/nodes/src/construction-dimension/panel.tsx @@ -1,6 +1,7 @@ 'use client' import { + type AnyNode, type AnyNodeId, type ConstructionDimensionDatumPolicy, type ConstructionDimensionDrawingPresentation, @@ -86,16 +87,6 @@ export default function ConstructionDimensionPanel() { const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined return node?.type === 'construction-dimension' ? node : null }) - const foundationControllers = useScene( - useShallow((state) => - Object.values(state.nodes).filter( - (candidate): candidate is ConstructionDimensionNode => - candidate.type === 'construction-dimension' && - candidate.id !== dimension?.id && - candidate.drawingType === 'foundation-plan', - ), - ), - ) const updateNode = useScene((state) => state.updateNode) const deleteNode = useScene((state) => state.deleteNode) const activeDrawingType = useDrawingView((state) => state.drawingType) @@ -127,10 +118,14 @@ export default function ConstructionDimensionPanel() { drawingType, presentation, ) + const firstFoundationController = + presentation === 'controlled' && !dimension.controllingDimensionId + ? selectFoundationControllers(useScene.getState().nodes, dimension.id)[0] + : undefined update({ drawingOverrides, ...(presentation === 'controlled' && !dimension.controllingDimensionId - ? { controllingDimensionId: foundationControllers[0]?.id ?? null } + ? { controllingDimensionId: firstFoundationController?.id ?? null } : {}), }) } @@ -207,21 +202,13 @@ export default function ConstructionDimensionPanel() { value={activePresentation} /> {activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? ( - update({ - controllingDimensionId: controllingDimensionId as NonNullable< - ConstructionDimensionNode['controllingDimensionId'] - >, + controllingDimensionId, }) } - options={foundationControllers.map((controller) => ({ - label: controller.name || 'Foundation dimension', - value: controller.id, - }))} - placeholder="No foundation dimensions" value={dimension.controllingDimensionId ?? ''} /> ) : null} @@ -339,6 +326,51 @@ export default function ConstructionDimensionPanel() { ) } +function selectFoundationControllers( + nodes: Record, + excludedId: AnyNodeId, +): ConstructionDimensionNode[] { + return Object.values(nodes).filter( + (candidate): candidate is ConstructionDimensionNode => + candidate.type === 'construction-dimension' && + candidate.id !== excludedId && + candidate.drawingType === 'foundation-plan', + ) +} + +function FoundationControllerField({ + dimensionId, + value, + onChange, +}: { + dimensionId: AnyNodeId + value: string + onChange: (value: NonNullable) => void +}) { + const foundationControllers = useScene( + useShallow((state) => selectFoundationControllers(state.nodes, dimensionId)), + ) + return ( + + onChange( + controllingDimensionId as NonNullable< + ConstructionDimensionNode['controllingDimensionId'] + >, + ) + } + options={foundationControllers.map((controller) => ({ + label: controller.name || 'Foundation dimension', + value: controller.id, + }))} + placeholder="No foundation dimensions" + value={value} + /> + ) +} + function parseSuppressedSegments(value: string): number[] { return [ ...new Set( diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index b4e83eaf..74f2f509 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -526,6 +526,7 @@ const PostProcessingPasses = ({ let visualAlpha = contentAlpha if (outlineEnabled) { const outlineNode = mergedOutline(scene, camera, { + enabled: () => !useViewer.getState().cameraDragging, primaryObjects: outliner.selectedObjects, secondaryObjects: outliner.hoveredObjects, primaryEdgeThickness: uniform(1), diff --git a/packages/viewer/src/lib/merged-outline-node.test.ts b/packages/viewer/src/lib/merged-outline-node.test.ts new file mode 100644 index 00000000..34a81b5b --- /dev/null +++ b/packages/viewer/src/lib/merged-outline-node.test.ts @@ -0,0 +1,22 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { Object3D, PerspectiveCamera, Scene } from 'three' +import { mergedOutline } from './merged-outline-node' + +describe('merged outline rendering', () => { + test('skips outline work while the pass is disabled', () => { + const outline = mergedOutline(new Scene(), new PerspectiveCamera(), { + enabled: () => false, + primaryObjects: [new Object3D()], + }) + const frame = { + get renderer(): never { + throw new Error('outline renderer should not be touched') + }, + } + + expect(() => outline.updateBefore(frame)).not.toThrow() + outline.dispose() + }) +}) diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index 4c621ffb..e7dbdcd8 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -125,6 +125,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlowNode: any secondaryEdgeGlowNode: any downSampleRatio: number + enabled: () => boolean updateBeforeType: string private readonly _depthRT: RenderTarget @@ -189,6 +190,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlow?: any secondaryEdgeGlow?: any downSampleRatio?: number + enabled?: () => boolean } = {}, ) { super('vec4') @@ -201,6 +203,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlow = float(0), secondaryEdgeGlow = float(0), downSampleRatio = 2, + enabled = () => true, } = params this.scene = scene @@ -212,6 +215,7 @@ export class MergedOutlineNode extends TempNode { this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow) this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow) this.downSampleRatio = downSampleRatio + this.enabled = enabled this.updateBeforeType = NodeUpdateType.FRAME this._depthRT = new RenderTarget() @@ -301,8 +305,9 @@ export class MergedOutlineNode extends TempNode { } updateBefore(frame: any) { - const hasPrimary = this.primaryObjects.length > 0 - const hasSecondary = this.secondaryObjects.length > 0 + const enabled = this.enabled() + const hasPrimary = enabled && this.primaryObjects.length > 0 + const hasSecondary = enabled && this.secondaryObjects.length > 0 const hasAny = hasPrimary || hasSecondary // Fast-path: nothing to render and nothing was rendered last frame either, From c3659acdf81b7546dbaa26718f41321654ecb938 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 23 Jul 2026 09:58:28 -0400 Subject: [PATCH 11/11] fix(editor): don't flag camera as dragging on ACTION.NONE controlstart (#536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit camera-controls fires controlstart for every pointerdown — including buttons mapped to ACTION.NONE (plain left click in edit mode). Since #535 that set cameraDragging=true with no rest/sleep ever following to clear it, so every canvas click (selection, wall placement) was suppressed once the camera was at rest. Only flag dragging when currentAction actually drives the camera, and clear the flag on controlend for mapped-button taps with zero movement (no wake -> no rest/sleep). Co-authored-by: Claude Fable 5 --- .../editor/custom-camera-controls.tsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index d415fb97..1b4ce7e3 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -457,11 +457,14 @@ export const CustomCameraControls = () => { } }, [freezeActivePoseInterpolation]) - const beginLocalCameraInteraction = useCallback(() => { - cancelPoseApplication() - cameraDraggingLifecycle.begin() - emitter.emit('camera-controls:interaction-start', undefined) - }, [cameraDraggingLifecycle, cancelPoseApplication]) + const beginLocalCameraInteraction = useCallback( + ({ dragging = true }: { dragging?: boolean } = {}) => { + cancelPoseApplication() + if (dragging) cameraDraggingLifecycle.begin() + emitter.emit('camera-controls:interaction-start', undefined) + }, + [cameraDraggingLifecycle, cancelPoseApplication], + ) const applyPendingPose = useCallback(() => { if (isFirstPersonMode) { @@ -1123,10 +1126,18 @@ export const CustomCameraControls = () => { // Cancel any in-progress 2D-origin navigation pose when the user starts // dragging (right-click orbit, middle-click pan, touch). `controlstart` // fires only for user pointer interactions — not for programmatic - // moveTo/rotateTo which emit `transitionstart` instead. + // moveTo/rotateTo which emit `transitionstart` instead. It also fires for + // pointerdowns whose button is mapped to ACTION.NONE (plain left click in + // edit mode); those must not flag the camera as dragging — no rest/sleep + // ever follows to clear the flag, which would leave canvas clicks + // (selection, placement) suppressed until the next real camera move. const handleControlStart = useCallback(() => { clearPendingFloorplanNavigationPose() - beginLocalCameraInteraction() + beginLocalCameraInteraction({ + dragging: controls.current + ? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE + : false, + }) }, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose]) // Preview mode: auto-navigate camera to selected node (viewer behavior) @@ -1435,6 +1446,16 @@ export const CustomCameraControls = () => { cameraDraggingLifecycle.end() }, [cameraDraggingLifecycle]) + const onControlEnd = useCallback(() => { + // A mapped-button tap with zero camera movement never wakes the + // controls, so no rest/sleep follows — clear the dragging flag on + // release. While damping is still settling (`active`), rest/sleep + // clears it instead. + if (!controls.current?.active) { + cameraDraggingLifecycle.end() + } + }, [cameraDraggingLifecycle]) + // Preset capture mode frames a single subtree (often a 0.3–2m preset), // so the default 2m minDistance prevents the user from getting close // enough to compose a good thumbnail. Relax the clamp to 0.5m while @@ -1455,6 +1476,7 @@ export const CustomCameraControls = () => { minDistance={minDistance} minPolarAngle={0} mouseButtons={mouseButtons} + onControlEnd={onControlEnd} onControlStart={handleControlStart} onUpdate={handleCameraUpdate} onRest={onRest}