From 46548c35e53afadcf0698e1ed0ffc7e3645dd5f8 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 4 May 2026 11:29:01 +0530 Subject: [PATCH] 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 8c61b407..60b041ce 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 99ad4c06..4f1fbd8f 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 d7091fd1..c52f9b41 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 8423ec4f..8ebb774e 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 00000000..edc3b832 --- /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 031f433e..281141d0 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 b6e18c1a..9eee0d6f 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 debf4e6e..c43607a7 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 363a859c..fabd1c3b 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 53162017..db80f6f9 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 a782ed95..6e87ca9e 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 98c98467..31490322 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 01a77f43..81382b1e 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 }