@ifc-lite/renderer 1.41.1 → 1.44.0

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 (120) 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-fit-policy.d.ts.map +1 -1
  14. package/dist/camera-fit-policy.js +18 -7
  15. package/dist/camera-fit-policy.js.map +1 -1
  16. package/dist/camera-framing.d.ts +86 -0
  17. package/dist/camera-framing.d.ts.map +1 -0
  18. package/dist/camera-framing.js +230 -0
  19. package/dist/camera-framing.js.map +1 -0
  20. package/dist/camera-guards.d.ts +148 -0
  21. package/dist/camera-guards.d.ts.map +1 -0
  22. package/dist/camera-guards.js +150 -0
  23. package/dist/camera-guards.js.map +1 -0
  24. package/dist/camera-matrices.d.ts +14 -0
  25. package/dist/camera-matrices.d.ts.map +1 -0
  26. package/dist/camera-matrices.js +146 -0
  27. package/dist/camera-matrices.js.map +1 -0
  28. package/dist/camera-preset-view.d.ts +56 -0
  29. package/dist/camera-preset-view.d.ts.map +1 -0
  30. package/dist/camera-preset-view.js +180 -0
  31. package/dist/camera-preset-view.js.map +1 -0
  32. package/dist/camera-projection.d.ts +1 -1
  33. package/dist/camera-projection.d.ts.map +1 -1
  34. package/dist/camera-projection.js +83 -30
  35. package/dist/camera-projection.js.map +1 -1
  36. package/dist/camera-state.d.ts +62 -0
  37. package/dist/camera-state.d.ts.map +1 -0
  38. package/dist/camera-state.js +5 -0
  39. package/dist/camera-state.js.map +1 -0
  40. package/dist/camera.d.ts +22 -11
  41. package/dist/camera.d.ts.map +1 -1
  42. package/dist/camera.js +110 -85
  43. package/dist/camera.js.map +1 -1
  44. package/dist/deviation/deviation-pipeline.d.ts.map +1 -1
  45. package/dist/deviation/deviation-pipeline.js +21 -4
  46. package/dist/deviation/deviation-pipeline.js.map +1 -1
  47. package/dist/environment.d.ts.map +1 -1
  48. package/dist/environment.js +23 -1
  49. package/dist/environment.js.map +1 -1
  50. package/dist/index.d.ts +333 -43
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +753 -704
  53. package/dist/index.js.map +1 -1
  54. package/dist/math.d.ts +59 -2
  55. package/dist/math.d.ts.map +1 -1
  56. package/dist/math.js +173 -23
  57. package/dist/math.js.map +1 -1
  58. package/dist/model-bounds-tracker.d.ts +96 -0
  59. package/dist/model-bounds-tracker.d.ts.map +1 -0
  60. package/dist/model-bounds-tracker.js +147 -0
  61. package/dist/model-bounds-tracker.js.map +1 -0
  62. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  63. package/dist/pointcloud/point-cloud-node.js +24 -4
  64. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  65. package/dist/render-degradation.d.ts +77 -0
  66. package/dist/render-degradation.d.ts.map +1 -0
  67. package/dist/render-degradation.js +52 -0
  68. package/dist/render-degradation.js.map +1 -0
  69. package/dist/render-section-draw.d.ts +51 -0
  70. package/dist/render-section-draw.d.ts.map +1 -0
  71. package/dist/render-section-draw.js +66 -0
  72. package/dist/render-section-draw.js.map +1 -0
  73. package/dist/render-section-plane.d.ts +128 -0
  74. package/dist/render-section-plane.d.ts.map +1 -0
  75. package/dist/render-section-plane.js +298 -0
  76. package/dist/render-section-plane.js.map +1 -0
  77. package/dist/renderer-overlays.d.ts +144 -0
  78. package/dist/renderer-overlays.d.ts.map +1 -0
  79. package/dist/renderer-overlays.js +257 -0
  80. package/dist/renderer-overlays.js.map +1 -0
  81. package/dist/renderer-symbolic-overlays.d.ts +52 -0
  82. package/dist/renderer-symbolic-overlays.d.ts.map +1 -0
  83. package/dist/renderer-symbolic-overlays.js +115 -0
  84. package/dist/renderer-symbolic-overlays.js.map +1 -0
  85. package/dist/scene-geometry.d.ts +32 -0
  86. package/dist/scene-geometry.d.ts.map +1 -1
  87. package/dist/scene-geometry.js +66 -0
  88. package/dist/scene-geometry.js.map +1 -1
  89. package/dist/scene.d.ts +86 -1
  90. package/dist/scene.d.ts.map +1 -1
  91. package/dist/scene.js +391 -201
  92. package/dist/scene.js.map +1 -1
  93. package/dist/section-2d-lift.d.ts +105 -0
  94. package/dist/section-2d-lift.d.ts.map +1 -0
  95. package/dist/section-2d-lift.js +162 -0
  96. package/dist/section-2d-lift.js.map +1 -0
  97. package/dist/section-2d-line-buffer.d.ts +58 -0
  98. package/dist/section-2d-line-buffer.d.ts.map +1 -0
  99. package/dist/section-2d-line-buffer.js +109 -0
  100. package/dist/section-2d-line-buffer.js.map +1 -0
  101. package/dist/section-2d-overlay.d.ts +43 -75
  102. package/dist/section-2d-overlay.d.ts.map +1 -1
  103. package/dist/section-2d-overlay.js +189 -559
  104. package/dist/section-2d-overlay.js.map +1 -1
  105. package/dist/section-plane-basis.d.ts +22 -3
  106. package/dist/section-plane-basis.d.ts.map +1 -1
  107. package/dist/section-plane-basis.js +51 -10
  108. package/dist/section-plane-basis.js.map +1 -1
  109. package/dist/section-plane.d.ts.map +1 -1
  110. package/dist/section-plane.js +10 -1
  111. package/dist/section-plane.js.map +1 -1
  112. package/dist/shaders/section-2d-overlay.wgsl.d.ts +102 -0
  113. package/dist/shaders/section-2d-overlay.wgsl.d.ts.map +1 -0
  114. package/dist/shaders/section-2d-overlay.wgsl.js +281 -0
  115. package/dist/shaders/section-2d-overlay.wgsl.js.map +1 -0
  116. package/dist/visual-enhancement.d.ts +33 -0
  117. package/dist/visual-enhancement.d.ts.map +1 -0
  118. package/dist/visual-enhancement.js +46 -0
  119. package/dist/visual-enhancement.js.map +1 -0
  120. package/package.json +4 -3
@@ -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,25 +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);
67
+ // Standalone 3D DXF reference-layer overlay (issue #2043, follow-up to
68
+ // #1782/#1929's 2D-only DXF underlay). Independent buffer so 3D DXF
69
+ // visibility is independent of the 2D underlay and the other overlays
70
+ // above, but reuses the same line pipeline + shared overlay colour —
71
+ // mirrors the grid overlay exactly. Line paths only (walls/boundaries);
72
+ // DXF fills/text are not lifted to 3D in this iteration.
73
+ dxfLines = new WorldLineBuffer(SECTION_2D_UNIFORM_SLOT_INDEX.dxf);
84
74
  // Standalone 3D clash-overlap-box overlay (#1277): the wireframe AABB of a
85
75
  // focused clash, drawn in its OWN distinct colour (not the shared overlay
86
76
  // line colour) so the overlap region reads as a third colour next to the two
87
77
  // glowing clash elements.
88
- clashBoxLineVertexBuffer = null;
89
- clashBoxLineVertexCount = 0;
78
+ clashBoxLines = new WorldLineBuffer(SECTION_2D_UNIFORM_SLOT_INDEX.clashBox);
90
79
  clashBoxLineColor = [1, 0, 1, 1];
91
80
  constructor(device, format, sampleCount = 4) {
92
81
  this.device = device;
@@ -97,198 +86,27 @@ export class Section2DOverlayRenderer {
97
86
  if (this.initialized)
98
87
  return;
99
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.
100
92
  this.bindGroupLayout = this.device.createBindGroupLayout({
101
93
  entries: [
102
94
  {
103
95
  binding: 0,
104
96
  visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
105
- buffer: { type: 'uniform' },
97
+ buffer: {
98
+ type: 'uniform',
99
+ hasDynamicOffset: true,
100
+ minBindingSize: SECTION_2D_UNIFORM_BYTES,
101
+ },
106
102
  },
107
103
  ],
108
104
  });
109
105
  const pipelineLayout = this.device.createPipelineLayout({
110
106
  bindGroupLayouts: [this.bindGroupLayout],
111
107
  });
112
- // Shader for filled polygons. Applies the user-defined cap style
113
- // (fill colour + screen-space hatch) on top of the EXACT 2D section
114
- // polygons produced by SectionCutter. Per-vertex colour now DRIVES the
115
- // fill when a polygon opts in (alpha ≥ 0): a material-layer wall/slab
116
- // fills each layer with its own IfcMaterial colour, matching the 3D
117
- // build-up. Polygons that pass the sentinel alpha −1 fall back to the
118
- // uniform cap style, so single-material cuts read as one architectural
119
- // section exactly as before. Hatch + stroke apply over either base.
120
- const fillShader = this.device.createShaderModule({
121
- code: `
122
- struct Uniforms {
123
- viewProj: mat4x4<f32>,
124
- planeOffset: vec4<f32>, // Small offset to render slightly in front of section plane
125
- capFillColor: vec4<f32>,
126
- capStrokeColor: vec4<f32>,
127
- // x=patternId, y=spacingPx, z=angleRad, w=widthPx
128
- params: vec4<f32>,
129
- // x=secondaryAngleRad, y,z,w reserved
130
- params2: vec4<f32>,
131
- }
132
- @binding(0) @group(0) var<uniform> uniforms: Uniforms;
133
-
134
- struct VertexInput {
135
- @location(0) position: vec3<f32>,
136
- @location(1) color: vec4<f32>,
137
- }
138
-
139
- struct VertexOutput {
140
- @builtin(position) position: vec4<f32>,
141
- @location(0) color: vec4<f32>,
142
- }
143
-
144
- @vertex
145
- fn vs_main(input: VertexInput) -> VertexOutput {
146
- var output: VertexOutput;
147
- let offsetPos = input.position + uniforms.planeOffset.xyz;
148
- output.position = uniforms.viewProj * vec4<f32>(offsetPos, 1.0);
149
- output.color = input.color;
150
- return output;
151
- }
152
-
153
- // Screen-space hatch pattern helpers (ported from section-cap.wgsl).
154
- fn lineMask(u: f32, s: f32, w: f32) -> f32 {
155
- let f = fract(u / s) * s;
156
- let d = min(f, s - f);
157
- return 1.0 - smoothstep(w * 0.5, w * 0.5 + 1.0, d);
158
- }
159
- fn rotate(p: vec2<f32>, a: f32) -> vec2<f32> {
160
- let c = cos(a);
161
- let s = sin(a);
162
- return vec2<f32>(c * p.x - s * p.y, s * p.x + c * p.y);
163
- }
164
- fn hatchIntensity(fragCoord: vec2<f32>, patternId: u32, spacing: f32, angle: f32, width: f32, angle2: f32) -> f32 {
165
- let p = fragCoord;
166
- if (patternId == 0u) { return 0.0; } // solid
167
- if (patternId == 1u) { // diagonal
168
- let r = rotate(p, angle);
169
- return lineMask(r.x, spacing, width);
170
- }
171
- if (patternId == 2u) { // cross-hatch
172
- let r = rotate(p, angle);
173
- let r2 = rotate(p, angle2);
174
- return max(lineMask(r.x, spacing, width), lineMask(r2.x, spacing, width));
175
- }
176
- if (patternId == 3u) { return lineMask(p.y, spacing, width); } // horizontal
177
- if (patternId == 4u) { return lineMask(p.x, spacing, width); } // vertical
178
- if (patternId == 5u) {
179
- // Concrete (ISO 128-50): clean regular dot grid. The previous
180
- // version layered dashes on top which looked noisy and broken.
181
- // Dots sit at every grid intersection; radius scales with
182
- // stroke width so the user's width slider works consistently.
183
- let gx = p.x - round(p.x / spacing) * spacing;
184
- let gy = p.y - round(p.y / spacing) * spacing;
185
- let d = sqrt(gx * gx + gy * gy);
186
- let radius = max(1.0, width * 1.2);
187
- return 1.0 - smoothstep(radius, radius + 1.0, d);
188
- }
189
- if (patternId == 6u) { // brick
190
- let bandH = spacing;
191
- let band = floor(p.y / bandH);
192
- let offset = select(0.0, bandH, (u32(band) & 1u) == 1u);
193
- let horiz = lineMask(p.y, bandH, width);
194
- let vertPos = p.x + offset * 0.5;
195
- let vert = step(fract(vertPos / (bandH * 2.0)), 0.02);
196
- return max(horiz, vert);
197
- }
198
- if (patternId == 7u) { // insulation
199
- let y = spacing * 0.5 * sin(p.x * 6.2831853 / spacing) + p.y;
200
- return lineMask(y, spacing, width);
201
- }
202
- return 0.0;
203
- }
204
-
205
- struct FragOut {
206
- @location(0) color: vec4<f32>,
207
- @location(1) objectId: vec4<f32>,
208
- }
209
-
210
- @fragment
211
- fn fs_main(input: VertexOutput) -> FragOut {
212
- let patternId = u32(uniforms.params.x + 0.5);
213
- let spacing = max(2.0, uniforms.params.y);
214
- let angle = uniforms.params.z;
215
- let width = max(1.0, uniforms.params.w);
216
- let angle2 = uniforms.params2.x;
217
-
218
- let h = hatchIntensity(input.position.xy, patternId, spacing, angle, width, angle2);
219
- // Per-polygon colour (a material-layer slab fills with its own
220
- // IfcMaterial RGBA) overrides the uniform cap fill when present.
221
- // Polygons without a colour carry the sentinel alpha −1 and fall back
222
- // to the user's cap style, byte-identically. Hatch + stroke apply over
223
- // whichever base is chosen, so the architectural hatch still works.
224
- let useVertex = input.color.a >= 0.0;
225
- let baseFill = select(uniforms.capFillColor, input.color, useVertex);
226
- let rgb = mix(baseFill.rgb, uniforms.capStrokeColor.rgb, h * uniforms.capStrokeColor.a);
227
- let a = max(baseFill.a, h * uniforms.capStrokeColor.a);
228
-
229
- var out: FragOut;
230
- out.color = vec4<f32>(rgb, a);
231
- out.objectId = vec4<f32>(0.0, 0.0, 0.0, 0.0);
232
- return out;
233
- }
234
- `,
235
- });
236
- // Shader for lines (uniform color)
237
- const lineShader = this.device.createShaderModule({
238
- code: `
239
- // Mirrors the fill shader's uniform layout so both pipelines can share
240
- // one buffer; the line shader only reads viewProj, planeOffset and the
241
- // appended lineColor (byte offset 144). capFillColor/… are unused here but
242
- // declared for correct field offsets.
243
- struct Uniforms {
244
- viewProj: mat4x4<f32>,
245
- planeOffset: vec4<f32>,
246
- capFillColor: vec4<f32>,
247
- capStrokeColor: vec4<f32>,
248
- params: vec4<f32>,
249
- params2: vec4<f32>,
250
- lineColor: vec4<f32>,
251
- }
252
- @binding(0) @group(0) var<uniform> uniforms: Uniforms;
253
-
254
- struct VertexInput {
255
- @location(0) position: vec3<f32>,
256
- }
257
-
258
- struct VertexOutput {
259
- @builtin(position) position: vec4<f32>,
260
- }
261
-
262
- @vertex
263
- fn vs_main(input: VertexInput) -> VertexOutput {
264
- var output: VertexOutput;
265
- let offsetPos = input.position + uniforms.planeOffset.xyz;
266
- let clip = uniforms.viewProj * vec4<f32>(offsetPos, 1.0);
267
- // Reverse-Z decal nudge for lines coplanar with model faces
268
- // (issue #812). WebGPU forbids depthStencil.depthBias on non-
269
- // triangle topologies, so we do the equivalent in clip space:
270
- // adding a small positive multiple of clip.w raises NDC z by a
271
- // constant after the w-divide, which under reverse-Z means
272
- // "slightly closer" — enough to beat MSAA jitter on annotation
273
- // lines that ride exactly on a wall/floor.
274
- output.position = vec4<f32>(clip.x, clip.y, clip.z + 5e-5 * clip.w, clip.w);
275
- return output;
276
- }
277
-
278
- struct FragOutLine {
279
- @location(0) color: vec4<f32>,
280
- @location(1) objectId: vec4<f32>,
281
- }
282
-
283
- @fragment
284
- fn fs_main(input: VertexOutput) -> FragOutLine {
285
- var out: FragOutLine;
286
- out.color = uniforms.lineColor; // consumer-themeable (defaults black)
287
- out.objectId = vec4<f32>(0.0, 0.0, 0.0, 0.0);
288
- return out;
289
- }
290
- `,
291
- });
108
+ const fillShader = this.device.createShaderModule({ code: SECTION_2D_CAP_FILL_WGSL });
109
+ const lineShader = this.device.createShaderModule({ code: SECTION_2D_OVERLAY_LINE_WGSL });
292
110
  // Pipeline for filled polygons
293
111
  this.fillPipeline = this.device.createRenderPipeline({
294
112
  layout: pipelineLayout,
@@ -394,73 +212,48 @@ export class Section2DOverlayRenderer {
394
212
  count: this.sampleCount,
395
213
  },
396
214
  });
397
- // Create uniform buffer.
398
- // viewProj — mat4x4 64 B
399
- // planeOffset vec4 16 B
400
- // capFillColor — vec4 16 B
401
- // capStrokeColor vec4 16 B
402
- // params — vec4 16 B x=patternId, y=spacingPx, z=angleRad, w=widthPx
403
- // params2 — vec4 16 B x=secondaryAngleRad
404
- // lineColor — vec4 16 B overlay/section-cut line colour
405
- // Total: 160 B. The fill fragment shader reads up to params2 (144 B); the
406
- // line shader reads viewProj/planeOffset + the appended lineColor (offset
407
- // 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);
408
224
  this.uniformBuffer = this.device.createBuffer({
409
- size: 160,
225
+ size: this.uniformStride * SECTION_2D_UNIFORM_SLOT_COUNT,
410
226
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
411
227
  });
412
- // 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.
413
231
  this.bindGroup = this.device.createBindGroup({
414
232
  layout: this.bindGroupLayout,
415
233
  entries: [
416
- { binding: 0, resource: { buffer: this.uniformBuffer } },
234
+ {
235
+ binding: 0,
236
+ resource: { buffer: this.uniformBuffer, offset: 0, size: SECTION_2D_UNIFORM_BYTES },
237
+ },
417
238
  ],
418
239
  });
419
240
  this.initialized = true;
420
241
  }
421
242
  /**
422
- * Transform 2D coordinates to 3D coordinates on the section plane.
423
- *
424
- * Cardinal axis path (legacy, unchanged):
425
- * - Y axis (down): 2D (x, y) = 3D (x, z) - looking down at XZ plane
426
- * - Z axis (front): 2D (x, y) = 3D (x, y) - looking along Z at XY plane
427
- * - X axis (side): 2D (x, y) = 3D (z, y) - looking along X at ZY plane
428
- * When flipped, the 2D x coordinate is negated.
429
- *
430
- * Custom-plane path (issue #243): when `customPlane` is supplied, the
431
- * 3D point is `origin + tangent*x2d + bitangent*y2d`. The same basis
432
- * is used by `SectionCutter` to project triangle-plane intersections
433
- * to 2D, so the round-trip is exact and the cap polygons land
434
- * 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.
435
246
  */
436
- transform2Dto3D(x2d, y2d, axis, planePosition, flipped = false, customPlane) {
437
- if (customPlane) {
438
- // Custom plane: bypass the cardinal-axis swap. `flipped` is
439
- // intentionally ignored because for arbitrary planes the cutter
440
- // does not mirror its 2D output (mirroring only makes sense for
441
- // cardinal projections that have a consistent "view direction").
442
- const o = customPlane.origin;
443
- const t = customPlane.tangent;
444
- const b = customPlane.bitangent;
445
- return [
446
- o[0] + t[0] * x2d + b[0] * y2d,
447
- o[1] + t[1] * x2d + b[1] * y2d,
448
- o[2] + t[2] * x2d + b[2] * y2d,
449
- ];
450
- }
451
- // Handle flipped - the 2D x coordinate was negated during projection
452
- const x = flipped ? -x2d : x2d;
453
- switch (axis) {
454
- case 'down': // Y axis - horizontal cut (floor plan)
455
- // 2D.x = 3D.x, 2D.y = 3D.z -> 3D (x, planeY, y)
456
- return [x, planePosition, y2d];
457
- case 'front': // Z axis - vertical cut (section view)
458
- // 2D.x = 3D.x, 2D.y = 3D.y -> 3D (x, y, planeZ)
459
- return [x, y2d, planePosition];
460
- case 'side': // X axis - vertical cut (side elevation)
461
- // 2D.x = 3D.z, 2D.y = 3D.y -> 3D (planeX, y, x)
462
- return [planePosition, y2d, x];
463
- }
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
+ };
464
257
  }
465
258
  /**
466
259
  * Upload 2D drawing data to GPU buffers.
@@ -477,113 +270,30 @@ export class Section2DOverlayRenderer {
477
270
  */
478
271
  uploadDrawing(polygons, lines, axis, planePosition, flipped = false, customPlane) {
479
272
  this.init();
480
- // Clean up old buffers and reset counts
481
- if (this.fillVertexBuffer) {
482
- this.fillVertexBuffer.destroy();
483
- this.fillVertexBuffer = null;
484
- }
485
- if (this.fillIndexBuffer) {
486
- this.fillIndexBuffer.destroy();
487
- this.fillIndexBuffer = null;
488
- }
489
- if (this.lineVertexBuffer) {
490
- this.lineVertexBuffer.destroy();
491
- this.lineVertexBuffer = null;
492
- }
493
- this.fillIndexCount = 0;
494
- this.lineVertexCount = 0;
495
- // Build fill geometry (triangulated polygons)
496
- const fillVertices = [];
497
- const fillIndices = [];
498
- let vertexOffset = 0;
499
- for (const polygon of polygons) {
500
- const outer = polygon.polygon.outer;
501
- if (outer.length < 3)
502
- continue;
503
- // Per-polygon fill colour. A material-layer wall/slab delivers one polygon
504
- // per layer, each carrying its IfcMaterial RGBA (window frame/glass parts
505
- // likewise). Polygons WITHOUT a colour use the sentinel alpha −1 so the
506
- // fill shader falls back to the uniform cap style (architectural fill +
507
- // hatch) byte-identically — see fs_main.
508
- const color = polygon.color ?? [0, 0, 0, -1];
509
- // Hole-aware ear-clipping (reused from the IfcAnnotationFillArea fill path)
510
- // replaces the old convex fan. The fan ignored holes and inverted on the
511
- // CONCAVE cross-sections that arbitrary IFC profiles (and material-layer
512
- // slabs) cut into, leaving the cut face uncovered — it read as a hollow
513
- // shell. Section 2D points are (x, y); the triangulator works in (x, z),
514
- // so y maps to z.
515
- const outerRing = outer.map((p) => ({ x: p.x, z: p.y }));
516
- const holeRings = polygon.polygon.holes
517
- .filter((h) => h.length >= 3)
518
- .map((h) => h.map((p) => ({ x: p.x, z: p.y })));
519
- const stitched = holeRings.length > 0 ? joinHoles(outerRing, holeRings) : outerRing;
520
- const tris = earClip(stitched);
521
- if (tris.length === 0)
522
- continue;
523
- const baseVertex = vertexOffset;
524
- for (const pt of stitched) {
525
- const [x3d, y3d, z3d] = this.transform2Dto3D(pt.x, pt.z, axis, planePosition, flipped, customPlane);
526
- fillVertices.push(x3d, y3d, z3d, color[0], color[1], color[2], color[3]);
527
- vertexOffset++;
528
- }
529
- for (const [a, b, c] of tris) {
530
- fillIndices.push(baseVertex + a, baseVertex + b, baseVertex + c);
531
- }
532
- }
533
- // Create fill buffers
534
- if (fillVertices.length > 0) {
535
- 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) {
536
277
  this.fillVertexBuffer = this.device.createBuffer({
537
- size: fillVertexData.byteLength,
278
+ size: fill.vertices.byteLength,
538
279
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
539
280
  });
540
- this.device.queue.writeBuffer(this.fillVertexBuffer, 0, fillVertexData);
541
- const fillIndexData = new Uint32Array(fillIndices);
281
+ this.device.queue.writeBuffer(this.fillVertexBuffer, 0, fill.vertices);
542
282
  this.fillIndexBuffer = this.device.createBuffer({
543
- size: fillIndexData.byteLength,
283
+ size: fill.indices.byteLength,
544
284
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
545
285
  });
546
- this.device.queue.writeBuffer(this.fillIndexBuffer, 0, fillIndexData);
547
- this.fillIndexCount = fillIndices.length;
548
- }
549
- // Build line geometry
550
- const lineVertices = [];
551
- // Polygon outlines
552
- for (const polygon of polygons) {
553
- const outer = polygon.polygon.outer;
554
- for (let i = 0; i < outer.length; i++) {
555
- const p1 = outer[i];
556
- const p2 = outer[(i + 1) % outer.length];
557
- const [x1, y1, z1] = this.transform2Dto3D(p1.x, p1.y, axis, planePosition, flipped, customPlane);
558
- const [x2, y2, z2] = this.transform2Dto3D(p2.x, p2.y, axis, planePosition, flipped, customPlane);
559
- lineVertices.push(x1, y1, z1, x2, y2, z2);
560
- }
561
- // Hole outlines
562
- for (const hole of polygon.polygon.holes) {
563
- for (let i = 0; i < hole.length; i++) {
564
- const p1 = hole[i];
565
- const p2 = hole[(i + 1) % hole.length];
566
- const [x1, y1, z1] = this.transform2Dto3D(p1.x, p1.y, axis, planePosition, flipped, customPlane);
567
- const [x2, y2, z2] = this.transform2Dto3D(p2.x, p2.y, axis, planePosition, flipped, customPlane);
568
- lineVertices.push(x1, y1, z1, x2, y2, z2);
569
- }
570
- }
571
- }
572
- // Additional drawing lines (hatching, etc.)
573
- for (const line of lines) {
574
- const [x1, y1, z1] = this.transform2Dto3D(line.line.start.x, line.line.start.y, axis, planePosition, flipped, customPlane);
575
- const [x2, y2, z2] = this.transform2Dto3D(line.line.end.x, line.line.end.y, axis, planePosition, flipped, customPlane);
576
- lineVertices.push(x1, y1, z1, x2, y2, z2);
286
+ this.device.queue.writeBuffer(this.fillIndexBuffer, 0, fill.indices);
287
+ this.fillIndexCount = fill.indices.length;
577
288
  }
578
- // Create line buffer
579
- if (lineVertices.length > 0) {
580
- const lineVertexData = new Float32Array(lineVertices);
289
+ const outline = buildDrawingOutlineVertices(polygons, lines, lift);
290
+ if (outline) {
581
291
  this.lineVertexBuffer = this.device.createBuffer({
582
- size: lineVertexData.byteLength,
292
+ size: outline.byteLength,
583
293
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
584
294
  });
585
- this.device.queue.writeBuffer(this.lineVertexBuffer, 0, lineVertexData);
586
- 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
587
297
  }
588
298
  }
589
299
  /**
@@ -624,29 +334,27 @@ export class Section2DOverlayRenderer {
624
334
  */
625
335
  uploadAnnotationLines3D(vertices) {
626
336
  this.init();
627
- if (this.annotationLineVertexBuffer) {
628
- this.annotationLineVertexBuffer.destroy();
629
- this.annotationLineVertexBuffer = null;
630
- }
631
- this.annotationLineVertexCount = 0;
632
- if (vertices.length < 6)
633
- return;
634
- this.annotationLineVertexBuffer = this.device.createBuffer({
635
- size: vertices.byteLength,
636
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
637
- });
638
- this.device.queue.writeBuffer(this.annotationLineVertexBuffer, 0, vertices);
639
- this.annotationLineVertexCount = vertices.length / 3;
337
+ this.annotationLines.upload(this.device, vertices);
640
338
  }
641
339
  clearAnnotationLines3D() {
642
- if (this.annotationLineVertexBuffer) {
643
- this.annotationLineVertexBuffer.destroy();
644
- this.annotationLineVertexBuffer = null;
645
- }
646
- this.annotationLineVertexCount = 0;
340
+ this.annotationLines.clear();
647
341
  }
648
342
  hasAnnotationLines3D() {
649
- 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);
650
358
  }
651
359
  /**
652
360
  * Upload a flat Float32Array of 3D line-list vertices for the alignment
@@ -656,55 +364,13 @@ export class Section2DOverlayRenderer {
656
364
  */
657
365
  uploadAlignmentLines3D(vertices) {
658
366
  this.init();
659
- if (this.alignmentLineVertexBuffer) {
660
- this.alignmentLineVertexBuffer.destroy();
661
- this.alignmentLineVertexBuffer = null;
662
- }
663
- this.alignmentLineVertexCount = 0;
664
- if (vertices.length < 6)
665
- return;
666
- this.alignmentLineVertexBuffer = this.device.createBuffer({
667
- size: vertices.byteLength,
668
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
669
- });
670
- this.device.queue.writeBuffer(this.alignmentLineVertexBuffer, 0, vertices);
671
- this.alignmentLineVertexCount = vertices.length / 3;
367
+ this.alignmentLines.upload(this.device, vertices);
672
368
  }
673
369
  clearAlignmentLines3D() {
674
- if (this.alignmentLineVertexBuffer) {
675
- this.alignmentLineVertexBuffer.destroy();
676
- this.alignmentLineVertexBuffer = null;
677
- }
678
- this.alignmentLineVertexCount = 0;
370
+ this.alignmentLines.clear();
679
371
  }
680
372
  hasAlignmentLines3D() {
681
- return this.alignmentLineVertexCount > 0;
682
- }
683
- /**
684
- * Draw the standalone annotation line overlay. Uses the same line pipeline
685
- * as the section cut outlines (vertex format: 3 floats per vertex, line-list
686
- * topology) but reads from a separate vertex buffer. The pipeline's
687
- * `planeOffset` uniform is zeroed so vertices render at their authored
688
- * world position.
689
- */
690
- drawAnnotationLines3D(pass, viewProj) {
691
- this.init();
692
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
693
- return;
694
- if (!this.annotationLineVertexBuffer || this.annotationLineVertexCount === 0)
695
- return;
696
- // Reuse the existing fill uniform buffer slot 0 (viewProj + planeOffset).
697
- // The line shader only reads those two fields, so the fill/cap-style
698
- // tail of the uniforms is harmless leftover data.
699
- const uniforms = new Float32Array(40);
700
- uniforms.set(viewProj, 0);
701
- // planeOffset = 0 — vertices are already in world space.
702
- uniforms.set(this.overlayLineColor, 36); // lineColor (byte offset 144)
703
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
704
- pass.setPipeline(this.linePipeline);
705
- pass.setBindGroup(0, this.bindGroup);
706
- pass.setVertexBuffer(0, this.annotationLineVertexBuffer);
707
- pass.draw(this.annotationLineVertexCount);
373
+ return this.alignmentLines.has();
708
374
  }
709
375
  /**
710
376
  * Draw the alignment centerline overlay. Identical pipeline/uniform setup as
@@ -712,19 +378,10 @@ export class Section2DOverlayRenderer {
712
378
  */
713
379
  drawAlignmentLines3D(pass, viewProj) {
714
380
  this.init();
715
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
716
- return;
717
- if (!this.alignmentLineVertexBuffer || this.alignmentLineVertexCount === 0)
381
+ const resources = this.lineResources();
382
+ if (!resources)
718
383
  return;
719
- const uniforms = new Float32Array(40);
720
- uniforms.set(viewProj, 0);
721
- // planeOffset = 0 — vertices are already in world space.
722
- uniforms.set(this.overlayLineColor, 36); // lineColor (byte offset 144)
723
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
724
- pass.setPipeline(this.linePipeline);
725
- pass.setBindGroup(0, this.bindGroup);
726
- pass.setVertexBuffer(0, this.alignmentLineVertexBuffer);
727
- pass.draw(this.alignmentLineVertexCount);
384
+ this.alignmentLines.draw(pass, resources, viewProj, this.overlayLineColor);
728
385
  }
729
386
  /**
730
387
  * Upload structural-grid (IfcGridAxis) segments as a flat
@@ -734,29 +391,13 @@ export class Section2DOverlayRenderer {
734
391
  */
735
392
  uploadGridLines3D(vertices) {
736
393
  this.init();
737
- if (this.gridLineVertexBuffer) {
738
- this.gridLineVertexBuffer.destroy();
739
- this.gridLineVertexBuffer = null;
740
- }
741
- this.gridLineVertexCount = 0;
742
- if (vertices.length < 6)
743
- return;
744
- this.gridLineVertexBuffer = this.device.createBuffer({
745
- size: vertices.byteLength,
746
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
747
- });
748
- this.device.queue.writeBuffer(this.gridLineVertexBuffer, 0, vertices);
749
- this.gridLineVertexCount = vertices.length / 3;
394
+ this.gridLines.upload(this.device, vertices);
750
395
  }
751
396
  clearGridLines3D() {
752
- if (this.gridLineVertexBuffer) {
753
- this.gridLineVertexBuffer.destroy();
754
- this.gridLineVertexBuffer = null;
755
- }
756
- this.gridLineVertexCount = 0;
397
+ this.gridLines.clear();
757
398
  }
758
399
  hasGridLines3D() {
759
- return this.gridLineVertexCount > 0;
400
+ return this.gridLines.has();
760
401
  }
761
402
  /**
762
403
  * Draw the structural-grid overlay. Identical pipeline/uniform setup as
@@ -764,19 +405,38 @@ export class Section2DOverlayRenderer {
764
405
  */
765
406
  drawGridLines3D(pass, viewProj) {
766
407
  this.init();
767
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
408
+ const resources = this.lineResources();
409
+ if (!resources)
768
410
  return;
769
- if (!this.gridLineVertexBuffer || this.gridLineVertexCount === 0)
411
+ this.gridLines.draw(pass, resources, viewProj, this.overlayLineColor);
412
+ }
413
+ /**
414
+ * Upload the DXF reference-layer's line paths as a flat `[x,y,z, x,y,z, …]`
415
+ * line-list in world space (issue #2043). Mirrors `uploadGridLines3D` with
416
+ * a separate buffer so 3D DXF visibility is independent of the 2D
417
+ * underlay and the other overlays above. Pass an empty array (or omit)
418
+ * to clear.
419
+ */
420
+ uploadDxfLines3D(vertices) {
421
+ this.init();
422
+ this.dxfLines.upload(this.device, vertices);
423
+ }
424
+ clearDxfLines3D() {
425
+ this.dxfLines.clear();
426
+ }
427
+ hasDxfLines3D() {
428
+ return this.dxfLines.has();
429
+ }
430
+ /**
431
+ * Draw the 3D DXF reference-layer overlay. Identical pipeline/uniform
432
+ * setup as `drawGridLines3D`, reading from the separate DXF buffer.
433
+ */
434
+ drawDxfLines3D(pass, viewProj) {
435
+ this.init();
436
+ const resources = this.lineResources();
437
+ if (!resources)
770
438
  return;
771
- const uniforms = new Float32Array(40);
772
- uniforms.set(viewProj, 0);
773
- // planeOffset = 0 — vertices are already in world space.
774
- uniforms.set(this.overlayLineColor, 36); // lineColor (byte offset 144)
775
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
776
- pass.setPipeline(this.linePipeline);
777
- pass.setBindGroup(0, this.bindGroup);
778
- pass.setVertexBuffer(0, this.gridLineVertexBuffer);
779
- pass.draw(this.gridLineVertexCount);
439
+ this.dxfLines.draw(pass, resources, viewProj, this.overlayLineColor);
780
440
  }
781
441
  /** Colour for the clash-overlap box (its own, not the shared overlay colour). */
782
442
  setClashBoxLineColor(color) {
@@ -789,46 +449,21 @@ export class Section2DOverlayRenderer {
789
449
  */
790
450
  uploadClashBoxLines3D(vertices) {
791
451
  this.init();
792
- if (this.clashBoxLineVertexBuffer) {
793
- this.clashBoxLineVertexBuffer.destroy();
794
- this.clashBoxLineVertexBuffer = null;
795
- }
796
- this.clashBoxLineVertexCount = 0;
797
- if (vertices.length < 6)
798
- return;
799
- this.clashBoxLineVertexBuffer = this.device.createBuffer({
800
- size: vertices.byteLength,
801
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
802
- });
803
- this.device.queue.writeBuffer(this.clashBoxLineVertexBuffer, 0, vertices);
804
- this.clashBoxLineVertexCount = vertices.length / 3;
452
+ this.clashBoxLines.upload(this.device, vertices);
805
453
  }
806
454
  clearClashBoxLines3D() {
807
- if (this.clashBoxLineVertexBuffer) {
808
- this.clashBoxLineVertexBuffer.destroy();
809
- this.clashBoxLineVertexBuffer = null;
810
- }
811
- this.clashBoxLineVertexCount = 0;
455
+ this.clashBoxLines.clear();
812
456
  }
813
457
  hasClashBoxLines3D() {
814
- return this.clashBoxLineVertexCount > 0;
458
+ return this.clashBoxLines.has();
815
459
  }
816
460
  /** Draw the clash-overlap box in its own colour. Same line pipeline. (#1277) */
817
461
  drawClashBoxLines3D(pass, viewProj) {
818
462
  this.init();
819
- if (!this.linePipeline || !this.uniformBuffer || !this.bindGroup)
463
+ const resources = this.lineResources();
464
+ if (!resources)
820
465
  return;
821
- if (!this.clashBoxLineVertexBuffer || this.clashBoxLineVertexCount === 0)
822
- return;
823
- const uniforms = new Float32Array(40);
824
- uniforms.set(viewProj, 0);
825
- // planeOffset = 0 — vertices are already in world space.
826
- uniforms.set(this.clashBoxLineColor, 36); // lineColor (byte offset 144)
827
- this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
828
- pass.setPipeline(this.linePipeline);
829
- pass.setBindGroup(0, this.bindGroup);
830
- pass.setVertexBuffer(0, this.clashBoxLineVertexBuffer);
831
- pass.draw(this.clashBoxLineVertexCount);
466
+ this.clashBoxLines.draw(pass, resources, viewProj, this.clashBoxLineColor);
832
467
  }
833
468
  /**
834
469
  * Check if there is geometry to draw
@@ -847,7 +482,7 @@ export class Section2DOverlayRenderer {
847
482
  if (!this.hasGeometry()) {
848
483
  return;
849
484
  }
850
- const { axis, viewProj } = options;
485
+ const { viewProj } = options;
851
486
  // No offset — cap renders exactly on the section plane. The previous
852
487
  // 0.3m bias was there to keep the outline lines clear of below-plane
853
488
  // geometry, but it made the cap visually drift off the slider plane
@@ -856,55 +491,43 @@ export class Section2DOverlayRenderer {
856
491
  // coincident below-plane top faces is not an issue; the stencil gate
857
492
  // keeps the fill restricted to the actual cap polygons.
858
493
  const offset = [0, 0, 0];
859
- // Update uniforms. Layout mirrors the WGSL struct above:
860
- // 0..15 viewProj
861
- // 16..19 planeOffset
862
- // 20..23 capFillColor
863
- // 24..27 capStrokeColor
864
- // 28..31 params (patternId, spacingPx, angleRad, widthPx)
865
- // 32..35 params2 (secondaryAngleRad, _, _, _)
866
- // 36..39 lineColor (section-cut outline colour)
867
- const uniforms = new Float32Array(40);
868
- uniforms.set(viewProj, 0);
869
- uniforms.set(this.overlayLineColor, 36); // section-cut outline colour
870
- uniforms[16] = offset[0];
871
- uniforms[17] = offset[1];
872
- uniforms[18] = offset[2];
873
- 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;
874
504
  const cs = options.capStyle;
875
505
  if (cs) {
876
- uniforms[20] = cs.fillColor[0];
877
- uniforms[21] = cs.fillColor[1];
878
- uniforms[22] = cs.fillColor[2];
879
- uniforms[23] = cs.fillColor[3];
880
- uniforms[24] = cs.strokeColor[0];
881
- uniforms[25] = cs.strokeColor[1];
882
- uniforms[26] = cs.strokeColor[2];
883
- uniforms[27] = cs.strokeColor[3];
884
- uniforms[28] = cs.patternId;
885
- uniforms[29] = cs.spacingPx;
886
- uniforms[30] = cs.angleRad;
887
- uniforms[31] = cs.widthPx;
888
- 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;
889
513
  }
890
514
  else {
891
515
  // Sensible defaults when caller omits style (e.g. legacy lines-only
892
516
  // use): solid fill using a warm-paper colour, no hatch.
893
- uniforms[20] = 0.92;
894
- uniforms[21] = 0.88;
895
- uniforms[22] = 0.78;
896
- uniforms[23] = 1;
897
- uniforms[24] = 0.10;
898
- uniforms[25] = 0.10;
899
- uniforms[26] = 0.10;
900
- uniforms[27] = 1;
901
- uniforms[28] = 0; // solid pattern
902
- uniforms[29] = 8;
903
- uniforms[30] = Math.PI / 4;
904
- uniforms[31] = 1;
905
- uniforms[32] = -Math.PI / 4;
906
- }
907
- 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);
908
531
  // Filled polygons = the 3D section cap. Render them ONLY when the
909
532
  // caller opts in (`showFills: true` + a capStyle). This replaces the
910
533
  // old stencil-parity cap, which leaked hatch into empty sky on non-
@@ -917,7 +540,7 @@ export class Section2DOverlayRenderer {
917
540
  this.fillIndexBuffer &&
918
541
  this.fillIndexCount > 0) {
919
542
  pass.setPipeline(this.fillPipeline);
920
- pass.setBindGroup(0, this.bindGroup);
543
+ pass.setBindGroup(0, this.bindGroup, [capOffset]);
921
544
  pass.setVertexBuffer(0, this.fillVertexBuffer);
922
545
  pass.setIndexBuffer(this.fillIndexBuffer, 'uint32');
923
546
  pass.drawIndexed(this.fillIndexCount);
@@ -929,19 +552,26 @@ export class Section2DOverlayRenderer {
929
552
  this.lineVertexBuffer &&
930
553
  this.lineVertexCount > 0) {
931
554
  pass.setPipeline(this.linePipeline);
932
- pass.setBindGroup(0, this.bindGroup);
555
+ pass.setBindGroup(0, this.bindGroup, [capOffset]);
933
556
  pass.setVertexBuffer(0, this.lineVertexBuffer);
934
557
  pass.draw(this.lineVertexCount);
935
558
  }
936
559
  }
937
560
  /**
938
- * 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.
939
567
  */
940
568
  dispose() {
941
569
  this.clearGeometry();
942
570
  this.clearAnnotationLines3D();
943
571
  this.clearAlignmentLines3D();
944
572
  this.clearGridLines3D();
573
+ this.clearDxfLines3D();
574
+ this.clearClashBoxLines3D();
945
575
  if (this.uniformBuffer) {
946
576
  this.uniformBuffer.destroy();
947
577
  this.uniformBuffer = null;