@jgengine/core 0.11.0 → 0.13.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 (256) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/ai/populationDirector.d.ts +103 -0
  3. package/dist/ai/populationDirector.js +190 -0
  4. package/dist/ai/pursuit.d.ts +56 -0
  5. package/dist/ai/pursuit.js +53 -0
  6. package/dist/authoring.d.ts +1 -1
  7. package/dist/authoring.js +1 -1
  8. package/dist/combat/effects.d.ts +11 -4
  9. package/dist/combat/effects.js +24 -17
  10. package/dist/combat/magazine.d.ts +13 -0
  11. package/dist/combat/magazine.js +27 -0
  12. package/dist/combat/vfxPresets.d.ts +176 -0
  13. package/dist/combat/vfxPresets.js +74 -0
  14. package/dist/combat/weaponFire.d.ts +114 -0
  15. package/dist/combat/weaponFire.js +96 -0
  16. package/dist/combat.d.ts +5 -2
  17. package/dist/combat.js +4 -1
  18. package/dist/devtools/controls.d.ts +38 -0
  19. package/dist/devtools/controls.js +181 -0
  20. package/dist/devtools/devtools.d.ts +5 -197
  21. package/dist/devtools/devtools.js +38 -774
  22. package/dist/devtools/discover.d.ts +32 -0
  23. package/dist/devtools/discover.js +240 -0
  24. package/dist/devtools/fallbackSeams.d.ts +50 -0
  25. package/dist/devtools/fallbackSeams.js +111 -0
  26. package/dist/devtools/frame.d.ts +15 -0
  27. package/dist/devtools/frame.js +247 -0
  28. package/dist/devtools/logs.d.ts +15 -0
  29. package/dist/devtools/logs.js +86 -0
  30. package/dist/devtools/overrides.d.ts +16 -0
  31. package/dist/devtools/overrides.js +66 -0
  32. package/dist/devtools/types.d.ts +212 -0
  33. package/dist/devtools/types.js +1 -0
  34. package/dist/devtools/urlFlags.d.ts +22 -0
  35. package/dist/devtools/urlFlags.js +62 -0
  36. package/dist/economy/auctionBook.d.ts +122 -0
  37. package/dist/economy/auctionBook.js +213 -0
  38. package/dist/economy/priceHistory.d.ts +44 -0
  39. package/dist/economy/priceHistory.js +89 -0
  40. package/dist/economy/unlockPoints.d.ts +92 -0
  41. package/dist/economy/unlockPoints.js +103 -0
  42. package/dist/editor/commandApply.d.ts +5 -0
  43. package/dist/editor/commandApply.js +755 -0
  44. package/dist/editor/commands.d.ts +48 -3
  45. package/dist/editor/commands.js +40 -754
  46. package/dist/editor/directives.d.ts +94 -0
  47. package/dist/editor/directives.js +206 -0
  48. package/dist/editor/document.d.ts +37 -2
  49. package/dist/editor/document.js +422 -6
  50. package/dist/editor/entityCatalog.d.ts +42 -0
  51. package/dist/editor/entityCatalog.js +101 -0
  52. package/dist/editor/environment.d.ts +74 -0
  53. package/dist/editor/environment.js +169 -0
  54. package/dist/editor/index.d.ts +26 -1
  55. package/dist/editor/index.js +8 -1
  56. package/dist/editor/minimap.d.ts +31 -0
  57. package/dist/editor/minimap.js +73 -0
  58. package/dist/editor/streamer.d.ts +66 -0
  59. package/dist/editor/streamer.js +68 -0
  60. package/dist/editor/types.d.ts +143 -0
  61. package/dist/editor/world.d.ts +136 -0
  62. package/dist/editor/world.js +303 -0
  63. package/dist/game/breeding.d.ts +161 -0
  64. package/dist/game/breeding.js +181 -0
  65. package/dist/game/cameraConfig.d.ts +451 -0
  66. package/dist/game/cameraConfig.js +1 -0
  67. package/dist/game/clipRoles.d.ts +51 -0
  68. package/dist/game/clipRoles.js +142 -0
  69. package/dist/game/defineGame.d.ts +16 -9
  70. package/dist/game/defineGame.js +14 -6
  71. package/dist/game/defineSystem.d.ts +2 -0
  72. package/dist/game/defineSystem.js +2 -0
  73. package/dist/game/events.d.ts +28 -0
  74. package/dist/game/partAnimation.d.ts +78 -0
  75. package/dist/game/partAnimation.js +147 -0
  76. package/dist/game/playableGame.d.ts +72 -440
  77. package/dist/game/playableGame.js +1 -1
  78. package/dist/game/progression.d.ts +35 -0
  79. package/dist/game/progression.js +22 -1
  80. package/dist/game/quest.js +1 -3
  81. package/dist/game/tribe.d.ts +184 -0
  82. package/dist/game/tribe.js +432 -0
  83. package/dist/gameplay.d.ts +3 -1
  84. package/dist/gameplay.js +3 -1
  85. package/dist/inventory/encumbrance.d.ts +69 -0
  86. package/dist/inventory/encumbrance.js +68 -0
  87. package/dist/inventory/inventoryModel.d.ts +24 -8
  88. package/dist/inventory/inventoryModel.js +47 -8
  89. package/dist/item/durability.js +1 -3
  90. package/dist/meta/changelog.d.ts +1 -1
  91. package/dist/meta/changelog.js +48 -1
  92. package/dist/movement/movementModel.d.ts +15 -1
  93. package/dist/movement/movementModel.js +33 -15
  94. package/dist/movement/playerMovement.js +102 -2
  95. package/dist/multiplayer/combatSnapshot.js +13 -8
  96. package/dist/multiplayer/presenceModel.d.ts +2 -0
  97. package/dist/multiplayer/presenceModel.js +8 -0
  98. package/dist/physics/drivableVehicle.d.ts +2 -0
  99. package/dist/physics/drivableVehicle.js +1 -1
  100. package/dist/physics/flightDynamics.d.ts +82 -0
  101. package/dist/physics/flightDynamics.js +165 -0
  102. package/dist/physics/gravityField.d.ts +25 -0
  103. package/dist/physics/gravityField.js +41 -0
  104. package/dist/physics/kinematicVehicle.d.ts +87 -0
  105. package/dist/physics/kinematicVehicle.js +193 -11
  106. package/dist/physics/physicsWorld.d.ts +26 -0
  107. package/dist/physics/physicsWorld.js +109 -117
  108. package/dist/physics/vehicleObstacles.d.ts +44 -0
  109. package/dist/physics/vehicleObstacles.js +115 -0
  110. package/dist/random/rng.d.ts +4 -1
  111. package/dist/random/rng.js +4 -1
  112. package/dist/runtime/adapter.d.ts +13 -0
  113. package/dist/runtime/adapter.js +13 -0
  114. package/dist/runtime/context/combat.d.ts +51 -0
  115. package/dist/runtime/context/combat.js +131 -0
  116. package/dist/runtime/context/combatFx.js +15 -5
  117. package/dist/runtime/context/deathLoot.d.ts +30 -0
  118. package/dist/runtime/context/deathLoot.js +39 -0
  119. package/dist/runtime/context/player.d.ts +48 -0
  120. package/dist/runtime/context/player.js +125 -0
  121. package/dist/runtime/context/scene.d.ts +60 -0
  122. package/dist/runtime/context/scene.js +329 -0
  123. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  124. package/dist/runtime/context/snapshotCodecs.js +26 -0
  125. package/dist/runtime/descriptors/baseline.js +26 -3
  126. package/dist/runtime/descriptors/features.d.ts +5 -0
  127. package/dist/runtime/descriptors/features.js +20 -2
  128. package/dist/runtime/gameContext.d.ts +2 -428
  129. package/dist/runtime/gameContext.js +133 -500
  130. package/dist/runtime/gameContextTypes.d.ts +500 -0
  131. package/dist/runtime/gameContextTypes.js +1 -0
  132. package/dist/runtime/headlessRunner.d.ts +17 -2
  133. package/dist/runtime/headlessRunner.js +15 -0
  134. package/dist/runtime/hostPolicy.d.ts +51 -0
  135. package/dist/runtime/hostPolicy.js +68 -0
  136. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  137. package/dist/runtime/hostedGameRunner.js +1 -0
  138. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  139. package/dist/runtime/hostedWorldSession.js +1 -0
  140. package/dist/runtime/worldSnapshot.d.ts +13 -1
  141. package/dist/runtime/worldSnapshot.js +15 -3
  142. package/dist/scene/assetCatalog.d.ts +7 -0
  143. package/dist/scene/assetSpace.js +1 -3
  144. package/dist/scene/authoredTriggers.d.ts +50 -0
  145. package/dist/scene/authoredTriggers.js +114 -0
  146. package/dist/scene/builtinSceneKinds.js +8 -3
  147. package/dist/scene/colliders.d.ts +79 -0
  148. package/dist/scene/colliders.js +170 -0
  149. package/dist/scene/collisionMesh.d.ts +94 -0
  150. package/dist/scene/collisionMesh.js +699 -0
  151. package/dist/scene/companion.d.ts +147 -0
  152. package/dist/scene/companion.js +193 -0
  153. package/dist/scene/entityStats.d.ts +7 -9
  154. package/dist/scene/entityStats.js +19 -15
  155. package/dist/scene/sceneKinds.d.ts +16 -0
  156. package/dist/scene/sceneKinds.js +4 -0
  157. package/dist/scene/sceneRaycast.js +15 -0
  158. package/dist/stats/spawnLevelStats.d.ts +110 -0
  159. package/dist/stats/spawnLevelStats.js +140 -0
  160. package/dist/stats/statModifiers.d.ts +16 -0
  161. package/dist/stats/statModifiers.js +30 -1
  162. package/dist/stats/statPool.d.ts +70 -0
  163. package/dist/stats/statPool.js +62 -0
  164. package/dist/store/changeSignal.d.ts +3 -0
  165. package/dist/store/changeSignal.js +2 -0
  166. package/dist/store/observableKeyedStore.d.ts +2 -0
  167. package/dist/store/observableKeyedStore.js +1 -0
  168. package/dist/survival/decayMeter.js +1 -3
  169. package/dist/survival/regionHealth.js +1 -3
  170. package/dist/ui/hudLayout.d.ts +5 -0
  171. package/dist/ui/hudLayout.js +17 -1
  172. package/dist/ui/panelModel.d.ts +120 -0
  173. package/dist/ui/panelModel.js +168 -0
  174. package/dist/ui.d.ts +1 -0
  175. package/dist/ui.js +1 -0
  176. package/dist/world/authoredEntities.d.ts +29 -0
  177. package/dist/world/authoredEntities.js +29 -0
  178. package/dist/world/authoredObjects.d.ts +15 -1
  179. package/dist/world/authoredObjects.js +13 -1
  180. package/dist/world/authoredSpawn.d.ts +39 -0
  181. package/dist/world/authoredSpawn.js +37 -0
  182. package/dist/world/buildSockets.d.ts +152 -0
  183. package/dist/world/buildSockets.js +188 -0
  184. package/dist/world/buildingLots.d.ts +84 -0
  185. package/dist/world/buildingLots.js +119 -0
  186. package/dist/world/buildings.d.ts +6 -0
  187. package/dist/world/cityBlocks.d.ts +152 -0
  188. package/dist/world/cityBlocks.js +670 -0
  189. package/dist/world/cityContent.d.ts +77 -0
  190. package/dist/world/cityContent.js +261 -0
  191. package/dist/world/cityGenerator.d.ts +41 -0
  192. package/dist/world/cityGenerator.js +58 -0
  193. package/dist/world/cityGeometry.d.ts +114 -0
  194. package/dist/world/cityGeometry.js +573 -0
  195. package/dist/world/cityKind.d.ts +314 -0
  196. package/dist/world/cityKind.js +1578 -0
  197. package/dist/world/environmentSummary.js +57 -1
  198. package/dist/world/features.d.ts +53 -5
  199. package/dist/world/features.js +15 -3
  200. package/dist/world/grassKind.js +2 -2
  201. package/dist/world/markers.d.ts +50 -4
  202. package/dist/world/markers.js +49 -0
  203. package/dist/world/massing.js +1 -1
  204. package/dist/world/minimapBake.d.ts +78 -0
  205. package/dist/world/minimapBake.js +208 -0
  206. package/dist/world/minimapTrack.d.ts +17 -0
  207. package/dist/world/minimapTrack.js +22 -0
  208. package/dist/world/place.d.ts +166 -0
  209. package/dist/world/place.js +96 -0
  210. package/dist/world/placeAsset.js +3 -1
  211. package/dist/world/poleLineKind.d.ts +40 -0
  212. package/dist/world/poleLineKind.js +97 -0
  213. package/dist/world/polyline.js +9 -2
  214. package/dist/world/resourceNode.d.ts +109 -0
  215. package/dist/world/resourceNode.js +140 -0
  216. package/dist/world/roads.d.ts +19 -2
  217. package/dist/world/roads.js +60 -3
  218. package/dist/world/scatter.js +2 -20
  219. package/dist/world/streetGenerator.d.ts +159 -0
  220. package/dist/world/streetGenerator.js +684 -0
  221. package/dist/world/streets.d.ts +103 -1
  222. package/dist/world/streets.js +273 -0
  223. package/dist/world/terraform.js +58 -88
  224. package/dist/world/terrain.d.ts +65 -1
  225. package/dist/world/terrain.js +119 -2
  226. package/dist/world.d.ts +14 -5
  227. package/dist/world.js +12 -3
  228. package/package.json +1 -1
  229. package/skills/game-design/SKILL.md +28 -0
  230. package/skills/game-design/agents/openai.yaml +4 -0
  231. package/skills/game-design/references/application-playbook.md +170 -0
  232. package/skills/game-design/references/field-guide.md +288 -0
  233. package/skills/jgengine/SKILL.md +8 -4
  234. package/skills/jgengine/api.md +182 -81
  235. package/skills/jgengine/capabilities.md +104 -2
  236. package/skills/jgengine/recipes/minimal-game.md +88 -0
  237. package/skills/jgengine-combat/SKILL.md +19 -1
  238. package/skills/jgengine-combat/api.md +75 -2
  239. package/skills/jgengine-combat/capabilities.md +61 -1
  240. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  241. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  242. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  243. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  244. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  245. package/skills/jgengine-combat/reference.md +33 -4
  246. package/skills/jgengine-gameplay/SKILL.md +8 -0
  247. package/skills/jgengine-gameplay/api.md +182 -26
  248. package/skills/jgengine-gameplay/capabilities.md +96 -2
  249. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  250. package/skills/jgengine-gameplay/reference-systems.md +10 -5
  251. package/skills/jgengine-world/SKILL.md +13 -0
  252. package/skills/jgengine-world/api.md +453 -21
  253. package/skills/jgengine-world/capabilities.md +112 -2
  254. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  255. package/skills/jgengine-world/reference.md +43 -4
  256. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,208 @@
1
+ import { pointInPolygon } from "./scatterRegion.js";
2
+ /** The built-in height→color ramp and water color a bake uses when no palette override is given. */
3
+ export const DEFAULT_MINIMAP_PALETTE = {
4
+ low: "#3f5d34",
5
+ high: "#cdbb93",
6
+ water: "#2f5d78",
7
+ };
8
+ function parseHex(hex) {
9
+ let value = hex.trim().replace(/^#/, "");
10
+ if (value.length === 3)
11
+ value = value.split("").map((c) => c + c).join("");
12
+ const int = Number.parseInt(value, 16);
13
+ if (!Number.isFinite(int) || value.length !== 6)
14
+ return { r: 0, g: 0, b: 0 };
15
+ return { r: (int >> 16) & 0xff, g: (int >> 8) & 0xff, b: int & 0xff };
16
+ }
17
+ function mix(a, b, t) {
18
+ const k = Math.max(0, Math.min(1, t));
19
+ return {
20
+ r: Math.round(a.r + (b.r - a.r) * k),
21
+ g: Math.round(a.g + (b.g - a.g) * k),
22
+ b: Math.round(a.b + (b.b - a.b) * k),
23
+ };
24
+ }
25
+ /**
26
+ * Rasterizes a top-down terrain image: height maps onto a low→high color ramp (normalized over the
27
+ * sampled height range), slope darkens the shade, water fills below `waterLevel`, and each zone
28
+ * polygon tints the ground it covers. Deterministic for a fixed source. The bounds are preserved so
29
+ * a player-centered minimap pans the image correctly.
30
+ * @capability minimap-bake rasterize authored terrain + zones into a top-down minimap image
31
+ */
32
+ export function bakeMinimapImage(source, options = {}) {
33
+ const resolution = Math.max(8, Math.floor(options.resolution ?? 128));
34
+ const palette = { ...DEFAULT_MINIMAP_PALETTE, ...options.palette };
35
+ const low = parseHex(palette.low);
36
+ const high = parseHex(palette.high);
37
+ const water = parseHex(palette.water);
38
+ const { minX, minZ, maxX, maxZ } = source.bounds;
39
+ const spanX = Math.max(1e-6, maxX - minX);
40
+ const spanZ = Math.max(1e-6, maxZ - minZ);
41
+ // Longer world axis gets `resolution` pixels; the other keeps aspect.
42
+ const width = spanX >= spanZ ? resolution : Math.max(8, Math.round((spanX / spanZ) * resolution));
43
+ const height = spanZ >= spanX ? resolution : Math.max(8, Math.round((spanZ / spanX) * resolution));
44
+ const worldX = (px) => minX + ((px + 0.5) / width) * spanX;
45
+ const worldZ = (py) => minZ + ((py + 0.5) / height) * spanZ;
46
+ // First pass: height range for a stable ramp.
47
+ let hMin = Infinity;
48
+ let hMax = -Infinity;
49
+ const heights = new Float64Array(width * height);
50
+ for (let py = 0; py < height; py += 1) {
51
+ for (let px = 0; px < width; px += 1) {
52
+ const h = source.sampleHeight(worldX(px), worldZ(py));
53
+ heights[py * width + px] = h;
54
+ if (h < hMin)
55
+ hMin = h;
56
+ if (h > hMax)
57
+ hMax = h;
58
+ }
59
+ }
60
+ const hSpan = Math.max(1e-6, hMax - hMin);
61
+ const pixels = new Uint8ClampedArray(width * height * 4);
62
+ const zones = source.zones ?? [];
63
+ for (let py = 0; py < height; py += 1) {
64
+ for (let px = 0; px < width; px += 1) {
65
+ const wx = worldX(px);
66
+ const wz = worldZ(py);
67
+ const h = heights[py * width + px];
68
+ let color;
69
+ if (source.waterLevel !== undefined && h <= source.waterLevel) {
70
+ color = water;
71
+ }
72
+ else {
73
+ color = mix(low, high, (h - hMin) / hSpan);
74
+ if (source.sampleNormal !== undefined) {
75
+ const n = source.sampleNormal(wx, wz);
76
+ const shade = 0.62 + 0.38 * Math.max(0, Math.min(1, n[1]));
77
+ color = { r: Math.round(color.r * shade), g: Math.round(color.g * shade), b: Math.round(color.b * shade) };
78
+ }
79
+ }
80
+ for (const zone of zones) {
81
+ if (pointInPolygon([wx, wz], zone.polygon)) {
82
+ color = mix(color, parseHex(zone.color), zone.alpha ?? 0.5);
83
+ }
84
+ }
85
+ const index = (py * width + px) * 4;
86
+ pixels[index] = color.r;
87
+ pixels[index + 1] = color.g;
88
+ pixels[index + 2] = color.b;
89
+ pixels[index + 3] = 255;
90
+ }
91
+ }
92
+ return { width, height, pixels, bounds: { minX, minZ, maxX, maxZ } };
93
+ }
94
+ // --- Minimal pure PNG encoder (RGBA, stored/uncompressed DEFLATE) ------------------------------
95
+ const CRC_TABLE = (() => {
96
+ const table = new Uint32Array(256);
97
+ for (let n = 0; n < 256; n += 1) {
98
+ let c = n;
99
+ for (let k = 0; k < 8; k += 1)
100
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
101
+ table[n] = c >>> 0;
102
+ }
103
+ return table;
104
+ })();
105
+ function crc32(bytes) {
106
+ let c = 0xffffffff;
107
+ for (let i = 0; i < bytes.length; i += 1)
108
+ c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
109
+ return (c ^ 0xffffffff) >>> 0;
110
+ }
111
+ function adler32(bytes) {
112
+ let a = 1;
113
+ let b = 0;
114
+ for (let i = 0; i < bytes.length; i += 1) {
115
+ a = (a + bytes[i]) % 65521;
116
+ b = (b + a) % 65521;
117
+ }
118
+ return ((b << 16) | a) >>> 0;
119
+ }
120
+ /** Wraps raw bytes in a zlib stream using only stored (uncompressed) DEFLATE blocks. */
121
+ function zlibStore(data) {
122
+ const blocks = [0x78, 0x01]; // zlib header (no compression)
123
+ let offset = 0;
124
+ while (offset < data.length) {
125
+ const chunk = Math.min(0xffff, data.length - offset);
126
+ const final = offset + chunk >= data.length ? 1 : 0;
127
+ blocks.push(final, chunk & 0xff, (chunk >> 8) & 0xff, ~chunk & 0xff, (~chunk >> 8) & 0xff);
128
+ for (let i = 0; i < chunk; i += 1)
129
+ blocks.push(data[offset + i]);
130
+ offset += chunk;
131
+ }
132
+ const checksum = adler32(data);
133
+ blocks.push((checksum >>> 24) & 0xff, (checksum >>> 16) & 0xff, (checksum >>> 8) & 0xff, checksum & 0xff);
134
+ return Uint8Array.from(blocks);
135
+ }
136
+ function chunk(type, data) {
137
+ const typeBytes = Uint8Array.from([...type].map((c) => c.charCodeAt(0)));
138
+ const body = new Uint8Array(typeBytes.length + data.length);
139
+ body.set(typeBytes, 0);
140
+ body.set(data, typeBytes.length);
141
+ const crc = crc32(body);
142
+ const out = new Uint8Array(4 + body.length + 4);
143
+ const view = new DataView(out.buffer);
144
+ view.setUint32(0, data.length);
145
+ out.set(body, 4);
146
+ view.setUint32(4 + body.length, crc);
147
+ return out;
148
+ }
149
+ function base64(bytes) {
150
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
151
+ let out = "";
152
+ for (let i = 0; i < bytes.length; i += 3) {
153
+ const b0 = bytes[i];
154
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
155
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
156
+ out += chars[b0 >> 2];
157
+ out += chars[((b0 & 3) << 4) | (b1 >> 4)];
158
+ out += i + 1 < bytes.length ? chars[((b1 & 15) << 2) | (b2 >> 6)] : "=";
159
+ out += i + 2 < bytes.length ? chars[b2 & 63] : "=";
160
+ }
161
+ return out;
162
+ }
163
+ /** Encodes an RGBA bake to raw PNG bytes (pure; stored DEFLATE, valid for any decoder). */
164
+ export function encodeBakePng(bake) {
165
+ const { width, height, pixels } = bake;
166
+ const ihdr = new Uint8Array(13);
167
+ const ihdrView = new DataView(ihdr.buffer);
168
+ ihdrView.setUint32(0, width);
169
+ ihdrView.setUint32(4, height);
170
+ ihdr[8] = 8; // bit depth
171
+ ihdr[9] = 6; // color type RGBA
172
+ // 10,11,12 = compression/filter/interlace = 0
173
+ // Filtered scanlines: filter byte 0 (none) per row.
174
+ const raw = new Uint8Array(height * (1 + width * 4));
175
+ for (let y = 0; y < height; y += 1) {
176
+ const rowStart = y * (1 + width * 4);
177
+ raw[rowStart] = 0;
178
+ raw.set(pixels.subarray(y * width * 4, (y + 1) * width * 4), rowStart + 1);
179
+ }
180
+ const signature = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
181
+ const parts = [
182
+ signature,
183
+ chunk("IHDR", ihdr),
184
+ chunk("IDAT", zlibStore(raw)),
185
+ chunk("IEND", new Uint8Array(0)),
186
+ ];
187
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
188
+ const out = new Uint8Array(total);
189
+ let offset = 0;
190
+ for (const part of parts) {
191
+ out.set(part, offset);
192
+ offset += part.length;
193
+ }
194
+ return out;
195
+ }
196
+ /**
197
+ * Encodes a bake as a `data:image/png;base64,...` URI — the exact string the `Minimap` /
198
+ * `WorldMap` `background` prop takes. Deterministic for a fixed bake.
199
+ * @capability minimap-bake encode a baked minimap as a PNG data URI for the Minimap background
200
+ */
201
+ export function minimapBakeToPngDataUri(bake) {
202
+ return `data:image/png;base64,${base64(encodeBakePng(bake))}`;
203
+ }
204
+ /** One-call convenience: rasterize a source and return `{ background, mapBounds }` for a minimap. */
205
+ export function bakeMinimapBackground(source, options = {}) {
206
+ const bake = bakeMinimapImage(source, options);
207
+ return { background: minimapBakeToPngDataUri(bake), mapBounds: bake.bounds };
208
+ }
@@ -0,0 +1,17 @@
1
+ import type { WorldXZ } from "./minimap.js";
2
+ /** A world-space segment defining a linear "track" minimap — points are projected onto the `from`→`to` axis. */
3
+ export interface TrackAxis {
4
+ from: WorldXZ;
5
+ to: WorldXZ;
6
+ }
7
+ /**
8
+ * Project a world point onto the `from`→`to` segment of `axis` and return the
9
+ * clamped 0..1 fraction of how far along the track it lies (0 at `from`, 1 at
10
+ * `to`). The linear analogue of `projectToMinimap` — feeds a horizontal
11
+ * corridor/route progress rail. Accepts XZ (`[x, z]`) or XYZ (`[x, y, z]`)
12
+ * points (index 0 = x, index 2 when length 3 else index 1 = z). A zero-length
13
+ * axis returns 0.
14
+ *
15
+ * @capability minimap-track project a world point to a 0..1 fraction along a linear track axis
16
+ */
17
+ export declare function trackFraction(point: WorldXZ | readonly [number, number, number], axis: TrackAxis): number;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Project a world point onto the `from`→`to` segment of `axis` and return the
3
+ * clamped 0..1 fraction of how far along the track it lies (0 at `from`, 1 at
4
+ * `to`). The linear analogue of `projectToMinimap` — feeds a horizontal
5
+ * corridor/route progress rail. Accepts XZ (`[x, z]`) or XYZ (`[x, y, z]`)
6
+ * points (index 0 = x, index 2 when length 3 else index 1 = z). A zero-length
7
+ * axis returns 0.
8
+ *
9
+ * @capability minimap-track project a world point to a 0..1 fraction along a linear track axis
10
+ */
11
+ export function trackFraction(point, axis) {
12
+ const px = point[0];
13
+ const pz = point.length === 3 ? point[2] : point[1];
14
+ const ax = axis.to[0] - axis.from[0];
15
+ const az = axis.to[1] - axis.from[1];
16
+ const lengthSq = ax * ax + az * az;
17
+ if (lengthSq === 0)
18
+ return 0;
19
+ const dot = (px - axis.from[0]) * ax + (pz - axis.from[1]) * az;
20
+ const fraction = dot / lengthSq;
21
+ return fraction < 0 ? 0 : fraction > 1 ? 1 : fraction;
22
+ }
@@ -0,0 +1,166 @@
1
+ import type { PhysicsConfig } from "../game/defineGame.js";
2
+ /**
3
+ * The place model: a world is the place you play in — substrate (ground) plus laws (physics,
4
+ * surface) — never a dressed diorama. Sky look, foliage scatter, props, and sculpt are content:
5
+ * they are authored in the editor (or by a content preset that writes into the scene document)
6
+ * and consumed through the authored-scene seams, not declared on the world.
7
+ *
8
+ * Hard rules the API enforces:
9
+ * - No `seed` anywhere in a world definition. Determinism comes from the runtime: the engine
10
+ * derives generator/scatter seeds from the world `id` plus the save/run seed (see
11
+ * {@link seedForPlace}), so the same place replays identically without the author owning RNG.
12
+ * - No sky, vegetation, or theme fields. A world with no authored sky renders the engine
13
+ * default sky; "void"/"space" is content, not a world knob.
14
+ * - No genre or franchise presets. `generator` carries algorithm parameters only.
15
+ */
16
+ /**
17
+ * Matter/feel laws of a place's ground that systems, physics, and audio read — restitution for a
18
+ * metal chamber the pieces ping off, stickiness for a slime pit, a soft felt mat. The same rule
19
+ * systems play on any surface; swapping the surface (plus content audio) changes the feel without
20
+ * forking the rules. This is place law, not a theme kit: visual/audio dressing stays in content.
21
+ */
22
+ export interface SurfaceLaws {
23
+ /** Named matter of the ground ("metal", "slime", "felt", …) — a label systems and audio cues key off. */
24
+ matter?: string;
25
+ /** Sliding friction scalar, 0 (ice) … 1+ (grippy). Interpreted by movement/physics systems. */
26
+ friction?: number;
27
+ /** Bounce energy retention, 0 (dead stop) … 1 (perfect bounce). Interpreted by physics/board systems. */
28
+ restitution?: number;
29
+ /** Free-form serializable place-law traits game systems read (e.g. `{ stuckTurns: 1 }` for slime). */
30
+ traits?: Readonly<Record<string, number | string | boolean>>;
31
+ }
32
+ /** Serializable algorithm parameters for a procedural ground generator. Never a genre kit. */
33
+ export interface GroundGenerator {
34
+ /** Algorithm identifier the game's generation system resolves (e.g. "fbm", "caves", "layers"). */
35
+ algorithm: string;
36
+ /**
37
+ * Algorithm tuning only — amplitudes, frequencies, thresholds, palettes of ids. Never a `seed`
38
+ * (the engine injects one from world id + save/run) and never a genre/franchise preset name.
39
+ */
40
+ params?: Readonly<Record<string, number | string | boolean>>;
41
+ }
42
+ /**
43
+ * Size of a `flat` ground: extents in world units. `Infinity` on an axis means unbounded — an
44
+ * endless plain needs no bounds number invented for it. `y` optionally bounds vertical play space.
45
+ */
46
+ export interface FlatGroundSize {
47
+ x: number;
48
+ /** Optional vertical bound of the play volume; omit for open sky. */
49
+ y?: number;
50
+ z: number;
51
+ radius?: never;
52
+ }
53
+ /** Size of a `round` ground: the planet/sphere radius in world units. Nothing else. */
54
+ export interface RoundGroundSize {
55
+ radius: number;
56
+ x?: never;
57
+ y?: never;
58
+ z?: never;
59
+ }
60
+ /** Size of a `voxel` ground: the generator's domain per axis; axes may be `Infinity` for streaming volumes. */
61
+ export interface VoxelGroundSize {
62
+ x: number;
63
+ y: number;
64
+ z: number;
65
+ radius?: never;
66
+ }
67
+ /** Size of a `board` ground: a 2D surface you look at, in cells or layout units. */
68
+ export interface BoardGroundSize {
69
+ x: number;
70
+ y: number;
71
+ z?: never;
72
+ radius?: never;
73
+ }
74
+ /** A 3D walkable plane/slab. `Infinity` axes are unbounded; no separate "infinite" mode exists. */
75
+ export interface FlatGround {
76
+ mode: "flat";
77
+ size: FlatGroundSize;
78
+ surface?: SurfaceLaws;
79
+ generator?: GroundGenerator;
80
+ }
81
+ /** A planet/sphere you play on the outside of. Sized by `radius` only. */
82
+ export interface RoundGround {
83
+ mode: "round";
84
+ size: RoundGroundSize;
85
+ surface?: SurfaceLaws;
86
+ generator?: GroundGenerator;
87
+ }
88
+ /** A procedural volume of blocks. `size` is the generator domain; `generator` is algorithm params only. */
89
+ export interface VoxelGround {
90
+ mode: "voxel";
91
+ size: VoxelGroundSize;
92
+ surface?: SurfaceLaws;
93
+ generator?: GroundGenerator;
94
+ }
95
+ /**
96
+ * A 2D surface you look at — grid games, solitaire, tabletop. Physics is often zero-gravity or
97
+ * omitted; the game owns the face it draws on the board.
98
+ */
99
+ export interface BoardGround {
100
+ /** `"stage"` is accepted as an alias and normalized to `"board"`. */
101
+ mode: "board" | "stage";
102
+ size: BoardGroundSize;
103
+ surface?: SurfaceLaws;
104
+ }
105
+ /** The substrate of a place, discriminated by `mode` — TS rejects a `radius` on `flat` and `x`/`z` on `round`. */
106
+ export type GroundConfig = FlatGround | RoundGround | VoxelGround | BoardGround;
107
+ /** Canonical ground modes after normalization (`stage` → `board`). */
108
+ export type GroundMode = "flat" | "round" | "voxel" | "board";
109
+ /** {@link BoardGround} with the `stage` alias resolved away. */
110
+ export type ResolvedBoardGround = Omit<BoardGround, "mode"> & {
111
+ mode: "board";
112
+ };
113
+ /** A place's ground after `world()` normalization. */
114
+ export type ResolvedGround = FlatGround | RoundGround | VoxelGround | ResolvedBoardGround;
115
+ /** Input to {@link world}: place identity, substrate, and the laws of this place. */
116
+ export interface PlaceConfig {
117
+ /** Place identity — names the world for saves, seeds, and world switching. */
118
+ id: string;
119
+ /** The substrate: what you stand on / look at, discriminated by `mode`. */
120
+ ground: GroundConfig;
121
+ /**
122
+ * Laws of THIS place (gravity, jump). Each world carries its own physics, so a game with several
123
+ * places (a station and a moon) gives each different laws; `defineGameDefinition` resolves the
124
+ * active world's physics over the game-level default. The engine owns simulation.
125
+ */
126
+ physics?: PhysicsConfig;
127
+ }
128
+ /** A declared place — the `world()` result carried on `GameDefinition.world`. */
129
+ export interface PlaceWorldFeature {
130
+ kind: "place";
131
+ id: string;
132
+ ground: ResolvedGround;
133
+ physics?: PhysicsConfig;
134
+ }
135
+ /**
136
+ * Declares the place a game is played in: substrate (`ground`) + laws (`physics`, `ground.surface`).
137
+ * The thin default start — `flat` with `Infinity` axes and default physics, `board` for 2D — is a
138
+ * complete world; dressing it (sky, foliage, props, sculpt) is editor-authored scene content.
139
+ * Multiple worlds per game are first-class: declare one `world()` per place, each with its own id,
140
+ * ground, and physics. Games that are not a spatial place (pure UI/rules) omit `world` entirely.
141
+ *
142
+ * @capability world-place declare the place a game happens in — flat/round/voxel/board ground, surface laws, per-place physics
143
+ */
144
+ export declare function world(config: PlaceConfig): PlaceWorldFeature;
145
+ /**
146
+ * The engine-side seed derivation for a place: a stable string from the world `id` plus the
147
+ * save/run seed. Generators and scatter receive this — never a `seed` field in the world
148
+ * definition — so a save replays its world deterministically and a fresh run can vary by
149
+ * `runSeed` alone.
150
+ *
151
+ * @capability world-seed derive a deterministic generator/scatter seed from world id + save/run seed
152
+ */
153
+ export declare function seedForPlace(worldId: string, runSeed?: string): string;
154
+ /**
155
+ * Resolves the physics laws in effect for the active world: a place world's own `physics` wins
156
+ * field-by-field over the game-level default. Non-place worlds (and games with no world) keep the
157
+ * game-level config unchanged.
158
+ */
159
+ export declare function resolveWorldPhysics(worldFeature: {
160
+ kind: string;
161
+ physics?: PhysicsConfig;
162
+ } | undefined, base: PhysicsConfig | undefined): PhysicsConfig | undefined;
163
+ /** Type guard for the place-model world feature. */
164
+ export declare function isPlaceWorld(worldFeature: {
165
+ kind: string;
166
+ } | undefined): worldFeature is PlaceWorldFeature;
@@ -0,0 +1,96 @@
1
+ const BANNED_WORLD_KEYS = ["sky", "vegetation", "terrain", "environment", "weather", "seed"];
2
+ function assertNoDressing(config) {
3
+ const record = config;
4
+ for (const key of BANNED_WORLD_KEYS) {
5
+ if (record[key] !== undefined) {
6
+ throw new Error(key === "seed"
7
+ ? "world: seed does not belong in a world definition — the engine derives seeds from the world id and the save/run seed (seedForPlace)."
8
+ : `world: "${key}" does not belong in a world definition — a world is substrate + laws. Author sky, foliage, and dressing in the editor (or a content preset that writes scene data).`);
9
+ }
10
+ }
11
+ }
12
+ function assertAxis(mode, axis, value, allowInfinite) {
13
+ const ok = value > 0 && (allowInfinite ? true : Number.isFinite(value));
14
+ if (!ok) {
15
+ throw new Error(`world: ground.size.${axis} for mode "${mode}" must be a positive${allowInfinite ? "" : " finite"} number, got ${value}`);
16
+ }
17
+ }
18
+ function resolveGround(ground) {
19
+ if (ground["seed"] !== undefined) {
20
+ throw new Error("world: seed does not belong on ground — the engine injects seeds into generators (seedForPlace).");
21
+ }
22
+ if ("generator" in ground && ground.generator?.params !== undefined && "seed" in ground.generator.params) {
23
+ throw new Error("world: generator.params.seed is not allowed — the engine injects the seed from world id + save/run (seedForPlace).");
24
+ }
25
+ switch (ground.mode) {
26
+ case "flat": {
27
+ assertAxis("flat", "x", ground.size.x, true);
28
+ assertAxis("flat", "z", ground.size.z, true);
29
+ if (ground.size.y !== undefined)
30
+ assertAxis("flat", "y", ground.size.y, true);
31
+ return ground;
32
+ }
33
+ case "round": {
34
+ assertAxis("round", "radius", ground.size.radius, false);
35
+ return ground;
36
+ }
37
+ case "voxel": {
38
+ assertAxis("voxel", "x", ground.size.x, true);
39
+ assertAxis("voxel", "y", ground.size.y, true);
40
+ assertAxis("voxel", "z", ground.size.z, true);
41
+ return ground;
42
+ }
43
+ case "board":
44
+ case "stage": {
45
+ assertAxis("board", "x", ground.size.x, false);
46
+ assertAxis("board", "y", ground.size.y, false);
47
+ return ground.mode === "board" ? ground : { ...ground, mode: "board" };
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * Declares the place a game is played in: substrate (`ground`) + laws (`physics`, `ground.surface`).
53
+ * The thin default start — `flat` with `Infinity` axes and default physics, `board` for 2D — is a
54
+ * complete world; dressing it (sky, foliage, props, sculpt) is editor-authored scene content.
55
+ * Multiple worlds per game are first-class: declare one `world()` per place, each with its own id,
56
+ * ground, and physics. Games that are not a spatial place (pure UI/rules) omit `world` entirely.
57
+ *
58
+ * @capability world-place declare the place a game happens in — flat/round/voxel/board ground, surface laws, per-place physics
59
+ */
60
+ export function world(config) {
61
+ if (config.id.trim().length === 0) {
62
+ throw new Error("world: id must be non-empty — it names the place for saves, seeds, and world switching");
63
+ }
64
+ assertNoDressing(config);
65
+ return {
66
+ kind: "place",
67
+ id: config.id,
68
+ ground: resolveGround(config.ground),
69
+ ...(config.physics === undefined ? {} : { physics: config.physics }),
70
+ };
71
+ }
72
+ /**
73
+ * The engine-side seed derivation for a place: a stable string from the world `id` plus the
74
+ * save/run seed. Generators and scatter receive this — never a `seed` field in the world
75
+ * definition — so a save replays its world deterministically and a fresh run can vary by
76
+ * `runSeed` alone.
77
+ *
78
+ * @capability world-seed derive a deterministic generator/scatter seed from world id + save/run seed
79
+ */
80
+ export function seedForPlace(worldId, runSeed = "") {
81
+ return runSeed.length === 0 ? worldId : `${worldId}:${runSeed}`;
82
+ }
83
+ /**
84
+ * Resolves the physics laws in effect for the active world: a place world's own `physics` wins
85
+ * field-by-field over the game-level default. Non-place worlds (and games with no world) keep the
86
+ * game-level config unchanged.
87
+ */
88
+ export function resolveWorldPhysics(worldFeature, base) {
89
+ if (worldFeature === undefined || worldFeature.kind !== "place" || worldFeature.physics === undefined)
90
+ return base;
91
+ return base === undefined ? worldFeature.physics : { ...base, ...worldFeature.physics };
92
+ }
93
+ /** Type guard for the place-model world feature. */
94
+ export function isPlaceWorld(worldFeature) {
95
+ return worldFeature?.kind === "place";
96
+ }
@@ -7,10 +7,12 @@ function normalizePosition(position) {
7
7
  const t = position;
8
8
  return { x: t[0], y: t[1], z: t[2] };
9
9
  }
10
+ let placementSequence = 0;
10
11
  function placementId(assetId, explicit) {
11
12
  if (explicit !== undefined && explicit.length > 0)
12
13
  return explicit;
13
- return `placed_${assetId}_${Date.now().toString(36)}`;
14
+ placementSequence += 1;
15
+ return `placed_${assetId}_${Date.now().toString(36)}_${placementSequence.toString(36)}`;
14
16
  }
15
17
  /**
16
18
  * Resolve a place-asset intent into a shared payload (editor + games, one verb).
@@ -0,0 +1,40 @@
1
+ import { type Vec3 } from "./catenary.js";
2
+ import { type ParamSchema, type ParsedParams, type SceneKindObject, type SceneKindResolveContext } from "../scene/sceneKinds.js";
3
+ /**
4
+ * The editor path kind marking a polyline as a pole/cable run.
5
+ * @capability pole-line editor-authorable poles + sagging cables along a path
6
+ */
7
+ export declare const POLE_LINE_KIND = "pole_line";
8
+ /** One placed pole: grounded base position plus facing yaw along the run. */
9
+ export interface Pole {
10
+ id: string;
11
+ position: Vec3;
12
+ yaw: number;
13
+ }
14
+ /** One resolved cable span: a point string to loft into a tube, plus its radius. */
15
+ export interface Cable {
16
+ id: string;
17
+ points: Vec3[];
18
+ radius: number;
19
+ }
20
+ /** The renderable payload the resolver returns and the shell renderer consumes. */
21
+ export interface ResolvedPoleLine {
22
+ poles: Pole[];
23
+ cables: Cable[];
24
+ poleHeight: number;
25
+ poleAsset: string;
26
+ /** Wires per span — drives the renderer's crossarm width and insulator count. */
27
+ wireCount: number;
28
+ /** Lateral spread between adjacent wires, meters — the renderer hangs insulators at these offsets. */
29
+ wireSpacing: number;
30
+ }
31
+ /** The pole-line parameter schema — drives the inspector and `meta` parse via the studio seam. */
32
+ export declare const POLE_LINE_SCHEMA: ParamSchema;
33
+ /**
34
+ * Resolve poles + cables from a document object's points and parsed params. Poles come from the
35
+ * generic `placeAlongPath` sampler; between each consecutive pair, `wireCount` parallel cables are
36
+ * strung with lateral offset and quadratic-Bézier `sag` (deeper on longer spans). Pure data.
37
+ */
38
+ export declare function resolvePoleLine(object: SceneKindObject, params: ParsedParams, context?: SceneKindResolveContext): ResolvedPoleLine;
39
+ /** Registers the pole-line scene kind (schema + resolver + inspector note). Called by `registerBuiltinSceneKinds`. @internal */
40
+ export declare function registerPoleLineKind(): void;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `pole_line` studio: poles + sagging cables strung along an editor-drawn path — power lines, fences,
3
+ * streetlight runs, ziplines. Pure config here (spacing/height/wire params + a resolver built from the
4
+ * generic `placeAlongPath` and `sagCurve` primitives); the instanced pole/cable meshes live in the
5
+ * `shell` renderer. An engine environment kind (like water/grass_field/soil) so wire runs are
6
+ * drawable and slider-authorable in every editor session and persisted in `editor.scene.json` —
7
+ * promoted from `examples/studios` for studio/editor parity (#1101).
8
+ */
9
+ import { placeAlongPath } from "./pathInstances.js";
10
+ import { sagCurve } from "./catenary.js";
11
+ import { registerSceneKind, } from "../scene/sceneKinds.js";
12
+ /**
13
+ * The editor path kind marking a polyline as a pole/cable run.
14
+ * @capability pole-line editor-authorable poles + sagging cables along a path
15
+ */
16
+ export const POLE_LINE_KIND = "pole_line";
17
+ /** The pole-line parameter schema — drives the inspector and `meta` parse via the studio seam. */
18
+ export const POLE_LINE_SCHEMA = {
19
+ fields: [
20
+ { type: "text", key: "poleAsset", label: "pole asset", default: "", group: "poles" },
21
+ { type: "range", key: "spacing", label: "spacing", min: 2, max: 40, step: 0.5, default: 8, unit: "m", group: "poles" },
22
+ { type: "range", key: "poleHeight", label: "pole height", min: 1, max: 20, step: 0.25, default: 6, unit: "m", group: "poles" },
23
+ { type: "bool", key: "snapToTerrain", label: "snap to ground", default: true, group: "poles" },
24
+ { type: "action", key: "randomizePoles", label: "randomize poles", action: "randomize", group: "poles" },
25
+ { type: "range", key: "wireCount", label: "wires", min: 0, max: 8, step: 1, default: 3, group: "cables" },
26
+ { type: "range", key: "wireSpacing", label: "wire spread", min: 0, max: 3, step: 0.05, default: 0.5, unit: "m", group: "cables" },
27
+ { type: "range", key: "cableRadius", label: "cable radius", min: 0.01, max: 0.5, step: 0.01, default: 0.05, unit: "m", group: "cables" },
28
+ { type: "range", key: "sag", label: "sag", min: 0, max: 6, step: 0.1, default: 0.8, unit: "m", group: "cables" },
29
+ { type: "range", key: "sagPerMeter", label: "sag / m", min: 0, max: 0.3, step: 0.005, default: 0.04, group: "cables" },
30
+ { type: "range", key: "cableSegments", label: "cable smoothness", min: 2, max: 48, step: 1, default: 12, group: "cables" },
31
+ { type: "seed", key: "seed", label: "seed", default: "", group: "cables" },
32
+ { type: "action", key: "randomizeCables", label: "randomize cables", action: "randomize", group: "cables" },
33
+ ],
34
+ groups: [
35
+ { id: "poles", label: "Poles" },
36
+ { id: "cables", label: "Cables" },
37
+ ],
38
+ };
39
+ /**
40
+ * Resolve poles + cables from a document object's points and parsed params. Poles come from the
41
+ * generic `placeAlongPath` sampler; between each consecutive pair, `wireCount` parallel cables are
42
+ * strung with lateral offset and quadratic-Bézier `sag` (deeper on longer spans). Pure data.
43
+ */
44
+ export function resolvePoleLine(object, params, context) {
45
+ const spacing = params["spacing"];
46
+ const poleHeight = params["poleHeight"];
47
+ const wireCount = Math.round(params["wireCount"]);
48
+ const wireSpacing = params["wireSpacing"];
49
+ const cableRadius = params["cableRadius"];
50
+ const sag = params["sag"];
51
+ const sagPerMeter = params["sagPerMeter"];
52
+ const cableSegments = Math.round(params["cableSegments"]);
53
+ const snap = params["snapToTerrain"];
54
+ const placed = placeAlongPath(object.points ?? [], {
55
+ spacing,
56
+ ...(snap && context?.sampleHeight !== undefined ? { sampleHeight: context.sampleHeight } : {}),
57
+ });
58
+ const poles = placed.map((instance) => ({ id: `${object.id}/pole/${instance.index}`, position: instance.position, yaw: instance.yaw }));
59
+ const cables = [];
60
+ if (wireCount > 0) {
61
+ for (let i = 0; i + 1 < poles.length; i += 1) {
62
+ const a = poles[i];
63
+ const b = poles[i + 1];
64
+ const dx = b.position[0] - a.position[0];
65
+ const dz = b.position[2] - a.position[2];
66
+ const span = Math.hypot(dx, dz) || 1;
67
+ const perpX = -dz / span;
68
+ const perpZ = dx / span;
69
+ const drop = sag + sagPerMeter * span;
70
+ const half = (wireCount - 1) / 2;
71
+ for (let w = 0; w < wireCount; w += 1) {
72
+ const offset = (w - half) * wireSpacing;
73
+ const topA = [a.position[0] + perpX * offset, a.position[1] + poleHeight, a.position[2] + perpZ * offset];
74
+ const topB = [b.position[0] + perpX * offset, b.position[1] + poleHeight, b.position[2] + perpZ * offset];
75
+ cables.push({ id: `${object.id}/cable/${i}/${w}`, points: sagCurve(topA, topB, drop, cableSegments), radius: cableRadius });
76
+ }
77
+ }
78
+ }
79
+ return { poles, cables, poleHeight, poleAsset: params["poleAsset"], wireCount, wireSpacing };
80
+ }
81
+ /** Registers the pole-line scene kind (schema + resolver + inspector note). Called by `registerBuiltinSceneKinds`. @internal */
82
+ export function registerPoleLineKind() {
83
+ registerSceneKind({
84
+ kind: POLE_LINE_KIND,
85
+ target: "path",
86
+ label: "Pole line / cables",
87
+ pathShape: "line",
88
+ addCategory: "Studios",
89
+ accent: "#fbbf24",
90
+ schema: POLE_LINE_SCHEMA,
91
+ resolve: (object, params, context) => resolvePoleLine(object, params, context),
92
+ note: (object, params) => {
93
+ const resolved = resolvePoleLine(object, params);
94
+ return `${resolved.poles.length} poles · ${resolved.cables.length} cables`;
95
+ },
96
+ });
97
+ }