fix(paint-slots): make window/door paint hits land on the tagged children

The window/door root is an invisible hitbox the system gives full-depth
BoxGeometry; its front face intercepted every paint/hover ray, so the hit
resolved to the hitbox (no slotId) → role null → paint silently disabled.

Disable the hitbox's own raycast in the visual path so R3F's recursive
intersect returns the tagged frame/glass (panel/glass) children instead;
selection still works because those child hits bubble to the root's event
handlers. Restored to the default raycast each build, and kept for 'opening'
windows/doors (no visuals to paint, still need a selectable hitbox). The
'cutout' child is visible=false so the raycaster already skips it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-17 09:10:43 -04:00
co-authored by Claude Opus 4.8
parent 9f1627e923
commit cebb29460d
2 changed files with 18 additions and 0 deletions
@@ -27,6 +27,8 @@ import useViewer from '../../store/use-viewer'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
// Disables a mesh's own raycast so its children become the hit targets.
const noopHitboxRaycast: THREE.Mesh['raycast'] = () => {}
const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' })
let baseMaterial = getBaseMaterial()
let revealMaterial: THREE.Material = defaultRevealMaterial
@@ -2022,6 +2024,10 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
mesh.geometry.dispose()
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
mesh.material = hitboxMaterial
// Default (selectable) hitbox raycast — restored each build; the visual path
// below disables it so the tagged panel/glass children are the hit targets
// (otherwise the full-depth invisible box intercepts every paint/hover ray).
mesh.raycast = THREE.Mesh.prototype.raycast
// Sync transform from node (React may lag behind the system by a frame during drag)
mesh.position.set(node.position[0], node.position[1], node.position[2])
@@ -2077,6 +2083,9 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
return
}
// Visuals exist: let the tagged children receive paint/hover/selection rays.
mesh.raycast = noopHitboxRaycast
const insideWidth = width - 2 * frameThickness
const leafH = height - frameThickness // only top frame
const leafDepth = 0.04
@@ -24,6 +24,8 @@ import useViewer from '../../store/use-viewer'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
// Disables a mesh's own raycast so its children become the hit targets.
const noopHitboxRaycast: THREE.Mesh['raycast'] = () => {}
let baseMaterial = getBaseMaterial()
let glassMaterial: THREE.Material = defaultGlassMaterial
// Per-frame viewer state, captured so the per-node mesh builder (which runs
@@ -3228,6 +3230,10 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
mesh.geometry.dispose()
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
mesh.material = hitboxMaterial
// Default (selectable) hitbox raycast — restored each build; the visual path
// below disables it so the tagged frame/glass children are the hit targets
// (otherwise the full-depth invisible box intercepts every paint/hover ray).
mesh.raycast = THREE.Mesh.prototype.raycast
// Sync transform from node (React may lag behind the system by a frame during drag)
mesh.position.set(node.position[0], node.position[1], node.position[2])
@@ -3268,6 +3274,9 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
return
}
// Visuals exist: let the tagged children receive paint/hover/selection rays.
mesh.raycast = noopHitboxRaycast
if (windowType === 'sliding') {
addSlidingWindowVisuals(node, mesh)
syncWindowCutout(node, mesh)