lecodes-sdk 0.20.2 → 1.0.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 (77) hide show
  1. package/dist/global.d.ts +17 -5
  2. package/dist/types/canvas/Canvas.d.ts +2 -0
  3. package/dist/types/gl/DecalSet.d.ts +48 -3
  4. package/dist/types/gl/Geometry.d.ts +12 -0
  5. package/dist/types/gl/Lightmap.d.ts +9 -0
  6. package/dist/types/gl/Material.d.ts +4 -0
  7. package/dist/types/gl/Mesh.d.ts +7 -1
  8. package/dist/types/gl/Model.d.ts +34 -0
  9. package/dist/types/gl/Particles.d.ts +13 -0
  10. package/dist/types/gl/Scene.d.ts +1 -1
  11. package/dist/types/gl/animation/DynamicBone.d.ts +173 -0
  12. package/dist/types/gl/{IK.d.ts → animation/IK.d.ts} +4 -4
  13. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  14. package/dist/types/gl/{AudioSource.d.ts → audio/AudioSource.d.ts} +6 -6
  15. package/dist/types/gl/{AudioZone.d.ts → audio/AudioZone.d.ts} +4 -4
  16. package/dist/types/gl/{SceneAudio.d.ts → audio/SceneAudio.d.ts} +1 -1
  17. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  18. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  19. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  20. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  21. package/dist/types/gl/{Ragdoll.d.ts → physics/Ragdoll.d.ts} +4 -4
  22. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  23. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  24. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +6 -6
  25. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  26. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  27. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  28. package/dist/types/inject.d.ts +28 -26
  29. package/dist/types/runtime/files.d.ts +24 -1
  30. package/dist/types/scene/defineScene.d.ts +10 -3
  31. package/dist/types.json +1 -1
  32. package/package.json +1 -1
  33. package/prompts/README.md +142 -142
  34. package/src/bridges.d.ts +1599 -1481
  35. package/src/canvas/Canvas.ts +21 -0
  36. package/src/compile/__tests__/compile.test.ts +11 -0
  37. package/src/compile/header.ts +6 -3
  38. package/src/compile/index.ts +1 -0
  39. package/src/compile/sceneEditor.ts +42 -1
  40. package/src/gl/DecalSet.ts +132 -5
  41. package/src/gl/Geometry.ts +348 -284
  42. package/src/gl/Lightmap.ts +32 -5
  43. package/src/gl/Material.ts +21 -0
  44. package/src/gl/Mesh.ts +120 -102
  45. package/src/gl/Model.ts +33 -1
  46. package/src/gl/Node.ts +1 -1
  47. package/src/gl/Particles.ts +21 -3
  48. package/src/gl/Scene.ts +1 -1
  49. package/src/gl/animation/DynamicBone.ts +459 -0
  50. package/src/gl/{IK.ts → animation/IK.ts} +4 -4
  51. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  52. package/src/gl/{AudioSource.ts → audio/AudioSource.ts} +7 -7
  53. package/src/gl/{AudioZone.ts → audio/AudioZone.ts} +75 -75
  54. package/src/gl/{SceneAudio.ts → audio/SceneAudio.ts} +2 -2
  55. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  56. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  57. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  58. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  59. package/src/gl/{Ragdoll.ts → physics/Ragdoll.ts} +8 -6
  60. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  61. package/src/gl/{Trigger.ts → physics/Trigger.ts} +45 -45
  62. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  63. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +10 -10
  64. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  65. package/src/gl/vehicle/Vehicle.ts +666 -0
  66. package/src/gl/vehicle/Wheel.ts +290 -0
  67. package/src/inject.ts +226 -224
  68. package/src/runtime/files.ts +32 -2
  69. package/src/scene/defineScene.ts +26 -10
  70. package/src/scene/level.ts +2 -2
  71. package/dist/types/gl/Gearbox.d.ts +0 -86
  72. package/dist/types/gl/Vehicle.d.ts +0 -191
  73. package/dist/types/gl/Wheel.d.ts +0 -95
  74. package/src/gl/Gearbox.ts +0 -212
  75. package/src/gl/Vehicle.ts +0 -473
  76. package/src/gl/Wheel.ts +0 -240
  77. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
@@ -35,6 +35,11 @@ const OP = {
35
35
  DRAW_IMAGE: 30,
36
36
  } as const
37
37
 
38
+ // Above this many buffered values, a buffer that is STILL growing between bakes is a leak, not a
39
+ // drawing (a dense static picture is a few thousand; a 15 Hz graph without reset() passes this in
40
+ // seconds and doubles its frame cost every minute).
41
+ const CMD_GROWTH_WARN = 20000
42
+
38
43
  const ALIGN = { left: 0, center: 1, right: 2, start: 3, end: 4 } as const
39
44
  const BASELINE = { alphabetic: 0, top: 1, middle: 2, bottom: 3, hanging: 4, ideographic: 5 } as const
40
45
  const JOIN = { miter: 0, round: 1, bevel: 2 } as const
@@ -76,6 +81,11 @@ export class Canvas {
76
81
  // also refreshes the on-screen image node(s) via _creatorUI — the UI consumer the 2D/3D texture
77
82
  // hooks below don't cover. Stays a bare flag so a non-UI canvas never references the UI bridge.
78
83
  private _uiBound = false
84
+ // Growth watch: the command buffer is a RECORDING that every bake replays in full, so a canvas
85
+ // redrawn every frame without `reset()` gets slower with every update. `_bakeSurface` compares the
86
+ // buffer against the previous bake and warns once when it keeps growing past the threshold.
87
+ private _lastBakeLen = 0
88
+ private _warnedGrowth = false
79
89
 
80
90
  constructor(width: number, height: number, opts: { pixelRatio?: number } = {}) {
81
91
  this.width = width
@@ -198,6 +208,17 @@ export class Canvas {
198
208
 
199
209
  /** @internal — rasterize the recorded commands into the surface; returns the surface id. */
200
210
  _bakeSurface(): number {
211
+ const len = this._cmd.length
212
+ if (!this._warnedGrowth && len > CMD_GROWTH_WARN && this._lastBakeLen > 0 && len > this._lastBakeLen) {
213
+ this._warnedGrowth = true
214
+ console.warn(
215
+ `[canvas] ${this.width}×${this.height}: the command buffer keeps growing (${len} values, ` +
216
+ `${this._lastBakeLen} at the previous bake) — a Canvas records every call and each update() ` +
217
+ `replays all of it, so a redraw that never calls reset() gets slower every frame. ` +
218
+ `Call canvas.reset() before redrawing (or toBitmap() + reset() to flatten a drawing).`,
219
+ )
220
+ }
221
+ this._lastBakeLen = len
201
222
  const buf = new Float32Array(this._cmd)
202
223
  this._surfaceId = _creatorCanvas.rasterize(this._surfaceId, this.width, this.height, this.pixelRatio, buf, buf.length, this._refs)
203
224
  return this._surfaceId
@@ -39,6 +39,17 @@ test("a 2D project (new Scene2D) is marked 2d: enable and gl: disable", async ()
39
39
  expect(js).toContain("_creator2d.createScene")
40
40
  })
41
41
 
42
+ test("a 3D project with a Canvas is NOT marked 2d: enable (Canvas keeps a `_creator2d.` texture path)", async () => {
43
+ const js = await compileProject({
44
+ name: "Car",
45
+ publicUrl: "https://le.codes",
46
+ entries: [ { path: "/main.ts", type: "text", text: "const c = new Canvas(64, 64)\nc.update()\nnew Scene().open()\n" } ],
47
+ })
48
+ expect(js).toContain("_creator2d.") // the canvas 2D-texture path survives tree-shaking
49
+ expect(js).not.toContain("// 2d: enable") // …but no Scene2D → the 2D engine is not in use
50
+ expect(js).not.toContain("// gl: disable")
51
+ })
52
+
42
53
  test("2D and 3D bundles are DISJOINT (tree-shake drops the other engine)", async () => {
43
54
  const twoD = await compileProject({
44
55
  name: "2D", publicUrl: "https://le.codes", header: false,
@@ -33,8 +33,11 @@ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}
33
33
  else if (code.includes("_creator.createEngine")) header += `// gl: defer\n`
34
34
 
35
35
  // 2D (creator-2d) — non-exclusive: emitted alongside `gl: disable` so hosts skip Filament and a
36
- // 2D-aware host brings up the lightweight sokol engine instead.
37
- if (code.includes("_creator2d.")) header += `// 2d: enable\n`
36
+ // 2D-aware host brings up the lightweight sokol engine instead. Keyed on the scene constructor
37
+ // (like GL above), NOT on any `_creator2d.` reference: Canvas carries a 2D-texture path
38
+ // (`canvas.texture()` → `_creator2d.createTextureFromCanvas`) that chisel keeps in a pure-3D
39
+ // bundle, and nothing 2D works without a Scene2D anyway.
40
+ if (code.includes("_creator2d.createScene")) header += `// 2d: enable\n`
38
41
 
39
42
  if (code.includes("_creator.createARController")) header += `// ar: enable\n`
40
43
 
@@ -61,7 +64,7 @@ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}
61
64
  /** Same engine signals the header uses, for callers that need the project kind (e.g. icon type). */
62
65
  export const detectProjectKind = (code: string): "ar" | "ui" | "2d" | "3d" => {
63
66
  if (code.includes("_creator.createARController")) return "ar"
64
- if (code.includes("_creator2d.")) return "2d"
67
+ if (code.includes("_creator2d.createScene")) return "2d"
65
68
  if (!code.includes("_creator.")) return "ui"
66
69
  return "3d"
67
70
  }
@@ -73,6 +73,7 @@ export {
73
73
  export {
74
74
  loadSceneHarness,
75
75
  sceneEditorEntries,
76
+ sceneImportsOf,
76
77
  SCENE_ENTRY_PATH,
77
78
  SCENE_HARNESS_DIR,
78
79
  } from "./sceneEditor"
@@ -47,10 +47,44 @@ export const loadSceneHarness = (fallbackDir?: string): Record<string, string> =
47
47
  * the project's file list — `.editor.ts` entries are picked out of it. Compile with
48
48
  * `entryOverride: SCENE_ENTRY_PATH`, `header: false`, `format: "iife"`, `editor: true`.
49
49
  */
50
+ /** Resolve a relative import specifier against a project path ("./src/map.scene.ts" + "./map/x.scene"
51
+ * → "./src/map/x.scene"). */
52
+ const resolveRelative = (fromPath: string, spec: string): string => {
53
+ const segs = (p: string) => p.split("/").filter((s) => s !== "" && s !== ".")
54
+ const out = segs(fromPath).slice(0, -1)
55
+ for (const seg of segs(spec)) {
56
+ if (seg === "..") out.pop()
57
+ else out.push(seg)
58
+ }
59
+ return "./" + out.join("/")
60
+ }
61
+
62
+ /**
63
+ * The other scene files a scene file imports as prefabs (`import x from './x.scene'`), as project
64
+ * paths present in `projectPaths`. The edit bundle registers their handles BY PATH
65
+ * (`__lecodesSceneFiles`), so the editor can live-patch a `prefab:` node — add, duplicate, undo —
66
+ * without a compile: the doc sees `prefab: X` as an identifier, the host maps it through the
67
+ * file's import table to this path, and the harness resolves the path to the running handle.
68
+ */
69
+ export const sceneImportsOf = (sceneText: string, scenePath: string, projectPaths: string[]): string[] => {
70
+ const known = new Set(projectPaths)
71
+ const out: string[] = []
72
+ const re = /^\s*import\s+[A-Za-z_$][\w$]*\s+from\s+["']([^"']+\.scene(?:\.ts)?)["']/gm
73
+ for (const m of sceneText.matchAll(re)) {
74
+ const spec = m[1]
75
+ if (!spec.startsWith(".")) continue
76
+ let p = resolveRelative(scenePath, spec)
77
+ if (!p.endsWith(".ts")) p += ".ts"
78
+ if (known.has(p) && !out.includes(p)) out.push(p)
79
+ }
80
+ return out
81
+ }
82
+
50
83
  export const sceneEditorEntries = (
51
84
  scenePath: string,
52
85
  harness: Record<string, string>,
53
86
  projectPaths: string[] = [],
87
+ sceneImports: string[] = [],
54
88
  ): CompileEntry[] => {
55
89
  const entries: CompileEntry[] = Object.entries(harness).map(([ name, text ]) => ({
56
90
  path: `${SCENE_HARNESS_DIR}/${name}`,
@@ -74,15 +108,22 @@ export const sceneEditorEntries = (
74
108
  const materialImports = materialPaths.map((p, i) => `import __mat${i} from "${p}"\n`).join("")
75
109
  const materialRegister = materialPaths.length === 0 ? ""
76
110
  : `;(globalThis as any).__lecodesMaterials = { ${materialPaths.map((p, i) => `${JSON.stringify(p)}: __mat${i}`).join(", ")} }\n`
111
+ // The scene's prefab imports, registered by project path (see sceneImportsOf) — the same module
112
+ // instances the scene itself imported, so a live-patched prefab node is the same handle.
113
+ const sceneImportLines = sceneImports.map((p, i) => `import __scn${i} from "${p}"\n`).join("")
114
+ const sceneRegister = sceneImports.length === 0 ? ""
115
+ : `;(globalThis as any).__lecodesSceneFiles = { ${sceneImports.map((p, i) => `${JSON.stringify(p)}: __scn${i}`).join(", ")} }\n`
77
116
  entries.push({
78
117
  path: SCENE_ENTRY_PATH,
79
118
  type: "text",
80
119
  text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
81
120
  + materialImports
121
+ + sceneImportLines
82
122
  + `import "${scenePath}"\n`
83
123
  + editorImports
84
124
  + `import "${SCENE_HARNESS_DIR}/main.ts"\n`
85
- + materialRegister,
125
+ + materialRegister
126
+ + sceneRegister,
86
127
  })
87
128
  return entries
88
129
  }
@@ -35,6 +35,14 @@ export type DecalOptions = {
35
35
  fadeIn?: number
36
36
  /** Seconds of fade at the end of `life` (ignored with life 0). Default 0. */
37
37
  fadeOut?: number
38
+ /** Opacity multipliers at the image's BOTTOM and TOP edge (−Y / +Y), interpolated along it — a
39
+ * tire mark that darkens as the slide deepens. Default `[1, 1]`. */
40
+ gradient?: [number, number]
41
+ /** Tilt of the image's bottom and top edge, as a unit-space slope (Δy per Δx across the box):
42
+ * the edge pivots on the corner of the side it keeps and cuts INTO the box on the other, so two
43
+ * boxes can meet on one shared line — what `DecalTrail` uses to mitre its joints. `0` = square.
44
+ * Default `[0, 0]`. */
45
+ caps?: [number, number]
38
46
  }
39
47
 
40
48
  export type DecalSetOptions = DecalOptions & {
@@ -63,8 +71,10 @@ export type DecalSetOptions = DecalOptions & {
63
71
  /** HDR boost of the image (0 = none). Default material only. */
64
72
  emissive?: number
65
73
  name?: string
66
- /** Coarse draw order, 0 (first) … 7 (last); default 4 — see `Mesh.renderPriority`. Decals are
67
- * blended and sort with the other blended draws of their priority. */
74
+ /** Coarse draw order, 0 (first) … 7 (last) — see `Mesh.renderPriority`. Default 3: a decal is
75
+ * part of the surface it sits on, so it draws before every other blended thing (meshes 4,
76
+ * particles 5) — the engine's depth sort compares object centres, and a set spread over the
77
+ * level has no useful centre. */
68
78
  renderPriority?: number
69
79
  }
70
80
 
@@ -86,7 +96,7 @@ export type DecalPlacement = DecalOptions & {
86
96
  rotation?: QuatLike | Vec3Like
87
97
  }
88
98
 
89
- const RECORD = 23
99
+ const RECORD = 27
90
100
  const NO_SLOT = 0xFFFFFFFF
91
101
 
92
102
  export class DecalSet extends Node {
@@ -116,7 +126,7 @@ export class DecalSet extends Node {
116
126
  }
117
127
  if (_creator.createDecalSet) {
118
128
  _creator.createDecalSet(this.id, Material.idOf(this._material), this._max)
119
- if (options.renderPriority !== undefined) this.renderPriority = options.renderPriority
129
+ this.renderPriority = options.renderPriority ?? 3
120
130
  } else if (!DecalSet._warned) {
121
131
  DecalSet._warned = true
122
132
  console.warn("[decals] this host has no createDecalSet — decals are not drawn")
@@ -200,7 +210,8 @@ export class DecalSet extends Node {
200
210
  return this._write(slot, right, up, normal, new Vec3(p.position), p)
201
211
  }
202
212
 
203
- private _write(slot: number, right: Vec3, up: Vec3, normal: Vec3, centre: Vec3, o: DecalOptions): number {
213
+ /** @internal One decal's frame → record → host (`DecalTrail` writes its segments through here). */
214
+ _write(slot: number, right: Vec3, up: Vec3, normal: Vec3, centre: Vec3, o: DecalOptions): number {
204
215
  const d = this._defaults
205
216
  const size = o.size ?? d.size!
206
217
  const w = typeof size === "number" ? size : size[0]
@@ -222,6 +233,10 @@ export class DecalSet extends Node {
222
233
  const opacity = o.opacity ?? d.opacity!
223
234
  r[16] = tr; r[17] = tg; r[18] = tb; r[19] = ta * opacity
224
235
  r[20] = o.life ?? d.life!; r[21] = o.fadeIn ?? d.fadeIn!; r[22] = o.fadeOut ?? d.fadeOut!
236
+ const caps = o.caps ?? d.caps
237
+ r[23] = caps ? caps[0] : 0; r[24] = caps ? caps[1] : 0
238
+ const gradient = o.gradient ?? d.gradient
239
+ r[25] = gradient ? gradient[0] : 1; r[26] = gradient ? gradient[1] : 1
225
240
  if (slot === NO_SLOT) {
226
241
  if (!_creator.addDecal) return -1
227
242
  const s = _creator.addDecal(this.id, r)
@@ -231,3 +246,115 @@ export class DecalSet extends Node {
231
246
  return slot
232
247
  }
233
248
  }
249
+
250
+ /** A `DecalTrail`'s look: the strip's width, and what every segment carries. */
251
+ export type DecalTrailOptions = Omit<DecalOptions, "size" | "caps" | "gradient"> & {
252
+ /** Width of the strip on the surface, world units. */
253
+ width: number
254
+ }
255
+
256
+ // The mitre stops making sense past this turn between two segments (a sharp reversal would need a
257
+ // box longer than the strip is wide); the trail breaks there and starts afresh.
258
+ const TRAIL_MAX_SLOPE = 1.75 // tan 60°
259
+
260
+ /**
261
+ * A continuous strip of decals along a path — a tire's skid mark, a dragged body, a tread track.
262
+ * Feed it points (`add`) as the thing moves; every new point becomes one box-decal SEGMENT from the
263
+ * previous point, its image's up along the travel, and the joints between segments are MITRED: each
264
+ * box is cut along the bisector it shares with its neighbour (`caps`), so the strip has no overlaps
265
+ * darkening the outside of a bend and no wedges of gap on the inside. The opacity given with each
266
+ * point is interpolated along the segment (`gradient`), so a mark can darken as a slide deepens and
267
+ * fade as it ends. Segments are the set's decals — same ring, same `max` budget: a set of 900 holds
268
+ * 900 segments across every trail drawn from it.
269
+ *
270
+ * The previous segment is REWRITTEN when the next point arrives (its far cap becomes the shared
271
+ * mitre), through `DecalSet.update`'s path — a recycled slot is simply left alone by the engine.
272
+ */
273
+ export class DecalTrail {
274
+ private _prev: { point: Vec3, opacity: number } | null = null
275
+ private _seg: {
276
+ slot: number, start: Vec3, end: Vec3, dir: Vec3, right: Vec3, normal: Vec3,
277
+ slopeStart: number, a0: number, a1: number,
278
+ } | null = null
279
+
280
+ readonly set: DecalSet
281
+ readonly options: DecalTrailOptions
282
+
283
+ constructor(set: DecalSet, options: DecalTrailOptions) {
284
+ this.set = set
285
+ this.options = options
286
+ }
287
+
288
+ /** Extend the strip to `point` on a surface with `normal`, `opacity` (0..1) at that point. The
289
+ * first call after a start / `end()` only anchors the strip. Returns the new segment's slot, or
290
+ * −1 when nothing was drawn (the anchor, a point that did not move, no host support). */
291
+ add(point: Vec3Like, normal: Vec3Like, opacity = 1): number {
292
+ const p = new Vec3(point)
293
+ const n = new Vec3(normal).normalize()
294
+ const prev = this._prev
295
+ this._prev = { point: p, opacity }
296
+ if (!prev) return -1
297
+ // The segment, flattened onto the surface plane.
298
+ let d = p.sub(prev.point)
299
+ d = d.sub(n.scale(d.dot(n)))
300
+ const len = d.length()
301
+ if (len < 1e-3) { this._prev = prev; return -1 } // not moved: wait for a real step
302
+ const dir = d.scale(1 / len)
303
+ const right = dir.cross(n) // DecalSet.spawn's frame: right × up = normal, image unmirrored from the normal's side
304
+ const w = this.options.width
305
+
306
+ // Mitre with the previous segment: the joint's bisector is the sum of the two right normals,
307
+ // and each box takes it as a cap — the previous one at its END (rewritten now), this one at its
308
+ // START — with the slopes measured in each box's own frame. A trail's first segment, or one
309
+ // after a break, starts square.
310
+ let slopeStart = 0
311
+ const last = this._seg
312
+ if (last) {
313
+ const lastDir = last.dir.sub(n.scale(last.dir.dot(n))).normalize()
314
+ const lastRight = lastDir.cross(n)
315
+ const m = lastRight.add(right)
316
+ const denom = m.dot(right)
317
+ const slopeEnd = Math.abs(denom) > 1e-4 ? m.dot(lastDir) / denom : Infinity
318
+ if (Math.abs(slopeEnd) <= TRAIL_MAX_SLOPE) {
319
+ this._rewrite(last, slopeEnd)
320
+ slopeStart = -slopeEnd
321
+ } else {
322
+ this._rewrite(last, 0) // too sharp to mitre: leave the last one square, start afresh
323
+ }
324
+ }
325
+ const seg = { slot: -1, start: prev.point, end: p, dir, right, normal: n, slopeStart, a0: prev.opacity, a1: opacity }
326
+ seg.slot = this._box(seg, 0, -1)
327
+ this._seg = seg
328
+ return seg.slot
329
+ }
330
+
331
+ /** Finish the strip. With a `point`, one last segment runs out to it at opacity 0 — a mark that
332
+ * tapers away instead of stopping dead. The next `add` anchors a new strip. */
333
+ end(point?: Vec3Like, normal: Vec3Like = Vec3.up): this {
334
+ if (point && this._prev) this.add(point, normal, 0)
335
+ this._prev = null
336
+ this._seg = null
337
+ return this
338
+ }
339
+
340
+ private _rewrite(seg: NonNullable<DecalTrail["_seg"]>, slopeEnd: number): void {
341
+ if (seg.slot >= 0) this._box(seg, slopeEnd, seg.slot)
342
+ }
343
+
344
+ /** Write one segment's box: the strip between its joints, extended past each mitred joint by the
345
+ * mitre's reach (w/2 · |slope|) so the tilted cap still passes through the joint's centre. */
346
+ private _box(seg: NonNullable<DecalTrail["_seg"]>, slopeEnd: number, slot: number): number {
347
+ const w = this.options.width
348
+ const L = seg.end.sub(seg.start).length()
349
+ const extStart = 0.5 * w * Math.abs(seg.slopeStart)
350
+ const extEnd = 0.5 * w * Math.abs(slopeEnd)
351
+ const boxLen = L + extStart + extEnd
352
+ const centre = seg.start.add(seg.dir.scale(0.5 * boxLen - extStart))
353
+ // World slopes → unit-space slopes: x is measured in widths, y in box lengths.
354
+ const caps: [number, number] = [ seg.slopeStart * (w / boxLen), slopeEnd * (w / boxLen) ]
355
+ const { width: _w, ...rest } = this.options
356
+ const s = this.set._write(slot < 0 ? NO_SLOT : slot, seg.right, seg.dir, seg.normal, centre,
357
+ { ...rest, size: [ w, boxLen ], caps, gradient: [ seg.a0, seg.a1 ] })
358
+ return slot < 0 ? s : slot
359
+ }
360
+ }