Files
editor/packages/nodes/src/column/definition.ts
T
Wassim SAMADandClaude Opus 4.8 0cdaf8b1b9 feat(paint-slots): migrate column onto the unified slot model
Column exposes shaft / base / capital / frame slots on node.slots (base when
baseStyle!=none, capital when capitalStyle!=none, frame only for non-vertical
support styles). The single material context is widened to a per-slot map plus
a ColumnSlotContext; each part subtree is wrapped in <ColumnSlot> so every
mesh primitive resolves its material AND stamps userData.slotId from the same
context. Decorative shaft parts inherit shaft, base/capital carvings inherit
their parent slot. Resolution: textures-off wall role -> node.slots ref ->
legacy material/preset -> declared default (shaft/base/capital concrete-plaster,
frame metal-steel). Monochrome unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:34:39 -04:00

398 lines
14 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 {
ColumnNode as ColumnNodeSchema,
type ColumnNode as ColumnNodeType,
type HandleDescriptor,
type NodeDefinition,
} from '@pascal-app/core'
import { buildColumnFloorplan } from './floorplan'
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
import { columnFloorplanMoveTarget } from './floorplan-move'
import { columnPaint } from './paint'
import { columnParametrics } from './parametrics'
import { ColumnNode } from './schema'
import { columnSlots } from './slots'
// Limits + offsets shared with the in-world arrows. Mirrors the floors
// the renderer clamps to (`Math.max(0.2, node.height)` etc.) so a drag
// can't push values past what the renderer will accept.
const SIDE_HANDLE_OFFSET = 0.18
const HEIGHT_HANDLE_OFFSET = 0.22
const BRACE_HANDLE_OFFSET = 0.3
const SPREAD_HANDLE_OFFSET = 0.22
const ROTATE_CORNER_OFFSET = 0.32
const ROTATE_RING_OFFSET = 0.04
const MOVE_FRONT_OFFSET = 0.35
const MIN_COLUMN_HEIGHT = 0.2
const MIN_COLUMN_WIDTH = 0.1
const MIN_COLUMN_DEPTH = 0.1
const MIN_COLUMN_RADIUS = 0.05
const MIN_BRACE_DIMENSION = 0.04
const MIN_BRACE_BOTTOM_SPREAD = 0.2
const MIN_BRACE_TOP_SPREAD = 0
const ROUND_CROSS_SECTIONS = new Set<ColumnNodeType['crossSection']>([
'round',
'octagonal',
'sixteen-sided',
])
function columnHeightHandle(): HandleDescriptor<ColumnNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
anchor: 'min',
min: MIN_COLUMN_HEIGHT,
currentValue: (n) => n.height,
apply: (_n, newValue) => ({ height: newValue }),
placement: {
position: (n) => [0, n.height + HEIGHT_HANDLE_OFFSET, 0],
},
}
}
function columnRadiusHandle(): HandleDescriptor<ColumnNodeType> {
return {
kind: 'radial-resize',
axis: 'x',
min: MIN_COLUMN_RADIUS,
currentValue: (n) => n.radius,
apply: (_n, newValue) => ({ radius: newValue }),
placement: {
position: (n) => [n.radius + SIDE_HANDLE_OFFSET, n.height / 2, 0],
},
// Guide ring traces the column's footprint at mid-height while the
// user is hovering or dragging the radius arrow — clarifies which
// edge the drag controls on round / octagonal / sixteen-sided shafts.
decoration: {
kind: 'ring',
radius: (n) => n.radius + SIDE_HANDLE_OFFSET * 0.5,
y: (n) => n.height / 2,
},
}
}
function columnAxisHandle(axis: 'x' | 'z'): HandleDescriptor<ColumnNodeType> {
return {
kind: 'linear-resize',
axis,
anchor: 'center',
min: axis === 'x' ? MIN_COLUMN_WIDTH : MIN_COLUMN_DEPTH,
currentValue: (n) => (axis === 'x' ? n.width : n.depth),
apply: (_n, newValue) => (axis === 'x' ? { width: newValue } : { depth: newValue }),
placement: {
position: (n) => {
const half = axis === 'x' ? n.width / 2 : n.depth / 2
return axis === 'x'
? [half + SIDE_HANDLE_OFFSET, n.height / 2, 0]
: [0, n.height / 2, half + SIDE_HANDLE_OFFSET]
},
},
}
}
function columnUniformHandle(): HandleDescriptor<ColumnNodeType> {
// Square columns keep width === depth. We anchor the arrow on the +X
// side and write BOTH fields from the same delta.
return {
kind: 'linear-resize',
axis: 'x',
anchor: 'center',
min: MIN_COLUMN_WIDTH,
currentValue: (n) => n.width,
apply: (_n, newValue) => ({ width: newValue, depth: newValue }),
placement: {
position: (n) => [n.width / 2 + SIDE_HANDLE_OFFSET, n.height / 2, 0],
},
}
}
// Bottom-spread arrow — sits just outside the right foot of the splay
// (a-frame / x-brace etc.), one beam-width above the floor plate so it
// clears the support legs. Drags symmetrically: anchor='center' so
// pointer Δ of d grows the full leg-to-leg distance by 2d, both legs
// moving ±d. Defaults mirror the renderer fall-throughs.
function columnBraceBottomSpreadHandle(): HandleDescriptor<ColumnNodeType> {
return {
kind: 'linear-resize',
axis: 'x',
anchor: 'center',
min: MIN_BRACE_BOTTOM_SPREAD,
currentValue: (n) =>
Math.max(MIN_BRACE_BOTTOM_SPREAD, n.braceBottomSpread ?? Math.max(n.width * 3, 1.2)),
apply: (_n, newValue) => ({ braceBottomSpread: newValue }),
placement: {
position: (n) => {
const spread = Math.max(
MIN_BRACE_BOTTOM_SPREAD,
n.braceBottomSpread ?? Math.max(n.width * 3, 1.2),
)
return [spread / 2 + SPREAD_HANDLE_OFFSET, 0.08, 0]
},
},
}
}
// Top-spread arrow — at the right tip of the brace's top edge. Same
// symmetric anchor as the bottom spread. For a-frame this is the small
// "pinch" at the top; for y/v-frame and x-brace it's the wider opening.
function columnBraceTopSpreadHandle(): HandleDescriptor<ColumnNodeType> {
return {
kind: 'linear-resize',
axis: 'x',
anchor: 'center',
min: MIN_BRACE_TOP_SPREAD,
currentValue: (n) => Math.max(MIN_BRACE_TOP_SPREAD, n.braceTopSpread ?? 0.12),
apply: (_n, newValue) => ({ braceTopSpread: newValue }),
placement: {
position: (n) => {
const spread = Math.max(MIN_BRACE_TOP_SPREAD, n.braceTopSpread ?? 0.12)
return [spread / 2 + SPREAD_HANDLE_OFFSET, n.height + 0.08, 0]
},
},
}
}
function columnBraceHandle(axis: 'x' | 'z'): HandleDescriptor<ColumnNodeType> {
return {
kind: 'linear-resize',
axis,
anchor: 'center',
min: MIN_BRACE_DIMENSION,
currentValue: (n) => (axis === 'x' ? (n.braceWidth ?? n.width) : (n.braceDepth ?? n.depth)),
apply: (_n, newValue) => (axis === 'x' ? { braceWidth: newValue } : { braceDepth: newValue }),
placement: {
position: (n) => {
// Position outside any splay so the arrow clears the legs.
const half =
axis === 'x'
? Math.max(n.braceBottomSpread ?? 0, n.braceTopSpread ?? 0, n.braceWidth ?? n.width) / 2
: (n.braceDepth ?? n.depth) / 2
return axis === 'x'
? [half + BRACE_HANDLE_OFFSET, n.height / 2, 0]
: [0, n.height / 2, half + BRACE_HANDLE_OFFSET]
},
},
}
}
// Which supports surface bottom-spread / top-spread in the renderer. Phase
// 1 covers the simple two-leg / single-foot supports; x-brace / k-brace /
// tripod / trestle / portal-frame / box-frame will hook the same handles
// in a follow-up once we audit their leg geometry.
const STYLES_WITH_BOTTOM_SPREAD = new Set<ColumnNodeType['supportStyle']>(['a-frame'])
const STYLES_WITH_TOP_SPREAD = new Set<ColumnNodeType['supportStyle']>([
'a-frame',
'y-frame',
'v-frame',
])
// Resolve the column's visible XZ footprint half-extents per supportStyle
// + crossSection. Vertical supports use the shaft geometry (radius for
// round / octagonal / sixteen-sided, width/depth for square / rectangular);
// non-vertical supports fall back to the widest sensible brace bound so
// the rotation handle clears the splay.
function columnFootprintHalf(n: ColumnNodeType): { halfX: number; halfZ: number } {
if (n.supportStyle === 'vertical') {
if (ROUND_CROSS_SECTIONS.has(n.crossSection)) {
return { halfX: n.radius, halfZ: n.radius }
}
if (n.crossSection === 'square') {
return { halfX: n.width / 2, halfZ: n.width / 2 }
}
return { halfX: n.width / 2, halfZ: n.depth / 2 }
}
return {
halfX:
Math.max(n.width, n.braceWidth ?? 0, n.braceBottomSpread ?? 0, n.braceTopSpread ?? 0) / 2,
halfZ: Math.max(n.depth, n.braceDepth ?? 0) / 2,
}
}
// Whole-column rotation gizmo — same pattern as the elevator. Curved
// two-headed arrow at the +X / +Z corner of the footprint, a guide ring
// at the corner-diagonal radius on hover/drag. `apply` negates the
// angular delta so dragging the cursor CCW around the column rotates
// the column CCW (cursor atan2 ticks opposite-handed from three.js Ry).
function columnRotateHandle(): HandleDescriptor<ColumnNodeType> {
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
placement: {
// Offset along +Z only so the gizmo sticks out the front of the
// column rather than diagonally at the corner — keeps the rotate
// arrow from crowding the +X side handles (radius / axis / uniform)
// while still reading as attached to the column.
position: (n) => {
const { halfX, halfZ } = columnFootprintHalf(n)
const yMid = Math.max(n.height, MIN_COLUMN_HEIGHT) / 2
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
},
// Fixed 45° tilt — leans the curve clockwise (as seen from above)
// toward the column's front face.
rotationY: () => -Math.PI / 4,
},
decoration: {
kind: 'ring',
radius: (n) => {
const { halfX, halfZ } = columnFootprintHalf(n)
return Math.hypot(halfX, halfZ) + ROTATE_RING_OFFSET
},
y: (n) => Math.max(n.height, MIN_COLUMN_HEIGHT) / 2,
},
}
}
function columnMoveHandle(): HandleDescriptor<ColumnNodeType> {
return {
kind: 'translate',
placement: {
// Low to the floor at the front edge (matches the item move grip) so it
// reads as a floor-move grip and stays clear of the body resize / rotate
// handles that sit at mid-height.
position: (n) => {
const { halfZ } = columnFootprintHalf(n)
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const { halfX, halfZ } = columnFootprintHalf(n)
const dimX = Math.max(halfX * 2, MIN_COLUMN_WIDTH)
const dimZ = Math.max(halfZ * 2, MIN_COLUMN_DEPTH)
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[] {
// 1. Height (universal).
// 2. Footprint arrows depending on supportStyle + crossSection:
// - non-vertical supports → braceWidth + braceDepth (skips crossSection)
// plus per-style spread arrows at the splay endpoints.
// - round / octagonal / sixteen-sided → single radius arrow
// - square → uniform width+depth
// - rectangular → width + depth (independent)
const handles: HandleDescriptor<ColumnNodeType>[] = [columnHeightHandle()]
if (node.supportStyle !== 'vertical') {
handles.push(columnBraceHandle('x'), columnBraceHandle('z'))
if (STYLES_WITH_BOTTOM_SPREAD.has(node.supportStyle)) {
handles.push(columnBraceBottomSpreadHandle())
}
if (STYLES_WITH_TOP_SPREAD.has(node.supportStyle)) {
handles.push(columnBraceTopSpreadHandle())
}
} else if (ROUND_CROSS_SECTIONS.has(node.crossSection)) {
handles.push(columnRadiusHandle())
} else if (node.crossSection === 'square') {
handles.push(columnUniformHandle())
} else {
handles.push(columnAxisHandle('x'), columnAxisHandle('z'))
}
handles.push(columnRotateHandle(), columnMoveHandle())
return handles
}
/**
* Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in
* the renderer). Inspector / move / floorplan still go through legacy
* paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx
* (their hardcoded `case 'column':` entries fire before the registry
* fallback).
*
* Capabilities: column doesn't declare `movable` because its move is
* bespoke (legacy MoveColumnTool snaps to slab + free placement on
* the X/Z plane with rotation).
*
* Defaults computed via stub-parse so we leverage every zod
* `.default()` annotation on the schema (~60 fields).
*/
export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column',
schemaVersion: 1,
schema: ColumnNode,
category: 'structure',
surfaceRole: 'wall',
defaults: () => {
const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
slots: (node) => columnSlots(node as ColumnNodeType),
paint: columnPaint,
// Slab elevation lift via the generic `<FloorElevationSystem>`.
floorPlaced: {
footprint: (node) => {
const column = node as ColumnNodeType
return {
dimensions: [column.width, column.height, column.depth] as [number, number, number],
// Column stores Y rotation as a scalar; the slab-overlap query
// expects the full Euler tuple.
rotation: [0, column.rotation, 0] as [number, number, number],
}
},
},
},
parametrics: columnParametrics,
handles: columnHandles,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
// Stage D — 3D move-tool (registry-driven). Replaces the legacy
// `MoveColumnTool` in editor's dispatcher. Same 0.5m grid snap +
// live-transform preview the legacy used.
affordanceTools: {
move: () => import('./move-tool'),
},
// Registry-driven placement tool — renders a translucent `ColumnPreview`
// ghost at the cursor (mirroring the shelf build tool) instead of the
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
// registry-first path mounts this and skips the legacy `<ColumnTool>`.
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Place column' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
floorplan: buildColumnFloorplan,
// 2D body move routes through this kind-specific target so the column
// aligns by its footprint *edges* (and snaps flush to wall faces) instead
// of the overlay's generic free-translate path, which aligned by bbox
// centre and gathered candidates from SVG bounding boxes only. Mirrors the
// shelf move target.
floorplanMoveTarget: columnFloorplanMoveTarget,
// 2D drag affordances — `column-resize` handles every dimension arrow
// the floor-plan builder emits per cross-section / support style (the
// payload's `dim` field discriminates radius / uniform / width / depth
// / brace-width / brace-depth / spreads). `column-rotate` powers the
// corner rotate-arrow.
floorplanAffordances: {
'column-resize': columnResizeAffordance,
'column-rotate': columnRotateAffordance,
},
presentation: {
label: 'Column',
description: 'A parametric column with configurable cross-section, base, and capital.',
icon: { kind: 'url', src: '/icons/column.png' },
paletteSection: 'structure',
paletteOrder: 70,
},
mcp: {
description: 'A parametric column placed on a slab or level.',
},
}