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>
377 lines
11 KiB
TypeScript
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 }
|
|
}
|