From 09bfb0b484c44d4a38ec714fa5573a675a10e7af Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 1 May 2026 11:35:26 +0530 Subject: [PATCH] Add frameless door openings and first-person collider support --- packages/core/src/schema/nodes/door.ts | 8 + .../core/src/systems/door/door-system.tsx | 10 ++ .../core/src/systems/wall/wall-system.tsx | 80 ++++++++- .../first-person/build-collider-world.ts | 15 +- .../src/components/editor/floorplan-panel.tsx | 169 ++++++++++++++---- .../src/components/ui/panels/door-panel.tsx | 125 +++++++++++-- packages/editor/src/hooks/use-keyboard.ts | 20 ++- .../renderers/door/door-renderer.tsx | 15 +- 8 files changed, 379 insertions(+), 63 deletions(-) diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index f898216b..57e0ad46 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -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 diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx index 508c9242..93f4abd4 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/core/src/systems/door/door-system.tsx @@ -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) { diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 5a40c09a..ba47cea0 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -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 +} diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index d6a6e82e..0b951ede 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -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) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 53374ae3..d77dffee 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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" /> )} - - {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( - - ))} - - + {opening.openingKind === 'opening' ? ( + <> + {openingPlanPath ? ( + + ) : ( + + )} + {archPlanPath && ( + + )} + + ) : ( + <> + + {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( + + ))} + + + + )} ) } diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index e02f13c7..22142ee4 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -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 ( + +
+ + handleUpdate( + v === 'opening' + ? { + openingKind: v, + openingShape, + cornerRadius, + archHeight, + openingRevealRadius, + } + : { openingKind: v }, + ) + } + options={[ + { label: 'Door', value: 'door' }, + { label: 'Opening', value: 'opening' }, + ]} + value={node.openingKind} + /> +
+
+ -
- } - label="Flip Side" - onClick={handleFlip} - /> -
+ {!isOpening && ( +
+ } + label="Flip Side" + onClick={handleFlip} + /> +
+ )}
@@ -256,6 +294,66 @@ export function DoorPanel() { /> + {isOpening && ( + +
+ + 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} + /> +
+ {openingShape === 'rounded' && ( + <> + handleUpdate({ cornerRadius: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round(cornerRadius * 100) / 100} + /> + handleUpdate({ openingRevealRadius: v })} + precision={3} + step={0.005} + unit="m" + value={Math.round(openingRevealRadius * 1000) / 1000} + /> + + )} + {openingShape === 'arch' && ( + handleUpdate({ archHeight: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round(archHeight * 100) / 100} + /> + )} +
+ )} + + {!isOpening && ( + <> + + )} + } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index a87d40ce..dd762386 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -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 diff --git a/packages/viewer/src/components/renderers/door/door-renderer.tsx b/packages/viewer/src/components/renderers/door/door-renderer.tsx index 6c978f3e..ff389f31 100644 --- a/packages/viewer/src/components/renderers/door/door-renderer.tsx +++ b/packages/viewer/src/components/renderers/door/door-renderer.tsx @@ -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(null!) @@ -14,16 +15,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const handlers = useNodeEvents(node, 'door') const isTransient = !!(node.metadata as Record | 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 (