Files
editor/packages/nodes/src/shelf/floorplan.ts
T
Aymeric RabotandGitHub 0b338cf647 feat(editor): live floor-stacking, unified handle system, slab-hole editing + interaction polish (#375)
- Live slab-stacking Y previews for all floor-placed kinds (item/shelf/spawn/column/stair) during placement + both move pathways, via a shared core resolver; canonical positions unchanged.
- Unified 3D handle system (one drag pipeline + one visual primitive) with forgiving invisible hit-areas on every handle, kept on EDITOR_LAYER so they don't poison the MRT scene pass.
- Hover + click-to-edit slab holes in 3D (manual hole -> hole editor; stair/elevator hole -> select owner); generic cross-arrow polygon-move grip; normalized handle interaction colors.
- NaN-safe node mutations + non-finite shadow-light bounds guard.
- Built on #373 (level-scoped alignment / registry slab tool); #373 owns X/Z alignment, this owns Y floor-stacking.
2026-06-05 16:24:48 -04:00

144 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { sanitizeShelfDimensions } from './dimensions'
import type { ShelfResizePayload } from './floorplan-affordances'
import type { ShelfNode } from './schema'
// Offsets for the floor-plan selection chrome. Resize chevrons sit a
// hair off the footprint rim; the rotate-arrow corner sits a bit further
// out so it doesn't crowd the resize arrows.
const RESIZE_ARROW_OFFSET = 0.12
const ROTATE_ARROW_CORNER_OFFSET = 0.22
/**
* 2D floor-plan representation of a shelf. The unit's outer footprint
* projects to a rectangle of `width × depth` centered on the shelf's
* position, rotated by its Y angle. For `bookshelf` / `cubby` with
* columns > 1, vertical column dividers project as thin lines so the
* grid is legible from above.
*
* Brackets / posts / individual boards are intentionally omitted — they
* stack vertically under the topmost board from a top-down view and
* adding them clutters the plan without conveying useful information.
*
* When selected, emits resize chevrons matching the 3D handle set
* (width on +X, depth on +Z) plus a rotate-arrow at the front-right
* corner. Body move continues to flow through `shelfFloorplanMoveTarget`
* (engaged from the action-menu Move button, not from these arrows).
*/
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
const shelf = sanitizeShelfDimensions(node)
const [px, , pz] = shelf.position
const ry = shelf.rotation[1] ?? 0
// Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction
// ends up visually matching Three.js Y-rotation (CCW from a top-down
// view) — same `rotation` value rotates the same way in both views.
// Stair already does this; column / shelf / roof-segment now do too.
const planRy = -ry
const halfW = shelf.width / 2
const halfD = shelf.depth / 2
const isSelected = ctx?.viewState?.selected ?? false
// Floor-plan fill: a single neutral fill regardless of `material`.
// 2D doesn't render the actual paint material — surfaces in plan view
// read as outline + tone, not photoreal texture.
const footprintChildren: FloorplanGeometry[] = [
{
kind: 'rect',
x: -halfW,
y: -halfD,
width: shelf.width,
height: shelf.depth,
fill: '#d6d3d1',
stroke: '#1f2937',
strokeWidth: 0.015,
opacity: 0.9,
},
]
// Show column dividers for grid-style shelves so the cubby / bookshelf
// grid is visible from above.
if ((shelf.style === 'bookshelf' || shelf.style === 'cubby') && shelf.columns > 1) {
const innerWidth = shelf.width - 2 * shelf.thickness
const colStep = innerWidth / shelf.columns
for (let c = 1; c < shelf.columns; c++) {
const x = -innerWidth / 2 + c * colStep
footprintChildren.push({
kind: 'line',
x1: x,
y1: -halfD + shelf.thickness,
x2: x,
y2: halfD - shelf.thickness,
stroke: '#1f2937',
strokeWidth: 0.012,
opacity: 0.7,
})
}
}
const footprintGroup: FloorplanGeometry = {
kind: 'group',
transform: { translate: [px, pz], rotate: planRy },
children: footprintChildren,
}
if (!isSelected) {
return footprintGroup
}
// Selection chrome lives in world coords (not under the rotated
// transform group) so the cursor projection in the affordance can use
// the same coord system the dispatcher's `planPoint` arrives in.
// `planRy` (= -ry) is the rotation the SVG group uses; arrows and
// their plan-coord projections must use the same.
const cosR = Math.cos(planRy)
const sinR = Math.sin(planRy)
// Plan vectors for the shelf-local axes — SVG `rotate(planRy)` maps
// local (1, 0) → (cos planRy, sin planRy) and local (0, 1) → (-sin planRy, cos planRy).
const localXInPlan: [number, number] = [cosR, sinR]
const localZInPlan: [number, number] = [-sinR, cosR]
const children: FloorplanGeometry[] = [footprintGroup]
const emitResizeArrow = (
dim: ShelfResizePayload['dim'],
localAxis: [number, number],
localOffset: number,
) => {
const planAxis: [number, number] = [
localAxis[0] * cosR - localAxis[1] * sinR,
localAxis[0] * sinR + localAxis[1] * cosR,
]
children.push({
kind: 'move-arrow',
point: [px + planAxis[0] * localOffset, pz + planAxis[1] * localOffset],
angle: Math.atan2(planAxis[1], planAxis[0]),
affordance: 'shelf-resize',
payload: { dim, planAxis } satisfies ShelfResizePayload,
})
}
emitResizeArrow('width', [1, 0], halfW + RESIZE_ARROW_OFFSET)
emitResizeArrow('depth', [0, 1], halfD + RESIZE_ARROW_OFFSET)
// Rotate-arrow at the +X / +Z corner — matches the 3D
// `shelfRotateHandle` corner placement so users see the rotation
// affordance in the same quadrant across views.
const cornerLocalX = halfW + ROTATE_ARROW_CORNER_OFFSET
const cornerLocalZ = halfD + ROTATE_ARROW_CORNER_OFFSET
const cornerPlanX = cornerLocalX * cosR - cornerLocalZ * sinR
const cornerPlanY = cornerLocalX * sinR + cornerLocalZ * cosR
// The arc-arrow's local +X reads as the radial-outward direction; the
// diagonal corner is the (+X +Z) sum direction in shelf-local.
const radialPlanX = localXInPlan[0] + localZInPlan[0]
const radialPlanY = localXInPlan[1] + localZInPlan[1]
children.push({
kind: 'rotate-arrow',
point: [px + cornerPlanX, pz + cornerPlanY],
angle: Math.atan2(radialPlanY, radialPlanX),
affordance: 'shelf-rotate',
pivot: [px, pz],
})
return { kind: 'group', children }
}