From db765f1eb392789540961daf7129e4a212d2a190 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 4 May 2026 22:54:52 +0530 Subject: [PATCH] Enable pillar duplication in floating action menu --- packages/core/src/schema/nodes/column.ts | 7 + .../editor/floating-action-menu.tsx | 5 +- .../src/components/ui/panels/column-panel.tsx | 29 + .../renderers/column/column-renderer.tsx | 705 +++++++++++++----- 4 files changed, 577 insertions(+), 169 deletions(-) diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index d19f725d..a7fa83a8 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -86,6 +86,7 @@ export const ColumnNode = BaseNode.extend({ shaftStartScale: z.number().min(0.2).max(2).default(0.72), shaftEndScale: z.number().min(0.2).max(2).default(0.72), shaftSegmentCount: z.number().int().min(1).max(64).default(24), + shaftTwistStep: z.number().min(-90).max(90).default(0), shaftCornerRadius: z.number().min(0).max(0.3).default(0.035), shaftDetail: ColumnShaftDetail.default('none'), baseStyle: ColumnBaseStyle.default('round-rings'), @@ -144,6 +145,7 @@ export const ColumnNode = BaseNode.extend({ - height/radius/width/depth: primary dimensions in meters - edgeSoftness: bevel radius for square/plinth/block edges - shaftProfile/shaftDetail: profile and surface treatment of the shaft + - shaftTwistStep: per-segment shaft rotation in degrees - shaftCornerRadius: rounded-corner radius for square/rectangular shaft cross-sections - baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls - baseHeight/capitalHeight: bottom and top block proportions @@ -168,6 +170,7 @@ export const COLUMN_PRESETS = { shaftStartScale: 0.72, shaftEndScale: 0.72, shaftSegmentCount: 1, + shaftTwistStep: 0, shaftCornerRadius: 0.035, shaftDetail: 'none', baseStyle: 'square-plinth', @@ -217,6 +220,7 @@ export const COLUMN_PRESETS = { shaftStartScale: 0.72, shaftEndScale: 0.72, shaftSegmentCount: 1, + shaftTwistStep: 0, shaftCornerRadius: 0.045, shaftDetail: 'none', baseStyle: 'simple-square', @@ -266,6 +270,7 @@ export const COLUMN_PRESETS = { shaftStartScale: 0.82, shaftEndScale: 0.72, shaftSegmentCount: 32, + shaftTwistStep: 0, shaftCornerRadius: 0.035, shaftDetail: 'none', baseStyle: 'square-plinth', @@ -315,6 +320,7 @@ export const COLUMN_PRESETS = { shaftStartScale: 0.68, shaftEndScale: 0.68, shaftSegmentCount: 32, + shaftTwistStep: 0, shaftCornerRadius: 0.035, shaftDetail: 'none', baseStyle: 'square-plinth', @@ -364,6 +370,7 @@ export const COLUMN_PRESETS = { shaftStartScale: 0.84, shaftEndScale: 0.84, shaftSegmentCount: 32, + shaftTwistStep: 0, shaftCornerRadius: 0.035, shaftDetail: 'none', baseStyle: 'square-plinth', diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 6aa84cf0..2f696d27 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, type CeilingNode, + ColumnNode, DoorNode, FenceNode, generateId, @@ -262,6 +263,8 @@ export function FloatingActionMenu() { duplicate = WindowNode.parse(duplicateInfo) } else if (node.type === 'item') { duplicate = ItemNode.parse(duplicateInfo) + } else if (node.type === 'column') { + duplicate = ColumnNode.parse(duplicateInfo) } else if (node.type === 'wall') { duplicate = WallNode.parse(duplicateInfo) } else if (node.type === 'fence') { @@ -322,6 +325,7 @@ export function FloatingActionMenu() { } if ( duplicate.type === 'item' || + duplicate.type === 'column' || duplicate.type === 'wall' || duplicate.type === 'fence' || duplicate.type === 'window' || @@ -425,7 +429,6 @@ export function FloatingActionMenu() { onDuplicate={ node && node.type !== 'spawn' && - node.type !== 'column' && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type) ? handleDuplicate diff --git a/packages/editor/src/components/ui/panels/column-panel.tsx b/packages/editor/src/components/ui/panels/column-panel.tsx index e4ae296a..8b7e97de 100644 --- a/packages/editor/src/components/ui/panels/column-panel.tsx +++ b/packages/editor/src/components/ui/panels/column-panel.tsx @@ -155,6 +155,7 @@ function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial< shaftStartScale: 0.72, shaftEndScale: 0.72, shaftSegmentCount: 1, + shaftTwistStep: 0, } } @@ -406,6 +407,34 @@ export function ColumnPanel() { /> )} + + handleUpdate({ + shaftTwistStep: value, + ...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8 + ? { shaftSegmentCount: 12 } + : {}), + }) + } + precision={0} + step={5} + unit="°" + value={node.shaftTwistStep ?? 0} + /> + {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && ( + handleUpdate({ shaftSegmentCount: Math.round(value) })} + precision={0} + step={1} + value={node.shaftSegmentCount ?? 12} + /> + )} (baseMaterial as Material) const ColumnEdgeSoftnessContext = createContext(0.025) +const COLUMN_UV_SCALE = 1 function ColumnMaterial() { const material = useContext(ColumnMaterialContext) return } +function setUvAttributes(geometry: BufferGeometry, uvs: number[]) { + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.setAttribute('uv2', new Float32BufferAttribute(uvs.slice(), 2)) + return geometry +} + +function toUvReadyGeometry(geometry: BufferGeometry) { + return geometry.index ? geometry.toNonIndexed() : geometry +} + +function applyPlanarColumnUvs(geometry: BufferGeometry) { + const mappedGeometry = toUvReadyGeometry(geometry) + const positions = mappedGeometry.getAttribute('position') + const normals = mappedGeometry.getAttribute('normal') + const uvs: number[] = [] + + for (let index = 0; index < positions.count; index += 1) { + const x = positions.getX(index) + const y = positions.getY(index) + const z = positions.getZ(index) + const normalX = normals ? Math.abs(normals.getX(index)) : 0 + const normalY = normals ? Math.abs(normals.getY(index)) : 1 + const normalZ = normals ? Math.abs(normals.getZ(index)) : 0 + + if (normalY >= normalX && normalY >= normalZ) { + uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE) + } else if (normalX >= normalZ) { + uvs.push(z * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE) + } else { + uvs.push(x * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE) + } + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function ellipseCircumference(radiusX: number, radiusZ: number) { + const a = Math.max(0.001, Math.abs(radiusX)) + const b = Math.max(0.001, Math.abs(radiusZ)) + return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))) +} + +function applyCylindricalColumnUvs( + geometry: BufferGeometry, + sideCircumference: number, + height: number, +) { + const mappedGeometry = toUvReadyGeometry(geometry) + const positions = mappedGeometry.getAttribute('position') + const normals = mappedGeometry.getAttribute('normal') + const defaultUvs = mappedGeometry.getAttribute('uv') + const halfHeight = height / 2 + const uvs: number[] = [] + + for (let index = 0; index < positions.count; index += 1) { + const x = positions.getX(index) + const y = positions.getY(index) + const z = positions.getZ(index) + const normalY = normals ? Math.abs(normals.getY(index)) : 0 + + if (normalY > 0.65) { + uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE) + } else { + const defaultU = defaultUvs ? defaultUvs.getX(index) : 0 + uvs.push(defaultU * sideCircumference * COLUMN_UV_SCALE, (y + halfHeight) * COLUMN_UV_SCALE) + } + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function applySphericalColumnUvs(geometry: BufferGeometry, radius: number) { + const mappedGeometry = toUvReadyGeometry(geometry) + const defaultUvs = mappedGeometry.getAttribute('uv') + if (!defaultUvs) return mappedGeometry + + const uvs: number[] = [] + const circumference = Math.PI * 2 * radius + const arcHeight = Math.PI * radius + + for (let index = 0; index < defaultUvs.count; index += 1) { + uvs.push( + defaultUvs.getX(index) * circumference * COLUMN_UV_SCALE, + defaultUvs.getY(index) * arcHeight * COLUMN_UV_SCALE, + ) + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function applyTorusColumnUvs(geometry: BufferGeometry, ringRadius: number, tubeRadius: number) { + const mappedGeometry = toUvReadyGeometry(geometry) + const defaultUvs = mappedGeometry.getAttribute('uv') + if (!defaultUvs) return mappedGeometry + + const uvs: number[] = [] + const ringLength = Math.PI * 2 * Math.max(0.001, ringRadius) + const tubeLength = Math.PI * 2 * Math.max(0.001, tubeRadius) + + for (let index = 0; index < defaultUvs.count; index += 1) { + uvs.push( + defaultUvs.getX(index) * ringLength * COLUMN_UV_SCALE, + defaultUvs.getY(index) * tubeLength * COLUMN_UV_SCALE, + ) + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function createColumnBoxGeometry(width: number, height: number, depth: number, bevelRadius = 0) { + const geometry = + bevelRadius > 0.001 + ? new RoundedBoxGeometry(width, height, depth, 3, bevelRadius) + : new BoxGeometry(width, height, depth) + return applyPlanarColumnUvs(geometry) +} + +function createColumnCylinderGeometry({ + height, + radiusBottom, + radiusTop = radiusBottom, + radiusX = 1, + radiusZ = 1, + segments = 32, +}: { + height: number + radiusBottom: number + radiusTop?: number + radiusX?: number + radiusZ?: number + segments?: number +}) { + const geometry = new CylinderGeometry(radiusTop, radiusBottom, height, segments) + geometry.scale(radiusX, 1, radiusZ) + const sideRadius = Math.max(radiusTop, radiusBottom) + return applyCylindricalColumnUvs( + geometry, + ellipseCircumference(sideRadius * radiusX, sideRadius * radiusZ), + height, + ) +} + +function createColumnSphereGeometry(radius: number, widthSegments = 10, heightSegments = 8) { + return applySphericalColumnUvs(new SphereGeometry(radius, widthSegments, heightSegments), radius) +} + +function createColumnTorusGeometry({ + arc = Math.PI * 2, + radialSegments = 10, + ringRadius, + scaleX = ringRadius, + scaleY = ringRadius, + scaleZ = 1, + tubeRadius, + tubularSegments = 24, +}: { + arc?: number + radialSegments?: number + ringRadius: number + scaleX?: number + scaleY?: number + scaleZ?: number + tubeRadius: number + tubularSegments?: number +}) { + const geometry = new TorusGeometry(1, 0.18, radialSegments, tubularSegments, arc) + geometry.scale(scaleX, scaleY, scaleZ) + return applyTorusColumnUvs(geometry, ringRadius, tubeRadius) +} + function createColumnMaterial({ material, materialPreset, @@ -36,12 +216,19 @@ function getShaftProfile(node: ColumnNode) { 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( - 1, - shaftProfile === 'straight' && shaftTaper <= 0 ? 1 : (node.shaftSegmentCount ?? 24), + 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) @@ -70,6 +257,181 @@ function getShaftScaleAt(node: ColumnNode, t: number) { return Math.max(0.1, profileScale) } +type VectorTuple = [number, number, number] + +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 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, @@ -83,31 +445,14 @@ function SquareBlock({ depth: number softenEdges?: boolean }) { - 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 roundedGeometry = useMemo(() => { - if (bevelRadius <= 0.001) return null - return new RoundedBoxGeometry(width, height, depth, 3, bevelRadius) - }, [bevelRadius, depth, height, width]) - - if (height <= 0) return null - - const position = [0, y + height / 2, 0] as const - return ( - - {roundedGeometry ? ( - - ) : ( - - )} - - + ) } @@ -126,13 +471,13 @@ function RoundBlock({ radius: number segments?: number }) { - if (height <= 0) return null - return ( - - - - + ) } @@ -196,13 +541,15 @@ function OvalBlock({ depth: number segments?: number }) { - if (height <= 0) return null - return ( - - - - + ) } @@ -240,13 +587,14 @@ function TaperedRoundShaft({ node, y, height }: { node: ColumnNode; y: number; h const t = (index + 0.5) / segmentCount const profileScale = getShaftScaleAt(node, t) return ( - + + + ) })} @@ -263,14 +611,15 @@ function TaperedSquareShaft({ node, y, height }: { node: ColumnNode; y: number; const t = (index + 0.5) / segmentCount const profileScale = getShaftScaleAt(node, t) return ( - + + + ) })} @@ -285,13 +634,7 @@ function Shaft({ node, y, height }: { node: ColumnNode; y: number; height: numbe const offset = Math.max(node.radius * 0.78, node.width * 0.22) return ( - - - - + {( [ [offset, 0], @@ -300,14 +643,15 @@ function Shaft({ node, y, height }: { node: ColumnNode; y: number; height: numbe [0, -offset], ] satisfies [number, number][] ).map(([x, z], index) => ( - - - - + radius={sideRadius} + segments={16} + x={x} + y={y} + z={z} + /> ))} ) @@ -439,19 +783,18 @@ function Base({ node, height }: { node: ColumnNode; height: number }) { {Array.from({ length: ribCount }, (_, index) => { const angle = (index / ribCount) * Math.PI * 2 return ( - - - - + segments={6} + /> ) })} ( - - - - + softenEdges={false} + width={node.width * 0.36} + /> ))} ) @@ -515,15 +858,15 @@ function BaseCarvings({ node, height }: { node: ColumnNode; height: number }) { {Array.from({ length: count }, (_, index) => { const angle = (index / count) * Math.PI * 2 return ( - - - - + segments={5} + /> ) })} @@ -653,14 +996,13 @@ function Flutes({ const x = Math.cos(angle) * shaftRadius const z = Math.sin(angle) * shaftRadius return ( - - - - + radius={fluteRadius} + segments={8} + /> ) })} @@ -699,33 +1041,54 @@ function DravidianShaftPanels({ rotation?: number }) => ( - - - - - - - - - - - - - - - - + + + + {panelShape === 'diamond' && ( - - - - + )} {panelShape === 'arched' && ( - - - - + )} ) @@ -781,19 +1144,18 @@ function SpiralRibs({ const angle = (ribIndex / ribCount) * Math.PI * 2 + t * spiralTwist * Math.PI * 2 const taperScale = 1 - Math.min(shaftTaper, 0.85) * t return ( - - - - + segments={8} + /> ) })} @@ -830,19 +1192,18 @@ function LowerCarvedBand({ {Array.from({ length: count }, (_, index) => { const angle = (index / count) * Math.PI * 2 return ( - - - - + segments={5} + /> ) })} @@ -907,26 +1268,38 @@ function CapitalCarvings({ ))} {xPositions.map((x, index) => ( - - - - - - - - + + ))} {zPositions.map((z, index) => ( - - - - - - - - + + ))} @@ -946,14 +1319,11 @@ function CapitalCarvings({ {Array.from({ length: beadCount }, (_, index) => { const angle = (index / beadCount) * Math.PI * 2 return ( - - - - + radius={beadRadius} + /> ) })} @@ -1015,15 +1385,14 @@ function Volutes({ return ( {volutes.map((volute, index) => ( - - - - + scaleZ={radius * 0.28} + tubeRadius={radius * 0.18} + /> ))} ) @@ -1060,15 +1429,15 @@ function LeafCarvings({ Array.from({ length: leafCount }, (_, index) => { const angle = (index / leafCount) * Math.PI * 2 + row.offset return ( - - - - + segments={6} + /> ) }), )} @@ -1106,14 +1475,14 @@ function Capital({ node, y, height }: { node: ColumnNode; y: number; height: num const angle = (index / count) * Math.PI * 2 const distance = Math.max(node.width, node.depth) * 0.56 return ( - - - - + radiusX={0.035} + rotation={[0, 0, 0]} + segments={6} + /> ) })}