vgpu-render 0.2.0 → 0.3.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.
@@ -9,6 +9,10 @@
9
9
  *
10
10
  * See `camera-2d.ts` for what `world2d` and `screen` mean.
11
11
  */
12
+ import { gridLines2D } from "./geometry-generators.js";
13
+ import { sampleCurve2D } from "./curve.js";
14
+ const WIRE = { filled: false };
15
+ const SOLID = { filled: true };
12
16
  const DEFAULT_2D_COLOR = [1, 1, 1, 1];
13
17
  const DEFAULT_2D_LAYER = 1;
14
18
  /** An unfilled shape still has to be visible, so a stroke defaults to one pixel. */
@@ -47,6 +51,21 @@ function angleRange(options) {
47
51
  }
48
52
  return { startAngle, sweep };
49
53
  }
54
+ /** `[visible, gap, phase]` of a dashed outline; a dash needs a real gap to break the stroke. */
55
+ function dashValue(value) {
56
+ const visible = finite(value[0], "dash visible");
57
+ const gap = finite(value[1], "dash gap");
58
+ const phase = finite(value[2], "dash phase");
59
+ if (visible <= 0)
60
+ throw new RangeError("dash visible must be greater than zero");
61
+ if (gap <= 0)
62
+ throw new RangeError("dash gap must be greater than zero");
63
+ return [visible, gap, phase];
64
+ }
65
+ /** A dashed outline: the pattern is validated once, here. */
66
+ function dashed(dash) {
67
+ return { filled: false, dash: dashValue(dash) };
68
+ }
50
69
  function vec2(value, name) {
51
70
  if (value.length !== 2) {
52
71
  throw new RangeError(`${name} must contain two values`);
@@ -127,7 +146,86 @@ export class FrameDrawList2D {
127
146
  });
128
147
  return this;
129
148
  }
149
+ /** Straight segment (the 3D `draw_line`). */
130
150
  line(start, end, options = {}) {
151
+ return this.#line(start, end, options, undefined);
152
+ }
153
+ /** Straight segment dashed along its length (the 3D `draw_line_d`). */
154
+ line_d(start, end, dash, options = {}) {
155
+ return this.#line(start, end, options, dashValue(dash));
156
+ }
157
+ /**
158
+ * Arrow with an open V head, the 2D twin of `FrameDrawList.arrow`: the shaft runs to `origin +
159
+ * direction`, and the head is two lines back from the tip.
160
+ */
161
+ arrow(origin, direction, options = {}) {
162
+ const { end, left, right } = this.#arrowParts(origin, direction, options);
163
+ // The open head is an outline, so the shaft runs all the way to the tip and the two head lines
164
+ // sit on top of it.
165
+ this.line(origin, end, options);
166
+ this.line(end, left, options);
167
+ this.line(end, right, options);
168
+ return this;
169
+ }
170
+ /** Arrow with a filled triangular head (`arrow` keeps the open line head). */
171
+ arrow_s(origin, direction, options = {}) {
172
+ const { end, base, left, right } = this.#arrowParts(origin, direction, options);
173
+ this.line(origin, base, options);
174
+ this.triangle_s(end, left, right, {
175
+ ...(options.color === undefined ? {} : { color: options.color }),
176
+ ...(options.space === undefined ? {} : { space: options.space }),
177
+ ...(options.layer === undefined ? {} : { layer: options.layer }),
178
+ });
179
+ return this;
180
+ }
181
+ /** Arrow whose shaft is dashed; the two head lines stay solid like the filled head. */
182
+ arrow_d(origin, direction, dash, options = {}) {
183
+ const { end, left, right } = this.#arrowParts(origin, direction, options);
184
+ this.line_d(origin, end, dashValue(dash), options);
185
+ // Only the shaft dashes: the head outline stays solid so the tip keeps its shape, matching the
186
+ // filled head of `arrow_s_d`.
187
+ this.line(end, left, options);
188
+ this.line(end, right, options);
189
+ return this;
190
+ }
191
+ /** Arrow with a filled head and a dashed shaft. */
192
+ arrow_s_d(origin, direction, dash, options = {}) {
193
+ const { end, base, left, right } = this.#arrowParts(origin, direction, options);
194
+ this.line_d(origin, base, dashValue(dash), options);
195
+ this.triangle_s(end, left, right, {
196
+ ...(options.color === undefined ? {} : { color: options.color }),
197
+ ...(options.space === undefined ? {} : { space: options.space }),
198
+ ...(options.layer === undefined ? {} : { layer: options.layer }),
199
+ });
200
+ return this;
201
+ }
202
+ #arrowParts(origin, direction, options) {
203
+ const from = vec2(origin, "origin");
204
+ const delta = vec2(direction, "direction");
205
+ const length = Math.hypot(delta[0], delta[1]);
206
+ const end = [from[0] + delta[0], from[1] + delta[1]];
207
+ const unit = length > 0 ? [delta[0] / length, delta[1] / length] : [1, 0];
208
+ const headLength = finite(options.headLength ?? Math.min(14, Math.max(6, length * 0.25)), "headLength");
209
+ const headAngle = finite(options.headAngle ?? Math.PI / 6, "headAngle");
210
+ const back = Math.cos(headAngle) * headLength;
211
+ const spread = Math.sin(headAngle) * headLength;
212
+ const side = [-unit[1], unit[0]];
213
+ return {
214
+ end,
215
+ base: [end[0] - unit[0] * back, end[1] - unit[1] * back],
216
+ left: [end[0] - unit[0] * back + side[0] * spread, end[1] - unit[1] * back + side[1] * spread],
217
+ right: [end[0] - unit[0] * back - side[0] * spread, end[1] - unit[1] * back - side[1] * spread],
218
+ back,
219
+ };
220
+ }
221
+ ray(origin, direction, options = {}) {
222
+ return this.#line(origin, [origin[0] + direction[0], origin[1] + direction[1]], options, undefined);
223
+ }
224
+ /** Ray dashed along its length. */
225
+ ray_d(origin, direction, dash, options = {}) {
226
+ return this.#line(origin, [origin[0] + direction[0], origin[1] + direction[1]], options, dashValue(dash));
227
+ }
228
+ #line(start, end, options, dashArgument) {
131
229
  this.#assertOpen();
132
230
  const resolvedStart = vec2(start, "start");
133
231
  const resolvedEnd = vec2(end, "end");
@@ -137,7 +235,7 @@ export class FrameDrawList2D {
137
235
  const width = finite(options.width ?? this.#defaults.line.width ?? 1, "width");
138
236
  if (width <= 0)
139
237
  throw new RangeError("width must be greater than zero");
140
- const dash = options.dash ?? this.#defaults.line.dash;
238
+ const dash = dashArgument ?? this.#defaults.line.dash;
141
239
  const joint = options.joint ?? this.#defaults.line.joint;
142
240
  this.#lines.push({
143
241
  kind: "line2d",
@@ -154,23 +252,31 @@ export class FrameDrawList2D {
154
252
  });
155
253
  return this;
156
254
  }
157
- ray(origin, direction, options = {}) {
158
- return this.line(origin, [origin[0] + direction[0], origin[1] + direction[1]], options);
159
- }
160
255
  /**
161
256
  * Path of segments, the 2D twin of `FrameDrawList.lineStrip`. Dash distance accumulates over
162
257
  * the whole path and an open strip joins only its interior vertices, matching the 3D layer.
163
258
  */
164
259
  lineStrip(points, options = {}, closed = false) {
260
+ return this.#strip(points, options, undefined, closed);
261
+ }
262
+ /** Open strip dashed along its length. */
263
+ lineStrip_d(points, dash, options = {}, closed = false) {
264
+ return this.#strip(points, options, dashValue(dash), closed);
265
+ }
266
+ /** Closed strip: the last point joins the first (the 3D `draw_line_loop`). */
267
+ lineLoop(points, options = {}) {
268
+ return this.#strip(points, options, undefined, true);
269
+ }
270
+ /** Closed strip dashed along its length. */
271
+ lineLoop_d(points, dash, options = {}) {
272
+ return this.#strip(points, options, dashValue(dash), true);
273
+ }
274
+ #strip(points, options, dash, closed) {
165
275
  this.#assertOpen();
166
- const dash = options.dash ?? this.#defaults.line.dash;
167
276
  const vertexDistances = [0];
168
277
  let traveled = 0;
169
278
  const segment = (a, b) => {
170
- this.line(a, b, {
171
- ...options,
172
- ...(dash === undefined ? {} : { dash: [dash[0], dash[1], dash[2] + traveled] }),
173
- });
279
+ this.#line(a, b, options, dash === undefined ? undefined : [dash[0], dash[1], dash[2] + traveled]);
174
280
  traveled += Math.hypot(b[0] - a[0], b[1] - a[1]);
175
281
  vertexDistances.push(traveled);
176
282
  };
@@ -192,90 +298,233 @@ export class FrameDrawList2D {
192
298
  polyline(points, options = {}, closed = false) {
193
299
  return this.lineStrip(points, options, closed);
194
300
  }
301
+ polyline_d(points, dash, options = {}, closed = false) {
302
+ return this.lineStrip_d(points, dash, options, closed);
303
+ }
304
+ /**
305
+ * Cubic curve sampled into a polyline, the 2D twin of `FrameDrawList.curve`. The curve's own
306
+ * `closed` flag decides whether the last point joins the first.
307
+ */
308
+ curve(curve, options = {}) {
309
+ const { tolerance, transform, ...lineOptions } = options;
310
+ return this.#strip(sampleCurve2D(curve, tolerance, transform), lineOptions, undefined, curve.closed ?? false);
311
+ }
312
+ /** Cubic curve sampled into a dashed polyline. */
313
+ curve_d(curve, dash, options = {}) {
314
+ const { tolerance, transform, ...lineOptions } = options;
315
+ return this.#strip(sampleCurve2D(curve, tolerance, transform), lineOptions, dashValue(dash), curve.closed ?? false);
316
+ }
195
317
  /**
196
318
  * Axis-aligned grid of lines, the 2D twin of `FrameDrawList.grid` and reference `grid_2d`: `cells`
197
319
  * are the cells per axis, so the drawn line count is one more on each side.
198
320
  */
199
321
  grid(center, cells, spacing, options = {}) {
322
+ return this.#grid(center, cells, spacing, options, undefined);
323
+ }
324
+ /** Grid whose lines are dashed along their length. */
325
+ grid_d(center, cells, spacing, dash, options = {}) {
326
+ return this.#grid(center, cells, spacing, options, dashValue(dash));
327
+ }
328
+ #grid(center, cells, spacing, options, dash) {
200
329
  this.#assertOpen();
330
+ const { rotation = 0, skew = [0, 0], outer = "none", ...lineOptions } = options;
201
331
  const [columns, rows] = cells;
202
332
  if (!Number.isInteger(columns) || !Number.isInteger(rows) || columns < 1 || rows < 1) {
203
333
  throw new RangeError("grid cells must be positive integers");
204
334
  }
205
335
  const [stepX, stepY] = positiveSize(spacing, "spacing");
206
336
  const [centerX, centerY] = vec2(center, "center");
207
- const halfWidth = (columns * stepX) / 2;
208
- const halfHeight = (rows * stepY) / 2;
209
- for (let column = 0; column <= columns; column++) {
210
- const x = centerX - halfWidth + column * stepX;
211
- this.line([x, centerY - halfHeight], [x, centerY + halfHeight], options);
337
+ const angle = finite(rotation, "rotation");
338
+ const [skewX, skewY] = skew;
339
+ finite(skewX, "skew x");
340
+ finite(skewY, "skew y");
341
+ if (outer !== "none" && outer !== "x" && outer !== "y" && outer !== "xy") {
342
+ throw new RangeError('grid outer must be "none", "x", "y" or "xy"');
212
343
  }
213
- for (let row = 0; row <= rows; row++) {
214
- const y = centerY - halfHeight + row * stepY;
215
- this.line([centerX - halfWidth, y], [centerX + halfWidth, y], options);
344
+ for (const [a, b] of gridLines2D([columns, rows], [stepX, stepY], [centerX, centerY], angle, [skewX, skewY], outer)) {
345
+ this.#line(a, b, lineOptions, dash);
216
346
  }
217
347
  return this;
218
348
  }
219
- /** Rectangle with the given full width and height (reference `Rectangle::new`). */
349
+ /**
350
+ * Rectangle with the given full width and height, stroked by default like the 3D `draw_rect`.
351
+ * `rect_s` fills it and `rect_d` dashes the outline.
352
+ */
220
353
  rect(center, size, options = {}) {
221
- const full = positiveSize(size, "size");
222
- this.#pushShape("rect", center, { size: [full[0] / 2, full[1] / 2] }, options);
354
+ this.#rectVariant(center, size, options, WIRE);
223
355
  return this;
224
356
  }
225
- /** Rectangle with rounded corners; the shader clamps the radius to half the shorter side. */
226
- roundedRect(center, size, cornerRadius, options = {}) {
357
+ /** Solid rectangle (reference `Rectangle`, the 3D `draw_rect_s`). */
358
+ rect_s(center, size, options = {}) {
359
+ this.#rectVariant(center, size, options, SOLID);
360
+ return this;
361
+ }
362
+ /**
363
+ * Rectangle outline dashed along its perimeter (the 3D `draw_rect_d`). `dash` is
364
+ * `[visible, gap, phase]` in shape-space units, the same triple the line layer takes.
365
+ */
366
+ rect_d(center, size, dash, options = {}) {
367
+ this.#rectVariant(center, size, options, dashed(dash));
368
+ return this;
369
+ }
370
+ #rectVariant(center, size, options, variant) {
227
371
  const full = positiveSize(size, "size");
228
- this.#pushShape("rounded-rect", center, { size: [full[0] / 2, full[1] / 2], cornerRadius: nonNegative(cornerRadius, "cornerRadius") }, options);
372
+ this.#pushShape("rect", center, { size: [full[0] / 2, full[1] / 2] }, options, variant);
373
+ }
374
+ /** Rounded rectangle outline; the shader clamps the radius to half the shorter side. */
375
+ rect_r(center, size, cornerRadius, options = {}) {
376
+ this.#rectRVariant(center, size, cornerRadius, options, WIRE);
377
+ return this;
378
+ }
379
+ /** Solid rounded rectangle (reference `RoundedRectangle`, the 3D `_s` counterpart). */
380
+ rect_r_s(center, size, cornerRadius, options = {}) {
381
+ this.#rectRVariant(center, size, cornerRadius, options, SOLID);
382
+ return this;
383
+ }
384
+ /** Rounded rectangle outline dashed along its perimeter. */
385
+ rect_r_d(center, size, cornerRadius, dash, options = {}) {
386
+ this.#rectRVariant(center, size, cornerRadius, options, dashed(dash));
229
387
  return this;
230
388
  }
231
- /** Filled disc with the given radius (reference `Circle::new`). */
389
+ #rectRVariant(center, size, cornerRadius, options, variant) {
390
+ const full = positiveSize(size, "size");
391
+ this.#pushShape("rounded-rect", center, { size: [full[0] / 2, full[1] / 2], cornerRadius: nonNegative(cornerRadius, "cornerRadius") }, options, variant);
392
+ }
393
+ /** Disc outline with the given radius (reference `Circle`, the 3D `draw_circle`). */
232
394
  circle(center, radius, options = {}) {
233
- const r = positive(radius, "radius");
234
- this.#pushShape("circle", center, { size: [r, r] }, options);
395
+ this.#circleVariant(center, radius, options, WIRE);
396
+ return this;
397
+ }
398
+ /** Solid disc (reference `Circle::new`, the 3D `draw_circle_s`). */
399
+ circle_s(center, radius, options = {}) {
400
+ this.#circleVariant(center, radius, options, SOLID);
235
401
  return this;
236
402
  }
237
- /** Ellipse with independent x and y radii (reference `Ellipse::new(x_radius, y_radius)`). */
403
+ /** Dashed circle outline. */
404
+ circle_d(center, radius, dash, options = {}) {
405
+ this.#circleVariant(center, radius, options, dashed(dash));
406
+ return this;
407
+ }
408
+ #circleVariant(center, radius, options, variant) {
409
+ const r = positive(radius, "radius");
410
+ this.#pushShape("circle", center, { size: [r, r] }, options, variant);
411
+ }
412
+ /** Ellipse outline with independent x and y radii (reference `Ellipse`). */
238
413
  ellipse(center, radii, options = {}) {
239
- const r = positiveSize(radii, "radii");
240
- this.#pushShape("ellipse", center, { size: r }, options);
414
+ this.#ellipseVariant(center, radii, options, WIRE);
415
+ return this;
416
+ }
417
+ /** Solid ellipse (reference `Ellipse::new`, the 3D `_s` counterpart). */
418
+ ellipse_s(center, radii, options = {}) {
419
+ this.#ellipseVariant(center, radii, options, SOLID);
241
420
  return this;
242
421
  }
422
+ /** Dashed ellipse outline. */
423
+ ellipse_d(center, radii, dash, options = {}) {
424
+ this.#ellipseVariant(center, radii, options, dashed(dash));
425
+ return this;
426
+ }
427
+ #ellipseVariant(center, radii, options, variant) {
428
+ const r = positiveSize(radii, "radii");
429
+ this.#pushShape("ellipse", center, { size: r }, options, variant);
430
+ }
243
431
  /**
244
- * Circular arc from `startAngle` through `sweep`. It is a curve without an interior, so an
245
- * unfilled arc (the default) strokes it with `strokeWidth` while `fill: true` leaves a hairline.
432
+ * Circular arc from `startAngle` through `sweep`. It is a curve without an interior, so it is
433
+ * only ever stroked; `arc_s` would have nothing to fill.
246
434
  */
247
435
  arc(center, radius, options = {}) {
436
+ this.#arcVariant(center, radius, options, WIRE);
437
+ return this;
438
+ }
439
+ /** Arc outline dashed along its sweep. */
440
+ arc_d(center, radius, dash, options = {}) {
441
+ this.#arcVariant(center, radius, options, dashed(dash));
442
+ return this;
443
+ }
444
+ #arcVariant(center, radius, options, variant) {
248
445
  const r = positive(radius, "radius");
249
446
  const angles = angleRange(options);
250
- this.#pushShape("arc", center, { size: [r, r], ...angles }, options);
251
- return this;
447
+ this.#pushShape("arc", center, { size: [r, r], ...angles }, options, variant);
252
448
  }
253
- /** Ring band between two radii (reference `Annulus::new(inner_radius, outer_radius)`). */
449
+ /** Ring outline between two radii (reference `Annulus`). */
254
450
  annulus(center, innerRadius, outerRadius, options = {}) {
451
+ this.#annulusVariant(center, innerRadius, outerRadius, options, WIRE);
452
+ return this;
453
+ }
454
+ /** Filled ring band (reference `Annulus::new`, the 3D `_s` counterpart). */
455
+ annulus_s(center, innerRadius, outerRadius, options = {}) {
456
+ this.#annulusVariant(center, innerRadius, outerRadius, options, SOLID);
457
+ return this;
458
+ }
459
+ /** Dashed annulus outline; both boundaries are stroked. */
460
+ annulus_d(center, innerRadius, outerRadius, dash, options = {}) {
461
+ this.#annulusVariant(center, innerRadius, outerRadius, options, dashed(dash));
462
+ return this;
463
+ }
464
+ #annulusVariant(center, innerRadius, outerRadius, options, variant) {
255
465
  const inner = nonNegative(innerRadius, "innerRadius");
256
466
  const outer = positive(outerRadius, "outerRadius");
257
467
  if (inner >= outer)
258
468
  throw new RangeError("innerRadius must be smaller than outerRadius");
259
- this.#pushShape("annulus", center, { size: [outer, inner] }, options);
260
- return this;
469
+ this.#pushShape("annulus", center, { size: [outer, inner] }, options, variant);
261
470
  }
262
- /** Capsule whose straight section is `length` long along y (reference `Capsule2d::new(radius, length)`). */
471
+ /** Capsule outline whose straight section is `length` long along y (reference `Capsule2d`). */
263
472
  capsule(center, radius, length, options = {}) {
473
+ this.#capsuleVariant(center, radius, length, options, WIRE);
474
+ return this;
475
+ }
476
+ /** Solid capsule (reference `Capsule2d::new`, the 3D `_s` counterpart). */
477
+ capsule_s(center, radius, length, options = {}) {
478
+ this.#capsuleVariant(center, radius, length, options, SOLID);
479
+ return this;
480
+ }
481
+ /** Dashed capsule outline. */
482
+ capsule_d(center, radius, length, dash, options = {}) {
483
+ this.#capsuleVariant(center, radius, length, options, dashed(dash));
484
+ return this;
485
+ }
486
+ #capsuleVariant(center, radius, length, options, variant) {
264
487
  const r = positive(radius, "radius");
265
488
  const straight = nonNegative(length, "length");
266
- this.#pushShape("capsule", center, { size: [r, straight / 2] }, options);
267
- return this;
489
+ this.#pushShape("capsule", center, { size: [r, straight / 2] }, options, variant);
268
490
  }
269
- /** Regular polygon with a vertex on the +x axis (reference `RegularPolygon::new(circumradius, sides)`). */
491
+ /** Regular polygon outline with a vertex on the +x axis (reference `RegularPolygon`). */
270
492
  polygon(center, circumradius, sides, options = {}) {
493
+ this.#polygonVariant(center, circumradius, sides, options, WIRE);
494
+ return this;
495
+ }
496
+ /** Solid regular polygon (reference `RegularPolygon::new`, the 3D `_s` counterpart). */
497
+ polygon_s(center, circumradius, sides, options = {}) {
498
+ this.#polygonVariant(center, circumradius, sides, options, SOLID);
499
+ return this;
500
+ }
501
+ /** Dashed regular polygon outline. */
502
+ polygon_d(center, circumradius, sides, dash, options = {}) {
503
+ this.#polygonVariant(center, circumradius, sides, options, dashed(dash));
504
+ return this;
505
+ }
506
+ #polygonVariant(center, circumradius, sides, options, variant) {
271
507
  const r = positive(circumradius, "circumradius");
272
508
  if (!Number.isInteger(sides) || sides < 3)
273
509
  throw new RangeError("sides must be an integer of at least 3");
274
- this.#pushShape("polygon", center, { size: [r, r], sides }, options);
275
- return this;
510
+ this.#pushShape("polygon", center, { size: [r, r], sides }, options, variant);
276
511
  }
277
- /** Arbitrary triangle (reference `Triangle2d::new(a, b, c)`); `rotation` turns about its centroid. */
512
+ /** Triangle outline (reference `Triangle2d`); `rotation` turns about its centroid. */
278
513
  triangle(a, b, c, options = {}) {
514
+ this.#triangleVariant(a, b, c, options, WIRE);
515
+ return this;
516
+ }
517
+ /** Solid triangle (reference `Triangle2d::new`, the 3D `_s` counterpart). */
518
+ triangle_s(a, b, c, options = {}) {
519
+ this.#triangleVariant(a, b, c, options, SOLID);
520
+ return this;
521
+ }
522
+ /** Dashed triangle outline. */
523
+ triangle_d(a, b, c, dash, options = {}) {
524
+ this.#triangleVariant(a, b, c, options, dashed(dash));
525
+ return this;
526
+ }
527
+ #triangleVariant(a, b, c, options, variant) {
279
528
  const p0 = vec2(a, "a");
280
529
  const p1 = vec2(b, "b");
281
530
  const p2 = vec2(c, "c");
@@ -284,28 +533,66 @@ export class FrameDrawList2D {
284
533
  size: [p0[0] - center[0], p0[1] - center[1]],
285
534
  vertexB: [p1[0] - center[0], p1[1] - center[1]],
286
535
  vertexC: [p2[0] - center[0], p2[1] - center[1]],
287
- }, options);
288
- return this;
536
+ }, options, variant);
289
537
  }
290
- /** Diamond with the given full diagonals (reference `Rhombus::new(horizontal, vertical)`). */
538
+ /** Diamond outline with the given full diagonals (reference `Rhombus`). */
291
539
  rhombus(center, diagonals, options = {}) {
292
- const d = positiveSize(diagonals, "diagonals");
293
- this.#pushShape("rhombus", center, { size: [d[0] / 2, d[1] / 2] }, options);
540
+ this.#rhombusVariant(center, diagonals, options, WIRE);
294
541
  return this;
295
542
  }
296
- /** Disc sector from `startAngle` through `sweep` (reference `CircularSector`). */
543
+ /** Solid diamond (reference `Rhombus::new`, the 3D `_s` counterpart). */
544
+ rhombus_s(center, diagonals, options = {}) {
545
+ this.#rhombusVariant(center, diagonals, options, SOLID);
546
+ return this;
547
+ }
548
+ /** Dashed diamond outline. */
549
+ rhombus_d(center, diagonals, dash, options = {}) {
550
+ this.#rhombusVariant(center, diagonals, options, dashed(dash));
551
+ return this;
552
+ }
553
+ #rhombusVariant(center, diagonals, options, variant) {
554
+ const d = positiveSize(diagonals, "diagonals");
555
+ this.#pushShape("rhombus", center, { size: [d[0] / 2, d[1] / 2] }, options, variant);
556
+ }
557
+ /** Disc sector outline from `startAngle` through `sweep` (reference `CircularSector`). */
297
558
  sector(center, radius, options = {}) {
559
+ this.#sectorVariant(center, radius, options, WIRE);
560
+ return this;
561
+ }
562
+ /** Solid disc sector (reference `CircularSector`, the 3D `_s` counterpart). */
563
+ sector_s(center, radius, options = {}) {
564
+ this.#sectorVariant(center, radius, options, SOLID);
565
+ return this;
566
+ }
567
+ /** Dashed sector outline: the arc plus both radii. */
568
+ sector_d(center, radius, dash, options = {}) {
569
+ this.#sectorVariant(center, radius, options, dashed(dash));
570
+ return this;
571
+ }
572
+ #sectorVariant(center, radius, options, variant) {
298
573
  const r = positive(radius, "radius");
299
574
  const angles = angleRange(options);
300
- this.#pushShape("sector", center, { size: [r, r], ...angles }, options);
301
- return this;
575
+ this.#pushShape("sector", center, { size: [r, r], ...angles }, options, variant);
302
576
  }
303
- /** Disc region between a chord and its arc (reference `CircularSegment`). */
577
+ /** Disc segment outline between a chord and its arc (reference `CircularSegment`). */
304
578
  segment(center, radius, options = {}) {
579
+ this.#segmentVariant(center, radius, options, WIRE);
580
+ return this;
581
+ }
582
+ /** Solid disc segment (reference `CircularSegment`, the 3D `_s` counterpart). */
583
+ segment_s(center, radius, options = {}) {
584
+ this.#segmentVariant(center, radius, options, SOLID);
585
+ return this;
586
+ }
587
+ /** Dashed segment outline: the arc plus the chord. */
588
+ segment_d(center, radius, dash, options = {}) {
589
+ this.#segmentVariant(center, radius, options, dashed(dash));
590
+ return this;
591
+ }
592
+ #segmentVariant(center, radius, options, variant) {
305
593
  const r = positive(radius, "radius");
306
594
  const angles = angleRange(options);
307
- this.#pushShape("segment", center, { size: [r, r], ...angles }, options);
308
- return this;
595
+ this.#pushShape("segment", center, { size: [r, r], ...angles }, options, variant);
309
596
  }
310
597
  /**
311
598
  * One joint instance for a path vertex. Degenerate wedges whose centre coincides with a
@@ -336,14 +623,16 @@ export class FrameDrawList2D {
336
623
  ...(dash === undefined ? {} : { dash: [dash[0], dash[1], dash[2] + distance] }),
337
624
  });
338
625
  }
339
- /** Records one shape once its per-shape arguments have been validated. */
340
- #pushShape(shape, center, fields, options) {
626
+ /** One instanced quad per shape is enough because the shader resolves the outline, and the
627
+ * dash variant only needs one extra per-instance pattern. */
628
+ #pushShape(shape, center, fields, options, variant) {
341
629
  this.#assertOpen();
342
630
  const thickness = positive(options.strokeWidth ?? DEFAULT_2D_STROKE_WIDTH, "strokeWidth");
343
631
  const rotation = finite(options.rotation ?? 0, "rotation");
344
632
  const cornerRadius = fields.cornerRadius ?? 0;
345
633
  if (cornerRadius < 0)
346
634
  throw new RangeError("cornerRadius must not be negative");
635
+ const dash = variant?.dash;
347
636
  this.#shapes.push({
348
637
  kind: "shape2d",
349
638
  shape,
@@ -353,7 +642,7 @@ export class FrameDrawList2D {
353
642
  vertexC: fields.vertexC ?? [0, 0],
354
643
  cornerRadius,
355
644
  thickness,
356
- filled: options.fill ?? true,
645
+ filled: variant.filled,
357
646
  rotation,
358
647
  startAngle: fields.startAngle ?? 0,
359
648
  sweep: fields.sweep ?? 0,
@@ -361,6 +650,7 @@ export class FrameDrawList2D {
361
650
  color: color(options.color),
362
651
  space: spaceValue(options.space),
363
652
  layer: layer(options.layer),
653
+ ...(dash === undefined ? {} : { dash }),
364
654
  });
365
655
  }
366
656
  get points() {
@@ -1,7 +1,9 @@
1
+ import type { Vec2 } from "./camera-2d.js";
1
2
  import type { Vec3 } from "./frame-draw-list.js";
2
3
  import { type CircleBasis } from "./vector-math.js";
3
4
  export type Triangle = readonly [Vec3, Vec3, Vec3];
4
5
  export type Segment = readonly [Vec3, Vec3];
6
+ export type Segment2D = readonly [Vec2, Vec2];
5
7
  export type MeshData = {
6
8
  readonly triangles: readonly Triangle[];
7
9
  readonly segments: readonly Segment[];
@@ -99,3 +101,10 @@ export declare function coneWire(radius: number, height: number, segments: numbe
99
101
  */
100
102
  export declare function cylinderWire(radius: number, halfHeight: number, segments: number, sideSegmentCount: number, center: Vec3, basis: CircleBasis): ShapeWire;
101
103
  export declare function gridLines(cells: readonly [number, number], spacing: readonly [number, number], center?: Vec3, rotation?: readonly [number, number, number], skew?: readonly [number, number], outer?: "none" | "x" | "z" | "xz"): Segment[];
104
+ /**
105
+ * 2D counterpart of `gridLines`: the same cell/skew/outer contract in the XY plane.
106
+ *
107
+ * `rotation` is one anticlockwise angle around the grid centre. `outer` keeps the x-axis
108
+ * boundary lines (`"x"`), the y-axis ones (`"y"`), or both (`"xy"`).
109
+ */
110
+ export declare function gridLines2D(cells: readonly [number, number], spacing: readonly [number, number], center?: Vec2, rotation?: number, skew?: readonly [number, number], outer?: "none" | "x" | "y" | "xy"): Segment2D[];
@@ -479,3 +479,39 @@ export function gridLines(cells, spacing, center = [0, 0, 0], rotation = [0, 0,
479
479
  }
480
480
  return segments;
481
481
  }
482
+ /**
483
+ * 2D counterpart of `gridLines`: the same cell/skew/outer contract in the XY plane.
484
+ *
485
+ * `rotation` is one anticlockwise angle around the grid centre. `outer` keeps the x-axis
486
+ * boundary lines (`"x"`), the y-axis ones (`"y"`), or both (`"xy"`).
487
+ */
488
+ export function gridLines2D(cells, spacing, center = [0, 0], rotation = 0, skew = [0, 0], outer = "none") {
489
+ const [cellX, cellY] = cells;
490
+ const [spacingX, spacingY] = spacing;
491
+ if (cellX <= 0 || cellY <= 0 || spacingX <= 0 || spacingY <= 0)
492
+ return [];
493
+ const skewTan = [Math.tan(skew[0]), Math.tan(skew[1])];
494
+ const dx = [spacingX, spacingX * skewTan[1]];
495
+ const dy = [spacingY * skewTan[0], spacingY];
496
+ const start = [-dx[0] * cellX * 0.5 - dy[0] * cellY * 0.5, -dx[1] * cellX * 0.5 - dy[1] * cellY * 0.5];
497
+ const cos = Math.cos(rotation);
498
+ const sin = Math.sin(rotation);
499
+ const place = (p) => [
500
+ center[0] + p[0] * cos - p[1] * sin,
501
+ center[1] + p[0] * sin + p[1] * cos,
502
+ ];
503
+ const plus = (a, b) => [a[0] + b[0], a[1] + b[1]];
504
+ const scale = (v, n) => [v[0] * n, v[1] * n];
505
+ const keepX = outer === "x" || outer === "xy";
506
+ const keepY = outer === "y" || outer === "xy";
507
+ const segments = [];
508
+ for (let yIndex = keepX ? 0 : 1; yIndex < (keepX ? cellY + 1 : cellY); yIndex++) {
509
+ const from = plus(start, scale(dy, yIndex));
510
+ segments.push([place(from), place(plus(from, scale(dx, cellX)))]);
511
+ }
512
+ for (let xIndex = keepY ? 0 : 1; xIndex < (keepY ? cellX + 1 : cellX); xIndex++) {
513
+ const from = plus(start, scale(dx, xIndex));
514
+ segments.push([place(from), place(plus(from, scale(dy, cellY)))]);
515
+ }
516
+ return segments;
517
+ }
@@ -43,6 +43,12 @@ function sameVec2(a, b) {
43
43
  function sameColor(a, b) {
44
44
  return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
45
45
  }
46
+ /** Dash patterns are optional, so both sides being absent is also a match. */
47
+ function sameDash(a, b) {
48
+ if (a === undefined || b === undefined)
49
+ return a === b;
50
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
51
+ }
46
52
  function samePoint2D(a, b) {
47
53
  return sameVec2(a.position, b.position) && sameColor(a.color, b.color) && a.size === b.size && a.sizeMode === b.sizeMode && a.round === b.round;
48
54
  }
@@ -83,6 +89,7 @@ function sameShape2D(a, b) {
83
89
  a.startAngle === b.startAngle &&
84
90
  a.sweep === b.sweep &&
85
91
  a.sides === b.sides &&
92
+ sameDash(a.dash, b.dash) &&
86
93
  sameColor(a.color, b.color));
87
94
  }
88
95
  /** Two-dimensional point lifted onto the near plane the projection matrix maps to. */
@@ -163,7 +170,7 @@ class SpaceBucket2D {
163
170
  this.#points = instances({ capacity: capacities.points, attributes: { point: "float32x3", color: "float32x4", size: "float32", flags: "uint32" } });
164
171
  this.#lines = instances({ capacity: capacities.lines, attributes: { start: "float32x3", end: "float32x3", color: "float32x4", colorEnd: "float32x4", width: "float32", flags: "uint32", dash: "float32x3", normal: "float32x3", joint: "uint32" } });
165
172
  this.#joints = instances({ capacity: capacities.joints, attributes: { previous: "float32x3", center: "float32x3", next: "float32x3", color: "float32x4", width: "float32", flags: "uint32", dash: "float32x3", joint: "uint32", previousNormal: "float32x3", nextNormal: "float32x3" } });
166
- this.#shapes = instances({ capacity: capacities.shapes, attributes: { shape: "uint32", center: "float32x3", size: "float32x2", vertexB: "float32x2", vertexC: "float32x2", params: "float32x4", rotationRadius: "float32x2", color: "float32x4", flags: "uint32" } });
173
+ this.#shapes = instances({ capacity: capacities.shapes, attributes: { shape: "uint32", center: "float32x3", size: "float32x2", vertexB: "float32x2", vertexC: "float32x2", params: "float32x4", rotationRadius: "float32x2", color: "float32x4", flags: "uint32", dash: "float32x3" } });
167
174
  this.#pointBridge = instanceGeometry(gpu, this.#points, { mesh: zeroGeometry(gpu) });
168
175
  this.#lineBridge = instanceGeometry(gpu, this.#lines, { mesh: zeroGeometry(gpu) });
169
176
  this.#jointBridge = instanceGeometry(gpu, this.#joints, { mesh: jointGeometry(gpu, options.jointVertexCount) });
@@ -380,10 +387,11 @@ class SpaceBucket2D {
380
387
  rotationRadius: [command.rotation, boundingRadius(command)],
381
388
  color: command.color,
382
389
  flags: (command.filled ? 1 : 0) | (command.sides << 8),
390
+ dash: command.dash ?? [0, 0, 0],
383
391
  });
384
392
  }
385
393
  #addShapeId(index) {
386
- const id = this.#shapes.add({ shape: 0, center: [0, 0, 0], size: [0, 0], vertexB: [0, 0], vertexC: [0, 0], params: [0, 0, 0, 0], rotationRadius: [0, 0], color: [0, 0, 0, 0], flags: 0 });
394
+ const id = this.#shapes.add({ shape: 0, center: [0, 0, 0], size: [0, 0], vertexB: [0, 0], vertexC: [0, 0], params: [0, 0, 0, 0], rotationRadius: [0, 0], color: [0, 0, 0, 0], flags: 0, dash: [0, 0, 0] });
387
395
  world(this.#shapes, id);
388
396
  this.#shapeIds[index] = id;
389
397
  return id;