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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
11015ea1ed
commit
d747d2f0ea
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
isCurvedWall,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Build placement-measurement dimension lines for a door / window
|
||||
* being moved on a wall. Mirrors the legacy
|
||||
* `movingOpeningPlacementMeasurements` in `floorplan-panel.tsx`:
|
||||
*
|
||||
* - Find the previous opening on the same wall (or the wall start
|
||||
* if none) → distance from its right face to this opening's
|
||||
* left face.
|
||||
* - Find the next opening (or wall end) → distance from this
|
||||
* opening's right face to its left face.
|
||||
* - Each renders as a `dimension` primitive offset to the wall's
|
||||
* outer face so the labels don't overlap the wall body.
|
||||
*
|
||||
* Returns an empty array if the parent isn't a wall, the wall is
|
||||
* curved, or the opening is at wall length 0 (invalid).
|
||||
*/
|
||||
export function buildOpeningPlacementDimensions(
|
||||
opening: DoorNode | WindowNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry[] {
|
||||
const wall = ctx.parent as WallNode | null
|
||||
if (!wall || wall.type !== 'wall') return []
|
||||
if (isCurvedWall(wall)) return []
|
||||
|
||||
const [x1, z1] = wall.start
|
||||
const [x2, z2] = wall.end
|
||||
const dx = x2 - x1
|
||||
const dz = z2 - z1
|
||||
const wallLength = Math.hypot(dx, dz)
|
||||
if (wallLength < 1e-6) return []
|
||||
|
||||
const dirX = dx / wallLength
|
||||
const dirZ = dz / wallLength
|
||||
|
||||
// Outward normal — chosen by the wall builder via the level
|
||||
// centroid. We replicate that decision here so the dimension lines
|
||||
// land on the same face. Walk wall's siblings (the level's other
|
||||
// walls) via ctx.resolve to compute the centroid.
|
||||
const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ)
|
||||
|
||||
const halfWidth = opening.width / 2
|
||||
const startDist = opening.position[0] - halfWidth
|
||||
const endDist = opening.position[0] + halfWidth
|
||||
|
||||
// Walk wall.children to find adjacent openings (door OR window).
|
||||
// ctx.siblings only includes same-kind nodes; doors + windows need
|
||||
// each other so we go via the parent's children directly.
|
||||
const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[]
|
||||
let leftBoundary: number | null = null
|
||||
let rightBoundary: number | null = null
|
||||
for (const childId of childIds) {
|
||||
if (childId === opening.id) continue
|
||||
const sibling = ctx.resolve(childId) as AnyNode | undefined
|
||||
if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue
|
||||
const sib = sibling as DoorNode | WindowNode
|
||||
const sibStart = sib.position[0] - sib.width / 2
|
||||
const sibEnd = sib.position[0] + sib.width / 2
|
||||
if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) {
|
||||
leftBoundary = sibEnd
|
||||
}
|
||||
if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) {
|
||||
rightBoundary = sibStart
|
||||
}
|
||||
}
|
||||
|
||||
const leftFromDist = leftBoundary ?? 0
|
||||
const rightToDist = rightBoundary ?? wallLength
|
||||
|
||||
// Place the dimension line at a constant offset from the wall's
|
||||
// outer face — same value the legacy uses for its placement
|
||||
// measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The
|
||||
// dimension's `start` / `end` are points on that outer face (not
|
||||
// the wall centerline), so the extension lines stay short and the
|
||||
// overall layout matches the legacy treatment 1:1.
|
||||
const wallThickness = wall.thickness ?? 0.1
|
||||
const halfThickness = wallThickness / 2
|
||||
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
|
||||
|
||||
// Project a point on the wall axis at distance `along` onto the
|
||||
// wall's outer face by adding `halfThickness * outwardNormal`.
|
||||
const facePoint = (along: number): readonly [number, number] => [
|
||||
x1 + dirX * along + outwardNormal[0] * halfThickness,
|
||||
z1 + dirZ * along + outwardNormal[1] * halfThickness,
|
||||
]
|
||||
|
||||
const out: FloorplanGeometry[] = []
|
||||
|
||||
const leftDistance = startDist - leftFromDist
|
||||
if (leftDistance >= 0.01) {
|
||||
out.push({
|
||||
kind: 'dimension',
|
||||
start: facePoint(leftFromDist),
|
||||
end: facePoint(startDist),
|
||||
offsetNormal: outwardNormal,
|
||||
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
|
||||
extensionOvershoot: 0.12,
|
||||
text: `${Number.parseFloat(leftDistance.toFixed(2))}m`,
|
||||
stroke: '#f97316',
|
||||
})
|
||||
}
|
||||
|
||||
const rightDistance = rightToDist - endDist
|
||||
if (rightDistance >= 0.01) {
|
||||
out.push({
|
||||
kind: 'dimension',
|
||||
start: facePoint(endDist),
|
||||
end: facePoint(rightToDist),
|
||||
offsetNormal: outwardNormal,
|
||||
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
|
||||
extensionOvershoot: 0.12,
|
||||
text: `${Number.parseFloat(rightDistance.toFixed(2))}m`,
|
||||
stroke: '#f97316',
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the perpendicular wall normal that points away from the
|
||||
* other walls' centroid — same logic the wall builder uses to place
|
||||
* its own dimension overlay so left / right placement dimensions land
|
||||
* on the same face the wall label is on.
|
||||
*/
|
||||
function computeOutwardNormal(
|
||||
wall: WallNode,
|
||||
ctx: GeometryContext,
|
||||
dirX: number,
|
||||
dirZ: number,
|
||||
): readonly [number, number] {
|
||||
const nx = -dirZ
|
||||
const nz = dirX
|
||||
|
||||
// Find the level by walking up via wall.parentId.
|
||||
const level = wall.parentId
|
||||
? (ctx.resolve(wall.parentId as AnyNodeId) as AnyNode | undefined)
|
||||
: null
|
||||
const levelChildren = ((level as unknown as { children?: AnyNodeId[] })?.children ??
|
||||
[]) as AnyNodeId[]
|
||||
let sumX = 0
|
||||
let sumZ = 0
|
||||
let count = 0
|
||||
for (const childId of levelChildren) {
|
||||
const child = ctx.resolve(childId) as AnyNode | undefined
|
||||
if (!child || child.type !== 'wall') continue
|
||||
const w = child as WallNode
|
||||
sumX += w.start[0] + w.end[0]
|
||||
sumZ += w.start[1] + w.end[1]
|
||||
count += 2
|
||||
}
|
||||
if (count === 0) return [nx, nz]
|
||||
|
||||
const centroidX = sumX / count
|
||||
const centroidZ = sumZ / count
|
||||
const wallMidX = (wall.start[0] + wall.end[0]) / 2
|
||||
const wallMidZ = (wall.start[1] + wall.end[1]) / 2
|
||||
const fromCentroidX = wallMidX - centroidX
|
||||
const fromCentroidZ = wallMidZ - centroidZ
|
||||
const facingAway = fromCentroidX * nx + fromCentroidZ * nz >= 0 ? 1 : -1
|
||||
return [nx * facingAway, nz * facingAway]
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Shared "edit polygon" floor-plan affordances. Used by kinds whose
|
||||
* primary editable shape is a `polygon: [number, number][]` field
|
||||
* (slab, ceiling, site, zone) with optional `holes: [number, number][][]`.
|
||||
*
|
||||
* Each affordance accepts an optional `holeIndex` in its payload — when
|
||||
* present, the operation targets `node.holes[holeIndex]`; otherwise it
|
||||
* targets the outer `node.polygon`. The same factory wires both
|
||||
* boundary and hole interactions without duplicating the math.
|
||||
*
|
||||
* Three affordances available:
|
||||
*
|
||||
* - `move-vertex` — drag an existing vertex.
|
||||
* - `add-vertex` — insert a new vertex at an edge midpoint, then drag
|
||||
* it (click-without-drag reverts to the snapshot).
|
||||
* - `move-edge` — drag a whole edge perpendicular to itself (both
|
||||
* endpoints translate by `normal * projection`).
|
||||
*/
|
||||
|
||||
export type PolygonVertexPayload = {
|
||||
/** Target a hole's polygon instead of the boundary. */
|
||||
holeIndex?: number
|
||||
vertexIndex: number
|
||||
}
|
||||
|
||||
export type AddVertexPayload = {
|
||||
holeIndex?: number
|
||||
edgeIndex: number
|
||||
}
|
||||
|
||||
export type EdgeDragPayload = {
|
||||
holeIndex?: number
|
||||
edgeIndex: number
|
||||
}
|
||||
|
||||
type PolygonShape = {
|
||||
polygon: ReadonlyArray<readonly [number, number]>
|
||||
holes?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>
|
||||
}
|
||||
|
||||
function getRing(node: PolygonShape, holeIndex: number | undefined): [number, number][] | null {
|
||||
if (holeIndex === undefined) {
|
||||
return node.polygon.map(([x, y]) => [x, y] as [number, number])
|
||||
}
|
||||
const hole = node.holes?.[holeIndex]
|
||||
if (!hole) return null
|
||||
return hole.map(([x, y]) => [x, y] as [number, number])
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a patch object that, when applied to the node, updates the
|
||||
* targeted ring (boundary polygon or specific hole) to `nextRing`. The
|
||||
* cast through `unknown → Partial<unknown> → never` satisfies the
|
||||
* generic `updateNodes` patch type without forcing every variant of
|
||||
* the kind union into scope here.
|
||||
*/
|
||||
function buildRingPatch(
|
||||
node: PolygonShape,
|
||||
holeIndex: number | undefined,
|
||||
nextRing: ReadonlyArray<[number, number]>,
|
||||
): unknown {
|
||||
if (holeIndex === undefined) {
|
||||
return { polygon: nextRing }
|
||||
}
|
||||
const nextHoles = (node.holes ?? []).map((hole, i) =>
|
||||
i === holeIndex ? nextRing : hole.map(([x, y]) => [x, y] as [number, number]),
|
||||
)
|
||||
return { holes: nextHoles }
|
||||
}
|
||||
|
||||
export function createPolygonVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
|
||||
kind: string,
|
||||
): FloorplanAffordance<N> {
|
||||
return {
|
||||
start({ node, payload }): FloorplanAffordanceSession {
|
||||
const { vertexIndex, holeIndex } = payload as PolygonVertexPayload
|
||||
const originalRing = getRing(node, holeIndex)
|
||||
if (!originalRing) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit() {
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
? (planPoint as WallPlanPoint)
|
||||
: snapPointToGrid(planPoint as WallPlanPoint)
|
||||
const nextRing: [number, number][] = originalRing.map((p, i) =>
|
||||
i === vertexIndex ? [snapped[0], snapped[1]] : p,
|
||||
)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
},
|
||||
canCommit() {
|
||||
const final = useScene.getState().nodes[node.id] as N | undefined
|
||||
if (!final || (final as unknown as { type: string }).type !== kind) return false
|
||||
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
|
||||
return !!finalRing && finalRing.length >= 3
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion to `createPolygonVertexAffordance`. Inserts a new vertex at
|
||||
* the midpoint of edge `edgeIndex` (between vertices i and i+1) and
|
||||
* then drags that new vertex with the pointer. The dispatcher's
|
||||
* snapshot was taken **before** `start()` ran, so a pointer-up without
|
||||
* movement reverts to the pre-insert ring — "click without drag" is a
|
||||
* no-op, matching the legacy slab boundary editor.
|
||||
*/
|
||||
export function createPolygonAddVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
|
||||
kind: string,
|
||||
): FloorplanAffordance<N> {
|
||||
return {
|
||||
start({ node, payload }): FloorplanAffordanceSession {
|
||||
const { edgeIndex, holeIndex } = payload as AddVertexPayload
|
||||
const originalRing = getRing(node, holeIndex)
|
||||
if (!originalRing) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit() {
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
const a = originalRing[edgeIndex]
|
||||
const b = originalRing[(edgeIndex + 1) % originalRing.length]
|
||||
if (!a || !b) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit() {
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
const midpoint: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
const newVertexIndex = edgeIndex + 1
|
||||
const initialRing: [number, number][] = [
|
||||
...originalRing.slice(0, newVertexIndex),
|
||||
midpoint,
|
||||
...originalRing.slice(newVertexIndex),
|
||||
]
|
||||
|
||||
// Apply the insert immediately so the user sees the new vertex
|
||||
// before they even move.
|
||||
const initialPatch = buildRingPatch(node, holeIndex, initialRing)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: initialPatch as Partial<unknown> as never }])
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
? (planPoint as WallPlanPoint)
|
||||
: snapPointToGrid(planPoint as WallPlanPoint)
|
||||
const nextRing: [number, number][] = initialRing.map((p, i) =>
|
||||
i === newVertexIndex ? [snapped[0], snapped[1]] : p,
|
||||
)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
},
|
||||
canCommit() {
|
||||
const final = useScene.getState().nodes[node.id] as N | undefined
|
||||
if (!final || (final as unknown as { type: string }).type !== kind) return false
|
||||
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
|
||||
return !!finalRing && finalRing.length >= 3
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge-drag: move a whole edge perpendicular to itself. Both endpoints
|
||||
* translate by `edgeNormal * projectedDelta`. The other vertices of
|
||||
* the ring stay put — adjacent edges effectively pivot around their
|
||||
* far endpoints.
|
||||
*
|
||||
* Snap is grid-aligned on the projected scalar (so a Shift-free drag
|
||||
* lands on grid lines along the edge normal).
|
||||
*/
|
||||
export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: AnyNodeId }>(
|
||||
kind: string,
|
||||
): FloorplanAffordance<N> {
|
||||
return {
|
||||
start({ node, payload, initialPlanPoint }): FloorplanAffordanceSession {
|
||||
const { edgeIndex, holeIndex } = payload as EdgeDragPayload
|
||||
const originalRing = getRing(node, holeIndex)
|
||||
if (!originalRing) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit() {
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
const startVertex = originalRing[edgeIndex]
|
||||
const endVertex = originalRing[(edgeIndex + 1) % originalRing.length]
|
||||
if (!startVertex || !endVertex) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit() {
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
const dx = endVertex[0] - startVertex[0]
|
||||
const dy = endVertex[1] - startVertex[1]
|
||||
const len = Math.hypot(dx, dy)
|
||||
if (len < 1e-6) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit() {
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
// Perpendicular unit normal (rotate 90° CCW).
|
||||
const normalX = -dy / len
|
||||
const normalY = dx / len
|
||||
const startX = initialPlanPoint[0]
|
||||
const startY = initialPlanPoint[1]
|
||||
const edgeStartIndex = edgeIndex
|
||||
const edgeEndIndex = (edgeIndex + 1) % originalRing.length
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Project the pointer delta onto the edge normal — that's the
|
||||
// signed perpendicular distance the edge should travel.
|
||||
const deltaX = planPoint[0] - startX
|
||||
const deltaY = planPoint[1] - startY
|
||||
let projection = deltaX * normalX + deltaY * normalY
|
||||
if (!modifiers.shiftKey) {
|
||||
// Snap the projection scalar to a 0.5m grid (legacy uses the
|
||||
// same half-meter snap for slab edges).
|
||||
projection = Math.round(projection * 2) / 2
|
||||
}
|
||||
const nextRing: [number, number][] = originalRing.map((p, i) => {
|
||||
if (i === edgeStartIndex || i === edgeEndIndex) {
|
||||
return [p[0] + normalX * projection, p[1] + normalY * projection]
|
||||
}
|
||||
return [p[0], p[1]] as [number, number]
|
||||
})
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
},
|
||||
canCommit() {
|
||||
const final = useScene.getState().nodes[node.id] as N | undefined
|
||||
if (!final || (final as unknown as { type: string }).type !== kind) return false
|
||||
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
|
||||
return !!finalRing && finalRing.length >= 3
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { type AnyNode, type AnyNodeId, isCurvedWall, type WallNode } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Shared helpers for the kinds whose 2D move snaps onto a wall in plan
|
||||
* space (door, window, item with `attachTo === 'wall' | 'wall-side'`).
|
||||
*
|
||||
* The 3D move tools listen to R3F `WallEvent`s (mesh-hit with normal)
|
||||
* for wall snapping. The 2D path doesn't have that — pointer events
|
||||
* land on the SVG layer, not on the wall meshes. This helper does the
|
||||
* equivalent plan-space projection: for each wall on the level, find
|
||||
* the perpendicular projection of the pointer onto the wall line and
|
||||
* pick the closest one within a reasonable range.
|
||||
*
|
||||
* Curved walls are excluded — the legacy door / window placement also
|
||||
* rejects curved walls (mitering + arc + opening would tear in 3D).
|
||||
*/
|
||||
|
||||
const WALL_SNAP_DISTANCE_M = 1.5
|
||||
|
||||
export type WallHit = {
|
||||
wall: WallNode
|
||||
/** Distance along the wall from `start` (clamped to [0, length]). */
|
||||
localX: number
|
||||
/** Signed perpendicular distance from the wall axis (+ on the "front" side). */
|
||||
perpDistance: number
|
||||
/** Which face of the wall the pointer was on. */
|
||||
side: 'front' | 'back'
|
||||
/** Wall direction unit vector, x. */
|
||||
dirX: number
|
||||
/** Wall direction unit vector, y (== z in plan). */
|
||||
dirY: number
|
||||
/** Wall length in metres. */
|
||||
wallLength: number
|
||||
/**
|
||||
* Rotation around Y in **wall-local** space — 0 for the front face,
|
||||
* π for the back. Matches the 3D `calculateItemRotation(normal)`
|
||||
* convention (normal +Z → 0, normal -Z → π). Items / doors / windows
|
||||
* are children of the wall mesh, so their `rotation.y` is in the
|
||||
* wall's local frame; writing a world-space rotation here would mis-
|
||||
* orient the node by `wallRotation` (off by 90° on vertical walls).
|
||||
*/
|
||||
itemRotation: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every wall under `parentLevelId` and return the closest one to
|
||||
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
|
||||
* `excludeWallId` skips a specific wall (e.g. the current parent during
|
||||
* a re-parent flow if you want a "must change" guard).
|
||||
*/
|
||||
export function findClosestWallInPlan(
|
||||
planPoint: readonly [number, number],
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
parentLevelId: AnyNodeId | null,
|
||||
excludeWallId?: AnyNodeId,
|
||||
): WallHit | null {
|
||||
if (!parentLevelId) return null
|
||||
const level = nodes[parentLevelId]
|
||||
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
|
||||
if (!Array.isArray(childIds)) return null
|
||||
|
||||
let best: WallHit | null = null
|
||||
|
||||
for (const childId of childIds) {
|
||||
const node = nodes[childId]
|
||||
if (!node || node.type !== 'wall') continue
|
||||
if (childId === excludeWallId) continue
|
||||
const wall = node as WallNode
|
||||
if (isCurvedWall(wall)) continue
|
||||
|
||||
const sx = wall.start[0]
|
||||
const sy = wall.start[1]
|
||||
const dx = wall.end[0] - sx
|
||||
const dy = wall.end[1] - sy
|
||||
const wallLength = Math.hypot(dx, dy)
|
||||
if (wallLength < 1e-6) continue
|
||||
|
||||
const dirX = dx / wallLength
|
||||
const dirY = dy / wallLength
|
||||
|
||||
// Project pointer onto wall axis.
|
||||
const px = planPoint[0] - sx
|
||||
const py = planPoint[1] - sy
|
||||
const along = px * dirX + py * dirY
|
||||
const perpRaw = px * -dirY + py * dirX // signed perpendicular distance
|
||||
const clampedAlong = Math.max(0, Math.min(wallLength, along))
|
||||
|
||||
// Distance from the pointer to the wall segment (not just the line).
|
||||
const closestPointX = sx + dirX * clampedAlong
|
||||
const closestPointY = sy + dirY * clampedAlong
|
||||
const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY)
|
||||
if (distance > WALL_SNAP_DISTANCE_M) continue
|
||||
if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue
|
||||
|
||||
// Side determination, calibrated to the 3D wall convention. In
|
||||
// wall-local space the wall extends along +X and its +Z axis is the
|
||||
// front-face normal. After `mesh.rotation.y = -wallAngle`:
|
||||
// - For a wall going `+X` in plan (wallAngle=0): wall-local +Z
|
||||
// maps to world +Z = plan +Y, so the front face is on plan +Y.
|
||||
// `perpRaw = py` is positive → front.
|
||||
// - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z
|
||||
// maps to world -X = plan -X, so the front face is on plan -X.
|
||||
// `perpRaw = -px` is positive there → front.
|
||||
// So `perpRaw >= 0` is consistently the front side. The earlier
|
||||
// labelling had this flipped, which produced rotations that were
|
||||
// off by 90° on non-horizontal walls.
|
||||
const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back'
|
||||
|
||||
// Rotation in wall-local space — matches 3D `calculateItemRotation`:
|
||||
// 0 when the item faces the front normal (+Z), π for the back. The
|
||||
// node is parented to the wall, so this composes with the wall's
|
||||
// own rotation when rendered. Don't return a world-space rotation
|
||||
// here — the consumer writes this straight into `node.rotation[1]`.
|
||||
const itemRotation = side === 'front' ? 0 : Math.PI
|
||||
|
||||
best = {
|
||||
wall,
|
||||
localX: clampedAlong,
|
||||
perpDistance: perpRaw,
|
||||
side,
|
||||
dirX,
|
||||
dirY,
|
||||
wallLength,
|
||||
itemRotation,
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
Reference in New Issue
Block a user