ai-chatbot/packages/nodes/src/shared/opening-guides-runtime.ts
Aymeric Rabot 69aa272004 perf(editor): stabilize 3D opening-guide rendering, remove per-tick GPU churn
Reuse one THREE.Line + preallocated position buffer per guide slot, mutating
endpoints in place each drag tick instead of rebuilding the geometry, line, and
two Vector3s and re-uploading the GPU buffer every frame. Key guides by a stable
semantic id (sill / head / gap:side / vertical / spacing:i) so a slot that
persists keeps its React element and drei <Html> pill mounted as the guide set
churns, rather than remounting under shifting index keys.

Also: make useOpeningGuides.clear() a no-op when already empty so the common
no-guide hover frame doesn't push a fresh [] and re-render to the same nothing;
dispose the move-tool cursor EdgesGeometry on unmount; and memoize the
placement-tool cursor EdgesGeometry (static fallback dims) so it isn't
reallocated and orphaned on every render during placement.

Reviewed by Codex (peer + adversarial): no correctness, hook-order, or GPU-leak
regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:36:06 -04:00

260 lines
8.2 KiB
TypeScript

// Runtime glue between the pure `computeOpeningGuides` geometry (core) and the
// editor's 3D guide store, used by the door/window move + placement tools. Lives
// in `nodes` (not core) because it talks to the editor store; kept thin so each
// tool's per-tick hook is a single call.
import {
type AnyNode,
type AnyNodeId,
computeOpeningGuides,
detectVerticalAlignment,
type OpeningSpan,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
} from '@pascal-app/core'
import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor'
// Parity with `snapLocalXToNeighbors`' along-wall threshold.
const SILL_SNAP_THRESHOLD_M = 0.08
// Hide a dimension that has collapsed to nothing (sill flush to the floor, or
// head flush to the wall top) so it doesn't render a zero-length "0m" pill.
const MIN_DIMENSION_M = 0.02
/** Maps a wall-local point (s along the wall, y above the wall base) to the move
* tool's render frame — the caller passes its own `wallLocalToWorld` closure so
* the guides land in exactly the same (building-local) frame as the drag cursor. */
type ToWorld = (s: number, y: number) => [number, number, number]
/** The moving opening's same-wall neighbours, as wall-local spans. */
export function collectOpeningSiblings(
wall: WallNode,
movingId: string,
nodes: Record<string, AnyNode>,
): OpeningSpan[] {
const out: OpeningSpan[] = []
const childIds = Array.isArray(wall.children) ? wall.children : []
for (const childId of childIds) {
if (childId === movingId) continue
const node = nodes[childId as AnyNodeId]
if (!node || (node.type !== 'door' && node.type !== 'window')) continue
out.push({
id: node.id,
centerS: node.position[0],
width: node.width,
centerY: node.position[1],
height: node.height,
})
}
return out
}
/**
* Vertical sill/centre/top snap for a window — the chosen "snap + guide"
* behaviour. Returns the snapped wall-local Y when a sibling sill/centre/top is
* within threshold, else null so the caller falls back to the grid. Mirrors
* `snapLocalXToNeighbors` on the vertical axis.
*/
export function resolveSillSnap(args: {
wall: WallNode
movingId: string
localX: number
localY: number
width: number
height: number
nodes: Record<string, AnyNode>
}): number | null {
const siblings = collectOpeningSiblings(args.wall, args.movingId, args.nodes)
const match = detectVerticalAlignment(
{
id: args.movingId,
centerS: args.localX,
width: args.width,
centerY: args.localY,
height: args.height,
},
siblings,
SILL_SNAP_THRESHOLD_M,
)
return match ? args.localY + match.snap : null
}
/** Compute and publish the 3D opening guides for the current drag tick. */
export function publishOpeningGuides3D(args: {
wall: WallNode
movingId: string
centerS: number
centerY: number
width: number
height: number
includeVertical: boolean
toWorld: ToWorld
nodes: Record<string, AnyNode>
}): void {
const { wall, centerS, centerY, width, toWorld } = args
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
const wallHeight = wall.height ?? 2.5
const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes)
const guides = computeOpeningGuides({
moving: { id: args.movingId, centerS, width, centerY, height: args.height },
siblings,
wall: { length: wallLength, height: wallHeight },
includeVertical: args.includeVertical,
})
const out: OpeningGuide3D[] = []
// Stable `id`s keyed on the guide's semantic role (not list position) so the
// 3D layer can keep a persisting slot's element + `<Html>` pill mounted as the
// set churns each tick — see `OpeningGuide3D`.
if (guides.sillHead) {
if (guides.sillHead.sill > MIN_DIMENSION_M) {
out.push({
kind: 'dimension',
id: 'sill',
from: toWorld(centerS, 0),
to: toWorld(centerS, guides.sillHead.bottomY),
value: guides.sillHead.sill,
})
}
if (guides.sillHead.head > MIN_DIMENSION_M) {
out.push({
kind: 'dimension',
id: 'head',
from: toWorld(centerS, guides.sillHead.topY),
to: toWorld(centerS, wallHeight),
value: guides.sillHead.head,
})
}
}
for (const gap of guides.gaps) {
out.push({
kind: 'dimension',
id: `gap:${gap.side}`,
from: toWorld(gap.fromS, centerY),
to: toWorld(gap.toS, centerY),
value: gap.distance,
})
}
if (guides.vertical) {
const target = siblings.find((s) => s.id === guides.vertical?.targetId)
if (target) {
const lo = Math.min(centerS - width / 2, target.centerS - target.width / 2)
const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2)
out.push({
kind: 'align-line',
id: 'vertical',
from: toWorld(lo, guides.vertical.y),
to: toWorld(hi, guides.vertical.y),
})
}
}
if (guides.equalSpacing) {
const { gap, segments } = guides.equalSpacing
segments.forEach((seg, i) => {
out.push({
kind: 'badge',
id: `spacing:${i}`,
at: toWorld((seg.fromS + seg.toS) / 2, centerY),
value: gap,
})
})
}
useOpeningGuides.getState().set(out)
}
export function clearOpeningGuides3D(): void {
useOpeningGuides.getState().clear()
}
/** Wall-local (s along the wall, y above the wall base) → the move tool's render
* frame, given the level Y offset + slab elevation. Shared by the wall-event
* publisher (which already has them) and the resize publisher (which derives
* them from the scene). Same frame as `wallLocalToWorld`. */
function makeWallToWorld(wall: WallNode, levelYOffset: number, slabElevation: number): ToWorld {
const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return (s, y) => [
wall.start[0] + s * cos,
slabElevation + y + levelYOffset,
wall.start[1] + s * sin,
]
}
/** Like {@link makeWallToWorld} but derives the level Y + slab elevation from the
* scene, for callers without a wall event — i.e. the resize handles. */
export function wallToWorld(wall: WallNode): ToWorld {
const levelId = wall.parentId as AnyNodeId | undefined
const levelYOffset = levelId ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) : 0
const slabElevation = spatialGridManager.getSlabElevationForWall(
wall.parentId ?? '',
wall.start,
wall.end,
)
return makeWallToWorld(wall, levelYOffset, slabElevation)
}
/**
* Publish 3D opening guides for an opening being placed or moved on a wall via a
* wall event. The caller passes the level Y + slab elevation it already computed
* for the drag cursor, so the guides share the cursor's frame exactly — the one
* place the door/window move + placement tools publish from.
*/
export function publishOpeningGuidesForWallEvent(args: {
wall: WallNode
movingId: string
centerS: number
centerY: number
width: number
height: number
includeVertical: boolean
levelYOffset: number
slabElevation: number
}): void {
const { wall, levelYOffset, slabElevation, ...rest } = args
publishOpeningGuides3D({
...rest,
wall,
nodes: useScene.getState().nodes,
toWorld: makeWallToWorld(wall, levelYOffset, slabElevation),
})
}
/**
* Publish 3D opening guides for an opening being RESIZED via a handle arrow.
* Resolves the host wall + transform from the scene (no wall event), then reuses
* the shared publish. Doors pass `includeVertical: false` (they sit on the
* floor); windows pass `true` so a height drag also shows the live sill/head.
*/
export function publishOpeningResizeGuides(
node: {
id: string
parentId?: string | null
position: readonly [number, number, number]
width: number
height: number
},
includeVertical: boolean,
): void {
const nodes = useScene.getState().nodes
const wall = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined
if (wall?.type !== 'wall') return
publishOpeningGuides3D({
wall,
movingId: node.id,
centerS: node.position[0],
centerY: node.position[1],
width: node.width,
height: node.height,
includeVertical,
nodes,
toWorld: wallToWorld(wall),
})
}