From 8cd34aa12267a2c5e48e00eb432325cd00d2d02c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 19 Aug 2026 10:58:15 -0400 Subject: [PATCH 1/2] fix(nodes/wall): let a hovered wall out of its batch so it can outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hover feedback for a wall is an outline, nothing else: `SelectionMaterialSync` skips walls outright and the cutaway pass tints only a selection or a delete hover, so `default`, `paint-ready` and `paint-disabled` all reduce to `outliner.hoveredObjects` and a colour. `MergedOutlineNode` draws that mask with `renderer.render(scene, camera)` on the main camera, which enables no `BATCHED_LAYER`. Once #608 sews a level's walls (wall mode `up`, 8+ walls, 180ms quiet) every wall on the floor is on that layer alone, so it reaches neither the depth pass nor a mask pass and hovering it lights up nothing at all. Selection looks fine only because `collectTintedWalls` already pulls a selected wall back out of the batch — the click lands, the wall starts drawing itself, and the outline appears. Hover had no such release, so on any finished floor the wall you point at goes dark: no hover outline in select mode, and in paint mode no preview of the surface the next click will paint. Release on any hover, not just a delete one. Same mechanism, one more reason: selection and delete tint through materials the merged mesh never reads, a plain hover outlines through a camera that never sees it, and both are fixed by the wall drawing its own geometry while it is lit. One wall is lit at a time, so the cost is the one extra draw call the delete path already accepted. Verified on a 12-wall level in the community editor: before, hovering a wall in `Full height` produced no outline in select or paint mode while `Cutaway` (where batching stands down) outlined correctly; after, both modes outline in `Full height`. Co-Authored-By: Claude Opus 5 --- .../nodes/src/wall/wall-batch-system.test.ts | 27 ++++++++++++++++++- packages/nodes/src/wall/wall-batch-system.tsx | 22 ++++++++++----- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index bc6ce39aa..fd9ad1379 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test } from 'bun:test' import { sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { BufferGeometry, Float32BufferAttribute, Mesh, MeshBasicMaterial } from 'three' -import { collectWallBatchCandidates } from './wall-batch-system' +import { collectTintedWalls, collectWallBatchCandidates } from './wall-batch-system' const registeredIds: string[] = [] @@ -14,6 +15,7 @@ afterEach(() => { sceneRegistry.nodes.delete(id) } useScene.setState({ nodes: {}, rootNodeIds: [] } as never) + useViewer.setState({ hoverHighlightMode: 'default', hoveredId: null } as never) }) function registerWall(id: string) { @@ -45,3 +47,26 @@ describe('collectWallBatchCandidates', () => { expect(candidates.map((candidate) => candidate.nodeId)).toEqual(wallIds.slice(8)) }) }) + +describe('collectTintedWalls', () => { + // Regression: hover feedback for a wall is an outline, and the outline node + // renders through the main camera — which never sees a sewn wall. A hovered + // wall has to leave its batch or hovering it lights up nothing, in select + // mode and in paint mode alike. + const wallIds = new Set(['wall_a', 'wall_b']) + + test.each(['default', 'paint-ready', 'paint-disabled', 'delete'])( + 'a %s hover takes the wall out of its batch', + (hoverHighlightMode) => { + useViewer.setState({ hoverHighlightMode, hoveredId: 'wall_a' } as never) + + expect([...collectTintedWalls(wallIds)]).toEqual(['wall_a']) + }, + ) + + test('a hover over a non-wall node tints nothing', () => { + useViewer.setState({ hoverHighlightMode: 'default', hoveredId: 'slab_a' } as never) + + expect([...collectTintedWalls(wallIds)]).toEqual([]) + }) +}) diff --git a/packages/nodes/src/wall/wall-batch-system.tsx b/packages/nodes/src/wall/wall-batch-system.tsx index bb1694651..e4dc99f80 100644 --- a/packages/nodes/src/wall/wall-batch-system.tsx +++ b/packages/nodes/src/wall/wall-batch-system.tsx @@ -182,21 +182,29 @@ export function canBatchWalls(wallMode: WallMode, isolationActive: boolean): boo } /** - * Walls the cutaway pass is currently tinting — a selection or a delete hover. + * Walls the viewer is currently lighting up — a selection, or any hover. * - * It paints them by swapping the materials on the wall's own mesh, which the - * merged mesh does not follow, so a lit wall goes back to drawing itself. There - * are only ever a handful, and a handful of extra draw calls is what the tint - * costs. + * A selection or delete hover paints the wall by swapping the materials on its + * own mesh, which the merged mesh does not follow. Every other hover draws an + * outline instead, and that needs the same thing for a different reason: the + * outline node renders `outliner.hoveredObjects` through the main camera, which + * enables no batched layer, so a sewn wall reaches neither mask pass and + * hovering it lights up nothing at all — in select mode, and in paint mode + * where the outline is the only signal for which surface the next click lands + * on. + * + * Both wants are the same one: a lit wall goes back to drawing its own + * geometry. Only ever a handful are lit at once, and a handful of extra draw + * calls is what lighting them costs. */ -function collectTintedWalls(wallIds: ReadonlySet): Set { +export function collectTintedWalls(wallIds: ReadonlySet): Set { const viewer = useViewer.getState() const tinted = new Set() for (const id of viewer.selection.selectedIds) if (wallIds.has(id)) tinted.add(id) for (const id of viewer.previewSelectedIds) if (wallIds.has(id)) tinted.add(id) - const hovered = viewer.hoverHighlightMode === 'delete' ? viewer.hoveredId : null + const hovered = viewer.hoveredId if (hovered && wallIds.has(hovered)) tinted.add(hovered) return tinted From e555cf69691ce1fb0bac2eca992c943d06dd47ea Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 19 Aug 2026 12:09:56 -0400 Subject: [PATCH 2/2] chore: apply biome formatting to the hover test Co-Authored-By: Claude Opus 5 --- .../nodes/src/wall/wall-batch-system.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index fd9ad1379..286bcb266 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -55,14 +55,16 @@ describe('collectTintedWalls', () => { // mode and in paint mode alike. const wallIds = new Set(['wall_a', 'wall_b']) - test.each(['default', 'paint-ready', 'paint-disabled', 'delete'])( - 'a %s hover takes the wall out of its batch', - (hoverHighlightMode) => { - useViewer.setState({ hoverHighlightMode, hoveredId: 'wall_a' } as never) + test.each([ + 'default', + 'paint-ready', + 'paint-disabled', + 'delete', + ])('a %s hover takes the wall out of its batch', (hoverHighlightMode) => { + useViewer.setState({ hoverHighlightMode, hoveredId: 'wall_a' } as never) - expect([...collectTintedWalls(wallIds)]).toEqual(['wall_a']) - }, - ) + expect([...collectTintedWalls(wallIds)]).toEqual(['wall_a']) + }) test('a hover over a non-wall node tints nothing', () => { useViewer.setState({ hoverHighlightMode: 'default', hoveredId: 'slab_a' } as never)