@solidrt/core 0.0.71 → 0.0.72

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
@@ -16,6 +16,11 @@ matches the work before starting it:
16
16
  before writing any per-frame code, any animation, or anything that writes
17
17
  properties in a loop.
18
18
 
19
+ When SolidRT itself gets in the way (a bug, a missing export, a doc that
20
+ misled you, a trap nothing warned about), append it to SOLIDRT-FEEDBACK.md
21
+ in the project root, creating the file if needed: what you hit, and what
22
+ you expected instead.
23
+
19
24
  ## The window is device-sized - design fluid
20
25
 
21
26
  A SolidRT window is host-sized and resizable, and the SAME app runs on phones,
@@ -304,7 +309,10 @@ that reads exactly like Solid fallout.
304
309
 
305
310
  - Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
306
311
  with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
307
- onKeyDown/Up, onTextInput, onFocus/onBlur. Text entry: focus a node with an
312
+ onKeyDown/Up, onTextInput, onFocus/onBlur. `pointerEvents` gates hit
313
+ testing and cascades like CSS: "none" on a container also disables its
314
+ subtree until a descendant sets "auto" (or "all", which makes the element
315
+ one target and skips its children). Text entry: focus a node with an
308
316
  `onTextInput` handler. Focus alone never raises the on-screen keyboard: a
309
317
  tap on the focused node (or explicit startTextInput()) does, and never
310
318
  while a physical keyboard is attached; on keyboard-equipped platforms the
@@ -617,8 +625,11 @@ that reads exactly like Solid fallout.
617
625
  update: it runs after every onFrame callback and the reactive flush,
618
626
  when the frame's state is final, for what must see everything the frame
619
627
  wrote and write once more (a camera follow, a fit to a measured box;
620
- Unity's LateUpdate); its signal writes are flushed before the paint
621
- too, the way onLayout's are. `onBeforeRender(fn, {
628
+ Unity's LateUpdate) - native motion (a clip player, a node transition)
629
+ steps before the frame's callbacks and starts what they declared after
630
+ them, so a follow of an animated node reads the pose this frame draws;
631
+ its signal writes are flushed before the paint too, the way onLayout's
632
+ are. `onBeforeRender(fn, {
622
633
  publish: true })` runs after those, and again after a frame's onLayout
623
634
  handlers: the pass where an extension hands the engine what the frame
624
635
  wrote into its mirrors (a scene its record buffers and lights, a sprite
@@ -24,7 +24,13 @@ Rules, in order of leverage:
24
24
  use them for anything interactive), `{ duration, curve }` is a tween
25
25
  (`linear | ease | ease-in | ease-out | ease-in-out` or a cubic-bezier
26
26
  array; tweens restart from the current value on retarget, CSS
27
- semantics). Keys are property names plus `all` as catch-all; a string
27
+ semantics). Keys are the animatable property names (the
28
+ TransitionPropName set: opacity, transform components, color, shadow,
29
+ and x/y/w/h on the detached primitives), `all` as catch-all, and
30
+ `layout`: a layout property (width, height, padding, flex, order)
31
+ never takes an entry of its own; whatever layout does to the
32
+ element's box animates through `layout`, which slides it from its
33
+ painted box to its new one. A string
28
34
  is shorthand (`transition="300ms ease-out"`); `delay` holds each
29
35
  write, `from` animates the first attach in (enter), `exit` animates
30
36
  removal out before the node frees, `stagger` on a parent cascades its
@@ -8,7 +8,7 @@ the walk.
8
8
 
9
9
  ## Handlers
10
10
 
11
- {{ decl packages/core/src/types.d.ts PointerProps }}
11
+ {{ decl packages/core/src/types.d.ts InputProps }}
12
12
 
13
13
  `pointerEvents="none"` takes an element out of hit testing, and the walk skips
14
14
  it as an ancestor too, so the `parentX`/`parentY` of an event stay in the frame
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.71",
3
+ "version": "0.0.72",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -33,8 +33,8 @@
33
33
  "AGENTS.md"
34
34
  ],
35
35
  "devDependencies": {
36
- "@solidrt/flux-types": "0.0.71",
37
- "@solidrt/test": "0.0.71"
36
+ "@solidrt/flux-types": "0.0.72",
37
+ "@solidrt/test": "0.0.72"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@solidjs/signals": "2.0.0-rc.13",
package/src/gpu.ts CHANGED
@@ -152,6 +152,13 @@ export {
152
152
  export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
153
153
  export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute, VertexBufferLayout, VertexFormat } from "flux:gpu"
154
154
 
155
+ // The vertex vocabulary as data (vertex.ts): the format codecs, the
156
+ // arithmetic over an attribute list and the record accessors that the 3d
157
+ // geometry layouts, the 3d instance streams and the 2d material style
158
+ // streams all run on.
159
+ export { attributeAccess, checkLayout, encodeRecord, formatFeeds, isFloatFormat, isFloatLayout, layoutBytes, layoutComponents, layoutFields, layoutKey, layoutSlot, layoutStride, layoutView, writeRecord, VERTEX_FORMATS } from "./vertex.ts"
160
+ export type { AttributeAccess, FormatCodec, FormatKind } from "./vertex.ts"
161
+
155
162
  // The draw-list verbs, re-exported raw: entries live and die with their draw
156
163
  // target (see createDrawTarget below), so there is no per-entry lifetime to
157
164
  // wrap. addDraw adds an entry (appended, or inserted via opts.before) and
package/src/index.ts CHANGED
@@ -58,7 +58,7 @@ export type {
58
58
  Element,
59
59
  LayoutProps,
60
60
  TransformProps,
61
- PointerProps,
61
+ InputProps,
62
62
  TransitionProps,
63
63
  Transition,
64
64
  TransitionCurve,
@@ -119,6 +119,8 @@ export {
119
119
  createRenderEffect,
120
120
  createRoot,
121
121
  createStore,
122
+ createProjection,
123
+ snapshot,
122
124
  reconcile,
123
125
  merge,
124
126
  omit,
@@ -131,6 +133,22 @@ export {
131
133
  } from "@solidjs/signals"
132
134
  export type { Accessor, Setter, Signal, Store, StoreSetter } from "@solidjs/signals"
133
135
 
136
+ // Async and optimistic state (from @solidjs/signals). `isPending` and
137
+ // `latest` read an async value's in-flight change, `refresh` re-asks it
138
+ // and `affects` marks it as changing; `action` runs a mutation as a
139
+ // transition, and the optimistic primitives show the expected value until
140
+ // it completes. See CHEATSHEET.md "Async" and "Actions & optimistic".
141
+ export {
142
+ action,
143
+ createOptimistic,
144
+ createOptimisticStore,
145
+ isPending,
146
+ latest,
147
+ refresh,
148
+ affects,
149
+ } from "@solidjs/signals"
150
+ export type { Refreshable } from "@solidjs/signals"
151
+
134
152
  // Owner and lifecycle (from @solidjs/signals). `createRoot` above is the fourth
135
153
  // member of this group - it creates an owned scope, these reach the owner it
136
154
  // established. Capture an owner to restore it across an async boundary
package/src/renderer.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // createErrorBoundary comes from signals, not solid-js: solid-js marks it
2
2
  // @internal (an app writes <Errored>, a renderer reaches for the primitive)
3
3
  // and leaves it out of its public types.
4
- import { createErrorBoundary, createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
4
+ import { createErrorBoundary, createRoot, onCleanup, NotReadyError, runWithOwner, untrack } from "@solidjs/signals"
5
5
  import { createRenderer } from "@solidjs/universal"
6
6
  import type { Element } from "solid-js"
7
7
  import * as tree from "flux:rendertree"
@@ -122,6 +122,13 @@ let warnedLeakTypes = new Set<string>()
122
122
  // warning: a leak that keeps growing at a stable set of types warns again
123
123
  // each time the total crosses the next power of ten.
124
124
  let warnedMagnitude = -1
125
+ // The other cause of orphans: a subtree whose build a throw cut short (a
126
+ // child expression or component body that threw an error, or a pending
127
+ // read on its way to <Loading>, which retries and abandons again). The
128
+ // elements created before the throw are never inserted. Counted where
129
+ // such throws pass (insert's guard, the root boundary) so the warning can
130
+ // point at it (okf/backlog/suspend-retry-orphan-elements.md).
131
+ let abandonedBuilds = 0
125
132
 
126
133
  export function scanForOrphans(now: number): void {
127
134
  if (!import.meta.env.DEV) return
@@ -144,11 +151,17 @@ export function scanForOrphans(now: number): void {
144
151
  for (let [type] of fresh) warnedLeakTypes.add(type)
145
152
  warnedMagnitude = magnitude
146
153
  let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
154
+ let abandoned =
155
+ abandonedBuilds === 0
156
+ ? ""
157
+ : `A build cut short by a throw leaves what it had created unreachable too: ${abandonedBuilds} ` +
158
+ `such throws so far (an error in a child expression or component body, or a pending read ` +
159
+ `on its way to <Loading>, which retries and abandons again). `
147
160
  console.warn(
148
161
  `Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
149
162
  `The usual cause is reading an element-valued prop more than once (every read ` +
150
163
  `builds a new subtree); read it once where it mounts, or resolve it with ` +
151
- `children(). If these nodes are intentionally kept for later mounting, ignore ` +
164
+ `children(). ${abandoned}If these nodes are intentionally kept for later mounting, ignore ` +
152
165
  `this. The next warning comes when a new element type joins the list or the ` +
153
166
  `total passes ${10 ** (magnitude + 1)}.`,
154
167
  )
@@ -345,6 +358,28 @@ export let { memo, createComponent, createElement, createTextNode, insertNode, s
345
358
  renderer
346
359
  let { effect: rawEffect, insert: rawInsert } = renderer
347
360
 
361
+ /** A ref callback as a component prop: a function, or an array of them. */
362
+ export type RefCallback<T> = ((handle: T) => void) | RefCallback<T>[]
363
+
364
+ /**
365
+ * Hands `handle` to a component's `ref` callback prop the way Solid hands
366
+ * an element to an element's `ref`: read untracked, called outside any
367
+ * owner, so a signal setter passed straight in (`ref={setLayer}`) writes
368
+ * without tripping the owned-scope guard. A component body is an owned
369
+ * scope, so calling the prop directly there throws
370
+ * REACTIVE_WRITE_IN_OWNED_SCOPE in dev; `untrack` does not lift that.
371
+ * Accepts an array of callbacks like Solid's applyRef does.
372
+ */
373
+ export function callRef<T>(read: () => RefCallback<T> | undefined, handle: T): void {
374
+ let callback = untrack(read)
375
+ if (callback === undefined) return
376
+ runWithOwner(null, () => {
377
+ if (Array.isArray(callback)) {
378
+ for (let f of (callback as unknown[]).flat(Infinity) as ((handle: T) => void)[]) f(handle)
379
+ } else callback(handle)
380
+ })
381
+ }
382
+
348
383
  // ------ Per-node error containment --------
349
384
  //
350
385
  // Every reactive write into the tree goes through two exports the compiled
@@ -382,6 +417,9 @@ function guard<T>(fn: (prev?: T) => T, describe: () => string, nested: boolean,
382
417
  last = value
383
418
  return value
384
419
  } catch (e) {
420
+ // Only a child expression builds a subtree a throw can abandon; a
421
+ // prop expression creates no elements.
422
+ if (nested) abandonedBuilds += 1
385
423
  if (e instanceof NotReadyError) throw e
386
424
  if (!failing) {
387
425
  failing = true
@@ -456,6 +494,7 @@ export function render(code: () => any) {
456
494
  (error, reset) => {
457
495
  // The boundary hands the error as an accessor.
458
496
  let err = error()
497
+ abandonedBuilds += 1
459
498
  console.error("Uncaught error: the app is replaced by the error window until reset or reload.", err)
460
499
  let win = errorWindow(err, reset)
461
500
  errorWindows.add(win.id)
package/src/types.d.ts CHANGED
@@ -381,7 +381,9 @@ export interface WheelEvent extends PointerEvent {
381
381
  // Routing: keydown/keyup dispatch along the focused node's ancestor chain,
382
382
  // leaf->root, always ending at the window root; with nothing focused they go
383
383
  // to the window root alone. <window onKeyDown> is therefore the app-global
384
- // shortcut point.
384
+ // shortcut point. An Escape keydown nobody consumed then runs the back
385
+ // handler stack (onBack) without its platform default, so a control that
386
+ // takes Escape itself calls stopPropagation.
385
387
  export interface KeyEvent {
386
388
  /** When the key event happened, in milliseconds, on the clock of
387
389
  * `PointerEvent.timeStamp`. */
@@ -421,7 +423,7 @@ export interface NodeProps {
421
423
  label?: string
422
424
  }
423
425
 
424
- export interface PointerProps extends NodeProps {
426
+ export interface InputProps extends NodeProps {
425
427
  onPointerDown?: (event: PointerEvent) => void
426
428
  onPointerUp?: (event: PointerEvent) => void
427
429
  /**
@@ -456,6 +458,15 @@ export interface PointerProps extends NodeProps {
456
458
  * still moves through setFocus.
457
459
  */
458
460
  focusable?: boolean
461
+ /**
462
+ * Hit testing, with CSS pointer-events inheritance: unset inherits the
463
+ * nearest ancestor's value (root: "auto"). "none" makes the element and
464
+ * its subtree transparent and drops it from the bubble path; a descendant
465
+ * that sets "auto" or "all" opts back in. "auto" hits the element's box
466
+ * with children deepest-first. "all" (not CSS's) hits the box as one
467
+ * target and never descends: the whole element is the target whatever its
468
+ * children's shapes, nothing outside the box is captured.
469
+ */
459
470
  pointerEvents?: "auto" | "none" | "all"
460
471
  /**
461
472
  * The mouse cursor while this element is hovered: a platform shape by its
@@ -762,7 +773,7 @@ export interface TransitionProps {
762
773
 
763
774
  // Primitives
764
775
 
765
- export interface WindowProps extends LayoutProps, PointerProps, TransitionProps {
776
+ export interface WindowProps extends LayoutProps, InputProps, TransitionProps {
766
777
  children?: Children
767
778
  title?: string
768
779
  fullscreen?: boolean
@@ -817,7 +828,7 @@ export interface WindowShaderProps {
817
828
  // Everything a view offers besides layout: d-view uses this directly (a
818
829
  // detached view has no taffy presence, so layout props would be rejected at
819
830
  // runtime); the layout `view` adds LayoutProps below.
820
- export interface ViewOwnProps extends TransformProps, PointerProps {
831
+ export interface ViewOwnProps extends TransformProps, InputProps {
821
832
  children?: Children
822
833
  trace?: boolean
823
834
  /**
@@ -970,7 +981,7 @@ export interface ViewProps extends ViewOwnProps, LayoutProps {}
970
981
  // the box for a clip to cut. `path` and `line` strokes stay centered on their
971
982
  // geometry - there the geometry is the stroke, not a box. A dashed stroke
972
983
  // dashes that same inset outline (see DashProps).
973
- export interface RectProps extends PaintProps, PointerProps, DashProps {
984
+ export interface RectProps extends PaintProps, InputProps, DashProps {
974
985
  // Corner radius, measured on the box (the stroke's outer edge). A single
975
986
  // number applies to all four corners; an array is [top-left, top-right,
976
987
  // bottom-right, bottom-left] (CSS border-radius order).
@@ -979,7 +990,7 @@ export interface RectProps extends PaintProps, PointerProps, DashProps {
979
990
  }
980
991
 
981
992
  // Strokes paint inside the box, same as `RectProps`.
982
- export interface OvalProps extends PaintProps, PointerProps, DashProps {
993
+ export interface OvalProps extends PaintProps, InputProps, DashProps {
983
994
  shadow?: ShadowProps
984
995
  }
985
996
 
@@ -1036,7 +1047,7 @@ export interface DashProps {
1036
1047
  // fill). On a polyline "fill" and "stroke-and-fill" fill the polygon
1037
1048
  // (nonzero, implicitly closed) and hit-test its interior; on the two-point
1038
1049
  // form fill has no effect, a segment has no interior.
1039
- export interface LineProps extends PaintProps, PointerProps, DashProps {
1050
+ export interface LineProps extends PaintProps, InputProps, DashProps {
1040
1051
  /**
1041
1052
  * Polyline vertices as a flat [x0, y0, x1, y1, ...] in the element's local
1042
1053
  * space (the space x1..y2 use). Takes precedence over the endpoints while
@@ -1061,7 +1072,7 @@ export interface LineProps extends PaintProps, PointerProps, DashProps {
1061
1072
  * points) plus the stroke's reach, at a `d-path`'s x/y - not its layout box
1062
1073
  * or the inherited one.
1063
1074
  */
1064
- export interface PathProps extends PaintProps, PointerProps, DashProps {
1075
+ export interface PathProps extends PaintProps, InputProps, DashProps {
1065
1076
  d?: string
1066
1077
  fillRule?: "nonzero" | "evenodd"
1067
1078
  /** Shadows the drawn silhouette (fill and/or stroke); `spread` is rejected here. */
@@ -1128,12 +1139,12 @@ export interface TextRunProps {
1128
1139
  * Pointer handlers fire for the boxes of the run's own text on each line it
1129
1140
  * spans and bubble to the enclosing spans and text.
1130
1141
  */
1131
- export interface SpanProps extends TextRunProps, PointerProps {
1142
+ export interface SpanProps extends TextRunProps, InputProps {
1132
1143
  children?: Children
1133
1144
  color?: Color | Gradient
1134
1145
  }
1135
1146
 
1136
- export interface TextProps extends PaintProps, PointerProps, TextRunProps {
1147
+ export interface TextProps extends PaintProps, InputProps, TextRunProps {
1137
1148
  children?: Children
1138
1149
  textAlign?: TextAlign
1139
1150
  maxLines?: number
@@ -1174,7 +1185,7 @@ export interface TextProps extends PaintProps, PointerProps, TextRunProps {
1174
1185
  * Texture alpha is premultiplied, so additive modes need no manual
1175
1186
  * premultiplication.
1176
1187
  */
1177
- export interface TextureProps extends PaintProps, PointerProps {
1188
+ export interface TextureProps extends PaintProps, InputProps {
1178
1189
  src?: TextureId
1179
1190
  /**
1180
1191
  * Corner radius, as on a rect: the image is drawn as a rounded rect filled
package/src/vertex.ts ADDED
@@ -0,0 +1,294 @@
1
+ // The vertex vocabulary as data: one codec per vertex format (the engine's
2
+ // table in the same spelling, so it is also the list a declared format
3
+ // must be one of), and the arithmetic over an attribute LIST - the stride
4
+ // it interleaves to, its identity key, where a name sits, whether a
5
+ // Float32Array can hold it - plus the accessors that read and write one
6
+ // attribute of a record through its codec, so no caller ever knows an
7
+ // offset, a stride or a format. @solidrt/3d's geometry layouts and
8
+ // instance streams and @solidrt/2d's material style streams run on this;
9
+ // the named presets (a 3d geometry's "base" layout) stay in the packages.
10
+ //
11
+ // Every format is a multiple of 4 bytes (WebGPU's alignment rule), which
12
+ // is what keeps every offset and stride 4-aligned with no padding
13
+ // arithmetic anywhere, and a Float32Array view over any record buffer
14
+ // always valid.
15
+
16
+ import type { VertexAttribute, VertexFormat } from "flux:gpu"
17
+
18
+ /** The shader `in` family a vertex format feeds (WebGPU's rule: the
19
+ * format decides): "float" for the float and normalized formats (`in
20
+ * vec*`), "uint" for uint* (`in uvec*`), "sint" for sint* (`in ivec*`). */
21
+ export type FormatKind = "float" | "uint" | "sint"
22
+
23
+ /** One vertex format's codec: its size, its component count, its kind,
24
+ * and the read and write of component `k` of an attribute at byte `at`
25
+ * of a DataView, in the value the shader sees (a normalized integer
26
+ * decodes to 0..1 / -1..1, an unnormalized one to its exact value). */
27
+ export type FormatCodec = {
28
+ bytes: number
29
+ components: number
30
+ kind: FormatKind
31
+ get(dv: DataView, at: number, k: number): number
32
+ set(dv: DataView, at: number, k: number, v: number): void
33
+ }
34
+
35
+ // The integer component kinds behind the packed formats: width, the
36
+ // value that maps to 1 (GL ES 3.0's normalization rule, -max mapping to
37
+ // -1 and the one value below it clamped there), and the raw accessors.
38
+ type IntKind = { bytes: number; max: number; get(dv: DataView, at: number): number; set(dv: DataView, at: number, v: number): void }
39
+ const U8: IntKind = { bytes: 1, max: 255, get: (dv, at) => dv.getUint8(at), set: (dv, at, v) => dv.setUint8(at, v) }
40
+ const S8: IntKind = { bytes: 1, max: 127, get: (dv, at) => dv.getInt8(at), set: (dv, at, v) => dv.setInt8(at, v) }
41
+ const U16: IntKind = { bytes: 2, max: 65535, get: (dv, at) => dv.getUint16(at, true), set: (dv, at, v) => dv.setUint16(at, v, true) }
42
+ const S16: IntKind = { bytes: 2, max: 32767, get: (dv, at) => dv.getInt16(at, true), set: (dv, at, v) => dv.setInt16(at, v, true) }
43
+ const U32: IntKind = { bytes: 4, max: 4294967295, get: (dv, at) => dv.getUint32(at, true), set: (dv, at, v) => dv.setUint32(at, v, true) }
44
+ const S32: IntKind = { bytes: 4, max: 2147483647, get: (dv, at) => dv.getInt32(at, true), set: (dv, at, v) => dv.setInt32(at, v, true) }
45
+
46
+ function clamp(v: number, lo: number, hi: number): number {
47
+ return v < lo ? lo : v > hi ? hi : v
48
+ }
49
+
50
+ function floatCodec(components: number, bytes: number, get: (dv: DataView, at: number) => number, set: (dv: DataView, at: number, v: number) => void): FormatCodec {
51
+ return {
52
+ bytes: components * bytes,
53
+ components,
54
+ kind: "float",
55
+ get: (dv, at, k) => get(dv, at + k * bytes),
56
+ set: (dv, at, k, v) => set(dv, at + k * bytes, v),
57
+ }
58
+ }
59
+
60
+ function unormCodec(components: number, int: IntKind): FormatCodec {
61
+ return {
62
+ bytes: components * int.bytes,
63
+ components,
64
+ kind: "float",
65
+ get: (dv, at, k) => int.get(dv, at + k * int.bytes) / int.max,
66
+ set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, 0, 1) * int.max)),
67
+ }
68
+ }
69
+
70
+ function snormCodec(components: number, int: IntKind): FormatCodec {
71
+ return {
72
+ bytes: components * int.bytes,
73
+ components,
74
+ kind: "float",
75
+ get: (dv, at, k) => Math.max(int.get(dv, at + k * int.bytes) / int.max, -1),
76
+ set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, -1, 1) * int.max)),
77
+ }
78
+ }
79
+
80
+ function uintCodec(components: number, int: IntKind): FormatCodec {
81
+ return {
82
+ bytes: components * int.bytes,
83
+ components,
84
+ kind: "uint",
85
+ get: (dv, at, k) => int.get(dv, at + k * int.bytes),
86
+ set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, 0, int.max))),
87
+ }
88
+ }
89
+
90
+ function sintCodec(components: number, int: IntKind): FormatCodec {
91
+ return {
92
+ bytes: components * int.bytes,
93
+ components,
94
+ kind: "sint",
95
+ get: (dv, at, k) => int.get(dv, at + k * int.bytes),
96
+ set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, -int.max - 1, int.max))),
97
+ }
98
+ }
99
+
100
+ let getF32 = (dv: DataView, at: number) => dv.getFloat32(at, true)
101
+ let setF32 = (dv: DataView, at: number, v: number) => dv.setFloat32(at, v, true)
102
+ let getF16 = (dv: DataView, at: number) => dv.getFloat16(at, true)
103
+ let setF16 = (dv: DataView, at: number, v: number) => dv.setFloat16(at, v, true)
104
+
105
+ /** The vertex vocabulary: one codec per format (the engine's table in
106
+ * the same spelling), so it is also the list a declared format must be
107
+ * one of. A format feeds the shader `in` of its kind and component
108
+ * count (`formatFeeds`). */
109
+ export const VERTEX_FORMATS: Record<VertexFormat, FormatCodec> = {
110
+ float32: floatCodec(1, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
111
+ float32x2: floatCodec(2, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
112
+ float32x3: floatCodec(3, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
113
+ float32x4: floatCodec(4, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
114
+ float16x2: floatCodec(2, Float16Array.BYTES_PER_ELEMENT, getF16, setF16),
115
+ float16x4: floatCodec(4, Float16Array.BYTES_PER_ELEMENT, getF16, setF16),
116
+ unorm8x4: unormCodec(4, U8),
117
+ snorm8x4: snormCodec(4, S8),
118
+ unorm16x2: unormCodec(2, U16),
119
+ unorm16x4: unormCodec(4, U16),
120
+ snorm16x2: snormCodec(2, S16),
121
+ snorm16x4: snormCodec(4, S16),
122
+ uint8x4: uintCodec(4, U8),
123
+ uint16x2: uintCodec(2, U16),
124
+ uint16x4: uintCodec(4, U16),
125
+ uint32: uintCodec(1, U32),
126
+ uint32x2: uintCodec(2, U32),
127
+ uint32x3: uintCodec(3, U32),
128
+ uint32x4: uintCodec(4, U32),
129
+ sint8x4: sintCodec(4, S8),
130
+ sint16x2: sintCodec(2, S16),
131
+ sint16x4: sintCodec(4, S16),
132
+ sint32: sintCodec(1, S32),
133
+ sint32x2: sintCodec(2, S32),
134
+ sint32x3: sintCodec(3, S32),
135
+ sint32x4: sintCodec(4, S32),
136
+ }
137
+
138
+ /** Whether a layout declaring `declared` may feed a program input the
139
+ * engine reflects as `reflected` (float32xN for `vec*`, uint32xN for
140
+ * `uvec*`, sint32xN for `ivec*`): same component count and same kind,
141
+ * the engine's own pipeline rule. */
142
+ export function formatFeeds(declared: VertexFormat, reflected: VertexFormat): boolean {
143
+ let d = VERTEX_FORMATS[declared]
144
+ let r = VERTEX_FORMATS[reflected]
145
+ return d.components === r.components && d.kind === r.kind
146
+ }
147
+
148
+ /** Whether a format is one of the float32 family: what a Float32Array
149
+ * holds directly. */
150
+ export function isFloatFormat(format: VertexFormat): boolean {
151
+ return format.startsWith("float32")
152
+ }
153
+
154
+ /** Bytes per record of an attribute list - its interleave stride. */
155
+ export function layoutStride(layout: readonly VertexAttribute[]): number {
156
+ let stride = 0
157
+ for (let attr of layout) stride += VERTEX_FORMATS[attr.format].bytes
158
+ return stride
159
+ }
160
+
161
+ /** Values per record of an attribute list: its attributes' components
162
+ * summed - what a record written as a flat value list counts in. */
163
+ export function layoutComponents(layout: readonly VertexAttribute[]): number {
164
+ let n = 0
165
+ for (let attr of layout) n += VERTEX_FORMATS[attr.format].components
166
+ return n
167
+ }
168
+
169
+ /** An attribute list's identity as a string (name:format per attribute,
170
+ * in order): two lists with equal keys interleave identically. */
171
+ export function layoutKey(layout: readonly VertexAttribute[]): string {
172
+ return layout.map(a => a.name + ":" + a.format).join(",")
173
+ }
174
+
175
+ /** Where an attribute sits in the interleave: its byte offset, format
176
+ * and component count. Null when the list does not carry that name. */
177
+ export function layoutSlot(layout: readonly VertexAttribute[], name: string): { offset: number; format: VertexFormat; components: number } | null {
178
+ let offset = 0
179
+ for (let attr of layout) {
180
+ let codec = VERTEX_FORMATS[attr.format]
181
+ if (attr.name === name) return { offset, format: attr.format, components: codec.components }
182
+ offset += codec.bytes
183
+ }
184
+ return null
185
+ }
186
+
187
+ /** Whether every attribute of a list is float32-family: such a buffer
188
+ * is a Float32Array of `layoutStride / 4` floats per record. */
189
+ export function isFloatLayout(layout: readonly VertexAttribute[]): boolean {
190
+ return layout.every(a => isFloatFormat(a.format))
191
+ }
192
+
193
+ /** The check an attribute list must pass before it names a buffer: a
194
+ * known format on every attribute and no name twice. Throws naming
195
+ * `where` (the dev validation policy). */
196
+ export function checkLayout(layout: readonly VertexAttribute[], where: string): void {
197
+ let seen = new Set<string>()
198
+ for (let attr of layout) {
199
+ if (!(attr.format in VERTEX_FORMATS)) {
200
+ throw new Error(where + ": unknown vertex format '" + String(attr.format) + "' for " + attr.name + " (expected " + Object.keys(VERTEX_FORMATS).join(", ") + ")")
201
+ }
202
+ if (seen.has(attr.name)) throw new Error(where + ": duplicate attribute '" + attr.name + "'")
203
+ seen.add(attr.name)
204
+ }
205
+ }
206
+
207
+ /** The bytes of a record buffer, whatever view holds them. */
208
+ export function layoutBytes(records: ArrayBufferView): Uint8Array {
209
+ return new Uint8Array(records.buffer, records.byteOffset, records.byteLength)
210
+ }
211
+
212
+ /** The view a record buffer of `layout` is handed out as: a Float32Array
213
+ * over an all-float layout (indexable as floats), a Uint8Array over a
214
+ * layout with a packed channel. */
215
+ export function layoutView(layout: readonly VertexAttribute[], buffer: ArrayBuffer, byteOffset = 0, byteLength = buffer.byteLength - byteOffset): ArrayBufferView {
216
+ return isFloatLayout(layout)
217
+ ? new Float32Array(buffer, byteOffset, byteLength / Float32Array.BYTES_PER_ELEMENT)
218
+ : new Uint8Array(buffer, byteOffset, byteLength)
219
+ }
220
+
221
+ /** A view on one attribute of a record buffer: component `k` of record
222
+ * `i`, read and written as the value the shader sees, through the
223
+ * format's codec. The one way anything touches record data, so no
224
+ * caller knows an offset, a stride or a format. */
225
+ export type AttributeAccess = {
226
+ format: VertexFormat
227
+ components: number
228
+ get(i: number, k: number): number
229
+ set(i: number, k: number, v: number): void
230
+ }
231
+
232
+ /** The accessor for `name` over a bare record buffer of `layout`, bound
233
+ * to that buffer; null when the layout lacks the name. */
234
+ export function attributeAccess(records: ArrayBufferView, layout: readonly VertexAttribute[], name: string): AttributeAccess | null {
235
+ let slot = layoutSlot(layout, name)
236
+ if (slot === null) return null
237
+ let stride = layoutStride(layout)
238
+ let codec = VERTEX_FORMATS[slot.format]
239
+ let offset = slot.offset
240
+ let dv = new DataView(records.buffer, records.byteOffset, records.byteLength)
241
+ return {
242
+ format: slot.format,
243
+ components: codec.components,
244
+ get: (i, k) => codec.get(dv, i * stride + offset, k),
245
+ set: (i, k, v) => codec.set(dv, i * stride + offset, k, v),
246
+ }
247
+ }
248
+
249
+ /** One accessor per attribute of `layout`, in order, reading through
250
+ * `view()` at every call - the form for a buffer that growth replaces:
251
+ * the accessors stay valid, the DataView behind them moves. */
252
+ export function layoutFields(layout: readonly VertexAttribute[], view: () => DataView): AttributeAccess[] {
253
+ let stride = layoutStride(layout)
254
+ let offset = 0
255
+ return layout.map(attr => {
256
+ let codec = VERTEX_FORMATS[attr.format]
257
+ let at = offset
258
+ offset += codec.bytes
259
+ return {
260
+ format: attr.format,
261
+ components: codec.components,
262
+ get: (i, k) => codec.get(view(), i * stride + at, k),
263
+ set: (i, k, v) => codec.set(view(), i * stride + at, k, v),
264
+ }
265
+ })
266
+ }
267
+
268
+ /** Encode `values` (one per component, in layout order, as the shader
269
+ * sees them) into record `i` of a buffer: indexed float stores over an
270
+ * all-float layout given as `floats` (the values ARE the bytes; indexed
271
+ * stores beat TypedArray.set over a plain array under QuickJS, which
272
+ * walks the array-like generically), the accessors' codecs otherwise.
273
+ * Throws naming `site` on a value count that is not the layout's. */
274
+ export function writeRecord(layout: readonly VertexAttribute[], fields: readonly AttributeAccess[], floats: Float32Array | null, i: number, values: ArrayLike<number>, site: string): void {
275
+ let n = layoutComponents(layout)
276
+ if (values.length !== n) throw new Error(site + ": a " + layoutKey(layout) + " record is " + n + " values, got " + values.length)
277
+ if (floats !== null) {
278
+ let at = i * n
279
+ for (let k = 0; k < n; k++) floats[at + k] = values[k]!
280
+ return
281
+ }
282
+ let j = 0
283
+ for (let f of fields) for (let k = 0; k < f.components; k++) f.set(i, k, values[j++]!)
284
+ }
285
+
286
+ /** One record of `layout` encoded from `values`: the bytes a blank slot
287
+ * copies (a material's fresh style record). */
288
+ export function encodeRecord(layout: readonly VertexAttribute[], values: ArrayLike<number>, site: string): Uint8Array {
289
+ let buffer = new ArrayBuffer(layoutStride(layout))
290
+ let dv = new DataView(buffer)
291
+ let fields = layoutFields(layout, () => dv)
292
+ writeRecord(layout, fields, isFloatLayout(layout) ? new Float32Array(buffer) : null, 0, values, site)
293
+ return new Uint8Array(buffer)
294
+ }
package/src/window.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, flush } from "@solidjs/signals"
2
2
  import { requestFrame, setPointerLock } from "flux:rendertree"
3
+ import { startTransitions } from "flux:spatial"
3
4
  import { renderFrame } from "sol:render"
4
5
  import { on, once } from "sol:events"
5
6
  import { exit as nativeExit, background as nativeBackground, registerProtocolHandler as nativeRegisterProtocolHandler } from "sol:app"
@@ -470,7 +471,9 @@ export function onLayout(fn: () => void) {
470
471
  // The last JS of a frame, in two passes. The plain handlers are the app's
471
472
  // late update: they run once per frame after every onFrame callback and
472
473
  // the reactive flush, so a follow or a fit reads the finished state of the
473
- // frame (Unity's LateUpdate). The publish handlers run after them, and
474
+ // frame (Unity's LateUpdate) - native motion stepped before the frame's
475
+ // callbacks and started after them (runFrame), so a pose read here is the
476
+ // one this frame draws. The publish handlers run after them, and
474
477
  // once more after the post-layout handlers' flush (the one other JS entry
475
478
  // ahead of the paint): an extension hands its pending writes to the engine
476
479
  // there - a scene's record bytes and lights, a layer's style and pose
@@ -527,7 +530,11 @@ export interface BeforeRenderOptions {
527
530
  * Calls `fn` right before every frame renders: after the frame's `onFrame`
528
531
  * callbacks and the reactive flush, when the frame's state is final. The
529
532
  * frame's late update: a camera follow, a fit to a measured box, anything
530
- * that must see what every frame callback wrote and write once more.
533
+ * that must see what every frame callback wrote and write once more. The
534
+ * engine's own motion (a clip player, a node transition) has stepped to
535
+ * this frame's pose before the frame's callbacks and started what they
536
+ * declared (an enter from its `from`) after them, so a follow of an
537
+ * animated node reads where it is drawn, not where it was.
531
538
  * Writes made here, to the engine or through signals, are in this frame:
532
539
  * the pending reactive writes are flushed once every handler has run.
533
540
  *
@@ -597,13 +604,15 @@ let backHandlers: ((e: BackEvent) => void)[] = []
597
604
 
598
605
  /**
599
606
  * Calls `fn` on the user's back intent (Android back button/gesture, the
600
- * desktop dev chord). Call `e.preventDefault()` when back means in-app
601
- * navigation right now (close a modal, previous screen); unprevented, the
602
- * event passes to the handler registered before this one, and if none of them
603
- * prevents it either, to the default action: what the platform does at the
604
- * root of an app - `background()` on Android, `exit()` elsewhere. Apps
605
- * without a handler get the platform's behavior, which is the correct
606
- * zero-effort default.
607
+ * pad's back button, the desktop dev chord). Call `e.preventDefault()` when
608
+ * back means in-app navigation right now (close a modal, previous screen);
609
+ * unprevented, the event passes to the handler registered before this one,
610
+ * and if none of them prevents it either, to the default action: what the
611
+ * platform does at the root of an app - `background()` on Android, `exit()`
612
+ * elsewhere. Apps without a handler get the platform's behavior, which is
613
+ * the correct zero-effort default. An Escape keydown that no key handler
614
+ * consumed (`stopPropagation`) runs the same stack without the default
615
+ * action: Escape closes the topmost overlay and never leaves the app.
607
616
  *
608
617
  * Handlers form a stack: the most recently registered runs first and the first
609
618
  * to prevent ends the dispatch, so each screen or overlay owns one step of the
@@ -641,6 +650,7 @@ export function setWindowRoot(nodeId: number) {
641
650
  export function attachWindow(nodeId: number) {
642
651
  setWindowRoot(nodeId)
643
652
  let unsubscribe: () => void = null!
653
+ let unsubFrameStart: () => void = null!
644
654
  let unsubDown: () => void = null!
645
655
  let unsubUp: () => void = null!
646
656
  let unsubCancel: () => void = null!
@@ -665,8 +675,10 @@ export function attachWindow(nodeId: number) {
665
675
  // (okf/done/onframe-tick-reset-on-reload.md). The callbacks stay
666
676
  // registered and run on the first real render event, before the first
667
677
  // paint with their writes applied.
678
+ // The frame's tick is stamped by "frameStart", ahead of this handler
679
+ // and of the engine's motion events (a clip's end, a settle), so a
680
+ // handler of those reads the frame's clock, not the last frame's.
668
681
  function runFrame(t: number, frame: number, bootstrap = false) {
669
- if (!bootstrap) latestTick = t
670
682
  if (!bootstrap && animationFrames.size > 0) {
671
683
  let frames = animationFrames
672
684
  animationFrames = new Map()
@@ -679,6 +691,12 @@ export function attachWindow(nodeId: number) {
679
691
  } catch (err) {
680
692
  console.error("Error in reactive flush:", err)
681
693
  }
694
+ // The frame's start pass: the native motion the callbacks and the
695
+ // flush declared (a node created with an enter, one let go of with an
696
+ // exit) starts here, where Unity runs its animation update - after
697
+ // Update, before LateUpdate - so the late pass reads the pose the
698
+ // frame draws. The engine steps running motion before the callbacks.
699
+ startTransitions()
682
700
  runBeforeRender(bootstrap)
683
701
  scanForOrphans(t)
684
702
  renderFrame()
@@ -690,6 +708,9 @@ export function attachWindow(nodeId: number) {
690
708
  if (hz > 0) refreshRate = hz
691
709
  })
692
710
 
711
+ unsubFrameStart = on("frameStart", ({ time }: { time: number; frame: number }) => {
712
+ latestTick = time * 1000
713
+ })
693
714
  unsubscribe = on("render", ({ time, frame }: { time: number; frame: number }) => {
694
715
  runFrame(time * 1000, frame)
695
716
  })
@@ -787,13 +808,33 @@ export function attachWindow(nodeId: number) {
787
808
  }
788
809
  })
789
810
 
811
+ // The back stack: top down, stopping at the first handler that prevents.
812
+ // The native triggers (the Android button, the pad's back, the dev chord)
813
+ // carry the platform default when none does; an unconsumed Escape runs
814
+ // the same stack without it, since Escape closes what is open and never
815
+ // leaves the app.
816
+ let dispatchBack = (platformDefault: boolean) => {
817
+ let prevented = false
818
+ let e: BackEvent = {
819
+ preventDefault: () => {
820
+ prevented = true
821
+ },
822
+ }
823
+ // Copy first: a handler may unregister (itself or others) mid-dispatch.
824
+ let stack = [...backHandlers]
825
+ for (let i = stack.length - 1; i >= 0 && !prevented; i--) stack[i]!(e)
826
+ if (!prevented && platformDefault) backDefault()
827
+ }
828
+
790
829
  // Key events dispatch along the focused node's ancestor chain, leaf->root
791
830
  // (the pointer bubbling contract), so a container hears keys from focused
792
831
  // descendants and the window root hears everything: <window onKeyDown> is
793
832
  // the app-global shortcut point. With nothing focused the path is the
794
833
  // window root alone - key events are never dropped. The path is resolved
795
834
  // at dispatch time from current focus (nothing to freeze: keyup follows
796
- // focus, as in the DOM).
835
+ // focus, as in the DOM). An Escape keydown that reaches the end of the
836
+ // chain unconsumed is a back: the web's own default action for the key
837
+ // (closing a dialog), which a handler suppresses with stopPropagation.
797
838
  let dispatchKey = (raw: any, handler: string) => {
798
839
  let target = focusedNode() ?? windowRootId
799
840
  let stopped = false
@@ -807,25 +848,14 @@ export function attachWindow(nodeId: number) {
807
848
  getEventHandler(id, handler)?.(e)
808
849
  if (stopped) break
809
850
  }
851
+ if (!stopped && handler === "onKeyDown" && e.key === "Escape") dispatchBack(false)
810
852
  }
811
853
 
812
854
  unsubKeyDown = on("keydown", (raw: any) => dispatchKey(raw, "onKeyDown"))
813
855
 
814
856
  unsubKeyUp = on("keyup", (raw: any) => dispatchKey(raw, "onKeyUp"))
815
857
 
816
- unsubBack = on("back", () => {
817
- let prevented = false
818
- let e: BackEvent = {
819
- preventDefault: () => {
820
- prevented = true
821
- },
822
- }
823
- // Copy first: a handler may unregister (itself or others) mid-dispatch.
824
- // Top of the stack down, stopping as soon as one takes the event.
825
- let stack = [...backHandlers]
826
- for (let i = stack.length - 1; i >= 0 && !prevented; i--) stack[i]!(e)
827
- if (!prevented) backDefault()
828
- })
858
+ unsubBack = on("back", () => dispatchBack(true))
829
859
 
830
860
  unsubTextInput = on("textInput", (e: any) => {
831
861
  let id = focusedNode()
@@ -855,6 +885,7 @@ export function attachWindow(nodeId: number) {
855
885
  onCleanup(() => {
856
886
  setInterestRoot(null)
857
887
  if (unsubscribe) unsubscribe()
888
+ if (unsubFrameStart) unsubFrameStart()
858
889
  if (unsubDown) unsubDown()
859
890
  if (unsubUp) unsubUp()
860
891
  if (unsubCancel) unsubCancel()