ai-chatbot/packages/nodes/src/shelf/floorplan-move.ts
Wassim SAMAD 924567293a shelf: v2 — cubby default, withBottom, item hosting, paintable surface
Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle
and a `children: ItemNode[]` field for item hosting. Schema-level defaults
preserve the v1 wall-shelf visual so existing scenes load unchanged; the
placement tool spreads `shelfDefinition.defaults()` for fresh shelves
(cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on).

Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share
the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per
row, plus the bottom-board top when `withBottom` is on for cubby /
bookshelf.

Material is a single paintable surface (same shape walls / slabs / stairs
use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so
unpainted shelves read as the canonical off-white.

Preview clones each cached material before mutating `transparent / opacity`
on the ghost — without the clone the mutation leaked into the cached
`getShelfMaterial` instance every committed shelf was using, rendering
them all see-through after the first placement preview rendered.

Store hardening: `migrateNodes` patches missing `children: []` on v1
shelves, and `updateNodesAction` reparenting tolerates a missing children
array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint
mode picks up the kind.

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

102 lines
4.3 KiB
TypeScript

import {
type AnyNodeId,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
type ShelfNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, triggerSFX, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
/**
* 2D floor-plan move handler for shelf — behaves like items in the
* floor-plan move flow:
*
* - Each pointermove writes the absolute world-plan target position
* to `useLiveTransforms` (so the 2D layer's `effectiveNode` override
* re-renders the SVG at the new position) AND mutates the
* registered mesh's `position` directly (so the 3D view mirrors the
* drag in real time).
* - On commit, `canCommit` writes the final position to `scene` as a
* single tracked update — the dispatcher's snapshot-diff captures
* it as one undoable step.
* - On any non-commit unmount (escape, abnormal teardown) the
* dispatcher clears `useLiveTransforms` for affectedIds, so the 3D
* visual snaps back to the reverted scene state.
*
* Unlike `slab` / `ceiling`, this writes the **absolute** position (the
* shelf carries its location in `node.position`, not in polygon
* vertices). The 2D layer's override branch for `shelf` mirrors `item`'s
* world-plan handling.
*/
const GRID_STEP = 0.5
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node }) => {
const shelfId = node.id as AnyNodeId
const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
const originalRotationY = node.rotation[1] ?? 0
let lastPosition: [number, number, number] = originalPosition
let lastSnapKey: string | null = null
const session: FloorplanMoveTargetSession = {
affectedIds: [shelfId],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next
// Grid-snap SFX on cell crossings — matches the 3D `MoveSlabTool`
// and the placement coordinators. Item / slab / wall flows fire
// the same cue, so the shelf following along is the expected UX.
const snapKey = `${snapped[0]},${snapped[1]}`
if (snapKey !== lastSnapKey) {
triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey
}
// Live preview — same shape items use. `useLiveTransforms.position`
// holds world-plan coords (level-local); the 2D `FloorplanRegistryLayer`
// override for `shelf` reads this and re-renders the SVG entry.
useLiveTransforms.getState().set(shelfId, {
position: next,
rotation: originalRotationY,
})
// Mirror to the 3D mesh so split-view follows the cursor without
// touching scene state per tick (no CSG, no React re-render of
// geometry — same imperative live-drag pattern as the 3D
// `MoveRegistryNodeTool`).
const mesh = sceneRegistry.nodes.get(shelfId) as THREE.Object3D | undefined
if (mesh) mesh.position.set(next[0], next[1], next[2])
},
canCommit() {
const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined
if (!live || live.type !== 'shelf') return false
if (lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) {
return false
}
// Side-effect commit — write final position. The dispatcher's
// snapshot-diff right after `canCommit` returns picks this up as
// the single tracked change for undo. `useLiveTransforms` is
// cleared in the dispatcher's commit path (and in our
// abnormal-unmount cleanup) so the 3D view reconciles to the
// committed scene position on the next render.
useScene.getState().updateNodes([
{
id: shelfId,
data: { position: lastPosition },
},
])
// The shelf's geometry doesn't depend on `position` (it's the
// group's transform, not the build inputs), but we mark dirty so
// any sibling-aware system that does watch position re-runs.
useScene.getState().markDirty(shelfId)
useLiveTransforms.getState().clear(shelfId)
return true
},
}
return session
}