Skip to content

Commit e91b2a9

Browse files
stand the batch down in every wall mode but "up"
The merged mesh captures one material set when it is sewn and nothing re-reads it. That holds in "up", where a wall's materials never move. It does not hold anywhere else: "cutaway" re-assigns them from the camera's facing test every time the view turns far enough, and "down" and "translucent" make every wall see-through. In those modes the merged copy kept drawing walls the cutaway pass had already turned to glass, so rotating the camera left the near walls solid and the mode did nothing. Isolation already had a stand-down for a related reason; this puts both behind one predicate rather than growing a second mechanism. Selection and delete-hover tints are applied the same way — by swapping the materials on the wall's own mesh — so a tinted wall now drops out of its batch and draws itself, the way an edited one already did. Selection itself was never affected: it is drawn by the outline pass, not the tint. The batch therefore buys nothing in cutaway mode. Recovering it there needs the merged buffer ordered by wall normal so the camera-dependent hidden set stays contiguous, which is a change of its own.
1 parent d116227 commit e91b2a9

2 files changed

Lines changed: 91 additions & 10 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import type { WallMode } from '../../store/use-viewer'
3+
import { canBatchWalls } from './wall-batch-system'
4+
5+
/**
6+
* The merged mesh captures one material set when it is sewn and never re-reads
7+
* it, so it may only exist while every batched wall's materials hold still.
8+
* These are the states in which that is true.
9+
*/
10+
describe('canBatchWalls', () => {
11+
test('merges in the one mode that leaves wall materials alone', () => {
12+
expect(canBatchWalls('up', false)).toBe(true)
13+
})
14+
15+
test('stands down in cutaway — the facing test re-assigns materials as the camera turns', () => {
16+
expect(canBatchWalls('cutaway', false)).toBe(false)
17+
})
18+
19+
test('stands down in the modes that make walls see-through', () => {
20+
expect(canBatchWalls('down', false)).toBe(false)
21+
expect(canBatchWalls('translucent', false)).toBe(false)
22+
})
23+
24+
test('stands down under isolation whatever the wall mode', () => {
25+
const modes: WallMode[] = ['up', 'cutaway', 'down', 'translucent']
26+
for (const mode of modes) {
27+
expect(canBatchWalls(mode, true)).toBe(false)
28+
}
29+
})
30+
})

packages/viewer/src/systems/wall/wall-batch-system.tsx

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
revealBatchedWall,
1414
type WallBatch,
1515
} from '../../lib/wall-batch'
16+
import useViewer, { type WallMode } from '../../store/use-viewer'
1617
import { drainRebuiltWalls, getPendingWallRebuildCount } from './wall-system'
1718

1819
// A level's walls are merged only once they stop changing. Below this many
@@ -105,6 +106,43 @@ function toCandidate(nodeId: string, node: WallNode): Candidate | null {
105106
return { nodeId, mesh, materials }
106107
}
107108

109+
/**
110+
* Whether a level's walls may be merged at all right now.
111+
*
112+
* The merged mesh captures one material set when it is sewn and nothing
113+
* re-reads it, so batching is only sound while every batched wall's materials
114+
* hold still. That is true in one wall mode. `cutaway` re-assigns materials
115+
* from the camera's facing test as the view turns, `down` makes every wall
116+
* see-through and `translucent` does the same by definition — in all three the
117+
* merged copy would keep drawing walls the cutaway pass has since turned to
118+
* glass. Isolation is the other stand-down: it hides the level root the merged
119+
* mesh hangs off, which would leave a focused batched wall drawn by nobody.
120+
*/
121+
export function canBatchWalls(wallMode: WallMode, isolationActive: boolean): boolean {
122+
return !isolationActive && wallMode === 'up'
123+
}
124+
125+
/**
126+
* Walls the cutaway pass is currently tinting — a selection or a delete hover.
127+
*
128+
* It paints them by swapping the materials on the wall's own mesh, which the
129+
* merged mesh does not follow, so a lit wall goes back to drawing itself. There
130+
* are only ever a handful, and a handful of extra draw calls is what the tint
131+
* costs.
132+
*/
133+
function collectTintedWalls(wallIds: ReadonlySet<string>): string[] {
134+
const viewer = useViewer.getState()
135+
const tinted: string[] = []
136+
137+
for (const id of viewer.selection.selectedIds) if (wallIds.has(id)) tinted.push(id)
138+
for (const id of viewer.previewSelectedIds) if (wallIds.has(id)) tinted.push(id)
139+
140+
const hovered = viewer.hoverHighlightMode === 'delete' ? viewer.hoveredId : null
141+
if (hovered && wallIds.has(hovered)) tinted.push(hovered)
142+
143+
return tinted
144+
}
145+
108146
function materialSetKey(materials: readonly Material[]): string {
109147
return materials.map((material) => material.uuid).join('|')
110148
}
@@ -269,6 +307,18 @@ function runBatchFrame(
269307
}
270308
changedWalls.clear()
271309

310+
// A tinted wall paints itself through materials the merged mesh never reads,
311+
// so it goes back to drawing its own geometry for as long as it is lit. It
312+
// stays out afterwards: one wall short of a batch is not worth re-sewing a
313+
// floor over, and the level's own re-merge threshold decides when it is.
314+
for (const nodeId of collectTintedWalls(wallIds)) {
315+
if (!batchByNode.has(nodeId)) continue
316+
const record = batchByNode.get(nodeId)
317+
if (record) staleLevels.add(record.levelId)
318+
releaseWall(nodeId)
319+
changed = true
320+
}
321+
272322
// A wall that left the scene carries no mark of its own — deleting one
273323
// dirties the neighbours it re-mitres, not the node that went away. The
274324
// wall count moving is the cheap tell that the batch needs reconciling.
@@ -282,18 +332,19 @@ function runBatchFrame(
282332
}
283333
}
284334

285-
// Isolation hides everything outside the focused subtree, and a level's
286-
// merged mesh hangs off the level root — so it goes dark with everything
287-
// else. A focused wall that the batch had sewn in would then be drawn by
288-
// nobody: its own mesh is silent, its stand-in is hidden. Rather than teach
289-
// the filter about merged geometry, the batch stands down for as long as the
290-
// filter is up and sews the floors back together once it lifts.
291-
const isolated = isIsolationActive()
292-
if (isolated !== batchingSuspended) {
293-
batchingSuspended = isolated
335+
// Two things make merging unsound, and both are handled the same way: the
336+
// batch stands down for as long as they hold, and sews the floors back
337+
// together once they lift. Isolation hides everything outside the focused
338+
// subtree, and a level's merged mesh hangs off the level root — so it goes
339+
// dark with everything else, leaving a focused batched wall drawn by nobody.
340+
// Every wall mode but `up` re-assigns wall materials the merged mesh does not
341+
// follow. See `canBatchWalls`.
342+
const suspended = !canBatchWalls(useViewer.getState().wallMode, isIsolationActive())
343+
if (suspended !== batchingSuspended) {
344+
batchingSuspended = suspended
294345
for (const levelId of [...batchesByLevel.keys()]) disposeLevelBatches(levelId)
295346
staleLevels.clear()
296-
if (!isolated) {
347+
if (!suspended) {
297348
for (const levelId of sceneRegistry.byType.level ?? EMPTY_IDS) staleLevels.add(levelId)
298349
}
299350
changed = true

0 commit comments

Comments
 (0)