'use client' import { type ColumnNode, useLiveNodeOverrides, useLiveTransforms, useRegistry, } from '@pascal-app/core' import { baseMaterial, type ColorPreset, createColumnBoxGeometry, createColumnCylinderGeometry, createColumnSphereGeometry, createColumnTorusGeometry, createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, type RenderShading, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { createContext, useContext, useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' const ColumnMaterialContext = createContext(baseMaterial()) const ColumnEdgeSoftnessContext = createContext(0.025) function ColumnMaterial() { const material = useContext(ColumnMaterialContext) return } function createColumnMaterial({ material, materialPreset, shading, textures, colorPreset, }: Pick & { 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 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 ( ) } 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 ( ) } 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 ( {(node.bracePlateEnabled ?? true) && ( <> )} ) } 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 ( {(node.bracePlateEnabled ?? true) && ( <> )} ) } 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 ( {(node.bracePlateEnabled ?? true) && ( <> )} ) } 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 ( {(node.bracePlateEnabled ?? true) && ( <> )} ) } 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 ( {(node.bracePlateEnabled ?? true) && ( <> )} ) } 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 ( {(node.bracePlateEnabled ?? true) && ( <> )} ) } 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 ( {feet.map((foot, index) => ( ))} {(node.bracePlateEnabled ?? true) && ( <> {feet.map((foot, index) => ( ))} )} ) } 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 ( {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 ( ) })} {(node.bracePlateEnabled ?? true) && ( <> {footPoints.map((foot, index) => ( ))} {topPoints.map((top, index) => ( ))} )} ) } 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 ( {(node.bracePlateEnabled ?? true) && ( <> {[leftBottom, rightBottom].map((foot, index) => ( ))} {[leftTop, rightTop].map((top, index) => ( ))} )} ) } 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 ( {corners.map((corner, index) => ( ))} {topCorners.map((corner, index) => ( ))} {corners.map((corner, index) => ( ))} {(node.bracePlateEnabled ?? true) && ( <> {corners.map((corner, index) => ( ))} {topCorners.map((corner, index) => ( ))} )} ) } 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 ( ) } 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 ( ) } 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 ( ) } 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 ( ) } function SquareBlock({ y, height, width, depth, softenEdges = true, }: { y: number height: number width: number depth: number softenEdges?: boolean }) { return ( ) } function RoundBlock({ x = 0, y, z = 0, height, radius, segments = 32, }: { x?: number y: number z?: number height: number radius: number segments?: number }) { return ( ) } 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 } 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 ( {innerWidth > 0 && ( )} {innerDepth > 0 && ( )} {( [ [cornerX, cornerZ], [cornerX, -cornerZ], [-cornerX, cornerZ], [-cornerX, -cornerZ], ] satisfies [number, number][] ).map(([x, z], index) => ( ))} ) } function OvalBlock({ y, height, width, depth, segments = 32, }: { y: number height: number width: number depth: number segments?: number }) { return ( ) } 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 } return } function TaperedRoundShaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) { const segmentCount = getShaftSegmentCount(node) const segmentHeight = height / segmentCount return ( {Array.from({ length: segmentCount }, (_, index) => { const t = (index + 0.5) / segmentCount const profileScale = getShaftScaleAt(node, t) return ( ) })} ) } function TaperedSquareShaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) { const segmentCount = getShaftSegmentCount(node) const segmentHeight = height / segmentCount return ( {Array.from({ length: segmentCount }, (_, index) => { const t = (index + 0.5) / segmentCount const profileScale = getShaftScaleAt(node, t) return ( ) })} ) } 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 ( {( [ [offset, 0], [-offset, 0], [0, offset], [0, -offset], ] satisfies [number, number][] ).map(([x, z], index) => ( ))} ) } if ( node.crossSection === 'round' || node.crossSection === 'octagonal' || node.crossSection === 'sixteen-sided' ) { return } return } 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 ( ) } if (baseStyle === 'square-plinth') { return ( ) } if (baseStyle === 'stepped-square') { const tierCount = Math.max(3, node.baseTierCount ?? 3) const tierHeight = height / tierCount const stepSpread = node.baseStepSpread ?? 0.42 return ( {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 ( ) })} ) } 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 ( ) } 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 ( {Array.from({ length: ribCount }, (_, index) => { const angle = (index / ribCount) * Math.PI * 2 return ( ) })} ) } if (baseStyle === 'panelled-pedestal') { const inset = node.basePanelInset ?? 0.02 return ( {( [ [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) => ( ))} ) } return } 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 ( {Array.from({ length: count }, (_, index) => { const angle = (index / count) * Math.PI * 2 return ( ) })} ) } 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 ( {rings.map((ring, index) => ( ))} ) } 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 ( {bands.map((y, index) => ( ))} ) } 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 ( {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 ( ) })} ) } 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 }) { return ( {panelShape === 'diamond' && ( )} {panelShape === 'arched' && ( )} ) } 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 ( {rows.map((t, rowIndex) => { const y = shaftY + shaftHeight * t return ( ) })} ) } 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 ( {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 ( ) })} ) } 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 ( {Array.from({ length: count }, (_, index) => { const angle = (index / count) * Math.PI * 2 return ( ) })} ) } 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 ( {bands.map((bandY, index) => ( ))} {xPositions.map((x, index) => ( ))} {zPositions.map((z, index) => ( ))} ) } 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 ( {bands.map((bandY, index) => ( ))} {Array.from({ length: beadCount }, (_, index) => { const angle = (index / beadCount) * Math.PI * 2 return ( ) })} ) } 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 ( {volutes.map((volute, index) => ( ))} ) } 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 ( {rows.flatMap((row, rowIndex) => Array.from({ length: leafCount }, (_, index) => { const angle = (index / leafCount) * Math.PI * 2 + row.offset return ( ) }), )} ) } 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 ( {Array.from({ length: tierCount }, (_, index) => { const t = index / Math.max(1, tierCount - 1) const scale = (node.capitalWidthScale ?? 1.6) + t * 0.32 return ( ) })} {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 ( ) })} ) } if (capitalStyle === 'rounded' || capitalStyle === 'doric') { const topWidth = node.width * (node.capitalWidthScale ?? 1.34) const topDepth = node.depth * (node.capitalDepthScale ?? node.capitalWidthScale ?? 1.34) return ( ) } 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 ( {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 ( ) })} ) } 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 ( ) } const widthScale = node.capitalWidthScale ?? (capitalStyle === 'simple-slab' ? 1.28 : 1.18) const depthScale = node.capitalDepthScale ?? widthScale if (node.crossSection === 'square' || node.crossSection === 'rectangular') { return ( ) } return ( ) } export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { const ref = useRef(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( () => (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 material = useMemo( () => createColumnMaterial({ material: node.material, materialPreset: node.materialPreset, shading, textures, colorPreset, }), [ shading, textures, colorPreset, 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 ( {node.supportStyle === 'a-frame' ? ( ) : node.supportStyle === 'y-frame' ? ( ) : node.supportStyle === 'v-frame' ? ( ) : node.supportStyle === 'x-brace' ? ( ) : node.supportStyle === 'k-brace' ? ( ) : node.supportStyle === 'single-strut' ? ( ) : node.supportStyle === 'tripod' ? ( ) : node.supportStyle === 'trestle' ? ( ) : node.supportStyle === 'portal-frame' ? ( ) : node.supportStyle === 'box-frame' ? ( ) : ( <> )} ) } export default ColumnRenderer