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>
This commit is contained in:
Wassim SAMAD
2026-06-01 13:13:23 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 891e578481
commit 8abfc94b99
40 changed files with 188 additions and 550 deletions
@@ -1,11 +1,6 @@
'use client'
import {
type CeilingNode,
resolveLevelId,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { PolygonEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect } from 'react'
+1 -6
View File
@@ -1,11 +1,6 @@
'use client'
import {
type CeilingNode,
resolveLevelId,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { PolygonEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect } from 'react'
+4 -15
View File
@@ -154,20 +154,14 @@ function columnBraceHandle(axis: 'x' | 'z'): HandleDescriptor<ColumnNodeType> {
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 },
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
? 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]
@@ -205,12 +199,7 @@ function columnFootprintHalf(n: ColumnNodeType): { halfX: number; halfZ: number
}
return {
halfX:
Math.max(
n.width,
n.braceWidth ?? 0,
n.braceBottomSpread ?? 0,
n.braceTopSpread ?? 0,
) / 2,
Math.max(n.width, n.braceWidth ?? 0, n.braceBottomSpread ?? 0, n.braceTopSpread ?? 0) / 2,
halfZ: Math.max(n.depth, n.braceDepth ?? 0) / 2,
}
}
@@ -53,8 +53,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
const initialRadius = node.radius
const initialBraceWidth = node.braceWidth ?? node.width
const initialBraceDepth = node.braceDepth ?? node.depth
const initialBraceBottomSpread =
node.braceBottomSpread ?? Math.max(node.width * 3, 1.2)
const initialBraceBottomSpread = node.braceBottomSpread ?? Math.max(node.width * 3, 1.2)
const initialBraceTopSpread = node.braceTopSpread ?? 0.12
let lastPatch: Partial<ColumnNode> = {}
@@ -110,10 +109,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
return
case 'brace-top-spread':
commitPatch({
braceTopSpread: Math.max(
MIN_BRACE_TOP_SPREAD,
initialBraceTopSpread + 2 * projDelta,
),
braceTopSpread: Math.max(MIN_BRACE_TOP_SPREAD, initialBraceTopSpread + 2 * projDelta),
})
return
}
+1 -2
View File
@@ -102,8 +102,7 @@ export function buildColumnFloorplan(
// outward tip in plan coords. Captured at emit-time so the
// affordance doesn't need to recompute `column.rotation` (and
// a mid-drag rotation can't drift the projection basis).
const outwardLocal: [number, number] =
localDirection === 'x' ? [1, 0] : [0, 1]
const outwardLocal: [number, number] = localDirection === 'x' ? [1, 0] : [0, 1]
const [planAxisX, planAxisY] = rotatePlanVector(outwardLocal[0], outwardLocal[1], rot)
children.push({
kind: 'move-arrow',
+1 -3
View File
@@ -355,9 +355,7 @@ export default function ColumnPanel() {
// so the preset's braceWidth / braceDepth win over
// the carried-from-previous-style values.
const stylePreset =
option.value === 'vertical'
? {}
: SUPPORT_STYLE_DEFAULTS[option.value]
option.value === 'vertical' ? {} : SUPPORT_STYLE_DEFAULTS[option.value]
handleUpdate({
supportStyle: option.value,
...(option.value !== 'vertical'
+2 -5
View File
@@ -1,6 +1,6 @@
import {
type ElevatorNode as ElevatorNodeType,
ElevatorNode as ElevatorNodeSchema,
type ElevatorNode as ElevatorNodeType,
getElevatorCabDepth,
getElevatorCabWidth,
getElevatorShaftDepth,
@@ -10,11 +10,8 @@ import {
type NodeDefinition,
resolveElevatorLevels,
} from '@pascal-app/core'
import {
elevatorResizeAffordance,
elevatorRotateAffordance,
} from './floorplan-affordances'
import { buildElevatorFloorplan } from './floorplan'
import { elevatorResizeAffordance, elevatorRotateAffordance } from './floorplan-affordances'
import { elevatorParametrics } from './parametrics'
import { ElevatorNode } from './schema'
+6 -1
View File
@@ -326,7 +326,12 @@ export function buildElevatorFloorplan(
{ local: [outerHalfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 },
{ local: [-(outerHalfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 },
{ local: [0, outerHalfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 },
{ local: [0, -(outerHalfD + sideArrowOffset)], localAngle: -Math.PI / 2, axis: 'z', side: -1 },
{
local: [0, -(outerHalfD + sideArrowOffset)],
localAngle: -Math.PI / 2,
axis: 'z',
side: -1,
},
]
for (const side of sides) {
const [ox, oz] = rotate(side.local[0], side.local[1])
@@ -3,15 +3,15 @@ import {
getPitchFromActiveRoofHeight,
type HandleDescriptor,
type NodeDefinition,
type RoofSegmentNode as RoofSegmentNodeType,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofSegmentNode as RoofSegmentNodeType,
} from '@pascal-app/core'
import { buildRoofSegmentFloorplan } from './floorplan'
import {
roofSegmentMoveTarget,
roofSegmentResizeAffordance,
roofSegmentRotateAffordance,
} from './floorplan-affordances'
import { buildRoofSegmentFloorplan } from './floorplan'
import { roofSegmentParametrics } from './parametrics'
import { RoofSegmentNode } from './schema'
@@ -154,11 +154,7 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
// is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor
// back into roof-local coords.
void roofRot
let lastLocal: [number, number, number] = [
node.position[0],
node.position[1],
node.position[2],
]
let lastLocal: [number, number, number] = [node.position[0], node.position[1], node.position[2]]
return {
affectedIds: [segmentId],
+2 -6
View File
@@ -1,10 +1,6 @@
import type {
HandleDescriptor,
NodeDefinition,
ShelfNode as ShelfNodeType,
} from '@pascal-app/core'
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core'
import { buildShelfFloorplan } from './floorplan'
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
import { shelfFloorplanMoveTarget } from './floorplan-move'
import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry'
import { shelfParametrics } from './parametrics'
+1 -4
View File
@@ -24,10 +24,7 @@ const ROTATE_ARROW_CORNER_OFFSET = 0.22
* 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 {
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
const [px, , pz] = node.position
const ry = node.rotation[1] ?? 0
// Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction
+4 -7
View File
@@ -1,8 +1,8 @@
import {
type HandleDescriptor,
type NodeDefinition,
type StairNode as StairNodeType,
StairNode as StairNodeSchema,
type StairNode as StairNodeType,
} from '@pascal-app/core'
const MIN_CURVED_RISE = 0.3
@@ -223,11 +223,7 @@ function stairRotateGizmoPosition(n: StairNodeType): [number, number, number] {
}
const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH)
const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2
return [
width / 2 + STAIR_ROTATE_CORNER_OFFSET,
yMid,
-STAIR_ROTATE_CORNER_OFFSET,
]
return [width / 2 + STAIR_ROTATE_CORNER_OFFSET, yMid, -STAIR_ROTATE_CORNER_OFFSET]
}
function stairRotateHandle(): HandleDescriptor<StairNodeType> {
@@ -288,6 +284,8 @@ function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
handles.push(stairRotateHandle())
return handles
}
import { buildStairFloorplan } from './floorplan'
import {
curvedStairInnerRadiusAffordance,
curvedStairSweepAffordance,
@@ -296,7 +294,6 @@ import {
segmentWidthAffordance,
stairRotateAffordance,
} from './floorplan-affordances'
import { buildStairFloorplan } from './floorplan'
import { stairFloorplanMoveTarget } from './floorplan-move'
import { stairParametrics } from './parametrics'
import { StairNode } from './schema'
@@ -146,10 +146,7 @@ export const curvedStairWidthAffordance: FloorplanAffordance<StairNode> = {
affectedIds: [stairId],
apply({ planPoint }) {
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
const newWidth = Math.max(
MIN_CURVED_WIDTH,
initialWidth + (currentRadial - initialRadial),
)
const newWidth = Math.max(MIN_CURVED_WIDTH, initialWidth + (currentRadial - initialRadial))
lastWidth = newWidth
useScene.getState().updateNode(stairId, { width: newWidth })
},
@@ -172,7 +169,9 @@ 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 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
@@ -303,9 +302,7 @@ export const curvedStairSweepAffordance: FloorplanAffordance<StairNode> = {
return true
},
commit() {
useScene
.getState()
.updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
useScene.getState().updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
},
}
},
+5 -19
View File
@@ -11,6 +11,7 @@ import type {
// `definition.ts` so the 2D handle visually lines up with where the 3D
// curved-arrow gizmo would sit at the matching world point.
const STAIR_ROTATE_PLAN_OFFSET = 0.4
import {
buildFloorplanStairEntry,
buildSvgAnnularSectorPath,
@@ -134,14 +135,8 @@ export function buildStairFloorplan(
// Segment-local +X (width axis) and +Z (run axis) in plan coords,
// captured here so the affordance handler can project pointer
// deltas without re-walking the stair chain.
const axisX: readonly [number, number] = [
(c1.x - c0.x) / width,
(c1.y - c0.y) / width,
]
const axisZ: readonly [number, number] = [
(c3.x - c0.x) / length,
(c3.y - c0.y) / length,
]
const axisX: readonly [number, number] = [(c1.x - c0.x) / width, (c1.y - c0.y) / width]
const axisZ: readonly [number, number] = [(c3.x - c0.x) / length, (c3.y - c0.y) / length]
const rightMid: [number, number] = [(c1.x + c2.x) / 2, (c1.y + c2.y) / 2]
const leftMid: [number, number] = [(c0.x + c3.x) / 2, (c0.y + c3.y) / 2]
const frontEdgeMid: [number, number] = [(c2.x + c3.x) / 2, (c2.y + c3.y) / 2]
@@ -277,13 +272,7 @@ export function buildStairFloorplan(
// steps past `dashedFromIndex` are dashed.
const isEmphasised = stairType === 'spiral' ? isLast : isFirst || isLast
const stepWidth =
stairType === 'spiral'
? isEmphasised
? 1.8
: 1.15
: isEmphasised
? 1.5
: 1.1
stairType === 'spiral' ? (isEmphasised ? 1.8 : 1.15) : isEmphasised ? 1.5 : 1.1
children.push({
kind: 'line',
x1: inner.x,
@@ -455,10 +444,7 @@ export function buildStairFloorplan(
localZ = -STAIR_ROTATE_PLAN_OFFSET
} else {
const isSpiral = stairType === 'spiral'
const innerR = Math.max(
isSpiral ? 0.05 : 0.2,
stair.innerRadius ?? (isSpiral ? 0.2 : 0.9),
)
const innerR = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9))
const outerR = innerR + (stair.width ?? 1)
const sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
const radius = outerR + STAIR_ROTATE_PLAN_OFFSET
+1 -4
View File
@@ -651,10 +651,7 @@ function SpiralStepSupportMesh({
const sizeX = Math.max(0.04, innerRadius - spiralColumnRadius + 0.04)
const sizeY = Math.max(thickness * 0.55, 0.025)
const sizeZ = Math.max(0.04, Math.min(0.12, sizeY * 1.5))
const geometry = useMemo(
() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ),
[sizeX, sizeY, sizeZ],
)
const geometry = useMemo(() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ), [sizeX, sizeY, sizeZ])
useEffect(
() => () => {
geometry.dispose()
+9 -7
View File
@@ -187,13 +187,15 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node })
// until the user commits. Batched into a single zustand
// notification — otherwise each per-wall `.set` would re-render
// every override subscriber once per linked wall per tick.
useLiveNodeOverrides.getState().setMany([
[wallId, { start: nextStart, end: nextEnd }],
...linkedUpdates.map(
(upd) =>
[upd.id, { start: upd.start, end: upd.end }] as [string, Record<string, unknown>],
),
])
useLiveNodeOverrides
.getState()
.setMany([
[wallId, { start: nextStart, end: nextEnd }],
...linkedUpdates.map(
(upd) =>
[upd.id, { start: upd.start, end: upd.end }] as [string, Record<string, unknown>],
),
])
// Surface bridge-wall previews so the floor-plan SVG layer can
// render dashed outlines of what `commit()` will insert. Mirrors
+1 -3
View File
@@ -460,9 +460,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
if (axis) {
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
const snappedProj = shiftPressedRef.current
? rawProj
: snapScalarToGrid(rawProj, snapStep)
const snappedProj = shiftPressedRef.current ? rawProj : snapScalarToGrid(rawProj, snapStep)
const perpDelta = snappedProj - originalProj
deltaX = axis[0] * perpDelta
deltaZ = axis[1] * perpDelta