@pascal-app/nodes 1.0.0-beta.4 → 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.
Files changed (56) hide show
  1. package/dist/cabinet/flame-index.d.ts +3 -0
  2. package/dist/cabinet/flame-index.d.ts.map +1 -0
  3. package/dist/cabinet/flame-index.js +13 -0
  4. package/dist/cabinet/system.d.ts +2 -0
  5. package/dist/cabinet/system.d.ts.map +1 -1
  6. package/dist/cabinet/system.js +28 -8
  7. package/dist/cabinet/tool.d.ts.map +1 -1
  8. package/dist/cabinet/tool.js +2 -1
  9. package/dist/ceiling/panel.d.ts.map +1 -1
  10. package/dist/ceiling/panel.js +8 -2
  11. package/dist/ceiling/parametrics.js +1 -1
  12. package/dist/column/panel.js +1 -1
  13. package/dist/column/parametrics.js +1 -1
  14. package/dist/dormer/placement-guides.d.ts.map +1 -1
  15. package/dist/dormer/placement-guides.js +5 -4
  16. package/dist/elevator/definition.d.ts.map +1 -1
  17. package/dist/elevator/definition.js +2 -0
  18. package/dist/fence/curve-tool.d.ts.map +1 -1
  19. package/dist/fence/curve-tool.js +14 -9
  20. package/dist/fence/move-control-point-tool.d.ts.map +1 -1
  21. package/dist/fence/move-control-point-tool.js +13 -8
  22. package/dist/fence/move-tangent-tool.d.ts.map +1 -1
  23. package/dist/fence/move-tangent-tool.js +13 -8
  24. package/dist/fence/tool.d.ts.map +1 -1
  25. package/dist/fence/tool.js +8 -11
  26. package/dist/measurement/renderer.d.ts.map +1 -1
  27. package/dist/measurement/renderer.js +6 -5
  28. package/dist/measurement/surface-query.js +5 -5
  29. package/dist/measurement/tool.d.ts.map +1 -1
  30. package/dist/measurement/tool.js +7 -5
  31. package/dist/ridge-vent/renderer.d.ts.map +1 -1
  32. package/dist/ridge-vent/renderer.js +2 -2
  33. package/dist/shared/mep-ghost.d.ts.map +1 -1
  34. package/dist/shared/mep-ghost.js +19 -1
  35. package/dist/slab/panel.d.ts.map +1 -1
  36. package/dist/slab/panel.js +1 -1
  37. package/dist/wall/curve-tool.d.ts +3 -4
  38. package/dist/wall/curve-tool.d.ts.map +1 -1
  39. package/dist/wall/curve-tool.js +17 -13
  40. package/dist/wall/panel.d.ts.map +1 -1
  41. package/dist/wall/panel.js +56 -14
  42. package/dist/wall/parametrics.js +1 -1
  43. package/dist/wall/system.d.ts +3 -0
  44. package/dist/wall/system.d.ts.map +1 -1
  45. package/dist/wall/system.js +5 -1
  46. package/dist/wall/tool.d.ts.map +1 -1
  47. package/dist/wall/tool.js +24 -27
  48. package/dist/wall/wall-batch-system.d.ts +23 -0
  49. package/dist/wall/wall-batch-system.d.ts.map +1 -0
  50. package/dist/wall/wall-batch-system.js +389 -0
  51. package/dist/wall/wall-batch.d.ts +66 -0
  52. package/dist/wall/wall-batch.d.ts.map +1 -0
  53. package/dist/wall/wall-batch.js +169 -0
  54. package/dist/zone/quantities-panel.d.ts.map +1 -1
  55. package/dist/zone/quantities-panel.js +4 -3
  56. 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":"AA4TA,MAAM,CAAC,OAAO,UAAU,mBAAmB,uCA2F1C"}
1
+ {"version":3,"file":"quantities-panel.d.ts","sourceRoot":"","sources":["../../src/zone/quantities-panel.tsx"],"names":[],"mappings":"AA8TA,MAAM,CAAC,OAAO,UAAU,mBAAmB,uCA6F1C"}