Files
editor/packages/nodes/src/fence/floorplan-affordances.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

133 lines
4.8 KiB
TypeScript

import {
type AnyNode,
type AnyNodeId,
type FenceNode,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useScene,
type WallNode,
} from '@pascal-app/core'
import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor'
/**
* Floor-plan 2D drag affordances for fence — sister to the 3D
* `actions/move-endpoint.ts` `DragAction`. Same legacy interaction
* (endpoint snap pipeline + linked-fence cascade via `endpoint-match`
* with an epsilon, ALT-detach), driven from SVG pointer events instead
* of R3F grid events.
*
* Why not share the `DragAction`? The 3D code goes through
* `createDragSession` which assumes a `SceneApi`-style helper bag
* (snapshot, restoreAll, pauseHistory, resumeHistory). The 2D registry
* layer owns those semantics directly via the dispatcher's snapshot +
* pause/resume dance, so the affordance only needs the pure mutation
* logic. The shape is intentionally close to the legacy fence drag —
* 1:1 behaviorally.
*/
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
type FenceEndpointPayload = { fenceId: AnyNodeId; endpoint: 'start' | 'end' }
function pointsNearlyEqual(a: FencePlanPoint, b: FencePlanPoint): boolean {
return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
}
function collectLevel(
nodes: Record<AnyNodeId, AnyNode>,
parentId: string | null,
): { walls: WallNode[]; fences: FenceNode[] } {
const walls: WallNode[] = []
const fences: FenceNode[] = []
for (const node of Object.values(nodes)) {
if (!node) continue
if ((node.parentId ?? null) !== parentId) continue
if (node.type === 'wall') walls.push(node as WallNode)
else if (node.type === 'fence') fences.push(node as FenceNode)
}
return { walls, fences }
}
function collectLinkedFences(
fences: FenceNode[],
draggedFenceId: AnyNodeId,
linkedPoint: FencePlanPoint,
): Array<{ id: AnyNodeId; start: FencePlanPoint; end: FencePlanPoint }> {
const out: Array<{ id: AnyNodeId; start: FencePlanPoint; end: FencePlanPoint }> = []
for (const fence of fences) {
if (fence.id === draggedFenceId) continue
if (!pointsNearlyEqual(fence.start, linkedPoint) && !pointsNearlyEqual(fence.end, linkedPoint))
continue
out.push({
id: fence.id,
start: [fence.start[0], fence.start[1]],
end: [fence.end[0], fence.end[1]],
})
}
return out
}
export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { endpoint } = payload as FenceEndpointPayload
const originalStart: FencePlanPoint = [node.start[0], node.start[1]]
const originalEnd: FencePlanPoint = [node.end[0], node.end[1]]
const originalMovingPoint = endpoint === 'start' ? originalStart : originalEnd
const fixedPoint: FencePlanPoint = endpoint === 'start' ? originalEnd : originalStart
const parentId = node.parentId ?? null
const { walls, fences } = collectLevel(nodes, parentId)
const linkedOriginals = collectLinkedFences(fences, node.id, originalMovingPoint)
const affectedIds: AnyNodeId[] = [node.id, ...linkedOriginals.map((l) => l.id)]
return {
affectedIds,
apply({ planPoint, modifiers }) {
// Re-collect siblings each tick: the user might be dragging a
// fence whose sibling positions changed (the dragged fence
// itself is excluded via `ignoreFenceIds`).
const sceneNodes = useScene.getState().nodes
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint,
walls: nextWalls,
fences: nextFences,
start: fixedPoint,
angleSnap: !modifiers.shiftKey,
ignoreFenceIds: [node.id],
})
const nextStart = endpoint === 'start' ? snapped : fixedPoint
const nextEnd = endpoint === 'end' ? snapped : fixedPoint
const linkedUpdates = modifiers.altKey
? []
: linkedOriginals.map((l) => ({
id: l.id,
start: pointsNearlyEqual(l.start, originalMovingPoint) ? snapped : l.start,
end: pointsNearlyEqual(l.end, originalMovingPoint) ? snapped : l.end,
}))
useScene.getState().updateNodes([
{ id: node.id, data: { start: nextStart, end: nextEnd } },
...linkedUpdates.map((u) => ({
id: u.id,
data: { start: u.start, end: u.end },
})),
])
},
canCommit() {
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
return (
!!finalFence &&
finalFence.type === 'fence' &&
isWallLongEnough(finalFence.start, finalFence.end)
)
},
}
},
}