@rig-c/core 2.71.0 → 2.72.0

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/docs/AUTHORING.md CHANGED
@@ -2935,8 +2935,9 @@ rather than being checked afterwards.
2935
2935
 
2936
2936
  #### 3.4.5 `depth` — give every vertex its own z, instead of one cylinder radius
2937
2937
 
2938
- **On a `contour` or a `grid`.** A grid is the one it was built for: a turn
2939
- needs interior vertices to move, and a contour has none of its own.
2938
+ **On a `contour` or a `grid` generator, or directly on an authored mesh**
2939
+ (including named or raw weighted geometry). A grid supplies interior vertices;
2940
+ an authored mesh keeps its existing triangulation and weights.
2940
2941
 
2941
2942
  A `yaw` or `pitch` key (§4.12) turns a part by treating it as painted on a
2942
2943
  cylinder: a vertex `u` off the axis gets `z = √(radius² − u²)`. That is the right
@@ -2954,6 +2955,35 @@ off a greyscale sheet in the part's own pixel grid:
2954
2955
  }
2955
2956
  ```
2956
2957
 
2958
+ For authored geometry, put the same block beside `weights` or `vertices`:
2959
+
2960
+ ```json
2961
+ "depth": { "image": "strand_depth.png", "near": "white", "zScale": 12 }
2962
+ ```
2963
+
2964
+ The attachment must name an `image`. Each vertex's **setup world position**
2965
+ (the weighted sum of its setup bind positions) is located in the same window
2966
+ as the generators: the part is centred on the slot bone's setup world origin,
2967
+ x right and y up, while the sheet is in part pixels, x right and y down.
2968
+ UVs do not locate these samples. On a scaled atlas page the same drawing-sized
2969
+ sheet is read through the existing page-to-drawing conversion. A sheet whose
2970
+ size differs from the part is refused by `CE_COMPILE_GREY_SHEET_SIZE_MISMATCH`.
2971
+ A top-level `depth` beside a generator is refused; use that generator's depth
2972
+ field for generated geometry.
2973
+
2974
+ An authored `yaw` or `pitch` key with `depth: true` evaluates in **setup world**,
2975
+ including `about` in world units. Its driving-axis displacement is
2976
+ `(coordinate - about) * (cos(angle) - 1) - z * sin(angle)`; with
2977
+ `narrowing: false` only `-z * sin(angle)` remains. Each rounded world offset is
2978
+ written through every influencing bone's inverse setup linear transform.
2979
+ Subsequent bone animation and physics carry those bind offsets with the mesh:
2980
+ the displacement after posing is the weighted composition of the posed bones
2981
+ with their inverse setup transforms, rather than a fixed global translation.
2982
+ The reported turn ceiling measures the setup-world triangles on both axes;
2983
+ A39 still measures the actual posed artifact. Other transform kinds and meshes
2984
+ without this new input keep their existing evaluation spaces and emitted bytes.
2985
+ Naming a sheet without a depth turn changes no skeleton or atlas byte.
2986
+
2957
2987
  | Field | Meaning |
2958
2988
  | --- | --- |
2959
2989
  | `image` | **required.** The sheet, relative to the rig's `images` directory, and the **same pixel size as this attachment's `image`**. It is not packed into the atlas — it is a measurement rigc reads at compile time, not art anything draws. Under `--atlas-in` that is still the drawing's size, on a page that declares a `scale:` too: the sheet is read at each vertex's texel position over the scale the atlas states, so one sheet serves the loose parts and every pack of them |
@@ -7330,7 +7360,7 @@ retain the minimal inputs and report that code rather than inventing author data
7330
7360
  | <a id="CE_COMPILE_DEPTH_IMAGE_MISSING"></a>`CE_COMPILE_DEPTH_IMAGE_MISSING` | “&lt;value&gt;: the depth block has no "image"; it is the sheet to sample, relative to the rig's images directory” | State the depth image relative to the rig images directory. |
7331
7361
  | <a id="CE_COMPILE_DEPTH_NEAR_INVALID"></a>`CE_COMPILE_DEPTH_NEAR_INVALID` | “&lt;value&gt;: the depth block says "near": &lt;value&gt;; it is "white" or "black". Both conventions are in use, and a sheet read with the wrong one turns the part inside out with every gate green.” | State near as white or black to declare the sheet convention. |
7332
7362
  | <a id="CE_COMPILE_DEPTH_TONE_SCALE_INVALID"></a>`CE_COMPILE_DEPTH_TONE_SCALE_INVALID` | “&lt;value&gt;: "zScale" is &lt;value&gt;; it is a finite number of world units”<br>“&lt;value&gt;: "zScale" is &lt;value&gt;; it is how many units the map's full range spans, so a positive number. To put the near end at the back, say "near": "black" — a negative scale states the same thing twice and the two can then disagree.”<br>“&lt;value&gt;: depth tone "&lt;value&gt;" is &lt;value&gt;; it is a finite number”<br>“&lt;value&gt;: depth tone "contrast" is &lt;value&gt;; a contrast at or below 0 collapses the range onto the midpoint (or turns it inside out), so the map would describe a flat part. It is a positive number, and 1 leaves the range alone.”<br>“&lt;value&gt;: depth tone "gamma" is &lt;value&gt;; a gamma at or below 0 maps every level to the same nearness, so the map would describe a flat part. It is a positive number, and 1 is the straight line.”<br>Here the values named in the message include: “bias”. | Use finite depth tone values, positive gamma/contrast and a positive finite zScale. |
7333
- | <a id="CE_COMPILE_DEPTH_COVERAGE_INCOMPLETE"></a>`CE_COMPILE_DEPTH_COVERAGE_INCOMPLETE` | “&lt;value&gt;: the depth map "&lt;value&gt;" does not cover &lt;value&gt; of the mesh's &lt;value&gt; vertices — the first is vertex &lt;value&gt; at (&lt;value&gt;, &lt;value&gt;)[ / in the page's texels, pixel (&lt;value&gt;, &lt;value&gt;) of the drawing-sized sheet]. &lt;value&gt; Sampling anyway would give those vertices the sheet's background depth and fold them away from the turn.”<br>“[A contour mesh puts every vertex ON the silhouette and pushes it out by the margin, so a sheet cut to the art stops short of every one of them. Dilate the sheet past the mesh margin, or lower the margin. / A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the lattice reaches past it wherever the art does not fill the window. Dilate the sheet to the window, or state "us"/"vs" that keep the lattice inside the art.]” | Supply a depth sheet covering every mesh vertex in the part's drawing-sized pixel grid. |
7363
+ | <a id="CE_COMPILE_DEPTH_COVERAGE_INCOMPLETE"></a>`CE_COMPILE_DEPTH_COVERAGE_INCOMPLETE` | “&lt;value&gt;: the depth map "&lt;value&gt;" does not cover &lt;value&gt; of the mesh's &lt;value&gt; vertices — the first is vertex &lt;value&gt; at (&lt;value&gt;, &lt;value&gt;)[ / in the page's texels, pixel (&lt;value&gt;, &lt;value&gt;) of the drawing-sized sheet]. &lt;value&gt; Sampling anyway would give those vertices the sheet's background depth and fold them away from the turn.”<br>“[A contour mesh puts every vertex ON the silhouette and pushes it out by the margin, so a sheet cut to the art stops short of every one of them. Dilate the sheet past the mesh margin, or lower the margin. / A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the lattice reaches past it wherever the art does not fill the window. Dilate the sheet to the window, or state "us"/"vs" that keep the lattice inside the art. / The authored setup vertices reach outside the sheet coverage. Extend the sheet to cover their bilinear taps, or correct the setup geometry.]” | Supply a depth sheet covering every mesh vertex in the part's drawing-sized pixel grid. |
7334
7364
  | <a id="CE_COMPILE_MESH_BONE_SETUP_TRANSFORM_MISSING"></a>`CE_COMPILE_MESH_BONE_SETUP_TRANSFORM_MISSING` | “&lt;value&gt;: bone "&lt;value&gt;" has no setup transform” | Correct the bone/setup hierarchy; report a missing compiled transform for a declared bone. |
7335
7365
  | <a id="CE_COMPILE_MESH_BONE_NOT_DECLARED"></a>`CE_COMPILE_MESH_BONE_NOT_DECLARED` | “&lt;value&gt;: bone "&lt;value&gt;" is not in the rig's bone list” | Use a bone name declared in the rig. |
7336
7366
  | <a id="CE_COMPILE_GREY_SHEET_IMAGE_MISSING"></a>`CE_COMPILE_GREY_SHEET_IMAGE_MISSING` | “&lt;value&gt;: the "&lt;value&gt;" block has no image to read” | State the image in the named depth/soft block. |
@@ -7467,7 +7497,7 @@ retain the minimal inputs and report that code rather than inventing author data
7467
7497
  | <a id="CE_COMPILE_SEQUENCE_KEY_INDEX_OUTSIDE_SERIES"></a>`CE_COMPILE_SEQUENCE_KEY_INDEX_OUTSIDE_SERIES` | “&lt;value&gt; (t=&lt;value&gt;): index &lt;value&gt; is past the end of a &lt;value&gt;-frame series (frames 0 to &lt;value&gt;). `Sequence.resolveIndex` clamps it to the last frame, so the key would start on a frame it does not name. `index` is 0-based.” | Use a zero-based index within the frame series. |
7468
7498
  | <a id="CE_COMPILE_DEFORM_TRANSFORM_AND_VERTICES"></a>`CE_COMPILE_DEFORM_TRANSFORM_AND_VERTICES` | “&lt;value&gt; (t=&lt;value&gt;): the key carries both a "transform" and a "vertices" run, and they are two answers to one question. Authored offsets and a stated model do not combine, for the same reason a mesh cannot carry both a "generator" and authored geometry — drop one.”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>Here the values named in the message include: “default”. | Use a transform model or authored vertex offsets. |
7469
7499
  | <a id="CE_COMPILE_DEFORM_TRANSFORM_START_INDEX"></a>`CE_COMPILE_DEFORM_TRANSFORM_START_INDEX` | “&lt;value&gt; (t=&lt;value&gt;): the key states a "transform" and a start index. A transform is a model of the whole attachment and is evaluated over all &lt;value&gt; of its vertices, so it always starts at deform index 0. A model applied to part of a run leaves a step at the run's edge — write the partial run by hand if that is what you mean.”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>Here the values named in the message include: “default”. | Apply a transform over the whole attachment without a start index, or author the partial run. |
7470
- | <a id="CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED"></a>`CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED` | “&lt;value&gt; (t=&lt;value&gt;): a "transform" states where every vertex of this attachment goes, and this one has a vertex the arithmetic cannot place — &lt;value&gt;. Fix the weights, or write the bind-space pairs yourself and start the run with "offset".”<br>“vertex &lt;value&gt;'s &lt;value&gt; weights sum to &lt;value&gt; rather than 1. A stated model becomes a deform run by writing each vertex's world displacement D into every influence as Mᵢ⁻¹·D, which moves the vertex by D·Σwᵢ — so a sum that is not 1 has no displacement that lands it where the model says. A20_MESH_WEIGHTS_COHERENT is the assertion that requires that sum, at the same 1e-3”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>Here the values named in the message include: “default”. | Fix weights so setup vertices can be positioned, or author bind-space pairs using offset. |
7500
+ | <a id="CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED"></a>`CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED` | “&lt;value&gt; (t=&lt;value&gt;): a "transform" states where every vertex of this attachment goes, and this one has a vertex the arithmetic cannot place — &lt;value&gt;. Fix the weights, or write the bind-space pairs yourself and start the run with "offset".”<br>“vertex &lt;value&gt;'s &lt;value&gt; weights sum to &lt;value&gt; rather than 1. A stated model becomes a deform run by writing each vertex's world displacement D into every influence as Mᵢ⁻¹·D, which moves the vertex by D·Σwᵢ — so a sum that is not 1 has no displacement that lands it where the model says. A20_MESH_WEIGHTS_COHERENT is the assertion that requires that sum, at the same 1e-3”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>Here the values named in the message include: “default”.<br>“&lt;value&gt;: depth needs resolved setup positions — &lt;value&gt;” | Fix weights so setup vertices can be positioned, or author bind-space pairs using offset. |
7471
7501
  | <a id="CE_COMPILE_DEFORM_TRANSFORM_OFFSET_COUNT"></a>`CE_COMPILE_DEFORM_TRANSFORM_OFFSET_COUNT` | “&lt;value&gt; (t=&lt;value&gt;): the transform produced &lt;value&gt; numbers for &lt;value&gt; vertices”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>Here the values named in the message include: “default”. | Report the transform model and compiler defect: the produced offset count does not match the vertices. |
7472
7502
  | <a id="CE_COMPILE_DEFORM_TRANSFORM_RUN_LENGTH"></a>`CE_COMPILE_DEFORM_TRANSFORM_RUN_LENGTH` | “&lt;value&gt; (t=&lt;value&gt;): the transform expanded to &lt;value&gt; numbers and this attachment's deform array is &lt;value&gt; long”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>Here the values named in the message include: “default”. | Report the transform model and compiler defect: the expanded offsets do not match the deform array. |
7473
7503
  | <a id="CE_COMPILE_DEFORM_START_SOURCES_CONFLICT"></a>`CE_COMPILE_DEFORM_START_SOURCES_CONFLICT` | “&lt;value&gt; (t=&lt;value&gt;): a key gives its start as "offset" (an index into the deform array) or as "fromVertex" (a vertex index rigc translates), never both”<br>Also: “animation "&lt;value&gt;" deform &lt;value&gt;/&lt;value&gt;/&lt;value&gt;”<br>Here the values named in the message include: “default”. | Use offset or fromVertex, rather than both. |
@@ -7645,7 +7675,9 @@ retain the minimal inputs and report that code rather than inventing author data
7645
7675
  | <a id="CE_DEFORM_TRANSFORM_OBJECT"></a>`CE_DEFORM_TRANSFORM_OBJECT` | “&lt;where&gt;: "transform" is &lt;JSON.stringify(transform)&gt;; it is an object with a "kind"” | Write transform as an object with a kind. |
7646
7676
  | <a id="CE_DEFORM_TRANSFORM_KIND"></a>`CE_DEFORM_TRANSFORM_KIND` | “&lt;where&gt;: "transform" has kind &lt;JSON.stringify(kind)&gt;; this spec evaluates &lt;DEFORM_TRANSFORM_KINDS.join(', ')&gt;. A model that is none of those is still authorable as a "vertices" run.”<br>Here the values named in the message include: “yaw”, “bend”, “wave”, “pitch”, “affine”. | Choose a supported transform kind, or author the deformation as a vertices run. |
7647
7677
  | <a id="CE_DEFORM_DEPTH_RADIUS_CONFLICT"></a>`CE_DEFORM_DEPTH_RADIUS_CONFLICT` | “&lt;where&gt;: transform &lt;kind&gt; states both "depth": true and a radius &lt;JSON.stringify(t.radius)&gt;. They are two answers to how far forward each vertex sits — a cylinder derives it, a map states it — and a key carrying both leaves a reader unable to say which one the output came from. Drop one.” | Choose depth:true or a radius and remove the other. |
7648
- | <a id="CE_DEFORM_DEPTH_MAP_REQUIRED"></a>`CE_DEFORM_DEPTH_MAP_REQUIRED` | “&lt;where&gt;: transform &lt;kind&gt; says "depth": true and this attachment has no depth map. The map is named on the attachment's generator, as `"depth": { "image": …, "near": …, "zScale": … }`, because it describes the art rather than this key. Name it there, or give this key a radius.” | Declare the attachment generator depth map or give this transform a radius. |
7678
+ | <a id="CE_DEFORM_DEPTH_MAP_REQUIRED"></a>`CE_DEFORM_DEPTH_MAP_REQUIRED` | “&lt;where&gt;: transform &lt;kind&gt; says "depth": true and this attachment has no depth map. The map is named on an authored attachment or its contour/grid generator, as `"depth": { "image": …, "near": …, "zScale": … }`, because it describes the art rather than this key. Name it there, or give this key a radius.” | Declare the authored attachment or contour/grid generator depth map, or give this transform a radius. |
7679
+ | <a id="CE_COMPILE_DEPTH_AUTHORED_REQUIRED"></a>`CE_COMPILE_DEPTH_AUTHORED_REQUIRED` | “&lt;where&gt;: attachment "depth" describes authored geometry; a generated mesh states its depth on the contour or grid generator instead” | Put depth on the contour/grid generator or remove the generator and author the geometry. |
7680
+ | <a id="CE_COMPILE_DEPTH_IMAGE_REQUIRED"></a>`CE_COMPILE_DEPTH_IMAGE_REQUIRED` | “&lt;where&gt;: an authored mesh with "depth" needs an "image" to locate the part's pixel grid” | Name the part image the sheet describes. |
7649
7681
  | <a id="CE_DEFORM_DEPTH_SAMPLE_COUNT"></a>`CE_DEFORM_DEPTH_SAMPLE_COUNT` | “&lt;where&gt;: transform &lt;kind&gt; has &lt;depth.length&gt; sampled depths for &lt;count&gt; vertices” | Report this compiler defect with the inputs and printed sampled-depth and vertex counts; do not edit generated samples. |
7650
7682
  | <a id="CE_DEFORM_RADIUS_POSITIVE"></a>`CE_DEFORM_RADIUS_POSITIVE` | “&lt;where&gt;: transform &lt;kind&gt; has radius &lt;radius&gt;; it is the radius of the cylinder the part is painted on, so a positive number” | State a positive cylinder radius. |
7651
7683
  | <a id="CE_DEFORM_VERTEX_OUTSIDE_CYLINDER"></a>`CE_DEFORM_VERTEX_OUTSIDE_CYLINDER` | “&lt;where&gt;: transform &lt;kind&gt; has radius &lt;radius&gt;, and vertex &lt;v&gt; sits at &lt;kind === 'yaw' ? 'x' : 'y'&gt;=&lt;setup[2 * v + along]&gt;, which is &lt;round(Math.abs(u) - radius)&gt; past it (about=&lt;about&gt;). The cylinder has no surface there, so its depth would be 0 and the projection would be a different model at that vertex. Raise the radius to where the part actually sits, or move the vertex.” | Raise radius to include the measured vertex or move that vertex within the cylinder. |
@@ -7749,7 +7781,7 @@ retain the minimal inputs and report that code rather than inventing author data
7749
7781
 
7750
7782
  | Code | Fixed message text and alternatives | What to change |
7751
7783
  | --- | --- | --- |
7752
- | <a id="CE_KEYS_UNKNOWN_FIELD"></a>`CE_KEYS_UNKNOWN_FIELD` | “&lt;where&gt;: &lt;what&gt; has &lt;strays.length === 1 ? 'a key' : `${strays.length} keys`&gt; this compiler does not read: &lt;named.join(', ')&gt;. Nothing reads such a key, so it would be dropped from the emitted skeleton in silence — fix the spelling or remove it. {This shape has no fields at all. / Known here: &lt;[...known].sort().join(', ')&gt;.}”<br>“{"&lt;key&gt;" / "&lt;key&gt;" (did you mean &lt;near.map((n) =&gt; `"${n}"`).join(', ')&gt;?)}”<br>Also: “skin "&lt;value&gt;" slot "&lt;value&gt;" attachment "&lt;value&gt;"”<br>“&lt;value&gt; generator.bones[&lt;value&gt;]”<br>“{constraint "&lt;value&gt;" / constraints[&lt;value&gt;]}”<br>“&lt;value&gt;.transform.envelope”<br>“&lt;value&gt; weights[&lt;value&gt;][&lt;value&gt;]”<br>“invariants.deformMayFold[&lt;value&gt;]”<br>“animations."&lt;value&gt;"”<br>“&lt;value&gt;.offsets[&lt;value&gt;]”<br>“{stop "&lt;value&gt;" / stops[&lt;value&gt;]}”<br>“&lt;value&gt; properties."&lt;value&gt;".to."&lt;value&gt;"”<br>“this rig spec”<br>“&lt;value&gt;.derive”<br>“setup."&lt;value&gt;"”<br>“physics."&lt;value&gt;"”<br>“&lt;value&gt;'s "lower"”<br>“&lt;value&gt; "sequence"”<br>“&lt;value&gt; generator.bias”<br>“&lt;value&gt; generator.depth”<br>“&lt;value&gt;.events[&lt;value&gt;]”<br>“&lt;value&gt;.tracks[&lt;value&gt;]”<br>“invariants.axes[&lt;value&gt;]”<br>“&lt;value&gt; generator.falloff”<br>“&lt;value&gt;.drawOrder[&lt;value&gt;]”<br>“&lt;value&gt; generator.influence”<br>“invariants.detached[&lt;value&gt;]”<br>“{slot "&lt;value&gt;" / slots[&lt;value&gt;]}”<br>“invariants.consumerDrivenMix[&lt;value&gt;]”<br>“event "&lt;value&gt;"”<br>“&lt;value&gt;'s "from"”<br>“this motion spec”<br>“{absent / &lt;value&gt;}”<br>“{region / &lt;value&gt;}”<br>“&lt;value&gt;.keys[&lt;value&gt;]”<br>“&lt;value&gt; generator.soft”<br>“{linkedmesh / &lt;value&gt;}”<br>“&lt;value&gt;.deform[&lt;value&gt;]”<br>“&lt;value&gt;.sequence[&lt;value&gt;]”<br>“&lt;value&gt; generator (&lt;value&gt;)”<br>“{bone "&lt;value&gt;" / bones[&lt;value&gt;]}”<br>Here the values named in the message include: “uvs”, “mixX”, “blend”, “scaleX”, “groups”, “radius”, “images”, “axisBone”, “maxBones”, “massBone”, “softness”, “timelines”, “archetype”, “rotateMode”, “windGlobal”, “localTarget”, “spacingMode”, “positionMode”, “inertiaGlobal”, “dampingGlobal”, “gravityGlobal”, “strengthGlobal”, “skeleton.stageBox”, “invariants.idleDrivesMeshes”, “ik”, “lag”, “why”, “cut”, “fps”, “icon”, “dark”, “cols”, “kind”, “hull”, “cell”, “path”, “loop”, “mask”, “ease”, “ramp”, “note”, “mode”, “size”, “rows”, “mixY”, “alpha”, “pairs”, “inner”, “curve”, “limit”, “edges”, “float”, “start”, “audio”, “along”, “clamp”, “width”, “skins”, “links”, “chain”, “phase”, “about”, “power”, “count”, “color”, “gamma”, “index”, “delay”, “shearY”, “source”, “convex”, “target”, “amount”, “volume”, “shearX”, “slider”, “string”, “center”, “parent”, “closed”, “zScale”, “inward”, “margin”, “across”, “height”, “digits”, “scaleY”, “anchor”, “degrees”, “inverse”, “stretch”, “easings”, “against”, “default”, “inherit”, “lengths”, “carried”, “balance”, “stagger”, “outward”, “through”, “vertices”, “axis_deg”, “contrast”, “compress”, “notUnder”, “property”, “duration”, “controls”, “additive”, “rotation”, “triangles”, “mixScaleY”, “mixScaleX”, “mixShearY”, “tolerance”, “mixRotate”, “meshSlots”, “minWeight”, “mixGlobal”, “amplitude”, “massGlobal”, “meshCenter”, “fromVertex”, “slotWindow”, “wavelength”, “"skeleton"”, “attachments”, “vertexCount”, “maxVertices”, “localSource”, “"invariants"”, “bendPositive”, “boneIndexing”, “constantSpeed”, “meshTriangles”, “referenceScale”, “narrowing”. | Fix each printed stray key's spelling using the suggested or known names, or remove it. |
7784
+ | <a id="CE_KEYS_UNKNOWN_FIELD"></a>`CE_KEYS_UNKNOWN_FIELD` | “&lt;where&gt;: &lt;what&gt; has &lt;strays.length === 1 ? 'a key' : `${strays.length} keys`&gt; this compiler does not read: &lt;named.join(', ')&gt;. Nothing reads such a key, so it would be dropped from the emitted skeleton in silence — fix the spelling or remove it. {This shape has no fields at all. / Known here: &lt;[...known].sort().join(', ')&gt;.}”<br>“{"&lt;key&gt;" / "&lt;key&gt;" (did you mean &lt;near.map((n) =&gt; `"${n}"`).join(', ')&gt;?)}”<br>Also: “skin "&lt;value&gt;" slot "&lt;value&gt;" attachment "&lt;value&gt;"”<br>“&lt;value&gt; generator.bones[&lt;value&gt;]”<br>“{constraint "&lt;value&gt;" / constraints[&lt;value&gt;]}”<br>“&lt;value&gt;.transform.envelope”<br>“&lt;value&gt; weights[&lt;value&gt;][&lt;value&gt;]”<br>“invariants.deformMayFold[&lt;value&gt;]”<br>“animations."&lt;value&gt;"”<br>“&lt;value&gt;.offsets[&lt;value&gt;]”<br>“{stop "&lt;value&gt;" / stops[&lt;value&gt;]}”<br>“&lt;value&gt; properties."&lt;value&gt;".to."&lt;value&gt;"”<br>“this rig spec”<br>“&lt;value&gt;.derive”<br>“setup."&lt;value&gt;"”<br>“physics."&lt;value&gt;"”<br>“&lt;value&gt;'s "lower"”<br>“&lt;value&gt; "sequence"”<br>“&lt;value&gt; generator.bias”<br>“&lt;value&gt; generator.depth”<br>“&lt;value&gt; depth”<br>“&lt;value&gt;.events[&lt;value&gt;]”<br>“&lt;value&gt;.tracks[&lt;value&gt;]”<br>“invariants.axes[&lt;value&gt;]”<br>“&lt;value&gt; generator.falloff”<br>“&lt;value&gt;.drawOrder[&lt;value&gt;]”<br>“&lt;value&gt; generator.influence”<br>“invariants.detached[&lt;value&gt;]”<br>“{slot "&lt;value&gt;" / slots[&lt;value&gt;]}”<br>“invariants.consumerDrivenMix[&lt;value&gt;]”<br>“event "&lt;value&gt;"”<br>“&lt;value&gt;'s "from"”<br>“this motion spec”<br>“{absent / &lt;value&gt;}”<br>“{region / &lt;value&gt;}”<br>“&lt;value&gt;.keys[&lt;value&gt;]”<br>“&lt;value&gt; generator.soft”<br>“{linkedmesh / &lt;value&gt;}”<br>“&lt;value&gt;.deform[&lt;value&gt;]”<br>“&lt;value&gt;.sequence[&lt;value&gt;]”<br>“&lt;value&gt; generator (&lt;value&gt;)”<br>“{bone "&lt;value&gt;" / bones[&lt;value&gt;]}”<br>Here the values named in the message include: “uvs”, “mixX”, “blend”, “scaleX”, “groups”, “radius”, “images”, “axisBone”, “maxBones”, “massBone”, “softness”, “timelines”, “archetype”, “rotateMode”, “windGlobal”, “localTarget”, “spacingMode”, “positionMode”, “inertiaGlobal”, “dampingGlobal”, “gravityGlobal”, “strengthGlobal”, “skeleton.stageBox”, “invariants.idleDrivesMeshes”, “ik”, “lag”, “why”, “cut”, “fps”, “icon”, “dark”, “cols”, “kind”, “hull”, “cell”, “path”, “loop”, “mask”, “ease”, “ramp”, “note”, “mode”, “size”, “rows”, “mixY”, “alpha”, “pairs”, “inner”, “curve”, “limit”, “edges”, “float”, “start”, “audio”, “along”, “clamp”, “width”, “skins”, “links”, “chain”, “phase”, “about”, “power”, “count”, “color”, “gamma”, “index”, “delay”, “shearY”, “source”, “convex”, “target”, “amount”, “volume”, “shearX”, “slider”, “string”, “center”, “parent”, “closed”, “zScale”, “inward”, “margin”, “across”, “height”, “digits”, “scaleY”, “anchor”, “degrees”, “inverse”, “stretch”, “easings”, “against”, “default”, “inherit”, “lengths”, “carried”, “balance”, “stagger”, “outward”, “through”, “vertices”, “axis_deg”, “contrast”, “compress”, “notUnder”, “property”, “duration”, “controls”, “additive”, “rotation”, “triangles”, “mixScaleY”, “mixScaleX”, “mixShearY”, “tolerance”, “mixRotate”, “meshSlots”, “minWeight”, “mixGlobal”, “amplitude”, “massGlobal”, “meshCenter”, “fromVertex”, “slotWindow”, “wavelength”, “"skeleton"”, “attachments”, “vertexCount”, “maxVertices”, “localSource”, “"invariants"”, “bendPositive”, “boneIndexing”, “constantSpeed”, “meshTriangles”, “referenceScale”, “narrowing”. | Fix each printed stray key's spelling using the suggested or known names, or remove it. |
7753
7785
  | <a id="CE_KEYS_NUMBER_FLOAT32_FINITE"></a>`CE_KEYS_NUMBER_FLOAT32_FINITE` | “&lt;where&gt;: &lt;place(path)&gt; is &lt;String(node)&gt;; a number in this file is finite at float32 precision, at most ±&lt;String(FLOAT32_MAX)&gt;, because the skeleton is written as float32 — past that it would be emitted as Infinity, which JSON writes as null, a value the file never stated.&lt;why&gt;”<br>“{ / { This one is finite as a double and has no float32 but Infinity. / JSON has no spelling for Infinity: a literal past ±1.7976931348623157e308, such as 1e309, parses to it.}}” | State a finite number whose float32 rounding stays finite; inspect the printed path and magnitude. |
7754
7786
  | <a id="CE_KEYS_FIELD_VALUE_TYPE"></a>`CE_KEYS_FIELD_VALUE_TYPE` | “&lt;where&gt;: &lt;visit.name([key, ...wrong.tail])&gt; is &lt;typeFound(wrong.value)&gt;; &lt;wrong.required&gt; is required”<br>“a boolean (true or false)”<br>“a number”<br>“a number or null”<br>“a string”<br>“a string or null”<br>“an array of arrays of numbers”<br>“an array of numbers”<br>“an array of numbers, or null”<br>“an array of strings”<br>“an object whose every value is a string or null”<br>“an object whose every value is an array of numbers”<br>“an object whose every value is an array of strings” | Replace the named field or element with the required JSON type printed in the message. |
7755
7787
  | <a id="CE_KEYS_ENUM_VALUE"></a>`CE_KEYS_ENUM_VALUE` | “&lt;where&gt;: &lt;visit.name([key])&gt; is &lt;JSON.stringify(value) ?? String(value)&gt;; one of &lt;rule.set.map((name) =&gt; JSON.stringify(name)).join(', ')&gt; — &lt;rule.readAs&gt;”<br>“anything else was read as a ribbon by the mesh checks and as a ring by the control-bone reader and the geometry builder, so the refusal it got named a fault the part did not have”<br>“anything else was read as no rotation source, and the bone was emitted without the setup rotation it asked for”<br>“anything else was read as the default "name" in silence: a weighted "vertices" run was refused as unflagged, and every other attachment built byte for byte as if the key were absent” | Choose one of the printed names for the named field; the readAs explanation describes why another spelling changes its meaning. |
@@ -12491,7 +12523,7 @@ nonselected output is the stored source geometry through the unchanged setup
12491
12523
  matrices; it does not borrow a different selection's deform. The control
12492
12524
  checks both results and the source of the difference, without an epsilon.
12493
12525
 
12494
- ## 15. Posing in place for a player — the runtime poser (stage 0)
12526
+ ## 15. Posing in place for a player — the runtime poser (stage 1)
12495
12527
 
12496
12528
  rigc poses a model document twice over, on purpose (issue #1591). The **oracle**
12497
12529
  (`src/core/`) is the reference: each pose is a pure function of the document,
@@ -12526,17 +12558,43 @@ exact resolved table identity `(skin, model.slots[attachment.slot].name, placeho
12526
12558
  The display `name` may be shared by different placeholders; it is never a table
12527
12559
  key to guess the source from. These load-time strings do not change the pose path.
12528
12560
 
12529
- | | what stage 0 poses |
12561
+ | | what stage 1 poses |
12530
12562
  | --- | --- |
12531
12563
  | bones | local and world transforms, all five inherit modes, skin-required bones under the view's skin (an inactive bone is zeros, as the oracle poses it) |
12532
12564
  | timelines | `rotate`, `translate(x/y)`, `scale(x/y)`, `shear(x/y)`; a slot's `attachment`, `rgba`, `rgb` and `alpha`; ik and transform timelines; linear, stepped and Bézier keys, each read from a per-pose key cursor |
12533
12565
  | constraints | one- and two-bone ik; world-space, absolute transform constraints; the document's order, each followed by the bones below what it moved posed again |
12534
12566
  | vertices | regions, meshes, weighted meshes and linked meshes, written into the pose's buffer |
12567
+ | skins and events | named/no-skin views with their skin-required bones and constraints; event delivery in key order over `(last, time]`, tail then head across a wrap |
12535
12568
  | time | one animation at full weight from the setup pose, at a time (`apply`) or stepped as a looping track (`step`, the track time wrapped by the duration as `openTrack` wraps it) |
12536
12569
 
12570
+ Skin switching is a load-time view change: compile `underSkin(document, name)`
12571
+ (or `underNoSkin(document)`), create the replacement `RuntimePose`, then call
12572
+ `replacement.restore(animationName, oldPose.trackTime, oldPose.time)`. Restore
12573
+ carries both clocks **and the event cursor**: its event list is empty, a key at
12574
+ or before that restored animation time is not fired again, and later keys and
12575
+ subsequent loops still deliver normally. Do not restore by setting trackTime and
12576
+ calling apply: setAnimation starts the event cursor at −1, so that sequence
12577
+ replays already-passed cues. `restore` refuses non-finite times by name.
12578
+ The old buffers belong to the old view; do not keep drawing them as the new
12579
+ skin. An ambiguous merged `all` view still refuses `skin-conflict`; that refusal
12580
+ does not reject either named view.
12581
+
12582
+ Read `pose.events` immediately after `apply`/`step`. It is a reused borrowed list
12583
+ of immutable load-time records: `name`, float32 key `time`, `int`, `float`,
12584
+ `string`, `volume`, `balance`. Duplicate-time keys keep their authored order.
12585
+ The interval and wrap rules are the oracle's `openTrack` rules; a new animation
12586
+ starts at previous event time −1, so an apply at 0 includes its zero-time keys.
12587
+ The list is replaced on the next pose; retain copies only when needed. Delivery
12588
+ itself reuses records and list storage. The legacy five-column oracle event row
12589
+ is unchanged. Public `EventTimeline` observations also hold the audio details:
12590
+ silent keys keep volume/balance at 0; on a voiced key an omitted balance falls
12591
+ back to the definition's **volume**, matching the measured runtime rather than
12592
+ inventing a correction. An explicit `ignore: ['events']` diagnostic still mutes
12593
+ the list; normal compilation never does.
12594
+
12537
12595
  **Everything else is refused at load, by name.** `RuntimeRefusal.missing` lists
12538
12596
  every construct met, each `{ feature, detail, animation }`, and the message
12539
- reads `not posed by the runtime poser (stage 0), N construct(s) missing:
12597
+ reads `not posed by the runtime poser (stage 1), N construct(s) missing:
12540
12598
  <feature>: <detail>; …`. A model-level feature refuses the whole model; an
12541
12599
  animation-level one refuses that animation alone (`model.refused`;
12542
12600
  `model.animation(name)` and `setAnimation(name)` throw its refusal) and every
@@ -12545,7 +12603,6 @@ refused: nothing is posed approximately.
12545
12603
 
12546
12604
  | feature | refuses | what it is |
12547
12605
  | --- | --- | --- |
12548
- | `skin` | the model | a skin other than `default` |
12549
12606
  | `path-constraint`, `physics-constraint`, `slider-constraint` | the model | a constraint of that kind |
12550
12607
  | `local-transform`, `additive-transform` | the model | a transform constraint with `localSource` or `localTarget`, or `additive` |
12551
12608
  | `inactive-constrained-bone` | the model | an applied ik or transform constraint naming a bone the view leaves inactive (what the oracle writes there depends on the pass before) |
@@ -12554,7 +12611,6 @@ refused: nothing is posed approximately.
12554
12611
  | `skin-conflict` | as above | a placeholder the skins fill differently |
12555
12612
  | `oracle-absent` | the model | a pose the oracle itself leaves out (`constraintsAbsentWhy`) |
12556
12613
  | `inherit-timeline`, `deform-timeline`, `sequence-timeline`, `draw-order-timeline`, `dark-colour-timeline` | the animation | a timeline of that kind (`dark-colour` is `rgba2` / `rgb2`) |
12557
- | `events` | the animation, unless `ignore: ['events']` | event keys. Events move no pose, so a consumer that reads none may ask for them to be ignored and is handed a poser that fires none; without the request a dropped cue would be silent |
12558
12614
 
12559
12615
  A call it cannot answer throws `RuntimeInputError`, naming the call:
12560
12616
  `apply: no animation is set (setAnimation first)`, `animation "x" is not one of
@@ -12577,3 +12633,21 @@ side here. The selftest's `runtime-poser` suite holds the gate to its plants (a
12577
12633
  refused feature smuggled through — each caught by name), a seeded population of
12578
12634
  every construct above, the looping step against `openTrack`, and a steady-state
12579
12635
  frame measured at 0 bytes with the collector off.
12636
+
12637
+ ## 16. Compare sequential mesh candidates against one motion reference
12638
+
12639
+ Programmatic callers that gate candidates one at a time can import
12640
+ `prepareMotionReference` and `comparePreparedMotion` from `rig-c/meshcompare`.
12641
+ Pass the one-shot `MotionComparisonInput` without `candidates` to preparation,
12642
+ then pass each complete input with its candidate list to comparison. The handle
12643
+ reuses the reference's parsed build, art samples, carriers and poses only while
12644
+ the reference model text, schedule, attachments (including mask alpha bytes),
12645
+ art-fit bounds and motion options match exactly. A changed bound field, even
12646
+ `+0` to `-0`, throws `MeshReductionError` with
12647
+ `COMPARE_PREPARED_MISMATCH` naming the field; a forged handle throws
12648
+ `COMPARE_PREPARED_HANDLE`. Prepare a new handle for a different selection or
12649
+ held-out schedule. Returned reports are detached from the prepared state, and
12650
+ the one-shot `compareMeshesInMotion` retains its report and refusal contract.
12651
+ The full comparison contract and its input types are in the repository's
12652
+ [MESH_REDUCTION.md](https://github.com/rig-c/core/blob/main/docs/MESH_REDUCTION.md#the-comparison-as-implemented-stage-c1),
12653
+ which is not shipped in the package.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.71.0",
3
+ "version": "2.72.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/compile.ts CHANGED
@@ -3011,7 +3011,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3011
3011
  const meshes: CompileResult['meshes'] = [];
3012
3012
  // `skin/slot/attachment` -> per-vertex z, for the attachments that named a
3013
3013
  // depth map. Read at deform-key time; never emitted. See `AttachmentContext`.
3014
- const attachmentDepths = new Map<string, number[]>();
3014
+ const attachmentDepths = new Map<string, number[] | { z: number[]; geometry: DeformGeometry }>();
3015
3015
  // The same for a `soft` mask's per-vertex level, which a deform envelope's
3016
3016
  // `mask` reads (issue #1413). Never emitted either.
3017
3017
  const attachmentSoftMasks = new Map<string, DeformSoftMask>();
@@ -3729,6 +3729,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3729
3729
  if (deformTimelines.get(skinName)?.get(track.slot)?.has(track.attachment)) {
3730
3730
  throw new CompileError(`${at}: two deform timelines on one attachment; merge them into one`, 'CE_COMPILE_DEFORM_TIMELINE_DUPLICATE');
3731
3731
  }
3732
+ const sampledDepth = attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`);
3732
3733
  const keys = compileDeformTrack(
3733
3734
  track,
3734
3735
  motion,
@@ -3736,10 +3737,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3736
3737
  anim.duration,
3737
3738
  {
3738
3739
  ...deformGeometryOf(attachment, at, transforms),
3739
- depth: attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3740
+ depth: (Array.isArray(sampledDepth) ? sampledDepth : sampledDepth?.z) ?? null,
3740
3741
  soft: attachmentSoftMasks.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3741
3742
  },
3742
3743
  deformTransforms,
3744
+ sampledDepth !== undefined && !Array.isArray(sampledDepth) ? sampledDepth.geometry : null,
3743
3745
  );
3744
3746
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time);
3745
3747
  attachmentTimelinesOf(skinName, track.slot).set(track.attachment, { deform: keys });
@@ -4367,7 +4369,7 @@ interface AttachmentContext {
4367
4369
  * round trip and then vanish — and the whole point of this table is to be
4368
4370
  * read by `evaluateDeformTransform` at key time and never emitted at all.
4369
4371
  */
4370
- depths: Map<string, number[]>;
4372
+ depths: Map<string, number[] | { z: number[]; geometry: DeformGeometry }>;
4371
4373
  /**
4372
4374
  * A `soft` mask's file and its sampled level per vertex, keyed like `depths`
4373
4375
  * — what a deform envelope's `mask` reads at key time (issue #1413). Never
@@ -5755,7 +5757,12 @@ function buildRigMesh(
5755
5757
  if (authored && att.generator) {
5756
5758
  throw new CompileError(`${where}: a mesh is either authored geometry or a generator, never both`, 'CE_COMPILE_MESH_AUTHORED_AND_GENERATED');
5757
5759
  }
5758
- if (att.generator) return buildGeneratedMesh(att, att.generator, placeholder, where, ctx);
5760
+ if (att.generator) {
5761
+ if (att.depth !== undefined) {
5762
+ throw new CompileError(`${where}: attachment "depth" describes authored geometry; a generated mesh states its depth on the contour or grid generator instead`, 'CE_COMPILE_DEPTH_AUTHORED_REQUIRED');
5763
+ }
5764
+ return buildGeneratedMesh(att, att.generator, placeholder, where, ctx);
5765
+ }
5759
5766
  if (att.vertices && att.weights) {
5760
5767
  throw new CompileError(
5761
5768
  `${where}: a mesh gives geometry as "vertices" or as "weights", never both — "weights" is the by-name form of the same data`, 'CE_COMPILE_MESH_GEOMETRY_SOURCES_CONFLICT',
@@ -5833,6 +5840,35 @@ function buildRigMesh(
5833
5840
  height: f32(height),
5834
5841
  };
5835
5842
  if (att.sequence !== undefined) out.sequence = buildSequence(att.sequence);
5843
+ let depth: ReturnType<typeof sampleMeshDepth> | undefined;
5844
+ if (att.depth !== undefined) {
5845
+ if (img === undefined) {
5846
+ throw new CompileError(`${where}: an authored mesh with "depth" needs an "image" to locate the part's pixel grid`, 'CE_COMPILE_DEPTH_IMAGE_REQUIRED');
5847
+ }
5848
+ const anchor = ctx.transforms.get(ctx.anchorBone);
5849
+ if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`, 'CE_COMPILE_SLOT_SETUP_TRANSFORM_MISSING');
5850
+ const geometry = deformGeometryOf(out, where, ctx.transforms, anchor);
5851
+ if (geometry.setup === null) {
5852
+ throw new CompileError(`${where}: depth needs resolved setup positions — ${geometry.setupWhy}`, 'CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED');
5853
+ }
5854
+ const world = geometry.setup;
5855
+ const plate = partPlate(img);
5856
+ const toArt = img.width / plate.width;
5857
+ // The generator's window is centred on the slot bone's setup WORLD origin.
5858
+ // UVs describe texture placement, not a vertex's position in that window.
5859
+ const points: Array<[number, number]> = [];
5860
+ for (let v = 0; v < world.length; v += 2) {
5861
+ points.push([(world[v] - anchor.worldX + img.width / 2) / toArt,
5862
+ (img.height / 2 - (world[v + 1] - anchor.worldY)) / toArt]);
5863
+ }
5864
+ depth = sampleMeshDepth(att.depth, 'authored', points, att.triangles,
5865
+ (px, py) => [anchor.worldX + px * toArt - img.width / 2, anchor.worldY + img.height / 2 - py * toArt],
5866
+ img.pageGrid === undefined ? plateAlpha(plate) : { unlocated: img.pageGrid.said },
5867
+ sheetGridOf(img, plate), where, ctx,
5868
+ Array.from({ length: world.length / 2 }, (_, v) => [world[2 * v], world[2 * v + 1]]));
5869
+ geometry.depth = depth.z;
5870
+ ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, { z: depth.z, geometry });
5871
+ }
5836
5872
  // Register it as `authored`: geometry rigc did not build and whose topology it
5837
5873
  // therefore gets to assume nothing about. The generator-topology assertions
5838
5874
  // read this and skip rather than measuring a ring that was never a ring.
@@ -5843,6 +5879,7 @@ function buildRigMesh(
5843
5879
  ctx.meshes.push({
5844
5880
  slot: ctx.slotName,
5845
5881
  kind: 'authored',
5882
+ ...(depth === undefined ? {} : { depth: depth.summary }),
5846
5883
  attachments: [ctx.slotName],
5847
5884
  vertices: uvCount / 2,
5848
5885
  triangles: att.triangles.length / 3,
@@ -6204,8 +6241,8 @@ function buildGeneratedMesh(
6204
6241
  * not invent tolerances.
6205
6242
  */
6206
6243
  function sampleMeshDepth(
6207
- spec: NonNullable<Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'contour' }>['depth']>,
6208
- kind: 'contour' | 'grid',
6244
+ spec: RigDepthMap,
6245
+ kind: 'contour' | 'grid' | 'authored',
6209
6246
  points: ReadonlyArray<readonly [number, number]>,
6210
6247
  /** The mesh's own triangle list, for the turn ceiling. */
6211
6248
  triangles: ReadonlyArray<number>,
@@ -6244,6 +6281,8 @@ function sampleMeshDepth(
6244
6281
  grid: SheetGrid,
6245
6282
  where: string,
6246
6283
  ctx: AttachmentContext,
6284
+ /** Authored setup-world vertices, already in the depth turn's evaluation space. */
6285
+ turnPoints?: ReadonlyArray<readonly [number, number]>,
6247
6286
  ): {
6248
6287
  z: number[];
6249
6288
  /** The tone-curved 0..1 nearness per vertex, which `bind` thresholds. */
@@ -6327,7 +6366,9 @@ function sampleMeshDepth(
6327
6366
  kind === 'contour'
6328
6367
  ? 'A contour mesh puts every vertex ON the silhouette and pushes it out by the margin, so a sheet cut to ' +
6329
6368
  'the art stops short of every one of them. Dilate the sheet past the mesh margin, or lower the margin.'
6330
- : 'A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the ' +
6369
+ : kind === 'authored'
6370
+ ? 'The authored setup vertices reach outside the sheet coverage. Extend the sheet to cover their bilinear taps, or correct the setup geometry.'
6371
+ : 'A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the ' +
6331
6372
  'lattice reaches past it wherever the art does not fill the window. Dilate the sheet to the window, or ' +
6332
6373
  'state "us"/"vs" that keep the lattice inside the art.';
6333
6374
  throw new CompileError(
@@ -6368,7 +6409,7 @@ function sampleMeshDepth(
6368
6409
  range: [f32(lo), f32(hi)],
6369
6410
  ...(partAlpha instanceof Uint8Array ? { undrawn } : { undrawn: null, unlocated: partAlpha.unlocated }),
6370
6411
  ceiling: turnCeiling(
6371
- points.map(([px, py]) => toBind(px, py)),
6412
+ turnPoints ?? points.map(([px, py]) => toBind(px, py)),
6372
6413
  z,
6373
6414
  triangles,
6374
6415
  ctx.turnNarrowing === undefined ? undefined : { narrowing: ctx.turnNarrowing },
@@ -8737,7 +8778,7 @@ export interface DeformGeometry {
8737
8778
  /** Why `setup` is null, phrased for the refusal. Null when it is not. */
8738
8779
  setupWhy: string | null;
8739
8780
  /**
8740
- * Per-vertex `z`, when this attachment's generator named a depth map.
8781
+ * Per-vertex `z`, when this attachment or its generator named a depth map.
8741
8782
  *
8742
8783
  * Not measured from the attachment like everything else here — there
8743
8784
  * is nowhere in the format to measure it FROM, which is the whole reason the
@@ -8771,6 +8812,8 @@ export function deformGeometryOf(
8771
8812
  where: string,
8772
8813
  /** Every bone's setup world transform, by name. */
8773
8814
  transforms: Map<string, BoneTransform>,
8815
+ /** Force the world-offset path for an authored depth turn, including rigid meshes. */
8816
+ depthAnchor?: BoneTransform,
8774
8817
  ): DeformGeometry {
8775
8818
  const type = att.kind;
8776
8819
  let worldVerticesLength: number;
@@ -8816,6 +8859,15 @@ export function deformGeometryOf(
8816
8859
  const vertices = att.vertices;
8817
8860
  const weighted = vertices.weighted;
8818
8861
  if (!vertices.weighted) {
8862
+ if (depthAnchor !== undefined) {
8863
+ return {
8864
+ weighted, deformLength: worldVerticesLength, vertexCount: worldVerticesLength / 2,
8865
+ boneCounts: Array(worldVerticesLength / 2).fill(1),
8866
+ setup: setupWorldVertices(vertices, depthAnchor, transforms, where).flat(),
8867
+ influenceBones: Array(worldVerticesLength / 2).fill(depthAnchor),
8868
+ setupWhy: null, depth: null, soft: null,
8869
+ };
8870
+ }
8819
8871
  return {
8820
8872
  weighted,
8821
8873
  deformLength: worldVerticesLength,
@@ -8879,7 +8931,7 @@ export function deformGeometryOf(
8879
8931
  // the runtime's last bits (`b = −2.3e-8` on an unrotated bone) into its
8880
8932
  // offset, about 1e-6 on gallery/look's hair locks. Evaluating on the authored
8881
8933
  // positions instead would need them carried here from the binder.
8882
- if (boneCounts.every((n) => n === 1) && bones.size === 1) {
8934
+ if (depthAnchor === undefined && boneCounts.every((n) => n === 1) && bones.size === 1) {
8883
8935
  return { ...common, setup: bindSpace, influenceBones: null, setupWhy: null };
8884
8936
  }
8885
8937
  // -- several influences, evaluated in world (issue #389) -------------------
@@ -9055,6 +9107,7 @@ function compileDeformTrack(
9055
9107
  duration: number,
9056
9108
  geometry: DeformGeometry,
9057
9109
  generated: CompileResult['deformTransforms'],
9110
+ depthWorld: DeformGeometry | null = null,
9058
9111
  ): ModelKey[] {
9059
9112
  const skin = track.skin ?? 'default';
9060
9113
  const where = `animation "${animName}" deform ${skin}/${track.slot}/${track.attachment}`;
@@ -9080,6 +9133,7 @@ function compileDeformTrack(
9080
9133
  // definite, which the arithmetic does for any weighting that closes at 1 and
9081
9134
  // for no weighting that does not (issue #389).
9082
9135
  if (key.transform !== undefined) {
9136
+ const evaluated = ('depth' in key.transform && key.transform.depth === true ? depthWorld : null) ?? geometry;
9083
9137
  if (key.vertices !== undefined && key.vertices !== null) {
9084
9138
  throw new CompileError(
9085
9139
  `${where} (t=${key.t}): the key carries both a "transform" and a "vertices" run, and they are two answers to ` +
@@ -9090,33 +9144,33 @@ function compileDeformTrack(
9090
9144
  if (key.offset !== undefined || key.fromVertex !== undefined) {
9091
9145
  throw new CompileError(
9092
9146
  `${where} (t=${key.t}): the key states a "transform" and a start index. A transform is a model of the whole ` +
9093
- `attachment and is evaluated over all ${geometry.vertexCount} of its vertices, so it always starts at deform ` +
9147
+ `attachment and is evaluated over all ${evaluated.vertexCount} of its vertices, so it always starts at deform ` +
9094
9148
  'index 0. A model applied to part of a run leaves a step at the run\'s edge — write the partial run by hand ' +
9095
9149
  'if that is what you mean.', 'CE_COMPILE_DEFORM_TRANSFORM_START_INDEX',
9096
9150
  );
9097
9151
  }
9098
- if (geometry.setup === null) {
9152
+ if (evaluated.setup === null) {
9099
9153
  throw new CompileError(
9100
9154
  `${where} (t=${key.t}): a "transform" states where every vertex of this attachment goes, and this one has a ` +
9101
- `vertex the arithmetic cannot place — ${geometry.setupWhy}. Fix the weights, or write the bind-space pairs ` +
9155
+ `vertex the arithmetic cannot place — ${evaluated.setupWhy}. Fix the weights, or write the bind-space pairs ` +
9102
9156
  'yourself and start the run with "offset".', 'CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED',
9103
9157
  );
9104
9158
  }
9105
9159
  const report = evaluateDeformTransform(
9106
9160
  key.transform,
9107
- geometry.setup,
9161
+ evaluated.setup,
9108
9162
  onModelGrid,
9109
9163
  `${where} (t=${key.t})`,
9110
- geometry.depth,
9111
- geometry.soft,
9164
+ evaluated.depth,
9165
+ evaluated.soft,
9112
9166
  );
9113
- if (report.offsets.length !== geometry.vertexCount * 2) {
9167
+ if (report.offsets.length !== evaluated.vertexCount * 2) {
9114
9168
  // Unreachable while `setup` is one pair per vertex, which is the whole
9115
9169
  // reason both are derived from the same walk. Stated rather than
9116
9170
  // assumed: a silent mismatch here is the overrun A35 exists for.
9117
9171
  throw new CompileError(
9118
9172
  `${where} (t=${key.t}): the transform produced ${report.offsets.length} numbers for ` +
9119
- `${geometry.vertexCount} vertices`, 'CE_COMPILE_DEFORM_TRANSFORM_OFFSET_COUNT',
9173
+ `${evaluated.vertexCount} vertices`, 'CE_COMPILE_DEFORM_TRANSFORM_OFFSET_COUNT',
9120
9174
  );
9121
9175
  }
9122
9176
  // -- one displacement per vertex -> one pair per influence (issue #389) -
@@ -9128,11 +9182,11 @@ function compileDeformTrack(
9128
9182
  // displacement it expands is the ROUNDED one the report prints, so a
9129
9183
  // reader can reproduce every emitted pair from the audit rather than from
9130
9184
  // a second evaluation.
9131
- const run = expandDeformToInfluences(report.offsets, geometry);
9132
- if (run.length !== geometry.deformLength) {
9185
+ const run = expandDeformToInfluences(report.offsets, evaluated);
9186
+ if (run.length !== evaluated.deformLength) {
9133
9187
  throw new CompileError(
9134
9188
  `${where} (t=${key.t}): the transform expanded to ${run.length} numbers and this attachment's deform ` +
9135
- `array is ${geometry.deformLength} long`, 'CE_COMPILE_DEFORM_TRANSFORM_RUN_LENGTH',
9189
+ `array is ${evaluated.deformLength} long`, 'CE_COMPILE_DEFORM_TRANSFORM_RUN_LENGTH',
9136
9190
  );
9137
9191
  }
9138
9192
  generated.push({
@@ -9148,14 +9202,14 @@ function compileDeformTrack(
9148
9202
  // other way to know they were not evaluated alike. Said only on the path
9149
9203
  // where it is not the attachment's own space, so every existing report
9150
9204
  // prints exactly the lines it printed before.
9151
- ...(geometry.influenceBones === null
9205
+ ...(evaluated.influenceBones === null
9152
9206
  ? {}
9153
9207
  : {
9154
9208
  expanded: run,
9155
9209
  derived: [
9156
9210
  ...report.derived,
9157
- `evaluated at setup WORLD positions, because ${geometry.vertexCount} vertices carry ` +
9158
- `${geometry.deformLength / 2} influences; each displacement D is written into every influence as ` +
9211
+ `evaluated at setup WORLD positions, because ${evaluated.vertexCount} vertices carry ` +
9212
+ `${evaluated.deformLength / 2} influences; each displacement D is written into every influence as ` +
9159
9213
  'Mᵢ⁻¹·D, so the runtime composes Σwᵢ·Mᵢ·Mᵢ⁻¹·D = D',
9160
9214
  ],
9161
9215
  }),
@@ -33,6 +33,10 @@ import { gridRound } from './index.ts';
33
33
 
34
34
  /** An event definition, as the model states it (`ModelEvent`): the payload a key falls back to. */
35
35
  export interface CoreEventDef {
36
+ /** Audio metadata retained additively; the five-column oracle row is unchanged. */
37
+ audio?: string;
38
+ volume?: number;
39
+ balance?: number;
36
40
  name: string;
37
41
  int: number;
38
42
  float: number;
@@ -41,6 +45,9 @@ export interface CoreEventDef {
41
45
 
42
46
  /** One event key, as read: its time (float32) and the payload it fires with, each field resolved. */
43
47
  export interface CoreEventKey {
48
+ /** Audio delivery values, as observed from the public EventTimeline API. */
49
+ volume?: number;
50
+ balance?: number;
44
51
  time: number;
45
52
  name: string;
46
53
  int: number;
@@ -55,8 +62,8 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'obj
55
62
  const EVENT_DEF_FIELDS = ['name', 'int', 'float', 'string', 'audio', 'volume', 'balance'] as const;
56
63
  const EVENT_KEY_FIELDS = ['time', 'name', 'int', 'float', 'string', 'volume', 'balance'] as const;
57
64
 
58
- function payload(raw: Record<string, unknown>, at: string, problems: string[]): { int?: number; float?: number; string?: string } {
59
- const out: { int?: number; float?: number; string?: string } = {};
65
+ function payload(raw: Record<string, unknown>, at: string, problems: string[]): { int?: number; float?: number; string?: string; volume?: number; balance?: number } {
66
+ const out: { int?: number; float?: number; string?: string; volume?: number; balance?: number } = {};
60
67
  if (raw.int !== undefined) {
61
68
  if (typeof raw.int !== 'number' || !Number.isInteger(raw.int)) problems.push(`${at}: int is ${JSON.stringify(raw.int)}, not a whole number`);
62
69
  else out.int = raw.int;
@@ -69,7 +76,10 @@ function payload(raw: Record<string, unknown>, at: string, problems: string[]):
69
76
  if (typeof raw.string !== 'string') problems.push(`${at}: string is ${JSON.stringify(raw.string)}, not a string`);
70
77
  else out.string = raw.string;
71
78
  }
72
- for (const key of ['volume', 'balance'] as const) if (raw[key] !== undefined && (typeof raw[key] !== 'number' || !Number.isFinite(raw[key]))) problems.push(`${at}: ${key} is ${JSON.stringify(raw[key])}, not a finite number`);
79
+ for (const key of ['volume', 'balance'] as const) if (raw[key] !== undefined) {
80
+ if (typeof raw[key] !== 'number' || !Number.isFinite(raw[key])) problems.push(`${at}: ${key} is ${JSON.stringify(raw[key])}, not a finite number`);
81
+ else out[key] = raw[key];
82
+ }
73
83
  return out;
74
84
  }
75
85
 
@@ -90,7 +100,7 @@ export function readEventDefs(value: unknown, problems: string[]): Map<string, C
90
100
  if (raw.audio !== undefined && typeof raw.audio !== 'string') problems.push(`${at} "${raw.name}": audio is ${JSON.stringify(raw.audio)}, not a string`);
91
101
  if (out.has(raw.name)) problems.push(`${at}: event "${raw.name}" is declared twice`);
92
102
  const p = payload(raw, `${at} "${raw.name}"`, problems);
93
- out.set(raw.name, { name: raw.name, int: p.int ?? 0, float: p.float ?? 0, string: p.string ?? '' });
103
+ out.set(raw.name, { audio: typeof raw.audio === 'string' ? raw.audio : '', volume: p.volume ?? 0, balance: p.balance ?? 0, name: raw.name, int: p.int ?? 0, float: p.float ?? 0, string: p.string ?? '' });
94
104
  });
95
105
  return out;
96
106
  }
@@ -121,7 +131,9 @@ export function readEventKeys(value: unknown, label: string, defs: ReadonlyMap<s
121
131
  return;
122
132
  }
123
133
  const p = payload(raw, at, problems);
124
- out.push({ time: Math.fround(typeof t === 'number' && Number.isFinite(t) ? t : 0), name: def.name, int: p.int ?? def.int, float: p.float ?? def.float, string: p.string ?? def.string });
134
+ // Observed oracle behavior: a voiced key's omitted balance falls back to
135
+ // the definition's volume, not its balance. A silent event keeps both at 0.
136
+ out.push({ volume: def.audio ? p.volume ?? def.volume ?? 0 : 0, balance: def.audio ? p.balance ?? def.volume ?? 0 : 0, time: Math.fround(typeof t === 'number' && Number.isFinite(t) ? t : 0), name: def.name, int: p.int ?? def.int, float: p.float ?? def.float, string: p.string ?? def.string });
125
137
  });
126
138
  return out;
127
139
  }
package/src/deformgen.ts CHANGED
@@ -374,7 +374,7 @@ export function evaluateDeformTransform(
374
374
  round: Rounder,
375
375
  where: string,
376
376
  /**
377
- * Per-vertex `z` for this attachment, when its generator named a depth map.
377
+ * Per-vertex `z` for this attachment, when it or its generator named a depth map.
378
378
  * Null when it named none — which is what makes `"depth": true` refusable by
379
379
  * name rather than silently falling back to a cylinder.
380
380
  */
@@ -448,7 +448,7 @@ export function evaluateDeformTransform(
448
448
  if (fromDepth && depth === null) {
449
449
  throw new CompileError(
450
450
  `${where}: transform ${kind} says "depth": true and this attachment has no depth map. The map is named on ` +
451
- 'the attachment\'s generator, as `"depth": { "image": …, "near": …, "zScale": … }`, because it describes ' +
451
+ 'an authored attachment or its contour/grid generator, as `"depth": { "image": …, "near": …, "zScale": … }`, because it describes ' +
452
452
  'the art rather than this key. Name it there, or give this key a radius.', 'CE_DEFORM_DEPTH_MAP_REQUIRED',
453
453
  );
454
454
  }
@@ -107,6 +107,7 @@ import {
107
107
  type SourceMesh,
108
108
  type WorstSample,
109
109
  } from './meshquality.ts';
110
+ import { createHash } from 'node:crypto';
110
111
  import { areaBand, stretchSingularValues, triangleAreas } from './areaband.ts';
111
112
  import { uvCarriers, uvCarriersWith, type Carrier, type CarrierPlant } from './meshcarriers.ts';
112
113
  import { CoreInputError, readModel, underNoSkin, underSkin, type CompiledDocument } from './core/index.ts';
@@ -187,6 +188,12 @@ export interface MotionComparisonInput {
187
188
  overBound?: OverBoundRequest;
188
189
  }
189
190
 
191
+ /** The candidate list is supplied on each use; every other input is bound to the reference. */
192
+ export type MotionReferenceInput = Omit<MotionComparisonInput, 'candidates'>;
193
+
194
+ /** An opaque reference prepared by {@link prepareMotionReference}. Its data is held outside the returned object. */
195
+ export interface PreparedMotionReference { readonly kind: 'prepared-motion-reference/1' }
196
+
190
197
  /**
191
198
  * Issue #1291's faults in carrying `motionAmplitude` into the setup measurement, for the `mesh-compare` suite's
192
199
  * negative controls: the amplitude not handed over; `null` handed over as the field left out; the field not validated
@@ -896,6 +903,110 @@ function foldingSlots(raw: unknown, who: string): Set<string> {
896
903
  return new Set(list as string[]);
897
904
  }
898
905
 
906
+ type GeometryRead = { geometry: EvidenceSection | null; counts: MeshCounts | null };
907
+ type MotionRead = { section: EvidenceSection & { schedule: ScheduleUsed }; perFrame: NonNullable<CandidateReport['perFrame']>; overBound: OverBoundReport | null } | null;
908
+ interface PreparedMotionData {
909
+ frames: Planned[];
910
+ walks: Walk[];
911
+ folding: Set<string>;
912
+ scheduleUsed: ScheduleUsed;
913
+ samplesOf: Array<{ samples: Sample[]; art: number }>;
914
+ refCarriers: Array<Array<Carrier | null>>;
915
+ posedRef: Posed | { refused: string };
916
+ }
917
+ interface PreparedState {
918
+ reference: Build;
919
+ refs: AttachmentRef[];
920
+ order: number[];
921
+ refGeometry: GeometryRead;
922
+ motionData: PreparedMotionData | null;
923
+ referenceMotion: MotionRead;
924
+ digests: Record<string, string>;
925
+ }
926
+
927
+ const preparedReferences = new WeakMap<PreparedMotionReference, PreparedState>();
928
+
929
+ /** One digest per bound field, so a mismatch names the field instead of accepting a stale pose or mask. */
930
+ function boundDigests(input: MotionReferenceInput): Record<string, string> {
931
+ // JSON.stringify maps -0 to 0, drops undefined object fields, and maps non-finite numbers to null. A bound
932
+ // option's signed zero can survive validation and is echoed in a report, so these are not safe digest aliases.
933
+ const exact = (value: unknown, visiting = new Set<object>()): unknown => {
934
+ if (value === null) return ['null'];
935
+ if (typeof value === 'number') return ['number', Object.is(value, -0) ? '-0' : String(value)];
936
+ if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'undefined') return [typeof value, value ?? null];
937
+ if (typeof value !== 'object') return [typeof value, String(value)];
938
+ if (visiting.has(value)) throw new Error('cyclic bound input');
939
+ visiting.add(value);
940
+ const encoded = Array.isArray(value)
941
+ ? ['array', Array.from({ length: value.length }, (_, i) => Object.prototype.hasOwnProperty.call(value, i) ? exact(value[i], visiting) : ['hole'])]
942
+ : ['object', Object.keys(value).map((key) => [key, exact((value as Record<string, unknown>)[key], visiting)])];
943
+ visiting.delete(value);
944
+ return encoded;
945
+ };
946
+ const digest = (value: unknown): string => createHash('sha256').update(JSON.stringify(exact(value))).digest('hex');
947
+ return {
948
+ reference: digest(input.reference),
949
+ schedule: digest(input.schedule),
950
+ attachments: digest(input.attachments.map((a) => ({
951
+ attachment: a.attachment,
952
+ art: { threshold: a.art.threshold, frame: a.art.frame, mask: {
953
+ width: a.art.mask.width,
954
+ height: a.art.mask.height,
955
+ alpha: createHash('sha256').update(a.art.mask.alpha).digest('hex'),
956
+ } },
957
+ finalThreshold: a.finalThreshold,
958
+ minArtSamples: a.minArtSamples,
959
+ regions: a.regions,
960
+ }))),
961
+ referenceArtFit: digest(input.referenceArtFit),
962
+ candidateArtFit: digest(input.candidateArtFit),
963
+ bounds: digest(input.bounds),
964
+ motionRequired: digest(input.motionRequired),
965
+ perFrame: digest(input.perFrame),
966
+ motionAmplitude: digest({ present: Object.prototype.hasOwnProperty.call(input, 'motionAmplitude'), value: input.motionAmplitude }),
967
+ overBound: digest({ present: Object.prototype.hasOwnProperty.call(input, 'overBound'), value: input.overBound }),
968
+ };
969
+ }
970
+
971
+ /** Prepare the source once for sequential candidates on the same schedule and the same art. */
972
+ export function prepareMotionReference(input: MotionReferenceInput): PreparedMotionReference {
973
+ if (!isObject(input)) missing('the prepared reference input', input, 'a MotionReferenceInput object');
974
+ const ref: unknown = input.reference;
975
+ let id = 'prepared-candidate';
976
+ while (isObject(ref) && id === ref.id) id += '-';
977
+ const model = isObject(ref) && typeof ref.model === 'string' ? ref.model : '';
978
+ validateInput({ ...input, candidates: [{ id, model }] }, null);
979
+ // No caller-owned array enters the cache: a later mutation is detected against this snapshot at use.
980
+ let snapshot: MotionReferenceInput;
981
+ try { snapshot = structuredClone(input); }
982
+ catch (e) { refuse('COMPARE_INPUT_MISSING', `the prepared reference input cannot be copied (${(e as Error).message}); required cloneable model, schedule, art and options`); }
983
+ let state: PreparedState | null = null;
984
+ const asComparison: MotionComparisonInput = { ...snapshot, candidates: [{ id, model: snapshot.reference.model }] };
985
+ compareMeshesInMotionWith(asComparison, null, undefined, (ready) => { state = ready; });
986
+ if (state === null) throw new Error('prepareMotionReference: reference preparation produced no state');
987
+ const ready: PreparedState = state;
988
+ try { ready.digests = boundDigests(snapshot); }
989
+ catch (e) { refuse('COMPARE_INPUT_MISSING', `the prepared reference input cannot be digested (${(e as Error).message}); required a complete mask and comparison options`); }
990
+ const handle: PreparedMotionReference = Object.freeze({ kind: 'prepared-motion-reference/1' });
991
+ preparedReferences.set(handle, ready);
992
+ return handle;
993
+ }
994
+
995
+ /** Compare a candidate against the prepared source; every bound input must still have the prepared bytes. */
996
+ export function comparePreparedMotion(handle: PreparedMotionReference, input: MotionComparisonInput): MeshQualityReport {
997
+ const ready = preparedReferences.get(handle);
998
+ if (ready === undefined) refuse('COMPARE_PREPARED_HANDLE', 'the prepared reference handle was not made by prepareMotionReference; required its opaque handle');
999
+ let now: Record<string, string>;
1000
+ try { now = boundDigests(input); }
1001
+ catch { refuse('COMPARE_PREPARED_MISMATCH', 'the bound input cannot be digested; required the reference, schedule, attachments and options used to prepare the handle'); }
1002
+ for (const [field, digest] of Object.entries(ready.digests)) {
1003
+ if (now[field] !== digest) refuse('COMPARE_PREPARED_MISMATCH', `${field} changed after prepareMotionReference; required the same reference, schedule, art and options for this handle`);
1004
+ }
1005
+ const report = compareMeshesInMotionWith(input, null, ready);
1006
+ if (report === null) throw new Error('comparePreparedMotion: comparison produced no report');
1007
+ return report;
1008
+ }
1009
+
899
1010
  /**
900
1011
  * Compare one or more candidate builds with a reference build in motion — §3
901
1012
  * and §4's motion rows — and measure each build's art fit at the setup pose
@@ -907,7 +1018,9 @@ function foldingSlots(raw: unknown, who: string): Set<string> {
907
1018
  * undercut at the setup pose fails `referenceArtFit`, and `measureMeshQuality`'s own codes for the art).
908
1019
  */
909
1020
  export function compareMeshesInMotion(input: MotionComparisonInput): MeshQualityReport {
910
- return compareMeshesInMotionWith(input, null);
1021
+ const report = compareMeshesInMotionWith(input, null);
1022
+ if (report === null) throw new Error('compareMeshesInMotion: comparison produced no report');
1023
+ return report;
911
1024
  }
912
1025
 
913
1026
  /**
@@ -916,18 +1029,20 @@ export function compareMeshesInMotion(input: MotionComparisonInput): MeshQuality
916
1029
  * surface*).
917
1030
  */
918
1031
  export function compareMeshesInMotionPlanted(input: MotionComparisonInput, plant: ComparePlant): MeshQualityReport {
919
- return compareMeshesInMotionWith(input, plant);
1032
+ const report = compareMeshesInMotionWith(input, plant);
1033
+ if (report === null) throw new Error('compareMeshesInMotionPlanted: comparison produced no report');
1034
+ return report;
920
1035
  }
921
1036
 
922
- function compareMeshesInMotionWith(input: MotionComparisonInput, plant: ComparePlant | null): MeshQualityReport {
1037
+ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: ComparePlant | null, prepared?: PreparedState, capture?: (state: PreparedState) => void): MeshQualityReport | null {
923
1038
  validateInput(input, plant);
924
1039
  const amplitude = input.motionAmplitude;
925
1040
  // What the setup measurement is handed: the field as the caller set it — left out as left out, `null` as `null`.
926
1041
  const handed: { motionAmplitude?: MotionAmplitude | null } =
927
1042
  plant === 'amplitude-not-carried' || amplitude === undefined || (plant === 'null-read-as-left-out' && amplitude === null) ? {} : { motionAmplitude: amplitude };
928
- const refs = input.attachments.map((a) => a.attachment);
929
- const reference = readBuild(input.reference, 'reference', refs);
930
- const candidates = input.candidates.map((c, i) => readBuild(c, `candidates[${i}]`, refs));
1043
+ const refs = prepared?.refs ?? input.attachments.map((a) => a.attachment);
1044
+ const reference = prepared?.reference ?? readBuild(input.reference, 'reference', refs);
1045
+ const candidates = capture === undefined ? input.candidates.map((c, i) => readBuild(c, `candidates[${i}]`, refs)) : [];
931
1046
  for (const c of candidates) {
932
1047
  const d = firstDifference(reference.raw, c.raw, [], refs);
933
1048
  if (d !== null) {
@@ -939,13 +1054,13 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
939
1054
  }
940
1055
  // The slots in skeleton order fix the order attachments are reported in (§2).
941
1056
  const slotRank = new Map(reference.doc.slots.map((s, i) => [s.name, i]));
942
- const order = input.attachments.map((_a, i) => i).sort((p, q) => (slotRank.get(refs[p].slot) ?? 0) - (slotRank.get(refs[q].slot) ?? 0) || (refs[p].attachment < refs[q].attachment ? -1 : refs[p].attachment > refs[q].attachment ? 1 : 0));
1057
+ const order = prepared?.order ?? input.attachments.map((_a, i) => i).sort((p, q) => (slotRank.get(refs[p].slot) ?? 0) - (slotRank.get(refs[q].slot) ?? 0) || (refs[p].attachment < refs[q].attachment ? -1 : refs[p].attachment > refs[q].attachment ? 1 : 0));
943
1058
  for (const ref of refs) {
944
1059
  if (!slotRank.has(ref.slot)) missing(`attachment ${nameOf(ref)}'s slot`, ref.slot, `one of the reference's slots [${[...slotRank.keys()].join(', ')}]`);
945
1060
  }
946
1061
 
947
1062
  // --- the setup art fit, each build under its own bounds (§3 (a)) ----------------------------
948
- const geometryOf = (b: Build, fit: ArtFitBounds): { geometry: EvidenceSection | null; counts: MeshCounts | null } => {
1063
+ const geometryOf = (b: Build, fit: ArtFitBounds): GeometryRead => {
949
1064
  const sections: EvidenceSection[] = [];
950
1065
  const counts: MeshCounts[] = [];
951
1066
  for (const i of order) {
@@ -987,7 +1102,7 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
987
1102
  };
988
1103
  return { geometry: joinSections(sections), counts: total };
989
1104
  };
990
- const refGeometry = geometryOf(reference, input.referenceArtFit);
1105
+ const refGeometry = prepared?.refGeometry ?? geometryOf(reference, input.referenceArtFit);
991
1106
  // §3 *Independent evidence* (a), P8: a reference that fails its own art fit — coverage, overshoot or undercut against
992
1107
  // its own mask under `referenceArtFit` — is refused AS A REFERENCE: a deviation from a reference that does not carry
993
1108
  // its own art is not evidence, so no candidate is measured against it. (a) is those three rows and no other: winding
@@ -1006,24 +1121,26 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1006
1121
 
1007
1122
  // --- motion ---------------------------------------------------------------------------------
1008
1123
  const schedule = input.schedule;
1009
- type Motion = { section: EvidenceSection & { schedule: ScheduleUsed }; perFrame: NonNullable<CandidateReport['perFrame']>; overBound: OverBoundReport | null } | null;
1010
1124
  // Issue #1315: the request as the caller set it; `?? undefined` only matters under the unvalidated plant.
1011
1125
  const overBound: OverBoundRequest | undefined = input.overBound ?? undefined;
1012
- let motionOf: (b: Build, isReference: boolean) => Motion = () => null;
1126
+ let motionOf: (b: Build, isReference: boolean) => MotionRead = () => null;
1127
+ let motionData: PreparedMotionData | null = null;
1013
1128
  if (schedule !== null) {
1014
- const physicsConstraints = reference.doc.constraints.filter((c) => c.kind === 'physics').length;
1015
- const { frames, walks, walkSteps } = planSchedule(schedule, reference.doc, physicsConstraints);
1016
- const folding = foldingSlots(reference.raw, `reference "${reference.id}"`);
1017
- const scheduleUsed: ScheduleUsed = {
1129
+ const existing = prepared?.motionData;
1130
+ const planned = existing === undefined ? planSchedule(schedule, reference.doc, reference.doc.constraints.filter((c) => c.kind === 'physics').length) : null;
1131
+ const frames = existing?.frames ?? planned!.frames;
1132
+ const walks = existing?.walks ?? planned!.walks;
1133
+ const folding = existing?.folding ?? foldingSlots(reference.raw, `reference "${reference.id}"`);
1134
+ const scheduleUsed: ScheduleUsed = existing?.scheduleUsed ?? {
1018
1135
  ...schedule,
1019
1136
  walked: frames.map((f) => f.ref),
1020
1137
  roles: (['baseline', 'selection', 'held-out'] as const).filter((r) => frames.some((f) => f.ref.role === r)),
1021
1138
  heldOutClaim: frames.some((f) => f.ref.role === 'held-out'),
1022
1139
  reset: 'physics reset at time 0',
1023
- walks: walks.map((w, i) => ({ animation: w.animation, phase: w.phase, steps: walkSteps[i] })),
1140
+ walks: walks.map((w, i) => ({ animation: w.animation, phase: w.phase, steps: planned!.walkSteps[i] })),
1024
1141
  };
1025
1142
  // The samples, per attachment: art pixel centres at the final threshold, then the reference hull's UVs (§3).
1026
- const samplesOf = order.map((i) => {
1143
+ const samplesOf = existing?.samplesOf ?? order.map((i) => {
1027
1144
  const a = input.attachments[i];
1028
1145
  const { mask, frame } = a.art;
1029
1146
  const bits = artOf(mask, a.finalThreshold);
@@ -1046,9 +1163,10 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1046
1163
  const carrierPlant: CarrierPlant | null = plant === 'slack-support-unread' || plant === 'narrowest-slack' || plant === 'any-shared-vertex' ? plant : null;
1047
1164
  const carriersOf = (uvs: readonly number[], triangles: readonly number[], samples: ReadonlyArray<{ uv: [number, number] }>, who: string): Array<Carrier | null> =>
1048
1165
  uvCarriersWith(uvs, triangles, samples, who, carrierPlant);
1049
- const refCarriers = order.map((i, k) => carriersOf(reference.meshes[i].uvs, reference.meshes[i].triangles, samplesOf[k].samples, `reference "${reference.id}", attachment ${nameOf(refs[i])}`));
1166
+ const refCarriers = existing?.refCarriers ?? order.map((i, k) => carriersOf(reference.meshes[i].uvs, reference.meshes[i].triangles, samplesOf[k].samples, `reference "${reference.id}", attachment ${nameOf(refs[i])}`));
1050
1167
  const clock = plant === 'frame-physics-clock' ? 'frame' : 'render';
1051
- const posedRef = poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks, clock);
1168
+ const posedRef = existing?.posedRef ?? poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks, clock);
1169
+ motionData = { frames, walks, folding, scheduleUsed, samplesOf, refCarriers, posedRef };
1052
1170
 
1053
1171
  motionOf = (b: Build, isReference: boolean) => {
1054
1172
  const carriers = isReference ? refCarriers : order.map((i, k) => carriersOf(b.meshes[i].uvs, b.meshes[i].triangles, samplesOf[k].samples, `candidate "${b.id}", attachment ${nameOf(refs[i])}`));
@@ -1311,9 +1429,13 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1311
1429
  return { section: { ...sectionOf(grouped), schedule: scheduleUsed }, perFrame, overBound: located };
1312
1430
  };
1313
1431
  }
1314
- const referenceMotion = motionOf(reference, true);
1432
+ const referenceMotion = prepared?.referenceMotion ?? motionOf(reference, true);
1433
+ if (capture !== undefined) {
1434
+ capture({ reference, refs, order, refGeometry, motionData, referenceMotion, digests: {} });
1435
+ return null;
1436
+ }
1315
1437
  const candidateMotions = candidates.map((c) => motionOf(c, false));
1316
- const reportOf = (b: Build, geometry: { geometry: EvidenceSection | null; counts: MeshCounts | null }, measured: Motion, isCandidate: boolean): CandidateReport => {
1438
+ const reportOf = (b: Build, geometry: GeometryRead, measured: MotionRead, isCandidate: boolean): CandidateReport => {
1317
1439
  const motion = measured === null ? null : measured.section;
1318
1440
  // P6: the geometry verdict pass (every required row measured and passing), and — when motion is required — the motion verdict pass.
1319
1441
  const accepted = geometry.geometry !== null && geometry.geometry.verdict === 'pass' && (!input.motionRequired || (motion !== null && motion.verdict === 'pass'));
@@ -1354,7 +1476,7 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1354
1476
  : {}
1355
1477
  : { overBound: { maxSamples: overBound.maxSamples, triangles: overBound.triangles } }),
1356
1478
  };
1357
- return {
1479
+ const report: MeshQualityReport = {
1358
1480
  spec: MESH_QUALITY_REPORT_SPEC,
1359
1481
  operation: 'compare',
1360
1482
  effective,
@@ -1365,4 +1487,6 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1365
1487
  candidates: candidates.map((c, i) => reportOf(c, candGeometry[i], candidateMotions[i], true)),
1366
1488
  termination: null,
1367
1489
  };
1490
+ // The prepared geometry, pose and reference rows live across calls. A caller may edit a returned report.
1491
+ return prepared === undefined ? report : structuredClone(report);
1368
1492
  }
package/src/rig.ts CHANGED
@@ -471,8 +471,8 @@ export interface RigDepthMap {
471
471
  */
472
472
  near: 'white' | 'black';
473
473
  /**
474
- * How many world units the map's full 0..1 range spans, in the attachment's
475
- * own units — the number `radius` used to carry.
474
+ * How many units the map's full 0..1 range spans: setup world units on an
475
+ * authored mesh, the generator's bind units on a generated mesh.
476
476
  *
477
477
  * Authored, never measured: 8 bits of level say nothing about scale, so a
478
478
  * compiler that picked one would be inventing the depth of the art.
@@ -971,6 +971,8 @@ export interface RigMeshAttachment {
971
971
  color?: string;
972
972
  /** Build the geometry instead of authoring it — see `RigMeshGenerator`. */
973
973
  generator?: RigMeshGenerator;
974
+ /** A setup-world depth turn over authored geometry; sampled in the part's pixel grid. */
975
+ depth?: RigDepthMap;
974
976
  /** A numbered image series over this one triangulation — see `RigSequence`. */
975
977
  sequence?: RigSequence;
976
978
  }
@@ -2077,7 +2079,7 @@ export const RIG_KEYS = {
2077
2079
  RigRegionAttachment: ['type', 'name', 'path', 'image', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'width', 'height', 'color', 'sequence'],
2078
2080
  RigMeshAttachment: [
2079
2081
  'type', 'name', 'path', 'image', 'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges',
2080
- 'width', 'height', 'color', 'generator', 'sequence',
2082
+ 'width', 'height', 'color', 'generator', 'depth', 'sequence',
2081
2083
  ],
2082
2084
  RigLinkedMeshAttachment: [
2083
2085
  'type', 'name', 'path', 'image', 'source', 'slot', 'skin', 'timelines', 'width', 'height', 'color', 'sequence',
@@ -2201,7 +2203,7 @@ export const RIG_TYPES = {
2201
2203
  RigMeshAttachment: {
2202
2204
  type: 'enum', name: 'string', path: 'string', image: 'string', uvs: 'number[]', triangles: 'number[]',
2203
2205
  vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', hull: 'number', edges: 'number[]',
2204
- width: 'number', height: 'number', color: 'string', generator: 'object', sequence: 'object',
2206
+ width: 'number', height: 'number', color: 'string', generator: 'object', depth: 'object', sequence: 'object',
2205
2207
  },
2206
2208
  RigLinkedMeshAttachment: {
2207
2209
  type: 'enum', name: 'string', path: 'string', image: 'string', source: 'string', slot: 'string', skin: 'string',
@@ -2557,6 +2559,7 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
2557
2559
  at(binding, 'RigMeshBinding', `${who} weights[${i}][${j}]`, [...here, 'weights', i, j]);
2558
2560
  }
2559
2561
  }
2562
+ at(att.depth, 'RigDepthMap', `${who} depth`, [...here, 'depth']);
2560
2563
  if (!isObj(att.generator)) continue;
2561
2564
  const gen = att.generator;
2562
2565
  const kind = RIG_GENERATOR_KINDS.find((k) => k === gen.kind);
@@ -21,7 +21,7 @@
21
21
  * corpus through both at the oracle's samples and compares full doubles at
22
22
  * tolerance 0 (*The runtime poser* there). The oracle is not changed for it.
23
23
  *
24
- * ## Stage 0: what it poses, and what it refuses
24
+ * ## Stage 1: what it poses, and what it refuses
25
25
  *
26
26
  * Bones and their five inherit modes; the rotate, translate, scale and shear
27
27
  * timelines; a slot's attachment and colour (`rgba`, `rgb`, `alpha`)
@@ -32,10 +32,14 @@
32
32
  * looping track. Everything else is refused at load by name with the list of
33
33
  * what is missing (`./model.ts`'s header and `RUNTIME_FEATURES`); a consumer
34
34
  * falls back to the oracle for a refused model or animation, so nothing breaks
35
- * while coverage grows.
35
+ * while coverage grows. Named skin views and their required bones are posed;
36
+ * to switch, compile the new underSkin/underNoSkin view at load and restore the
37
+ * animation/time and event cursor with newPose.restore(name, old.trackTime,
38
+ * old.time). Restoration delivers no already-passed cue. `pose.events` borrows immutable seven-field
39
+ * deliveries over the last interval, in oracle order, until the next apply/step.
36
40
  *
37
41
  * ```ts
38
- * const model = compileRuntime(underNoSkin(readModel(text)), { ignore: ['events'] });
42
+ * const model = compileRuntime(underNoSkin(readModel(text)));
39
43
  * const pose = new RuntimePose(model);
40
44
  * pose.setAnimation('walk');
41
45
  * pose.step(1 / 60); // or pose.apply(t)
@@ -76,5 +80,5 @@ export {
76
80
  L_SHEAR_X,
77
81
  L_SHEAR_Y,
78
82
  } from './model.ts';
79
- export type { RuntimeAnimation, RuntimeAttachment, RuntimeFeature, RuntimeMissing, RuntimeModel, RuntimeOptions, RuntimePlant, RuntimeSlot } from './model.ts';
83
+ export type { RuntimeAnimation, RuntimeEvent, RuntimeAttachment, RuntimeFeature, RuntimeMissing, RuntimeModel, RuntimeOptions, RuntimePlant, RuntimeSlot } from './model.ts';
80
84
  export { RuntimePose } from './pose.ts';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The runtime poser's load-time half (issue #1591, stage 0): a skin view of
2
+ * The runtime poser's load-time half (issue #1591, stage 1): a skin view of
3
3
  * the compiled model (`readModel`, then `underSkin` / `underNoSkin` — the
4
4
  * oracle's reader and its views, read and never written) compiled once into
5
5
  * index-resolved bones and slots, a constraint plan, compiled timelines and
@@ -12,8 +12,7 @@
12
12
  * (`RuntimeMissing`), and:
13
13
  *
14
14
  * - **refuses the model** (`RuntimeRefusal`, every missing feature listed)
15
- * when one is a property of the whole model — a skin other than `default`, a
16
- * path, physics or slider constraint, a transform constraint reading or
15
+ * when one is a property of the whole model — a path, physics or slider constraint, a transform constraint reading or
17
16
  * writing local values or adding (`localSource`, `localTarget`,
18
17
  * `additive`), an ik or transform constraint over a bone the view leaves
19
18
  * inactive, a world-space transform onto a `noScale` or
@@ -23,16 +22,15 @@
23
22
  * - **refuses an animation** (`RuntimeModel.refused`; `animation(name)`
24
23
  * throws the same `RuntimeRefusal`) when the construct is the animation's
25
24
  * — an `inherit` timeline, a deform or sequence timeline, a draw-order
26
- * timeline, event keys, a dark-colour (`rgba2`, `rgb2`) timeline, or an
25
+ * timeline, a dark-colour (`rgba2`, `rgb2`) timeline, or an
27
26
  * attachment key showing a clipping attachment or a region with no atlas
28
27
  * rectangle. Every other animation of the model is posed.
29
28
  *
30
- * `RUNTIME_FEATURES` is the closed list of what can be named. Events are the
31
- * one construct that moves no pose, so they alone can be ignored on request
32
- * (`RuntimeOptions.ignore`): a consumer that reads no event says so, and is
33
- * then handed a poser that fires none. Without the request an animation with
34
- * event keys is refused, because a poser that dropped them silently would be
35
- * a player that misses its cues and reports nothing.
29
+ * `RUNTIME_FEATURES` is the closed list of what can be named. Stage 1 reads
30
+ * named/no-skin views and delivers event keys, with immutable payloads compiled
31
+ * at load. `ignore: ['events']` remains an explicit diagnostic opt-out; it is
32
+ * never the default. A merged view with conflicting attachments still refuses
33
+ * by name, as the oracle itself cannot resolve that view without file order.
36
34
  *
37
35
  * ## What the oracle decides, the runtime reads
38
36
  *
@@ -48,18 +46,16 @@
48
46
  * no randomness, no I/O.
49
47
  */
50
48
  import type { ModelVertices } from '../model.ts';
51
- import { activeBones, constraintRecords, CORE_REGION_KINDS, foldInheritMode, readBlend, readColour, shownAttachment, shownRow, sourceOfDoc, type CompiledDocument, type CoreAnimation, type CoreBlendMode } from '../core/index.ts';
49
+ import { activeBones, constraintRecords, CORE_REGION_KINDS, foldInheritMode, readBlend, readColour, shownAttachment, shownRow, sourceOfDoc, underSkin, type CompiledDocument, type CoreAnimation, type CoreBlendMode } from '../core/index.ts';
52
50
  import { constraintInactiveWhy, constraintsAbsentWhy, TRANSFORM_PROPERTIES, type CoreIkRecord, type CoreTransformRecord } from '../core/constraints.ts';
53
- import { CORE_DEFAULT_SKIN } from '../core/skins.ts';
54
51
  import { RUNTIME_PI } from '../core/world.ts';
55
52
  import { compileCurve, type RuntimeCurve } from './curves.ts';
56
53
 
57
54
  /** The stage of issue #1591 this poser implements. */
58
- export const RUNTIME_STAGE = 0;
55
+ export const RUNTIME_STAGE = 1;
59
56
 
60
57
  /** Every construct the runtime poser can refuse, by the name its refusals carry. */
61
58
  export const RUNTIME_FEATURES = [
62
- 'skin',
63
59
  'path-constraint',
64
60
  'physics-constraint',
65
61
  'slider-constraint',
@@ -75,7 +71,6 @@ export const RUNTIME_FEATURES = [
75
71
  'deform-timeline',
76
72
  'sequence-timeline',
77
73
  'draw-order-timeline',
78
- 'events',
79
74
  'dark-colour-timeline',
80
75
  ] as const;
81
76
  export type RuntimeFeature = (typeof RUNTIME_FEATURES)[number];
@@ -124,6 +119,12 @@ export interface RuntimePlant {
124
119
  readonly admit?: readonly RuntimeFeature[];
125
120
  /** The update order's scratch sized by the bone count instead of the longest target list: the short-buffer plant. */
126
121
  readonly scratchByBones?: boolean;
122
+ /** Wrong-skin source selected by a control, never by a consumer. */
123
+ readonly attachmentSkin?: string;
124
+ /** Drops a key at zero on a wrap, the event boundary plant. */
125
+ readonly eventWrapOffByOne?: boolean;
126
+ /** The old restore behavior, which repeats keys up to the restored time. */
127
+ readonly restoreEventsFromStart?: boolean;
127
128
  }
128
129
 
129
130
  // A bone's local values, seven per bone.
@@ -277,6 +278,17 @@ export interface RuntimeConstraintTimeline {
277
278
  readonly flags: Uint8Array | null;
278
279
  }
279
280
 
281
+ /** One immutable event delivery, reused by every pose of this model. */
282
+ export interface RuntimeEvent {
283
+ readonly name: string;
284
+ readonly time: number;
285
+ readonly int: number;
286
+ readonly float: number;
287
+ readonly string: string;
288
+ readonly volume: number;
289
+ readonly balance: number;
290
+ }
291
+
280
292
  /** One covered animation, compiled. */
281
293
  export interface RuntimeAnimation {
282
294
  readonly name: string;
@@ -287,12 +299,16 @@ export interface RuntimeAnimation {
287
299
  readonly constraints: ReadonlyArray<RuntimeConstraintTimeline | null>;
288
300
  /** How many key cursors a pose keeps for it. */
289
301
  readonly cursors: number;
290
- /** Event keys it holds — 0 unless the consumer asked for events to be ignored. */
302
+ /** Number of authored event keys (including an explicitly muted diagnostic). */
291
303
  readonly events: number;
304
+ readonly eventKeys: readonly RuntimeEvent[];
292
305
  }
293
306
 
294
307
  /** The compiled model a pose is made of (`compileRuntime`). Read-only once built. */
295
308
  export interface RuntimeModel {
309
+ /** Control-only boundary defect; fixed at load. */
310
+ readonly eventWrapOffByOne: boolean;
311
+ readonly restoreEventsFromStart: boolean;
296
312
  readonly doc: CompiledDocument;
297
313
  readonly boneNames: readonly string[];
298
314
  readonly parents: Int32Array;
@@ -365,7 +381,6 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
365
381
  const refusals = new Refusals(new Set(plant.admit ?? []));
366
382
  const ignoreEvents = (options.ignore ?? []).includes('events');
367
383
 
368
- if (doc.skins.some((k) => k.name !== CORE_DEFAULT_SKIN)) refusals.note('skin', `the document declares skins [${doc.skins.map((k) => k.name).join(', ')}]; stage ${RUNTIME_STAGE} poses the default skin alone`);
369
384
  const absentWhy = constraintsAbsentWhy(doc);
370
385
  if (absentWhy !== null) refusals.note('oracle-absent', `the oracle leaves the bones out: ${absentWhy}`);
371
386
 
@@ -440,6 +455,7 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
440
455
  const attachments: RuntimeAttachment[] = [];
441
456
  let vertexCount = 0;
442
457
  const sourceOf = sourceOfDoc(doc);
458
+ const attachmentView = plant.attachmentSkin === undefined ? doc : underSkin(doc, plant.attachmentSkin);
443
459
  /** Per slot, each placeholder's attachment index (−1 shows nothing) and what it misses, resolved once and its refusal noted for every animation that shows it. */
444
460
  const resolvedBySlot = doc.slots.map(() => new Map<string | null, { index: number; missing: readonly [RuntimeFeature, string] | null }>());
445
461
  const resolve = (si: number, placeholder: string | null, animation: string | null): number => {
@@ -454,7 +470,7 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
454
470
  };
455
471
  const resolveRecord = (si: number, placeholder: string | null): { index: number; missing: readonly [RuntimeFeature, string] | null } => {
456
472
  const slot = doc.slots[si];
457
- const shown = placeholder === null ? null : shownAttachment(doc, slot, placeholder);
473
+ const shown = placeholder === null ? null : shownAttachment(attachmentView, slot, placeholder);
458
474
  if (shown === null) return { index: -1, missing: null };
459
475
  if ('conflict' in shown) return { index: -1, missing: ['skin-conflict', `slot "${slot.name}" placeholder "${placeholder}" is filled by skins that disagree`] };
460
476
  const row = shownRow(shown);
@@ -538,6 +554,8 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
538
554
  if (refusals.model.length > 0) throw new RuntimeRefusal(refusals.model);
539
555
  const names = doc.animations.map((a) => a.name);
540
556
  return {
557
+ eventWrapOffByOne: plant.eventWrapOffByOne === true,
558
+ restoreEventsFromStart: plant.restoreEventsFromStart === true,
541
559
  doc,
542
560
  boneNames: doc.bones.map((b) => b.name),
543
561
  parents,
@@ -667,7 +685,6 @@ function compileAnimation(
667
685
  if (deform.length > 0) refusals.note('deform-timeline', `${deform.length} deform timeline(s): ${deform.slice(0, 3).map((a) => `${a.skin}/${a.slot}/${a.attachment}`).join(', ')}${deform.length > 3 ? ', …' : ''}`, name);
668
686
  if (sequence.length > 0) refusals.note('sequence-timeline', `${sequence.length} sequence timeline(s): ${sequence.slice(0, 3).map((a) => `${a.skin}/${a.slot}/${a.attachment}`).join(', ')}${sequence.length > 3 ? ', …' : ''}`, name);
669
687
  if (tl.drawOrder.length > 0) refusals.note('draw-order-timeline', `${tl.drawOrder.length} draw-order key(s)`, name);
670
- if (tl.events.length > 0 && !ignoreEvents) refusals.note('events', `${tl.events.length} event key(s); a poser that fires none is handed out only on request (ignore: ['events'])`, name);
671
688
 
672
689
  const lastBy = <T extends { name: string }>(targets: readonly T[]): Map<string, T> => new Map(targets.map((t) => [t.name, t]));
673
690
  const bones: RuntimeBoneTimeline[] = [];
@@ -715,5 +732,5 @@ function compileAnimation(
715
732
  constraintTimelines[c.index] = { constraint: c.index, curve: compileCurve(keyed.keys, TRANSFORM_PROPERTIES.length), cursor: cursors++, flags: null };
716
733
  }
717
734
  }
718
- return { name, duration: tl.duration, bones, slots: slotTimelines, constraints: constraintTimelines, cursors, events: tl.events.length };
735
+ return { name, duration: tl.duration, bones, slots: slotTimelines, constraints: constraintTimelines, cursors, events: tl.events.length, eventKeys: Object.freeze(ignoreEvents ? [] : tl.events.map(key => Object.freeze({ name: key.name, time: key.time, int: key.int, float: key.float, string: key.string, volume: key.volume ?? 0, balance: key.balance ?? 0 }))) };
719
736
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The runtime poser's per-instance half (issue #1591, stage 0): one
2
+ * The runtime poser's per-instance half (issue #1591, stage 1): one
3
3
  * `RuntimePose` per skeleton on screen, every buffer allocated when it is
4
4
  * made and overwritten in place on every pose, so that posing a frame
5
5
  * allocates nothing.
@@ -71,6 +71,7 @@ import {
71
71
  W_Y,
72
72
  WORLDS,
73
73
  type RuntimeAnimation,
74
+ type RuntimeEvent,
74
75
  type RuntimeIk,
75
76
  type RuntimeModel,
76
77
  type RuntimeTransform,
@@ -114,6 +115,11 @@ export class RuntimePose {
114
115
  trackTime = 0;
115
116
  time = 0;
116
117
 
118
+ private readonly fired: RuntimeEvent[] = [];
119
+ /** Borrowed immutable event records, in delivery order, until the next apply/step. */
120
+ get events(): readonly RuntimeEvent[] { return this.fired; }
121
+ private eventTime = -1;
122
+
117
123
  private current: RuntimeAnimation | null = null;
118
124
  private readonly cursors: Int32Array;
119
125
  // The constraints' values as posed: an ik's mix, softness and flags, a transform's six mixes.
@@ -160,12 +166,30 @@ export class RuntimePose {
160
166
  this.cursors.fill(-1);
161
167
  this.trackTime = 0;
162
168
  this.time = 0;
169
+ this.eventTime = -1;
170
+ this.fired.length = 0;
171
+ }
172
+
173
+ /**
174
+ * Restore one animation's two clocks without delivering keys already passed.
175
+ * A skin/view replacement uses the old pose's trackTime and wrapped time.
176
+ * Unlike setAnimation + apply, restoration never replays the initial interval.
177
+ */
178
+ restore(name: string, trackTime: number, animationTime: number): void {
179
+ if (!Number.isFinite(trackTime)) throw new RuntimeInputError(`restore: track time ${trackTime} is not finite`);
180
+ if (!Number.isFinite(animationTime)) throw new RuntimeInputError(`restore: animation time ${animationTime} is not finite`);
181
+ this.setAnimation(name);
182
+ this.trackTime = trackTime;
183
+ if (!this.model.restoreEventsFromStart) this.eventTime = animationTime;
184
+ this.apply(animationTime);
163
185
  }
164
186
 
165
187
  /** The setup pose: every constraint applied with its own values, every slot as it is set up. */
166
188
  setupPose(): void {
167
189
  this.current = null;
168
190
  this.time = 0;
191
+ this.eventTime = -1;
192
+ this.fired.length = 0;
169
193
  this.locals.set(this.model.setup);
170
194
  this.hierarchy();
171
195
  this.constraintValues(null, 0);
@@ -178,6 +202,7 @@ export class RuntimePose {
178
202
  const anim = this.current;
179
203
  if (anim === null) throw new RuntimeInputError('apply: no animation is set (setAnimation first)');
180
204
  if (!Number.isFinite(t)) throw new RuntimeInputError(`apply: the time ${t} is not finite`);
205
+ this.collectEvents(anim, t);
181
206
  this.time = t;
182
207
  this.boneTimelines(anim, t);
183
208
  this.hierarchy();
@@ -194,6 +219,21 @@ export class RuntimePose {
194
219
  this.apply(anim.duration === 0 ? 0 : this.trackTime % anim.duration);
195
220
  }
196
221
 
222
+ /** The oracle interval (last,t], tail then head on a wrap; never copies a payload. */
223
+ private collectEvents(anim: RuntimeAnimation, t: number): void {
224
+ const last = this.eventTime;
225
+ const wrapped = t < last;
226
+ const keys = anim.eventKeys;
227
+ let count = 0;
228
+ if (wrapped) for (let i = 0; i < keys.length; i++) if (keys[i].time > last) this.fired[count++] = keys[i];
229
+ for (let i = 0; i < keys.length; i++) {
230
+ const key = keys[i];
231
+ if (key.time <= t && (wrapped ? !this.model.eventWrapOffByOne || key.time > 0 : key.time > last)) this.fired[count++] = key;
232
+ }
233
+ this.fired.length = count;
234
+ this.eventTime = t;
235
+ }
236
+
197
237
  // ---------------------------------------------------------------------------
198
238
  // the bones
199
239
  // ---------------------------------------------------------------------------