Files
editor/packages/nodes/src/fence/floorplan.ts
T
Wassim SAMADandClaude Opus 4.7 d747d2f0ea Phase 5 Stage E: full kind migration into packages/nodes
Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.

Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
  (boundary editors, hole editors, placement tools, move tools,
  endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
  stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
  elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
  spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`

Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
  `roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
  `stair-segment/` packages with definition + schema + renderer / system
  / floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
  (ceiling, door, item, shelf, slab, window) — single registry-driven
  dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
  drags (ceiling, fence, slab, wall) — using the shared
  `polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
  (door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
  (door, item, slab, window).

Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
  hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
  shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
  `floorplan-render-context.tsx` updated for the registry layer's new
  contract (props, affordance dispatch, render context).

Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
  `wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
  the registry-only world.

Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.

Wiki: new `plugin-authoring.md` page, README index updated.

Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:14:12 -04:00

377 lines
11 KiB
TypeScript

import {
type FloorplanGeometry,
type GeometryContext,
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
sampleWallCenterline,
} from '@pascal-app/core'
import type { FenceNode } from './schema'
/**
* Stage C floor-plan builder for fence. 1:1 visual port of the legacy
* `FloorplanFenceLayer` from `floorplan-panel.tsx`:
*
* 1. Three stacked stroke paths along the centerline, all with
* `vectorEffect: 'non-scaling-stroke'` so widths stay constant on
* screen at any zoom:
* a. Optional glow (semi-transparent, only when active/hovered).
* b. White underlay — the visual "fence body" base layer.
* c. Dark accent — the actual fence outline.
* 2. Style-aware markers at computed positions along the centerline:
* - `privacy`: rotated rectangle (vertical slat).
* - `rail`: concentric circle stack (post + ring + tiny center).
* - default `slat`: white X mark with a coloured X on top.
* 3. Markers thinned when `showInfill === false` — only first + last
* remain (matches legacy "endpoints only" mode).
* 4. Selection chrome: dots on the endpoints + centered length label
* (same shape as the wall builder).
*
* `getFloorplanFenceMarkerTs` is inlined here — it was a private helper
* in the legacy panel and is fence-specific, so it lives with the kind.
*/
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function getFloorplanFenceLength(fence: FenceNode): number {
return isCurvedWall(fence)
? getWallCurveLength(fence)
: Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1])
}
/**
* Distribute markers along the fence centerline. Spacing depends on
* `postSpacing` (tighter for privacy style); `inset` keeps the first /
* last marker away from the endpoints. Returns a list of t-values in
* [0, 1] suitable for `getWallCurveFrameAt`.
*/
function getFloorplanFenceMarkerTs(fence: FenceNode): number[] {
const length = getFloorplanFenceLength(fence)
if (length <= 0.24) return [0.5]
const spacing = clamp(
fence.style === 'privacy' ? fence.postSpacing * 0.72 : fence.postSpacing,
0.34,
1.5,
)
const inset = clamp(
Math.max(fence.postSize * 1.25, fence.edgeInset * 10),
0.18,
Math.min(0.48, length * 0.22),
)
const usableLength = Math.max(length - inset * 2, 0)
if (usableLength <= 0.001) return [0.5]
const markerCount = Math.max(1, Math.min(24, Math.floor(usableLength / spacing) + 1))
if (markerCount === 1) return [0.5]
return Array.from({ length: markerCount }, (_, index) =>
clamp((inset + (usableLength * index) / (markerCount - 1)) / length, 0.08, 0.92),
)
}
function buildCenterlinePathD(points: ReadonlyArray<{ x: number; y: number }>): string {
if (points.length < 2) return ''
const first = points[0]!
return [`M ${first.x} ${first.y}`, ...points.slice(1).map((p) => `L ${p.x} ${p.y}`)].join(' ')
}
function buildMarker(
fence: FenceNode,
point: { x: number; y: number },
angleRadians: number,
accentColor: string,
surfaceColor: string,
isActive: boolean,
): FloorplanGeometry {
const markerStrokeWidth = isActive ? 1.65 : 1.35
if (fence.style === 'privacy') {
const w = clamp(fence.postSize * 0.58, 0.038, 0.068)
const h = clamp(Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), 0.1, 0.17)
// Surface plate underneath + accent rectangle on top — gives a clean
// "punched out of the underlay stroke" look at all zooms.
return {
kind: 'group',
transform: { translate: [point.x, point.y], rotate: angleRadians },
children: [
{
kind: 'rect',
x: -(w + 0.032) / 2,
y: -(h + 0.038) / 2,
width: w + 0.032,
height: h + 0.038,
rx: 0.014,
ry: 0.014,
fill: surfaceColor,
},
{
kind: 'rect',
x: -w / 2,
y: -h / 2,
width: w,
height: h,
rx: 0.01,
ry: 0.01,
fill: accentColor,
},
],
}
}
if (fence.style === 'rail') {
const r = clamp(fence.postSize * 0.52, 0.048, 0.078)
return {
kind: 'group',
transform: { translate: [point.x, point.y] },
children: [
{ kind: 'circle', cx: 0, cy: 0, r: r + 0.018, fill: surfaceColor },
{
kind: 'circle',
cx: 0,
cy: 0,
r,
fill: surfaceColor,
stroke: accentColor,
strokeWidth: markerStrokeWidth,
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'circle',
cx: 0,
cy: 0,
r: r * 0.34,
fill: accentColor,
fillOpacity: isActive ? 0.24 : 0.18,
vectorEffect: 'non-scaling-stroke',
},
],
}
}
// Default — slat X mark.
const half = clamp(fence.postSize * 0.42, 0.03, 0.055)
return {
kind: 'group',
transform: { translate: [point.x, point.y], rotate: angleRadians },
children: [
// White underlay so the X shows against any background.
{
kind: 'line',
x1: -half,
y1: -half,
x2: half,
y2: half,
stroke: surfaceColor,
strokeWidth: 2.8,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'line',
x1: half,
y1: -half,
x2: -half,
y2: half,
stroke: surfaceColor,
strokeWidth: 2.8,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
// Accent X on top.
{
kind: 'line',
x1: -half,
y1: -half,
x2: half,
y2: half,
stroke: accentColor,
strokeWidth: markerStrokeWidth,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'line',
x1: half,
y1: -half,
x2: -half,
y2: half,
stroke: accentColor,
strokeWidth: markerStrokeWidth,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
},
],
}
}
export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): FloorplanGeometry {
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const isHovered = view?.hovered ?? false
const isActive = isSelected || isHighlighted
const showInteractiveChrome = isActive || isHovered
// Centerline path — sampled for curved fences so the underlay /
// accent / glow all trace the same shape.
const centerlinePoints = isCurvedWall(node)
? sampleWallCenterline(node, 24)
: [
{ x: node.start[0], y: node.start[1] },
{ x: node.end[0], y: node.end[1] },
]
const pathD = buildCenterlinePathD(centerlinePoints)
// Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke`
// (light blue from the legacy palette, same as walls); otherwise dark
// accent. Mirrors the `fenceStroke` ternary in the legacy panel.
const accentStroke =
isActive && palette
? palette.selectedStroke
: isHovered && palette
? palette.wallHoverStroke
: '#111827'
const glowStroke =
isActive && palette
? palette.selectedStroke
: isHovered && palette
? palette.wallHoverStroke
: accentStroke
const underlayStroke = 'rgba(255, 255, 255, 0.98)'
// Surface (white) for marker plates — themed dark surface would look
// wrong on the white underlay, so we hardcode white here.
const markerSurface = '#ffffff'
// Widths step up on hover (between idle and active) — same pattern the
// legacy panel uses for `fenceUnderlayWidth` / `fenceStrokeWidth`.
const underlayWidth = isActive ? 6.5 : isHovered ? 6 : 5.2
const accentWidth = isActive ? 2.6 : isHovered ? 2.35 : 2.05
// Glow only appears when active or hovered. Opacity gradient matches
// the legacy (0.22 active / 0.14 hover / 0 idle).
const glowOpacity = isActive ? 0.22 : isHovered ? 0.14 : 0
// Marker frames. Filter to first+last when infill is off so the user
// still sees end posts (matches legacy).
const markerTs = getFloorplanFenceMarkerTs(node)
const markerFrames = markerTs.map((t) => {
const frame = getWallCurveFrameAt(node, t)
return {
point: frame.point,
angle: Math.atan2(frame.tangent.y, frame.tangent.x),
}
})
const visibleMarkers =
(node.showInfill ?? true)
? markerFrames
: markerFrames.filter((_, i) => i === 0 || i === markerFrames.length - 1)
const children: FloorplanGeometry[] = []
// 1. Glow (only when active / highlighted / hovered). Wide,
// low-opacity ring. Width steps with the interaction level so hover
// is subtler than active.
if (glowOpacity > 0) {
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: glowStroke,
strokeWidth: isActive ? 9.5 : isHovered ? 8.8 : 8.2,
strokeOpacity: glowOpacity,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
}
// 2. White underlay — visible fence body base layer.
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: underlayStroke,
strokeOpacity: 0.98,
strokeWidth: underlayWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
// 3. Dark accent on top.
children.push({
kind: 'path',
d: pathD,
fill: 'none',
stroke: accentStroke,
strokeWidth: accentWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
// 4. Style-aware markers. Pass `showInteractiveChrome` so hover also
// bumps marker stroke widths slightly (legacy panel does the same).
for (const marker of visibleMarkers) {
children.push(
buildMarker(
node,
marker.point,
marker.angle,
accentStroke,
markerSurface,
showInteractiveChrome,
),
)
}
// 5. Hit-line for click detection.
children.push({
kind: 'hit-line',
x1: node.start[0],
y1: node.start[1],
x2: node.end[0],
y2: node.end[1],
strokeWidthPx: 18,
cursor: 'pointer',
})
// 6. Endpoint handles + length label when selected.
if (isSelected) {
children.push({
kind: 'endpoint-handle',
point: [node.start[0], node.start[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'start' as const },
})
children.push({
kind: 'endpoint-handle',
point: [node.end[0], node.end[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'end' as const },
})
const length = getWallCurveLength(node)
if (length >= 0.1) {
const midX = (node.start[0] + node.end[0]) / 2
const midZ = (node.start[1] + node.end[1]) / 2
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
children.push({
kind: 'dimension-label',
cx: midX,
cy: midZ,
text: `${Number.parseFloat(length.toFixed(2))}m`,
angle: Math.atan2(dz, dx),
})
}
}
return { kind: 'group', children }
}