Phase 5 Stage E: full kind migration into packages/nodes
Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.
Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
(boundary editors, hole editors, placement tools, move tools,
endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`
Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
`roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
`stair-segment/` packages with definition + schema + renderer / system
/ floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
(ceiling, door, item, shelf, slab, window) — single registry-driven
dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
drags (ceiling, fence, slab, wall) — using the shared
`polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
(door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
(door, item, slab, window).
Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
`floorplan-render-context.tsx` updated for the registry layer's new
contract (props, affordance dispatch, render context).
Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
`wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
the registry-only world.
Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.
Wiki: new `plugin-authoring.md` page, README index updated.
Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
11015ea1ed
commit
d747d2f0ea
@@ -0,0 +1,64 @@
|
||||
import { ColumnNode as ColumnNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import { buildColumnFloorplan } from './floorplan'
|
||||
import { columnParametrics } from './parametrics'
|
||||
import { ColumnNode } from './schema'
|
||||
|
||||
/**
|
||||
* 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',
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
parametrics: columnParametrics,
|
||||
|
||||
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'),
|
||||
},
|
||||
floorplan: buildColumnFloorplan,
|
||||
|
||||
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.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
ColumnNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for column. Inlined from the legacy
|
||||
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
|
||||
* footprint shape depends on `crossSection` (square / rectangular /
|
||||
* round / octagonal / sixteen-sided) and `supportStyle` (vertical /
|
||||
* a-frame / x-brace / etc.) — brace supports use a rotated rectangle
|
||||
* spanning the base spread; standalone columns use the shaft profile.
|
||||
*
|
||||
* When selected, switches to a themed accent stroke and emits a move
|
||||
* handle at the column center. No dimension overlay (columns don't
|
||||
* have a natural "length" axis like a wall).
|
||||
*/
|
||||
export function buildColumnFloorplan(
|
||||
node: ColumnNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const polygon = getColumnPlanFootprint(node)
|
||||
if (polygon.length < 3) return null
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
|
||||
|
||||
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||
opacity: 0.92,
|
||||
},
|
||||
]
|
||||
|
||||
// Hatch overlay on selected — same `<defs>` pattern as the wall.
|
||||
if (isSelected && palette) {
|
||||
children.push({
|
||||
kind: 'hatch',
|
||||
points,
|
||||
color: palette.selectedHatch,
|
||||
opacity: 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
// Move handle at the column center when selected.
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [node.position[0], node.position[2]],
|
||||
})
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
// ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
|
||||
|
||||
type PlanPoint = { x: number; y: number }
|
||||
|
||||
function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
|
||||
const c = Math.cos(rotation)
|
||||
const s = Math.sin(rotation)
|
||||
return [x * c - y * s, x * s + y * c]
|
||||
}
|
||||
|
||||
function getRotatedRectanglePolygon(
|
||||
center: PlanPoint,
|
||||
width: number,
|
||||
depth: number,
|
||||
rotation: number,
|
||||
): PlanPoint[] {
|
||||
const halfW = width / 2
|
||||
const halfD = depth / 2
|
||||
const corners: Array<[number, number]> = [
|
||||
[-halfW, -halfD],
|
||||
[halfW, -halfD],
|
||||
[halfW, halfD],
|
||||
[-halfW, halfD],
|
||||
]
|
||||
return corners.map(([x, y]) => {
|
||||
const [rx, ry] = rotatePlanVector(x, y, rotation)
|
||||
return { x: center.x + rx, y: center.y + ry }
|
||||
})
|
||||
}
|
||||
|
||||
function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
|
||||
const center: PlanPoint = { x: column.position[0], y: column.position[2] }
|
||||
|
||||
// Brace-support columns: rotated rectangle spanning the base spread.
|
||||
if (
|
||||
column.supportStyle === 'a-frame' ||
|
||||
column.supportStyle === 'y-frame' ||
|
||||
column.supportStyle === 'v-frame' ||
|
||||
column.supportStyle === 'x-brace' ||
|
||||
column.supportStyle === 'k-brace' ||
|
||||
column.supportStyle === 'single-strut' ||
|
||||
column.supportStyle === 'tripod' ||
|
||||
column.supportStyle === 'trestle' ||
|
||||
column.supportStyle === 'portal-frame' ||
|
||||
column.supportStyle === 'box-frame'
|
||||
) {
|
||||
const width = Math.max(
|
||||
column.supportStyle === 'a-frame' ||
|
||||
column.supportStyle === 'x-brace' ||
|
||||
column.supportStyle === 'k-brace' ||
|
||||
column.supportStyle === 'single-strut' ||
|
||||
column.supportStyle === 'tripod' ||
|
||||
column.supportStyle === 'trestle' ||
|
||||
column.supportStyle === 'portal-frame' ||
|
||||
column.supportStyle === 'box-frame'
|
||||
? (column.braceBottomSpread ?? 1.2)
|
||||
: 0,
|
||||
column.braceTopSpread ??
|
||||
(column.supportStyle === 'y-frame' ||
|
||||
column.supportStyle === 'v-frame' ||
|
||||
column.supportStyle === 'x-brace' ||
|
||||
column.supportStyle === 'k-brace' ||
|
||||
column.supportStyle === 'single-strut' ||
|
||||
column.supportStyle === 'tripod' ||
|
||||
column.supportStyle === 'trestle' ||
|
||||
column.supportStyle === 'portal-frame' ||
|
||||
column.supportStyle === 'box-frame'
|
||||
? 1
|
||||
: 0),
|
||||
(column.braceWidth ?? column.width) * 2,
|
||||
)
|
||||
const depth = Math.max(
|
||||
column.supportStyle === 'tripod' ||
|
||||
column.supportStyle === 'trestle' ||
|
||||
column.supportStyle === 'box-frame'
|
||||
? (column.braceTopSpread ?? 1)
|
||||
: 0,
|
||||
column.braceDepth ?? column.depth,
|
||||
0.08,
|
||||
)
|
||||
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
|
||||
}
|
||||
|
||||
// Standalone column: shaft profile expanded for base + capital.
|
||||
const isRound =
|
||||
column.crossSection === 'round' ||
|
||||
column.crossSection === 'octagonal' ||
|
||||
column.crossSection === 'sixteen-sided'
|
||||
const shaftWidth = isRound ? column.radius * 2 : column.width
|
||||
const shaftDepth = isRound ? column.radius * 2 : column.depth
|
||||
const width = Math.max(
|
||||
shaftWidth,
|
||||
column.width * column.baseWidthScale,
|
||||
column.width * column.capitalWidthScale,
|
||||
)
|
||||
const depth = Math.max(
|
||||
shaftDepth,
|
||||
column.depth * column.baseDepthScale,
|
||||
column.depth * column.capitalDepthScale,
|
||||
)
|
||||
|
||||
if (column.crossSection === 'square' || column.crossSection === 'rectangular') {
|
||||
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
|
||||
}
|
||||
|
||||
const segmentCount =
|
||||
column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32
|
||||
|
||||
return Array.from({ length: segmentCount }, (_, index) => {
|
||||
const angle = (index / segmentCount) * Math.PI * 2
|
||||
const localX = Math.cos(angle) * (width / 2)
|
||||
const localY = Math.sin(angle) * (depth / 2)
|
||||
const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation)
|
||||
return { x: center.x + offsetX, y: center.y + offsetY }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { columnDefinition } from './definition'
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ColumnNode,
|
||||
ColumnNode as ColumnNodeSchema,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — column's registry-driven 3D move affordance.
|
||||
*
|
||||
* Replaces the legacy `MoveColumnTool` in `editor/src/components/tools/
|
||||
* column/move-column-tool.tsx`. Behaviour is identical: grid:move
|
||||
* snaps the cursor to a 0.5m grid and previews the column at that
|
||||
* position via `useLiveTransforms` + a direct `sceneRegistry.nodes.get
|
||||
* (id).position.set(...)` (the live-drag exception documented in
|
||||
* `wiki/architecture/tools.md`); grid:click commits via `useScene.
|
||||
* updateNode`. Cancel restores the pre-drag position.
|
||||
*
|
||||
* Wired via `def.affordanceTools.move`. The editor's `MoveTool`
|
||||
* dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup
|
||||
* picks this up before its legacy chain reaches `<MoveColumnTool>`.
|
||||
*/
|
||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||
|
||||
function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
let committed = false
|
||||
|
||||
const applyPreview = (position: [number, number, number]) => {
|
||||
setPreviewPosition(position)
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position,
|
||||
rotation: node.rotation,
|
||||
})
|
||||
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const position: [number, number, number] = [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
0,
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
const nodeId = (node as { id?: ColumnNode['id'] }).id
|
||||
|
||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||
committed = true
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, { position })
|
||||
} else if (node.parentId) {
|
||||
const column = ColumnNodeSchema.parse({
|
||||
...node,
|
||||
id: undefined,
|
||||
metadata: {},
|
||||
position,
|
||||
})
|
||||
committed = true
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().createNode(column, node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
triggerSFX('sfx:item-place')
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(node.position[0], node.position[1], node.position[2])
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
if (!committed) {
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(node.position[0], node.position[1], node.position[2])
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
||||
}
|
||||
|
||||
export default MoveColumnTool
|
||||
@@ -0,0 +1,930 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
COLUMN_PRESETS,
|
||||
type ColumnNode,
|
||||
type ColumnPresetId,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
cn,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
SliderControl,
|
||||
ToggleControl,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const SELECT_CLASS =
|
||||
'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border'
|
||||
|
||||
const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({
|
||||
value: value as ColumnPresetId,
|
||||
label: preset.label,
|
||||
}))
|
||||
|
||||
const COLUMN_PROPORTION_PRESETS = {
|
||||
slender: {
|
||||
label: 'Slender',
|
||||
height: 3.6,
|
||||
width: 0.34,
|
||||
baseHeight: 0.18,
|
||||
capitalHeight: 0.16,
|
||||
baseWidthScale: 1.18,
|
||||
capitalWidthScale: 1.16,
|
||||
edgeSoftness: 0.02,
|
||||
},
|
||||
standard: {
|
||||
label: 'Standard',
|
||||
height: 2.9,
|
||||
width: 0.44,
|
||||
baseHeight: 0.22,
|
||||
capitalHeight: 0.2,
|
||||
baseWidthScale: 1.24,
|
||||
capitalWidthScale: 1.22,
|
||||
edgeSoftness: 0.025,
|
||||
},
|
||||
heavy: {
|
||||
label: 'Heavy',
|
||||
height: 3,
|
||||
width: 0.58,
|
||||
baseHeight: 0.28,
|
||||
capitalHeight: 0.26,
|
||||
baseWidthScale: 1.34,
|
||||
capitalWidthScale: 1.3,
|
||||
edgeSoftness: 0.035,
|
||||
},
|
||||
stout: {
|
||||
label: 'Short / Stout',
|
||||
height: 2.2,
|
||||
width: 0.62,
|
||||
baseHeight: 0.3,
|
||||
capitalHeight: 0.28,
|
||||
baseWidthScale: 1.38,
|
||||
capitalWidthScale: 1.34,
|
||||
edgeSoftness: 0.04,
|
||||
},
|
||||
} as const
|
||||
|
||||
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
|
||||
|
||||
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
|
||||
([value, preset]) => ({
|
||||
value: value as ColumnProportionPresetId,
|
||||
label: preset.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const SUPPORT_STYLE_OPTIONS: Array<{ label: string; value: ColumnNode['supportStyle'] }> = [
|
||||
{ label: 'Vertical', value: 'vertical' },
|
||||
{ label: 'A-Frame', value: 'a-frame' },
|
||||
{ label: 'Y Support', value: 'y-frame' },
|
||||
{ label: 'V Support', value: 'v-frame' },
|
||||
{ label: 'X Brace', value: 'x-brace' },
|
||||
{ label: 'K Brace', value: 'k-brace' },
|
||||
{ label: 'Single Strut', value: 'single-strut' },
|
||||
{ label: 'Tripod', value: 'tripod' },
|
||||
{ label: 'Trestle', value: 'trestle' },
|
||||
{ label: 'Portal Frame', value: 'portal-frame' },
|
||||
{ label: 'Box Frame', value: 'box-frame' },
|
||||
]
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
|
||||
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
||||
return {
|
||||
name: label,
|
||||
supportStyle: 'supportStyle' in preset ? preset.supportStyle : 'vertical',
|
||||
...preset,
|
||||
}
|
||||
}
|
||||
|
||||
function proportionUpdates(
|
||||
node: ColumnNode,
|
||||
presetId: ColumnProportionPresetId,
|
||||
): Partial<ColumnNode> {
|
||||
const preset = COLUMN_PROPORTION_PRESETS[presetId]
|
||||
const depth =
|
||||
node.crossSection === 'rectangular'
|
||||
? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6)
|
||||
: preset.width
|
||||
const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18)
|
||||
|
||||
return {
|
||||
height: preset.height,
|
||||
width: preset.width,
|
||||
depth,
|
||||
radius: preset.width / 2,
|
||||
baseHeight: preset.baseHeight,
|
||||
capitalHeight: preset.capitalHeight,
|
||||
baseWidthScale: preset.baseWidthScale,
|
||||
baseDepthScale: preset.baseWidthScale,
|
||||
capitalWidthScale: preset.capitalWidthScale,
|
||||
capitalDepthScale: preset.capitalWidthScale,
|
||||
edgeSoftness: preset.edgeSoftness,
|
||||
shaftCornerRadius,
|
||||
}
|
||||
}
|
||||
|
||||
function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<ColumnNode> {
|
||||
if (shaftProfile === 'tapered') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0.14,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.82,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
if (shaftProfile === 'bulged') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0.12,
|
||||
shaftStartScale: 0.68,
|
||||
shaftEndScale: 0.68,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
if (shaftProfile === 'hourglass') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0.12,
|
||||
shaftStartScale: 0.84,
|
||||
shaftEndScale: 0.84,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.72,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 1,
|
||||
shaftTwistStep: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export default function ColumnPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ColumnNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
triggerSFX('sfx:structure-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [deleteNode, selectedId, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
triggerSFX('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
|
||||
const shaftProfile = node.shaftProfile ?? 'straight'
|
||||
const supportStyle = node.supportStyle ?? 'vertical'
|
||||
const isBraceSupport =
|
||||
supportStyle === 'a-frame' ||
|
||||
supportStyle === 'y-frame' ||
|
||||
supportStyle === 'v-frame' ||
|
||||
supportStyle === 'x-brace' ||
|
||||
supportStyle === 'k-brace' ||
|
||||
supportStyle === 'single-strut' ||
|
||||
supportStyle === 'tripod' ||
|
||||
supportStyle === 'trestle' ||
|
||||
supportStyle === 'portal-frame' ||
|
||||
supportStyle === 'box-frame'
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/column.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Column'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Preset">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return
|
||||
handleUpdate(presetUpdates(event.target.value as ColumnPresetId))
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Apply preset...</option>
|
||||
{COLUMN_PRESET_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Shape">
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
{SUPPORT_STYLE_OPTIONS.map((option) => {
|
||||
const isSelected = supportStyle === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
)}
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
supportStyle: option.value,
|
||||
...(option.value !== 'vertical'
|
||||
? {
|
||||
crossSection: 'rectangular',
|
||||
width: node.braceWidth ?? node.width,
|
||||
depth: node.braceDepth ?? node.depth,
|
||||
baseStyle: 'none',
|
||||
capitalStyle: 'none',
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isBraceSupport ? (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Brace Width"
|
||||
max={0.8}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ braceWidth: value, width: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={node.braceWidth ?? node.width}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Brace Depth"
|
||||
max={0.8}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ braceDepth: value, depth: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={node.braceDepth ?? node.depth}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
|
||||
}
|
||||
value={node.crossSection}
|
||||
>
|
||||
<option value="round">Round</option>
|
||||
<option value="square">Square</option>
|
||||
<option value="rectangular">Rectangular</option>
|
||||
</select>
|
||||
<SliderControl
|
||||
label="Edge Softness"
|
||||
max={0.12}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ edgeSoftness: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.edgeSoftness ?? 0.025}
|
||||
/>
|
||||
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
|
||||
<SliderControl
|
||||
label="Shaft Corner Radius"
|
||||
max={0.3}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.shaftCornerRadius ?? 0.035}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
{!isBraceSupport && (
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return
|
||||
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Apply proportion...</option>
|
||||
{COLUMN_PROPORTION_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={6}
|
||||
min={0.8}
|
||||
onChange={(value) => handleUpdate({ height: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={node.height}
|
||||
/>
|
||||
{isBraceSupport ? (
|
||||
<>
|
||||
{(supportStyle === 'a-frame' ||
|
||||
supportStyle === 'x-brace' ||
|
||||
supportStyle === 'k-brace' ||
|
||||
supportStyle === 'single-strut' ||
|
||||
supportStyle === 'tripod' ||
|
||||
supportStyle === 'trestle' ||
|
||||
supportStyle === 'portal-frame' ||
|
||||
supportStyle === 'box-frame') && (
|
||||
<SliderControl
|
||||
label="Bottom Spread"
|
||||
max={4}
|
||||
min={0.2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
braceBottomSpread: value,
|
||||
braceTopSpread:
|
||||
supportStyle === 'a-frame'
|
||||
? Math.min(node.braceTopSpread ?? 0.12, value)
|
||||
: (node.braceTopSpread ?? 1),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={node.braceBottomSpread ?? 1.2}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
label={supportStyle === 'y-frame' ? 'Fork Spread' : 'Top Spread'}
|
||||
max={
|
||||
supportStyle === 'y-frame' ||
|
||||
supportStyle === 'v-frame' ||
|
||||
supportStyle === 'x-brace' ||
|
||||
supportStyle === 'k-brace' ||
|
||||
supportStyle === 'single-strut' ||
|
||||
supportStyle === 'tripod' ||
|
||||
supportStyle === 'trestle' ||
|
||||
supportStyle === 'box-frame'
|
||||
? 4
|
||||
: Math.max(0.2, node.braceBottomSpread ?? 1.2)
|
||||
}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ braceTopSpread: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={
|
||||
node.braceTopSpread ??
|
||||
(supportStyle === 'y-frame' ||
|
||||
supportStyle === 'v-frame' ||
|
||||
supportStyle === 'x-brace' ||
|
||||
supportStyle === 'k-brace' ||
|
||||
supportStyle === 'single-strut' ||
|
||||
supportStyle === 'tripod' ||
|
||||
supportStyle === 'trestle' ||
|
||||
supportStyle === 'portal-frame' ||
|
||||
supportStyle === 'box-frame'
|
||||
? 1
|
||||
: 0.12)
|
||||
}
|
||||
/>
|
||||
<ToggleControl
|
||||
checked={node.bracePlateEnabled ?? true}
|
||||
label="Connector Plates"
|
||||
onChange={(checked) => handleUpdate({ bracePlateEnabled: checked })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={1.6}
|
||||
min={0.12}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
width: value,
|
||||
radius: value / 2,
|
||||
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.width}
|
||||
/>
|
||||
{node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={1.6}
|
||||
min={0.12}
|
||||
onChange={(value) => handleUpdate({ depth: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.depth}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
{!isBraceSupport && (
|
||||
<PanelSection title="Shaft">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))
|
||||
}
|
||||
value={shaftProfile}
|
||||
>
|
||||
<option value="straight">Straight</option>
|
||||
<option value="tapered">Tapered</option>
|
||||
<option value="bulged">Bulged</option>
|
||||
<option value="hourglass">Hourglass</option>
|
||||
</select>
|
||||
{shaftProfile === 'straight' && (
|
||||
<SliderControl
|
||||
label="Shaft Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.72}
|
||||
/>
|
||||
)}
|
||||
{shaftProfile === 'tapered' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Bottom Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.82}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Top Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftEndScale ?? 0.72}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Taper"
|
||||
max={0.45}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftTaper: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftTaper ?? 0.14}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shaftProfile === 'bulged' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.68}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Bulge"
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftBulge ?? 0.12}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shaftProfile === 'hourglass' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.84}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Waist"
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftBulge ?? 0.12}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Segment Twist"
|
||||
max={90}
|
||||
min={-90}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
shaftTwistStep: value,
|
||||
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
|
||||
? { shaftSegmentCount: 12 }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
precision={0}
|
||||
step={5}
|
||||
unit="°"
|
||||
value={node.shaftTwistStep ?? 0}
|
||||
/>
|
||||
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
|
||||
<SliderControl
|
||||
label="Twist Segments"
|
||||
max={48}
|
||||
min={4}
|
||||
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.shaftSegmentCount ?? 12}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Ring Pairs"
|
||||
max={4}
|
||||
min={0}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
ringCount: Math.round(value) * 2,
|
||||
ringPlacement: 'ends',
|
||||
ringSpread: node.ringSpread ?? 0.16,
|
||||
ringThickness: node.ringThickness ?? 0.055,
|
||||
})
|
||||
}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={Math.ceil((node.ringCount ?? 0) / 2)}
|
||||
/>
|
||||
{(node.ringCount ?? 0) > 0 && (
|
||||
<SliderControl
|
||||
label="Ring Thickness"
|
||||
max={0.14}
|
||||
min={0.01}
|
||||
onChange={(value) => handleUpdate({ ringThickness: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.ringThickness ?? 0.055}
|
||||
/>
|
||||
)}
|
||||
{(node.ringCount ?? 0) > 0 && (
|
||||
<SliderControl
|
||||
label="Ring Spread"
|
||||
max={0.45}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.ringSpread ?? 0.16}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{!isBraceSupport && (
|
||||
<PanelSection title="Ends">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
const capitalStyle = event.target.value as ColumnNode['capitalStyle']
|
||||
handleUpdate({
|
||||
capitalStyle,
|
||||
...(capitalStyle === 'none'
|
||||
? {}
|
||||
: {
|
||||
capitalHeight: Math.max(node.capitalHeight, 0.12),
|
||||
capitalTierCount:
|
||||
capitalStyle === 'stepped'
|
||||
? Math.max(node.capitalTierCount ?? 3, 3)
|
||||
: node.capitalTierCount,
|
||||
capitalWidthScale: Math.max(
|
||||
node.capitalWidthScale ?? 1.3,
|
||||
capitalStyle === 'stepped' ? 1.42 : 1.28,
|
||||
),
|
||||
capitalDepthScale: Math.max(
|
||||
node.capitalDepthScale ?? 1.3,
|
||||
capitalStyle === 'stepped' ? 1.42 : 1.28,
|
||||
),
|
||||
capitalStepSpread:
|
||||
capitalStyle === 'stepped'
|
||||
? Math.max(node.capitalStepSpread ?? 0.34, 0.34)
|
||||
: node.capitalStepSpread,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')}
|
||||
>
|
||||
<option value="none">No Top</option>
|
||||
<option value="simple">Simple Top</option>
|
||||
<option value="stepped">Stepped Top</option>
|
||||
<option value="rounded">Rounded Top</option>
|
||||
</select>
|
||||
{node.capitalStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Top Height"
|
||||
max={0.8}
|
||||
min={0.06}
|
||||
onChange={(value) => handleUpdate({ capitalHeight: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.capitalHeight}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Top Width"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
capitalWidthScale: value,
|
||||
...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.capitalWidthScale ?? 1.28}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Top Depth"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) => handleUpdate({ capitalDepthScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle === 'stepped' && (
|
||||
<SliderControl
|
||||
label="Top Tiers"
|
||||
max={8}
|
||||
min={3}
|
||||
onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.capitalTierCount ?? 3}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle === 'stepped' && (
|
||||
<SliderControl
|
||||
label="Top Step Spread"
|
||||
max={0.9}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ capitalStepSpread: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.capitalStepSpread ?? 0.34}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
className={`${SELECT_CLASS} mt-2`}
|
||||
onChange={(event) => {
|
||||
const baseStyle = event.target.value as ColumnNode['baseStyle']
|
||||
handleUpdate({
|
||||
baseStyle,
|
||||
...(baseStyle === 'none'
|
||||
? {}
|
||||
: {
|
||||
baseHeight: Math.max(node.baseHeight, 0.12),
|
||||
baseTierCount:
|
||||
baseStyle === 'stepped-square'
|
||||
? Math.max(node.baseTierCount ?? 3, 3)
|
||||
: node.baseTierCount,
|
||||
baseWidthScale: Math.max(
|
||||
node.baseWidthScale ?? 1.24,
|
||||
baseStyle === 'stepped-square' ? 1.42 : 1.24,
|
||||
),
|
||||
baseDepthScale: Math.max(
|
||||
node.baseDepthScale ?? 1.24,
|
||||
baseStyle === 'stepped-square' ? 1.42 : 1.24,
|
||||
),
|
||||
baseStepSpread:
|
||||
baseStyle === 'stepped-square'
|
||||
? Math.max(node.baseStepSpread ?? 0.34, 0.34)
|
||||
: node.baseStepSpread,
|
||||
basePlinthHeightRatio:
|
||||
baseStyle === 'round-rings'
|
||||
? (node.basePlinthHeightRatio ?? 0.44)
|
||||
: node.basePlinthHeightRatio,
|
||||
baseRoundBandScale:
|
||||
baseStyle === 'round-rings'
|
||||
? (node.baseRoundBandScale ?? 0.92)
|
||||
: node.baseRoundBandScale,
|
||||
baseNeckScale:
|
||||
baseStyle === 'round-rings'
|
||||
? (node.baseNeckScale ?? 0.72)
|
||||
: node.baseNeckScale,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
value={node.baseStyle ?? 'square-plinth'}
|
||||
>
|
||||
<option value="none">No Bottom</option>
|
||||
<option value="simple-square">Simple Block Bottom</option>
|
||||
<option value="square-plinth">Square Plinth Bottom</option>
|
||||
<option value="stepped-square">Stepped Bottom</option>
|
||||
<option value="round-rings">Rounded Bottom</option>
|
||||
</select>
|
||||
{node.baseStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Bottom Height"
|
||||
max={0.8}
|
||||
min={0.06}
|
||||
onChange={(value) => handleUpdate({ baseHeight: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.baseHeight}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Bottom Width"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
baseWidthScale: value,
|
||||
...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.baseWidthScale ?? 1.24}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle !== 'none' && node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Bottom Depth"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) => handleUpdate({ baseDepthScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Plinth Thickness"
|
||||
max={0.7}
|
||||
min={0.2}
|
||||
onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.basePlinthHeightRatio ?? 0.44}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Round Band Width"
|
||||
max={1.2}
|
||||
min={0.5}
|
||||
onChange={(value) => handleUpdate({ baseRoundBandScale: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseRoundBandScale ?? 0.92}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Neck Width"
|
||||
max={1}
|
||||
min={0.35}
|
||||
onChange={(value) => handleUpdate({ baseNeckScale: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseNeckScale ?? 0.72}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'stepped-square' && (
|
||||
<SliderControl
|
||||
label="Bottom Tiers"
|
||||
max={8}
|
||||
min={3}
|
||||
onChange={(value) => handleUpdate({ baseTierCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.baseTierCount ?? 3}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'stepped-square' && (
|
||||
<SliderControl
|
||||
label="Bottom Step Spread"
|
||||
max={0.9}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ baseStepSpread: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseStepSpread ?? 0.34}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Transform">
|
||||
<SliderControl
|
||||
label="Yaw"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(value) => handleUpdate({ rotation: (value * Math.PI) / 180 })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { ColumnNode } from './schema'
|
||||
|
||||
/**
|
||||
* Stage A inspector — minimal. Column has 60+ schema fields (cross-
|
||||
* section, shaft profile, capital style, base style, carvings, ring
|
||||
* placement, etc.); the legacy `<ColumnPanel>` renders these via
|
||||
* panel-manager's hardcoded switch. The descriptor below registers
|
||||
* the kind as "has parametric data" without trying to express the
|
||||
* full legacy panel — Stage E will replace it via `customPanel`.
|
||||
*/
|
||||
export const columnParametrics: ParametricDescriptor<ColumnNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Dimensions',
|
||||
fields: [
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 0.5, max: 6, step: 0.05 },
|
||||
{ key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 },
|
||||
{ key: 'depth', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 },
|
||||
],
|
||||
},
|
||||
],
|
||||
customPanel: () => import('./panel'),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { ColumnNode } from '@pascal-app/core'
|
||||
Reference in New Issue
Block a user