diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index a1dc64be..d7bcee52 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8440,49 +8440,52 @@ export function FloorplanPanel() { return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) - const floorplanFenceEntries = useMemo( - () => - fences.flatMap((fence) => { - const live = useLiveTransforms.getState().get(fence.id) - const fenceCenterX = (fence.start[0] + fence.end[0]) / 2 - const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2 - const displayFence = live - ? { - ...fence, - start: [ - fence.start[0] + (live.position[0] - fenceCenterX), - fence.start[1] + (live.position[2] - fenceCenterZ), - ] as typeof fence.start, - end: [ - fence.end[0] + (live.position[0] - fenceCenterX), - fence.end[1] + (live.position[2] - fenceCenterZ), - ] as typeof fence.end, - } - : fence - const centerline = isCurvedWall(displayFence) - ? sampleWallCenterline(displayFence, 24) - : [ - { x: displayFence.start[0], y: displayFence.start[1] }, - { x: displayFence.end[0], y: displayFence.end[1] }, - ] - const path = buildSvgPolylinePath(centerline) - if (!path) { - return [] - } - - const markerFrames = getFloorplanFenceMarkerTs(displayFence).map((t) => { - const frame = getWallCurveFrameAt(displayFence, t) - - return { - angleDeg: (Math.atan2(frame.tangent.y, frame.tangent.x) * 180) / Math.PI, - point: frame.point, + const floorplanFenceEntries = useMemo(() => { + // Fence migrated to def.floorplan (Phase 5 Stage C). When registered, + // FloorplanRegistryLayer renders the fence polyline; this legacy + // path short-circuits to avoid double-render. Removed entirely in + // Phase 6 cleanup. + if (nodeRegistry.has('fence')) return [] + return fences.flatMap((fence) => { + const live = useLiveTransforms.getState().get(fence.id) + const fenceCenterX = (fence.start[0] + fence.end[0]) / 2 + const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2 + const displayFence = live + ? { + ...fence, + start: [ + fence.start[0] + (live.position[0] - fenceCenterX), + fence.start[1] + (live.position[2] - fenceCenterZ), + ] as typeof fence.start, + end: [ + fence.end[0] + (live.position[0] - fenceCenterX), + fence.end[1] + (live.position[2] - fenceCenterZ), + ] as typeof fence.end, } - }) + : fence + const centerline = isCurvedWall(displayFence) + ? sampleWallCenterline(displayFence, 24) + : [ + { x: displayFence.start[0], y: displayFence.start[1] }, + { x: displayFence.end[0], y: displayFence.end[1] }, + ] + const path = buildSvgPolylinePath(centerline) + if (!path) { + return [] + } - return [{ fence: displayFence, centerline, markerFrames, path }] - }), - [fences, movingFloorplanNodeRevision], - ) + const markerFrames = getFloorplanFenceMarkerTs(displayFence).map((t) => { + const frame = getWallCurveFrameAt(displayFence, t) + + return { + angleDeg: (Math.atan2(frame.tangent.y, frame.tangent.x) * 180) / Math.PI, + point: frame.point, + } + }) + + return [{ fence: displayFence, centerline, markerFrames, path }] + }) + }, [fences, movingFloorplanNodeRevision]) const wallPolygons = useMemo( () => walls.map((wall) => { @@ -8579,33 +8582,36 @@ export function FloorplanPanel() { }), [displayFloorplanWallById, movingFloorplanNodeRevision, movingNode, openings], ) - const slabPolygons = useMemo( - () => - slabs.flatMap((slab) => { - const polygon = toFloorplanPolygon(slab.polygon) - if (polygon.length < 3) { - return [] - } + const slabPolygons = useMemo(() => { + // Slab migrated to def.floorplan (Phase 5 Stage C). When registered, + // FloorplanRegistryLayer renders the slab polygon; this legacy + // path short-circuits to avoid double-render. Removed entirely in + // Phase 6 cleanup. + if (nodeRegistry.has('slab')) return [] + return slabs.flatMap((slab) => { + const polygon = toFloorplanPolygon(slab.polygon) + if (polygon.length < 3) { + return [] + } - const holes = (slab.holes ?? []) - .map((hole) => toFloorplanPolygon(hole)) - .filter((hole) => hole.length >= 3) - const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab)) - const visualHoles = holes + const holes = (slab.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3) + const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab)) + const visualHoles = holes - return [ - { - slab, - polygon, - holes, - visualPolygon, - visualHoles, - path: formatPolygonPath(visualPolygon, visualHoles), - }, - ] - }), - [slabs], - ) + return [ + { + slab, + polygon, + holes, + visualPolygon, + visualHoles, + path: formatPolygonPath(visualPolygon, visualHoles), + }, + ] + }) + }, [slabs]) const displaySlabPolygons = useMemo(() => { if (!(slabBoundaryDraft || slabHoleBoundaryDraft || slabHoleMoveDraft)) { return slabPolygons @@ -8662,29 +8668,31 @@ export function FloorplanPanel() { return nextEntry }) }, [slabBoundaryDraft, slabHoleBoundaryDraft, slabHoleMoveDraft, slabPolygons]) - const ceilingPolygons = useMemo( - () => - ceilings.flatMap((ceiling) => { - const polygon = toFloorplanPolygon(ceiling.polygon) - if (polygon.length < 3) { - return [] - } + const ceilingPolygons = useMemo(() => { + // Ceiling migrated to def.floorplan (Phase 5 Stage C). When registered, + // FloorplanRegistryLayer renders the ceiling polygon; this legacy + // path short-circuits. Removed entirely in Phase 6 cleanup. + if (nodeRegistry.has('ceiling')) return [] + return ceilings.flatMap((ceiling) => { + const polygon = toFloorplanPolygon(ceiling.polygon) + if (polygon.length < 3) { + return [] + } - const holes = (ceiling.holes ?? []) - .map((hole) => toFloorplanPolygon(hole)) - .filter((hole) => hole.length >= 3) + const holes = (ceiling.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3) - return [ - { - ceiling, - polygon, - holes, - path: formatPolygonPath(polygon, holes), - }, - ] - }), - [ceilings], - ) + return [ + { + ceiling, + polygon, + holes, + path: formatPolygonPath(polygon, holes), + }, + ] + }) + }, [ceilings]) const displayCeilingPolygons = useMemo(() => { if (!(ceilingBoundaryDraft || ceilingHoleBoundaryDraft || ceilingHoleMoveDraft)) { return ceilingPolygons @@ -8802,24 +8810,28 @@ export function FloorplanPanel() { ), [levelDescendantNodes], ) - const floorplanSpawnEntries = useMemo( - () => - spawns - .filter((spawn) => spawn.visible !== false) - .map((spawn) => { - const live = useLiveTransforms.getState().get(spawn.id) - - return { - spawn, - position: { - x: live?.position[0] ?? spawn.position[0], - y: live?.position[2] ?? spawn.position[2], - }, - rotation: live?.rotation ?? spawn.rotation, - } - }), - [movingFloorplanNodeRevision, spawns], - ) + const floorplanSpawnEntries = useMemo(() => { + // Spawn migrated to the registry-driven floor-plan layer (Phase 5 + // Stage C). When registered, FloorplanRegistryLayer renders the + // spawn marker via def.floorplan; FloorplanRegistryActionMenu + // handles select / move / delete. Returning [] here skips the + // legacy rendering + action menu paths to avoid double-render. + // Removed entirely in Phase 6 cleanup. + if (nodeRegistry.has('spawn')) return [] + return spawns + .filter((spawn) => spawn.visible !== false) + .map((spawn) => { + const live = useLiveTransforms.getState().get(spawn.id) + return { + spawn, + position: { + x: live?.position[0] ?? spawn.position[0], + y: live?.position[2] ?? spawn.position[2], + }, + rotation: live?.rotation ?? spawn.rotation, + } + }) + }, [movingFloorplanNodeRevision, spawns]) const floorplanItemEntries = useMemo(() => { const transformCache = new Map() diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index afd197d8..ab3b8749 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -1,20 +1,23 @@ import type { NodeDefinition } from '@pascal-app/core' +import { buildCeilingFloorplan } from './floorplan' import { ceilingParametrics } from './parametrics' import { CeilingNode } from './schema' /** - * Ceiling — Phase 5 batch kind, polygon-based. Structurally identical - * to slab but mounted at `height` rather than `elevation`. + * Ceiling — Phase 5 batch kind, polygon-based. Structurally similar to + * slab but with React-rendered hosted children + TSL shader materials + + * named meshes that other systems poke (`getObjectByName('ceiling-grid')`). * - * Capabilities: - * - **No `movable`**: ceiling move is bespoke via legacy `MoveCeilingTool` - * + the floor-plan boundary / hole editors. Capability-driven dispatch - * keeps the legacy mover (preserves polygon-aware behavior). - * - **`surfaces.top`**: items host on the ceiling at `height`. - * - `selectable`, `duplicable`, `deletable` standard. + * **Stage B intentionally skipped**: pure `def.geometry` extraction + * would lose the React children rendering (hosted items) and the + * named-mesh structure. Ceiling keeps `def.renderer` as the custom + * escape hatch (per plans/editor-node-registry.md "custom-behavior + * escape hatch"). Renderer wraps the legacy CeilingRenderer; system + * wraps the legacy CeilingSystem. * - * Relations: `hosts: ['item']` for ceiling-mounted items (lights, fans). - * `cascadeDelete: 'descendants'` removes hosted items on ceiling delete. + * **Stage C completed**: `def.floorplan` builder draws the ceiling + * polygon as a dashed outline in floor plan; legacy `ceilingPolygons` + * short-circuits to [] when ceiling is registered. */ export const ceilingDefinition: NodeDefinition = { kind: 'ceiling', @@ -59,6 +62,7 @@ export const ceilingDefinition: NodeDefinition = { module: () => import('./system'), priority: 4, }, + floorplan: buildCeilingFloorplan, toolHints: [ { key: 'Left click', label: 'Trace ceiling outline' }, diff --git a/packages/nodes/src/ceiling/floorplan.ts b/packages/nodes/src/ceiling/floorplan.ts new file mode 100644 index 00000000..4e618afa --- /dev/null +++ b/packages/nodes/src/ceiling/floorplan.ts @@ -0,0 +1,37 @@ +import type { CeilingNode, FloorplanGeometry, FloorplanPoint } from '@pascal-app/core' + +/** + * Stage C floor-plan builder for ceiling. Renders the polygon outline + * as a dashed boundary (ceilings are above and would visually obscure + * the slab/walls if drawn solid). Same shape as slab but visually + * distinct. + */ +export function buildCeilingFloorplan(node: CeilingNode): FloorplanGeometry | null { + const polygon = node.polygon + if (!polygon || polygon.length < 3) return null + + const outer: FloorplanPoint[] = polygon.map(([x, z]) => [x, z] as FloorplanPoint) + + const ring = (points: FloorplanPoint[]) => { + const [first, ...rest] = points + if (!first) return '' + return [`M ${first[0]} ${first[1]}`, ...rest.map(([x, y]) => `L ${x} ${y}`), 'Z'].join(' ') + } + + const segments: string[] = [ring(outer)] + const holes = node.holes ?? [] + for (const hole of holes) { + if (hole.length < 3) continue + segments.push(ring(hole.map(([x, z]) => [x, z] as FloorplanPoint))) + } + + return { + kind: 'path', + d: segments.join(' '), + fill: 'none', + stroke: '#94a3b8', + strokeWidth: 0.03, + strokeDasharray: '0.15 0.1', + opacity: 0.7, + } +} diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index c9ed499c..19612678 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -1,34 +1,21 @@ import type { NodeDefinition } from '@pascal-app/core' +import { buildFenceFloorplan } from './floorplan' +import { buildFenceGeometry } from './geometry' import { fenceParametrics } from './parametrics' import { FenceNode } from './schema' /** - * Fence — the first Phase 5 batch-migration kind. + * Fence — Phase 5 batch kind. Stage B complete: `def.geometry` drives + * the rebuild via the generic ``; `` + * mounts the empty group. No per-kind renderer or system file. * - * What this definition encodes: - * - **Capabilities**: snappable (other walls/fences/items snap to it), - * surfaces (front + back faces host items), selectable, duplicable, - * deletable. No `movable` capability — fence move is bespoke - * endpoint-drag, same shape as wall (handled by legacy MoveFenceTool - * until the affordance port). - * - **Relations**: `linkedBy: 'endpoint-match'` for fence-corner cascade. - * No `hosts` field — doors/windows don't mount on fences. `affectsSpatial` - * omitted: moving a fence doesn't dirty slabs/zones in the legacy - * behavior, so the registry stays parity-equivalent until we - * explicitly add the cascade (separate decision). - * - **Parametrics**: dimensions, posts, style — see `./parametrics.ts`. - * - **toolHints**: placement panel hints for the fence-build tool. - * - **Renderer + system**: thin placeholder mesh + re-export of the - * legacy `FenceSystem`. Same shape as wall milestone B; future Phase 5+ - * extracts the pure geometry function and migrates to `def.geometry`. + * Capabilities: + * - **No `movable`**: fence move is bespoke endpoint-drag. Capability- + * driven dispatch keeps the legacy MoveFenceTool until the + * affordance port (Stage D). + * - `surfaces.sides`, `selectable`, `duplicable`, `deletable` standard. * - * Tool field stays absent: fence has 4 separate tools (build, curve, - * move, move-endpoint) wired through editor state, not the registry - * tool dispatch. They keep running unchanged until the affordance port. - * - * Migration is gated by `feature-flag.ts` - * (env: `NEXT_PUBLIC_USE_REGISTRY_FOR_FENCE`). See - * `plans/editor-node-registry.md#phase-5` for the batch order. + * Relations: `linkedBy: 'endpoint-match'` for corner cascade. */ export const fenceDefinition: NodeDefinition = { kind: 'fence', @@ -71,16 +58,15 @@ export const fenceDefinition: NodeDefinition = { parametrics: fenceParametrics, - renderer: { - kind: 'parametric', - module: () => import('./renderer'), - }, - system: { - module: () => import('./system'), - // Same frame priority as the legacy FenceSystem (4 — runs after door/ - // window animations at 2-3, before zone/level systems at 6+). - priority: 4, - }, + // Stage B: pure geometry function. Generic rebuilds + // on dirtyNodes; mounts the empty group. + // `renderer` + `system` fields dropped along with their files. + geometry: buildFenceGeometry, + // Stage C: floor-plan rendering. FloorplanRegistryLayer iterates kinds + // with `floorplan` set and renders via FloorplanGeometryRenderer. + // Legacy `floorplanFenceEntries` short-circuits to [] when fence is + // registered (see floorplan-panel.tsx). + floorplan: buildFenceFloorplan, toolHints: [ { key: 'Left click', label: 'Set fence start / end' }, diff --git a/packages/nodes/src/fence/floorplan.ts b/packages/nodes/src/fence/floorplan.ts new file mode 100644 index 00000000..bfa60839 --- /dev/null +++ b/packages/nodes/src/fence/floorplan.ts @@ -0,0 +1,32 @@ +import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core' +import { isCurvedWall, sampleWallCenterline } from '@pascal-app/core' +import type { FenceNode } from './schema' + +/** + * Stage C floor-plan builder for fence. Draws the fence centerline as + * a polyline; thickness becomes the stroke width. + * + * Curved fences sample the centerline at 24 segments — same density the + * legacy `floorplanFenceEntries` useMemo uses, so straight + curved + * fences look comparable to the legacy rendering. + * + * Visual nuances the legacy ships (side hatching to indicate thickness + * direction, post markers along the centerline) are deferred — Phase 5 + * Stage D will revisit if real visual parity is needed. + */ +export function buildFenceFloorplan(node: FenceNode): FloorplanGeometry { + const points: FloorplanPoint[] = isCurvedWall(node) + ? sampleWallCenterline(node, 24).map((p) => [p.x, p.y] as FloorplanPoint) + : [ + [node.start[0], node.start[1]], + [node.end[0], node.end[1]], + ] + + return { + kind: 'polyline', + points, + stroke: node.color || '#475569', + strokeWidth: Math.max(node.thickness, 0.05), + opacity: 0.9, + } +} diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts new file mode 100644 index 00000000..03461b6e --- /dev/null +++ b/packages/nodes/src/fence/geometry.ts @@ -0,0 +1,29 @@ +import { DEFAULT_STAIR_MATERIAL, generateFenceGeometry } from '@pascal-app/viewer' +import { Group, Mesh } from 'three' +import type { FenceNode } from './schema' + +/** + * Stage B builder for fence. Reuses the legacy `generateFenceGeometry` + * (pure function from viewer that returns a merged BufferGeometry of + * posts + base + top rail + curve spans) and wraps it in a Mesh-in-Group + * shape the generic `` expects. + * + * Material is a single shared reference — fences look the same regardless + * of instance, so we don't clone per node. If per-fence material + * customization lands later (color picker on the panel maps to a real + * material), this becomes a per-node lookup. + * + * Phase 6 cleanup moves the 280 lines of geometry math out of the + * legacy `viewer/src/systems/fence/fence-system.tsx` into this folder + * once the legacy system file is deleted. Until then `generateFenceGeometry` + * is publicly re-exported from viewer. + */ +export function buildFenceGeometry(node: FenceNode): Group { + const group = new Group() + const geometry = generateFenceGeometry(node) + const mesh = new Mesh(geometry, DEFAULT_STAIR_MATERIAL) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + return group +} diff --git a/packages/nodes/src/fence/renderer.tsx b/packages/nodes/src/fence/renderer.tsx deleted file mode 100644 index 64116314..00000000 --- a/packages/nodes/src/fence/renderer.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import { type FenceNode, useRegistry, useScene } from '@pascal-app/core' -import { DEFAULT_STAIR_MATERIAL, useNodeEvents } from '@pascal-app/viewer' -import { useLayoutEffect, useMemo, useRef } from 'react' -import type { Mesh } from 'three' - -/** - * Thin fence renderer — registers an empty mesh, marks the node dirty so - * `FenceSystem` (re-exported via `./system`) fills the geometry next - * frame, and wires pointer events through `useNodeEvents`. - * - * Behaviorally identical to the legacy `FenceRenderer` in - * `@pascal-app/viewer/components/renderers/fence/fence-renderer.tsx`. - * Phase 0 shims pick which one mounts based on `nodeRegistry.has('fence')`. - * - * Material is `DEFAULT_STAIR_MATERIAL` (legacy reuse; fence and stairs - * share the wood-tone preset). - */ -const FenceRenderer = ({ node }: { node: FenceNode }) => { - const ref = useRef(null!) - const handlers = useNodeEvents(node, 'fence') - const material = useMemo(() => DEFAULT_STAIR_MATERIAL, []) - - useRegistry(node.id, 'fence', ref) - useLayoutEffect(() => { - useScene.getState().markDirty(node.id) - }, [node.id]) - - return ( - - - - ) -} - -export default FenceRenderer diff --git a/packages/nodes/src/fence/system.tsx b/packages/nodes/src/fence/system.tsx deleted file mode 100644 index 37457cbb..00000000 --- a/packages/nodes/src/fence/system.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client' - -import { FenceSystem } from '@pascal-app/viewer' - -/** - * Registry-driven fence system bundle. - * - * Wraps the legacy `FenceSystem` (re-exported from viewer) so it mounts - * via `RegisteredSystems` when fence is registry-driven. The legacy - * `` wrapper around `` in - * `viewer/components/viewer/index.tsx` short-circuits whenever - * `nodeRegistry.has('fence')` is true — same pattern wall uses. - * - * Phase 6 deletes the legacy mount point; until then this bundle is the - * single mount surface for fence's per-frame work when registry-driven. - * - * Future Phase 5+ work: extract fence geometry out of `FenceSystem`'s - * useFrame body into a pure `buildFenceGeometry(node, ctx)` and migrate - * to `def.geometry`. The generic `` will then handle - * the rebuild loop and this bundle can be deleted. - */ -const FenceSystems = () => { - return -} - -export default FenceSystems diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 12fc1741..44a57b7b 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -1,29 +1,24 @@ import type { NodeDefinition } from '@pascal-app/core' +import { buildSlabFloorplan } from './floorplan' +import { buildSlabGeometry } from './geometry' import { slabParametrics } from './parametrics' import { SlabNode } from './schema' /** - * Slab — Phase 5 batch kind, polygon-based. + * Slab — Phase 5 batch kind, polygon-based. Stage B: `def.geometry` + * drives the rebuild via generic ; + * mounts the empty group. No per-kind renderer or system file. * * Capabilities: * - **No `movable`**: slab's "move" today is whole-slab translation via * legacy `MoveSlabTool`, which integrates with the floor-plan boundary / - * hole editors. Per the capability-driven dispatch rule, omitting - * `movable` keeps the legacy mover (preserves polygon-aware behavior). - * Migration to the generic mover is possible in a later milestone if - * the legacy mover proves equivalent. + * hole editors. Capability-driven dispatch keeps the legacy mover. * - **`surfaces.top`**: items host on the slab top at `elevation`. * - `selectable`, `duplicable`, `deletable` standard. * * Relations: * - `hosts: ['item']` — items mount on the slab top. * - `cascadeDelete: 'descendants'` — deleting a slab removes hosted items. - * - * Renderer + system: thin renderer + re-export of the legacy `SlabSystem`. - * Same shape as wall / fence runtime port. - * - * Tool field absent: slab has 3 tools (slab-tool, boundary-editor, hole- - * editor) wired through editor state, not registry dispatch. */ export const slabDefinition: NodeDefinition = { kind: 'slab', @@ -59,14 +54,11 @@ export const slabDefinition: NodeDefinition = { parametrics: slabParametrics, - renderer: { - kind: 'parametric', - module: () => import('./renderer'), - }, - system: { - module: () => import('./system'), - priority: 4, - }, + // Stage B: pure geometry function. + geometry: buildSlabGeometry, + // Stage C: floor-plan rendering. Legacy `slabPolygons` short-circuits + // to [] when slab is registered (see floorplan-panel.tsx). + floorplan: buildSlabFloorplan, toolHints: [ { key: 'Left click', label: 'Trace slab outline' }, diff --git a/packages/nodes/src/slab/floorplan.ts b/packages/nodes/src/slab/floorplan.ts new file mode 100644 index 00000000..ad0afdfd --- /dev/null +++ b/packages/nodes/src/slab/floorplan.ts @@ -0,0 +1,52 @@ +import { + type FloorplanGeometry, + type FloorplanPoint, + getRenderableSlabPolygon, + type SlabNode, +} from '@pascal-app/core' + +/** + * Stage C floor-plan builder for slab. Renders the slab polygon as a + * filled path with holes cut out. + * + * Uses `getRenderableSlabPolygon` (the same helper the legacy + * floorplan-panel.tsx uses) to compute the visual polygon — accounts + * for wall-clipping when a slab is auto-generated from walls. + */ +export function buildSlabFloorplan(node: SlabNode): FloorplanGeometry | null { + const polygon = node.polygon + if (!polygon || polygon.length < 3) return null + + const visualPolygon = getRenderableSlabPolygon(node) + if (!visualPolygon || visualPolygon.length < 3) return null + + const outer: FloorplanPoint[] = visualPolygon.map(([x, z]) => [x, z] as FloorplanPoint) + + // SVG path with outer ring + hole subpaths. Each subpath uses M/L + // commands + Z to close. Holes follow the outer ring; FloorplanGeometry + // 'path' kind supports this natively (renderer passes the `d` string + // straight to the SVG ). + const segments: string[] = [] + const ring = (points: FloorplanPoint[]) => { + const [first, ...rest] = points + if (!first) return '' + return [`M ${first[0]} ${first[1]}`, ...rest.map(([x, y]) => `L ${x} ${y}`), 'Z'].join(' ') + } + segments.push(ring(outer)) + + const holes = node.holes ?? [] + for (const hole of holes) { + if (hole.length < 3) continue + const holePts: FloorplanPoint[] = hole.map(([x, z]) => [x, z] as FloorplanPoint) + segments.push(ring(holePts)) + } + + return { + kind: 'path', + d: segments.join(' '), + fill: '#cbd5e1', + stroke: '#475569', + strokeWidth: 0.03, + opacity: 0.85, + } +} diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts new file mode 100644 index 00000000..ad031d77 --- /dev/null +++ b/packages/nodes/src/slab/geometry.ts @@ -0,0 +1,61 @@ +import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' +import { + applyMaterialPresetToMaterials, + createMaterial, + DEFAULT_SLAB_MATERIAL, + generateSlabGeometry, +} from '@pascal-app/viewer' +import { DoubleSide, Group, Mesh, MeshStandardMaterial } from 'three' + +/** + * Stage B builder for slab. Reuses `generateSlabGeometry` (pure + * triangulation + hole CSG from viewer) and the same material cache + * pattern the legacy slab renderer used. + * + * Materials are cached by `{material, materialPreset}` signature so + * slabs sharing settings share the GPU resource. Cached entry mutation + * (preset apply) is preserved — async texture loads still update the + * rendered material after re-mount. + */ +const slabMaterialCache = new Map() + +function getSlabMaterial(node: SlabNode): MeshStandardMaterial { + const cacheKey = JSON.stringify({ + material: node.material ?? null, + materialPreset: node.materialPreset ?? null, + }) + const cached = slabMaterialCache.get(cacheKey) + if (cached) return cached + + const preset = getMaterialPresetByRef(node.materialPreset) + const material = preset + ? new MeshStandardMaterial() + : node.material + ? createMaterial(node.material).clone() + : DEFAULT_SLAB_MATERIAL.clone() + + if (preset) { + applyMaterialPresetToMaterials(material, preset) + } + + material.transparent = false + material.opacity = 1 + material.alphaMap = null + material.side = DoubleSide + material.depthWrite = true + material.needsUpdate = true + + slabMaterialCache.set(cacheKey, material) + return material +} + +export function buildSlabGeometry(node: SlabNode): Group { + const group = new Group() + const geometry = generateSlabGeometry(node) + const material = getSlabMaterial(node) + const mesh = new Mesh(geometry, material) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + return group +} diff --git a/packages/nodes/src/slab/renderer.tsx b/packages/nodes/src/slab/renderer.tsx deleted file mode 100644 index 1364ea08..00000000 --- a/packages/nodes/src/slab/renderer.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client' - -import { getMaterialPresetByRef, type SlabNode, useRegistry, useScene } from '@pascal-app/core' -import { - applyMaterialPresetToMaterials, - createMaterial, - DEFAULT_SLAB_MATERIAL, - useNodeEvents, -} from '@pascal-app/viewer' -import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import type { Mesh } from 'three' -import * as THREE from 'three' - -/** - * Thin slab renderer. Mounts a placeholder mesh, registers it with - * `sceneRegistry`, and marks the node dirty so `SlabSystem` fills the - * geometry next frame. - * - * Behaviorally identical to the legacy `SlabRenderer` in - * `@pascal-app/viewer/components/renderers/slab/slab-renderer.tsx` — - * same placeholder geometry, same material cache, same render output. - * - * Material logic is preserved from legacy: slab can carry either a raw - * `material` or a `materialPreset` (preset takes precedence; preset - * apply mutates the cached material instance so async texture loads - * still hit the rendered mesh on re-mount). - * - * No `def.geometry` yet — slab polygon geometry depends on holes + - * triangulation that lives inside `SlabSystem`'s useFrame body. Future - * milestone can extract a pure builder if useful, but the system is - * already efficient (rebuilds only dirty nodes); no urgency. - */ -const slabMaterialCache = new Map() - -function createEmptyGeometry() { - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - return geometry -} - -function getSlabMaterial( - cacheKey: string, - params: { material?: SlabNode['material']; materialPreset?: string }, -) { - const cached = slabMaterialCache.get(cacheKey) - if (cached) return cached - - const preset = getMaterialPresetByRef(params.materialPreset) - const slabMaterial = preset - ? new THREE.MeshStandardMaterial() - : params.material - ? createMaterial(params.material).clone() - : DEFAULT_SLAB_MATERIAL.clone() - - if (preset) { - applyMaterialPresetToMaterials(slabMaterial, preset) - } - - slabMaterial.transparent = false - slabMaterial.opacity = 1 - slabMaterial.alphaMap = null - slabMaterial.side = THREE.DoubleSide - slabMaterial.depthWrite = true - slabMaterial.needsUpdate = true - - slabMaterialCache.set(cacheKey, slabMaterial) - return slabMaterial -} - -const SlabRenderer = ({ node }: { node: SlabNode }) => { - const ref = useRef(null!) - const placeholderGeometry = useMemo(createEmptyGeometry, []) - const handlers = useNodeEvents(node, 'slab') - - useRegistry(node.id, 'slab', ref) - - useLayoutEffect(() => { - useScene.getState().markDirty(node.id) - }, [node.id]) - - useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry]) - - const material = useMemo(() => { - const resolvedMaterial = node.material - const resolvedMaterialPreset = node.materialPreset - const cacheKey = JSON.stringify({ - material: resolvedMaterial ?? null, - materialPreset: resolvedMaterialPreset ?? null, - }) - - return getSlabMaterial(cacheKey, { - material: resolvedMaterial, - materialPreset: resolvedMaterialPreset, - }) - }, [ - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - node.materialPreset, - ]) - - return ( - - ) -} - -export default SlabRenderer diff --git a/packages/nodes/src/slab/system.tsx b/packages/nodes/src/slab/system.tsx deleted file mode 100644 index 8041007d..00000000 --- a/packages/nodes/src/slab/system.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client' - -import { SlabSystem } from '@pascal-app/viewer' - -/** - * Registry-driven slab system bundle. Re-exports the legacy `SlabSystem` - * (still in viewer) so it mounts via `RegisteredSystems` when slab is - * registry-driven. `` in viewer/components/ - * viewer/index.tsx short-circuits whenever `nodeRegistry.has('slab')` - * is true — same shape wall and fence use. - * - * Future Phase 5+: extract polygon triangulation + hole CSG into a pure - * `buildSlabGeometry(node)` and migrate to `def.geometry`. The legacy - * system body has it well-isolated; should be a clean extraction. - */ -const SlabSystems = () => { - return -} - -export default SlabSystems diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 17982020..c270949c 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -1,4 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' +import { buildSpawnFloorplan } from './floorplan' import { spawnParametrics } from './parametrics' import { SpawnNode } from './schema' @@ -34,12 +35,14 @@ export const spawnDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, - // `floorplan: buildSpawnFloorplan` deferred — spawn already renders in - // the legacy floorplan-panel.tsx via `floorplanSpawnEntries`. Adding it - // here would double-render. The pure builder lives in - // ./floorplan.ts ready to wire when the legacy inline branch is - // removed (Phase 5 spawn-floorplan migration PR — same shape as the - // wall feature flag, but per kind in the legacy panel itself). + // Stage C migration: floor-plan rendering via def.floorplan. + // floorplan-panel.tsx's `floorplanSpawnEntries` short-circuits to [] + // when `nodeRegistry.has('spawn')`, so this builder is the single + // path. FloorplanRegistryLayer renders + handles click-to-select; + // FloorplanRegistryActionMenu handles move / duplicate (disabled) / + // delete. Legacy spawn click handlers in FloorplanNodeLayer become + // dead code once Phase 6 cleanup removes the [] entries path. + floorplan: buildSpawnFloorplan, tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place spawn point' }, diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 53bcba52..9008659a 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -49,7 +49,7 @@ export { DoorSystem } from './systems/door/door-system' // Fence system follows the wall re-export pattern — composed into the // registry-driven fence definition's `def.system`. Removed in Phase 6 // alongside the legacy fence mount point. -export { FenceSystem } from './systems/fence/fence-system' +export { FenceSystem, generateFenceGeometry } from './systems/fence/fence-system' export { InteractiveSystem } from './systems/interactive/interactive-system' // Item systems for the registry-driven item definition. ItemSystem // applies attachTo-driven transforms each frame; ItemLightSystem @@ -61,7 +61,7 @@ export { getRoofMaterialArray } from './systems/roof/roof-materials' // 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 { SlabSystem } from './systems/slab/slab-system' +export { generateSlabGeometry, SlabSystem } from './systems/slab/slab-system' export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials' export { WallCutout } from './systems/wall/wall-cutout' export { getVisibleWallMaterials } from './systems/wall/wall-materials' diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx index 42f7f0dc..31807dad 100644 --- a/packages/viewer/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -242,7 +242,7 @@ function createFenceParts(fence: FenceNode): FencePart[] { return parts } -function generateFenceGeometry(fence: FenceNode) { +export function generateFenceGeometry(fence: FenceNode) { const parts = createFenceParts(fence) const geometries = parts.map(createFencePartGeometry)