@ifc-lite/renderer 1.21.0 → 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.
- package/dist/device.d.ts +6 -0
- package/dist/device.d.ts.map +1 -1
- package/dist/device.js +8 -0
- package/dist/device.js.map +1 -1
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +231 -6
- package/dist/index.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +10 -0
- package/dist/pipeline.js.map +1 -1
- package/dist/section-2d-overlay.d.ts +21 -0
- package/dist/section-2d-overlay.d.ts.map +1 -1
- package/dist/section-2d-overlay.js +67 -0
- package/dist/section-2d-overlay.js.map +1 -1
- package/dist/shaders/symbolic-overlay.wgsl.d.ts +10 -0
- package/dist/shaders/symbolic-overlay.wgsl.d.ts.map +1 -0
- package/dist/shaders/symbolic-overlay.wgsl.js +192 -0
- package/dist/shaders/symbolic-overlay.wgsl.js.map +1 -0
- package/dist/symbolic-overlay-pipelines.d.ts +110 -0
- package/dist/symbolic-overlay-pipelines.d.ts.map +1 -0
- package/dist/symbolic-overlay-pipelines.js +783 -0
- package/dist/symbolic-overlay-pipelines.js.map +1 -0
- package/dist/symbolic-text-atlas.d.ts +68 -0
- package/dist/symbolic-text-atlas.d.ts.map +1 -0
- package/dist/symbolic-text-atlas.js +144 -0
- package/dist/symbolic-text-atlas.js.map +1 -0
- package/package.json +3 -3
|
@@ -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
|