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:
co-authored by
Claude Opus 4.8
parent
891e578481
commit
8abfc94b99
@@ -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,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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user