lecodes-cli 0.19.0 → 0.19.2

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 (254) hide show
  1. package/dist/index.js +730 -235
  2. package/package.json +14 -13
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/compile/aspectMacro.ts +0 -86
  86. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  87. package/runtime/sdk/compile/assetMacro.ts +0 -146
  88. package/runtime/sdk/compile/assetName.ts +0 -50
  89. package/runtime/sdk/compile/bundler.ts +0 -287
  90. package/runtime/sdk/compile/compileProject.ts +0 -129
  91. package/runtime/sdk/compile/detectEntry.ts +0 -128
  92. package/runtime/sdk/compile/fontMacro.ts +0 -459
  93. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  94. package/runtime/sdk/compile/header.ts +0 -67
  95. package/runtime/sdk/compile/index.ts +0 -100
  96. package/runtime/sdk/compile/libraryImports.ts +0 -52
  97. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  98. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  99. package/runtime/sdk/compile/serverSplit.ts +0 -233
  100. package/runtime/sdk/compile/serverTypes.ts +0 -227
  101. package/runtime/sdk/compile/sfnt.ts +0 -98
  102. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  103. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  104. package/runtime/sdk/compile/sourcemap.ts +0 -25
  105. package/runtime/sdk/core/Aspect.ts +0 -568
  106. package/runtime/sdk/core/InspectorUI.ts +0 -212
  107. package/runtime/sdk/core/StateMachine.ts +0 -308
  108. package/runtime/sdk/core/color.ts +0 -66
  109. package/runtime/sdk/core/compWrite.ts +0 -42
  110. package/runtime/sdk/core/events.ts +0 -38
  111. package/runtime/sdk/core/fields.ts +0 -120
  112. package/runtime/sdk/core/registry.ts +0 -23
  113. package/runtime/sdk/core/signals.ts +0 -277
  114. package/runtime/sdk/core/time.ts +0 -84
  115. package/runtime/sdk/g2/Camera2D.ts +0 -40
  116. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  117. package/runtime/sdk/g2/Node2D.ts +0 -267
  118. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  119. package/runtime/sdk/g2/Physics2D.ts +0 -346
  120. package/runtime/sdk/g2/Scene2D.ts +0 -209
  121. package/runtime/sdk/g2/Shape2D.ts +0 -259
  122. package/runtime/sdk/g2/Sprite.ts +0 -89
  123. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  124. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  125. package/runtime/sdk/g2/Texture2D.ts +0 -47
  126. package/runtime/sdk/g2/Tilemap.ts +0 -41
  127. package/runtime/sdk/g2/Tileset.ts +0 -71
  128. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  129. package/runtime/sdk/g2/autotile.ts +0 -433
  130. package/runtime/sdk/g2/cells.ts +0 -91
  131. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  132. package/runtime/sdk/g2/groups2d.ts +0 -106
  133. package/runtime/sdk/g2/loop.ts +0 -50
  134. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  135. package/runtime/sdk/g2/touch.ts +0 -83
  136. package/runtime/sdk/gl/Camera.ts +0 -160
  137. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  138. package/runtime/sdk/gl/CharacterController.ts +0 -238
  139. package/runtime/sdk/gl/Gearbox.ts +0 -212
  140. package/runtime/sdk/gl/Geometry.ts +0 -279
  141. package/runtime/sdk/gl/IK.ts +0 -193
  142. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  143. package/runtime/sdk/gl/Light.ts +0 -107
  144. package/runtime/sdk/gl/Lightmap.ts +0 -256
  145. package/runtime/sdk/gl/Material.ts +0 -260
  146. package/runtime/sdk/gl/Mesh.ts +0 -83
  147. package/runtime/sdk/gl/Model.ts +0 -124
  148. package/runtime/sdk/gl/NavAgent.ts +0 -337
  149. package/runtime/sdk/gl/NavMesh.ts +0 -397
  150. package/runtime/sdk/gl/Node.ts +0 -350
  151. package/runtime/sdk/gl/Noise.ts +0 -30
  152. package/runtime/sdk/gl/Particles.ts +0 -676
  153. package/runtime/sdk/gl/Physics.ts +0 -222
  154. package/runtime/sdk/gl/Plane.ts +0 -53
  155. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  156. package/runtime/sdk/gl/Ray.ts +0 -16
  157. package/runtime/sdk/gl/Scene.ts +0 -486
  158. package/runtime/sdk/gl/Shape.ts +0 -409
  159. package/runtime/sdk/gl/Terrain.ts +0 -1092
  160. package/runtime/sdk/gl/Texture.ts +0 -63
  161. package/runtime/sdk/gl/Trigger.ts +0 -45
  162. package/runtime/sdk/gl/Vehicle.ts +0 -473
  163. package/runtime/sdk/gl/Wheel.ts +0 -240
  164. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  165. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  166. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  167. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  168. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  169. package/runtime/sdk/gl/animation/core.ts +0 -336
  170. package/runtime/sdk/gl/controls.ts +0 -95
  171. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  172. package/runtime/sdk/gl/scenarios.ts +0 -291
  173. package/runtime/sdk/gl/state.ts +0 -6
  174. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  175. package/runtime/sdk/gl/touch.ts +0 -68
  176. package/runtime/sdk/inject.ts +0 -197
  177. package/runtime/sdk/math/Mathf.ts +0 -118
  178. package/runtime/sdk/math/mat4.ts +0 -278
  179. package/runtime/sdk/math/quat.ts +0 -232
  180. package/runtime/sdk/math/vec.ts +0 -255
  181. package/runtime/sdk/net/codec.ts +0 -119
  182. package/runtime/sdk/net/core.ts +0 -384
  183. package/runtime/sdk/net/index.ts +0 -181
  184. package/runtime/sdk/net/replication.ts +0 -622
  185. package/runtime/sdk/plugins/camera.ts +0 -81
  186. package/runtime/sdk/plugins/geolocation.ts +0 -123
  187. package/runtime/sdk/plugins/oauth.ts +0 -61
  188. package/runtime/sdk/plugins/permission.ts +0 -7
  189. package/runtime/sdk/plugins/push.ts +0 -132
  190. package/runtime/sdk/plugins/qr.ts +0 -73
  191. package/runtime/sdk/plugins/service.ts +0 -47
  192. package/runtime/sdk/runtime/app.ts +0 -101
  193. package/runtime/sdk/runtime/appEvents.ts +0 -54
  194. package/runtime/sdk/runtime/channel.ts +0 -61
  195. package/runtime/sdk/runtime/clipboard.ts +0 -20
  196. package/runtime/sdk/runtime/datetime.ts +0 -329
  197. package/runtime/sdk/runtime/device.ts +0 -293
  198. package/runtime/sdk/runtime/fetch.ts +0 -77
  199. package/runtime/sdk/runtime/files.ts +0 -23
  200. package/runtime/sdk/runtime/input.ts +0 -175
  201. package/runtime/sdk/runtime/media.ts +0 -111
  202. package/runtime/sdk/runtime/misc.ts +0 -16
  203. package/runtime/sdk/runtime/net.ts +0 -36
  204. package/runtime/sdk/runtime/rpc.ts +0 -218
  205. package/runtime/sdk/runtime/service.ts +0 -83
  206. package/runtime/sdk/runtime/share.ts +0 -9
  207. package/runtime/sdk/runtime/storage.ts +0 -13
  208. package/runtime/sdk/runtime/touch.ts +0 -76
  209. package/runtime/sdk/scene/defineScene.ts +0 -1353
  210. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  211. package/runtime/sdk/scene/gizmos.ts +0 -148
  212. package/runtime/sdk/scene/grammar.ts +0 -120
  213. package/runtime/sdk/scene/material.ts +0 -188
  214. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  215. package/runtime/sdk/server/auth/global.ts +0 -80
  216. package/runtime/sdk/server/auth/host.ts +0 -318
  217. package/runtime/sdk/server/auth/models.ts +0 -83
  218. package/runtime/sdk/server/auth/types.ts +0 -50
  219. package/runtime/sdk/server/channel.ts +0 -56
  220. package/runtime/sdk/server/context.ts +0 -36
  221. package/runtime/sdk/server/db/defineDb.ts +0 -237
  222. package/runtime/sdk/server/db/fields.ts +0 -132
  223. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  224. package/runtime/sdk/server/db/index.ts +0 -7
  225. package/runtime/sdk/server/db/marci/query.ts +0 -412
  226. package/runtime/sdk/server/db/types.ts +0 -202
  227. package/runtime/sdk/server/errors.ts +0 -12
  228. package/runtime/sdk/server/host.ts +0 -74
  229. package/runtime/sdk/server/inject.ts +0 -13
  230. package/runtime/sdk/server/runtime.ts +0 -133
  231. package/runtime/sdk/server/validate.ts +0 -87
  232. package/runtime/sdk/ui/NativeView.ts +0 -142
  233. package/runtime/sdk/ui/UI.ts +0 -39
  234. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  235. package/runtime/sdk/ui/UIButton.ts +0 -101
  236. package/runtime/sdk/ui/UIContainer.ts +0 -60
  237. package/runtime/sdk/ui/UIImage.ts +0 -83
  238. package/runtime/sdk/ui/UIInput.ts +0 -185
  239. package/runtime/sdk/ui/UIModal.ts +0 -139
  240. package/runtime/sdk/ui/UINode.ts +0 -830
  241. package/runtime/sdk/ui/UIPager.ts +0 -362
  242. package/runtime/sdk/ui/UIPopover.ts +0 -100
  243. package/runtime/sdk/ui/UIScreen.ts +0 -123
  244. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  245. package/runtime/sdk/ui/UISpacer.ts +0 -14
  246. package/runtime/sdk/ui/UITabs.ts +0 -236
  247. package/runtime/sdk/ui/UIText.ts +0 -51
  248. package/runtime/sdk/ui/UIVideo.ts +0 -88
  249. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  250. package/runtime/sdk/ui/UIWidget.ts +0 -127
  251. package/runtime/sdk/ui/fonts.ts +0 -13
  252. package/runtime/sdk/ui/presentable.ts +0 -117
  253. package/runtime/sdk/ui/router.ts +0 -132
  254. package/runtime/sdk/ui/theme.ts +0 -84
@@ -1,1092 +0,0 @@
1
- // Terrain — a heightmap ground (docs/terrain-plan.md). The SDK OWNS the data: `heights` (metres per
2
- // sample), `holes` (a byte per sample) and `control` (RGBA layer weights per sample) are plain typed
3
- // arrays the developer edits directly or through the helpers below; `commit()` pushes the dirty
4
- // rectangle to the engine (chunk renderables, the Jolt height field, the control texture). Queries
5
- // (`heightAt`, `normalAt`) run in JS on the same triangle split every host draws and collides, so a
6
- // game reads the ground identically on desktop, web-lite and headless.
7
- //
8
- // const t = Terrain.create({ size: 513, cellSize: 1, heights: (x, z) => noise(x, z) * 40, layers: [
9
- // { albedo: asset('./tex/grass.ktx2'), tiling: 4 },
10
- // { albedo: asset('./tex/rock.ktx2'), tiling: 6, triplanar: true },
11
- // { albedo: asset('./tex/dirt.ktx2'), tiling: 5 },
12
- // { albedo: asset('./tex/snow.ktx2'), tiling: 6 },
13
- // ], normals: [asset('./tex/normals01.ktx2'), asset('./tex/normals23.ktx2')] })
14
- // t.paintAuto({ base: 0, slope: { layer: 1, from: 30, to: 45 }, height: { layer: 3, from: 60, to: 70 } })
15
- // t.conform(road, { offset: 0.05, falloff: 6 }) // the ground hugs the road mesh: cut AND fill
16
- // t.hole({ circle: { x: 120, z: 80, r: 4 } }) // no triangles, no collision, no navmesh there
17
- // scene.add(t.node.aspect(Shape, { heightfield: true }).aspect(Physics, { motion: 'static' }))
18
- // t.commit()
19
- // const y = t.heightAt(x, z) // NaN inside a hole / off the grid
20
- //
21
- // Coordinates: sample (0, 0) sits at the node's origin, +X across columns, +Z across rows, heights on
22
- // +Y — LOCAL to `node`; move/rotate the node to place the terrain (keep its scale 1). World-space
23
- // callers use `worldHeightAt` / `toLocal`.
24
- //
25
- // Layers: four PBR layers (albedo + per-layer tiling / roughness / triplanar) blended by `control`;
26
- // normal maps come PACKED two per texture (`normals[0]` = layers 0+1: XY in RG / BA, `normals[1]` =
27
- // layers 2+3) because the material's sampler budget is nine on every host — `lecodes assets` packs
28
- // them; `normalScale` per layer switches the read on.
29
- //
30
- // Hosts: `_creator.terrainCreate` (creator-gl) builds the chunks natively; a host without it (web-lite,
31
- // headless, an older build) gets the same chunks as child Mesh nodes from gl/terrainMesh.ts — the
32
- // project runs everywhere, normal maps just read flat there.
33
-
34
- import { Node } from "./Node"
35
- import { Mesh } from "./Mesh"
36
- import { Model } from "./Model"
37
- import { Material } from "./Material"
38
- import { Texture } from "./Texture"
39
- import { Geometry } from "./Geometry"
40
- import { Shape } from "./Shape"
41
- import { Color, type ColorInput } from "../core/color"
42
- import { fetch } from "../runtime/fetch"
43
- import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
44
- import {
45
- buildTerrainChunk, chunkCounts, faceNormalAt, gridRaycast, heightAt as gridHeightAt, normalAt as gridNormalAt, type TerrainGrid,
46
- } from "./terrainMesh"
47
-
48
- export type TerrainLayer = {
49
- /** Albedo texture (a URL / `asset()` handle, or a loaded Texture). Unset = white. */
50
- albedo?: string | Texture
51
- /** Metres per texture repeat. Default 8. */
52
- tiling?: number
53
- /** Perceptual roughness of the layer. Default 1. */
54
- roughness?: number
55
- /** Normal-map strength (0 = the layer's slot of the pack is not read). Default 0 — set 1 when the
56
- * layer has a normal map in `normals`. */
57
- normalScale?: number
58
- /** Sample this layer triplanar in world space (cliff rock): no stretching on steep slopes; its
59
- * normal map is then skipped. Default false. */
60
- triplanar?: boolean
61
- }
62
-
63
- export type TerrainOptions = {
64
- /** Samples per side (square). 257 / 513 / 1025 are the usual sizes. Default 129. */
65
- size?: number
66
- sizeX?: number
67
- sizeZ?: number
68
- /** Metres between samples. Default 1. */
69
- cellSize?: number
70
- /** Cells per chunk side (≤ 255). Default 64. */
71
- chunk?: number
72
- /** Heights in metres: an array of sizeX × sizeZ (row-major, z rows), or a function of the sample's
73
- * LOCAL position in metres. Default flat 0. */
74
- heights?: Float32Array | ((x: number, z: number) => number)
75
- /** A byte per sample, 1 = hole (a triangle needs its three samples valid). */
76
- holes?: Uint8Array
77
- /** RGBA layer weights per sample (normalised in the shader). Default: all layer 0. */
78
- control?: Uint8Array
79
- layers?: TerrainLayer[]
80
- /** The two normal-map packs: `[layers 0+1, layers 2+3]` — XY of the even layer in RG, the odd in BA. */
81
- normals?: [string | Texture | undefined, (string | Texture)?]
82
- /** Multiplier on every layer's albedo. */
83
- tint?: ColorInput
84
- name?: string
85
- /** A material to use instead of `Material.terrain()` (a custom terrain shader with the same uniforms). */
86
- material?: Material
87
- }
88
-
89
- /** A region of the terrain in LOCAL metres (XZ). `node` = the XZ footprint of a node's triangles. */
90
- export type TerrainRegion =
91
- | { circle: { x: number, z: number, r: number } }
92
- | { rect: { x: number, z: number, w: number, h: number } }
93
- | { polygon: readonly (readonly [number, number])[] }
94
- | { node: Node }
95
-
96
- export type ConformOptions = {
97
- /** How far below the target surface the ground sits (keeps bilinear terrain strictly under a
98
- * curved road between samples). Default 0.05. */
99
- offset?: number
100
- /** Metres over which the surrounding ground blends back to its old height. Default 4; 0 = hard edge. */
101
- falloff?: number
102
- /** `both` (default) cuts and fills; `lower` only cuts; `raise` only fills. */
103
- mode?: "both" | "lower" | "raise"
104
- }
105
-
106
- export type RibbonOptions = {
107
- /** Width in metres. */
108
- width: number
109
- /** Metres of ribbon per texture repeat along its length. Default = width. */
110
- uvScale?: number
111
- }
112
-
113
- /** A brush dab for `sculpt` — LOCAL metres. */
114
- export type SculptOptions = {
115
- x: number
116
- z: number
117
- /** Brush radius in metres. */
118
- radius: number
119
- /** `raise` / `lower` by `amount` metres at full weight; `smooth` blends toward the 3×3 average
120
- * (`amount` 0…1 = how far); `flatten` pulls toward `target` (default: the height under the
121
- * centre) by `amount` 0…1. Default raise. */
122
- mode?: "raise" | "lower" | "smooth" | "flatten"
123
- amount?: number
124
- /** 0…1 — the share of the radius at full weight; the rest feathers out (default 0.5). */
125
- hardness?: number
126
- target?: number
127
- }
128
-
129
- /** `snapshot()` — the three sample arrays, copied; `restore()` puts them back (undo, a level reset). */
130
- export type TerrainSnapshot = { heights: Float32Array, holes: Uint8Array, control: Uint8Array }
131
-
132
- /** A ray hit on the terrain (`raycast` local, `raycastWorld` world). */
133
- export type TerrainHit = { point: Vec3, normal: Vec3, distance: number }
134
-
135
- export type AutoPaintRules = {
136
- /** The layer everything starts as. Default 0. */
137
- base?: number
138
- /** Slope rule: the layer takes over between `from` and `to` degrees of slope. */
139
- slope?: { layer: number, from: number, to: number }
140
- /** Height rule: the layer takes over between `from` and `to` metres (snow line). */
141
- height?: { layer: number, from: number, to: number }
142
- /** Low rule: the layer takes over BELOW `to` metres, fading in from `from` (valley floors). */
143
- low?: { layer: number, from: number, to: number }
144
- }
145
-
146
- /** What Shape { heightfield } reads off a terrain node (`node._terrain`). */
147
- export type TerrainData = {
148
- sizeX: number, sizeZ: number, cellSize: number,
149
- heights: Float32Array, holes: Uint8Array | null,
150
- }
151
-
152
- type Rect = { x0: number, z0: number, x1: number, z1: number } // inclusive sample rect
153
-
154
- const smoothstep = (a: number, b: number, t: number): number => {
155
- const x = Math.min(1, Math.max(0, (t - a) / (b - a || 1e-9)))
156
- return x * x * (3 - 2 * x)
157
- }
158
-
159
- const FILE_MAGIC = 0x5245544c // 'LTER' little-endian
160
- const FILE_VERSION = 1
161
-
162
- let warnedPixels = false
163
- let warnedHost = false
164
-
165
- export class Terrain {
166
- /** The node the terrain hangs off: transform it to place the ground; add it to the scene. */
167
- readonly node: Node
168
- readonly sizeX: number
169
- readonly sizeZ: number
170
- readonly cellSize: number
171
- readonly chunk: number
172
- /** sizeX × sizeZ heights in metres, row-major (z rows). Edit, then `commit()`. */
173
- readonly heights: Float32Array
174
- /** A byte per sample, 1 = hole. */
175
- readonly holes: Uint8Array
176
- /** RGBA layer weights per sample. */
177
- readonly control: Uint8Array
178
- /** The splat material (`Material.terrain()` unless one was passed). */
179
- readonly material: Material
180
- /** Resolves when every layer texture has loaded (the terrain renders white until then). */
181
- readonly ready: Promise<void>
182
- layers: TerrainLayer[]
183
-
184
- private _native = false
185
- private _chunkMeshes: (Mesh | null)[] = []
186
- private _controlTex: Texture | null = null
187
- private _dirty: Rect | null = null
188
- private _holesTouched = false
189
- private _controlDirty: Rect | null = null
190
- private _anyHole = false
191
- private _cast = true
192
- private _receive = true
193
-
194
- private constructor(opts: TerrainOptions) {
195
- this.sizeX = Math.max(2, Math.floor(opts.sizeX ?? opts.size ?? 129))
196
- this.sizeZ = Math.max(2, Math.floor(opts.sizeZ ?? opts.size ?? 129))
197
- this.cellSize = opts.cellSize ?? 1
198
- this.chunk = Math.min(255, Math.max(1, Math.floor(opts.chunk ?? 64)))
199
- const n = this.sizeX * this.sizeZ
200
- this.heights = new Float32Array(n)
201
- if (opts.heights instanceof Float32Array) this.heights.set(opts.heights.subarray(0, n))
202
- else if (typeof opts.heights === "function") {
203
- const f = opts.heights
204
- for (let z = 0; z < this.sizeZ; z++) for (let x = 0; x < this.sizeX; x++) this.heights[z * this.sizeX + x] = f(x * this.cellSize, z * this.cellSize)
205
- }
206
- this.holes = new Uint8Array(n)
207
- if (opts.holes) { this.holes.set(opts.holes.subarray(0, n)); this._anyHole = this.holes.some((v) => v !== 0) }
208
- this.control = new Uint8Array(n * 4)
209
- if (opts.control) this.control.set(opts.control.subarray(0, n * 4))
210
- else for (let i = 0; i < n; i++) this.control[i * 4] = 255
211
- this.layers = (opts.layers ?? []).slice(0, 4)
212
- this.material = opts.material ?? Material.terrain()
213
- this.material.set("gridSize", [ this.sizeX, this.sizeZ ]).set("cellSize", this.cellSize)
214
- if (opts.tint !== undefined) this.material.set("tint", Color.toHexString(opts.tint))
215
-
216
- this.node = new Node()
217
- if (opts.name !== undefined) this.node.name = opts.name
218
- ;(this.node as unknown as { _terrain: Terrain })._terrain = this
219
- this._build()
220
- this._uploadControl(null)
221
- this.ready = this._applyLayers(opts.normals)
222
- }
223
-
224
- /** Build a terrain from data / a generator (synchronous; `ready` resolves once its textures loaded). */
225
- static create(opts: TerrainOptions = {}): Terrain { return new Terrain(opts) }
226
-
227
- /** The terrain a node carries (the node `Terrain.create` returned), or null. */
228
- static of(node: Node | null | undefined): Terrain | null {
229
- return (node as unknown as { _terrain?: Terrain } | null | undefined)?._terrain ?? null
230
- }
231
-
232
- /** Load a `.terrain` file (`Terrain.serialize` / the editor / `lecodes terrain import`); layer textures
233
- * are not in the file — pass them in `opts` (they are asset handles the file cannot hold). */
234
- static async load(url: string, opts: Omit<TerrainOptions, "size" | "sizeX" | "sizeZ" | "heights" | "holes" | "control"> = {}): Promise<Terrain> {
235
- const resp = await fetch(url, { useOnce: true })
236
- if (resp.status >= 400) throw new Error(`Terrain: could not fetch ${url} (HTTP ${resp.status})`)
237
- if (!_creatorUtils.fetchToBytes) { resp.dispose(); throw new Error("Terrain.load needs a newer host (no binary fetch reader)") }
238
- const bytes = _creatorUtils.fetchToBytes((resp as unknown as { _id: number })._id)
239
- resp.dispose()
240
- const data = Terrain.parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer)
241
- const layers = opts.layers ?? data.layers
242
- return new Terrain({ ...opts, ...data, layers, cellSize: opts.cellSize ?? data.cellSize, chunk: opts.chunk ?? data.chunk })
243
- }
244
-
245
- // ---- queries (LOCAL metres) --------------------------------------------------------------------
246
-
247
- private get _grid(): TerrainGrid {
248
- return { sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, heights: this.heights, holes: this._anyHole ? this.holes : null }
249
- }
250
-
251
- /** Height at a local (x, z) on the drawn triangles; NaN in a hole or outside the grid. */
252
- heightAt(x: number, z: number): number { return gridHeightAt(this._grid, x, z) }
253
-
254
- /** The drawn triangle's normal at a local (x, z) (unit Vec3, +Y up). */
255
- normalAt(x: number, z: number): Vec3 {
256
- const n = faceNormalAt(this._grid, x, z)
257
- return new Vec3(n[0], n[1], n[2])
258
- }
259
-
260
- /** The smooth (vertex) normal at integer sample (ix, iz). */
261
- sampleNormal(ix: number, iz: number): Vec3 {
262
- const n = gridNormalAt(this._grid, ix, iz)
263
- return new Vec3(n[0], n[1], n[2])
264
- }
265
-
266
- /** Extent in local metres: `[width, depth]`. */
267
- get extent(): [number, number] { return [ (this.sizeX - 1) * this.cellSize, (this.sizeZ - 1) * this.cellSize ] }
268
-
269
- /** Min / max height over the grid. */
270
- get heightRange(): [number, number] {
271
- let lo = Infinity, hi = -Infinity
272
- for (let i = 0; i < this.heights.length; i++) { const h = this.heights[i]; if (h < lo) lo = h; if (h > hi) hi = h }
273
- return [ lo, hi ]
274
- }
275
-
276
- /** A world-space point into the terrain's local frame. */
277
- toLocal(p: Vec3Like): Vec3 {
278
- const inv = invertAffine(this._world())
279
- return transformPoint(inv, cx(p), cy(p), cz(p))
280
- }
281
-
282
- /** A local point into world space. */
283
- toWorld(p: Vec3Like): Vec3 { return transformPoint(this._world(), cx(p), cy(p), cz(p)) }
284
-
285
- /** The ground's WORLD height under a world (x, z): NaN off the grid / in a hole. */
286
- worldHeightAt(x: number, z: number): number {
287
- const l = this.toLocal([ x, 0, z ])
288
- const h = this.heightAt(l.x, l.z)
289
- if (Number.isNaN(h)) return NaN
290
- return this.toWorld([ l.x, h, l.z ]).y
291
- }
292
-
293
- /** Nearest hit of a LOCAL ray on the drawn triangles (holes let it through), null on a miss.
294
- * Exact against what is rendered — the editor's brush and a host without physics pick with it. */
295
- raycast(origin: Vec3Like, dir: Vec3Like, maxDistance = 1e5): TerrainHit | null {
296
- let dx = cx(dir), dy = cy(dir), dz = cz(dir)
297
- const l = Math.hypot(dx, dy, dz)
298
- if (!(l > 0)) return null
299
- dx /= l; dy /= l; dz /= l
300
- const h = gridRaycast(this._grid, cx(origin), cy(origin), cz(origin), dx, dy, dz, maxDistance)
301
- return h ? { point: new Vec3(h.x, h.y, h.z), normal: new Vec3(h.nx, h.ny, h.nz), distance: h.distance } : null
302
- }
303
-
304
- /** `raycast` in WORLD space — through the node's transform (rotation + uniform scale). */
305
- raycastWorld(origin: Vec3Like, dir: Vec3Like, maxDistance = 1e5): TerrainHit | null {
306
- const o = this.toLocal(origin)
307
- const p = this.toLocal([ cx(origin) + cx(dir), cy(origin) + cy(dir), cz(origin) + cz(dir) ])
308
- const ldx = p.x - o.x, ldy = p.y - o.y, ldz = p.z - o.z
309
- const scale = Math.hypot(ldx, ldy, ldz) / (Math.hypot(cx(dir), cy(dir), cz(dir)) || 1) // local units per world metre
310
- const hit = this.raycast(o, [ ldx, ldy, ldz ], maxDistance * (scale || 1))
311
- if (!hit) return null
312
- const wp = this.toWorld(hit.point)
313
- const wn = this.toWorld([ hit.point.x + hit.normal.x, hit.point.y + hit.normal.y, hit.point.z + hit.normal.z ])
314
- const nx = wn.x - wp.x, ny = wn.y - wp.y, nz = wn.z - wp.z
315
- const nl = Math.hypot(nx, ny, nz) || 1
316
- return {
317
- point: wp,
318
- normal: new Vec3(nx / nl, ny / nl, nz / nl),
319
- distance: Math.hypot(wp.x - cx(origin), wp.y - cy(origin), wp.z - cz(origin)),
320
- }
321
- }
322
-
323
- // ---- edits (mark dirty; nothing reaches the engine until commit) --------------------------------
324
-
325
- /** Write a rectangle of samples (`src` = w × h heights, row-major). */
326
- setHeights(x0: number, z0: number, w: number, h: number, src: ArrayLike<number>): this {
327
- for (let z = 0; z < h; z++) {
328
- const zz = z0 + z
329
- if (zz < 0 || zz >= this.sizeZ) continue
330
- for (let x = 0; x < w; x++) {
331
- const xx = x0 + x
332
- if (xx < 0 || xx >= this.sizeX) continue
333
- this.heights[zz * this.sizeX + xx] = src[z * w + x]
334
- }
335
- }
336
- this._touch(x0, z0, x0 + w - 1, z0 + h - 1)
337
- return this
338
- }
339
-
340
- /** Apply `fn(oldHeight, weight, x, z)` over a region; `weight` is 1 inside, fading to 0 over
341
- * `falloff` metres outside it. The building block of flatten / raise / hole. */
342
- shape(region: TerrainRegion, fn: (h: number, w: number, x: number, z: number) => number, falloff = 0): this {
343
- const r = this._regionRect(region, falloff)
344
- if (!r) return this
345
- const weight = this._regionWeights(region, r, falloff)
346
- let k = 0
347
- for (let iz = r.z0; iz <= r.z1; iz++) {
348
- for (let ix = r.x0; ix <= r.x1; ix++, k++) {
349
- const w = weight[k]
350
- if (w <= 0) continue
351
- const i = iz * this.sizeX + ix
352
- this.heights[i] = fn(this.heights[i], w, ix * this.cellSize, iz * this.cellSize)
353
- }
354
- }
355
- this._touch(r.x0, r.z0, r.x1, r.z1)
356
- return this
357
- }
358
-
359
- /** Flatten a region to `height` (a plateau / a building pad), blending over `falloff` metres. */
360
- flatten(region: TerrainRegion, height: number, opts: { falloff?: number } = {}): this {
361
- return this.shape(region, (h, w) => h + (height - h) * w, opts.falloff ?? 4)
362
- }
363
-
364
- /** Raise (or lower, negative) a region by `delta` metres, blending over `falloff` metres. */
365
- raise(region: TerrainRegion, delta: number, opts: { falloff?: number } = {}): this {
366
- return this.shape(region, (h, w) => h + delta * w, opts.falloff ?? 4)
367
- }
368
-
369
- /** Copies of heights / holes / control — an undo point or a level reset. */
370
- snapshot(): TerrainSnapshot {
371
- return { heights: this.heights.slice(), holes: this.holes.slice(), control: this.control.slice() }
372
- }
373
-
374
- /** Put a `snapshot()` back (same grid size) — everything marked dirty for the next `commit()`. */
375
- restore(s: TerrainSnapshot): this {
376
- const n = this.sizeX * this.sizeZ
377
- if (s.heights.length !== n || s.holes.length !== n || s.control.length !== n * 4) throw new Error("Terrain.restore: snapshot size mismatch")
378
- this.heights.set(s.heights)
379
- this.holes.set(s.holes)
380
- this.control.set(s.control)
381
- this._anyHole = this.holes.some((v) => v !== 0)
382
- this._holesTouched = true
383
- this._touch(0, 0, this.sizeX - 1, this.sizeZ - 1)
384
- this._touchControl(0, 0, this.sizeX - 1, this.sizeZ - 1)
385
- return this
386
- }
387
-
388
- /** One brush dab: raise / lower / smooth / flatten a disc of `radius` metres around (x, z), feathered
389
- * by `hardness` — the editor's sculpt tool and runtime deformation (craters, digging) share it. */
390
- sculpt(opts: SculptOptions): this {
391
- const hardness = Math.min(1, Math.max(0, opts.hardness ?? 0.5))
392
- const inner = opts.radius * hardness, falloff = Math.max(0, opts.radius - inner)
393
- const region: TerrainRegion = { circle: { x: opts.x, z: opts.z, r: inner } }
394
- const amount = opts.amount ?? 1
395
- switch (opts.mode ?? "raise") {
396
- case "raise": return this.shape(region, (h, w) => h + amount * w, falloff)
397
- case "lower": return this.shape(region, (h, w) => h - amount * w, falloff)
398
- case "flatten": {
399
- let target = opts.target ?? this.heightAt(opts.x, opts.z)
400
- if (Number.isNaN(target)) {
401
- // the centre is in a hole / off-grid: level to the disc's mean instead
402
- let sum = 0, n = 0
403
- this.shape(region, (h) => { sum += h; n++; return h }, falloff)
404
- target = n ? sum / n : 0
405
- }
406
- const k = Math.min(1, Math.max(0, amount))
407
- return this.shape(region, (h, w) => h + (target - h) * k * w, falloff)
408
- }
409
- case "smooth": {
410
- const r = this._regionRect(region, falloff)
411
- if (!r) return this
412
- // blur reads a snapshot (the rect + one ring), so the pass is order-independent
413
- const x0 = Math.max(0, r.x0 - 1), z0 = Math.max(0, r.z0 - 1), x1 = Math.min(this.sizeX - 1, r.x1 + 1), z1 = Math.min(this.sizeZ - 1, r.z1 + 1)
414
- const w = x1 - x0 + 1, hgt = z1 - z0 + 1
415
- const src = new Float32Array(w * hgt)
416
- for (let iz = z0; iz <= z1; iz++) for (let ix = x0; ix <= x1; ix++) src[(iz - z0) * w + (ix - x0)] = this.heights[iz * this.sizeX + ix]
417
- const k = Math.min(1, Math.max(0, amount))
418
- const c = this.cellSize
419
- return this.shape(region, (h, wt, px, pz) => {
420
- const ix = Math.round(px / c), iz = Math.round(pz / c)
421
- let sum = 0, n = 0
422
- for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
423
- const sx = ix + dx, sz = iz + dz
424
- if (sx < x0 || sx > x1 || sz < z0 || sz > z1) continue
425
- sum += src[(sz - z0) * w + (sx - x0)]; n++
426
- }
427
- return n ? h + (sum / n - h) * k * wt : h
428
- }, falloff)
429
- }
430
- }
431
- return this
432
- }
433
-
434
- /** Cut a hole (no triangles, no collision, no navmesh) — or fill one back with `on = false`. */
435
- hole(region: TerrainRegion, on = true): this {
436
- const r = this._regionRect(region, 0)
437
- if (!r) return this
438
- const weight = this._regionWeights(region, r, 0)
439
- let k = 0
440
- for (let iz = r.z0; iz <= r.z1; iz++) {
441
- for (let ix = r.x0; ix <= r.x1; ix++, k++) {
442
- if (weight[k] <= 0) continue
443
- this.holes[iz * this.sizeX + ix] = on ? 1 : 0
444
- }
445
- }
446
- this._holesTouched = true
447
- this._anyHole = on || this.holes.some((v) => v !== 0)
448
- this._touch(r.x0, r.z0, r.x1, r.z1)
449
- return this
450
- }
451
-
452
- /**
453
- * Conform the ground to a mesh — the terrain takes the target's TOP surface minus `offset` under
454
- * its footprint (a road sits exactly on it, nothing pokes through), and blends back to the old
455
- * ground over `falloff` metres around it. `target` = a node (a Mesh, a Model, or a node holding
456
- * them — every triangle in the subtree counts), a Geometry in local coordinates, or a raw soup
457
- * (9 floats per triangle, local). Call again after the target moves.
458
- */
459
- conform(target: Node | Geometry | Float32Array, opts: ConformOptions = {}): this {
460
- const tris = this._triangles(target)
461
- if (tris.length < 9) return this
462
- const offset = opts.offset ?? 0.05
463
- const falloff = Math.max(0, opts.falloff ?? 4)
464
- const mode = opts.mode ?? "both"
465
- const cell = this.cellSize
466
- const sx = this.sizeX, sz = this.sizeZ
467
- const inside = new Uint8Array(sx * sz)
468
- const target2 = new Float32Array(sx * sz).fill(Infinity)
469
- let fx0 = sx, fz0 = sz, fx1 = -1, fz1 = -1
470
- // 1. every sample under a triangle takes the LOWEST covering surface: nothing of the target may
471
- // end up below ground (overlapping road quads at a bend, a building's floor rather than its roof)
472
- for (let t = 0; t + 8 < tris.length; t += 9) {
473
- const ax = tris[t], ay = tris[t + 1], az = tris[t + 2]
474
- const bx = tris[t + 3], by = tris[t + 4], bz = tris[t + 5]
475
- const qx = tris[t + 6], qy = tris[t + 7], qz = tris[t + 8]
476
- const minX = Math.max(0, Math.ceil(Math.min(ax, bx, qx) / cell)), maxX = Math.min(sx - 1, Math.floor(Math.max(ax, bx, qx) / cell))
477
- const minZ = Math.max(0, Math.ceil(Math.min(az, bz, qz) / cell)), maxZ = Math.min(sz - 1, Math.floor(Math.max(az, bz, qz) / cell))
478
- if (minX > maxX || minZ > maxZ) continue
479
- const det = (bx - ax) * (qz - az) - (qx - ax) * (bz - az)
480
- if (Math.abs(det) < 1e-12) continue // vertical / degenerate in XZ
481
- for (let iz = minZ; iz <= maxZ; iz++) {
482
- const pz = iz * cell
483
- for (let ix = minX; ix <= maxX; ix++) {
484
- const px = ix * cell
485
- const l1 = ((bx - ax) * (pz - az) - (px - ax) * (bz - az)) / det // weight of q
486
- const l2 = ((px - ax) * (qz - az) - (qx - ax) * (pz - az)) / det // weight of b
487
- const l0 = 1 - l1 - l2
488
- if (l0 < -1e-6 || l1 < -1e-6 || l2 < -1e-6) continue
489
- const h = l0 * ay + l2 * by + l1 * qy
490
- const i = iz * sx + ix
491
- if (h < target2[i]) target2[i] = h
492
- if (!inside[i]) { inside[i] = 1; if (ix < fx0) fx0 = ix; if (ix > fx1) fx1 = ix; if (iz < fz0) fz0 = iz; if (iz > fz1) fz1 = iz }
493
- }
494
- }
495
- }
496
- if (fx1 < 0) return this
497
- const apply = (i: number, target: number): void => {
498
- const h = this.heights[i]
499
- this.heights[i] = mode === "lower" ? Math.min(h, target) : mode === "raise" ? Math.max(h, target) : target
500
- }
501
- // 2. the footprint itself
502
- for (let iz = fz0; iz <= fz1; iz++) for (let ix = fx0; ix <= fx1; ix++) {
503
- const i = iz * sx + ix
504
- if (inside[i]) { target2[i] -= offset; apply(i, target2[i]) }
505
- }
506
- // 3. the ring: every sample outside the footprint but within `falloff` of the target's BOUNDARY
507
- // blends from the boundary's own height (− offset) back to the old ground. The distance is
508
- // measured to the triangle edges in XZ, not to the nearest inside sample — the first outside
509
- // sample is usually a fraction of a cell from the road edge, and anchoring it there is what
510
- // keeps the ground under the edge itself at road level (docs/terrain-plan.md §4.1). The first
511
- // CELL outside is a flat shoulder (weight 0): the terrain triangle straddling the edge then
512
- // interpolates between two samples at road level, so the edge is exact even where the natural
513
- // ground is tens of metres away — a blend starting AT the edge would leak a few percent of that
514
- // difference back onto the edge. Edges are binned on a coarse grid so a long road stays cheap.
515
- const shoulder = cell
516
- const ring = Math.max(falloff, shoulder)
517
- const R = Math.ceil(ring / cell)
518
- {
519
- const coarse = Math.max(ring, cell * 2)
520
- const bins = new Map<number, number[]>() // coarse cell → edge indices (start float offset)
521
- const edges: number[] = [] // ax, az, ay, bx, bz, by per edge
522
- const key = (cx0: number, cz0: number): number => cz0 * 65536 + cx0
523
- const addEdge = (ax: number, ay: number, az: number, bx: number, by: number, bz: number): void => {
524
- const e = edges.length
525
- edges.push(ax, az, ay, bx, bz, by)
526
- const cx0 = Math.floor(Math.min(ax, bx) / coarse), cx1 = Math.floor(Math.max(ax, bx) / coarse)
527
- const cz0 = Math.floor(Math.min(az, bz) / coarse), cz1 = Math.floor(Math.max(az, bz) / coarse)
528
- for (let cz = cz0; cz <= cz1; cz++) for (let cxx = cx0; cxx <= cx1; cxx++) {
529
- const k = key(cxx, cz)
530
- const list = bins.get(k)
531
- if (list) list.push(e); else bins.set(k, [ e ])
532
- }
533
- }
534
- for (let t = 0; t + 8 < tris.length; t += 9) {
535
- addEdge(tris[t], tris[t + 1], tris[t + 2], tris[t + 3], tris[t + 4], tris[t + 5])
536
- addEdge(tris[t + 3], tris[t + 4], tris[t + 5], tris[t + 6], tris[t + 7], tris[t + 8])
537
- addEdge(tris[t + 6], tris[t + 7], tris[t + 8], tris[t], tris[t + 1], tris[t + 2])
538
- }
539
- const rx0 = Math.max(0, fx0 - R), rz0 = Math.max(0, fz0 - R), rx1 = Math.min(sx - 1, fx1 + R), rz1 = Math.min(sz - 1, fz1 + R)
540
- for (let iz = rz0; iz <= rz1; iz++) {
541
- const pz = iz * cell
542
- for (let ix = rx0; ix <= rx1; ix++) {
543
- const i = iz * sx + ix
544
- if (inside[i]) continue
545
- const px = ix * cell
546
- let best = Infinity, bestH = 0
547
- const ccx = Math.floor(px / coarse), ccz = Math.floor(pz / coarse)
548
- for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
549
- const list = bins.get(key(ccx + dx, ccz + dz))
550
- if (!list) continue
551
- for (const e of list) {
552
- const ax = edges[e], az = edges[e + 1], ay = edges[e + 2], bx = edges[e + 3], bz = edges[e + 4], by = edges[e + 5]
553
- const ex = bx - ax, ez = bz - az
554
- const l2 = ex * ex + ez * ez
555
- const u = l2 > 0 ? Math.min(1, Math.max(0, ((px - ax) * ex + (pz - az) * ez) / l2)) : 0
556
- const qx = ax + ex * u, qz = az + ez * u
557
- const d2 = (px - qx) * (px - qx) + (pz - qz) * (pz - qz)
558
- if (d2 < best) { best = d2; bestH = ay + (by - ay) * u }
559
- }
560
- }
561
- if (best === Infinity) continue
562
- const d = Math.sqrt(best)
563
- if (d >= ring) continue
564
- const target = bestH - offset
565
- // 0 across the shoulder → 1 at falloff
566
- const s = d <= shoulder ? 0 : falloff > shoulder ? smoothstep(0, 1, (d - shoulder) / (falloff - shoulder)) : 1
567
- apply(i, target + (this.heights[i] - target) * s)
568
- }
569
- }
570
- this._touch(rx0, rz0, rx1, rz1)
571
- }
572
- return this
573
- }
574
-
575
- /** `conform` to a ribbon along `points` (world/local metres, see `ribbon`) — the road stamp. */
576
- conformPath(points: readonly Vec3Like[], opts: ConformOptions & RibbonOptions): this {
577
- const g = Terrain.ribbon(points, opts)
578
- return this.conform(g, opts)
579
- }
580
-
581
- /**
582
- * A flat ribbon Geometry along a polyline (the road mesh AND the conform target): `width` metres,
583
- * vertices take the points' own Y, UV0 = (across 0…1, along / uvScale). Build it from heights you
584
- * sampled with `heightAt` and smoothed, then `Mesh.from(geometry, { material })` + `conform`.
585
- * It also carries a lightmap UV (uv1: along 0…1 × across 0…width/length — the strip's true aspect,
586
- * so `lecodes lightmap bake` gives it uniform texels), which makes the road a bake receiver.
587
- */
588
- static ribbon(points: readonly Vec3Like[], opts: RibbonOptions): Geometry {
589
- const n = points.length
590
- if (n < 2) throw new Error("Terrain.ribbon needs at least two points")
591
- const half = opts.width / 2
592
- const uvScale = opts.uvScale ?? opts.width
593
- const verts = new Float32Array(n * 6), normals = new Float32Array(n * 6), uv = new Float32Array(n * 4)
594
- const uv1 = new Float32Array(n * 4)
595
- let along = 0
596
- for (let i = 0; i < n; i++) {
597
- const p = points[i]
598
- const prev = points[Math.max(0, i - 1)], next = points[Math.min(n - 1, i + 1)]
599
- let tx = cx(next) - cx(prev), tz = cz(next) - cz(prev)
600
- const tl = Math.hypot(tx, tz) || 1
601
- tx /= tl; tz /= tl
602
- const sxv = -tz * half, szv = tx * half
603
- if (i > 0) along += Math.hypot(cx(p) - cx(prev), cy(p) - cy(prev), cz(p) - cz(prev))
604
- const o = i * 6
605
- verts[o] = cx(p) - sxv; verts[o + 1] = cy(p); verts[o + 2] = cz(p) - szv
606
- verts[o + 3] = cx(p) + sxv; verts[o + 4] = cy(p); verts[o + 5] = cz(p) + szv
607
- normals[o + 1] = 1; normals[o + 4] = 1
608
- uv[i * 4] = 0; uv[i * 4 + 1] = along / uvScale; uv[i * 4 + 2] = 1; uv[i * 4 + 3] = along / uvScale
609
- uv1[i * 4] = along; uv1[i * 4 + 2] = along // scaled to 0…1 below, once the length is known
610
- }
611
- const length = along || 1, across = opts.width / length
612
- for (let i = 0; i < n; i++) { uv1[i * 4] /= length; uv1[i * 4 + 1] = 0; uv1[i * 4 + 2] /= length; uv1[i * 4 + 3] = across }
613
- const idx = new Uint16Array((n - 1) * 6)
614
- for (let i = 0; i + 1 < n; i++) {
615
- const a = i * 2, b = a + 1, c = a + 2, d = a + 3
616
- // left = a/c, right = b/d; CCW seen from +Y: (a, d, c), (a, b, d)
617
- idx.set([ a, d, c, a, b, d ], i * 6)
618
- }
619
- // segment normals from the geometry (a banked or climbing ribbon): (d − a) × (c − a) faces up
620
- for (let i = 0; i + 1 < n; i++) {
621
- const a = i * 2, c = a + 2, d = a + 3
622
- const ux = verts[d * 3] - verts[a * 3], uy = verts[d * 3 + 1] - verts[a * 3 + 1], uz = verts[d * 3 + 2] - verts[a * 3 + 2]
623
- const vx = verts[c * 3] - verts[a * 3], vy = verts[c * 3 + 1] - verts[a * 3 + 1], vz = verts[c * 3 + 2] - verts[a * 3 + 2]
624
- let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
625
- const l = Math.hypot(nx, ny, nz) || 1
626
- nx /= l; ny /= l; nz /= l
627
- for (const v of [ a, a + 1, c, d ]) { normals[v * 3] = nx; normals[v * 3 + 1] = ny; normals[v * 3 + 2] = nz }
628
- }
629
- const g = new Geometry(verts, normals, idx, uv)
630
- g.uv1 = uv1
631
- return g
632
- }
633
-
634
- /** Write RGBA layer weights for a rectangle of samples (`rgba` = w × h × 4 bytes). */
635
- paint(x0: number, z0: number, w: number, h: number, rgba: ArrayLike<number>): this {
636
- for (let z = 0; z < h; z++) {
637
- const zz = z0 + z
638
- if (zz < 0 || zz >= this.sizeZ) continue
639
- for (let x = 0; x < w; x++) {
640
- const xx = x0 + x
641
- if (xx < 0 || xx >= this.sizeX) continue
642
- const s = (zz * this.sizeX + xx) * 4, d = (z * w + x) * 4
643
- this.control[s] = rgba[d]; this.control[s + 1] = rgba[d + 1]; this.control[s + 2] = rgba[d + 2]; this.control[s + 3] = rgba[d + 3]
644
- }
645
- }
646
- this._touchControl(x0, z0, x0 + w - 1, z0 + h - 1)
647
- return this
648
- }
649
-
650
- /** Paint `layer` over a region with `strength` (0…1), feathered over `falloff` metres. */
651
- paintLayer(region: TerrainRegion, layer: number, strength = 1, falloff = 2): this {
652
- const r = this._regionRect(region, falloff)
653
- if (!r) return this
654
- const weight = this._regionWeights(region, r, falloff)
655
- let k = 0
656
- for (let iz = r.z0; iz <= r.z1; iz++) {
657
- for (let ix = r.x0; ix <= r.x1; ix++, k++) {
658
- const w = weight[k] * strength
659
- if (w <= 0) continue
660
- this._blendLayer(iz * this.sizeX + ix, layer, w)
661
- }
662
- }
663
- this._touchControl(r.x0, r.z0, r.x1, r.z1)
664
- return this
665
- }
666
-
667
- /** Paint the whole terrain from slope / height rules (grass on the flats, rock on cliffs, snow on
668
- * the peaks, dirt in the valleys) — the CPU pass a procedural level starts from. */
669
- paintAuto(rules: AutoPaintRules = {}): this {
670
- const base = rules.base ?? 0
671
- const n = [ 0, 0, 0 ]
672
- for (let iz = 0; iz < this.sizeZ; iz++) {
673
- for (let ix = 0; ix < this.sizeX; ix++) {
674
- const i = iz * this.sizeX + ix
675
- const c = i * 4
676
- this.control[c] = 0; this.control[c + 1] = 0; this.control[c + 2] = 0; this.control[c + 3] = 0
677
- this.control[c + Math.min(3, Math.max(0, base))] = 255
678
- gridNormalAt(this._grid, ix, iz, n)
679
- const h = this.heights[i]
680
- if (rules.low) this._blendLayer(i, rules.low.layer, 1 - smoothstep(rules.low.from, rules.low.to, h))
681
- if (rules.slope) {
682
- const deg = Math.acos(Math.min(1, Math.max(-1, n[1]))) * 180 / Math.PI
683
- this._blendLayer(i, rules.slope.layer, smoothstep(rules.slope.from, rules.slope.to, deg))
684
- }
685
- if (rules.height) this._blendLayer(i, rules.height.layer, smoothstep(rules.height.from, rules.height.to, h))
686
- }
687
- }
688
- this._touchControl(0, 0, this.sizeX - 1, this.sizeZ - 1)
689
- return this
690
- }
691
-
692
- private _blendLayer(i: number, layer: number, w: number): void {
693
- if (w <= 0) return
694
- const c = i * 4
695
- const L = Math.min(3, Math.max(0, layer | 0))
696
- for (let k = 0; k < 4; k++) {
697
- const target = k === L ? 255 : 0
698
- this.control[c + k] = Math.round(this.control[c + k] + (target - this.control[c + k]) * Math.min(1, w))
699
- }
700
- }
701
-
702
- // ---- commit ------------------------------------------------------------------------------------
703
-
704
- /** Push every pending edit to the engine: chunk renderables + the height-field collider for the
705
- * dirty rectangle, the control texture. Call once after a batch of edits. */
706
- commit(): this {
707
- const d = this._dirty
708
- if (d) {
709
- const w = d.x1 - d.x0 + 1, h = d.z1 - d.z0 + 1
710
- const holes = this._anyHole ? this.holes : null
711
- if (this._native) _creator.terrainUpdate!(this.node.id, this.heights, holes, d.x0, d.z0, w, h)
712
- else this._rebuildFallbackChunks(d)
713
- const shape = this.node.get(Shape)
714
- if (shape && shape.heightfield && shape.shapeId) {
715
- const range = shape._heightfieldRange
716
- const [ lo, hi ] = this.heightRange
717
- if (range && (lo < range[0] || hi > range[1])) shape._rebuildShape()
718
- else if (_creator.physicsUpdateHeightField) _creator.physicsUpdateHeightField(shape.shapeId, this.heights, this.sizeX, this.sizeZ, d.x0, d.z0, w, h, holes)
719
- }
720
- this._dirty = null
721
- this._holesTouched = false
722
- }
723
- if (this._controlDirty) { this._uploadControl(this._controlDirty); this._controlDirty = null }
724
- return this
725
- }
726
-
727
- set castShadows(v: boolean) { this._cast = v; this._applyShadows() }
728
- get castShadows(): boolean { return this._cast }
729
- set receiveShadows(v: boolean) { this._receive = v; this._applyShadows() }
730
- get receiveShadows(): boolean { return this._receive }
731
-
732
- /** The whole terrain as a `.terrain` file (LTER: heights quantised to 16 bits, holes, control). */
733
- serialize(): ArrayBuffer {
734
- const [ lo, hi ] = this.heightRange
735
- const range = hi - lo > 1e-6 ? hi - lo : 1
736
- const n = this.sizeX * this.sizeZ
737
- const meta = {
738
- sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, chunk: this.chunk, heightMin: lo, heightMax: hi,
739
- layers: this.layers.map((l) => ({ tiling: l.tiling, roughness: l.roughness, normalScale: l.normalScale, triplanar: l.triplanar })),
740
- blobs: { heights: { fmt: "u16", off: 0, len: n * 2 }, holes: { off: n * 2, len: n }, control: { off: n * 3, len: n * 4 } },
741
- }
742
- const json = JSON.stringify(meta)
743
- const jsonBytes = utf8(json)
744
- const headerLen = 12 + jsonBytes.length
745
- const blobStart = (headerLen + 3) & ~3
746
- const out = new ArrayBuffer(blobStart + n * 7)
747
- const dv = new DataView(out)
748
- dv.setUint32(0, FILE_MAGIC, true)
749
- dv.setUint32(4, FILE_VERSION, true)
750
- dv.setUint32(8, jsonBytes.length, true)
751
- new Uint8Array(out, 12, jsonBytes.length).set(jsonBytes)
752
- const u16 = new Uint16Array(out, blobStart, n)
753
- for (let i = 0; i < n; i++) u16[i] = Math.round(((this.heights[i] - lo) / range) * 65535)
754
- new Uint8Array(out, blobStart + n * 2, n).set(this.holes)
755
- new Uint8Array(out, blobStart + n * 3, n * 4).set(this.control)
756
- return out
757
- }
758
-
759
- /** Decode a `.terrain` file into create() options (heights back in metres). */
760
- static parse(buf: ArrayBuffer): { sizeX: number, sizeZ: number, cellSize: number, chunk: number, heights: Float32Array, holes: Uint8Array, control: Uint8Array, layers: TerrainLayer[] } {
761
- const dv = new DataView(buf)
762
- if (buf.byteLength < 12 || dv.getUint32(0, true) !== FILE_MAGIC) throw new Error("Terrain: not a .terrain file")
763
- const version = dv.getUint32(4, true)
764
- if (version !== FILE_VERSION) throw new Error(`Terrain: unsupported file version ${version}`)
765
- const jsonLen = dv.getUint32(8, true)
766
- const meta = JSON.parse(fromUtf8(new Uint8Array(buf, 12, jsonLen))) as ReturnType<typeof JSON.parse>
767
- const blobStart = (12 + jsonLen + 3) & ~3
768
- const n = meta.sizeX * meta.sizeZ
769
- const heights = new Float32Array(n)
770
- const range = meta.heightMax - meta.heightMin > 1e-6 ? meta.heightMax - meta.heightMin : 1
771
- const u16 = new Uint16Array(buf.slice(blobStart + meta.blobs.heights.off, blobStart + meta.blobs.heights.off + n * 2))
772
- for (let i = 0; i < n; i++) heights[i] = meta.heightMin + (u16[i] / 65535) * range
773
- const holes = new Uint8Array(buf.slice(blobStart + meta.blobs.holes.off, blobStart + meta.blobs.holes.off + n))
774
- const control = new Uint8Array(buf.slice(blobStart + meta.blobs.control.off, blobStart + meta.blobs.control.off + n * 4))
775
- return { sizeX: meta.sizeX, sizeZ: meta.sizeZ, cellSize: meta.cellSize, chunk: meta.chunk, heights, holes, control, layers: meta.layers ?? [] }
776
- }
777
-
778
- // ---- internals ---------------------------------------------------------------------------------
779
-
780
- /** @internal what Shape { heightfield } reads. */
781
- get _data(): TerrainData {
782
- return { sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, heights: this.heights, holes: this._anyHole ? this.holes : null }
783
- }
784
-
785
- private _build(): void {
786
- const holes = this._anyHole ? this.holes : null
787
- if (_creator.terrainCreate) {
788
- const n = _creator.terrainCreate(this.node.id, Material.idOf(this.material), this.heights, this.sizeX, this.sizeZ, this.cellSize, this.chunk, holes)
789
- if (n > 0) { this._native = true; return }
790
- }
791
- if (!warnedHost) { warnedHost = true; console.warn("Terrain: this host has no native terrain — drawing the chunks as meshes (normal maps read flat)") }
792
- const [ nx, nz ] = chunkCounts(this._grid, this.chunk)
793
- this._chunkMeshes = new Array(nx * nz).fill(null)
794
- for (let cz = 0; cz < nz; cz++) for (let cx2 = 0; cx2 < nx; cx2++) this._buildFallbackChunk(cx2, cz, nx)
795
- }
796
-
797
- private _buildFallbackChunk(cx2: number, cz: number, nx: number): void {
798
- const k = cz * nx + cx2
799
- const old = this._chunkMeshes[k]
800
- if (old) { old.destroy(); this._chunkMeshes[k] = null } // destroy = engine subtree removal + registry cleanup
801
- const geom = buildTerrainChunk(this._grid, cx2, cz, this.chunk)
802
- if (!geom) return
803
- const mesh = new Mesh(geom, this.material)
804
- mesh.name = `chunk_${cx2}_${cz}`
805
- mesh.castShadows = this._cast
806
- mesh.receiveShadows = this._receive
807
- ;(mesh as unknown as { _terrainChunk: boolean })._terrainChunk = true
808
- this.node.add(mesh)
809
- this._chunkMeshes[k] = mesh
810
- }
811
-
812
- private _rebuildFallbackChunks(d: Rect): void {
813
- const [ nx, nz ] = chunkCounts(this._grid, this.chunk)
814
- const cx0 = Math.floor(Math.max(0, d.x0 - 1) / this.chunk), cz0 = Math.floor(Math.max(0, d.z0 - 1) / this.chunk)
815
- const cx1 = Math.min(nx - 1, Math.floor(Math.min(this.sizeX - 1, d.x1 + 1) / this.chunk))
816
- const cz1 = Math.min(nz - 1, Math.floor(Math.min(this.sizeZ - 1, d.z1 + 1) / this.chunk))
817
- for (let cz = cz0; cz <= cz1; cz++) for (let cx2 = cx0; cx2 <= cx1; cx2++) this._buildFallbackChunk(cx2, cz, nx)
818
- }
819
-
820
- private _applyShadows(): void {
821
- if (this._native) _creator.terrainSetShadows?.(this.node.id, this._cast, this._receive)
822
- else for (const m of this._chunkMeshes) if (m) { m.castShadows = this._cast; m.receiveShadows = this._receive }
823
- }
824
-
825
- private _uploadControl(region: Rect | null): void {
826
- if (!_creator.createTexturePixels || !_creator.updateTexturePixels) {
827
- if (!warnedPixels) { warnedPixels = true; console.warn("Terrain: this host cannot upload a control map — every layer shows equally") }
828
- return
829
- }
830
- if (!this._controlTex) {
831
- const id = _creator.createTexturePixels(this.sizeX, this.sizeZ, 4, this.control, false)
832
- if (id === 0xFFFFFFFF) return
833
- this._controlTex = new Texture(this.sizeX, this.sizeZ, id)
834
- this._controlTex.wrapS = 0
835
- this._controlTex.wrapT = 0
836
- this.material.set("control", this._controlTex)
837
- return
838
- }
839
- const r = region ?? { x0: 0, z0: 0, x1: this.sizeX - 1, z1: this.sizeZ - 1 }
840
- const w = r.x1 - r.x0 + 1, h = r.z1 - r.z0 + 1
841
- const sub = new Uint8Array(w * h * 4)
842
- for (let z = 0; z < h; z++) sub.set(this.control.subarray(((r.z0 + z) * this.sizeX + r.x0) * 4, ((r.z0 + z) * this.sizeX + r.x0 + w) * 4), z * w * 4)
843
- _creator.updateTexturePixels(this._controlTex._id, r.x0, r.z0, w, h, sub)
844
- }
845
-
846
- private async _applyLayers(normals?: TerrainOptions["normals"]): Promise<void> {
847
- const m = this.material
848
- const tiling = [ 8, 8, 8, 8 ], rough = [ 1, 1, 1, 1 ], nscale = [ 0, 0, 0, 0 ], tri = [ 0, 0, 0, 0 ]
849
- const waits: Promise<void>[] = []
850
- const bind = (key: string, src: string | Texture | undefined): void => {
851
- if (src === undefined) return
852
- if (src instanceof Texture) { m.set(key, src); return }
853
- waits.push(Texture.load(src).then((t) => { m.set(key, t) }, (e) => { console.warn(`Terrain: ${key}: ${e instanceof Error ? e.message : String(e)}`) }))
854
- }
855
- this.layers.forEach((l, i) => {
856
- tiling[i] = l.tiling ?? 8
857
- rough[i] = l.roughness ?? 1
858
- nscale[i] = l.normalScale ?? 0
859
- tri[i] = l.triplanar ? 1 : 0
860
- bind(`albedo${i}`, l.albedo)
861
- })
862
- m.set("tiling", tiling).set("roughness", rough).set("normalScale", nscale).set("triplanar", tri)
863
- bind("normals01", normals?.[0])
864
- bind("normals23", normals?.[1])
865
- await Promise.all(waits)
866
- }
867
-
868
- /** Re-push layer scalars after editing `layers` (textures given as Texture apply too). */
869
- applyLayers(): Promise<void> { return this._applyLayers() }
870
-
871
- private _touch(x0: number, z0: number, x1: number, z1: number): void {
872
- x0 = Math.max(0, x0); z0 = Math.max(0, z0); x1 = Math.min(this.sizeX - 1, x1); z1 = Math.min(this.sizeZ - 1, z1)
873
- if (x0 > x1 || z0 > z1) return
874
- const d = this._dirty
875
- this._dirty = d ? { x0: Math.min(d.x0, x0), z0: Math.min(d.z0, z0), x1: Math.max(d.x1, x1), z1: Math.max(d.z1, z1) } : { x0, z0, x1, z1 }
876
- }
877
-
878
- private _touchControl(x0: number, z0: number, x1: number, z1: number): void {
879
- x0 = Math.max(0, x0); z0 = Math.max(0, z0); x1 = Math.min(this.sizeX - 1, x1); z1 = Math.min(this.sizeZ - 1, z1)
880
- if (x0 > x1 || z0 > z1) return
881
- const d = this._controlDirty
882
- this._controlDirty = d ? { x0: Math.min(d.x0, x0), z0: Math.min(d.z0, z0), x1: Math.max(d.x1, x1), z1: Math.max(d.z1, z1) } : { x0, z0, x1, z1 }
883
- }
884
-
885
- /** The sample rectangle a region (+ falloff) touches, or null when it misses the grid. */
886
- private _regionRect(region: TerrainRegion, falloff: number): Rect | null {
887
- const c = this.cellSize
888
- let minX: number, minZ: number, maxX: number, maxZ: number
889
- if ("circle" in region) {
890
- const { x, z, r } = region.circle
891
- minX = x - r - falloff; maxX = x + r + falloff; minZ = z - r - falloff; maxZ = z + r + falloff
892
- } else if ("rect" in region) {
893
- const { x, z, w, h } = region.rect
894
- minX = x - falloff; maxX = x + w + falloff; minZ = z - falloff; maxZ = z + h + falloff
895
- } else if ("polygon" in region) {
896
- minX = Infinity; maxX = -Infinity; minZ = Infinity; maxZ = -Infinity
897
- for (const [ px, pz ] of region.polygon) { if (px < minX) minX = px; if (px > maxX) maxX = px; if (pz < minZ) minZ = pz; if (pz > maxZ) maxZ = pz }
898
- minX -= falloff; maxX += falloff; minZ -= falloff; maxZ += falloff
899
- } else {
900
- const tris = this._triangles(region.node)
901
- if (tris.length < 9) return null
902
- minX = Infinity; maxX = -Infinity; minZ = Infinity; maxZ = -Infinity
903
- for (let i = 0; i < tris.length; i += 3) { const px = tris[i], pz = tris[i + 2]; if (px < minX) minX = px; if (px > maxX) maxX = px; if (pz < minZ) minZ = pz; if (pz > maxZ) maxZ = pz }
904
- minX -= falloff; maxX += falloff; minZ -= falloff; maxZ += falloff
905
- }
906
- const r = { x0: Math.max(0, Math.floor(minX / c)), z0: Math.max(0, Math.floor(minZ / c)), x1: Math.min(this.sizeX - 1, Math.ceil(maxX / c)), z1: Math.min(this.sizeZ - 1, Math.ceil(maxZ / c)) }
907
- return r.x0 <= r.x1 && r.z0 <= r.z1 ? r : null
908
- }
909
-
910
- /** Per-sample weights over `r`: 1 inside the region, smooth 1→0 across `falloff` metres outside. */
911
- private _regionWeights(region: TerrainRegion, r: Rect, falloff: number): Float32Array {
912
- const w = r.x1 - r.x0 + 1, h = r.z1 - r.z0 + 1
913
- const out = new Float32Array(w * h)
914
- const c = this.cellSize
915
- const fade = (d: number): number => (d <= 0 ? 1 : falloff > 0 && d < falloff ? 1 - smoothstep(0, 1, d / falloff) : 0)
916
- if ("node" in region) {
917
- // coverage by the node's triangles, no falloff
918
- const tris = this._triangles(region.node)
919
- for (let t = 0; t + 8 < tris.length; t += 9) {
920
- const ax = tris[t], az = tris[t + 2], bx = tris[t + 3], bz = tris[t + 5], qx = tris[t + 6], qz = tris[t + 8]
921
- const det = (bx - ax) * (qz - az) - (qx - ax) * (bz - az)
922
- if (Math.abs(det) < 1e-12) continue
923
- const minX = Math.max(r.x0, Math.ceil(Math.min(ax, bx, qx) / c)), maxX = Math.min(r.x1, Math.floor(Math.max(ax, bx, qx) / c))
924
- const minZ = Math.max(r.z0, Math.ceil(Math.min(az, bz, qz) / c)), maxZ = Math.min(r.z1, Math.floor(Math.max(az, bz, qz) / c))
925
- for (let iz = minZ; iz <= maxZ; iz++) for (let ix = minX; ix <= maxX; ix++) {
926
- const px = ix * c, pz = iz * c
927
- const l1 = ((bx - ax) * (pz - az) - (px - ax) * (bz - az)) / det
928
- const l2 = ((px - ax) * (qz - az) - (qx - ax) * (pz - az)) / det
929
- if (l1 < -1e-6 || l2 < -1e-6 || 1 - l1 - l2 < -1e-6) continue
930
- out[(iz - r.z0) * w + (ix - r.x0)] = 1
931
- }
932
- }
933
- return out
934
- }
935
- let k = 0
936
- for (let iz = r.z0; iz <= r.z1; iz++) {
937
- const pz = iz * c
938
- for (let ix = r.x0; ix <= r.x1; ix++, k++) {
939
- const px = ix * c
940
- if ("circle" in region) {
941
- const d = Math.hypot(px - region.circle.x, pz - region.circle.z) - region.circle.r
942
- out[k] = fade(d)
943
- } else if ("rect" in region) {
944
- const { x, z, w: rw, h: rh } = region.rect
945
- const dx = Math.max(x - px, 0, px - (x + rw)), dz = Math.max(z - pz, 0, pz - (z + rh))
946
- out[k] = fade(Math.hypot(dx, dz))
947
- } else {
948
- const poly = region.polygon
949
- out[k] = fade(pointInPolygon(poly, px, pz) ? 0 : distanceToPolygon(poly, px, pz))
950
- }
951
- }
952
- }
953
- return out
954
- }
955
-
956
- /** World matrix of the node (column-major). */
957
- private _world(): Float32Array {
958
- const m = new Float32Array(16)
959
- _creator.getWorldMatrix(this.node.id, m)
960
- return m
961
- }
962
-
963
- /** A target's triangles in the terrain's LOCAL frame, 9 floats per triangle. */
964
- private _triangles(target: Node | Geometry | Float32Array): Float32Array {
965
- if (target instanceof Float32Array) return target
966
- if (target instanceof Geometry) return soupFromGeometry(target, null)
967
- const inv = invertAffine(this._world())
968
- const parts: Float32Array[] = []
969
- const scratch = new Float32Array(16)
970
- const walk = (n: Node): void => {
971
- if ((n as unknown as { _terrain?: unknown })._terrain) return
972
- _creator.getWorldMatrix(n.id, scratch)
973
- const toLocal = multiplyAffine(inv, scratch)
974
- const geom = n.geometry
975
- if (geom && geom._meshKind === 0) parts.push(soupFromGeometry(geom, toLocal))
976
- else if (n instanceof Model && _creator.glbTriangles) {
977
- const soup = _creator.glbTriangles(n.id)
978
- if (soup.length >= 9) parts.push(transformSoup(soup, toLocal))
979
- }
980
- for (const c of n.children) walk(c)
981
- }
982
- walk(target)
983
- const total = parts.reduce((s, p) => s + p.length, 0)
984
- const out = new Float32Array(total)
985
- let o = 0
986
- for (const p of parts) { out.set(p, o); o += p.length }
987
- return out
988
- }
989
- }
990
-
991
- // ---- helpers -------------------------------------------------------------------------------------
992
-
993
- const soupFromGeometry = (g: Geometry, m: Float32Array | null): Float32Array => {
994
- const idx = g.indices, v = g.vertices
995
- const out = new Float32Array(idx.length * 3)
996
- for (let i = 0; i < idx.length; i++) {
997
- const vi = idx[i] * 3
998
- out[i * 3] = v[vi]; out[i * 3 + 1] = v[vi + 1]; out[i * 3 + 2] = v[vi + 2]
999
- }
1000
- return m ? transformSoup(out, m) : out
1001
- }
1002
-
1003
- const transformSoup = (s: Float32Array, m: Float32Array): Float32Array => {
1004
- const out = new Float32Array(s.length)
1005
- for (let i = 0; i + 2 < s.length; i += 3) {
1006
- const x = s[i], y = s[i + 1], z = s[i + 2]
1007
- out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
1008
- out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
1009
- out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
1010
- }
1011
- return out
1012
- }
1013
-
1014
- const transformPoint = (m: Float32Array, x: number, y: number, z: number): Vec3 =>
1015
- new Vec3(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14])
1016
-
1017
- /** a × b for column-major affine 4×4s. */
1018
- const multiplyAffine = (a: Float32Array, b: Float32Array): Float32Array => {
1019
- const o = new Float32Array(16)
1020
- for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
1021
- o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
1022
- }
1023
- return o
1024
- }
1025
-
1026
- /** Inverse of a column-major affine (TRS) 4×4. */
1027
- const invertAffine = (m: Float32Array): Float32Array => {
1028
- const a00 = m[0], a01 = m[1], a02 = m[2], a10 = m[4], a11 = m[5], a12 = m[6], a20 = m[8], a21 = m[9], a22 = m[10]
1029
- const b01 = a22 * a11 - a12 * a21, b11 = -a22 * a10 + a12 * a20, b21 = a21 * a10 - a11 * a20
1030
- let det = a00 * b01 + a01 * b11 + a02 * b21
1031
- if (Math.abs(det) < 1e-20) det = 1e-20
1032
- det = 1 / det
1033
- const o = new Float32Array(16)
1034
- o[0] = b01 * det; o[1] = (-a22 * a01 + a02 * a21) * det; o[2] = (a12 * a01 - a02 * a11) * det
1035
- o[4] = b11 * det; o[5] = (a22 * a00 - a02 * a20) * det; o[6] = (-a12 * a00 + a02 * a10) * det
1036
- o[8] = b21 * det; o[9] = (-a21 * a00 + a01 * a20) * det; o[10] = (a11 * a00 - a01 * a10) * det
1037
- const tx = m[12], ty = m[13], tz = m[14]
1038
- o[12] = -(o[0] * tx + o[4] * ty + o[8] * tz)
1039
- o[13] = -(o[1] * tx + o[5] * ty + o[9] * tz)
1040
- o[14] = -(o[2] * tx + o[6] * ty + o[10] * tz)
1041
- o[15] = 1
1042
- return o
1043
- }
1044
-
1045
- const pointInPolygon = (poly: readonly (readonly [number, number])[], x: number, z: number): boolean => {
1046
- let inside = false
1047
- for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
1048
- const [ xi, zi ] = poly[i], [ xj, zj ] = poly[j]
1049
- if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi || 1e-12) + xi) inside = !inside
1050
- }
1051
- return inside
1052
- }
1053
-
1054
- const distanceToPolygon = (poly: readonly (readonly [number, number])[], x: number, z: number): number => {
1055
- let best = Infinity
1056
- for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
1057
- const [ ax, az ] = poly[j], [ bx, bz ] = poly[i]
1058
- const dx = bx - ax, dz = bz - az
1059
- const l2 = dx * dx + dz * dz
1060
- const t = l2 > 0 ? Math.min(1, Math.max(0, ((x - ax) * dx + (z - az) * dz) / l2)) : 0
1061
- const d = Math.hypot(x - (ax + dx * t), z - (az + dz * t))
1062
- if (d < best) best = d
1063
- }
1064
- return best
1065
- }
1066
-
1067
- // QuickJS has no TextEncoder: the header is ASCII JSON, encoded by hand.
1068
- const utf8 = (s: string): Uint8Array => {
1069
- const out: number[] = []
1070
- for (let i = 0; i < s.length; i++) {
1071
- let c = s.charCodeAt(i)
1072
- if (c < 0x80) out.push(c)
1073
- else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f))
1074
- else if (c >= 0xd800 && c < 0xdc00 && i + 1 < s.length) {
1075
- c = 0x10000 + ((c - 0xd800) << 10) + (s.charCodeAt(++i) - 0xdc00)
1076
- out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
1077
- } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
1078
- }
1079
- return Uint8Array.from(out)
1080
- }
1081
-
1082
- const fromUtf8 = (b: Uint8Array): string => {
1083
- let s = ""
1084
- for (let i = 0; i < b.length;) {
1085
- const c = b[i++]
1086
- if (c < 0x80) s += String.fromCharCode(c)
1087
- else if (c < 0xe0) s += String.fromCharCode(((c & 0x1f) << 6) | (b[i++] & 0x3f))
1088
- else if (c < 0xf0) { s += String.fromCharCode(((c & 0x0f) << 12) | ((b[i] & 0x3f) << 6) | (b[i + 1] & 0x3f)); i += 2 }
1089
- else { const cp = ((c & 0x07) << 18) | ((b[i] & 0x3f) << 12) | ((b[i + 1] & 0x3f) << 6) | (b[i + 2] & 0x3f); i += 3; s += String.fromCodePoint(cp) }
1090
- }
1091
- return s
1092
- }