Add frameless door openings and first-person collider support

This commit is contained in:
sudhir
2026-05-01 11:35:26 +05:30
parent 915393d649
commit 09bfb0b484
8 changed files with 379 additions and 63 deletions
+8
View File
@@ -32,6 +32,13 @@ export const DoorNode = BaseNode.extend({
width: z.number().default(0.9),
height: z.number().default(2.1),
// Opening mode
openingKind: z.enum(['door', 'opening']).default('door'),
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
cornerRadius: z.number().min(0).default(0.15),
archHeight: z.number().min(0).default(0.45),
openingRevealRadius: z.number().min(0).default(0.025),
// Frame
frameThickness: z.number().default(0.05),
frameDepth: z.number().default(0.07),
@@ -81,6 +88,7 @@ export const DoorNode = BaseNode.extend({
panicBarHeight: z.number().default(1.0),
}).describe(dedent`Door node - a parametric door placed on a wall
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
- openingKind/openingShape: hinged door or frameless wall opening shape
- segments: rows stacked top to bottom, each defining its own columnRatios
- type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
- hingesSide/swingDirection/swingAngle: which way the door opens and how far it is currently open
@@ -78,6 +78,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const {
width,
height,
openingKind,
frameThickness,
frameDepth,
threshold,
@@ -97,6 +98,11 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
if (openingKind === 'opening') {
syncDoorCutout(node, mesh)
return
}
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
const leafW = width - 2 * frameThickness
const leafH = height - frameThickness // only top frame
@@ -306,6 +312,10 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ)
}
syncDoorCutout(node, mesh)
}
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
+79 -1
View File
@@ -5,7 +5,7 @@ import { computeBoundsTree } from 'three-mesh-bvh'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import type { AnyNode, AnyNodeId, DoorNode, WallNode } from '../../schema'
import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
@@ -546,6 +546,11 @@ function collectCutoutBrushes(
for (const child of childrenNodes) {
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
if (child.type === 'door' && child.openingKind === 'opening') {
brushes.push(createDoorOpeningCutoutBrush(child, wallThickness))
continue
}
const childMesh = sceneRegistry.nodes.get(child.id)
if (!childMesh) continue
@@ -600,3 +605,76 @@ function collectCutoutBrushes(
return brushes
}
function createDoorOpeningCutoutBrush(door: DoorNode, wallThickness: number): Brush {
const shape = createDoorOpeningCutoutShape(door)
const depth = wallThickness * 2
const bevelSize =
door.openingShape === 'rounded'
? Math.min(
Math.max(door.openingRevealRadius ?? 0.025, 0),
Math.max(wallThickness * 0.45, 0.001),
Math.max((door.cornerRadius ?? 0.15) * 0.45, 0.001),
)
: 0
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: bevelSize > 0,
bevelSegments: bevelSize > 0 ? 8 : 0,
bevelSize,
bevelThickness: bevelSize,
curveSegments: 24,
})
geometry.translate(0, 0, -depth / 2)
geometry.computeBoundsTree = computeBoundsTree
geometry.computeBoundsTree({ maxLeafSize: 10 })
return new Brush(geometry)
}
function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape {
const halfWidth = door.width / 2
const bottom = door.position[1] - door.height / 2
const top = door.position[1] + door.height / 2
const centerX = door.position[0]
const left = centerX - halfWidth
const right = centerX + halfWidth
const width = Math.max(door.width, 1e-6)
const height = Math.max(door.height, 1e-6)
const shape = new THREE.Shape()
if (door.openingShape === 'arch') {
const archHeight = Math.min(Math.max(door.archHeight ?? width / 2, 0.01), height)
const springY = top - archHeight
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, springY)
shape.quadraticCurveTo(centerX, top, left, springY)
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
if (door.openingShape === 'rounded') {
const radius = Math.min(Math.max(door.cornerRadius ?? 0.15, 0), width / 2, height)
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, top - radius)
shape.absarc(right - radius, top - radius, radius, 0, Math.PI / 2, false)
shape.lineTo(left + radius, top)
shape.absarc(left + radius, top - radius, radius, Math.PI / 2, Math.PI, false)
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, top)
shape.lineTo(left, top)
shape.closePath()
return shape
}
@@ -45,6 +45,12 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
return 'isMesh' in object && (object as THREE.Mesh).isMesh
}
function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
return Array.isArray(material)
? material.some((entry) => entry.visible)
: material.visible
}
function cloneWorldGeometry(mesh: THREE.Mesh) {
const sourceGeometry = mesh.geometry
const position = sourceGeometry.getAttribute('position')
@@ -79,6 +85,8 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
const node = useScene.getState().nodes[nodeId]
if (!node || node.type !== 'door') return false
if (node.openingKind === 'opening') return true
if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty')
@@ -109,7 +117,12 @@ function collectColliderGeometriesFromNode(
if (visitedMeshes.has(object)) return
visitedMeshes.add(object)
if (isMesh(object) && object.visible && !SKIPPED_MESH_NAMES.has(object.name)) {
if (
isMesh(object) &&
object.visible &&
isColliderMaterialVisible(object.material) &&
!SKIPPED_MESH_NAMES.has(object.name)
) {
const geometry = cloneWorldGeometry(object)
if (geometry) {
geometries.push(geometry)
@@ -4346,9 +4346,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
const doorCubeSize = Math.min(Math.max(width * 0.08, 0.06), 0.12)
const doorCubeInset = doorCubeSize * 0.5
const doorCubeStroke = palette.openingStroke
const hingeCubeCenter = { x: hx + nx * doorCubeInset, y: hy + ny * doorCubeInset }
const strikeCubeCenter = { x: ox2 - nx * doorCubeInset, y: oy2 - ny * doorCubeInset }
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
const hingeCubeCenter = {
x: hx + nx * hingeTangentSign * doorCubeInset,
y: hy + ny * hingeTangentSign * doorCubeInset,
}
const strikeCubeCenter = {
x: ox2 - nx * hingeTangentSign * doorCubeInset,
y: oy2 - ny * hingeTangentSign * doorCubeInset,
}
const leafHalfThickness = doorCubeSize * 0.18
const leafSideOffset = hingeTangentSign * (doorCubeSize / 2 + leafHalfThickness)
const leafStart = {
@@ -4377,7 +4383,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
x: leafStart.x + closedLeafVector.x * openCos - closedLeafVector.y * openSin,
y: leafStart.y + closedLeafVector.x * openSin + closedLeafVector.y * openCos,
}
const doorBackgroundPoints = [
const doorBackgroundPointList = [
{
x: svgP1.x - px * depthDirectionSign * depthExtraOffset,
y: svgP1.y - py * depthDirectionSign * depthExtraOffset,
@@ -4395,8 +4401,76 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
y: svgP4.y + py * depthDirectionSign * depthExtraOffset,
},
]
const doorBackgroundPoints = doorBackgroundPointList
.map((point) => `${point.x},${point.y}`)
.join(' ')
const openingPlanPath =
opening.openingKind === 'opening' && opening.openingShape === 'rounded'
? (() => {
const [a, b, c, d] = doorBackgroundPointList
if (!(a && b && c && d)) return null
const tangentRadius = Math.min(width * 0.14, doorCubeSize * 1.6)
const depthRadius = Math.min(
Math.hypot(svgP4.x - svgP1.x, svgP4.y - svgP1.y) * 0.42,
doorCubeSize,
)
const radius = Math.min(tangentRadius, depthRadius)
const offset = (from: Point2D, to: Point2D, distance: number) => {
const dx = to.x - from.x
const dy = to.y - from.y
const length = Math.hypot(dx, dy)
if (length < 1e-6) return from
return {
x: from.x + (dx / length) * Math.min(distance, length / 2),
y: from.y + (dy / length) * Math.min(distance, length / 2),
}
}
const aToB = offset(a, b, radius)
const bToA = offset(b, a, radius)
const bToC = offset(b, c, radius)
const cToB = offset(c, b, radius)
const cToD = offset(c, d, radius)
const dToC = offset(d, c, radius)
const dToA = offset(d, a, radius)
const aToD = offset(a, d, radius)
return [
`M ${aToB.x} ${aToB.y}`,
`L ${bToA.x} ${bToA.y}`,
`Q ${b.x} ${b.y} ${bToC.x} ${bToC.y}`,
`L ${cToB.x} ${cToB.y}`,
`Q ${c.x} ${c.y} ${cToD.x} ${cToD.y}`,
`L ${dToC.x} ${dToC.y}`,
`Q ${d.x} ${d.y} ${dToA.x} ${dToA.y}`,
`L ${aToD.x} ${aToD.y}`,
`Q ${a.x} ${a.y} ${aToB.x} ${aToB.y}`,
'Z',
].join(' ')
})()
: null
const archPlanPath =
opening.openingKind === 'opening' && opening.openingShape === 'arch'
? (() => {
const centerStart = {
x: (svgP1.x + svgP4.x) / 2,
y: (svgP1.y + svgP4.y) / 2,
}
const centerEnd = {
x: (svgP2.x + svgP3.x) / 2,
y: (svgP2.y + svgP3.y) / 2,
}
const midpoint = {
x: (centerStart.x + centerEnd.x) / 2,
y: (centerStart.y + centerEnd.y) / 2,
}
const bow = Math.min(width * 0.18, doorCubeSize * 1.8)
return `M ${centerStart.x} ${centerStart.y} Q ${midpoint.x + px * bow} ${
midpoint.y + py * bow
} ${centerEnd.x} ${centerEnd.y}`
})()
: null
const leafPolygonPoints = [
{
x: leafStart.x - nx * leafHalfThickness,
@@ -4467,34 +4541,67 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
vectorEffect="non-scaling-stroke"
/>
)}
<polygon fill="#ffffff" points={doorBackgroundPoints} stroke="none" />
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect
fill="#ffffff"
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
stroke={doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2}
/>
))}
<polygon
fill="#ffffff"
points={leafPolygonPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
/>
<path
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
{opening.openingKind === 'opening' ? (
<>
{openingPlanPath ? (
<path
d={openingPlanPath}
fill="#ffffff"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
/>
) : (
<polygon
fill="#ffffff"
points={doorBackgroundPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
/>
)}
{archPlanPath && (
<path
d={archPlanPath}
fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
)}
</>
) : (
<>
<polygon fill="#ffffff" points={doorBackgroundPoints} stroke="none" />
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect
fill="#ffffff"
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
stroke={doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2}
/>
))}
<polygon
fill="#ffffff"
points={leafPolygonPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
/>
<path
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
</>
)}
</g>
)
}
@@ -85,9 +85,10 @@ export function DoorPanel() {
}, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
const numSegs = node?.segments.length
const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node?.segments.map((s) => s.heightRatio / totalH)
if (!node) return
const numSegs = node.segments.length
const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node.segments.map((s) => s.heightRatio / totalH)
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
const delta = clamped - normH[segIdx]!
@@ -97,7 +98,7 @@ export function DoorPanel() {
if (i === neighborIdx) return neighborVal
return v
})
const updated = node?.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
const updated = node.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
handleUpdate({ segments: updated })
}
@@ -131,6 +132,11 @@ export function DoorPanel() {
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
openingKind: node.openingKind,
openingShape: node.openingShape,
cornerRadius: node.cornerRadius,
archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius,
contentPadding: node.contentPadding,
hingesSide: node.hingesSide,
swingDirection: node.swingDirection,
@@ -177,6 +183,11 @@ export function DoorPanel() {
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const cornerRadius = node.cornerRadius ?? 0.15
const archHeight = node.archHeight ?? 0.45
const openingRevealRadius = node.openingRevealRadius ?? 0.025
return (
<PanelWrapper
@@ -206,6 +217,31 @@ export function DoorPanel() {
</PresetsPopover>
</div>
<PanelSection title="Type">
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate(
v === 'opening'
? {
openingKind: v,
openingShape,
cornerRadius,
archHeight,
openingRevealRadius,
}
: { openingKind: v },
)
}
options={[
{ label: 'Door', value: 'door' },
{ label: 'Opening', value: 'opening' },
]}
value={node.openingKind}
/>
</div>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label={
@@ -221,14 +257,16 @@ export function DoorPanel() {
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
{!isOpening && (
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
)}
</PanelSection>
<PanelSection title="Dimensions">
@@ -256,6 +294,66 @@ export function DoorPanel() {
/>
</PanelSection>
{isOpening && (
<PanelSection title="Opening Shape">
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate({
openingShape: v,
...(v === 'rounded' ? { cornerRadius, openingRevealRadius } : {}),
...(v === 'arch' ? { archHeight } : {}),
})
}
options={[
{ label: 'Rect', value: 'rectangle' },
{ label: 'Rounded', value: 'rounded' },
{ label: 'Arch', value: 'arch' },
]}
value={openingShape}
/>
</div>
{openingShape === 'rounded' && (
<>
<SliderControl
label="Corner Radius"
max={Math.min(node.width / 2, node.height)}
min={0}
onChange={(v) => handleUpdate({ cornerRadius: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
<SliderControl
label="Reveal Radius"
max={0.08}
min={0}
onChange={(v) => handleUpdate({ openingRevealRadius: v })}
precision={3}
step={0.005}
unit="m"
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</>
)}
{openingShape === 'arch' && (
<SliderControl
label="Arch Height"
max={node.height}
min={0.05}
onChange={(v) => handleUpdate({ archHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
/>
)}
</PanelSection>
)}
{!isOpening && (
<>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
@@ -567,6 +665,9 @@ export function DoorPanel() {
</div>
</PanelSection>
</>
)}
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
+12 -8
View File
@@ -153,12 +153,14 @@ export const useKeyboard = ({
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'door') {
e.preventDefault()
const currentSwingAngle = node.swingAngle ?? 0
useScene.getState().updateNode(node.id, {
swingAngle:
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
})
sfxEmitter.emit('sfx:item-rotate')
if (node.openingKind !== 'opening') {
const currentSwingAngle = node.swingAngle ?? 0
useScene.getState().updateNode(node.id, {
swingAngle:
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
})
sfxEmitter.emit('sfx:item-rotate')
}
} else if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
@@ -181,8 +183,10 @@ export const useKeyboard = ({
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'door') {
e.preventDefault()
useScene.getState().updateNode(node.id, { swingAngle: 0 })
sfxEmitter.emit('sfx:item-rotate')
if (node.openingKind !== 'opening') {
useScene.getState().updateNode(node.id, { swingAngle: 0 })
sfxEmitter.emit('sfx:item-rotate')
}
} else if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
@@ -1,8 +1,9 @@
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useLayoutEffect, useRef } from 'react'
import { MeshBasicMaterial, type Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const ref = useRef<Mesh>(null!)
@@ -14,16 +15,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_DOOR_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<mesh
castShadow
material={material}
material={doorHitboxMaterial}
position={node.position}
receiveShadow
ref={ref}