@solidrt/2d 0.0.55 → 0.0.56

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.
package/src/layer.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  //
12
12
  // Two instance-buffer slots split ownership: slot 0 is the pose buffer,
13
13
  // written ONLY by the core (one coalesced write per flush however many
14
- // nodes moved); slot 1 is the style buffer [u0, v0, u1, v1, tint rgba],
15
- // JS-owned and published through the zero-copy write lease. Never write
14
+ // nodes moved); slot 1 is the style buffer [u0, v0, u1, v1, tint rgba,
15
+ // renderOrder], JS-owned and published through the zero-copy write lease. Never write
16
16
  // the pose buffer from JS - the core's staging mirror is the owner and
17
17
  // will overwrite. For motion only JS can compute at large populations, the
18
18
  // records layer (records.ts) is the escape hatch.
@@ -20,9 +20,12 @@
20
20
  // Sprites hold FIXED instance slots (freed slots recycle): draw order is
21
21
  // slot order, so removal never shifts records and pose sinks never rebind.
22
22
  // Layer space is pixels, top-left origin, y-down - the render tree's
23
- // frame. The camera is a shared-params write (uCamera), never per-sprite.
23
+ // frame. The camera (offset, zoom, rotation about a pivot - CameraUpdate
24
+ // in camera.ts) is a shared-params write (uCamera + uCameraRot), never
25
+ // per-sprite; pointer dispatch (dispatch.ts) undoes it with
26
+ // unprojectCamera.
24
27
  import { getOwner, onCleanup } from "@solidrt/core"
25
- import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
28
+ import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
26
29
  import {
27
30
  beginBufferWrite,
28
31
  createBuffer,
@@ -38,7 +41,10 @@ import type { BufferId, TextureId } from "@solidrt/core/gpu"
38
41
  import * as spatial from "flux:spatial"
39
42
  import type { NodeId, NodeTransition } from "flux:spatial"
40
43
  import { on } from "srt:events"
41
- import { checkOversample } from "./oversample.ts"
44
+ import { checkCamera, projectCamera, unprojectCamera } from "./camera.ts"
45
+ import type { CameraState, CameraUpdate } from "./camera.ts"
46
+ import { spriteDispatch } from "./dispatch.ts"
47
+ import { checkOversample, thrashSentinel } from "./oversample.ts"
42
48
  import type { Frame } from "./frames.ts"
43
49
  import { FULL_FRAME, writeFrame } from "./frames.ts"
44
50
  import type { RecordLayer } from "./records.ts"
@@ -46,8 +52,16 @@ import { FRAGMENT, INSTANCE_ATTRIBUTES_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
46
52
 
47
53
  /** Floats per pose record (the core's Pose2D projection). */
48
54
  export const POSE_FLOATS = 5
49
- /** Floats per style record: [u0, v0, u1, v1, tintR, tintG, tintB, tintA]. */
50
- export const STYLE_FLOATS = 8
55
+
56
+ // Float offset of world y in a pose record - what `orderBy: "y"` keys on.
57
+ const POSE_Y_FIELD = 1
58
+ /** Floats per style record:
59
+ * [u0, v0, u1, v1, tintR, tintG, tintB, tintA, renderOrder]. */
60
+ export const STYLE_FLOATS = 9
61
+
62
+ // Float offset of renderOrder in a style record - what `orderBy: "renderOrder"`
63
+ // keys on.
64
+ const STYLE_KEY_FIELD = 8
51
65
 
52
66
  const RESOLVED = Promise.resolve()
53
67
 
@@ -56,7 +70,9 @@ const TRANSFORM = new Float32Array(10)
56
70
  const FLAT_BOUNDS = new Float32Array([-0.5, -0.5, 0, 0.5, 0.5, 0])
57
71
  const RAY_ORIGIN = new Float32Array(3)
58
72
  const RAY_DIR = new Float32Array([0, 0, 1])
59
- const BOX = new Float32Array(6)
73
+ const BOX = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, 0, 1])
74
+ // worldPosition's world-matrix scratch (column-major; translation at 12, 13).
75
+ const WORLD = new Float32Array(16)
60
76
 
61
77
  // Settle routing: the core's "spatialTransitionEnd" event carries the node
62
78
  // id, so the handles with a transition DECLARED are indexed by node (only
@@ -91,37 +107,60 @@ function declareTransition(node: NodeId, handle: Sprite | SpriteGroup, transitio
91
107
  * fields - they touch no GPU state (the scene-graph handler rule).
92
108
  */
93
109
  export type Sprite = {
94
- /** The owning layer, null after removeSprite. */
95
- layer: SpriteLayer | RecordLayer | null
110
+ /** The owning layer, null after removeSprite (which owns the write). */
111
+ readonly layer: SpriteLayer | RecordLayer | null
96
112
  /**
97
113
  * The sprite's SPATIAL ARENA node - the citizenship handle: bind core
98
114
  * producers to it or reach it through flux:spatial directly (the layer
99
115
  * still owns the node's life; destroy it only via removeSprite). Null
100
116
  * on a record layer's sprites.
101
117
  */
102
- node: NodeId | null
118
+ readonly node: NodeId | null
103
119
  /** Instance slot: fixed for the sprite's life on the node layer, the
104
- * shifting draw-order index on a record layer. */
105
- _slot: number
106
- /** Pose mirror (node layer): what setSprite composes transforms from. */
107
- _x: number
108
- _y: number
109
- _w: number
110
- _h: number
111
- _rot: number
112
- /** Mirror flags (both layer kinds): re-applied to every frame write. */
113
- _flipX: boolean
114
- _flipY: boolean
120
+ * shifting draw-order index on a record layer. Readable (readonly, like
121
+ * the other underscore fields: cheap reads without getSprite's
122
+ * allocation); writes go through setSprite. */
123
+ readonly _slot: number
124
+ /** Pose mirror (node layer): what setSprite composes transforms from.
125
+ * On a RECORD layer this mirror goes stale once records are written
126
+ * raw - there the records array is the truth. */
127
+ readonly _x: number
128
+ readonly _y: number
129
+ readonly _w: number
130
+ readonly _h: number
131
+ readonly _rot: number
132
+ /** Mirror flags (both layer kinds): re-applied to every frame write.
133
+ * The cheap way to read flip state. */
134
+ readonly _flipX: boolean
135
+ readonly _flipY: boolean
136
+ /** Visibility mirror (node layer; always true on a record layer's
137
+ * sprites). The cheap read; writes go through setSprite. */
138
+ readonly _visible: boolean
139
+ /** The enclosing group (null at the layer root, always null on a record
140
+ * layer's sprites) - the bubble path; writes go through
141
+ * setSpriteParent/addSprite's `parent`. */
142
+ readonly _parent: SpriteGroup | null
115
143
  onPointerDown?: (event: SpritePointerEvent) => void
116
144
  onPointerMove?: (event: SpritePointerEvent) => void
117
145
  onPointerUp?: (event: SpritePointerEvent) => void
118
146
  onPointerEnter?: (event: SpritePointerEvent) => void
119
147
  onPointerLeave?: (event: SpritePointerEvent) => void
148
+ /** A wheel notch over the sprite; bubbles like down/move/up. */
149
+ onWheel?: (event: SpriteWheelEvent) => void
150
+ /** A press that released on this sprite without dragging (see
151
+ * SpriteTapEvent); bubbles like down/move/up. */
152
+ onTap?: (event: SpriteTapEvent) => void
120
153
  /** A declared transition (setSpriteTransition) settled naturally on
121
154
  * one component; a cancel or snap never fires. */
122
155
  onTransitionEnd?: (event: TransitionEndEvent) => void
123
156
  }
124
157
 
158
+ /** The layer-internal mutable view of a Sprite: the readonly on the
159
+ * underscore fields is for the public surface; internal write sites
160
+ * annotate with this (readonly does not affect assignability, so the two
161
+ * types flow into each other freely). */
162
+ export type SpriteState = { -readonly [K in keyof Sprite]: Sprite[K] }
163
+
125
164
  /** The settled component of a node transition: `position` is x/y,
126
165
  * `scale` w/h (a group's uniform scale). */
127
166
  export type TransitionEndEvent = {
@@ -148,17 +187,35 @@ export type SpriteOptions = {
148
187
  rotation?: number
149
188
  /** RGBA multiplier 0..1 each; default opaque white (the texture as-is). */
150
189
  tint?: [number, number, number, number]
190
+ /**
191
+ * Explicit draw-order key, read only by a layer created with `orderBy:
192
+ * "renderOrder"` (default 0; ties keep slot order, so untouched sprites draw
193
+ * as without one). The raise idiom: `setSprite(hit, { renderOrder: ++top })`
194
+ * on interaction, back to 0 to restore. Node layer only - a record
195
+ * layer's 13-float record has no key field (order it by one of its own
196
+ * fields with `orderBy: { field }`); setting this there throws.
197
+ */
198
+ renderOrder?: number
199
+ /**
200
+ * Show or hide the sprite (default true), @solidrt/3d's setVisible in
201
+ * the setSprite bag: hidden, its pose slot zeroes (nothing drawn) and
202
+ * pick/pickRect skip it; the handle, slot, style records and any
203
+ * running transition state stay, so showing again restores the sprite
204
+ * as it was. Node layer only - a record sprite has no node (hide it by
205
+ * zeroing w or h); setting this there throws.
206
+ */
207
+ visible?: boolean
151
208
  }
152
209
 
153
210
  export type AddSpriteOptions = SpriteOptions & {
154
- /** Mount under this group (node layer only); pose fields are then local
155
- * to it. Reparent later with setSpriteParent. */
156
- parent?: SpriteGroup
211
+ /** Mount under this group (node layer only; null = the layer root, the
212
+ * default); pose fields are then local to it. Reparent later with
213
+ * setSpriteParent. */
214
+ parent?: SpriteGroup | null
157
215
  }
158
216
 
159
- export type SpritePointerEvent = {
160
- /** The sprite hit (the topmost at the point), constant while captured. */
161
- sprite: Sprite
217
+ /** What every layer pointer event carries, whichever handler sees it. */
218
+ export type LayerEventBase = {
162
219
  /** Pointer position in LAYER pixels (the camera mapping undone). */
163
220
  x: number
164
221
  y: number
@@ -169,6 +226,74 @@ export type SpritePointerEvent = {
169
226
  ctrlKey: boolean
170
227
  altKey: boolean
171
228
  metaKey: boolean
229
+ /**
230
+ * The element event the layer's leaf received, in the leaf's own frame
231
+ * (localX/localY, clientX/clientY, movementX/Y): what core's
232
+ * recognizers consume, so a sprite drags itself through `createPan` and
233
+ * a camera rides `createTransform` on the same events the walk carries.
234
+ */
235
+ native: ElementPointerEvent
236
+ /**
237
+ * Stops the walk after the current handler: no enclosing group and none
238
+ * of the layer's listeners see the event. Stopping a DOWN claims the
239
+ * whole press - that pointer's move, up and tap never reach the layer
240
+ * either, so a sprite that drags itself stops its down once and a
241
+ * camera attached at the root never pans it.
242
+ */
243
+ stopPropagation(): void
244
+ }
245
+
246
+ /** The event as a sprite's or group's handler sees it. */
247
+ export type SpritePointerEvent = LayerEventBase & {
248
+ /** The sprite the event is about (the topmost hit at the point, or the
249
+ * captured sprite during a drag) - constant while the event bubbles. */
250
+ sprite: Sprite
251
+ /** The sprite or group whose handler is running; changes as the event
252
+ * bubbles from the hit sprite through its enclosing groups. */
253
+ currentTarget: Sprite | SpriteGroup
254
+ }
255
+
256
+ /**
257
+ * The event as the LAYER's listeners see it (LayerBase.listen), the last
258
+ * stop of the walk: `sprite` is the hit sprite it bubbled from, or null
259
+ * over empty space, where the layer is the only target.
260
+ */
261
+ export type LayerPointerEvent = LayerEventBase & {
262
+ sprite: Sprite | null
263
+ currentTarget: SpriteLayer | RecordLayer
264
+ }
265
+
266
+ type WheelFields = {
267
+ /** The wheel delta as the element event reports it. */
268
+ deltaX: number
269
+ deltaY: number
270
+ native: ElementWheelEvent
271
+ }
272
+ type TapFields = {
273
+ /** 1 for a tap, 2 for the second of a double tap (the same target,
274
+ * within the repeat interval and distance), and so on - DOM's `detail`,
275
+ * Unity's `clickCount`. */
276
+ tapCount: number
277
+ }
278
+
279
+ export type SpriteWheelEvent = SpritePointerEvent & WheelFields
280
+ export type LayerWheelEvent = LayerPointerEvent & WheelFields
281
+ /**
282
+ * A press that released on the target it pressed without travelling past
283
+ * the slop, the only pointer down for its whole press (a pinch never taps).
284
+ * Dispatched after the up, bubbling the same way; `x`/`y` are the release
285
+ * point.
286
+ */
287
+ export type SpriteTapEvent = SpritePointerEvent & TapFields
288
+ export type LayerTapEvent = LayerPointerEvent & TapFields
289
+
290
+ /** A listener at the layer root; see LayerBase.listen. */
291
+ export type LayerPointerListener = {
292
+ onPointerDown?: (event: LayerPointerEvent) => void
293
+ onPointerMove?: (event: LayerPointerEvent) => void
294
+ onPointerUp?: (event: LayerPointerEvent) => void
295
+ onWheel?: (event: LayerWheelEvent) => void
296
+ onTap?: (event: LayerTapEvent) => void
172
297
  }
173
298
 
174
299
  export type SpriteHandlers = {
@@ -176,14 +301,15 @@ export type SpriteHandlers = {
176
301
  onPointerMove: (event: ElementPointerEvent) => void
177
302
  onPointerUp: (event: ElementPointerEvent) => void
178
303
  onPointerLeave: (event: ElementPointerEvent) => void
304
+ onWheel: (event: ElementWheelEvent) => void
179
305
  }
180
306
 
181
- export type CameraUpdate = {
182
- /** World pixel at the viewport's top-left corner. */
183
- x?: number
184
- y?: number
185
- /** World-to-screen scale; 1 is pixel-for-pixel. */
186
- zoom?: number
307
+ /** Validate an [r, g, b, a] tint (throws - the dev validation policy).
308
+ * Internal - every layer kind (sprite, record, tile) calls it. */
309
+ export function checkTint(verb: string, tint: [number, number, number, number]): void {
310
+ if (!(Array.isArray(tint) && tint.length === 4 && tint.every(Number.isFinite))) {
311
+ throw new Error(`${verb}: tint must be [r, g, b, a], got ${JSON.stringify(tint)}`)
312
+ }
187
313
  }
188
314
 
189
315
  export type SpriteLayerOptions = {
@@ -194,6 +320,9 @@ export type SpriteLayerOptions = {
194
320
  */
195
321
  capacity?: number
196
322
  clearColor?: [number, number, number, number]
323
+ /** Layer tint, [r, g, b, a] in 0..1, multiplied over every sprite's own
324
+ * tint; default opaque white (sprites as-is). See setTint. */
325
+ tint?: [number, number, number, number]
197
326
  label?: string
198
327
  /**
199
328
  * Target texels per layer pixel (positive integer, default 1). The layer
@@ -206,6 +335,30 @@ export type SpriteLayerOptions = {
206
335
  oversample?: number
207
336
  /** Skip the owner-scoped auto-dispose (see createSpriteLayer). */
208
337
  autoFree?: boolean
338
+ /**
339
+ * Draw sprites in KEY order instead of slot order, produced by core at
340
+ * every publish (the gpu `instanceOrder` primitive across the pose/style
341
+ * buffer pair - no per-sprite JS anywhere): `"y"` keys on the sprite's
342
+ * WORLD y, the pose the core itself writes, so a perspective crowd
343
+ * paints back to front (smaller y = further up the screen = drawn
344
+ * first) - and because the key is core-owned, sprites moved by native
345
+ * transitions or any other core producer re-sort with zero JS per
346
+ * frame. Slots stay fixed - handles, picking and the style records are
347
+ * untouched, only the draw order changes - and ties keep slot order, so
348
+ * sprites at equal y draw exactly as without it.
349
+ *
350
+ * `"renderOrder"` keys on the app-owned per-sprite `renderOrder` field instead
351
+ * (setSprite, default 0): explicit layering for painter-order scenes -
352
+ * raise a dragged piece, click-to-front, hover emphasis - with stable
353
+ * handles and no record churn.
354
+ *
355
+ * Known limitation for both keys: pick() resolves overlapping sprites by
356
+ * slot order, not visual order, when a key is set. (The record layer's
357
+ * RecordLayerOptions instead takes `"y"` or a raw `{ field }` offset
358
+ * into its own records; pose records are core-owned, so the node layer
359
+ * names its keys.)
360
+ */
361
+ orderBy?: "y" | "renderOrder"
209
362
  }
210
363
 
211
364
  /**
@@ -213,21 +366,44 @@ export type SpriteLayerOptions = {
213
366
  * uniform scale - never a sprite size) that sprites and other groups
214
367
  * parent under, so a ship with turrets or a dragged stack moves as one
215
368
  * subtree recomputed in native code. Groups render nothing and cannot be
216
- * picked; sprites are always the leaves.
369
+ * picked - sprites are always the leaves - but down/move/up events bubble
370
+ * from a hit child sprite through its enclosing groups (enter/leave pair
371
+ * on the sprite alone), so one group handler covers a whole assembly.
217
372
  */
218
373
  export type SpriteGroup = {
219
- /** The owning layer, null after removeGroup. */
220
- layer: SpriteLayer | null
374
+ /** The owning layer, null after removeGroup (which owns the write). */
375
+ readonly layer: SpriteLayer | null
221
376
  /** The group's spatial arena node. */
222
- node: NodeId
223
- _x: number
224
- _y: number
225
- _rot: number
226
- _scale: number
377
+ readonly node: NodeId
378
+ /** Pose mirror (readonly, like Sprite's underscore fields: cheap reads);
379
+ * writes go through setGroup. */
380
+ readonly _x: number
381
+ readonly _y: number
382
+ readonly _rot: number
383
+ readonly _scale: number
384
+ /** Visibility mirror; writes go through setGroup. */
385
+ readonly _visible: boolean
386
+ /** See Sprite._parent. */
387
+ readonly _parent: SpriteGroup | null
388
+ /** The handles parented here (removeGroup removes them with it);
389
+ * internal -
390
+ * membership writes go through addSprite/addGroup/setSpriteParent/
391
+ * setGroup. */
392
+ readonly _children: Set<SpriteState | GroupState>
393
+ /** Bubbled from a hit child sprite; see SpritePointerEvent. A group
394
+ * never receives enter/leave (hover pairs on the sprite alone). */
395
+ onPointerDown?: (event: SpritePointerEvent) => void
396
+ onPointerMove?: (event: SpritePointerEvent) => void
397
+ onPointerUp?: (event: SpritePointerEvent) => void
398
+ onWheel?: (event: SpriteWheelEvent) => void
399
+ onTap?: (event: SpriteTapEvent) => void
227
400
  /** See Sprite.onTransitionEnd. */
228
401
  onTransitionEnd?: (event: TransitionEndEvent) => void
229
402
  }
230
403
 
404
+ /** The layer-internal mutable view of a SpriteGroup (see SpriteState). */
405
+ export type GroupState = { -readonly [K in keyof SpriteGroup]: SpriteGroup[K] }
406
+
231
407
  /** Group fields, all optional: absent keys keep values. */
232
408
  export type GroupOptions = {
233
409
  /** Position in the parent frame (layer pixels at the root). */
@@ -238,6 +414,10 @@ export type GroupOptions = {
238
414
  /** Uniform scale on the whole subtree (this one scales child sprites -
239
415
  * a group is a frame, not a sprite size). */
240
416
  scale?: number
417
+ /** Show or hide the group's WHOLE subtree (default true); each child
418
+ * keeps its own `visible`, so showing the group again restores the
419
+ * subtree as it was. See SpriteOptions.visible. */
420
+ visible?: boolean
241
421
  /** Reparent (null = make the group a root). */
242
422
  parent?: SpriteGroup | null
243
423
  }
@@ -246,11 +426,27 @@ export type GroupOptions = {
246
426
  export type LayerBase = {
247
427
  /** The layer's output: an ordinary texture id (`<texture src>`). */
248
428
  texture: TextureId
249
- /** Element handlers wiring sprite pointer events; see handlersFor. */
429
+ /** Element handlers wiring the layer's pointer events (sprites, groups
430
+ * and the root listeners); see handlersFor. */
250
431
  handlers: SpriteHandlers
251
432
  /** Live sprite count. */
252
433
  readonly count: number
434
+ /** Layer pixels, as created or last set by setSize. */
435
+ readonly width: number
436
+ readonly height: number
253
437
  setSize(width: number, height: number): void
438
+ /**
439
+ * Listen at the root of the event walk. Every down, move, up, wheel and
440
+ * tap arrives here after the hit sprite and its enclosing groups
441
+ * (`sprite` set) or as the walk's only stop over empty space (`sprite`
442
+ * null), unless a handler stopped it on the way. Listeners run in
443
+ * registration order and all of them run - the root is the last stop,
444
+ * there is nothing left to claim. Returns the remover. The app's own
445
+ * root handling (deselect on a miss, a marquee) and controls
446
+ * (createCamera2d's attach) meet here, which is why the root is a list
447
+ * where a sprite has plain fields.
448
+ */
449
+ listen(listener: LayerPointerListener): () => void
254
450
  /** Target texels per layer pixel; see setOversample. */
255
451
  readonly oversample: number
256
452
  /**
@@ -262,118 +458,54 @@ export type LayerBase = {
262
458
  */
263
459
  setOversample(n: number): void
264
460
  setCamera(update: CameraUpdate): void
265
- /** Topmost sprite whose rotated rect contains the layer-pixel point. */
266
- pick(x: number, y: number): Sprite | null
461
+ /** The camera as last set (a fresh object per call, every field
462
+ * present): the argument for projectCamera/unprojectCamera -
463
+ * @solidrt/3d's scene.camera(). */
464
+ camera(): CameraState
465
+ /** World (layer) pixels -> viewport pixels under the current camera:
466
+ * projectCamera over camera(). */
467
+ project(x: number, y: number): [number, number]
468
+ /** Viewport pixels -> world (layer) pixels, the inverse: what pointer
469
+ * dispatch applies to every event. */
470
+ unproject(x: number, y: number): [number, number]
471
+ /**
472
+ * Tint the whole layer, [r, g, b, a] in 0..1: a uniform multiplied over
473
+ * every sprite's own tint (day/night, a dimmed parallax plane, a
474
+ * fade-in). One shared-params write - no record touches, cheap to
475
+ * animate - and the same contract as TileLayer.setTint, so one signal
476
+ * drives a whole scene across layer kinds.
477
+ */
478
+ setTint(tint: [number, number, number, number]): void
479
+ /** Every shown sprite whose rotated rect contains the layer-pixel point
480
+ * (a hidden sprite or subtree is never hit), topmost first - the
481
+ * all-hits shape of @solidrt/3d's pick; `pick(x, y)[0]` is the topmost.
482
+ * Topmost means draw order (highest slot on the node layer, last added
483
+ * on a record layer); an `orderBy` key is not consulted (see
484
+ * SpriteLayerOptions.orderBy's known limitation). */
485
+ pick(x: number, y: number): Sprite[]
267
486
  /**
268
- * Handlers for a leaf whose LAYOUT size differs from the layer size
269
- * (events scale by layer/layout; null layout means "already layer pixels",
270
- * which is what the built-in leaf uses).
487
+ * handlers for a leaf whose LAYOUT size differs from the layer size
488
+ * (events scale by layer/layout; a leaf laid out AT layer size just uses
489
+ * `handlers`). `layout` is read per event, so a resize-reactive layout
490
+ * just works - @solidrt/3d's handlersFor, one dimension down.
271
491
  */
272
- handlersFor(layout: (() => { width: number; height: number } | null) | null): SpriteHandlers
492
+ handlersFor(layout: () => { width: number; height: number }): SpriteHandlers
273
493
  dispose(): void
274
494
  _add(opts?: AddSpriteOptions): Sprite
275
- _write(sprite: Sprite, opts: SpriteOptions): void
495
+ _write(sprite: SpriteState, opts: SpriteOptions): void
276
496
  _read(sprite: Sprite): Required<SpriteOptions>
277
- _remove(sprite: Sprite): void
497
+ _remove(sprite: SpriteState): void
278
498
  _schedule(): void
279
499
  }
280
500
 
281
501
  export type SpriteLayer = LayerBase & {
282
502
  /**
283
- * Every sprite whose rotated rect overlaps the layer-pixel rect (the
284
- * core BVH overlap query, exact for rotated sprites), unordered - the
285
- * marquee query. Node layer only.
503
+ * Every shown sprite whose rotated rect overlaps the layer-pixel rect
504
+ * (the core BVH overlap query, exact for rotated sprites), unordered -
505
+ * the marquee query. Node layer only.
286
506
  */
287
- pickRect(x: number, y: number, w: number, h: number): Sprite[]
288
- _groups: Set<SpriteGroup>
289
- }
290
-
291
- /**
292
- * Pointer dispatch shared by both layer kinds: capture per pointer, hover
293
- * pairing, no bubbling (the sprite list is flat). Layout null = the leaf is
294
- * laid out at layer size, so localX/localY are layer pixels already (the
295
- * element hit test undid every ancestor transform - never getBoundingBox
296
- * here). Internal - layers expose the result as handlers/handlersFor.
297
- */
298
- export function spriteDispatch(state: {
299
- size: () => [number, number]
300
- camera: () => [number, number, number]
301
- pick: (x: number, y: number) => Sprite | null
302
- }): (layout: (() => { width: number; height: number } | null) | null) => SpriteHandlers {
303
- let capture = new Map<number, Sprite>()
304
- let hover = new Map<number, Sprite>()
305
- return layout => {
306
- let toLayer = (e: ElementPointerEvent): [number, number] => {
307
- let x = e.localX
308
- let y = e.localY
309
- let l = layout?.()
310
- let [width, height] = state.size()
311
- if (l && l.width > 0 && l.height > 0) {
312
- x *= width / l.width
313
- y *= height / l.height
314
- }
315
- // Undo the camera: screen -> world.
316
- let [camX, camY, camZoom] = state.camera()
317
- return [x / camZoom + camX, y / camZoom + camY]
318
- }
319
- let makeEvent = (sprite: Sprite, x: number, y: number, e: ElementPointerEvent): SpritePointerEvent => ({
320
- sprite,
321
- x,
322
- y,
323
- pointerId: e.pointerId,
324
- pointerType: e.pointerType,
325
- button: e.button,
326
- shiftKey: e.shiftKey,
327
- ctrlKey: e.ctrlKey,
328
- altKey: e.altKey,
329
- metaKey: e.metaKey,
330
- })
331
- return {
332
- onPointerDown(e) {
333
- let [x, y] = toLayer(e)
334
- let hit = state.pick(x, y)
335
- if (!hit) return
336
- capture.set(e.pointerId, hit)
337
- hit.onPointerDown?.(makeEvent(hit, x, y, e))
338
- },
339
- onPointerMove(e) {
340
- let [x, y] = toLayer(e)
341
- let captured = capture.get(e.pointerId)
342
- if (captured) {
343
- if (captured.layer) captured.onPointerMove?.(makeEvent(captured, x, y, e))
344
- return
345
- }
346
- let hit = state.pick(x, y)
347
- let prev = hover.get(e.pointerId) ?? null
348
- if (prev !== hit) {
349
- if (prev && prev.layer) prev.onPointerLeave?.(makeEvent(prev, x, y, e))
350
- if (hit) hit.onPointerEnter?.(makeEvent(hit, x, y, e))
351
- if (hit) hover.set(e.pointerId, hit)
352
- else hover.delete(e.pointerId)
353
- }
354
- hit?.onPointerMove?.(makeEvent(hit, x, y, e))
355
- },
356
- onPointerUp(e) {
357
- let [x, y] = toLayer(e)
358
- let captured = capture.get(e.pointerId)
359
- if (captured) {
360
- capture.delete(e.pointerId)
361
- if (captured.layer) captured.onPointerUp?.(makeEvent(captured, x, y, e))
362
- return
363
- }
364
- let hit = state.pick(x, y)
365
- hit?.onPointerUp?.(makeEvent(hit, x, y, e))
366
- },
367
- onPointerLeave(e) {
368
- let [x, y] = toLayer(e)
369
- let prev = hover.get(e.pointerId)
370
- if (prev) {
371
- hover.delete(e.pointerId)
372
- if (prev.layer) prev.onPointerLeave?.(makeEvent(prev, x, y, e))
373
- }
374
- },
375
- }
376
- }
507
+ pickRect(x: number, y: number, width: number, height: number): Sprite[]
508
+ _groups: Set<GroupState>
377
509
  }
378
510
 
379
511
  /** The stored UVs at `at` un-mirrored by the sprite's flags: the frame as
@@ -428,6 +560,9 @@ export function createSpriteLayer(
428
560
  let label = opts?.label ?? "sprites"
429
561
  let oversample = opts?.oversample ?? 1
430
562
  checkOversample("createSpriteLayer", oversample, width, height)
563
+ let tint = opts?.tint ?? [1, 1, 1, 1]
564
+ checkTint("createSpriteLayer", tint)
565
+ let thrash = thrashSentinel(`sprite layer "${label}"`)
431
566
  // One unit quad (triangle strip), reused by every instance.
432
567
  let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
433
568
  label: `${label}-quad`,
@@ -440,7 +575,7 @@ export function createSpriteLayer(
440
575
  FRAGMENT,
441
576
  width * oversample,
442
577
  height * oversample,
443
- { uViewport: [width, height], uCamera: [0, 0, 1, 1] },
578
+ { uViewport: [width, height], uCamera: [0, 0, 1, 1], uCameraRot: [1, 0, 0, 0], uTint: tint },
444
579
  {
445
580
  label,
446
581
  topology: "triangle-strip",
@@ -449,6 +584,16 @@ export function createSpriteLayer(
449
584
  buffer: quad,
450
585
  instanceAttributes: INSTANCE_ATTRIBUTES_SPLIT,
451
586
  instanceBuffers: [pose, style],
587
+ // "y" keys on world y in the pose record (slot 0), "renderOrder" on the
588
+ // app-owned key in the style record (slot 1); either way the core
589
+ // gathers BOTH buffers under the one permutation at every publish
590
+ // and republishes the sibling itself when the key buffer re-orders.
591
+ instanceOrder:
592
+ opts?.orderBy === "y"
593
+ ? { field: POSE_Y_FIELD }
594
+ : opts?.orderBy === "renderOrder"
595
+ ? { field: STYLE_KEY_FIELD, slot: 1 }
596
+ : undefined,
452
597
  instanceCount: 0,
453
598
  blend: "alpha",
454
599
  textures: { uAtlas: atlas },
@@ -461,6 +606,9 @@ export function createSpriteLayer(
461
606
  let camX = 0
462
607
  let camY = 0
463
608
  let camZoom = 1
609
+ let camRot = 0
610
+ let camPivotX = 0
611
+ let camPivotY = 0
464
612
  let disposed = false
465
613
  let scheduled = false
466
614
  let styleDirty = false
@@ -473,7 +621,7 @@ export function createSpriteLayer(
473
621
  let freeSlots: number[] = []
474
622
  let gpuCapacity = capacity
475
623
  let styleData = new Float32Array(capacity * STYLE_FLOATS)
476
- let byNode = new Map<NodeId, Sprite>()
624
+ let byNode = new Map<NodeId, SpriteState>()
477
625
 
478
626
  let flush = () => {
479
627
  scheduled = false
@@ -514,7 +662,7 @@ export function createSpriteLayer(
514
662
  styleDirty = true
515
663
  }
516
664
 
517
- let writeStyle = (sprite: Sprite, opts: SpriteOptions) => {
665
+ let writeStyle = (sprite: SpriteState, opts: SpriteOptions) => {
518
666
  let at = sprite._slot * STYLE_FLOATS
519
667
  let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
520
668
  let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
@@ -536,13 +684,13 @@ export function createSpriteLayer(
536
684
  styleData[at + 7] = opts.tint[3]
537
685
  styleDirty = true
538
686
  }
687
+ if (opts.renderOrder !== undefined) {
688
+ styleData[at + STYLE_KEY_FIELD] = opts.renderOrder
689
+ styleDirty = true
690
+ }
539
691
  }
540
692
 
541
- let dispatch = spriteDispatch({
542
- size: () => [width, height],
543
- camera: () => [camX, camY, camZoom],
544
- pick: (x, y) => layer.pick(x, y),
545
- })
693
+ let listeners = new Set<LayerPointerListener>()
546
694
 
547
695
  let layer: SpriteLayer = {
548
696
  texture,
@@ -550,6 +698,12 @@ export function createSpriteLayer(
550
698
  get count() {
551
699
  return byNode.size
552
700
  },
701
+ get width() {
702
+ return width
703
+ },
704
+ get height() {
705
+ return height
706
+ },
553
707
  setSize(w, h) {
554
708
  if (disposed || (w === width && h === height)) return
555
709
  checkOversample("setSize", oversample, w, h)
@@ -558,24 +712,49 @@ export function createSpriteLayer(
558
712
  setTargetSize(texture, w * oversample, h * oversample)
559
713
  setTargetParams(texture, { uViewport: [w, h] })
560
714
  },
715
+ listen(listener) {
716
+ listeners.add(listener)
717
+ return () => {
718
+ listeners.delete(listener)
719
+ }
720
+ },
561
721
  get oversample() {
562
722
  return oversample
563
723
  },
564
724
  setOversample(n) {
565
725
  if (disposed || n === oversample) return
566
726
  checkOversample("setOversample", n, width, height)
727
+ thrash()
567
728
  oversample = n
568
729
  setTargetSize(texture, width * n, height * n)
569
730
  },
570
731
  setCamera(update) {
571
732
  if (disposed) return
733
+ checkCamera(update)
572
734
  if (update.x !== undefined) camX = update.x
573
735
  if (update.y !== undefined) camY = update.y
574
- if (update.zoom !== undefined) {
575
- if (!(update.zoom > 0)) throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`)
576
- camZoom = update.zoom
577
- }
578
- setTargetParams(texture, { uCamera: [camX, camY, camZoom, camZoom] })
736
+ if (update.zoom !== undefined) camZoom = update.zoom
737
+ if (update.rotation !== undefined) camRot = update.rotation
738
+ if (update.pivotX !== undefined) camPivotX = update.pivotX
739
+ if (update.pivotY !== undefined) camPivotY = update.pivotY
740
+ setTargetParams(texture, {
741
+ uCamera: [camX, camY, camZoom, camZoom],
742
+ uCameraRot: [Math.cos(camRot), Math.sin(camRot), camPivotX, camPivotY],
743
+ })
744
+ },
745
+ camera() {
746
+ return { x: camX, y: camY, zoom: camZoom, rotation: camRot, pivotX: camPivotX, pivotY: camPivotY }
747
+ },
748
+ project(x, y) {
749
+ return projectCamera(layer.camera(), x, y)
750
+ },
751
+ unproject(x, y) {
752
+ return unprojectCamera(layer.camera(), x, y)
753
+ },
754
+ setTint(next) {
755
+ if (disposed) return
756
+ checkTint("setTint", next)
757
+ setTargetParams(texture, { uTint: next })
579
758
  },
580
759
  pick(x, y) {
581
760
  // The index reads as of the last core flush; run any pending batch
@@ -584,26 +763,29 @@ export function createSpriteLayer(
584
763
  RAY_ORIGIN[0] = x
585
764
  RAY_ORIGIN[1] = y
586
765
  RAY_ORIGIN[2] = -1
587
- let best: Sprite | null = null
766
+ let out: Sprite[] = []
588
767
  for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR)) {
589
768
  // The arena is shared (a 3d scene lives in the same index): only
590
- // this layer's nodes count. Topmost = highest slot (draw order).
769
+ // this layer's nodes count.
591
770
  let sprite = byNode.get(hit.node)
592
- if (sprite && (best === null || sprite._slot > best._slot)) best = sprite
771
+ if (sprite) out.push(sprite)
593
772
  }
594
- return best
773
+ // Topmost first: higher slot = drawn later = on top.
774
+ return out.sort((a, b) => b._slot - a._slot)
595
775
  },
596
- pickRect(x, y, w, h) {
776
+ pickRect(x, y, width, height) {
597
777
  if (scheduled) flush()
598
- BOX[0] = x
599
- BOX[1] = y
600
- BOX[2] = -1
601
- BOX[3] = x + w
602
- BOX[4] = y + h
778
+ // The marquee as a "box" volume: center, half extents, no rotation,
779
+ // a unit deep so every sprite plane (z = 0) lies inside it.
780
+ BOX[0] = x + width / 2
781
+ BOX[1] = y + height / 2
782
+ BOX[2] = 0
783
+ BOX[3] = width / 2
784
+ BOX[4] = height / 2
603
785
  BOX[5] = 1
604
786
  let out: Sprite[] = []
605
- for (let node of spatial.overlap(BOX)) {
606
- let sprite = byNode.get(node)
787
+ for (let hit of spatial.overlap("box", BOX)) {
788
+ let sprite = byNode.get(hit.node)
607
789
  if (sprite) out.push(sprite)
608
790
  }
609
791
  return out
@@ -614,6 +796,7 @@ export function createSpriteLayer(
614
796
  dispose() {
615
797
  if (disposed) return
616
798
  disposed = true
799
+ listeners.clear()
617
800
  for (let sprite of byNode.values()) {
618
801
  sprite.layer = null
619
802
  declared.delete(sprite.node!)
@@ -638,7 +821,7 @@ export function createSpriteLayer(
638
821
  if (disposed) throw new Error("addSprite: layer is disposed")
639
822
  let slot = freeSlots.pop() ?? highWater++
640
823
  if (slot >= gpuCapacity) grow(gpuCapacity * 2)
641
- let sprite: Sprite = {
824
+ let sprite: SpriteState = {
642
825
  layer,
643
826
  node: null,
644
827
  _slot: slot,
@@ -649,18 +832,23 @@ export function createSpriteLayer(
649
832
  _rot: opts?.rotation ?? 0,
650
833
  _flipX: false,
651
834
  _flipY: false,
835
+ _visible: opts?.visible ?? true,
836
+ _parent: opts?.parent ?? null,
652
837
  }
653
838
  fillTransform(sprite._x, sprite._y, sprite._rot, sprite._w, sprite._h)
654
- let node = spatial.createNode(TRANSFORM, true)
839
+ let node = spatial.createNode(TRANSFORM, sprite._visible)
655
840
  sprite.node = node
656
841
  if (opts?.parent) {
657
842
  if (opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
658
843
  spatial.setParent(node, opts.parent.node)
844
+ opts.parent._children.add(sprite)
659
845
  }
660
846
  spatial.setBounds(node, FLAT_BOUNDS)
661
847
  spatial.bindPoseRecord(node, pose, slot)
662
848
  byNode.set(node, sprite)
663
- writeStyle(sprite, { frame: FULL_FRAME, tint: [1, 1, 1, 1], ...opts })
849
+ // renderOrder defaults to 0 explicitly: a recycled slot holds the
850
+ // previous occupant's key otherwise.
851
+ writeStyle(sprite, { frame: FULL_FRAME, tint: [1, 1, 1, 1], renderOrder: 0, ...opts })
664
852
  layer._schedule()
665
853
  return sprite
666
854
  },
@@ -672,7 +860,18 @@ export function createSpriteLayer(
672
860
  if (opts.h !== undefined && opts.h !== sprite._h) (sprite._h = opts.h), (moved = true)
673
861
  if (opts.rotation !== undefined && opts.rotation !== sprite._rot) (sprite._rot = opts.rotation), (moved = true)
674
862
  if (moved) writeTransform(sprite)
675
- if (opts.frame !== undefined || opts.tint !== undefined || opts.flipX !== undefined || opts.flipY !== undefined) {
863
+ if (opts.visible !== undefined && opts.visible !== sprite._visible) {
864
+ sprite._visible = opts.visible
865
+ spatial.setVisible(sprite.node!, opts.visible)
866
+ moved = true
867
+ }
868
+ if (
869
+ opts.frame !== undefined ||
870
+ opts.tint !== undefined ||
871
+ opts.flipX !== undefined ||
872
+ opts.flipY !== undefined ||
873
+ opts.renderOrder !== undefined
874
+ ) {
676
875
  writeStyle(sprite, opts)
677
876
  }
678
877
  if (moved || styleDirty) layer._schedule()
@@ -689,10 +888,16 @@ export function createSpriteLayer(
689
888
  flipY: sprite._flipY,
690
889
  rotation: sprite._rot,
691
890
  tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
891
+ renderOrder: styleData[at + STYLE_KEY_FIELD]!,
892
+ visible: sprite._visible,
692
893
  }
693
894
  },
694
895
  _remove(sprite) {
695
896
  sprite.layer = null
897
+ if (sprite._parent) {
898
+ sprite._parent._children.delete(sprite)
899
+ sprite._parent = null
900
+ }
696
901
  // Destroying the node zeroes its pose slot at the next core flush
697
902
  // (zero scale = nothing drawn); the slot then recycles.
698
903
  byNode.delete(sprite.node!)
@@ -708,6 +913,13 @@ export function createSpriteLayer(
708
913
  },
709
914
  _groups: new Set(),
710
915
  }
916
+ let dispatch = spriteDispatch({
917
+ size: () => [width, height],
918
+ camera: () => layer.camera(),
919
+ pick: (x, y) => layer.pick(x, y),
920
+ root: layer,
921
+ listeners,
922
+ })
711
923
  layer.handlers = dispatch(null)
712
924
 
713
925
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
@@ -719,7 +931,8 @@ export function createSpriteLayer(
719
931
  * order, and a removed sprite's slot recycles to the next add - so unlike
720
932
  * the record layer there is no painter's-insertion-order guarantee across
721
933
  * removals. Opaque-or-transparent pixel art (the overwhelming case) never
722
- * notices; z-ordered translucency is the sort-key backlog item. Past the
934
+ * notices; a scene that needs depth order sorts by world y with the
935
+ * layer's `orderBy: "y"` (core-produced, zero JS per frame). Past the
723
936
  * layer's reservation both instance buffers double (pose sinks move in one
724
937
  * core retarget); reserve with `capacity` to avoid the copies.
725
938
  */
@@ -745,10 +958,33 @@ export function removeSprite(sprite: Sprite): void {
745
958
  sprite.layer?._remove(sprite)
746
959
  }
747
960
 
961
+ /**
962
+ * A sprite's or group's position in LAYER pixels: its local x/y composed
963
+ * through every enclosing group (@solidrt/3d's worldPosition, Three's
964
+ * getWorldPosition), read from the core's world matrix as the tree stands
965
+ * now, pending writes included. Under a transition this is the
966
+ * mid-flight pose - what picking and the screen show - where the handle's
967
+ * own fields hold the target. A record layer's sprite has no groups, so
968
+ * its world position is its own, read as getSprite reads it (the records
969
+ * array). A fresh pair per call; null once the handle is inert.
970
+ */
971
+ export function worldPosition(target: Sprite | SpriteGroup): [number, number] | null {
972
+ if (target.layer === null) return null
973
+ // The set only groups carry tells the two handle kinds apart (as in
974
+ // removeGroup); a node-less sprite is a record layer's.
975
+ if (!("_children" in target) && target.node === null) {
976
+ let s = target.layer._read(target)
977
+ return [s.x, s.y]
978
+ }
979
+ spatial.worldMatrix(target.node!, WORLD)
980
+ return [WORLD[12]!, WORLD[13]!]
981
+ }
982
+
748
983
  /**
749
984
  * Re-parent a sprite under a group (null = back to the layer root); its
750
985
  * pose fields then read in the new parent's frame, where the sprite keeps
751
- * them (it holds its local pose, not its world pose). Node layer only.
986
+ * them (it holds its local pose, not its world pose; worldPosition reads
987
+ * the composed one). Node layer only.
752
988
  */
753
989
  export function setSpriteParent(sprite: Sprite, parent: SpriteGroup | null): void {
754
990
  let layer = sprite.layer
@@ -756,6 +992,10 @@ export function setSpriteParent(sprite: Sprite, parent: SpriteGroup | null): voi
756
992
  if (sprite.node === null) throw new Error("setSpriteParent: record layers have no groups")
757
993
  if (parent && parent.layer !== layer) throw new Error("setSpriteParent: group belongs to another layer")
758
994
  spatial.setParent(sprite.node, parent ? parent.node : null)
995
+ let s: SpriteState = sprite
996
+ if (s._parent) s._parent._children.delete(s)
997
+ s._parent = parent
998
+ if (parent) parent._children.add(s)
759
999
  layer._schedule()
760
1000
  }
761
1001
 
@@ -796,12 +1036,16 @@ export function addGroup(layer: SpriteLayer, opts?: GroupOptions): SpriteGroup {
796
1036
  _y: opts?.y ?? 0,
797
1037
  _rot: opts?.rotation ?? 0,
798
1038
  _scale: opts?.scale ?? 1,
799
- } as SpriteGroup
1039
+ _visible: opts?.visible ?? true,
1040
+ _parent: opts?.parent ?? null,
1041
+ _children: new Set(),
1042
+ } as GroupState
800
1043
  writeGroupTransform(group)
801
- group.node = spatial.createNode(TRANSFORM, true)
1044
+ group.node = spatial.createNode(TRANSFORM, group._visible)
802
1045
  if (opts?.parent) {
803
1046
  if (opts.parent.layer !== layer) throw new Error("addGroup: parent group belongs to another layer")
804
1047
  spatial.setParent(group.node, opts.parent.node)
1048
+ opts.parent._children.add(group)
805
1049
  }
806
1050
  layer._groups.add(group)
807
1051
  layer._schedule()
@@ -816,33 +1060,57 @@ function writeGroupTransform(group: SpriteGroup): void {
816
1060
  export function setGroup(group: SpriteGroup, opts: GroupOptions): void {
817
1061
  let layer = group.layer
818
1062
  if (!layer) return
1063
+ let g: GroupState = group
819
1064
  let moved = false
820
- if (opts.x !== undefined && opts.x !== group._x) (group._x = opts.x), (moved = true)
821
- if (opts.y !== undefined && opts.y !== group._y) (group._y = opts.y), (moved = true)
822
- if (opts.rotation !== undefined && opts.rotation !== group._rot) (group._rot = opts.rotation), (moved = true)
823
- if (opts.scale !== undefined && opts.scale !== group._scale) (group._scale = opts.scale), (moved = true)
1065
+ if (opts.x !== undefined && opts.x !== g._x) (g._x = opts.x), (moved = true)
1066
+ if (opts.y !== undefined && opts.y !== g._y) (g._y = opts.y), (moved = true)
1067
+ if (opts.rotation !== undefined && opts.rotation !== g._rot) (g._rot = opts.rotation), (moved = true)
1068
+ if (opts.scale !== undefined && opts.scale !== g._scale) (g._scale = opts.scale), (moved = true)
824
1069
  if (moved) {
825
1070
  writeGroupTransform(group)
826
1071
  spatial.writeTransform(group.node, TRANSFORM)
827
1072
  }
1073
+ if (opts.visible !== undefined && opts.visible !== g._visible) {
1074
+ g._visible = opts.visible
1075
+ spatial.setVisible(group.node, opts.visible)
1076
+ moved = true
1077
+ }
828
1078
  if (opts.parent !== undefined) {
829
1079
  if (opts.parent && opts.parent.layer !== layer) throw new Error("setGroup: parent group belongs to another layer")
830
1080
  spatial.setParent(group.node, opts.parent ? opts.parent.node : null)
1081
+ if (g._parent) g._parent._children.delete(g)
1082
+ g._parent = opts.parent
1083
+ if (opts.parent) opts.parent._children.add(g)
831
1084
  moved = true
832
1085
  }
833
1086
  if (moved) layer._schedule()
834
1087
  }
835
1088
 
836
1089
  /**
837
- * Remove a group: its children (sprites and groups) become layer roots and
838
- * KEEP THEIR LOCAL POSE, so they jump to root frame unless the caller
839
- * removes or re-parents them too (a component tree unmounts children first
840
- * and never sees this). The handle goes inert.
1090
+ * Remove a group AND everything under it: child sprites and groups are
1091
+ * removed with it - Unity's Destroy, Godot's free, the subtree form of
1092
+ * removeSprite. To keep a child, re-parent it out first (setSpriteParent /
1093
+ * setGroup's `parent`). Every removed handle goes inert. A component tree
1094
+ * unmounts children first and never sees the recursion. (@solidrt/3d's
1095
+ * `remove` DETACHES a re-addable subtree instead - its nodes exist outside
1096
+ * a scene; a sprite cannot exist outside its layer, so here remove means
1097
+ * destroy, exactly as it does for removeSprite.)
841
1098
  */
842
1099
  export function removeGroup(group: SpriteGroup): void {
843
1100
  let layer = group.layer
844
1101
  if (!layer) return
845
- group.layer = null
1102
+ let g: GroupState = group
1103
+ // Children first, over a snapshot (each removal edits _children); the
1104
+ // set only groups carry tells the two handle kinds apart.
1105
+ for (let child of [...g._children]) {
1106
+ if ("_children" in child) removeGroup(child)
1107
+ else removeSprite(child)
1108
+ }
1109
+ g.layer = null
1110
+ if (g._parent) {
1111
+ g._parent._children.delete(g)
1112
+ g._parent = null
1113
+ }
846
1114
  layer._groups.delete(group)
847
1115
  declared.delete(group.node)
848
1116
  spatial.destroyNode(group.node)