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>
102 lines
4.3 KiB
TypeScript
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
|
|
}
|