@aethervtt/shard-vector 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Dias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @aethervtt/shard-vector
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Part of [Shard](https://github.com/aethervtt/shard), a WebGPU-first 2D/3D game engine in TypeScript. See the repository for documentation.
@@ -0,0 +1,52 @@
1
+ export type Ring = [number, number][];
2
+ export type CoverageShape = {
3
+ kind: 'rect';
4
+ x: number;
5
+ y: number;
6
+ w: number;
7
+ h: number;
8
+ } | {
9
+ kind: 'polygon';
10
+ outer: Ring;
11
+ holes?: Ring[];
12
+ } | {
13
+ kind: 'multipolygon';
14
+ polygons: {
15
+ outer: Ring;
16
+ holes?: Ring[];
17
+ }[];
18
+ } | {
19
+ kind: 'brush';
20
+ points: Ring;
21
+ radius: number;
22
+ };
23
+ export interface CoverageMesh {
24
+ /** x, z per vertex. */
25
+ positions: Float32Array;
26
+ /** 1 inside the shape, down to 0 at the feather's outer edge. */
27
+ coverage: Float32Array;
28
+ indices: Uint32Array;
29
+ /** How many of `indices` belong to the core (coverage 1); ring triangles follow them. */
30
+ coreIndices: number;
31
+ }
32
+ export interface CoverageOptions {
33
+ /** Width of the soft edge outside the shape, in world units. 0: a hard edge. */
34
+ feather: number;
35
+ /** Chord error allowed on arcs (round caps, corners), in world units. Default 0.01. */
36
+ error?: number;
37
+ /**
38
+ * Drops input points the outline doesn't need: the simplified outline stays within this distance
39
+ * of the given one (Douglas–Peucker). A mask of texel t loses nothing at 8 bits with t / 255.
40
+ * Default 0: every point kept.
41
+ */
42
+ tolerance?: number;
43
+ }
44
+ /** Checks a region shape (from a host or a file) and returns it typed. */
45
+ export declare function parseCoverageShape(value: unknown, path?: string): CoverageShape;
46
+ /**
47
+ * Tessellates a region shape with its feather. Core triangles (coverage 1) come first; a stencil
48
+ * that takes each texel's first triangle per region makes overlaps harmless (see above).
49
+ */
50
+ export declare function featheredCoverage(shape: CoverageShape, options: CoverageOptions): CoverageMesh;
51
+ /** The bounds of a shape with its feather: [minX, minZ, maxX, maxZ]. */
52
+ export declare function coverageBounds(shape: CoverageShape, feather: number): [number, number, number, number];
@@ -0,0 +1,423 @@
1
+ import { polygon, ShardError } from "@aethervtt/shard-core";
2
+ const MITER_LIMIT = 4;
3
+ class Builder {
4
+ positions = [];
5
+ coverage = [];
6
+ core = [];
7
+ ring = [];
8
+ vertex(x, z, c) {
9
+ this.positions.push(x, z);
10
+ this.coverage.push(c);
11
+ return this.coverage.length - 1;
12
+ }
13
+ finish() {
14
+ const indices = new Uint32Array(this.core.length + this.ring.length);
15
+ indices.set(this.core);
16
+ indices.set(this.ring, this.core.length);
17
+ return {
18
+ positions: new Float32Array(this.positions),
19
+ coverage: new Float32Array(this.coverage),
20
+ indices,
21
+ coreIndices: this.core.length
22
+ };
23
+ }
24
+ }
25
+ function invalid(message, path) {
26
+ return new ShardError("vector/invalid-geometry", message, {
27
+ path,
28
+ hint: "A region shape is { kind: 'rect' | 'polygon' | 'multipolygon' | 'brush', ... } in world (x, z)."
29
+ });
30
+ }
31
+ function finite(value, path, min = -Infinity) {
32
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min)
33
+ throw invalid(min === 0 ? "Expected a non-negative number" : "Expected a number", path);
34
+ return value;
35
+ }
36
+ function ring(value, path, min) {
37
+ if (!Array.isArray(value) || value.length < min)
38
+ throw invalid(`Expected at least ${min} points`, path);
39
+ for (let i = 0; i < value.length; i++) {
40
+ const p = value[i];
41
+ if (!Array.isArray(p) || p.length !== 2)
42
+ throw invalid("Expected a point [x, z]", `${path}/${i}`);
43
+ finite(p[0], `${path}/${i}/0`);
44
+ finite(p[1], `${path}/${i}/1`);
45
+ }
46
+ return value;
47
+ }
48
+ function polygonOf(value, path) {
49
+ if (!value || typeof value !== "object") throw invalid("Expected { outer, holes }", path);
50
+ const p = value;
51
+ const holes = p.holes === void 0 ? [] : p.holes;
52
+ if (!Array.isArray(holes)) throw invalid("Expected a list of holes", `${path}/holes`);
53
+ return {
54
+ outer: ring(p.outer, `${path}/outer`, 3),
55
+ holes: holes.map((h, i) => ring(h, `${path}/holes/${i}`, 3))
56
+ };
57
+ }
58
+ function parseCoverageShape(value, path = "/shape") {
59
+ if (!value || typeof value !== "object") throw invalid("Expected a shape object", path);
60
+ const s = value;
61
+ switch (s.kind) {
62
+ case "rect":
63
+ return {
64
+ kind: "rect",
65
+ x: finite(s.x, `${path}/x`),
66
+ y: finite(s.y, `${path}/y`),
67
+ w: finite(s.w, `${path}/w`, 0),
68
+ h: finite(s.h, `${path}/h`, 0)
69
+ };
70
+ case "polygon":
71
+ return { kind: "polygon", ...polygonOf(s, path) };
72
+ case "multipolygon": {
73
+ if (!Array.isArray(s.polygons))
74
+ throw invalid("Expected a list of polygons", `${path}/polygons`);
75
+ return {
76
+ kind: "multipolygon",
77
+ polygons: s.polygons.map((p, i) => polygonOf(p, `${path}/polygons/${i}`))
78
+ };
79
+ }
80
+ case "brush":
81
+ return {
82
+ kind: "brush",
83
+ points: ring(s.points, `${path}/points`, 1),
84
+ radius: finite(s.radius, `${path}/radius`, 0)
85
+ };
86
+ default:
87
+ throw invalid(`Unknown shape kind ${JSON.stringify(s.kind)}`, `${path}/kind`);
88
+ }
89
+ }
90
+ function arcSteps(r, angle, error) {
91
+ if (angle <= 1e-9) return 0;
92
+ if (r <= error) return Math.max(1, Math.ceil(angle / (Math.PI / 2)));
93
+ const step = 2 * Math.acos(Math.max(-1, 1 - error / r));
94
+ return Math.max(1, Math.min(512, Math.ceil(angle / step)));
95
+ }
96
+ function area(r) {
97
+ let a = 0;
98
+ for (let i = 0, j = r.length - 1; i < r.length; j = i++)
99
+ a += r[j][0] * r[i][1] - r[i][0] * r[j][1];
100
+ return a / 2;
101
+ }
102
+ function clean(r, closed, tolerance) {
103
+ const out = [];
104
+ for (const p of r) {
105
+ const last = out[out.length - 1];
106
+ if (!last || last[0] !== p[0] || last[1] !== p[1]) out.push(p);
107
+ }
108
+ if (closed && out.length > 1) {
109
+ const a = out[0];
110
+ const z = out[out.length - 1];
111
+ if (a[0] === z[0] && a[1] === z[1]) out.pop();
112
+ }
113
+ return tolerance > 0 ? simplify(out, closed, tolerance) : out;
114
+ }
115
+ function simplify(r, closed, tolerance) {
116
+ const n = r.length;
117
+ if (n <= (closed ? 4 : 2)) return r;
118
+ const keep = new Uint8Array(n);
119
+ const t2 = tolerance * tolerance;
120
+ const spans = [];
121
+ keep[0] = 1;
122
+ if (closed) {
123
+ let far = 1;
124
+ let best = -1;
125
+ for (let i = 1; i < n; i++) {
126
+ const dx = r[i][0] - r[0][0];
127
+ const dz = r[i][1] - r[0][1];
128
+ const d = dx * dx + dz * dz;
129
+ if (d > best) {
130
+ best = d;
131
+ far = i;
132
+ }
133
+ }
134
+ keep[far] = 1;
135
+ spans.push(0, far, far, n);
136
+ } else {
137
+ keep[n - 1] = 1;
138
+ spans.push(0, n - 1);
139
+ }
140
+ while (spans.length > 0) {
141
+ const end = spans.pop();
142
+ const start = spans.pop();
143
+ if (end - start < 2) continue;
144
+ const a = r[start];
145
+ const b = r[end % n];
146
+ const cx = b[0] - a[0];
147
+ const cz = b[1] - a[1];
148
+ const len2 = cx * cx + cz * cz;
149
+ let worst = -1;
150
+ let at = -1;
151
+ for (let i = start + 1; i < end; i++) {
152
+ const p = r[i];
153
+ let dx = p[0] - a[0];
154
+ let dz = p[1] - a[1];
155
+ if (len2 > 0) {
156
+ const t = Math.max(0, Math.min(1, (dx * cx + dz * cz) / len2));
157
+ dx -= t * cx;
158
+ dz -= t * cz;
159
+ }
160
+ const d = dx * dx + dz * dz;
161
+ if (d > worst) {
162
+ worst = d;
163
+ at = i;
164
+ }
165
+ }
166
+ if (worst > t2) {
167
+ keep[at] = 1;
168
+ spans.push(start, at, at, end);
169
+ }
170
+ }
171
+ const out = [];
172
+ for (let i = 0; i < n; i++) if (keep[i]) out.push(r[i]);
173
+ return out;
174
+ }
175
+ function polygonCoverage(b, outer, holes, feather, error, tolerance) {
176
+ const rings = [outer, ...holes].map((r, i) => {
177
+ const c = clean(r, true, tolerance);
178
+ const ccw = area(c) > 0;
179
+ return i === 0 === ccw ? c : [...c].reverse();
180
+ }).filter((r) => r.length >= 3);
181
+ if (rings.length === 0 || rings[0].length < 3) return;
182
+ let total = 0;
183
+ for (const r of rings) total += r.length;
184
+ const flat = new Float64Array(total * 2);
185
+ const starts = [];
186
+ let k = 0;
187
+ const firstVertex = b.coverage.length;
188
+ for (let i = 0; i < rings.length; i++) {
189
+ if (i > 0) starts.push(k);
190
+ for (const [x, z] of rings[i]) {
191
+ flat[k * 2] = x;
192
+ flat[k * 2 + 1] = z;
193
+ b.vertex(x, z, 1);
194
+ k++;
195
+ }
196
+ }
197
+ const tris = polygon.triangulate(flat, starts);
198
+ for (let i = 0; i < tris.length; i++) b.core.push(firstVertex + tris[i]);
199
+ if (feather <= 0) return;
200
+ let base = firstVertex;
201
+ for (const r of rings) {
202
+ featherRing(b, r, base, feather, error);
203
+ base += r.length;
204
+ }
205
+ }
206
+ function featherRing(b, r, first, feather, error) {
207
+ const n = r.length;
208
+ const nx = new Float64Array(n);
209
+ const nz = new Float64Array(n);
210
+ for (let i = 0; i < n; i++) {
211
+ const a = r[i];
212
+ const c = r[(i + 1) % n];
213
+ const dx = c[0] - a[0];
214
+ const dz = c[1] - a[1];
215
+ const len = Math.sqrt(dx * dx + dz * dz) || 1;
216
+ nx[i] = dz / len;
217
+ nz[i] = -dx / len;
218
+ }
219
+ const endOf = new Int32Array(n);
220
+ const startOf = new Int32Array(n);
221
+ for (let i = 0; i < n; i++) {
222
+ const p = r[i];
223
+ const e0 = (i - 1 + n) % n;
224
+ const e1 = i;
225
+ const turn = nx[e0] * nz[e1] - nz[e0] * nx[e1];
226
+ const convex = turn > 0;
227
+ if (convex) {
228
+ const o0 = b.vertex(p[0] + nx[e0] * feather, p[1] + nz[e0] * feather, 0);
229
+ const o1 = b.vertex(p[0] + nx[e1] * feather, p[1] + nz[e1] * feather, 0);
230
+ endOf[i] = o0;
231
+ startOf[i] = o1;
232
+ const a0 = Math.atan2(nz[e0], nx[e0]);
233
+ let a1 = Math.atan2(nz[e1], nx[e1]);
234
+ let da = a1 - a0;
235
+ while (da > Math.PI) da -= Math.PI * 2;
236
+ while (da < -Math.PI) da += Math.PI * 2;
237
+ a1 = a0 + da;
238
+ const steps = arcSteps(feather, Math.abs(da), error);
239
+ let prev = o0;
240
+ for (let s = 1; s <= steps; s++) {
241
+ const next = s === steps ? o1 : b.vertex(
242
+ p[0] + Math.cos(a0 + da * s / steps) * feather,
243
+ p[1] + Math.sin(a0 + da * s / steps) * feather,
244
+ 0
245
+ );
246
+ b.ring.push(first + i, prev, next);
247
+ prev = next;
248
+ }
249
+ } else {
250
+ const mx = nx[e0] + nx[e1];
251
+ const mz = nz[e0] + nz[e1];
252
+ const ml = Math.sqrt(mx * mx + mz * mz);
253
+ let ox = nx[e1];
254
+ let oz = nz[e1];
255
+ if (ml > 1e-9) {
256
+ const ux = mx / ml;
257
+ const uz = mz / ml;
258
+ const scale = Math.min(MITER_LIMIT, 1 / Math.max(1e-6, ux * nx[e1] + uz * nz[e1]));
259
+ ox = ux * scale;
260
+ oz = uz * scale;
261
+ }
262
+ const m = b.vertex(p[0] + ox * feather, p[1] + oz * feather, 0);
263
+ endOf[i] = m;
264
+ startOf[i] = m;
265
+ }
266
+ }
267
+ for (let i = 0; i < n; i++) {
268
+ const j = (i + 1) % n;
269
+ const pi = first + i;
270
+ const pj = first + j;
271
+ b.ring.push(pi, pj, endOf[j], pi, endOf[j], startOf[i]);
272
+ }
273
+ }
274
+ function disk(b, c, radius, feather, error) {
275
+ arc(b, c, radius, feather, 0, Math.PI * 2, error);
276
+ }
277
+ function arc(b, c, radius, feather, a0, a1, error) {
278
+ const span = a1 - a0;
279
+ const steps = Math.max(1, arcSteps(radius + feather, Math.abs(span), error));
280
+ const centre = b.vertex(c[0], c[1], 1);
281
+ let prevIn = -1;
282
+ let prevOut = -1;
283
+ for (let s = 0; s <= steps; s++) {
284
+ const a = a0 + span * s / steps;
285
+ const cx = Math.cos(a);
286
+ const cz = Math.sin(a);
287
+ const inner = b.vertex(c[0] + cx * radius, c[1] + cz * radius, 1);
288
+ const outer = feather > 0 ? b.vertex(c[0] + cx * (radius + feather), c[1] + cz * (radius + feather), 0) : -1;
289
+ if (s > 0) {
290
+ if (radius > 0) b.core.push(centre, prevIn, inner);
291
+ if (feather > 0) b.ring.push(prevIn, inner, outer, prevIn, outer, prevOut);
292
+ }
293
+ prevIn = inner;
294
+ prevOut = outer;
295
+ }
296
+ }
297
+ function brushCoverage(b, pts, radius, feather, error, tolerance) {
298
+ const path = clean(pts, false, tolerance);
299
+ if (path.length === 0) return;
300
+ if (path.length === 1) {
301
+ disk(b, path[0], radius, feather, error);
302
+ return;
303
+ }
304
+ const n = path.length;
305
+ const segments = n - 1;
306
+ const nx = new Float64Array(segments);
307
+ const nz = new Float64Array(segments);
308
+ for (let s = 0; s < segments; s++) {
309
+ const a = path[s];
310
+ const c = path[s + 1];
311
+ const dx = c[0] - a[0];
312
+ const dz = c[1] - a[1];
313
+ const len = Math.sqrt(dx * dx + dz * dz) || 1;
314
+ nx[s] = -dz / len;
315
+ nz[s] = dx / len;
316
+ }
317
+ const r = radius;
318
+ const f = feather;
319
+ for (let s = 0; s < segments; s++) {
320
+ const a = path[s];
321
+ const c = path[s + 1];
322
+ const ox = nx[s];
323
+ const oz = nz[s];
324
+ const al = b.vertex(a[0] + ox * r, a[1] + oz * r, 1);
325
+ const ar = b.vertex(a[0] - ox * r, a[1] - oz * r, 1);
326
+ const cl = b.vertex(c[0] + ox * r, c[1] + oz * r, 1);
327
+ const cr = b.vertex(c[0] - ox * r, c[1] - oz * r, 1);
328
+ b.core.push(al, ar, cl, ar, cr, cl);
329
+ if (f > 0) {
330
+ const alo = b.vertex(a[0] + ox * (r + f), a[1] + oz * (r + f), 0);
331
+ const clo = b.vertex(c[0] + ox * (r + f), c[1] + oz * (r + f), 0);
332
+ const aro = b.vertex(a[0] - ox * (r + f), a[1] - oz * (r + f), 0);
333
+ const cro = b.vertex(c[0] - ox * (r + f), c[1] - oz * (r + f), 0);
334
+ b.ring.push(al, cl, clo, al, clo, alo, ar, cr, cro, ar, cro, aro);
335
+ }
336
+ }
337
+ for (let v = 1; v < n - 1; v++) {
338
+ const s0 = v - 1;
339
+ const s1 = v;
340
+ const a0 = Math.atan2(nz[s0], nx[s0]);
341
+ let da = Math.atan2(nz[s1], nx[s1]) - a0;
342
+ while (da > Math.PI) da -= Math.PI * 2;
343
+ while (da < -Math.PI) da += Math.PI * 2;
344
+ if (Math.abs(da) < 1e-9) continue;
345
+ const start = da > 0 ? a0 + Math.PI : a0;
346
+ arc(b, path[v], r, f, start, start + da, error);
347
+ }
348
+ const first = Math.atan2(nz[0], nx[0]);
349
+ arc(b, path[0], r, f, first, first + Math.PI, error);
350
+ const last = Math.atan2(nz[segments - 1], nx[segments - 1]);
351
+ arc(b, path[n - 1], r, f, last + Math.PI, last + Math.PI * 2, error);
352
+ }
353
+ function featheredCoverage(shape, options) {
354
+ const feather = Math.max(0, options.feather);
355
+ const error = options.error ?? 0.01;
356
+ const tolerance = Math.max(0, options.tolerance ?? 0);
357
+ const b = new Builder();
358
+ switch (shape.kind) {
359
+ case "rect":
360
+ if (shape.w > 0 && shape.h > 0) {
361
+ const { x, y, w, h } = shape;
362
+ polygonCoverage(
363
+ b,
364
+ [
365
+ [x, y],
366
+ [x + w, y],
367
+ [x + w, y + h],
368
+ [x, y + h]
369
+ ],
370
+ [],
371
+ feather,
372
+ error,
373
+ 0
374
+ );
375
+ }
376
+ break;
377
+ case "polygon":
378
+ polygonCoverage(b, shape.outer, shape.holes ?? [], feather, error, tolerance);
379
+ break;
380
+ case "multipolygon":
381
+ for (const p of shape.polygons)
382
+ polygonCoverage(b, p.outer, p.holes ?? [], feather, error, tolerance);
383
+ break;
384
+ case "brush":
385
+ brushCoverage(b, shape.points, shape.radius, feather, error, tolerance);
386
+ break;
387
+ }
388
+ return b.finish();
389
+ }
390
+ function coverageBounds(shape, feather) {
391
+ let minX = Infinity;
392
+ let minZ = Infinity;
393
+ let maxX = -Infinity;
394
+ let maxZ = -Infinity;
395
+ const add = (x, z, pad) => {
396
+ if (x - pad < minX) minX = x - pad;
397
+ if (z - pad < minZ) minZ = z - pad;
398
+ if (x + pad > maxX) maxX = x + pad;
399
+ if (z + pad > maxZ) maxZ = z + pad;
400
+ };
401
+ switch (shape.kind) {
402
+ case "rect":
403
+ add(shape.x, shape.y, feather);
404
+ add(shape.x + shape.w, shape.y + shape.h, feather);
405
+ break;
406
+ case "polygon":
407
+ for (const p of shape.outer) add(p[0], p[1], feather * MITER_LIMIT);
408
+ break;
409
+ case "multipolygon":
410
+ for (const poly of shape.polygons)
411
+ for (const p of poly.outer) add(p[0], p[1], feather * MITER_LIMIT);
412
+ break;
413
+ case "brush":
414
+ for (const p of shape.points) add(p[0], p[1], shape.radius + feather);
415
+ break;
416
+ }
417
+ return [minX, minZ, maxX, maxZ];
418
+ }
419
+ export {
420
+ coverageBounds,
421
+ featheredCoverage,
422
+ parseCoverageShape
423
+ };
@@ -0,0 +1,4 @@
1
+ export { type CoverageMesh, type CoverageOptions, type CoverageShape, coverageBounds, featheredCoverage, parseCoverageShape, type Ring, } from './coverage.js';
2
+ export { vectorPlugin } from './plugin.js';
3
+ export { STROKE_UNITS, syncShapes, VECTOR_SHADERS, VectorMaterial, VectorShape, VectorState, type VectorStateValue, } from './shape.js';
4
+ export { meshArea, parseGeometry, type StrokeUnits, type TessellateOptions, tessellate, type VectorGeometry, type VectorMesh, type VectorStyle, } from './tessellate.js';
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ import {
2
+ coverageBounds,
3
+ featheredCoverage,
4
+ parseCoverageShape
5
+ } from "./coverage.js";
6
+ import { vectorPlugin } from "./plugin.js";
7
+ import {
8
+ STROKE_UNITS,
9
+ syncShapes,
10
+ VECTOR_SHADERS,
11
+ VectorMaterial,
12
+ VectorShape,
13
+ VectorState
14
+ } from "./shape.js";
15
+ import {
16
+ meshArea,
17
+ parseGeometry,
18
+ tessellate
19
+ } from "./tessellate.js";
20
+ export {
21
+ STROKE_UNITS,
22
+ VECTOR_SHADERS,
23
+ VectorMaterial,
24
+ VectorShape,
25
+ VectorState,
26
+ coverageBounds,
27
+ featheredCoverage,
28
+ meshArea,
29
+ parseCoverageShape,
30
+ parseGeometry,
31
+ syncShapes,
32
+ tessellate,
33
+ vectorPlugin
34
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Vector drawings (0057): pen, line, rect, ellipse, cone and polygon shapes with stroke and
3
+ * fill, in the drawings band. Needs `forwardPlugin` (render/forward).
4
+ */
5
+ export declare const vectorPlugin: import("@aethervtt/shard-runtime").Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,28 @@
1
+ import { PostUpdate } from "@aethervtt/shard-core";
2
+ import { registerShaders, Shaders } from "@aethervtt/shard-render";
3
+ import { definePlugin } from "@aethervtt/shard-runtime";
4
+ import { TransformSystems } from "@aethervtt/shard-transform";
5
+ import {
6
+ observeShapeRemovals,
7
+ syncShapes,
8
+ VECTOR_SHADERS,
9
+ VectorMaterial,
10
+ VectorShape,
11
+ VectorState
12
+ } from "./shape.js";
13
+ const vectorPlugin = definePlugin({
14
+ name: "vector",
15
+ dependencies: ["render/forward", "core/transform"],
16
+ provides: [VectorShape, VectorMaterial, VectorState],
17
+ build(app) {
18
+ app.world.initResource(VectorState);
19
+ observeShapeRemovals(app.world);
20
+ app.addSystems(PostUpdate, syncShapes.before(TransformSystems));
21
+ },
22
+ ready(app) {
23
+ registerShaders(app.world.resource(Shaders), VECTOR_SHADERS);
24
+ }
25
+ });
26
+ export {
27
+ vectorPlugin
28
+ };
@@ -0,0 +1,41 @@
1
+ import { type AssetRef, type Entity, type World } from '@aethervtt/shard-core';
2
+ export declare const STROKE_UNITS: readonly ['world', 'css-px'];
3
+ export declare const VectorShape: import("@aethervtt/shard-core").ComponentDef<{
4
+ readonly geometry: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").JsonValue, "object">;
5
+ readonly stroke: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color, "f32">;
6
+ readonly strokeWidth: import("@aethervtt/shard-core").FieldType<number, "f32">;
7
+ readonly strokeUnits: import("@aethervtt/shard-core").FieldType<"css-px" | "world", "u8">;
8
+ readonly fill: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color, "f32">;
9
+ readonly fillOpacity: import("@aethervtt/shard-core").FieldType<number, "f32">;
10
+ readonly rev: import("@aethervtt/shard-core").FieldType<number, "u32">;
11
+ }>;
12
+ export declare const VectorMaterial: import("@aethervtt/shard-render").MaterialType;
13
+ export declare const VECTOR_SHADERS: Record<string, string>;
14
+ interface ShapeRecord {
15
+ /** Absent while the shape has nothing to draw. */
16
+ mesh: AssetRef<'Mesh'> | undefined;
17
+ material: AssetRef<'Material'>;
18
+ rev: number;
19
+ strokeWidth: number;
20
+ strokeUnits: string;
21
+ filled: boolean;
22
+ }
23
+ export interface VectorStateValue {
24
+ shapes: Map<Entity, ShapeRecord>;
25
+ removed: Entity[];
26
+ /** Rebuilds since start (tessellations). */
27
+ rebuilds: number;
28
+ /** The densest zoom, CSS px per world unit: curves are fine to half a pixel there. */
29
+ pixelsPerUnit: number;
30
+ }
31
+ export declare const VectorState: import("@aethervtt/shard-core").ResourceDef<VectorStateValue>;
32
+ /**
33
+ * Tessellates changed VectorShapes whose rev (or stroke width, units, or fill) changed, and keeps
34
+ * their material's colors current. Adds Mesh3d, MeshMaterial and a drawings-band GroundLayer.
35
+ */
36
+ export declare const syncShapes: import("@aethervtt/shard-core").SystemDef<{
37
+ q: import("@aethervtt/shard-core").Query;
38
+ changed: Entity[];
39
+ }>;
40
+ export declare function observeShapeRemovals(world: World): void;
41
+ export {};
package/dist/shape.js ADDED
@@ -0,0 +1,216 @@
1
+ import {
2
+ defineComponent,
3
+ defineResource,
4
+ defineSystem,
5
+ onRemove,
6
+ ShardError,
7
+ t
8
+ } from "@aethervtt/shard-core";
9
+ import { Mesh } from "@aethervtt/shard-mesh";
10
+ import {
11
+ defineMaterial,
12
+ GpuAssetsResource,
13
+ GROUND_BANDS,
14
+ GroundLayer,
15
+ MaterialAsset,
16
+ Materials,
17
+ Mesh3d,
18
+ Meshes,
19
+ MeshMaterial,
20
+ NotShadowCaster
21
+ } from "@aethervtt/shard-render";
22
+ import { LogResource } from "@aethervtt/shard-runtime";
23
+ import { parseGeometry, tessellate } from "./tessellate.js";
24
+ const STROKE_UNITS = ["world", "css-px"];
25
+ const VectorShape = defineComponent(
26
+ "vector/VectorShape",
27
+ {
28
+ geometry: t.json({
29
+ default: { kind: "rect", width: 1, height: 1 },
30
+ description: "Local (x, z) shape: { kind: 'pen', points } | { kind: 'line', from, to } | { kind: 'rect', width, height } (from the origin) | { kind: 'ellipse', rx, ry } (centred) | { kind: 'cone', length, angle } (apex at the origin, along +x, angle in degrees) | { kind: 'polygon', outer, holes }."
31
+ }),
32
+ stroke: t.color({
33
+ default: [0, 0, 0, 1],
34
+ description: "Stroke color (linear), alpha included."
35
+ }),
36
+ strokeWidth: t.f32({
37
+ default: 2,
38
+ min: 0,
39
+ description: "Stroke width, in strokeUnits. 0: none."
40
+ }),
41
+ strokeUnits: t.enum(STROKE_UNITS, {
42
+ description: "world: scales with the map. css-px: the same on screen at any zoom (widened per view, never rebuilt)."
43
+ }),
44
+ fill: t.color({
45
+ default: [1, 1, 1, 1],
46
+ description: "Fill color (linear), for closed shapes."
47
+ }),
48
+ fillOpacity: t.f32({ min: 0, max: 1, description: "Fill opacity. 0: no fill." }),
49
+ rev: t.u32({
50
+ description: "Revision of the geometry and stroke: the mesh is rebuilt only when it changes."
51
+ })
52
+ },
53
+ {
54
+ description: "A vector drawing on the table (0057): stroke and fill, tessellated once per rev, drawn in the drawings band (30) unless it has its own GroundLayer."
55
+ }
56
+ );
57
+ const VectorMaterial = defineMaterial("vector/VectorMaterial", {
58
+ extends: "none",
59
+ blend: "premultiplied",
60
+ fields: {
61
+ stroke: t.color({ default: [0, 0, 0, 1] }),
62
+ fill: t.color({ default: [1, 1, 1, 1] })
63
+ },
64
+ shader: "vector::shape",
65
+ description: "A vector shape's stroke and fill colors (0057). One per VectorShape."
66
+ });
67
+ const VECTOR_SHADERS = {
68
+ "vector::shape": `
69
+ import shard::pbr::types::VertexOutput;
70
+ import shard::view::view;
71
+ import shard::mesh::{ vertex_tangent, vertex_world };
72
+ import material::vector_material::VectorMaterial;
73
+
74
+ /** CSS-pixel strokes: offset (tangent xyz) \xD7 half width in CSS px (w) \xD7 this view's pixel size. */
75
+ override fn vertex_position(position: vec3f, normal: vec3f, uv: vec2f) -> vec3f {
76
+ let t = vertex_tangent();
77
+ if (t.w <= 0.0) { return position; }
78
+ let world = vertex_world(position);
79
+ var per_px = view.pixelScale.x * view.pixelScale.y;
80
+ if (view.pixelScale.z < 0.5) { per_px *= max(-(view.view * vec4f(world, 1.0)).z, 1e-4); }
81
+ // Offsets are in world units; undo the instance's (uniform) scale.
82
+ let scale = length(vertex_world(position + vec3f(1.0, 0.0, 0.0)) - world);
83
+ return position + t.xyz * (t.w * per_px / max(scale, 1e-6));
84
+ }
85
+
86
+ override fn shade(in: VertexOutput) -> vec4f {
87
+ let c = select(VectorMaterial.fill, VectorMaterial.stroke, in.uv.x > 0.5);
88
+ if (c.a <= 0.0) { discard; }
89
+ // Display-referred: the scene's exposure doesn't dim a drawing.
90
+ return vec4f(c.rgb * c.a / view.exposure, c.a);
91
+ }`
92
+ };
93
+ const VectorState = defineResource("vector/State", {
94
+ description: "Each VectorShape's mesh and material, and how many rebuilds ran.",
95
+ init: () => ({ shapes: /* @__PURE__ */ new Map(), removed: [], rebuilds: 0, pixelsPerUnit: 256 })
96
+ });
97
+ function materialValues(v) {
98
+ return {
99
+ stroke: [...v.stroke],
100
+ fill: [v.fill[0], v.fill[1], v.fill[2], v.fill[3] * v.fillOpacity]
101
+ };
102
+ }
103
+ const syncShapes = defineSystem({
104
+ name: "vector/sync",
105
+ description: "Rebuilds VectorShape meshes when their rev changes; keeps colors current.",
106
+ setup: (world) => ({ q: world.query({ with: [VectorShape] }), changed: [] }),
107
+ run: ({ q, changed }, world, ctx) => {
108
+ const state = world.resource(VectorState);
109
+ if (state.removed.length > 0) {
110
+ for (const e of state.removed) dropShape(world, state, e);
111
+ state.removed.length = 0;
112
+ }
113
+ const since = ctx.lastRunTick;
114
+ changed.length = 0;
115
+ for (const table of q.tables) {
116
+ if (table.lastChanged(VectorShape) <= since) continue;
117
+ const ticks = table.changedTicks(VectorShape);
118
+ for (let row = 0; row < table.count; row++)
119
+ if (ticks[row] > since) changed.push(table.entities[row]);
120
+ }
121
+ for (const e of changed) updateShape(world, state, e);
122
+ }
123
+ });
124
+ function updateShape(world, state, e) {
125
+ const v = world.get(e, VectorShape);
126
+ const filled = v.fillOpacity > 0 && v.fill[3] > 0;
127
+ let rec = state.shapes.get(e);
128
+ const needsMesh = !rec || rec.rev !== v.rev || rec.strokeWidth !== v.strokeWidth || rec.strokeUnits !== v.strokeUnits || rec.filled !== filled;
129
+ let data;
130
+ if (needsMesh) {
131
+ try {
132
+ const geometry = parseGeometry(v.geometry);
133
+ data = tessellate(
134
+ geometry,
135
+ { strokeWidth: v.strokeWidth, strokeUnits: v.strokeUnits, fill: filled },
136
+ { pixelsPerUnit: state.pixelsPerUnit }
137
+ );
138
+ state.rebuilds++;
139
+ } catch (err) {
140
+ if (!(err instanceof ShardError)) throw err;
141
+ world.tryResource(LogResource)?.log("warn", `VectorShape ${e}: ${err.message}`, {
142
+ code: err.code,
143
+ path: err.path,
144
+ hint: err.hint
145
+ });
146
+ return;
147
+ }
148
+ }
149
+ const materials = world.resource(Materials);
150
+ if (!rec) {
151
+ const material = materials.add(
152
+ new MaterialAsset(materialValues(v), VectorMaterial),
153
+ `vector:shape/${e}`
154
+ );
155
+ rec = {
156
+ mesh: void 0,
157
+ material,
158
+ rev: v.rev,
159
+ strokeWidth: v.strokeWidth,
160
+ strokeUnits: v.strokeUnits,
161
+ filled
162
+ };
163
+ state.shapes.set(e, rec);
164
+ world.add(e, MeshMaterial, { material });
165
+ if (!world.has(e, NotShadowCaster)) world.add(e, NotShadowCaster);
166
+ if (!world.has(e, GroundLayer)) world.add(e, GroundLayer, { band: GROUND_BANDS.drawings });
167
+ } else materials.get(rec.material)?.set(materialValues(v));
168
+ if (data) {
169
+ if (data.indices.length === 0) {
170
+ if (world.has(e, Mesh3d)) world.remove(e, Mesh3d);
171
+ } else {
172
+ const meshes = world.resource(Meshes);
173
+ if (rec.mesh) meshes.get(rec.mesh)?.update(data);
174
+ else rec.mesh = meshes.add(Mesh.create(data), `vector:shape/${e}`);
175
+ if (!world.has(e, Mesh3d)) world.add(e, Mesh3d, { mesh: rec.mesh });
176
+ }
177
+ }
178
+ rec.rev = v.rev;
179
+ rec.strokeWidth = v.strokeWidth;
180
+ rec.strokeUnits = v.strokeUnits;
181
+ rec.filled = filled;
182
+ }
183
+ function dropShape(world, state, e) {
184
+ const rec = state.shapes.get(e);
185
+ if (!rec) return;
186
+ state.shapes.delete(e);
187
+ const meshes = world.resource(Meshes);
188
+ const materials = world.resource(Materials);
189
+ const gpu = world.tryResource(GpuAssetsResource);
190
+ if (rec.mesh) {
191
+ const mesh = meshes.get(rec.mesh);
192
+ if (mesh) gpu?.releaseMesh(mesh);
193
+ meshes.delete(rec.mesh.guid);
194
+ }
195
+ const material = materials.get(rec.material);
196
+ if (material) gpu?.releaseMaterial(material);
197
+ materials.delete(rec.material.guid);
198
+ if (world.isAlive(e)) {
199
+ if (world.has(e, Mesh3d)) world.remove(e, Mesh3d);
200
+ if (world.has(e, MeshMaterial)) world.remove(e, MeshMaterial);
201
+ }
202
+ }
203
+ function observeShapeRemovals(world) {
204
+ world.observe(onRemove(VectorShape), ({ entity, world: w }) => {
205
+ w.tryResource(VectorState)?.removed.push(entity);
206
+ });
207
+ }
208
+ export {
209
+ STROKE_UNITS,
210
+ VECTOR_SHADERS,
211
+ VectorMaterial,
212
+ VectorShape,
213
+ VectorState,
214
+ observeShapeRemovals,
215
+ syncShapes
216
+ };
@@ -0,0 +1,56 @@
1
+ export type VectorGeometry = {
2
+ kind: 'pen';
3
+ points: [number, number][];
4
+ } | {
5
+ kind: 'line';
6
+ from: [number, number];
7
+ to: [number, number];
8
+ } | {
9
+ kind: 'rect';
10
+ width: number;
11
+ height: number;
12
+ } | {
13
+ kind: 'ellipse';
14
+ rx: number;
15
+ ry: number;
16
+ } | {
17
+ kind: 'cone';
18
+ length: number;
19
+ angle: number;
20
+ } | {
21
+ kind: 'polygon';
22
+ outer: [number, number][];
23
+ holes?: [number, number][][];
24
+ };
25
+ export type StrokeUnits = 'world' | 'css-px';
26
+ export interface VectorStyle {
27
+ /** Stroke width: world units, or CSS pixels. 0: no stroke. */
28
+ strokeWidth: number;
29
+ strokeUnits: StrokeUnits;
30
+ /** Whether to fill (closed shapes only). */
31
+ fill: boolean;
32
+ }
33
+ export interface TessellateOptions {
34
+ /**
35
+ * The densest zoom, in CSS pixels per world unit: curves are subdivided to a chord error of
36
+ * 0.5 CSS px there. Default 256.
37
+ */
38
+ pixelsPerUnit?: number;
39
+ }
40
+ /** Tessellated vertex and index data (a `MeshData` for `@aethervtt/shard-mesh`). */
41
+ export interface VectorMesh {
42
+ positions: Float32Array;
43
+ normals: Float32Array;
44
+ uvs: Float32Array;
45
+ tangents: Float32Array;
46
+ indices: Uint32Array;
47
+ }
48
+ /** Checks a geometry value (from a scene file or a host) and returns it typed. */
49
+ export declare function parseGeometry(value: unknown, path?: string): VectorGeometry;
50
+ /**
51
+ * Tessellates a shape into a mesh in its local XZ plane. Fills only closed shapes; pen strokes
52
+ * get round joins and caps, the rest mitred joins (butt caps on open lines).
53
+ */
54
+ export declare function tessellate(geometry: VectorGeometry, style: VectorStyle, options?: TessellateOptions): VectorMesh;
55
+ /** Area of the upward-facing triangles of a mesh (world units²): tests and stats. */
56
+ export declare function meshArea(mesh: VectorMesh, u?: number): number;
@@ -0,0 +1,379 @@
1
+ import { polygon, ShardError } from "@aethervtt/shard-core";
2
+ class Builder {
3
+ positions;
4
+ uvs;
5
+ tangents;
6
+ indices;
7
+ vertices = 0;
8
+ count = 0;
9
+ constructor(vertices, indices) {
10
+ this.positions = new Float32Array(Math.max(4, vertices) * 3);
11
+ this.uvs = new Float32Array(Math.max(4, vertices) * 2);
12
+ this.tangents = new Float32Array(Math.max(4, vertices) * 4);
13
+ this.indices = new Uint32Array(Math.max(6, indices));
14
+ }
15
+ vertex(x, z, u, tx, tz, tw) {
16
+ const i = this.vertices;
17
+ if ((i + 1) * 3 > this.positions.length) this.grow();
18
+ this.positions[i * 3] = x;
19
+ this.positions[i * 3 + 2] = z;
20
+ this.uvs[i * 2] = u;
21
+ this.tangents[i * 4] = tx;
22
+ this.tangents[i * 4 + 2] = tz;
23
+ this.tangents[i * 4 + 3] = tw;
24
+ this.vertices = i + 1;
25
+ return i;
26
+ }
27
+ /** Where a vertex ends up: CSS-pixel strokes widen along their tangent (any scale will do). */
28
+ x(i) {
29
+ return this.positions[i * 3] + this.tangents[i * 4] * this.tangents[i * 4 + 3];
30
+ }
31
+ z(i) {
32
+ return this.positions[i * 3 + 2] + this.tangents[i * 4 + 2] * this.tangents[i * 4 + 3];
33
+ }
34
+ /**
35
+ * A triangle facing up (+y): CCW seen from above is clockwise in (x, z). CSS-pixel strokes are
36
+ * wound by where their vertices end up once widened, not their shared centreline.
37
+ */
38
+ triangle(a, b, c) {
39
+ if (this.count + 3 > this.indices.length) {
40
+ const next = new Uint32Array(this.indices.length * 2);
41
+ next.set(this.indices);
42
+ this.indices = next;
43
+ }
44
+ const ax = this.x(a);
45
+ const az = this.z(a);
46
+ const cross = (this.x(b) - ax) * (this.z(c) - az) - (this.x(c) - ax) * (this.z(b) - az);
47
+ this.indices[this.count++] = a;
48
+ if (cross > 0) {
49
+ this.indices[this.count++] = c;
50
+ this.indices[this.count++] = b;
51
+ } else {
52
+ this.indices[this.count++] = b;
53
+ this.indices[this.count++] = c;
54
+ }
55
+ }
56
+ grow() {
57
+ const n = this.positions.length / 3;
58
+ const p = new Float32Array(n * 6);
59
+ p.set(this.positions);
60
+ this.positions = p;
61
+ const u = new Float32Array(n * 4);
62
+ u.set(this.uvs);
63
+ this.uvs = u;
64
+ const t = new Float32Array(n * 8);
65
+ t.set(this.tangents);
66
+ this.tangents = t;
67
+ }
68
+ finish() {
69
+ const n = this.vertices;
70
+ const normals = new Float32Array(n * 3);
71
+ for (let i = 0; i < n; i++) normals[i * 3 + 1] = 1;
72
+ return {
73
+ positions: this.positions.slice(0, n * 3),
74
+ normals,
75
+ uvs: this.uvs.slice(0, n * 2),
76
+ tangents: this.tangents.slice(0, n * 4),
77
+ indices: this.indices.slice(0, this.count)
78
+ };
79
+ }
80
+ }
81
+ function invalid(message, path) {
82
+ return new ShardError("vector/invalid-geometry", message, {
83
+ path,
84
+ hint: "geometry is { kind: 'pen' | 'line' | 'rect' | 'ellipse' | 'cone' | 'polygon', ... } in local (x, z) units."
85
+ });
86
+ }
87
+ function point(value, path) {
88
+ if (!Array.isArray(value) || value.length !== 2 || !Number.isFinite(value[0]) || !Number.isFinite(value[1]))
89
+ throw invalid("Expected a point [x, z]", path);
90
+ return value;
91
+ }
92
+ function points(value, path, min) {
93
+ if (!Array.isArray(value) || value.length < min)
94
+ throw invalid(`Expected at least ${min} points`, path);
95
+ for (let i = 0; i < value.length; i++) point(value[i], `${path}/${i}`);
96
+ return value;
97
+ }
98
+ function num(value, path) {
99
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
100
+ throw invalid("Expected a non-negative number", path);
101
+ return value;
102
+ }
103
+ function parseGeometry(value, path = "/geometry") {
104
+ if (!value || typeof value !== "object") throw invalid("Expected a geometry object", path);
105
+ const g = value;
106
+ switch (g.kind) {
107
+ case "pen":
108
+ return { kind: "pen", points: points(g.points, `${path}/points`, 1) };
109
+ case "line":
110
+ return { kind: "line", from: point(g.from, `${path}/from`), to: point(g.to, `${path}/to`) };
111
+ case "rect":
112
+ return {
113
+ kind: "rect",
114
+ width: num(g.width, `${path}/width`),
115
+ height: num(g.height, `${path}/height`)
116
+ };
117
+ case "ellipse":
118
+ return { kind: "ellipse", rx: num(g.rx, `${path}/rx`), ry: num(g.ry, `${path}/ry`) };
119
+ case "cone":
120
+ return {
121
+ kind: "cone",
122
+ length: num(g.length, `${path}/length`),
123
+ angle: num(g.angle, `${path}/angle`)
124
+ };
125
+ case "polygon": {
126
+ const holes = g.holes === void 0 ? [] : g.holes;
127
+ if (!Array.isArray(holes)) throw invalid("Expected a list of holes", `${path}/holes`);
128
+ return {
129
+ kind: "polygon",
130
+ outer: points(g.outer, `${path}/outer`, 3),
131
+ holes: holes.map((h, i) => points(h, `${path}/holes/${i}`, 3))
132
+ };
133
+ }
134
+ default:
135
+ throw invalid(`Unknown geometry kind ${JSON.stringify(g.kind)}`, `${path}/kind`);
136
+ }
137
+ }
138
+ function arcSegments(r, angle, error) {
139
+ if (r <= error) return Math.max(3, Math.ceil(angle / (Math.PI / 2)));
140
+ const step = 2 * Math.acos(1 - error / r);
141
+ return Math.max(3, Math.min(1024, Math.ceil(angle / step)));
142
+ }
143
+ function outline(g, error) {
144
+ switch (g.kind) {
145
+ case "pen":
146
+ return { rings: [g.points], closed: false };
147
+ case "line":
148
+ return { rings: [[g.from, g.to]], closed: false };
149
+ case "rect":
150
+ return {
151
+ rings: [
152
+ [
153
+ [0, 0],
154
+ [g.width, 0],
155
+ [g.width, g.height],
156
+ [0, g.height]
157
+ ]
158
+ ],
159
+ closed: true
160
+ };
161
+ case "ellipse": {
162
+ const n = arcSegments(Math.max(g.rx, g.ry), Math.PI * 2, error);
163
+ const ring = [];
164
+ for (let i = 0; i < n; i++) {
165
+ const a = i / n * Math.PI * 2;
166
+ ring.push([Math.cos(a) * g.rx, Math.sin(a) * g.ry]);
167
+ }
168
+ return { rings: [ring], closed: true };
169
+ }
170
+ case "cone": {
171
+ const half = Math.min(360, g.angle) * Math.PI / 360;
172
+ const n = arcSegments(g.length, half * 2, error);
173
+ const ring = [[0, 0]];
174
+ for (let i = 0; i <= n; i++) {
175
+ const a = -half + i / n * half * 2;
176
+ ring.push([Math.cos(a) * g.length, Math.sin(a) * g.length]);
177
+ }
178
+ return { rings: [ring], closed: true };
179
+ }
180
+ case "polygon":
181
+ return { rings: [g.outer, ...g.holes ?? []], closed: true };
182
+ }
183
+ }
184
+ function fillRings(b, rings) {
185
+ let total = 0;
186
+ for (const r of rings) total += r.length;
187
+ const flat = new Float64Array(total * 2);
188
+ const holes = [];
189
+ let k = 0;
190
+ for (let i = 0; i < rings.length; i++) {
191
+ if (i > 0) holes.push(k);
192
+ for (const [x, z] of rings[i]) {
193
+ flat[k * 2] = x;
194
+ flat[k * 2 + 1] = z;
195
+ k++;
196
+ }
197
+ }
198
+ const first = b.vertices;
199
+ for (let i = 0; i < total; i++) b.vertex(flat[i * 2], flat[i * 2 + 1], 0, 0, 0, 0);
200
+ const tris = polygon.triangulate(flat, holes);
201
+ for (let i = 0; i < tris.length; i += 3)
202
+ b.triangle(first + tris[i], first + tris[i + 1], first + tris[i + 2]);
203
+ }
204
+ const MITER_LIMIT = 4;
205
+ function strokePath(b, pts, closed, half, css, round, error) {
206
+ const path = [];
207
+ for (const p of pts) {
208
+ const last = path[path.length - 1];
209
+ if (!last || last[0] !== p[0] || last[1] !== p[1]) path.push(p);
210
+ }
211
+ if (closed && path.length > 2) {
212
+ const a = path[0];
213
+ const z = path[path.length - 1];
214
+ if (a[0] === z[0] && a[1] === z[1]) path.pop();
215
+ }
216
+ const n = path.length;
217
+ const put = (x, z, ox, oz) => css ? b.vertex(x, z, 1, ox, oz, half) : b.vertex(x + ox * half, z + oz * half, 1, 0, 0, 0);
218
+ if (n === 1) {
219
+ if (round) roundDot(b, path[0], half, css, error);
220
+ return;
221
+ }
222
+ const segments = closed ? n : n - 1;
223
+ const nx = new Float64Array(segments);
224
+ const nz = new Float64Array(segments);
225
+ for (let s = 0; s < segments; s++) {
226
+ const a = path[s];
227
+ const c = path[(s + 1) % n];
228
+ const dx = c[0] - a[0];
229
+ const dz = c[1] - a[1];
230
+ const len = Math.sqrt(dx * dx + dz * dz) || 1;
231
+ nx[s] = -dz / len;
232
+ nz[s] = dx / len;
233
+ }
234
+ if (round) {
235
+ for (let s = 0; s < segments; s++) {
236
+ const a = path[s];
237
+ const c = path[(s + 1) % n];
238
+ const l0 = put(a[0], a[1], nx[s], nz[s]);
239
+ const r0 = put(a[0], a[1], -nx[s], -nz[s]);
240
+ const l1 = put(c[0], c[1], nx[s], nz[s]);
241
+ const r1 = put(c[0], c[1], -nx[s], -nz[s]);
242
+ b.triangle(l0, r0, l1);
243
+ b.triangle(r0, r1, l1);
244
+ }
245
+ const joins = closed ? n : n - 2;
246
+ for (let j = 0; j < joins; j++) {
247
+ const v = closed ? j : j + 1;
248
+ const s0 = (v - 1 + segments) % segments;
249
+ const s1 = v % segments;
250
+ const a0 = Math.atan2(nz[s0], nx[s0]);
251
+ let a1 = Math.atan2(nz[s1], nx[s1]);
252
+ let da = a1 - a0;
253
+ while (da > Math.PI) da -= Math.PI * 2;
254
+ while (da < -Math.PI) da += Math.PI * 2;
255
+ a1 = a0 + da;
256
+ const side = da > 0 ? -1 : 1;
257
+ wedge(
258
+ b,
259
+ path[v],
260
+ half,
261
+ css,
262
+ a0 + (side < 0 ? Math.PI : 0),
263
+ a1 + (side < 0 ? Math.PI : 0),
264
+ error
265
+ );
266
+ }
267
+ if (!closed) {
268
+ const a0 = Math.atan2(nz[0], nx[0]);
269
+ wedge(b, path[0], half, css, a0, a0 + Math.PI, error);
270
+ const e = segments - 1;
271
+ const a1 = Math.atan2(nz[e], nx[e]);
272
+ wedge(b, path[n - 1], half, css, a1 + Math.PI, a1 + Math.PI * 2, error);
273
+ }
274
+ return;
275
+ }
276
+ let firstL = -1;
277
+ let firstR = -1;
278
+ let prevL = -1;
279
+ let prevR = -1;
280
+ for (let i = 0; i < n; i++) {
281
+ let ox;
282
+ let oz;
283
+ const s0 = i - 1;
284
+ const s1 = i;
285
+ const has0 = closed || s0 >= 0;
286
+ const has1 = closed || s1 < segments;
287
+ if (has0 && has1) {
288
+ const a = (s0 + segments) % segments;
289
+ const c = s1 % segments;
290
+ const mx = nx[a] + nx[c];
291
+ const mz = nz[a] + nz[c];
292
+ const ml = Math.sqrt(mx * mx + mz * mz);
293
+ if (ml < 1e-9) {
294
+ ox = nx[c];
295
+ oz = nz[c];
296
+ } else {
297
+ const ux = mx / ml;
298
+ const uz = mz / ml;
299
+ const scale = Math.min(MITER_LIMIT, 1 / Math.max(1e-6, ux * nx[c] + uz * nz[c]));
300
+ ox = ux * scale;
301
+ oz = uz * scale;
302
+ }
303
+ } else if (has1) {
304
+ ox = nx[s1];
305
+ oz = nz[s1];
306
+ } else {
307
+ ox = nx[s0];
308
+ oz = nz[s0];
309
+ }
310
+ const p = path[i];
311
+ const l = put(p[0], p[1], ox, oz);
312
+ const r = put(p[0], p[1], -ox, -oz);
313
+ if (i === 0) {
314
+ firstL = l;
315
+ firstR = r;
316
+ } else {
317
+ b.triangle(prevL, prevR, l);
318
+ b.triangle(prevR, r, l);
319
+ }
320
+ prevL = l;
321
+ prevR = r;
322
+ }
323
+ if (closed) {
324
+ b.triangle(prevL, prevR, firstL);
325
+ b.triangle(prevR, firstR, firstL);
326
+ }
327
+ }
328
+ function wedge(b, c, half, css, a0, a1, error) {
329
+ const span = Math.abs(a1 - a0);
330
+ if (span < 1e-6) return;
331
+ const steps = arcSegments(half, span, css ? 0.5 : error);
332
+ const centre = css ? b.vertex(c[0], c[1], 1, 0, 0, half) : b.vertex(c[0], c[1], 1, 0, 0, 0);
333
+ let prev = -1;
334
+ for (let i = 0; i <= steps; i++) {
335
+ const a = a0 + (a1 - a0) * i / steps;
336
+ const ox = Math.cos(a);
337
+ const oz = Math.sin(a);
338
+ const v = css ? b.vertex(c[0], c[1], 1, ox, oz, half) : b.vertex(c[0] + ox * half, c[1] + oz * half, 1, 0, 0, 0);
339
+ if (prev >= 0) b.triangle(centre, prev, v);
340
+ prev = v;
341
+ }
342
+ }
343
+ function roundDot(b, c, half, css, error) {
344
+ wedge(b, c, half, css, 0, Math.PI * 2, error);
345
+ }
346
+ function tessellate(geometry, style, options = {}) {
347
+ const error = 0.5 / (options.pixelsPerUnit ?? 256);
348
+ const { rings, closed } = outline(geometry, error);
349
+ let estimate = 0;
350
+ for (const r of rings) estimate += r.length;
351
+ const b = new Builder(estimate * 6 + 16, estimate * 12 + 24);
352
+ if (style.fill && closed) fillRings(b, rings);
353
+ if (style.strokeWidth > 0) {
354
+ const css = style.strokeUnits === "css-px";
355
+ const half = style.strokeWidth / 2;
356
+ const round = geometry.kind === "pen";
357
+ for (const ring of rings) strokePath(b, ring, closed, half, css, round, error);
358
+ }
359
+ return b.finish();
360
+ }
361
+ function meshArea(mesh, u) {
362
+ let area = 0;
363
+ const p = mesh.positions;
364
+ for (let i = 0; i < mesh.indices.length; i += 3) {
365
+ const a = mesh.indices[i];
366
+ const b = mesh.indices[i + 1];
367
+ const c = mesh.indices[i + 2];
368
+ if (u !== void 0 && mesh.uvs[a * 2] !== u) continue;
369
+ area += Math.abs(
370
+ (p[b * 3] - p[a * 3]) * (p[c * 3 + 2] - p[a * 3 + 2]) - (p[c * 3] - p[a * 3]) * (p[b * 3 + 2] - p[a * 3 + 2])
371
+ ) / 2;
372
+ }
373
+ return area;
374
+ }
375
+ export {
376
+ meshArea,
377
+ parseGeometry,
378
+ tessellate
379
+ };
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-vector",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./tessellate": {
13
+ "types": "./dist/tessellate.d.ts",
14
+ "default": "./dist/tessellate.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "dependencies": {
23
+ "@aethervtt/shard-core": "0.1.0",
24
+ "@aethervtt/shard-mesh": "0.1.0",
25
+ "@aethervtt/shard-render": "0.1.0",
26
+ "@aethervtt/shard-runtime": "0.1.0",
27
+ "@aethervtt/shard-transform": "0.1.0"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/aethervtt/shard.git",
32
+ "directory": "packages/vector"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": true
37
+ },
38
+ "shard": {
39
+ "buildSha": "8e14c9896f82a3554b156f1ffdd68f2cc3e7d78a"
40
+ }
41
+ }