ai-chatbot/packages/nodes/src/stair/floorplan-affordances.ts
Wassim SAMAD 8abfc94b99
feat(editor): preset-system polish — paint panel, slim action bar, icon rail (#354)
* feat(editor): preset-system polish — paint panel, slim action bar, icon rail

- Export `MaterialPaintPanel` so embedders host the paint material picker in
  their own panel (community docks it in the Build sidebar) instead of the
  bottom action bar.
- ActionMenu: drop the build / material-paint / furnish modes and the
  structure-tools palette row + paint tray (the host's Build sidebar owns
  building now). Reduce `structure-tools` to the shared `tools` lookup still
  used by cursor/floorplan indicators; remove the orphaned `useContextualTools`.
- MaterialPicker: swatches wrap into a fluid `auto-fill` grid that fills width.
- IconRail (tab-bar): bigger icons, grayscale-when-idle, Radix tooltip, w-14
  rail; sync `RAIL_WIDTH` to 56.
- Inspector footer: hand the host `footer` to kind-owned custom panels via
  `InspectorFooterContext` so the save button renders without per-kind wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: biome format pass

Whole-repo formatter normalization (line wrap/unwrap only, no logic changes)
surfaced by the format-on-edit hook against prior drift. Kept separate from the
feature commit so the preset-system diff stays reviewable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:13:23 -04:00

310 lines
11 KiB
TypeScript

import {
type AnyNodeId,
type FloorplanAffordance,
type FloorplanAffordanceSession,
type StairNode,
type StairSegmentNode,
useScene,
} from '@pascal-app/core'
// Minimums + max sweep mirror the 3D handles in
// `packages/editor/src/components/editor/stair-segment-handles.tsx` so a 2D
// drag can't push a stair past what the 3D drag would allow.
const MIN_SEGMENT_WIDTH = 0.4
const MIN_SEGMENT_LENGTH = 0.4
const MIN_CURVED_WIDTH = 0.4
const MIN_CURVED_INNER_RADIUS_SPIRAL = 0.05
const MIN_CURVED_INNER_RADIUS_CURVED = 0.2
const MIN_CURVED_SWEEP = Math.PI / 12
const MAX_CURVED_SWEEP = Math.PI * 2 - 0.05
type SegmentWidthPayload = {
segmentId: string
side: 'left' | 'right'
axisX: readonly [number, number]
}
type SegmentLengthPayload = {
segmentId: string
axisZ: readonly [number, number]
}
type CurvedSweepPayload = {
end: 'start' | 'end'
}
function noopSession(): FloorplanAffordanceSession {
return {
affectedIds: [],
apply() {},
canCommit() {
return false
},
}
}
/**
* Straight-stair segment side arrow → segment `width`. Sister to the 3D
* `StairSegmentSideArrow` width drag (~line 235 of stair-segment-handles.tsx).
* Width grows symmetrically around the segment centerline — the chain
* rebuilds from the segment's `width` field, no opposite-edge anchor write
* required. `axisX` is the segment-local +X axis in plan coords, captured
* at emit-time so the projection stays valid through the drag.
*/
export const segmentWidthAffordance: FloorplanAffordance<StairNode> = {
start({ payload, nodes, initialPlanPoint }) {
const { segmentId, side, axisX } = payload as SegmentWidthPayload
const segmentNodeId = segmentId as AnyNodeId
const segment = nodes[segmentNodeId] as StairSegmentNode | undefined
if (!segment || segment.type !== 'stair-segment') return noopSession()
const initialWidth = segment.width
const sign = side === 'right' ? 1 : -1
const ax = axisX[0]
const ay = axisX[1]
const initialProj = initialPlanPoint[0] * ax + initialPlanPoint[1] * ay
let lastWidth = initialWidth
return {
affectedIds: [segmentNodeId],
apply({ planPoint }) {
const currentProj = planPoint[0] * ax + planPoint[1] * ay
const delta = sign * (currentProj - initialProj)
const newWidth = Math.max(MIN_SEGMENT_WIDTH, initialWidth + delta)
lastWidth = newWidth
useScene.getState().updateNode(segmentNodeId, { width: newWidth })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(segmentNodeId, { width: lastWidth })
},
}
},
}
/**
* Straight-stair segment length arrow → segment `length`. Sister to the 3D
* `StairSegmentLengthArrow` drag. The chain anchors each segment's back
* face, so length simply extends/contracts the front. `axisZ` is the
* segment-local +Z (run) direction in plan coords.
*/
export const segmentLengthAffordance: FloorplanAffordance<StairNode> = {
start({ payload, nodes, initialPlanPoint }) {
const { segmentId, axisZ } = payload as SegmentLengthPayload
const segmentNodeId = segmentId as AnyNodeId
const segment = nodes[segmentNodeId] as StairSegmentNode | undefined
if (!segment || segment.type !== 'stair-segment') return noopSession()
const initialLength = segment.length
const az = axisZ[0]
const ay = axisZ[1]
const initialProj = initialPlanPoint[0] * az + initialPlanPoint[1] * ay
let lastLength = initialLength
return {
affectedIds: [segmentNodeId],
apply({ planPoint }) {
const currentProj = planPoint[0] * az + planPoint[1] * ay
const delta = currentProj - initialProj
const newLength = Math.max(MIN_SEGMENT_LENGTH, initialLength + delta)
lastLength = newLength
useScene.getState().updateNode(segmentNodeId, { length: newLength })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(segmentNodeId, { length: lastLength })
},
}
},
}
/**
* Curved / spiral width arrow → stair `width`. Drag radially outward grows
* the body, anchored at the inner radius (matches the 3D
* `CurvedStairWidthArrow`). The sweep bisector matches the existing
* floor-plan emitter (`sectorStartAngle = -rotation - sweep/2`, bisector
* = -rotation).
*/
export const curvedStairWidthAffordance: FloorplanAffordance<StairNode> = {
start({ node, initialPlanPoint }) {
const stairId = node.id as AnyNodeId
const initialWidth = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
const midAngle = -node.rotation
const cx = node.position[0]
const cz = node.position[2]
const radialX = Math.cos(midAngle)
const radialZ = Math.sin(midAngle)
const initialRadial =
(initialPlanPoint[0] - cx) * radialX + (initialPlanPoint[1] - cz) * radialZ
let lastWidth = initialWidth
return {
affectedIds: [stairId],
apply({ planPoint }) {
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
const newWidth = Math.max(MIN_CURVED_WIDTH, initialWidth + (currentRadial - initialRadial))
lastWidth = newWidth
useScene.getState().updateNode(stairId, { width: newWidth })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(stairId, { width: lastWidth })
},
}
},
}
/**
* Curved / spiral inner-radius arrow → stair `innerRadius` + `width`. Keeps
* the outer edge pinned (width absorbs the radial delta) so dragging only
* shifts the inner rim, matching the 3D `CurvedStairInnerRadiusArrow`.
*/
export const curvedStairInnerRadiusAffordance: FloorplanAffordance<StairNode> = {
start({ node, initialPlanPoint }) {
const stairId = node.id as AnyNodeId
const isSpiral = node.stairType === 'spiral'
const minInnerRadius = isSpiral
? MIN_CURVED_INNER_RADIUS_SPIRAL
: MIN_CURVED_INNER_RADIUS_CURVED
const initialInnerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9)
const initialWidth = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
const initialOuterRadius = initialInnerRadius + initialWidth
const maxInnerRadius = initialOuterRadius - MIN_CURVED_WIDTH
const midAngle = -node.rotation
const cx = node.position[0]
const cz = node.position[2]
const radialX = Math.cos(midAngle)
const radialZ = Math.sin(midAngle)
const initialRadial =
(initialPlanPoint[0] - cx) * radialX + (initialPlanPoint[1] - cz) * radialZ
let lastInner = initialInnerRadius
let lastWidth = initialWidth
return {
affectedIds: [stairId],
apply({ planPoint }) {
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
const innerDelta = currentRadial - initialRadial
const newInner = Math.min(
maxInnerRadius,
Math.max(minInnerRadius, initialInnerRadius + innerDelta),
)
const newWidth = initialOuterRadius - newInner
lastInner = newInner
lastWidth = newWidth
useScene.getState().updateNode(stairId, { innerRadius: newInner, width: newWidth })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(stairId, { innerRadius: lastInner, width: lastWidth })
},
}
},
}
/**
* Whole-stair rotation gizmo → stair `rotation`. Mirrors the 3D
* `stairRotateHandle` (arc-resize, curved-arrow shape). Angular drag
* around the stair's plan-space pivot — atan2 ticks CW visually, the
* stored `rotation` field is the schema's Y-axis radians, and the
* floorplan plots sectors at `-rotation`, so a positive cursor delta
* (CCW around the centre in standard math coords / CW on screen given
* inverted Y) should DECREASE `rotation`. Same `- delta` convention the
* 3D handle uses; cursor handedness across both views matches.
*/
export const stairRotateAffordance: FloorplanAffordance<StairNode> = {
start({ node, initialPlanPoint }) {
const stairId = node.id as AnyNodeId
const initialRotation = node.rotation ?? 0
const cx = node.position[0]
const cz = node.position[2]
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
let lastRotation = initialRotation
return {
affectedIds: [stairId],
apply({ planPoint }) {
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
let delta = currentAngle - initialAngle
// Wrap to [-π, π] so a drag crossing ±π doesn't flip sign mid-gesture.
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
const newRotation = initialRotation - delta
lastRotation = newRotation
useScene.getState().updateNode(stairId, { rotation: newRotation })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(stairId, { rotation: lastRotation })
},
}
},
}
/**
* Curved / spiral sweep arrows → stair `sweepAngle` + `rotation`. Anchors
* the opposite edge world-fixed by nudging `rotation` by half the applied
* sweep delta — mirror of the 3D `CurvedStairSweepArrow`. Sign math derives
* from the floorplan convention `sectorStartAngle = -rotation - sweep/2`:
*
* END handle (sweep += Δ, fix start): ΔR = -Δ/2
* START handle (sweep -= Δ, fix end): ΔR = +Δ/2
*/
export const curvedStairSweepAffordance: FloorplanAffordance<StairNode> = {
start({ node, payload, initialPlanPoint }) {
const { end } = payload as CurvedSweepPayload
const stairId = node.id as AnyNodeId
const initialSweep =
node.sweepAngle ?? (node.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
const sweepSign = Math.sign(initialSweep) || 1
const initialRotation = node.rotation
const cx = node.position[0]
const cz = node.position[2]
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
let lastSweep = initialSweep
let lastRotation = initialRotation
return {
affectedIds: [stairId],
apply({ planPoint }) {
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
let delta = currentAngle - initialAngle
// Wrap to [-π, π] so a drag crossing ±π doesn't flip sign mid-gesture.
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
const sweepDelta = end === 'end' ? delta : -delta
const targetSweep = initialSweep + sweepDelta
const clampedAbs = Math.min(
MAX_CURVED_SWEEP,
Math.max(MIN_CURVED_SWEEP, Math.abs(targetSweep)),
)
const newSweep = sweepSign * clampedAbs
const appliedDelta = newSweep - initialSweep
const rotationShift = end === 'end' ? -appliedDelta / 2 : appliedDelta / 2
const newRotation = initialRotation + rotationShift
lastSweep = newSweep
lastRotation = newRotation
useScene.getState().updateNode(stairId, { sweepAngle: newSweep, rotation: newRotation })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
},
}
},
}