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>
2174 lines
64 KiB
TypeScript
2174 lines
64 KiB
TypeScript
'use client'
|
|
|
|
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
|
|
import {
|
|
baseMaterial,
|
|
createColumnBoxGeometry,
|
|
createColumnCylinderGeometry,
|
|
createColumnSphereGeometry,
|
|
createColumnTorusGeometry,
|
|
createMaterial,
|
|
createMaterialFromPresetRef,
|
|
useNodeEvents,
|
|
} from '@pascal-app/viewer'
|
|
import { createContext, useContext, useMemo, useRef } from 'react'
|
|
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
|
|
|
|
const ColumnMaterialContext = createContext<Material>(baseMaterial as Material)
|
|
const ColumnEdgeSoftnessContext = createContext(0.025)
|
|
|
|
function ColumnMaterial() {
|
|
const material = useContext(ColumnMaterialContext)
|
|
return <primitive attach="material" object={material} />
|
|
}
|
|
|
|
function createColumnMaterial({
|
|
material,
|
|
materialPreset,
|
|
}: Pick<ColumnNode, 'material' | 'materialPreset'>) {
|
|
const presetMaterial = createMaterialFromPresetRef(materialPreset)
|
|
if (presetMaterial) return presetMaterial
|
|
if (material) return createMaterial(material)
|
|
return baseMaterial
|
|
}
|
|
|
|
function getSegments(node: ColumnNode) {
|
|
if (node.crossSection === 'octagonal') return 8
|
|
if (node.crossSection === 'sixteen-sided') return 16
|
|
return 32
|
|
}
|
|
|
|
function getShaftProfile(node: ColumnNode) {
|
|
return node.shaftProfile ?? (node.shaftTaper > 0 ? 'tapered' : 'straight')
|
|
}
|
|
|
|
function getShaftSegmentCount(node: ColumnNode) {
|
|
const shaftProfile = getShaftProfile(node)
|
|
const shaftTaper = node.shaftTaper ?? 0
|
|
const hasTwist = Math.abs(node.shaftTwistStep ?? 0) > 0.001
|
|
return Math.max(
|
|
hasTwist ? 4 : 1,
|
|
shaftProfile === 'straight' && shaftTaper <= 0 && !hasTwist
|
|
? 1
|
|
: (node.shaftSegmentCount ?? (hasTwist ? 12 : 24)),
|
|
)
|
|
}
|
|
|
|
function getShaftTwistRadians(node: ColumnNode, index: number) {
|
|
return ((node.shaftTwistStep ?? 0) * Math.PI * index) / 180
|
|
}
|
|
|
|
function getShaftScaleAt(node: ColumnNode, t: number) {
|
|
const shaftProfile = getShaftProfile(node)
|
|
const shaftTaper = Math.min(node.shaftTaper ?? 0, 0.85)
|
|
const startScale = node.shaftStartScale ?? 0.72
|
|
const endScale = node.shaftEndScale ?? startScale
|
|
const shaftBulge =
|
|
node.shaftBulge ??
|
|
(shaftProfile === 'bulged'
|
|
? 0.16
|
|
: shaftProfile === 'baluster'
|
|
? 0.2
|
|
: shaftProfile === 'hourglass'
|
|
? 0.18
|
|
: 0)
|
|
const taperedScale = 1 - shaftTaper * t
|
|
const linearScale = (startScale + (endScale - startScale) * t) * taperedScale
|
|
const bulgeCurve = Math.sin(Math.PI * t)
|
|
const hourglassCurve = Math.abs(t - 0.5) * 2
|
|
const profileScale =
|
|
shaftProfile === 'bulged' || shaftProfile === 'baluster'
|
|
? linearScale + shaftBulge * bulgeCurve
|
|
: shaftProfile === 'hourglass'
|
|
? linearScale - shaftBulge * (1 - hourglassCurve)
|
|
: linearScale
|
|
|
|
return Math.max(0.1, profileScale)
|
|
}
|
|
|
|
type VectorTuple = [number, number, number]
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value))
|
|
}
|
|
|
|
function MappedBox({
|
|
depth,
|
|
height,
|
|
position,
|
|
rotation,
|
|
softenEdges = true,
|
|
width,
|
|
}: {
|
|
depth: number
|
|
height: number
|
|
position: VectorTuple
|
|
rotation?: VectorTuple
|
|
softenEdges?: boolean
|
|
width: number
|
|
}) {
|
|
const edgeSoftness = useContext(ColumnEdgeSoftnessContext)
|
|
const minDimension = Math.max(0, Math.min(width, height, depth))
|
|
const bevelRadius = softenEdges ? Math.min(Math.max(0, edgeSoftness), minDimension * 0.35) : 0
|
|
const geometry = useMemo(() => {
|
|
if (height <= 0 || width <= 0 || depth <= 0) return null
|
|
return createColumnBoxGeometry(width, height, depth, bevelRadius)
|
|
}, [bevelRadius, depth, height, width])
|
|
|
|
if (!geometry) return null
|
|
|
|
return (
|
|
<mesh dispose={null} position={position} rotation={rotation}>
|
|
<primitive attach="geometry" dispose={null} object={geometry} />
|
|
<ColumnMaterial />
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
function FlatEndedBeam({
|
|
depth,
|
|
end,
|
|
start,
|
|
width,
|
|
}: {
|
|
depth: number
|
|
end: VectorTuple
|
|
start: VectorTuple
|
|
width: number
|
|
}) {
|
|
const dx = end[0] - start[0]
|
|
const dy = end[1] - start[1]
|
|
const dz = end[2] - start[2]
|
|
const length = Math.hypot(dx, dy, dz)
|
|
const geometry = useMemo(() => {
|
|
if (length <= 0.001 || width <= 0 || depth <= 0) return null
|
|
|
|
const halfWidth = width / 2
|
|
const halfDepth = depth / 2
|
|
const bottomY = start[1]
|
|
const topY = end[1]
|
|
const bottomCenterX = start[0]
|
|
const topCenterX = end[0]
|
|
const bottomCenterZ = start[2]
|
|
const topCenterZ = end[2]
|
|
const vertices: VectorTuple[] = [
|
|
[bottomCenterX - halfWidth, bottomY, bottomCenterZ - halfDepth],
|
|
[bottomCenterX + halfWidth, bottomY, bottomCenterZ - halfDepth],
|
|
[bottomCenterX + halfWidth, bottomY, bottomCenterZ + halfDepth],
|
|
[bottomCenterX - halfWidth, bottomY, bottomCenterZ + halfDepth],
|
|
[topCenterX - halfWidth, topY, topCenterZ - halfDepth],
|
|
[topCenterX + halfWidth, topY, topCenterZ - halfDepth],
|
|
[topCenterX + halfWidth, topY, topCenterZ + halfDepth],
|
|
[topCenterX - halfWidth, topY, topCenterZ + halfDepth],
|
|
]
|
|
const faceQuads: [number, number, number, number][] = [
|
|
[0, 1, 2, 3],
|
|
[4, 7, 6, 5],
|
|
[0, 4, 5, 1],
|
|
[1, 5, 6, 2],
|
|
[2, 6, 7, 3],
|
|
[3, 7, 4, 0],
|
|
]
|
|
const positions: number[] = []
|
|
const uvs: number[] = []
|
|
const pushVertex = (vertexIndex: number, uv: [number, number]) => {
|
|
const vertex = vertices[vertexIndex]
|
|
if (!vertex) return false
|
|
positions.push(...vertex)
|
|
uvs.push(...uv)
|
|
return true
|
|
}
|
|
const pushTriangle = (
|
|
a: number,
|
|
b: number,
|
|
c: number,
|
|
uvA: [number, number],
|
|
uvB: [number, number],
|
|
uvC: [number, number],
|
|
) => {
|
|
const va = vertices[a]
|
|
const vb = vertices[b]
|
|
const vc = vertices[c]
|
|
if (!va || !vb || !vc) return
|
|
pushVertex(a, uvA)
|
|
pushVertex(b, uvB)
|
|
pushVertex(c, uvC)
|
|
}
|
|
|
|
for (const [a, b, c, d] of faceQuads) {
|
|
const va = vertices[a]
|
|
const vb = vertices[b]
|
|
const vc = vertices[c]
|
|
const vd = vertices[d]
|
|
if (!va || !vb || !vc || !vd) continue
|
|
|
|
const edgeU = Math.hypot(vb[0] - va[0], vb[1] - va[1], vb[2] - va[2])
|
|
const edgeV = Math.hypot(vd[0] - va[0], vd[1] - va[1], vd[2] - va[2])
|
|
const uvA: [number, number] = [0, 0]
|
|
const uvB: [number, number] = [edgeU, 0]
|
|
const uvC: [number, number] = [edgeU, edgeV]
|
|
const uvD: [number, number] = [0, edgeV]
|
|
|
|
pushTriangle(a, b, c, uvA, uvB, uvC)
|
|
pushTriangle(a, c, d, uvA, uvC, uvD)
|
|
pushTriangle(a, c, b, uvA, uvC, uvB)
|
|
pushTriangle(a, d, c, uvA, uvD, uvC)
|
|
}
|
|
|
|
const geometry = new BufferGeometry()
|
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
|
|
geometry.setAttribute('uv2', new Float32BufferAttribute(uvs.slice(), 2))
|
|
geometry.computeVertexNormals()
|
|
return geometry
|
|
}, [depth, length, start, end, width])
|
|
|
|
if (!geometry) return null
|
|
|
|
return (
|
|
<mesh dispose={null}>
|
|
<primitive attach="geometry" dispose={null} object={geometry} />
|
|
<ColumnMaterial />
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
function AFrameSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const bottomSpread = Math.max(0.2, node.braceBottomSpread ?? Math.max(node.width * 3, 1.2))
|
|
const topSpread = clamp(node.braceTopSpread ?? 0.12, 0, bottomSpread)
|
|
const bottomY = 0
|
|
const topY = height
|
|
const leftBottom: VectorTuple = [-bottomSpread / 2, bottomY, 0]
|
|
const rightBottom: VectorTuple = [bottomSpread / 2, bottomY, 0]
|
|
const leftTop: VectorTuple = [-topSpread / 2, topY, 0]
|
|
const rightTop: VectorTuple = [topSpread / 2, topY, 0]
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const footPlateWidth = braceWidth * 1.9
|
|
const footPlateDepth = braceDepth * 1.75
|
|
const topPlateWidth = Math.max(topSpread + braceWidth * 1.9, braceWidth * 2.2)
|
|
const topPlateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
<FlatEndedBeam depth={braceDepth} end={leftTop} start={leftBottom} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={rightTop} start={rightBottom} width={braceWidth} />
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
<MappedBox
|
|
depth={footPlateDepth}
|
|
height={plateHeight}
|
|
position={[leftBottom[0], plateHeight / 2, leftBottom[2]]}
|
|
width={footPlateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={footPlateDepth}
|
|
height={plateHeight}
|
|
position={[rightBottom[0], plateHeight / 2, rightBottom[2]]}
|
|
width={footPlateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={topPlateDepth}
|
|
height={plateHeight}
|
|
position={[0, height - plateHeight / 2, 0]}
|
|
width={topPlateWidth}
|
|
/>
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function YFrameSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const topSpread = Math.max(0.2, node.braceTopSpread ?? 0.9)
|
|
const splitY = height * 0.56
|
|
const foot: VectorTuple = [0, 0, 0]
|
|
const split: VectorTuple = [0, splitY, 0]
|
|
const leftTop: VectorTuple = [-topSpread / 2, height, 0]
|
|
const rightTop: VectorTuple = [topSpread / 2, height, 0]
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const footPlateWidth = braceWidth * 1.9
|
|
const footPlateDepth = braceDepth * 1.75
|
|
const topPlateWidth = topSpread + braceWidth * 1.9
|
|
const topPlateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
<FlatEndedBeam depth={braceDepth} end={split} start={foot} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={leftTop} start={split} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={rightTop} start={split} width={braceWidth} />
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
<MappedBox
|
|
depth={footPlateDepth}
|
|
height={plateHeight}
|
|
position={[foot[0], plateHeight / 2, foot[2]]}
|
|
width={footPlateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={topPlateDepth}
|
|
height={plateHeight}
|
|
position={[0, height - plateHeight / 2, 0]}
|
|
width={topPlateWidth}
|
|
/>
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function VFrameSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const topSpread = Math.max(0.2, node.braceTopSpread ?? 1)
|
|
const foot: VectorTuple = [0, 0, 0]
|
|
const leftTop: VectorTuple = [-topSpread / 2, height, 0]
|
|
const rightTop: VectorTuple = [topSpread / 2, height, 0]
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const footPlateWidth = braceWidth * 1.9
|
|
const footPlateDepth = braceDepth * 1.75
|
|
const topPlateWidth = topSpread + braceWidth * 1.9
|
|
const topPlateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
<FlatEndedBeam depth={braceDepth} end={leftTop} start={foot} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={rightTop} start={foot} width={braceWidth} />
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
<MappedBox
|
|
depth={footPlateDepth}
|
|
height={plateHeight}
|
|
position={[foot[0], plateHeight / 2, foot[2]]}
|
|
width={footPlateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={topPlateDepth}
|
|
height={plateHeight}
|
|
position={[0, height - plateHeight / 2, 0]}
|
|
width={topPlateWidth}
|
|
/>
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function XBraceSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const bottomSpread = Math.max(0.2, node.braceBottomSpread ?? 1)
|
|
const topSpread = Math.max(0.2, node.braceTopSpread ?? 1)
|
|
const leftBottom: VectorTuple = [-bottomSpread / 2, 0, 0]
|
|
const rightBottom: VectorTuple = [bottomSpread / 2, 0, 0]
|
|
const leftTop: VectorTuple = [-topSpread / 2, height, 0]
|
|
const rightTop: VectorTuple = [topSpread / 2, height, 0]
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const footPlateWidth = braceWidth * 1.9
|
|
const footPlateDepth = braceDepth * 1.75
|
|
const topPlateWidth = braceWidth * 1.9
|
|
const topPlateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
<FlatEndedBeam depth={braceDepth} end={rightTop} start={leftBottom} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={leftTop} start={rightBottom} width={braceWidth} />
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
<MappedBox
|
|
depth={footPlateDepth}
|
|
height={plateHeight}
|
|
position={[leftBottom[0], plateHeight / 2, leftBottom[2]]}
|
|
width={footPlateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={footPlateDepth}
|
|
height={plateHeight}
|
|
position={[rightBottom[0], plateHeight / 2, rightBottom[2]]}
|
|
width={footPlateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={topPlateDepth}
|
|
height={plateHeight}
|
|
position={[leftTop[0], height - plateHeight / 2, leftTop[2]]}
|
|
width={topPlateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={topPlateDepth}
|
|
height={plateHeight}
|
|
position={[rightTop[0], height - plateHeight / 2, rightTop[2]]}
|
|
width={topPlateWidth}
|
|
/>
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function KBraceSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const spread = Math.max(0.2, Math.max(node.braceBottomSpread ?? 1, node.braceTopSpread ?? 1))
|
|
const leftBottom: VectorTuple = [-spread / 2, 0, 0]
|
|
const leftTop: VectorTuple = [-spread / 2, height, 0]
|
|
const centerBottom: VectorTuple = [0, 0, 0]
|
|
const centerMiddle: VectorTuple = [0, height / 2, 0]
|
|
const centerTop: VectorTuple = [0, height, 0]
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const plateWidth = braceWidth * 1.9
|
|
const plateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
<FlatEndedBeam depth={braceDepth} end={centerTop} start={centerBottom} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={centerMiddle} start={leftBottom} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={centerMiddle} start={leftTop} width={braceWidth} />
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
position={[leftBottom[0], plateHeight / 2, leftBottom[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
position={[centerBottom[0], plateHeight / 2, centerBottom[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
position={[leftTop[0], height - plateHeight / 2, leftTop[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
position={[centerTop[0], height - plateHeight / 2, centerTop[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function SingleStrutSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const spread = Math.max(0.2, Math.max(node.braceBottomSpread ?? 1, node.braceTopSpread ?? 1))
|
|
const bottom: VectorTuple = [-spread / 2, 0, 0]
|
|
const top: VectorTuple = [spread / 2, height, 0]
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const plateWidth = braceWidth * 1.9
|
|
const plateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
<FlatEndedBeam depth={braceDepth} end={top} start={bottom} width={braceWidth} />
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
position={[bottom[0], plateHeight / 2, bottom[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
position={[top[0], height - plateHeight / 2, top[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function TripodSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const width = Math.max(0.2, node.braceBottomSpread ?? 1.1)
|
|
const depth = Math.max(0.2, node.braceTopSpread ?? 1.1)
|
|
const top: VectorTuple = [0, height, 0]
|
|
const feet: VectorTuple[] = [
|
|
[0, 0, -depth / 2],
|
|
[-width / 2, 0, depth / 2],
|
|
[width / 2, 0, depth / 2],
|
|
]
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const plateWidth = braceWidth * 1.9
|
|
const plateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
{feet.map((foot, index) => (
|
|
<FlatEndedBeam
|
|
depth={braceDepth}
|
|
end={top}
|
|
key={`leg-${index}`}
|
|
start={foot}
|
|
width={braceWidth}
|
|
/>
|
|
))}
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
{feet.map((foot, index) => (
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
key={`foot-${index}`}
|
|
position={[foot[0], plateHeight / 2, foot[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
))}
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
position={[0, height - plateHeight / 2, 0]}
|
|
width={plateWidth}
|
|
/>
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function TrestleSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const width = Math.max(0.2, node.braceBottomSpread ?? 1.2)
|
|
const depth = Math.max(0.2, node.braceTopSpread ?? 1)
|
|
const zPositions = [-depth / 2, depth / 2]
|
|
const topPoints: VectorTuple[] = zPositions.map((z) => [0, height, z])
|
|
const footPoints: VectorTuple[] = zPositions.flatMap((z) => [
|
|
[-width / 2, 0, z] as VectorTuple,
|
|
[width / 2, 0, z] as VectorTuple,
|
|
])
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const plateWidth = braceWidth * 1.9
|
|
const plateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
{zPositions.map((z, index) => {
|
|
const leftBottom: VectorTuple = [-width / 2, 0, z]
|
|
const rightBottom: VectorTuple = [width / 2, 0, z]
|
|
const top: VectorTuple = topPoints[index] ?? [0, height, z]
|
|
return (
|
|
<group key={`frame-${z}`}>
|
|
<FlatEndedBeam depth={braceDepth} end={top} start={leftBottom} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={top} start={rightBottom} width={braceWidth} />
|
|
</group>
|
|
)
|
|
})}
|
|
<FlatEndedBeam
|
|
depth={braceDepth}
|
|
end={topPoints[1] ?? [0, height, depth / 2]}
|
|
start={topPoints[0] ?? [0, height, -depth / 2]}
|
|
width={braceWidth}
|
|
/>
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
{footPoints.map((foot, index) => (
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
key={`foot-${index}`}
|
|
position={[foot[0], plateHeight / 2, foot[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
))}
|
|
{topPoints.map((top, index) => (
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
key={`top-${index}`}
|
|
position={[top[0], height - plateHeight / 2, top[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function PortalFrameSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const width = Math.max(0.2, node.braceBottomSpread ?? 1.4)
|
|
const leftBottom: VectorTuple = [-width / 2, 0, 0]
|
|
const rightBottom: VectorTuple = [width / 2, 0, 0]
|
|
const leftTop: VectorTuple = [-width / 2, height, 0]
|
|
const rightTop: VectorTuple = [width / 2, height, 0]
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const plateWidth = braceWidth * 1.9
|
|
const plateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
<FlatEndedBeam depth={braceDepth} end={leftTop} start={leftBottom} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={rightTop} start={rightBottom} width={braceWidth} />
|
|
<FlatEndedBeam depth={braceDepth} end={rightTop} start={leftTop} width={braceWidth} />
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
{[leftBottom, rightBottom].map((foot, index) => (
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
key={`foot-${index}`}
|
|
position={[foot[0], plateHeight / 2, foot[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
))}
|
|
{[leftTop, rightTop].map((top, index) => (
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
key={`top-${index}`}
|
|
position={[top[0], height - plateHeight / 2, top[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function BoxFrameSupport({ node }: { node: ColumnNode }) {
|
|
const height = Math.max(0.2, node.height)
|
|
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
|
|
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
|
|
const width = Math.max(0.2, node.braceBottomSpread ?? 1.4)
|
|
const depth = Math.max(0.2, node.braceTopSpread ?? 1)
|
|
const corners: VectorTuple[] = [
|
|
[-width / 2, 0, -depth / 2],
|
|
[width / 2, 0, -depth / 2],
|
|
[width / 2, 0, depth / 2],
|
|
[-width / 2, 0, depth / 2],
|
|
]
|
|
const topCorners = corners.map(([x, _y, z]) => [x, height, z] as VectorTuple)
|
|
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
|
|
const plateWidth = braceWidth * 1.9
|
|
const plateDepth = braceDepth * 1.75
|
|
|
|
return (
|
|
<group>
|
|
{corners.map((corner, index) => (
|
|
<FlatEndedBeam
|
|
depth={braceDepth}
|
|
end={topCorners[index] ?? [corner[0], height, corner[2]]}
|
|
key={`post-${index}`}
|
|
start={corner}
|
|
width={braceWidth}
|
|
/>
|
|
))}
|
|
{topCorners.map((corner, index) => (
|
|
<FlatEndedBeam
|
|
depth={braceDepth}
|
|
end={topCorners[(index + 1) % topCorners.length] ?? corner}
|
|
key={`top-rail-${index}`}
|
|
start={corner}
|
|
width={braceWidth}
|
|
/>
|
|
))}
|
|
{corners.map((corner, index) => (
|
|
<FlatEndedBeam
|
|
depth={braceDepth}
|
|
end={corners[(index + 1) % corners.length] ?? corner}
|
|
key={`bottom-rail-${index}`}
|
|
start={corner}
|
|
width={braceWidth}
|
|
/>
|
|
))}
|
|
{(node.bracePlateEnabled ?? true) && (
|
|
<>
|
|
{corners.map((corner, index) => (
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
key={`foot-${index}`}
|
|
position={[corner[0], plateHeight / 2, corner[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
))}
|
|
{topCorners.map((corner, index) => (
|
|
<MappedBox
|
|
depth={plateDepth}
|
|
height={plateHeight}
|
|
key={`top-${index}`}
|
|
position={[corner[0], height - plateHeight / 2, corner[2]]}
|
|
width={plateWidth}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function MappedCylinder({
|
|
height,
|
|
position,
|
|
radius,
|
|
radiusBottom = radius,
|
|
radiusTop = radius,
|
|
radiusX = 1,
|
|
radiusZ = 1,
|
|
rotation,
|
|
segments = 32,
|
|
}: {
|
|
height: number
|
|
position: VectorTuple
|
|
radius: number
|
|
radiusBottom?: number
|
|
radiusTop?: number
|
|
radiusX?: number
|
|
radiusZ?: number
|
|
rotation?: VectorTuple
|
|
segments?: number
|
|
}) {
|
|
const geometry = useMemo(() => {
|
|
if (height <= 0 || radius <= 0 || radiusBottom < 0 || radiusTop < 0) return null
|
|
return createColumnCylinderGeometry({
|
|
height,
|
|
radiusBottom,
|
|
radiusTop,
|
|
radiusX,
|
|
radiusZ,
|
|
segments,
|
|
})
|
|
}, [height, radius, radiusBottom, radiusTop, radiusX, radiusZ, segments])
|
|
|
|
if (!geometry) return null
|
|
|
|
return (
|
|
<mesh dispose={null} position={position} rotation={rotation}>
|
|
<primitive attach="geometry" dispose={null} object={geometry} />
|
|
<ColumnMaterial />
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
function MappedCone({
|
|
height,
|
|
position,
|
|
radiusX,
|
|
radiusZ = radiusX,
|
|
rotation,
|
|
segments = 6,
|
|
}: {
|
|
height: number
|
|
position: VectorTuple
|
|
radiusX: number
|
|
radiusZ?: number
|
|
rotation?: VectorTuple
|
|
segments?: number
|
|
}) {
|
|
const geometry = useMemo(() => {
|
|
if (height <= 0 || radiusX <= 0 || radiusZ <= 0) return null
|
|
return createColumnCylinderGeometry({
|
|
height,
|
|
radiusBottom: 1,
|
|
radiusTop: 0,
|
|
radiusX,
|
|
radiusZ,
|
|
segments,
|
|
})
|
|
}, [height, radiusX, radiusZ, segments])
|
|
|
|
if (!geometry) return null
|
|
|
|
return (
|
|
<mesh dispose={null} position={position} rotation={rotation}>
|
|
<primitive attach="geometry" dispose={null} object={geometry} />
|
|
<ColumnMaterial />
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
function MappedSphere({
|
|
position,
|
|
radius,
|
|
segments = 10,
|
|
verticalSegments = 8,
|
|
}: {
|
|
position: VectorTuple
|
|
radius: number
|
|
segments?: number
|
|
verticalSegments?: number
|
|
}) {
|
|
const geometry = useMemo(() => {
|
|
if (radius <= 0) return null
|
|
return createColumnSphereGeometry(radius, segments, verticalSegments)
|
|
}, [radius, segments, verticalSegments])
|
|
|
|
if (!geometry) return null
|
|
|
|
return (
|
|
<mesh dispose={null} position={position}>
|
|
<primitive attach="geometry" dispose={null} object={geometry} />
|
|
<ColumnMaterial />
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
function MappedTorus({
|
|
arc,
|
|
position,
|
|
ringRadius,
|
|
rotation,
|
|
scaleX,
|
|
scaleY,
|
|
scaleZ,
|
|
tubeRadius,
|
|
}: {
|
|
arc?: number
|
|
position: VectorTuple
|
|
ringRadius: number
|
|
rotation?: VectorTuple
|
|
scaleX?: number
|
|
scaleY?: number
|
|
scaleZ?: number
|
|
tubeRadius: number
|
|
}) {
|
|
const geometry = useMemo(() => {
|
|
if (ringRadius <= 0 || tubeRadius <= 0) return null
|
|
return createColumnTorusGeometry({
|
|
arc,
|
|
ringRadius,
|
|
scaleX,
|
|
scaleY,
|
|
scaleZ,
|
|
tubeRadius,
|
|
})
|
|
}, [arc, ringRadius, scaleX, scaleY, scaleZ, tubeRadius])
|
|
|
|
if (!geometry) return null
|
|
|
|
return (
|
|
<mesh dispose={null} position={position} rotation={rotation}>
|
|
<primitive attach="geometry" dispose={null} object={geometry} />
|
|
<ColumnMaterial />
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
function SquareBlock({
|
|
y,
|
|
height,
|
|
width,
|
|
depth,
|
|
softenEdges = true,
|
|
}: {
|
|
y: number
|
|
height: number
|
|
width: number
|
|
depth: number
|
|
softenEdges?: boolean
|
|
}) {
|
|
return (
|
|
<MappedBox
|
|
depth={depth}
|
|
height={height}
|
|
position={[0, y + height / 2, 0]}
|
|
softenEdges={softenEdges}
|
|
width={width}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function RoundBlock({
|
|
x = 0,
|
|
y,
|
|
z = 0,
|
|
height,
|
|
radius,
|
|
segments = 32,
|
|
}: {
|
|
x?: number
|
|
y: number
|
|
z?: number
|
|
height: number
|
|
radius: number
|
|
segments?: number
|
|
}) {
|
|
return (
|
|
<MappedCylinder
|
|
height={height}
|
|
position={[x, y + height / 2, z]}
|
|
radius={radius}
|
|
segments={segments}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function RoundedRectangleShaftSegment({
|
|
y,
|
|
height,
|
|
width,
|
|
depth,
|
|
cornerRadius,
|
|
}: {
|
|
y: number
|
|
height: number
|
|
width: number
|
|
depth: number
|
|
cornerRadius: number
|
|
}) {
|
|
if (height <= 0) return null
|
|
|
|
const radius = Math.min(Math.max(0, cornerRadius), Math.min(width, depth) * 0.45)
|
|
if (radius <= 0.001) {
|
|
return <SquareBlock depth={depth} height={height} width={width} y={y} />
|
|
}
|
|
|
|
const innerWidth = Math.max(0, width - radius * 2)
|
|
const innerDepth = Math.max(0, depth - radius * 2)
|
|
const cornerX = width / 2 - radius
|
|
const cornerZ = depth / 2 - radius
|
|
|
|
return (
|
|
<group>
|
|
{innerWidth > 0 && (
|
|
<SquareBlock depth={depth} height={height} softenEdges={false} width={innerWidth} y={y} />
|
|
)}
|
|
{innerDepth > 0 && (
|
|
<SquareBlock depth={innerDepth} height={height} softenEdges={false} width={width} y={y} />
|
|
)}
|
|
{(
|
|
[
|
|
[cornerX, cornerZ],
|
|
[cornerX, -cornerZ],
|
|
[-cornerX, cornerZ],
|
|
[-cornerX, -cornerZ],
|
|
] satisfies [number, number][]
|
|
).map(([x, z], index) => (
|
|
<RoundBlock height={height} key={index} radius={radius} segments={18} x={x} y={y} z={z} />
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function OvalBlock({
|
|
y,
|
|
height,
|
|
width,
|
|
depth,
|
|
segments = 32,
|
|
}: {
|
|
y: number
|
|
height: number
|
|
width: number
|
|
depth: number
|
|
segments?: number
|
|
}) {
|
|
return (
|
|
<MappedCylinder
|
|
height={height}
|
|
position={[0, y + height / 2, 0]}
|
|
radius={1}
|
|
radiusX={width / 2}
|
|
radiusZ={depth / 2}
|
|
segments={segments}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ColumnBlock({
|
|
node,
|
|
y,
|
|
height,
|
|
scale = 1,
|
|
}: {
|
|
node: ColumnNode
|
|
y: number
|
|
height: number
|
|
scale?: number
|
|
}) {
|
|
if (height <= 0) return null
|
|
|
|
const width = node.width * scale
|
|
const depth = node.depth * scale
|
|
const radius = node.radius * scale
|
|
|
|
if (node.crossSection === 'square' || node.crossSection === 'rectangular') {
|
|
return <SquareBlock depth={depth} height={height} width={width} y={y} />
|
|
}
|
|
|
|
return <RoundBlock height={height} radius={radius} segments={getSegments(node)} y={y} />
|
|
}
|
|
|
|
function TaperedRoundShaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) {
|
|
const segmentCount = getShaftSegmentCount(node)
|
|
const segmentHeight = height / segmentCount
|
|
|
|
return (
|
|
<group>
|
|
{Array.from({ length: segmentCount }, (_, index) => {
|
|
const t = (index + 0.5) / segmentCount
|
|
const profileScale = getShaftScaleAt(node, t)
|
|
return (
|
|
<group key={index} rotation={[0, getShaftTwistRadians(node, index), 0]}>
|
|
<RoundBlock
|
|
height={segmentHeight * 1.015}
|
|
radius={node.radius * profileScale}
|
|
segments={getSegments(node)}
|
|
y={y + index * segmentHeight}
|
|
/>
|
|
</group>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function TaperedSquareShaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) {
|
|
const segmentCount = getShaftSegmentCount(node)
|
|
const segmentHeight = height / segmentCount
|
|
|
|
return (
|
|
<group>
|
|
{Array.from({ length: segmentCount }, (_, index) => {
|
|
const t = (index + 0.5) / segmentCount
|
|
const profileScale = getShaftScaleAt(node, t)
|
|
return (
|
|
<group key={index} rotation={[0, getShaftTwistRadians(node, index), 0]}>
|
|
<RoundedRectangleShaftSegment
|
|
cornerRadius={(node.shaftCornerRadius ?? 0.035) * profileScale}
|
|
depth={node.depth * profileScale}
|
|
height={segmentHeight * 1.015}
|
|
width={node.width * profileScale}
|
|
y={y + index * segmentHeight}
|
|
/>
|
|
</group>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function Shaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) {
|
|
if (height <= 0) return null
|
|
|
|
if (node.style === 'cluster') {
|
|
const sideRadius = Math.max(0.04, node.radius * 0.36)
|
|
const offset = Math.max(node.radius * 0.78, node.width * 0.22)
|
|
return (
|
|
<group>
|
|
<RoundBlock height={height} radius={node.radius * 0.62} segments={24} y={y} />
|
|
{(
|
|
[
|
|
[offset, 0],
|
|
[-offset, 0],
|
|
[0, offset],
|
|
[0, -offset],
|
|
] satisfies [number, number][]
|
|
).map(([x, z], index) => (
|
|
<RoundBlock
|
|
height={height}
|
|
key={`${x}-${z}-${index}`}
|
|
radius={sideRadius}
|
|
segments={16}
|
|
x={x}
|
|
y={y}
|
|
z={z}
|
|
/>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (
|
|
node.crossSection === 'round' ||
|
|
node.crossSection === 'octagonal' ||
|
|
node.crossSection === 'sixteen-sided'
|
|
) {
|
|
return <TaperedRoundShaft height={height} node={node} y={y} />
|
|
}
|
|
|
|
return <TaperedSquareShaft height={height} node={node} y={y} />
|
|
}
|
|
|
|
function Base({ node, height }: { node: ColumnNode; height: number }) {
|
|
if (height <= 0) return null
|
|
|
|
const baseStyle = node.baseStyle ?? 'round-rings'
|
|
const widthScale = node.baseWidthScale ?? 1.24
|
|
const depthScale = node.baseDepthScale ?? widthScale
|
|
|
|
if (baseStyle === 'none') return null
|
|
|
|
if (baseStyle === 'simple-square') {
|
|
return (
|
|
<SquareBlock
|
|
depth={node.depth * depthScale}
|
|
height={height}
|
|
width={node.width * widthScale}
|
|
y={0}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (baseStyle === 'square-plinth') {
|
|
return (
|
|
<group>
|
|
<SquareBlock
|
|
depth={node.depth * depthScale}
|
|
height={height * 0.35}
|
|
width={node.width * widthScale}
|
|
y={0}
|
|
/>
|
|
<SquareBlock
|
|
depth={node.depth * Math.max(0.9, depthScale * 0.84)}
|
|
height={height * 0.65}
|
|
width={node.width * Math.max(0.9, widthScale * 0.84)}
|
|
y={height * 0.35}
|
|
/>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (baseStyle === 'stepped-square') {
|
|
const tierCount = Math.max(3, node.baseTierCount ?? 3)
|
|
const tierHeight = height / tierCount
|
|
const stepSpread = node.baseStepSpread ?? 0.42
|
|
return (
|
|
<group>
|
|
{Array.from({ length: tierCount }, (_, index) => {
|
|
const t = index / Math.max(1, tierCount - 1)
|
|
const widthScaleAt = Math.max(0.5, widthScale - t * stepSpread)
|
|
const depthScaleAt = Math.max(0.5, depthScale - t * stepSpread)
|
|
return (
|
|
<SquareBlock
|
|
depth={node.depth * depthScaleAt}
|
|
height={tierHeight * 1.01}
|
|
key={index}
|
|
width={node.width * widthScaleAt}
|
|
y={index * tierHeight}
|
|
/>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (baseStyle === 'round-rings') {
|
|
const baseWidth = node.width * widthScale
|
|
const baseDepth = node.depth * depthScale
|
|
const plinthRatio = Math.min(0.7, Math.max(0.2, node.basePlinthHeightRatio ?? 0.44))
|
|
const plinthHeight = height * plinthRatio
|
|
const roundedHeight = height - plinthHeight
|
|
const bandHeight = roundedHeight * 0.57
|
|
const neckHeight = roundedHeight - bandHeight
|
|
const bandScale = node.baseRoundBandScale ?? 0.92
|
|
const neckScale = node.baseNeckScale ?? 0.72
|
|
return (
|
|
<group>
|
|
<SquareBlock depth={baseDepth} height={plinthHeight} width={baseWidth} y={0} />
|
|
<OvalBlock
|
|
depth={baseDepth * bandScale}
|
|
height={bandHeight}
|
|
segments={32}
|
|
width={baseWidth * bandScale}
|
|
y={plinthHeight}
|
|
/>
|
|
<OvalBlock
|
|
depth={baseDepth * neckScale}
|
|
height={neckHeight}
|
|
segments={32}
|
|
width={baseWidth * neckScale}
|
|
y={plinthHeight + bandHeight}
|
|
/>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (baseStyle === 'lotus' || baseStyle === 'ribbed-lotus') {
|
|
const ribCount = node.baseRibCount ?? (baseStyle === 'ribbed-lotus' ? 24 : 14)
|
|
const ribRadius = Math.max(0.01, node.width * 0.025)
|
|
const baseRadius = Math.max(node.radius * widthScale, node.width * widthScale * 0.5)
|
|
return (
|
|
<group>
|
|
<SquareBlock
|
|
depth={node.depth * 1.28}
|
|
height={height * 0.22}
|
|
width={node.width * 1.28}
|
|
y={0}
|
|
/>
|
|
<RoundBlock
|
|
height={height * 0.24}
|
|
radius={baseRadius * 0.86}
|
|
segments={32}
|
|
y={height * 0.22}
|
|
/>
|
|
{Array.from({ length: ribCount }, (_, index) => {
|
|
const angle = (index / ribCount) * Math.PI * 2
|
|
return (
|
|
<MappedCylinder
|
|
height={height * 0.38}
|
|
key={index}
|
|
position={[
|
|
Math.cos(angle) * baseRadius * 0.86,
|
|
height * 0.58,
|
|
Math.sin(angle) * baseRadius * 0.86,
|
|
]}
|
|
radius={ribRadius}
|
|
rotation={[0, -angle, 0]}
|
|
segments={6}
|
|
/>
|
|
)
|
|
})}
|
|
<RoundBlock
|
|
height={height * 0.16}
|
|
radius={baseRadius * 0.72}
|
|
segments={32}
|
|
y={height * 0.82}
|
|
/>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (baseStyle === 'panelled-pedestal') {
|
|
const inset = node.basePanelInset ?? 0.02
|
|
return (
|
|
<group>
|
|
<SquareBlock
|
|
depth={node.depth * widthScale}
|
|
height={height}
|
|
width={node.width * widthScale}
|
|
y={0}
|
|
/>
|
|
{(
|
|
[
|
|
[0, node.depth * widthScale * 0.51, 0],
|
|
[0, -node.depth * widthScale * 0.51, 0],
|
|
[node.width * widthScale * 0.51, 0, Math.PI / 2],
|
|
[-node.width * widthScale * 0.51, 0, Math.PI / 2],
|
|
] satisfies [number, number, number][]
|
|
).map(([x, z, rotation], index) => (
|
|
<MappedBox
|
|
depth={inset}
|
|
height={height * 0.42}
|
|
key={index}
|
|
position={[x, height * 0.5, z]}
|
|
rotation={[0, rotation, 0]}
|
|
softenEdges={false}
|
|
width={node.width * 0.36}
|
|
/>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
return <ColumnBlock height={height} node={node} scale={1.12} y={0} />
|
|
}
|
|
|
|
function BaseCarvings({ node, height }: { node: ColumnNode; height: number }) {
|
|
const placement = node.carvingPlacement ?? 'capital'
|
|
const carvingLevel = node.baseCarvingLevel ?? 0
|
|
if (carvingLevel <= 0 || height <= 0 || (placement !== 'base' && placement !== 'all')) {
|
|
return null
|
|
}
|
|
|
|
const count = Math.max(8, carvingLevel * 8)
|
|
const radius = Math.max(node.radius * 1.04, Math.max(node.width, node.depth) * 0.5)
|
|
const y = height * 0.52
|
|
|
|
return (
|
|
<group>
|
|
{Array.from({ length: count }, (_, index) => {
|
|
const angle = (index / count) * Math.PI * 2
|
|
return (
|
|
<MappedCone
|
|
height={height * 0.28}
|
|
key={index}
|
|
position={[Math.cos(angle) * radius, y, Math.sin(angle) * radius]}
|
|
radiusX={0.014}
|
|
radiusZ={0.01}
|
|
rotation={[0.36, -angle, 0]}
|
|
segments={5}
|
|
/>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function Rings({
|
|
node,
|
|
shaftY,
|
|
shaftHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
shaftY: number
|
|
shaftHeight: number
|
|
}) {
|
|
if (node.ringCount <= 0 || shaftHeight <= 0) return null
|
|
|
|
const ringPlacement = node.ringPlacement ?? 'ends'
|
|
const ringSpread = Math.min(0.45, Math.max(0.04, node.ringSpread ?? 0.16))
|
|
const ringHeight = Math.min(
|
|
node.ringThickness ?? 0.055,
|
|
shaftHeight / Math.max(8, node.ringCount * 3),
|
|
)
|
|
const rings = Array.from({ length: node.ringCount }, (_, index) => {
|
|
const pairIndex = Math.floor(index / 2)
|
|
const nearTop = index % 2 === 1
|
|
const pairCount = Math.ceil(node.ringCount / 2)
|
|
const pairT = pairCount <= 1 ? 0 : pairIndex / (pairCount - 1)
|
|
const offset = Math.min(0.48, 0.06 + pairT * Math.max(0, ringSpread - 0.06))
|
|
const oneSideT =
|
|
0.06 + (index / Math.max(1, node.ringCount - 1)) * Math.max(0, ringSpread - 0.06)
|
|
const t =
|
|
ringPlacement === 'even'
|
|
? (index + 1) / (node.ringCount + 1)
|
|
: ringPlacement === 'top'
|
|
? 1 - Math.min(0.48, oneSideT)
|
|
: ringPlacement === 'bottom'
|
|
? Math.min(0.48, oneSideT)
|
|
: nearTop
|
|
? 1 - offset
|
|
: offset
|
|
return {
|
|
scale: Math.min(1.4, getShaftScaleAt(node, t) + 0.12),
|
|
y: shaftY + shaftHeight * t - ringHeight / 2,
|
|
}
|
|
}).sort((a, b) => a.y - b.y)
|
|
|
|
return (
|
|
<group>
|
|
{rings.map((ring, index) => (
|
|
<ColumnBlock height={ringHeight} key={index} node={node} scale={ring.scale} y={ring.y} />
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function LatheBands({
|
|
node,
|
|
shaftY,
|
|
shaftHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
shaftY: number
|
|
shaftHeight: number
|
|
}) {
|
|
const latheRingCount = Math.max(
|
|
node.latheRingCount ?? 0,
|
|
node.shaftDetail === 'lathe-turned' ? 8 : 0,
|
|
)
|
|
if (latheRingCount <= 0 || shaftHeight <= 0) return null
|
|
|
|
const placement = node.latheRingSpacing ?? 'ends'
|
|
const bandHeight = Math.min(0.04, shaftHeight / Math.max(12, latheRingCount * 3))
|
|
const bands = Array.from({ length: latheRingCount }, (_, index) => {
|
|
const pairIndex = Math.floor(index / 2)
|
|
const nearTop = index % 2 === 1
|
|
const offset = Math.min(0.48, 0.1 + pairIndex * 0.04)
|
|
const t =
|
|
placement === 'even'
|
|
? (index + 1) / (latheRingCount + 1)
|
|
: placement === 'top'
|
|
? 1 - Math.min(0.48, 0.08 + index * 0.04)
|
|
: placement === 'bottom'
|
|
? Math.min(0.48, 0.08 + index * 0.04)
|
|
: nearTop
|
|
? 1 - offset
|
|
: offset
|
|
return shaftY + shaftHeight * t - bandHeight / 2
|
|
}).sort((a, b) => a - b)
|
|
|
|
return (
|
|
<group>
|
|
{bands.map((y, index) => (
|
|
<ColumnBlock
|
|
height={bandHeight}
|
|
key={index}
|
|
node={node}
|
|
scale={0.82 + (index % 2) * 0.08}
|
|
y={y}
|
|
/>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function Flutes({
|
|
node,
|
|
shaftY,
|
|
shaftHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
shaftY: number
|
|
shaftHeight: number
|
|
}) {
|
|
const fluteCount = Math.max(node.fluteCount, node.shaftDetail === 'fluted' ? 16 : 0)
|
|
if (fluteCount <= 0 || shaftHeight <= 0 || node.crossSection !== 'round') return null
|
|
|
|
const fluteDepth = node.fluteDepth ?? 0.02
|
|
const fluteWidth = node.fluteWidth ?? fluteDepth
|
|
const fluteRadius = Math.max(0.006, fluteWidth * 0.42)
|
|
const shaftRadius = node.radius * 0.74
|
|
|
|
return (
|
|
<group>
|
|
{Array.from({ length: fluteCount }, (_, index) => {
|
|
const angle = (index / fluteCount) * Math.PI * 2
|
|
const x = Math.cos(angle) * shaftRadius
|
|
const z = Math.sin(angle) * shaftRadius
|
|
return (
|
|
<MappedCylinder
|
|
height={shaftHeight * 0.92}
|
|
key={index}
|
|
position={[x, shaftY + shaftHeight / 2, z]}
|
|
radius={fluteRadius}
|
|
segments={8}
|
|
/>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function DravidianPanelFace({
|
|
position,
|
|
rotation = 0,
|
|
panelHeight,
|
|
panelWidth,
|
|
rail,
|
|
reliefDepth,
|
|
panelShape,
|
|
}: {
|
|
position: [number, number, number]
|
|
rotation?: number
|
|
panelHeight: number
|
|
panelWidth: number
|
|
rail: number
|
|
reliefDepth: number
|
|
panelShape: NonNullable<ColumnNode['panelShape']>
|
|
}) {
|
|
return (
|
|
<group position={position} rotation={[0, rotation, 0]}>
|
|
<MappedBox
|
|
depth={reliefDepth}
|
|
height={rail}
|
|
position={[0, panelHeight / 2, 0]}
|
|
softenEdges={false}
|
|
width={panelWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={reliefDepth}
|
|
height={rail}
|
|
position={[0, -panelHeight / 2, 0]}
|
|
softenEdges={false}
|
|
width={panelWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={reliefDepth}
|
|
height={panelHeight}
|
|
position={[panelWidth / 2, 0, 0]}
|
|
softenEdges={false}
|
|
width={rail}
|
|
/>
|
|
<MappedBox
|
|
depth={reliefDepth}
|
|
height={panelHeight}
|
|
position={[-panelWidth / 2, 0, 0]}
|
|
softenEdges={false}
|
|
width={rail}
|
|
/>
|
|
{panelShape === 'diamond' && (
|
|
<MappedBox
|
|
depth={reliefDepth}
|
|
height={panelHeight * 0.42}
|
|
position={[0, 0, 0]}
|
|
rotation={[0, 0, Math.PI / 4]}
|
|
softenEdges={false}
|
|
width={rail * 1.2}
|
|
/>
|
|
)}
|
|
{panelShape === 'arched' && (
|
|
<MappedTorus
|
|
arc={Math.PI}
|
|
position={[0, panelHeight * 0.28, 0]}
|
|
ringRadius={panelWidth * 0.42}
|
|
scaleX={panelWidth * 0.42}
|
|
scaleY={rail}
|
|
scaleZ={reliefDepth}
|
|
tubeRadius={Math.max(rail, reliefDepth) * 0.18}
|
|
/>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function DravidianShaftPanels({
|
|
node,
|
|
shaftY,
|
|
shaftHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
shaftY: number
|
|
shaftHeight: number
|
|
}) {
|
|
const panelCount = Math.max(
|
|
node.panelCount ?? 0,
|
|
node.style === 'dravidian-carved' || node.shaftDetail === 'panelled' ? 3 : 0,
|
|
)
|
|
if (panelCount <= 0 || shaftHeight <= 0) return null
|
|
|
|
const shaftWidth = node.width * 0.72
|
|
const shaftDepth = node.depth * 0.72
|
|
const panelHeight = Math.min(0.42, shaftHeight / Math.max(4, panelCount + 2))
|
|
const panelWidth = node.width * 0.26
|
|
const rail = Math.max(0.012, node.width * 0.028)
|
|
const reliefDepth = Math.max(0.012, node.panelInsetDepth ?? node.width * 0.025)
|
|
const rows = Array.from({ length: panelCount }, (_, index) => (index + 1) / (panelCount + 1))
|
|
const panelShape = node.panelShape ?? 'rectangle'
|
|
|
|
const faceProps = { panelHeight, panelWidth, rail, reliefDepth, panelShape }
|
|
|
|
return (
|
|
<group>
|
|
{rows.map((t, rowIndex) => {
|
|
const y = shaftY + shaftHeight * t
|
|
return (
|
|
<group key={rowIndex}>
|
|
<DravidianPanelFace
|
|
position={[0, y, shaftDepth / 2 + reliefDepth / 2]}
|
|
{...faceProps}
|
|
/>
|
|
<DravidianPanelFace
|
|
position={[0, y, -shaftDepth / 2 - reliefDepth / 2]}
|
|
{...faceProps}
|
|
/>
|
|
<DravidianPanelFace
|
|
position={[shaftWidth / 2 + reliefDepth / 2, y, 0]}
|
|
rotation={Math.PI / 2}
|
|
{...faceProps}
|
|
/>
|
|
<DravidianPanelFace
|
|
position={[-shaftWidth / 2 - reliefDepth / 2, y, 0]}
|
|
rotation={Math.PI / 2}
|
|
{...faceProps}
|
|
/>
|
|
</group>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function SpiralRibs({
|
|
node,
|
|
shaftY,
|
|
shaftHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
shaftY: number
|
|
shaftHeight: number
|
|
}) {
|
|
const spiralRibCount = node.spiralRibCount ?? 0
|
|
const spiralTwist = node.spiralTwist ?? 0
|
|
const shaftTaper = node.shaftTaper ?? 0
|
|
const ribCountSetting = Math.max(spiralRibCount, node.shaftDetail === 'spiral' ? 12 : 0)
|
|
if (ribCountSetting <= 0 || spiralTwist === 0 || shaftHeight <= 0) return null
|
|
|
|
const ribCount = Math.min(ribCountSetting, 24)
|
|
const stepCount = 28
|
|
const ribDistance = node.radius * 0.78
|
|
const ribWidth = Math.max(0.012, node.radius * 0.06)
|
|
const segmentHeight = (shaftHeight / stepCount) * 1.18
|
|
const lean = spiralTwist > 0 ? -0.55 : 0.55
|
|
|
|
return (
|
|
<group>
|
|
{Array.from({ length: ribCount * stepCount }, (_, index) => {
|
|
const ribIndex = index % ribCount
|
|
const stepIndex = Math.floor(index / ribCount)
|
|
const t = (stepIndex + 0.5) / stepCount
|
|
const angle = (ribIndex / ribCount) * Math.PI * 2 + t * spiralTwist * Math.PI * 2
|
|
const taperScale = 1 - Math.min(shaftTaper, 0.85) * t
|
|
return (
|
|
<MappedCylinder
|
|
height={segmentHeight}
|
|
key={index}
|
|
position={[
|
|
Math.cos(angle) * ribDistance * taperScale,
|
|
shaftY + shaftHeight * t,
|
|
Math.sin(angle) * ribDistance * taperScale,
|
|
]}
|
|
radius={ribWidth}
|
|
rotation={[0, -angle, lean]}
|
|
segments={8}
|
|
/>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function LowerCarvedBand({
|
|
node,
|
|
shaftY,
|
|
shaftHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
shaftY: number
|
|
shaftHeight: number
|
|
}) {
|
|
const placement = node.carvingPlacement ?? 'capital'
|
|
if (
|
|
!node.lowerBandEnabled ||
|
|
shaftHeight <= 0 ||
|
|
(placement !== 'shaft' && placement !== 'all')
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const bandHeight = Math.min(node.lowerBandHeight ?? 0.24, shaftHeight * 0.35)
|
|
const y = shaftY + shaftHeight * 0.12
|
|
const level = Math.max(1, node.lowerBandCarvingLevel ?? 1)
|
|
const count = Math.max(6, level * 6)
|
|
const distance = Math.max(node.radius * 0.82, Math.max(node.width, node.depth) * 0.36)
|
|
|
|
return (
|
|
<group>
|
|
<ColumnBlock height={bandHeight} node={node} scale={0.84} y={y} />
|
|
{Array.from({ length: count }, (_, index) => {
|
|
const angle = (index / count) * Math.PI * 2
|
|
return (
|
|
<MappedCylinder
|
|
height={bandHeight * 0.62}
|
|
key={index}
|
|
position={[
|
|
Math.cos(angle) * distance,
|
|
y + bandHeight * 0.5,
|
|
Math.sin(angle) * distance,
|
|
]}
|
|
radius={0.012}
|
|
rotation={[0, -angle, 0]}
|
|
segments={5}
|
|
/>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function CapitalCarvings({
|
|
node,
|
|
capitalY,
|
|
capitalHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
capitalY: number
|
|
capitalHeight: number
|
|
}) {
|
|
const placement = node.carvingPlacement ?? 'capital'
|
|
const carvingLevel = Math.max(node.carvingLevel ?? 0, node.capitalCarvingLevel ?? 0)
|
|
const bandSetting = node.capitalBandCount ?? 0
|
|
if (
|
|
(carvingLevel <= 0 && bandSetting <= 0) ||
|
|
capitalHeight <= 0 ||
|
|
(placement !== 'capital' && placement !== 'all')
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const level = Math.min(Math.max(carvingLevel, bandSetting > 0 ? 1 : 0), 4)
|
|
const bandHeight = Math.min(0.035, capitalHeight / 8)
|
|
const bandCount = Math.min(bandSetting > 0 ? bandSetting : level + 1, 16)
|
|
const bands = Array.from({ length: bandCount }, (_, index) => {
|
|
const t = (index + 1) / (bandCount + 1)
|
|
return capitalY + capitalHeight * t - bandHeight / 2
|
|
})
|
|
|
|
if (node.crossSection === 'square' || node.crossSection === 'rectangular') {
|
|
const dentilCount = Math.max(node.dentilCount ?? 0, level * 4, 4)
|
|
const dentilHeight = Math.min(0.08, capitalHeight * 0.28)
|
|
const dentilDepth = Math.min(0.08, Math.min(node.width, node.depth) * 0.16)
|
|
const dentilWidth = Math.max(0.025, node.width / (dentilCount * 1.75))
|
|
const halfWidth = node.width * 0.56
|
|
const halfDepth = node.depth * 0.56
|
|
const y = capitalY + capitalHeight * 0.28
|
|
const xPositions = Array.from({ length: dentilCount }, (_, index) => {
|
|
const t = dentilCount === 1 ? 0.5 : index / (dentilCount - 1)
|
|
return -halfWidth + t * halfWidth * 2
|
|
})
|
|
const zPositions = Array.from({ length: dentilCount }, (_, index) => {
|
|
const t = dentilCount === 1 ? 0.5 : index / (dentilCount - 1)
|
|
return -halfDepth + t * halfDepth * 2
|
|
})
|
|
|
|
return (
|
|
<group>
|
|
{bands.map((bandY, index) => (
|
|
<ColumnBlock
|
|
height={bandHeight}
|
|
key={`band-${index}`}
|
|
node={node}
|
|
scale={1.28}
|
|
y={bandY}
|
|
/>
|
|
))}
|
|
{xPositions.map((x, index) => (
|
|
<group key={`front-back-dentil-${index}`}>
|
|
<MappedBox
|
|
depth={dentilDepth}
|
|
height={dentilHeight}
|
|
position={[x, y, halfDepth]}
|
|
softenEdges={false}
|
|
width={dentilWidth}
|
|
/>
|
|
<MappedBox
|
|
depth={dentilDepth}
|
|
height={dentilHeight}
|
|
position={[x, y, -halfDepth]}
|
|
softenEdges={false}
|
|
width={dentilWidth}
|
|
/>
|
|
</group>
|
|
))}
|
|
{zPositions.map((z, index) => (
|
|
<group key={`side-dentil-${index}`}>
|
|
<MappedBox
|
|
depth={dentilWidth}
|
|
height={dentilHeight}
|
|
position={[halfWidth, y, z]}
|
|
softenEdges={false}
|
|
width={dentilDepth}
|
|
/>
|
|
<MappedBox
|
|
depth={dentilWidth}
|
|
height={dentilHeight}
|
|
position={[-halfWidth, y, z]}
|
|
softenEdges={false}
|
|
width={dentilDepth}
|
|
/>
|
|
</group>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
const beadCount = Math.max(node.beadCount ?? 0, 8, level * 8)
|
|
const beadRadius = Math.max(0.012, Math.min(0.03, node.radius * 0.12))
|
|
const beadDistance = node.radius * 1.24
|
|
const beadY = capitalY + capitalHeight * 0.24
|
|
|
|
return (
|
|
<group>
|
|
{bands.map((bandY, index) => (
|
|
<ColumnBlock height={bandHeight} key={`band-${index}`} node={node} scale={1.24} y={bandY} />
|
|
))}
|
|
{Array.from({ length: beadCount }, (_, index) => {
|
|
const angle = (index / beadCount) * Math.PI * 2
|
|
return (
|
|
<MappedSphere
|
|
key={`bead-${index}`}
|
|
position={[Math.cos(angle) * beadDistance, beadY, Math.sin(angle) * beadDistance]}
|
|
radius={beadRadius}
|
|
/>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function Volutes({
|
|
node,
|
|
capitalY,
|
|
capitalHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
capitalY: number
|
|
capitalHeight: number
|
|
}) {
|
|
if (!['volute', 'ionic-volute'].includes(node.capitalStyle ?? 'simple') || capitalHeight <= 0)
|
|
return null
|
|
|
|
const y = capitalY + capitalHeight * 0.62
|
|
const radius = node.voluteSize ?? Math.min(0.085, Math.max(0.04, node.width * 0.12))
|
|
const x = node.width * 0.46
|
|
const z = node.depth * 0.7
|
|
const maxVolutes = Math.max(0, Math.min(node.voluteCount ?? 4, 8))
|
|
const volutes = [
|
|
{
|
|
position: [x, y, z] as [number, number, number],
|
|
rotation: [0, 0, 0] as [number, number, number],
|
|
},
|
|
{
|
|
position: [-x, y, z] as [number, number, number],
|
|
rotation: [0, 0, 0] as [number, number, number],
|
|
},
|
|
{
|
|
position: [x, y, -z] as [number, number, number],
|
|
rotation: [0, Math.PI, 0] as [number, number, number],
|
|
},
|
|
{
|
|
position: [-x, y, -z] as [number, number, number],
|
|
rotation: [0, Math.PI, 0] as [number, number, number],
|
|
},
|
|
{
|
|
position: [z, y, x] as [number, number, number],
|
|
rotation: [0, Math.PI / 2, 0] as [number, number, number],
|
|
},
|
|
{
|
|
position: [z, y, -x] as [number, number, number],
|
|
rotation: [0, Math.PI / 2, 0] as [number, number, number],
|
|
},
|
|
{
|
|
position: [-z, y, x] as [number, number, number],
|
|
rotation: [0, -Math.PI / 2, 0] as [number, number, number],
|
|
},
|
|
{
|
|
position: [-z, y, -x] as [number, number, number],
|
|
rotation: [0, -Math.PI / 2, 0] as [number, number, number],
|
|
},
|
|
].slice(0, maxVolutes)
|
|
|
|
return (
|
|
<group>
|
|
{volutes.map((volute, index) => (
|
|
<MappedTorus
|
|
key={index}
|
|
position={volute.position}
|
|
ringRadius={radius}
|
|
rotation={volute.rotation}
|
|
scaleZ={radius * 0.28}
|
|
tubeRadius={radius * 0.18}
|
|
/>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function LeafCarvings({
|
|
node,
|
|
capitalY,
|
|
capitalHeight,
|
|
}: {
|
|
node: ColumnNode
|
|
capitalY: number
|
|
capitalHeight: number
|
|
}) {
|
|
if (
|
|
!['leaf-carved', 'corinthian-leaf'].includes(node.capitalStyle ?? 'simple') ||
|
|
capitalHeight <= 0
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const leafCount = node.leafCount ?? (node.crossSection === 'round' ? 18 : 12)
|
|
const distance = Math.max(node.radius * 1.05, Math.max(node.width, node.depth) * 0.48)
|
|
const rowCount = Math.max(0, Math.min(node.leafRows ?? 2, 4))
|
|
const rows = Array.from({ length: rowCount }, (_, index) => ({
|
|
y: capitalY + capitalHeight * (0.3 + index * 0.16),
|
|
scale: 0.28 - index * 0.04,
|
|
offset: index % 2 === 0 ? 0 : Math.PI / leafCount,
|
|
}))
|
|
|
|
return (
|
|
<group>
|
|
{rows.flatMap((row, rowIndex) =>
|
|
Array.from({ length: leafCount }, (_, index) => {
|
|
const angle = (index / leafCount) * Math.PI * 2 + row.offset
|
|
return (
|
|
<MappedCone
|
|
height={capitalHeight * row.scale}
|
|
key={`${rowIndex}-${index}`}
|
|
position={[Math.cos(angle) * distance, row.y, Math.sin(angle) * distance]}
|
|
radiusX={0.018}
|
|
radiusZ={0.01}
|
|
rotation={[0.48, -angle, 0]}
|
|
segments={6}
|
|
/>
|
|
)
|
|
}),
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function Capital({ node, y, height }: { node: ColumnNode; y: number; height: number }) {
|
|
if (height <= 0) return null
|
|
|
|
const capitalStyle = node.capitalStyle ?? 'simple'
|
|
if (capitalStyle === 'none') return null
|
|
|
|
if (capitalStyle === 'south-indian-bracket' || capitalStyle === 'wood-bracket') {
|
|
const tierCount = Math.max(1, node.bracketTierCount ?? 3)
|
|
const tierHeight = height / tierCount
|
|
const bracketDepth = node.bracketDepth ?? 0.35
|
|
return (
|
|
<group>
|
|
{Array.from({ length: tierCount }, (_, index) => {
|
|
const t = index / Math.max(1, tierCount - 1)
|
|
const scale = (node.capitalWidthScale ?? 1.6) + t * 0.32
|
|
return (
|
|
<SquareBlock
|
|
depth={node.depth * scale + bracketDepth * t}
|
|
height={tierHeight}
|
|
key={index}
|
|
width={node.width * scale + bracketDepth * t}
|
|
y={y + index * tierHeight}
|
|
/>
|
|
)
|
|
})}
|
|
{Array.from({ length: node.pendantCount ?? 0 }, (_, index) => {
|
|
const count = Math.max(1, node.pendantCount ?? 0)
|
|
const angle = (index / count) * Math.PI * 2
|
|
const distance = Math.max(node.width, node.depth) * 0.56
|
|
return (
|
|
<MappedCone
|
|
height={height * 0.28}
|
|
key={index}
|
|
position={[Math.cos(angle) * distance, y - height * 0.1, Math.sin(angle) * distance]}
|
|
radiusX={0.035}
|
|
rotation={[0, 0, 0]}
|
|
segments={6}
|
|
/>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (capitalStyle === 'rounded' || capitalStyle === 'doric') {
|
|
const topWidth = node.width * (node.capitalWidthScale ?? 1.34)
|
|
const topDepth = node.depth * (node.capitalDepthScale ?? node.capitalWidthScale ?? 1.34)
|
|
return (
|
|
<group>
|
|
<OvalBlock
|
|
depth={topDepth * 0.72}
|
|
height={height * 0.24}
|
|
segments={32}
|
|
width={topWidth * 0.72}
|
|
y={y}
|
|
/>
|
|
<OvalBlock
|
|
depth={topDepth * 0.92}
|
|
height={height * 0.32}
|
|
segments={32}
|
|
width={topWidth * 0.92}
|
|
y={y + height * 0.24}
|
|
/>
|
|
<SquareBlock
|
|
depth={topDepth}
|
|
height={height * 0.44}
|
|
width={topWidth}
|
|
y={y + height * 0.56}
|
|
/>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (capitalStyle === 'stepped') {
|
|
const widthScale = node.capitalWidthScale ?? 1.46
|
|
const depthScale = node.capitalDepthScale ?? widthScale
|
|
const tierCount = Math.max(3, node.capitalTierCount ?? 3)
|
|
const tierHeight = height / tierCount
|
|
const stepSpread = node.capitalStepSpread ?? 0.42
|
|
|
|
return (
|
|
<group>
|
|
{Array.from({ length: tierCount }, (_, index) => {
|
|
const t = index / Math.max(1, tierCount - 1)
|
|
const widthScaleAt = Math.max(0.5, widthScale - (1 - t) * stepSpread)
|
|
const depthScaleAt = Math.max(0.5, depthScale - (1 - t) * stepSpread)
|
|
return (
|
|
<SquareBlock
|
|
depth={node.depth * depthScaleAt}
|
|
height={tierHeight * 1.01}
|
|
key={index}
|
|
width={node.width * widthScaleAt}
|
|
y={y + index * tierHeight}
|
|
/>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (
|
|
capitalStyle === 'volute' ||
|
|
capitalStyle === 'ionic-volute' ||
|
|
capitalStyle === 'leaf-carved' ||
|
|
capitalStyle === 'corinthian-leaf'
|
|
) {
|
|
const topWidth = node.width * (node.capitalWidthScale ?? 1.46)
|
|
const topDepth = node.depth * (node.capitalDepthScale ?? node.capitalWidthScale ?? 1.46)
|
|
|
|
return (
|
|
<group>
|
|
<ColumnBlock height={height * 0.24} node={node} scale={0.9} y={y} />
|
|
<ColumnBlock height={height * 0.2} node={node} scale={1.08} y={y + height * 0.24} />
|
|
<SquareBlock
|
|
depth={topDepth}
|
|
height={height * 0.28}
|
|
width={topWidth}
|
|
y={y + height * 0.44}
|
|
/>
|
|
<Volutes capitalHeight={height} capitalY={y} node={node} />
|
|
<LeafCarvings capitalHeight={height} capitalY={y} node={node} />
|
|
</group>
|
|
)
|
|
}
|
|
|
|
const widthScale = node.capitalWidthScale ?? (capitalStyle === 'simple-slab' ? 1.28 : 1.18)
|
|
const depthScale = node.capitalDepthScale ?? widthScale
|
|
|
|
if (node.crossSection === 'square' || node.crossSection === 'rectangular') {
|
|
return (
|
|
<SquareBlock
|
|
depth={node.depth * depthScale}
|
|
height={height}
|
|
width={node.width * widthScale}
|
|
y={y}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<RoundBlock
|
|
height={height}
|
|
radius={Math.max(node.radius * widthScale, node.width * widthScale * 0.5)}
|
|
segments={getSegments(node)}
|
|
y={y}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
|
|
const ref = useRef<Group>(null!)
|
|
const handlers = useNodeEvents(node, 'column')
|
|
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
|
const material = useMemo(
|
|
() =>
|
|
createColumnMaterial({
|
|
material: node.material,
|
|
materialPreset: node.materialPreset,
|
|
}),
|
|
[
|
|
node.material,
|
|
node.material?.preset,
|
|
node.material?.properties,
|
|
node.material?.texture,
|
|
node.materialPreset,
|
|
],
|
|
)
|
|
|
|
useRegistry(node.id, node.type, ref)
|
|
|
|
const shaftLayout = useMemo(() => {
|
|
const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4)
|
|
const capitalHeight =
|
|
node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4)
|
|
const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight)
|
|
return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight }
|
|
}, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height])
|
|
|
|
return (
|
|
<ColumnMaterialContext.Provider value={material}>
|
|
<ColumnEdgeSoftnessContext.Provider value={node.edgeSoftness ?? 0.025}>
|
|
<group
|
|
position={liveTransform?.position ?? node.position}
|
|
ref={ref}
|
|
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]}
|
|
visible={node.visible}
|
|
{...handlers}
|
|
>
|
|
{node.supportStyle === 'a-frame' ? (
|
|
<AFrameSupport node={node} />
|
|
) : node.supportStyle === 'y-frame' ? (
|
|
<YFrameSupport node={node} />
|
|
) : node.supportStyle === 'v-frame' ? (
|
|
<VFrameSupport node={node} />
|
|
) : node.supportStyle === 'x-brace' ? (
|
|
<XBraceSupport node={node} />
|
|
) : node.supportStyle === 'k-brace' ? (
|
|
<KBraceSupport node={node} />
|
|
) : node.supportStyle === 'single-strut' ? (
|
|
<SingleStrutSupport node={node} />
|
|
) : node.supportStyle === 'tripod' ? (
|
|
<TripodSupport node={node} />
|
|
) : node.supportStyle === 'trestle' ? (
|
|
<TrestleSupport node={node} />
|
|
) : node.supportStyle === 'portal-frame' ? (
|
|
<PortalFrameSupport node={node} />
|
|
) : node.supportStyle === 'box-frame' ? (
|
|
<BoxFrameSupport node={node} />
|
|
) : (
|
|
<>
|
|
<Base height={shaftLayout.baseHeight} node={node} />
|
|
<BaseCarvings height={shaftLayout.baseHeight} node={node} />
|
|
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
|
|
<Rings
|
|
node={node}
|
|
shaftHeight={shaftLayout.shaftHeight}
|
|
shaftY={shaftLayout.shaftY}
|
|
/>
|
|
<LatheBands
|
|
node={node}
|
|
shaftHeight={shaftLayout.shaftHeight}
|
|
shaftY={shaftLayout.shaftY}
|
|
/>
|
|
<Flutes
|
|
node={node}
|
|
shaftHeight={shaftLayout.shaftHeight}
|
|
shaftY={shaftLayout.shaftY}
|
|
/>
|
|
<LowerCarvedBand
|
|
node={node}
|
|
shaftHeight={shaftLayout.shaftHeight}
|
|
shaftY={shaftLayout.shaftY}
|
|
/>
|
|
<DravidianShaftPanels
|
|
node={node}
|
|
shaftHeight={shaftLayout.shaftHeight}
|
|
shaftY={shaftLayout.shaftY}
|
|
/>
|
|
<SpiralRibs
|
|
node={node}
|
|
shaftHeight={shaftLayout.shaftHeight}
|
|
shaftY={shaftLayout.shaftY}
|
|
/>
|
|
<Capital
|
|
height={shaftLayout.capitalHeight}
|
|
node={node}
|
|
y={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
|
/>
|
|
<CapitalCarvings
|
|
capitalHeight={shaftLayout.capitalHeight}
|
|
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
|
node={node}
|
|
/>
|
|
</>
|
|
)}
|
|
</group>
|
|
</ColumnEdgeSoftnessContext.Provider>
|
|
</ColumnMaterialContext.Provider>
|
|
)
|
|
}
|
|
|
|
export default ColumnRenderer
|