@ifc-lite/renderer 1.43.0 → 1.44.1

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 (102) hide show
  1. package/dist/camera-animation.d.ts +20 -32
  2. package/dist/camera-animation.d.ts.map +1 -1
  3. package/dist/camera-animation.js +71 -358
  4. package/dist/camera-animation.js.map +1 -1
  5. package/dist/camera-controls.d.ts +11 -51
  6. package/dist/camera-controls.d.ts.map +1 -1
  7. package/dist/camera-controls.js +140 -12
  8. package/dist/camera-controls.js.map +1 -1
  9. package/dist/camera-first-person.d.ts +52 -0
  10. package/dist/camera-first-person.d.ts.map +1 -0
  11. package/dist/camera-first-person.js +119 -0
  12. package/dist/camera-first-person.js.map +1 -0
  13. package/dist/camera-framing.d.ts +86 -0
  14. package/dist/camera-framing.d.ts.map +1 -0
  15. package/dist/camera-framing.js +230 -0
  16. package/dist/camera-framing.js.map +1 -0
  17. package/dist/camera-guards.d.ts +148 -0
  18. package/dist/camera-guards.d.ts.map +1 -0
  19. package/dist/camera-guards.js +150 -0
  20. package/dist/camera-guards.js.map +1 -0
  21. package/dist/camera-matrices.d.ts +14 -0
  22. package/dist/camera-matrices.d.ts.map +1 -0
  23. package/dist/camera-matrices.js +146 -0
  24. package/dist/camera-matrices.js.map +1 -0
  25. package/dist/camera-preset-view.d.ts +56 -0
  26. package/dist/camera-preset-view.d.ts.map +1 -0
  27. package/dist/camera-preset-view.js +180 -0
  28. package/dist/camera-preset-view.js.map +1 -0
  29. package/dist/camera-projection.d.ts +1 -1
  30. package/dist/camera-projection.d.ts.map +1 -1
  31. package/dist/camera-projection.js +83 -30
  32. package/dist/camera-projection.js.map +1 -1
  33. package/dist/camera-state.d.ts +62 -0
  34. package/dist/camera-state.d.ts.map +1 -0
  35. package/dist/camera-state.js +5 -0
  36. package/dist/camera-state.js.map +1 -0
  37. package/dist/camera.d.ts +22 -11
  38. package/dist/camera.d.ts.map +1 -1
  39. package/dist/camera.js +75 -120
  40. package/dist/camera.js.map +1 -1
  41. package/dist/environment.d.ts.map +1 -1
  42. package/dist/environment.js +23 -1
  43. package/dist/environment.js.map +1 -1
  44. package/dist/fill-bridge-anchor.d.ts +32 -0
  45. package/dist/fill-bridge-anchor.d.ts.map +1 -0
  46. package/dist/fill-bridge-anchor.js +82 -0
  47. package/dist/fill-bridge-anchor.js.map +1 -0
  48. package/dist/fill-predicates.d.ts +44 -0
  49. package/dist/fill-predicates.d.ts.map +1 -0
  50. package/dist/fill-predicates.js +65 -0
  51. package/dist/fill-predicates.js.map +1 -0
  52. package/dist/fill-triangulate.d.ts +102 -0
  53. package/dist/fill-triangulate.d.ts.map +1 -0
  54. package/dist/fill-triangulate.js +333 -0
  55. package/dist/fill-triangulate.js.map +1 -0
  56. package/dist/index.d.ts +154 -8
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +294 -45
  59. package/dist/index.js.map +1 -1
  60. package/dist/math.d.ts +49 -1
  61. package/dist/math.d.ts.map +1 -1
  62. package/dist/math.js +122 -67
  63. package/dist/math.js.map +1 -1
  64. package/dist/renderer-symbolic-overlays.d.ts.map +1 -1
  65. package/dist/renderer-symbolic-overlays.js +20 -25
  66. package/dist/renderer-symbolic-overlays.js.map +1 -1
  67. package/dist/scene-geometry.d.ts +32 -0
  68. package/dist/scene-geometry.d.ts.map +1 -1
  69. package/dist/scene-geometry.js +66 -0
  70. package/dist/scene-geometry.js.map +1 -1
  71. package/dist/scene.d.ts +25 -0
  72. package/dist/scene.d.ts.map +1 -1
  73. package/dist/scene.js +91 -109
  74. package/dist/scene.js.map +1 -1
  75. package/dist/section-2d-lift.d.ts +109 -0
  76. package/dist/section-2d-lift.d.ts.map +1 -0
  77. package/dist/section-2d-lift.js +168 -0
  78. package/dist/section-2d-lift.js.map +1 -0
  79. package/dist/section-2d-line-buffer.d.ts +58 -0
  80. package/dist/section-2d-line-buffer.d.ts.map +1 -0
  81. package/dist/section-2d-line-buffer.js +109 -0
  82. package/dist/section-2d-line-buffer.js.map +1 -0
  83. package/dist/section-2d-overlay.d.ts +28 -77
  84. package/dist/section-2d-overlay.d.ts.map +1 -1
  85. package/dist/section-2d-overlay.js +161 -593
  86. package/dist/section-2d-overlay.js.map +1 -1
  87. package/dist/section-plane-basis.d.ts +22 -3
  88. package/dist/section-plane-basis.d.ts.map +1 -1
  89. package/dist/section-plane-basis.js +51 -10
  90. package/dist/section-plane-basis.js.map +1 -1
  91. package/dist/section-plane.d.ts.map +1 -1
  92. package/dist/section-plane.js +10 -1
  93. package/dist/section-plane.js.map +1 -1
  94. package/dist/shaders/section-2d-overlay.wgsl.d.ts +102 -0
  95. package/dist/shaders/section-2d-overlay.wgsl.d.ts.map +1 -0
  96. package/dist/shaders/section-2d-overlay.wgsl.js +281 -0
  97. package/dist/shaders/section-2d-overlay.wgsl.js.map +1 -0
  98. package/dist/symbolic-overlay-pipelines.d.ts +10 -39
  99. package/dist/symbolic-overlay-pipelines.d.ts.map +1 -1
  100. package/dist/symbolic-overlay-pipelines.js +20 -163
  101. package/dist/symbolic-overlay-pipelines.js.map +1 -1
  102. package/package.json +3 -2
@@ -7,41 +7,23 @@
7
7
  * Renders 2D section drawings (cut polygons, outlines, hatching) as a 3D overlay
8
8
  * on the section plane in the WebGPU viewport. This provides an integrated view
9
9
  * where the architectural drawing appears directly on the section cut surface.
10
+ *
11
+ * SIZE (issue #2456): this module is deliberately over the ~400-line house rule.
12
+ * Everything that could leave without dragging a GPU resource across a module
13
+ * boundary has left — the WGSL to `shaders/section-2d-overlay.wgsl.ts`, the
14
+ * 2D→3D lift and cap triangulation to `section-2d-lift.ts`, the per-family
15
+ * vertex buffer to `section-2d-line-buffer.ts`. What is left is one nullable,
16
+ * `init()`-created / `dispose()`-destroyed GPU object (two pipelines, one
17
+ * bind-group layout, one bind group, one uniform buffer holding a 160-byte
18
+ * record per draw site) plus
19
+ * the published `upload*`/`clear*`/`has*`/`draw*` API over it. Splitting that
20
+ * further means giving those shared resources a second owner, which is the cut
21
+ * #2456 explicitly refuses. Do not "fix" the line count by doing it.
10
22
  */
11
23
  import { PIPELINE_CONSTANTS } from './constants.js';
12
- import { earClip, joinHoles } from './symbolic-overlay-pipelines.js';
13
- // Fill colors by IFC type (architectural convention).
14
- //
15
- // PARITY-ALLOW (#913): this is the **2D drafting** palette and is deliberately
16
- // independent of the canonical 3D styling table
17
- // (`ifc_lite_processing::style::default_color_for_type`). 2D plan/section fills
18
- // follow drawing conventions (heavier alpha, line-weight-driven greys), not the
19
- // 3D material appearance, so it is the one sanctioned second colour table. Do
20
- // not "sync" it to the 3D defaults; do not add a third table anywhere else.
21
- const IFC_TYPE_FILL_COLORS = {
22
- IfcWall: [0.69, 0.69, 0.69, 0.95],
23
- IfcWallStandardCase: [0.69, 0.69, 0.69, 0.95],
24
- IfcColumn: [0.56, 0.56, 0.56, 0.95],
25
- IfcBeam: [0.56, 0.56, 0.56, 0.95],
26
- IfcSlab: [0.78, 0.78, 0.78, 0.95],
27
- IfcRoof: [0.82, 0.82, 0.82, 0.95],
28
- IfcFooting: [0.50, 0.50, 0.50, 0.95],
29
- IfcPile: [0.44, 0.44, 0.44, 0.95],
30
- IfcWindow: [0.91, 0.96, 0.99, 0.7],
31
- IfcDoor: [0.96, 0.90, 0.83, 0.95],
32
- IfcStair: [0.85, 0.85, 0.85, 0.95],
33
- IfcStairFlight: [0.85, 0.85, 0.85, 0.95],
34
- IfcRailing: [0.75, 0.75, 0.75, 0.95],
35
- IfcPipeSegment: [0.63, 0.82, 1.0, 0.95],
36
- IfcDuctSegment: [0.75, 1.0, 0.75, 0.95],
37
- IfcFurnishingElement: [1.0, 0.88, 0.75, 0.95],
38
- IfcSpace: [0.94, 0.94, 0.94, 0.5],
39
- IfcSpatialZone: [0.88, 0.80, 0.96, 0.5],
40
- default: [0.82, 0.82, 0.82, 0.95],
41
- };
42
- function getFillColor(ifcType) {
43
- return IFC_TYPE_FILL_COLORS[ifcType] || IFC_TYPE_FILL_COLORS.default;
44
- }
24
+ import { SECTION_2D_CAP_FILL_WGSL, SECTION_2D_OVERLAY_LINE_WGSL, SECTION_2D_UNIFORM_BYTES, SECTION_2D_UNIFORM_FLOATS, SECTION_2D_UNIFORM_SLOTS, SECTION_2D_UNIFORM_SLOT_COUNT, SECTION_2D_UNIFORM_SLOT_INDEX, sectionUniformSlotStride, } from './shaders/section-2d-overlay.wgsl.js';
25
+ import { buildCapFillGeometry, buildDrawingOutlineVertices, createSectionLift, } from './section-2d-lift.js';
26
+ import { WorldLineBuffer, } from './section-2d-line-buffer.js';
45
27
  export class Section2DOverlayRenderer {
46
28
  device;
47
29
  fillPipeline = null;
@@ -49,6 +31,8 @@ export class Section2DOverlayRenderer {
49
31
  bindGroupLayout = null;
50
32
  uniformBuffer = null;
51
33
  bindGroup = null;
34
+ /** Byte stride between the uniform slots in {@link uniformBuffer}. Set by `init()`. */
35
+ uniformStride = 0;
52
36
  format;
53
37
  sampleCount;
54
38
  initialized = false;
@@ -57,7 +41,9 @@ export class Section2DOverlayRenderer {
57
41
  // opaque black for backwards compatibility; a consumer can theme it (e.g. light
58
42
  // lines on a dark canvas) via setOverlayLineColor().
59
43
  overlayLineColor = [0, 0, 0, 1];
60
- // Cached geometry buffers
44
+ // Cached section-cut geometry buffers. Unlike the world-space overlays below
45
+ // these ride the section plane and the fill half is indexed, so they are not
46
+ // WorldLineBuffers.
61
47
  fillVertexBuffer = null;
62
48
  fillIndexBuffer = null;
63
49
  fillIndexCount = 0;
@@ -68,33 +54,28 @@ export class Section2DOverlayRenderer {
68
54
  // does not depend on a section plane being active. Used by the
69
55
  // "Show IFC Annotations" toggle so that authored 2D drawing curves
70
56
  // (IfcAnnotation polylines/arcs) are visible in any view.
71
- annotationLineVertexBuffer = null;
72
- annotationLineVertexCount = 0;
57
+ annotationLines = new WorldLineBuffer(SECTION_2D_UNIFORM_SLOT_INDEX.annotation);
73
58
  // Standalone 3D alignment centerline overlay. Independent buffer from the
74
59
  // annotation lines (separate visibility toggle) but reuses the same line
75
60
  // pipeline. IfcAlignment renders as a thin line here — not a ribbon mesh —
76
61
  // to match IfcGrid axes / IfcAnnotation curves.
77
- alignmentLineVertexBuffer = null;
78
- alignmentLineVertexCount = 0;
62
+ alignmentLines = new WorldLineBuffer(SECTION_2D_UNIFORM_SLOT_INDEX.alignment);
79
63
  // Standalone 3D structural-grid (IfcGridAxis) overlay. Independent buffer so
80
64
  // grid visibility is independent of the annotation/alignment overlays, but
81
65
  // reuses the same line pipeline (issue #967).
82
- gridLineVertexBuffer = null;
83
- gridLineVertexCount = 0;
66
+ gridLines = new WorldLineBuffer(SECTION_2D_UNIFORM_SLOT_INDEX.grid);
84
67
  // Standalone 3D DXF reference-layer overlay (issue #2043, follow-up to
85
68
  // #1782/#1929's 2D-only DXF underlay). Independent buffer so 3D DXF
86
69
  // visibility is independent of the 2D underlay and the other overlays
87
70
  // above, but reuses the same line pipeline + shared overlay colour —
88
71
  // mirrors the grid overlay exactly. Line paths only (walls/boundaries);
89
72
  // DXF fills/text are not lifted to 3D in this iteration.
90
- dxfLineVertexBuffer = null;
91
- dxfLineVertexCount = 0;
73
+ dxfLines = new WorldLineBuffer(SECTION_2D_UNIFORM_SLOT_INDEX.dxf);
92
74
  // Standalone 3D clash-overlap-box overlay (#1277): the wireframe AABB of a
93
75
  // focused clash, drawn in its OWN distinct colour (not the shared overlay
94
76
  // line colour) so the overlap region reads as a third colour next to the two
95
77
  // glowing clash elements.
96
- clashBoxLineVertexBuffer = null;
97
- clashBoxLineVertexCount = 0;
78
+ clashBoxLines = new WorldLineBuffer(SECTION_2D_UNIFORM_SLOT_INDEX.clashBox);
98
79
  clashBoxLineColor = [1, 0, 1, 1];
99
80
  constructor(device, format, sampleCount = 4) {
100
81
  this.device = device;
@@ -105,198 +86,27 @@ export class Section2DOverlayRenderer {
105
86
  if (this.initialized)
106
87
  return;
107
88
  // Create bind group layout
89
+ // `hasDynamicOffset`: one bind group serves every draw site, each reading
90
+ // its own 160-byte slot at a per-draw offset. See
91
+ // SECTION_2D_UNIFORM_SLOT_INDEX for why the slots exist at all.
108
92
  this.bindGroupLayout = this.device.createBindGroupLayout({
109
93
  entries: [
110
94
  {
111
95
  binding: 0,
112
96
  visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
113
- buffer: { type: 'uniform' },
97
+ buffer: {
98
+ type: 'uniform',
99
+ hasDynamicOffset: true,
100
+ minBindingSize: SECTION_2D_UNIFORM_BYTES,
101
+ },
114
102
  },
115
103
  ],
116
104
  });
117
105
  const pipelineLayout = this.device.createPipelineLayout({
118
106
  bindGroupLayouts: [this.bindGroupLayout],
119
107
  });
120
- // Shader for filled polygons. Applies the user-defined cap style
121
- // (fill colour + screen-space hatch) on top of the EXACT 2D section
122
- // polygons produced by SectionCutter. Per-vertex colour now DRIVES the
123
- // fill when a polygon opts in (alpha ≥ 0): a material-layer wall/slab
124
- // fills each layer with its own IfcMaterial colour, matching the 3D
125
- // build-up. Polygons that pass the sentinel alpha −1 fall back to the
126
- // uniform cap style, so single-material cuts read as one architectural
127
- // section exactly as before. Hatch + stroke apply over either base.
128
- const fillShader = this.device.createShaderModule({
129
- code: `
130
- struct Uniforms {
131
- viewProj: mat4x4<f32>,
132
- planeOffset: vec4<f32>, // Small offset to render slightly in front of section plane
133
- capFillColor: vec4<f32>,
134
- capStrokeColor: vec4<f32>,
135
- // x=patternId, y=spacingPx, z=angleRad, w=widthPx
136
- params: vec4<f32>,
137
- // x=secondaryAngleRad, y,z,w reserved
138
- params2: vec4<f32>,
139
- }
140
- @binding(0) @group(0) var<uniform> uniforms: Uniforms;
141
-
142
- struct VertexInput {
143
- @location(0) position: vec3<f32>,
144
- @location(1) color: vec4<f32>,
145
- }
146
-
147
- struct VertexOutput {
148
- @builtin(position) position: vec4<f32>,
149
- @location(0) color: vec4<f32>,
150
- }
151
-
152
- @vertex
153
- fn vs_main(input: VertexInput) -> VertexOutput {
154
- var output: VertexOutput;
155
- let offsetPos = input.position + uniforms.planeOffset.xyz;
156
- output.position = uniforms.viewProj * vec4<f32>(offsetPos, 1.0);
157
- output.color = input.color;
158
- return output;
159
- }
160
-
161
- // Screen-space hatch pattern helpers (ported from section-cap.wgsl).
162
- fn lineMask(u: f32, s: f32, w: f32) -> f32 {
163
- let f = fract(u / s) * s;
164
- let d = min(f, s - f);
165
- return 1.0 - smoothstep(w * 0.5, w * 0.5 + 1.0, d);
166
- }
167
- fn rotate(p: vec2<f32>, a: f32) -> vec2<f32> {
168
- let c = cos(a);
169
- let s = sin(a);
170
- return vec2<f32>(c * p.x - s * p.y, s * p.x + c * p.y);
171
- }
172
- fn hatchIntensity(fragCoord: vec2<f32>, patternId: u32, spacing: f32, angle: f32, width: f32, angle2: f32) -> f32 {
173
- let p = fragCoord;
174
- if (patternId == 0u) { return 0.0; } // solid
175
- if (patternId == 1u) { // diagonal
176
- let r = rotate(p, angle);
177
- return lineMask(r.x, spacing, width);
178
- }
179
- if (patternId == 2u) { // cross-hatch
180
- let r = rotate(p, angle);
181
- let r2 = rotate(p, angle2);
182
- return max(lineMask(r.x, spacing, width), lineMask(r2.x, spacing, width));
183
- }
184
- if (patternId == 3u) { return lineMask(p.y, spacing, width); } // horizontal
185
- if (patternId == 4u) { return lineMask(p.x, spacing, width); } // vertical
186
- if (patternId == 5u) {
187
- // Concrete (ISO 128-50): clean regular dot grid. The previous
188
- // version layered dashes on top which looked noisy and broken.
189
- // Dots sit at every grid intersection; radius scales with
190
- // stroke width so the user's width slider works consistently.
191
- let gx = p.x - round(p.x / spacing) * spacing;
192
- let gy = p.y - round(p.y / spacing) * spacing;
193
- let d = sqrt(gx * gx + gy * gy);
194
- let radius = max(1.0, width * 1.2);
195
- return 1.0 - smoothstep(radius, radius + 1.0, d);
196
- }
197
- if (patternId == 6u) { // brick
198
- let bandH = spacing;
199
- let band = floor(p.y / bandH);
200
- let offset = select(0.0, bandH, (u32(band) & 1u) == 1u);
201
- let horiz = lineMask(p.y, bandH, width);
202
- let vertPos = p.x + offset * 0.5;
203
- let vert = step(fract(vertPos / (bandH * 2.0)), 0.02);
204
- return max(horiz, vert);
205
- }
206
- if (patternId == 7u) { // insulation
207
- let y = spacing * 0.5 * sin(p.x * 6.2831853 / spacing) + p.y;
208
- return lineMask(y, spacing, width);
209
- }
210
- return 0.0;
211
- }
212
-
213
- struct FragOut {
214
- @location(0) color: vec4<f32>,
215
- @location(1) objectId: vec4<f32>,
216
- }
217
-
218
- @fragment
219
- fn fs_main(input: VertexOutput) -> FragOut {
220
- let patternId = u32(uniforms.params.x + 0.5);
221
- let spacing = max(2.0, uniforms.params.y);
222
- let angle = uniforms.params.z;
223
- let width = max(1.0, uniforms.params.w);
224
- let angle2 = uniforms.params2.x;
225
-
226
- let h = hatchIntensity(input.position.xy, patternId, spacing, angle, width, angle2);
227
- // Per-polygon colour (a material-layer slab fills with its own
228
- // IfcMaterial RGBA) overrides the uniform cap fill when present.
229
- // Polygons without a colour carry the sentinel alpha −1 and fall back
230
- // to the user's cap style, byte-identically. Hatch + stroke apply over
231
- // whichever base is chosen, so the architectural hatch still works.
232
- let useVertex = input.color.a >= 0.0;
233
- let baseFill = select(uniforms.capFillColor, input.color, useVertex);
234
- let rgb = mix(baseFill.rgb, uniforms.capStrokeColor.rgb, h * uniforms.capStrokeColor.a);
235
- let a = max(baseFill.a, h * uniforms.capStrokeColor.a);
236
-
237
- var out: FragOut;
238
- out.color = vec4<f32>(rgb, a);
239
- out.objectId = vec4<f32>(0.0, 0.0, 0.0, 0.0);
240
- return out;
241
- }
242
- `,
243
- });
244
- // Shader for lines (uniform color)
245
- const lineShader = this.device.createShaderModule({
246
- code: `
247
- // Mirrors the fill shader's uniform layout so both pipelines can share
248
- // one buffer; the line shader only reads viewProj, planeOffset and the
249
- // appended lineColor (byte offset 144). capFillColor/… are unused here but
250
- // declared for correct field offsets.
251
- struct Uniforms {
252
- viewProj: mat4x4<f32>,
253
- planeOffset: vec4<f32>,
254
- capFillColor: vec4<f32>,
255
- capStrokeColor: vec4<f32>,
256
- params: vec4<f32>,
257
- params2: vec4<f32>,
258
- lineColor: vec4<f32>,
259
- }
260
- @binding(0) @group(0) var<uniform> uniforms: Uniforms;
261
-
262
- struct VertexInput {
263
- @location(0) position: vec3<f32>,
264
- }
265
-
266
- struct VertexOutput {
267
- @builtin(position) position: vec4<f32>,
268
- }
269
-
270
- @vertex
271
- fn vs_main(input: VertexInput) -> VertexOutput {
272
- var output: VertexOutput;
273
- let offsetPos = input.position + uniforms.planeOffset.xyz;
274
- let clip = uniforms.viewProj * vec4<f32>(offsetPos, 1.0);
275
- // Reverse-Z decal nudge for lines coplanar with model faces
276
- // (issue #812). WebGPU forbids depthStencil.depthBias on non-
277
- // triangle topologies, so we do the equivalent in clip space:
278
- // adding a small positive multiple of clip.w raises NDC z by a
279
- // constant after the w-divide, which under reverse-Z means
280
- // "slightly closer" — enough to beat MSAA jitter on annotation
281
- // lines that ride exactly on a wall/floor.
282
- output.position = vec4<f32>(clip.x, clip.y, clip.z + 5e-5 * clip.w, clip.w);
283
- return output;
284
- }
285
-
286
- struct FragOutLine {
287
- @location(0) color: vec4<f32>,
288
- @location(1) objectId: vec4<f32>,
289
- }
290
-
291
- @fragment
292
- fn fs_main(input: VertexOutput) -> FragOutLine {
293
- var out: FragOutLine;
294
- out.color = uniforms.lineColor; // consumer-themeable (defaults black)
295
- out.objectId = vec4<f32>(0.0, 0.0, 0.0, 0.0);
296
- return out;
297
- }
298
- `,
299
- });
108
+ const fillShader = this.device.createShaderModule({ code: SECTION_2D_CAP_FILL_WGSL });
109
+ const lineShader = this.device.createShaderModule({ code: SECTION_2D_OVERLAY_LINE_WGSL });
300
110
  // Pipeline for filled polygons
301
111
  this.fillPipeline = this.device.createRenderPipeline({
302
112
  layout: pipelineLayout,
@@ -402,73 +212,48 @@ export class Section2DOverlayRenderer {
402
212
  count: this.sampleCount,
403
213
  },
404
214
  });
405
- // Create uniform buffer.
406
- // viewProj — mat4x4 64 B
407
- // planeOffset vec4 16 B
408
- // capFillColor — vec4 16 B
409
- // capStrokeColor vec4 16 B
410
- // params — vec4 16 B x=patternId, y=spacingPx, z=angleRad, w=widthPx
411
- // params2 — vec4 16 B x=secondaryAngleRad
412
- // lineColor — vec4 16 B overlay/section-cut line colour
413
- // Total: 160 B. The fill fragment shader reads up to params2 (144 B); the
414
- // line shader reads viewProj/planeOffset + the appended lineColor (offset
415
- // 144 B), so the two pipelines share this one buffer without aliasing.
215
+ // One 160-byte uniform buffer shared by BOTH pipelines: the fill fragment
216
+ // shader reads up to params2 (144 B), the line shader reads
217
+ // viewProj/planeOffset plus the appended lineColor at byte offset 144, so
218
+ // they do not alias. Field offsets live in SECTION_2D_UNIFORM_SLOTS next to
219
+ // the WGSL that defines them.
220
+ // …once per draw site (SECTION_2D_UNIFORM_SLOT_COUNT of them), spaced by
221
+ // the device's dynamic-offset alignment. Still one buffer under one owner;
222
+ // what changed is that the six draws no longer overwrite each other.
223
+ this.uniformStride = sectionUniformSlotStride(this.device);
416
224
  this.uniformBuffer = this.device.createBuffer({
417
- size: 160,
225
+ size: this.uniformStride * SECTION_2D_UNIFORM_SLOT_COUNT,
418
226
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
419
227
  });
420
- // Create bind group
228
+ // Create bind group. `size` pins the binding to ONE record — without it
229
+ // the binding would span the whole buffer and the dynamic offset would be
230
+ // rejected for every slot but the first.
421
231
  this.bindGroup = this.device.createBindGroup({
422
232
  layout: this.bindGroupLayout,
423
233
  entries: [
424
- { binding: 0, resource: { buffer: this.uniformBuffer } },
234
+ {
235
+ binding: 0,
236
+ resource: { buffer: this.uniformBuffer, offset: 0, size: SECTION_2D_UNIFORM_BYTES },
237
+ },
425
238
  ],
426
239
  });
427
240
  this.initialized = true;
428
241
  }
429
242
  /**
430
- * Transform 2D coordinates to 3D coordinates on the section plane.
431
- *
432
- * Cardinal axis path (legacy, unchanged):
433
- * - Y axis (down): 2D (x, y) = 3D (x, z) - looking down at XZ plane
434
- * - Z axis (front): 2D (x, y) = 3D (x, y) - looking along Z at XY plane
435
- * - X axis (side): 2D (x, y) = 3D (z, y) - looking along X at ZY plane
436
- * When flipped, the 2D x coordinate is negated.
437
- *
438
- * Custom-plane path (issue #243): when `customPlane` is supplied, the
439
- * 3D point is `origin + tangent*x2d + bitangent*y2d`. The same basis
440
- * is used by `SectionCutter` to project triangle-plane intersections
441
- * to 2D, so the round-trip is exact and the cap polygons land
442
- * precisely on the user's tilted plane.
243
+ * The shared line-pipeline resources a WorldLineBuffer borrows for a draw.
244
+ * Returns null before `init()` has produced them (or after `dispose()`), so
245
+ * every `draw*Lines3D` bails on the same condition it always did.
443
246
  */
444
- transform2Dto3D(x2d, y2d, axis, planePosition, flipped = false, customPlane) {
445
- if (customPlane) {
446
- // Custom plane: bypass the cardinal-axis swap. `flipped` is
447
- // intentionally ignored because for arbitrary planes the cutter
448
- // does not mirror its 2D output (mirroring only makes sense for
449
- // cardinal projections that have a consistent "view direction").
450
- const o = customPlane.origin;
451
- const t = customPlane.tangent;
452
- const b = customPlane.bitangent;
453
- return [
454
- o[0] + t[0] * x2d + b[0] * y2d,
455
- o[1] + t[1] * x2d + b[1] * y2d,
456
- o[2] + t[2] * x2d + b[2] * y2d,
457
- ];
458
- }
459
- // Handle flipped - the 2D x coordinate was negated during projection
460
- const x = flipped ? -x2d : x2d;
461
- switch (axis) {
462
- case 'down': // Y axis - horizontal cut (floor plan)
463
- // 2D.x = 3D.x, 2D.y = 3D.z -> 3D (x, planeY, y)
464
- return [x, planePosition, y2d];
465
- case 'front': // Z axis - vertical cut (section view)
466
- // 2D.x = 3D.x, 2D.y = 3D.y -> 3D (x, y, planeZ)
467
- return [x, y2d, planePosition];
468
- case 'side': // X axis - vertical cut (side elevation)
469
- // 2D.x = 3D.z, 2D.y = 3D.y -> 3D (planeX, y, x)
470
- return [planePosition, y2d, x];
471
- }
247
+ lineResources() {
248
+ if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
249
+ return null;
250
+ return {
251
+ device: this.device,
252
+ pipeline: this.linePipeline,
253
+ bindGroup: this.bindGroup,
254
+ uniformBuffer: this.uniformBuffer,
255
+ uniformStride: this.uniformStride,
256
+ };
472
257
  }
473
258
  /**
474
259
  * Upload 2D drawing data to GPU buffers.
@@ -485,113 +270,30 @@ export class Section2DOverlayRenderer {
485
270
  */
486
271
  uploadDrawing(polygons, lines, axis, planePosition, flipped = false, customPlane) {
487
272
  this.init();
488
- // Clean up old buffers and reset counts
489
- if (this.fillVertexBuffer) {
490
- this.fillVertexBuffer.destroy();
491
- this.fillVertexBuffer = null;
492
- }
493
- if (this.fillIndexBuffer) {
494
- this.fillIndexBuffer.destroy();
495
- this.fillIndexBuffer = null;
496
- }
497
- if (this.lineVertexBuffer) {
498
- this.lineVertexBuffer.destroy();
499
- this.lineVertexBuffer = null;
500
- }
501
- this.fillIndexCount = 0;
502
- this.lineVertexCount = 0;
503
- // Build fill geometry (triangulated polygons)
504
- const fillVertices = [];
505
- const fillIndices = [];
506
- let vertexOffset = 0;
507
- for (const polygon of polygons) {
508
- const outer = polygon.polygon.outer;
509
- if (outer.length < 3)
510
- continue;
511
- // Per-polygon fill colour. A material-layer wall/slab delivers one polygon
512
- // per layer, each carrying its IfcMaterial RGBA (window frame/glass parts
513
- // likewise). Polygons WITHOUT a colour use the sentinel alpha −1 so the
514
- // fill shader falls back to the uniform cap style (architectural fill +
515
- // hatch) byte-identically — see fs_main.
516
- const color = polygon.color ?? [0, 0, 0, -1];
517
- // Hole-aware ear-clipping (reused from the IfcAnnotationFillArea fill path)
518
- // replaces the old convex fan. The fan ignored holes and inverted on the
519
- // CONCAVE cross-sections that arbitrary IFC profiles (and material-layer
520
- // slabs) cut into, leaving the cut face uncovered — it read as a hollow
521
- // shell. Section 2D points are (x, y); the triangulator works in (x, z),
522
- // so y maps to z.
523
- const outerRing = outer.map((p) => ({ x: p.x, z: p.y }));
524
- const holeRings = polygon.polygon.holes
525
- .filter((h) => h.length >= 3)
526
- .map((h) => h.map((p) => ({ x: p.x, z: p.y })));
527
- const stitched = holeRings.length > 0 ? joinHoles(outerRing, holeRings) : outerRing;
528
- const tris = earClip(stitched);
529
- if (tris.length === 0)
530
- continue;
531
- const baseVertex = vertexOffset;
532
- for (const pt of stitched) {
533
- const [x3d, y3d, z3d] = this.transform2Dto3D(pt.x, pt.z, axis, planePosition, flipped, customPlane);
534
- fillVertices.push(x3d, y3d, z3d, color[0], color[1], color[2], color[3]);
535
- vertexOffset++;
536
- }
537
- for (const [a, b, c] of tris) {
538
- fillIndices.push(baseVertex + a, baseVertex + b, baseVertex + c);
539
- }
540
- }
541
- // Create fill buffers
542
- if (fillVertices.length > 0) {
543
- const fillVertexData = new Float32Array(fillVertices);
273
+ this.clearGeometry();
274
+ const lift = createSectionLift(axis, planePosition, flipped, customPlane);
275
+ const fill = buildCapFillGeometry(polygons, lift);
276
+ if (fill) {
544
277
  this.fillVertexBuffer = this.device.createBuffer({
545
- size: fillVertexData.byteLength,
278
+ size: fill.vertices.byteLength,
546
279
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
547
280
  });
548
- this.device.queue.writeBuffer(this.fillVertexBuffer, 0, fillVertexData);
549
- const fillIndexData = new Uint32Array(fillIndices);
281
+ this.device.queue.writeBuffer(this.fillVertexBuffer, 0, fill.vertices);
550
282
  this.fillIndexBuffer = this.device.createBuffer({
551
- size: fillIndexData.byteLength,
283
+ size: fill.indices.byteLength,
552
284
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
553
285
  });
554
- this.device.queue.writeBuffer(this.fillIndexBuffer, 0, fillIndexData);
555
- this.fillIndexCount = fillIndices.length;
286
+ this.device.queue.writeBuffer(this.fillIndexBuffer, 0, fill.indices);
287
+ this.fillIndexCount = fill.indices.length;
556
288
  }
557
- // Build line geometry
558
- const lineVertices = [];
559
- // Polygon outlines
560
- for (const polygon of polygons) {
561
- const outer = polygon.polygon.outer;
562
- for (let i = 0; i < outer.length; i++) {
563
- const p1 = outer[i];
564
- const p2 = outer[(i + 1) % outer.length];
565
- const [x1, y1, z1] = this.transform2Dto3D(p1.x, p1.y, axis, planePosition, flipped, customPlane);
566
- const [x2, y2, z2] = this.transform2Dto3D(p2.x, p2.y, axis, planePosition, flipped, customPlane);
567
- lineVertices.push(x1, y1, z1, x2, y2, z2);
568
- }
569
- // Hole outlines
570
- for (const hole of polygon.polygon.holes) {
571
- for (let i = 0; i < hole.length; i++) {
572
- const p1 = hole[i];
573
- const p2 = hole[(i + 1) % hole.length];
574
- const [x1, y1, z1] = this.transform2Dto3D(p1.x, p1.y, axis, planePosition, flipped, customPlane);
575
- const [x2, y2, z2] = this.transform2Dto3D(p2.x, p2.y, axis, planePosition, flipped, customPlane);
576
- lineVertices.push(x1, y1, z1, x2, y2, z2);
577
- }
578
- }
579
- }
580
- // Additional drawing lines (hatching, etc.)
581
- for (const line of lines) {
582
- const [x1, y1, z1] = this.transform2Dto3D(line.line.start.x, line.line.start.y, axis, planePosition, flipped, customPlane);
583
- const [x2, y2, z2] = this.transform2Dto3D(line.line.end.x, line.line.end.y, axis, planePosition, flipped, customPlane);
584
- lineVertices.push(x1, y1, z1, x2, y2, z2);
585
- }
586
- // Create line buffer
587
- if (lineVertices.length > 0) {
588
- const lineVertexData = new Float32Array(lineVertices);
289
+ const outline = buildDrawingOutlineVertices(polygons, lines, lift);
290
+ if (outline) {
589
291
  this.lineVertexBuffer = this.device.createBuffer({
590
- size: lineVertexData.byteLength,
292
+ size: outline.byteLength,
591
293
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
592
294
  });
593
- this.device.queue.writeBuffer(this.lineVertexBuffer, 0, lineVertexData);
594
- this.lineVertexCount = lineVertices.length / 3; // Each vertex is 3 floats
295
+ this.device.queue.writeBuffer(this.lineVertexBuffer, 0, outline);
296
+ this.lineVertexCount = outline.length / 3; // Each vertex is 3 floats
595
297
  }
596
298
  }
597
299
  /**
@@ -632,29 +334,27 @@ export class Section2DOverlayRenderer {
632
334
  */
633
335
  uploadAnnotationLines3D(vertices) {
634
336
  this.init();
635
- if (this.annotationLineVertexBuffer) {
636
- this.annotationLineVertexBuffer.destroy();
637
- this.annotationLineVertexBuffer = null;
638
- }
639
- this.annotationLineVertexCount = 0;
640
- if (vertices.length < 6)
641
- return;
642
- this.annotationLineVertexBuffer = this.device.createBuffer({
643
- size: vertices.byteLength,
644
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
645
- });
646
- this.device.queue.writeBuffer(this.annotationLineVertexBuffer, 0, vertices);
647
- this.annotationLineVertexCount = vertices.length / 3;
337
+ this.annotationLines.upload(this.device, vertices);
648
338
  }
649
339
  clearAnnotationLines3D() {
650
- if (this.annotationLineVertexBuffer) {
651
- this.annotationLineVertexBuffer.destroy();
652
- this.annotationLineVertexBuffer = null;
653
- }
654
- this.annotationLineVertexCount = 0;
340
+ this.annotationLines.clear();
655
341
  }
656
342
  hasAnnotationLines3D() {
657
- return this.annotationLineVertexCount > 0;
343
+ return this.annotationLines.has();
344
+ }
345
+ /**
346
+ * Draw the standalone annotation line overlay. Uses the same line pipeline
347
+ * as the section cut outlines (vertex format: 3 floats per vertex, line-list
348
+ * topology) but reads from a separate vertex buffer. The pipeline's
349
+ * `planeOffset` uniform is zeroed so vertices render at their authored
350
+ * world position.
351
+ */
352
+ drawAnnotationLines3D(pass, viewProj) {
353
+ this.init();
354
+ const resources = this.lineResources();
355
+ if (!resources)
356
+ return;
357
+ this.annotationLines.draw(pass, resources, viewProj, this.overlayLineColor);
658
358
  }
659
359
  /**
660
360
  * Upload a flat Float32Array of 3D line-list vertices for the alignment
@@ -664,55 +364,13 @@ export class Section2DOverlayRenderer {
664
364
  */
665
365
  uploadAlignmentLines3D(vertices) {
666
366
  this.init();
667
- if (this.alignmentLineVertexBuffer) {
668
- this.alignmentLineVertexBuffer.destroy();
669
- this.alignmentLineVertexBuffer = null;
670
- }
671
- this.alignmentLineVertexCount = 0;
672
- if (vertices.length < 6)
673
- return;
674
- this.alignmentLineVertexBuffer = this.device.createBuffer({
675
- size: vertices.byteLength,
676
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
677
- });
678
- this.device.queue.writeBuffer(this.alignmentLineVertexBuffer, 0, vertices);
679
- this.alignmentLineVertexCount = vertices.length / 3;
367
+ this.alignmentLines.upload(this.device, vertices);
680
368
  }
681
369
  clearAlignmentLines3D() {
682
- if (this.alignmentLineVertexBuffer) {
683
- this.alignmentLineVertexBuffer.destroy();
684
- this.alignmentLineVertexBuffer = null;
685
- }
686
- this.alignmentLineVertexCount = 0;
370
+ this.alignmentLines.clear();
687
371
  }
688
372
  hasAlignmentLines3D() {
689
- return this.alignmentLineVertexCount > 0;
690
- }
691
- /**
692
- * Draw the standalone annotation line overlay. Uses the same line pipeline
693
- * as the section cut outlines (vertex format: 3 floats per vertex, line-list
694
- * topology) but reads from a separate vertex buffer. The pipeline's
695
- * `planeOffset` uniform is zeroed so vertices render at their authored
696
- * world position.
697
- */
698
- drawAnnotationLines3D(pass, viewProj) {
699
- this.init();
700
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
701
- return;
702
- if (!this.annotationLineVertexBuffer || this.annotationLineVertexCount === 0)
703
- return;
704
- // Reuse the existing fill uniform buffer slot 0 (viewProj + planeOffset).
705
- // The line shader only reads those two fields, so the fill/cap-style
706
- // tail of the uniforms is harmless leftover data.
707
- const uniforms = new Float32Array(40);
708
- uniforms.set(viewProj, 0);
709
- // planeOffset = 0 — vertices are already in world space.
710
- uniforms.set(this.overlayLineColor, 36); // lineColor (byte offset 144)
711
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
712
- pass.setPipeline(this.linePipeline);
713
- pass.setBindGroup(0, this.bindGroup);
714
- pass.setVertexBuffer(0, this.annotationLineVertexBuffer);
715
- pass.draw(this.annotationLineVertexCount);
373
+ return this.alignmentLines.has();
716
374
  }
717
375
  /**
718
376
  * Draw the alignment centerline overlay. Identical pipeline/uniform setup as
@@ -720,19 +378,10 @@ export class Section2DOverlayRenderer {
720
378
  */
721
379
  drawAlignmentLines3D(pass, viewProj) {
722
380
  this.init();
723
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
381
+ const resources = this.lineResources();
382
+ if (!resources)
724
383
  return;
725
- if (!this.alignmentLineVertexBuffer || this.alignmentLineVertexCount === 0)
726
- return;
727
- const uniforms = new Float32Array(40);
728
- uniforms.set(viewProj, 0);
729
- // planeOffset = 0 — vertices are already in world space.
730
- uniforms.set(this.overlayLineColor, 36); // lineColor (byte offset 144)
731
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
732
- pass.setPipeline(this.linePipeline);
733
- pass.setBindGroup(0, this.bindGroup);
734
- pass.setVertexBuffer(0, this.alignmentLineVertexBuffer);
735
- pass.draw(this.alignmentLineVertexCount);
384
+ this.alignmentLines.draw(pass, resources, viewProj, this.overlayLineColor);
736
385
  }
737
386
  /**
738
387
  * Upload structural-grid (IfcGridAxis) segments as a flat
@@ -742,29 +391,13 @@ export class Section2DOverlayRenderer {
742
391
  */
743
392
  uploadGridLines3D(vertices) {
744
393
  this.init();
745
- if (this.gridLineVertexBuffer) {
746
- this.gridLineVertexBuffer.destroy();
747
- this.gridLineVertexBuffer = null;
748
- }
749
- this.gridLineVertexCount = 0;
750
- if (vertices.length < 6)
751
- return;
752
- this.gridLineVertexBuffer = this.device.createBuffer({
753
- size: vertices.byteLength,
754
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
755
- });
756
- this.device.queue.writeBuffer(this.gridLineVertexBuffer, 0, vertices);
757
- this.gridLineVertexCount = vertices.length / 3;
394
+ this.gridLines.upload(this.device, vertices);
758
395
  }
759
396
  clearGridLines3D() {
760
- if (this.gridLineVertexBuffer) {
761
- this.gridLineVertexBuffer.destroy();
762
- this.gridLineVertexBuffer = null;
763
- }
764
- this.gridLineVertexCount = 0;
397
+ this.gridLines.clear();
765
398
  }
766
399
  hasGridLines3D() {
767
- return this.gridLineVertexCount > 0;
400
+ return this.gridLines.has();
768
401
  }
769
402
  /**
770
403
  * Draw the structural-grid overlay. Identical pipeline/uniform setup as
@@ -772,19 +405,10 @@ export class Section2DOverlayRenderer {
772
405
  */
773
406
  drawGridLines3D(pass, viewProj) {
774
407
  this.init();
775
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
408
+ const resources = this.lineResources();
409
+ if (!resources)
776
410
  return;
777
- if (!this.gridLineVertexBuffer || this.gridLineVertexCount === 0)
778
- return;
779
- const uniforms = new Float32Array(40);
780
- uniforms.set(viewProj, 0);
781
- // planeOffset = 0 — vertices are already in world space.
782
- uniforms.set(this.overlayLineColor, 36); // lineColor (byte offset 144)
783
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
784
- pass.setPipeline(this.linePipeline);
785
- pass.setBindGroup(0, this.bindGroup);
786
- pass.setVertexBuffer(0, this.gridLineVertexBuffer);
787
- pass.draw(this.gridLineVertexCount);
411
+ this.gridLines.draw(pass, resources, viewProj, this.overlayLineColor);
788
412
  }
789
413
  /**
790
414
  * Upload the DXF reference-layer's line paths as a flat `[x,y,z, x,y,z, …]`
@@ -795,29 +419,13 @@ export class Section2DOverlayRenderer {
795
419
  */
796
420
  uploadDxfLines3D(vertices) {
797
421
  this.init();
798
- if (this.dxfLineVertexBuffer) {
799
- this.dxfLineVertexBuffer.destroy();
800
- this.dxfLineVertexBuffer = null;
801
- }
802
- this.dxfLineVertexCount = 0;
803
- if (vertices.length < 6)
804
- return;
805
- this.dxfLineVertexBuffer = this.device.createBuffer({
806
- size: vertices.byteLength,
807
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
808
- });
809
- this.device.queue.writeBuffer(this.dxfLineVertexBuffer, 0, vertices);
810
- this.dxfLineVertexCount = vertices.length / 3;
422
+ this.dxfLines.upload(this.device, vertices);
811
423
  }
812
424
  clearDxfLines3D() {
813
- if (this.dxfLineVertexBuffer) {
814
- this.dxfLineVertexBuffer.destroy();
815
- this.dxfLineVertexBuffer = null;
816
- }
817
- this.dxfLineVertexCount = 0;
425
+ this.dxfLines.clear();
818
426
  }
819
427
  hasDxfLines3D() {
820
- return this.dxfLineVertexCount > 0;
428
+ return this.dxfLines.has();
821
429
  }
822
430
  /**
823
431
  * Draw the 3D DXF reference-layer overlay. Identical pipeline/uniform
@@ -825,19 +433,10 @@ export class Section2DOverlayRenderer {
825
433
  */
826
434
  drawDxfLines3D(pass, viewProj) {
827
435
  this.init();
828
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
436
+ const resources = this.lineResources();
437
+ if (!resources)
829
438
  return;
830
- if (!this.dxfLineVertexBuffer || this.dxfLineVertexCount === 0)
831
- return;
832
- const uniforms = new Float32Array(40);
833
- uniforms.set(viewProj, 0);
834
- // planeOffset = 0 — vertices are already in world space.
835
- uniforms.set(this.overlayLineColor, 36); // lineColor (byte offset 144)
836
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
837
- pass.setPipeline(this.linePipeline);
838
- pass.setBindGroup(0, this.bindGroup);
839
- pass.setVertexBuffer(0, this.dxfLineVertexBuffer);
840
- pass.draw(this.dxfLineVertexCount);
439
+ this.dxfLines.draw(pass, resources, viewProj, this.overlayLineColor);
841
440
  }
842
441
  /** Colour for the clash-overlap box (its own, not the shared overlay colour). */
843
442
  setClashBoxLineColor(color) {
@@ -850,46 +449,21 @@ export class Section2DOverlayRenderer {
850
449
  */
851
450
  uploadClashBoxLines3D(vertices) {
852
451
  this.init();
853
- if (this.clashBoxLineVertexBuffer) {
854
- this.clashBoxLineVertexBuffer.destroy();
855
- this.clashBoxLineVertexBuffer = null;
856
- }
857
- this.clashBoxLineVertexCount = 0;
858
- if (vertices.length < 6)
859
- return;
860
- this.clashBoxLineVertexBuffer = this.device.createBuffer({
861
- size: vertices.byteLength,
862
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
863
- });
864
- this.device.queue.writeBuffer(this.clashBoxLineVertexBuffer, 0, vertices);
865
- this.clashBoxLineVertexCount = vertices.length / 3;
452
+ this.clashBoxLines.upload(this.device, vertices);
866
453
  }
867
454
  clearClashBoxLines3D() {
868
- if (this.clashBoxLineVertexBuffer) {
869
- this.clashBoxLineVertexBuffer.destroy();
870
- this.clashBoxLineVertexBuffer = null;
871
- }
872
- this.clashBoxLineVertexCount = 0;
455
+ this.clashBoxLines.clear();
873
456
  }
874
457
  hasClashBoxLines3D() {
875
- return this.clashBoxLineVertexCount > 0;
458
+ return this.clashBoxLines.has();
876
459
  }
877
460
  /** Draw the clash-overlap box in its own colour. Same line pipeline. (#1277) */
878
461
  drawClashBoxLines3D(pass, viewProj) {
879
462
  this.init();
880
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
463
+ const resources = this.lineResources();
464
+ if (!resources)
881
465
  return;
882
- if (!this.clashBoxLineVertexBuffer || this.clashBoxLineVertexCount === 0)
883
- return;
884
- const uniforms = new Float32Array(40);
885
- uniforms.set(viewProj, 0);
886
- // planeOffset = 0 — vertices are already in world space.
887
- uniforms.set(this.clashBoxLineColor, 36); // lineColor (byte offset 144)
888
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
889
- pass.setPipeline(this.linePipeline);
890
- pass.setBindGroup(0, this.bindGroup);
891
- pass.setVertexBuffer(0, this.clashBoxLineVertexBuffer);
892
- pass.draw(this.clashBoxLineVertexCount);
466
+ this.clashBoxLines.draw(pass, resources, viewProj, this.clashBoxLineColor);
893
467
  }
894
468
  /**
895
469
  * Check if there is geometry to draw
@@ -908,7 +482,7 @@ export class Section2DOverlayRenderer {
908
482
  if (!this.hasGeometry()) {
909
483
  return;
910
484
  }
911
- const { axis, viewProj } = options;
485
+ const { viewProj } = options;
912
486
  // No offset — cap renders exactly on the section plane. The previous
913
487
  // 0.3m bias was there to keep the outline lines clear of below-plane
914
488
  // geometry, but it made the cap visually drift off the slider plane
@@ -917,55 +491,43 @@ export class Section2DOverlayRenderer {
917
491
  // coincident below-plane top faces is not an issue; the stencil gate
918
492
  // keeps the fill restricted to the actual cap polygons.
919
493
  const offset = [0, 0, 0];
920
- // Update uniforms. Layout mirrors the WGSL struct above:
921
- // 0..15 viewProj
922
- // 16..19 planeOffset
923
- // 20..23 capFillColor
924
- // 24..27 capStrokeColor
925
- // 28..31 params (patternId, spacingPx, angleRad, widthPx)
926
- // 32..35 params2 (secondaryAngleRad, _, _, _)
927
- // 36..39 lineColor (section-cut outline colour)
928
- const uniforms = new Float32Array(40);
929
- uniforms.set(viewProj, 0);
930
- uniforms.set(this.overlayLineColor, 36); // section-cut outline colour
931
- uniforms[16] = offset[0];
932
- uniforms[17] = offset[1];
933
- uniforms[18] = offset[2];
934
- uniforms[19] = 0;
494
+ // Update uniforms. Field offsets come from SECTION_2D_UNIFORM_SLOTS, which
495
+ // sits next to the WGSL struct it describes.
496
+ const S = SECTION_2D_UNIFORM_SLOTS;
497
+ const uniforms = new Float32Array(SECTION_2D_UNIFORM_FLOATS);
498
+ uniforms.set(viewProj, S.viewProj);
499
+ uniforms.set(this.overlayLineColor, S.lineColor); // section-cut outline colour
500
+ uniforms[S.planeOffset + 0] = offset[0];
501
+ uniforms[S.planeOffset + 1] = offset[1];
502
+ uniforms[S.planeOffset + 2] = offset[2];
503
+ uniforms[S.planeOffset + 3] = 0;
935
504
  const cs = options.capStyle;
936
505
  if (cs) {
937
- uniforms[20] = cs.fillColor[0];
938
- uniforms[21] = cs.fillColor[1];
939
- uniforms[22] = cs.fillColor[2];
940
- uniforms[23] = cs.fillColor[3];
941
- uniforms[24] = cs.strokeColor[0];
942
- uniforms[25] = cs.strokeColor[1];
943
- uniforms[26] = cs.strokeColor[2];
944
- uniforms[27] = cs.strokeColor[3];
945
- uniforms[28] = cs.patternId;
946
- uniforms[29] = cs.spacingPx;
947
- uniforms[30] = cs.angleRad;
948
- uniforms[31] = cs.widthPx;
949
- uniforms[32] = cs.secondaryAngleRad;
506
+ uniforms.set(cs.fillColor, S.capFillColor);
507
+ uniforms.set(cs.strokeColor, S.capStrokeColor);
508
+ uniforms[S.params + 0] = cs.patternId;
509
+ uniforms[S.params + 1] = cs.spacingPx;
510
+ uniforms[S.params + 2] = cs.angleRad;
511
+ uniforms[S.params + 3] = cs.widthPx;
512
+ uniforms[S.params2 + 0] = cs.secondaryAngleRad;
950
513
  }
951
514
  else {
952
515
  // Sensible defaults when caller omits style (e.g. legacy lines-only
953
516
  // use): solid fill using a warm-paper colour, no hatch.
954
- uniforms[20] = 0.92;
955
- uniforms[21] = 0.88;
956
- uniforms[22] = 0.78;
957
- uniforms[23] = 1;
958
- uniforms[24] = 0.10;
959
- uniforms[25] = 0.10;
960
- uniforms[26] = 0.10;
961
- uniforms[27] = 1;
962
- uniforms[28] = 0; // solid pattern
963
- uniforms[29] = 8;
964
- uniforms[30] = Math.PI / 4;
965
- uniforms[31] = 1;
966
- uniforms[32] = -Math.PI / 4;
967
- }
968
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
517
+ uniforms.set([0.92, 0.88, 0.78, 1], S.capFillColor);
518
+ uniforms.set([0.10, 0.10, 0.10, 1], S.capStrokeColor);
519
+ uniforms[S.params + 0] = 0; // solid pattern
520
+ uniforms[S.params + 1] = 8;
521
+ uniforms[S.params + 2] = Math.PI / 4;
522
+ uniforms[S.params + 3] = 1;
523
+ uniforms[S.params2 + 0] = -Math.PI / 4;
524
+ }
525
+ // The section cut owns slot 0. Both draws below read it, which is correct:
526
+ // the fill and the outline are one drawing with one plane offset and one
527
+ // style. What they must NOT share is the record the world-space line
528
+ // families write, whose zeroed cap-style tail would otherwise arrive here.
529
+ const capOffset = SECTION_2D_UNIFORM_SLOT_INDEX.sectionCut * this.uniformStride;
530
+ this.device.queue.writeBuffer(this.uniformBuffer, capOffset, uniforms);
969
531
  // Filled polygons = the 3D section cap. Render them ONLY when the
970
532
  // caller opts in (`showFills: true` + a capStyle). This replaces the
971
533
  // old stencil-parity cap, which leaked hatch into empty sky on non-
@@ -978,7 +540,7 @@ export class Section2DOverlayRenderer {
978
540
  this.fillIndexBuffer &&
979
541
  this.fillIndexCount > 0) {
980
542
  pass.setPipeline(this.fillPipeline);
981
- pass.setBindGroup(0, this.bindGroup);
543
+ pass.setBindGroup(0, this.bindGroup, [capOffset]);
982
544
  pass.setVertexBuffer(0, this.fillVertexBuffer);
983
545
  pass.setIndexBuffer(this.fillIndexBuffer, 'uint32');
984
546
  pass.drawIndexed(this.fillIndexCount);
@@ -990,13 +552,18 @@ export class Section2DOverlayRenderer {
990
552
  this.lineVertexBuffer &&
991
553
  this.lineVertexCount > 0) {
992
554
  pass.setPipeline(this.linePipeline);
993
- pass.setBindGroup(0, this.bindGroup);
555
+ pass.setBindGroup(0, this.bindGroup, [capOffset]);
994
556
  pass.setVertexBuffer(0, this.lineVertexBuffer);
995
557
  pass.draw(this.lineVertexCount);
996
558
  }
997
559
  }
998
560
  /**
999
- * Dispose of GPU resources
561
+ * Dispose of GPU resources.
562
+ *
563
+ * Every family's buffer must be released here. The clash box (#1277) was the
564
+ * sixth family added and was missing from this list, leaking its vertex
565
+ * buffer on every teardown — `section-2d-overlay-lifecycle.test.ts` now counts
566
+ * destroys against uploads so a seventh family cannot repeat it.
1000
567
  */
1001
568
  dispose() {
1002
569
  this.clearGeometry();
@@ -1004,6 +571,7 @@ export class Section2DOverlayRenderer {
1004
571
  this.clearAlignmentLines3D();
1005
572
  this.clearGridLines3D();
1006
573
  this.clearDxfLines3D();
574
+ this.clearClashBoxLines3D();
1007
575
  if (this.uniformBuffer) {
1008
576
  this.uniformBuffer.destroy();
1009
577
  this.uniformBuffer = null;