* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * editor: per-door-type floor-plan symbols Render a distinct, static plan symbol for each door type in the registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`), independent of the door's live open/close animation: - single / hinged: fixed 90° swing with a dashed quarter-circle arc - double / french: two mirrored half-width leaves + dashed arcs - folding / bifold: static zigzag accordion (~80% span) on the wall face - sliding: bypass — two overlapping panels on parallel tracks + arrow - pocket: thin white leaf, ~60% closed, sliding into the solid wall - barn: surface-mounted panel parked over the wall, dashed closed-ghost + slide arrow The swing arc is dashed in screen-pixel units (the renderer uses non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection / slideDirection as appropriate. Also includes pre-existing working-tree changes unrelated to the door symbols: group move/rotate transform and box-select tweaks, and a regenerated ifc-converter next-env.d.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix recessed ceiling fixtures and draw safety * feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D) Snap the wall draft / endpoint-move point onto existing wall geometry — corners, midpoints, wall–wall intersections, and along-wall edges — and show a beacon at the snap point whose glyph encodes what it caught (square = corner, triangle = midpoint, ✕ = intersection, circle = edge). - Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested). - Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon and a 2D SVG glyph beacon, both indigo to match the alignment guides. - Gated by a new persisted "Magnetic snap" toggle in the Display menu (useEditor); honored by draw + commit + endpoint-move in both views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * editor: garage and open-doorway floor-plan symbols Extend the per-door-type plan symbols in the registry floor-plan builder (packages/nodes/src/door/floorplan.ts): - open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel (mirrors the 3D system, which renders only the cutout for openings) - garage sectional: closed leaf + side tracks into the garage + dashed parked ghost at the inner end - garage roll-up: closed leaf + coil barrel (capsule) with a coil hint - garage tilt-up: closed leaf + dashed parked panel + dashed curved up-and-over swing path - gate the swing arc to actual swing doors (hinged/double/french) so other types fall back to the plain footprint Garage mechanisms sit on the interior (door-local -z) side to match the 3D garage builders, independent of swingDirection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * arch: enforce layer boundaries — registry dispatch, store relocation, shared helper Three architectural fixes to bring the branch into full compliance: 1. **ceiling-system kind check → CeilingCutCapability** Replace `child.type === 'item'` branch in `ceiling-system` with registry dispatch. Add `CeilingCutCapability` type to `packages/core` registry types, implement `buildCeilingHole` on `itemDefinition`, and rewrite `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through `nodeRegistry` — viewer never again inspects a node's kind directly. 2. **useAlignmentGuides + useWallSnapIndicator → packages/editor** These stores are editor-only UI (snap beacons, alignment guides). Move them from `packages/core/src/store/` to `packages/editor/src/store/`, re-export from `packages/editor`, and update all 34 consumer files across `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`. 3. **findLevelAncestorId extracted to core** `item-light-system` had a private `resolveNodeLevelId` that duplicated level-ancestor traversal logic. Extract it as `findLevelAncestorId` in `packages/core` (spatial-grid-sync), export it, and replace the local copy. All four packages typecheck cleanly (zero errors). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: fix lint, untrack .claude/launch.json Run bun check --write to clear 8 Biome errors (formatting + import order + one unused import). Untrack .claude/launch.json and add it plus .claude/settings.local.json to .gitignore so local IDE/agent configs stop landing in commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
import {
|
||
type AnyNode,
|
||
getScaledDimensions,
|
||
type HandleDescriptor,
|
||
type ItemNode as ItemNodeType,
|
||
type NodeDefinition,
|
||
} from '@pascal-app/core'
|
||
import { buildItemFloorplan } from './floorplan'
|
||
import { itemFloorplanMoveTarget } from './floorplan-move'
|
||
import { itemParametrics } from './parametrics'
|
||
import { ItemNode } from './schema'
|
||
|
||
// The two floor gizmos flank the item at mid-height so they never overlap,
|
||
// even on small items: move sits past the left edge, rotate past the right
|
||
// edge, both floated the same distance in front of the item. Mirrors the
|
||
// wall-item layout below (WALL_SIDE_OFFSET / WALL_GIZMO_LIFT). The guide ring
|
||
// traces a circle slightly outside the footprint's bounding circle.
|
||
const GIZMO_SIDE_OFFSET = 0.3
|
||
const GIZMO_FRONT_OFFSET = 0.3
|
||
const ROTATE_RING_OFFSET = 0.06
|
||
|
||
// Whole-item rotation handle — the two-headed curved arrow. `arc-resize`
|
||
// does the angular drag math (raycasts a horizontal plane at the gizmo's
|
||
// Y, measures cursor bearing around the item's local origin, returns the
|
||
// delta). Holding Shift snaps to 15° increments (handled generically in
|
||
// node-arrow-handles for any `shape: 'rotate'`), matching the R/T rotate
|
||
// step for placed items. Item rotation is stored as `[x, y, z]`; only the
|
||
// Y component turns.
|
||
function itemRotateHandle(): HandleDescriptor<ItemNodeType> {
|
||
return {
|
||
kind: 'arc-resize',
|
||
axis: 'angular',
|
||
shape: 'rotate',
|
||
// Negate the cursor delta to match three.js Y-rotation handedness
|
||
// (positive Ry takes +X → −Z, while atan2(z, x) increases +X → +Z).
|
||
apply: (initial, delta) => {
|
||
const [rx, ry, rz] = initial.rotation ?? [0, 0, 0]
|
||
return { rotation: [rx, ry - delta, rz] }
|
||
},
|
||
placement: {
|
||
// Past the item's right edge at mid-height, floated in front. The
|
||
// registered item mesh carries position + rotation only (scale lives on
|
||
// an inner mesh), so the scaled footprint maps straight to world.
|
||
position: (n) => {
|
||
const [w, h, d] = getScaledDimensions(n)
|
||
return [w / 2 + GIZMO_SIDE_OFFSET, h / 2, d / 2 + GIZMO_FRONT_OFFSET]
|
||
},
|
||
// Fixed −45° tilt leans the curve toward the item's front face.
|
||
rotationY: () => -Math.PI / 4,
|
||
},
|
||
decoration: {
|
||
kind: 'ring',
|
||
radius: (n) => {
|
||
const [w, , d] = getScaledDimensions(n)
|
||
return Math.hypot(w / 2, d / 2) + ROTATE_RING_OFFSET
|
||
},
|
||
y: (n) => getScaledDimensions(n)[1] / 2,
|
||
},
|
||
}
|
||
}
|
||
|
||
// The 4-way move cross, just past the item's left edge. Press-drag hands the
|
||
// item to its placement coordinator (showing the bounding box, dimension labels
|
||
// and grid-snap ticker) and commits on release — press-drag-release motion with
|
||
// the full placement feedback.
|
||
function itemMoveHandle(): HandleDescriptor<ItemNodeType> {
|
||
return {
|
||
kind: 'tap-action',
|
||
shape: 'move-cross',
|
||
cursor: 'move',
|
||
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
|
||
placement: {
|
||
// Past the item's left edge at mid-height, mirroring the rotate grip on
|
||
// the right so the two never overlap on small items.
|
||
position: (n) => {
|
||
const [w, h, d] = getScaledDimensions(n)
|
||
return [-(w / 2 + GIZMO_SIDE_OFFSET), h / 2, d / 2 + GIZMO_FRONT_OFFSET]
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// ---- Wall-mounted items (attachTo 'wall' / 'wall-side') ----
|
||
// These live in the wall's local frame: position is [along-wall, up, depth]
|
||
// and the item faces along the wall normal (its local +Z). Both gizmos use
|
||
// `portal: 'grandparent'` so they render in the wall frame like door / window
|
||
// handles, and sit a little off the wall surface (+Z) so they're grabbable.
|
||
|
||
// How far off the wall surface (along the normal) the wall gizmos float, and
|
||
// how far to either side of the item they sit.
|
||
const WALL_GIZMO_LIFT = 0.12
|
||
const WALL_SIDE_OFFSET = 0.3
|
||
|
||
// Spin the item flat against the wall — rotation about its local +Z (the wall
|
||
// normal), written to rotation[2]. Sits just past the item's right edge.
|
||
function itemWallRotateHandle(): HandleDescriptor<ItemNodeType> {
|
||
return {
|
||
kind: 'arc-resize',
|
||
axis: 'angular',
|
||
shape: 'rotate',
|
||
rotationPlane: 'node-normal',
|
||
portal: 'grandparent',
|
||
apply: (initial, delta) => {
|
||
const [rx, ry, rz] = initial.rotation ?? [0, 0, 0]
|
||
return { rotation: [rx, ry, rz + delta] }
|
||
},
|
||
placement: {
|
||
position: (n) => {
|
||
const [w] = getScaledDimensions(n)
|
||
return [w / 2 + WALL_SIDE_OFFSET, 0, WALL_GIZMO_LIFT]
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// Move cross past the item's left edge on the wall face. Tap to hand the item
|
||
// to its placement coordinator (`engageMove`) — same feedback as the floating
|
||
// move button, and the coordinator handles the wall ↔ floor ↔ ceiling
|
||
// transitions the generic translate drag couldn't. `plane: 'node-normal'`
|
||
// stands the cross up against the wall face.
|
||
function itemWallMoveHandle(): HandleDescriptor<ItemNodeType> {
|
||
return {
|
||
kind: 'tap-action',
|
||
shape: 'move-cross',
|
||
plane: 'node-normal',
|
||
portal: 'grandparent',
|
||
cursor: 'move',
|
||
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
|
||
placement: {
|
||
position: (n) => {
|
||
const [w] = getScaledDimensions(n)
|
||
return [-(w / 2 + WALL_SIDE_OFFSET), 0, WALL_GIZMO_LIFT]
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Item — Phase 5 batch kind. Catalog-backed, GLB-rendered, multi-host.
|
||
*
|
||
* Demonstrates the **custom `def.renderer` escape hatch** (see
|
||
* plans/editor-node-registry.md): items use `useGLTF` from drei to
|
||
* load CDN assets, plus a non-trivial interactive-widget layer inside
|
||
* the rendered scene. Not expressible as a pure `def.geometry`. The
|
||
* registry mounts the custom React renderer as-is.
|
||
*
|
||
* Capabilities:
|
||
* - **No `movable`**: item's move is bespoke `MoveItemContent` —
|
||
* handles attachTo transitions mid-drag (floor ↔ wall ↔ ceiling),
|
||
* asset.attachTo lookups, scale-preserving Y math for surface
|
||
* placement. The smooth generic mover can't express that. Legacy
|
||
* mover keeps running via capability-driven dispatch.
|
||
* - `selectable`, `duplicable`, `deletable` standard.
|
||
*
|
||
* Stages:
|
||
* - A: registered.
|
||
* - B: N/A — def.renderer escape hatch (GLB / useGLTF).
|
||
* - C: `def.floorplan` resolves parent chain via `ctx.resolve`,
|
||
* returns a rotated rectangle (width × depth). Mirrors the legacy
|
||
* `getItemFloorplanTransform` math. Legacy `floorplanItemEntries`
|
||
* short-circuits when item is registered.
|
||
*
|
||
* `toolHints`: matches the legacy ItemHelper UI (mouse / R / T / Shift /
|
||
* Esc) — registry-driven placement panel.
|
||
*/
|
||
export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||
kind: 'item',
|
||
schemaVersion: 1,
|
||
schema: ItemNode,
|
||
category: 'furnish',
|
||
surfaceRole: 'furnishing',
|
||
|
||
// Defaults shape is cast: the schema requires a fully-typed `asset`
|
||
// field, but in practice items are always created from the catalog
|
||
// (the asset is supplied at placement time). `createNode` re-parses
|
||
// through the schema, so any missing zod defaults fill at runtime.
|
||
defaults: () =>
|
||
({
|
||
object: 'node',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: {},
|
||
children: [],
|
||
position: [0, 0, 0],
|
||
rotation: [0, 0, 0],
|
||
scale: [1, 1, 1],
|
||
asset: {
|
||
id: 'placeholder',
|
||
category: 'misc',
|
||
name: 'Item',
|
||
thumbnail: '',
|
||
src: 'asset:placeholder',
|
||
dimensions: [1, 1, 1],
|
||
source: 'library',
|
||
},
|
||
}) as unknown as Omit<ItemNodeType, 'id' | 'type'>,
|
||
|
||
capabilities: {
|
||
selectable: { hitVolume: 'bbox' },
|
||
duplicable: true,
|
||
deletable: true,
|
||
// Items participate in compositions — e.g. "table-with-plants",
|
||
// "shelf-with-books-on-top" — so they're presettable in their own
|
||
// right (and as descendants of presettable parents). The GLB-kind
|
||
// catalog still exists alongside; preset-flavoured items become
|
||
// siblings of GLB items inside the unified `items` table.
|
||
//
|
||
// Items can be hosted on walls (assets with `attachTo: 'wall'`)
|
||
// via `wallId` + `wallT`. When a composition that includes a
|
||
// wall-hosted item is saved as a preset (a sconce, a hanging
|
||
// shelf, etc.), the host app strips these via `getHostRefFields(def)`
|
||
// so the descendant re-attaches against the new wall geometry at
|
||
// placement time.
|
||
hostRefFields: ['wallId', 'wallT'],
|
||
// Floor items get lifted by slabs underneath via the generic
|
||
// `<FloorElevationSystem>`. Wall- / ceiling-attached items live in
|
||
// their parent's local frame and skip the lift via `applies`.
|
||
floorPlaced: {
|
||
footprint: (node) => {
|
||
const item = node as ItemNodeType
|
||
return { dimensions: getScaledDimensions(item), rotation: item.rotation }
|
||
},
|
||
applies: (node) => !(node as ItemNodeType).asset.attachTo,
|
||
},
|
||
// Recessed ceiling fixtures cut a hole in their host ceiling. The viewer's
|
||
// CeilingSystem queries this capability on each child of a ceiling so it
|
||
// never needs to branch on `node.type`.
|
||
ceilingCut: {
|
||
buildCeilingHole(rawNode: AnyNode): Array<[number, number]> | null {
|
||
const node = rawNode as ItemNodeType
|
||
if (!node.asset.recessed || node.asset.attachTo !== 'ceiling') return null
|
||
|
||
// Inset slightly so the fixture's trim (widest part, sitting in the
|
||
// ceiling plane) overlaps the solid ceiling around the opening and hides
|
||
// the cut edge. Same constant the old ceiling-system used.
|
||
const INSET = 0.82
|
||
const [width, , depth] = getScaledDimensions(node)
|
||
const halfW = (width / 2) * INSET
|
||
const halfD = (depth / 2) * INSET
|
||
const cx = node.position[0]
|
||
const cz = node.position[2]
|
||
const yaw = node.rotation?.[1] ?? 0
|
||
const cos = Math.cos(yaw)
|
||
const sin = Math.sin(yaw)
|
||
|
||
// Rotate the (inset) footprint corners about Y and translate to the
|
||
// item's plan position. Y-rotation of (dx, dz): (dx·cos + dz·sin, −dx·sin + dz·cos).
|
||
return (
|
||
[
|
||
[-halfW, -halfD],
|
||
[halfW, -halfD],
|
||
[halfW, halfD],
|
||
[-halfW, halfD],
|
||
] as Array<[number, number]>
|
||
).map(([dx, dz]) => [cx + dx * cos + dz * sin, cz - dx * sin + dz * cos])
|
||
},
|
||
},
|
||
},
|
||
|
||
parametrics: itemParametrics,
|
||
|
||
// In-world rotate + move gizmos for selected items.
|
||
// - Floor items: world-Y rotate + free floor-plane move cross.
|
||
// - Wall items: wall-normal rotate (spin flat against the wall) + a move
|
||
// cross constrained to the wall face. Both ride the wall frame.
|
||
// - Ceiling items: no gizmos yet (move via the move tool).
|
||
handles: (node) => {
|
||
const attachTo = (node as ItemNodeType).asset.attachTo
|
||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||
return [itemWallRotateHandle(), itemWallMoveHandle()]
|
||
}
|
||
if (attachTo) return []
|
||
return [itemRotateHandle(), itemMoveHandle()]
|
||
},
|
||
|
||
renderer: {
|
||
kind: 'parametric',
|
||
module: () => import('./renderer'),
|
||
},
|
||
system: {
|
||
module: () => import('./system'),
|
||
// Same priority as the legacy ItemSystem.
|
||
priority: 2,
|
||
},
|
||
// Catalog placement tool — mounted when `useEditor.tool === 'item'`.
|
||
// Wraps the same placement coordinator the move-tool uses (surface
|
||
// strategies for floor / wall / ceiling / item-surface). Replaces
|
||
// the legacy `editor/src/components/tools/item/item-tool.tsx`.
|
||
tool: () => import('./tool'),
|
||
|
||
// Stage D — 3D move-tool (registry-driven). Adopts the moving node
|
||
// and runs the placement coordinator with surface strategies for
|
||
// floor / wall / ceiling / item-surface, including attachTo
|
||
// *transitions* (drop a wall item on a ceiling and have it switch).
|
||
// Replaces the legacy `MoveItemContent` in editor's dispatcher; the
|
||
// `getRegistryAffordanceTool('item', 'move')` lookup picks this up.
|
||
affordanceTools: {
|
||
move: () => import('./move-tool'),
|
||
},
|
||
|
||
// Stage C: floor-plan polygon. ctx.resolve walks the parent chain
|
||
// (wall / nested item / level) to compute the world-space transform.
|
||
floorplan: buildItemFloorplan,
|
||
// 2D move-on-floorplan handler. Branches on `asset.attachTo`:
|
||
// wall items snap to walls (like door / window), ceiling items
|
||
// snap to ceiling polygons, floor items snap to slabs. attachTo
|
||
// *transitions* (drop a wall item on a ceiling) remain canonical
|
||
// in the 3D path; 2D only re-anchors within the same family.
|
||
floorplanMoveTarget: itemFloorplanMoveTarget,
|
||
|
||
toolHints: [
|
||
{ key: 'Left click', label: 'Place item' },
|
||
{ key: 'R', label: 'Rotate counterclockwise' },
|
||
{ key: 'T', label: 'Rotate clockwise' },
|
||
{ key: 'Shift', label: 'Free place' },
|
||
{ key: 'Esc', label: 'Cancel' },
|
||
],
|
||
|
||
presentation: {
|
||
label: 'Item',
|
||
description: 'A catalog-backed item (furniture, fixtures, decorations).',
|
||
icon: { kind: 'url', src: '/icons/item.png' },
|
||
paletteSection: 'furnish',
|
||
paletteOrder: 10,
|
||
},
|
||
|
||
mcp: {
|
||
description:
|
||
'A catalog-backed item with asset reference, transforms, and optional attachTo for wall/ceiling mounting.',
|
||
},
|
||
}
|