Add column presets and procedural shaft controls

This commit is contained in:
sudhir
2026-05-04 16:38:52 +05:30
parent 46548c35e5
commit 848529acb0
28 changed files with 2808 additions and 27 deletions
@@ -25,8 +25,8 @@ import { Move } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu'
@@ -40,6 +40,7 @@ const ALLOWED_TYPES = [
'stair-segment',
'wall',
'fence',
'column',
'slab',
'ceiling',
'spawn',
@@ -147,10 +148,7 @@ export function FloatingActionMenu() {
node.type === 'wall'
? obj.localToWorld(
new THREE.Vector3(
Math.hypot(
segment.end[0] - segment.start[0],
segment.end[1] - segment.start[1],
),
Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]),
0,
0,
),
@@ -186,6 +184,7 @@ export function FloatingActionMenu() {
node.type === 'door' ||
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'spawn' ||
@@ -335,7 +334,7 @@ export function FloatingActionMenu() {
} else if (duplicate.type === 'stair') {
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
}
if (duplicate.type !== 'stair' && duplicate.type !== 'roof') {
if (duplicate.type !== 'stair') {
setSelection({ selectedIds: [] })
}
}
@@ -426,6 +425,7 @@ export function FloatingActionMenu() {
onDuplicate={
node &&
node.type !== 'spawn' &&
node.type !== 'column' &&
!DELETE_ONLY_TYPES.includes(node.type) &&
!HOLE_TYPES.includes(node.type)
? handleDuplicate
@@ -3,6 +3,7 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type ColumnNode,
emitter,
type FenceNode,
getMaterialPresetByRef,
@@ -65,6 +66,7 @@ type SelectableNodeType =
| 'wall'
| 'fence'
| 'item'
| 'column'
| 'building'
| 'zone'
| 'slab'
@@ -333,7 +335,7 @@ function applyStairPaintPreview(
}
function applySingleSurfacePaintPreview(
node: FenceNode | SlabNode | CeilingNode,
node: FenceNode | ColumnNode | SlabNode | CeilingNode,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
if (node.type === 'ceiling') {
@@ -377,12 +379,32 @@ function applySingleSurfacePaintPreview(
}
}
const mesh = getRegisteredMesh(node.id)
if (!mesh) return null
const registeredObject = getRegisteredNodeObject(node.id)
const mesh =
registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
const previewMaterial = getSingleSurfacePreviewMaterial(material)
if (!previewMaterial) return null
if (node.type === 'column') {
if (!registeredObject) return null
const restores: PaintPreviewCleanup[] = []
registeredObject.traverse((object) => {
if (!(object as Mesh).isMesh) return
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
if (!mesh) return null
if (node.type === 'slab') {
const slabMaterial = previewMaterial.clone()
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
@@ -542,6 +564,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
'wall',
'fence',
'item',
'column',
'zone',
'slab',
'ceiling',
@@ -595,6 +618,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -658,6 +682,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -857,7 +882,12 @@ export const SelectionManager = () => {
}
}
if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') {
if (
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling'
) {
const compatible = hasActivePaintMaterial(activePaintMaterial)
return {
@@ -870,17 +900,16 @@ export const SelectionManager = () => {
.getState()
.updateNode(
node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch<FenceNode | SlabNode | CeilingNode>(
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
buildSingleSurfaceMaterialPatch<
FenceNode | ColumnNode | SlabNode | CeilingNode
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
)
}
: null,
preview: compatible
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | SlabNode | CeilingNode,
node as FenceNode | ColumnNode | SlabNode | CeilingNode,
activePaintMaterial,
)
: () => previewCursor('not-allowed'),
@@ -963,6 +992,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'slab',
'ceiling',
'roof',
@@ -1131,6 +1161,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'building',
'zone',
'slab',
@@ -1227,6 +1258,7 @@ export const SelectionManager = () => {
} else if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -1279,6 +1311,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'building',
'slab',
'ceiling',
@@ -1352,6 +1385,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'slab',
'ceiling',
'roof',
@@ -0,0 +1,97 @@
import '../../../three-types'
import {
type AnyNode,
COLUMN_PRESETS,
ColumnNode,
type ColumnPresetId,
emitter,
type GridEvent,
type LevelNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const COLUMN_ICON = (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Column"
src="/icons/column.png"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
const { label, ...preset } = COLUMN_PRESETS[presetId]
return ColumnNode.parse({
name: label,
position,
rotation: 0,
...preset,
})
}
type ColumnToolProps = {
currentLevelId: LevelNode['id'] | null
}
export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId }) => {
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
const cursorRef = useRef<Group>(null)
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
setCursorPosition(nextPosition)
cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2])
}
const onGridClick = (event: GridEvent) => {
const position: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
useScene.getState().createNode(column, currentLevelId)
useViewer.getState().setSelection({ selectedIds: [column.id as AnyNode['id']] })
sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
}
}, [currentLevelId])
if (!currentLevelId) return null
return (
<CursorSphere
color="#a78bfa"
height={2.8}
ref={cursorRef}
showTooltip
tooltipContent={COLUMN_ICON}
/>
)
}
@@ -0,0 +1,105 @@
import '../../../three-types'
import {
type AnyNodeId,
ColumnNode,
type ColumnNode as ColumnNodeType,
emitter,
type GridEvent,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
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?: ColumnNodeType['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 = ColumnNode.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)
sfxEmitter.emit('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} />
}
@@ -1,6 +1,7 @@
import type {
BuildingNode,
CeilingNode,
ColumnNode,
DoorNode,
FenceNode,
ItemNode,
@@ -18,6 +19,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveColumnTool } from '../column/move-column-tool'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
@@ -100,6 +102,7 @@ export const MoveTool: React.FC<{
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
@@ -10,6 +10,7 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { ColumnTool } from './column/column-tool'
import { DoorTool } from './door/door-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool'
@@ -164,7 +165,10 @@ export const ToolManager: React.FC = () => {
{!movingNode && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handleSpawnSelected} />
)}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
{!movingNode && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={selectedLevelId} />
)}
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
</group>
</>
)
@@ -24,6 +24,7 @@ export const tools: ToolConfig[] = [
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
@@ -0,0 +1,686 @@
'use client'
import {
type AnyNode,
COLUMN_PRESETS,
type ColumnNode,
type ColumnPresetId,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
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,
}))
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,
...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,
}
}
export 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
sfxEmitter.emit('sfx:structure-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('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'
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">
<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">
<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}
/>
<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>
<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="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>
<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>
)
}
@@ -12,6 +12,7 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
window: { icon: '/icons/window.png', label: 'Window' },
slab: { icon: '/icons/floor.png', label: 'Slab' },
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
column: { icon: '/icons/column.png', label: 'Column' },
fence: { icon: '/icons/fence.png', label: 'Fence' },
roof: { icon: '/icons/roof.png', label: 'Roof' },
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
@@ -5,6 +5,7 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type ColumnNode,
type DoorNode,
type FenceNode,
type ItemNode,
@@ -23,6 +24,7 @@ import { useIsMobile } from '../../../hooks/use-mobile'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel'
import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel'
@@ -45,6 +47,7 @@ type MovableNode =
| WindowNode
| DoorNode
| CeilingNode
| ColumnNode
| SlabNode
| WallNode
| FenceNode
@@ -59,6 +62,7 @@ const MOVABLE_TYPES = new Set<string>([
'window',
'door',
'ceiling',
'column',
'slab',
'wall',
'fence',
@@ -90,6 +94,8 @@ function panelForType(type: string | null) {
return <SlabPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'fence':
@@ -250,6 +256,8 @@ export function PanelManager() {
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'fence':
@@ -0,0 +1,77 @@
import { type AnyNodeId, type ColumnNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface ColumnTreeNodeProps {
nodeId: AnyNodeId
depth: number
isLast?: boolean
}
export const ColumnTreeNode = memo(function ColumnTreeNode({
nodeId,
depth,
isLast,
}: ColumnTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const node = useScene((s) => s.nodes[nodeId] as ColumnNode | undefined)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
},
[nodeId, setSelection],
)
const defaultName = node?.name || 'Column'
return (
<TreeNodeWrapper
actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth}
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
isVisible={isVisible}
label={
<InlineRenameInput
defaultName={defaultName}
isEditing={isEditing}
nodeId={nodeId}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
}
nodeId={nodeId}
onClick={handleClick}
onDoubleClick={() => focusTreeNode(nodeId)}
onMouseEnter={() => setHoveredId(nodeId)}
onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}}
/>
)
})
@@ -56,6 +56,7 @@ export function focusTreeNode(nodeId: AnyNodeId) {
import { cn } from '../../../../../lib/utils'
import { BuildingTreeNode } from './building-tree-node'
import { CeilingTreeNode } from './ceiling-tree-node'
import { ColumnTreeNode } from './column-tree-node'
import { DoorTreeNode } from './door-tree-node'
import { FenceTreeNode } from './fence-tree-node'
import { ItemTreeNode } from './item-tree-node'
@@ -84,6 +85,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
case 'ceiling':
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'column':
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'level':
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
case 'slab':
+13 -8
View File
@@ -2,6 +2,7 @@
import {
type CeilingNode,
type ColumnNode,
type FenceNode,
getCatalogMaterialById,
getEffectiveRoofSurfaceMaterial,
@@ -21,7 +22,7 @@ import {
export type PaintableMaterialTarget = Extract<
MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
>
export type SingleSurfaceMaterialRole = 'surface'
@@ -131,10 +132,9 @@ export function buildStairSurfaceMaterialPatch(
}
}
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<TNode> {
export function buildSingleSurfaceMaterialPatch<
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
return {
material,
materialPreset,
@@ -170,7 +170,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
})
? {
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
@@ -190,7 +190,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
})
? {
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
@@ -210,7 +210,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
})
? {
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
@@ -220,6 +220,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
if (
(selectedNode.type === 'fence' ||
selectedNode.type === 'column' ||
selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') &&
selectedMaterialTarget.role === 'surface'
@@ -267,6 +268,10 @@ export function resolvePaintTargetFromSelection(params: {
return 'fence'
}
if (selectedNode.type === 'column') {
return 'column'
}
if (selectedNode.type === 'slab') {
return 'slab'
}
+3
View File
@@ -5,6 +5,7 @@ import {
type AssetInput,
type BuildingNode,
type CeilingNode,
type ColumnNode,
type DoorNode,
type FenceNode,
type ItemNode,
@@ -139,6 +140,7 @@ type EditorState = {
| DoorNode
| FenceNode
| CeilingNode
| ColumnNode
| SlabNode
| WallNode
| RoofNode
@@ -155,6 +157,7 @@ type EditorState = {
| DoorNode
| FenceNode
| CeilingNode
| ColumnNode
| SlabNode
| WallNode
| RoofNode