spoint 0.1.638 → 0.1.640

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.
@@ -268,28 +268,38 @@ export function createCameraController(camera, scene) {
268
268
  inputPitchDelta *= decay
269
269
  }
270
270
 
271
+ // must apply+decay look deltas here: the gameplay branch in update() is unreachable in edit mode (early return), so this is the only place the fly-cam rotates
272
+ function updateEditFlyCam(frameDt, inputState) {
273
+ applyLookInput(frameDt)
274
+ const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch)
275
+ const fwd = (inputState.forward?1:0)-(inputState.backward?1:0)
276
+ const strafe = (inputState.right?1:0)-(inputState.left?1:0)
277
+ const up = (inputState.jump?1:0)-(inputState.crouch?1:0)
278
+ const moving = (fwd || strafe || up) ? 1 : 0
279
+ editBoost = moving ? Math.min(editBoostMax, editBoost + editBoostRate * frameDt) : 1
280
+ const altMul = _editAltitudeSpeedMul(editCamPos.x, editCamPos.y, editCamPos.z)
281
+ const maxV = editCamSpeed * editBoost * altMul
282
+ const wishX = (fwd*sy + strafe*(-cy)) * maxV, wishY = up * maxV, wishZ = (fwd*cy + strafe*sy) * maxV
283
+ const aT = 1 - Math.exp(-editAccelHz * frameDt)
284
+ editVel.x += (wishX - editVel.x) * aT; editVel.y += (wishY - editVel.y) * aT; editVel.z += (wishZ - editVel.z) * aT
285
+ editCamPos.x += editVel.x * frameDt; editCamPos.y += editVel.y * frameDt; editCamPos.z += editVel.z * frameDt
286
+ // must scale horizontal look by cos(pitch), or a steep downward pitch still aims level (fly-cam looks into empty sky)
287
+ camera.position.copy(editCamPos); camera.lookAt(editCamPos.x + sy*cp*100, editCamPos.y + sp*100, editCamPos.z + cy*cp*100)
288
+ }
289
+
290
+ // head hidden only while camera is inside the player head (dist<0.01), restored the moment it zooms out
291
+ function updateHeadBoneVisibility(inHead) {
292
+ if (!headBone || inHead === headBoneHidden) return
293
+ if (inHead) { headBone.scale.set(0, 0, 0); headBone.position.y -= fpsHeadDownOffset }
294
+ else { headBone.scale.set(1, 1, 1); headBone.position.y += fpsHeadDownOffset }
295
+ headBoneHidden = inHead
296
+ if (_onCameraInHead) try { _onCameraInHead(inHead) } catch (_) {}
297
+ }
298
+
271
299
  function update(localPlayer, localMesh, frameDt, inputState) {
272
300
  if (mode === 'custom' || mode === 'fixed') return
273
301
  if (!localPlayer && !editMode) return
274
- if (editMode && inputState) {
275
- // must apply+decay look deltas here: the gameplay branch below is unreachable in edit mode (early return), so this is the only place the fly-cam rotates
276
- applyLookInput(frameDt)
277
- const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch)
278
- const fwd = (inputState.forward?1:0)-(inputState.backward?1:0)
279
- const strafe = (inputState.right?1:0)-(inputState.left?1:0)
280
- const up = (inputState.jump?1:0)-(inputState.crouch?1:0)
281
- const moving = (fwd || strafe || up) ? 1 : 0
282
- editBoost = moving ? Math.min(editBoostMax, editBoost + editBoostRate * frameDt) : 1
283
- const altMul = _editAltitudeSpeedMul(editCamPos.x, editCamPos.y, editCamPos.z)
284
- const maxV = editCamSpeed * editBoost * altMul
285
- const wishX = (fwd*sy + strafe*(-cy)) * maxV, wishY = up * maxV, wishZ = (fwd*cy + strafe*sy) * maxV
286
- const aT = 1 - Math.exp(-editAccelHz * frameDt)
287
- editVel.x += (wishX - editVel.x) * aT; editVel.y += (wishY - editVel.y) * aT; editVel.z += (wishZ - editVel.z) * aT
288
- editCamPos.x += editVel.x * frameDt; editCamPos.y += editVel.y * frameDt; editCamPos.z += editVel.z * frameDt
289
- // must scale horizontal look by cos(pitch), or a steep downward pitch still aims level (fly-cam looks into empty sky)
290
- camera.position.copy(editCamPos); camera.lookAt(editCamPos.x + sy*cp*100, editCamPos.y + sp*100, editCamPos.z + cy*cp*100)
291
- return
292
- }
302
+ if (editMode && inputState) { updateEditFlyCam(frameDt, inputState); return }
293
303
  if (localMesh) camTarget.set(localMesh.position.x, localMesh.position.y + headHeight, localMesh.position.z)
294
304
  else camTarget.set(localPlayer.position[0], localPlayer.position[1] + headHeight, localPlayer.position[2])
295
305
  applyLookInput(frameDt)
@@ -300,14 +310,7 @@ export function createCameraController(camera, scene) {
300
310
  const sy = Math.sin(yaw), cy = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch)
301
311
  const fwdX = sy*cp, fwdY = sp, fwdZ = cy*cp
302
312
  const dist = mode === 'fps' ? 0 : zoomStages[zoomIndex]
303
- // head hidden only while camera is inside the player head (dist<0.01), restored the moment it zooms out
304
- const inHead = dist < 0.01
305
- if (headBone && inHead !== headBoneHidden) {
306
- if (inHead) { headBone.scale.set(0, 0, 0); headBone.position.y -= fpsHeadDownOffset }
307
- else { headBone.scale.set(1, 1, 1); headBone.position.y += fpsHeadDownOffset }
308
- headBoneHidden = inHead
309
- if (_onCameraInHead) try { _onCameraInHead(inHead) } catch (_) {}
310
- }
313
+ updateHeadBoneVisibility(dist < 0.01)
311
314
  if (dist < 0.01) updateFPS(localMesh, frameDt, fwdX, fwdY, fwdZ)
312
315
  else updateTPS(dist, localMesh, frameDt, fwdX, fwdY, fwdZ, -cy, sy)
313
316
  }
@@ -353,6 +356,30 @@ export function createCameraController(camera, scene) {
353
356
  return len > 0.001 ? [dx/len, dy/len, dz/len] : [fwdX, fwdY, fwdZ]
354
357
  }
355
358
 
359
+ // On entering edit mode the fly-cam takes over (driven by update()'s editMode
360
+ // branch). CAPTURE the gameplay camera orientation first so it can be restored
361
+ // on exit -- yaw/pitch/zoomIndex are SHARED with the fly-cam (the editMode
362
+ // branch mutates yaw/pitch), so without this the gameplay camera returns to the
363
+ // editor's last orientation, not its pre-edit follow pose (the "camera did not
364
+ // return to the correct location" defect).
365
+ function setEditMode(enabled, localMesh) {
366
+ if (enabled && !editMode) {
367
+ _gameplayCam = { yaw, pitch, zoomIndex }
368
+ // seed the fly-cam offset back-and-up from the player (not glued to the head-height spot, which could stare into a wall)
369
+ const px = localMesh ? localMesh.position.x : camera.position.x
370
+ const py = localMesh ? localMesh.position.y : camera.position.y
371
+ const pz = localMesh ? localMesh.position.z : camera.position.z
372
+ const sy = Math.sin(yaw), cy = Math.cos(yaw)
373
+ editCamPos.set(px - sy * 8, py + 5, pz - cy * 8)
374
+ pitch = -0.35
375
+ editVel.set(0, 0, 0); editBoost = 1
376
+ } else if (!enabled && editMode && _gameplayCam) {
377
+ yaw = _gameplayCam.yaw; pitch = _gameplayCam.pitch; zoomIndex = _gameplayCam.zoomIndex
378
+ _gameplayCam = null
379
+ }
380
+ editMode = enabled
381
+ }
382
+
356
383
  return {
357
384
  update, applyConfig, getAimDirection, setMode, getMode: () => mode,
358
385
  setEnvironment: meshes => { envMeshes.length = 0; envMeshes.push(...meshes); _bvhDirty = true },
@@ -385,29 +412,7 @@ export function createCameraController(camera, scene) {
385
412
  setVRYaw: v => { yaw = v }, getVRYaw: () => yaw,
386
413
  setVRPitch: v => { pitch = v }, getVRPitch: () => pitch,
387
414
  adjustVRPitch: delta => { pitch = Math.max(pitchMin, Math.min(pitchMax, pitch + delta)) },
388
- setEditMode: (enabled, localMesh) => {
389
- // On entering edit mode the fly-cam takes over (driven by update()'s editMode
390
- // branch). CAPTURE the gameplay camera orientation first so it can be restored
391
- // on exit -- yaw/pitch/zoomIndex are SHARED with the fly-cam (the editMode
392
- // branch mutates yaw/pitch), so without this the gameplay camera returns to the
393
- // editor's last orientation, not its pre-edit follow pose (the "camera did not
394
- // return to the correct location" defect).
395
- if (enabled && !editMode) {
396
- _gameplayCam = { yaw, pitch, zoomIndex }
397
- // seed the fly-cam offset back-and-up from the player (not glued to the head-height spot, which could stare into a wall)
398
- const px = localMesh ? localMesh.position.x : camera.position.x
399
- const py = localMesh ? localMesh.position.y : camera.position.y
400
- const pz = localMesh ? localMesh.position.z : camera.position.z
401
- const sy = Math.sin(yaw), cy = Math.cos(yaw)
402
- editCamPos.set(px - sy * 8, py + 5, pz - cy * 8)
403
- pitch = -0.35
404
- editVel.set(0, 0, 0); editBoost = 1
405
- } else if (!enabled && editMode && _gameplayCam) {
406
- yaw = _gameplayCam.yaw; pitch = _gameplayCam.pitch; zoomIndex = _gameplayCam.zoomIndex
407
- _gameplayCam = null
408
- }
409
- editMode = enabled
410
- },
415
+ setEditMode,
411
416
  getEditMode: () => editMode,
412
417
  getEditCameraPosition: () => editCamPos,
413
418
  setEditCameraPosition: (x, y, z) => { editCamPos.set(x, y, z); editVel.set(0, 0, 0); editBoost = 1; _lastAltSampleX = Infinity; _lastAltSampleZ = Infinity },
@@ -30,6 +30,70 @@ function attachContextMenu(el, getItems) {
30
30
  return C.useContextMenu(el, null, ({ x, y }) => openContextMenu(x, y, getItems()))
31
31
  }
32
32
 
33
+ // Shared node classification, reused by both the tree-row icon glyph and the search `type:` scope filter.
34
+ function classifyNode(node) {
35
+ if (node.model) return 'model'
36
+ if (node.custom && node.custom.mesh) return 'primitive'
37
+ if (node._appName || node.appName) return 'app'
38
+ return 'other'
39
+ }
40
+ const _typeGlyph = { model: 'M', primitive: 'P', app: 'A', other: 'o' }
41
+
42
+ function nodeMatchesQuery(node, q) {
43
+ const id = (node.id || '').toLowerCase(), app = (node._appName || node.appName || node.label || '').toLowerCase()
44
+ let typeFilter = null
45
+ const m = /^type:(\w+)\s*/.exec(q)
46
+ if (m) { typeFilter = m[1]; q = q.slice(m[0].length) }
47
+ if (typeFilter && classifyNode(node) !== typeFilter) return false
48
+ if (!q) return true
49
+ return id.includes(q) || app.includes(q)
50
+ }
51
+ function subtreeMatchesQuery(node, q) {
52
+ if (!q) return true
53
+ if (nodeMatchesQuery(node, q)) return true
54
+ return (node.children || []).some(child => subtreeMatchesQuery(child, q))
55
+ }
56
+ function flattenTree(nodes, depth, q, seen, expanded, out) {
57
+ for (const node of nodes || []) {
58
+ if (q && !subtreeMatchesQuery(node, q)) continue
59
+ if (!seen.has(node.id)) { seen.add(node.id); expanded.add(node.id) }
60
+ const kids = node.children || []
61
+ const hasKids = kids.length > 0
62
+ const isExpanded = q ? true : expanded.has(node.id)
63
+ out.push({ node, depth, hasKids, expanded: isExpanded })
64
+ if (hasKids && isExpanded) flattenTree(kids, depth + 1, q, seen, expanded, out)
65
+ }
66
+ return out
67
+ }
68
+
69
+ // Drop an id from a batch drag if it is an ancestor of another id in the same batch -- reparenting an ancestor
70
+ // under its own descendant target (or moving it while the descendant also moves) is structurally impossible.
71
+ function isAncestorOf(parentOf, candidateId, descendantId) {
72
+ let p = parentOf.get(descendantId)
73
+ while (p != null) { if (p === candidateId) return true; p = parentOf.get(p) }
74
+ return false
75
+ }
76
+ function dedupeNonAncestors(parentOf, ids) {
77
+ if (ids.length < 2) return ids
78
+ return ids.filter(id => !ids.some(other => other !== id && isAncestorOf(parentOf, id, other)))
79
+ }
80
+
81
+ function indexParents(nodes, parentId, parentOf) {
82
+ for (const n of nodes || []) { parentOf.set(n.id, parentId); indexParents(n.children, n.id, parentOf) }
83
+ }
84
+
85
+ // Above VIRTUALIZE_THRESHOLD, only viewport+overscan rows render as real TreeItems; the rest become 2 sized spacer divs.
86
+ function sliceVirtualWindow(rows, scrollTop, viewportH, rowHeight, overscan) {
87
+ const visibleCount = Math.max(1, Math.ceil((viewportH || 400) / rowHeight))
88
+ const startIdx = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
89
+ const endIdx = Math.min(rows.length, startIdx + visibleCount + overscan * 2)
90
+ return {
91
+ spacerTop: startIdx * rowHeight,
92
+ spacerBottom: (rows.length - endIdx) * rowHeight,
93
+ renderedRows: rows.slice(startIdx, endIdx)
94
+ }
95
+ }
96
+
33
97
  export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, onDuplicate, onRename, onReparent, onLockChange, onHiddenChange } = {}) {
34
98
  let _ents = [], _q = '', _sel = null
35
99
  const _expanded = new Set()
@@ -42,42 +106,6 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
42
106
  const _locked = new Set()
43
107
  const _hiddenInEditor = new Set()
44
108
 
45
- // Shared node classification, reused by both the tree-row icon glyph and the search `type:` scope filter.
46
- function classify(node) {
47
- if (node.model) return 'model'
48
- if (node.custom && node.custom.mesh) return 'primitive'
49
- if (node._appName || node.appName) return 'app'
50
- return 'other'
51
- }
52
- const _typeGlyph = { model: 'M', primitive: 'P', app: 'A', other: 'o' }
53
-
54
- function matches(node) {
55
- const id = (node.id || '').toLowerCase(), app = (node._appName || node.appName || node.label || '').toLowerCase()
56
- let q = _q, typeFilter = null
57
- const m = /^type:(\w+)\s*/.exec(q)
58
- if (m) { typeFilter = m[1]; q = q.slice(m[0].length) }
59
- if (typeFilter && classify(node) !== typeFilter) return false
60
- if (!q) return true
61
- return id.includes(q) || app.includes(q)
62
- }
63
- function subtreeMatches(node) {
64
- if (!_q) return true
65
- if (matches(node)) return true
66
- return (node.children || []).some(subtreeMatches)
67
- }
68
- function flatten(nodes, depth, out) {
69
- for (const node of nodes || []) {
70
- if (_q && !subtreeMatches(node)) continue
71
- if (!_seen.has(node.id)) { _seen.add(node.id); _expanded.add(node.id) }
72
- const kids = node.children || []
73
- const hasKids = kids.length > 0
74
- const expanded = _q ? true : _expanded.has(node.id)
75
- out.push({ node, depth, hasKids, expanded })
76
- if (hasKids && expanded) flatten(kids, depth + 1, out)
77
- }
78
- return out
79
- }
80
-
81
109
  container.classList.add('ds-ep-panel')
82
110
  container.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0'
83
111
 
@@ -95,18 +123,6 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
95
123
  return all.has(node.id) && all.size > 1 ? [...all] : [node.id]
96
124
  }
97
125
 
98
- // Drop an id from a batch drag if it is an ancestor of another id in the same batch -- reparenting an ancestor
99
- // under its own descendant target (or moving it while the descendant also moves) is structurally impossible.
100
- function _isAncestorOf(candidateId, descendantId) {
101
- let p = _parentOf.get(descendantId)
102
- while (p != null) { if (p === candidateId) return true; p = _parentOf.get(p) }
103
- return false
104
- }
105
- function _dedupeNonAncestors(ids) {
106
- if (ids.length < 2) return ids
107
- return ids.filter(id => !ids.some(other => other !== id && _isAncestorOf(id, other)))
108
- }
109
-
110
126
  function nodeMenuItems(node) {
111
127
  const hasParent = !!_parentOf.get(node.id)
112
128
  const ids = _bulkIds(node)
@@ -144,6 +160,72 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
144
160
  ]
145
161
  }
146
162
 
163
+ function attachRowKebab(el, node) {
164
+ if (el.querySelector('[data-kebab]')) return
165
+ const kebab = document.createElement('button')
166
+ kebab.setAttribute('data-kebab', '')
167
+ kebab.textContent = '...'
168
+ kebab.title = 'More actions'
169
+ kebab.style.cssText = 'position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:none;color:rgba(255,255,255,0.4);cursor:pointer;font:11px monospace;padding:2px 6px;opacity:0;transition:opacity 0.1s'
170
+ kebab.addEventListener('click', (e) => {
171
+ e.preventDefault(); e.stopPropagation()
172
+ const rect = kebab.getBoundingClientRect()
173
+ openContextMenu(rect.left, rect.bottom, nodeMenuItems(node))
174
+ })
175
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
176
+ el.appendChild(kebab)
177
+ el.addEventListener('mouseenter', () => { kebab.style.opacity = '1' })
178
+ el.addEventListener('mouseleave', () => { kebab.style.opacity = '0' })
179
+ }
180
+
181
+ // Top/bottom third of a row = sibling-of-target reparent; middle third = child. children is an
182
+ // unordered Set server-side, so this only picks the parent, not sibling order.
183
+ function attachRowDragDrop(el, node, id) {
184
+ // If this row is part of the current multi-selection, drag the whole set as one batch; otherwise single-id drag (existing behavior).
185
+ const _dragIds = _bulkIds(node)
186
+ const drag = C.useDraggable(el, { data: _dragIds.length > 1 ? { id, ids: _dragIds } : { id }, kind: 'scene-node' })
187
+ let _indicator = null
188
+ const _clearIndicator = () => { if (_indicator) { _indicator.remove(); _indicator = null } }
189
+ const _showIndicator = (edge) => {
190
+ _clearIndicator()
191
+ _indicator = document.createElement('div')
192
+ _indicator.className = 'ds-ep-tree-drop-indicator'
193
+ _indicator.style.cssText = `position:absolute;left:0;right:0;height:2px;background:var(--accent,#4af);pointer-events:none;z-index:10;${edge === 'top' ? 'top:-1px' : 'bottom:-1px'}`
194
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
195
+ el.appendChild(_indicator)
196
+ }
197
+ const drop = C.useDropTarget(el, {
198
+ accepts: ['scene-node'],
199
+ onDragOver: (payload) => {
200
+ const clientY = payload?.pointerEvent?.clientY
201
+ if (clientY == null) { _clearIndicator(); return }
202
+ const r = el.getBoundingClientRect()
203
+ const frac = (clientY - r.top) / r.height
204
+ if (frac < 0.25) _showIndicator('top')
205
+ else if (frac > 0.75) _showIndicator('bottom')
206
+ else _clearIndicator()
207
+ },
208
+ onDragLeave: _clearIndicator,
209
+ onDrop: ({ data, pointerEvent }) => {
210
+ _clearIndicator()
211
+ if (!data?.id) return
212
+ const dragIds = dedupeNonAncestors(_parentOf, data.ids && data.ids.length > 1 ? data.ids : [data.id])
213
+ if (dragIds.includes(id)) return
214
+ const r = el.getBoundingClientRect()
215
+ const frac = pointerEvent ? (pointerEvent.clientY - r.top) / r.height : 0.5
216
+ if (frac < 0.25 || frac > 0.75) {
217
+ const siblingParent = _parentOf.get(id) || null
218
+ dragIds.forEach(dragId => onReparent(dragId, siblingParent))
219
+ showToast((dragIds.length > 1 ? `Moved ${dragIds.length} entities` : 'Moved ' + dragIds[0]) + ' to ' + (siblingParent ? 'be a sibling of ' + id : 'root'))
220
+ } else {
221
+ dragIds.forEach(dragId => onReparent(dragId, id))
222
+ showToast((dragIds.length > 1 ? `Reparented ${dragIds.length} entities` : 'Reparented ' + dragIds[0]) + ' -> ' + id)
223
+ }
224
+ }
225
+ })
226
+ _menuTeardowns.push(() => { drag.destroy(); drop.destroy(); _clearIndicator() })
227
+ }
228
+
147
229
  function attachRowBehaviors(rows) {
148
230
  clearItemMenus()
149
231
  const els = container.querySelectorAll('.ds-ep-tree-item[data-eid]')
@@ -160,68 +242,8 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
160
242
  // (immune to vdom node replacement between mousedown and click).
161
243
  }
162
244
  _menuTeardowns.push(attachContextMenu(el, () => nodeMenuItems(node)))
163
- if (!el.querySelector('[data-kebab]')) {
164
- const kebab = document.createElement('button')
165
- kebab.setAttribute('data-kebab', '')
166
- kebab.textContent = '...'
167
- kebab.title = 'More actions'
168
- kebab.style.cssText = 'position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:none;color:rgba(255,255,255,0.4);cursor:pointer;font:11px monospace;padding:2px 6px;opacity:0;transition:opacity 0.1s'
169
- kebab.addEventListener('click', (e) => {
170
- e.preventDefault(); e.stopPropagation()
171
- const rect = kebab.getBoundingClientRect()
172
- openContextMenu(rect.left, rect.bottom, nodeMenuItems(node))
173
- })
174
- if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
175
- el.appendChild(kebab)
176
- el.addEventListener('mouseenter', () => { kebab.style.opacity = '1' })
177
- el.addEventListener('mouseleave', () => { kebab.style.opacity = '0' })
178
- }
179
- if (onReparent && typeof C.useDraggable === 'function') {
180
- // If this row is part of the current multi-selection, drag the whole set as one batch; otherwise single-id drag (existing behavior).
181
- const _dragIds = _bulkIds(node)
182
- const drag = C.useDraggable(el, { data: _dragIds.length > 1 ? { id, ids: _dragIds } : { id }, kind: 'scene-node' })
183
- // Top/bottom third of a row = sibling-of-target reparent; middle third = child. children is an unordered Set server-side, so this only picks the parent, not sibling order.
184
- let _indicator = null
185
- const _clearIndicator = () => { if (_indicator) { _indicator.remove(); _indicator = null } }
186
- const _showIndicator = (edge) => {
187
- _clearIndicator()
188
- _indicator = document.createElement('div')
189
- _indicator.className = 'ds-ep-tree-drop-indicator'
190
- _indicator.style.cssText = `position:absolute;left:0;right:0;height:2px;background:var(--accent,#4af);pointer-events:none;z-index:10;${edge === 'top' ? 'top:-1px' : 'bottom:-1px'}`
191
- if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
192
- el.appendChild(_indicator)
193
- }
194
- const drop = C.useDropTarget(el, {
195
- accepts: ['scene-node'],
196
- onDragOver: (payload) => {
197
- const clientY = payload?.pointerEvent?.clientY
198
- if (clientY == null) { _clearIndicator(); return }
199
- const r = el.getBoundingClientRect()
200
- const frac = (clientY - r.top) / r.height
201
- if (frac < 0.25) _showIndicator('top')
202
- else if (frac > 0.75) _showIndicator('bottom')
203
- else _clearIndicator()
204
- },
205
- onDragLeave: _clearIndicator,
206
- onDrop: ({ data, pointerEvent }) => {
207
- _clearIndicator()
208
- if (!data?.id) return
209
- const dragIds = _dedupeNonAncestors(data.ids && data.ids.length > 1 ? data.ids : [data.id])
210
- if (dragIds.includes(id)) return
211
- const r = el.getBoundingClientRect()
212
- const frac = pointerEvent ? (pointerEvent.clientY - r.top) / r.height : 0.5
213
- if (frac < 0.25 || frac > 0.75) {
214
- const siblingParent = _parentOf.get(id) || null
215
- dragIds.forEach(dragId => onReparent(dragId, siblingParent))
216
- showToast((dragIds.length > 1 ? `Moved ${dragIds.length} entities` : 'Moved ' + dragIds[0]) + ' to ' + (siblingParent ? 'be a sibling of ' + id : 'root'))
217
- } else {
218
- dragIds.forEach(dragId => onReparent(dragId, id))
219
- showToast((dragIds.length > 1 ? `Reparented ${dragIds.length} entities` : 'Reparented ' + dragIds[0]) + ' -> ' + id)
220
- }
221
- }
222
- })
223
- _menuTeardowns.push(() => { drag.destroy(); drop.destroy(); _clearIndicator() })
224
- }
245
+ attachRowKebab(el, node)
246
+ if (onReparent && typeof C.useDraggable === 'function') attachRowDragDrop(el, node, id)
225
247
  })
226
248
  }
227
249
 
@@ -235,7 +257,7 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
235
257
  const { node, depth, hasKids, expanded } = row
236
258
  const _label = node.label || node._appName || node.appName || node.id
237
259
  const _appTag = node._appName || node.appName || ''
238
- const _glyph = node.light ? 'L' : _typeGlyph[classify(node)]
260
+ const _glyph = node.light ? 'L' : _typeGlyph[classifyNode(node)]
239
261
  const _flags = (_locked.has(node.id) ? ' 🔒' : '') + (_hiddenInEditor.has(node.id) ? ' 👁‍🗨' : '')
240
262
  return C.TreeItem({
241
263
  label: '[' + _glyph + '] ' + _label + _flags,
@@ -248,20 +270,34 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
248
270
  })
249
271
  }
250
272
 
273
+ function bindPanelBodyRef(el, virtualized) {
274
+ if (!el) return
275
+ _viewportH = el.clientHeight
276
+ if (virtualized && !el._dsScrollListener) {
277
+ el._dsScrollListener = true
278
+ el.addEventListener('scroll', () => { _scrollTop = el.scrollTop; _viewportH = el.clientHeight; render() }, { passive: true })
279
+ }
280
+ if (el._dsRootDrop || !onReparent || typeof C.useDropTarget !== 'function') return
281
+ el._dsRootDrop = true
282
+ const d = C.useDropTarget(el, { accepts: ['scene-node'], onDrop: ({ data }) => {
283
+ if (!data?.id) return
284
+ const dragIds = dedupeNonAncestors(_parentOf, data.ids && data.ids.length > 1 ? data.ids : [data.id]).filter(id => _parentOf.get(id))
285
+ if (!dragIds.length) return
286
+ dragIds.forEach(id => onReparent(id, null))
287
+ showToast(dragIds.length > 1 ? `Unparented ${dragIds.length} entities` : 'Unparented ' + dragIds[0])
288
+ } })
289
+ el._dsRootDropDestroy = d.destroy
290
+ }
291
+
251
292
  let _lastRows = []
252
293
  function render() {
253
- const rows = flatten(_ents, 0, [])
294
+ const rows = flattenTree(_ents, 0, _q, _seen, _expanded, [])
254
295
  _lastRows = rows
255
296
 
256
297
  const virtualized = rows.length > VIRTUALIZE_THRESHOLD
257
298
  let treeChildren, spacerTop = 0, spacerBottom = 0, renderedRows = rows
258
299
  if (virtualized) {
259
- const visibleCount = Math.max(1, Math.ceil((_viewportH || 400) / ROW_HEIGHT))
260
- const startIdx = Math.max(0, Math.floor(_scrollTop / ROW_HEIGHT) - OVERSCAN)
261
- const endIdx = Math.min(rows.length, startIdx + visibleCount + OVERSCAN * 2)
262
- spacerTop = startIdx * ROW_HEIGHT
263
- spacerBottom = (rows.length - endIdx) * ROW_HEIGHT
264
- renderedRows = rows.slice(startIdx, endIdx)
300
+ ;({ spacerTop, spacerBottom, renderedRows } = sliceVirtualWindow(rows, _scrollTop, _viewportH, ROW_HEIGHT, OVERSCAN))
265
301
  treeChildren = renderedRows.map(_rowEl)
266
302
  } else {
267
303
  treeChildren = rows.map(_rowEl)
@@ -273,24 +309,7 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
273
309
  : h('div', {
274
310
  class: 'ds-ep-panel-body flush', style: 'flex:1;min-height:0;overflow-y:auto',
275
311
  // Drop onto the panel background (not a row) = unparent to root.
276
- ref: (el) => {
277
- if (!el) return
278
- _viewportH = el.clientHeight
279
- if (virtualized && !el._dsScrollListener) {
280
- el._dsScrollListener = true
281
- el.addEventListener('scroll', () => { _scrollTop = el.scrollTop; _viewportH = el.clientHeight; render() }, { passive: true })
282
- }
283
- if (el._dsRootDrop || !onReparent || typeof C.useDropTarget !== 'function') return
284
- el._dsRootDrop = true
285
- const d = C.useDropTarget(el, { accepts: ['scene-node'], onDrop: ({ data }) => {
286
- if (!data?.id) return
287
- const dragIds = _dedupeNonAncestors(data.ids && data.ids.length > 1 ? data.ids : [data.id]).filter(id => _parentOf.get(id))
288
- if (!dragIds.length) return
289
- dragIds.forEach(id => onReparent(id, null))
290
- showToast(dragIds.length > 1 ? `Unparented ${dragIds.length} entities` : 'Unparented ' + dragIds[0])
291
- } })
292
- el._dsRootDropDestroy = d.destroy
293
- }
312
+ ref: (el) => bindPanelBodyRef(el, virtualized)
294
313
  },
295
314
  virtualized
296
315
  ? h('div', null,
@@ -314,19 +333,16 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
314
333
  }
315
334
 
316
335
  const _parentOf = new Map()
317
- function indexParents(nodes, parentId) {
318
- for (const n of nodes || []) { _parentOf.set(n.id, parentId); indexParents(n.children, n.id) }
319
- }
320
336
 
321
337
  container.tabIndex = 0
322
338
 
323
- // Capture-phase delegated click + multi-select handlers, immune to vdom node replacement
324
- // between mousedown and mouseup (applyDiff re-renders the entire tree, which can swap the
325
- // DOM node under an in-progress pointer interaction -- see the row
339
+ // Capture-phase delegated multi-select handler, immune to vdom node replacement between
340
+ // mousedown and mouseup (applyDiff re-renders the entire tree, which can swap the DOM node
341
+ // under an in-progress pointer interaction -- see the row
326
342
  // scene-hierarchy-click-races-vdom-rerender-lost-click). The capture phase fires on the
327
343
  // STABLE container before the event reaches any individual row element, so the data-eid
328
344
  // lookup always succeeds regardless of whether the row was re-rendered mid-click.
329
- container.addEventListener('mousedown', (e) => {
345
+ function onContainerMouseDown(e) {
330
346
  if (!e.ctrlKey && !e.metaKey && !e.shiftKey) {
331
347
  if (_multiSel.size) { _multiSel.clear(); render() }
332
348
  return
@@ -336,9 +352,9 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
336
352
  const id = item.getAttribute('data-eid')
337
353
  if (_multiSel.has(id)) _multiSel.delete(id); else _multiSel.add(id)
338
354
  render()
339
- }, true)
355
+ }
340
356
 
341
- container.addEventListener('click', (e) => {
357
+ function onContainerClick(e) {
342
358
  const item = e.target.closest('.ds-ep-tree-item[data-eid]')
343
359
  if (!item) return
344
360
  const id = item.getAttribute('data-eid')
@@ -348,9 +364,9 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
348
364
  _sel = id
349
365
  onSelect?.(id)
350
366
  render()
351
- }, true)
367
+ }
352
368
 
353
- container.addEventListener('keydown', (e) => {
369
+ function onContainerKeyDown(e) {
354
370
  if (!_lastRows.length) return
355
371
  const idx = _sel ? _lastRows.findIndex(r => r.node.id === _sel) : -1
356
372
  if (e.key === 'ArrowDown') {
@@ -379,11 +395,15 @@ export function createSceneHierarchy(container, { onSelect, onFocus, onDelete, o
379
395
  if (ok) { onDelete(id); showToast('Deleted ' + id) }
380
396
  })
381
397
  }
382
- })
398
+ }
399
+
400
+ container.addEventListener('mousedown', onContainerMouseDown, true)
401
+ container.addEventListener('click', onContainerClick, true)
402
+ container.addEventListener('keydown', onContainerKeyDown)
383
403
 
384
404
  render()
385
405
  return {
386
- updateEntities(ents) { _ents = ents || []; _parentOf.clear(); indexParents(_ents, null); render() },
406
+ updateEntities(ents) { _ents = ents || []; _parentOf.clear(); indexParents(_ents, null, _parentOf); render() },
387
407
  setSelected(id) { _sel = id; render() },
388
408
  get selectedId() { return _sel },
389
409
  // Client-side-only lock/hidden-in-editor sets, read by editor.js (pick-gating) and app.js (editor-overlay
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.638",
3
+ "version": "0.1.640",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -34,6 +34,55 @@
34
34
 
35
35
  const _now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())
36
36
 
37
+ // Rough per-shape resident-body byte estimate: dominated by the args payload (convex hull point
38
+ // arrays are the only variable-size shape here; box/capsule args are a handful of floats) plus a
39
+ // fixed per-body/broadphase-node overhead so even tiny shapes count for something under the budget.
40
+ const _BODY_OVERHEAD_BYTES = 256
41
+ function estimateBodyBytes(a) {
42
+ if (!a) return _BODY_OVERHEAD_BYTES
43
+ const args = a.args
44
+ let n = 0
45
+ if (args && typeof args.byteLength === 'number') n = args.byteLength
46
+ else if (Array.isArray(args)) n = args.length * 4
47
+ return _BODY_OVERHEAD_BYTES + n
48
+ }
49
+
50
+ // Chunk placements memoized per (cx,cz) for world lifetime: classify is expensive and the ring is walked twice per rebuild.
51
+ const chunkKey = (cx, cz) => ((cx & 0x3fffff) * 0x400000) + (cz & 0x3fffff)
52
+
53
+ // Greedy same-pass clustering: merges centers within mergeRadius of an already-picked center so their
54
+ // rings aren't walked twice, then caps the result to maxCenters (nearest-to-existing-pick-order, i.e.
55
+ // whichever centers getCenters() returns first win a slot -- callers should return closer/more-recent
56
+ // players first if they want to bias which players keep their ring under a maxCenters squeeze).
57
+ function clusterCenters(centers, mergeRadius, maxCenters) {
58
+ const picked = []
59
+ for (const c of centers) {
60
+ let merged = false
61
+ for (const p of picked) { if (Math.hypot(c[0] - p[0], c[1] - p[1]) <= mergeRadius) { merged = true; break } }
62
+ if (!merged) picked.push(c)
63
+ if (picked.length >= maxCenters) break
64
+ }
65
+ return picked
66
+ }
67
+
68
+ // A rebuild is due if ANY center moved more than the hysteresis threshold from its previous ring
69
+ // position, OR the player POPULATION changed (a new player joined with no ring yet, or one left) --
70
+ // matching centers positionally (not by count alone) so a same-size swap (player A left, player B
71
+ // joined at a different spot in the same tick) still triggers, since neither raw count nor "closest
72
+ // pair distance" alone would catch that combination reliably.
73
+ function ringMoved(centers, curCenters, moveThreshold) {
74
+ if (curCenters.length !== centers.length) return true
75
+ for (let i = 0; i < centers.length; i++) {
76
+ let nearest = Infinity
77
+ for (let j = 0; j < curCenters.length; j++) {
78
+ const d = Math.hypot(centers[i][0] - curCenters[j][0], centers[i][1] - curCenters[j][1])
79
+ if (d < nearest) nearest = d
80
+ }
81
+ if (nearest > moveThreshold) return true
82
+ }
83
+ return false
84
+ }
85
+
37
86
  export function createColliderStreamer(spec = {}) {
38
87
  const physics = spec.physics
39
88
  // getCenters (preferred, multi-player) wins over getCenter (legacy single-center shim) when both are given.
@@ -94,23 +143,9 @@ export function createColliderStreamer(spec = {}) {
94
143
  const b = _lru.get(placementId)
95
144
  if (b !== undefined) { _residentBytes -= b; _lru.delete(placementId) }
96
145
  }
97
- // Rough per-shape resident-body byte estimate: dominated by the args payload (convex hull point
98
- // arrays are the only variable-size shape here; box/capsule args are a handful of floats) plus a
99
- // fixed per-body/broadphase-node overhead so even tiny shapes count for something under the budget.
100
- const _BODY_OVERHEAD_BYTES = 256
101
- function _estimateBytes(a) {
102
- if (!a) return _BODY_OVERHEAD_BYTES
103
- const args = a.args
104
- let n = 0
105
- if (args && typeof args.byteLength === 'number') n = args.byteLength
106
- else if (Array.isArray(args)) n = args.length * 4
107
- return _BODY_OVERHEAD_BYTES + n
108
- }
109
146
  let curCenter = null, rebuilding = false, disposed = false, _timer = null, rebuildCount = 0
110
147
  let curCenters = [] // last-used ring centers, for diagnostics/tests
111
148
 
112
- // Chunk placements memoized per (cx,cz) for world lifetime: classify is expensive and the ring is walked twice per rebuild.
113
- const _chunkKey = (cx, cz) => ((cx & 0x3fffff) * 0x400000) + (cz & 0x3fffff)
114
149
  const _CHUNK_CACHE_CAP = 4096
115
150
  const _chunkCache = new Map()
116
151
  function _chunkCacheGet(k) {
@@ -130,7 +165,7 @@ export function createColliderStreamer(spec = {}) {
130
165
  // unbudgeted=true: initial pre-gameplay start() build, computes the whole ring in one pass.
131
166
  function _beginBudget(unbudgeted) { _budgetDeadline = _now() + COMPUTE_BUDGET_MS; _newThisPass = 0; _deferred = false; _budgetOff = !!unbudgeted }
132
167
  function chunkPlacements(cx, cz) {
133
- const k = _chunkKey(cx, cz)
168
+ const k = chunkKey(cx, cz)
134
169
  let v = _chunkCacheGet(k)
135
170
  if (v) return v
136
171
  if (!_budgetOff && (_newThisPass >= MAX_NEW_CHUNKS_PER_PASS || _now() >= _budgetDeadline)) { _deferred = true; return _EMPTY }
@@ -140,21 +175,6 @@ export function createColliderStreamer(spec = {}) {
140
175
 
141
176
  const radiusSq = radius * radius, keepRadiusSq = keepRadius * keepRadius
142
177
 
143
- // Greedy same-pass clustering: merges centers within mergeRadius of an already-picked center so their
144
- // rings aren't walked twice, then caps the result to maxCenters (nearest-to-existing-pick-order, i.e.
145
- // whichever centers getCenters() returns first win a slot -- callers should return closer/more-recent
146
- // players first if they want to bias which players keep their ring under a maxCenters squeeze).
147
- function _clusterCenters(centers) {
148
- const picked = []
149
- for (const c of centers) {
150
- let merged = false
151
- for (const p of picked) { if (Math.hypot(c[0] - p[0], c[1] - p[1]) <= mergeRadius) { merged = true; break } }
152
- if (!merged) picked.push(c)
153
- if (picked.length >= maxCenters) break
154
- }
155
- return picked
156
- }
157
-
158
178
  // Ring walk around ONE center; classifies each candidate into desired/keep from one distance calc.
159
179
  // Returns raw per-center results (not yet capped) -- capping happens after the union across all
160
180
  // centers in classifyRings below, so a candidate close to center B doesn't lose its cap slot just
@@ -202,7 +222,7 @@ export function createColliderStreamer(spec = {}) {
202
222
  function scheduleAdd(p) {
203
223
  const a = bodyArgs(p); if (!a) return
204
224
  const placementId = p[idField]
205
- _touch(placementId, _estimateBytes(a))
225
+ _touch(placementId, estimateBodyBytes(a))
206
226
  if (_useQueue) {
207
227
  live.set(placementId, _PENDING)
208
228
  physics.enqueueAdd(a.shape, a.args, a.position, 'static', { rotation: a.rotation, shapeKey: a.shapeKey }, (id) => {
@@ -297,7 +317,7 @@ export function createColliderStreamer(spec = {}) {
297
317
  addDeadline = _now() + ADD_BUDGET_MS
298
318
  }
299
319
  } else {
300
- _touch(p[idField], _lru.get(p[idField]) ?? _estimateBytes(bodyArgs(p))) // re-affirm LRU recency for a still-desired survivor
320
+ _touch(p[idField], _lru.get(p[idField]) ?? estimateBodyBytes(bodyArgs(p))) // re-affirm LRU recency for a still-desired survivor
301
321
  }
302
322
  }
303
323
  // skip remove pass if budget-deferred: an incomplete keep set would wrongly drop in-range bodies; curCenters stays stale so _check re-converges.
@@ -328,30 +348,13 @@ export function createColliderStreamer(spec = {}) {
328
348
  if (disposed) return
329
349
  _timer = setTimeout(_check, deferredRetry ? 16 : intervalMs)
330
350
  }
331
- // A rebuild is due if ANY center moved more than the hysteresis threshold from its previous ring
332
- // position, OR the player POPULATION changed (a new player joined with no ring yet, or one left) --
333
- // matching centers positionally (not by count alone) so a same-size swap (player A left, player B
334
- // joined at a different spot in the same tick) still triggers, since neither raw count nor "closest
335
- // pair distance" alone would catch that combination reliably.
336
- function _ringMoved(centers) {
337
- if (curCenters.length !== centers.length) return true
338
- for (let i = 0; i < centers.length; i++) {
339
- let nearest = Infinity
340
- for (let j = 0; j < curCenters.length; j++) {
341
- const d = Math.hypot(centers[i][0] - curCenters[j][0], centers[i][1] - curCenters[j][1])
342
- if (d < nearest) nearest = d
343
- }
344
- if (nearest > radius * rebuildAt) return true
345
- }
346
- return false
347
- }
348
351
  function _check() {
349
352
  if (disposed) return
350
353
  try {
351
354
  const raw = getCenters()
352
355
  if (raw.length && !rebuilding) {
353
- const centers = _clusterCenters(raw)
354
- if (!curCenters.length || _ringMoved(centers)) {
356
+ const centers = clusterCenters(raw, mergeRadius, maxCenters)
357
+ if (!curCenters.length || ringMoved(centers, curCenters, radius * rebuildAt)) {
355
358
  _rebuildMulti(centers).then(d => _scheduleNext(!!d)).catch(() => _scheduleNext(false))
356
359
  return
357
360
  }
@@ -364,7 +367,7 @@ export function createColliderStreamer(spec = {}) {
364
367
  if (disposed) return
365
368
  if (typeof spec.prewarm === 'function' && typeof physics.preallocatePool === 'function') spec.prewarm(physics, cap)
366
369
  const raw = getCenters()
367
- const centers = raw.length ? _clusterCenters(raw) : [[0, 0]]
370
+ const centers = raw.length ? clusterCenters(raw, mergeRadius, maxCenters) : [[0, 0]]
368
371
  await _rebuildMulti(centers, true)
369
372
  setColliderIds(_liveIds)
370
373
  _timer = setTimeout(_check, intervalMs)