Merge remote-tracking branch 'origin/main' into fix/bake-door-window-anims

This commit is contained in:
Wassim SAMAD
2026-06-28 16:32:41 -04:00
189 changed files with 8853 additions and 3665 deletions
@@ -1,16 +1,19 @@
import {
type AnyNodeId,
clampDoorOperationState,
DEFAULT_WALL_THICKNESS,
type DoorNode,
DoorNode as DoorNodeSchema,
getDoorRenderOpenAmount,
getEffectiveNode,
getWallThickness,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
@@ -26,6 +29,7 @@ import {
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -187,6 +191,19 @@ function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh {
return mesh
}
const NO_RAYCAST = () => {}
// An open door leaf swings perpendicular to the wall, so in a top-down view its
// flat panel blankets the room interior and wins the selection raycast over the
// slab/items beneath it. Drop the swung leaf out of the raycast so a click on
// the floor falls through to what's underneath; the door stays selectable via
// its proud invisible cutout proxy at the opening (see syncDoorCutout).
function disableSubtreeRaycast(object: THREE.Object3D) {
object.traverse((child) => {
;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST
})
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots
if (!slots) return false
@@ -1313,6 +1330,14 @@ function addDoorLeaf(
)
addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0)
}
// When the leaf is swung open it projects into the room and would otherwise
// win a top-down selection click over the floor beneath it. Drop only the
// swung leaf out of the raycast; a closed leaf stays in the wall plane and
// keeps its hit-eligibility (so paint-by-slot still works on it).
if (Math.abs(swingRotation) > 1e-3) {
disableSubtreeRaycast(leafGroup)
}
}
// Names of the re-poseable moving groups each operation-door builder emits. The
@@ -2653,18 +2678,21 @@ function hideEmptyGeometryMeshes(root: THREE.Object3D) {
}
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
// ── Cutout: invisible raycast hit target for the whole opening ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
cutout = new THREE.Mesh()
cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// wins the scene raycast over the wall in front of the recessed door body —
// making it the selection AND paint hit target for the whole opening. The
// paint capability then re-raycasts the door's parts to find the slot.
// The cutout (invisible) is proud of the wall on both faces, so it wins the
// scene raycast over the wall in front of the recessed door body — making it
// the selection AND paint hit target for the whole opening. The paint
// capability then re-raycasts the door's parts to find the slot. Its depth
// is snug to the wall (not 1m) so it no longer blankets the room floor in a
// top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
mesh.add(cutout)
}
cutout.geometry.dispose()
const depth = resolveOpeningCutoutProxyDepth(node)
const openingShape = getEffectiveOpeningShape(node)
if (openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
@@ -2676,12 +2704,12 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else if (openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedTopShape(
@@ -2692,18 +2720,30 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
getDoorTopRadii(node, node.width, node.height),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
}
cutout.visible = false
}
// Resolve the cutout proxy depth from the opening's parent wall thickness so
// the proxy stays proud of both wall faces (front/back selection) without the
// old 1m depth that blanketed the floor. Falls back to the default thickness
// when the parent wall isn't a resolvable wall node.
function resolveOpeningCutoutProxyDepth(node: DoorNode): number {
const parentId = node.parentId
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
const wallThickness =
parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS
return getOpeningCutoutProxyDepth(wallThickness)
}
/**
* Build a fresh door mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
@@ -10,6 +10,20 @@ export type OpeningCutoutRect = {
top: number
}
// The cutout proxy doubles as the invisible raycast hit target for an opening:
// centered on the wall and extending past both faces so it wins the scene
// raycast over the recessed door/window body for front AND back selection +
// paint. It only needs to clear the wall thickness plus a small proud margin —
// the wall CSG brush ignores this proxy's depth entirely (it rebuilds its own
// full-thickness box from the proxy's X/Y bounds in `collectCutoutBrushes`), so
// a snug depth keeps the cut intact while no longer blanketing the room floor in
// a top-down view (the bug a 1m-deep proxy caused in narrow hallways).
const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08
export function getOpeningCutoutProxyDepth(wallThickness: number): number {
return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN
}
type CornerRadii = {
topLeft: number
topRight: number
@@ -1,12 +1,15 @@
import {
type AnyNodeId,
DEFAULT_WALL_THICKNESS,
getEffectiveNode,
getWallThickness,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
@@ -22,6 +25,7 @@ import {
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -164,6 +168,21 @@ function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh {
return mesh
}
const NO_RAYCAST = () => {}
// An open casement sash swings perpendicular to the wall, so in a top-down view
// its flat panel blankets the room interior and wins the selection raycast over
// the slab/items beneath it. Drop the swung sash out of the raycast so a floor
// click falls through; the window stays selectable via its proud invisible
// cutout proxy at the opening (see syncWindowCutout). Skipped while closed so
// paint-by-slot still resolves on the sash.
function disableSubtreeRaycastIfSwung(object: THREE.Object3D, rotationY: number) {
if (Math.abs(rotationY) <= 1e-3) return
object.traverse((child) => {
;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST
})
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots
if (!slots) return false
@@ -1021,6 +1040,8 @@ function addRectCasementSash(
)
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
disableSubtreeRaycastIfSwung(sash, rotationY)
}
function addFrenchCasementHingeMarkers(
@@ -1245,6 +1266,7 @@ function addShapedFrenchCasementSash(
sashDepth * 0.08,
)
}
disableSubtreeRaycastIfSwung(sash, rotationY)
return
}
@@ -1272,6 +1294,7 @@ function addShapedFrenchCasementSash(
sashDepth * 0.08,
)
}
disableSubtreeRaycastIfSwung(sash, rotationY)
}
function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
@@ -1535,6 +1558,8 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
}
}
disableSubtreeRaycastIfSwung(sash, sash.rotation.y)
currentWindowSlot = 'frame'
addBox(
mesh,
@@ -1697,6 +1722,8 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
disableSubtreeRaycastIfSwung(sash, sash.rotation.y)
// Small hinge markers make the pivot side legible when the sash is closed.
currentWindowSlot = 'frame'
addBox(
@@ -3598,20 +3625,23 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
}
function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
// ── Cutout: invisible raycast hit target for the whole opening ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
cutout = new THREE.Mesh()
cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// wins the scene raycast over the wall in front of the recessed window —
// making it the selection AND paint hit target for the whole opening. The
// paint capability then re-raycasts the window's parts to find the slot.
// The cutout (invisible) is proud of the wall on both faces, so it wins the
// scene raycast over the wall in front of the recessed window — making it
// the selection AND paint hit target for the whole opening. The paint
// capability then re-raycasts the window's parts to find the slot. Its depth
// is snug to the wall (not 1m) so it no longer blankets the room floor in a
// top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
mesh.add(cutout)
}
cutout.geometry.dispose()
const depth = resolveOpeningCutoutProxyDepth(node)
if (isRectangleOnlyWindowType(node)) {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
} else if (node.openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape(
@@ -3622,12 +3652,12 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else if (node.openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedShape(
@@ -3638,18 +3668,30 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
getWindowRoundedRadii(node, node.width, node.height),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
}
cutout.visible = false
}
// Resolve the cutout proxy depth from the opening's parent wall thickness so
// the proxy stays proud of both wall faces (front/back selection) without the
// old 1m depth that blanketed the floor. Falls back to the default thickness
// when the parent wall isn't a resolvable wall node.
function resolveOpeningCutoutProxyDepth(node: WindowNode): number {
const parentId = node.parentId
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
const wallThickness =
parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS
return getOpeningCutoutProxyDepth(wallThickness)
}
/**
* Build a fresh window mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).