Wassim SAMAD f4ea07e05b Phase 5 Stage D moves: live-drag mesh.position for slab / ceiling / fence
User-reported regressions on the 1:1 legacy ports: slab/ceiling moves
were slow (polygon CSG rebuilds per scene.update tick), fence moves
teleported briefly on commit (residual mesh.position offset survived
the geometry rebuild). All three now use the same live-drag pattern
the legacy fence move was designed for:

- During drag, write only to `sceneRegistry.nodes.get(id).position`
  + `useLiveTransforms`. No `scene.update`, no polygon rebuild, no
  React re-render of geometry.
- History stays UNPAUSED — scene state isn't changing.
- On commit, a single `scene.update` writes the translated
  polygon (or fence start/end + linked-fence cascade). Recorded as one
  natural undo step.
- Tools leave `mesh.position` at the drag delta on commit;
  GeometrySystem / CeilingSystem reset it to (0,0,0) when they
  rebuild the geometry on the next frame. By the time position
  clears, the new geometry is in place — no teleport.

Two framework changes enable this:
- `GeometrySystem` (viewer/systems/geometry) now resets
  `group.position` + `group.rotation` after every rebuild, matching
  the legacy `FenceSystem.updateFenceGeometry` behavior. Tools that
  translate the group during live-drag can rely on the reset.
- Legacy `CeilingSystem.updateCeilingGeometry` extends its existing
  `position.y` reset to cover X/Z too — previously it left X/Z at the
  drag delta after rebuild, double-translating the visual.

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

149 lines
5.4 KiB
TypeScript

'use client'
import {
type AnyNode,
type AnyNodeId,
type GeometryContext,
nodeRegistry,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import type { Group, Mesh } from 'three'
/**
* Generic geometry system.
*
* For every node in `dirtyNodes` whose definition exposes `def.geometry`,
* this system:
* 1. Looks up the registered `Group` from `sceneRegistry` (mounted by the
* framework's `<ParametricNodeRenderer>`, or a custom renderer that
* opts into the same mount contract).
* 2. Builds a `GeometryContext` from the current scene snapshot.
* 3. Calls `def.geometry(node, ctx)` to get the new `Object3D`.
* 4. Disposes the registered group's existing children + their geometries
* and materials.
* 5. Reparents the returned object's children onto the registered group.
* 6. Clears the dirty flag.
*
* This is the "no per-kind system needed" path documented in
* `wiki/architecture/node-definitions.md`. A kind that only rebuilds on
* dirty (shelf, item, fence segment, etc.) ships nothing more than a pure
* `geometry` function — no `renderer.tsx`, no `system.tsx`.
*
* Kinds with `def.system` declared run their own systems *in addition* to
* this one — animation + cascade + named-mesh material poking stay
* kind-specific.
*
* Frame priority 2 mirrors the per-kind shelf system it replaces. Door
* animation systems run at priority 2 today too, marking dirty so the
* geometry rebuild lands at priority 3-4 next frame. Door/window/wall
* still have their own systems (they need cross-cutting work this system
* doesn't cover) — they coexist; this system only acts on kinds that
* declare `def.geometry`.
*/
export const GeometrySystem = () => {
const dirtyNodes = useScene((s) => s.dirtyNodes)
const clearDirty = useScene((s) => s.clearDirty)
useFrame(() => {
if (dirtyNodes.size === 0) return
const nodes = useScene.getState().nodes
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node) return
const def = nodeRegistry.get(node.type)
const builder = def?.geometry
if (!builder) return
const group = sceneRegistry.nodes.get(id) as Group | undefined
if (!group) return // mount hasn't run — keep dirty for next frame
const ctx = buildGeometryContext(node, nodes)
// The builder is typed against the kind's specific node — at the
// generic system level we lose that refinement, so the cast lands
// here. Builders are responsible for trusting their schema.
const built = (builder as (n: AnyNode, c: GeometryContext) => { children: unknown[] })(
node,
ctx,
) as unknown as Group
disposeChildren(group)
for (const child of [...built.children]) {
group.add(child)
}
// Reset transform — matches the legacy per-kind systems
// (e.g. FenceSystem.updateFenceGeometry) which clear
// mesh.position/rotation when rebuilding. Tools that translate
// the group via `mesh.position` for live-drag visuals rely on
// this reset to restore the canonical position after scene
// state catches up (otherwise the geometry rebuild renders on
// top of the residual offset → double-translation teleport).
group.position.set(0, 0, 0)
group.rotation.set(0, 0, 0)
clearDirty(id as AnyNodeId)
})
}, 2)
return null
}
function buildGeometryContext(node: AnyNode, nodes: Record<string, AnyNode>): GeometryContext {
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
const children: AnyNode[] = Array.isArray(childIds)
? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
: []
const parentId = node.parentId as AnyNodeId | null
const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
// Siblings = same kind, same parent, excluding self. Walks the parent's
// children array; falls back to scanning the whole scene if the parent
// doesn't carry a `children` list (rare — most parents do).
let siblings: AnyNode[] = []
if (parent) {
const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
if (Array.isArray(parentChildIds)) {
for (const sid of parentChildIds) {
if (sid === node.id) continue
const s = nodes[sid]
if (s && s.type === node.type) siblings.push(s)
}
} else {
siblings = Object.values(nodes).filter(
(n) => n !== node && n.type === node.type && n.parentId === parentId,
)
}
}
return { resolve, children, siblings, parent }
}
function disposeChildren(group: Group) {
for (const child of [...group.children]) {
group.remove(child)
const mesh = child as Partial<Mesh> & { geometry?: { dispose?: () => void } }
if (mesh.geometry?.dispose) mesh.geometry.dispose()
if ('material' in mesh) {
const m = (mesh as { material: unknown }).material
if (Array.isArray(m)) {
for (const mat of m) {
if (mat && typeof (mat as { dispose?: () => void }).dispose === 'function') {
;(mat as { dispose: () => void }).dispose()
}
}
} else if (m && typeof (m as { dispose?: () => void }).dispose === 'function') {
;(m as { dispose: () => void }).dispose()
}
}
}
}
export default GeometrySystem