From 4a402756fcda0fe995a7f705d5caf828e7f4860b Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 14 May 2026 23:29:16 +0530 Subject: [PATCH] Fix floorplan labels and door/fence geometry --- .../floorplan-measurements-layer.tsx | 159 +++--- .../editor/floating-action-menu.tsx | 5 +- .../editor/wall-move-side-handles.tsx | 167 +++++- .../ui/action-menu/structure-tools.tsx | 14 +- packages/nodes/src/door/panel.tsx | 61 +- .../viewer/src/systems/door/door-system.tsx | 532 +++++++++++++++++- 6 files changed, 816 insertions(+), 122 deletions(-) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx index f2a8947f..811e6b20 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx @@ -128,12 +128,26 @@ type FloorplanMeasurementsLayerProps = { className: string measurements: LinearMeasurementOverlay[] palette: FloorplanMeasurementPalette + sceneRotationDeg?: number +} + +function normalizeReadableScreenAngle(angleDeg: number) { + let normalized = ((((angleDeg + 180) % 360) + 360) % 360) - 180 + + if (normalized > 90) { + normalized -= 180 + } else if (normalized <= -90) { + normalized += 180 + } + + return normalized } export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLayer({ className, measurements, palette, + sceneRotationDeg = 0, }: FloorplanMeasurementsLayerProps) { if (measurements.length === 0) { return null @@ -141,81 +155,90 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay return ( <> - {measurements.map((measurement) => ( - - - - - - {measurement.showTicks !== false ? ( - <> - + (() => { + const screenLabelAngleDeg = normalizeReadableScreenAngle( + measurement.labelAngleDeg + sceneRotationDeg, + ) + const localLabelAngleDeg = screenLabelAngleDeg - sceneRotationDeg + + return ( + + - - - ) : null} - - {measurement.label} - - - ))} + + + {measurement.showTicks !== false ? ( + <> + + + + ) : null} + + {measurement.label} + + + ) + })(), + )} ) }) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 20ac4b20..7e21d81a 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -482,7 +482,10 @@ export function FloatingActionMenu() { : undefined } onMove={ - node && node.type !== 'wall' && !DELETE_ONLY_TYPES.includes(node.type) + node && + node.type !== 'wall' && + node.type !== 'fence' && + !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined } diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 917e01e0..83ac1db1 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, DEFAULT_WALL_HEIGHT, + type FenceNode, getWallCurveFrameAt, getWallThickness, isCurvedWall, @@ -11,9 +12,8 @@ import { type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { createPortal, type ThreeEvent } from '@react-three/fiber' +import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useState } from 'react' -import { useThree } from '@react-three/fiber' import { BufferGeometry, ConeGeometry, @@ -34,7 +34,6 @@ const ARROW_COLOR = '#8381ed' const ARROW_HOVER_COLOR = '#a5b4fc' type WallMoveHandle = { - direction: [number, number] key: string position: [number, number, number] rotationY: number @@ -91,13 +90,13 @@ export function WallMoveSideHandles() { const curvingFence = useEditor((state) => state.curvingFence) const selectedId = selectedIds.length === 1 ? selectedIds[0] : null - const wall = useScene((state) => { + const selectedNode = useScene((state) => { const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null - return node?.type === 'wall' ? node : null + return node?.type === 'wall' || node?.type === 'fence' ? node : null }) const shouldRender = - Boolean(wall) && + Boolean(selectedNode) && !isFloorplanHovered && mode !== 'delete' && !movingNode && @@ -106,9 +105,13 @@ export function WallMoveSideHandles() { !curvingWall && !curvingFence - if (!shouldRender || !wall) return null + if (!shouldRender || !selectedNode) return null - return + return selectedNode.type === 'wall' ? ( + + ) : ( + + ) } function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { @@ -163,10 +166,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) const { camera } = useThree() - const zoom = - camera instanceof OrthographicCamera - ? 1 / camera.zoom - : 1 + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 const scale = (isHovered ? 1.12 : 1) * zoom @@ -195,11 +195,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov } return ( - + createArrowHandleGeometry(), []) + const { camera } = useThree() + + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const scale = (isHovered ? 1.12 : 1) * zoom + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + + const activateFenceMove = (event: ThreeEvent) => { + event.stopPropagation() + event.nativeEvent.preventDefault() + document.body.style.cursor = 'grabbing' + + sfxEmitter.emit('sfx:item-pick') + useEditor.getState().setMovingNode(fence) + useEditor.getState().setMovingWallEndpoint(null) + useEditor.getState().setMovingFenceEndpoint(null) + useEditor.getState().setCurvingWall(null) + useEditor.getState().setCurvingFence(null) + useViewer.getState().setSelection({ selectedIds: [] }) + } + + return ( + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + renderOrder={1002} + > + + + + + ) +} + function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] @@ -257,6 +318,77 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { ] } +function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) { + const [levelObject, setLevelObject] = useState(() => + fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null, + ) + + useEffect(() => { + let frameId = 0 + + const resolveLevelObject = () => { + const nextLevelObject = fence.parentId + ? (sceneRegistry.nodes.get(fence.parentId) ?? null) + : null + setLevelObject((currentLevelObject) => { + if (currentLevelObject === nextLevelObject) { + return currentLevelObject + } + return nextLevelObject + }) + + if (!nextLevelObject) { + frameId = window.requestAnimationFrame(resolveLevelObject) + } + } + + resolveLevelObject() + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId) + } + } + }, [fence.parentId]) + + const handles = useMemo(() => getFenceMoveHandles(fence), [fence]) + + if (!levelObject || handles.length === 0) return null + + return createPortal( + + {handles.map((handle) => ( + + ))} + , + levelObject, + ) +} + +function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] { + const dx = fence.end[0] - fence.start[0] + const dz = fence.end[1] - fence.start[1] + const length = Math.hypot(dx, dz) + + if (length < 1e-6) { + return [] + } + + const midpoint: [number, number] = [ + (fence.start[0] + fence.end[0]) / 2, + (fence.start[1] + fence.end[1]) / 2, + ] + const normal: [number, number] = [-dz / length, dx / length] + const fenceHeight = fence.height ?? 1.8 + const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) + const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) + + return [ + buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), + buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), + ] +} + function buildWallMoveHandle( key: string, midpoint: [number, number], @@ -265,7 +397,6 @@ function buildWallMoveHandle( height: number, ): WallMoveHandle { return { - direction, key, position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset], rotationY: Math.atan2(-direction[1], direction[0]), diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 9ef91351..305e1328 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -20,17 +20,17 @@ export type ToolConfig = { export const tools: ToolConfig[] = [ { id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' }, + { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, + { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, + { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, + { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, + { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, + { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, + { id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' }, // { id: 'room', iconSrc: '/icons/room.png', label: 'Room' }, // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' }, { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, - { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, - { id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' }, - { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, - { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, - { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, - { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, - { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, { id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' }, diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index c6f82e4e..001ff37f 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -80,6 +80,25 @@ const foldingDoorSegments: DoorNode['segments'] = [ }, ] +const hingedDoorSegments: DoorNode['segments'] = [ + { + type: 'panel', + heightRatio: 0.4, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.6, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, +] + const defaultDoorDimensions: Record = { hinged: { width: 0.9, height: 2.1 }, double: { width: 1.5, height: 2.1 }, @@ -93,6 +112,19 @@ const defaultDoorDimensions: Record = { + hinged: hingedDoorSegments, + double: hingedDoorSegments, + french: frenchDoorSegments, + folding: foldingDoorSegments, + pocket: foldingDoorSegments, + barn: foldingDoorSegments, + sliding: frenchDoorSegments, + 'garage-sectional': foldingDoorSegments, + 'garage-rollup': foldingDoorSegments, + 'garage-tiltup': foldingDoorSegments, +} + function isSameDoorValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { return Math.abs(current - next) < 1e-6 @@ -367,7 +399,12 @@ export default function DoorPanel() { const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door' const supportsHingeSide = doorType === 'hinged' const supportsHandleSide = doorType === 'hinged' - const supportsTopShape = !isGarageDoor + const supportsTopShape = + !isGarageDoor && + doorType !== 'folding' && + doorType !== 'pocket' && + doorType !== 'barn' && + doorType !== 'sliding' const maxDoorWidth = isGarageDoor ? 6 : 3 const setOpeningTopRadius = (index: number, value: number, commit = false) => { @@ -382,6 +419,7 @@ export default function DoorPanel() { const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial => { const dimensions = defaultDoorDimensions[nextDoorType] + const segments = structuredClone(defaultDoorSegmentsByType[nextDoorType]) const dimensionUpdates = { width: dimensions.width, height: dimensions.height, @@ -395,10 +433,10 @@ export default function DoorPanel() { leafCount: 2, ...dimensionUpdates, handleSide: 'right', + segments, ...(nextDoorType === 'french' ? { contentPadding: [0.045, 0.055], - segments: frenchDoorSegments, } : {}), } @@ -410,13 +448,14 @@ export default function DoorPanel() { doorType: nextDoorType, leafCount: 4, ...dimensionUpdates, + openingShape: 'rectangle', handle: true, handleSide: 'right', trackStyle: 'visible', operationState: Math.max(node.operationState ?? 0, 0.65), threshold: false, contentPadding: [0.03, 0.04], - segments: foldingDoorSegments, + segments, } } @@ -426,6 +465,7 @@ export default function DoorPanel() { doorType: nextDoorType, leafCount: 1, ...dimensionUpdates, + openingShape: 'rectangle', handle: true, handleSide: 'right', trackStyle: 'pocket', @@ -433,7 +473,7 @@ export default function DoorPanel() { operationState: node.operationState ?? 0, threshold: false, contentPadding: [0.035, 0.045], - segments: foldingDoorSegments, + segments, } } @@ -443,6 +483,7 @@ export default function DoorPanel() { doorType: nextDoorType, leafCount: 1, ...dimensionUpdates, + openingShape: 'rectangle', handle: true, handleSide: 'right', trackStyle: 'visible', @@ -450,7 +491,7 @@ export default function DoorPanel() { operationState: node.operationState ?? 0, threshold: false, contentPadding: [0.035, 0.045], - segments: foldingDoorSegments, + segments, } } @@ -460,6 +501,7 @@ export default function DoorPanel() { doorType: nextDoorType, leafCount: 2, ...dimensionUpdates, + openingShape: 'rectangle', handle: true, handleSide: 'right', trackStyle: 'visible', @@ -467,7 +509,7 @@ export default function DoorPanel() { operationState: node.operationState ?? 0, threshold: false, contentPadding: [0.03, 0.04], - segments: frenchDoorSegments, + segments, } } @@ -484,7 +526,7 @@ export default function DoorPanel() { operationState: 0, garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)), contentPadding: [0.04, 0.04], - segments: foldingDoorSegments, + segments, } } @@ -501,7 +543,7 @@ export default function DoorPanel() { operationState: 0, garagePanelCount: 4, contentPadding: [0.04, 0.04], - segments: foldingDoorSegments, + segments, } } @@ -518,7 +560,7 @@ export default function DoorPanel() { operationState: 0, garagePanelCount: 4, contentPadding: [0.04, 0.04], - segments: foldingDoorSegments, + segments, } } @@ -527,6 +569,7 @@ export default function DoorPanel() { doorType: nextDoorType, leafCount: 1, ...dimensionUpdates, + segments, threshold: true, } } diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index f309a55a..a8e19318 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -109,6 +109,25 @@ function addShape( parent.add(mesh) } +function addShapeAt( + parent: THREE.Object3D, + material: THREE.Material, + shape: THREE.Shape, + depth: number, + x: number, + y: number, + z: number, +) { + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 24, + }) + geometry.translate(x, y, z - 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)) } @@ -145,6 +164,51 @@ function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHei return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0)) } +function getOneSidedArchBoundaryYAtX( + x: number, + left: number, + right: number, + top: number, + archHeight: number, + curvedSide: 'left' | 'right', +) { + const width = right - left + if (width <= 1e-6) return top + const clampedArchHeight = getClampedArchHeight(width, Number.MAX_SAFE_INTEGER, archHeight) + const springY = top - clampedArchHeight + const distanceFromApex = + curvedSide === 'left' + ? Math.max(0, Math.min((right - x) / width, 1)) + : Math.max(0, Math.min((x - left) / width, 1)) + return ( + springY + clampedArchHeight * Math.sqrt(Math.max(1 - distanceFromApex * distanceFromApex, 0)) + ) +} + +function getRoundedBoundaryYAtX( + x: number, + left: number, + right: number, + top: number, + radii: TopCornerRadii, +) { + 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 createArchBandShape( width: number, outerSpringY: number, @@ -358,6 +422,174 @@ function createRoundedLeafFrameShape( return outer } +function createRoundedClippedLeafFrameShape( + left: number, + right: number, + bottom: number, + top: number, + fullLeft: number, + fullRight: number, + radii: TopCornerRadii, + insetX: number, + insetY: number, +) { + const outerRadii = normalizeTopCornerRadii(radii, fullRight - fullLeft, top - bottom) + const outer = createTopClippedRectShape(left, right, bottom, top, (x) => + getRoundedBoundaryYAtX(x, fullLeft, fullRight, top, { + topLeft: outerRadii.topLeft, + topRight: outerRadii.topRight, + bottomLeft: 0, + bottomRight: 0, + }), + ) + + if (!outer) return null + + const innerLeft = left + insetX + const innerRight = right - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerFullLeft = fullLeft + insetX + const innerFullRight = fullRight - insetX + const innerRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0), + topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0), + }, + innerFullRight - innerFullLeft, + innerTop - innerBottom, + ) + const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => + getRoundedBoundaryYAtX(x, innerFullLeft, innerFullRight, innerTop, { + topLeft: innerRadii.topLeft, + topRight: innerRadii.topRight, + bottomLeft: 0, + bottomRight: 0, + }), + ) + + if (holeShape) outer.holes.push(shapeToReversedPath(holeShape)) + + return outer +} + +function createArchedLeafFrameShape( + width: number, + bottom: number, + top: number, + archHeight: number, + insetX: number, + insetY: number, +) { + const halfWidth = width / 2 + const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight) + 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 innerArchHeight = getClampedArchHeight( + innerRight - innerLeft, + innerTop - innerBottom, + Math.max(archHeight - insetY, 0.01), + ) + outer.holes.push( + shapeToReversedPath( + createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight), + ), + ) + + return outer +} + +function createArchedClippedLeafFrameShape( + left: number, + right: number, + bottom: number, + top: number, + fullLeft: number, + fullRight: number, + archHeight: number, + insetX: number, + insetY: number, +) { + const fullCenterX = (fullLeft + fullRight) / 2 + const fullHalfWidth = (fullRight - fullLeft) / 2 + const springY = top - archHeight + const outer = createTopClippedRectShape(left, right, bottom, top, (x) => + getArchBoundaryY(x - fullCenterX, fullHalfWidth, springY, archHeight), + ) + + if (!outer) return null + + const innerLeft = left + insetX + const innerRight = right - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerFullLeft = fullLeft + insetX + const innerFullRight = fullRight - insetX + const innerArchHeight = getClampedArchHeight( + innerFullRight - innerFullLeft, + innerTop - innerBottom, + Math.max(archHeight - insetY, 0.01), + ) + const innerFullCenterX = (innerFullLeft + innerFullRight) / 2 + const innerFullHalfWidth = (innerFullRight - innerFullLeft) / 2 + const innerSpringY = innerTop - innerArchHeight + const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => + getArchBoundaryY(x - innerFullCenterX, innerFullHalfWidth, innerSpringY, innerArchHeight), + ) + + if (holeShape) outer.holes.push(shapeToReversedPath(holeShape)) + + return outer +} + +function createOneSidedArchLeafFrameShape( + left: number, + right: number, + bottom: number, + top: number, + archHeight: number, + insetX: number, + insetY: number, + curvedSide: 'left' | 'right', +) { + const outer = createTopClippedRectShape(left, right, bottom, top, (x) => + getOneSidedArchBoundaryYAtX(x, left, right, top, archHeight, curvedSide), + ) + + if (!outer) return null + + const innerLeft = left + insetX + const innerRight = right - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerArchHeight = getClampedArchHeight( + innerRight - innerLeft, + innerTop - innerBottom, + Math.max(archHeight - insetY, 0.01), + ) + const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => + getOneSidedArchBoundaryYAtX(x, innerLeft, innerRight, innerTop, innerArchHeight, curvedSide), + ) + + if (holeShape) outer.holes.push(shapeToReversedPath(holeShape)) + + return outer +} + function createTopClippedRectShape( left: number, right: number, @@ -395,6 +627,7 @@ function disposeObject(object: THREE.Object3D) { function addLeafSegmentContent({ addLeafBox, + addLeafShape, leafWidth, leafHeight, leafCenterX, @@ -403,6 +636,11 @@ function addLeafSegmentContent({ segments, contentPadding, keepFrameWhenEmpty = false, + renderPerimeterFrame = true, + openingShape = 'rectangle', + openingTopRadii, + archHeight, + archOuterSide, }: { addLeafBox: ( material: THREE.Material, @@ -413,6 +651,7 @@ function addLeafSegmentContent({ y: number, z: number, ) => void + addLeafShape?: (material: THREE.Material, shape: THREE.Shape, depth: number) => void leafWidth: number leafHeight: number leafCenterX: number @@ -421,12 +660,17 @@ function addLeafSegmentContent({ segments: DoorNode['segments'] contentPadding: DoorNode['contentPadding'] keepFrameWhenEmpty?: boolean + renderPerimeterFrame?: boolean + openingShape?: DoorNode['openingShape'] + openingTopRadii?: TopCornerRadii + archHeight?: number + archOuterSide?: 'left' | 'right' }) { const hasLeafContent = segments.some((seg) => seg.type !== 'empty') const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty const cpX = contentPadding[0] const cpY = contentPadding[1] - if (shouldRenderFrame && cpY > 0) { + if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) { addLeafBox( baseMaterial, leafWidth, @@ -446,7 +690,7 @@ function addLeafSegmentContent({ 0, ) } - if (shouldRenderFrame && cpX > 0) { + if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) { const innerH = leafHeight - 2 * cpY addLeafBox( baseMaterial, @@ -474,9 +718,46 @@ function addLeafSegmentContent({ const contentTop = leafCenterY + contentH / 2 let segY = contentTop + const leafLeft = leafCenterX - leafWidth / 2 + const leafRight = leafCenterX + leafWidth / 2 + const leafTop = leafCenterY + leafHeight / 2 + const hasShapedTop = openingShape === 'rounded' || openingShape === 'arch' + const clampedLeafArchHeight = + openingShape === 'arch' ? getClampedArchHeight(leafWidth, leafHeight, archHeight) : 0 + const leafSpringY = leafTop - clampedLeafArchHeight + const topBoundaryAtX = (x: number) => { + if (openingShape === 'rounded' && openingTopRadii) { + return getRoundedBoundaryYAtX(x, leafLeft, leafRight, leafTop, { + topLeft: openingTopRadii.topLeft, + topRight: openingTopRadii.topRight, + bottomLeft: 0, + bottomRight: 0, + }) + } + + if (openingShape === 'arch') { + if (archOuterSide) { + return getOneSidedArchBoundaryYAtX( + x, + leafLeft, + leafRight, + leafTop, + archHeight, + archOuterSide, + ) + } + + return getArchBoundaryY(x - leafCenterX, leafWidth / 2, leafSpringY, clampedLeafArchHeight) + } + + return leafTop + } + for (const seg of segments) { 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) const usableW = contentW - (numCols - 1) * seg.dividerThickness @@ -494,15 +775,32 @@ function addLeafSegmentContent({ cx = leafCenterX - 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, - ) + const dividerLeft = cx + const dividerRight = cx + seg.dividerThickness + const dividerShape = + hasShapedTop && addLeafShape + ? createTopClippedRectShape( + dividerLeft, + dividerRight, + segBottom, + segTop, + topBoundaryAtX, + ) + : null + + if (dividerShape && addLeafShape) { + addLeafShape(baseMaterial, dividerShape, leafDepth + 0.001) + } else { + addLeafBox( + baseMaterial, + seg.dividerThickness, + segH, + leafDepth + 0.001, + cx + seg.dividerThickness / 2, + segCenterY, + 0, + ) + } cx += seg.dividerThickness } } @@ -513,15 +811,68 @@ function addLeafSegmentContent({ if (seg.type === 'glass') { const glassDepth = Math.max(0.004, leafDepth * 0.15) - addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + const segmentLeft = colX - colW / 2 + const segmentRight = colX + colW / 2 + const glassShape = + hasShapedTop && addLeafShape + ? createTopClippedRectShape( + segmentLeft, + segmentRight, + segBottom, + segTop, + topBoundaryAtX, + ) + : null + + if (glassShape && addLeafShape) { + addLeafShape(glassMaterial, glassShape, glassDepth) + } else { + addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } } else if (seg.type === 'panel') { - addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + const segmentLeft = colX - colW / 2 + const segmentRight = colX + colW / 2 + const outerPanelShape = + hasShapedTop && addLeafShape + ? createTopClippedRectShape( + segmentLeft, + segmentRight, + segBottom, + segTop, + topBoundaryAtX, + ) + : null + + if (outerPanelShape && addLeafShape) { + addLeafShape(baseMaterial, outerPanelShape, leafDepth) + } else { + addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + } 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) + const insetLeft = colX - panelW / 2 + const insetRight = colX + panelW / 2 + const insetTop = segTop - seg.panelInset + const insetBottom = segBottom + seg.panelInset + const innerPanelShape = + hasShapedTop && addLeafShape + ? createTopClippedRectShape( + insetLeft, + insetRight, + insetBottom, + insetTop, + topBoundaryAtX, + ) + : null + + if (innerPanelShape && addLeafShape) { + addLeafShape(baseMaterial, innerPanelShape, effectiveDepth) + } else { + addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + } } } } @@ -551,6 +902,12 @@ function addDoorLeaf( panicBar, panicBarHeight, doorHeight, + openingShape, + openingTopRadii, + archHeight, + roundedBoundary, + archedBoundary, + archOuterSide, }: { leafWidth: number leafHeight: number @@ -570,6 +927,20 @@ function addDoorLeaf( panicBar: boolean panicBarHeight: number doorHeight: number + openingShape: DoorNode['openingShape'] + openingTopRadii: TopCornerRadii + archHeight: number + roundedBoundary?: { + fullLeft: number + fullRight: number + radii: TopCornerRadii + } + archedBoundary?: { + fullLeft: number + fullRight: number + archHeight: number + } + archOuterSide?: 'left' | 'right' }, ) { const hasLeafContent = segments.some((seg) => seg.type !== 'empty') @@ -587,9 +958,90 @@ function addDoorLeaf( y: number, z: number, ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) + const addLeafShape = (material: THREE.Material, shape: THREE.Shape, depth: number) => + addShapeAt(leafGroup, material, shape, depth, -hingeX, 0, 0) + + const localLeafCenterX = leafCenterX - hingeX + const leafBottom = leafCenterY - leafHeight / 2 + const leafTop = leafCenterY + leafHeight / 2 + const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch' + + if (usesShapedLeafFrame && hasLeafContent) { + if (openingShape === 'rounded') { + const roundedLeafShape = roundedBoundary + ? createRoundedClippedLeafFrameShape( + leafCenterX - leafWidth / 2, + leafCenterX + leafWidth / 2, + leafBottom, + leafTop, + roundedBoundary.fullLeft, + roundedBoundary.fullRight, + roundedBoundary.radii, + contentPadding[0], + contentPadding[1], + ) + : createRoundedLeafFrameShape( + leafWidth, + leafBottom, + leafTop, + openingTopRadii, + contentPadding[0], + contentPadding[1], + ) + + if (roundedLeafShape) { + if (roundedBoundary) { + addLeafShape(baseMaterial, roundedLeafShape, leafDepth) + } else { + addShapeAt(leafGroup, baseMaterial, roundedLeafShape, leafDepth, localLeafCenterX, 0, 0) + } + } + } else if (openingShape === 'arch') { + const archedLeafShape = archOuterSide + ? createOneSidedArchLeafFrameShape( + leafCenterX - leafWidth / 2, + leafCenterX + leafWidth / 2, + leafBottom, + leafTop, + archHeight, + contentPadding[0], + contentPadding[1], + archOuterSide, + ) + : archedBoundary + ? createArchedClippedLeafFrameShape( + leafCenterX - leafWidth / 2, + leafCenterX + leafWidth / 2, + leafBottom, + leafTop, + archedBoundary.fullLeft, + archedBoundary.fullRight, + archedBoundary.archHeight, + contentPadding[0], + contentPadding[1], + ) + : createArchedLeafFrameShape( + leafWidth, + leafBottom, + leafTop, + getClampedArchHeight(leafWidth, leafHeight, archHeight), + contentPadding[0], + contentPadding[1], + ) + + if (archedLeafShape) { + if (archOuterSide || archedBoundary) { + addLeafShape(baseMaterial, archedLeafShape, leafDepth) + } else { + addShapeAt(leafGroup, baseMaterial, archedLeafShape, leafDepth, localLeafCenterX, 0, 0) + } + } + } + } addLeafSegmentContent({ addLeafBox, + addLeafShape, leafWidth, leafHeight, leafCenterX, @@ -597,6 +1049,11 @@ function addDoorLeaf( leafDepth, segments, contentPadding, + renderPerimeterFrame: !usesShapedLeafFrame, + openingShape, + openingTopRadii, + archHeight, + archOuterSide, }) if (hasLeafContent && handle) { @@ -640,8 +1097,6 @@ function addDoorLeaf( const hingeH = 0.1 const hingeW = 0.024 const hingeD = leafDepth + 0.016 - const leafBottom = leafCenterY - leafHeight / 2 - const leafTop = leafCenterY + leafHeight / 2 addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) @@ -1401,6 +1856,15 @@ function addGarageTiltupDoor( addBox(mesh, revealMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0) } +function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { + return node.doorType === 'folding' || + node.doorType === 'pocket' || + node.doorType === 'barn' || + node.doorType === 'sliding' + ? 'rectangle' + : (node.openingShape ?? 'rectangle') +} + function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -1422,7 +1886,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { width, height, openingKind, - openingShape, + openingShape: rawOpeningShape, frameThickness, frameDepth, threshold, @@ -1444,6 +1908,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { slideDirection = 'left', garagePanelCount = 4, } = node + const openingShape = getEffectiveOpeningShape(node) ?? rawOpeningShape const runtimeDoorState = useInteractive.getState().doors[node.id] const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle const operationState = runtimeDoorState?.operationState ?? nodeOperationState @@ -1662,6 +2127,15 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { }) } else if (doorType === 'double' || doorType === 'french') { const doubleLeafW = insideWidth / 2 + const fullLeafTopRadii = getDoorTopRadii(node, insideWidth, leafH) + const roundedBoundary = + openingShape === 'rounded' + ? { + fullLeft: -insideWidth / 2, + fullRight: insideWidth / 2, + radii: fullLeafTopRadii, + } + : undefined addDoorLeaf(mesh, { leafWidth: doubleLeafW, leafHeight: leafH, @@ -1681,6 +2155,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { panicBar, panicBarHeight, doorHeight: height, + openingShape, + openingTopRadii: + openingShape === 'rounded' + ? { topLeft: fullLeafTopRadii.topLeft, topRight: 0 } + : fullLeafTopRadii, + archHeight: node.archHeight ?? 0.45, + roundedBoundary, + archOuterSide: openingShape === 'arch' ? 'left' : undefined, }) addDoorLeaf(mesh, { leafWidth: doubleLeafW, @@ -1701,6 +2183,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { panicBar, panicBarHeight, doorHeight: height, + openingShape, + openingTopRadii: + openingShape === 'rounded' + ? { topLeft: 0, topRight: fullLeafTopRadii.topRight } + : fullLeafTopRadii, + archHeight: node.archHeight ?? 0.45, + roundedBoundary, + archOuterSide: openingShape === 'arch' ? 'right' : undefined, }) } else { const hingeX = hingesSide === 'right' ? insideWidth / 2 : -insideWidth / 2 @@ -1724,6 +2214,9 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { panicBar, panicBarHeight, doorHeight: height, + openingShape, + openingTopRadii: getDoorTopRadii(node, insideWidth, leafH), + archHeight: node.archHeight ?? 0.45, }) } @@ -1739,7 +2232,8 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { mesh.add(cutout) } cutout.geometry.dispose() - if (node.openingShape === 'arch') { + const openingShape = getEffectiveOpeningShape(node) + if (openingShape === 'arch') { cutout.geometry = new THREE.ExtrudeGeometry( createArchShape( -node.width / 2, @@ -1755,7 +2249,7 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { }, ) cutout.geometry.translate(0, 0, -0.5) - } else if (node.openingShape === 'rounded') { + } else if (openingShape === 'rounded') { cutout.geometry = new THREE.ExtrudeGeometry( createRoundedTopShape( -node.width / 2,