@pascal-app/nodes 1.0.0-beta.3 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cabinet/flame-index.d.ts +3 -0
- package/dist/cabinet/flame-index.d.ts.map +1 -0
- package/dist/cabinet/flame-index.js +13 -0
- package/dist/cabinet/system.d.ts +2 -0
- package/dist/cabinet/system.d.ts.map +1 -1
- package/dist/cabinet/system.js +28 -8
- package/dist/cabinet/tool.d.ts.map +1 -1
- package/dist/cabinet/tool.js +2 -1
- package/dist/ceiling/panel.d.ts.map +1 -1
- package/dist/ceiling/panel.js +8 -2
- package/dist/ceiling/parametrics.js +1 -1
- package/dist/column/panel.js +1 -1
- package/dist/column/parametrics.js +1 -1
- package/dist/dormer/placement-guides.d.ts.map +1 -1
- package/dist/dormer/placement-guides.js +5 -4
- package/dist/elevator/definition.d.ts.map +1 -1
- package/dist/elevator/definition.js +2 -0
- package/dist/fence/curve-tool.d.ts.map +1 -1
- package/dist/fence/curve-tool.js +14 -9
- package/dist/fence/move-control-point-tool.d.ts.map +1 -1
- package/dist/fence/move-control-point-tool.js +13 -8
- package/dist/fence/move-tangent-tool.d.ts.map +1 -1
- package/dist/fence/move-tangent-tool.js +13 -8
- package/dist/fence/tool.d.ts.map +1 -1
- package/dist/fence/tool.js +8 -11
- package/dist/measurement/renderer.d.ts.map +1 -1
- package/dist/measurement/renderer.js +6 -5
- package/dist/measurement/surface-query.js +5 -5
- package/dist/measurement/tool.d.ts.map +1 -1
- package/dist/measurement/tool.js +7 -5
- package/dist/ridge-vent/renderer.d.ts.map +1 -1
- package/dist/ridge-vent/renderer.js +2 -2
- package/dist/shared/mep-ghost.d.ts.map +1 -1
- package/dist/shared/mep-ghost.js +19 -1
- package/dist/slab/panel.d.ts.map +1 -1
- package/dist/slab/panel.js +1 -1
- package/dist/wall/curve-tool.d.ts +3 -4
- package/dist/wall/curve-tool.d.ts.map +1 -1
- package/dist/wall/curve-tool.js +17 -13
- package/dist/wall/panel.d.ts.map +1 -1
- package/dist/wall/panel.js +56 -14
- package/dist/wall/parametrics.js +1 -1
- package/dist/wall/system.d.ts +3 -0
- package/dist/wall/system.d.ts.map +1 -1
- package/dist/wall/system.js +5 -1
- package/dist/wall/tool.d.ts.map +1 -1
- package/dist/wall/tool.js +24 -27
- package/dist/wall/wall-batch-system.d.ts +23 -0
- package/dist/wall/wall-batch-system.d.ts.map +1 -0
- package/dist/wall/wall-batch-system.js +389 -0
- package/dist/wall/wall-batch.d.ts +66 -0
- package/dist/wall/wall-batch.d.ts.map +1 -0
- package/dist/wall/wall-batch.js +169 -0
- package/dist/zone/quantities-panel.d.ts.map +1 -1
- package/dist/zone/quantities-panel.js +4 -3
- package/package.json +7 -7
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type WallMode } from '@pascal-app/viewer';
|
|
2
|
+
import { type Material, Mesh } from 'three';
|
|
3
|
+
export type WallBatchCandidate = {
|
|
4
|
+
nodeId: string;
|
|
5
|
+
mesh: Mesh;
|
|
6
|
+
materials: Material[];
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Whether a level's walls may be merged at all right now.
|
|
10
|
+
*
|
|
11
|
+
* The merged mesh captures one material set when it is sewn and nothing
|
|
12
|
+
* re-reads it, so batching is only sound while every batched wall's materials
|
|
13
|
+
* hold still. That is true in one wall mode. `cutaway` re-assigns materials
|
|
14
|
+
* from the camera's facing test as the view turns, `down` makes every wall
|
|
15
|
+
* see-through and `translucent` does the same by definition — in all three the
|
|
16
|
+
* merged copy would keep drawing walls the cutaway pass has since turned to
|
|
17
|
+
* glass. Isolation is the other stand-down: it hides the level root the merged
|
|
18
|
+
* mesh hangs off, which would leave a focused batched wall drawn by nobody.
|
|
19
|
+
*/
|
|
20
|
+
export declare function canBatchWalls(wallMode: WallMode, isolationActive: boolean): boolean;
|
|
21
|
+
export declare function collectWallBatchCandidates(levelId: string, excludedNodeIds?: ReadonlySet<string>): Map<string, WallBatchCandidate[]>;
|
|
22
|
+
export declare const WallBatchSystem: () => null;
|
|
23
|
+
//# sourceMappingURL=wall-batch-system.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wall-batch-system.d.ts","sourceRoot":"","sources":["../../src/wall/wall-batch-system.tsx"],"names":[],"mappings":"AAGA,OAAO,EAML,KAAK,QAAQ,EACd,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,KAAK,QAAQ,EAAW,IAAI,EAAiB,MAAM,OAAO,CAAA;AA4HnE,MAAM,MAAM,kBAAkB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,QAAQ,EAAE,CAAA;CAAE,CAAA;AA8BtF;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,eAAe,EAAE,OAAO,GAAG,OAAO,CAEnF;AA2BD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,MAAM,EACf,eAAe,GAAE,WAAW,CAAC,MAAM,CAAa,GAC/C,GAAG,CAAC,MAAM,EAAE,kBAAkB,EAAE,CAAC,CAoBnC;AAqFD,eAAO,MAAM,eAAe,YAwB3B,CAAA"}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { sceneRegistry, useScene } from '@pascal-app/core';
|
|
3
|
+
import { drainRebuiltWalls, getPendingWallRebuildCount, isIsolationActive, SCENE_LAYER, useViewer, } from '@pascal-app/viewer';
|
|
4
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
5
|
+
import { useEffect, useRef } from 'react';
|
|
6
|
+
import { Matrix4, Mesh } from 'three';
|
|
7
|
+
import { applyWallBatchGroups, buildWallBatch, hideBatchedWall, revealBatchedWall, } from './wall-batch';
|
|
8
|
+
// A level's walls are merged only once they stop changing. Below this many
|
|
9
|
+
// walls a merge is not worth the buffer, and the leftovers (a selected wall,
|
|
10
|
+
// a lone partition) keep drawing themselves.
|
|
11
|
+
const MIN_BATCH_WALLS = 8;
|
|
12
|
+
// Quiet window after the last wall change before the merge runs.
|
|
13
|
+
const BATCH_SETTLE_MS = 180;
|
|
14
|
+
const lastAppearance = {
|
|
15
|
+
shading: undefined,
|
|
16
|
+
textures: undefined,
|
|
17
|
+
colorPreset: undefined,
|
|
18
|
+
sceneTheme: undefined,
|
|
19
|
+
materials: null,
|
|
20
|
+
};
|
|
21
|
+
function appearanceChanged() {
|
|
22
|
+
const viewer = useViewer.getState();
|
|
23
|
+
const materials = useScene.getState().materials;
|
|
24
|
+
if (lastAppearance.shading === viewer.shading &&
|
|
25
|
+
lastAppearance.textures === viewer.textures &&
|
|
26
|
+
lastAppearance.colorPreset === viewer.colorPreset &&
|
|
27
|
+
lastAppearance.sceneTheme === viewer.sceneTheme &&
|
|
28
|
+
lastAppearance.materials === materials) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
lastAppearance.shading = viewer.shading;
|
|
32
|
+
lastAppearance.textures = viewer.textures;
|
|
33
|
+
lastAppearance.colorPreset = viewer.colorPreset;
|
|
34
|
+
lastAppearance.sceneTheme = viewer.sceneTheme;
|
|
35
|
+
lastAppearance.materials = materials;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const batchesByLevel = new Map();
|
|
39
|
+
const batchByNode = new Map();
|
|
40
|
+
const staleLevels = new Set();
|
|
41
|
+
const changedWalls = new Set();
|
|
42
|
+
const EMPTY_IDS = new Set();
|
|
43
|
+
let knownWallCount = -1;
|
|
44
|
+
let lastWallChangeAtMs = 0;
|
|
45
|
+
let batchingSuspended = false;
|
|
46
|
+
/**
|
|
47
|
+
* Batched walls are drawn by the merged mesh but still picked, measured and
|
|
48
|
+
* highlighted through their own meshes, so the merged copy must stay out of
|
|
49
|
+
* every raycast.
|
|
50
|
+
*/
|
|
51
|
+
function skipRaycast() {
|
|
52
|
+
// intentionally empty — see the note above
|
|
53
|
+
}
|
|
54
|
+
function showOwnGeometry(nodeId) {
|
|
55
|
+
const mesh = sceneRegistry.nodes.get(nodeId);
|
|
56
|
+
if (mesh)
|
|
57
|
+
revealBatchedWall(mesh);
|
|
58
|
+
}
|
|
59
|
+
/** Hands a wall back to itself: the merged mesh stops drawing it, it resumes. */
|
|
60
|
+
function releaseWall(nodeId) {
|
|
61
|
+
const record = batchByNode.get(nodeId);
|
|
62
|
+
if (record) {
|
|
63
|
+
record.hidden.add(nodeId);
|
|
64
|
+
applyWallBatchGroups(record.batch, record.hidden);
|
|
65
|
+
batchByNode.delete(nodeId);
|
|
66
|
+
}
|
|
67
|
+
showOwnGeometry(nodeId);
|
|
68
|
+
}
|
|
69
|
+
function disposeLevelBatches(levelId) {
|
|
70
|
+
const records = batchesByLevel.get(levelId);
|
|
71
|
+
if (!records)
|
|
72
|
+
return;
|
|
73
|
+
for (const record of records) {
|
|
74
|
+
record.mesh.removeFromParent();
|
|
75
|
+
record.batch.geometry.dispose();
|
|
76
|
+
for (const nodeId of record.nodeIds) {
|
|
77
|
+
if (batchByNode.get(nodeId) === record)
|
|
78
|
+
batchByNode.delete(nodeId);
|
|
79
|
+
showOwnGeometry(nodeId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
batchesByLevel.delete(levelId);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A wall joins a batch only if its whole material set is opaque. Translucent
|
|
86
|
+
* and cut-away walls depend on per-object blend ordering, which merging would
|
|
87
|
+
* change — they keep the per-wall path.
|
|
88
|
+
*/
|
|
89
|
+
function toCandidate(nodeId, node, excludedNodeIds = EMPTY_IDS) {
|
|
90
|
+
if (excludedNodeIds.has(nodeId))
|
|
91
|
+
return null;
|
|
92
|
+
if (node.visible === false)
|
|
93
|
+
return null;
|
|
94
|
+
const mesh = sceneRegistry.nodes.get(nodeId);
|
|
95
|
+
if (!mesh?.visible)
|
|
96
|
+
return null;
|
|
97
|
+
// Solo's shadow-caster-only pass and the viewer's isolation filter both
|
|
98
|
+
// hide a wall by taking it off the scene layer. Sewing it in would put it
|
|
99
|
+
// back on screen through the merged mesh, which neither asked for.
|
|
100
|
+
if (!mesh.layers.isEnabled(SCENE_LAYER))
|
|
101
|
+
return null;
|
|
102
|
+
if (!mesh.geometry?.getAttribute('position'))
|
|
103
|
+
return null;
|
|
104
|
+
const materials = mesh.material;
|
|
105
|
+
if (!Array.isArray(materials) || materials.length === 0)
|
|
106
|
+
return null;
|
|
107
|
+
if (materials.some((material) => material.transparent))
|
|
108
|
+
return null;
|
|
109
|
+
return { nodeId, mesh, materials };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Whether a level's walls may be merged at all right now.
|
|
113
|
+
*
|
|
114
|
+
* The merged mesh captures one material set when it is sewn and nothing
|
|
115
|
+
* re-reads it, so batching is only sound while every batched wall's materials
|
|
116
|
+
* hold still. That is true in one wall mode. `cutaway` re-assigns materials
|
|
117
|
+
* from the camera's facing test as the view turns, `down` makes every wall
|
|
118
|
+
* see-through and `translucent` does the same by definition — in all three the
|
|
119
|
+
* merged copy would keep drawing walls the cutaway pass has since turned to
|
|
120
|
+
* glass. Isolation is the other stand-down: it hides the level root the merged
|
|
121
|
+
* mesh hangs off, which would leave a focused batched wall drawn by nobody.
|
|
122
|
+
*/
|
|
123
|
+
export function canBatchWalls(wallMode, isolationActive) {
|
|
124
|
+
return !isolationActive && wallMode === 'up';
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Walls the cutaway pass is currently tinting — a selection or a delete hover.
|
|
128
|
+
*
|
|
129
|
+
* It paints them by swapping the materials on the wall's own mesh, which the
|
|
130
|
+
* merged mesh does not follow, so a lit wall goes back to drawing itself. There
|
|
131
|
+
* are only ever a handful, and a handful of extra draw calls is what the tint
|
|
132
|
+
* costs.
|
|
133
|
+
*/
|
|
134
|
+
function collectTintedWalls(wallIds) {
|
|
135
|
+
const viewer = useViewer.getState();
|
|
136
|
+
const tinted = new Set();
|
|
137
|
+
for (const id of viewer.selection.selectedIds)
|
|
138
|
+
if (wallIds.has(id))
|
|
139
|
+
tinted.add(id);
|
|
140
|
+
for (const id of viewer.previewSelectedIds)
|
|
141
|
+
if (wallIds.has(id))
|
|
142
|
+
tinted.add(id);
|
|
143
|
+
const hovered = viewer.hoverHighlightMode === 'delete' ? viewer.hoveredId : null;
|
|
144
|
+
if (hovered && wallIds.has(hovered))
|
|
145
|
+
tinted.add(hovered);
|
|
146
|
+
return tinted;
|
|
147
|
+
}
|
|
148
|
+
function materialSetKey(materials) {
|
|
149
|
+
return materials.map((material) => material.uuid).join('|');
|
|
150
|
+
}
|
|
151
|
+
export function collectWallBatchCandidates(levelId, excludedNodeIds = EMPTY_IDS) {
|
|
152
|
+
const nodes = useScene.getState().nodes;
|
|
153
|
+
const level = nodes[levelId];
|
|
154
|
+
const grouped = new Map();
|
|
155
|
+
if (level?.type !== 'level')
|
|
156
|
+
return grouped;
|
|
157
|
+
for (const childId of level.children) {
|
|
158
|
+
const child = nodes[childId];
|
|
159
|
+
if (child?.type !== 'wall')
|
|
160
|
+
continue;
|
|
161
|
+
const candidate = toCandidate(childId, child, excludedNodeIds);
|
|
162
|
+
if (!candidate)
|
|
163
|
+
continue;
|
|
164
|
+
const key = materialSetKey(candidate.materials);
|
|
165
|
+
const bucket = grouped.get(key);
|
|
166
|
+
if (bucket)
|
|
167
|
+
bucket.push(candidate);
|
|
168
|
+
else
|
|
169
|
+
grouped.set(key, [candidate]);
|
|
170
|
+
}
|
|
171
|
+
return grouped;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Walls on this level that no batch currently draws.
|
|
175
|
+
*
|
|
176
|
+
* Editing a wall drops it out of its batch — a group-list rewrite that touches
|
|
177
|
+
* no buffer — and it goes back to drawing itself. Re-sewing the level only
|
|
178
|
+
* pays off once enough walls have drifted out, so a single edit leaves the
|
|
179
|
+
* floor's merged mesh exactly where it was.
|
|
180
|
+
*/
|
|
181
|
+
function unbatchedWallCount(levelId, excludedNodeIds = EMPTY_IDS) {
|
|
182
|
+
const nodes = useScene.getState().nodes;
|
|
183
|
+
const level = nodes[levelId];
|
|
184
|
+
if (level?.type !== 'level')
|
|
185
|
+
return 0;
|
|
186
|
+
let count = 0;
|
|
187
|
+
for (const childId of level.children) {
|
|
188
|
+
if (excludedNodeIds.has(childId))
|
|
189
|
+
continue;
|
|
190
|
+
if (batchByNode.has(childId))
|
|
191
|
+
continue;
|
|
192
|
+
const child = nodes[childId];
|
|
193
|
+
if (child?.type !== 'wall')
|
|
194
|
+
continue;
|
|
195
|
+
if (toCandidate(childId, child))
|
|
196
|
+
count++;
|
|
197
|
+
}
|
|
198
|
+
return count;
|
|
199
|
+
}
|
|
200
|
+
const localMatrix = new Matrix4();
|
|
201
|
+
const rootInverse = new Matrix4();
|
|
202
|
+
function mergeLevel(levelId, excludedNodeIds = EMPTY_IDS) {
|
|
203
|
+
disposeLevelBatches(levelId);
|
|
204
|
+
const root = sceneRegistry.nodes.get(levelId);
|
|
205
|
+
if (!root)
|
|
206
|
+
return;
|
|
207
|
+
root.updateWorldMatrix(true, false);
|
|
208
|
+
rootInverse.copy(root.matrixWorld).invert();
|
|
209
|
+
const records = [];
|
|
210
|
+
for (const candidates of collectWallBatchCandidates(levelId, excludedNodeIds).values()) {
|
|
211
|
+
if (candidates.length < MIN_BATCH_WALLS)
|
|
212
|
+
continue;
|
|
213
|
+
const sources = candidates.map((candidate) => {
|
|
214
|
+
candidate.mesh.updateWorldMatrix(true, false);
|
|
215
|
+
return {
|
|
216
|
+
nodeId: candidate.nodeId,
|
|
217
|
+
geometry: candidate.mesh.geometry,
|
|
218
|
+
matrix: localMatrix.multiplyMatrices(rootInverse, candidate.mesh.matrixWorld).clone(),
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
const batch = buildWallBatch(sources);
|
|
222
|
+
if (!batch)
|
|
223
|
+
continue;
|
|
224
|
+
const mesh = new Mesh(batch.geometry, candidates[0].materials);
|
|
225
|
+
mesh.name = 'wall-batch';
|
|
226
|
+
mesh.castShadow = true;
|
|
227
|
+
mesh.receiveShadow = true;
|
|
228
|
+
mesh.matrixAutoUpdate = false;
|
|
229
|
+
mesh.raycast = skipRaycast;
|
|
230
|
+
root.add(mesh);
|
|
231
|
+
const record = {
|
|
232
|
+
levelId,
|
|
233
|
+
mesh,
|
|
234
|
+
batch,
|
|
235
|
+
hidden: new Set(),
|
|
236
|
+
nodeIds: candidates.map((candidate) => candidate.nodeId),
|
|
237
|
+
};
|
|
238
|
+
records.push(record);
|
|
239
|
+
for (const candidate of candidates) {
|
|
240
|
+
hideBatchedWall(candidate.mesh);
|
|
241
|
+
batchByNode.set(candidate.nodeId, record);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (records.length > 0)
|
|
245
|
+
batchesByLevel.set(levelId, records);
|
|
246
|
+
}
|
|
247
|
+
export const WallBatchSystem = () => {
|
|
248
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
249
|
+
const wakeRef = useRef(null);
|
|
250
|
+
useFrame(() => runBatchFrame(invalidate, wakeRef), 5);
|
|
251
|
+
useEffect(() => () => {
|
|
252
|
+
if (wakeRef.current)
|
|
253
|
+
clearTimeout(wakeRef.current);
|
|
254
|
+
for (const levelId of [...batchesByLevel.keys()])
|
|
255
|
+
disposeLevelBatches(levelId);
|
|
256
|
+
changedWalls.clear();
|
|
257
|
+
staleLevels.clear();
|
|
258
|
+
knownWallCount = -1;
|
|
259
|
+
batchingSuspended = false;
|
|
260
|
+
lastAppearance.shading = undefined;
|
|
261
|
+
lastAppearance.textures = undefined;
|
|
262
|
+
lastAppearance.colorPreset = undefined;
|
|
263
|
+
lastAppearance.sceneTheme = undefined;
|
|
264
|
+
lastAppearance.materials = null;
|
|
265
|
+
}, []);
|
|
266
|
+
return null;
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Follows the scene's dirty tracking rather than watching the walls itself.
|
|
270
|
+
*
|
|
271
|
+
* A wall changes for exactly one reason the merged mesh cares about: the wall
|
|
272
|
+
* system rebuilt its geometry. That system already runs off `dirtyNodes`, so
|
|
273
|
+
* this reads the same signal from both ends — the marks still standing when
|
|
274
|
+
* this frame reaches us, and the rebuild notices the wall system left behind
|
|
275
|
+
* for the walls whose marks it has already cleared. Nothing here re-derives
|
|
276
|
+
* "did this wall move" on its own, and the per-frame cost is the size of the
|
|
277
|
+
* dirty set rather than the size of the floor.
|
|
278
|
+
*/
|
|
279
|
+
function runBatchFrame(invalidate, wakeRef) {
|
|
280
|
+
const wallIds = sceneRegistry.byType.wall ?? EMPTY_IDS;
|
|
281
|
+
const nodes = useScene.getState().nodes;
|
|
282
|
+
// Walls the wall system rebuilt: it clears each mark as it goes, so by the
|
|
283
|
+
// time this runs the store no longer names them.
|
|
284
|
+
drainRebuiltWalls(changedWalls);
|
|
285
|
+
// Walls still marked: the wall system deferred them to a later frame (a
|
|
286
|
+
// progressive import) or their mesh had not mounted yet.
|
|
287
|
+
for (const nodeId of useScene.getState().dirtyNodes) {
|
|
288
|
+
if (wallIds.has(nodeId))
|
|
289
|
+
changedWalls.add(nodeId);
|
|
290
|
+
}
|
|
291
|
+
let changed = changedWalls.size > 0;
|
|
292
|
+
for (const nodeId of changedWalls) {
|
|
293
|
+
const record = batchByNode.get(nodeId);
|
|
294
|
+
if (record)
|
|
295
|
+
staleLevels.add(record.levelId);
|
|
296
|
+
const node = nodes[nodeId];
|
|
297
|
+
if (node?.type === 'wall' && node.parentId)
|
|
298
|
+
staleLevels.add(node.parentId);
|
|
299
|
+
releaseWall(nodeId);
|
|
300
|
+
}
|
|
301
|
+
changedWalls.clear();
|
|
302
|
+
// A tinted wall paints itself through materials the merged mesh never reads,
|
|
303
|
+
// so it goes back to drawing its own geometry for as long as it is lit. It
|
|
304
|
+
// stays out afterwards: one wall short of a batch is not worth re-sewing a
|
|
305
|
+
// floor over, and the level's own re-merge threshold decides when it is.
|
|
306
|
+
const tintedWalls = collectTintedWalls(wallIds);
|
|
307
|
+
for (const nodeId of tintedWalls) {
|
|
308
|
+
if (!batchByNode.has(nodeId))
|
|
309
|
+
continue;
|
|
310
|
+
const record = batchByNode.get(nodeId);
|
|
311
|
+
if (record)
|
|
312
|
+
staleLevels.add(record.levelId);
|
|
313
|
+
releaseWall(nodeId);
|
|
314
|
+
changed = true;
|
|
315
|
+
}
|
|
316
|
+
// A theme, texture or material-library switch re-makes every wall's
|
|
317
|
+
// materials without marking a single node, so the merged copies have to be
|
|
318
|
+
// sewn again from the new ones. See `appearanceChanged`.
|
|
319
|
+
if (appearanceChanged()) {
|
|
320
|
+
for (const levelId of [...batchesByLevel.keys()])
|
|
321
|
+
disposeLevelBatches(levelId);
|
|
322
|
+
for (const levelId of sceneRegistry.byType.level ?? EMPTY_IDS)
|
|
323
|
+
staleLevels.add(levelId);
|
|
324
|
+
changed = true;
|
|
325
|
+
}
|
|
326
|
+
// A wall that left the scene carries no mark of its own — deleting one
|
|
327
|
+
// dirties the neighbours it re-mitres, not the node that went away. The
|
|
328
|
+
// wall count moving is the cheap tell that the batch needs reconciling.
|
|
329
|
+
if (wallIds.size !== knownWallCount) {
|
|
330
|
+
knownWallCount = wallIds.size;
|
|
331
|
+
for (const [nodeId, record] of [...batchByNode]) {
|
|
332
|
+
if (wallIds.has(nodeId))
|
|
333
|
+
continue;
|
|
334
|
+
staleLevels.add(record.levelId);
|
|
335
|
+
releaseWall(nodeId);
|
|
336
|
+
changed = true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Two things make merging unsound, and both are handled the same way: the
|
|
340
|
+
// batch stands down for as long as they hold, and sews the floors back
|
|
341
|
+
// together once they lift. Isolation hides everything outside the focused
|
|
342
|
+
// subtree, and a level's merged mesh hangs off the level root — so it goes
|
|
343
|
+
// dark with everything else, leaving a focused batched wall drawn by nobody.
|
|
344
|
+
// Every wall mode but `up` re-assigns wall materials the merged mesh does not
|
|
345
|
+
// follow. See `canBatchWalls`.
|
|
346
|
+
const suspended = !canBatchWalls(useViewer.getState().wallMode, isIsolationActive());
|
|
347
|
+
if (suspended !== batchingSuspended) {
|
|
348
|
+
batchingSuspended = suspended;
|
|
349
|
+
for (const levelId of [...batchesByLevel.keys()])
|
|
350
|
+
disposeLevelBatches(levelId);
|
|
351
|
+
staleLevels.clear();
|
|
352
|
+
if (!suspended) {
|
|
353
|
+
for (const levelId of sceneRegistry.byType.level ?? EMPTY_IDS)
|
|
354
|
+
staleLevels.add(levelId);
|
|
355
|
+
}
|
|
356
|
+
changed = true;
|
|
357
|
+
}
|
|
358
|
+
if (batchingSuspended) {
|
|
359
|
+
staleLevels.clear();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const now = performance.now();
|
|
363
|
+
if (changed)
|
|
364
|
+
lastWallChangeAtMs = now;
|
|
365
|
+
if (staleLevels.size === 0)
|
|
366
|
+
return;
|
|
367
|
+
// Merging mid-edit would sew stale geometry in: a dragged wall's neighbours
|
|
368
|
+
// are deferred to the wall system's trailing-edge flush, and those rebuilds
|
|
369
|
+
// land after the drag's last dirty mark. Waiting on its queue — not just on
|
|
370
|
+
// a clock — is what keeps a re-sewn floor in step with the walls it copies.
|
|
371
|
+
const settled = !changed && getPendingWallRebuildCount() === 0 && now - lastWallChangeAtMs >= BATCH_SETTLE_MS;
|
|
372
|
+
if (!settled) {
|
|
373
|
+
// The canvas renders on demand, so nothing would bring us back once the
|
|
374
|
+
// scene goes quiet — poke one frame after the window should have closed.
|
|
375
|
+
if (wakeRef.current)
|
|
376
|
+
clearTimeout(wakeRef.current);
|
|
377
|
+
wakeRef.current = setTimeout(() => {
|
|
378
|
+
wakeRef.current = null;
|
|
379
|
+
invalidate();
|
|
380
|
+
}, BATCH_SETTLE_MS + 20);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
for (const levelId of staleLevels) {
|
|
384
|
+
if (unbatchedWallCount(levelId, tintedWalls) >= MIN_BATCH_WALLS) {
|
|
385
|
+
mergeLevel(levelId, tintedWalls);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
staleLevels.clear();
|
|
389
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/** A contiguous vertex range one wall contributes to one material run. */
|
|
3
|
+
export type WallBatchSlice = {
|
|
4
|
+
nodeId: string;
|
|
5
|
+
start: number;
|
|
6
|
+
count: number;
|
|
7
|
+
};
|
|
8
|
+
/** Every triangle drawn with one material index, in wall order. */
|
|
9
|
+
export type WallBatchRun = {
|
|
10
|
+
materialIndex: number;
|
|
11
|
+
start: number;
|
|
12
|
+
count: number;
|
|
13
|
+
slices: WallBatchSlice[];
|
|
14
|
+
};
|
|
15
|
+
export type WallBatchSource = {
|
|
16
|
+
nodeId: string;
|
|
17
|
+
geometry: THREE.BufferGeometry;
|
|
18
|
+
/** Source-local to batch-root transform, baked into the merged vertices. */
|
|
19
|
+
matrix: THREE.Matrix4;
|
|
20
|
+
};
|
|
21
|
+
export type WallBatch = {
|
|
22
|
+
geometry: THREE.BufferGeometry;
|
|
23
|
+
runs: WallBatchRun[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Concatenates wall geometries into one buffer laid out material-major,
|
|
27
|
+
* wall-minor: every triangle sharing a material index ends up in a single
|
|
28
|
+
* contiguous run, so the merged mesh costs one draw call per material
|
|
29
|
+
* instead of one per wall per material.
|
|
30
|
+
*
|
|
31
|
+
* Vertices are baked into the batch root's frame, so the merged mesh needs
|
|
32
|
+
* no transform of its own. Each wall's slice of every run is recorded, which
|
|
33
|
+
* is what lets a single wall be pulled back out later without touching the
|
|
34
|
+
* buffers — see `applyWallBatchGroups`.
|
|
35
|
+
*
|
|
36
|
+
* Sources must be non-indexed (the wall pipeline's `applyWorldPlanarWallUVs`
|
|
37
|
+
* already de-indexes) and are skipped if they carry no positions.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildWallBatch(sources: readonly WallBatchSource[]): WallBatch | null;
|
|
40
|
+
/**
|
|
41
|
+
* Rewrites the merged geometry's draw groups so the listed walls are skipped.
|
|
42
|
+
*
|
|
43
|
+
* Pulling a wall out of the batch is what happens while it is being dragged:
|
|
44
|
+
* it goes back to drawing itself, and the merged mesh has to stop drawing it
|
|
45
|
+
* or the two would overlap. Because each wall owns a contiguous slice of each
|
|
46
|
+
* run, skipping it is a matter of splitting that run around the hole — no
|
|
47
|
+
* vertex data moves and nothing is re-uploaded to the GPU, so a drag costs a
|
|
48
|
+
* handful of group objects rather than a rebuild of the floor.
|
|
49
|
+
*
|
|
50
|
+
* Each hidden wall adds at most one extra group (one extra draw call) per run,
|
|
51
|
+
* so callers should re-merge once the holes stop being temporary.
|
|
52
|
+
*/
|
|
53
|
+
export declare function applyWallBatchGroups(batch: WallBatch, hidden: ReadonlySet<string>): void;
|
|
54
|
+
/**
|
|
55
|
+
* Silences a wall the batch now draws.
|
|
56
|
+
*
|
|
57
|
+
* Emptying the draw range is not enough — three.js still submits a zero-count
|
|
58
|
+
* group, so 1000 muted walls cost 1000 draw calls. `visible = false` would
|
|
59
|
+
* cost nothing but takes the wall's children (cutters, treatments) down with
|
|
60
|
+
* it. Moving the mesh alone off the scene layer skips it in every pass while
|
|
61
|
+
* its subtree keeps rendering and picking.
|
|
62
|
+
*/
|
|
63
|
+
export declare function hideBatchedWall(mesh: THREE.Object3D): void;
|
|
64
|
+
/** Hands a wall back its own draw call — unless solo or isolation still hide it. */
|
|
65
|
+
export declare function revealBatchedWall(mesh: THREE.Object3D): void;
|
|
66
|
+
//# sourceMappingURL=wall-batch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wall-batch.d.ts","sourceRoot":"","sources":["../../src/wall/wall-batch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,0EAA0E;AAC1E,MAAM,MAAM,cAAc,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAE7E,mEAAmE;AACnE,MAAM,MAAM,YAAY,GAAG;IACzB,aAAa,EAAE,MAAM,CAAA;IACrB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,cAAc,EAAE,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,KAAK,CAAC,cAAc,CAAA;IAC9B,4EAA4E;IAC5E,MAAM,EAAE,KAAK,CAAC,OAAO,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,EAAE,KAAK,CAAC,cAAc,CAAA;IAC9B,IAAI,EAAE,YAAY,EAAE,CAAA;CACrB,CAAA;AAkCD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,GAAG,SAAS,GAAG,IAAI,CAmEpF;AAwCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,IAAI,CAmBxF;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,GAAG,IAAI,CAE1D;AAED,oFAAoF;AACpF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,GAAG,IAAI,CAE5D"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { hideFromScene, showInScene } from '@pascal-app/viewer';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
const BATCH_ATTRIBUTES = ['position', 'normal', 'uv', 'uv2'];
|
|
4
|
+
const ATTRIBUTE_ITEM_SIZE = {
|
|
5
|
+
position: 3,
|
|
6
|
+
normal: 3,
|
|
7
|
+
uv: 2,
|
|
8
|
+
uv2: 2,
|
|
9
|
+
};
|
|
10
|
+
function planSource(source) {
|
|
11
|
+
const position = source.geometry.getAttribute('position');
|
|
12
|
+
if (!position || position.count === 0)
|
|
13
|
+
return null;
|
|
14
|
+
const declared = source.geometry.groups.length > 0
|
|
15
|
+
? source.geometry.groups
|
|
16
|
+
: [{ start: 0, count: position.count, materialIndex: 0 }];
|
|
17
|
+
const groups = [];
|
|
18
|
+
for (const group of declared) {
|
|
19
|
+
const start = Math.max(0, group.start);
|
|
20
|
+
const count = Math.min(group.count, position.count - start);
|
|
21
|
+
if (count <= 0)
|
|
22
|
+
continue;
|
|
23
|
+
groups.push({ materialIndex: group.materialIndex ?? 0, start, count });
|
|
24
|
+
}
|
|
25
|
+
return groups.length > 0 ? { source, groups } : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Concatenates wall geometries into one buffer laid out material-major,
|
|
29
|
+
* wall-minor: every triangle sharing a material index ends up in a single
|
|
30
|
+
* contiguous run, so the merged mesh costs one draw call per material
|
|
31
|
+
* instead of one per wall per material.
|
|
32
|
+
*
|
|
33
|
+
* Vertices are baked into the batch root's frame, so the merged mesh needs
|
|
34
|
+
* no transform of its own. Each wall's slice of every run is recorded, which
|
|
35
|
+
* is what lets a single wall be pulled back out later without touching the
|
|
36
|
+
* buffers — see `applyWallBatchGroups`.
|
|
37
|
+
*
|
|
38
|
+
* Sources must be non-indexed (the wall pipeline's `applyWorldPlanarWallUVs`
|
|
39
|
+
* already de-indexes) and are skipped if they carry no positions.
|
|
40
|
+
*/
|
|
41
|
+
export function buildWallBatch(sources) {
|
|
42
|
+
const planned = [];
|
|
43
|
+
const totals = new Map();
|
|
44
|
+
for (const source of sources) {
|
|
45
|
+
const entry = planSource(source);
|
|
46
|
+
if (!entry)
|
|
47
|
+
continue;
|
|
48
|
+
planned.push(entry);
|
|
49
|
+
for (const group of entry.groups) {
|
|
50
|
+
totals.set(group.materialIndex, (totals.get(group.materialIndex) ?? 0) + group.count);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (planned.length === 0)
|
|
54
|
+
return null;
|
|
55
|
+
const names = BATCH_ATTRIBUTES.filter((name) => planned.every((entry) => entry.source.geometry.getAttribute(name)));
|
|
56
|
+
if (!names.includes('position'))
|
|
57
|
+
return null;
|
|
58
|
+
let totalVertices = 0;
|
|
59
|
+
for (const count of totals.values())
|
|
60
|
+
totalVertices += count;
|
|
61
|
+
const buffers = new Map(names.map((name) => [name, new Float32Array(totalVertices * ATTRIBUTE_ITEM_SIZE[name])]));
|
|
62
|
+
const normalMatrix = new THREE.Matrix3();
|
|
63
|
+
const vector = new THREE.Vector3();
|
|
64
|
+
const runs = [];
|
|
65
|
+
let cursor = 0;
|
|
66
|
+
for (const materialIndex of [...totals.keys()].sort((left, right) => left - right)) {
|
|
67
|
+
const runStart = cursor;
|
|
68
|
+
const slices = [];
|
|
69
|
+
for (const entry of planned) {
|
|
70
|
+
const sliceStart = cursor;
|
|
71
|
+
normalMatrix.getNormalMatrix(entry.source.matrix);
|
|
72
|
+
for (const group of entry.groups) {
|
|
73
|
+
if (group.materialIndex !== materialIndex)
|
|
74
|
+
continue;
|
|
75
|
+
copyGroup(entry.source, group, names, buffers, cursor, normalMatrix, vector);
|
|
76
|
+
cursor += group.count;
|
|
77
|
+
}
|
|
78
|
+
if (cursor > sliceStart) {
|
|
79
|
+
slices.push({ nodeId: entry.source.nodeId, start: sliceStart, count: cursor - sliceStart });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
runs.push({ materialIndex, start: runStart, count: cursor - runStart, slices });
|
|
83
|
+
}
|
|
84
|
+
const geometry = new THREE.BufferGeometry();
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
geometry.setAttribute(name, new THREE.BufferAttribute(buffers.get(name), ATTRIBUTE_ITEM_SIZE[name]));
|
|
87
|
+
}
|
|
88
|
+
geometry.computeBoundingSphere();
|
|
89
|
+
geometry.computeBoundingBox();
|
|
90
|
+
const batch = { geometry, runs };
|
|
91
|
+
applyWallBatchGroups(batch, EMPTY_HIDDEN);
|
|
92
|
+
return batch;
|
|
93
|
+
}
|
|
94
|
+
const EMPTY_HIDDEN = new Set();
|
|
95
|
+
function copyGroup(source, group, names, buffers, writeAt, normalMatrix, vector) {
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
const attribute = source.geometry.getAttribute(name);
|
|
98
|
+
const target = buffers.get(name);
|
|
99
|
+
const itemSize = ATTRIBUTE_ITEM_SIZE[name];
|
|
100
|
+
for (let offset = 0; offset < group.count; offset += 1) {
|
|
101
|
+
const from = group.start + offset;
|
|
102
|
+
const to = (writeAt + offset) * itemSize;
|
|
103
|
+
if (name === 'position') {
|
|
104
|
+
vector.fromBufferAttribute(attribute, from).applyMatrix4(source.matrix);
|
|
105
|
+
target[to] = vector.x;
|
|
106
|
+
target[to + 1] = vector.y;
|
|
107
|
+
target[to + 2] = vector.z;
|
|
108
|
+
}
|
|
109
|
+
else if (name === 'normal') {
|
|
110
|
+
vector.fromBufferAttribute(attribute, from).applyMatrix3(normalMatrix).normalize();
|
|
111
|
+
target[to] = vector.x;
|
|
112
|
+
target[to + 1] = vector.y;
|
|
113
|
+
target[to + 2] = vector.z;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
target[to] = attribute.getX(from);
|
|
117
|
+
target[to + 1] = attribute.getY(from);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Rewrites the merged geometry's draw groups so the listed walls are skipped.
|
|
124
|
+
*
|
|
125
|
+
* Pulling a wall out of the batch is what happens while it is being dragged:
|
|
126
|
+
* it goes back to drawing itself, and the merged mesh has to stop drawing it
|
|
127
|
+
* or the two would overlap. Because each wall owns a contiguous slice of each
|
|
128
|
+
* run, skipping it is a matter of splitting that run around the hole — no
|
|
129
|
+
* vertex data moves and nothing is re-uploaded to the GPU, so a drag costs a
|
|
130
|
+
* handful of group objects rather than a rebuild of the floor.
|
|
131
|
+
*
|
|
132
|
+
* Each hidden wall adds at most one extra group (one extra draw call) per run,
|
|
133
|
+
* so callers should re-merge once the holes stop being temporary.
|
|
134
|
+
*/
|
|
135
|
+
export function applyWallBatchGroups(batch, hidden) {
|
|
136
|
+
batch.geometry.clearGroups();
|
|
137
|
+
for (const run of batch.runs) {
|
|
138
|
+
let cursor = run.start;
|
|
139
|
+
if (hidden.size > 0) {
|
|
140
|
+
for (const slice of run.slices) {
|
|
141
|
+
if (!hidden.has(slice.nodeId))
|
|
142
|
+
continue;
|
|
143
|
+
if (slice.start > cursor) {
|
|
144
|
+
batch.geometry.addGroup(cursor, slice.start - cursor, run.materialIndex);
|
|
145
|
+
}
|
|
146
|
+
cursor = slice.start + slice.count;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const end = run.start + run.count;
|
|
150
|
+
if (end > cursor)
|
|
151
|
+
batch.geometry.addGroup(cursor, end - cursor, run.materialIndex);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Silences a wall the batch now draws.
|
|
156
|
+
*
|
|
157
|
+
* Emptying the draw range is not enough — three.js still submits a zero-count
|
|
158
|
+
* group, so 1000 muted walls cost 1000 draw calls. `visible = false` would
|
|
159
|
+
* cost nothing but takes the wall's children (cutters, treatments) down with
|
|
160
|
+
* it. Moving the mesh alone off the scene layer skips it in every pass while
|
|
161
|
+
* its subtree keeps rendering and picking.
|
|
162
|
+
*/
|
|
163
|
+
export function hideBatchedWall(mesh) {
|
|
164
|
+
hideFromScene(mesh, 'batched');
|
|
165
|
+
}
|
|
166
|
+
/** Hands a wall back its own draw call — unless solo or isolation still hide it. */
|
|
167
|
+
export function revealBatchedWall(mesh) {
|
|
168
|
+
showInScene(mesh, 'batched');
|
|
169
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"quantities-panel.d.ts","sourceRoot":"","sources":["../../src/zone/quantities-panel.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"quantities-panel.d.ts","sourceRoot":"","sources":["../../src/zone/quantities-panel.tsx"],"names":[],"mappings":"AA8TA,MAAM,CAAC,OAAO,UAAU,mBAAmB,uCA6F1C"}
|