vgpu-render 0.1.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.
Files changed (46) hide show
  1. package/README.md +101 -34
  2. package/dist/curve.d.ts +26 -1
  3. package/dist/curve.js +53 -0
  4. package/dist/debug-draw-3d.d.ts +3 -3
  5. package/dist/debug-draw-3d.js +10 -10
  6. package/dist/frame-draw-list-2d.d.ts +286 -0
  7. package/dist/frame-draw-list-2d.js +668 -0
  8. package/dist/{frame-recorder.d.ts → frame-draw-list.d.ts} +60 -8
  9. package/dist/{frame-recorder.js → frame-draw-list.js} +51 -10
  10. package/dist/geometry-generators.d.ts +11 -2
  11. package/dist/geometry-generators.js +37 -1
  12. package/dist/gpu-debug-draw-2d.d.ts +1 -1
  13. package/dist/gpu-debug-draw-2d.js +10 -2
  14. package/dist/gpu-debug-draw-3d.d.ts +1 -1
  15. package/dist/gpu-debug-draw-3d.js +4 -2
  16. package/dist/gpu-text-3d.d.ts +1 -1
  17. package/dist/gpu-text-3d.js +49 -2
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +2 -3
  20. package/dist/mesh-batch-3d.d.ts +1 -1
  21. package/dist/shape2d-shader.js +295 -7
  22. package/dist/text-atlas.d.ts +6 -4
  23. package/dist/text-atlas.js +9 -6
  24. package/dist/text-font.d.ts +48 -7
  25. package/dist/text-font.js +63 -11
  26. package/dist/text-layout.d.ts +63 -4
  27. package/dist/text-layout.js +58 -8
  28. package/dist/text-msdf-baked.d.ts +9 -4
  29. package/dist/text-msdf-baked.js +9 -4
  30. package/dist/text-msdf-font.d.ts +10 -4
  31. package/dist/text-msdf-font.js +9 -3
  32. package/dist/vector-math.d.ts +19 -0
  33. package/dist/vector-math.js +37 -0
  34. package/package.json +1 -1
  35. package/dist/frame-recorder-2d.d.ts +0 -177
  36. package/dist/frame-recorder-2d.js +0 -378
  37. package/dist/layout-3d.d.ts +0 -227
  38. package/dist/layout-3d.js +0 -226
  39. package/dist/scene-examples.d.ts +0 -39
  40. package/dist/scene-examples.js +0 -200
  41. package/dist/scenes-2d.d.ts +0 -18
  42. package/dist/scenes-2d.js +0 -160
  43. package/dist/scenes-3d.d.ts +0 -46
  44. package/dist/scenes-3d.js +0 -1994
  45. package/dist/text-scenes.d.ts +0 -85
  46. package/dist/text-scenes.js +0 -338
@@ -1,6 +1,6 @@
1
1
  import { type Curve3D, type CurveTransform } from "./curve.js";
2
- import { FrameRecorder2D, type FrameCommand2D } from "./frame-recorder-2d.js";
3
- import { type TextAnchor, type TextJustify, type TextLineBreak } from "./text-layout.js";
2
+ import { FrameDrawList2D, type FrameCommand2D } from "./frame-draw-list-2d.js";
3
+ import { type TextAnchor, type TextFontStyle, type TextFontWidth, type TextJustify, type TextLineBreak } from "./text-layout.js";
4
4
  export type Vec3 = readonly [number, number, number];
5
5
  export type Color = readonly [number, number, number, number];
6
6
  export type LayerMask = number;
@@ -114,6 +114,12 @@ export type TextSpanOptions = {
114
114
  readonly color?: Color;
115
115
  readonly fontSize?: number;
116
116
  readonly fontFamily?: string;
117
+ /** reference `TextFont::weight`, 1 to 1000. Defaults to the block's, then to 400. */
118
+ readonly weight?: number;
119
+ /** reference `TextFont::style`, minus `Oblique`: Canvas2D drops the slant angle. */
120
+ readonly style?: TextFontStyle;
121
+ /** reference `TextFont::width`, as a `font-stretch` keyword. Inert unless the face has a `wdth` axis. */
122
+ readonly width?: TextFontWidth;
117
123
  };
118
124
  export type TextOptions = {
119
125
  color?: Color;
@@ -143,6 +149,21 @@ export type TextOptions = {
143
149
  */
144
150
  pixelSize?: number;
145
151
  fontFamily?: string;
152
+ /**
153
+ * reference `TextFont::weight`, 1 to 1000, default `400`. A face that does not carry the weight is
154
+ * emboldened by the browser in one synthesised step, so the advance does not change.
155
+ */
156
+ weight?: number;
157
+ /**
158
+ * reference `TextFont::style`. `italic` is the only slant Canvas2D honours: its shorthand drops the
159
+ * angle of `Oblique(a)`, so a synthesised slant is all an oblique could mean here.
160
+ */
161
+ style?: TextFontStyle;
162
+ /**
163
+ * reference `TextFont::width`, spelled as a `font-stretch` keyword. Canvas2D keeps the keyword only when
164
+ * the face has a `wdth` axis; the bundled static subset has none, so every stop draws identically.
165
+ */
166
+ width?: TextFontWidth;
146
167
  /**
147
168
  * Absolute line height in pixels, reference `LineHeight::Px`. Exclusive with `lineHeightScale`;
148
169
  * omitting both leaves the line height at reference default, `RelativeToFont(1.2)`.
@@ -164,6 +185,12 @@ export type TextOptions = {
164
185
  bounds?: readonly [number, number];
165
186
  anchor?: TextAnchor;
166
187
  space?: TextSpace;
188
+ /**
189
+ * Local rotation of a `world-plane` block, in radians, as reference `Label3D.rotation` Euler angles
190
+ * (applied Y, then X, then Z). The reference `Label3D` labels use `[-PI/2, 0, 0]` to lay the block
191
+ * flat on the XZ plane, readable from above; the two camera-relative spaces ignore it.
192
+ */
193
+ rotation?: readonly [number, number, number];
167
194
  /**
168
195
  * Which registered glyph source draws this block: its encoding and raster scale. Blocks that name
169
196
  * nothing use the first source, so a frame can mix a sharp pass and a cheap pass.
@@ -179,6 +206,9 @@ export type ResolvedTextSpan = {
179
206
  readonly color: Color;
180
207
  readonly fontSize: number;
181
208
  readonly fontFamily: string;
209
+ readonly weight: number;
210
+ readonly style: TextFontStyle;
211
+ readonly width: TextFontWidth;
182
212
  };
183
213
  export type TextCommand = {
184
214
  readonly kind: "text";
@@ -190,6 +220,9 @@ export type TextCommand = {
190
220
  readonly fontSize: number;
191
221
  readonly pixelSize: number;
192
222
  readonly fontFamily: string;
223
+ readonly weight: number;
224
+ readonly style: TextFontStyle;
225
+ readonly width: TextFontWidth;
193
226
  /**
194
227
  * Resolved line height in pixels: the value a `LineHeight::Px` block asked for, or
195
228
  * `1.2 × fontSize` for the default, which `lineHeightScale` then marks as resolved per line.
@@ -207,6 +240,7 @@ export type TextCommand = {
207
240
  readonly bounds?: readonly [number, number];
208
241
  readonly anchor: TextAnchor;
209
242
  readonly space: TextSpace;
243
+ readonly rotation: readonly [number, number, number];
210
244
  readonly source?: string;
211
245
  readonly depthTest: boolean;
212
246
  readonly layer: LayerMask;
@@ -248,6 +282,12 @@ export type TextDefaults = {
248
282
  fontSize?: number;
249
283
  pixelSize?: number;
250
284
  fontFamily?: string;
285
+ /** Default reference `TextFont::weight`, 1 to 1000. */
286
+ weight?: number;
287
+ /** Default reference `TextFont::style`. */
288
+ style?: TextFontStyle;
289
+ /** Default reference `TextFont::width`, as a `font-stretch` keyword. */
290
+ width?: TextFontWidth;
251
291
  /**
252
292
  * Absolute line height in pixels, reference `LineHeight::Px`. Omitted means reference default,
253
293
  * `RelativeToFont(1.2)`: every line is 1.2 times its own largest span, so a line holding a
@@ -261,11 +301,12 @@ export type TextDefaults = {
261
301
  lineBreak?: TextLineBreak;
262
302
  anchor?: TextAnchor;
263
303
  space?: TextSpace;
304
+ rotation?: readonly [number, number, number];
264
305
  source?: string;
265
306
  depthTest?: boolean;
266
307
  layer?: LayerMask;
267
308
  };
268
- export type FrameRecorderDefaults = {
309
+ export type FrameDrawListDefaults = {
269
310
  point?: PointDefaults;
270
311
  line?: LineDefaults;
271
312
  text?: TextDefaults;
@@ -276,6 +317,8 @@ export declare const DEFAULT_LAYER = 1;
276
317
  export declare const DEFAULT_TEXT_SIZE = 16;
277
318
  /** Default world units per font pixel, i.e. a 0.32 world-unit glyph at the default size. */
278
319
  export declare const DEFAULT_TEXT_PIXEL_SIZE = 0.02;
320
+ /** `world-plane` blocks face +Z until a rotation lays them down, like a reference `Label3D`. */
321
+ export declare const DEFAULT_TEXT_ROTATION: readonly [number, number, number];
279
322
  /** reference `LineHeight` default is `RelativeToFont(1.2)`. */
280
323
  export declare const DEFAULT_LINE_HEIGHT_RATIO = 1.2;
281
324
  export type ResolvedPointDefaults = {
@@ -301,6 +344,9 @@ export type ResolvedTextDefaults = {
301
344
  fontSize: number | undefined;
302
345
  pixelSize: number | undefined;
303
346
  fontFamily: string | undefined;
347
+ weight: number | undefined;
348
+ style: TextFontStyle | undefined;
349
+ width: TextFontWidth | undefined;
304
350
  lineHeight: number | undefined;
305
351
  lineHeightScale: number | undefined;
306
352
  letterSpacing: number | undefined;
@@ -308,25 +354,31 @@ export type ResolvedTextDefaults = {
308
354
  lineBreak: TextLineBreak | undefined;
309
355
  anchor: TextAnchor | undefined;
310
356
  space: TextSpace | undefined;
357
+ rotation: readonly [number, number, number] | undefined;
311
358
  source: string | undefined;
312
359
  depthTest: boolean | undefined;
313
360
  layer: LayerMask | undefined;
314
361
  };
315
- export type ResolvedFrameRecorderDefaults = {
362
+ export type ResolvedFrameDrawListDefaults = {
316
363
  point: ResolvedPointDefaults;
317
364
  line: ResolvedLineDefaults;
318
365
  text: ResolvedTextDefaults;
319
366
  };
320
367
  /** Validates and resolves style defaults so invalid values fail at construction time. */
321
- export declare function resolveFrameRecorderDefaults(defaults?: FrameRecorderDefaults): ResolvedFrameRecorderDefaults;
322
- export declare class FrameRecorder {
368
+ export declare function resolveFrameDrawListDefaults(defaults?: FrameDrawListDefaults): ResolvedFrameDrawListDefaults;
369
+ /**
370
+ * One frame's draw list: records primitives, then hands them to `GpuDebugDraw3D` as
371
+ * `FrameCommand[]`. This is the `ImDrawList` / `GizmoBuffer` layer; GPU command encoding
372
+ * happens later in the renderer.
373
+ */
374
+ export declare class FrameDrawList {
323
375
  #private;
324
- constructor(defaults?: FrameRecorderDefaults);
376
+ constructor(defaults?: FrameDrawListDefaults);
325
377
  /**
326
378
  * The 2D layer: `Vec2` primitives on the reference-style `Camera2d` spaces described in
327
379
  * `camera-2d.ts`. Drawing here records into the same frame and layer mask as the 3D methods.
328
380
  */
329
- get d2(): FrameRecorder2D;
381
+ get d2(): FrameDrawList2D;
330
382
  point(position: Vec3, options?: PointOptions): this;
331
383
  line(start: Vec3, end: Vec3, options?: LineOptions): this;
332
384
  ray(origin: Vec3, direction: Vec3, options?: LineOptions): this;
@@ -1,14 +1,16 @@
1
1
  import { sampleCurve } from "./curve.js";
2
- import { FrameRecorder2D } from "./frame-recorder-2d.js";
2
+ import { FrameDrawList2D } from "./frame-draw-list-2d.js";
3
3
  import { gridLines } from "./geometry-generators.js";
4
4
  import { DEFAULT_FONT_FAMILY } from "./text-font.js";
5
- import { resolveTextAnchor, DEFAULT_LINE_HEIGHT_SCALE } from "./text-layout.js";
5
+ import { resolveTextAnchor, DEFAULT_LINE_HEIGHT_SCALE, DEFAULT_FONT_WEIGHT, MAX_FONT_WEIGHT, MIN_FONT_WEIGHT, TEXT_FONT_STYLES, TEXT_FONT_WIDTHS, } from "./text-layout.js";
6
6
  export const DEFAULT_COLOR = [1, 1, 1, 1];
7
7
  export const DEFAULT_LAYER = 1;
8
8
  /** Default block font size in pixels; the world spaces scale it by `DEFAULT_TEXT_PIXEL_SIZE`. */
9
9
  export const DEFAULT_TEXT_SIZE = 16;
10
10
  /** Default world units per font pixel, i.e. a 0.32 world-unit glyph at the default size. */
11
11
  export const DEFAULT_TEXT_PIXEL_SIZE = 0.02;
12
+ /** `world-plane` blocks face +Z until a rotation lays them down, like a reference `Label3D`. */
13
+ export const DEFAULT_TEXT_ROTATION = [0, 0, 0];
12
14
  /** reference `LineHeight` default is `RelativeToFont(1.2)`. */
13
15
  export const DEFAULT_LINE_HEIGHT_RATIO = DEFAULT_LINE_HEIGHT_SCALE;
14
16
  function finite(value, name) {
@@ -79,6 +81,24 @@ function textSpaceValue(value, name) {
79
81
  }
80
82
  return value;
81
83
  }
84
+ function fontWeightValue(value, name) {
85
+ if (!Number.isFinite(value) || value < MIN_FONT_WEIGHT || value > MAX_FONT_WEIGHT) {
86
+ throw new RangeError(`${name} must be between ${MIN_FONT_WEIGHT} and ${MAX_FONT_WEIGHT}`);
87
+ }
88
+ return value;
89
+ }
90
+ function textFontStyleValue(value, name) {
91
+ if (!TEXT_FONT_STYLES.includes(value)) {
92
+ throw new RangeError(`${name} must be one of ${TEXT_FONT_STYLES.join(", ")}`);
93
+ }
94
+ return value;
95
+ }
96
+ function textFontWidthValue(value, name) {
97
+ if (!TEXT_FONT_WIDTHS.includes(value)) {
98
+ throw new RangeError(`${name} must be one of ${TEXT_FONT_WIDTHS.join(", ")}`);
99
+ }
100
+ return value;
101
+ }
82
102
  function textBounds(value, name) {
83
103
  if (value.length !== 2)
84
104
  throw new RangeError(`${name} must contain two values`);
@@ -90,7 +110,7 @@ function textBounds(value, name) {
90
110
  * Folds the block defaults into every span so the renderer resolves a style once per span and
91
111
  * never has to guess what an omitted field meant.
92
112
  */
93
- function resolveTextSpans(text, options, defaults, baseFontSize, baseFontFamily) {
113
+ function resolveTextSpans(text, options, defaults, baseFontSize, baseFontFamily, baseWeight, baseStyle, baseWidth) {
94
114
  const input = typeof text === "string" ? [{ text }] : text;
95
115
  const baseColor = color(options.color ?? defaults.color);
96
116
  return input.map((span) => {
@@ -102,11 +122,15 @@ function resolveTextSpans(text, options, defaults, baseFontSize, baseFontFamily)
102
122
  color: span.color === undefined ? baseColor : color(span.color),
103
123
  fontSize,
104
124
  fontFamily: span.fontFamily ?? baseFontFamily,
125
+ // A span overrides the block's face one axis at a time, like a nested `TextFont` component.
126
+ weight: span.weight === undefined ? baseWeight : fontWeightValue(span.weight, "span weight"),
127
+ style: span.style === undefined ? baseStyle : textFontStyleValue(span.style, "span style"),
128
+ width: span.width === undefined ? baseWidth : textFontWidthValue(span.width, "span width"),
105
129
  };
106
130
  });
107
131
  }
108
132
  /** Validates and resolves style defaults so invalid values fail at construction time. */
109
- export function resolveFrameRecorderDefaults(defaults = {}) {
133
+ export function resolveFrameDrawListDefaults(defaults = {}) {
110
134
  const point = defaults.point ?? {};
111
135
  const line = defaults.line ?? {};
112
136
  const text = defaults.text ?? {};
@@ -168,6 +192,9 @@ export function resolveFrameRecorderDefaults(defaults = {}) {
168
192
  fontSize: textSize,
169
193
  pixelSize: textPixelSize,
170
194
  fontFamily: text.fontFamily,
195
+ weight: text.weight === undefined ? undefined : fontWeightValue(text.weight, "default text weight"),
196
+ style: text.style === undefined ? undefined : textFontStyleValue(text.style, "default text style"),
197
+ width: text.width === undefined ? undefined : textFontWidthValue(text.width, "default text width"),
171
198
  lineHeight: textLineHeight,
172
199
  lineHeightScale: textLineHeightScale,
173
200
  letterSpacing: text.letterSpacing === undefined ? undefined : finite(text.letterSpacing, "default text letterSpacing"),
@@ -175,13 +202,19 @@ export function resolveFrameRecorderDefaults(defaults = {}) {
175
202
  lineBreak: text.lineBreak === undefined ? undefined : textLineBreakValue(text.lineBreak, "default text lineBreak"),
176
203
  anchor: textAnchor,
177
204
  space: text.space === undefined ? undefined : textSpaceValue(text.space, "default text space"),
205
+ rotation: text.rotation === undefined ? undefined : vec3(text.rotation, "default text rotation"),
178
206
  source: text.source,
179
207
  depthTest: text.depthTest,
180
208
  layer: text.layer === undefined ? undefined : layerValue(text.layer, "default text layer"),
181
209
  },
182
210
  };
183
211
  }
184
- export class FrameRecorder {
212
+ /**
213
+ * One frame's draw list: records primitives, then hands them to `GpuDebugDraw3D` as
214
+ * `FrameCommand[]`. This is the `ImDrawList` / `GizmoBuffer` layer; GPU command encoding
215
+ * happens later in the renderer.
216
+ */
217
+ export class FrameDrawList {
185
218
  #points = [];
186
219
  #lines = [];
187
220
  #joints = [];
@@ -191,10 +224,10 @@ export class FrameRecorder {
191
224
  #d2;
192
225
  #sealed = false;
193
226
  constructor(defaults = {}) {
194
- this.#defaults = resolveFrameRecorderDefaults(defaults);
195
- // The 2D layer shares this recorder's resolved point/line styles, so a style set on
227
+ this.#defaults = resolveFrameDrawListDefaults(defaults);
228
+ // The 2D layer shares this drawList's resolved point/line styles, so a style set on
196
229
  // `DebugDraw3D` cannot apply to one layer and not the other.
197
- this.#d2 = new FrameRecorder2D({ point: this.#defaults.point, line: this.#defaults.line }, () => this.assertOpen());
230
+ this.#d2 = new FrameDrawList2D({ point: this.#defaults.point, line: this.#defaults.line }, () => this.assertOpen());
198
231
  }
199
232
  /**
200
233
  * The 2D layer: `Vec2` primitives on the reference-style `Camera2d` spaces described in
@@ -431,10 +464,14 @@ export class FrameRecorder {
431
464
  throw new RangeError("lineHeight must be greater than zero");
432
465
  const letterSpacing = finite(options.letterSpacing ?? defaults.letterSpacing ?? 0, "letterSpacing");
433
466
  const fontFamily = options.fontFamily ?? defaults.fontFamily ?? DEFAULT_FONT_FAMILY;
467
+ const weight = fontWeightValue(options.weight ?? defaults.weight ?? DEFAULT_FONT_WEIGHT, "weight");
468
+ const style = textFontStyleValue(options.style ?? defaults.style ?? "normal", "style");
469
+ const width = textFontWidthValue(options.width ?? defaults.width ?? "normal", "width");
434
470
  const justify = options.justify ?? defaults.justify ?? "left";
435
471
  const lineBreak = options.lineBreak ?? defaults.lineBreak ?? "word";
436
472
  const anchor = options.anchor ?? defaults.anchor ?? "center";
437
473
  const space = options.space ?? defaults.space ?? "world-plane";
474
+ const rotation = vec3(options.rotation ?? defaults.rotation ?? DEFAULT_TEXT_ROTATION, "rotation");
438
475
  const source = options.source ?? defaults.source;
439
476
  const bounds = options.bounds;
440
477
  if (bounds !== undefined)
@@ -451,13 +488,16 @@ export class FrameRecorder {
451
488
  this.#texts.push({
452
489
  kind: "text",
453
490
  position: vec3(position, "position"),
454
- spans: resolveTextSpans(text, options, defaults, fontSize, fontFamily),
491
+ spans: resolveTextSpans(text, options, defaults, fontSize, fontFamily, weight, style, width),
455
492
  color: color(options.color ?? defaults.color),
456
493
  outlineWidth,
457
494
  outlineColor,
458
495
  fontSize,
459
496
  pixelSize,
460
497
  fontFamily,
498
+ weight,
499
+ style,
500
+ width,
461
501
  lineHeight,
462
502
  ...(lineHeightScale === undefined ? {} : { lineHeightScale }),
463
503
  letterSpacing,
@@ -465,6 +505,7 @@ export class FrameRecorder {
465
505
  lineBreak,
466
506
  anchor,
467
507
  space,
508
+ rotation,
468
509
  ...(source === undefined ? {} : { source }),
469
510
  depthTest: options.depthTest ?? defaults.depthTest ?? true,
470
511
  layer: layer(options.layer ?? defaults.layer),
@@ -510,6 +551,6 @@ export class FrameRecorder {
510
551
  }
511
552
  assertOpen() {
512
553
  if (this.#sealed)
513
- throw new Error("frame recorder is sealed");
554
+ throw new Error("frame drawList is sealed");
514
555
  }
515
556
  }
@@ -1,7 +1,9 @@
1
- import type { Vec3 } from "./frame-recorder.js";
2
- import { type CircleBasis } from "./layout-3d.js";
1
+ import type { Vec2 } from "./camera-2d.js";
2
+ import type { Vec3 } from "./frame-draw-list.js";
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[];
@@ -1,4 +1,4 @@
1
- import { crossVector, normalizeVector, rotateAroundAxis } from "./layout-3d.js";
1
+ import { crossVector, normalizeVector, rotateAroundAxis } from "./vector-math.js";
2
2
  const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
3
3
  const scale = (a, n) => [a[0] * n, a[1] * n, a[2] * n];
4
4
  const rotate = (p, rotation) => {
@@ -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
+ }
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { type FrameComputePass, type FramePass, type Gpu } from "vgpu";
16
16
  import { type Camera2D } from "./camera-2d.js";
17
- import type { FrameCommand2D } from "./frame-recorder-2d.js";
17
+ import type { FrameCommand2D } from "./frame-draw-list-2d.js";
18
18
  export type GpuDebugDraw2DOptions = {
19
19
  /**
20
20
  * WGSL for the shared primitives, generated by `GpuDebugDraw3D` so the two layers cannot drift
@@ -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;
@@ -1,6 +1,6 @@
1
1
  import { type Frame, type FramePass, type Gpu } from "vgpu";
2
2
  import type { Camera2D } from "./camera-2d.js";
3
- import type { FrameCommand } from "./frame-recorder.js";
3
+ import type { FrameCommand } from "./frame-draw-list.js";
4
4
  import { type GpuText3DOptions } from "./gpu-text-3d.js";
5
5
  /**
6
6
  * Named glyph sources. A text command picks one by name through `TextOptions.source`; commands that
@@ -710,8 +710,10 @@ export class GpuDebugDraw3D {
710
710
  this.#viewProjection = camera.viewProjection;
711
711
  this.#view = camera.view;
712
712
  // Every renderer that exists is prepared, even with an empty list: that is what drops the
713
- // previous frame's glyphs instead of leaving them on screen.
714
- if (textCommands.length > 0 || this.#texts.size > 0) {
713
+ // previous frame's glyphs instead of leaving them on screen. A caller that never configured a
714
+ // text source asked for geometry only, so the example scenes' own labels are drawn by nobody
715
+ // rather than failing the frame — a named source that is missing still throws.
716
+ if (this.#textOptions !== undefined && (textCommands.length > 0 || this.#texts.size > 0)) {
715
717
  this.#prepareText(textCommands, camera);
716
718
  }
717
719
  // Always prepared, even with an empty list: that is what drops the previous frame's 2D
@@ -32,7 +32,7 @@
32
32
  * glyph's translucent ink box from clipping the glyphs drawn after it.
33
33
  */
34
34
  import { type FrameComputePass, type FramePass, type Gpu } from "vgpu";
35
- import type { TextCommand } from "./frame-recorder.js";
35
+ import type { TextCommand } from "./frame-draw-list.js";
36
36
  import { type TextMeasurer } from "./text-layout.js";
37
37
  import type { GlyphRasterizer } from "./text-font.js";
38
38
  /** Both halves of a font: metrics for layout and pixels for the atlas. */
@@ -205,7 +205,12 @@ struct Out { @builtin(position) clip: vec4f, @location(0) uv: vec2f, @location(1
205
205
  let up = vec3f(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
206
206
  worldPosition = input.origin + right * local.x - up * local.y;
207
207
  } else {
208
- worldPosition = input.origin + vec3f(local.x, -local.y, 0.0);
208
+ // The instance world matrix carries the block's own plane basis: column 0 is the reading
209
+ // direction, column 1 the layout's down. Label3D.rotation lands on that basis, so the default
210
+ // identity keeps the plane facing +Z.
211
+ let axisX = input.world0.xyz;
212
+ let axisY = input.world1.xyz;
213
+ worldPosition = input.origin + axisX * local.x - axisY * local.y;
209
214
  }
210
215
  clip = camera.viewProjection * vec4f(worldPosition, 1.0);
211
216
  }
@@ -233,6 +238,41 @@ fn atlasTexelsPerPixel(uv: vec2f) -> f32 {
233
238
 
234
239
  ${fragment}`;
235
240
  }
241
+ /** `world-plane` blocks keep facing +Z until their own rotation lays them down. */
242
+ const IDENTITY_WORLD = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
243
+ /**
244
+ * The instance world matrix of a `world-plane` block, from the reference `Label3D.rotation` Euler
245
+ * order (Y, then X, then Z). Column 0 is the reading direction and column 1 the layout's down, so a
246
+ * `[-PI/2, 0, 0]` block lies flat on the XZ plane and reads from above.
247
+ *
248
+ * The basis travels in the instance world matrix rather than in two extra instance attributes: the
249
+ * text pipeline already uses the device's full vertex attribute budget.
250
+ */
251
+ function textPlaneWorld(rotation) {
252
+ const [pitch, yaw, roll] = rotation;
253
+ if (pitch === 0 && yaw === 0 && roll === 0)
254
+ return IDENTITY_WORLD;
255
+ const cosRoll = Math.cos(roll);
256
+ const sinRoll = Math.sin(roll);
257
+ const cosPitch = Math.cos(pitch);
258
+ const sinPitch = Math.sin(pitch);
259
+ const cosYaw = Math.cos(yaw);
260
+ const sinYaw = Math.sin(yaw);
261
+ // Rz, then Rx, then Ry: the vector order that matches reference YXZ Euler convention.
262
+ let x = [cosRoll, sinRoll, 0];
263
+ let y = [-sinRoll, cosRoll, 0];
264
+ x = [x[0], x[1] * cosPitch - x[2] * sinPitch, x[1] * sinPitch + x[2] * cosPitch];
265
+ y = [y[0], y[1] * cosPitch - y[2] * sinPitch, y[1] * sinPitch + y[2] * cosPitch];
266
+ x = [x[0] * cosYaw + x[2] * sinYaw, x[1], -x[0] * sinYaw + x[2] * cosYaw];
267
+ y = [y[0] * cosYaw + y[2] * sinYaw, y[1], -y[0] * sinYaw + y[2] * cosYaw];
268
+ const z = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]];
269
+ return new Float32Array([
270
+ x[0], x[1], x[2], 0,
271
+ y[0], y[1], y[2], 0,
272
+ z[0], z[1], z[2], 0,
273
+ 0, 0, 0, 1,
274
+ ]);
275
+ }
236
276
  export class GpuText3D {
237
277
  #gpu;
238
278
  #font;
@@ -349,6 +389,9 @@ export class GpuText3D {
349
389
  color: command.color,
350
390
  fontSize: command.fontSize,
351
391
  fontFamily: command.fontFamily,
392
+ weight: command.weight,
393
+ style: command.style,
394
+ width: command.width,
352
395
  // A relative line height stays relative all the way to layout, where each line resolves it
353
396
  // against that line's own largest span; only an explicit one is absolute.
354
397
  ...(command.lineHeightScale === undefined
@@ -361,6 +404,7 @@ export class GpuText3D {
361
404
  anchor: resolveTextAnchor(command.anchor),
362
405
  }, this.#font);
363
406
  const space = command.space === "world-billboard" ? SPACE_WORLD_BILLBOARD : command.space === "screen" ? SPACE_SCREEN : SPACE_WORLD_PLANE;
407
+ const planeWorld = space === SPACE_WORLD_PLANE ? textPlaneWorld(command.rotation) : IDENTITY_WORLD;
364
408
  // Screen text never takes part in depth testing, whatever the command asked for.
365
409
  const flags = space | (command.depthTest && space !== SPACE_SCREEN ? 0 : FLAG_NO_DEPTH);
366
410
  // The quad's size comes from the layout, not from the atlas. A distance field can be drawn at
@@ -382,7 +426,7 @@ export class GpuText3D {
382
426
  const rasterScale = dynamicRaster
383
427
  ? rasterScaleFor(quantizeRasterPx(Math.max(glyph.fontSize * screenPxPerFontPx, glyph.fontSize * rasterFloor)), glyph.fontSize)
384
428
  : undefined;
385
- const entry = this.#atlas.glyph(glyph.character, glyph.fontFamily, glyph.fontSize, rasterScale);
429
+ const entry = this.#atlas.glyph(glyph.character, glyph.face, glyph.fontSize, rasterScale);
386
430
  if (entry === undefined)
387
431
  continue;
388
432
  const id = this.#ids[index] ?? this.#addInstance(index);
@@ -398,6 +442,9 @@ export class GpuText3D {
398
442
  outlineWidth: command.outlineWidth,
399
443
  outlineColor: command.outlineColor,
400
444
  });
445
+ // The matrix always reflects this block's own plane, because instances are reused across
446
+ // blocks and a stale basis would tilt a block that asked for none.
447
+ this.#glyphs.setWorld(id, planeWorld);
401
448
  index++;
402
449
  }
403
450
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from "./debug-draw-3d.js";
2
2
  export * from "./camera-2d.js";
3
- export * from "./frame-recorder.js";
4
- export * from "./frame-recorder-2d.js";
3
+ export * from "./frame-draw-list.js";
4
+ export * from "./frame-draw-list-2d.js";
5
5
  export * from "./color-space.js";
6
6
  export * from "./curve.js";
7
7
  export * from "./geometry-generators.js";
@@ -11,7 +11,6 @@ export * from "./gpu-text-3d.js";
11
11
  export * from "./infinite-grid-3d.js";
12
12
  export * from "./infinite-grid-2d.js";
13
13
  export * from "./mesh-batch-3d.js";
14
- export * from "./scenes-3d.js";
15
14
  export * from "./text-atlas.js";
16
15
  export * from "./text-font.js";
17
16
  export * from "./text-layout.js";
@@ -19,3 +18,4 @@ export * from "./text-sdf.js";
19
18
  export * from "./text-msdf.js";
20
19
  export * from "./text-msdf-font.js";
21
20
  export * from "./text-msdf-baked.js";
21
+ export type { CircleBasis } from "./vector-math.js";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from "./debug-draw-3d.js";
2
2
  export * from "./camera-2d.js";
3
- export * from "./frame-recorder.js";
4
- export * from "./frame-recorder-2d.js";
3
+ export * from "./frame-draw-list.js";
4
+ export * from "./frame-draw-list-2d.js";
5
5
  export * from "./color-space.js";
6
6
  export * from "./curve.js";
7
7
  export * from "./geometry-generators.js";
@@ -11,7 +11,6 @@ export * from "./gpu-text-3d.js";
11
11
  export * from "./infinite-grid-3d.js";
12
12
  export * from "./infinite-grid-2d.js";
13
13
  export * from "./mesh-batch-3d.js";
14
- export * from "./scenes-3d.js";
15
14
  export * from "./text-atlas.js";
16
15
  export * from "./text-font.js";
17
16
  export * from "./text-layout.js";
@@ -1,6 +1,6 @@
1
1
  import { type FramePass, type Gpu } from "vgpu";
2
2
  import type { MeshData } from "./geometry-generators.js";
3
- import type { Color } from "./frame-recorder.js";
3
+ import type { Color } from "./frame-draw-list.js";
4
4
  export declare class MeshBatch3D {
5
5
  #private;
6
6
  constructor(gpu: Gpu, mesh: MeshData, color: Color, solid: boolean);