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,