spoint 0.1.661 → 0.1.663

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.
@@ -1,41 +1,6 @@
1
1
  // Client visual layer for the ez-tree forest. Reads the SAME deterministic placement as the server (VegPlacement.js) so the visual trunk matches the collided trunk. Per species: one InstancedMesh2 for branches + one for leaves, 3 mesh LODs + a cheap shadow LOD + BVH per-instance frustum culling. window.__veg / window.__vegProfile.
2
2
  import * as THREE from 'three'
3
3
  import { InstancedMesh2 } from '@three.ez/instanced-mesh'
4
- import { MeshoptSimplifier } from 'meshoptimizer'
5
- // '@dgreenheck/ez-tree' is intentionally NOT a static import here (was: `import { Tree } from
6
- // '@dgreenheck/ez-tree'`). A static top-level import is resolved during ES module GRAPH LINKING,
7
- // before any module body executes -- a 404/missing-package on this one import poisons the ENTIRE
8
- // graph rooted at app.js (app.js statically imports Vegetation.js), aborting client boot with zero
9
- // console output, even from systems that textually import before this one and would otherwise have
10
- // succeeded (confirmed live: a module-graph reproduction with this exact static-import shape showed
11
- // sibling static imports before the failing one never evaluate either -- graph linking is atomic,
12
- // see eztree-fix-live-reproduce-and-verify PRD row for the harness + output).
13
- // Root cause of the underlying absence: single-writer contention on the shared main checkout's
14
- // node_modules directory when multiple concurrent worktree sessions run npm-install-adjacent
15
- // operations against the SAME junction target at once (see scripts/worktree-setup.mjs and
16
- // AGENTS.md's main-node-modules-missing-ez-tree-package-intermittent history) -- this can still
17
- // transiently 404 under contention even after worktree-setup.mjs's own torn-install guard, since a
18
- // junction always resolves LIVE to whatever main's node_modules currently contains at request time.
19
- // A dynamic import, awaited once and cached, keeps the failure LOCAL to createVegetation() (which
20
- // already has a per-species try/catch and whose own caller in app.js already .catch()es) instead of
21
- // poisoning module linking -- the rest of the client (terrain, physics, HUD, netcode, other apps)
22
- // boots fine and only vegetation degrades to "skipped" with a clear console warning.
23
- // RESIDUAL RISK (not fully eliminated by this fix): a package going missing/corrupt AFTER this
24
- // dynamic import has already resolved once per page load (e.g. torn mid-way through THIS page's own
25
- // boot) is not retried -- the cached rejection/resolution is final for that page session's lifetime,
26
- // matching how a static import would behave too (a page load either gets a working module graph or
27
- // it doesn't; there is no live hot-swap of an already-linked/already-resolved import). The dynamic
28
- // import only prevents the SPECIFIC failure mode of "vegetation being unavailable" from cascading
29
- // into "nothing at all is available" -- it does not make ez-tree loading retry-resilient within a
30
- // single page load, and it does nothing for any OTHER package that is statically imported elsewhere
31
- // in the graph (this fix is scoped to ez-tree, the one package this task's report named; a fully
32
- // general fix would need every client entry-adjacent static import audited the same way, which is a
33
- // larger cross-cutting change than this task's reported symptom asked for).
34
- let _ezTreeModPromise = null
35
- function loadEzTree() {
36
- if (!_ezTreeModPromise) _ezTreeModPromise = import('@dgreenheck/ez-tree')
37
- return _ezTreeModPromise
38
- }
39
4
  // streaming-gltf's octahedral impostor (FULL-sphere octahedron - works from any angle, incl ground
40
5
  // level; the agargaro vendored lib only implemented HEMI so ground views rendered nothing). Plain
41
6
  // Single canonical impostor implementation (packages/streaming-gltf/src/octahedral-impostor-ez.js,
@@ -48,179 +13,17 @@ import { createCachedAnchorField } from '/src/terrain/ClimateCache.js'
48
13
  import { createBiomeOverride } from '/src/terrain/BiomeOverride.js'
49
14
  import { dbg } from './debug-log.js'
50
15
  import { RenderControls } from './RenderControls.js'
16
+ import { loadEzTree, makeWindUniforms, applyWind, awaitMatTextures, capGeo, simplifyGeo, buildSpecies, makeEmptyGeo, TARGET_H } from './VegetationBuild.js'
51
17
 
52
18
  const _dbgVeg = dbg('vegetation')
53
19
  const _occBoxGeo = new THREE.BoxGeometry(1, 1, 1) // shared, never-rendered proxy geo for occlusion candidates
54
20
  const _occBoxMat = new THREE.MeshBasicMaterial()
55
21
 
56
- // species (parity wire-id contract) -> ez-tree preset names; 'Bush' has no exact preset (lib ships 'Bush 1/2/3'), mapped explicitly so a missing preset never silently diverges from the collider table
57
- const PRESET = {
58
- 'Oak Large': 'Oak Large', 'Pine Medium': 'Pine Medium', 'Aspen Medium': 'Aspen Medium', 'Ash Medium': 'Ash Medium', 'Bush': 'Bush 1',
59
- 'Ash Small': 'Ash Small', 'Ash Large': 'Ash Large', 'Aspen Small': 'Aspen Small', 'Aspen Large': 'Aspen Large', 'Bush 2': 'Bush 2',
60
- 'Bush 3': 'Bush 3', 'Oak Small': 'Oak Small', 'Oak Medium': 'Oak Medium', 'Pine Small': 'Pine Small', 'Pine Large': 'Pine Large',
61
- }
62
-
63
- // target real-world full-tree height (m) per species: ez-tree's native ~100-unit scale mismatches the trunk collider table, LOD cutovers, and impostor size; normalizing makes visual==collider==LOD==impostor size
64
- const TARGET_H = {
65
- 'Oak Large': 9, 'Pine Medium': 12, 'Aspen Medium': 9.5, 'Ash Medium': 10, 'Bush': 2.8,
66
- 'Ash Small': 6.5, 'Ash Large': 13, 'Aspen Small': 6, 'Aspen Large': 13, 'Bush 2': 3.0,
67
- 'Bush 3': 3.2, 'Oak Small': 5.5, 'Oak Medium': 7, 'Pine Small': 8, 'Pine Large': 16,
68
- }
69
-
70
22
  const DROP_MARGIN = 64 // metres past the ring before a chunk is dropped (hysteresis)
71
23
 
72
24
  const _v = new THREE.Vector3(), _q = new THREE.Quaternion(), _camPos = new THREE.Vector3()
73
25
  const _vanMat = new THREE.Matrix4(), _vanProj = new THREE.Matrix4(), _vanFrustum = new THREE.Frustum() // scratch for window.__vegVanishProbe
74
26
 
75
- // shared wind uniform (one per veg system); advancing one .value per frame sways all LODs of all species with zero per-instance JS
76
- function makeWindUniforms() { return { uVegTime: { value: 0 }, uVegWind: { value: 1 } } }
77
-
78
- function _dissolveBoundaryDistanceShaderChunk(boundaries) {
79
- const uniforms = boundaries.map((_, i) => `uniform float uVegLodB${i};`).join('\n')
80
- const mindist = boundaries.map((_, i) => `_vegDistToB = min(_vegDistToB, abs(_vegCamDist - uVegLodB${i}));`).join('\n ')
81
- return { uniforms, mindist }
82
- }
83
-
84
- const VEG_DISSOLVE_FADE_BAND_M = 3.0
85
-
86
- function applyWind(material, wind, lodBoundaries) {
87
- const chunk = lodBoundaries && lodBoundaries.length ? _dissolveBoundaryDistanceShaderChunk(lodBoundaries) : null
88
- material.onBeforeCompile = (shader) => {
89
- shader.uniforms.uVegTime = wind.uVegTime
90
- shader.uniforms.uVegWind = wind.uVegWind
91
- shader.vertexShader = 'uniform float uVegTime;\nuniform float uVegWind;\n' + shader.vertexShader
92
- // windPhase/tint are per-instance uniforms @three.ez declares; its injection runs after this base
93
- shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
94
- '#include <begin_vertex>\n' +
95
- 'float _wsway = (position.y) * 0.06;\n' +
96
- 'float _wph = uVegTime * 1.3 + windPhase;\n' +
97
- 'transformed.x += sin(_wph) * _wsway * uVegWind;\n' +
98
- 'transformed.z += cos(_wph * 0.8) * _wsway * 0.6 * uVegWind;')
99
- // per-instance shade: multiply the lit diffuse by the instance tint (brightness variation).
100
- shader.fragmentShader = shader.fragmentShader.replace('#include <color_fragment>',
101
- '#include <color_fragment>\n diffuseColor.rgb *= tint;')
102
- if (chunk) {
103
- lodBoundaries.forEach((d, i) => { shader.uniforms['uVegLodB' + i] = { value: d } })
104
- shader.vertexShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.vertexShader
105
- shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
106
- '#include <begin_vertex>\nvVegCamDist = distance(cameraPosition, (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz);')
107
- shader.fragmentShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.fragmentShader
108
- shader.fragmentShader = shader.fragmentShader.replace('#include <dithering_fragment>',
109
- '#include <dithering_fragment>\n' +
110
- `float _vegCamDist = vVegCamDist;\n` +
111
- `float _vegDistToB = 1e9;\n` +
112
- ` ${chunk.mindist}\n` +
113
- `float _vegFade = clamp(_vegDistToB / ${VEG_DISSOLVE_FADE_BAND_M.toFixed(1)}, 0.0, 1.0);\n` +
114
- 'float _vegDither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n' +
115
- 'if (_vegDither > _vegFade) discard;')
116
- }
117
- }
118
- material.customProgramCacheKey = () => 'vegwind3' + (chunk ? '_fade' + lodBoundaries.join('_') : '')
119
- return material
120
- }
121
-
122
- // Must await texture decode before sampling: ez-tree loads bark/leaf maps async, so baking the impostor atlas in the same tick would sample undefined images -> blank atlas.
123
- async function awaitMatTextures(mats) {
124
- const texes = []
125
- for (const m of mats) {
126
- if (!m) continue
127
- for (const k of ['map', 'normalMap', 'roughnessMap', 'aoMap', 'alphaMap', 'bumpMap']) {
128
- const t = m[k]; if (t && t.isTexture) texes.push(t)
129
- }
130
- }
131
- await Promise.all(texes.map(async (t) => {
132
- const img = t.image
133
- try {
134
- if (img && typeof img.decode === 'function') { await img.decode(); t.needsUpdate = true; return }
135
- } catch (_) {}
136
- // fallback: poll until the image has dimensions, ~1s cap
137
- for (let i = 0; i < 60; i++) {
138
- if (t.image && (t.image.width > 0 || t.image.videoWidth > 0)) { t.needsUpdate = true; return }
139
- await new Promise(r => setTimeout(r, 16))
140
- }
141
- }))
142
- }
143
-
144
- // Caps a geometry to at most maxTris triangles (meshopt); the LOD0 budget bounding the worst near-tree.
145
- async function capGeo(geo, maxTris) {
146
- try {
147
- const idx = geo.index ? geo.index.array : null
148
- if (!idx) return geo
149
- const tris = idx.length / 3
150
- if (tris <= maxTris) return geo
151
- return await simplifyGeo(geo, maxTris / tris, false)
152
- } catch (_) { return geo }
153
- }
154
-
155
- // Meshopt-simplifies to `ratio` of triangles. Must ALWAYS return a NEW distinct geometry object (never the source) -- @three.ez's addLevel reuses the LOD object when geometry===existing, so two LOD levels sharing one object alias one instanceIndex array and stomp each other's drawn slots (a tree vanishing in a band at the cutover).
156
- async function simplifyGeo(geo, ratio, sloppy) {
157
- try {
158
- await MeshoptSimplifier.ready
159
- const idx = geo.index ? geo.index.array : null
160
- const pos = geo.attributes.position.array
161
- if (!idx || !pos) return geo.clone()
162
- const target = Math.max(12, Math.floor((idx.length / 3) * ratio) * 3)
163
- const fn = sloppy && MeshoptSimplifier.simplifySloppy ? 'simplifySloppy' : 'simplify'
164
- const args = sloppy ? [idx, pos, 3, target, 0.05] : [idx, pos, 3, target, 0.02, ['Sparse']]
165
- const [newIdx] = MeshoptSimplifier[fn](...args)
166
- const out = geo.clone()
167
- if (newIdx && newIdx.length >= 3) out.setIndex(new THREE.BufferAttribute(newIdx, 1))
168
- return out
169
- } catch (_) { return geo.clone() }
170
- }
171
-
172
- function buildSpecies(name, Tree) {
173
- const tree = new Tree()
174
- tree.loadPreset(PRESET[name] || name)
175
- const branchGeo = tree.branchesMesh.geometry
176
- const leafGeo = tree.leavesMesh.geometry
177
- const branchMat = tree.branchesMesh.material
178
- const leafMat = tree.leavesMesh.material
179
- branchMat.shadowSide = THREE.FrontSide
180
- leafMat.alphaTest = Math.max(leafMat.alphaTest || 0, 0.5) // no-MSAA fallback (A2C needs samples)
181
- leafMat.transparent = false
182
- leafMat.side = THREE.DoubleSide
183
- // Apply depth bias to mesh LOD to match impostor bias, preventing flicker at LOD boundary.
184
- // units -8 -> -32 (2026-07-10, live A/B via GL readPixels flicker-score harness against a real
185
- // close-range trunk, world ~3-8m from camera): -8 was proven LIVE-INSUFFICIENT -- trunk mesh vs
186
- // mapspinner's independently-rendered terrain depth z-fight every single frame at that range
187
- // (strict alternation between trunk color and terrain color on 18-23 of 20-24 sampled frames),
188
- // even though occlusion/LOD-swap/per-instance-visibility were all confirmed INERT for this exact
189
- // symptom (occludedKeys.size===0 always, instancesCount constant, getVisibilityAt always true --
190
- // the flicker is a raw GPU depth-test tie-break flip, not a game-logic visibility toggle). A/B
191
- // tested -20/-50/-100 all fully eliminated it (0/20 frames changed, vs 18/20 baseline); -32 keeps
192
- // a safety margin above the smallest working value without over-biasing.
193
- branchMat.polygonOffset = true
194
- branchMat.polygonOffsetFactor = -4
195
- branchMat.polygonOffsetUnits = -32
196
- leafMat.polygonOffset = true
197
- leafMat.polygonOffsetFactor = -4
198
- leafMat.polygonOffsetUnits = -32
199
- // normalize to a real-world height: scale branch+leaf by the same factor so visual size agrees with the trunk collider + LOD + impostor
200
- branchGeo.computeBoundingBox(); leafGeo.computeBoundingBox()
201
- const minY = Math.min(branchGeo.boundingBox.min.y, leafGeo.boundingBox.min.y)
202
- const maxY = Math.max(branchGeo.boundingBox.max.y, leafGeo.boundingBox.max.y)
203
- const nativeH = maxY - minY
204
- const target = TARGET_H[name] || 9
205
- const s = (Number.isFinite(nativeH) && nativeH > 1e-3) ? target / nativeH : 1
206
- for (const g of [branchGeo, leafGeo]) {
207
- g.scale(s, s, s)
208
- g.translate(0, -minY * s, 0) // drop base to y=0 so the trunk rests on the ground
209
- g.computeBoundingBox(); g.computeBoundingSphere()
210
- }
211
- const bb = branchGeo.boundingBox, lb = leafGeo.boundingBox
212
- const width = Math.max(bb.max.x - bb.min.x, bb.max.z - bb.min.z, lb.max.x - lb.min.x, lb.max.z - lb.min.z) || target * 0.7
213
- const dims = { width, height: target }
214
- return { branchGeo, leafGeo, branchMat, leafMat, tree, dims }
215
- }
216
-
217
- // zero-area geo: the far leaf LOD swaps to this so leaf cards vanish where the impostor takes over (no double-draw)
218
- function makeEmptyGeo() {
219
- const g = new THREE.BufferGeometry()
220
- g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(9), 3))
221
- g.setIndex([0, 1, 2])
222
- return g
223
- }
224
27
 
225
28
  export async function createVegetation(opts = {}) {
226
29
  const { renderer, scene, frame } = opts
@@ -0,0 +1,206 @@
1
+ // Species-mesh build helpers for Vegetation.js's createVegetation: ez-tree dynamic loader, wind-sway
2
+ // shader injection, texture-decode/geometry-simplify async helpers, and per-species mesh construction
3
+ // (buildSpecies normalizes ez-tree's native scale to this project's real-world TARGET_H per species,
4
+ // applying the polygonOffset trunk-flicker fix -- see that function's own comment). Split out as
5
+ // Vegetation.js's largest stateless block -- each function here only touches its own module-scoped
6
+ // constants/caches (_ezTreeModPromise, PRESET, TARGET_H), never createVegetation's own closure state
7
+ // (camera/chunk/instance scratch objects stay in Vegetation.js, only used there).
8
+
9
+ import * as THREE from 'three'
10
+ import { MeshoptSimplifier } from 'meshoptimizer'
11
+
12
+ let _ezTreeModPromise = null
13
+ function loadEzTree() {
14
+ if (!_ezTreeModPromise) _ezTreeModPromise = import('@dgreenheck/ez-tree')
15
+ return _ezTreeModPromise
16
+ }
17
+ // streaming-gltf's octahedral impostor (FULL-sphere octahedron - works from any angle, incl ground
18
+ // level; the agargaro vendored lib only implemented HEMI so ground views rendered nothing). Plain
19
+ // Single canonical impostor implementation (packages/streaming-gltf/src/octahedral-impostor-ez.js,
20
+ // shared with ModelPool's OctahedralImpostorEzTier via the same package import elsewhere -- no more
21
+ // client/vendor duplicate, see AGENTS.md draw-call-audit-impostor-system-unification).
22
+ import { createOctahedralImpostorMaterial, computeObjectBoundingSphere } from 'streaming-gltf/octahedral-impostor-ez' // full-sphere octahedron (works at ground level, unlike hemi-only variants)
23
+ import { buildSharedImpostorAtlas, createSharedImpostorMesh } from './VegImpostorTier.js'
24
+ import { placementsForChunk, VEG, SPECIES } from '/src/terrain/VegPlacement.js'
25
+ import { createCachedAnchorField } from '/src/terrain/ClimateCache.js'
26
+ import { createBiomeOverride } from '/src/terrain/BiomeOverride.js'
27
+ import { dbg } from './debug-log.js'
28
+ import { RenderControls } from './RenderControls.js'
29
+
30
+ const _dbgVeg = dbg('vegetation')
31
+ const _occBoxGeo = new THREE.BoxGeometry(1, 1, 1) // shared, never-rendered proxy geo for occlusion candidates
32
+ const _occBoxMat = new THREE.MeshBasicMaterial()
33
+
34
+ // species (parity wire-id contract) -> ez-tree preset names; 'Bush' has no exact preset (lib ships 'Bush 1/2/3'), mapped explicitly so a missing preset never silently diverges from the collider table
35
+ const PRESET = {
36
+ 'Oak Large': 'Oak Large', 'Pine Medium': 'Pine Medium', 'Aspen Medium': 'Aspen Medium', 'Ash Medium': 'Ash Medium', 'Bush': 'Bush 1',
37
+ 'Ash Small': 'Ash Small', 'Ash Large': 'Ash Large', 'Aspen Small': 'Aspen Small', 'Aspen Large': 'Aspen Large', 'Bush 2': 'Bush 2',
38
+ 'Bush 3': 'Bush 3', 'Oak Small': 'Oak Small', 'Oak Medium': 'Oak Medium', 'Pine Small': 'Pine Small', 'Pine Large': 'Pine Large',
39
+ }
40
+
41
+ // target real-world full-tree height (m) per species: ez-tree's native ~100-unit scale mismatches the trunk collider table, LOD cutovers, and impostor size; normalizing makes visual==collider==LOD==impostor size
42
+ const TARGET_H = {
43
+ 'Oak Large': 9, 'Pine Medium': 12, 'Aspen Medium': 9.5, 'Ash Medium': 10, 'Bush': 2.8,
44
+ 'Ash Small': 6.5, 'Ash Large': 13, 'Aspen Small': 6, 'Aspen Large': 13, 'Bush 2': 3.0,
45
+ 'Bush 3': 3.2, 'Oak Small': 5.5, 'Oak Medium': 7, 'Pine Small': 8, 'Pine Large': 16,
46
+ }
47
+
48
+ const DROP_MARGIN = 64 // metres past the ring before a chunk is dropped (hysteresis)
49
+
50
+ const _v = new THREE.Vector3(), _q = new THREE.Quaternion(), _camPos = new THREE.Vector3()
51
+ const _vanMat = new THREE.Matrix4(), _vanProj = new THREE.Matrix4(), _vanFrustum = new THREE.Frustum() // scratch for window.__vegVanishProbe
52
+
53
+ // shared wind uniform (one per veg system); advancing one .value per frame sways all LODs of all species with zero per-instance JS
54
+ function makeWindUniforms() { return { uVegTime: { value: 0 }, uVegWind: { value: 1 } } }
55
+
56
+ function _dissolveBoundaryDistanceShaderChunk(boundaries) {
57
+ const uniforms = boundaries.map((_, i) => `uniform float uVegLodB${i};`).join('\n')
58
+ const mindist = boundaries.map((_, i) => `_vegDistToB = min(_vegDistToB, abs(_vegCamDist - uVegLodB${i}));`).join('\n ')
59
+ return { uniforms, mindist }
60
+ }
61
+
62
+ const VEG_DISSOLVE_FADE_BAND_M = 3.0
63
+
64
+ function applyWind(material, wind, lodBoundaries) {
65
+ const chunk = lodBoundaries && lodBoundaries.length ? _dissolveBoundaryDistanceShaderChunk(lodBoundaries) : null
66
+ material.onBeforeCompile = (shader) => {
67
+ shader.uniforms.uVegTime = wind.uVegTime
68
+ shader.uniforms.uVegWind = wind.uVegWind
69
+ shader.vertexShader = 'uniform float uVegTime;\nuniform float uVegWind;\n' + shader.vertexShader
70
+ // windPhase/tint are per-instance uniforms @three.ez declares; its injection runs after this base
71
+ shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
72
+ '#include <begin_vertex>\n' +
73
+ 'float _wsway = (position.y) * 0.06;\n' +
74
+ 'float _wph = uVegTime * 1.3 + windPhase;\n' +
75
+ 'transformed.x += sin(_wph) * _wsway * uVegWind;\n' +
76
+ 'transformed.z += cos(_wph * 0.8) * _wsway * 0.6 * uVegWind;')
77
+ // per-instance shade: multiply the lit diffuse by the instance tint (brightness variation).
78
+ shader.fragmentShader = shader.fragmentShader.replace('#include <color_fragment>',
79
+ '#include <color_fragment>\n diffuseColor.rgb *= tint;')
80
+ if (chunk) {
81
+ lodBoundaries.forEach((d, i) => { shader.uniforms['uVegLodB' + i] = { value: d } })
82
+ shader.vertexShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.vertexShader
83
+ shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
84
+ '#include <begin_vertex>\nvVegCamDist = distance(cameraPosition, (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz);')
85
+ shader.fragmentShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.fragmentShader
86
+ shader.fragmentShader = shader.fragmentShader.replace('#include <dithering_fragment>',
87
+ '#include <dithering_fragment>\n' +
88
+ `float _vegCamDist = vVegCamDist;\n` +
89
+ `float _vegDistToB = 1e9;\n` +
90
+ ` ${chunk.mindist}\n` +
91
+ `float _vegFade = clamp(_vegDistToB / ${VEG_DISSOLVE_FADE_BAND_M.toFixed(1)}, 0.0, 1.0);\n` +
92
+ 'float _vegDither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n' +
93
+ 'if (_vegDither > _vegFade) discard;')
94
+ }
95
+ }
96
+ material.customProgramCacheKey = () => 'vegwind3' + (chunk ? '_fade' + lodBoundaries.join('_') : '')
97
+ return material
98
+ }
99
+
100
+ // Must await texture decode before sampling: ez-tree loads bark/leaf maps async, so baking the impostor atlas in the same tick would sample undefined images -> blank atlas.
101
+ async function awaitMatTextures(mats) {
102
+ const texes = []
103
+ for (const m of mats) {
104
+ if (!m) continue
105
+ for (const k of ['map', 'normalMap', 'roughnessMap', 'aoMap', 'alphaMap', 'bumpMap']) {
106
+ const t = m[k]; if (t && t.isTexture) texes.push(t)
107
+ }
108
+ }
109
+ await Promise.all(texes.map(async (t) => {
110
+ const img = t.image
111
+ try {
112
+ if (img && typeof img.decode === 'function') { await img.decode(); t.needsUpdate = true; return }
113
+ } catch (_) {}
114
+ // fallback: poll until the image has dimensions, ~1s cap
115
+ for (let i = 0; i < 60; i++) {
116
+ if (t.image && (t.image.width > 0 || t.image.videoWidth > 0)) { t.needsUpdate = true; return }
117
+ await new Promise(r => setTimeout(r, 16))
118
+ }
119
+ }))
120
+ }
121
+
122
+ // Caps a geometry to at most maxTris triangles (meshopt); the LOD0 budget bounding the worst near-tree.
123
+ async function capGeo(geo, maxTris) {
124
+ try {
125
+ const idx = geo.index ? geo.index.array : null
126
+ if (!idx) return geo
127
+ const tris = idx.length / 3
128
+ if (tris <= maxTris) return geo
129
+ return await simplifyGeo(geo, maxTris / tris, false)
130
+ } catch (_) { return geo }
131
+ }
132
+
133
+ // Meshopt-simplifies to `ratio` of triangles. Must ALWAYS return a NEW distinct geometry object (never the source) -- @three.ez's addLevel reuses the LOD object when geometry===existing, so two LOD levels sharing one object alias one instanceIndex array and stomp each other's drawn slots (a tree vanishing in a band at the cutover).
134
+ async function simplifyGeo(geo, ratio, sloppy) {
135
+ try {
136
+ await MeshoptSimplifier.ready
137
+ const idx = geo.index ? geo.index.array : null
138
+ const pos = geo.attributes.position.array
139
+ if (!idx || !pos) return geo.clone()
140
+ const target = Math.max(12, Math.floor((idx.length / 3) * ratio) * 3)
141
+ const fn = sloppy && MeshoptSimplifier.simplifySloppy ? 'simplifySloppy' : 'simplify'
142
+ const args = sloppy ? [idx, pos, 3, target, 0.05] : [idx, pos, 3, target, 0.02, ['Sparse']]
143
+ const [newIdx] = MeshoptSimplifier[fn](...args)
144
+ const out = geo.clone()
145
+ if (newIdx && newIdx.length >= 3) out.setIndex(new THREE.BufferAttribute(newIdx, 1))
146
+ return out
147
+ } catch (_) { return geo.clone() }
148
+ }
149
+
150
+ function buildSpecies(name, Tree) {
151
+ const tree = new Tree()
152
+ tree.loadPreset(PRESET[name] || name)
153
+ const branchGeo = tree.branchesMesh.geometry
154
+ const leafGeo = tree.leavesMesh.geometry
155
+ const branchMat = tree.branchesMesh.material
156
+ const leafMat = tree.leavesMesh.material
157
+ branchMat.shadowSide = THREE.FrontSide
158
+ leafMat.alphaTest = Math.max(leafMat.alphaTest || 0, 0.5) // no-MSAA fallback (A2C needs samples)
159
+ leafMat.transparent = false
160
+ leafMat.side = THREE.DoubleSide
161
+ // Apply depth bias to mesh LOD to match impostor bias, preventing flicker at LOD boundary.
162
+ // units -8 -> -32 (2026-07-10, live A/B via GL readPixels flicker-score harness against a real
163
+ // close-range trunk, world ~3-8m from camera): -8 was proven LIVE-INSUFFICIENT -- trunk mesh vs
164
+ // mapspinner's independently-rendered terrain depth z-fight every single frame at that range
165
+ // (strict alternation between trunk color and terrain color on 18-23 of 20-24 sampled frames),
166
+ // even though occlusion/LOD-swap/per-instance-visibility were all confirmed INERT for this exact
167
+ // symptom (occludedKeys.size===0 always, instancesCount constant, getVisibilityAt always true --
168
+ // the flicker is a raw GPU depth-test tie-break flip, not a game-logic visibility toggle). A/B
169
+ // tested -20/-50/-100 all fully eliminated it (0/20 frames changed, vs 18/20 baseline); -32 keeps
170
+ // a safety margin above the smallest working value without over-biasing.
171
+ branchMat.polygonOffset = true
172
+ branchMat.polygonOffsetFactor = -4
173
+ branchMat.polygonOffsetUnits = -32
174
+ leafMat.polygonOffset = true
175
+ leafMat.polygonOffsetFactor = -4
176
+ leafMat.polygonOffsetUnits = -32
177
+ // normalize to a real-world height: scale branch+leaf by the same factor so visual size agrees with the trunk collider + LOD + impostor
178
+ branchGeo.computeBoundingBox(); leafGeo.computeBoundingBox()
179
+ const minY = Math.min(branchGeo.boundingBox.min.y, leafGeo.boundingBox.min.y)
180
+ const maxY = Math.max(branchGeo.boundingBox.max.y, leafGeo.boundingBox.max.y)
181
+ const nativeH = maxY - minY
182
+ const target = TARGET_H[name] || 9
183
+ const s = (Number.isFinite(nativeH) && nativeH > 1e-3) ? target / nativeH : 1
184
+ for (const g of [branchGeo, leafGeo]) {
185
+ g.scale(s, s, s)
186
+ g.translate(0, -minY * s, 0) // drop base to y=0 so the trunk rests on the ground
187
+ g.computeBoundingBox(); g.computeBoundingSphere()
188
+ }
189
+ const bb = branchGeo.boundingBox, lb = leafGeo.boundingBox
190
+ const width = Math.max(bb.max.x - bb.min.x, bb.max.z - bb.min.z, lb.max.x - lb.min.x, lb.max.z - lb.min.z) || target * 0.7
191
+ const dims = { width, height: target }
192
+ return { branchGeo, leafGeo, branchMat, leafMat, tree, dims }
193
+ }
194
+
195
+ // zero-area geo: the far leaf LOD swaps to this so leaf cards vanish where the impostor takes over (no double-draw)
196
+ function makeEmptyGeo() {
197
+ const g = new THREE.BufferGeometry()
198
+ g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(9), 3))
199
+ g.setIndex([0, 1, 2])
200
+ return g
201
+ }
202
+
203
+ export {
204
+ loadEzTree, makeWindUniforms, applyWind, awaitMatTextures, capGeo, simplifyGeo,
205
+ buildSpecies, makeEmptyGeo, PRESET, TARGET_H, VEG_DISSOLVE_FADE_BAND_M
206
+ }
@@ -10,180 +10,12 @@ import { createEditorEventLog } from './EditorEventLog.js'
10
10
  import { createWorldValidator } from './WorldValidator.js'
11
11
  import { createWaypointTimeline } from './WaypointTimeline.js'
12
12
  import { showToast, setSceneEntityIds } from './EditPanelDOM.js'
13
- import { fetchAssetManifest, ASSET_HOST } from './AssetManifest.js'
13
+ import { ASSET_HOST } from './AssetManifest.js'
14
14
  import { createWindowController } from './wm/WindowController.js'
15
- import { setSharedWM, promptText } from './wm/ui.js'
15
+ import { setSharedWM } from './wm/ui.js'
16
+ import { ADD_PRIMITIVES, buildAddMenuItems, buildPropCategoryItems, buildCategoryMenuItems, loadRecent, recordRecent, filterMenuItems, promptName, _ensureWmCSS, _ensureEditorResponsiveCSS, TABS, EDITOR_SHORTCUTS } from './EditorShellMenus.js'
16
17
  import { MSG } from '/src/protocol/MessageTypes.js'
17
18
 
18
- const ADD_PRIMITIVES = [
19
- { id: 'box-static', label: 'Box' },
20
- { id: 'sphere-static', label: 'Sphere' },
21
- { id: 'capsule-static', label: 'Capsule' },
22
- { id: 'cylinder-static', label: 'Cylinder' }
23
- ]
24
-
25
- function buildAddMenuItems(place, openPropSubmenu, scatterState) {
26
- const scatterLabel = scatterState && scatterState.on
27
- ? '✓ Scatter mode (drag to place many)'
28
- : 'Scatter mode (drag to place many)'
29
- return [
30
- ...(scatterState ? [{ label: scatterLabel, onSelect: () => scatterState.toggle() }] : []),
31
- { label: 'Prop...', onSelect: () => openPropSubmenu() },
32
- ...ADD_PRIMITIVES.map(p => ({ label: p.label, onSelect: () => place(p.id) }))
33
- ]
34
- }
35
-
36
- async function buildPropCategoryItems(onOpenCategory) {
37
- try {
38
- const manifest = await fetchAssetManifest()
39
- const cats = Object.keys(manifest).sort()
40
- // editor-place-menu-thumbnails: category glyph is a graceful fallback differentiator for the
41
- // category-list level (no per-category thumbnail exists in the manifest -- categories are just
42
- // string keys grouping models, see AssetManifest.js/manifest.json shape). Real per-MODEL thumb
43
- // images (manifest[cat][i].thumb, a live gh-pages-hosted PNG, confirmed present on every entry)
44
- // are wired at the model-row level in buildCategoryMenuItems below.
45
- return cats.length
46
- ? cats.map(cat => ({ label: `${_categoryGlyph(cat)} ${cat} (${(manifest[cat] || []).length})`, onSelect: () => onOpenCategory(cat, manifest[cat] || []) }))
47
- : [{ label: '(no props in catalog)', disabled: true }]
48
- } catch (e) {
49
- return [{ label: 'Catalog error: ' + e.message, disabled: true }]
50
- }
51
- }
52
-
53
- // Coarse category->glyph map (text-only fallback differentiator; the manifest has no per-category
54
- // icon/image field, only per-model `thumb`). Deliberately small and approximate -- any unmatched
55
- // category still gets the neutral default glyph rather than nothing.
56
- const _CATEGORY_GLYPHS = [
57
- [/kitchen|appliance|fridge|oven|stove|dish/i, '\u{1F373}'],
58
- [/bath|shower|toilet|sink/i, '\u{1F6BF}'],
59
- [/car|vehicle|truck|van|bus/i, '\u{1F697}'],
60
- [/tree|plant|foliage|flower|grass/i, '\u{1F333}'],
61
- [/rock|stone|boulder/i, '\u{1FAA8}'],
62
- [/chair|couch|sofa|table|desk|furniture|cabinet/i, '\u{1FA91}'],
63
- [/light|lamp/i, '\u{1F4A1}'],
64
- [/weapon|gun/i, '\u{1F52B}'],
65
- [/airport|container|industrial|barrel|dumpster/i, '\u{1F3ED}'],
66
- [/office/i, '\u{1F5C4}️']
67
- ]
68
- function _categoryGlyph(cat) {
69
- for (const [re, glyph] of _CATEGORY_GLYPHS) if (re.test(cat)) return glyph
70
- return '\u{1F4E6}' // generic package/prop glyph default
71
- }
72
-
73
- function buildCategoryMenuItems(models, onPlaceModel, onBack) {
74
- // _thumb carries the real manifest thumbnail URL (or null) through to the post-render DOM
75
- // decoration pass in openAddMenu -- ContextMenu's item shape ({label,onSelect,disabled}) has no
76
- // documented custom-render/icon hook (see openAddMenu's own comment), so the extra _thumb key
77
- // rides along unused by the kit and is read back out by label-text matching after applyDiff.
78
- const items = models.map(m => ({ label: m.name, onSelect: () => onPlaceModel(ASSET_HOST + m.path), _thumb: m.thumb ? ASSET_HOST + m.thumb : null }))
79
- return [{ label: '< Back', onSelect: onBack }, ...items]
80
- }
81
-
82
- // --- Add-menu recent-items tracking (editor-add-menu-recent) ---------------------------------
83
- // localStorage-persisted, keyed by asset url (props) or primitive kind ('box-static' etc).
84
- // Pure functions (recordRecent/loadRecent) so the list/dedupe/cap logic is exec_js-testable
85
- // independent of any DOM/menu wiring.
86
- const RECENT_KEY = 'ds-editor-add-menu-recent'
87
- const RECENT_MAX = 8
88
- function loadRecent() {
89
- try {
90
- const raw = localStorage.getItem(RECENT_KEY)
91
- const arr = raw ? JSON.parse(raw) : []
92
- return Array.isArray(arr) ? arr.filter(r => r && r.key && r.label) : []
93
- } catch (_) { return [] }
94
- }
95
- function recordRecent(entry, existing) {
96
- // entry: {key, label, kind:'primitive'|'prop', value}. Most-recent-first, deduped by key, capped at RECENT_MAX.
97
- const list = (existing || loadRecent()).filter(r => r.key !== entry.key)
98
- list.unshift(entry)
99
- const capped = list.slice(0, RECENT_MAX)
100
- try { localStorage.setItem(RECENT_KEY, JSON.stringify(capped)) } catch (_) {}
101
- return capped
102
- }
103
-
104
- // --- Add-menu substring filter (editor-add-menu-search) --------------------------------------
105
- // Pure: filters a flat item list by substring match on label, case-insensitive.
106
- function filterMenuItems(items, query) {
107
- const q = (query || '').trim().toLowerCase()
108
- if (!q) return items
109
- return items.filter(it => !it.disabled && (it.label || '').toLowerCase().includes(q))
110
- }
111
-
112
- function promptName(wm, { title, label, placeholder, initial = '' } = {}) {
113
- return promptText(wm, {
114
- title, label, placeholder, initial,
115
- validate: (raw) => {
116
- const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
117
- return name ? { ok: true, value: name } : { ok: false, error: (label || 'Name') + ' required' }
118
- }
119
- })
120
- }
121
-
122
- const TABS = ['Inspector', 'Apps', 'HookFlow', 'Events']
123
-
124
- const EDITOR_SHORTCUTS = [
125
- { combo: 'G / W', scope: 'gizmo', label: 'Translate (move) gizmo' },
126
- { combo: 'R / E', scope: 'gizmo', label: 'Rotate gizmo' },
127
- // Alt+S not bare S: bare WASDC drives the fly-camera, would collide with a plain letter shortcut.
128
- { combo: 'Alt+S', scope: 'gizmo', label: 'Scale gizmo' },
129
- { combo: 'F', scope: 'gizmo', label: 'Frame / focus selected entity' },
130
- { combo: 'Delete', scope: 'edit', label: 'Delete selected entity' },
131
- { combo: 'mod+Z', scope: 'history', label: 'Undo' },
132
- { combo: 'mod+Y', scope: 'history', label: 'Redo' },
133
- { combo: 'P', scope: 'editor', label: 'Toggle editor' },
134
- { combo: 'Alt+C', scope: 'debug', label: 'Toggle collider debug wireframe' },
135
- { combo: 'M', scope: 'nav', label: 'Open lobby' },
136
- { combo: 'X', scope: 'gizmo', label: 'Toggle snap-to-grid' },
137
- { combo: 'Y', scope: 'gizmo', label: 'Toggle gizmo space (world / local)' },
138
- { combo: 'Alt+P', scope: 'gizmo', label: 'Cycle multi-select pivot mode (active / centroid / individual)' },
139
- { combo: 'Alt+1..9', scope: 'camera', label: 'Recall camera bookmark N' },
140
- { combo: 'Ctrl+Alt+1..9', scope: 'camera', label: 'Save camera bookmark N' },
141
- { combo: 'Shift/Ctrl+click', scope: 'select', label: 'Add or remove an entity from multi-select' },
142
- { combo: 'Shift/Ctrl+drag (empty space)', scope: 'select', label: 'Marquee box-select entities in view' },
143
- { combo: 'Ctrl+drag Y-axis', scope: 'gizmo', label: 'Snap-to-surface while moving (raycasts down, grid-snap off)' },
144
- { combo: 'mod+C', scope: 'edit', label: 'Copy selected entity (transform + custom props)' },
145
- { combo: 'mod+V', scope: 'edit', label: 'Paste onto the currently-selected entity' },
146
- { combo: 'Arrow keys', scope: 'gizmo', label: 'Nudge selected entity on X/Z (grid step or 0.25)' },
147
- { combo: 'PageUp / PageDown', scope: 'gizmo', label: 'Nudge selected entity on Y' },
148
- { combo: '?', scope: 'editor', label: 'Toggle this shortcuts cheat-sheet' }
149
- ]
150
-
151
- let _wmCssInjected = false
152
- function _ensureWmCSS() {
153
- if (_wmCssInjected) return
154
- _wmCssInjected = true
155
- // Absolute server path, not import.meta.url-relative: import.meta.url of a bundled
156
- // app.js resolves to the bundle's own URL (not this source file's real location),
157
- // which would silently mis-resolve these hrefs to /wm/*.css instead of
158
- // /editor/wm/*.css once client/app.js is bundled by scripts/bundle-client.mjs. The
159
- // editor/ directory is a fixed, server-mounted path (client/editor/wm/*.css), so an
160
- // absolute reference is both bundling-safe and simpler than a relative one.
161
- for (const href of ['/editor/wm/os-token-bridge.css', '/editor/wm/wm.css']) {
162
- const l = document.createElement('link')
163
- l.rel = 'stylesheet'
164
- l.href = href
165
- document.head.appendChild(l)
166
- }
167
- }
168
-
169
- let _editorRespInjected = false
170
- function _ensureEditorResponsiveCSS() {
171
- if (_editorRespInjected) return
172
- _editorRespInjected = true
173
- const style = document.createElement('style')
174
- style.id = 'ds-editor-responsive'
175
- style.textContent = [
176
- '.ep-overlay .app-main{padding:0!important}',
177
- '.ep-overlay .app,.ep-overlay .app-shell{height:100%}',
178
- '.ep-overlay .app-main>*{flex:1;min-height:0}',
179
- '.ep-overlay .ds-ep-toolbar{flex-wrap:wrap;row-gap:4px;column-gap:6px}',
180
- '@media (pointer:coarse){.ep-overlay .ds-ep-tab,.ep-overlay .ds-ep-toolbar button,.ep-overlay .wm-btn,.ep-overlay .ds-ep-tree-row{min-height:44px}}',
181
- '.ds-ep-history-row:hover{background:var(--panel-2,rgba(255,255,255,0.06))}',
182
- '.ds-ep-history-row.current:hover{background:var(--accent-bg,rgba(80,160,255,0.24))}'
183
- ].join('\n')
184
- document.head.appendChild(style)
185
- }
186
-
187
19
  export function createEditPanel({ onPlace, onPlaceModel, onSave, onSaveWorld, onListWorlds, onGizmoModeChange, onGizmoSpaceChange, onPivotModeChange, onEntitySelect, onGetSource, onGetAppFiles, onDestroyEntity, onCreateApp, onSnapChange, onEventLogQuery, onReparent, onRename, onDuplicate, onLockChange, onHiddenChange, onScatterArm, onAlign, onDistribute, onGroup, isSingleplayer, onFsListTree, onFsGetSource, onFsSave, onFsMkdir, onFsDelete, onFsRename, onJumpToHistory, onAddWaypoint, onReorderWaypoints, onToggleMinimapOverlay, onWireCreate, floatingOrigin, onEdgeRemove, onPlaceBatch, onPlaytestStart, onPlaytestStop, onCommandPalette, onDebugModeChange, onOpenP2PRoom, onOpenFreddieChat } = {}) {
188
20
  const overlay = document.createElement('div')
189
21
  overlay.className = 'ds-247420 ep-overlay'
@@ -0,0 +1,183 @@
1
+ // Add-menu / prop-category / recent-items / shortcuts-cheatsheet helpers for EditorShell.js's
2
+ // createEditPanel: stateless (module-level RECENT_KEY/RECENT_MAX localStorage cache aside) menu-item
3
+ // builders, name-prompt validation, and one-time CSS injection. Split out as EditorShell.js's largest
4
+ // self-contained block -- none of these touch createEditPanel's own closure state, only their own
5
+ // params/module-level caches/constants.
6
+
7
+ import { ASSET_HOST, fetchAssetManifest } from './AssetManifest.js'
8
+ import { promptText } from './wm/ui.js'
9
+
10
+ const ADD_PRIMITIVES = [
11
+ { id: 'box-static', label: 'Box' },
12
+ { id: 'sphere-static', label: 'Sphere' },
13
+ { id: 'capsule-static', label: 'Capsule' },
14
+ { id: 'cylinder-static', label: 'Cylinder' }
15
+ ]
16
+
17
+ function buildAddMenuItems(place, openPropSubmenu, scatterState) {
18
+ const scatterLabel = scatterState && scatterState.on
19
+ ? '✓ Scatter mode (drag to place many)'
20
+ : 'Scatter mode (drag to place many)'
21
+ return [
22
+ ...(scatterState ? [{ label: scatterLabel, onSelect: () => scatterState.toggle() }] : []),
23
+ { label: 'Prop...', onSelect: () => openPropSubmenu() },
24
+ ...ADD_PRIMITIVES.map(p => ({ label: p.label, onSelect: () => place(p.id) }))
25
+ ]
26
+ }
27
+
28
+ async function buildPropCategoryItems(onOpenCategory) {
29
+ try {
30
+ const manifest = await fetchAssetManifest()
31
+ const cats = Object.keys(manifest).sort()
32
+ // editor-place-menu-thumbnails: category glyph is a graceful fallback differentiator for the
33
+ // category-list level (no per-category thumbnail exists in the manifest -- categories are just
34
+ // string keys grouping models, see AssetManifest.js/manifest.json shape). Real per-MODEL thumb
35
+ // images (manifest[cat][i].thumb, a live gh-pages-hosted PNG, confirmed present on every entry)
36
+ // are wired at the model-row level in buildCategoryMenuItems below.
37
+ return cats.length
38
+ ? cats.map(cat => ({ label: `${_categoryGlyph(cat)} ${cat} (${(manifest[cat] || []).length})`, onSelect: () => onOpenCategory(cat, manifest[cat] || []) }))
39
+ : [{ label: '(no props in catalog)', disabled: true }]
40
+ } catch (e) {
41
+ return [{ label: 'Catalog error: ' + e.message, disabled: true }]
42
+ }
43
+ }
44
+
45
+ // Coarse category->glyph map (text-only fallback differentiator; the manifest has no per-category
46
+ // icon/image field, only per-model `thumb`). Deliberately small and approximate -- any unmatched
47
+ // category still gets the neutral default glyph rather than nothing.
48
+ const _CATEGORY_GLYPHS = [
49
+ [/kitchen|appliance|fridge|oven|stove|dish/i, '\u{1F373}'],
50
+ [/bath|shower|toilet|sink/i, '\u{1F6BF}'],
51
+ [/car|vehicle|truck|van|bus/i, '\u{1F697}'],
52
+ [/tree|plant|foliage|flower|grass/i, '\u{1F333}'],
53
+ [/rock|stone|boulder/i, '\u{1FAA8}'],
54
+ [/chair|couch|sofa|table|desk|furniture|cabinet/i, '\u{1FA91}'],
55
+ [/light|lamp/i, '\u{1F4A1}'],
56
+ [/weapon|gun/i, '\u{1F52B}'],
57
+ [/airport|container|industrial|barrel|dumpster/i, '\u{1F3ED}'],
58
+ [/office/i, '\u{1F5C4}️']
59
+ ]
60
+ function _categoryGlyph(cat) {
61
+ for (const [re, glyph] of _CATEGORY_GLYPHS) if (re.test(cat)) return glyph
62
+ return '\u{1F4E6}' // generic package/prop glyph default
63
+ }
64
+
65
+ function buildCategoryMenuItems(models, onPlaceModel, onBack) {
66
+ // _thumb carries the real manifest thumbnail URL (or null) through to the post-render DOM
67
+ // decoration pass in openAddMenu -- ContextMenu's item shape ({label,onSelect,disabled}) has no
68
+ // documented custom-render/icon hook (see openAddMenu's own comment), so the extra _thumb key
69
+ // rides along unused by the kit and is read back out by label-text matching after applyDiff.
70
+ const items = models.map(m => ({ label: m.name, onSelect: () => onPlaceModel(ASSET_HOST + m.path), _thumb: m.thumb ? ASSET_HOST + m.thumb : null }))
71
+ return [{ label: '< Back', onSelect: onBack }, ...items]
72
+ }
73
+
74
+ // --- Add-menu recent-items tracking (editor-add-menu-recent) ---------------------------------
75
+ // localStorage-persisted, keyed by asset url (props) or primitive kind ('box-static' etc).
76
+ // Pure functions (recordRecent/loadRecent) so the list/dedupe/cap logic is exec_js-testable
77
+ // independent of any DOM/menu wiring.
78
+ const RECENT_KEY = 'ds-editor-add-menu-recent'
79
+ const RECENT_MAX = 8
80
+ function loadRecent() {
81
+ try {
82
+ const raw = localStorage.getItem(RECENT_KEY)
83
+ const arr = raw ? JSON.parse(raw) : []
84
+ return Array.isArray(arr) ? arr.filter(r => r && r.key && r.label) : []
85
+ } catch (_) { return [] }
86
+ }
87
+ function recordRecent(entry, existing) {
88
+ // entry: {key, label, kind:'primitive'|'prop', value}. Most-recent-first, deduped by key, capped at RECENT_MAX.
89
+ const list = (existing || loadRecent()).filter(r => r.key !== entry.key)
90
+ list.unshift(entry)
91
+ const capped = list.slice(0, RECENT_MAX)
92
+ try { localStorage.setItem(RECENT_KEY, JSON.stringify(capped)) } catch (_) {}
93
+ return capped
94
+ }
95
+
96
+ // --- Add-menu substring filter (editor-add-menu-search) --------------------------------------
97
+ // Pure: filters a flat item list by substring match on label, case-insensitive.
98
+ function filterMenuItems(items, query) {
99
+ const q = (query || '').trim().toLowerCase()
100
+ if (!q) return items
101
+ return items.filter(it => !it.disabled && (it.label || '').toLowerCase().includes(q))
102
+ }
103
+
104
+ function promptName(wm, { title, label, placeholder, initial = '' } = {}) {
105
+ return promptText(wm, {
106
+ title, label, placeholder, initial,
107
+ validate: (raw) => {
108
+ const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
109
+ return name ? { ok: true, value: name } : { ok: false, error: (label || 'Name') + ' required' }
110
+ }
111
+ })
112
+ }
113
+
114
+ const TABS = ['Inspector', 'Apps', 'HookFlow', 'Events']
115
+
116
+ const EDITOR_SHORTCUTS = [
117
+ { combo: 'G / W', scope: 'gizmo', label: 'Translate (move) gizmo' },
118
+ { combo: 'R / E', scope: 'gizmo', label: 'Rotate gizmo' },
119
+ // Alt+S not bare S: bare WASDC drives the fly-camera, would collide with a plain letter shortcut.
120
+ { combo: 'Alt+S', scope: 'gizmo', label: 'Scale gizmo' },
121
+ { combo: 'F', scope: 'gizmo', label: 'Frame / focus selected entity' },
122
+ { combo: 'Delete', scope: 'edit', label: 'Delete selected entity' },
123
+ { combo: 'mod+Z', scope: 'history', label: 'Undo' },
124
+ { combo: 'mod+Y', scope: 'history', label: 'Redo' },
125
+ { combo: 'P', scope: 'editor', label: 'Toggle editor' },
126
+ { combo: 'Alt+C', scope: 'debug', label: 'Toggle collider debug wireframe' },
127
+ { combo: 'M', scope: 'nav', label: 'Open lobby' },
128
+ { combo: 'X', scope: 'gizmo', label: 'Toggle snap-to-grid' },
129
+ { combo: 'Y', scope: 'gizmo', label: 'Toggle gizmo space (world / local)' },
130
+ { combo: 'Alt+P', scope: 'gizmo', label: 'Cycle multi-select pivot mode (active / centroid / individual)' },
131
+ { combo: 'Alt+1..9', scope: 'camera', label: 'Recall camera bookmark N' },
132
+ { combo: 'Ctrl+Alt+1..9', scope: 'camera', label: 'Save camera bookmark N' },
133
+ { combo: 'Shift/Ctrl+click', scope: 'select', label: 'Add or remove an entity from multi-select' },
134
+ { combo: 'Shift/Ctrl+drag (empty space)', scope: 'select', label: 'Marquee box-select entities in view' },
135
+ { combo: 'Ctrl+drag Y-axis', scope: 'gizmo', label: 'Snap-to-surface while moving (raycasts down, grid-snap off)' },
136
+ { combo: 'mod+C', scope: 'edit', label: 'Copy selected entity (transform + custom props)' },
137
+ { combo: 'mod+V', scope: 'edit', label: 'Paste onto the currently-selected entity' },
138
+ { combo: 'Arrow keys', scope: 'gizmo', label: 'Nudge selected entity on X/Z (grid step or 0.25)' },
139
+ { combo: 'PageUp / PageDown', scope: 'gizmo', label: 'Nudge selected entity on Y' },
140
+ { combo: '?', scope: 'editor', label: 'Toggle this shortcuts cheat-sheet' }
141
+ ]
142
+
143
+ let _wmCssInjected = false
144
+ function _ensureWmCSS() {
145
+ if (_wmCssInjected) return
146
+ _wmCssInjected = true
147
+ // Absolute server path, not import.meta.url-relative: import.meta.url of a bundled
148
+ // app.js resolves to the bundle's own URL (not this source file's real location),
149
+ // which would silently mis-resolve these hrefs to /wm/*.css instead of
150
+ // /editor/wm/*.css once client/app.js is bundled by scripts/bundle-client.mjs. The
151
+ // editor/ directory is a fixed, server-mounted path (client/editor/wm/*.css), so an
152
+ // absolute reference is both bundling-safe and simpler than a relative one.
153
+ for (const href of ['/editor/wm/os-token-bridge.css', '/editor/wm/wm.css']) {
154
+ const l = document.createElement('link')
155
+ l.rel = 'stylesheet'
156
+ l.href = href
157
+ document.head.appendChild(l)
158
+ }
159
+ }
160
+
161
+ let _editorRespInjected = false
162
+ function _ensureEditorResponsiveCSS() {
163
+ if (_editorRespInjected) return
164
+ _editorRespInjected = true
165
+ const style = document.createElement('style')
166
+ style.id = 'ds-editor-responsive'
167
+ style.textContent = [
168
+ '.ep-overlay .app-main{padding:0!important}',
169
+ '.ep-overlay .app,.ep-overlay .app-shell{height:100%}',
170
+ '.ep-overlay .app-main>*{flex:1;min-height:0}',
171
+ '.ep-overlay .ds-ep-toolbar{flex-wrap:wrap;row-gap:4px;column-gap:6px}',
172
+ '@media (pointer:coarse){.ep-overlay .ds-ep-tab,.ep-overlay .ds-ep-toolbar button,.ep-overlay .wm-btn,.ep-overlay .ds-ep-tree-row{min-height:44px}}',
173
+ '.ds-ep-history-row:hover{background:var(--panel-2,rgba(255,255,255,0.06))}',
174
+ '.ds-ep-history-row.current:hover{background:var(--accent-bg,rgba(80,160,255,0.24))}'
175
+ ].join('\n')
176
+ document.head.appendChild(style)
177
+ }
178
+
179
+ export {
180
+ ADD_PRIMITIVES, buildAddMenuItems, buildPropCategoryItems, buildCategoryMenuItems,
181
+ loadRecent, recordRecent, filterMenuItems, promptName,
182
+ _ensureWmCSS, _ensureEditorResponsiveCSS, TABS, EDITOR_SHORTCUTS
183
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.661",
3
+ "version": "0.1.663",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [