@ifc-lite/renderer 1.20.1 → 1.22.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.
@@ -0,0 +1,783 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ /**
5
+ * WebGPU pipelines for IfcAnnotation overlays — filled regions and text
6
+ * labels. Each pipeline is self-contained (owns its own buffers, bind groups,
7
+ * pipeline state, optional atlas texture) and exposes a `render(pass,
8
+ * viewProj)` entry point that the caller invokes from inside an existing
9
+ * RGBA-blended render pass.
10
+ *
11
+ * Triangulation: simple ear-clipping for polygon-with-optional-holes,
12
+ * inlined below to avoid adding a dependency. Good enough for typical IFC
13
+ * fill regions (rooms, hatched zones) which are usually convex or near-convex.
14
+ * Pathological concave shapes with many holes may show tessellation glitches —
15
+ * an `earcut` upgrade is straightforward when it matters.
16
+ */
17
+ import { SymbolicTextAtlas } from './symbolic-text-atlas.js';
18
+ import { SYMBOLIC_FILL_WGSL, SYMBOLIC_TEXT_WGSL, } from './shaders/symbolic-overlay.wgsl.js';
19
+ import { PIPELINE_CONSTANTS } from './constants.js';
20
+ const FILL_VERTEX_STRIDE_BYTES = (3 + 4) * 4; // pos.xyz + color.rgba, 4 bytes each
21
+ const TEXT_INSTANCE_STRIDE_BYTES = (3 + 3 + 3 + 4 + 4 + 3 + 1 + 1 + 4 + 1) * 4;
22
+ // origin.xyz + rightAxis.xyz + upAxis.xyz + uvBounds.xyzw + color.rgba
23
+ // + anchor.xyz + capHeight (shared per text label, used by the shader to
24
+ // compute a single screen-space scale for every glyph in the row)
25
+ // + billboard (1 = use camera-aligned axes, 0 = authored — IfcGridAxis only)
26
+ // + glyphOffsetSize.xyzw (baseline-relative 2D atlas-pixel offset + size
27
+ // in world units; only consulted on the billboard branch)
28
+ // + targetPxOverride (per-instance screen-pixel target cap height; 0 falls
29
+ // back to the uniform default — grid bubble glyphs use a larger value
30
+ // than tag text so the bubble stays proportional at all zoom levels).
31
+ // Uniform: viewProj (64 B) + viewportAndTarget (16 B) + cameraRight (16 B)
32
+ // + cameraUp (16 B) = 112 B.
33
+ const TEXT_UNIFORM_BYTES = 112;
34
+ // Default target glyph cap height in physical pixels. Roughly matches a
35
+ // 13–14px body font at 1× DPR — readable at any zoom without dominating
36
+ // the model. Authored IFC text height is ignored in screen space, but the
37
+ // authored cap height still feeds the scale ratio so glyph metrics keep
38
+ // their relative proportions.
39
+ const DEFAULT_TEXT_TARGET_PX = 14;
40
+ // ─── Fill pipeline ──────────────────────────────────────────────────────────
41
+ export class SymbolicFillPipeline {
42
+ device;
43
+ format;
44
+ sampleCount;
45
+ pipeline = null;
46
+ bindGroupLayout = null;
47
+ uniformBuffer = null;
48
+ bindGroup = null;
49
+ vertexBuffer = null;
50
+ vertexCount = 0;
51
+ constructor(device, presentationFormat, sampleCount = 1) {
52
+ this.device = device;
53
+ this.format = presentationFormat;
54
+ this.sampleCount = sampleCount;
55
+ }
56
+ init() {
57
+ if (this.pipeline)
58
+ return;
59
+ this.bindGroupLayout = this.device.createBindGroupLayout({
60
+ label: 'symbolic-fill-bgl',
61
+ entries: [
62
+ {
63
+ binding: 0,
64
+ visibility: GPUShaderStage.VERTEX,
65
+ buffer: { type: 'uniform' },
66
+ },
67
+ ],
68
+ });
69
+ const module = this.device.createShaderModule({
70
+ label: 'symbolic-fill-shader',
71
+ code: SYMBOLIC_FILL_WGSL,
72
+ });
73
+ this.pipeline = this.device.createRenderPipeline({
74
+ label: 'symbolic-fill-pipeline',
75
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
76
+ vertex: {
77
+ module,
78
+ entryPoint: 'vs_main',
79
+ buffers: [
80
+ {
81
+ arrayStride: FILL_VERTEX_STRIDE_BYTES,
82
+ attributes: [
83
+ { shaderLocation: 0, offset: 0, format: 'float32x3' }, // position
84
+ { shaderLocation: 1, offset: 3 * 4, format: 'float32x4' }, // color
85
+ ],
86
+ },
87
+ ],
88
+ },
89
+ fragment: {
90
+ module,
91
+ entryPoint: 'fs_main',
92
+ // The main render pass attaches 2 colour targets (presentation + the
93
+ // picker objectId) and runs MSAA. Pipelines used inside that pass
94
+ // must declare matching targets and sampleCount or WebGPU rejects
95
+ // them at validation time. The objectId slot is write-masked off so
96
+ // the picker IDs from the opaque pass underneath are preserved.
97
+ targets: [
98
+ {
99
+ format: this.format,
100
+ blend: {
101
+ // Standard "one * src + (1 - src.a) * dst" composite — the
102
+ // shader writes premultiplied alpha.
103
+ color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
104
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
105
+ },
106
+ writeMask: GPUColorWrite.ALL,
107
+ },
108
+ { format: 'rgba8unorm', writeMask: 0 },
109
+ ],
110
+ },
111
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
112
+ depthStencil: {
113
+ format: PIPELINE_CONSTANTS.DEPTH_FORMAT,
114
+ depthWriteEnabled: false,
115
+ // Reverse-Z: the renderer clears depth to 0.0 and uses 'greater' /
116
+ // 'greater-equal' for everything in the main pass. 'less-equal'
117
+ // would fail the test on every visible surface.
118
+ depthCompare: 'greater-equal',
119
+ },
120
+ multisample: { count: this.sampleCount },
121
+ });
122
+ this.uniformBuffer = this.device.createBuffer({
123
+ label: 'symbolic-fill-camera',
124
+ size: 64,
125
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
126
+ });
127
+ this.bindGroup = this.device.createBindGroup({
128
+ label: 'symbolic-fill-bg',
129
+ layout: this.bindGroupLayout,
130
+ entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }],
131
+ });
132
+ }
133
+ /**
134
+ * Upload a list of fill regions. Each region is triangulated (ear-clipping,
135
+ * holes-aware) into a single shared vertex buffer.
136
+ *
137
+ * Pass an empty array to clear. Triangulation skips degenerate rings
138
+ * (< 3 vertices) and silently drops any hole that can't be merged into the
139
+ * outer ring (rare; usually overlapping rings in malformed IFC).
140
+ */
141
+ upload(fills) {
142
+ this.init();
143
+ // Drop the previous buffer eagerly so swapping models doesn't accumulate.
144
+ if (this.vertexBuffer) {
145
+ this.vertexBuffer.destroy();
146
+ this.vertexBuffer = null;
147
+ }
148
+ this.vertexCount = 0;
149
+ if (fills.length === 0)
150
+ return;
151
+ // Triangulate everything into one big flat vertex stream.
152
+ const stream = [];
153
+ for (const fill of fills) {
154
+ triangulateFillTo(stream, fill);
155
+ }
156
+ if (stream.length === 0)
157
+ return;
158
+ const data = new Float32Array(stream);
159
+ this.vertexBuffer = this.device.createBuffer({
160
+ label: 'symbolic-fill-vbuf',
161
+ size: data.byteLength,
162
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
163
+ });
164
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, data);
165
+ this.vertexCount = data.length / (FILL_VERTEX_STRIDE_BYTES / 4);
166
+ }
167
+ hasGeometry() {
168
+ return this.vertexCount > 0;
169
+ }
170
+ render(pass, viewProj) {
171
+ if (!this.pipeline || !this.uniformBuffer || !this.bindGroup || !this.vertexBuffer)
172
+ return;
173
+ if (this.vertexCount === 0)
174
+ return;
175
+ this.device.queue.writeBuffer(this.uniformBuffer, 0, viewProj);
176
+ pass.setPipeline(this.pipeline);
177
+ pass.setBindGroup(0, this.bindGroup);
178
+ pass.setVertexBuffer(0, this.vertexBuffer);
179
+ pass.draw(this.vertexCount);
180
+ }
181
+ destroy() {
182
+ if (this.vertexBuffer)
183
+ this.vertexBuffer.destroy();
184
+ if (this.uniformBuffer)
185
+ this.uniformBuffer.destroy();
186
+ this.vertexBuffer = null;
187
+ this.uniformBuffer = null;
188
+ this.bindGroup = null;
189
+ this.bindGroupLayout = null;
190
+ this.pipeline = null;
191
+ this.vertexCount = 0;
192
+ }
193
+ }
194
+ // ─── Text pipeline ──────────────────────────────────────────────────────────
195
+ export class SymbolicTextPipeline {
196
+ device;
197
+ format;
198
+ sampleCount;
199
+ atlas;
200
+ pipeline = null;
201
+ bindGroupLayout = null;
202
+ uniformBuffer = null;
203
+ cornerBuffer = null;
204
+ instanceBuffer = null;
205
+ atlasTexture = null;
206
+ atlasView = null;
207
+ sampler = null;
208
+ bindGroup = null;
209
+ instanceCount = 0;
210
+ uploadedAtlasVersion = -1;
211
+ constructor(device, presentationFormat, sampleCount = 1, atlas) {
212
+ this.device = device;
213
+ this.format = presentationFormat;
214
+ this.sampleCount = sampleCount;
215
+ this.atlas = atlas ?? new SymbolicTextAtlas();
216
+ }
217
+ /** Expose the atlas so the upload pre-warms glyphs before instance encoding. */
218
+ getAtlas() {
219
+ return this.atlas;
220
+ }
221
+ init() {
222
+ if (this.pipeline)
223
+ return;
224
+ this.bindGroupLayout = this.device.createBindGroupLayout({
225
+ label: 'symbolic-text-bgl',
226
+ entries: [
227
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
228
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
229
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
230
+ ],
231
+ });
232
+ const module = this.device.createShaderModule({
233
+ label: 'symbolic-text-shader',
234
+ code: SYMBOLIC_TEXT_WGSL,
235
+ });
236
+ this.pipeline = this.device.createRenderPipeline({
237
+ label: 'symbolic-text-pipeline',
238
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
239
+ vertex: {
240
+ module,
241
+ entryPoint: 'vs_main',
242
+ buffers: [
243
+ // Per-vertex: corner index 0..3 as a u32.
244
+ {
245
+ arrayStride: 4,
246
+ stepMode: 'vertex',
247
+ attributes: [{ shaderLocation: 0, offset: 0, format: 'uint32' }],
248
+ },
249
+ // Per-instance: origin + rightAxis + upAxis + uvBounds + color
250
+ // + anchor + capHeight + billboard + glyphOffsetSize + targetPxOverride.
251
+ {
252
+ arrayStride: TEXT_INSTANCE_STRIDE_BYTES,
253
+ stepMode: 'instance',
254
+ attributes: [
255
+ { shaderLocation: 1, offset: 0, format: 'float32x3' }, // origin
256
+ { shaderLocation: 2, offset: 3 * 4, format: 'float32x3' }, // rightAxis
257
+ { shaderLocation: 3, offset: (3 + 3) * 4, format: 'float32x3' }, // upAxis
258
+ { shaderLocation: 4, offset: (3 + 3 + 3) * 4, format: 'float32x4' }, // uvBounds
259
+ { shaderLocation: 5, offset: (3 + 3 + 3 + 4) * 4, format: 'float32x4' }, // color
260
+ { shaderLocation: 6, offset: (3 + 3 + 3 + 4 + 4) * 4, format: 'float32x3' }, // anchor
261
+ { shaderLocation: 7, offset: (3 + 3 + 3 + 4 + 4 + 3) * 4, format: 'float32' }, // capHeight
262
+ { shaderLocation: 8, offset: (3 + 3 + 3 + 4 + 4 + 3 + 1) * 4, format: 'float32' }, // billboard
263
+ { shaderLocation: 9, offset: (3 + 3 + 3 + 4 + 4 + 3 + 1 + 1) * 4, format: 'float32x4' }, // glyphOffsetSize
264
+ { shaderLocation: 10, offset: (3 + 3 + 3 + 4 + 4 + 3 + 1 + 1 + 4) * 4, format: 'float32' }, // targetPxOverride
265
+ ],
266
+ },
267
+ ],
268
+ },
269
+ fragment: {
270
+ module,
271
+ entryPoint: 'fs_main',
272
+ // Matches the main render pass attachments — see SymbolicFillPipeline
273
+ // for the full reasoning.
274
+ targets: [
275
+ {
276
+ format: this.format,
277
+ blend: {
278
+ color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
279
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
280
+ },
281
+ writeMask: GPUColorWrite.ALL,
282
+ },
283
+ { format: 'rgba8unorm', writeMask: 0 },
284
+ ],
285
+ },
286
+ primitive: { topology: 'triangle-strip', cullMode: 'none' },
287
+ depthStencil: {
288
+ format: PIPELINE_CONSTANTS.DEPTH_FORMAT,
289
+ depthWriteEnabled: false,
290
+ // Reverse-Z: see SymbolicFillPipeline.
291
+ depthCompare: 'greater-equal',
292
+ },
293
+ multisample: { count: this.sampleCount },
294
+ });
295
+ // Per-vertex corner buffer: 4 corner indices for the triangle-strip quad.
296
+ // Corner ordering matches the (u, v) decoder in the vertex shader:
297
+ // 0 = BL, 1 = BR, 2 = TL, 3 = TR
298
+ const corners = new Uint32Array([0, 1, 2, 3]);
299
+ this.cornerBuffer = this.device.createBuffer({
300
+ label: 'symbolic-text-corner',
301
+ size: corners.byteLength,
302
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
303
+ });
304
+ this.device.queue.writeBuffer(this.cornerBuffer, 0, corners);
305
+ this.uniformBuffer = this.device.createBuffer({
306
+ label: 'symbolic-text-camera',
307
+ size: TEXT_UNIFORM_BYTES,
308
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
309
+ });
310
+ this.sampler = this.device.createSampler({
311
+ label: 'symbolic-text-sampler',
312
+ magFilter: 'linear',
313
+ minFilter: 'linear',
314
+ mipmapFilter: 'linear',
315
+ // Atlas glyphs are isolated; clamp keeps neighboring glyphs from
316
+ // bleeding when minified.
317
+ addressModeU: 'clamp-to-edge',
318
+ addressModeV: 'clamp-to-edge',
319
+ });
320
+ }
321
+ /** Re-upload the atlas to a GPUTexture when its version changes. */
322
+ syncAtlasTexture() {
323
+ if (this.uploadedAtlasVersion === this.atlas.getVersion() && this.atlasTexture)
324
+ return;
325
+ if (this.atlasTexture) {
326
+ this.atlasTexture.destroy();
327
+ this.atlasTexture = null;
328
+ this.atlasView = null;
329
+ }
330
+ this.atlasTexture = this.device.createTexture({
331
+ label: 'symbolic-text-atlas',
332
+ size: { width: this.atlas.atlasSize, height: this.atlas.atlasSize, depthOrArrayLayers: 1 },
333
+ format: 'rgba8unorm',
334
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
335
+ });
336
+ this.device.queue.copyExternalImageToTexture({ source: this.atlas.canvas, flipY: false }, { texture: this.atlasTexture }, { width: this.atlas.atlasSize, height: this.atlas.atlasSize });
337
+ this.atlasView = this.atlasTexture.createView();
338
+ this.uploadedAtlasVersion = this.atlas.getVersion();
339
+ // Rebuild the bind group with the new texture view.
340
+ this.bindGroup = null;
341
+ }
342
+ ensureBindGroup() {
343
+ if (this.bindGroup)
344
+ return;
345
+ if (!this.bindGroupLayout || !this.uniformBuffer || !this.atlasView || !this.sampler)
346
+ return;
347
+ this.bindGroup = this.device.createBindGroup({
348
+ label: 'symbolic-text-bg',
349
+ layout: this.bindGroupLayout,
350
+ entries: [
351
+ { binding: 0, resource: { buffer: this.uniformBuffer } },
352
+ { binding: 1, resource: this.atlasView },
353
+ { binding: 2, resource: this.sampler },
354
+ ],
355
+ });
356
+ }
357
+ /**
358
+ * Lay out the given text labels into the atlas, encode an instance buffer,
359
+ * and upload to the GPU. Pass an empty array to clear.
360
+ */
361
+ upload(texts) {
362
+ this.init();
363
+ if (this.instanceBuffer) {
364
+ this.instanceBuffer.destroy();
365
+ this.instanceBuffer = null;
366
+ }
367
+ this.instanceCount = 0;
368
+ if (texts.length === 0)
369
+ return;
370
+ // Pass 1: rasterise any new glyphs into the atlas.
371
+ const layouts = [];
372
+ for (const text of texts) {
373
+ const layout = this.atlas.layoutString(text.content);
374
+ if (layout.glyphs.length === 0)
375
+ continue;
376
+ const tint = text.color ?? [0.05, 0.05, 0.05, 1.0];
377
+ // The IFC text direction (dirX, dirZ) is the baseline X axis in world
378
+ // space. We want a 3D right-axis = baseline direction, up-axis = scene
379
+ // up (0, 1, 0). The glyph is scaled by the world-height and the per-
380
+ // glyph atlas-pixel size relative to the atlas-pixel cap height.
381
+ const heightWorld = text.height;
382
+ const heightAtlas = this.atlas.glyphPx;
383
+ const dirLen = Math.hypot(text.dirX, text.dirZ) || 1;
384
+ const ux = text.dirX / dirLen;
385
+ const uz = text.dirZ / dirLen;
386
+ // Alignment offsets in atlas pixels along the baseline + vertical axes.
387
+ const align = parseBoxAlignment(text.alignment);
388
+ const baselineOffset = align.vertical * this.atlas.glyphPx;
389
+ const horizontalOffset = align.horizontal * layout.totalAdvancePx;
390
+ // Detect "true visual center" alignment so a single-line label
391
+ // anchors the GLYPH's geometric centre on the anchor (rather than
392
+ // the atlas-slot midline — the canonical `parseBoxAlignment` output
393
+ // assumes the slot height equals the visual glyph which is rarely
394
+ // true for sans-serif digits and produces noticeable top-bias on
395
+ // grid bubble tags).
396
+ const isCenterH = align.horizontal === -0.5;
397
+ const isCenterV = align.vertical === -0.5;
398
+ // For horizontal centring across a multi-glyph label, the row's
399
+ // visual width is (lastXOffset + lastGlyphWidth) - firstXOffset.
400
+ // For a single-glyph label this collapses to the glyph's widthPx.
401
+ let visualWidthPx = layout.totalAdvancePx;
402
+ if (isCenterH && layout.glyphs.length > 0) {
403
+ const first = layout.glyphs[0];
404
+ const last = layout.glyphs[layout.glyphs.length - 1];
405
+ const left = first.xOffsetPx;
406
+ const right = last.xOffsetPx + last.glyph.widthPx;
407
+ visualWidthPx = right - left;
408
+ }
409
+ for (const entry of layout.glyphs) {
410
+ const glyph = entry.glyph;
411
+ // Glyph quad's bottom-left in atlas pixels relative to the text
412
+ // anchor.
413
+ const px0 = isCenterH
414
+ ? entry.xOffsetPx - visualWidthPx * 0.5
415
+ : entry.xOffsetPx + horizontalOffset;
416
+ const pyBottom = isCenterV
417
+ ? -glyph.heightPx * 0.5
418
+ : -baselineOffset - (glyph.heightPx - glyph.baselinePx);
419
+ const widthAtlas = glyph.widthPx;
420
+ const heightGlyphAtlas = glyph.heightPx;
421
+ // Convert atlas-pixel local coords to world-space offsets:
422
+ // right axis in world = (ux, 0, uz) * (widthAtlas * heightWorld / heightAtlas)
423
+ // up axis in world = (0, 1, 0) * (heightGlyphAtlas * heightWorld / heightAtlas)
424
+ const wScale = heightWorld / heightAtlas;
425
+ const widthWorld = widthAtlas * wScale;
426
+ const heightGlyphWorld = heightGlyphAtlas * wScale;
427
+ // Bottom-left origin of the glyph quad in world space.
428
+ const ox = text.worldPos[0] + ux * px0 * wScale;
429
+ const oy = text.worldPos[1] + pyBottom * wScale;
430
+ const oz = text.worldPos[2] + uz * px0 * wScale;
431
+ layouts.push({
432
+ origin: [ox, oy, oz],
433
+ rightAxis: [ux * widthWorld, 0, uz * widthWorld],
434
+ upAxis: [0, heightGlyphWorld, 0],
435
+ uvBounds: [glyph.u0, glyph.v0, glyph.u1, glyph.v1],
436
+ color: tint,
437
+ // Shared per-label anchor (text.worldPos) lets the shader compute
438
+ // one screen-space scale and apply it uniformly across all glyphs.
439
+ anchor: [text.worldPos[0], text.worldPos[1], text.worldPos[2]],
440
+ capHeight: heightWorld,
441
+ billboard: text.billboard ? 1.0 : 0.0,
442
+ // Per-glyph offset + size in world units. The shader uses these
443
+ // (via cameraRight/cameraUp) when billboard=1 so the glyph quad
444
+ // tracks the screen instead of the floor plane.
445
+ glyphOffsetSize: [
446
+ px0 * wScale, // offsetX from anchor along baseline
447
+ pyBottom * wScale, // offsetY (ascender / descender / baseline)
448
+ widthAtlas * wScale, // glyph width
449
+ heightGlyphAtlas * wScale, // glyph height
450
+ ],
451
+ targetPxOverride: text.targetPx ?? 0,
452
+ });
453
+ }
454
+ }
455
+ if (layouts.length === 0)
456
+ return;
457
+ // Pack into a Float32Array.
458
+ const stride = TEXT_INSTANCE_STRIDE_BYTES / 4;
459
+ const data = new Float32Array(layouts.length * stride);
460
+ let off = 0;
461
+ for (const l of layouts) {
462
+ data[off + 0] = l.origin[0];
463
+ data[off + 1] = l.origin[1];
464
+ data[off + 2] = l.origin[2];
465
+ data[off + 3] = l.rightAxis[0];
466
+ data[off + 4] = l.rightAxis[1];
467
+ data[off + 5] = l.rightAxis[2];
468
+ data[off + 6] = l.upAxis[0];
469
+ data[off + 7] = l.upAxis[1];
470
+ data[off + 8] = l.upAxis[2];
471
+ data[off + 9] = l.uvBounds[0];
472
+ data[off + 10] = l.uvBounds[1];
473
+ data[off + 11] = l.uvBounds[2];
474
+ data[off + 12] = l.uvBounds[3];
475
+ data[off + 13] = l.color[0];
476
+ data[off + 14] = l.color[1];
477
+ data[off + 15] = l.color[2];
478
+ data[off + 16] = l.color[3];
479
+ data[off + 17] = l.anchor[0];
480
+ data[off + 18] = l.anchor[1];
481
+ data[off + 19] = l.anchor[2];
482
+ data[off + 20] = l.capHeight;
483
+ data[off + 21] = l.billboard;
484
+ data[off + 22] = l.glyphOffsetSize[0];
485
+ data[off + 23] = l.glyphOffsetSize[1];
486
+ data[off + 24] = l.glyphOffsetSize[2];
487
+ data[off + 25] = l.glyphOffsetSize[3];
488
+ data[off + 26] = l.targetPxOverride;
489
+ off += stride;
490
+ }
491
+ this.instanceBuffer = this.device.createBuffer({
492
+ label: 'symbolic-text-instances',
493
+ size: data.byteLength,
494
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
495
+ });
496
+ this.device.queue.writeBuffer(this.instanceBuffer, 0, data);
497
+ this.instanceCount = layouts.length;
498
+ }
499
+ hasGeometry() {
500
+ return this.instanceCount > 0;
501
+ }
502
+ render(pass, viewProj, viewportPxWidth, viewportPxHeight, cameraRight, cameraUp, targetGlyphPx = DEFAULT_TEXT_TARGET_PX) {
503
+ if (!this.pipeline || !this.uniformBuffer || !this.cornerBuffer || !this.instanceBuffer)
504
+ return;
505
+ if (this.instanceCount === 0)
506
+ return;
507
+ this.syncAtlasTexture();
508
+ this.ensureBindGroup();
509
+ if (!this.bindGroup)
510
+ return;
511
+ // Pack the uniform:
512
+ // [0..15] viewProj (64 B)
513
+ // [16..19] (viewportW, viewportH, targetPx, pad)
514
+ // [20..23] cameraRight.xyz + pad
515
+ // [24..27] cameraUp.xyz + pad
516
+ const uniformData = new Float32Array(TEXT_UNIFORM_BYTES / 4);
517
+ uniformData.set(viewProj, 0);
518
+ uniformData[16] = viewportPxWidth;
519
+ uniformData[17] = viewportPxHeight;
520
+ uniformData[18] = targetGlyphPx;
521
+ uniformData[19] = 0;
522
+ uniformData[20] = cameraRight[0];
523
+ uniformData[21] = cameraRight[1];
524
+ uniformData[22] = cameraRight[2];
525
+ uniformData[23] = 0;
526
+ uniformData[24] = cameraUp[0];
527
+ uniformData[25] = cameraUp[1];
528
+ uniformData[26] = cameraUp[2];
529
+ uniformData[27] = 0;
530
+ this.device.queue.writeBuffer(this.uniformBuffer, 0, uniformData);
531
+ pass.setPipeline(this.pipeline);
532
+ pass.setBindGroup(0, this.bindGroup);
533
+ pass.setVertexBuffer(0, this.cornerBuffer);
534
+ pass.setVertexBuffer(1, this.instanceBuffer);
535
+ pass.draw(4, this.instanceCount);
536
+ }
537
+ destroy() {
538
+ if (this.instanceBuffer)
539
+ this.instanceBuffer.destroy();
540
+ if (this.cornerBuffer)
541
+ this.cornerBuffer.destroy();
542
+ if (this.uniformBuffer)
543
+ this.uniformBuffer.destroy();
544
+ if (this.atlasTexture)
545
+ this.atlasTexture.destroy();
546
+ this.instanceBuffer = null;
547
+ this.cornerBuffer = null;
548
+ this.uniformBuffer = null;
549
+ this.atlasTexture = null;
550
+ this.atlasView = null;
551
+ this.sampler = null;
552
+ this.bindGroup = null;
553
+ this.bindGroupLayout = null;
554
+ this.pipeline = null;
555
+ this.instanceCount = 0;
556
+ this.uploadedAtlasVersion = -1;
557
+ }
558
+ }
559
+ // ─── Helpers ────────────────────────────────────────────────────────────────
560
+ /**
561
+ * IFC BoxAlignment → normalized offsets in [-1, 0] for vertical and
562
+ * horizontal axes. Returned offset is multiplied by the relevant span.
563
+ *
564
+ * Vertical:
565
+ * "top" → 0 (no offset)
566
+ * "middle" → -0.5
567
+ * "bottom" → -1 (default per IFC)
568
+ * Horizontal:
569
+ * "left" → 0 (default per IFC)
570
+ * "center" → -0.5
571
+ * "right" → -1
572
+ *
573
+ * Unknown values fall back to ("bottom", "left"). Single-token values like
574
+ * "center" are interpreted as { vertical: "middle", horizontal: "center" }.
575
+ */
576
+ function parseBoxAlignment(s) {
577
+ const norm = s.toLowerCase().trim();
578
+ let horizontal = 0;
579
+ let vertical = -1;
580
+ if (norm === '')
581
+ return { horizontal, vertical };
582
+ if (norm.includes('top'))
583
+ vertical = 0;
584
+ else if (norm.includes('middle'))
585
+ vertical = -0.5;
586
+ else if (norm.includes('center') && !norm.includes('center-'))
587
+ vertical = -0.5;
588
+ else
589
+ vertical = -1;
590
+ if (norm.includes('right'))
591
+ horizontal = -1;
592
+ else if (norm.includes('center'))
593
+ horizontal = -0.5;
594
+ else
595
+ horizontal = 0;
596
+ return { horizontal, vertical };
597
+ }
598
+ /**
599
+ * Triangulate a polygon-with-holes via ear-clipping. Each output triangle is
600
+ * appended to `stream` as 3 × (x, y, z, r, g, b, a) entries (matching the
601
+ * fill pipeline's vertex layout). Holes that fully enclose nothing or have
602
+ * < 3 valid vertices are silently dropped.
603
+ *
604
+ * Ear-clipping is O(n²) which is fine for fill regions (typically < 100
605
+ * vertices). For pathological inputs we'd want earcut proper, but adding the
606
+ * npm dependency for marginal gain isn't worth it.
607
+ */
608
+ function triangulateFillTo(stream, fill) {
609
+ const { points, holesOffsets, worldY, color } = fill;
610
+ if (points.length < 6)
611
+ return;
612
+ // Convert the flat ring buffer into rings of {x, z} (Y is constant).
613
+ const totalVerts = points.length / 2;
614
+ const ringStarts = [0, ...Array.from(holesOffsets), totalVerts];
615
+ if (ringStarts.length < 2)
616
+ return;
617
+ // Pull each ring's vertices out.
618
+ const rings = [];
619
+ for (let r = 0; r < ringStarts.length - 1; r++) {
620
+ const start = ringStarts[r];
621
+ const end = ringStarts[r + 1];
622
+ if (end - start < 3)
623
+ continue;
624
+ const ring = [];
625
+ for (let v = start; v < end; v++) {
626
+ ring.push({ x: points[v * 2], z: points[v * 2 + 1] });
627
+ }
628
+ rings.push(ring);
629
+ }
630
+ if (rings.length === 0)
631
+ return;
632
+ // Stitch each hole into the outer ring with a bridge edge from the hole's
633
+ // rightmost vertex to the nearest outer-ring vertex (the same approach
634
+ // earcut.js takes for polygon-with-holes input). Ear-clipping then runs on
635
+ // the resulting simple polygon. The hole's winding is reversed first so the
636
+ // combined ring's signed area stays consistent and the ear-test sign holds.
637
+ const outer = rings[0];
638
+ const holes = rings.slice(1);
639
+ const stitched = holes.length === 0 ? outer : joinHoles(outer, holes);
640
+ const triangles = earClip(stitched);
641
+ for (const tri of triangles) {
642
+ for (const idx of tri) {
643
+ const v = stitched[idx];
644
+ stream.push(v.x, worldY, v.z, color[0], color[1], color[2], color[3]);
645
+ }
646
+ }
647
+ }
648
+ function joinHoles(outer, holes) {
649
+ if (holes.length === 0)
650
+ return outer;
651
+ const sorted = holes
652
+ .map((h) => {
653
+ let bestI = 0;
654
+ for (let i = 1; i < h.length; i++) {
655
+ if (h[i].x > h[bestI].x)
656
+ bestI = i;
657
+ }
658
+ return { ring: h, startIdx: bestI, startX: h[bestI].x, startZ: h[bestI].z };
659
+ })
660
+ .sort((a, b) => b.startX - a.startX);
661
+ let result = outer.slice();
662
+ for (const { ring, startIdx, startX, startZ } of sorted) {
663
+ // Find the outer-ring index with the smallest distance to the bridge
664
+ // start, preferring vertices to the right (x > startX). When nothing is
665
+ // to the right (hole touches the outer ring's right edge), fall back to
666
+ // global nearest.
667
+ let bestIdx = -1;
668
+ let bestDist = Infinity;
669
+ for (let i = 0; i < result.length; i++) {
670
+ const p = result[i];
671
+ if (p.x <= startX)
672
+ continue;
673
+ const d = (p.x - startX) * (p.x - startX) + (p.z - startZ) * (p.z - startZ);
674
+ if (d < bestDist) {
675
+ bestDist = d;
676
+ bestIdx = i;
677
+ }
678
+ }
679
+ if (bestIdx < 0) {
680
+ for (let i = 0; i < result.length; i++) {
681
+ const p = result[i];
682
+ const d = (p.x - startX) * (p.x - startX) + (p.z - startZ) * (p.z - startZ);
683
+ if (d < bestDist) {
684
+ bestDist = d;
685
+ bestIdx = i;
686
+ }
687
+ }
688
+ }
689
+ if (bestIdx < 0)
690
+ continue;
691
+ // Hole reversed so its winding opposes outer (outer is CCW after earClip
692
+ // normalisation; holes should be CW for the combined ring's area to come
693
+ // out right). Rotate to start at the bridge vertex.
694
+ const reversed = ring.slice().reverse();
695
+ const reversedStartIdx = ring.length - 1 - startIdx;
696
+ const rotated = [
697
+ ...reversed.slice(reversedStartIdx),
698
+ ...reversed.slice(0, reversedStartIdx),
699
+ ];
700
+ result = [
701
+ ...result.slice(0, bestIdx + 1),
702
+ ...rotated,
703
+ rotated[0],
704
+ result[bestIdx],
705
+ ...result.slice(bestIdx + 1),
706
+ ];
707
+ }
708
+ return result;
709
+ }
710
+ /** Ear-clipping triangulation of a simple polygon. Returns triangle vertex indices. */
711
+ function earClip(ring) {
712
+ const n = ring.length;
713
+ if (n < 3)
714
+ return [];
715
+ if (n === 3)
716
+ return [[0, 1, 2]];
717
+ // Working list of indices.
718
+ const indices = [];
719
+ // Determine winding: positive shoelace = CCW; otherwise reverse so the
720
+ // ear-test below uses a consistent sign.
721
+ let area = 0;
722
+ for (let i = 0; i < n; i++) {
723
+ const a = ring[i];
724
+ const b = ring[(i + 1) % n];
725
+ area += (a.x * b.z) - (b.x * a.z);
726
+ }
727
+ const ccw = area > 0;
728
+ for (let i = 0; i < n; i++) {
729
+ indices.push(ccw ? i : n - 1 - i);
730
+ }
731
+ const triangles = [];
732
+ let safety = indices.length * indices.length;
733
+ while (indices.length > 3 && safety-- > 0) {
734
+ let found = false;
735
+ for (let i = 0; i < indices.length; i++) {
736
+ const ia = indices[(i + indices.length - 1) % indices.length];
737
+ const ib = indices[i];
738
+ const ic = indices[(i + 1) % indices.length];
739
+ const a = ring[ia];
740
+ const b = ring[ib];
741
+ const c = ring[ic];
742
+ // Convex check (cross product of (b-a) × (c-b) > 0 for CCW).
743
+ const cross = (b.x - a.x) * (c.z - b.z) - (b.z - a.z) * (c.x - b.x);
744
+ if (cross <= 0)
745
+ continue;
746
+ // No other vertex inside triangle (a, b, c).
747
+ let containsOther = false;
748
+ for (let j = 0; j < indices.length; j++) {
749
+ const ij = indices[j];
750
+ if (ij === ia || ij === ib || ij === ic)
751
+ continue;
752
+ if (pointInTriangle(ring[ij], a, b, c)) {
753
+ containsOther = true;
754
+ break;
755
+ }
756
+ }
757
+ if (containsOther)
758
+ continue;
759
+ triangles.push([ia, ib, ic]);
760
+ indices.splice(i, 1);
761
+ found = true;
762
+ break;
763
+ }
764
+ if (!found)
765
+ break; // degenerate polygon — emit what we have
766
+ }
767
+ if (indices.length === 3) {
768
+ triangles.push([indices[0], indices[1], indices[2]]);
769
+ }
770
+ return triangles;
771
+ }
772
+ function pointInTriangle(p, a, b, c) {
773
+ const s1 = sign(p, a, b);
774
+ const s2 = sign(p, b, c);
775
+ const s3 = sign(p, c, a);
776
+ const hasNeg = s1 < 0 || s2 < 0 || s3 < 0;
777
+ const hasPos = s1 > 0 || s2 > 0 || s3 > 0;
778
+ return !(hasNeg && hasPos);
779
+ }
780
+ function sign(p1, p2, p3) {
781
+ return (p1.x - p3.x) * (p2.z - p3.z) - (p2.x - p3.x) * (p1.z - p3.z);
782
+ }
783
+ //# sourceMappingURL=symbolic-overlay-pipelines.js.map