diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d7bcee52..70771733 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8486,19 +8486,21 @@ export function FloorplanPanel() { return [{ fence: displayFence, centerline, markerFrames, path }] }) }, [fences, movingFloorplanNodeRevision]) - const wallPolygons = useMemo( - () => - walls.map((wall) => { - const floorplanWall = floorplanWallById.get(wall.id) ?? getFloorplanWall(wall) - const polygon = getWallPlanFootprint(floorplanWall, wallMiterData) - return { - points: formatPolygonPoints(polygon), - wall, - polygon, - } - }), - [floorplanWallById, wallMiterData, walls], - ) + const wallPolygons = useMemo(() => { + // Wall migrated to def.floorplan (Phase 5 Stage C). When registered, + // FloorplanRegistryLayer renders the mitered wall polygon; this + // legacy path short-circuits. Removed entirely in Phase 6 cleanup. + if (nodeRegistry.has('wall')) return [] + return walls.map((wall) => { + const floorplanWall = floorplanWallById.get(wall.id) ?? getFloorplanWall(wall) + const polygon = getWallPlanFootprint(floorplanWall, wallMiterData) + return { + points: formatPolygonPoints(polygon), + wall, + polygon, + } + }) + }, [floorplanWallById, wallMiterData, walls]) const displayWallPolygons = useMemo(() => { if (!(wallEndpointDraft || wallCurveDraft)) { return wallPolygons @@ -8547,41 +8549,49 @@ export function FloorplanPanel() { ) }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons]) - const openingsPolygons = useMemo( - () => - openings.flatMap((opening) => { - const wall = displayFloorplanWallById.get(opening.parentId as WallNode['id']) - if (!wall) return [] - const live = useLiveTransforms.getState().get(opening.id) - const displayOpening = - live && - (movingNode?.type === 'door' || movingNode?.type === 'window') && - movingNode.id === opening.id - ? { - ...opening, - position: [ - live.position[0], - opening.position[1], - live.position[2], - ] as typeof opening.position, - rotation: [ - opening.rotation[0], - live.rotation, - opening.rotation[2], - ] as typeof opening.rotation, - } - : opening - const polygon = getOpeningFootprint(wall, displayOpening) - return [ - { - opening: displayOpening, - points: formatPolygonPoints(polygon), - polygon, - }, - ] - }), - [displayFloorplanWallById, movingFloorplanNodeRevision, movingNode, openings], - ) + const openingsPolygons = useMemo(() => { + // Doors + windows migrated to def.floorplan (Phase 5 Stage C). When + // both registered, FloorplanRegistryLayer renders each opening's + // polygon via its kind's builder; this legacy path short-circuits + // to avoid double-render. Filter per kind so a partial migration + // would still work. + const doorRegistered = nodeRegistry.has('door') + const windowRegistered = nodeRegistry.has('window') + if (doorRegistered && windowRegistered) return [] + return openings.flatMap((opening) => { + if (doorRegistered && opening.type === 'door') return [] + if (windowRegistered && opening.type === 'window') return [] + const wall = displayFloorplanWallById.get(opening.parentId as WallNode['id']) + if (!wall) return [] + const live = useLiveTransforms.getState().get(opening.id) + const displayOpening = + live && + (movingNode?.type === 'door' || movingNode?.type === 'window') && + movingNode.id === opening.id + ? { + ...opening, + position: [ + live.position[0], + opening.position[1], + live.position[2], + ] as typeof opening.position, + rotation: [ + opening.rotation[0], + live.rotation, + opening.rotation[2], + ] as typeof opening.rotation, + } + : opening + const polygon = getOpeningFootprint(wall, displayOpening) + return [ + { + opening: displayOpening, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }) + }, [displayFloorplanWallById, movingFloorplanNodeRevision, movingNode, openings]) const slabPolygons = useMemo(() => { // Slab migrated to def.floorplan (Phase 5 Stage C). When registered, // FloorplanRegistryLayer renders the slab polygon; this legacy diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index e5520243..dadbd77d 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -1,4 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' +import { buildDoorFloorplan } from './floorplan' import { doorParametrics } from './parametrics' import { DoorNode } from './schema' @@ -12,16 +13,14 @@ import { DoorNode } from './schema' * keeps legacy `MoveDoorTool`. * - `selectable`, `duplicable`, `deletable` standard. * - * Relations: - * - `parentId` references a wall — re-anchors on wall move (handled by - * `DoorSystem`'s cascade to parent wall). - * - `cascadeDelete: 'children'` — door has no children in v1. - * - * Renderer + system: wrap-export legacy `DoorRenderer` + bundle - * `DoorSystem` + `DoorAnimationSystem`. - * - * Tool field absent: door placement / move tools wired through editor - * state, not registry dispatch. Legacy DoorTool / MoveDoorTool continue. + * Stages: + * - A: registered. + * - B: deferred — door geometry (frame / leaf / glass / hardware / + * segments) is ~800 lines in DoorSystem; extraction is a focused + * session. `def.renderer` (wrap-export of legacy DoorRenderer) + + * `def.system` (DoorSystem + DoorAnimationSystem bundle) hold parity. + * - C: `def.floorplan` polygon sits in parent wall's cutout. Legacy + * `openingPolygons` short-circuits door entries when registered. */ export const doorDefinition: NodeDefinition = { kind: 'door', @@ -56,6 +55,9 @@ export const doorDefinition: NodeDefinition = { // before wall mitering at 4). priority: 3, }, + // Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute + // direction + perpendicular for the cutout footprint. + floorplan: buildDoorFloorplan, toolHints: [ { key: 'Left click', label: 'Place door on wall' }, diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts new file mode 100644 index 00000000..5f58e4c0 --- /dev/null +++ b/packages/nodes/src/door/floorplan.ts @@ -0,0 +1,60 @@ +import type { + DoorNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + WallNode, +} from '@pascal-app/core' + +/** + * Stage C floor-plan builder for door. Doors render as a small polygon + * sitting in the wall's cutout — width = door.width along the wall + * direction, depth = wall.thickness perpendicular. + * + * Requires `ctx.parent` to be a wall (door.parentId is the wall it's + * mounted on). Returns null when the parent isn't a wall (orphaned + * doors during placement etc.). + * + * Inlined from the legacy `getOpeningFootprint` helper in + * floorplan-panel.tsx. Window's builder is structurally identical. + */ +export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null { + const wall = ctx.parent as WallNode | null + if (!wall || wall.type !== 'wall') return null + + const [x1, z1] = wall.start + const [x2, z2] = wall.end + const dx = x2 - x1 + const dz = z2 - z1 + const length = Math.sqrt(dx * dx + dz * dz) + if (length < 1e-9) return null + + const dirX = dx / length + const dirZ = dz / length + const perpX = -dirZ + const perpZ = dirX + + const distance = node.position[0] // door's local X = distance along wall + const width = node.width + const depth = wall.thickness ?? 0.1 + const cx = x1 + dirX * distance + const cz = z1 + dirZ * distance + const halfWidth = width / 2 + const halfDepth = depth / 2 + + const points: readonly FloorplanPoint[] = [ + [cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth], + [cx + dirX * halfWidth + perpX * halfDepth, cz + dirZ * halfWidth + perpZ * halfDepth], + [cx + dirX * halfWidth - perpX * halfDepth, cz + dirZ * halfWidth - perpZ * halfDepth], + [cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth], + ] + + return { + kind: 'polygon', + points, + fill: '#f8fafc', + stroke: '#374151', + strokeWidth: 0.015, + opacity: 0.95, + } +} diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 50a43a16..9fc945f7 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -1,34 +1,21 @@ import type { NodeDefinition } from '@pascal-app/core' +import { buildWallFloorplan } from './floorplan' import { wallParametrics } from './parametrics' import { WallNode } from './schema' /** * Wall — the Phase 3 stress test of the registry-driven node model. * - * What this definition encodes today: - * - **Capabilities**: cuttable (doors/windows punch holes), snappable - * (other walls, doors, windows snap to wall geometry), surfaces (front - * + back faces host items), selectable, duplicable, deletable. - * - **Relations**: hosts doors/windows/items; affects spatial slabs + - * ceilings + zones when moved; descendants cascade-delete; linked walls - * follow corners via endpoint-match (consumed by the affordances in a - * later milestone — the relations resolver already understands the - * declaration). - * - **Parametrics**: thickness / height / curveOffset for the inspector. - * - * What this definition does *not* yet encode: - * - `geometry` / `renderer` / `system` runtime — the existing - * `wall-renderer.tsx` + `wall-system.tsx` keep serving wall until - * Milestone B ports them into this folder. Until then, this definition - * is metadata-only and *intentionally not registered* in - * `builtinPlugin.nodes` — the Phase 0 shims only flip behavior when a - * kind is registered, so wall stays on its legacy path. - * - `tool` — wall's placement + endpoint drag + curve drag tools port in - * a follow-up milestone, expressed via the `DragAction` primitive so - * the affordances declared in `relations` get real handles. - * - * Migration is gated by `feature-flag.ts` (env: `NEXT_PUBLIC_USE_REGISTRY_FOR_WALL`). - * See `plans/editor-node-registry.md#phase-3` for the milestone breakdown. + * Stage A: registered (capabilities, relations, parametrics, presentation). + * Stage B: deferred — wall geometry depends on level-batch miter data that + * doesn't fit the generic `(node, ctx) => Group` shape without `ctx. + * levelData?.miters`. See plan's "GeometryContext" extension note. + * `renderer` + `system` keep wrap-exporting legacy WallRenderer + + * WallSystem + WallCutout. + * Stage C: `def.floorplan` builder produces the mitered plan footprint + * polygon using `ctx.siblings` to assemble miter context. + * floorplan-panel.tsx's `wallPolygons` short-circuits to [] when + * wall is registered. */ export const wallDefinition: NodeDefinition = { kind: 'wall', @@ -49,18 +36,12 @@ export const wallDefinition: NodeDefinition = { }), capabilities: { - // Wall move is bespoke today (endpoint drag, linked-wall corner cascade, - // ALT-detach). `MoveRegistryNodeTool`'s "translate on X/Z plane" shape - // doesn't apply — wall stays on its own move tool until the affordance - // port. Leaving `movable` omitted keeps that dispatch. + // Wall move is bespoke (endpoint drag, linked-wall corner cascade, + // ALT-detach). Omitting `movable` keeps the legacy MoveWallTool via + // capability-driven dispatch. selectable: { hitVolume: 'bbox' }, // Front + back faces host items (paintings, shelves, switches). - // `height` callback resolves per-instance so taller walls expose taller - // hosting surface — same shape used by shelf.top. surfaces: { - // Sides config — wall has two faces; concrete face-selection logic - // stays in the renderer/system for now. Phase 4 will derive snap - // targets from this. sides: { faces: 'all' }, }, duplicable: true, @@ -68,50 +49,26 @@ export const wallDefinition: NodeDefinition = { }, relations: { - // Doors / windows / items mount on walls. The host-resolver consumes - // this to validate `parentId` on creation and to re-anchor children - // when a wall moves (a milestone-B concern; the declaration lives here - // so the wiring exists ahead of time). hosts: ['door', 'window', 'item'], - // Moving a wall dirties the slabs / ceilings / zones that border it. - // Today this is *not* wired (the existing `wall-system` doesn't cascade - // to slab / zone) — the registry resolver gains this behavior for free - // once wall is registered. This is the "slab reflow on wall move" - // behavior gain called out in Phase 3 acceptance. affectsSpatial: ['slab', 'ceiling', 'zone'], - // Walls sharing an endpoint move together when a corner is dragged. - // The endpoint affordance (milestone C) uses this declaration via the - // shared cascade resolver — no hand-rolled `getLinkedWallSnapshots`. linkedBy: 'endpoint-match', - // Deleting a wall deletes its hosted doors/windows/items (today's - // implicit behavior, now declarative). cascadeDelete: 'descendants', }, parametrics: wallParametrics, - // Wall's renderer is the thin placeholder-mesh mount point from milestone - // B; the system bundle composes the legacy `WallSystem` + `WallCutout` - // re-exported from viewer (so we don't duplicate ~970 lines of CSG / - // mitering / cutaway logic just to swap the dispatch). The legacy - // mount in `` short-circuits the moment - // `nodeRegistry.has('wall')` is true. - // - // No `tool` yet — wall placement / endpoint drag / curve drag remain - // bespoke until the affordance port lands. Phase 0 shims keep the legacy - // wall tool running while wall is registered (it's not wired through the - // registry tool dispatch). renderer: { kind: 'parametric', module: () => import('./renderer'), }, system: { module: () => import('./system'), - // Priority 4 mirrors the legacy WallSystem's useFrame priority — keeps - // miter cascade running after door/window animation systems (priority 2) - // but before zone/level systems that read wall positions. + // Priority 4 mirrors the legacy WallSystem's useFrame priority. priority: 4, }, + // Stage C: floor-plan rendering. ctx.siblings provides other walls in + // the level so `calculateLevelMiters` can compute correct corner joins. + floorplan: buildWallFloorplan, presentation: { label: 'Wall', @@ -123,8 +80,5 @@ export const wallDefinition: NodeDefinition = { mcp: { description: 'A wall segment defined by start + end points, with optional curve sagitta.', - // Wall has hand-written semantic MCP tooling (`create_wall` builds full - // rooms from polygons; this entry is for the auto-derived single-wall - // primitive). Stays auto-derived until Phase 4 says otherwise. }, } diff --git a/packages/nodes/src/wall/floorplan.ts b/packages/nodes/src/wall/floorplan.ts new file mode 100644 index 00000000..7ef115f6 --- /dev/null +++ b/packages/nodes/src/wall/floorplan.ts @@ -0,0 +1,60 @@ +import { + type AnyNode, + calculateLevelMiters, + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + getWallPlanFootprint, + type WallNode, +} from '@pascal-app/core' + +// Same constants the legacy `getFloorplanWall` uses (editor/lib/floorplan/walls.ts). +// Slightly exaggerates thin walls so the 2D plan stays legible without +// drifting from BIM data. Inlined to keep nodes/wall self-contained. +const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18 +const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13 +const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035 + +function floorplanWallThickness(wall: WallNode): number { + const baseThickness = wall.thickness ?? 0.1 + const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE + return Math.min( + baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS, + Math.max(baseThickness, scaledThickness, FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS), + ) +} + +function exaggerateWallThickness(wall: WallNode): WallNode { + return { ...wall, thickness: floorplanWallThickness(wall) } +} + +/** + * Stage C floor-plan builder for wall. Returns the mitered plan + * footprint polygon. Uses `ctx.siblings` to gather other walls in the + * level so `calculateLevelMiters` produces the correct corner joins. + * + * Performance note: this recomputes level miter data per wall (O(N²) + * across N walls in the level). For < 100 walls per level this is + * sub-millisecond. If a real perf hotspot surfaces, the `ctx.levelData?. + * miters` extension flagged in the plan moves the batch computation to + * the dispatcher. + */ +export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null { + const siblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall') + const all = [node, ...siblings].map(exaggerateWallThickness) + const miters = calculateLevelMiters(all) + const self = all.find((w) => w.id === node.id) + if (!self) return null + + const polygon = getWallPlanFootprint(self, miters) + if (!polygon || polygon.length < 3) return null + + return { + kind: 'polygon', + points: polygon.map((p) => [p.x, p.y] as FloorplanPoint), + fill: '#374151', + stroke: '#1f2937', + strokeWidth: 0.02, + opacity: 0.92, + } +} diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 0ac4eb6d..a4ebdb12 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -1,4 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' +import { buildWindowFloorplan } from './floorplan' import { windowParametrics } from './parametrics' import { WindowNode } from './schema' @@ -6,8 +7,12 @@ import { WindowNode } from './schema' * Window — Phase 5 batch kind. Mirrors door's shape: hosted on walls, * cuts holes in them, animated open/close state for opening windows. * - * Capabilities: no `movable` (wall-bound drag is bespoke). Tool field - * absent (legacy WindowTool / MoveWindowTool continue). + * Stages: + * - A: registered. + * - B: deferred — window geometry ~800 lines; extraction is a focused + * session. `def.renderer` + `def.system` wrap-export legacy. + * - C: `def.floorplan` polygon sits in parent wall's cutout. Legacy + * `openingPolygons` short-circuits window entries when registered. */ export const windowDefinition: NodeDefinition = { kind: 'window', @@ -39,6 +44,9 @@ export const windowDefinition: NodeDefinition = { module: () => import('./system'), priority: 3, }, + // Stage C: floor-plan polygon. ctx.parent gives the wall for direction + // + thickness — same shape as door. + floorplan: buildWindowFloorplan, toolHints: [ { key: 'Left click', label: 'Place window on wall' }, diff --git a/packages/nodes/src/window/floorplan.ts b/packages/nodes/src/window/floorplan.ts new file mode 100644 index 00000000..e2278dd8 --- /dev/null +++ b/packages/nodes/src/window/floorplan.ts @@ -0,0 +1,56 @@ +import type { + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + WallNode, + WindowNode, +} from '@pascal-app/core' + +/** + * Stage C floor-plan builder for window. Mirrors door's shape — window + * polygon sits in the wall's cutout, width along wall, depth = wall + * thickness. Visually distinct via a glass-blue tint. + */ +export function buildWindowFloorplan( + node: WindowNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const wall = ctx.parent as WallNode | null + if (!wall || wall.type !== 'wall') return null + + const [x1, z1] = wall.start + const [x2, z2] = wall.end + const dx = x2 - x1 + const dz = z2 - z1 + const length = Math.sqrt(dx * dx + dz * dz) + if (length < 1e-9) return null + + const dirX = dx / length + const dirZ = dz / length + const perpX = -dirZ + const perpZ = dirX + + const distance = node.position[0] + const width = node.width + const depth = wall.thickness ?? 0.1 + const cx = x1 + dirX * distance + const cz = z1 + dirZ * distance + const halfWidth = width / 2 + const halfDepth = depth / 2 + + const points: readonly FloorplanPoint[] = [ + [cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth], + [cx + dirX * halfWidth + perpX * halfDepth, cz + dirZ * halfWidth + perpZ * halfDepth], + [cx + dirX * halfWidth - perpX * halfDepth, cz + dirZ * halfWidth - perpZ * halfDepth], + [cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth], + ] + + return { + kind: 'polygon', + points, + fill: '#bae6fd', + stroke: '#0c4a6e', + strokeWidth: 0.015, + opacity: 0.8, + } +}