@jgengine/core 0.19.0 → 0.22.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 (266) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +3 -0
  41. package/dist/editor/index.js +1 -0
  42. package/dist/editor/liveSync.d.ts +4 -1
  43. package/dist/editor/liveSync.js +26 -10
  44. package/dist/editor/materialAuthoring.d.ts +14 -0
  45. package/dist/editor/materialAuthoring.js +38 -0
  46. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  47. package/dist/editor/modelNodeVisibility.js +29 -0
  48. package/dist/editor/movementCatalog.d.ts +90 -0
  49. package/dist/editor/movementCatalog.js +144 -0
  50. package/dist/editor/simulation.d.ts +60 -0
  51. package/dist/editor/simulation.js +202 -0
  52. package/dist/editor/staticPrefab.d.ts +17 -0
  53. package/dist/editor/staticPrefab.js +51 -0
  54. package/dist/editor/terrainMaterials.d.ts +10 -0
  55. package/dist/editor/terrainMaterials.js +13 -0
  56. package/dist/editor/types.d.ts +11 -0
  57. package/dist/game/cameraConfig.d.ts +3 -1
  58. package/dist/game/clipRoles.d.ts +2 -1
  59. package/dist/game/clipRoles.js +7 -3
  60. package/dist/game/contentValidation.d.ts +69 -0
  61. package/dist/game/contentValidation.js +118 -0
  62. package/dist/game/controlGate.d.ts +8 -0
  63. package/dist/game/controlGate.js +43 -6
  64. package/dist/game/defineGame.d.ts +32 -3
  65. package/dist/game/dialogueGraph.d.ts +20 -0
  66. package/dist/game/dialogueGraph.js +107 -0
  67. package/dist/game/events.d.ts +5 -0
  68. package/dist/game/keyValueStore.d.ts +17 -4
  69. package/dist/game/keyValueStore.js +35 -23
  70. package/dist/game/lootModifiers.d.ts +17 -0
  71. package/dist/game/lootModifiers.js +26 -0
  72. package/dist/game/narrativeAuthoring.d.ts +67 -0
  73. package/dist/game/narrativeAuthoring.js +122 -0
  74. package/dist/game/playableGame.d.ts +52 -20
  75. package/dist/game/quest.d.ts +15 -0
  76. package/dist/game/quest.js +209 -63
  77. package/dist/game/questCatalog.d.ts +50 -0
  78. package/dist/game/questCatalog.js +205 -0
  79. package/dist/game/saveStore.d.ts +6 -5
  80. package/dist/game/saveStore.js +57 -57
  81. package/dist/game/systemRuntime.d.ts +2 -2
  82. package/dist/game/systemRuntime.js +84 -33
  83. package/dist/gameplay.d.ts +3 -0
  84. package/dist/gameplay.js +3 -0
  85. package/dist/interaction/promptRegistry.d.ts +5 -4
  86. package/dist/interaction/promptRegistry.js +8 -2
  87. package/dist/inventory/inventoryModel.js +9 -6
  88. package/dist/inventory/shapedGrid.d.ts +48 -9
  89. package/dist/inventory/shapedGrid.js +69 -10
  90. package/dist/item/durability.js +4 -0
  91. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  92. package/dist/item/itemInstanceRegistry.js +111 -7
  93. package/dist/item/itemgen.d.ts +1 -1
  94. package/dist/item/itemgen.js +1 -1
  95. package/dist/item/modularItem.js +7 -1
  96. package/dist/material/appearanceSignals.d.ts +53 -0
  97. package/dist/material/appearanceSignals.js +47 -0
  98. package/dist/material/hairCards.d.ts +47 -0
  99. package/dist/material/hairCards.js +136 -0
  100. package/dist/material/materialAsset.d.ts +162 -0
  101. package/dist/material/materialAsset.js +382 -0
  102. package/dist/meta/changelog.d.ts +1 -1
  103. package/dist/meta/changelog.js +348 -1
  104. package/dist/movement/characterController.d.ts +5 -2
  105. package/dist/movement/characterController.js +50 -23
  106. package/dist/movement/freeFlight.d.ts +2 -2
  107. package/dist/movement/freeFlight.js +3 -3
  108. package/dist/movement/movementModel.d.ts +24 -1
  109. package/dist/movement/movementModel.js +74 -7
  110. package/dist/movement/playerMovement.d.ts +28 -1
  111. package/dist/movement/playerMovement.js +425 -102
  112. package/dist/movement/poseState.d.ts +2 -0
  113. package/dist/movement/poseState.js +29 -1
  114. package/dist/movement/solidObstacles.d.ts +3 -1
  115. package/dist/movement/solidObstacles.js +8 -5
  116. package/dist/movement/terrainGrade.d.ts +13 -0
  117. package/dist/movement/terrainGrade.js +33 -0
  118. package/dist/movement/voxelController.d.ts +2 -2
  119. package/dist/movement/voxelController.js +3 -3
  120. package/dist/nav/navGrid.d.ts +19 -1
  121. package/dist/nav/navGrid.js +26 -10
  122. package/dist/nav/solidRoute.d.ts +50 -0
  123. package/dist/nav/solidRoute.js +144 -0
  124. package/dist/physics/ballisticSweep.d.ts +10 -3
  125. package/dist/physics/ballisticSweep.js +53 -29
  126. package/dist/physics/forceVolume.d.ts +41 -0
  127. package/dist/physics/forceVolume.js +88 -0
  128. package/dist/physics/kinematicVehicle.d.ts +8 -3
  129. package/dist/physics/kinematicVehicle.js +2 -2
  130. package/dist/physics/physicsWorldBackend.js +19 -3
  131. package/dist/physics/spatialGrid.d.ts +1 -1
  132. package/dist/physics/spatialGrid.js +8 -9
  133. package/dist/physics/vehicleDynamics.d.ts +3 -2
  134. package/dist/physics/vehicleDynamics.js +2 -2
  135. package/dist/physics/vehicleObstacles.d.ts +3 -2
  136. package/dist/physics/vehicleObstacles.js +29 -5
  137. package/dist/runtime/cameraDirector.d.ts +39 -2
  138. package/dist/runtime/cameraDirector.js +162 -0
  139. package/dist/runtime/commandDispatch.d.ts +14 -0
  140. package/dist/runtime/commandDispatch.js +19 -0
  141. package/dist/runtime/context/combat.d.ts +11 -4
  142. package/dist/runtime/context/combat.js +178 -11
  143. package/dist/runtime/context/deathLoot.d.ts +9 -8
  144. package/dist/runtime/context/deathLoot.js +16 -16
  145. package/dist/runtime/context/scene.d.ts +2 -0
  146. package/dist/runtime/context/scene.js +13 -21
  147. package/dist/runtime/descriptors/baseline.js +34 -3
  148. package/dist/runtime/descriptors/features.d.ts +2 -1
  149. package/dist/runtime/descriptors/features.js +18 -0
  150. package/dist/runtime/gameContext.js +79 -13
  151. package/dist/runtime/gameContextTypes.d.ts +14 -0
  152. package/dist/runtime/headlessRunner.d.ts +3 -1
  153. package/dist/runtime/headlessRunner.js +11 -11
  154. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  155. package/dist/runtime/hostedGameRunner.js +156 -10
  156. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  157. package/dist/runtime/hostedWorldSession.js +154 -16
  158. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  159. package/dist/runtime/inputRecorder.d.ts +1 -1
  160. package/dist/runtime/inputRecorder.js +21 -1
  161. package/dist/runtime/inputSnapshot.d.ts +16 -4
  162. package/dist/runtime/inputSnapshot.js +31 -3
  163. package/dist/runtime/runtimeSave.d.ts +10 -6
  164. package/dist/runtime/runtimeSave.js +11 -5
  165. package/dist/runtime/simContext.d.ts +8 -3
  166. package/dist/runtime/simContext.js +7 -3
  167. package/dist/runtime/simSnapshot.js +2 -0
  168. package/dist/runtime/worldReplication.d.ts +2 -0
  169. package/dist/runtime/worldReplication.js +1 -1
  170. package/dist/scene/assetCatalog.d.ts +2 -0
  171. package/dist/scene/behaviorRuntime.d.ts +26 -5
  172. package/dist/scene/behaviorRuntime.js +263 -16
  173. package/dist/scene/behaviors.d.ts +30 -1
  174. package/dist/scene/behaviors.js +29 -0
  175. package/dist/scene/entityStore.d.ts +14 -3
  176. package/dist/scene/entityStore.js +45 -9
  177. package/dist/scene/mount.d.ts +9 -0
  178. package/dist/scene/mount.js +35 -0
  179. package/dist/scene/possession.d.ts +2 -0
  180. package/dist/scene/possession.js +21 -1
  181. package/dist/scene/sceneRaycast.d.ts +7 -0
  182. package/dist/scene/sceneRaycast.js +55 -0
  183. package/dist/scene/spatial.d.ts +20 -1
  184. package/dist/scene/spatial.js +94 -2
  185. package/dist/scene/vehicleSeat.d.ts +36 -2
  186. package/dist/scene/vehicleSeat.js +89 -2
  187. package/dist/stats/accumulatorMeter.d.ts +10 -0
  188. package/dist/stats/accumulatorMeter.js +6 -0
  189. package/dist/stats/eventMeter.d.ts +9 -2
  190. package/dist/stats/eventMeter.js +5 -2
  191. package/dist/store/defineStore.d.ts +3 -1
  192. package/dist/store/defineStore.js +3 -1
  193. package/dist/store/observableKeyedStore.d.ts +1 -1
  194. package/dist/store/observableKeyedStore.js +8 -6
  195. package/dist/time/simClock.d.ts +6 -2
  196. package/dist/time/simClock.js +5 -0
  197. package/dist/vfx/particleDirector.d.ts +58 -6
  198. package/dist/vfx/particleDirector.js +113 -22
  199. package/dist/vfx/particles.d.ts +68 -5
  200. package/dist/vfx/particles.js +284 -32
  201. package/dist/visibility/assetStreaming.d.ts +21 -3
  202. package/dist/visibility/assetStreaming.js +108 -24
  203. package/dist/visibility/settings.d.ts +8 -2
  204. package/dist/visibility/settings.js +3 -0
  205. package/dist/visibility/visibilitySystem.js +1 -0
  206. package/dist/work/expedition.d.ts +110 -0
  207. package/dist/work/expedition.js +195 -0
  208. package/dist/work/staffedStation.d.ts +29 -0
  209. package/dist/work/staffedStation.js +27 -0
  210. package/dist/world/authoredAnimation.d.ts +38 -0
  211. package/dist/world/authoredAnimation.js +99 -0
  212. package/dist/world/authoredObjects.d.ts +24 -4
  213. package/dist/world/authoredObjects.js +43 -16
  214. package/dist/world/authoredSimulation.d.ts +74 -0
  215. package/dist/world/authoredSimulation.js +302 -0
  216. package/dist/world/authoredWeather.d.ts +120 -0
  217. package/dist/world/authoredWeather.js +314 -0
  218. package/dist/world/buildingKit.d.ts +8 -3
  219. package/dist/world/buildingKit.js +21 -8
  220. package/dist/world/environmentForces.d.ts +21 -0
  221. package/dist/world/environmentForces.js +42 -0
  222. package/dist/world/features.d.ts +3 -3
  223. package/dist/world/footprintGrid.d.ts +55 -0
  224. package/dist/world/footprintGrid.js +201 -0
  225. package/dist/world/grassKind.d.ts +10 -0
  226. package/dist/world/grassKind.js +22 -0
  227. package/dist/world/pathTerrain.d.ts +1 -1
  228. package/dist/world/terraform.d.ts +2 -2
  229. package/dist/world/terraform.js +46 -18
  230. package/dist/world/weather.d.ts +17 -0
  231. package/dist/world/weather.js +116 -20
  232. package/dist/world/worldSolids.d.ts +11 -0
  233. package/dist/world/worldSolids.js +112 -1
  234. package/dist/world.d.ts +5 -5
  235. package/dist/world.js +4 -4
  236. package/package.json +1 -1
  237. package/skills/ce-handoff/SKILL.md +1 -1
  238. package/skills/game-design/SKILL.md +2 -0
  239. package/skills/jgengine/SKILL.md +23 -2
  240. package/skills/jgengine/api.md +28 -17
  241. package/skills/jgengine/recipes/minimal-game.md +7 -2
  242. package/skills/jgengine-combat/SKILL.md +8 -2
  243. package/skills/jgengine-combat/api.md +22 -10
  244. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  245. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  246. package/skills/jgengine-combat/reference.md +24 -2
  247. package/skills/jgengine-gameplay/SKILL.md +63 -0
  248. package/skills/jgengine-gameplay/api.md +131 -37
  249. package/skills/jgengine-gameplay/capabilities.md +68 -0
  250. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  251. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  252. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  253. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  254. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  255. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  256. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  257. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  258. package/skills/jgengine-world/SKILL.md +12 -3
  259. package/skills/jgengine-world/api.md +201 -58
  260. package/skills/jgengine-world/capabilities.md +116 -3
  261. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  262. package/skills/jgengine-world/recipes/controls.md +4 -0
  263. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  264. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  265. package/skills/jgengine-world/reference.md +167 -5
  266. package/skills/jgengine-world/visibility.md +28 -1
@@ -41,16 +41,28 @@ export function occupiedCells(footprint, origin, rotation) {
41
41
  return rotated.map(([c, r]) => [c + origin[0], r + origin[1]]);
42
42
  }
43
43
  /**
44
- * A spatial grid inventory that holds shaped multi-cell items, Resident-Evil/Tarkov style.
44
+ * An immutable spatial inventory board holding caller-defined multi-cell items.
45
45
  *
46
- * @capability tetris-inventory a spatial grid inventory holding shaped multi-cell items
47
- * @internal
48
- */
46
+ * @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
47
+ */
49
48
  export function createShapedGrid(width, height) {
50
- if (width <= 0 || height <= 0)
51
- throw new Error("shaped grid needs positive dimensions");
49
+ requireDimensions(width, height);
52
50
  return { width, height, placements: [] };
53
51
  }
52
+ function requireDimensions(width, height) {
53
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
54
+ throw new RangeError("shaped grid needs positive integer dimensions");
55
+ }
56
+ }
57
+ function validCell(cell) {
58
+ return Number.isSafeInteger(cell[0]) && Number.isSafeInteger(cell[1]);
59
+ }
60
+ function validFootprint(footprint) {
61
+ return footprint.length > 0 && footprint.every(validCell);
62
+ }
63
+ function validRotation(rotation) {
64
+ return Number.isInteger(rotation) && rotation >= 0 && rotation <= 3;
65
+ }
54
66
  function inBounds(grid, cells) {
55
67
  return cells.every(([c, r]) => c >= 0 && r >= 0 && c < grid.width && r < grid.height);
56
68
  }
@@ -65,8 +77,16 @@ function occupancyMap(grid, ignoreId) {
65
77
  }
66
78
  return map;
67
79
  }
68
- /** @internal */
80
+ /**
81
+ * Check bounds and overlap for a discrete footprint, optionally ignoring the item being moved.
82
+ * @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
83
+ */
69
84
  export function canPlace(grid, footprint, origin, rotation, ignoreId) {
85
+ requireDimensions(grid.width, grid.height);
86
+ if (!validFootprint(footprint) || !validRotation(rotation))
87
+ return "invalid-footprint";
88
+ if (!validCell(origin))
89
+ return "invalid-origin";
70
90
  const cells = occupiedCells(footprint, origin, rotation);
71
91
  if (!inBounds(grid, cells))
72
92
  return "out-of-bounds";
@@ -77,7 +97,40 @@ export function canPlace(grid, footprint, origin, rotation, ignoreId) {
77
97
  }
78
98
  return null;
79
99
  }
80
- /** @internal */
100
+ /**
101
+ * Find the first legal placement without mutating the board; occupancy is indexed once per search.
102
+ *
103
+ * @capability shaped-placement find a shaped inventory item's first fit with ordered rotations, row-major placement, and an explicit search budget
104
+ */
105
+ export function findShapedPlacement(grid, footprint, options = {}) {
106
+ requireDimensions(grid.width, grid.height);
107
+ const rotations = options.rotations ?? [0, 1, 2, 3];
108
+ const maxChecks = options.maxChecks ?? 4096;
109
+ if (!validFootprint(footprint) || !rotations.every(validRotation))
110
+ throw new RangeError("invalid footprint or rotation");
111
+ if (!Number.isSafeInteger(maxChecks) || maxChecks < 0)
112
+ throw new RangeError("invalid placement search budget");
113
+ const occupied = occupancyMap(grid, options.ignoreId);
114
+ let checks = 0;
115
+ for (const rotation of rotations) {
116
+ const cells = rotateFootprint(footprint, rotation);
117
+ for (let row = 0; row < grid.height; row += 1) {
118
+ for (let column = 0; column < grid.width; column += 1) {
119
+ if (checks >= maxChecks)
120
+ return { status: "budget-exceeded", checks };
121
+ checks += 1;
122
+ if (cells.every(([c, r]) => column + c < grid.width && row + r < grid.height && !occupied.has(cellKey([column + c, row + r])))) {
123
+ return { status: "found", origin: [column, row], rotation, checks };
124
+ }
125
+ }
126
+ }
127
+ }
128
+ return { status: "no-space", checks };
129
+ }
130
+ /**
131
+ * Place a caller-defined item without mutating the source board; duplicate ids reject.
132
+ * @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
133
+ */
81
134
  export function placeShaped(grid, item, origin, rotation = 0) {
82
135
  if (grid.placements.some((p) => p.id === item.id)) {
83
136
  return { status: "rejected", reason: "duplicate-id", detail: item.id };
@@ -94,14 +147,20 @@ export function placeShaped(grid, item, origin, rotation = 0) {
94
147
  };
95
148
  return { status: "ok", grid: { ...grid, placements: [...grid.placements, placement] } };
96
149
  }
97
- /** @internal */
150
+ /**
151
+ * Remove one placement without mutating the source board.
152
+ * @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
153
+ */
98
154
  export function removeShaped(grid, id) {
99
155
  if (!grid.placements.some((p) => p.id === id)) {
100
156
  return { status: "rejected", reason: "unknown-id", detail: id };
101
157
  }
102
158
  return { status: "ok", grid: { ...grid, placements: grid.placements.filter((p) => p.id !== id) } };
103
159
  }
104
- /** @internal */
160
+ /**
161
+ * Move or rotate one placement, ignoring its current cells during overlap checks.
162
+ * @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
163
+ */
105
164
  export function moveShaped(grid, id, origin, rotation) {
106
165
  const existing = grid.placements.find((p) => p.id === id);
107
166
  if (existing === undefined)
@@ -21,6 +21,8 @@ export function wearAmount(spec, kind) {
21
21
  * @capability durability track item wear, breakage, and repair
22
22
  */
23
23
  export function applyWear(state, amount) {
24
+ if (Number.isNaN(amount))
25
+ throw new RangeError("Wear amount must not be NaN.");
24
26
  if (amount <= 0)
25
27
  return state;
26
28
  return { current: clamp(state.current - amount, 0, state.max), max: state.max };
@@ -40,6 +42,8 @@ export function repairQuote(spec, state, options) {
40
42
  return null;
41
43
  if (repair.station !== undefined && repair.station !== options?.station)
42
44
  return null;
45
+ if (options?.to !== undefined && !Number.isFinite(options.to))
46
+ return null;
43
47
  const loss = repair.qualityLossPerRepair ?? 0;
44
48
  const newMax = Math.max(1, state.max - loss);
45
49
  const capped = Math.min(state.current, newMax);
@@ -1,3 +1,26 @@
1
+ /** Save data for a registry; definitions must be structured-cloneable and use the caller's serialization format. */
2
+ export interface ItemInstanceRegistryState<TDef> {
3
+ prefix: string;
4
+ sequence: number;
5
+ entries: [string, TDef][];
6
+ }
7
+ /** Caller-owned synchronous storage; `entries()` must stream without materializing the collection. */
8
+ export interface ItemInstanceStorage<TDef> {
9
+ get(id: string): TDef | undefined;
10
+ has(id: string): boolean;
11
+ set(id: string, def: TDef): void;
12
+ delete(id: string): void;
13
+ count(): number;
14
+ entries(): Iterable<[string, TDef]>;
15
+ /** Consume entries completely and atomically replace storage; any failure must preserve previous data. */
16
+ replace(entries: Iterable<[string, TDef]>): void;
17
+ }
18
+ /** Inject storage and its saved allocator sequence when attaching an existing collection. */
19
+ export interface ItemInstanceRegistryOptions<TDef> {
20
+ storage?: ItemInstanceStorage<TDef>;
21
+ /** Nonnegative safe integer; defaults to zero. Retain it across releases and storage reattachment. */
22
+ sequence?: number;
23
+ }
1
24
  /**
2
25
  * A runtime store for procedurally generated item instances — a rolled unique gun, a rolled
3
26
  * affixed relic — keyed by a generated id distinct from any static catalog id. The counterpart a
@@ -5,6 +28,14 @@
5
28
  * rolls never need a hand-rolled parallel registry (#536.1).
6
29
  */
7
30
  export interface ItemInstanceRegistry<TDef> {
31
+ /** Detached definitions and allocator state, including ids of released instances in the sequence. */
32
+ state(): ItemInstanceRegistryState<TDef>;
33
+ /** Detached batches of at most `pageSize` entries; throws if the registry changes during iteration. Keep external storage and definitions unchanged until finished. */
34
+ statePages(pageSize: number): IterableIterator<ItemInstanceRegistryState<TDef>>;
35
+ /** Replace the registry; returns false without mutation when the prefix or allocator is invalid. */
36
+ restore(next: ItemInstanceRegistryState<TDef>): boolean;
37
+ /** Stream detached pages through atomic storage replacement. All pages must share prefix and sequence; malformed pages or storage failures throw without changing state. Empty saves require one empty page. */
38
+ restorePages(pages: Iterable<ItemInstanceRegistryState<TDef>>): boolean;
8
39
  /** Stores `def` under a fresh generated id derived from `baseId`; returns that id. */
9
40
  register(baseId: string, def: TDef): string;
10
41
  get(id: string): TDef | undefined;
@@ -19,7 +50,7 @@ export interface ItemInstanceRegistry<TDef> {
19
50
  *
20
51
  * @capability item-instance-registry a runtime store for procedurally generated item instances
21
52
  */
22
- export declare function createItemInstanceRegistry<TDef>(prefix?: string): ItemInstanceRegistry<TDef>;
53
+ export declare function createItemInstanceRegistry<TDef>(prefix?: string, options?: ItemInstanceRegistryOptions<TDef>): ItemInstanceRegistry<TDef>;
23
54
  /**
24
55
  * Bridges any procedural roller into a `LootEntry.generate` callback: rolls a `{ baseId, def }` pair
25
56
  * and registers it, returning the runtime id the loot roll hands back as the drop's `item`.
@@ -4,20 +4,124 @@
4
4
  *
5
5
  * @capability item-instance-registry a runtime store for procedurally generated item instances
6
6
  */
7
- export function createItemInstanceRegistry(prefix = "item") {
8
- let seq = 0;
9
- const store = new Map();
7
+ export function createItemInstanceRegistry(prefix = "item", options = {}) {
8
+ let seq = options.sequence ?? 0;
9
+ if (!Number.isSafeInteger(seq) || seq < 0)
10
+ throw new RangeError("Invalid item allocation sequence");
11
+ let revision = 0;
12
+ let memory = new Map();
13
+ const store = options.storage ?? {
14
+ get: (id) => memory.get(id),
15
+ has: (id) => memory.has(id),
16
+ set: (id, def) => void memory.set(id, def),
17
+ delete: (id) => void memory.delete(id),
18
+ count: () => memory.size,
19
+ entries: () => memory.entries(),
20
+ replace: (entries) => { memory = new Map(entries); },
21
+ };
22
+ const validHeader = (next) => next.prefix === prefix && Number.isSafeInteger(next.sequence) && next.sequence >= 0;
10
23
  return {
24
+ state: () => ({ prefix, sequence: seq, entries: structuredClone(Array.from(store.entries())) }),
25
+ statePages(pageSize) {
26
+ if (!Number.isSafeInteger(pageSize) || pageSize < 1)
27
+ throw new RangeError("Invalid item page size");
28
+ const sequence = seq;
29
+ const expectedRevision = revision;
30
+ return (function* () {
31
+ const entries = store.entries()[Symbol.iterator]();
32
+ let done = false;
33
+ let first = true;
34
+ try {
35
+ while (!done) {
36
+ if (revision !== expectedRevision)
37
+ throw new Error("Item registry changed during export");
38
+ const batch = [];
39
+ while (batch.length < pageSize) {
40
+ const next = entries.next();
41
+ if (next.done) {
42
+ done = true;
43
+ break;
44
+ }
45
+ batch.push(next.value);
46
+ }
47
+ const detached = structuredClone(batch);
48
+ if (revision !== expectedRevision)
49
+ throw new Error("Item registry changed during export");
50
+ if (first || detached.length > 0)
51
+ yield { prefix, sequence, entries: detached };
52
+ first = false;
53
+ }
54
+ }
55
+ finally {
56
+ entries.return?.();
57
+ }
58
+ })();
59
+ },
60
+ restore(next) {
61
+ if (!validHeader(next))
62
+ return false;
63
+ const entries = structuredClone(next.entries);
64
+ store.replace(entries);
65
+ seq = next.sequence;
66
+ revision += 1;
67
+ return true;
68
+ },
69
+ restorePages(pages) {
70
+ const iterator = pages[Symbol.iterator]();
71
+ let closed = false;
72
+ const close = () => {
73
+ if (closed)
74
+ return;
75
+ closed = true;
76
+ iterator.return?.();
77
+ };
78
+ try {
79
+ const first = iterator.next();
80
+ if (first.done || !validHeader(first.value))
81
+ return false;
82
+ const sequence = first.value.sequence;
83
+ store.replace((function* () {
84
+ let page = first;
85
+ try {
86
+ while (!page.done) {
87
+ if (!validHeader(page.value) || page.value.sequence !== sequence) {
88
+ throw new Error("Inconsistent item registry pages");
89
+ }
90
+ yield* structuredClone(page.value.entries);
91
+ page = iterator.next();
92
+ }
93
+ }
94
+ finally {
95
+ close();
96
+ }
97
+ })());
98
+ seq = sequence;
99
+ revision += 1;
100
+ return true;
101
+ }
102
+ finally {
103
+ close();
104
+ }
105
+ },
11
106
  register(baseId, def) {
12
- seq += 1;
13
- const id = `${prefix}:${baseId}:${seq}`;
107
+ let id;
108
+ do {
109
+ if (seq === Number.MAX_SAFE_INTEGER)
110
+ throw new RangeError("Item allocation sequence exhausted");
111
+ seq += 1;
112
+ revision += 1;
113
+ id = `${prefix}:${baseId}:${seq}`;
114
+ } while (store.has(id));
14
115
  store.set(id, def);
15
116
  return id;
16
117
  },
17
118
  get: (id) => store.get(id),
18
119
  has: (id) => store.has(id),
19
- release: (id) => void store.delete(id),
20
- count: () => store.size,
120
+ release(id) {
121
+ store.delete(id);
122
+ revision += 1;
123
+ },
124
+ count: () => store.count(),
21
125
  };
22
126
  }
23
127
  /**
@@ -148,7 +148,7 @@ export interface GenSchema {
148
148
  }
149
149
  /** Per-call options for {@link generate}. */
150
150
  export interface GenerateOptions {
151
- /** Force a step to a specific option id, consuming no rng for it (a requested rarity or family). */
151
+ /** Fix a step to an option id that still must satisfy its pool and constraint, consuming no rng for it. */
152
152
  pin?: Readonly<Record<string, string>>;
153
153
  }
154
154
  /**
@@ -41,7 +41,7 @@ export function generate(schema, rng, options = {}) {
41
41
  const pinId = pin?.[step.id];
42
42
  if (pinId !== undefined) {
43
43
  const option = poolFor(step.pool, view).find((candidate) => candidate.id === pinId);
44
- if (option === undefined)
44
+ if (option === undefined || (step.accept !== undefined && !step.accept(option, view)))
45
45
  return false;
46
46
  chosen.push({
47
47
  step: step.id,
@@ -48,7 +48,13 @@ export function isComplete(def, installed) {
48
48
  return missingRequiredSlots(def, installed).length === 0;
49
49
  }
50
50
  export function createModularItem(def, initial = []) {
51
- let installed = initial;
51
+ let installed = [];
52
+ for (const { slotId, part } of initial) {
53
+ const result = install(def, installed, slotId, part);
54
+ if (result.status === "rejected")
55
+ throw new Error(`Invalid initial part in slot ${slotId}: ${result.reason}`);
56
+ installed = result.installed;
57
+ }
52
58
  return {
53
59
  def,
54
60
  parts() {
@@ -0,0 +1,53 @@
1
+ import type { EnvironmentField, EnvironmentSample } from "../world/envField.js";
2
+ import type { MaterialSurfaceParameters } from "./materialAsset.js";
3
+ /** Resolved dry values from one assigned surface, after imported and instance edits. RGB is linear. */
4
+ export interface MaterialAppearanceBaseline {
5
+ roughness: number;
6
+ colorLinear: readonly [number, number, number];
7
+ sheen?: number;
8
+ clearcoat?: number;
9
+ clearcoatRoughness?: number;
10
+ }
11
+ /** Opt-in wet endpoints. Color retention multiplies linear albedo; it never replaces the authored hue. */
12
+ export interface MaterialWetnessPolicy {
13
+ influence?: number;
14
+ roughness?: number;
15
+ colorRetention?: number;
16
+ sheen?: number;
17
+ /** Requires an already physical material; omitting this preserves imported coat without enabling a new one. */
18
+ clearcoat?: {
19
+ strength: number;
20
+ roughness?: number;
21
+ };
22
+ }
23
+ /** Local appearance only. Wind is a separate geometry/simulation input, and no field creates motion. */
24
+ export interface MaterialAppearancePolicy {
25
+ wetness?: MaterialWetnessPolicy;
26
+ /** Sky-exposure attenuation of existing hair-card backlighting; this is not a shadow or scattering model. */
27
+ cardBacklightExposure?: boolean;
28
+ }
29
+ /** Sparse edits for one sample. Unrequested imported parameters, textures, coverage and transmission stay intact. */
30
+ export interface MaterialAppearanceResponse {
31
+ surface: MaterialSurfaceParameters;
32
+ colorLinear?: [number, number, number];
33
+ backlightExposure?: number;
34
+ wetness: number;
35
+ }
36
+ /**
37
+ * Resolve one surface from an immutable dry baseline, avoiding cumulative drift. Wetness already includes
38
+ * field shelter; neither exposure nor sheltered is multiplied into it again. No accumulation or drying simulation.
39
+ * @capability material-appearance-signals adapt selected surface response to sampled wetness and local card exposure
40
+ */
41
+ export declare function resolveMaterialAppearance(sample: EnvironmentSample, baseline: MaterialAppearanceBaseline, policy?: MaterialAppearancePolicy): MaterialAppearanceResponse;
42
+ /** Explicit sample position; callers schedule a bounded set of visible or relevant targets. */
43
+ export interface MaterialAppearanceTarget {
44
+ x: number;
45
+ z: number;
46
+ y?: number;
47
+ time: number;
48
+ }
49
+ /**
50
+ * Exactly one field sample per target; no traversal, subscription, hidden tick, or full-world scan.
51
+ * @capability material-appearance-signals sample one explicitly scheduled appearance target from the shared environment field
52
+ */
53
+ export declare function sampleMaterialAppearance(field: Pick<EnvironmentField, "sample">, target: MaterialAppearanceTarget, baseline: MaterialAppearanceBaseline, policy?: MaterialAppearancePolicy): MaterialAppearanceResponse;
@@ -0,0 +1,47 @@
1
+ function unit(value, path) {
2
+ if (!Number.isFinite(value) || value < 0 || value > 1)
3
+ throw new TypeError(`${path}: expected a finite value from 0 to 1.`);
4
+ return value;
5
+ }
6
+ function target(baseline, endpoint, wetness, path) {
7
+ if (baseline === undefined)
8
+ throw new TypeError(`${path}: resolve the original physical material value before adapting it.`);
9
+ const start = unit(baseline, `baseline.${path}`);
10
+ const end = unit(endpoint, `policy.${path}`);
11
+ return wetness === 0 ? start : wetness === 1 ? end : start + (end - start) * wetness;
12
+ }
13
+ /**
14
+ * Resolve one surface from an immutable dry baseline, avoiding cumulative drift. Wetness already includes
15
+ * field shelter; neither exposure nor sheltered is multiplied into it again. No accumulation or drying simulation.
16
+ * @capability material-appearance-signals adapt selected surface response to sampled wetness and local card exposure
17
+ */
18
+ export function resolveMaterialAppearance(sample, baseline, policy = {}) {
19
+ const wet = policy.wetness;
20
+ const wetness = unit(sample.wetness, "sample.wetness") * unit(wet?.influence ?? 1, "policy.wetness.influence");
21
+ const result = { surface: {}, wetness };
22
+ if (wet?.roughness !== undefined)
23
+ result.surface.roughness = target(baseline.roughness, wet.roughness, wetness, "roughness");
24
+ if (wet?.colorRetention !== undefined) {
25
+ const retention = 1 + (unit(wet.colorRetention, "policy.wetness.colorRetention") - 1) * wetness;
26
+ if (baseline.colorLinear.some(value => !Number.isFinite(value) || value < 0))
27
+ throw new TypeError("baseline.colorLinear: expected finite nonnegative linear RGB.");
28
+ result.colorLinear = baseline.colorLinear.map(value => value * retention);
29
+ }
30
+ if (wet?.sheen !== undefined)
31
+ result.surface.sheen = target(baseline.sheen, wet.sheen, wetness, "sheen");
32
+ if (wet?.clearcoat !== undefined) {
33
+ result.surface.clearcoat = target(baseline.clearcoat, wet.clearcoat.strength, wetness, "clearcoat");
34
+ if (wet.clearcoat.roughness !== undefined)
35
+ result.surface.clearcoatRoughness = target(baseline.clearcoatRoughness, wet.clearcoat.roughness, wetness, "clearcoatRoughness");
36
+ }
37
+ if (policy.cardBacklightExposure)
38
+ result.backlightExposure = unit(sample.lightExposure, "sample.lightExposure");
39
+ return result;
40
+ }
41
+ /**
42
+ * Exactly one field sample per target; no traversal, subscription, hidden tick, or full-world scan.
43
+ * @capability material-appearance-signals sample one explicitly scheduled appearance target from the shared environment field
44
+ */
45
+ export function sampleMaterialAppearance(field, target, baseline, policy = {}) {
46
+ return resolveMaterialAppearance(field.sample(target.x, target.z, target.time, target.y), baseline, policy);
47
+ }
@@ -0,0 +1,47 @@
1
+ import type { MaterialDiagnostic } from "./materialAsset.js";
2
+ /** Tangents follow card width (UV U); strand length follows UV V. Angles are radians. */
3
+ export declare const HAIR_CARD_STRAND_ROTATION: number;
4
+ /** An authored centreline, in model-local metres, with root-to-tip points. */
5
+ export interface HairCardGuide {
6
+ id: string;
7
+ points: readonly (readonly [number, number, number])[];
8
+ width: number;
9
+ tipWidth?: number;
10
+ /** Desired front-facing normal; must not be parallel to the strand direction. */
11
+ facing: readonly [number, number, number];
12
+ /** Atlas rectangle [uMin,vMin,uMax,vMax]; U is width and V is root to tip. */
13
+ uvRect?: readonly [number, number, number, number];
14
+ }
15
+ /** Serializable geometry authoring, independent of materials, grooming physics and collisions. */
16
+ export interface HairCardAuthoring {
17
+ guides: readonly HairCardGuide[];
18
+ subdivisionsPerSegment?: number;
19
+ /** Allocation ceiling for offline/editor generation; default 65,536 vertices. */
20
+ maxVertices?: number;
21
+ }
22
+ /** Renderer-neutral indexed ribbons with an explicit tangent frame and guide ranges. */
23
+ export interface HairCardMesh {
24
+ positions: number[];
25
+ normals: number[];
26
+ tangents: number[];
27
+ uvs: number[];
28
+ indices: number[];
29
+ bounds: {
30
+ min: [number, number, number];
31
+ max: [number, number, number];
32
+ };
33
+ guides: {
34
+ id: string;
35
+ vertexStart: number;
36
+ vertexCount: number;
37
+ indexStart: number;
38
+ indexCount: number;
39
+ }[];
40
+ }
41
+ /**
42
+ * Validate finite guides, tangent frames and bounded generation; crossing/collision is not solved.
43
+ * @capability hair-card-geometry validate authored ribbon guides and their vertex allocation ceiling
44
+ */
45
+ export declare function validateHairCardAuthoring(value: unknown): MaterialDiagnostic[];
46
+ /** Build deterministic model-local ribbon geometry; guides remain separately editable source data. */
47
+ export declare function buildHairCards(input: HairCardAuthoring): HairCardMesh;
@@ -0,0 +1,136 @@
1
+ /** Tangents follow card width (UV U); strand length follows UV V. Angles are radians. */
2
+ export const HAIR_CARD_STRAND_ROTATION = Math.PI / 2;
3
+ function vec(value) { return Array.isArray(value) && value.length === 3 && value.every((item) => typeof item === "number" && Number.isFinite(item)); }
4
+ function subtract(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
5
+ function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
6
+ function length(v) { return Math.hypot(...v); }
7
+ function normalized(v) { const magnitude = length(v); return [v[0] / magnitude, v[1] / magnitude, v[2] / magnitude]; }
8
+ /**
9
+ * Validate finite guides, tangent frames and bounded generation; crossing/collision is not solved.
10
+ * @capability hair-card-geometry validate authored ribbon guides and their vertex allocation ceiling
11
+ */
12
+ export function validateHairCardAuthoring(value) {
13
+ const diagnostics = [];
14
+ const error = (path, message) => diagnostics.push({ severity: "error", code: "invalid-hair-guide", path, message });
15
+ if (typeof value !== "object" || value === null || Array.isArray(value))
16
+ return [{ severity: "error", code: "invalid-hair-guide", path: "", message: "Expected hair card authoring data." }];
17
+ const input = value;
18
+ for (const key of Object.keys(input))
19
+ if (!["guides", "subdivisionsPerSegment", "maxVertices"].includes(key))
20
+ error(key, "Unsupported hair geometry field.");
21
+ const subdivisions = input.subdivisionsPerSegment ?? 1;
22
+ const maxVertices = input.maxVertices ?? 65_536;
23
+ if (typeof subdivisions !== "number" || !Number.isInteger(subdivisions) || subdivisions < 1 || subdivisions > 256)
24
+ error("subdivisionsPerSegment", "Expected an integer from 1 to 256.");
25
+ if (typeof maxVertices !== "number" || !Number.isInteger(maxVertices) || maxVertices < 4 || maxVertices > 1_000_000)
26
+ error("maxVertices", "Expected a vertex ceiling from 4 to 1,000,000.");
27
+ if (!Array.isArray(input.guides) || input.guides.length === 0 || input.guides.length > 10_000) {
28
+ error("guides", "Expected 1 to 10,000 authored guides.");
29
+ return diagnostics;
30
+ }
31
+ const ids = new Set();
32
+ let vertexCount = 0;
33
+ for (let index = 0; index < input.guides.length; index++) {
34
+ const guide = input.guides[index];
35
+ const path = `guides.${index}`;
36
+ if (typeof guide !== "object" || guide === null || Array.isArray(guide)) {
37
+ error(path, "Expected a guide object.");
38
+ continue;
39
+ }
40
+ for (const key of Object.keys(guide))
41
+ if (!["id", "points", "width", "tipWidth", "facing", "uvRect"].includes(key))
42
+ error(`${path}.${key}`, "Unsupported guide field.");
43
+ if (typeof guide.id !== "string" || guide.id.trim().length === 0 || ids.has(guide.id))
44
+ error(`${path}.id`, "Expected a unique stable guide ID.");
45
+ else
46
+ ids.add(guide.id);
47
+ if (typeof guide.width !== "number" || !Number.isFinite(guide.width) || guide.width <= 0)
48
+ error(`${path}.width`, "Expected a positive width in metres.");
49
+ if (guide.tipWidth !== undefined && (typeof guide.tipWidth !== "number" || !Number.isFinite(guide.tipWidth) || guide.tipWidth < 0))
50
+ error(`${path}.tipWidth`, "Expected a nonnegative tip width in metres.");
51
+ const facingValid = vec(guide.facing) && length(guide.facing) > 1e-8;
52
+ if (!facingValid)
53
+ error(`${path}.facing`, "Expected a finite nonzero facing vector.");
54
+ if (!Array.isArray(guide.points) || guide.points.length < 2 || guide.points.length > 10_000) {
55
+ error(`${path}.points`, "Expected 2 to 10,000 finite model-local points.");
56
+ continue;
57
+ }
58
+ vertexCount += 2 * ((guide.points.length - 1) * (typeof subdivisions === "number" ? subdivisions : 1) + 1);
59
+ const ceiling = typeof maxVertices === "number" && Number.isFinite(maxVertices) ? Math.min(maxVertices, 1_000_000) : 65_536;
60
+ if (vertexCount > ceiling) {
61
+ error("maxVertices", `Guides require at least ${vertexCount} vertices, above the ${ceiling} ceiling.`);
62
+ return diagnostics;
63
+ }
64
+ if (!guide.points.every(vec)) {
65
+ error(`${path}.points`, "Expected finite model-local points.");
66
+ continue;
67
+ }
68
+ for (let point = 1; point < guide.points.length; point++) {
69
+ const direction = subtract(guide.points[point], guide.points[point - 1]);
70
+ if (length(direction) <= 1e-8)
71
+ error(`${path}.points.${point}`, "Adjacent guide points must be distinct.");
72
+ else if (facingValid && length(cross(normalized(direction), normalized(guide.facing))) <= 1e-6)
73
+ error(`${path}.facing`, "Facing is parallel to a guide segment; choose a different card orientation.");
74
+ if (point < guide.points.length - 1 && length(subtract(guide.points[point + 1], guide.points[point - 1])) <= 1e-8)
75
+ error(`${path}.points.${point}`, "A reversing guide has no stable tangent at its bend.");
76
+ }
77
+ if (guide.uvRect !== undefined && (!Array.isArray(guide.uvRect) || guide.uvRect.length !== 4 || !guide.uvRect.every((item) => typeof item === "number" && Number.isFinite(item) && item >= 0 && item <= 1) || guide.uvRect[0] >= guide.uvRect[2] || guide.uvRect[1] >= guide.uvRect[3]))
78
+ error(`${path}.uvRect`, "Expected an ordered atlas rectangle within 0 to 1.");
79
+ }
80
+ if (typeof maxVertices === "number" && vertexCount > maxVertices)
81
+ error("maxVertices", `Guides require ${vertexCount} vertices, above the ${maxVertices} ceiling.`);
82
+ return diagnostics;
83
+ }
84
+ /** Build deterministic model-local ribbon geometry; guides remain separately editable source data. */
85
+ export function buildHairCards(input) {
86
+ const errors = validateHairCardAuthoring(input);
87
+ if (errors.length > 0)
88
+ throw new TypeError(errors.map((item) => `${item.path}: ${item.message}`).join("; "));
89
+ const mesh = { positions: [], normals: [], tangents: [], uvs: [], indices: [], bounds: { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }, guides: [] };
90
+ const subdivisions = input.subdivisionsPerSegment ?? 1;
91
+ for (const guide of input.guides) {
92
+ const vertexStart = mesh.positions.length / 3;
93
+ const indexStart = mesh.indices.length;
94
+ const samples = [[...guide.points[0]]];
95
+ for (let segment = 1; segment < guide.points.length; segment++) {
96
+ const from = guide.points[segment - 1], to = guide.points[segment];
97
+ for (let step = 1; step <= subdivisions; step++) {
98
+ const t = step / subdivisions;
99
+ samples.push([from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t]);
100
+ }
101
+ }
102
+ const distances = [0];
103
+ for (let index = 1; index < samples.length; index++)
104
+ distances.push(distances[index - 1] + length(subtract(samples[index], samples[index - 1])));
105
+ const totalLength = distances[distances.length - 1];
106
+ const atlas = guide.uvRect ?? [0, 0, 1, 1];
107
+ for (let index = 0; index < samples.length; index++) {
108
+ const tangent = normalized(subtract(samples[Math.min(index + 1, samples.length - 1)], samples[Math.max(index - 1, 0)]));
109
+ const widthDirection = cross(tangent, normalized(guide.facing));
110
+ if (length(widthDirection) <= 1e-6 || !widthDirection.every(Number.isFinite))
111
+ throw new TypeError(`Guide ${guide.id} has an unstable tangent frame at sample ${index}; change its bend or facing.`);
112
+ const across = normalized(widthDirection);
113
+ const normal = normalized(cross(across, tangent));
114
+ const t = distances[index] / totalLength;
115
+ const halfWidth = (guide.width + ((guide.tipWidth ?? guide.width * 0.1) - guide.width) * t) / 2;
116
+ for (let side = 0; side < 2; side++) {
117
+ const sign = side === 0 ? -1 : 1;
118
+ const position = [samples[index][0] + sign * across[0] * halfWidth, samples[index][1] + sign * across[1] * halfWidth, samples[index][2] + sign * across[2] * halfWidth];
119
+ mesh.positions.push(...position);
120
+ mesh.normals.push(...normal);
121
+ mesh.tangents.push(...across, 1);
122
+ mesh.uvs.push(side === 0 ? atlas[0] : atlas[2], atlas[1] + (atlas[3] - atlas[1]) * t);
123
+ for (let axis = 0; axis < 3; axis++) {
124
+ mesh.bounds.min[axis] = Math.min(mesh.bounds.min[axis], position[axis]);
125
+ mesh.bounds.max[axis] = Math.max(mesh.bounds.max[axis], position[axis]);
126
+ }
127
+ }
128
+ if (index < samples.length - 1) {
129
+ const left = vertexStart + index * 2;
130
+ mesh.indices.push(left, left + 1, left + 2, left + 1, left + 3, left + 2);
131
+ }
132
+ }
133
+ mesh.guides.push({ id: guide.id, vertexStart, vertexCount: samples.length * 2, indexStart, indexCount: mesh.indices.length - indexStart });
134
+ }
135
+ return mesh;
136
+ }