Fix floorplan labels and door/fence geometry
This commit is contained in:
+28
-5
@@ -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,7 +155,14 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
|
||||
|
||||
return (
|
||||
<>
|
||||
{measurements.map((measurement) => (
|
||||
{measurements.map((measurement) =>
|
||||
(() => {
|
||||
const screenLabelAngleDeg = normalizeReadableScreenAngle(
|
||||
measurement.labelAngleDeg + sceneRotationDeg,
|
||||
)
|
||||
const localLabelAngleDeg = screenLabelAngleDeg - sceneRotationDeg
|
||||
|
||||
return (
|
||||
<g
|
||||
className={className}
|
||||
key={measurement.id}
|
||||
@@ -179,7 +200,7 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
|
||||
{measurement.showTicks !== false ? (
|
||||
<>
|
||||
<FloorplanMeasurementTick
|
||||
angleDeg={measurement.labelAngleDeg}
|
||||
angleDeg={localLabelAngleDeg}
|
||||
isSelected={measurement.isSelected}
|
||||
palette={palette}
|
||||
stroke={measurement.stroke}
|
||||
@@ -187,7 +208,7 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
|
||||
y={measurement.dimensionLineStart.y1}
|
||||
/>
|
||||
<FloorplanMeasurementTick
|
||||
angleDeg={measurement.labelAngleDeg}
|
||||
angleDeg={localLabelAngleDeg}
|
||||
isSelected={measurement.isSelected}
|
||||
palette={palette}
|
||||
stroke={measurement.stroke}
|
||||
@@ -208,14 +229,16 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
|
||||
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
|
||||
fontWeight="600"
|
||||
textAnchor="middle"
|
||||
transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 <WallMoveSideHandlesForWall wall={wall} />
|
||||
return selectedNode.type === 'wall' ? (
|
||||
<WallMoveSideHandlesForWall wall={selectedNode} />
|
||||
) : (
|
||||
<WallMoveSideHandlesForFence fence={selectedNode} />
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<group
|
||||
position={handle.position}
|
||||
rotation={[0, handle.rotationY, 0]}
|
||||
scale={scale}
|
||||
>
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
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[] {
|
||||
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<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(
|
||||
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]),
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }> = {
|
||||
hinged: { width: 0.9, 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 },
|
||||
}
|
||||
|
||||
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 {
|
||||
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<DoorNode> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +775,22 @@ function addLeafSegmentContent({
|
||||
cx = leafCenterX - contentW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
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,
|
||||
@@ -503,6 +800,7 @@ function addLeafSegmentContent({
|
||||
segCenterY,
|
||||
0,
|
||||
)
|
||||
}
|
||||
cx += seg.dividerThickness
|
||||
}
|
||||
}
|
||||
@@ -513,18 +811,71 @@ function addLeafSegmentContent({
|
||||
|
||||
if (seg.type === 'glass') {
|
||||
const glassDepth = Math.max(0.004, leafDepth * 0.15)
|
||||
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') {
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
segY -= segH
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user