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