@solidrt/2d 0.0.58 → 0.0.59

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/AGENTS.md CHANGED
@@ -55,7 +55,7 @@ Contents:
55
55
  ### Three faces
56
56
 
57
57
  THREE faces, layered: the node-backed live layer (layer.ts:
58
- `createSpriteLayer`/`addSprite`/`setSprite`/`removeSprite` plus
58
+ `createSpriteLayer`/`addSprite`/`setSprite`/`destroySprite` plus
59
59
  `addGroup`/`setGroup`/`setSpriteParent`/`setSpriteTransition`/
60
60
  `setGroupTransition` - plain objects, no signals, usable without
61
61
  components), the records layer (records.ts: `createRecordLayer` - the
@@ -95,7 +95,7 @@ real draw order says so with `orderBy` ("y" or "renderOrder" - see below).
95
95
 
96
96
  Growth (past `capacity`, doubling): pose sinks move in ONE core
97
97
  `retargetRecords` call (full republish next flush), style re-uploads,
98
- `setDraw({ instanceBuffers })` swaps both, old buffers destroyed.
98
+ `setDrawBuffers({ buffers })` swaps both, old buffers destroyed.
99
99
 
100
100
  ### Picking
101
101
 
@@ -139,11 +139,14 @@ the mover stay in the plane with no 2d narrowphase of their own.
139
139
  Groups (`addGroup`/`<Group>`) are plain arena nodes (x, y, rotation,
140
140
  UNIFORM scale - a group is a frame, never a sprite size; sprite w/h
141
141
  lives in the sprite node's scale, which is why sprites cannot parent
142
- sprites). Child sprite pose fields are local to the group. `removeGroup`
143
- removes the SUBTREE: child sprites and groups die with it (remove means
144
- destroy here, as for removeSprite; re-parent a child out first to keep
145
- it). @solidrt/3d's `remove` instead DETACHES a re-addable subtree - its
146
- nodes exist outside a scene, sprites cannot exist outside their layer.
142
+ sprites). Child sprite pose fields are local to the group. `destroyGroup`
143
+ destroys the SUBTREE: child sprites and groups die with it (re-parent a
144
+ child out first to keep it), children first so each plays its own
145
+ `exit` and the group stays their frame until the last has settled. One
146
+ removal verb, the same word with the same meaning as @solidrt/3d's
147
+ `destroy`: a sprite cannot exist outside its layer, so there is no
148
+ detach here (3d's `remove`), and a sprite that should come back is
149
+ hidden (`visible: false`).
147
150
  A handle's x/y are local to its group; `worldPosition(sprite | group)`
148
151
  reads the layer-pixel position composed through every enclosing group
149
152
  from the core's world matrix (@solidrt/3d's worldPosition).
@@ -186,7 +189,7 @@ the WRITE path only - whatever computes the motion is excluded and is
186
189
  usually the dominant cost, e.g. a 24k-particle sim measured ~25ms with
187
190
  a near-free publish) - the axis is
188
191
  WHERE MOTION IS COMPUTED, not retained-vs-dynamic. The sprite functions
189
- (addSprite/setSprite/getSprite/removeSprite) work on both layer kinds;
192
+ (addSprite/setSprite/getSprite/destroySprite) work on both layer kinds;
190
193
  record sprites have `node: null` and no groups.
191
194
 
192
195
  ### Layer space
@@ -216,7 +219,7 @@ functions over it.
216
219
  Camera control: `createCamera2d(view | views, { viewport: () =>
217
220
  ({ width, height }), world?: { width, height }, min/maxZoom?, pivot?,
218
221
  deadZone?: { width, height }, panSpeed?, zoomSpeed?, rollSpeed?,
219
- followSpeed?, inertia?, x?, y?, zoom?, rotation? })` - Godot's Camera2D
222
+ damping?, followSpeed?, inertia?, x?, y?, zoom?, rotation? })` - Godot's Camera2D
220
223
  and Three's MapControls in one control over the shared CameraUpdate:
221
224
  pan with inertia on release, zoom about a point, eased `glideTo`/`fit`,
222
225
  `follow(x, y)` through a dead zone with damping, roll about the pivot,
@@ -242,7 +245,9 @@ finger; a rate slides at one viewport height per second), `zoom` (axis,
242
245
  octaves, positive in: a delta bracketed by a gesture - a pinch - applies
243
246
  at once about its focal point, an unbracketed one - a wheel notch -
244
247
  retargets an eased glide, which is how the control tells a finger from
245
- an impulse; notches compound on the pending target) and `roll` (axis,
248
+ an impulse; notches compound on the pending target, and `damping` scales
249
+ the settle time - 0 applies a notch at once - the 3d controls' knob) and
250
+ `roll` (axis,
246
251
  turns); a pan gesture's begin stops any glide (a finger landing on a
247
252
  gliding view holds it) and its end flings with the drag's velocity. The
248
253
  verbs, each pushing the pose at once: `panBy(dx, dy)` screen pixels,
@@ -274,7 +279,8 @@ inertia and the axis rates, pushes one setCamera per driven view when
274
279
  the pose changed and reports that), gated on the reactive
275
280
  `cam.active()` (true while a glide, fling, fit, follow or pan gesture
276
281
  needs frames, or a rate drives; false at rest), read `cam.camera()` for
277
- projectCamera. The camera has NO tap of its own: taps are the
282
+ projectCamera and `cam.pose()` for the control's own four values (the
283
+ shape `set()` takes, the 3d controls' `pose()`). The camera has NO tap of its own: taps are the
278
284
  dispatch's (`onTap` on the root with `e.sprite` null is "tap on empty
279
285
  space"). `<Camera2d input={input}>` inside `<SpriteLayer>` is all of
280
286
  that wired through context, the 3d `<OrbitCamera>` shape: options read
@@ -357,13 +363,36 @@ moving the same population imperatively). Retargeting every frame is
357
363
  also a legitimate pattern, not an abuse: rewriting a spring's TARGET
358
364
  each frame to chase a moving point (a follow-camera trailing a moving
359
365
  sprite) rides the spring's smoothing for free - a spring keeps its
360
- velocity through retargets, so the chase stays fluid. Mount poses always snap (the
361
- component declares the transition after the first pose sync; the
362
- function face sets it after addSprite). Each natural settle calls the
363
- handle's `onTransitionEnd` (plain field, or the `<Sprite>`/`<Group>` prop)
364
- with `{ component }` - target-only, never on a cancel, snap or removal;
365
- the raw "spatialTransitionEnd" engine event (srt:events, node =
366
- sprite.node) stays for flux:spatial consumers. See examples/springs.tsx.
366
+ velocity through retargets, so the chase stays fluid. A mount pose snaps
367
+ unless a component's `from` (2d units: `position: { from: [x, y] }`,
368
+ `rotation: { from: angle }`, `scale: { from: [w, h] }`, a group's
369
+ `from: s`) animates the sprite in from there - once, and only when the
370
+ declaration lands in the tick that added the sprite (the `<Sprite>` prop
371
+ or a setSpriteTransition right after addSprite; a later one animates
372
+ writes only). A component's `exit` (same units) is where it animates to
373
+ when destroySprite/destroyGroup lets go of it: the sprite stays drawn
374
+ while it leaves and is a GHOST meanwhile - no pick, raycast, overlap,
375
+ sweep or pointer event sees it - then frees when the last exit settles
376
+ (a bullet never hits a corpse; an entering sprite is live from its
377
+ first frame, whatever `from` it passes through). Either endpoint takes
378
+ the object form `{ value, duration?, curve?, bounce?, delay? }` to own
379
+ its direction's motion (an ease-out enter, an ease-in exit), and `delay`
380
+ on an entry holds its writes that long on the animation clock (a late
381
+ frame catches up, so a hitch never shifts a held start). `stagger` (ms)
382
+ goes on a GROUP's declaration (`setGroupTransition` / `<Group
383
+ transition>`), never on the sprites: it spaces the enters and exits of
384
+ everything under the group that begin in one frame by `index * stagger`
385
+ (add order in, tree order out whatever the destroy order; nearest
386
+ declaring group wins), and
387
+ cascades nothing unless the sprites declare `from`/`exit` - a hand of
388
+ cards fanning in, a menu popping in, is one `stagger` on the group. For
389
+ the sprites straight under the layer (no group) the layer's root carries
390
+ it: `createSpriteLayer(texture, { stagger })` / `layer.setStagger(ms)`.
391
+ Each natural settle calls the handle's `onTransitionEnd` (plain field,
392
+ or the `<Sprite>`/`<Group>` prop) with `{ component }` - target-only,
393
+ never on a cancel, snap or exit; the raw "spatialTransitionEnd" engine
394
+ event (srt:events, node = sprite.node) stays for flux:spatial consumers.
395
+ See examples/springs.tsx (tap a sprite: it leaves through its `exit`).
367
396
 
368
397
  ### Frame-rate motion
369
398
 
@@ -378,8 +407,9 @@ re-runs an effect per sprite per frame for nothing.
378
407
 
379
408
  Frame animation (animation.ts): `createAnimation(frames, fps, { loop })`
380
409
  is a clip with a shared wall-clock timer stepping every attached sprite
381
- (`anim.add(sprite)` / `remove`; `play`/`pause`; `loop: false` holds the
382
- last frame and fires `onFinish`, the mixer's name for it in
410
+ (`anim.add(sprite)` / `remove`; `play`/`pause`; `frame()`/`playing()`
411
+ read the clock, methods like the 3d mixer's `playing()`; `loop: false`
412
+ holds the last frame and fires `onFinish`, the mixer's name for it in
383
413
  @solidrt/3d). One timer per playing clip, one setSprite
384
414
  per sprite per STEP - an 8fps cycle is 8 publishes a second regardless of
385
415
  display rate, and a paused clip costs nothing. A sprite belongs to at
@@ -482,7 +512,9 @@ on approach, evict) - okf/backlog/2d-baked-layers.md.
482
512
 
483
513
  `SpriteLayer` owns the layer and its own view, rendered as the built-in
484
514
  `<texture>` leaf carrying the view's pointer handlers (opt out with
485
- `events={false}`; compose yourself with `output`, then spread
515
+ `events={false}`, which throws at mount together with a `pointer` feed -
516
+ the feed would listen at a root no event reaches; the same on `<View2d>`;
517
+ compose yourself with `output`, then spread
486
518
  `useSpriteLayer().viewport.handlers` onto your leaf; `output={false}` for
487
519
  no own view at all). `useSpriteLayer()` returns `{ layer, parent,
488
520
  viewport, pointer }` - the same shape as `useScene()` in `@solidrt/3d` -
@@ -611,7 +643,7 @@ hover, wheel and tap rules headless.
611
643
  a hoisted reference becomes a dead copy whose writes publish nothing.
612
644
  `layer.withRecords(fn)` is the hoist-proof read; a bare `layer.records`
613
645
  at use time is equally live.
614
- - Records layer: record order is draw order: `removeSprite` shifts every
646
+ - Records layer: record order is draw order: `destroySprite` shifts every
615
647
  later sprite down one slot (copyWithin + index fixup, O(later
616
648
  sprites)). Its flush publishes the WHOLE live prefix, not a dirty
617
649
  range: one moved sprite re-publishes count x 52 bytes - a single
@@ -650,7 +682,7 @@ hover, wheel and tap rules headless.
650
682
  ### Nodes, transitions and queries
651
683
 
652
684
  - Node layer: `sprite.node` is public FOR BINDING PRODUCERS, not for
653
- lifecycle - never destroyNode it yourself (removeSprite owns that), and
685
+ lifecycle - never destroyNode it yourself (destroySprite owns that), and
654
686
  a transform written through flux:spatial directly bypasses the sprite's
655
687
  pose mirror, so a later setSprite with the old x wins (its compare sees
656
688
  no change to skip, but partial writes compose from the mirror).
package/examples/anim.tsx CHANGED
@@ -62,15 +62,15 @@ function App() {
62
62
  }, period / 2)
63
63
  setTimeout(() => {
64
64
  check(shownIndex(looped[1]!) === 1, "loop advanced to frame 1")
65
- check(looping.frame === shownIndex(looped[1]!), "anim.frame matches the sprite's shown frame")
65
+ check(looping.frame() === shownIndex(looped[1]!), "anim.frame matches the sprite's shown frame")
66
66
  }, period * 1.5)
67
67
  setTimeout(() => {
68
68
  check(shownIndex(looped[2]!) === 0, "loop wrapped back to frame 0 after a full cycle")
69
69
  // One-shot: 4 frames at 5fps end at 800ms - long since done here.
70
70
  check(shownIndex(shotSprite) === frames.length - 1, "one-shot holds its last frame")
71
- check(!shot.playing, "one-shot stopped playing")
71
+ check(!shot.playing(), "one-shot stopped playing")
72
72
  check(ended === 1, `onFinish fired once, got ${ended}`)
73
- check(looping.playing, "the looping clip is still playing")
73
+ check(looping.playing(), "the looping clip is still playing")
74
74
  console.log(failures.length === 0 ? "ANIM-OK" : `ANIM-FAIL: ${failures.join("; ")}`)
75
75
  }, period * 4.5)
76
76
 
@@ -17,7 +17,7 @@ import {
17
17
  createSpriteLayer,
18
18
  getSprite,
19
19
  grid,
20
- removeSprite,
20
+ destroySprite,
21
21
  setGroup,
22
22
  setSprite,
23
23
  } from "@solidrt/2d"
@@ -83,11 +83,11 @@ function App() {
83
83
  {
84
84
  let extra = addSprite(nodes, { x: 10, y: 10, w: 8, h: 8 })
85
85
  let slotBefore = extra._slot
86
- removeSprite(extra)
86
+ destroySprite(extra)
87
87
  let reused = addSprite(nodes, { x: -100, y: -100, w: 0, h: 0 })
88
88
  check(reused._slot === slotBefore, "removed slot recycles to the next add")
89
89
  check(getSprite(extra) === null, "removed handle is inert")
90
- removeSprite(reused)
90
+ destroySprite(reused)
91
91
  }
92
92
 
93
93
  let pixelParity = () => {
@@ -158,7 +158,7 @@ function App() {
158
158
  check(nodes.pick(150, 100)[0] !== child, "the ungrouped position no longer hits")
159
159
  let f = getSprite(child)!
160
160
  check(f.x === 50 && f.y === 0, "getSprite reads the local pose")
161
- removeSprite(child)
161
+ destroySprite(child)
162
162
  // The group node stays for the layer to dispose.
163
163
  })
164
164
  }
@@ -13,8 +13,15 @@
13
13
  // random angle always takes the short arc, and the position spring keeps
14
14
  // its velocity when a shuffle lands mid-flight - retarget as fast as you
15
15
  // like, the motion stays continuous.
16
+ //
17
+ // Tap a sprite and it LEAVES: destroySprite lets go of the handle, and the
18
+ // `exit` on its scale entry shrinks it away on an ease-in of its own while
19
+ // the sprite is a ghost - drawn, but no tap or pick reaches it - then the
20
+ // core frees it. A fresh sprite pops into the empty slot a moment later,
21
+ // entering from scale zero (`from`); no settle callback, no bookkeeping.
16
22
  import { decodeImage, render } from "@solidrt/core"
17
- import { addSprite, createAtlas, createSpriteLayer, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
23
+ import { addSprite, createAtlas, createSpriteLayer, destroySprite, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
24
+ import type { SpriteHandle, SpriteTransition } from "@solidrt/2d"
18
25
  import logoBytes from "./logo.png" with { type: "binary" }
19
26
 
20
27
  const COLS = 20
@@ -23,6 +30,16 @@ const COUNT = COLS * ROWS
23
30
  const W = 720
24
31
  const H = 720
25
32
  const SPRITE = 30
33
+ // The pop-in / shrink-out of a sprite's size, and the pause before a
34
+ // tapped-away sprite is replaced.
35
+ const POP_MS = 300
36
+ const RESPAWN_MS = 600
37
+
38
+ const TRANSITION: SpriteTransition = {
39
+ position: { duration: 700, bounce: 0.3 },
40
+ rotation: { duration: 700 },
41
+ scale: { duration: POP_MS, bounce: 0.4, from: [0, 0], exit: { value: [0, 0], curve: "ease-in", duration: POP_MS / 2 } },
42
+ }
26
43
 
27
44
  function App() {
28
45
  let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
@@ -35,12 +52,17 @@ function App() {
35
52
 
36
53
  // Sprite k sits at grid slot slots[k]; each shuffle re-deals the slots.
37
54
  let slots = Array.from({ length: COUNT }, (_, i) => i)
38
- let sprites = slots.map(slot =>
39
- addSprite(layer, { x: slotX(slot), y: slotY(slot), w: SPRITE, h: SPRITE, frame: frames[slot % 4] }),
40
- )
41
- for (let sprite of sprites) {
42
- setSpriteTransition(sprite, { position: { duration: 700, bounce: 0.3 }, rotation: { duration: 700 } })
55
+ let spawn = (k: number): SpriteHandle => {
56
+ let sprite = addSprite(layer, { x: slotX(slots[k]!), y: slotY(slots[k]!), w: SPRITE, h: SPRITE, frame: frames[k % 4] })
57
+ // Declared in the adding tick, so the `from` plays: the sprite pops in.
58
+ setSpriteTransition(sprite, TRANSITION)
59
+ sprite.onTap = () => {
60
+ destroySprite(sprite)
61
+ setTimeout(() => (sprites[k] = spawn(k)), RESPAWN_MS)
62
+ }
63
+ return sprite
43
64
  }
65
+ let sprites = slots.map((_, k) => spawn(k))
44
66
 
45
67
  let shuffle = () => {
46
68
  for (let i = slots.length - 1; i > 0; i--) {
@@ -58,7 +80,7 @@ function App() {
58
80
 
59
81
  return (
60
82
  <window alignItems="center" justifyContent="center">
61
- <texture src={view.texture} width={W} height={H} />
83
+ <texture src={view.texture} width={W} height={H} {...view.handlers} />
62
84
  </window>
63
85
  )
64
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.58",
3
+ "version": "0.0.59",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -15,7 +15,7 @@
15
15
  "AGENTS.md"
16
16
  ],
17
17
  "peerDependencies": {
18
- "@solidjs/signals": "2.0.0-rc.6",
19
- "@solidrt/core": "0.0.58"
18
+ "@solidjs/signals": "2.0.0-rc.8",
19
+ "@solidrt/core": "0.0.59"
20
20
  }
21
21
  }
package/src/animation.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // playing clip and one frame write per attached sprite per STEP (not per
4
4
  // display frame), so an 8fps walk cycle generates 8 publishes a second and
5
5
  // a paused clip costs nothing - the demand-gate story unchanged. The clip
6
- // does not own its sprites (removeSprite prunes lazily on the next step),
6
+ // does not own its sprites (destroySprite prunes lazily on the next step),
7
7
  // and a sprite belongs to at most one animation. Plain JS over setSprite,
8
8
  // so it works on both layer kinds and composes with <Sprite> via ref (leave
9
9
  // the frame prop off - the clip owns that field).
@@ -28,10 +28,10 @@ export type AnimationOptions = {
28
28
 
29
29
  export type SpriteAnimation = {
30
30
  /** Current frame index into the clip. */
31
- readonly frame: number
31
+ frame(): number
32
32
  /** True while the clock advances: born playing, false after pause() and
33
33
  * after a one-shot ends. */
34
- readonly playing: boolean
34
+ playing(): boolean
35
35
  /**
36
36
  * Attach a sprite: it shows the clip's current frame immediately and
37
37
  * steps with the shared clock (every attached sprite shows the SAME
@@ -127,12 +127,8 @@ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOp
127
127
  }
128
128
 
129
129
  let handle: SpriteAnimation = {
130
- get frame() {
131
- return current
132
- },
133
- get playing() {
134
- return playing
135
- },
130
+ frame: () => current,
131
+ playing: () => playing,
136
132
  onFinish: undefined,
137
133
  add(sprite) {
138
134
  if (disposed) throw new Error("createAnimation: add on a disposed animation")
package/src/camera2d.ts CHANGED
@@ -53,7 +53,8 @@ import { projectCamera, unprojectCamera } from "./camera.ts"
53
53
  import type { CameraUpdate } from "./camera.ts"
54
54
 
55
55
  // Glide rate toward a pending zoom or pose, e-foldings per second: high
56
- // enough that a wheel notch reads as one push, low enough to look smooth.
56
+ // enough that a wheel notch reads as one push, low enough to look smooth
57
+ // (the 3d controls' rate, motion.ts there); `damping` scales a notch's.
57
58
  const GLIDE_EASE = 9
58
59
  // A glide lands when the remaining zoom gap is under this (relative) ...
59
60
  const GLIDE_EPSILON = 0.002
@@ -86,6 +87,7 @@ const ROLL_RATE = 0.5
86
87
  /** What a 2d camera drives: anything with the layers' `setCamera`. */
87
88
  export type Camera2dTarget = { setCamera(update: CameraUpdate): void }
88
89
 
90
+ /** The control's parameters: what set() takes and pose() returns filled. */
89
91
  export type Camera2dPose = {
90
92
  x?: number
91
93
  y?: number
@@ -121,6 +123,11 @@ export type Camera2dOptions = Camera2dPose & {
121
123
  panSpeed?: number
122
124
  zoomSpeed?: number
123
125
  rollSpeed?: number
126
+ /** How long an unbracketed zoom delta (a wheel notch) takes to ease in,
127
+ * as a multiple of the built-in settle time: 1 (the default) is the
128
+ * built-in ease, 2 coasts twice as long, 0 applies notches at once, as
129
+ * a pinch does. The 3d controls' knob, same meaning. */
130
+ damping?: number
124
131
  /** Multiplier over the built-in follow damping. */
125
132
  followSpeed?: number
126
133
  /** Whether a drag release keeps the view gliding (default true). */
@@ -133,6 +140,9 @@ export type Camera2d = {
133
140
  /** The pose as the layers receive it (a fresh object per call): the
134
141
  * argument for projectCamera/unprojectCamera. */
135
142
  camera(): CameraUpdate
143
+ /** Pose snapshot - the control's own four values, the shape set() takes
144
+ * (camera() adds the pivot the layers are told). */
145
+ pose(): Required<Camera2dPose>
136
146
  /** Merge a pose in (clamps apply) and push it. A snap: an x/y/zoom
137
147
  * write cancels a glide or fling in flight; rotation alone leaves them
138
148
  * running. */
@@ -222,6 +232,9 @@ function checkOptions(options: Camera2dOptions): void {
222
232
  }
223
233
  finite("pivot.x", options.pivot?.x)
224
234
  finite("pivot.y", options.pivot?.y)
235
+ if (options.damping !== undefined && !(Number.isFinite(options.damping) && options.damping >= 0)) {
236
+ throw new Error(`createCamera2d: damping must be a non-negative number, got ${options.damping}`)
237
+ }
225
238
  checkFraction("deadZone.width", options.deadZone?.width ?? 0)
226
239
  checkFraction("deadZone.height", options.deadZone?.height ?? 0)
227
240
  }
@@ -257,6 +270,7 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
257
270
  let panSpeed = () => options.panSpeed ?? 1
258
271
  let zoomExponent = () => options.zoomSpeed ?? 1
259
272
  let rollSpeed = () => options.rollSpeed ?? 1
273
+ let damping = () => options.damping ?? 1
260
274
  let followEase = () => FOLLOW_EASE * (options.followSpeed ?? 1)
261
275
  let inertia = () => options.inertia ?? true
262
276
 
@@ -443,7 +457,7 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
443
457
  let zoomAt = (sx: number, sy: number, factor: number, ease: boolean) => {
444
458
  positive("zoomAt factor", factor)
445
459
  readViewport()
446
- if (!ease) {
460
+ if (!ease || damping() <= 0) {
447
461
  interrupt()
448
462
  zoomTo(sx, sy, zoom * factor)
449
463
  return
@@ -512,6 +526,7 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
512
526
 
513
527
  return {
514
528
  camera,
529
+ pose: () => ({ x, y, zoom, rotation }),
515
530
  active,
516
531
  axes,
517
532
  set(pose) {
@@ -654,7 +669,9 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
654
669
  touch()
655
670
  }
656
671
  if (glide !== null && dt > 0) {
657
- let k = 1 - Math.exp(-GLIDE_EASE * dt)
672
+ // A wheel notch's glide coasts as `damping` says; a commanded
673
+ // glide (glideTo, fit) at the built-in rate.
674
+ let k = 1 - Math.exp(-(glide.kind === "anchor" ? GLIDE_EASE / damping() : GLIDE_EASE) * dt)
658
675
  if (glide.kind === "anchor") {
659
676
  let gap = Math.log(glide.target / zoom)
660
677
  zoom = Math.abs(gap) < GLIDE_EPSILON ? glide.target : zoom * Math.exp(gap * k)
@@ -1,8 +1,8 @@
1
1
  import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { ParentComponent } from "@solidrt/core"
3
- import { addGroup, removeGroup, setGroup, setGroupTransition } from "../layer.ts"
3
+ import { addGroup, destroyGroup, setGroup, setGroupTransition } from "../layer.ts"
4
4
  import type { SpriteGroup, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
5
- import type { NodeTransition } from "flux:spatial"
5
+ import type { SpriteTransition } from "../layer.ts"
6
6
  import { GroupContext, LayerContext } from "./context.ts"
7
7
 
8
8
  export type GroupProps = {
@@ -23,9 +23,9 @@ export type GroupProps = {
23
23
  onPointerUp?: (event: SpritePointerEvent) => void
24
24
  onWheel?: (event: SpriteWheelEvent) => void
25
25
  onTap?: (event: SpriteTapEvent) => void
26
- /** How pose-prop changes animate (see setGroupTransition); the mount
27
- * pose always snaps. */
28
- transition?: NodeTransition | string | null
26
+ /** How pose-prop changes animate (see setGroupTransition). The mount
27
+ * pose snaps, unless a component's `from` animates it in from there. */
28
+ transition?: SpriteTransition | string | null
29
29
  /** A declared transition settled on one component. */
30
30
  onTransitionEnd?: (event: TransitionEndEvent) => void
31
31
  ref?: (group: SpriteGroup) => void
@@ -63,6 +63,6 @@ export let Group: ParentComponent<GroupProps> = props => {
63
63
  },
64
64
  )
65
65
  untrack(() => props.ref)?.(group)
66
- onCleanup(() => removeGroup(group))
66
+ onCleanup(() => destroyGroup(group))
67
67
  return <GroupContext value={group}>{props.children}</GroupContext>
68
68
  }
@@ -241,6 +241,14 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
241
241
  }),
242
242
  )
243
243
  let events = untrack(() => props.events) !== false
244
+ // A feed on a built-in leaf carrying no handlers listens at a root no
245
+ // event reaches: a contradiction, so it throws (the dev validation
246
+ // policy) instead of feeding nothing.
247
+ if (pointer !== null && !events && !output) {
248
+ throw new Error(
249
+ "SpriteLayer: a pointer feed with events={false} receives nothing - the feed listens at the view's root, which only the leaf's handlers reach; keep events on, or compose the leaf with output and spread useSpriteLayer().viewport.handlers on it",
250
+ )
251
+ }
244
252
  let leaf: { id: number } | undefined
245
253
  // The view's pixel size: the props in fixed mode, the built-in leaf's
246
254
  // laid-out box in fill mode (getLayoutBox, the untransformed read, so a
@@ -1,8 +1,8 @@
1
1
  import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { VoidComponent } from "@solidrt/core"
3
- import { addSprite, removeSprite, setSprite, setSpriteTransition } from "../layer.ts"
3
+ import { addSprite, destroySprite, setSprite, setSpriteTransition } from "../layer.ts"
4
4
  import type { Sprite as SpriteHandle, SpriteOptions, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
5
- import type { NodeTransition } from "flux:spatial"
5
+ import type { SpriteTransition } from "../layer.ts"
6
6
  import { GroupContext, LayerContext } from "./context.ts"
7
7
 
8
8
  /**
@@ -29,9 +29,9 @@ export type SpritePointerProps = {
29
29
 
30
30
  export type SpriteProps = SpriteOptions &
31
31
  SpritePointerProps & {
32
- /** How pose-prop changes animate (see setSpriteTransition); the mount
33
- * pose always snaps. */
34
- transition?: NodeTransition | string | null
32
+ /** How pose-prop changes animate (see setSpriteTransition). The mount
33
+ * pose snaps, unless a component's `from` animates it in from there. */
34
+ transition?: SpriteTransition | string | null
35
35
  /** A declared transition settled on one component. */
36
36
  onTransitionEnd?: (event: TransitionEndEvent) => void
37
37
  ref?: (sprite: SpriteHandle) => void
@@ -67,6 +67,6 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
67
67
  },
68
68
  )
69
69
  untrack(() => props.ref)?.(sprite)
70
- onCleanup(() => removeSprite(sprite))
70
+ onCleanup(() => destroySprite(sprite))
71
71
  return null
72
72
  }
@@ -113,6 +113,14 @@ export let View2d: ParentComponent<View2dProps> = props => {
113
113
  )
114
114
  let output = untrack(() => props.output)
115
115
  let events = untrack(() => props.events) !== false
116
+ // A feed on a built-in leaf carrying no handlers listens at a root no
117
+ // event reaches: a contradiction, so it throws (the dev validation
118
+ // policy) instead of feeding nothing.
119
+ if (pointer !== null && !events && !output) {
120
+ throw new Error(
121
+ "View2d: a pointer feed with events={false} receives nothing - the feed listens at the view's root, which only the leaf's handlers reach; keep events on, or compose the leaf with output and spread view.handlers on it",
122
+ )
123
+ }
116
124
  let leaf: { id: number } | undefined
117
125
  // Auto oversample from the built-in leaf's window box, in device pixels,
118
126
  // per view pixel: the same pick as SpriteLayer's fixed mode.
package/src/dispatch.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
23
23
  import { unprojectCamera } from "./camera.ts"
24
24
  import type { CameraUpdate } from "./camera.ts"
25
- import type { LayerPointerListener, Sprite, SpriteGroup, SpriteHandlers, SpritePointerEvent } from "./layer.ts"
25
+ import type { LayerPointerListener, Sprite, SpriteGroup, LayerHandlers, SpritePointerEvent } from "./layer.ts"
26
26
  import type { ViewHandle } from "./views.ts"
27
27
 
28
28
  // Finger travel from the down point, in window pixels, past which a press
@@ -88,7 +88,7 @@ type Press = {
88
88
  alone: boolean
89
89
  }
90
90
 
91
- export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: number; height: number }) | null) => SpriteHandlers {
91
+ export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: number; height: number }) | null) => LayerHandlers {
92
92
  let now = deps.now ?? (() => performance.now())
93
93
  let presses = new Map<number, Press>()
94
94
  let hover = new Map<number, Sprite>()
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  // without Solid components) and the components (SpriteLayer/Sprite/Group/
16
16
  // View2d/TileLayer) on top. See AGENTS.md for the model and the traps.
17
17
 
18
- export { addGroup, addSprite, createSpriteLayer, getSprite, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, worldPosition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
18
+ export { addGroup, addSprite, createSpriteLayer, getSprite, destroyGroup, destroySprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, worldPosition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
19
19
  export { createRecordLayer, FLOATS_PER_SPRITE } from "./records.ts"
20
20
  export type { RecordLayer as RecordLayerHandle, RecordLayerOptions } from "./records.ts"
21
21
  export { pointInSprite } from "./pick.ts"
@@ -35,7 +35,7 @@ export type {
35
35
  MoveResult,
36
36
  Overlap,
37
37
  QueryOptions,
38
- RayHit,
38
+ Hit,
39
39
  Rect,
40
40
  LayerPointerEvent,
41
41
  LayerPointerListener,
@@ -43,12 +43,15 @@ export type {
43
43
  LayerWheelEvent,
44
44
  Sprite as SpriteHandle,
45
45
  SpriteGroup,
46
- SpriteHandlers,
46
+ LayerHandlers,
47
47
  SpriteLayer as SpriteLayerHandle,
48
48
  SpriteLayerOptions,
49
49
  SpriteOptions,
50
50
  SpritePointerEvent,
51
51
  SpriteTapEvent,
52
+ SpriteEndpoint,
53
+ SpriteTransition,
54
+ SpriteTransitionSpec,
52
55
  SpriteWheelEvent,
53
56
  TransitionEndEvent,
54
57
  Volume,