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
package/README.md CHANGED
@@ -30,23 +30,23 @@ dependencies of this package.
30
30
 
31
31
  ## Usage
32
32
 
33
- The public API uses an explicit frame recorder:
33
+ The public API uses an explicit frame draw list:
34
34
 
35
35
  ```ts
36
- const recorder = debug.beginFrame();
37
- recorder.point([0, 0, 0], { color: [1, 0, 0, 1], size: 8 });
38
- recorder.line([0, 0, 0], [1, 0, 0], { color: [0, 1, 0, 1], width: 2 });
39
- recorder.arrow([0, 0, 0], [1, 1, 0], { color: [0, 0.5, 1, 1] });
40
- recorder.lineStrip([[0, 0, 0], [1, 0, 1], [2, 0, 0]], { width: 4, joint: "round" }, false);
36
+ const drawList = debug.beginFrame();
37
+ drawList.point([0, 0, 0], { color: [1, 0, 0, 1], size: 8 });
38
+ drawList.line([0, 0, 0], [1, 0, 0], { color: [0, 1, 0, 1], width: 2 });
39
+ drawList.arrow([0, 0, 0], [1, 1, 0], { color: [0, 0.5, 1, 1] });
40
+ drawList.lineStrip([[0, 0, 0], [1, 0, 1], [2, 0, 0]], { width: 4, joint: "round" }, false);
41
41
  // Per-vertex normals select the reference `*_n` shading: each segment uses its start
42
42
  // vertex's normal and the joints between them blend the two normals.
43
- recorder.lineStrip([[0, 0, 0], [1, 0, 0], [1, 1, 0]], { width: 4, joint: "round", normals: [[0, 1, 0], [0, 1, 0], [0, 1, 0]] }, false);
44
- recorder.grid([0, 0, 0], [4, 4], [0.5, 0.5], { color: [0.4, 0.4, 0.5, 1] });
45
- recorder.curve(cubicBezier, { color: [1, 1, 1, 1], tolerance: 0.08 });
46
- recorder.mesh(sphere(0.5), { solid: true, color: [0.2, 0.6, 1, 0.8] });
47
- recorder.text([0, 1, 0], "world label", { fontSize: 32, pixelSize: 0.01, color: [1, 0.8, 0.2, 1] });
48
- recorder.text([24, 24, 0], "screen HUD", { space: "screen", anchor: "top-left", fontSize: 16 });
49
- const commands = debug.finishFrame(recorder);
43
+ drawList.lineStrip([[0, 0, 0], [1, 0, 0], [1, 1, 0]], { width: 4, joint: "round", normals: [[0, 1, 0], [0, 1, 0], [0, 1, 0]] }, false);
44
+ drawList.grid([0, 0, 0], [4, 4], [0.5, 0.5], { color: [0.4, 0.4, 0.5, 1] });
45
+ drawList.curve(cubicBezier, { color: [1, 1, 1, 1], tolerance: 0.08 });
46
+ drawList.mesh(sphere(0.5), { solid: true, color: [0.2, 0.6, 1, 0.8] });
47
+ drawList.text([0, 1, 0], "world label", { fontSize: 32, pixelSize: 0.01, color: [1, 0.8, 0.2, 1] });
48
+ drawList.text([24, 24, 0], "screen HUD", { space: "screen", anchor: "top-left", fontSize: 16 });
49
+ const commands = debug.finishFrame(drawList);
50
50
 
51
51
  // `view` is the world-to-view matrix; normal-aware strokes read it like the reference view matrix.
52
52
  renderer.prepare(commands, { viewProjection, view });
@@ -108,7 +108,7 @@ alphas, which the addon does not expose.
108
108
  ## Pipeline
109
109
 
110
110
  ```text
111
- FrameRecorder -> FrameCommand[] -> GpuDebugDraw3D
111
+ FrameDrawList -> FrameCommand[] -> GpuDebugDraw3D
112
112
  points -> instanced billboards
113
113
  lines -> screen-space or world-space quads, dash + normal aware
114
114
  triangles -> transient solid meshes
@@ -129,10 +129,12 @@ const frame = debug.beginFrame();
129
129
  frame.d2.line([0, 0], [120, 40], { width: 2, color: [1, 0, 0, 1] });
130
130
  frame.d2.lineStrip([[0, 0], [40, 80], [90, 10]], { width: 4, joint: "round" });
131
131
  frame.d2.grid([0, 0], [8, 5], [40, 40], { color: [0.4, 0.4, 0.5, 1] });
132
- frame.d2.rect([0, 0], [80, 40], { fill: false, strokeWidth: 2 });
133
- frame.d2.roundedRect([0, -60], [120, 40], 12, { color: [1, 0.7, 0.3, 1] });
134
- frame.d2.circle([0, 120], 60, { color: [0, 0.5, 1, 1] });
135
- frame.d2.arc([0, 120], 80, { fill: false, strokeWidth: 3, startAngle: 0, sweep: Math.PI / 2 });
132
+ frame.d2.rect([0, 0], [80, 40], { strokeWidth: 2 });
133
+ frame.d2.rect_s([160, 0], [80, 40], { color: [0.9, 0.9, 1, 1] });
134
+ frame.d2.rect_d([320, 0], [80, 40], [12, 6, 0], { strokeWidth: 2 });
135
+ frame.d2.rect_r_s([0, -60], [120, 40], 12, { color: [1, 0.7, 0.3, 1] });
136
+ frame.d2.circle_s([0, 120], 60, { color: [0, 0.5, 1, 1] });
137
+ frame.d2.arc([0, 120], 80, { strokeWidth: 3, startAngle: 0, sweep: Math.PI / 2 });
136
138
  const commands = debug.finishFrame(frame);
137
139
  // The third argument is the 2D camera; omit it and the last one stays in effect.
138
140
  renderer.prepare(commands, { viewProjection, view }, { pan: [0, 0], zoom: 1 });
@@ -146,17 +148,32 @@ Two coordinate spaces share one pixel-based pipeline:
146
148
  | `screen` | top-left corner | down | one canvas pixel | no |
147
149
 
148
150
  `world2d` mirrors the reference 2D camera: the origin is the centre of the target, `+y` points up and
149
- `zoom` is canvas pixels per world unit. `2d_shapes`' `X_EXTENT = 1000` is a pixel-scale world
151
+ `zoom` is canvas pixels per world unit. Both spaces are in **target pixels**, like the text `screen`
152
+ space, so a HiDPI caller scales `screen` content itself (`gallery/screen-scale.ts` does that for the
153
+ gallery); `world2d` needs no such step because its projection already reads the target size. `2d_shapes`' `X_EXTENT = 1000` is a pixel-scale world
150
154
  coordinate in that sense, not a top-left one — the top-left, +y-down space is the reference UI space,
151
155
  and here it is `screen`, the same one `TextSpace "screen"` already uses.
152
156
 
153
- - Strokes: `line`, `ray`, `lineStrip`/`polyline`, `grid`, `point`; screen-pixel or world widths,
154
- dashes with the same phase accumulation as the 3D paths, and round/bevel/miter joints.
155
- - Shapes: `rect`, `roundedRect`, `circle`, `ellipse`, `arc`, `annulus`, `capsule`, `polygon`,
157
+ - Strokes: `line`, `ray`, `lineStrip`/`polyline`, `lineLoop`, `grid`, `point`; every stroke has a
158
+ `_d` twin (`line_d`, `ray_d`, `lineStrip_d`, `polyline_d`, `lineLoop_d`, `grid_d`) that takes the
159
+ same `[visible, gap, phase]` triple and phase accumulation as the 3D dashed paths. Screen-pixel or
160
+ world widths, and round/bevel/miter joints.
161
+ - Arrows: `arrow` / `arrow_s` / `arrow_d` / `arrow_s_d` — the open V head by default, a filled head
162
+ for `_s`, and the same two forms dashed for `_d`, with `headLength` / `headAngle` options.
163
+ - Curves: `curve(curve, options)` / `curve_d(curve, dash, options)` sample a cubic control polygon
164
+ (`Curve2D`, the 2D twin of `Curve3D`) into a strip, with `tolerance`, `transform.center/rotation/scale`
165
+ and `closed` the same way the 3D page uses them. The 2D Curve page mirrors the 3D one: a
166
+ construction band below the matrix draws every control point (pink anchors, amber handles) for the
167
+ seven reference curve shapes.
168
+ - Shapes: `rect`, `rect_r`, `circle`, `ellipse`, `arc`, `annulus`, `capsule`, `polygon`,
156
169
  `triangle`, `rhombus`, `sector`, `segment`. Each is one instanced quad evaluated as a signed
157
170
  distance field in the fragment stage, so nothing is triangulated on the CPU and an outline
158
- width stays a pixel count at any zoom. `fill: false` plus `strokeWidth` turns any of them into
159
- an outline.
171
+ width stays a pixel count at any zoom. Every primitive follows the 3D naming: `<shape>` strokes
172
+ the outline, `<shape>_s` fills it and `<shape>_d` dashes the outline along its perimeter,
173
+ taking the same `[visible, gap, phase]` triple as the line layer. `arc` has no interior, so it
174
+ has no `_s` variant, and `Shape2DOptions` no longer carries `fill` — the method name decides.
175
+ Dashes are cut from the shape's perimeter parameter, a closed form for every primitive except
176
+ the ellipse (Simpson-integrated arc length), so they stay continuous across corners.
160
177
  - The layer is an overlay: it draws after every 3D primitive, pins itself to the near plane and
161
178
  does not write depth. Screen-space text remains the last thing drawn.
162
179
  - `InfiniteGrid2D` renders the reference's 2D grid as a full-viewport shader quad: decimal LOD levels,
@@ -164,11 +181,17 @@ does not write depth. Screen-space text remains the last thing drawn.
164
181
  `cellSize`, `lineWidthPixels`, `minPixelsBetweenCells`, the LOD range and both line colors from
165
182
  the gallery's grid panel, and draws as the 2D layer's background.
166
183
  - The 2D camera is independent of the 3D one (`mode: "3d" | "2d"` in the gallery config); the
167
- `2d-*` pages fit their declared `view2d` extent, drag to pan and scroll to zoom.
184
+ `2d-*` pages fit their declared `view2d` extent, drag or hold WASD to pan, hold shift to sprint, and scroll to zoom. The gallery
185
+ classifies the 2D layer like the 3D scenes: `2d-index` is the API index, the twelve shape pages
186
+ (`2d-rect`, `2d-rounded-rect`, `2d-circle`, `2d-ellipse`, `2d-arc`, `2d-annulus`, `2d-capsule`,
187
+ `2d-polygon`, `2d-triangle`, `2d-rhombus`, `2d-sector`, `2d-segment`) each own one primitive, and
188
+ `2d-lines`, `2d-curves`, `2d-markers`, `2d-arrows`, `2d-spaces`, `2d-grid` cover the stroke API
189
+ families. Every matrix page's column titles and sample labels are `world2d` text, so they pan
190
+ and zoom with the geometry they annotate.
168
191
 
169
192
  ## Text
170
193
 
171
- `recorder.text(position, text, options)` draws one block per call. `text` is a string or a list
194
+ `drawList.text(position, text, options)` draws one block per call. `text` is a string or a list
172
195
  of spans, the equivalent of a text root entity plus its child span entities.
173
196
 
174
197
  Three placement spaces share one pipeline and are selected per block:
@@ -179,6 +202,13 @@ Three placement spaces share one pipeline and are selected per block:
179
202
  | `world-billboard` | world units, block rebuilt from the camera's axes | `fontSize × pixelSize` |
180
203
  | `screen` | **top-left origin pixels** (`position.z` ignored) | `fontSize` pixels |
181
204
 
205
+ The `screen` space is in **target pixels** — the same units as the pass's viewport — because the
206
+ library has no DOM access and must not guess a display density. A caller on a HiDPI display owns the
207
+ conversion: the gallery authors its pages in CSS pixels and multiplies every screen-space command by
208
+ `devicePixelRatio` before handing the frame over (`gallery/screen-scale.ts`). A block placed at
209
+ `[24, 24]` with `fontSize: 18` in CSS pixels then draws at `[48, 48]` and 36 target pixels on a 2×
210
+ display, which is what keeps a label the same physical size on every screen.
211
+
182
212
  `fontSize` is in **font pixels** and is the layout unit; `pixelSize` is world units per font pixel.
183
213
  That pair is the reference `font_size` / `pixel_size`, and it lets a block keep a fixed world
184
214
  size while its glyphs are rasterised at any resolution. Which resolution that is belongs to the glyph
@@ -187,6 +217,12 @@ source: one that can rasterise on demand is asked for the size the block covers
187
217
  `fontSize × rasterScale`. Layout options follow the reference layout model: `justify`, `lineBreak`,
188
218
  `bounds` (`TextBounds`, in font pixels), `anchor`, `lineHeight` and `letterSpacing`.
189
219
 
220
+ `rotation` turns a `world-plane` block away from the XY plane, with the reference `Label3D.rotation`
221
+ Euler order (Y, then X, then Z) in radians. `[-Math.PI / 2, 0, 0]` lays a block flat on the XZ plane
222
+ so it reads from above, which is what the reference example labels do; the two camera-relative
223
+ spaces ignore it. The basis travels in the instance world matrix, so the text pipeline stays inside
224
+ the device's vertex attribute budget.
225
+
190
226
  `lineHeight` is the absolute pixel form when you pass one, and the default is a `1.2` multiple of the
191
227
  font size otherwise — which means a line's box is that multiple of **the line's own
192
228
  largest span**, so a line holding a bigger span is taller. `lineHeightScale` names that multiple
@@ -198,6 +234,34 @@ shaping pass adds it to each cluster's advance.
198
234
  World text is depth-tested but never depth-written, so geometry in front of it
199
235
  occludes it; `screen` text pins itself to the near plane and always wins.
200
236
 
237
+ ### Font face
238
+
239
+ `weight` (1–1000, default `400`), `style` (`normal` or `italic`) and `width` (the nine
240
+ `font-stretch` keywords) select the face a block is rasterised in, mirroring bevy's `TextFont`.
241
+ They are block defaults, and a span overrides one axis of them at a time rather than replacing the
242
+ whole face. The glyph atlas keys on all three, so two weights of one character cannot share texels.
243
+
244
+ How much of that a browser can honour depends on the loaded face, and the limits are real:
245
+
246
+ | option | what Canvas2D does |
247
+ | --- | --- |
248
+ | `weight` | A weight the loaded family does not carry is **synthesised** in one step (the threshold sits between 500 and 600), so `700` and `900` then look identical and the advance never changes. A variable font's real `wght` axis is not reachable — Canvas2D has no `font-variation-settings` — but the browser **does** pick a face by the weight it was registered with, so registering the same family once per weight gives real weights. |
249
+ | `style` | `italic` is the only slant the font shorthand keeps; `oblique <angle>` reads back as plain `italic`, which is why bevy's `FontStyle::Oblique(a)` is not exposed. A face without an italic is slanted by the browser. |
250
+ | `width` | The keyword only reaches the font when the face has a `wdth` axis. The bundled static subset has none, so every stop draws at normal width there. A percentage is rejected by the shorthand, so only the nine named stops exist here. |
251
+
252
+ The bundled Fira Mono subset is a static Medium face, so out of the box `weight` only ever produces
253
+ one synthesised step. `loadFontFaces(family, faces)` is the way out: it registers each entry as a face
254
+ of `family` with its own `weight` descriptor, so `weight: 200` through `900` select genuinely different
255
+ faces. The gallery does exactly that for the `text-font-query` page, which draws with eight instances
256
+ of a variable font built by `bun scripts/build-font-faces.ts` (see
257
+ `gallery/assets/`). That is gallery-only payload: the library's own entry point does not
258
+ carry it.
259
+
260
+ A custom glyph source receives the resolved face — `{ family, weight, style, width }` — instead of a
261
+ family string, because those are the fields the atlas keys on. A source that holds one fixed face (a
262
+ prebaked page, one parsed font buffer) sets `faceKey` to the part it can honour, so a weight it cannot
263
+ rasterise does not multiply identical atlas entries.
264
+
201
265
  ### Outline
202
266
 
203
267
  `outlineWidth` (in font pixels) rings every glyph in the block in `outlineColor`, which defaults to
@@ -381,8 +445,8 @@ const renderer = new GpuDebugDraw3D(gpu, {
381
445
  },
382
446
  });
383
447
 
384
- recorder.text([0, 0, 0], "read me", { source: "sharp" });
385
- recorder.text([0, -1, 0], "and me", { source: "cheap" });
448
+ drawList.text([0, 0, 0], "read me", { source: "sharp" });
449
+ drawList.text([0, -1, 0], "and me", { source: "cheap" });
386
450
  ```
387
451
 
388
452
  Blocks that name nothing use the first source registered, and `defaultTextStyle` can set one for the
@@ -391,7 +455,10 @@ actually names it, so registering sources you never draw costs nothing.
391
455
 
392
456
  Seven scenes exercise text end to end, and none of them is a port — the reference addon has no text
393
457
  API; the layout they run follows the reference layout model, and `docs/text-reference.md` records how
394
- that was verified.
458
+ that was verified. The geometry examples also draw their own labels, because the reference scenes
459
+ own `Label3D` captions for every column and row; those go through the same pipeline, lying flat on
460
+ the ground with `rotation: [-Math.PI / 2, 0, 0]`. A `GpuDebugDraw3D` built without a `text` option
461
+ skips those blocks instead of failing, so geometry-only callers keep working.
395
462
 
396
463
  `text-compare` is the parity matrix, in the shape of the line/circle examples: rows are glyph sources,
397
464
  columns are font sizes, transposed relative to those because text grows sideways. The six
@@ -457,7 +524,7 @@ gallery to a single glyph source (`auto`, `bitmap`, `bitmap4x`, `sdf`, `sdf4x`,
457
524
  - `InfiniteGrid3D` shader-driven ground grid ported from the reference grid addon
458
525
  (decimal LOD layers, screen-space width, edge/grazing/center/far fades).
459
526
  - Orbit/pan/zoom camera controls in the gallery, tuned to the reference
460
- `entities/free_camera.gd` and layered on a velocity model (`src/demo-camera.ts`): a drag is
527
+ `entities/free_camera.gd` and layered on a velocity model (`gallery/demo-camera.ts`): a drag is
461
528
  an angular velocity (0.25°/px) or a linear velocity (`distance`/FOV in perspective,
462
529
  `orthographicHeight` in orthographic), a wheel notch is a scalar velocity in log₂ space
463
530
  (1.08× per notch), and a released drag keeps its velocity and glides — the exact integral of
@@ -488,7 +555,7 @@ const debug = new DebugDraw3D({
488
555
  });
489
556
  ```
490
557
 
491
- `src/demo-config.ts` contains independently validated camera/view configuration
558
+ `gallery/demo-config.ts` contains independently validated camera/view configuration
492
559
  and injected storage helpers. Gallery controls support grid, opacity, animation,
493
560
  width multiplier, projection, background, camera preset and reset; camera
494
561
  interaction is persisted. The sidebar groups them into three collapsible
@@ -507,11 +574,11 @@ deterministic preset without localStorage: `/?compare=1&scene=circle`. Orthograp
507
574
  changes the orthographic extent.
508
575
 
509
576
  The gallery UI is built with **Base UI + shadcn (base-lyra preset) + Tailwind v4**
510
- in `src/components/`, and the imperative WebGPU side lives in `src/demo-engine.ts`.
577
+ in `gallery/components/`, and the imperative WebGPU side lives in `gallery/demo-engine.ts`.
511
578
  React only renders chrome; the render loop, camera, and persistence stay outside
512
579
  React and are read through `useSyncExternalStore`
513
- (`src/demo-hooks.ts`). `src/demo.tsx` mounts the shell, `src/index.css` holds the base-lyra
514
- theme tokens, and `src/demo.css` keeps the three-column layout, compare mode
580
+ (`gallery/demo-hooks.ts`). `gallery/demo.tsx` mounts the shell, `gallery/index.css` holds the base-lyra
581
+ theme tokens, and `gallery/demo.css` keeps the three-column layout, compare mode
515
582
  and code highlighting.
516
583
 
517
584
  Runtime comparison viewers (see `docs/visual-comparison.md` and `docs/text-reference.md`):
package/dist/curve.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Vec3 } from "./frame-recorder.js";
1
+ import type { Vec2 } from "./camera-2d.js";
2
+ import type { Vec3 } from "./frame-draw-list.js";
2
3
  export type CurvePoint = {
3
4
  readonly position: Vec3;
4
5
  readonly in?: Vec3;
@@ -14,3 +15,27 @@ export type CurveTransform = {
14
15
  };
15
16
  export declare function rotatePoint(p: Vec3, rotation: readonly [number, number, number]): Vec3;
16
17
  export declare function sampleCurve(curve: Curve3D, tolerance?: number, transform?: CurveTransform): Vec3[];
18
+ /** One control point of a 2D cubic curve; `in`/`out` are handles relative to `position`. */
19
+ export type CurvePoint2D = {
20
+ readonly position: Vec2;
21
+ readonly in?: Vec2;
22
+ readonly out?: Vec2;
23
+ };
24
+ export type Curve2D = {
25
+ readonly points: readonly CurvePoint2D[];
26
+ readonly closed?: boolean;
27
+ };
28
+ export type CurveTransform2D = {
29
+ readonly center?: Vec2;
30
+ /** Anticlockwise rotation in radians. */
31
+ readonly rotation?: number;
32
+ readonly scale?: number;
33
+ };
34
+ /** Applies a curve transform to one point; callers drawing control points use this. */
35
+ export declare function placeCurvePoint2D(point: Vec2, transform?: CurveTransform2D): Vec2;
36
+ /**
37
+ * Samples a 2D cubic curve into a polyline, the 2D twin of `sampleCurve`. `tolerance` is the
38
+ * approximate segment length in `world2d` units: smaller values subdivide each cubic further, so
39
+ * a `lineStrip` can follow the curve as closely as the caller needs.
40
+ */
41
+ export declare function sampleCurve2D(curve: Curve2D, tolerance?: number, transform?: CurveTransform2D): Vec2[];
package/dist/curve.js CHANGED
@@ -44,3 +44,56 @@ export function sampleCurve(curve, tolerance = 0.08, transform = {}) {
44
44
  out.push(place(curve.closed ? points[0].position : points[points.length - 1].position));
45
45
  return out;
46
46
  }
47
+ const add2 = (a, b) => [a[0] + b[0], a[1] + b[1]];
48
+ function cubic2(p0, p1, p2, p3, t) {
49
+ const u = 1 - t;
50
+ const a = u * u * u;
51
+ const b = 3 * u * u * t;
52
+ const c = 3 * u * t * t;
53
+ const d = t * t * t;
54
+ return [
55
+ a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0],
56
+ a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1],
57
+ ];
58
+ }
59
+ /** Applies a curve transform to one point; callers drawing control points use this. */
60
+ export function placeCurvePoint2D(point, transform = {}) {
61
+ const rotation = transform.rotation ?? 0;
62
+ const scale = transform.scale ?? 1;
63
+ const center = transform.center ?? [0, 0];
64
+ const cos = Math.cos(rotation);
65
+ const sin = Math.sin(rotation);
66
+ const x = point[0] * scale;
67
+ const y = point[1] * scale;
68
+ return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos];
69
+ }
70
+ /**
71
+ * Samples a 2D cubic curve into a polyline, the 2D twin of `sampleCurve`. `tolerance` is the
72
+ * approximate segment length in `world2d` units: smaller values subdivide each cubic further, so
73
+ * a `lineStrip` can follow the curve as closely as the caller needs.
74
+ */
75
+ export function sampleCurve2D(curve, tolerance = 1, transform = {}) {
76
+ const points = curve.points;
77
+ const place = (p) => placeCurvePoint2D(p, transform);
78
+ if (points.length < 2)
79
+ return points.map((point) => place(point.position));
80
+ const out = [];
81
+ const count = curve.closed ? points.length : points.length - 1;
82
+ const step = Math.max(tolerance, 0.1);
83
+ for (let i = 0; i < count; i++) {
84
+ const a = points[i];
85
+ const b = points[(i + 1) % points.length];
86
+ const p0 = a.position;
87
+ const p1 = add2(a.position, a.out ?? [0, 0]);
88
+ const p2 = add2(b.position, b.in ?? [0, 0]);
89
+ const p3 = b.position;
90
+ const hull = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) +
91
+ Math.hypot(p2[0] - p1[0], p2[1] - p1[1]) +
92
+ Math.hypot(p3[0] - p2[0], p3[1] - p2[1]);
93
+ const steps = Math.max(2, Math.min(64, Math.ceil(hull / step)));
94
+ for (let s = 0; s < steps; s++)
95
+ out.push(place(cubic2(p0, p1, p2, p3, s / steps)));
96
+ }
97
+ out.push(place(curve.closed ? points[0].position : points[points.length - 1].position));
98
+ return out;
99
+ }
@@ -1,4 +1,4 @@
1
- import { FrameRecorder, type FrameCommand, type LayerMask, type LineDefaults, type PointDefaults } from "./frame-recorder.js";
1
+ import { FrameDrawList, type FrameCommand, type LayerMask, type LineDefaults, type PointDefaults } from "./frame-draw-list.js";
2
2
  export type DebugDrawOptions = {
3
3
  defaultVisibleLayers?: LayerMask;
4
4
  /** Style applied to every point that does not override it. */
@@ -9,6 +9,6 @@ export type DebugDrawOptions = {
9
9
  export declare class DebugDraw3D {
10
10
  #private;
11
11
  constructor(options?: DebugDrawOptions);
12
- beginFrame(): FrameRecorder;
13
- finishFrame(recorder: FrameRecorder): readonly FrameCommand[];
12
+ beginFrame(): FrameDrawList;
13
+ finishFrame(drawList: FrameDrawList): readonly FrameCommand[];
14
14
  }
@@ -1,4 +1,4 @@
1
- import { FrameRecorder, resolveFrameRecorderDefaults, } from "./frame-recorder.js";
1
+ import { FrameDrawList, resolveFrameDrawListDefaults, } from "./frame-draw-list.js";
2
2
  export class DebugDraw3D {
3
3
  #visibleLayers;
4
4
  #defaults;
@@ -14,22 +14,22 @@ export class DebugDraw3D {
14
14
  ...(options.defaultLineStyle === undefined ? {} : { line: { ...options.defaultLineStyle } }),
15
15
  };
16
16
  // Validate eagerly so invalid defaults fail at construction, not on the first frame.
17
- resolveFrameRecorderDefaults(this.#defaults);
17
+ resolveFrameDrawListDefaults(this.#defaults);
18
18
  }
19
19
  beginFrame() {
20
20
  if (this.#active !== undefined) {
21
21
  throw new Error("a debug draw frame is already active");
22
22
  }
23
- const recorder = new FrameRecorder(this.#defaults);
24
- this.#active = recorder;
25
- return recorder;
23
+ const drawList = new FrameDrawList(this.#defaults);
24
+ this.#active = drawList;
25
+ return drawList;
26
26
  }
27
- finishFrame(recorder) {
28
- if (this.#active !== recorder) {
29
- throw new Error("recorder does not belong to this debug draw instance");
27
+ finishFrame(drawList) {
28
+ if (this.#active !== drawList) {
29
+ throw new Error("drawList does not belong to this debug draw instance");
30
30
  }
31
- const commands = recorder.commands(this.#visibleLayers);
32
- recorder.seal();
31
+ const commands = drawList.commands(this.#visibleLayers);
32
+ drawList.seal();
33
33
  this.#active = undefined;
34
34
  return commands;
35
35
  }