From cebb29460d1ee6318b4b84a6f892e6d0b7fada31 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 17 Jun 2026 09:10:43 -0400 Subject: [PATCH] fix(paint-slots): make window/door paint hits land on the tagged children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/viewer/src/systems/door/door-system.tsx | 9 +++++++++ packages/viewer/src/systems/window/window-system.tsx | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 86c9587b..d261cb80 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -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 diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index e49101f0..feed9e73 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -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)