@solidrt/core 0.0.70 → 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
@@ -102,7 +108,12 @@ Rules, in order of leverage:
102
108
  boundary node itself (or any ancestor) are hoisted out of the cache and
103
109
  applied at composite time, so animating x/y/scale/rotate/opacity of a
104
110
  boundary does NOT re-raster it (verified by A/B measurement - the damage
105
- system classifies these as Transform and keeps the node's own cache).
111
+ system classifies these as Compose and keeps the node's own cache). One
112
+ exception, for crispness: a boundary holding text that comes to rest at
113
+ a new scale re-records once there (a snapshot boundary re-rasterizes
114
+ once), so its content is rasterized at the resting size instead of
115
+ stretched, and a scale transition with a known end rasters for that end
116
+ as it starts; a press scale that returns to 1 never re-rasters.
106
117
  What DOES invalidate the cache is any paint or content change inside the
107
118
  subtree - colors, path data, text, a Show toggling - so drive animation
108
119
  with transforms and keep the cached content itself static. Off a boundary,
@@ -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
@@ -51,6 +51,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
51
51
  - `gpu-texture-blend.tsx` - compositing two shader targets as stacked `<texture>` layers with `blendMode` ("plus" additive over a base pass), the alternative to a third shader that samples both. Click toggles against `"source-over"` to show why. The tree-level counterpart to blending within one draw (`gpu-particles.tsx`); without `blend: "add"` a target's own draw runs with GL blending disabled.
52
52
  - `gpu-raw-program.tsx` - the raw shading layer: compileShader/linkProgram/createRenderPipeline/createShaderTarget, one vertex stage shared by two programs, with and without the standard header.
53
53
  - `gpu-pipeline.tsx` - `createPipelineTexture`: a custom vertex+fragment pair drawing an interleaved vertex buffer (position and color attributes resolved by name), a depth-tested spinning cube whose `uTime` uniform is driven through `<texture params>` exactly like a fragment shader's.
54
+ - `gpu-shader-chain.tsx` - a chain of targets: a plasma `createShaderTexture` bound as a cube `createPipelineTexture`'s sampler input (`textures: { uMap: plasmaId }`). Sampler bindings are live dependencies, so the app drives only the plasma's `uTime` and the cube re-renders after it every frame, in that order, with no uniform and no per-frame code of its own. The plasma shows beside the cube on the `<texture>` that carries its params.
54
55
  - `gpu-particles.tsx` - an additive particle field: `topology: "points"` with `blend: "add"`, so overlapping gaussian splats (`gl_PointSize` from the vertex stage, `gl_PointCoord` falloff, premultiplied output) accumulate into a glowing volume instead of overwriting. The tints are typed vec3 uniforms filled from 3-number array params.
55
56
  - `gpu-instancing.tsx` - instanced drawing: one 3-vertex triangle drawn hundreds of times via `instanceCount`, each instance placed and tinted on a phyllotaxis spiral from `gl_InstanceID` alone. `setDraw(id, { instanceCount })` merges into the draw range per frame (absent keys keep their values, like params), so the population breathes without touching the buffer.
56
57
  - `gpu-draw-list.tsx` - `createDrawTarget`: one render target holding an ordered, mutable LIST of draws - two orbiting triangles from different programs sharing one depth buffer (the target owns the depth storage, each pipeline the test/write behavior), a third entry added and removed live via its stable `DrawId`, and `setDrawParams` as the per-object channel.
@@ -0,0 +1,135 @@
1
+ // A chain of targets: a plasma fragment pass feeding a cube pipeline that
2
+ // samples it. Sampler bindings are live dependencies (the model in
3
+ // @solidrt/core/docs/reference/gpu.md): the cube's target is bound to the
4
+ // plasma's through `textures`, so whenever the plasma re-renders, the cube
5
+ // re-renders after it in the same frame, in that order. The app therefore
6
+ // drives only the first pass: the plasma's uTime through the <texture>
7
+ // params prop, as in gpu-shader.tsx. The cube has no uniform of its own
8
+ // and no per-frame code; its view is fixed in the vertex shader, and its
9
+ // faces move because what they sample moves.
10
+ //
11
+ // The plasma is shown beside the cube because that <texture> is where its
12
+ // params prop lives. A source pass that is not on screen is driven the same
13
+ // way from any <texture> of its id. The cube's buffers and index buffer are
14
+ // the gpu-pipeline.tsx mesh with a UV per corner in place of a color.
15
+ import { render, onFrame, createSignal } from "@solidrt/core"
16
+ import { createBuffer, createPipelineTexture, createShaderTexture, glsl } from "@solidrt/core/gpu"
17
+
18
+ // The plasma target's side: what the cube's faces sample.
19
+ let PLASMA_SIZE = 256
20
+ // The cube target's side: what the window shows.
21
+ let CUBE_SIZE = 512
22
+
23
+ // Pass 1: a plasma, the only thing the app animates.
24
+ let PLASMA = glsl`
25
+ uniform float uTime;
26
+ void main() {
27
+ vec2 uv = vUV * 6.0;
28
+ float t = uTime;
29
+ float v = sin(uv.x + t) + sin(uv.y + t * 0.8) + sin((uv.x + uv.y) * 0.7 + t * 1.3)
30
+ + sin(length(uv - 3.0) * 1.5 - t);
31
+ vec3 col = 0.5 + 0.5 * cos(v * 1.2 + vec3(0.0, 2.1, 4.2));
32
+ fragColor = vec4(col, 1.0);
33
+ }
34
+ `
35
+
36
+ // Pass 2: the cube, viewed from one fixed angle. The projection rig and the
37
+ // clip-y flip are gpu-pipeline.tsx's.
38
+ let VERTEX = glsl`
39
+ in vec3 aPos;
40
+ in vec2 aUV;
41
+ out vec2 vTex;
42
+
43
+ void main() {
44
+ float cy = cos(0.7), sy = sin(0.7);
45
+ float cx = cos(0.5), sx = sin(0.5);
46
+ mat3 rotY = mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
47
+ mat3 rotX = mat3(1.0, 0.0, 0.0, 0.0, cx, sx, 0.0, -sx, cx);
48
+ vec3 p = rotX * (rotY * aPos);
49
+ p.z -= 2.5;
50
+ float w = -p.z;
51
+ float f = 2.0;
52
+ float a = 11.0 / 9.0;
53
+ float b = -20.0 / 9.0;
54
+ gl_Position = vec4(p.x * f, -p.y * f, w * a + b, w);
55
+ vTex = aUV;
56
+ }
57
+ `
58
+
59
+ // The sampler is the chain: uMap is bound to the plasma target below.
60
+ let FRAGMENT = glsl`
61
+ in vec2 vTex;
62
+ uniform sampler2D uMap;
63
+ void main() {
64
+ fragColor = texture(uMap, vTex);
65
+ }
66
+ `
67
+
68
+ // Interleaved [pos vec3, uv vec2]: 24 vertices, 4 per face, each face the
69
+ // whole plasma once. Every face winds counter-clockwise seen from outside.
70
+ function cubeVertices(): Float32Array {
71
+ type Vec3 = [number, number, number]
72
+ let verts: number[] = []
73
+ let quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3) => {
74
+ let uvs = [[0, 1], [1, 1], [1, 0], [0, 0]]
75
+ for (let [i, p] of [a, b, c, d].entries()) verts.push(p[0], p[1], p[2], uvs[i]![0]!, uvs[i]![1]!)
76
+ }
77
+ let s = 0.5
78
+ quad([-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]) // front
79
+ quad([s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]) // back
80
+ quad([s, -s, s], [s, -s, -s], [s, s, -s], [s, s, s]) // right
81
+ quad([-s, -s, -s], [-s, -s, s], [-s, s, s], [-s, s, -s]) // left
82
+ quad([-s, s, s], [s, s, s], [s, s, -s], [-s, s, -s]) // top
83
+ quad([-s, -s, -s], [s, -s, -s], [s, -s, s], [-s, -s, s]) // bottom
84
+ return new Float32Array(verts)
85
+ }
86
+
87
+ // Two triangles per face over its 4 shared vertices: 36 uint16 indices.
88
+ function cubeIndices(): Uint16Array {
89
+ let indices: number[] = []
90
+ for (let face = 0; face < 6; face++) {
91
+ let v = face * 4
92
+ indices.push(v, v + 1, v + 2, v, v + 2, v + 3)
93
+ }
94
+ return new Uint16Array(indices)
95
+ }
96
+
97
+ function App() {
98
+ let plasmaId = createShaderTexture(PLASMA, PLASMA_SIZE, PLASMA_SIZE, { uTime: 0 }, { label: "plasma" })
99
+ let bufferId = createBuffer(cubeVertices(), { label: "cube-verts" })
100
+ let indexId = createBuffer(cubeIndices(), { label: "cube-indices" })
101
+ // No params: nothing of the cube's is ever written. Its one input is the
102
+ // plasma target, and that binding is what re-renders it.
103
+ let cubeId = createPipelineTexture(VERTEX, FRAGMENT, CUBE_SIZE, CUBE_SIZE, null, {
104
+ label: "cube",
105
+ textures: { uMap: plasmaId },
106
+ buffers: [
107
+ {
108
+ attributes: [
109
+ { name: "aPos", format: "float32x3" },
110
+ { name: "aUV", format: "float32x2" },
111
+ ],
112
+ buffer: bufferId,
113
+ },
114
+ ],
115
+ indexBuffer: indexId,
116
+ indexFormat: "uint16",
117
+ depth: true,
118
+ cull: "back",
119
+ clearColor: [0.08, 0.08, 0.12, 1],
120
+ })
121
+ let [time, setTime] = createSignal(0)
122
+ onFrame((tick) => setTime(tick / 1000))
123
+
124
+ return (
125
+ <window alignItems="center" justifyContent="center" flexDirection="row" gap={24}>
126
+ <texture src={cubeId} width={CUBE_SIZE} height={CUBE_SIZE} />
127
+ <view flexDirection="column" alignItems="center" gap={8}>
128
+ <texture src={plasmaId} params={{ uTime: time() }} width={PLASMA_SIZE} height={PLASMA_SIZE} />
129
+ <text fontSize={14} color="#888">the plasma pass, the only one driven</text>
130
+ </view>
131
+ </window>
132
+ )
133
+ }
134
+
135
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.70",
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.70",
37
- "@solidrt/test": "0.0.70"
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/core.ts CHANGED
@@ -391,15 +391,18 @@ export function prepareText(text: string, options?: tree.MeasureTextOptions): tr
391
391
  }
392
392
 
393
393
  /**
394
- * Makes the glyph cells of printable ASCII for these font styles ahead of
395
- * their first use, off the frame: call it at startup with the type scale
396
- * the app draws in (the components' Window does it for the theme's), so a
397
- * screen's first paint in any of them never waits for cells. Glyphs outside
398
- * ASCII are made at first use. Each entry names a font as a <text> would;
399
- * line height and maxLines are ignored.
394
+ * Makes glyph cells ahead of their first use, off the frame: call it at
395
+ * startup with the type scale the app draws in (the components' Window
396
+ * does it for the theme's), so a screen's first paint in any of them never
397
+ * waits for cells. Each entry names a font as a <text> would (line height
398
+ * and maxLines are ignored) and warms its printable ASCII; an entry with a
399
+ * `text` also warms the glyphs that string shapes to in the font (a
400
+ * ligature, a curly quote, a letter beyond ASCII), so a headline or a
401
+ * label the app knows it will draw is warm too. Anything else is made at
402
+ * first use.
400
403
  */
401
- export function warmText(styles: tree.MeasureTextOptions[]): void {
402
- tree.warmText(styles)
404
+ export function warmText(entries: tree.WarmTextEntry[]): void {
405
+ tree.warmText(entries)
403
406
  }
404
407
 
405
408
  export type TextRenderingOptions = tree.TextRenderingOptions
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
@@ -753,6 +760,128 @@ export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferVie
753
760
  gpu.writeBuffer(id, toUint8(data), byteOffset)
754
761
  }
755
762
 
763
+ /**
764
+ * A stream of fixed-size records with a GPU buffer, a CPU mirror and a
765
+ * dirty range: the publish model shared by every JS-stepped population
766
+ * (a 2d sprite layer's records, a 3d mesh's instance streams). Write the
767
+ * mirror, `mark` the records written, then `publish` at the flush: the
768
+ * dirty range goes out as one `writeBuffer` at its byte offset, or the
769
+ * drawn prefix whole through the write lease when the buffer carries an
770
+ * instance order (the engine gathers the copy into key order, and a byte
771
+ * range has no stable position under a permutation). Growth replaces the
772
+ * buffer and the mirror (never a resize) and marks what the mirror held,
773
+ * so the next publish fills the replacement; the old buffer is the
774
+ * caller's to destroy once nothing binds it. Read `buffer` and `bytes` at
775
+ * use time: both are replaced by growth.
776
+ */
777
+ export type RecordStream = {
778
+ /** The GPU buffer the records publish to; replaced by `grow`. */
779
+ readonly buffer: gpu.BufferId
780
+ /** The mirror, `capacity * stride` bytes; replaced by `grow`. View it as
781
+ * the record layout wants (a Float32Array over it for an all-float
782
+ * record). */
783
+ readonly bytes: Uint8Array<ArrayBuffer>
784
+ /** Bytes per record. */
785
+ readonly stride: number
786
+ /** Records the mirror and the buffer hold. */
787
+ readonly capacity: number
788
+ /** Whether any record is marked since the last publish. */
789
+ readonly dirty: boolean
790
+ /** Extend the dirty range over records [first, last). */
791
+ mark(first: number, last: number): void
792
+ /** Send what is dirty and clear the mark. `whole`: the first `limit`
793
+ * records go out through the lease regardless of the range (the
794
+ * ordered form, and a replacement buffer's first fill); otherwise the
795
+ * dirty range, clipped to `limit` records (the live sprites, or the
796
+ * capacity when records are written ahead of their count). Nothing is
797
+ * sent for an empty range or a zero `limit`. */
798
+ publish(whole: boolean, limit: number): void
799
+ /** Replace the buffer and the mirror with room for `capacity` records,
800
+ * the old contents copied over and marked whole; returns the OLD buffer
801
+ * for the caller to destroy once its draws point at the new one. */
802
+ grow(capacity: number): gpu.BufferId
803
+ /** Free the buffer. Safe to call more than once. */
804
+ destroy(): void
805
+ }
806
+
807
+ /**
808
+ * Creates a record stream of `capacity` records of `stride` bytes (see
809
+ * {@link RecordStream}): a sized, unwritten GPU buffer and a zeroed
810
+ * mirror. Freed with the reactive owner like a buffer (opt out with
811
+ * `{ autoFree: false }` and call `destroy`).
812
+ */
813
+ export function createRecordStream(stride: number, capacity: number, opts?: CreateOptions): RecordStream {
814
+ if (!(Number.isInteger(stride) && stride > 0)) throw new Error("createRecordStream: stride must be a positive integer, got " + stride)
815
+ if (!(Number.isInteger(capacity) && capacity > 0)) throw new Error("createRecordStream: capacity must be a positive integer, got " + capacity)
816
+ let buffer = createBuffer(capacity * stride, { label: opts?.label, autoFree: false })
817
+ let bytes = new Uint8Array(capacity * stride)
818
+ let dirty: [number, number] | null = null
819
+ let destroyed = false
820
+ let stream: RecordStream = {
821
+ get buffer() {
822
+ return buffer
823
+ },
824
+ get bytes() {
825
+ return bytes
826
+ },
827
+ stride,
828
+ get capacity() {
829
+ return bytes.length / stride
830
+ },
831
+ get dirty() {
832
+ return dirty !== null
833
+ },
834
+ mark(first, last) {
835
+ let capacity = bytes.length / stride
836
+ if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last > capacity || first > last) {
837
+ throw new Error("RecordStream.mark: range [" + first + ", " + last + ") is outside the stream's " + capacity + " records")
838
+ }
839
+ if (first === last) return
840
+ if (dirty === null) dirty = [first, last]
841
+ else {
842
+ if (first < dirty[0]) dirty[0] = first
843
+ if (last > dirty[1]) dirty[1] = last
844
+ }
845
+ },
846
+ publish(whole, limit) {
847
+ let range = dirty
848
+ dirty = null
849
+ if (whole) {
850
+ let byteLength = Math.min(limit, bytes.length / stride) * stride
851
+ if (byteLength <= 0) return
852
+ let out = beginBufferWrite(buffer)
853
+ new Uint8Array(out.buffer, 0, byteLength).set(bytes.subarray(0, byteLength))
854
+ gpu.endBufferWrite(buffer, byteLength)
855
+ return
856
+ }
857
+ if (range === null) return
858
+ let lo = range[0]
859
+ let hi = Math.min(range[1], limit)
860
+ if (hi > lo) writeBuffer(buffer, bytes.subarray(lo * stride, hi * stride), lo * stride)
861
+ },
862
+ grow(next) {
863
+ let previous = bytes.length / stride
864
+ if (!(Number.isInteger(next) && next > previous)) {
865
+ throw new Error("RecordStream.grow: capacity must be an integer above the current " + previous + ", got " + next)
866
+ }
867
+ let old = buffer
868
+ buffer = createBuffer(next * stride, { label: opts?.label, autoFree: false })
869
+ let grown = new Uint8Array(next * stride)
870
+ grown.set(bytes)
871
+ bytes = grown
872
+ dirty = [0, previous]
873
+ return old
874
+ },
875
+ destroy() {
876
+ if (destroyed) return
877
+ destroyed = true
878
+ gpu.destroyBuffer(buffer)
879
+ },
880
+ }
881
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => stream.destroy())
882
+ return stream
883
+ }
884
+
756
885
  /**
757
886
  * Hands an ordered instance buffer's full record set to the engine - the
758
887
  * write-once form: the engine keeps the one CPU copy and sorts,
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,
@@ -94,7 +94,7 @@ export type {
94
94
  Color,
95
95
  Pct,
96
96
  } from "./types"
97
- export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
97
+ export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit, WarmTextEntry } from "flux:rendertree"
98
98
 
99
99
  // A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
100
100
  // half the element box. Keeps percentages a first-class branded value rather
@@ -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()