2430 lines
72 KiB
TypeScript

'use client'
import {
type ColumnNode,
useLiveNodeOverrides,
useLiveTransforms,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
baseMaterial,
type ColorPreset,
createColumnBoxGeometry,
createColumnCylinderGeometry,
createColumnSphereGeometry,
createColumnTorusGeometry,
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
type RenderShading,
resolveMaterialRef,
resolveSlotDefaultMaterial,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
import {
COLUMN_BASE_DEFAULT,
COLUMN_CAPITAL_DEFAULT,
COLUMN_FRAME_DEFAULT,
COLUMN_SHAFT_DEFAULT,
type ColumnSlotId,
} from './slots'
type ColumnSlotMaterials = Record<ColumnSlotId, Material>
type SceneMaterials = ReturnType<typeof useScene.getState>['materials']
const DEFAULT_COLUMN_MATERIAL = baseMaterial()
const DEFAULT_COLUMN_SLOT_MATERIALS = createSingleColumnMaterialMap(DEFAULT_COLUMN_MATERIAL)
const ColumnMaterialContext = createContext<ColumnSlotMaterials>(DEFAULT_COLUMN_SLOT_MATERIALS)
const ColumnSlotContext = createContext<ColumnSlotId>('shaft')
const ColumnEdgeSoftnessContext = createContext(0.025)
function ColumnMaterial() {
const slotId = useContext(ColumnSlotContext)
const materials = useContext(ColumnMaterialContext)
const material = materials[slotId] ?? materials.shaft
return <primitive attach="material" object={material} />
}
function ColumnSlot({ children, slotId }: { children: ReactNode; slotId: ColumnSlotId }) {
return <ColumnSlotContext.Provider value={slotId}>{children}</ColumnSlotContext.Provider>
}
function createSingleColumnMaterialMap(material: Material): ColumnSlotMaterials {
return {
shaft: material,
base: material,
capital: material,
frame: material,
}
}
function createLegacyColumnMaterial({
material,
materialPreset,
shading,
textures,
colorPreset,
}: Pick<ColumnNode, 'material' | 'materialPreset'> & {
shading: RenderShading
textures: boolean
colorPreset: ColorPreset
}) {
if (!textures) return createSurfaceRoleMaterial('wall', colorPreset)
const presetMaterial = createMaterialFromPresetRef(materialPreset, shading)
if (presetMaterial) return presetMaterial
if (material) return createMaterial(material, shading)
return baseMaterial(shading)
}
function resolveColumnSlotMaterial({
colorPreset,
legacyMaterial,
node,
sceneMaterials,
shading,
slotId,
textures,
}: {
colorPreset: ColorPreset
legacyMaterial: Material | null
node: ColumnNode
sceneMaterials: SceneMaterials
shading: RenderShading
slotId: ColumnSlotId
textures: boolean
}): Material {
if (!textures) return createSurfaceRoleMaterial('wall', colorPreset)
const slotRef = node.slots?.[slotId]
if (slotRef) {
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
if (resolved) return resolved
}
if (legacyMaterial) return legacyMaterial
if (slotId === 'frame') return resolveSlotDefaultMaterial(COLUMN_FRAME_DEFAULT, shading)
if (slotId === 'base') return resolveSlotDefaultMaterial(COLUMN_BASE_DEFAULT, shading)
if (slotId === 'capital') return resolveSlotDefaultMaterial(COLUMN_CAPITAL_DEFAULT, shading)
return resolveSlotDefaultMaterial(COLUMN_SHAFT_DEFAULT, shading)
}
function createColumnSlotMaterials({
colorPreset,
material,
materialPreset,
node,
sceneMaterials,
shading,
textures,
}: Pick<ColumnNode, 'material' | 'materialPreset'> & {
colorPreset: ColorPreset
node: ColumnNode
sceneMaterials: SceneMaterials
shading: RenderShading
textures: boolean
}): ColumnSlotMaterials {
const legacyMaterial =
materialPreset || material
? createLegacyColumnMaterial({ colorPreset, material, materialPreset, shading, textures })
: null
return {
shaft: resolveColumnSlotMaterial({
colorPreset,
legacyMaterial,
node,
sceneMaterials,
shading,
slotId: 'shaft',
textures,
}),
base: resolveColumnSlotMaterial({
colorPreset,
legacyMaterial,
node,
sceneMaterials,
shading,
slotId: 'base',
textures,
}),
capital: resolveColumnSlotMaterial({
colorPreset,
legacyMaterial,
node,
sceneMaterials,
shading,
slotId: 'capital',
textures,
}),
frame: resolveColumnSlotMaterial({
colorPreset,
legacyMaterial,
node,
sceneMaterials,
shading,
slotId: 'frame',
textures,
}),
}
}
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 slotId = useContext(ColumnSlotContext)
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
castShadow
dispose={null}
position={position}
receiveShadow
rotation={rotation}
userData={{ slotId }}
>
<primitive attach="geometry" dispose={null} object={geometry} />
<ColumnMaterial />
</mesh>
)
}
function FlatEndedBeam({
depth,
end,
start,
width,
}: {
depth: number
end: VectorTuple
start: VectorTuple
width: number
}) {
const slotId = useContext(ColumnSlotContext)
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 castShadow dispose={null} receiveShadow userData={{ slotId }}>
<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 slotId = useContext(ColumnSlotContext)
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
castShadow
dispose={null}
position={position}
receiveShadow
rotation={rotation}
userData={{ slotId }}
>
<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 slotId = useContext(ColumnSlotContext)
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
castShadow
dispose={null}
position={position}
receiveShadow
rotation={rotation}
userData={{ slotId }}
>
<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 slotId = useContext(ColumnSlotContext)
const geometry = useMemo(() => {
if (radius <= 0) return null
return createColumnSphereGeometry(radius, segments, verticalSegments)
}, [radius, segments, verticalSegments])
if (!geometry) return null
return (
<mesh castShadow dispose={null} position={position} receiveShadow userData={{ slotId }}>
<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 slotId = useContext(ColumnSlotContext)
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
castShadow
dispose={null}
position={position}
receiveShadow
rotation={rotation}
userData={{ slotId }}
>
<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}
/>
)
}
/**
* The column's geometry tree — either a fabricated support frame or the
* classical base / shaft / capital stack. Extracted from `ColumnRenderer`
* so the translucent placement ghost (`ColumnPreview`) renders the exact
* same shape without the registry registration, pointer handlers, or
* live-transform wiring the real renderer layers on. Material and edge
* softness arrive through context, so each caller controls appearance by
* wrapping this in its own providers.
*/
function ColumnBody({ node }: { node: ColumnNode }) {
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])
if (node.supportStyle !== 'vertical') {
const support =
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} />
) : (
<BoxFrameSupport node={node} />
)
return <ColumnSlot slotId="frame">{support}</ColumnSlot>
}
return (
<>
<ColumnSlot slotId="base">
<Base height={shaftLayout.baseHeight} node={node} />
<BaseCarvings height={shaftLayout.baseHeight} node={node} />
</ColumnSlot>
<ColumnSlot slotId="shaft">
<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} />
</ColumnSlot>
<ColumnSlot slotId="capital">
<Capital
height={shaftLayout.capitalHeight}
node={node}
y={shaftLayout.baseHeight + shaftLayout.shaftHeight}
/>
<CapitalCarvings
capitalHeight={shaftLayout.capitalHeight}
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
node={node}
/>
</ColumnSlot>
</>
)
}
/**
* Translucent, non-interactive ghost of a column — the placement tool's
* cursor preview, mirroring `ShelfPreview`. Builds the same geometry tree
* as the real renderer via `<ColumnBody>` but:
* - clones the material and makes it transparent (cloning is required:
* `createLegacyColumnMaterial` can hand back a shared/cached instance, and
* mutating it would turn every committed column see-through);
* - disables raycast on every mesh so the ghost doesn't intercept the
* placement cursor ray (which would stall `grid:move`);
* - renders at the local origin so the caller's cursor group positions it.
*/
export const ColumnPreview = ({ node }: { node: ColumnNode }) => {
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const groupRef = useRef<Group>(null)
const materials = useMemo(() => {
const ghost = createLegacyColumnMaterial({
material: node.material,
materialPreset: node.materialPreset,
shading,
textures,
colorPreset,
}).clone()
ghost.transparent = true
ghost.opacity = 0.5
ghost.depthWrite = false
return createSingleColumnMaterialMap(ghost)
}, [shading, textures, colorPreset, node.material, node.materialPreset])
useEffect(
() => () => {
for (const material of new Set(Object.values(materials))) material.dispose()
},
[materials],
)
// Strip pointer events off the freshly-built meshes every render — the
// geometry tree rebuilds when the ghost's dimensions change, so a one-shot
// effect wouldn't cover later meshes.
useEffect(() => {
groupRef.current?.traverse((obj) => {
;(obj as unknown as { raycast: () => void }).raycast = () => {}
})
})
return (
<ColumnMaterialContext.Provider value={materials}>
<ColumnEdgeSoftnessContext.Provider value={node.edgeSoftness ?? 0.025}>
<group ref={groupRef}>
<ColumnBody node={node} />
</group>
</ColumnEdgeSoftnessContext.Provider>
</ColumnMaterialContext.Provider>
)
}
export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
const ref = useRef<Group>(null!)
// Merge any live drag override so width / depth / radius / height
// arrows update the mesh on every pointer move, with zustand only
// hearing the commit on release. Subscribes narrowly to this node's
// override entry; unrelated writes don't re-render.
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id))
const node = useMemo<ColumnNode>(
() => (liveOverride ? ({ ...rawNode, ...liveOverride } as ColumnNode) : rawNode),
[rawNode, liveOverride],
)
const handlers = useNodeEvents(node, 'column')
const liveTransform = useLiveTransforms((state) => state.get(node.id))
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const sceneMaterials = useScene((state) => state.materials)
const materials = useMemo(
() =>
createColumnSlotMaterials({
material: node.material,
materialPreset: node.materialPreset,
node,
sceneMaterials,
shading,
textures,
colorPreset,
}),
[shading, textures, colorPreset, node, sceneMaterials],
)
useRegistry(node.id, node.type, ref)
return (
<ColumnMaterialContext.Provider value={materials}>
<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}
>
<ColumnBody node={node} />
</group>
</ColumnEdgeSoftnessContext.Provider>
</ColumnMaterialContext.Provider>
)
}
export default ColumnRenderer