Files
editor/packages/viewer/src/systems/item/item-system.tsx
T
Aymeric RabotandClaude Fable 5 7879b83df3 feat: face-frame hosting for roof wall children + items on roof walls
Wall-mounted items join doors/windows on roof-segment wall faces, and
the storage model moves to FACE-LOCAL coordinates so hosted children
track segment edits live.

- Children store roofFace + position [u, v, z-from-mid-plane] with
  rotation 0 in-frame — the exact wall-child conventions (the wall
  volume's mid-plane lands on the nominal footprint). A shared
  <RoofFaceHostFrame> derives segment pose + face frame from the
  live-override-merged segment: children follow resize handle drags in
  real time and never jump on commit. No re-anchor cascade needed —
  position is authoritative, the frame is derived. migrateNodes
  converts branch-era segment-local data.
- Items: roofWallStrategy + roof:* handlers in the placement
  coordinator (surface 'roof-wall'), Shift free-place normalized with
  walls, ItemSystem wall-side push extended to segment hosts, correct
  2D plan glyphs via face→segment→roof pose composition. The roof
  hit resolver + overlap guard moved to @pascal-app/editor (the
  coordinator lives there; nodes already depends on editor).
- Cuts: subtractAccessoryCuts extracted and applied in BOTH the
  merged-shell and per-segment CSG paths (full edit mode / painted
  segments used to lose every hole), built from the CURRENT host
  geometry and live-effective children so holes follow segment and
  opening drags.
- Handle rig: the grandparent portal now maps the node's world pose
  into the portal frame instead of composing parent+node registry
  poses — correct for any nesting (the face-frame group broke the
  old assumption), identical for walls.
- Host-field hygiene: useDraftNode.commit/adopt and the window panel
  duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall
  re-anchor clears and every revert restores them.

Codex-reviewed (design consultation, adversarial rounds on the
replaced cascade and on this refactor); frame conventions locked by
unit tests. Record: private-editor plans/editor-roof-wall-openings.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:21:14 -04:00

62 lines
2.0 KiB
TypeScript

import {
type AnyNodeId,
type ItemNode,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import type * as THREE from 'three'
// ============================================================================
// ITEM SYSTEM
// ============================================================================
/**
* Per-frame wall-side offset for items mounted to wall faces. The slab-
* elevation lift for floor items lives in the generic
* `<FloorElevationSystem>` and runs at priority 1 — it has already
* landed `mesh.position.y` by the time this system clears the dirty
* mark at priority 2.
*/
export const ItemSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
useFrame(() => {
if (dirtyNodes.size === 0) return
const nodes = useScene.getState().nodes
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'item') return
const item = node as ItemNode
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D
if (!mesh) return
if (item.asset.attachTo === 'wall-side') {
// Wall-attached item: offset Z by half the host wall's thickness.
// Roof-segment wall faces share the convention — the face frame's
// z = 0 is the wall mid-plane, so the same push lands the item on
// the outer surface.
const parent = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
const thickness =
parent?.type === 'wall'
? ((parent as WallNode).thickness ?? 0.1)
: parent?.type === 'roof-segment'
? (parent.wallThickness ?? 0.1)
: undefined
if (thickness !== undefined) {
const side = item.side === 'front' ? 1 : -1
mesh.position.z = (thickness / 2) * side
}
}
clearDirty(id as AnyNodeId)
})
}, 2)
return null
}