Fix floorplan labels and door/fence geometry

This commit is contained in:
sudhir
2026-05-20 00:45:33 +00:00
committed by open-pascal
parent cdde88f9c0
commit 4a402756fc
6 changed files with 816 additions and 122 deletions
@@ -128,12 +128,26 @@ type FloorplanMeasurementsLayerProps = {
className: string className: string
measurements: LinearMeasurementOverlay[] measurements: LinearMeasurementOverlay[]
palette: FloorplanMeasurementPalette 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({ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLayer({
className, className,
measurements, measurements,
palette, palette,
sceneRotationDeg = 0,
}: FloorplanMeasurementsLayerProps) { }: FloorplanMeasurementsLayerProps) {
if (measurements.length === 0) { if (measurements.length === 0) {
return null return null
@@ -141,81 +155,90 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
return ( return (
<> <>
{measurements.map((measurement) => ( {measurements.map((measurement) =>
<g (() => {
className={className} const screenLabelAngleDeg = normalizeReadableScreenAngle(
key={measurement.id} measurement.labelAngleDeg + sceneRotationDeg,
pointerEvents="none" )
style={{ userSelect: 'none' }} const localLabelAngleDeg = screenLabelAngleDeg - sceneRotationDeg
>
<FloorplanMeasurementLine return (
dashed={measurement.dashedExtensions ?? true} <g
isSelected={measurement.isSelected} className={className}
palette={palette} key={measurement.id}
segment={measurement.extensionStart} pointerEvents="none"
stroke={measurement.extensionStroke} style={{ userSelect: 'none' }}
/> >
<FloorplanMeasurementLine <FloorplanMeasurementLine
isSelected={measurement.isSelected} dashed={measurement.dashedExtensions ?? true}
palette={palette}
path={measurement.dimensionPathStart}
segment={measurement.dimensionLineStart}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
path={measurement.dimensionPathEnd}
segment={measurement.dimensionLineEnd}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionEnd}
stroke={measurement.extensionStroke}
/>
{measurement.showTicks !== false ? (
<>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected} isSelected={measurement.isSelected}
palette={palette} palette={palette}
stroke={measurement.stroke} segment={measurement.extensionStart}
x={measurement.dimensionLineStart.x1} stroke={measurement.extensionStroke}
y={measurement.dimensionLineStart.y1}
/> />
<FloorplanMeasurementTick <FloorplanMeasurementLine
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected} isSelected={measurement.isSelected}
palette={palette} palette={palette}
path={measurement.dimensionPathStart}
segment={measurement.dimensionLineStart}
stroke={measurement.stroke} stroke={measurement.stroke}
x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/> />
</> <FloorplanMeasurementLine
) : null} isSelected={measurement.isSelected}
<text palette={palette}
dominantBaseline="central" path={measurement.dimensionPathEnd}
fill={measurement.labelFill ?? palette.measurementStroke} segment={measurement.dimensionLineEnd}
fillOpacity={ stroke={measurement.stroke}
measurement.isSelected />
? FLOORPLAN_MEASUREMENT_LABEL_OPACITY <FloorplanMeasurementLine
: FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4 dashed={measurement.dashedExtensions ?? true}
} isSelected={measurement.isSelected}
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" palette={palette}
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE} segment={measurement.extensionEnd}
fontWeight="600" stroke={measurement.extensionStroke}
textAnchor="middle" />
transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`} {measurement.showTicks !== false ? (
x={measurement.labelX} <>
y={measurement.labelY} <FloorplanMeasurementTick
> angleDeg={localLabelAngleDeg}
{measurement.label} isSelected={measurement.isSelected}
</text> palette={palette}
</g> stroke={measurement.stroke}
))} x={measurement.dimensionLineStart.x1}
y={measurement.dimensionLineStart.y1}
/>
<FloorplanMeasurementTick
angleDeg={localLabelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/>
</>
) : null}
<text
dominantBaseline="central"
fill={measurement.labelFill ?? palette.measurementStroke}
fillOpacity={
measurement.isSelected
? FLOORPLAN_MEASUREMENT_LABEL_OPACITY
: FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4
}
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
fontWeight="600"
textAnchor="middle"
transform={`rotate(${localLabelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
x={measurement.labelX}
y={measurement.labelY}
>
{measurement.label}
</text>
</g>
)
})(),
)}
</> </>
) )
}) })
@@ -482,7 +482,10 @@ export function FloatingActionMenu() {
: undefined : undefined
} }
onMove={ 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 ? handleMove
: undefined : undefined
} }
@@ -3,6 +3,7 @@
import { import {
type AnyNodeId, type AnyNodeId,
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_HEIGHT,
type FenceNode,
getWallCurveFrameAt, getWallCurveFrameAt,
getWallThickness, getWallThickness,
isCurvedWall, isCurvedWall,
@@ -11,9 +12,8 @@ import {
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' 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 { useEffect, useMemo, useState } from 'react'
import { useThree } from '@react-three/fiber'
import { import {
BufferGeometry, BufferGeometry,
ConeGeometry, ConeGeometry,
@@ -34,7 +34,6 @@ const ARROW_COLOR = '#8381ed'
const ARROW_HOVER_COLOR = '#a5b4fc' const ARROW_HOVER_COLOR = '#a5b4fc'
type WallMoveHandle = { type WallMoveHandle = {
direction: [number, number]
key: string key: string
position: [number, number, number] position: [number, number, number]
rotationY: number rotationY: number
@@ -91,13 +90,13 @@ export function WallMoveSideHandles() {
const curvingFence = useEditor((state) => state.curvingFence) const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null 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 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 = const shouldRender =
Boolean(wall) && Boolean(selectedNode) &&
!isFloorplanHovered && !isFloorplanHovered &&
mode !== 'delete' && mode !== 'delete' &&
!movingNode && !movingNode &&
@@ -106,9 +105,13 @@ export function WallMoveSideHandles() {
!curvingWall && !curvingWall &&
!curvingFence !curvingFence
if (!shouldRender || !wall) return null if (!shouldRender || !selectedNode) return null
return <WallMoveSideHandlesForWall wall={wall} /> return selectedNode.type === 'wall' ? (
<WallMoveSideHandlesForWall wall={selectedNode} />
) : (
<WallMoveSideHandlesForFence fence={selectedNode} />
)
} }
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
@@ -163,10 +166,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
const { camera } = useThree() const { camera } = useThree()
const zoom = const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
camera instanceof OrthographicCamera
? 1 / camera.zoom
: 1
const scale = (isHovered ? 1.12 : 1) * zoom const scale = (isHovered ? 1.12 : 1) * zoom
@@ -195,11 +195,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
} }
return ( return (
<group <group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
position={handle.position}
rotation={[0, handle.rotationY, 0]}
scale={scale}
>
<mesh <mesh
frustumCulled={false} frustumCulled={false}
onPointerDown={activateWallMove} onPointerDown={activateWallMove}
@@ -231,6 +227,71 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
) )
} }
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => 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<PointerEvent>) => {
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 (
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
<mesh
frustumCulled={false}
onPointerDown={activateFenceMove}
onPointerEnter={(event) => {
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}
>
<primitive attach="geometry" object={arrowGeometry} />
<meshBasicMaterial
color={isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR}
depthTest
depthWrite
opacity={1}
side={DoubleSide}
transparent={false}
/>
</mesh>
</group>
)
}
function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
const dx = wall.end[0] - wall.start[0] const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1] 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<Object3D | null>(() =>
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(
<group>
{handles.map((handle) => (
<FenceMoveArrowHandle fence={fence} handle={handle} key={handle.key} />
))}
</group>,
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( function buildWallMoveHandle(
key: string, key: string,
midpoint: [number, number], midpoint: [number, number],
@@ -265,7 +397,6 @@ function buildWallMoveHandle(
height: number, height: number,
): WallMoveHandle { ): WallMoveHandle {
return { return {
direction,
key, key,
position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset], position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
rotationY: Math.atan2(-direction[1], direction[0]), rotationY: Math.atan2(-direction[1], direction[0]),
@@ -20,17 +20,17 @@ export type ToolConfig = {
export const tools: ToolConfig[] = [ export const tools: ToolConfig[] = [
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' }, { 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: 'room', iconSrc: '/icons/room.png', label: 'Room' },
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' }, // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { 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: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' }, { id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
+52 -9
View File
@@ -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<DoorNode['doorType'], { width: number; height: number }> = { const defaultDoorDimensions: Record<DoorNode['doorType'], { width: number; height: number }> = {
hinged: { width: 0.9, height: 2.1 }, hinged: { width: 0.9, height: 2.1 },
double: { width: 1.5, height: 2.1 }, double: { width: 1.5, height: 2.1 },
@@ -93,6 +112,19 @@ const defaultDoorDimensions: Record<DoorNode['doorType'], { width: number; heigh
'garage-tiltup': { width: 2.7, height: 2.4 }, 'garage-tiltup': { width: 2.7, height: 2.4 },
} }
const defaultDoorSegmentsByType: Record<DoorNode['doorType'], DoorNode['segments']> = {
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 { function isSameDoorValue(current: unknown, next: unknown): boolean {
if (typeof current === 'number' && typeof next === 'number') { if (typeof current === 'number' && typeof next === 'number') {
return Math.abs(current - next) < 1e-6 return Math.abs(current - next) < 1e-6
@@ -367,7 +399,12 @@ export default function DoorPanel() {
const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door' const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door'
const supportsHingeSide = doorType === 'hinged' const supportsHingeSide = doorType === 'hinged'
const supportsHandleSide = 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 maxDoorWidth = isGarageDoor ? 6 : 3
const setOpeningTopRadius = (index: number, value: number, commit = false) => { const setOpeningTopRadius = (index: number, value: number, commit = false) => {
@@ -382,6 +419,7 @@ export default function DoorPanel() {
const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial<DoorNode> => { const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial<DoorNode> => {
const dimensions = defaultDoorDimensions[nextDoorType] const dimensions = defaultDoorDimensions[nextDoorType]
const segments = structuredClone(defaultDoorSegmentsByType[nextDoorType])
const dimensionUpdates = { const dimensionUpdates = {
width: dimensions.width, width: dimensions.width,
height: dimensions.height, height: dimensions.height,
@@ -395,10 +433,10 @@ export default function DoorPanel() {
leafCount: 2, leafCount: 2,
...dimensionUpdates, ...dimensionUpdates,
handleSide: 'right', handleSide: 'right',
segments,
...(nextDoorType === 'french' ...(nextDoorType === 'french'
? { ? {
contentPadding: [0.045, 0.055], contentPadding: [0.045, 0.055],
segments: frenchDoorSegments,
} }
: {}), : {}),
} }
@@ -410,13 +448,14 @@ export default function DoorPanel() {
doorType: nextDoorType, doorType: nextDoorType,
leafCount: 4, leafCount: 4,
...dimensionUpdates, ...dimensionUpdates,
openingShape: 'rectangle',
handle: true, handle: true,
handleSide: 'right', handleSide: 'right',
trackStyle: 'visible', trackStyle: 'visible',
operationState: Math.max(node.operationState ?? 0, 0.65), operationState: Math.max(node.operationState ?? 0, 0.65),
threshold: false, threshold: false,
contentPadding: [0.03, 0.04], contentPadding: [0.03, 0.04],
segments: foldingDoorSegments, segments,
} }
} }
@@ -426,6 +465,7 @@ export default function DoorPanel() {
doorType: nextDoorType, doorType: nextDoorType,
leafCount: 1, leafCount: 1,
...dimensionUpdates, ...dimensionUpdates,
openingShape: 'rectangle',
handle: true, handle: true,
handleSide: 'right', handleSide: 'right',
trackStyle: 'pocket', trackStyle: 'pocket',
@@ -433,7 +473,7 @@ export default function DoorPanel() {
operationState: node.operationState ?? 0, operationState: node.operationState ?? 0,
threshold: false, threshold: false,
contentPadding: [0.035, 0.045], contentPadding: [0.035, 0.045],
segments: foldingDoorSegments, segments,
} }
} }
@@ -443,6 +483,7 @@ export default function DoorPanel() {
doorType: nextDoorType, doorType: nextDoorType,
leafCount: 1, leafCount: 1,
...dimensionUpdates, ...dimensionUpdates,
openingShape: 'rectangle',
handle: true, handle: true,
handleSide: 'right', handleSide: 'right',
trackStyle: 'visible', trackStyle: 'visible',
@@ -450,7 +491,7 @@ export default function DoorPanel() {
operationState: node.operationState ?? 0, operationState: node.operationState ?? 0,
threshold: false, threshold: false,
contentPadding: [0.035, 0.045], contentPadding: [0.035, 0.045],
segments: foldingDoorSegments, segments,
} }
} }
@@ -460,6 +501,7 @@ export default function DoorPanel() {
doorType: nextDoorType, doorType: nextDoorType,
leafCount: 2, leafCount: 2,
...dimensionUpdates, ...dimensionUpdates,
openingShape: 'rectangle',
handle: true, handle: true,
handleSide: 'right', handleSide: 'right',
trackStyle: 'visible', trackStyle: 'visible',
@@ -467,7 +509,7 @@ export default function DoorPanel() {
operationState: node.operationState ?? 0, operationState: node.operationState ?? 0,
threshold: false, threshold: false,
contentPadding: [0.03, 0.04], contentPadding: [0.03, 0.04],
segments: frenchDoorSegments, segments,
} }
} }
@@ -484,7 +526,7 @@ export default function DoorPanel() {
operationState: 0, operationState: 0,
garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)), garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)),
contentPadding: [0.04, 0.04], contentPadding: [0.04, 0.04],
segments: foldingDoorSegments, segments,
} }
} }
@@ -501,7 +543,7 @@ export default function DoorPanel() {
operationState: 0, operationState: 0,
garagePanelCount: 4, garagePanelCount: 4,
contentPadding: [0.04, 0.04], contentPadding: [0.04, 0.04],
segments: foldingDoorSegments, segments,
} }
} }
@@ -518,7 +560,7 @@ export default function DoorPanel() {
operationState: 0, operationState: 0,
garagePanelCount: 4, garagePanelCount: 4,
contentPadding: [0.04, 0.04], contentPadding: [0.04, 0.04],
segments: foldingDoorSegments, segments,
} }
} }
@@ -527,6 +569,7 @@ export default function DoorPanel() {
doorType: nextDoorType, doorType: nextDoorType,
leafCount: 1, leafCount: 1,
...dimensionUpdates, ...dimensionUpdates,
segments,
threshold: true, threshold: true,
} }
} }
+513 -19
View File
@@ -109,6 +109,25 @@ function addShape(
parent.add(mesh) 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) { function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) {
return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01)) 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)) 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( function createArchBandShape(
width: number, width: number,
outerSpringY: number, outerSpringY: number,
@@ -358,6 +422,174 @@ function createRoundedLeafFrameShape(
return outer 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( function createTopClippedRectShape(
left: number, left: number,
right: number, right: number,
@@ -395,6 +627,7 @@ function disposeObject(object: THREE.Object3D) {
function addLeafSegmentContent({ function addLeafSegmentContent({
addLeafBox, addLeafBox,
addLeafShape,
leafWidth, leafWidth,
leafHeight, leafHeight,
leafCenterX, leafCenterX,
@@ -403,6 +636,11 @@ function addLeafSegmentContent({
segments, segments,
contentPadding, contentPadding,
keepFrameWhenEmpty = false, keepFrameWhenEmpty = false,
renderPerimeterFrame = true,
openingShape = 'rectangle',
openingTopRadii,
archHeight,
archOuterSide,
}: { }: {
addLeafBox: ( addLeafBox: (
material: THREE.Material, material: THREE.Material,
@@ -413,6 +651,7 @@ function addLeafSegmentContent({
y: number, y: number,
z: number, z: number,
) => void ) => void
addLeafShape?: (material: THREE.Material, shape: THREE.Shape, depth: number) => void
leafWidth: number leafWidth: number
leafHeight: number leafHeight: number
leafCenterX: number leafCenterX: number
@@ -421,12 +660,17 @@ function addLeafSegmentContent({
segments: DoorNode['segments'] segments: DoorNode['segments']
contentPadding: DoorNode['contentPadding'] contentPadding: DoorNode['contentPadding']
keepFrameWhenEmpty?: boolean keepFrameWhenEmpty?: boolean
renderPerimeterFrame?: boolean
openingShape?: DoorNode['openingShape']
openingTopRadii?: TopCornerRadii
archHeight?: number
archOuterSide?: 'left' | 'right'
}) { }) {
const hasLeafContent = segments.some((seg) => seg.type !== 'empty') const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty
const cpX = contentPadding[0] const cpX = contentPadding[0]
const cpY = contentPadding[1] const cpY = contentPadding[1]
if (shouldRenderFrame && cpY > 0) { if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) {
addLeafBox( addLeafBox(
baseMaterial, baseMaterial,
leafWidth, leafWidth,
@@ -446,7 +690,7 @@ function addLeafSegmentContent({
0, 0,
) )
} }
if (shouldRenderFrame && cpX > 0) { if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) {
const innerH = leafHeight - 2 * cpY const innerH = leafHeight - 2 * cpY
addLeafBox( addLeafBox(
baseMaterial, baseMaterial,
@@ -474,9 +718,46 @@ function addLeafSegmentContent({
const contentTop = leafCenterY + contentH / 2 const contentTop = leafCenterY + contentH / 2
let segY = contentTop 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) { for (const seg of segments) {
const segH = (seg.heightRatio / totalRatio) * contentH const segH = (seg.heightRatio / totalRatio) * contentH
const segCenterY = segY - segH / 2 const segCenterY = segY - segH / 2
const segTop = segY
const segBottom = segY - segH
const numCols = seg.columnRatios.length const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const usableW = contentW - (numCols - 1) * seg.dividerThickness const usableW = contentW - (numCols - 1) * seg.dividerThickness
@@ -494,15 +775,32 @@ function addLeafSegmentContent({
cx = leafCenterX - contentW / 2 cx = leafCenterX - contentW / 2
for (let c = 0; c < numCols - 1; c++) { for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]! cx += colWidths[c]!
addLeafBox( const dividerLeft = cx
baseMaterial, const dividerRight = cx + seg.dividerThickness
seg.dividerThickness, const dividerShape =
segH, hasShapedTop && addLeafShape
leafDepth + 0.001, ? createTopClippedRectShape(
cx + seg.dividerThickness / 2, dividerLeft,
segCenterY, dividerRight,
0, 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 cx += seg.dividerThickness
} }
} }
@@ -513,15 +811,68 @@ function addLeafSegmentContent({
if (seg.type === 'glass') { if (seg.type === 'glass') {
const glassDepth = Math.max(0.004, leafDepth * 0.15) 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') { } 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 panelW = colW - 2 * seg.panelInset
const panelH = segH - 2 * seg.panelInset const panelH = segH - 2 * seg.panelInset
if (panelW > 0.01 && panelH > 0.01) { if (panelW > 0.01 && panelH > 0.01) {
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
const panelZ = leafDepth / 2 + effectiveDepth / 2 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, panicBar,
panicBarHeight, panicBarHeight,
doorHeight, doorHeight,
openingShape,
openingTopRadii,
archHeight,
roundedBoundary,
archedBoundary,
archOuterSide,
}: { }: {
leafWidth: number leafWidth: number
leafHeight: number leafHeight: number
@@ -570,6 +927,20 @@ function addDoorLeaf(
panicBar: boolean panicBar: boolean
panicBarHeight: number panicBarHeight: number
doorHeight: 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') const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
@@ -587,9 +958,90 @@ function addDoorLeaf(
y: number, y: number,
z: number, z: number,
) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) ) => 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({ addLeafSegmentContent({
addLeafBox, addLeafBox,
addLeafShape,
leafWidth, leafWidth,
leafHeight, leafHeight,
leafCenterX, leafCenterX,
@@ -597,6 +1049,11 @@ function addDoorLeaf(
leafDepth, leafDepth,
segments, segments,
contentPadding, contentPadding,
renderPerimeterFrame: !usesShapedLeafFrame,
openingShape,
openingTopRadii,
archHeight,
archOuterSide,
}) })
if (hasLeafContent && handle) { if (hasLeafContent && handle) {
@@ -640,8 +1097,6 @@ function addDoorLeaf(
const hingeH = 0.1 const hingeH = 0.1
const hingeW = 0.024 const hingeW = 0.024
const hingeD = leafDepth + 0.016 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 + 0.25, 0)
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0)
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 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) 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) { function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
// Root mesh is an invisible hitbox; all visuals live in child meshes // Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose() mesh.geometry.dispose()
@@ -1422,7 +1886,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
width, width,
height, height,
openingKind, openingKind,
openingShape, openingShape: rawOpeningShape,
frameThickness, frameThickness,
frameDepth, frameDepth,
threshold, threshold,
@@ -1444,6 +1908,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
slideDirection = 'left', slideDirection = 'left',
garagePanelCount = 4, garagePanelCount = 4,
} = node } = node
const openingShape = getEffectiveOpeningShape(node) ?? rawOpeningShape
const runtimeDoorState = useInteractive.getState().doors[node.id] const runtimeDoorState = useInteractive.getState().doors[node.id]
const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle
const operationState = runtimeDoorState?.operationState ?? nodeOperationState const operationState = runtimeDoorState?.operationState ?? nodeOperationState
@@ -1662,6 +2127,15 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
}) })
} else if (doorType === 'double' || doorType === 'french') { } else if (doorType === 'double' || doorType === 'french') {
const doubleLeafW = insideWidth / 2 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, { addDoorLeaf(mesh, {
leafWidth: doubleLeafW, leafWidth: doubleLeafW,
leafHeight: leafH, leafHeight: leafH,
@@ -1681,6 +2155,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
panicBar, panicBar,
panicBarHeight, panicBarHeight,
doorHeight: height, 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, { addDoorLeaf(mesh, {
leafWidth: doubleLeafW, leafWidth: doubleLeafW,
@@ -1701,6 +2183,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
panicBar, panicBar,
panicBarHeight, panicBarHeight,
doorHeight: height, doorHeight: height,
openingShape,
openingTopRadii:
openingShape === 'rounded'
? { topLeft: 0, topRight: fullLeafTopRadii.topRight }
: fullLeafTopRadii,
archHeight: node.archHeight ?? 0.45,
roundedBoundary,
archOuterSide: openingShape === 'arch' ? 'right' : undefined,
}) })
} else { } else {
const hingeX = hingesSide === 'right' ? insideWidth / 2 : -insideWidth / 2 const hingeX = hingesSide === 'right' ? insideWidth / 2 : -insideWidth / 2
@@ -1724,6 +2214,9 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
panicBar, panicBar,
panicBarHeight, panicBarHeight,
doorHeight: height, 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) mesh.add(cutout)
} }
cutout.geometry.dispose() cutout.geometry.dispose()
if (node.openingShape === 'arch') { const openingShape = getEffectiveOpeningShape(node)
if (openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry( cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape( createArchShape(
-node.width / 2, -node.width / 2,
@@ -1755,7 +2249,7 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
}, },
) )
cutout.geometry.translate(0, 0, -0.5) cutout.geometry.translate(0, 0, -0.5)
} else if (node.openingShape === 'rounded') { } else if (openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry( cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedTopShape( createRoundedTopShape(
-node.width / 2, -node.width / 2,