lecodes-cli 0.19.0 → 0.20.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 (270) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +1069 -396
  3. package/package.json +14 -13
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk-types.json +1 -1
  6. package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  7. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  8. package/runtime/web/assets/basis-C64VHDVD.js +1 -0
  9. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  10. package/runtime/web/assets/createViewerLite-BvHobVhp.js +852 -0
  11. package/runtime/web/assets/draco-BiISTFcR.js +118 -0
  12. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  13. package/runtime/web/assets/index-CfNCCBjN.js +2 -0
  14. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
  15. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
  16. package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  17. package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
  18. package/runtime/web/embed.html +32 -0
  19. package/runtime/web/embed.js +171 -0
  20. package/src/api.ts +302 -0
  21. package/src/browserAuth.ts +87 -0
  22. package/src/cmgenTool.ts +104 -0
  23. package/src/commands/app.ts +896 -0
  24. package/src/commands/appAndroid.ts +603 -0
  25. package/src/commands/appDesktop.ts +339 -0
  26. package/src/commands/appDesktopMac.ts +470 -0
  27. package/src/commands/appIcon.ts +187 -0
  28. package/src/commands/appShared.ts +542 -0
  29. package/src/commands/appTemplates.ts +907 -0
  30. package/src/commands/appTemplatesAndroid.ts +599 -0
  31. package/src/commands/appTemplatesGradlew.ts +9 -0
  32. package/src/commands/assets.ts +28 -0
  33. package/src/commands/clone.ts +60 -0
  34. package/src/commands/compile.ts +253 -0
  35. package/src/commands/create.ts +59 -0
  36. package/src/commands/design.ts +863 -0
  37. package/src/commands/designTemplates.ts +11 -0
  38. package/src/commands/desktop.ts +127 -0
  39. package/src/commands/dev.ts +236 -0
  40. package/src/commands/diff.ts +59 -0
  41. package/src/commands/init.ts +191 -0
  42. package/src/commands/install.ts +198 -0
  43. package/src/commands/lightmap.ts +290 -0
  44. package/src/commands/link.ts +147 -0
  45. package/src/commands/login.ts +24 -0
  46. package/src/commands/navmesh.ts +225 -0
  47. package/src/commands/pn.ts +305 -0
  48. package/src/commands/projectTemplates.ts +130 -0
  49. package/src/commands/pull.ts +109 -0
  50. package/src/commands/push.ts +173 -0
  51. package/src/commands/render.ts +416 -0
  52. package/src/commands/scene.ts +148 -0
  53. package/src/commands/shaders.ts +157 -0
  54. package/src/commands/status.ts +29 -0
  55. package/src/commands/test.ts +371 -0
  56. package/src/commands/thumbs.ts +178 -0
  57. package/src/commands/types.ts +87 -0
  58. package/src/commands/update.ts +190 -0
  59. package/src/compile/assetIcons.ts +214 -0
  60. package/src/compile/collect.ts +46 -0
  61. package/src/compile/collectLocal.ts +37 -0
  62. package/src/compile/designCompile.ts +104 -0
  63. package/src/compile/fonts.ts +156 -0
  64. package/src/compile/headlessBundle.ts +129 -0
  65. package/src/compile/nativeStack.ts +21 -0
  66. package/src/compile/projectCompile.ts +109 -0
  67. package/src/compile/sceneCompile.ts +113 -0
  68. package/src/compile/screenEntry.ts +127 -0
  69. package/src/compile/shaders.ts +245 -0
  70. package/src/config.ts +42 -0
  71. package/src/designMeta.ts +35 -0
  72. package/src/desktopRenderer.ts +545 -0
  73. package/src/desktopScript.ts +276 -0
  74. package/src/dev/clientTemplates.ts +160 -0
  75. package/src/dev/devServer.ts +333 -0
  76. package/src/dev/webRunner.ts +22 -0
  77. package/src/dev/wsServer.ts +144 -0
  78. package/src/distRoot.ts +20 -0
  79. package/src/ignore.ts +163 -0
  80. package/src/index.ts +494 -0
  81. package/src/lecodes-3d-editor.d.ts +41 -0
  82. package/src/lecodes-assets.d.ts +7 -0
  83. package/src/lecodes-design.d.ts +191 -0
  84. package/src/lecodes-renderer.d.ts +131 -0
  85. package/src/localFiles.ts +144 -0
  86. package/src/manifest.ts +46 -0
  87. package/src/matcTool.ts +137 -0
  88. package/src/peers.ts +40 -0
  89. package/src/project.ts +61 -0
  90. package/src/projectEnv.ts +94 -0
  91. package/src/qrcode-terminal.d.ts +9 -0
  92. package/src/releases.ts +125 -0
  93. package/src/serverDiff.ts +78 -0
  94. package/src/textDiff.ts +103 -0
  95. package/src/types.ts +0 -0
  96. package/src/util.ts +146 -0
  97. package/runtime/sdk/animate/animate.ts +0 -238
  98. package/runtime/sdk/animate/bezier.ts +0 -138
  99. package/runtime/sdk/animate/easings.ts +0 -126
  100. package/runtime/sdk/canvas/Canvas.ts +0 -305
  101. package/runtime/sdk/compile/aspectMacro.ts +0 -86
  102. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  103. package/runtime/sdk/compile/assetMacro.ts +0 -146
  104. package/runtime/sdk/compile/assetName.ts +0 -50
  105. package/runtime/sdk/compile/bundler.ts +0 -287
  106. package/runtime/sdk/compile/compileProject.ts +0 -129
  107. package/runtime/sdk/compile/detectEntry.ts +0 -128
  108. package/runtime/sdk/compile/fontMacro.ts +0 -459
  109. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  110. package/runtime/sdk/compile/header.ts +0 -67
  111. package/runtime/sdk/compile/index.ts +0 -100
  112. package/runtime/sdk/compile/libraryImports.ts +0 -52
  113. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  114. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  115. package/runtime/sdk/compile/serverSplit.ts +0 -233
  116. package/runtime/sdk/compile/serverTypes.ts +0 -227
  117. package/runtime/sdk/compile/sfnt.ts +0 -98
  118. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  119. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  120. package/runtime/sdk/compile/sourcemap.ts +0 -25
  121. package/runtime/sdk/core/Aspect.ts +0 -568
  122. package/runtime/sdk/core/InspectorUI.ts +0 -212
  123. package/runtime/sdk/core/StateMachine.ts +0 -308
  124. package/runtime/sdk/core/color.ts +0 -66
  125. package/runtime/sdk/core/compWrite.ts +0 -42
  126. package/runtime/sdk/core/events.ts +0 -38
  127. package/runtime/sdk/core/fields.ts +0 -120
  128. package/runtime/sdk/core/registry.ts +0 -23
  129. package/runtime/sdk/core/signals.ts +0 -277
  130. package/runtime/sdk/core/time.ts +0 -84
  131. package/runtime/sdk/g2/Camera2D.ts +0 -40
  132. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  133. package/runtime/sdk/g2/Node2D.ts +0 -267
  134. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  135. package/runtime/sdk/g2/Physics2D.ts +0 -346
  136. package/runtime/sdk/g2/Scene2D.ts +0 -209
  137. package/runtime/sdk/g2/Shape2D.ts +0 -259
  138. package/runtime/sdk/g2/Sprite.ts +0 -89
  139. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  140. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  141. package/runtime/sdk/g2/Texture2D.ts +0 -47
  142. package/runtime/sdk/g2/Tilemap.ts +0 -41
  143. package/runtime/sdk/g2/Tileset.ts +0 -71
  144. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  145. package/runtime/sdk/g2/autotile.ts +0 -433
  146. package/runtime/sdk/g2/cells.ts +0 -91
  147. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  148. package/runtime/sdk/g2/groups2d.ts +0 -106
  149. package/runtime/sdk/g2/loop.ts +0 -50
  150. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  151. package/runtime/sdk/g2/touch.ts +0 -83
  152. package/runtime/sdk/gl/Camera.ts +0 -160
  153. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  154. package/runtime/sdk/gl/CharacterController.ts +0 -238
  155. package/runtime/sdk/gl/Gearbox.ts +0 -212
  156. package/runtime/sdk/gl/Geometry.ts +0 -279
  157. package/runtime/sdk/gl/IK.ts +0 -193
  158. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  159. package/runtime/sdk/gl/Light.ts +0 -107
  160. package/runtime/sdk/gl/Lightmap.ts +0 -256
  161. package/runtime/sdk/gl/Material.ts +0 -260
  162. package/runtime/sdk/gl/Mesh.ts +0 -83
  163. package/runtime/sdk/gl/Model.ts +0 -124
  164. package/runtime/sdk/gl/NavAgent.ts +0 -337
  165. package/runtime/sdk/gl/NavMesh.ts +0 -397
  166. package/runtime/sdk/gl/Node.ts +0 -350
  167. package/runtime/sdk/gl/Noise.ts +0 -30
  168. package/runtime/sdk/gl/Particles.ts +0 -676
  169. package/runtime/sdk/gl/Physics.ts +0 -222
  170. package/runtime/sdk/gl/Plane.ts +0 -53
  171. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  172. package/runtime/sdk/gl/Ray.ts +0 -16
  173. package/runtime/sdk/gl/Scene.ts +0 -486
  174. package/runtime/sdk/gl/Shape.ts +0 -409
  175. package/runtime/sdk/gl/Terrain.ts +0 -1092
  176. package/runtime/sdk/gl/Texture.ts +0 -63
  177. package/runtime/sdk/gl/Trigger.ts +0 -45
  178. package/runtime/sdk/gl/Vehicle.ts +0 -473
  179. package/runtime/sdk/gl/Wheel.ts +0 -240
  180. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  181. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  182. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  183. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  184. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  185. package/runtime/sdk/gl/animation/core.ts +0 -336
  186. package/runtime/sdk/gl/controls.ts +0 -95
  187. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  188. package/runtime/sdk/gl/scenarios.ts +0 -291
  189. package/runtime/sdk/gl/state.ts +0 -6
  190. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  191. package/runtime/sdk/gl/touch.ts +0 -68
  192. package/runtime/sdk/inject.ts +0 -197
  193. package/runtime/sdk/math/Mathf.ts +0 -118
  194. package/runtime/sdk/math/mat4.ts +0 -278
  195. package/runtime/sdk/math/quat.ts +0 -232
  196. package/runtime/sdk/math/vec.ts +0 -255
  197. package/runtime/sdk/net/codec.ts +0 -119
  198. package/runtime/sdk/net/core.ts +0 -384
  199. package/runtime/sdk/net/index.ts +0 -181
  200. package/runtime/sdk/net/replication.ts +0 -622
  201. package/runtime/sdk/plugins/camera.ts +0 -81
  202. package/runtime/sdk/plugins/geolocation.ts +0 -123
  203. package/runtime/sdk/plugins/oauth.ts +0 -61
  204. package/runtime/sdk/plugins/permission.ts +0 -7
  205. package/runtime/sdk/plugins/push.ts +0 -132
  206. package/runtime/sdk/plugins/qr.ts +0 -73
  207. package/runtime/sdk/plugins/service.ts +0 -47
  208. package/runtime/sdk/runtime/app.ts +0 -101
  209. package/runtime/sdk/runtime/appEvents.ts +0 -54
  210. package/runtime/sdk/runtime/channel.ts +0 -61
  211. package/runtime/sdk/runtime/clipboard.ts +0 -20
  212. package/runtime/sdk/runtime/datetime.ts +0 -329
  213. package/runtime/sdk/runtime/device.ts +0 -293
  214. package/runtime/sdk/runtime/fetch.ts +0 -77
  215. package/runtime/sdk/runtime/files.ts +0 -23
  216. package/runtime/sdk/runtime/input.ts +0 -175
  217. package/runtime/sdk/runtime/media.ts +0 -111
  218. package/runtime/sdk/runtime/misc.ts +0 -16
  219. package/runtime/sdk/runtime/net.ts +0 -36
  220. package/runtime/sdk/runtime/rpc.ts +0 -218
  221. package/runtime/sdk/runtime/service.ts +0 -83
  222. package/runtime/sdk/runtime/share.ts +0 -9
  223. package/runtime/sdk/runtime/storage.ts +0 -13
  224. package/runtime/sdk/runtime/touch.ts +0 -76
  225. package/runtime/sdk/scene/defineScene.ts +0 -1353
  226. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  227. package/runtime/sdk/scene/gizmos.ts +0 -148
  228. package/runtime/sdk/scene/grammar.ts +0 -120
  229. package/runtime/sdk/scene/material.ts +0 -188
  230. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  231. package/runtime/sdk/server/auth/global.ts +0 -80
  232. package/runtime/sdk/server/auth/host.ts +0 -318
  233. package/runtime/sdk/server/auth/models.ts +0 -83
  234. package/runtime/sdk/server/auth/types.ts +0 -50
  235. package/runtime/sdk/server/channel.ts +0 -56
  236. package/runtime/sdk/server/context.ts +0 -36
  237. package/runtime/sdk/server/db/defineDb.ts +0 -237
  238. package/runtime/sdk/server/db/fields.ts +0 -132
  239. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  240. package/runtime/sdk/server/db/index.ts +0 -7
  241. package/runtime/sdk/server/db/marci/query.ts +0 -412
  242. package/runtime/sdk/server/db/types.ts +0 -202
  243. package/runtime/sdk/server/errors.ts +0 -12
  244. package/runtime/sdk/server/host.ts +0 -74
  245. package/runtime/sdk/server/inject.ts +0 -13
  246. package/runtime/sdk/server/runtime.ts +0 -133
  247. package/runtime/sdk/server/validate.ts +0 -87
  248. package/runtime/sdk/ui/NativeView.ts +0 -142
  249. package/runtime/sdk/ui/UI.ts +0 -39
  250. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  251. package/runtime/sdk/ui/UIButton.ts +0 -101
  252. package/runtime/sdk/ui/UIContainer.ts +0 -60
  253. package/runtime/sdk/ui/UIImage.ts +0 -83
  254. package/runtime/sdk/ui/UIInput.ts +0 -185
  255. package/runtime/sdk/ui/UIModal.ts +0 -139
  256. package/runtime/sdk/ui/UINode.ts +0 -830
  257. package/runtime/sdk/ui/UIPager.ts +0 -362
  258. package/runtime/sdk/ui/UIPopover.ts +0 -100
  259. package/runtime/sdk/ui/UIScreen.ts +0 -123
  260. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  261. package/runtime/sdk/ui/UISpacer.ts +0 -14
  262. package/runtime/sdk/ui/UITabs.ts +0 -236
  263. package/runtime/sdk/ui/UIText.ts +0 -51
  264. package/runtime/sdk/ui/UIVideo.ts +0 -88
  265. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  266. package/runtime/sdk/ui/UIWidget.ts +0 -127
  267. package/runtime/sdk/ui/fonts.ts +0 -13
  268. package/runtime/sdk/ui/presentable.ts +0 -117
  269. package/runtime/sdk/ui/router.ts +0 -132
  270. 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
- }