From 46548c35e53afadcf0698e1ed0ffc7e3645dd5f8 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 4 May 2026 11:29:01 +0530 Subject: [PATCH 1/4] Fix curved wall and fence angle measurements --- .../editor/wall-measurement-label.tsx | 352 ++++++++- .../components/tools/fence/fence-drafting.ts | 13 +- .../src/components/tools/fence/fence-tool.tsx | 162 ++++- .../tools/fence/move-fence-endpoint-tool.tsx | 147 +++- .../components/tools/shared/segment-angle.ts | 156 ++++ .../tools/wall/move-wall-endpoint-tool.tsx | 141 +++- .../components/tools/wall/wall-drafting.ts | 27 +- .../src/components/tools/wall/wall-tool.tsx | 136 +++- .../src/components/ui/panels/door-panel.tsx | 104 +++ .../src/components/ui/panels/window-panel.tsx | 106 ++- .../viewer/src/systems/door/door-system.tsx | 681 ++++++++++++++++-- .../viewer/src/systems/wall/wall-system.tsx | 30 +- .../src/systems/window/window-system.tsx | 571 ++++++++++++++- 13 files changed, 2458 insertions(+), 168 deletions(-) create mode 100644 packages/editor/src/components/tools/shared/segment-angle.ts diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index 8c61b407f..60b041ceb 100755 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -4,10 +4,12 @@ import { type AnyNodeId, calculateLevelMiters, DEFAULT_WALL_HEIGHT, + getScaledDimensions, getWallCurveLength, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, + type ItemNode, isCurvedWall, type Point2D, pointToKey, @@ -27,6 +29,8 @@ const GUIDE_Y_OFFSET = 0.08 const LABEL_LIFT = 0.08 const BAR_THICKNESS = 0.012 const LINE_OPACITY = 0.95 +const HEIGHT_TICK_HALF_LENGTH = 0.14 +const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16 const BAR_AXIS = new THREE.Vector3(0, 1, 0) @@ -39,6 +43,18 @@ type MeasurementGuide = { extEndStart: Vec3 extEndEnd: Vec3 labelPosition: Vec3 + heightStart: Vec3 + heightEnd: Vec3 + heightBottomTickStart: Vec3 + heightBottomTickEnd: Vec3 + heightTopTickStart: Vec3 + heightTopTickEnd: Vec3 + heightLabelPosition: Vec3 +} + +type WallFaceLine = { + start: Point2D + end: Point2D } function formatMeasurement(value: number, unit: 'metric' | 'imperial') { @@ -57,28 +73,28 @@ export function WallMeasurementLabel() { const nodes = useScene((state) => state.nodes) const selectedId = selectedIds.length === 1 ? selectedIds[0] : null - const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null - const wall = selectedNode?.type === 'wall' ? selectedNode : null + const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null + const measurableNode = + selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null - const [wallObjectState, setWallObjectState] = useState<{ - id: WallNode['id'] + const [objectState, setObjectState] = useState<{ + id: AnyNodeId object: THREE.Object3D } | null>(null) - const wallObject = - selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null + const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null useFrame(() => { - if (!selectedId || wallObject) return + if (!selectedId || selectedObject) return - const nextWallObject = sceneRegistry.nodes.get(selectedId) - if (nextWallObject) { - setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject }) + const nextObject = sceneRegistry.nodes.get(selectedId) + if (nextObject) { + setObjectState({ id: selectedId as AnyNodeId, object: nextObject }) } }) - if (!(wall && wallObject)) return null + if (!(measurableNode && selectedObject)) return null - return createPortal(, wallObject) + return createPortal(, selectedObject) } function getLevelWalls( @@ -97,6 +113,114 @@ function getLevelWalls( .filter((node): node is WallNode => Boolean(node && node.type === 'wall')) } +function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) { + if (!point) return false + + return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6 +} + +function getWallFaceLines( + wall: WallNode, + miterData: WallMiterData, +): { left: WallFaceLine; right: WallFaceLine } | null { + if (isCurvedWall(wall)) return null + + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 4) return null + + const startRight = footprint[0] + const endRight = footprint[1] + const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end) + const endLeft = footprint[hasEndCenterPoint ? 3 : 2] + const lastPoint = footprint[footprint.length - 1] + const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start) + const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1] + + if (!(startRight && endRight && endLeft && startLeft)) return null + + return { + left: { + start: startLeft, + end: endLeft, + }, + right: { + start: startRight, + end: endRight, + }, + } +} + +function getLineMidpoint(line: WallFaceLine): Point2D { + return { + x: (line.start.x + line.end.x) / 2, + y: (line.start.y + line.end.y) / 2, + } +} + +function getLevelWallsCenter(levelWalls: WallNode[]): Point2D { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const candidateWall of levelWalls) { + minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0]) + maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0]) + minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1]) + maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1]) + } + + return { + x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2, + y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2, + } +} + +function getWallOuterFaceLine( + wall: WallNode, + miterData: WallMiterData, + levelWalls: WallNode[], +): WallFaceLine | null { + const faceLines = getWallFaceLines(wall, miterData) + if (!faceLines) return null + + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { + return faceLines.left + } + + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { + return faceLines.right + } + + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return null + + const wallMidpoint = { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + } + const levelCenter = getLevelWallsCenter(levelWalls) + const normal = { x: -dy / length, y: dx / length } + const fromCenter = { + x: wallMidpoint.x - levelCenter.x, + y: wallMidpoint.y - levelCenter.y, + } + const outwardNormal = + fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y } + const rightMidpoint = getLineMidpoint(faceLines.right) + const leftMidpoint = getLineMidpoint(faceLines.left) + const rightScore = + (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x + + (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y + const leftScore = + (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x + + (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y + + return rightScore >= leftScore ? faceLines.right : faceLines.left +} + function getWallMiddlePoints( wall: WallNode, miterData: WallMiterData, @@ -136,7 +260,10 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 { return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA] } -function getWallExteriorOffsetSign(wall: Pick) { +function getWallExteriorOffsetSign( + wall: Pick, + levelWalls: WallNode[], +) { if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { return 1 } @@ -145,10 +272,31 @@ function getWallExteriorOffsetSign(wall: Pick= 0 ? 1 : -1 } -function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null { +function getCurvedWallMeasurementPath( + wall: WallNode, + miterData: WallMiterData, + levelWalls: WallNode[], +): Point2D[] | null { const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData) if (!boundaryPoints) return null @@ -156,7 +304,7 @@ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): const sidePointCount = 25 if (surface.length < sidePointCount * 2) return null - const offsetSign = getWallExteriorOffsetSign(wall) + const offsetSign = getWallExteriorOffsetSign(wall, levelWalls) if (offsetSign >= 0) { return surface.slice(sidePointCount).reverse() } @@ -170,14 +318,16 @@ function buildMeasurementGuide( ): MeasurementGuide | null { const levelWalls = getLevelWalls(wall, nodes) const miterData = calculateLevelMiters(levelWalls) - const middlePoints = getWallMiddlePoints(wall, miterData) - if (!middlePoints) return null + const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls) + const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData) + const measurementPoints = measurementLine ?? fallbackMiddlePoints + if (!measurementPoints) return null const height = wall.height ?? DEFAULT_WALL_HEIGHT - const startLocal = worldPointToWallLocal(wall, middlePoints.start) - const endLocal = worldPointToWallLocal(wall, middlePoints.end) + const startLocal = worldPointToWallLocal(wall, measurementPoints.start) + const endLocal = worldPointToWallLocal(wall, measurementPoints.end) const curvedMeasurementPath = isCurvedWall(wall) - ? getCurvedWallMeasurementPath(wall, miterData) + ? getCurvedWallMeasurementPath(wall, miterData, levelWalls) : null const guidePath: Vec3[] = curvedMeasurementPath ? curvedMeasurementPath.map((point) => { @@ -224,6 +374,38 @@ function buildMeasurementGuide( guideStart[1], (guideStart[2] + guideEnd[2]) / 2, ] as Vec3) + const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3 + const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart + const tickDx = guideEnd[0] - beforeGuideEnd[0] + const tickDz = guideEnd[2] - beforeGuideEnd[2] + const tickLength = Math.hypot(tickDx, tickDz) + const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1 + const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0 + const tickUnitX = -tangentZ + const tickUnitZ = tangentX + const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] }) + const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0] + const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2] + const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1 + const heightGuidePosition = [ + rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, + 0, + rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, + ] as Vec3 + const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({ + start: [ + heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH, + y, + heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH, + ], + end: [ + heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH, + y, + heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH, + ], + }) + const bottomHeightTick = getHorizontalHeightTick(0) + const topHeightTick = getHorizontalHeightTick(height) return { guidePath, @@ -236,6 +418,37 @@ function buildMeasurementGuide( extEndStart: [extensionEndBase[0], height, extensionEndBase[2]], extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]], labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]], + heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]], + heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]], + heightBottomTickStart: bottomHeightTick.start, + heightBottomTickEnd: bottomHeightTick.end, + heightTopTickStart: topHeightTick.start, + heightTopTickEnd: topHeightTick.end, + heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]], + } +} + +type HeightGuide = { + start: Vec3 + end: Vec3 + labelPosition: Vec3 +} + +function buildItemHeightGuide(item: ItemNode): { guide: HeightGuide; height: number } | null { + const [width, height, depth] = getScaledDimensions(item) + + if (!Number.isFinite(height) || height < 0.01) return null + + const x = Number.isFinite(width) ? width / 2 + 0.18 : 0.18 + const z = Number.isFinite(depth) ? depth / 2 + 0.18 : 0.18 + + return { + height, + guide: { + start: [x, 0, z], + end: [x, height, z], + labelPosition: [x, height / 2, z], + }, } } @@ -286,6 +499,45 @@ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) { ) } +function MeasurementLabel({ + label, + position, + color, + shadowColor, +}: { + label: string + position: Vec3 + color: string + shadowColor: string +}) { + return ( + +
+ {label} +
+ + ) +} + +function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) { + if (node.type === 'wall') { + return + } + + return +} + function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { const nodes = useScene((state) => state.nodes) const theme = useViewer((state) => state.theme) @@ -316,6 +568,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { return total }, [guide, wall]) const label = formatMeasurement(length, unit) + const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}` if (!(guide && Number.isFinite(length) && length >= 0.01)) return null @@ -324,23 +577,50 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { + + + - -
- {label} -
- + shadowColor={shadowColor} + /> + + + ) +} + +function ItemHeightMeasurementAnnotation({ item }: { item: ItemNode }) { + const theme = useViewer((state) => state.theme) + const unit = useViewer((state) => state.unit) + const isNight = theme === 'dark' + const color = isNight ? '#ffffff' : '#111111' + const shadowColor = isNight ? '#111111' : '#ffffff' + + const measurement = useMemo(() => buildItemHeightGuide(item), [item]) + + if (!measurement) return null + + return ( + + + ) } diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 99ad4c06d..4f1fbd8f0 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -1,14 +1,21 @@ -import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core' +import { + FenceNode, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + useScene, + type WallNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' import { + findWallSnapTarget, getWallAngleSnapStep, getWallGridStep, - type WallPlanPoint, - findWallSnapTarget, isWallLongEnough, snapPointTo45Degrees, snapPointToGrid, + type WallPlanPoint, } from '../wall/wall-drafting' export type FencePlanPoint = WallPlanPoint diff --git a/packages/editor/src/components/tools/fence/fence-tool.tsx b/packages/editor/src/components/tools/fence/fence-tool.tsx index d7091fd1b..c52f9b413 100644 --- a/packages/editor/src/components/tools/fence/fence-tool.tsx +++ b/packages/editor/src/components/tools/fence/fence-tool.tsx @@ -7,19 +7,129 @@ import { type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +import { + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../shared/segment-angle' import { createFenceOnCurrentLevel, - snapFenceDraftPoint, type FencePlanPoint, + snapFenceDraftPoint, } from './fence-drafting' const FENCE_PREVIEW_HEIGHT = 1.8 +const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 +const DRAFT_ANGLE_LABEL_Y = 0.28 + +type DraftAngleLabel = { + id: string + label: string + position: [number, number, number] +} + +type DraftMeasurementState = { + lengthLabel: string + lengthPosition: [number, number, number] + angleLabels: DraftAngleLabel[] +} | null + +type SegmentLike = { + id: string + start: FencePlanPoint + end: FencePlanPoint + curveOffset?: number +} + +function formatMeasurement(value: number, unit: 'metric' | 'imperial') { + if (unit === 'imperial') { + const feet = value * 3.280_84 + const wholeFeet = Math.floor(feet) + const inches = Math.round((feet - wholeFeet) * 12) + if (inches === 12) return `${wholeFeet + 1}'0"` + return `${wholeFeet}'${inches}"` + } + + return `${Number.parseFloat(value.toFixed(2))}m` +} + +function getDraftAngleLabels( + start: FencePlanPoint, + end: FencePlanPoint, + segments: SegmentLike[], +): DraftAngleLabel[] { + const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]] + const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]] + const endpoints = [ + { id: 'start', point: start, draftVector: draftFromStart }, + { id: 'end', point: end, draftVector: draftFromEnd }, + ] + const labels: DraftAngleLabel[] = [] + + for (const endpoint of endpoints) { + const connectedSegment = segments.find((segment) => + Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)), + ) + if (!connectedSegment) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + if (angle === null) continue + + labels.push({ + id: endpoint.id, + label: formatAngleRadians(angle), + position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], + }) + } + + return labels +} + +function getDraftMeasurementState( + start: FencePlanPoint, + end: FencePlanPoint, + segments: SegmentLike[], + unit: 'metric' | 'imperial', +): DraftMeasurementState { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + + if (length < 0.01) return null + + return { + lengthLabel: formatMeasurement(length, unit), + lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], + angleLabels: getDraftAngleLabels(start, end, segments), + } +} + +function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { + return [ + ...walls.map((wall) => ({ + id: wall.id, + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + })), + ...fences.map((fence) => ({ + id: fence.id, + start: fence.start, + end: fence.end, + curveOffset: fence.curveOffset, + })), + ] +} const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => { const direction = new Vector3(end.x - start.x, 0, end.z - start.z) @@ -70,12 +180,14 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } = } export const FenceTool: React.FC = () => { + const unit = useViewer((state) => state.unit) const cursorRef = useRef(null) const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const shiftPressed = useRef(false) + const [draftMeasurement, setDraftMeasurement] = useState(null) useEffect(() => { let previousFenceEnd: [number, number] | null = null @@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => { previousFenceEnd = currentFenceEnd updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current) + setDraftMeasurement( + getDraftMeasurementState( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + getReferenceSegments(walls, fences), + unit, + ), + ) } else { const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences }) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) + setDraftMeasurement(null) } } @@ -123,6 +244,7 @@ export const FenceTool: React.FC = () => { endingPoint.current.copy(startingPoint.current) buildingState.current = 1 previewRef.current.visible = true + setDraftMeasurement(null) } else { const snappedEnd = snapFenceDraftPoint({ point: localClick, @@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => { createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) previewRef.current.visible = false buildingState.current = 0 + setDraftMeasurement(null) } } @@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => { markToolCancelConsumed() buildingState.current = 0 previewRef.current.visible = false + setDraftMeasurement(null) } } @@ -169,7 +293,7 @@ export const FenceTool: React.FC = () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, []) + }, [unit]) return ( @@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => { transparent /> + + {draftMeasurement && ( + <> + + {draftMeasurement.angleLabels.map((angleLabel) => ( + + ))} + + )} ) } + +function DraftMeasurementLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx index 8423ec4fb..8ebb774e4 100644 --- a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx +++ b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx @@ -2,32 +2,113 @@ import { type AnyNodeId, - type FenceNode, - type WallNode, emitter, + type FenceNode, type GridEvent, pauseSceneHistory, resumeSceneHistory, useScene, + type WallNode, } from '@pascal-app/core' -import { Html } from '@react-three/drei' import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' -import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting' +import { + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../shared/segment-angle' import { isWallLongEnough } from '../wall/wall-drafting' +import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting' function samePoint(a: FencePlanPoint, b: FencePlanPoint) { return a[0] === b[0] && a[1] === b[1] } +type SegmentLike = { + id: string + start: FencePlanPoint + end: FencePlanPoint + curveOffset?: number +} + +type AngleLabelState = { + label: string + position: [number, number, number] +} | null + +function getEndpointAngleLabel(args: { + preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number } + segments: SegmentLike[] + nodeId: FenceNode['id'] +}): AngleLabelState { + const { preview, segments, nodeId } = args + const endpoints = [ + { + point: preview.start, + }, + { + point: preview.end, + }, + ] + const targetSegment: SegmentLike = { + id: nodeId, + start: preview.start, + end: preview.end, + curveOffset: preview.curveOffset, + } + + for (const endpoint of endpoints) { + const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment) + if (!targetReference) continue + + const connectedSegment = segments.find( + (segment) => + segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)), + ) + if (!connectedSegment) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(targetReference.vector, connectedReference) + if (angle === null) continue + + return { + label: formatAngleRadians(angle), + position: [endpoint.point[0], 0.34, endpoint.point[1]], + } + } + + return null +} + +function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { + return [ + ...walls.map((wall) => ({ + id: wall.id, + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + })), + ...fences.map((fence) => ({ + id: fence.id, + start: fence.start, + end: fence.end, + curveOffset: fence.curveOffset, + })), + ] +} + type LinkedFenceSnapshot = { id: FenceNode['id'] start: FencePlanPoint end: FencePlanPoint + curveOffset?: number } function getLinkedFenceSnapshots(args: { @@ -62,6 +143,7 @@ function getLinkedFenceSnapshots(args: { id: node.id, start: [...node.start] as FencePlanPoint, end: [...node.end] as FencePlanPoint, + curveOffset: node.curveOffset, }) } @@ -77,6 +159,7 @@ function getLinkedFenceUpdates( ) { return linkedFences.map((fence) => ({ id: fence.id, + curveOffset: fence.curveOffset, start: samePoint(fence.start, originalStart) ? nextStart : samePoint(fence.start, originalEnd) @@ -112,6 +195,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = }), ) const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null) + const [angleLabel, setAngleLabel] = useState(null) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const point = target.endpoint === 'start' ? target.fence.start : target.fence.end @@ -158,27 +242,35 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => { const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint + const linkedUpdates = detachLinkedFences + ? [] + : getLinkedFenceUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) previewRef.current = { start: nextStart, end: nextEnd } setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) - applyNodePreview([ - { id: nodeId, start: nextStart, end: nextEnd }, - ...(detachLinkedFences - ? [] - : getLinkedFenceUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - )), - ]) + setAngleLabel( + getEndpointAngleLabel({ + preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset }, + segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates], + nodeId, + }), + ) + applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates]) } - const restoreOriginal = () => { + const restoreOriginal = (clearAngleLabel = true) => { applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, ]) + if (clearAngleLabel) { + setAngleLabel(null) + } } const onGridMove = (event: GridEvent) => { @@ -240,6 +332,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = } useViewer.getState().setSelection({ selectedIds: [nodeId] }) + setAngleLabel(null) exitMoveMode() event.nativeEvent?.stopPropagation?.() } @@ -248,6 +341,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) + setAngleLabel(null) markToolCancelConsumed() exitMoveMode() } @@ -290,7 +384,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = return () => { if (!wasCommitted) { - restoreOriginal() + restoreOriginal(false) } resumeSceneHistory(useScene) emitter.off('grid:move', onGridMove) @@ -322,6 +416,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = + {angleLabel && } ) } + +function EndpointAngleLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/editor/src/components/tools/shared/segment-angle.ts b/packages/editor/src/components/tools/shared/segment-angle.ts new file mode 100644 index 000000000..edc3b832d --- /dev/null +++ b/packages/editor/src/components/tools/shared/segment-angle.ts @@ -0,0 +1,156 @@ +import { + type FenceNode, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + type WallNode, +} from '@pascal-app/core' + +export type PlanPoint = [number, number] + +export type SegmentAngleLike = Pick + +export type SegmentAngleReference = { + vector: PlanPoint + orientation: 'directed' | 'axis' +} + +const POINT_MATCH_TOLERANCE = 1e-5 +const SEGMENT_POINT_TOLERANCE = 0.15 +const CURVE_TANGENT_SAMPLE_SPACING = 0.08 + +function distanceSquared(a: PlanPoint, b: PlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + + return dx * dx + dz * dz +} + +function pointsMatch(a: PlanPoint, b: PlanPoint, tolerance = POINT_MATCH_TOLERANCE) { + return distanceSquared(a, b) <= tolerance * tolerance +} + +function getProjectedPointOnSegment(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null { + const [x1, z1] = segment.start + const [x2, z2] = segment.end + const dx = x2 - x1 + const dz = z2 - z1 + const lengthSquared = dx * dx + dz * dz + + if (lengthSquared < 1e-9) { + return null + } + + const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared + if (t <= 0 || t >= 1) { + return null + } + + return [x1 + dx * t, z1 + dz * t] +} + +function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null { + const curveLength = getWallCurveLength(segment) + const sampleCount = Math.max(24, Math.ceil(curveLength / CURVE_TANGENT_SAMPLE_SPACING)) + let best: { distance: number; tangent: PlanPoint } | null = null + + for (let index = 0; index <= sampleCount; index += 1) { + const frame = getWallCurveFrameAt(segment, index / sampleCount) + const candidate: PlanPoint = [frame.point.x, frame.point.y] + const distance = distanceSquared(point, candidate) + + if (best && distance >= best.distance) { + continue + } + + best = { + distance, + tangent: [frame.tangent.x, frame.tangent.y], + } + } + + if (!best || best.distance > SEGMENT_POINT_TOLERANCE * SEGMENT_POINT_TOLERANCE) { + return null + } + + return best.tangent +} + +export function formatAngleRadians(angle: number) { + return `${Math.round((angle * 180) / Math.PI)}°` +} + +export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null { + const firstLength = Math.hypot(first[0], first[1]) + const secondLength = Math.hypot(second[0], second[1]) + + if (firstLength < 1e-6 || secondLength < 1e-6) return null + + const dot = first[0] * second[0] + first[1] * second[1] + const cosine = Math.min(1, Math.max(-1, dot / (firstLength * secondLength))) + + return Math.acos(cosine) +} + +export function getAngleToSegmentReference( + vector: PlanPoint, + reference: SegmentAngleReference, +): number | null { + const angle = getAngleBetweenVectors(vector, reference.vector) + + if (angle === null || reference.orientation === 'directed') { + return angle + } + + const reverseAngle = getAngleBetweenVectors(vector, [-reference.vector[0], -reference.vector[1]]) + + if (reverseAngle === null) { + return angle + } + + return Math.min(angle, reverseAngle) +} + +export function getSegmentAngleReferenceAtPoint( + point: PlanPoint, + segment: SegmentAngleLike, +): SegmentAngleReference | null { + if (pointsMatch(point, segment.start)) { + const frame = getWallCurveFrameAt(segment, 0) + + return { + vector: [frame.tangent.x, frame.tangent.y], + orientation: 'directed', + } + } + + if (pointsMatch(point, segment.end)) { + const frame = getWallCurveFrameAt(segment, 1) + + return { + vector: [-frame.tangent.x, -frame.tangent.y], + orientation: 'directed', + } + } + + if (isCurvedWall(segment)) { + const tangent = getCurveTangentAtPoint(point, segment) + + return tangent + ? { + vector: tangent, + orientation: 'axis', + } + : null + } + + const projected = getProjectedPointOnSegment(point, segment) + if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) { + return null + } + + return { + vector: [segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]], + orientation: 'axis', + } +} diff --git a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx index 031f433e6..281141d0e 100644 --- a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx @@ -9,27 +9,87 @@ import { useScene, type WallNode, } from '@pascal-app/core' -import { Html } from '@react-three/drei' import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' import { - isWallLongEnough, - snapWallDraftPoint, - type WallPlanPoint, -} from './wall-drafting' + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../shared/segment-angle' +import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting' function samePoint(a: WallPlanPoint, b: WallPlanPoint) { return a[0] === b[0] && a[1] === b[1] } +type WallSegmentLike = { + id: WallNode['id'] + start: WallPlanPoint + end: WallPlanPoint + curveOffset?: number +} + +type AngleLabelState = { + label: string + position: [number, number, number] +} | null + +function getEndpointAngleLabel(args: { + preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number } + walls: WallSegmentLike[] + nodeId: WallNode['id'] +}): AngleLabelState { + const { preview, walls, nodeId } = args + const endpoints = [ + { + point: preview.start, + }, + { + point: preview.end, + }, + ] + const targetSegment: WallSegmentLike = { + id: nodeId, + start: preview.start, + end: preview.end, + curveOffset: preview.curveOffset, + } + + for (const endpoint of endpoints) { + const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment) + if (!targetReference) continue + + const connectedWall = walls.find( + (wall) => + wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), + ) + if (!connectedWall) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(targetReference.vector, connectedReference) + if (angle === null) continue + + return { + label: formatAngleRadians(angle), + position: [endpoint.point[0], 0.34, endpoint.point[1]], + } + } + + return null +} + type LinkedWallSnapshot = { id: WallNode['id'] start: WallPlanPoint end: WallPlanPoint + curveOffset?: number } function getLinkedWallSnapshots(args: { @@ -64,6 +124,7 @@ function getLinkedWallSnapshots(args: { id: node.id, start: [...node.start] as WallPlanPoint, end: [...node.end] as WallPlanPoint, + curveOffset: node.curveOffset, }) } @@ -79,6 +140,7 @@ function getLinkedWallUpdates( ) { return linkedWalls.map((wall) => ({ id: wall.id, + curveOffset: wall.curveOffset, start: samePoint(wall.start, originalStart) ? nextStart : samePoint(wall.start, originalEnd) @@ -114,6 +176,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ }), ) const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null) + const [angleLabel, setAngleLabel] = useState(null) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const point = target.endpoint === 'start' ? target.wall.start : target.wall.end @@ -155,24 +218,43 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => { const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint + const linkedUpdates = detachLinkedWalls + ? [] + : getLinkedWallUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) previewRef.current = { start: nextStart, end: nextEnd } setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) - applyNodePreview([ - { id: nodeId, start: nextStart, end: nextEnd }, - ...(detachLinkedWalls - ? [] - : getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - )), - ]) + setAngleLabel( + getEndpointAngleLabel({ + preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset }, + walls: [ + ...levelWalls.map((wall) => ({ + id: wall.id, + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + })), + ...linkedUpdates, + ], + nodeId, + }), + ) + applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates]) } - const restoreOriginal = () => { - applyNodePreview([{ id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current]) + const restoreOriginal = (clearAngleLabel = true) => { + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + if (clearAngleLabel) { + setAngleLabel(null) + } } const onGridMove = (event: GridEvent) => { @@ -235,6 +317,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } useViewer.getState().setSelection({ selectedIds: [nodeId] }) + setAngleLabel(null) exitMoveMode() event.nativeEvent?.stopPropagation?.() } @@ -243,6 +326,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) + setAngleLabel(null) markToolCancelConsumed() exitMoveMode() } @@ -285,7 +369,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ return () => { if (!wasCommitted) { - restoreOriginal() + restoreOriginal(false) } resumeSceneHistory(useScene) emitter.off('grid:move', onGridMove) @@ -317,6 +401,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ + {angleLabel && } ) } + +function EndpointAngleLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index b6e18c1a0..9eee0d6fb 100755 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -3,7 +3,10 @@ import { type AnyNodeId, type DoorNode, getScaledDimensions, + getWallCurveFrameAt, + getWallCurveLength, type ItemNode, + isCurvedWall, useScene, type WallNode, WallNode as WallSchema, @@ -62,10 +65,10 @@ export function snapPointTo45Degrees( const snappedAngle = Math.round(angle / angleStep) * angleStep const distance = Math.sqrt(dx * dx + dz * dz) - return snapPointToGrid([ - start[0] + Math.cos(snappedAngle) * distance, - start[1] + Math.sin(snappedAngle) * distance, - ], step) + return snapPointToGrid( + [start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance], + step, + ) } export function getWallAngleSnapStep(step = getWallGridStep()): number { @@ -336,11 +339,17 @@ export function findWallSnapTarget( continue } - const candidates: Array = [ - wall.start, - wall.end, - projectPointOntoWall(point, wall), - ] + const candidates: Array = [wall.start, wall.end] + + if (isCurvedWall(wall)) { + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) + for (let index = 0; index <= sampleCount; index += 1) { + const frame = getWallCurveFrameAt(wall, index / sampleCount) + candidates.push([frame.point.x, frame.point.y]) + } + } else { + candidates.push(projectPointOntoWall(point, wall)) + } for (const candidate of candidates) { if (!candidate) { continue diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index debf4e6e8..c43607a73 100755 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -1,14 +1,100 @@ import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +import { + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../shared/segment-angle' import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting' const WALL_HEIGHT = 2.5 +const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22 +const DRAFT_ANGLE_LABEL_Y = 0.28 + +type DraftAngleLabel = { + id: string + label: string + position: [number, number, number] +} + +type DraftMeasurementState = { + lengthLabel: string + lengthPosition: [number, number, number] + angleLabels: DraftAngleLabel[] +} | null + +function formatMeasurement(value: number, unit: 'metric' | 'imperial') { + if (unit === 'imperial') { + const feet = value * 3.280_84 + const wholeFeet = Math.floor(feet) + const inches = Math.round((feet - wholeFeet) * 12) + if (inches === 12) return `${wholeFeet + 1}'0"` + return `${wholeFeet}'${inches}"` + } + + return `${Number.parseFloat(value.toFixed(2))}m` +} + +function getDraftAngleLabels( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): DraftAngleLabel[] { + const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]] + const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]] + const endpoints = [ + { id: 'start', point: start, draftVector: draftFromStart }, + { id: 'end', point: end, draftVector: draftFromEnd }, + ] + const labels: DraftAngleLabel[] = [] + + for (const endpoint of endpoints) { + const connectedWall = walls.find((wall) => + Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), + ) + if (!connectedWall) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + if (angle === null) continue + + labels.push({ + id: endpoint.id, + label: formatAngleRadians(angle), + position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], + }) + } + + return labels +} + +function getDraftMeasurementState( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], + unit: 'metric' | 'imperial', +): DraftMeasurementState { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + + if (length < 0.01) return null + + return { + lengthLabel: formatMeasurement(length, unit), + lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], + angleLabels: getDraftAngleLabels(start, end, walls), + } +} /** * Update wall preview mesh geometry to create a vertical plane between two points @@ -67,12 +153,14 @@ const getCurrentLevelWalls = (): WallNode[] => { } export const WallTool: React.FC = () => { + const unit = useViewer((state) => state.unit) const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const shiftPressed = useRef(false) + const [draftMeasurement, setDraftMeasurement] = useState(null) useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] @@ -109,9 +197,18 @@ export const WallTool: React.FC = () => { previousWallEnd = currentWallEnd updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + setDraftMeasurement( + getDraftMeasurementState( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + walls, + unit, + ), + ) } else { // Not drawing a wall yet, show the snapped anchor point. cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1]) + setDraftMeasurement(null) } } @@ -126,6 +223,7 @@ export const WallTool: React.FC = () => { endingPoint.current.copy(startingPoint.current) buildingState.current = 1 wallPreviewRef.current.visible = true + setDraftMeasurement(null) } else if (buildingState.current === 1) { const snappedEnd = snapWallDraftPoint({ point: localClick, @@ -140,6 +238,7 @@ export const WallTool: React.FC = () => { createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) wallPreviewRef.current.visible = false buildingState.current = 0 + setDraftMeasurement(null) } } @@ -160,6 +259,7 @@ export const WallTool: React.FC = () => { markToolCancelConsumed() buildingState.current = 0 wallPreviewRef.current.visible = false + setDraftMeasurement(null) } } @@ -176,7 +276,7 @@ export const WallTool: React.FC = () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, []) + }, [unit]) return ( @@ -195,6 +295,38 @@ export const WallTool: React.FC = () => { transparent /> + + {draftMeasurement && ( + <> + + {draftMeasurement.angleLabels.map((angleLabel) => ( + + ))} + + )} ) } + +function DraftMeasurementLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 363a859ce..fabd1c3ba 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -254,6 +254,7 @@ export function DoorPanel() { const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) const isOpening = node.openingKind === 'opening' const openingShape = node.openingShape ?? 'rectangle' + const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle' const openingRadiusMode = node.openingRadiusMode ?? 'all' const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15] const cornerRadius = node.cornerRadius ?? 0.15 @@ -380,6 +381,108 @@ export function DoorPanel() { /> + {!isOpening && ( + +
+ + handleUpdate({ + openingShape: v as DoorNode['openingShape'], + ...(v === 'rounded' + ? { + openingRadiusMode, + openingTopRadii, + cornerRadius: Math.min(cornerRadius, maxRoundedRadius), + openingRevealRadius, + } + : {}), + ...(v === 'arch' ? { archHeight } : {}), + }) + } + options={[ + { label: 'Rect', value: 'rectangle' }, + { label: 'Rounded', value: 'rounded' }, + { label: 'Arch', value: 'arch' }, + ]} + value={doorShape} + /> +
+ {doorShape === 'rounded' && ( + <> +
+ + handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) + } + options={[ + { label: 'All', value: 'all' }, + { label: 'Individual', value: 'individual' }, + ]} + value={openingRadiusMode} + /> +
+ {openingRadiusMode === 'all' ? ( + previewDoorUpdate('cornerRadius', v)} + onCommit={(v) => commitDoorPreview('cornerRadius', v)} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + ) : ( + <> + {[ + ['Top Left', 0], + ['Top Right', 1], + ].map(([label, index]) => ( + setOpeningTopRadius(index as number, v)} + onCommit={(v) => setOpeningTopRadius(index as number, v, true)} + precision={2} + step={0.05} + unit="m" + value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100} + /> + ))} + + )} + previewDoorUpdate('openingRevealRadius', v)} + onCommit={(v) => commitDoorPreview('openingRevealRadius', v)} + precision={3} + step={0.005} + unit="m" + value={Math.round(openingRevealRadius * 1000) / 1000} + /> + + )} + {doorShape === 'arch' && ( + handleUpdate({ archHeight: v })} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={Math.round(archHeight * 100) / 100} + /> + )} +
+ )} + {isOpening && (
@@ -468,6 +571,7 @@ export function DoorPanel() { min={0.05} onChange={(v) => handleUpdate({ archHeight: v })} precision={2} + restoreOnCommit={false} step={0.05} unit="m" value={Math.round(archHeight * 100) / 100} diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index 531620177..db80f6f9f 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -64,7 +64,7 @@ function isSameRadiusTuple( current: [number, number, number, number], next: [number, number, number, number], ) { - return current.every((value, index) => Math.abs(value - next[index]) < 1e-6) + return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6) } export function WindowPanel() { @@ -267,6 +267,7 @@ export function WindowPanel() { const normRows = node.rowRatios.map((r) => r / rowSum) const isOpening = node.openingKind === 'opening' const openingShape = node.openingShape ?? 'rectangle' + const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle' const openingRadiusMode = node.openingRadiusMode ?? 'all' const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] const cornerRadius = node.cornerRadius ?? 0.15 @@ -457,6 +458,108 @@ export function WindowPanel() { /> + {!isOpening && ( + + + handleUpdate({ + openingShape: value as WindowNode['openingShape'], + ...(value === 'rounded' + ? { + openingRadiusMode, + openingCornerRadii, + cornerRadius: Math.min(cornerRadius, maxRoundedRadius), + openingRevealRadius, + } + : {}), + ...(value === 'arch' ? { archHeight } : {}), + }) + } + options={[ + { value: 'rectangle', label: 'Rect' }, + { value: 'rounded', label: 'Rounded' }, + { value: 'arch', label: 'Arch' }, + ]} + value={windowShape} + /> + {windowShape === 'rounded' && ( +
+ + handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) + } + options={[ + { value: 'all', label: 'All' }, + { value: 'individual', label: 'Individual' }, + ]} + value={openingRadiusMode} + /> + {openingRadiusMode === 'all' ? ( + previewWindowUpdate('cornerRadius', value)} + onCommit={(value) => commitWindowPreview('cornerRadius', value)} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + ) : ( + <> + {[ + ['Top Left', 0], + ['Top Right', 1], + ['Bottom Right', 2], + ['Bottom Left', 3], + ].map(([label, index]) => ( + setOpeningCornerRadius(index as number, value)} + onCommit={(value) => setOpeningCornerRadius(index as number, value, true)} + precision={2} + step={0.05} + unit="m" + value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100} + /> + ))} + + )} + previewWindowUpdate('openingRevealRadius', value)} + onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} + precision={3} + step={0.005} + unit="m" + value={Math.round(openingRevealRadius * 1000) / 1000} + /> +
+ )} + {windowShape === 'arch' && ( +
+ handleUpdate({ archHeight: value })} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={Math.round(archHeight * 100) / 100} + /> +
+ )} +
+ )} + {isOpening && ( handleUpdate({ archHeight: value })} precision={2} + restoreOnCommit={false} step={0.05} unit="m" value={Math.round(archHeight * 100) / 100} diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index a782ed954..6e87ca9ee 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,10 +1,5 @@ +import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' -import { - type AnyNodeId, - type DoorNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' import * as THREE from 'three' import { baseMaterial, glassMaterial } from '../../lib/materials' @@ -55,6 +50,300 @@ function addBox( parent.add(m) } +function addShape( + parent: THREE.Object3D, + material: THREE.Material, + shape: THREE.Shape, + depth: number, +) { + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 24, + }) + geometry.translate(0, 0, -depth / 2) + const mesh = new THREE.Mesh(geometry, material) + parent.add(mesh) +} + +function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) { + return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01)) +} + +function createArchShape( + left: number, + right: number, + bottom: number, + top: number, + archHeight: number, +) { + const centerX = (left + right) / 2 + const halfWidth = (right - left) / 2 + const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight) + const springY = top - clampedArchHeight + const shape = new THREE.Shape() + const segments = 32 + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, springY) + for (let index = 1; index <= segments; index += 1) { + const x = right + (left - right) * (index / segments) + shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight)) + } + shape.lineTo(left, bottom) + shape.closePath() + return shape +} + +function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) { + if (halfWidth <= 1e-6) return springY + const t = Math.min(Math.abs(x) / halfWidth, 1) + return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0)) +} + +function createArchBandShape( + width: number, + outerSpringY: number, + outerTopY: number, + innerSpringY: number, + innerTopY: number, + insetX: number, +) { + const halfWidth = width / 2 + const innerHalfWidth = Math.max(halfWidth - insetX, 0) + const outerArchHeight = Math.max(outerTopY - outerSpringY, 0) + const safeInnerTopY = Math.min(innerTopY, outerTopY - 0.001) + const safeInnerSpringY = Math.min(innerSpringY, safeInnerTopY - 0.001) + const innerArchHeight = Math.max(safeInnerTopY - safeInnerSpringY, 0) + const shape = new THREE.Shape() + const segments = 32 + const getSafeInnerBoundaryY = (x: number) => + Math.min( + getArchBoundaryY(x, innerHalfWidth, safeInnerSpringY, innerArchHeight), + getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight) - 0.001, + ) + + shape.moveTo(-halfWidth, outerSpringY) + for (let index = 1; index <= segments; index += 1) { + const x = -halfWidth + width * (index / segments) + shape.lineTo(x, getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight)) + } + + if (innerHalfWidth <= 0.001 || safeInnerTopY <= safeInnerSpringY + 0.001) { + shape.lineTo(halfWidth, outerSpringY) + shape.closePath() + return shape + } + + shape.lineTo(innerHalfWidth, outerSpringY) + shape.lineTo(innerHalfWidth, getSafeInnerBoundaryY(innerHalfWidth)) + for (let index = segments - 1; index >= 0; index -= 1) { + const x = -innerHalfWidth + innerHalfWidth * 2 * (index / segments) + shape.lineTo(x, getSafeInnerBoundaryY(x)) + } + shape.lineTo(-innerHalfWidth, outerSpringY) + shape.lineTo(-halfWidth, outerSpringY) + shape.closePath() + + return shape +} + +function createArchHeadBarShape(width: number, bottomY: number, springY: number, topY: number) { + const halfWidth = width / 2 + const archHeight = Math.max(topY - springY, 0) + const shape = new THREE.Shape() + const segments = 32 + + shape.moveTo(-halfWidth, bottomY) + shape.lineTo(halfWidth, bottomY) + shape.lineTo(halfWidth, springY) + for (let index = 1; index <= segments; index += 1) { + const x = halfWidth - width * (index / segments) + shape.lineTo(x, getArchBoundaryY(x, halfWidth, springY, archHeight)) + } + shape.lineTo(-halfWidth, bottomY) + shape.closePath() + + return shape +} + +type TopCornerRadii = { + topLeft: number + topRight: number +} + +function normalizeTopCornerRadii( + radii: TopCornerRadii, + width: number, + height: number, +): TopCornerRadii { + const next = { ...radii } + const scale = Math.min( + 1, + width / Math.max(next.topLeft + next.topRight, 1e-6), + height / Math.max(next.topLeft, 1e-6), + height / Math.max(next.topRight, 1e-6), + ) + + if (scale < 1) { + next.topLeft *= scale + next.topRight *= scale + } + + return next +} + +function getDoorTopRadii(node: DoorNode, width: number, height: number): TopCornerRadii { + if (node.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0] = node.openingTopRadii ?? [0.15, 0.15] + return normalizeTopCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height) + const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius } +} + +function createRoundedTopShape( + left: number, + right: number, + bottom: number, + top: number, + radii: TopCornerRadii, +) { + const shape = new THREE.Shape() + const { topLeft, topRight } = normalizeTopCornerRadii(radii, right - left, top - bottom) + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, top - topRight) + if (topRight > 1e-6) { + shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false) + } else { + shape.lineTo(right, top) + } + + shape.lineTo(left + topLeft, top) + if (topLeft > 1e-6) { + shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false) + } else { + shape.lineTo(left, top) + } + + shape.lineTo(left, bottom) + shape.closePath() + return shape +} + +function createRoundedDoorFrameShape( + width: number, + height: number, + frameThickness: number, + radii: TopCornerRadii, +) { + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const outerRadii = normalizeTopCornerRadii(radii, width, height) + const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii) + const inset = Math.min(frameThickness, width / 2 - 0.005, height - 0.005) + + if (inset <= 0.001) return outer + + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerTop = top - inset + const innerRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(outerRadii.topLeft - inset, 0), + topRight: Math.max(outerRadii.topRight - inset, 0), + }, + innerRight - innerLeft, + innerTop - bottom, + ) + const holeShape = createRoundedTopShape(innerLeft, innerRight, bottom, innerTop, innerRadii) + const hole = new THREE.Path(holeShape.getPoints(32).reverse()) + outer.holes.push(hole) + + return outer +} + +function shapeToReversedPath(shape: THREE.Shape) { + return new THREE.Path(shape.getPoints(40).reverse()) +} + +function createRoundedLeafFrameShape( + width: number, + bottom: number, + top: number, + radii: TopCornerRadii, + insetX: number, + insetY: number, +) { + const halfWidth = width / 2 + const outerRadii = normalizeTopCornerRadii(radii, width, top - bottom) + const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii) + const innerLeft = -halfWidth + insetX + const innerRight = halfWidth - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0), + topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0), + }, + innerRight - innerLeft, + innerTop - innerBottom, + ) + outer.holes.push( + shapeToReversedPath( + createRoundedTopShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii), + ), + ) + + return outer +} + +function createTopClippedRectShape( + left: number, + right: number, + bottom: number, + top: number, + getBoundaryY: (x: number) => number, +) { + const segments = 20 + const points: { x: number; y: number }[] = [] + + for (let index = 0; index <= segments; index += 1) { + const t = index / segments + const x = right + (left - right) * t + const y = Math.min(top, getBoundaryY(x)) + if (y > bottom + 0.001) points.push({ x, y }) + } + + if (points.length < 2) return null + + const shape = new THREE.Shape() + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + for (const point of points) { + shape.lineTo(point.x, point.y) + } + shape.closePath() + return shape +} + function disposeObject(object: THREE.Object3D) { object.traverse((child) => { if (child instanceof THREE.Mesh) child.geometry.dispose() @@ -82,6 +371,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { width, height, openingKind, + openingShape, frameThickness, frameDepth, threshold, @@ -129,41 +419,111 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { y: number, z: number, ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) + const addLeafShape = (shape: THREE.Shape, material: THREE.Material, depth: number, z = 0) => { + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 24, + }) + geometry.translate(-hingeX, 0, -depth / 2 + z) + const leafMesh = new THREE.Mesh(geometry, material) + leafGroup.add(leafMesh) + } // ── Frame members ── - // Left post — full height - addBox( - mesh, - baseMaterial, - frameThickness, - height, - frameDepth, - -width / 2 + frameThickness / 2, - 0, - 0, - ) - // Right post — full height - addBox( - mesh, - baseMaterial, - frameThickness, - height, - frameDepth, - width / 2 - frameThickness / 2, - 0, - 0, - ) - // Head (top bar) — full width - addBox( - mesh, - baseMaterial, - width, - frameThickness, - frameDepth, - 0, - height / 2 - frameThickness / 2, - 0, - ) + if (openingShape === 'arch') { + const frameBottom = -height / 2 + const frameTop = height / 2 + const frameArchHeight = getClampedArchHeight(width, height, node.archHeight) + const frameSpringY = frameTop - frameArchHeight + const frameInnerTopY = frameTop - frameThickness + const frameInnerSpringY = Math.min(frameSpringY + frameThickness, frameInnerTopY) + const useShallowHeadBar = frameArchHeight <= frameThickness * 2 + const frameHeadBottomY = useShallowHeadBar ? frameSpringY - frameThickness : frameSpringY + const postHeight = Math.max(frameHeadBottomY - frameBottom, 0.01) + + addBox( + mesh, + baseMaterial, + frameThickness, + postHeight, + frameDepth, + -width / 2 + frameThickness / 2, + frameBottom + postHeight / 2, + 0, + ) + addBox( + mesh, + baseMaterial, + frameThickness, + postHeight, + frameDepth, + width / 2 - frameThickness / 2, + frameBottom + postHeight / 2, + 0, + ) + addShape( + mesh, + baseMaterial, + useShallowHeadBar + ? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop) + : createArchBandShape( + width, + frameSpringY, + frameTop, + frameInnerSpringY, + frameInnerTopY, + frameThickness, + ), + frameDepth, + ) + } else if (openingShape === 'rounded') { + addShape( + mesh, + baseMaterial, + createRoundedDoorFrameShape( + width, + height, + frameThickness, + getDoorTopRadii(node, width, height), + ), + frameDepth, + ) + } else { + // Left post — full height + addBox( + mesh, + baseMaterial, + frameThickness, + height, + frameDepth, + -width / 2 + frameThickness / 2, + 0, + 0, + ) + // Right post — full height + addBox( + mesh, + baseMaterial, + frameThickness, + height, + frameDepth, + width / 2 - frameThickness / 2, + 0, + 0, + ) + // Head (top bar) — full width + addBox( + mesh, + baseMaterial, + width, + frameThickness, + frameDepth, + 0, + height / 2 - frameThickness / 2, + 0, + ) + } // ── Threshold (inside the frame) ── if (threshold) { @@ -179,16 +539,139 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { ) } - // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── + const usesShapedLeaf = openingShape === 'arch' || openingShape === 'rounded' + const leafBottom = leafCenterY - leafH / 2 + const leafTop = leafCenterY + leafH / 2 + const leafArchHeight = getClampedArchHeight( + leafW, + leafH, + Math.max((node.archHeight ?? leafW / 2) - frameThickness, 0.01), + ) + const leafArchSpringY = leafTop - leafArchHeight + const frameRadii = getDoorTopRadii(node, width, height) + const leafTopRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(frameRadii.topLeft - frameThickness, 0), + topRight: Math.max(frameRadii.topRight - frameThickness, 0), + }, + leafW, + leafH, + ) const cpX = contentPadding[0] const cpY = contentPadding[1] - if (hasLeafContent && cpY > 0) { + const useShallowLeafHeadBar = openingShape === 'arch' && cpY > 0 && leafArchHeight <= cpY * 2 + const shallowLeafHeadBottomY = leafArchSpringY - cpY + const getLeafBoundaryY = (x: number) => { + if (openingShape === 'arch') { + if (useShallowLeafHeadBar) return shallowLeafHeadBottomY + + const innerTop = leafTop - cpY + const innerSpringY = Math.min(Math.max(leafArchSpringY + cpY, leafBottom + cpY), innerTop) + const innerArchHeight = Math.max(innerTop - innerSpringY, 0.001) + const halfContentW = Math.max((leafW - 2 * cpX) / 2, 0.001) + const outerBoundaryY = getArchBoundaryY(x, leafW / 2, leafArchSpringY, leafArchHeight) + return Math.min( + getArchBoundaryY(x, halfContentW, innerSpringY, innerArchHeight), + outerBoundaryY - 0.001, + ) + } + + if (openingShape === 'rounded') { + const left = -leafW / 2 + cpX + const right = leafW / 2 - cpX + const top = leafTop - cpY + const innerRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(leafTopRadii.topLeft - Math.max(cpX, cpY), 0), + topRight: Math.max(leafTopRadii.topRight - Math.max(cpX, cpY), 0), + }, + right - left, + top - (leafBottom + cpY), + ) + + if (innerRadii.topLeft > 1e-6 && x < left + innerRadii.topLeft) { + const centerX = left + innerRadii.topLeft + const centerY = top - innerRadii.topLeft + const dx = x - centerX + return centerY + Math.sqrt(Math.max(innerRadii.topLeft * innerRadii.topLeft - dx * dx, 0)) + } + + if (innerRadii.topRight > 1e-6 && x > right - innerRadii.topRight) { + const centerX = right - innerRadii.topRight + const centerY = top - innerRadii.topRight + const dx = x - centerX + return centerY + Math.sqrt(Math.max(innerRadii.topRight * innerRadii.topRight - dx * dx, 0)) + } + + return top + } + + return leafTop + } + const createLeafCellShape = (left: number, right: number, bottom: number, top: number) => + createTopClippedRectShape(left, right, bottom, top, getLeafBoundaryY) + + // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── + if (hasLeafContent && openingShape === 'arch') { + const leafInnerTopY = leafTop - cpY + const leafInnerSpringY = Math.min( + Math.max(leafArchSpringY + cpY, leafBottom + cpY), + leafInnerTopY, + ) + const sideBottom = leafBottom + cpY + const sideTop = useShallowLeafHeadBar ? shallowLeafHeadBottomY : leafArchSpringY + const sideHeight = Math.max(sideTop - sideBottom, 0) + + if (cpY > 0) { + addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafBottom + cpY / 2, 0) + } + if (cpX > 0 && sideHeight > 0.01) { + addLeafBox( + baseMaterial, + cpX, + sideHeight, + leafDepth, + -leafW / 2 + cpX / 2, + sideBottom + sideHeight / 2, + 0, + ) + addLeafBox( + baseMaterial, + cpX, + sideHeight, + leafDepth, + leafW / 2 - cpX / 2, + sideBottom + sideHeight / 2, + 0, + ) + } + addLeafShape( + useShallowLeafHeadBar + ? createArchHeadBarShape(leafW, shallowLeafHeadBottomY, leafArchSpringY, leafTop) + : createArchBandShape( + leafW, + leafArchSpringY, + leafTop, + leafInnerSpringY, + leafInnerTopY, + cpX, + ), + baseMaterial, + leafDepth, + ) + } else if (hasLeafContent && openingShape === 'rounded') { + addLeafShape( + createRoundedLeafFrameShape(leafW, leafBottom, leafTop, leafTopRadii, cpX, cpY), + baseMaterial, + leafDepth, + ) + } else if (hasLeafContent && cpY > 0) { // Top strip addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) // Bottom strip addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) } - if (hasLeafContent && cpX > 0) { + if (hasLeafContent && !usesShapedLeaf && cpX > 0) { const innerH = leafH - 2 * cpY // Left strip addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) @@ -205,9 +688,12 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const contentTop = leafCenterY + contentH / 2 let segY = contentTop - for (const seg of segments) { + for (let segIndex = 0; segIndex < segments.length; segIndex += 1) { + const seg = segments[segIndex]! const segH = (seg.heightRatio / totalRatio) * contentH const segCenterY = segY - segH / 2 + const segTop = segY + const segBottom = segY - segH const numCols = seg.columnRatios.length const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) @@ -228,15 +714,24 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { cx = -contentW / 2 for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! - addLeafBox( - baseMaterial, - seg.dividerThickness, - segH, - leafDepth + 0.001, - cx + seg.dividerThickness / 2, - segCenterY, - 0, - ) + if (usesShapedLeaf) { + const dividerLeft = cx + const dividerRight = cx + seg.dividerThickness + const dividerShape = createLeafCellShape(dividerLeft, dividerRight, segBottom, segTop) + if (dividerShape) { + addLeafShape(dividerShape, baseMaterial, 0.012, leafDepth / 2 + 0.006) + } + } else { + addLeafBox( + baseMaterial, + seg.dividerThickness, + segH, + leafDepth + 0.001, + cx + seg.dividerThickness / 2, + segCenterY, + 0, + ) + } cx += seg.dividerThickness } } @@ -245,27 +740,61 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { for (let c = 0; c < numCols; c++) { const colW = colWidths[c]! const colX = colXCenters[c]! + const cellLeft = colX - colW / 2 + const cellRight = colX + colW / 2 if (seg.type === 'glass') { - // Glass only — no opaque backing so it's truly transparent const glassDepth = Math.max(0.004, leafDepth * 0.15) - addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + if (usesShapedLeaf) { + const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop) + if (shape) + addLeafShape(shape, glassMaterial, glassDepth, leafDepth / 2 + glassDepth / 2 + 0.004) + } else { + // Glass only — no opaque backing so it's truly transparent + addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } } else if (seg.type === 'panel') { - // Opaque leaf backing for this column - addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + if (usesShapedLeaf) { + const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop) + if (shape) addLeafShape(shape, baseMaterial, leafDepth) + } else { + // Opaque leaf backing for this column + addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + } // Raised panel detail const panelW = colW - 2 * seg.panelInset const panelH = segH - 2 * seg.panelInset if (panelW > 0.01 && panelH > 0.01) { const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) const panelZ = leafDepth / 2 + effectiveDepth / 2 - addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + if (usesShapedLeaf) { + const shape = createLeafCellShape( + colX - panelW / 2, + colX + panelW / 2, + segCenterY - panelH / 2, + segCenterY + panelH / 2, + ) + if (shape) addLeafShape(shape, baseMaterial, effectiveDepth, panelZ) + } else { + addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + } } } else { // 'empty' leaves the opening unfilled } } + if (usesShapedLeaf && segIndex < segments.length - 1) { + const railThickness = Math.min(Math.max(cpY, 0.02), Math.max(segH * 0.35, 0.02)) + const railShape = createLeafCellShape( + -contentW / 2, + contentW / 2, + segBottom - railThickness / 2, + segBottom + railThickness / 2, + ) + if (railShape) addLeafShape(railShape, baseMaterial, 0.012, leafDepth / 2 + 0.006) + } + segY -= segH } @@ -308,8 +837,6 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const hingeW = 0.024 const hingeD = leafDepth + 0.016 // Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top - const leafBottom = leafCenterY - leafH / 2 - const leafTop = leafCenterY + leafH / 2 addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ) @@ -327,6 +854,40 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { mesh.add(cutout) } cutout.geometry.dispose() - cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + if (node.openingShape === 'arch') { + cutout.geometry = new THREE.ExtrudeGeometry( + createArchShape( + -node.width / 2, + node.width / 2, + -node.height / 2, + node.height / 2, + getClampedArchHeight(node.width, node.height, node.archHeight), + ), + { + depth: 1, + bevelEnabled: false, + curveSegments: 24, + }, + ) + cutout.geometry.translate(0, 0, -0.5) + } else if (node.openingShape === 'rounded') { + cutout.geometry = new THREE.ExtrudeGeometry( + createRoundedTopShape( + -node.width / 2, + node.width / 2, + -node.height / 2, + node.height / 2, + getDoorTopRadii(node, node.width, node.height), + ), + { + depth: 1, + bevelEnabled: false, + curveSegments: 24, + }, + ) + cutout.geometry.translate(0, 0, -0.5) + } else { + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + } cutout.visible = false } diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 98c98467e..31490322d 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1,14 +1,10 @@ -import { useFrame } from '@react-three/fiber' -import * as THREE from 'three' -import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' -import { computeBoundsTree } from 'three-mesh-bvh' import { - calculateLevelMiters, type AnyNode, type AnyNodeId, + calculateLevelMiters, + DEFAULT_WALL_HEIGHT, type DoorNode, getAdjacentWallIds, - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallMiterBoundaryPoints, getWallPlanFootprint, @@ -21,10 +17,14 @@ import { sceneRegistry, spatialGridManager, useScene, - type WallNode, type WallMiterData, + type WallNode, type WindowNode, } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' +import { computeBoundsTree } from 'three-mesh-bvh' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() @@ -560,7 +560,13 @@ function collectCutoutBrushes( if ( (child.type === 'door' && child.openingKind === 'opening') || - (child.type === 'window' && child.openingKind === 'opening') + (child.type === 'door' && + child.openingKind === 'door' && + (child.openingShape === 'arch' || child.openingShape === 'rounded')) || + (child.type === 'window' && child.openingKind === 'opening') || + (child.type === 'window' && + child.openingKind === 'window' && + (child.openingShape === 'arch' || child.openingShape === 'rounded')) ) { brushes.push(createShapedOpeningCutoutBrush(child, wallThickness)) continue @@ -668,11 +674,17 @@ function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape if (opening.openingShape === 'arch') { const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height) const springY = top - archHeight + const segments = 32 shape.moveTo(left, bottom) shape.lineTo(right, bottom) shape.lineTo(right, springY) - shape.quadraticCurveTo(centerX, top, left, springY) + for (let index = 1; index <= segments; index += 1) { + const x = right + (left - right) * (index / segments) + const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1) + const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0)) + shape.lineTo(x, y) + } shape.lineTo(left, bottom) shape.closePath() return shape diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 01a77f430..81382b1e7 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,10 +1,5 @@ +import { type AnyNodeId, sceneRegistry, useScene, type WindowNode } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' -import { - type AnyNodeId, - sceneRegistry, - useScene, - type WindowNode, -} from '@pascal-app/core' import * as THREE from 'three' import { baseMaterial, glassMaterial } from '../../lib/materials' @@ -55,6 +50,521 @@ function addBox( parent.add(m) } +function addShape( + parent: THREE.Object3D, + material: THREE.Material, + shape: THREE.Shape, + depth: number, + z = 0, +) { + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 24, + }) + geometry.translate(0, 0, -depth / 2 + z) + const mesh = new THREE.Mesh(geometry, material) + parent.add(mesh) +} + +function createRectShape(left: number, right: number, bottom: number, top: number) { + const shape = new THREE.Shape() + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, top) + shape.lineTo(left, top) + shape.closePath() + return shape +} + +type CornerRadii = { + topLeft: number + topRight: number + bottomRight: number + bottomLeft: number +} + +function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii { + const next = { ...radii } + const scale = Math.min( + 1, + width / Math.max(next.topLeft + next.topRight, 1e-6), + width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), + height / Math.max(next.topLeft + next.bottomLeft, 1e-6), + height / Math.max(next.topRight + next.bottomRight, 1e-6), + ) + + if (scale < 1) { + next.topLeft *= scale + next.topRight *= scale + next.bottomRight *= scale + next.bottomLeft *= scale + } + + return next +} + +function getWindowRoundedRadii(node: WindowNode, width: number, height: number): CornerRadii { + if (node.openingRadiusMode === 'individual') { + const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = + node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] + return normalizeCornerRadii( + { + topLeft: Math.max(topLeft, 0), + topRight: Math.max(topRight, 0), + bottomRight: Math.max(bottomRight, 0), + bottomLeft: Math.max(bottomLeft, 0), + }, + width, + height, + ) + } + + const maxRadius = Math.min(width / 2, height / 2) + const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius) + return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius } +} + +function insetCornerRadii(radii: CornerRadii, inset: number, width: number, height: number) { + return normalizeCornerRadii( + { + topLeft: Math.max(radii.topLeft - inset, 0), + topRight: Math.max(radii.topRight - inset, 0), + bottomRight: Math.max(radii.bottomRight - inset, 0), + bottomLeft: Math.max(radii.bottomLeft - inset, 0), + }, + width, + height, + ) +} + +function createRoundedShape( + left: number, + right: number, + bottom: number, + top: number, + radii: CornerRadii, +) { + const shape = new THREE.Shape() + const { topLeft, topRight, bottomRight, bottomLeft } = radii + + shape.moveTo(left + bottomLeft, bottom) + shape.lineTo(right - bottomRight, bottom) + if (bottomRight > 1e-6) { + shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false) + } else { + shape.lineTo(right, bottom) + } + + shape.lineTo(right, top - topRight) + if (topRight > 1e-6) { + shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false) + } else { + shape.lineTo(right, top) + } + + shape.lineTo(left + topLeft, top) + if (topLeft > 1e-6) { + shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false) + } else { + shape.lineTo(left, top) + } + + shape.lineTo(left, bottom + bottomLeft) + if (bottomLeft > 1e-6) { + shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false) + } else { + shape.lineTo(left, bottom) + } + + shape.closePath() + return shape +} + +function createRoundedFrameShape( + width: number, + height: number, + frameThickness: number, + outerRadii: CornerRadii, +) { + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const outer = createRoundedShape(-halfWidth, halfWidth, bottom, top, outerRadii) + const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005) + + if (inset <= 0.001) return outer + + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerBottom = bottom + inset + const innerTop = top - inset + const innerRadii = insetCornerRadii( + outerRadii, + inset, + innerRight - innerLeft, + innerTop - innerBottom, + ) + const holeShape = createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii) + const hole = new THREE.Path(holeShape.getPoints(32).reverse()) + outer.holes.push(hole) + + return outer +} + +function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) { + return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01)) +} + +function createArchShape( + left: number, + right: number, + bottom: number, + top: number, + archHeight: number, +) { + const centerX = (left + right) / 2 + const halfWidth = (right - left) / 2 + const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight) + const springY = top - clampedArchHeight + const shape = new THREE.Shape() + const segments = 32 + + shape.moveTo(left, bottom) + shape.lineTo(right, bottom) + shape.lineTo(right, springY) + for (let index = 1; index <= segments; index += 1) { + const x = right + (left - right) * (index / segments) + shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight)) + } + shape.lineTo(left, bottom) + shape.closePath() + return shape +} + +function createArchedFrameShape( + width: number, + height: number, + archHeight: number, + frameThickness: number, +) { + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight) + const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005) + + if (inset <= 0.001) return outer + + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerBottom = bottom + inset + const innerTop = top - inset + const innerArchHeight = getClampedArchHeight( + innerRight - innerLeft, + innerTop - innerBottom, + archHeight - inset, + ) + const hole = new THREE.Path( + createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight) + .getPoints(32) + .reverse(), + ) + outer.holes.push(hole) + + return outer +} + +function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) { + if (halfWidth <= 1e-6) return springY + const t = Math.min(Math.abs(x) / halfWidth, 1) + return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0)) +} + +function getArchedOpeningHalfWidthAtY( + y: number, + halfWidth: number, + springY: number, + archHeight: number, +) { + if (y <= springY || archHeight <= 1e-6) return halfWidth + const normalizedY = Math.min(Math.max((y - springY) / archHeight, 0), 1) + return halfWidth * Math.sqrt(Math.max(1 - normalizedY * normalizedY, 0)) +} + +function getRoundedBoundaryYAtX( + x: number, + left: number, + right: number, + top: number, + radii: CornerRadii, +) { + if (radii.topLeft > 1e-6 && x < left + radii.topLeft) { + const centerX = left + radii.topLeft + const centerY = top - radii.topLeft + const dx = x - centerX + return centerY + Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dx * dx, 0)) + } + + if (radii.topRight > 1e-6 && x > right - radii.topRight) { + const centerX = right - radii.topRight + const centerY = top - radii.topRight + const dx = x - centerX + return centerY + Math.sqrt(Math.max(radii.topRight * radii.topRight - dx * dx, 0)) + } + + return top +} + +function getRoundedHorizontalBoundsAtY( + y: number, + left: number, + right: number, + top: number, + radii: CornerRadii, +) { + let minX = left + let maxX = right + + if (radii.topLeft > 1e-6 && y > top - radii.topLeft) { + const centerX = left + radii.topLeft + const centerY = top - radii.topLeft + const dy = y - centerY + minX = centerX - Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dy * dy, 0)) + } + + if (radii.topRight > 1e-6 && y > top - radii.topRight) { + const centerX = right - radii.topRight + const centerY = top - radii.topRight + const dy = y - centerY + maxX = centerX + Math.sqrt(Math.max(radii.topRight * radii.topRight - dy * dy, 0)) + } + + return { minX, maxX } +} + +function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { + const { + width, + height, + frameDepth, + frameThickness, + columnRatios, + rowRatios, + columnDividerThickness, + rowDividerThickness, + sill, + sillDepth, + sillThickness, + } = node + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const outerRadii = getWindowRoundedRadii(node, width, height) + const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)) + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerBottom = bottom + inset + const innerTop = top - inset + const innerW = innerRight - innerLeft + const innerH = innerTop - innerBottom + const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH) + + addShape( + mesh, + baseMaterial, + createRoundedFrameShape(width, height, inset, outerRadii), + frameDepth, + ) + + if (innerW > 0.01 && innerH > 0.01) { + const glassDepth = Math.max(0.004, frameDepth * 0.08) + addShape( + mesh, + glassMaterial, + createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii), + glassDepth, + ) + + const numCols = columnRatios.length + const numRows = rowRatios.length + const usableW = innerW - (numCols - 1) * columnDividerThickness + const usableH = innerH - (numRows - 1) * rowDividerThickness + const colSum = columnRatios.reduce((a, b) => a + b, 0) + const rowSum = rowRatios.reduce((a, b) => a + b, 0) + const colWidths = columnRatios.map((r) => (r / colSum) * usableW) + const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) + + let x = innerLeft + for (let c = 0; c < numCols - 1; c++) { + x += colWidths[c]! + const x1 = x + const x2 = x + columnDividerThickness + const dividerTop = Math.min( + getRoundedBoundaryYAtX(x1, innerLeft, innerRight, innerTop, innerRadii), + getRoundedBoundaryYAtX(x2, innerLeft, innerRight, innerTop, innerRadii), + ) + if (dividerTop > innerBottom + 0.01) { + addShape( + mesh, + baseMaterial, + createRectShape(x1, x2, innerBottom, dividerTop), + frameDepth + 0.001, + ) + } + x += columnDividerThickness + } + + let y = innerTop + for (let r = 0; r < numRows - 1; r++) { + y -= rowHeights[r]! + const yTop = y + const yBottom = y - rowDividerThickness + const { minX, maxX } = getRoundedHorizontalBoundsAtY( + yTop, + innerLeft, + innerRight, + innerTop, + innerRadii, + ) + if (maxX - minX > 0.01 && yTop > innerBottom) { + addShape( + mesh, + baseMaterial, + createRectShape(minX, maxX, Math.max(yBottom, innerBottom), yTop), + frameDepth + 0.001, + ) + } + y -= rowDividerThickness + } + } + + if (sill) { + const sillW = width + sillDepth * 0.4 + const sillZ = frameDepth / 2 + sillDepth / 2 + addBox( + mesh, + baseMaterial, + sillW, + sillThickness, + sillDepth, + 0, + -height / 2 - sillThickness / 2, + sillZ, + ) + } +} + +function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { + const { + width, + height, + frameDepth, + frameThickness, + columnRatios, + rowRatios, + columnDividerThickness, + rowDividerThickness, + sill, + sillDepth, + sillThickness, + } = node + const halfWidth = width / 2 + const bottom = -height / 2 + const top = height / 2 + const archHeight = getClampedArchHeight(width, height, node.archHeight) + const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)) + const innerLeft = -halfWidth + inset + const innerRight = halfWidth - inset + const innerBottom = bottom + inset + const innerTop = top - inset + const innerW = innerRight - innerLeft + const innerH = innerTop - innerBottom + const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset) + const innerSpringY = innerTop - innerArchHeight + + addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth) + + if (innerW > 0.01 && innerH > 0.01) { + const glassDepth = Math.max(0.004, frameDepth * 0.08) + addShape( + mesh, + glassMaterial, + createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight), + glassDepth, + ) + + const numCols = columnRatios.length + const numRows = rowRatios.length + const usableW = innerW - (numCols - 1) * columnDividerThickness + const usableH = innerH - (numRows - 1) * rowDividerThickness + const colSum = columnRatios.reduce((a, b) => a + b, 0) + const rowSum = rowRatios.reduce((a, b) => a + b, 0) + const colWidths = columnRatios.map((r) => (r / colSum) * usableW) + const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) + const innerHalfWidth = innerW / 2 + + let x = innerLeft + for (let c = 0; c < numCols - 1; c++) { + x += colWidths[c]! + const x1 = x + const x2 = x + columnDividerThickness + const dividerTop = Math.min( + getArchBoundaryY(x1, innerHalfWidth, innerSpringY, innerArchHeight), + getArchBoundaryY(x2, innerHalfWidth, innerSpringY, innerArchHeight), + ) + if (dividerTop > innerBottom + 0.01) { + addShape( + mesh, + baseMaterial, + createRectShape(x1, x2, innerBottom, dividerTop), + frameDepth + 0.001, + ) + } + x += columnDividerThickness + } + + let y = innerTop + for (let r = 0; r < numRows - 1; r++) { + y -= rowHeights[r]! + const yTop = y + const yBottom = y - rowDividerThickness + const halfAtTop = getArchedOpeningHalfWidthAtY( + yTop, + innerHalfWidth, + innerSpringY, + innerArchHeight, + ) + const x1 = -halfAtTop + const x2 = halfAtTop + if (x2 - x1 > 0.01 && yTop > innerBottom) { + addShape( + mesh, + baseMaterial, + createRectShape(x1, x2, Math.max(yBottom, innerBottom), yTop), + frameDepth + 0.001, + ) + } + y -= rowDividerThickness + } + } + + if (sill) { + const sillW = width + sillDepth * 0.4 + const sillZ = frameDepth / 2 + sillDepth / 2 + addBox( + mesh, + baseMaterial, + sillW, + sillThickness, + sillDepth, + 0, + -height / 2 - sillThickness / 2, + sillZ, + ) + } +} + function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -85,6 +595,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { sillDepth, sillThickness, openingKind, + openingShape, } = node if (openingKind === 'opening') { @@ -92,6 +603,18 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { return } + if (openingShape === 'arch') { + addArchedWindowVisuals(node, mesh) + syncWindowCutout(node, mesh) + return + } + + if (openingShape === 'rounded') { + addRoundedWindowVisuals(node, mesh) + syncWindowCutout(node, mesh) + return + } + const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness @@ -252,6 +775,40 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { mesh.add(cutout) } cutout.geometry.dispose() - cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + if (node.openingShape === 'arch') { + cutout.geometry = new THREE.ExtrudeGeometry( + createArchShape( + -node.width / 2, + node.width / 2, + -node.height / 2, + node.height / 2, + getClampedArchHeight(node.width, node.height, node.archHeight), + ), + { + depth: 1, + bevelEnabled: false, + curveSegments: 24, + }, + ) + cutout.geometry.translate(0, 0, -0.5) + } else if (node.openingShape === 'rounded') { + cutout.geometry = new THREE.ExtrudeGeometry( + createRoundedShape( + -node.width / 2, + node.width / 2, + -node.height / 2, + node.height / 2, + getWindowRoundedRadii(node, node.width, node.height), + ), + { + depth: 1, + bevelEnabled: false, + curveSegments: 24, + }, + ) + cutout.geometry.translate(0, 0, -0.5) + } else { + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + } cutout.visible = false } From 848529acb0ec8e6cb97bf6e6790b7a2dcc8fb330 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 4 May 2026 16:38:52 +0530 Subject: [PATCH 2/4] Add column presets and procedural shaft controls --- apps/editor/public/icons/column.png | Bin 19623 -> 32380 bytes apps/editor/public/icons/column1.png | Bin 0 -> 19623 bytes packages/core/src/events/bus.ts | 3 + .../hooks/scene-registry/scene-registry.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 16 +- packages/core/src/schema/material.ts | 1 + packages/core/src/schema/nodes/column.ts | 403 +++++ packages/core/src/schema/nodes/level.ts | 2 + packages/core/src/schema/types.ts | 2 + .../editor/floating-action-menu.tsx | 12 +- .../components/editor/selection-manager.tsx | 52 +- .../components/tools/column/column-tool.tsx | 97 ++ .../tools/column/move-column-tool.tsx | 105 ++ .../src/components/tools/item/move-tool.tsx | 3 + .../src/components/tools/tool-manager.tsx | 6 +- .../ui/action-menu/structure-tools.tsx | 1 + .../src/components/ui/panels/column-panel.tsx | 686 +++++++++ .../src/components/ui/panels/node-display.ts | 1 + .../components/ui/panels/panel-manager.tsx | 8 + .../panels/site-panel/column-tree-node.tsx | 77 + .../sidebar/panels/site-panel/tree-node.tsx | 3 + packages/editor/src/lib/material-paint.ts | 21 +- packages/editor/src/store/use-editor.tsx | 3 + .../renderers/column/column-renderer.tsx | 1302 +++++++++++++++++ .../components/renderers/node-renderer.tsx | 2 + .../components/viewer/selection-manager.tsx | 24 +- packages/viewer/src/hooks/use-node-events.ts | 3 + 28 files changed, 2808 insertions(+), 27 deletions(-) create mode 100644 apps/editor/public/icons/column1.png create mode 100644 packages/core/src/schema/nodes/column.ts create mode 100644 packages/editor/src/components/tools/column/column-tool.tsx create mode 100644 packages/editor/src/components/tools/column/move-column-tool.tsx create mode 100644 packages/editor/src/components/ui/panels/column-panel.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx create mode 100644 packages/viewer/src/components/renderers/column/column-renderer.tsx diff --git a/apps/editor/public/icons/column.png b/apps/editor/public/icons/column.png index 8e44f6d8e4c9613c39f32866c98d17d6628f4f6f..0e337b7f1be27db26e46b8e0f1af269b8c612fe0 100644 GIT binary patch literal 32380 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 T71D_0aiwNhtE+S)SK5C7SS>T+ diff --git a/apps/editor/public/icons/column1.png b/apps/editor/public/icons/column1.png new file mode 100644 index 0000000000000000000000000000000000000000..8e44f6d8e4c9613c39f32866c98d17d6628f4f6f GIT binary patch literal 19623 zcmdRV^;eW_*EZcL-2xH{3@zOaf*^u)mvj#`G$M_PbR*r}-QC?eNDR%;e7D2%{t@4- z#V@nwysmxjIQFrFFjW;fYz%S?I5;?L1$pUDaB%RK|NhWW{`+QlZao|h?t{I8w8Uo* zgQE_3Y&bYH1db!aCGyC}LZttG@&ETnZx3#KZLvrGXR7(lBOOYoBjH?5{h~wM%*xWzpCT zmd}T#mJEVAgo-C?n)92VxVX1+s_2B`^lP(J2W>~Id~?C-W*X)eU!c2xPa@dqG-lyA z%G)s1HDa^4x(B`*hJWm;R2G4x@p3unc=T*@bF|3H zv9{q`POFGs#^@4gop9bJR2Rjgz}{(;JhB*ns7pK<#TvrCUKQH5D=RBKLtLPK1_Fkap+!J3B}LO6^A}$A@#8{!1Mi*Cnc})za?dF~Hj} z83lK}sU|3s8~%!Vj!g>A177+*ITc|l%DpoBOh2Ud z5p;i-CX^4fUl-*k!pHT;ll)J3=K|}qK8l9i@cewsn=tE0T(&)r41D3GnufCQA#XzQ zIF~mb-j1mhM5>FdixQb3GU~*Mn5khSSa^FrI282O4pUz3F)Qo5YpBsIN zo*~H39$J!Aw9=-jH1U?Hw!Wig_^*?%Q z-%8*Pw$a2)lld|d?C-QiJ~w$$J@Zr@uV<))CpfJ4q4%nwg{TbRBCkKyQ4=DI>go6# z9bwVfQKg^bm~x)Q`CO;Has(iL)cfU^R)$3y5G~sPJceUGKHhX#CN|_3{7Hb}kB5;K z3uG|74W~9}kIvxha2U0`Ap<)}!Lz)QNr|=BJpUUh;#B%)Z&@8WqPJ{}MAQZ!npoQ4#PE>Bt_-&k$dW%N-PZk`Sr#FYgxE;Fj{GE;AJan}s44lvX zpuD=VEkkCO{&X z?;GQX*-xkJ!e_yh>TtmvQcOEk!(ZBq?$@@b!2}O((`9o%68c(dk<68UCMO}T3$ud& zud3o(Vgq_aczzAiJK^Gri%jq=Gy`$l{oH8ATc4V!|5GRIISNdL+sz)vYTLkmZ_)$? zqc@E+EMn!Y%5wL_Hp7+Xc~pZM2wcz_6b@o$)&n`}PRnwt4NrV+xYw}bojFD4Lo9dc z-oUejY5`vd2i)x<46ga$qjQ%v(3`3|x5B;ZqtSbXylQ>aAAotV3;^VTEB?jQ%_mZ< z$uH2*8zNx-J2n;=K7nqWL{+aBH9lr>bJfaHVIJnHt}WT~6W??zRb2AC>P*{ULxBsb znAJ@x-}<$}?-`5T=Ro}i4q5!4Zz6(o6D|vqHpj=u&Wg812sMb56MpHnHH)~}$zCSk zGD#AE?JL=iG*{1l80;Is;~R-~A1@;Ll@!GzybhLR+6g$@Ww${;dEVJ+d)l)KuKLv| z?F0+5q4leDhwNW^bE3k0oaGs%uXc7am`@_$Ew~?s2GM;Z|95^;<MrS2dX%pm2|9mVJ6*rCh-K8FKYxiMULbcLXo~yqf_+{TrTO~XigdGVcxD68@PDY{QuVHaP zsT0{9PfHyF(jfm};pm%*t#3$R}x?5g@-=Yx9`Ed(=IzdZOS74?6L-lLwu8V zU4H&On#WAi760(;%kDCda7DSh+Wdh7ZBcD74R0(XyHG9d5l;Sy= z?Neo*ZVBT2gUGaZgXN#H#YjJwq-3@&oyVJc7aXhPS*wi9F?8c^DgywIBhH21&(ykM z11x&Qr#Qu`%3>1A^=vMdWxq#s&VBkpkvp*jiJqN%+?9a?T>aD8}hIvGsXjYB{%;&|L+kBhdRqV;Z2aP90LWB zfkJ|2FF4QFN&4$=rZoTxNqZDWv4VVTe=pn43Qt%hcFggWjwiB4Y>e9A7=Nkm;)VF0 zcoV|?%icm=wXrJV7meN7*H&JYe7u^Ig5tbPb3VXk#-8%-?a{+Jzp3RuCsF$O4qR}?% z)XAcS4VMyQ#w?Cxm0;OARU((@%TDNOAk?8rDp@vq{*W_1cr`fYDQ7l(YcG+lB=+o| zz+qsvOY9`1hYLsT2$0rCjF5@FU2M$$Dx5hZ@b9v?aI>#z!2y3~a*8NRh_vYO;E{+` z1Kz2AjcvjgH&l>RbJR7CE} zZodgj*kfn@k^vfhf1P|GMv<@4BXc4zmjV_+?G*VM;Ha@ z{42X7eH^1uQxVFrxyn<_S^aMb`7;jA7Z97SsnGuk*xVzfEPTE6do^bQ2}8pbKNy1T zGfRaSf{Rdt;{T>6b=lKy`4dPd@$ZNby$h$rhRmdrK8msfns%dLrdwqSh~Z)pD^W!% zNk41iimBsmum#h9*lT9#;l|*F9)@67ocMwA^>?n&cP;gxL2XSn;w-fh=qJ7~xiZFq zxY3Klxr@VvQ0Ur7U+<-|Jt(m=WUb%-&_u%ow=xm&f{Y`@yGDh!D8bhu%8w5NhN@KA zMOEp5`Y03Uy}-nlF81z50IMuLA~-<}BNZ{6E56Y9ctiUjDX*Has@DM0D6$%QLPj8M zkBX4csq7Q$c*JxnvE$U%Qs$jBPUM#3IM$zXxDoOFI#e-@;9syQeP*CmV&}CQm>GvZ z2^`Voi}ivw9sjXct$F-eMkha!h&L4yJTO|yG!S<-%|${MH@$lf{IPjLp0e7t4{E1r z*8O#xo#06&UFLPG8(~1)TqKF?>{Ufk`0weG*~AQ_ZxugjnB2CyNu1gd>_w)`nS9o+ z`7NhGUQ?RM{08FCMw#;=XTv9Q8^f1{COjT46#tH_?T6iVoENlGl2Hr<5ZzEav(@?TgX38Buh z>n5cJE%CLL2A5gs;+_!2;zYIDSN=89+WHc;u0iO0!T6G{aXgSZ{_2j&VAk-pTr=vi z^2BA@^k%;&jUmeUA*W6e_ghB$&OA|6ov%XEq)s%|upYD*?HebWotV=zKXlbS?rQbi zIad?LX|ZKP2L)h=9Noeypf4iZwg_)!j|DVNJ#`62Kx9RbDPbZ>2sLx-h z9FvCYPu7I@uH=+p6=`^UPMa8pXc-9=E*l)_lj52-;}>%d=_8ro$x2{l(x@-@TF2dr zA92#hxNNtb%)}r-H_cj^)xNiwv$TD0xx#Tpuo{^n>e$8QRk6l{LAgH_hR59n;D(85Z&mFo4j00U9?n{BJsx4+$GX4HQGkCjM`<>vcp4Qgv>rUPX{ zpCvURU6x}kEtre-UgDu7*V)AA&ctuX55G<0o~3deR5(}Cdb9iI!oe(sZP1ET?4}pr z7~aSF`SXTM&cL_B@|=x7<31F)CrHW!&=5Ie5MGnEEIeVcS{Io3tc5}1=y`Z;@p$ML zWq)h-mnt(wCHXg?O{gDJYoJl$AM_Flz@}R$EzO75+&LKi-?HotdmNnH^NJ61z(9fs%8CX@V|c?nNNt59h|TxySFAZ}?;|}!`uV243&j4V{f&1Q z;`3KBdCUzosNz?jtW@+LE~ihPX{1Gh9ofJi(B6*GHD3hxP!6kAQ1VH};J;PTpMQGg zxys(L`&nTcSqF_qMGWpfdH+wGM~BR(Lv37N5t2z78H&4)c#=pW!KYxcXLK9=@XIn{ z{$@_lw_d){q~%oQoGgeax-nZ0&A5bB4&SoH`(fMzNgORNLByLor`OlJdY#Bi1y`)J z(i2i>^K9^R!rNFi>qiICf2~9Jtn>$C_J{1&v*(I;yv+pWiLW&}>_<(}_}gy z*{rTNlf1~i0f!*8ilv)|sf5QimljGKi^jWL%cYh+ z5a$w>?45FqfT$O$Q-;L_4j%RHwTRm0W0b#Basn@&*_0vde$8_y^Yp=Z61cRP%)I8` zn{?CcD3)ZXrB)W96IXl;_z}s78_t;_6-MJLJHMUh4|jL`@2qN$x4k2&<@_@c?i%NO zlD*G#VqX2zXJ*fK{96@G$d#H4(A=R@xF52-W?j6#d9jZZGkNV=OeJDg2JarwXWVd{ ze9yx-*Ytk!p`nqQE*Q-Vt#d4?{ymRP9P!}TbMYv%wyX^JBka!Lm0c-R-+b4GEY(aTJ)opOuv= z(scRi=rF2tIA6^$Q;R?PJP0@EY2FRp*@cq8XUFWeQos2pcfjz%aSTBXEgQ(Xw#JP& zB$*uRaWHru*o4?zpX8lpigzSYD6~KR-MKGa$&UJ=UMyX%!s8v!wK2^6s-k`A0{p?r zuS_Ds`F>DsS`|Dl(rxl)n#U7-K65JN$WUTOYU1jYyA_3(QzNzp2%#`p8%D9|>5b$6 zQ+UVuWT_TVx%>V=Xi~1BX6UC|##4!Bw9N2U;1A6@gxbZH^hDVO_ZI8aWq1Fm5R_37 zS6)$9ED@N(dld#hV^JQa@n^P@PneD)xw(|EI9WzeFxjTBa@(lH{oyWwF7%Qee}vqi z@af`_{?|hI{`HLGpT~6wpUV^*YZ?O^DQq>x?ACP(lwC9i>(YrMRd5mn`)YN!c@A46 zxRq;LuZ3hY{*H8bh5dsAqze7QCQ{lg;YP3=Vv7R8q1&YOpwlT2llf1jpI<2u%5EfT$=9D$b8eMU!FK~{V2LR>i3tl#Un zQg%1m&pN7lX!w_S&bG?_TCa7OPMLiU`lP6#wH5l9+tcNF ziR`&qwVYlk={9}%_6|~mRyscux#VV{$qSo$YG7o* zDLQ$-?a{aD-p+o4&BWS@Z3}_Gp2VWY*QgX(4m%f_AX&+<{Q?>06c$dzfGs5_W2QE$ zzzCT(r}%BnwUyts1C~@diwx<`n=}`FN@k6!!^N6vmb!!E4M7$^A{aLenD=}YXQg16 z-t4=Laoj(GG`F}LZ9Ryao2xyQjoO5-dzeIyS$jBTNc^D9dnIoh^zL+jxc=5CCcjaO zYa^=v?(Yepq#lMT*{gql_aN7sf4n!id7AmMdJlP|s)G~S*KTmLQPR+ldF;%pjBmsd zHV<~G7t$%xV(+_S{1;C0am|ZkH$Lx5zf4x4l59uwtfF@Fh6#R3td^0P0+6bq z%|wLwYj=d05!Bj`C*F5@FX-XeTu@6KZ^9OxasLf9rdVG_V`5%CSwk}9Q_COgv=JGC z<}D5lA6>AyBe2s>HPxHBjsl$>hT|Im$C|jJ*DALwk3tPB$y1;4H9wSU_3RXS6zyeZ z@P1KBQxsTHGfQ})Fp9COgs=PU=!D)yeI@|YHsF`5;>v3^!^3Z3$?a%x5e#0D>1nNI znTHZi9hTCg>@nxUh9VI%wq3(#Y11LEGxKC=wlT)r*04c*OkFuJ=u~xDi#tt@NYm7m z!PDq$5RZ6OeX%+LZ7TaS*;YGKHUaU~{W6mPr+uRQkT%!sn(IbjWqI+HyH)m|n;|ey z>LZ5=P*CY7BJRqbqT@8I{VY zy^~;N7RLjp6QXpMDMEPEN>$YEKHGdGks=5KO!0B{GTN(H3)E+vS&d4xcV|2@**4p2 z59`SD?jElwa~AiBe1Cme3m>DrqUzNl>G6VHIk-82@%Jox;qOfC6>s>U=E~NUccF#r z@h&UkBo90tO$sgOx*GGD>r!bz?NHzM8GE%J5illV0n&Y)SOT1$O{ZCnvRxq0o5;Lg zBKe)AygeEf6@=<9=$?Nz9pJx73zub?9J7bIcI$ z7w(aS(pw;rgekKwX{)k##iSa4)(bl6{;yxoXC(9K+I-w9J+Nxy0J>=pOcO{L&@~(_ z4y{c-IIb5~`RA3Al?9ve%*?gP@*8L@l||yb9j|85!diJh9`D@>h!;1C&XVj-?d|~!X}6vDA=UYs6#1)>f(sOwjI;H_J_ zwn{pbmD4^$X*iPC(o(VvG@T-|fboUFf@JiFwz6{8Iz$cj?hf7!G=uHIS0t30#YRL;IN_<}aXcx! zxcawv8IyJn_I_d`+nHND-_mRnHrng_sQ=~AiMl+I+#*PTm4vNHQ%aaSc9^ertCssl$gTkdjs7JTakFHU@<*jBZ?7@_%S;N72LM~S^ z`bf^z4V?X~eG)ZjYp6v2Ghhadt2M^P50|HGOw zBc5z&XH%8|Ba%v)@iFsSt$z2N<>1(UhKaQ zj9L|8??qF$30&6My<9nwvYMv~pZwkZkFtwqF*Rl75jQ9H-Gwow zM~)1V59<62xULj;$6F9tJAJ8-Xae#xdsJ3cR#3&z7BicEYtfwi9j9(nY;a4u8DQ7e zIL?(3b9u4PzPJx#c4CxMxz5#oIDnvtwivxqw>fe5D>c*Z+SSb`fr<_MV^-kEFY$;# zu)7mZzkh-ek&nU{EPZN;f(yB_nfz(TB!>i8n%gEpzMFQQ^YFfk|>+xI)959me?hvfrmh?+4QC-+sdF@15BxJK7fInT`Bm9Bmy6 zz)}XKwu8h$!&&^+A^~Sq<>4?7L`aTTjECz@DVAoAG12CSI7v;K>c#CUWtgQP#OM9l zVpjz?)RrUYR&}4znQMuhaV=pyQj*O zYR~0_v3>l)c)Viab1$yhJEw|DZm|~84|6>|9g<0CNApz)aWaG7f|BeH6Q30eQjK}5 zw~O-Zo(ImXf(GJ$;U@ly(TteY!tFDwI_2zo zZ@O}Gy}$y)Q>+Yj>EzjafL93bi0Cy|U`?i>*(Jh+|n+l7q;kDJ;+GMPv?tDps!h z!rt|bOd20dJkuu{c|S{%nO#~e~?WSo@880S?^&efV&4)>I_Q^18(Uz5*GmtKrIrETv!YO(cPH zg=LHFL3-sdcGAIUW}7WHpcs#r(Xm-Va@Qg=HVi7|M!aYkWW`*Ts&705S(jwtZ~Xn% z_pu{gE!ufi3j{s{R7f}?xj+)vJ&fpcqq=LS!w)bI=4u|t zvrrxtu08#uaj&&-0nW@k5Jvpq@+}u#vc6O-+dQQ{T2u}->jUujuHmC~N*q|XBHOAM z0<*x*(CetQwUj%D4pH5-(%~xvShzV;l*1nt_bk0?blqgX8dmG8#GclYq_0Z?b67{a zFr{i$ob!N|*5l_J9gaZ3oty9QC* z(hrsl3&k9$L}XglKGj-;S}B(GS-vC+>j~@RYAP`)5^nPk^kyMY44b=dx^Gv2?RwPo zvrGRHaJzDc2r2GxM_vfI%=BJwY47?TlNTCX1vLzQ1BB7jcwY^F{k@QB<%G^Marbr1 zxKiETUSu@Z=HN#iKE&vYaWvV+{oaO0NK(A;Qq090!I_NiD`D7Wc`^j<+bVcwLwvH( z&8sI0onG1PT5qQ+VX8v;SDu6k-y=^#tDV;LL#b>IGgu98ZfEE($7%{ZOaJGMaPG#| znvo%~j+kWI2lFMq0pkr8qf;wT9sdQUPrYUCZ!dj5)0BR3f(o}S0^t~*PFHwQj+ZZB z8YA195wEvERx1v7n`l3bA+Chz1?z2N)`UC6)fz;X;tSTHd~Fe{#pGhRDdGF-z5hPc zpD5F_mR|{$wW?A#jV3XQVssC>LqUgCJ{ytQa?AFmrOrAl4~?H zqGx~~T7|=#|C^r7+`qJow4l-Xs_OHqVsZfIOB*cO5SavA_-}WI+K_iWfFbK@M8~FH zK*x?`do@}eul8Q6goub88;QHHLl%rLK1}MEHNLXgT&rlyv!=Y?HX?DkOb7ft=ubBV zHH^#}UI2lD4_Ybvztj?@2{MyD&d$m53M_cN&>^_?Ke^tIC}pK)2S)+|4hy`@62A)p zo+uh_%JC0;3VMTTC&*x$rwvsFaUwLetxzU4g3jLJX$;z!P3suMNG!BFOuDQ1m5P^?;y>R3Rw{RFQz z);fKuWmCj>FRD&mFu8m2Vzj4+i;M|`K~`ivaI)-Ge;d2N9Mg^}pG-*gXpkV^sDzlU zk3HgoF!QJ%S>b)SDxYE(8Ox#IQ`iiS-=Jp(tkRgU_CC>@s&MGOigF50{bbZQwtAVy zA};rL07QcgwX}l5M;Pl2}P`{yBsUs&z z(zNm0+OnVC#Guwr7yUQp5xhJ2CzJZal_Pogp3|vYt4^{WG74Kz6sxQDVUC#ZT{GsP z0!(}(?HSdTv6)Bo&c9unVv$_A_HTx(=Ebuc2CtOIYdX4zC64E18+8pG_3(0V?}EDe zl49pIXqaHl3h&~pWqO0xva`JA$`>wr6D?I4S=ZSa-S}k+9rytD8jGantKCAH zYm-6Q_K@BR-n*1wA{)%xcZvq0GV+Upl^TU1pgOneywcw}jk3dt_xsmh)1>^UvWxSR zH@d=I?liJ}7YU8u{y3#1D5zFL++Nx8i-S2;pnc0L%wP?{?JQ<~S=Z)`4|x5~$M?IR zpFy(xOJt1*Hf=YMw|J*B?E9^kUq9~4k4RMCtkeHf6udr}_r|ie)MMV!%;X38%eu+G zj<}U%AjQFG^WN@~>FXX=WvYZEO89I(3Nyx|uEeqXsALzs3$+zkut$ zHIqx(!NR3<>Bx00oKh_>W$UH2^`i$XW;ftuzj0Y2uI;~W83-#S_XL2*&U=_1HV@0K znMg?ynLQ>AkD7q~iq6~Qy8GKYX2LTaRbd7t1?OmeTi%@;|Kr~zHR z6h)^V?-xWl^pM6)6yIg*XWM~$vpA864|!2Aq7A2oDbN`zidzz}aNK%a6-CC0J589XncW&S2*p3^?FtY;H7lW{3hfh*5Ar8odF8K!CBccx6lhac|6&?JYSNyOvqWAhu#?UEB(?~a) zorK%uLUwQoZ|_h80}4fWV`dEw01^pe*2u)VFCN{6$Q+bI-vf; zw+`0_=HD{*&TZnP*>CQa+lVK6C?a+}Jw`_AY*##ZRiAZ-PLeDHW)oZCjx-$VrwSj1 z=2&3I9gWGmOQ175&V`(xQ72~8xL@hzWXd|0Ne{lgicA)tv2?c*_**Juwu`$Uj9ZV| z88NR9fl2-P`f5yqfI0e5*DLa;%_zY@7kHBI1S#UZT;*86t0Xa+RPuR0ni5craY}*# z0Qz=A)9%>(B~~}iMI5@23^z{oo6ystO9JYBCRxoZt(Wg=2-fVKN6@Jy7dwxggesmz z8y4C9T}Is{U0UT~BuUO~7#WApuJs!^@OF(AK9cA|mYMdULW!>793258?@2K;6&~2d9jh!<;*$@U!ox7wm3utePw86}Kg*%!E>_0@ie)4_em_3Pb_P{l( zIH8}cG@?e^A}lMPU8*Jkzj7hT0{g-8Y_N=slX|?{;JIu8g?=RRyyzk(kDiNbLHJh` zbM~CC&CO$zed4CI^@c_ni1(=UGZv3o-*0!b$F7KBx>`bn!c~kTt9VbO6r8*JdPgox zkN;Y+rd`2@e@(q&lPw1kWD!{93U{4DctFOQT7?nz$5aRhgg=Uj&ue&qkI#bDu5PZ-$+ zhc_P0O^yX5U0JHF|GKOoFZLZx$Vw2HBPgbLtwIFSEd~{X7Y$kYAC6Ls&~bnw$5vF$*(d8J zw%@L+URxsxH_+U_4DhzKh`&2ykDt0HB1&y=-r49_LqMDwl+am*y~OPjQNf*|ll*ye zCurq%KYV=gSY~cGC;`SY03KDMqhz2zSFGC&_(}+XyqimCRdMX=?v<6?;>O;v$G={2 zoi&y@F{fKRfuzd>Y}gPNSR{C4e{L-Q8(;4@eEoCd6W<5QkI0|C$55Zdq-sBlq94dd zN&?7p_CaBSeI^qQT5f>G*J#h2)`-gv8(#6Sv{|np7SCGA{KF-NpUthL#I~EI3_YqG z|9FNCH!etfzsB;Bd z6y=VgG41Cu^5Ud)B3u6hbi%!c)==nJB=B@|!j*A$E9dN{qkS2uw~3l@GdL%__pgauG)m?z8Rrxa6_J|x{?l*jkg zmHU9gvo}b&*WM87xR8YhFol0My~NWAyP#~6scqH;Q7&=Ui|~b+XHt~I8mIoqa~*v0 z3nV*xYj?l1I3E}0-Z9@f)8qG0gEln66WEG8$knDRf|4 zsM%=L71`9L)524<{e(XAQIaHIg+uCD0+s;v%w~;j*=$>}W~Op$3LWF@0NBR0gQ03q z3b@7gmkH<%q2hkQn3c`PR8^xp9_LA4Y%gKHh@P#%en{tD{ESv`ABjA=<)B$Jd=z+) z)l6F|@W3E>e*hk`bS)2DKc|EM&LLg%Awf{C-jIKzgIV>zxBdORRsu8FOIOq%NJMn1 zNa2&iGbHq5l_3;P*U7jcQ=O4k_L=@qPRi8HN9kOm?8tDgJ*tWpMO!|()DoCUr zc;PtYD|Y~@R4nH~wy99OyPwk^EAz^mP8qhHWQ7WJo*_LIsVEk0<22 zcb)_=8YaoQb&@4yulq_CZQBN=w(fTljyE1r5s)L^>h&ZT=Fm1Tm)+h>Uyc{gkPSr;4e_-e-a z2PEb1$XB#ANAPUj^$f6s@Z14dBof;F)$aTU8Pc(#^v2sPkG=4h1HjVUC^wxR?~D}d z6N>X#8z1?`xj%Oq?@Dt<-syDb&KklN0pR00+=fYQLW9*LNA~WBS+m2d_jq_Qx^TTPv>yR8`#>>!V9?3p9t<`*WYBNr}X5OFC15Ea*Jw~_j7 zPXEZY)tpR!de+){pMviU9-7UNKDoP0^cm)_3Gq7(Y2)to!se6B6?Z=1pQSv-Nlmtx zhaufDiPL)-7jJH zZ~yg%(^dNz7aFjWu1Owkp>Im5W6GYdXr)RKusU7X3D=x}a2Ng3L;U_nYF#yO>l+8n z<`)0BiqI#Bct#w$^2c$>1nVQ8$I>vVB{k>;ZaTU(X}nw`BBYbamG$FRxzkIOiKCN6 z@H#E0)DWpRm#Ta`~7?=h<%Z;r$2>icg=Vx+BYLjO;;!uESl+j znfaag<1~7#0@zag5wotJ-QS z*DXNAe3V}vOzC)U;G$UYb*{Cd#c_sjmF}Ux0Z3iRx&{jwTgmr4I=`nP*Jvo!*6d5B zTw}RP3*XwN=t&;id${5vF05SG(qZcda8Ha);viUgKXnNsuxr1BTl?L;p9#JK0rR^U zFHD__0U+;(WfF6-^lps)t$`m!mBDxTaTF5vo>M<*tYCPr9d@NT1VO9i>ABC(ggra*dyFoidh)Mi8f) z_|NH+N4X4MSwj?m`{}LoWR<1)2)kCIZX?v-xM7s{VT5_oT=Bp}es~jaJUY9ExZDGE z>(4R(2{&Cg{f^c-X+h)DCD)25nI}ILv5SfOl0s!nFoyt|cwXfhm(ha%pUAI?KMGAu zbppx&KB@7%ebhXP_(as_Z3-2$IxKzuS}SQ8Lb?-k5}ABol}%iYAL~!S<`mEANw9&b z42h%7Rb>@$R zt0KUouq#rSpR7WFZ4LRPU42S#q@AE8Mh(G z(5R~kUm6KeUFU{xgt2tpeP!+ct?Ly?;0$-645RfMM@nwZNBez6X$SjPIOqDFMp?si zjs@SBOfWIbo#N;K`B2p|22SBx>fgU~a6R~wQJN0{mJ&zMbnhY!{ozmZfpH-xV_rHb z3EsTuZhQ@SCK*V!`l|h>wpWJ1+J84F)~;bueNDBlOI)Gxk^4_biDuKxDE$g=`KRI{ z@dyp|>LI4tE*i^uj|J}h#uX9Emdln*o3Vhdh?$PH@6<1~+2Omfo%D7p^`?p_3U#3{ zJ9t7oi%QeZ;k2p7>d-OEQnEsB z6Hw$Xlkgrj_uHyWGgCEsx@kVVISx@~tYK(tavu>|Z=Uul*6S1r)} z_kRbEM|X0%Tn%EZ?qtuO*p`zX?HBn+Vai9OA61Eo?Wh zVCnp7;L+Q=@PjFEfiQ#}`rR(Bz59wB=AG%3<{2e2r#?EZ5q@bT@eZp$jGBc~E=L@m z3OoO7M{a~-{|xcu@MVc$c^NV#I;ZB;fcXaTo_s+VE{lSc+o+lP zxEJo|es2fhlD2edOU1hIkVa8lE;IW01}0^FRlPEt643q#aZHa?hR?G0y>$hJisfR~ z=9KCtc%oBEP`BcGekoIEU8oI=8!`3V1gK2PE|C~Bsle!qJywxwbQ>iXuKv~cTNzfPRD2qQ4}N%tg#$ojONUN*La8fF>f22; zIH!e^|1ESKv8i{?%0$eqL*pxj4j7F~E;>d*jxB_aoi3frL8MmppiTj#>h|^S1qv{j zyk`BOA{CYb8)VRz)yn9=su{KL*7UO_JXM%E{4C4?e@1mDKnoFY`Nb_*j7ukY*5+vr zX!Y9nlK4-V>5rwOup%zlyQvp>B;--#G+jTi(xp|Z%;wVP_~+wamws9h#W8wj0_wC* zWDnoYtGYVWUi`AvOo$k)QQIZFX3wPMGADeF3B1L9_~hpEZ{ zV{W3Fw-%KRg&%7w&T5e#@?!5?C%03#Mijxr+!B`dZ^{ohdLdkEtE;&*4DP%d?}j<6sA*9M)X+<%Tccfi-HrA8pH{raE5&T) zmu7Km9hD5Fj+erXD8~OcZcmkESCdX)Oyga|$TP=Q3>H&1Pu(EzKc1%xq7aH1?W^Nec;VL|PVC z?~S$$L^-GGyP}NKNGz%H>%)o`DGiYDAY5Qe{p5RtrE*V<{rm+gTm+>1#(U(`ej302 zDl@=~6eo_?z)%l$W$3+K~-(TCYuf)y@O-^kfw6ap_ zu#;GghzobTIvO|+Wm}t-S70Q@Rw=zO^n5x8?2HeviY3)M)Ooq<3>Aw$@V7u)y}; zny@oKmwZn(s_a47zFde>u3HsaG0D@aullT@_22%wePQ(@2k~%gXa}Z;Q+%;%b5hna zWu5~bli0iVm^{$nW^R&=WV@fQ#Y+4h6Z|~IUq&&$kB84AwT>UzWPZp{_byZq{}s*h z>`21KIXHQ1+Yq@#Gt=)w{_4q}Rn)8N)4&wLYQd_Xz;dMsp1F<+RP>p|8%$t5uhxHd z0^Cb=Iq~-VxWrSdRnTNrTaaSXX@5`zz;L%Tlu(T%e>H;=HiMLXETbAu*Bg>j*7QlC z#x+Y*sj!LJ>M``KvUQYB3iq%evV#-VQHY`=-l@faO@K0JL>9 zVLjtkSwA#Ej=q#6pZVQ>cok+zxm3y5JkO}w8b!Fe*(Fh`hK~- zS9}g(eZ*1NQ?@SW22o*V!6`gLwzAzB54V*{i$-eF62Y0uYM&>F%=t)T#xbRz##|A3_6pa3y8bpAH%VY6NH3)1 zwzj5w3icn^(T&x<8$rBNyOH8FNM{^k*m-`h6|}zQbJ0~ikq5gcMM=p-^;V-#FLCvd zO|KsR-d4X!WEyk9*LZ0;MJ^Ph&9vME)mKnY&8Rfwami;suC&BfNufdVhpI^fZEk#1 zU&q7JqyJsBz98xu)d&F&DgNg+X$VVe#3O?Lvt_HYXJFItSh zHy@f(b*b4qw+PgZa*RkyuW$4`z2GI13^~01UpLp?&W6Ip6Osz46(uoB(i*q7W+_rL zG>=)XYKaB*xh!I3%)1s&vE!tAOb;Y=CRP3TQ?N!@FxppHpg3|KxdOyW`KEQdN zbAIFebI##GlKhGvEH^HexGl3GISw=Uix($jHL*4r`>cOFD_Q+^!}>aK#h6j#`js7A zhw_C)MU@&0^nOYkqFp~Xt+PM;(JY^8O_8{q0o#WW7;cpDtBP6FlZT@TGr~v9Z1qF4 zX18q5hz4QDh^bMOX>avcRePuryQJMRldV{5|StWt70iWX$C7zIE884FCG%BT8l!Yr@tL7wMOe4KA{ zr%ngM`~R0OzkQ@Jtnd51@+KaM$Bso|gBNi!r_IOV)EQI{OtsEkP?@)p2m{*D6EYKTO*RCn;xpT&8!7Vpd*V zj(Lot9gNd2nYu4^mgq{0r-4!4`d*AUrmFhB;}0`fg10#Fy^&bsmO$1d6a~1VFgp3| znQtHaqedZRSx)!0JN;N50brxtyp}nOM|$qPGt;oks-hnQU{Z**szOUI^^l1UL=8lH zbrnB&KT}?9%QLdBH$m7^WG~iCS2qou9@iTQtQQ;|-*9l^3^K~Cl~soKcc^?#%#0A* z|B}gOq>jSQEip;B8QE;t+H~q!B0%kf(4VP4APTkhkr$!L*_S(aY(1#S@ zxn5>TWlORuo4W@MKtbdO&!Pn9pa+C+L-xP3TC5{71~Si_@U%9=5p72OAxV+0;Mp#S z>()iNGm+QH{@h?VGwb%N1?X6!W8qcDmxd?E4y{2T>x(XbMFl? z%K36iA+>`Xx!Vj6HFx@&{G3Mr#osYCm9A&tERz<0lau?!b$uO~6N`j!C7K4ci?j*o z!ODX{;gBg0{#=Fhe0mz&IF`lH}z4~6obs@ka*Awn>%_8C#;~D1WHrRt@ z8{Duv7aqD@+nV4%ek%IB$%!G)v-)BLor8s&yFM-b7>MUI=5x3liDEN$ zM0pX$4w9})6q)n#%uc!Bq|&+~x%bMJrZ7Q%UH=m%@(9YA? zDECJHd^>ExG%h{L1DNNtpz!fT(Za05CO~};5u&`0)_#BVgOcGamIdjoY^>xhcZsYu1-if=qa_9 z{`fG8lc5L4-Pe**E-*OkV69dhR@wvYj|Yqm`5uM+sAZ}Fk)Gd5FB8-Dv<<`gO5&gl zA{5}pM>0N*bM^3b^9`d^C$e~?4Na~eOsq+X9DUeAp01|ew)h$g0v-L|`oE7E^R9;Q T71D_0aiwNhtE+S)SK5C7SS>T+ literal 0 HcmV?d00001 diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7530b30ec..8a5c0bf6f 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -4,6 +4,7 @@ import type { Object3D } from 'three' import type { BuildingNode, CeilingNode, + ColumnNode, DoorNode, FenceNode, GuideNode, @@ -57,6 +58,7 @@ export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent +export type ColumnEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent export type StairEvent = NodeEvent @@ -169,6 +171,7 @@ type EditorEvents = GridEvents & NodeEvents<'slab', SlabEvent> & NodeEvents<'spawn', SpawnEvent> & NodeEvents<'ceiling', CeilingEvent> & + NodeEvents<'column', ColumnEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'stair', StairEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index ec727481e..62efe95d5 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -13,6 +13,7 @@ export const sceneRegistry = { site: new Set(), building: new Set(), ceiling: new Set(), + column: new Set(), level: new Set(), wall: new Set(), fence: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59e84f2bc..50dad3366 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export type { CameraControlEvent, CameraControlFitSceneEvent, CeilingEvent, + ColumnEvent, DoorEvent, EventSuffix, FenceEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index cfb464c56..ed182320a 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -26,6 +26,20 @@ export { } from './material' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' +export { + COLUMN_PRESETS, + ColumnBaseStyle, + ColumnCapitalStyle, + ColumnCarvingPlacement, + ColumnCrossSection, + ColumnNode, + ColumnPanelShape, + type ColumnPresetId, + ColumnRingPlacement, + ColumnShaftDetail, + ColumnShaftProfile, + ColumnStyle, +} from './nodes/column' export { DoorNode, DoorSegment } from './nodes/door' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' @@ -50,8 +64,8 @@ export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' -export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { SpawnNode } from './nodes/spawn' +export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { getEffectiveStairSurfaceMaterial, StairNode, diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 846140ef0..e2e76ec59 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -46,6 +46,7 @@ export const MaterialTarget = z.enum([ 'stair', 'stair-segment', 'fence', + 'column', 'slab', 'ceiling', 'door', diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts new file mode 100644 index 000000000..d19f725d5 --- /dev/null +++ b/packages/core/src/schema/nodes/column.ts @@ -0,0 +1,403 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ColumnStyle = z.enum([ + 'plain', + 'faceted', + 'fluted', + 'lathe-turned', + 'dravidian-carved', + 'cluster', +]) + +export const ColumnCrossSection = z.enum([ + 'round', + 'square', + 'rectangular', + 'octagonal', + 'sixteen-sided', +]) + +export const ColumnShaftProfile = z.enum(['straight', 'tapered', 'bulged', 'baluster', 'hourglass']) + +export const ColumnShaftDetail = z.enum(['none', 'fluted', 'spiral', 'panelled', 'lathe-turned']) + +export const ColumnPanelShape = z.enum(['rectangle', 'arched', 'diamond']) + +export const ColumnBaseStyle = z.enum([ + 'none', + 'simple-square', + 'round-rings', + 'square-plinth', + 'stepped-square', + 'lotus', + 'ribbed-lotus', + 'panelled-pedestal', +]) + +export const ColumnCapitalStyle = z.enum([ + 'none', + 'simple', + 'simple-slab', + 'rounded', + 'stepped', + 'doric', + 'volute', + 'ionic-volute', + 'leaf-carved', + 'corinthian-leaf', + 'south-indian-bracket', + 'wood-bracket', +]) + +export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom']) + +export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all']) + +export type ColumnStyle = z.infer +export type ColumnCrossSection = z.infer +export type ColumnShaftProfile = z.infer +export type ColumnShaftDetail = z.infer +export type ColumnPanelShape = z.infer +export type ColumnBaseStyle = z.infer +export type ColumnCapitalStyle = z.infer +export type ColumnRingPlacement = z.infer +export type ColumnCarvingPlacement = z.infer + +export const ColumnNode = BaseNode.extend({ + id: objectId('column'), + type: nodeType('column'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + style: ColumnStyle.default('plain'), + crossSection: ColumnCrossSection.default('round'), + height: z.number().positive().default(2.8), + radius: z.number().positive().default(0.22), + width: z.number().positive().default(0.44), + depth: z.number().positive().default(0.44), + edgeSoftness: z.number().min(0).max(0.12).default(0.025), + baseHeight: z.number().min(0).default(0.18), + capitalHeight: z.number().min(0).default(0.18), + shaftProfile: ColumnShaftProfile.default('straight'), + shaftTaper: z.number().min(0).max(0.85).default(0), + shaftBulge: z.number().min(-0.5).max(0.8).default(0), + 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), + shaftCornerRadius: z.number().min(0).max(0.3).default(0.035), + shaftDetail: ColumnShaftDetail.default('none'), + baseStyle: ColumnBaseStyle.default('round-rings'), + baseWidthScale: z.number().min(0.4).max(3).default(1.24), + baseDepthScale: z.number().min(0.4).max(3).default(1.24), + baseTierCount: z.number().int().min(1).max(8).default(3), + baseStepSpread: z.number().min(0).max(1).default(0.42), + basePlinthHeightRatio: z.number().min(0.2).max(0.7).default(0.44), + baseRoundBandScale: z.number().min(0.5).max(1.2).default(0.92), + baseNeckScale: z.number().min(0.35).max(1).default(0.72), + baseRoundBandCount: z.number().int().min(0).max(16).default(3), + baseRibCount: z.number().int().min(0).max(48).default(0), + baseCarvingLevel: z.number().int().min(0).max(4).default(0), + basePanelInset: z.number().min(0).max(0.1).default(0.02), + capitalStyle: ColumnCapitalStyle.default('simple'), + capitalWidthScale: z.number().min(0.4).max(3).default(1.46), + capitalDepthScale: z.number().min(0.4).max(3).default(1.46), + capitalTierCount: z.number().int().min(1).max(8).default(3), + capitalStepSpread: z.number().min(0).max(1).default(0.42), + capitalBandCount: z.number().int().min(0).max(16).default(2), + voluteSize: z.number().min(0.02).max(0.3).default(0.08), + voluteCount: z.number().int().min(0).max(8).default(4), + leafCount: z.number().int().min(0).max(48).default(18), + leafRows: z.number().int().min(0).max(4).default(2), + bracketDepth: z.number().min(0).max(1.5).default(0.35), + bracketTierCount: z.number().int().min(0).max(8).default(3), + pendantCount: z.number().int().min(0).max(16).default(0), + capitalCarvingLevel: z.number().int().min(0).max(4).default(0), + ringCount: z.number().int().min(0).max(16).default(0), + ringPlacement: ColumnRingPlacement.default('ends'), + ringThickness: z.number().min(0.01).max(0.14).default(0.055), + ringSpread: z.number().min(0.04).max(0.45).default(0.16), + fluteCount: z.number().int().min(0).max(32).default(0), + fluteDepth: z.number().min(0.005).max(0.08).default(0.02), + fluteWidth: z.number().min(0.005).max(0.1).default(0.02), + spiralTwist: z.number().min(-3).max(3).default(0), + spiralRibCount: z.number().int().min(0).max(32).default(0), + panelCount: z.number().int().min(0).max(24).default(0), + panelInsetDepth: z.number().min(0).max(0.1).default(0.02), + panelShape: ColumnPanelShape.default('rectangle'), + latheRingCount: z.number().int().min(0).max(32).default(0), + latheRingSpacing: ColumnRingPlacement.default('ends'), + carvingLevel: z.number().int().min(0).max(4).default(0), + carvingPlacement: ColumnCarvingPlacement.default('capital'), + lowerBandEnabled: z.boolean().default(false), + lowerBandHeight: z.number().min(0).max(1).default(0.24), + lowerBandCarvingLevel: z.number().int().min(0).max(4).default(0), + dentilCount: z.number().int().min(0).max(48).default(0), + beadCount: z.number().int().min(0).max(64).default(0), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe(dedent` + Column node - used to represent structural or decorative pillars/columns. + - style: visual approach such as plain, lathe-turned, carved, or cluster + - crossSection: plan shape used by the procedural renderer + - 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 + - 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 + - ring/flute/spiral/panel/lathe/carving fields: procedural detail controls +`) + +export const COLUMN_PRESETS = { + basicPillar: { + label: 'Straight Round', + style: 'plain', + crossSection: 'round', + height: 2.9, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 0.72, + shaftEndScale: 0.72, + shaftSegmentCount: 1, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.26, + baseDepthScale: 1.26, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.22, + capitalDepthScale: 1.22, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + squarePillar: { + label: 'Square Block', + style: 'faceted', + crossSection: 'square', + height: 2.9, + radius: 0.24, + width: 0.48, + depth: 0.48, + edgeSoftness: 0.035, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 0.72, + shaftEndScale: 0.72, + shaftSegmentCount: 1, + shaftCornerRadius: 0.045, + shaftDetail: 'none', + baseStyle: 'simple-square', + baseWidthScale: 1.18, + baseDepthScale: 1.18, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.18, + capitalDepthScale: 1.18, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + taperedPillar: { + label: 'Tapered Round', + style: 'plain', + crossSection: 'round', + height: 3, + radius: 0.23, + width: 0.46, + depth: 0.46, + edgeSoftness: 0.025, + baseHeight: 0.23, + capitalHeight: 0.2, + shaftProfile: 'tapered', + shaftTaper: 0.14, + shaftBulge: 0, + shaftStartScale: 0.82, + shaftEndScale: 0.72, + shaftSegmentCount: 32, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.26, + baseDepthScale: 1.26, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.22, + capitalDepthScale: 1.22, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + bulgedPillar: { + label: 'Soft Bulged', + style: 'plain', + crossSection: 'round', + height: 2.9, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'bulged', + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.68, + shaftEndScale: 0.68, + shaftSegmentCount: 32, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.24, + baseDepthScale: 1.24, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.2, + capitalDepthScale: 1.2, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + hourglassPillar: { + label: 'Hourglass', + style: 'plain', + crossSection: 'round', + height: 2.9, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'hourglass', + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.84, + shaftEndScale: 0.84, + shaftSegmentCount: 32, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.24, + baseDepthScale: 1.24, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.2, + capitalDepthScale: 1.2, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, +} as const satisfies Record>> + +export type ColumnPresetId = keyof typeof COLUMN_PRESETS + +export type ColumnNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index c1d7f371d..0b24763a0 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -2,6 +2,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' +import { ColumnNode } from './column' import { FenceNode } from './fence' import { GuideNode } from './guide' import { ItemNode } from './item' @@ -21,6 +22,7 @@ export const LevelNode = BaseNode.extend({ z.union([ WallNode.shape.id, FenceNode.shape.id, + ColumnNode.shape.id, ItemNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 00e07fa19..a4977b5d4 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,6 +1,7 @@ import z from 'zod' import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' +import { ColumnNode } from './nodes/column' import { DoorNode } from './nodes/door' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' @@ -22,6 +23,7 @@ export const AnyNode = z.discriminatedUnion('type', [ SiteNode, BuildingNode, LevelNode, + ColumnNode, WallNode, FenceNode, ItemNode, diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fec5d288e..6aa84cf04 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -25,8 +25,8 @@ import { Move } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { duplicateRoofSubtree } from '../../lib/roof-duplication' -import { duplicateStairSubtree } from '../../lib/stair-duplication' import { sfxEmitter } from '../../lib/sfx-bus' +import { duplicateStairSubtree } from '../../lib/stair-duplication' import useEditor from '../../store/use-editor' import { NodeActionMenu } from './node-action-menu' @@ -40,6 +40,7 @@ const ALLOWED_TYPES = [ 'stair-segment', 'wall', 'fence', + 'column', 'slab', 'ceiling', 'spawn', @@ -147,10 +148,7 @@ export function FloatingActionMenu() { node.type === 'wall' ? obj.localToWorld( new THREE.Vector3( - Math.hypot( - segment.end[0] - segment.start[0], - segment.end[1] - segment.start[1], - ), + Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]), 0, 0, ), @@ -186,6 +184,7 @@ export function FloatingActionMenu() { node.type === 'door' || node.type === 'wall' || node.type === 'fence' || + node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'spawn' || @@ -335,7 +334,7 @@ export function FloatingActionMenu() { } else if (duplicate.type === 'stair') { setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) } - if (duplicate.type !== 'stair' && duplicate.type !== 'roof') { + if (duplicate.type !== 'stair') { setSelection({ selectedIds: [] }) } } @@ -426,6 +425,7 @@ export function FloatingActionMenu() { onDuplicate={ node && node.type !== 'spawn' && + node.type !== 'column' && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type) ? handleDuplicate diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index b42ee1c8e..71f278cf1 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + type ColumnNode, emitter, type FenceNode, getMaterialPresetByRef, @@ -65,6 +66,7 @@ type SelectableNodeType = | 'wall' | 'fence' | 'item' + | 'column' | 'building' | 'zone' | 'slab' @@ -333,7 +335,7 @@ function applyStairPaintPreview( } function applySingleSurfacePaintPreview( - node: FenceNode | SlabNode | CeilingNode, + node: FenceNode | ColumnNode | SlabNode | CeilingNode, material: ActivePaintMaterial, ): PaintPreviewCleanup | null { if (node.type === 'ceiling') { @@ -377,12 +379,32 @@ function applySingleSurfacePaintPreview( } } - const mesh = getRegisteredMesh(node.id) - if (!mesh) return null + const registeredObject = getRegisteredNodeObject(node.id) + const mesh = + registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null const previewMaterial = getSingleSurfacePreviewMaterial(material) if (!previewMaterial) return null + if (node.type === 'column') { + if (!registeredObject) return null + const restores: PaintPreviewCleanup[] = [] + + registeredObject.traverse((object) => { + if (!(object as Mesh).isMesh) return + restores.push(previewMeshMaterial(object as Mesh, previewMaterial)) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } + } + + if (!mesh) return null + if (node.type === 'slab') { const slabMaterial = previewMaterial.clone() applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset)) @@ -542,6 +564,7 @@ const SELECTION_STRATEGIES: Record = { 'wall', 'fence', 'item', + 'column', 'zone', 'slab', 'ceiling', @@ -595,6 +618,7 @@ const SELECTION_STRATEGIES: Record = { if ( node.type === 'wall' || node.type === 'fence' || + node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -658,6 +682,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { if ( node.type === 'wall' || node.type === 'fence' || + node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -857,7 +882,12 @@ export const SelectionManager = () => { } } - if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') { + if ( + node.type === 'fence' || + node.type === 'column' || + node.type === 'slab' || + node.type === 'ceiling' + ) { const compatible = hasActivePaintMaterial(activePaintMaterial) return { @@ -870,17 +900,16 @@ export const SelectionManager = () => { .getState() .updateNode( node.id as AnyNodeId, - buildSingleSurfaceMaterialPatch( - activePaintMaterial.material, - activePaintMaterial.materialPreset, - ), + buildSingleSurfaceMaterialPatch< + FenceNode | ColumnNode | SlabNode | CeilingNode + >(activePaintMaterial.material, activePaintMaterial.materialPreset), ) } : null, preview: compatible ? () => applySingleSurfacePaintPreview( - node as FenceNode | SlabNode | CeilingNode, + node as FenceNode | ColumnNode | SlabNode | CeilingNode, activePaintMaterial, ) : () => previewCursor('not-allowed'), @@ -963,6 +992,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'slab', 'ceiling', 'roof', @@ -1131,6 +1161,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'building', 'zone', 'slab', @@ -1227,6 +1258,7 @@ export const SelectionManager = () => { } else if ( node.type === 'wall' || node.type === 'fence' || + node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -1279,6 +1311,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'building', 'slab', 'ceiling', @@ -1352,6 +1385,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'slab', 'ceiling', 'roof', diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx new file mode 100644 index 000000000..9a239f104 --- /dev/null +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -0,0 +1,97 @@ +import '../../../three-types' + +import { + type AnyNode, + COLUMN_PRESETS, + ColumnNode, + type ColumnPresetId, + emitter, + type GridEvent, + type LevelNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import type { Group } from 'three' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' + +const COLUMN_ICON = ( + // eslint-disable-next-line @next/next/no-img-element + Column +) + +const roundToHalf = (value: number) => Math.round(value * 2) / 2 +const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId + +function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) { + const { label, ...preset } = COLUMN_PRESETS[presetId] + return ColumnNode.parse({ + name: label, + position, + rotation: 0, + ...preset, + }) +} + +type ColumnToolProps = { + currentLevelId: LevelNode['id'] | null +} + +export const ColumnTool: React.FC = ({ currentLevelId }) => { + const [, setCursorPosition] = useState<[number, number, number] | null>(null) + const cursorRef = useRef(null) + + useEffect(() => { + if (!currentLevelId) return + + const onGridMove = (event: GridEvent) => { + const nextPosition: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + 0, + roundToHalf(event.localPosition[2]), + ] + setCursorPosition(nextPosition) + cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2]) + } + + const onGridClick = (event: GridEvent) => { + const position: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + 0, + roundToHalf(event.localPosition[2]), + ] + const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) + useScene.getState().createNode(column, currentLevelId) + useViewer.getState().setSelection({ selectedIds: [column.id as AnyNode['id']] }) + sfxEmitter.emit('sfx:structure-build') + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + } + }, [currentLevelId]) + + if (!currentLevelId) return null + + return ( + + ) +} diff --git a/packages/editor/src/components/tools/column/move-column-tool.tsx b/packages/editor/src/components/tools/column/move-column-tool.tsx new file mode 100644 index 000000000..ae02e102b --- /dev/null +++ b/packages/editor/src/components/tools/column/move-column-tool.tsx @@ -0,0 +1,105 @@ +import '../../../three-types' + +import { + type AnyNodeId, + ColumnNode, + type ColumnNode as ColumnNodeType, + emitter, + type GridEvent, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useCallback, useEffect, useState } from 'react' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' + +const roundToHalf = (value: number) => Math.round(value * 2) / 2 + +export function MoveColumnTool({ node }: { node: ColumnNodeType }) { + const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + useEffect(() => { + useScene.temporal.getState().pause() + let committed = false + + const applyPreview = (position: [number, number, number]) => { + setPreviewPosition(position) + useLiveTransforms.getState().set(node.id, { + position, + rotation: node.rotation, + }) + sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2]) + } + + const onGridMove = (event: GridEvent) => { + applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]) + } + + const onGridClick = (event: GridEvent) => { + const position: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + 0, + roundToHalf(event.localPosition[2]), + ] + const nodeId = (node as { id?: ColumnNodeType['id'] }).id + + if (nodeId && useScene.getState().nodes[nodeId]) { + committed = true + useLiveTransforms.getState().clear(nodeId) + useScene.temporal.getState().resume() + useScene.getState().updateNode(nodeId, { position }) + } else if (node.parentId) { + const column = ColumnNode.parse({ + ...node, + id: undefined, + metadata: {}, + position, + }) + committed = true + useScene.temporal.getState().resume() + useScene.getState().createNode(column, node.parentId as AnyNodeId) + } + + useLiveTransforms.getState().clear(node.id) + sfxEmitter.emit('sfx:item-place') + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + useLiveTransforms.getState().clear(node.id) + sceneRegistry.nodes + .get(node.id) + ?.position.set(node.position[0], node.position[1], node.position[2]) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + useLiveTransforms.getState().clear(node.id) + if (!committed) { + sceneRegistry.nodes + .get(node.id) + ?.position.set(node.position[0], node.position[1], node.position[2]) + useScene.temporal.getState().resume() + } + } + }, [exitMoveMode, node]) + + return +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index b6c1397aa..8556d4e75 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,6 +1,7 @@ import type { BuildingNode, CeilingNode, + ColumnNode, DoorNode, FenceNode, ItemNode, @@ -18,6 +19,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { MoveBuildingContent } from '../building/move-building-tool' import { MoveCeilingTool } from '../ceiling/move-ceiling-tool' +import { MoveColumnTool } from '../column/move-column-tool' import { MoveDoorTool } from '../door/move-door-tool' import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveRoofTool } from '../roof/move-roof-tool' @@ -100,6 +102,7 @@ export const MoveTool: React.FC<{ if (movingNode.type === 'window') return if (movingNode.type === 'fence') return if (movingNode.type === 'ceiling') return + if (movingNode.type === 'column') return if (movingNode.type === 'slab') return if (movingNode.type === 'wall') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 0938a254e..a0205ae5a 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -10,6 +10,7 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor' import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' +import { ColumnTool } from './column/column-tool' import { DoorTool } from './door/door-tool' import { CurveFenceTool } from './fence/curve-fence-tool' import { FenceTool } from './fence/fence-tool' @@ -164,7 +165,10 @@ export const ToolManager: React.FC = () => { {!movingNode && showBuildTool && tool === 'spawn' && ( )} - {!movingNode && BuildToolComponent && } + {!movingNode && showBuildTool && tool === 'column' && ( + + )} + {!movingNode && BuildToolComponent && tool !== 'column' && } ) diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 219378852..cc060af61 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -24,6 +24,7 @@ export const tools: ToolConfig[] = [ // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' }, { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, + { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, diff --git a/packages/editor/src/components/ui/panels/column-panel.tsx b/packages/editor/src/components/ui/panels/column-panel.tsx new file mode 100644 index 000000000..e4ae296a3 --- /dev/null +++ b/packages/editor/src/components/ui/panels/column-panel.tsx @@ -0,0 +1,686 @@ +'use client' + +import { + type AnyNode, + COLUMN_PRESETS, + type ColumnNode, + type ColumnPresetId, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Move, Trash2 } from 'lucide-react' +import { useCallback } from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { ActionButton, ActionGroup } from '../controls/action-button' +import { PanelSection } from '../controls/panel-section' +import { SliderControl } from '../controls/slider-control' +import { PanelWrapper } from './panel-wrapper' + +const SELECT_CLASS = + 'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border' + +const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({ + value: value as ColumnPresetId, + label: preset.label, +})) + +const COLUMN_PROPORTION_PRESETS = { + slender: { + label: 'Slender', + height: 3.6, + width: 0.34, + baseHeight: 0.18, + capitalHeight: 0.16, + baseWidthScale: 1.18, + capitalWidthScale: 1.16, + edgeSoftness: 0.02, + }, + standard: { + label: 'Standard', + height: 2.9, + width: 0.44, + baseHeight: 0.22, + capitalHeight: 0.2, + baseWidthScale: 1.24, + capitalWidthScale: 1.22, + edgeSoftness: 0.025, + }, + heavy: { + label: 'Heavy', + height: 3, + width: 0.58, + baseHeight: 0.28, + capitalHeight: 0.26, + baseWidthScale: 1.34, + capitalWidthScale: 1.3, + edgeSoftness: 0.035, + }, + stout: { + label: 'Short / Stout', + height: 2.2, + width: 0.62, + baseHeight: 0.3, + capitalHeight: 0.28, + baseWidthScale: 1.38, + capitalWidthScale: 1.34, + edgeSoftness: 0.04, + }, +} as const + +type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS + +const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({ + value: value as ColumnProportionPresetId, + label: preset.label, +})) + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function presetUpdates(presetId: ColumnPresetId): Partial { + const { label, ...preset } = COLUMN_PRESETS[presetId] + return { + name: label, + ...preset, + } +} + +function proportionUpdates( + node: ColumnNode, + presetId: ColumnProportionPresetId, +): Partial { + const preset = COLUMN_PROPORTION_PRESETS[presetId] + const depth = + node.crossSection === 'rectangular' + ? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6) + : preset.width + const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18) + + return { + height: preset.height, + width: preset.width, + depth, + radius: preset.width / 2, + baseHeight: preset.baseHeight, + capitalHeight: preset.capitalHeight, + baseWidthScale: preset.baseWidthScale, + baseDepthScale: preset.baseWidthScale, + capitalWidthScale: preset.capitalWidthScale, + capitalDepthScale: preset.capitalWidthScale, + edgeSoftness: preset.edgeSoftness, + shaftCornerRadius, + } +} + +function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial { + if (shaftProfile === 'tapered') { + return { + shaftProfile, + shaftTaper: 0.14, + shaftBulge: 0, + shaftStartScale: 0.82, + shaftEndScale: 0.72, + shaftSegmentCount: 32, + } + } + + if (shaftProfile === 'bulged') { + return { + shaftProfile, + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.68, + shaftEndScale: 0.68, + shaftSegmentCount: 32, + } + } + + if (shaftProfile === 'hourglass') { + return { + shaftProfile, + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.84, + shaftEndScale: 0.84, + shaftSegmentCount: 32, + } + } + + return { + shaftProfile, + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 0.72, + shaftEndScale: 0.72, + shaftSegmentCount: 1, + } +} + +export function ColumnPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const selectedCount = useViewer((s) => s.selection.selectedIds.length) + const setSelection = useViewer((s) => s.setSelection) + const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + + const node = useScene((s) => + selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined, + ) + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + const handleDelete = useCallback(() => { + if (!selectedId) return + sfxEmitter.emit('sfx:structure-delete') + deleteNode(selectedId as AnyNode['id']) + setSelection({ selectedIds: [] }) + }, [deleteNode, selectedId, setSelection]) + + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + + if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null + const shaftProfile = node.shaftProfile ?? 'straight' + + return ( + + + + + + + + handleUpdate({ edgeSoftness: value })} + precision={3} + step={0.005} + unit="m" + value={node.edgeSoftness ?? 0.025} + /> + {(node.crossSection === 'square' || node.crossSection === 'rectangular') && ( + handleUpdate({ shaftCornerRadius: value })} + precision={3} + step={0.005} + unit="m" + value={node.shaftCornerRadius ?? 0.035} + /> + )} + + + + + handleUpdate({ height: value })} + precision={2} + step={0.05} + unit="m" + value={node.height} + /> + + handleUpdate({ + width: value, + radius: value / 2, + ...(node.crossSection === 'rectangular' ? {} : { depth: value }), + }) + } + precision={2} + step={0.02} + unit="m" + value={node.width} + /> + {node.crossSection === 'rectangular' && ( + handleUpdate({ depth: value })} + precision={2} + step={0.02} + unit="m" + value={node.depth} + /> + )} + + + + + {shaftProfile === 'straight' && ( + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.72} + /> + )} + {shaftProfile === 'tapered' && ( + <> + handleUpdate({ shaftStartScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.82} + /> + handleUpdate({ shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftEndScale ?? 0.72} + /> + handleUpdate({ shaftTaper: value })} + precision={2} + step={0.01} + value={node.shaftTaper ?? 0.14} + /> + + )} + {shaftProfile === 'bulged' && ( + <> + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.68} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + {shaftProfile === 'hourglass' && ( + <> + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.84} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + + handleUpdate({ + ringCount: Math.round(value) * 2, + ringPlacement: 'ends', + ringSpread: node.ringSpread ?? 0.16, + ringThickness: node.ringThickness ?? 0.055, + }) + } + precision={0} + step={1} + value={Math.ceil((node.ringCount ?? 0) / 2)} + /> + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.ringThickness ?? 0.055} + /> + )} + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} + precision={2} + step={0.01} + value={node.ringSpread ?? 0.16} + /> + )} + + + + + {node.capitalStyle !== 'none' && ( + handleUpdate({ capitalHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.capitalHeight} + /> + )} + {node.capitalStyle !== 'none' && ( + + handleUpdate({ + capitalWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ capitalDepthScale: value })} + precision={2} + step={0.02} + value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.capitalTierCount ?? 3} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalStepSpread: value })} + precision={2} + step={0.01} + value={node.capitalStepSpread ?? 0.34} + /> + )} + + {node.baseStyle !== 'none' && ( + handleUpdate({ baseHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.baseHeight} + /> + )} + {node.baseStyle !== 'none' && ( + + handleUpdate({ + baseWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ baseDepthScale: value })} + precision={2} + step={0.02} + value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ basePlinthHeightRatio: value })} + precision={2} + step={0.01} + value={node.basePlinthHeightRatio ?? 0.44} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseRoundBandScale: value })} + precision={2} + step={0.01} + value={node.baseRoundBandScale ?? 0.92} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseNeckScale: value })} + precision={2} + step={0.01} + value={node.baseNeckScale ?? 0.72} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.baseTierCount ?? 3} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseStepSpread: value })} + precision={2} + step={0.01} + value={node.baseStepSpread ?? 0.34} + /> + )} + + + + handleUpdate({ rotation: (value * Math.PI) / 180 })} + precision={0} + step={1} + unit="°" + value={Math.round((node.rotation * 180) / Math.PI)} + /> + + + + + } label="Move" onClick={handleMove} /> + } + label="Delete" + onClick={handleDelete} + /> + + + + ) +} diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 6cc26c666..3b5456801 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -12,6 +12,7 @@ const TYPE_DEFAULTS: Record = { window: { icon: '/icons/window.png', label: 'Window' }, slab: { icon: '/icons/floor.png', label: 'Slab' }, ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' }, + column: { icon: '/icons/column.png', label: 'Column' }, fence: { icon: '/icons/fence.png', label: 'Fence' }, roof: { icon: '/icons/roof.png', label: 'Roof' }, 'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' }, diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index d5faef6f0..e948ab808 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + type ColumnNode, type DoorNode, type FenceNode, type ItemNode, @@ -23,6 +24,7 @@ import { useIsMobile } from '../../../hooks/use-mobile' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CeilingPanel } from './ceiling-panel' +import { ColumnPanel } from './column-panel' import { DoorPanel } from './door-panel' import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' @@ -45,6 +47,7 @@ type MovableNode = | WindowNode | DoorNode | CeilingNode + | ColumnNode | SlabNode | WallNode | FenceNode @@ -59,6 +62,7 @@ const MOVABLE_TYPES = new Set([ 'window', 'door', 'ceiling', + 'column', 'slab', 'wall', 'fence', @@ -90,6 +94,8 @@ function panelForType(type: string | null) { return case 'ceiling': return + case 'column': + return case 'wall': return case 'fence': @@ -250,6 +256,8 @@ export function PanelManager() { return case 'ceiling': return + case 'column': + return case 'wall': return case 'fence': diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx new file mode 100644 index 000000000..af37aaf16 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx @@ -0,0 +1,77 @@ +import { type AnyNodeId, type ColumnNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import Image from 'next/image' +import { memo, useCallback, useState } from 'react' +import useEditor from './../../../../../store/use-editor' +import { InlineRenameInput } from './inline-rename-input' +import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { TreeNodeActions } from './tree-node-actions' + +interface ColumnTreeNodeProps { + nodeId: AnyNodeId + depth: number + isLast?: boolean +} + +export const ColumnTreeNode = memo(function ColumnTreeNode({ + nodeId, + depth, + isLast, +}: ColumnTreeNodeProps) { + const [isEditing, setIsEditing] = useState(false) + const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const node = useScene((s) => s.nodes[nodeId] as ColumnNode | undefined) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const handled = handleTreeSelection( + e, + nodeId, + useViewer.getState().selection.selectedIds, + setSelection, + ) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + }, + [nodeId, setSelection], + ) + + const defaultName = node?.name || 'Column' + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 4a4d91942..2e7ba5df9 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -56,6 +56,7 @@ export function focusTreeNode(nodeId: AnyNodeId) { import { cn } from '../../../../../lib/utils' import { BuildingTreeNode } from './building-tree-node' import { CeilingTreeNode } from './ceiling-tree-node' +import { ColumnTreeNode } from './column-tree-node' import { DoorTreeNode } from './door-tree-node' import { FenceTreeNode } from './fence-tree-node' import { ItemTreeNode } from './item-tree-node' @@ -84,6 +85,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr return case 'ceiling': return + case 'column': + return case 'level': return case 'slab': diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index ffdde7860..37582b0d1 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -2,6 +2,7 @@ import { type CeilingNode, + type ColumnNode, type FenceNode, getCatalogMaterialById, getEffectiveRoofSurfaceMaterial, @@ -21,7 +22,7 @@ import { export type PaintableMaterialTarget = Extract< MaterialTarget, - 'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling' + 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' > export type SingleSurfaceMaterialRole = 'surface' @@ -131,10 +132,9 @@ export function buildStairSurfaceMaterialPatch( } } -export function buildSingleSurfaceMaterialPatch( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { +export function buildSingleSurfaceMaterialPatch< + TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode, +>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial { return { material, materialPreset, @@ -170,7 +170,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'wall', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'wall', @@ -190,7 +190,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'roof', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'roof', @@ -210,7 +210,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'stair', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'stair', @@ -220,6 +220,7 @@ export function resolveActivePaintMaterialFromSelection(params: { if ( (selectedNode.type === 'fence' || + selectedNode.type === 'column' || selectedNode.type === 'slab' || selectedNode.type === 'ceiling') && selectedMaterialTarget.role === 'surface' @@ -267,6 +268,10 @@ export function resolvePaintTargetFromSelection(params: { return 'fence' } + if (selectedNode.type === 'column') { + return 'column' + } + if (selectedNode.type === 'slab') { return 'slab' } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 560dc0948..b3aa2616c 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -5,6 +5,7 @@ import { type AssetInput, type BuildingNode, type CeilingNode, + type ColumnNode, type DoorNode, type FenceNode, type ItemNode, @@ -139,6 +140,7 @@ type EditorState = { | DoorNode | FenceNode | CeilingNode + | ColumnNode | SlabNode | WallNode | RoofNode @@ -155,6 +157,7 @@ type EditorState = { | DoorNode | FenceNode | CeilingNode + | ColumnNode | SlabNode | WallNode | RoofNode diff --git a/packages/viewer/src/components/renderers/column/column-renderer.tsx b/packages/viewer/src/components/renderers/column/column-renderer.tsx new file mode 100644 index 000000000..1d7932dc2 --- /dev/null +++ b/packages/viewer/src/components/renderers/column/column-renderer.tsx @@ -0,0 +1,1302 @@ +import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' +import { createContext, useContext, useMemo, useRef } from 'react' +import type { Group, Material } from 'three' +import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' + +const ColumnMaterialContext = createContext(baseMaterial as Material) +const ColumnEdgeSoftnessContext = createContext(0.025) + +function ColumnMaterial() { + const material = useContext(ColumnMaterialContext) + return +} + +function createColumnMaterial({ + material, + materialPreset, +}: Pick) { + const presetMaterial = createMaterialFromPresetRef(materialPreset) + if (presetMaterial) return presetMaterial + if (material) return createMaterial(material) + return baseMaterial +} + +function getSegments(node: ColumnNode) { + if (node.crossSection === 'octagonal') return 8 + if (node.crossSection === 'sixteen-sided') return 16 + return 32 +} + +function getShaftProfile(node: ColumnNode) { + return node.shaftProfile ?? (node.shaftTaper > 0 ? 'tapered' : 'straight') +} + +function getShaftSegmentCount(node: ColumnNode) { + const shaftProfile = getShaftProfile(node) + const shaftTaper = node.shaftTaper ?? 0 + return Math.max( + 1, + shaftProfile === 'straight' && shaftTaper <= 0 ? 1 : (node.shaftSegmentCount ?? 24), + ) +} + +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) +} + +function SquareBlock({ + y, + height, + width, + depth, + softenEdges = true, +}: { + y: number + height: number + width: number + 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 ? ( + + ) : ( + + )} + + + ) +} + +function RoundBlock({ + x = 0, + y, + z = 0, + height, + radius, + segments = 32, +}: { + x?: number + y: number + z?: number + height: number + radius: number + segments?: number +}) { + if (height <= 0) return null + + 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 +}) { + if (height <= 0) return null + + 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 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 PanelFace = ({ + position, + rotation = 0, + }: { + position: [number, number, number] + rotation?: number + }) => ( + + + + + + + + + + + + + + + + + + {panelShape === 'diamond' && ( + + + + + )} + {panelShape === 'arched' && ( + + + + + )} + + ) + + 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 }: { node: ColumnNode }) => { + const ref = useRef(null!) + const handlers = useNodeEvents(node, 'column') + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + const material = useMemo( + () => createColumnMaterial({ material: node.material, materialPreset: node.materialPreset }), + [ + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + node.materialPreset, + ], + ) + + useRegistry(node.id, node.type, ref) + + const shaftLayout = useMemo(() => { + const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4) + const capitalHeight = + node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4) + const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight) + return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight } + }, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height]) + + return ( + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 80eb5b6ac..c4b6db979 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -3,6 +3,7 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' +import { ColumnRenderer } from './column/column-renderer' import { DoorRenderer } from './door/door-renderer' import { FenceRenderer } from './fence/fence-renderer' import { GuideRenderer } from './guide/guide-renderer' @@ -30,6 +31,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'site' && } {node.type === 'building' && } {node.type === 'ceiling' && } + {node.type === 'column' && } {node.type === 'level' && } {node.type === 'item' && } {node.type === 'slab' && } diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index f51c4dd54..9f7adadfd 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + type ColumnNode, emitter, type ItemNode, type LevelNode, @@ -32,6 +33,7 @@ type SelectableNodeType = | 'fence' | 'window' | 'door' + | 'column' | 'item' | 'slab' | 'ceiling' @@ -132,6 +134,11 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean = return pointInPolygonWithTolerance(item.position[0], item.position[2], zone.polygon) } + if (node.type === 'column') { + const column = node as ColumnNode + return pointInPolygonWithTolerance(column.position[0], column.position[2], zone.polygon) + } + if (node.type === 'wall') { const wall = node as WallNode const startIn = pointInPolygonWithTolerance(wall.start[0], wall.start[1], zone.polygon) @@ -227,9 +234,20 @@ const getStrategy = (): SelectionStrategy | null => { } } - // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors) + // Zone selected -> can select/hover contents (walls, items, columns, slabs, ceilings, roofs, windows, doors) return { - types: ['wall', 'fence', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'], + types: [ + 'wall', + 'fence', + 'item', + 'column', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'window', + 'door', + ], handleClick: (node, nativeEvent) => { let nodeToSelect = node if (node.type === 'roof-segment' && node.parentId) { @@ -258,6 +276,7 @@ const getStrategy = (): SelectionStrategy | null => { 'wall', 'fence', 'item', + 'column', 'slab', 'ceiling', 'roof', @@ -318,6 +337,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'slab', 'ceiling', 'roof', diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index d0b39b537..18487347e 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -3,6 +3,8 @@ import { type BuildingNode, type CeilingEvent, type CeilingNode, + type ColumnEvent, + type ColumnNode, type DoorEvent, type DoorNode, type EventSuffix, @@ -48,6 +50,7 @@ type NodeConfig = { slab: { node: SlabNode; event: SlabEvent } spawn: { node: SpawnNode; event: SpawnEvent } ceiling: { node: CeilingNode; event: CeilingEvent } + column: { node: ColumnNode; event: ColumnEvent } roof: { node: RoofNode; event: RoofEvent } 'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent } stair: { node: StairNode; event: StairEvent } From db765f1eb392789540961daf7129e4a212d2a190 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 4 May 2026 22:54:52 +0530 Subject: [PATCH 3/4] 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 | 701 +++++++++++++----- 4 files changed, 575 insertions(+), 167 deletions(-) diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index d19f725d5..a7fa83a8e 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 6aa84cf04..2f696d275 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 e4ae296a3..8b7e97de4 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,47 +257,205 @@ function getShaftScaleAt(node: ColumnNode, t: number) { return Math.max(0.1, profileScale) } -function SquareBlock({ - y, - height, - width, +type VectorTuple = [number, number, number] + +function MappedBox({ depth, + height, + position, + rotation, softenEdges = true, + width, }: { - y: number - height: number - width: number 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 roundedGeometry = useMemo(() => { - if (bevelRadius <= 0.001) return null - return new RoundedBoxGeometry(width, height, depth, 3, bevelRadius) + const geometry = useMemo(() => { + if (height <= 0 || width <= 0 || depth <= 0) return null + return createColumnBoxGeometry(width, height, depth, bevelRadius) }, [bevelRadius, depth, height, width]) - if (height <= 0) return null + if (!geometry) return null - const position = [0, y + height / 2, 0] as const + 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 ( - - {roundedGeometry ? ( - - ) : ( - - )} + + + + + ) +} + +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, @@ -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} + /> ) })} From 762c9fc763a2b34a2c06c9ba81a131300d4ce48f Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 4 May 2026 23:06:23 +0530 Subject: [PATCH 4/4] Refactor guide events and column placement handling --- packages/core/src/events/bus.ts | 8 - .../src/components/editor/floorplan-panel.tsx | 13 +- .../components/tools/column/column-tool.tsx | 10 +- .../src/components/tools/tool-manager.tsx | 10 +- .../components/ui/panels/reference-panel.tsx | 8 +- packages/editor/src/lib/guide-events.ts | 10 + .../renderers/column/column-renderer.tsx | 189 +----------------- .../src/systems/column/column-geometry.ts | 186 +++++++++++++++++ 8 files changed, 224 insertions(+), 210 deletions(-) create mode 100644 packages/editor/src/lib/guide-events.ts create mode 100644 packages/viewer/src/systems/column/column-geometry.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 8a5c0bf6f..fa467e9dd 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -7,7 +7,6 @@ import type { ColumnNode, DoorNode, FenceNode, - GuideNode, ItemNode, LevelNode, RoofNode, @@ -133,12 +132,6 @@ type ToolEvents = { 'tool:cancel': undefined } -type GuideEvents = { - 'guide:set-reference-scale': { guideId: GuideNode['id'] } - 'guide:cancel-reference-scale': undefined - 'guide:deleted': { guideId: GuideNode['id'] } -} - type PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -180,7 +173,6 @@ type EditorEvents = GridEvents & NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents & - GuideEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index bf3b5f9cd..9e20eee14 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -64,6 +64,7 @@ import { rotatePlanVector as rotateSharedPlanVector, type FloorplanNodeTransform as SharedFloorplanNodeTransform, } from '../../lib/floorplan' +import { guideEmitter } from '../../lib/guide-events' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' @@ -9131,9 +9132,9 @@ export function FloorplanPanel() { } } - emitter.on('guide:set-reference-scale', handleSetReferenceScale) + guideEmitter.on('guide:set-reference-scale', handleSetReferenceScale) return () => { - emitter.off('guide:set-reference-scale', handleSetReferenceScale) + guideEmitter.off('guide:set-reference-scale', handleSetReferenceScale) } }, [startReferenceScaleForGuide]) @@ -9143,9 +9144,9 @@ export function FloorplanPanel() { setPendingReferenceScale(null) } - emitter.on('guide:cancel-reference-scale', handleCancel) + guideEmitter.on('guide:cancel-reference-scale', handleCancel) return () => { - emitter.off('guide:cancel-reference-scale', handleCancel) + guideEmitter.off('guide:cancel-reference-scale', handleCancel) } }, []) @@ -9160,9 +9161,9 @@ export function FloorplanPanel() { clearGuideUi(payload.guideId) } - emitter.on('guide:deleted', handleDeleted) + guideEmitter.on('guide:deleted', handleDeleted) return () => { - emitter.off('guide:deleted', handleDeleted) + guideEmitter.off('guide:deleted', handleDeleted) } }, [clearGuideUi]) diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx index 9a239f104..e9593d1a0 100644 --- a/packages/editor/src/components/tools/column/column-tool.tsx +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -1,16 +1,15 @@ import '../../../three-types' import { - type AnyNode, COLUMN_PRESETS, ColumnNode, + type ColumnNode as ColumnNodeType, type ColumnPresetId, emitter, type GridEvent, type LevelNode, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import type { Group } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -41,9 +40,10 @@ function createColumnFromPreset(presetId: ColumnPresetId, position: [number, num type ColumnToolProps = { currentLevelId: LevelNode['id'] | null + onPlaced?: (nodeId: ColumnNodeType['id']) => void } -export const ColumnTool: React.FC = ({ currentLevelId }) => { +export const ColumnTool: React.FC = ({ currentLevelId, onPlaced }) => { const [, setCursorPosition] = useState<[number, number, number] | null>(null) const cursorRef = useRef(null) @@ -68,7 +68,7 @@ export const ColumnTool: React.FC = ({ currentLevelId }) => { ] const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) useScene.getState().createNode(column, currentLevelId) - useViewer.getState().setSelection({ selectedIds: [column.id as AnyNode['id']] }) + onPlaced?.(column.id) sfxEmitter.emit('sfx:structure-build') useEditor.getState().setTool(null) useEditor.getState().setMode('select') @@ -81,7 +81,7 @@ export const ColumnTool: React.FC = ({ currentLevelId }) => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) } - }, [currentLevelId]) + }, [currentLevelId, onPlaced]) if (!currentLevelId) return null diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index a0205ae5a..777c8d724 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -127,7 +127,7 @@ export const ToolManager: React.FC = () => { const showBuildTool = mode === 'build' && tool !== null const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null - const handleSpawnSelected = (nodeId: `spawn_${string}`) => { + const handlePlacedNodeSelected = (nodeId: AnyNodeId) => { setSelection({ selectedIds: [nodeId] }) } @@ -135,7 +135,7 @@ export const ToolManager: React.FC = () => { <> {showSiteBoundaryEditor && } {/* World-space tools: site boundary and building movement operate in world coordinates */} - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && } {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -160,13 +160,13 @@ export const ToolManager: React.FC = () => { {curvingWall && } {curvingFence && } {movingNode && movingNode.type !== 'building' && ( - + )} {!movingNode && showBuildTool && tool === 'spawn' && ( - + )} {!movingNode && showBuildTool && tool === 'column' && ( - + )} {!movingNode && BuildToolComponent && tool !== 'column' && } diff --git a/packages/editor/src/components/ui/panels/reference-panel.tsx b/packages/editor/src/components/ui/panels/reference-panel.tsx index 57b771c8d..723f76271 100644 --- a/packages/editor/src/components/ui/panels/reference-panel.tsx +++ b/packages/editor/src/components/ui/panels/reference-panel.tsx @@ -2,7 +2,6 @@ import { type AnyNode, - emitter, type GuideNode, loadAssetUrl, saveAsset, @@ -11,6 +10,7 @@ import { } from '@pascal-app/core' import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' +import { guideEmitter } from '../../../lib/guide-events' import { getGuideImageName } from '../../../lib/local-guide-image' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -98,7 +98,7 @@ export function ReferencePanel() { } deleteNode(selectedReferenceId as AnyNode['id']) - emitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) + guideEmitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) clearGuideUi(selectedReferenceId) setSelectedReferenceId(null) }, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId]) @@ -108,11 +108,11 @@ export function ReferencePanel() { return } - emitter.emit('guide:set-reference-scale', { guideId: node.id }) + guideEmitter.emit('guide:set-reference-scale', { guideId: node.id }) }, [node]) const handleCancelScale = useCallback(() => { - emitter.emit('guide:cancel-reference-scale') + guideEmitter.emit('guide:cancel-reference-scale') }, []) useEffect(() => { diff --git a/packages/editor/src/lib/guide-events.ts b/packages/editor/src/lib/guide-events.ts new file mode 100644 index 000000000..40bd172f0 --- /dev/null +++ b/packages/editor/src/lib/guide-events.ts @@ -0,0 +1,10 @@ +import type { GuideNode } from '@pascal-app/core' +import mitt from 'mitt' + +type GuideEditorEvents = { + 'guide:set-reference-scale': { guideId: GuideNode['id'] } + 'guide:cancel-reference-scale': undefined + 'guide:deleted': { guideId: GuideNode['id'] } +} + +export const guideEmitter = mitt() diff --git a/packages/viewer/src/components/renderers/column/column-renderer.tsx b/packages/viewer/src/components/renderers/column/column-renderer.tsx index 28ad8dde0..d80b89cf8 100644 --- a/packages/viewer/src/components/renderers/column/column-renderer.tsx +++ b/packages/viewer/src/components/renderers/column/column-renderer.tsx @@ -1,198 +1,23 @@ import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' import { createContext, useContext, useMemo, useRef } from 'react' -import { - BoxGeometry, - type BufferGeometry, - CylinderGeometry, - Float32BufferAttribute, - type Group, - type Material, - SphereGeometry, - TorusGeometry, -} from 'three' -import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' +import type { Group, Material } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' +import { + createColumnBoxGeometry, + createColumnCylinderGeometry, + createColumnSphereGeometry, + createColumnTorusGeometry, +} from '../../../systems/column/column-geometry' const ColumnMaterialContext = createContext(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, diff --git a/packages/viewer/src/systems/column/column-geometry.ts b/packages/viewer/src/systems/column/column-geometry.ts new file mode 100644 index 000000000..3319cfd65 --- /dev/null +++ b/packages/viewer/src/systems/column/column-geometry.ts @@ -0,0 +1,186 @@ +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + Float32BufferAttribute, + SphereGeometry, + TorusGeometry, +} from 'three' +import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' + +const COLUMN_UV_SCALE = 1 + +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) +} + +export 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) +} + +export 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, + ) +} + +export function createColumnSphereGeometry(radius: number, widthSegments = 10, heightSegments = 8) { + return applySphericalColumnUvs(new SphereGeometry(radius, widthSegments, heightSegments), radius) +} + +export 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) +}