@rig-c/core 2.72.0 → 2.73.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/README.md +10 -0
- package/docs/AUTHORING.md +88 -1
- package/docs/FACE.md +19 -0
- package/package.json +1 -1
- package/src/alpha_clip.ts +255 -0
- package/src/cli/core_commands.ts +2 -1
- package/src/cli/shared.ts +14 -1
- package/src/compile.ts +286 -3
- package/src/core/clipping.ts +5 -1
- package/src/mesh.ts +3 -1
- package/src/rig.ts +25 -3
- package/src/types.ts +26 -0
package/README.md
CHANGED
|
@@ -51,6 +51,16 @@ Everything in that table needs Bun and this package — `preview` and `vote` als
|
|
|
51
51
|
`@esotericsoftware/spine-core` installed beside it (see below) — and no clone, no
|
|
52
52
|
reference art, no art pipeline, no server.
|
|
53
53
|
|
|
54
|
+
### Alpha-derived clipping
|
|
55
|
+
|
|
56
|
+
Alpha-derived clipping takes a named source slot/skin/attachment plus explicit
|
|
57
|
+
alpha threshold, drawing-pixel simplification tolerance and mapped vertex budget.
|
|
58
|
+
Region, authored/resolved linked mesh and generated grid/contour depth sources use
|
|
59
|
+
ordinary weighted clipping, with the existing validation gate before emission.
|
|
60
|
+
See [AUTHORING: Alpha-derived clipping](docs/AUTHORING.md#alpha-derived-clipping)
|
|
61
|
+
for source-motion limits, fit reports and named refusals, and
|
|
62
|
+
[the face guide](docs/FACE.md#a-measured-alpha-aperture) for the depth-yaw flow.
|
|
63
|
+
|
|
54
64
|
## What rigc is, and what it is not
|
|
55
65
|
|
|
56
66
|
rigc emits **Spine's own skeleton data format**. That is the whole positioning, and
|
package/docs/AUTHORING.md
CHANGED
|
@@ -2620,6 +2620,7 @@ attachment rather than a second set of art.
|
|
|
2620
2620
|
| --- | --- |
|
|
2621
2621
|
| `type` | `"clipping"`. **Required** |
|
|
2622
2622
|
| `name`, `vertexCount`, `vertices`, `weights`, `boneIndexing`, `color` | exactly as on a bounding box |
|
|
2623
|
+
| `alphaClip` | Optional generated clipping geometry from a named source plate. Declare `skin`, `slot`, `attachment`, `alpha`, `tolerance`, and `maxVertices` in the object; all six are required. This form excludes authored vertex geometry and `convex`/`inverse`. See **Alpha-derived clipping** for source, carrier, budget, and deform refusals. |
|
|
2623
2624
|
| `end` | the last slot the clip applies to, **by name**. Absent is the parser's own encoding for *clip everything after this one*, which is why a typo cannot be told from an omission once the file is loaded: `findSlot` returns null on a miss and the parser assigns that null without a word, so the clip runs to the bottom of the draw order and takes every slot below it with it. rigc refuses a name the rig does not declare — `end names slot "X", which this rig does not declare` — and `A33` refuses it again on a skeleton rigc did not write |
|
|
2624
2625
|
| `convex` | default **false**. True tells the runtime the polygon is convex so it can clip without triangulating it, and a polygon that deforms concave is clipped by its convex hull instead (`ClippingAttachment.convex`). Nothing here checks that the polygon is in fact convex |
|
|
2625
2626
|
| `inverse` | default **false**. True makes everything **outside** the polygon visible instead of everything inside, and inverse clipping is always treated as convex (`ClippingAttachment.inverse`) |
|
|
@@ -7781,7 +7782,7 @@ retain the minimal inputs and report that code rather than inventing author data
|
|
|
7781
7782
|
|
|
7782
7783
|
| Code | Fixed message text and alternatives | What to change |
|
|
7783
7784
|
| --- | --- | --- |
|
|
7784
|
-
| <a id="CE_KEYS_UNKNOWN_FIELD"></a>`CE_KEYS_UNKNOWN_FIELD` | “<where>: <what> has <strays.length === 1 ? 'a key' : `${strays.length} keys`> this compiler does not read: <named.join(', ')>. 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: <[...known].sort().join(', ')>.}”<br>“{"<key>" / "<key>" (did you mean <near.map((n) => `"${n}"`).join(', ')>?)}”<br>Also: “skin "<value>" slot "<value>" attachment "<value>"”<br>“<value> generator.bones[<value>]”<br>“{constraint "<value>" / constraints[<value>]}”<br>“<value>.transform.envelope”<br>“<value> weights[<value>][<value>]”<br>“invariants.deformMayFold[<value>]”<br>“animations."<value>"”<br>“<value>.offsets[<value>]”<br>“{stop "<value>" / stops[<value>]}”<br>“<value> properties."<value>".to."<value>"”<br>“this rig spec”<br>“<value>.derive”<br>“setup."<value>"”<br>“physics."<value>"”<br>“<value>'s "lower"”<br>“<value> "sequence"”<br>“<value> generator.bias”<br>“<value> generator.depth”<br>“<value> depth”<br>“<value>.events[<value>]”<br>“<value>.tracks[<value>]”<br>“invariants.axes[<value>]”<br>“<value> generator.falloff”<br>“<value>.drawOrder[<value>]”<br>“<value> generator.influence”<br>“invariants.detached[<value>]”<br>“{slot "<value>" / slots[<value>]}”<br>“invariants.consumerDrivenMix[<value>]”<br>“event "<value>"”<br>“<value>'s "from"”<br>“this motion spec”<br>“{absent / <value>}”<br>“{region / <value>}”<br>“<value>.keys[<value>]”<br>“<value> generator.soft”<br>“{linkedmesh / <value>}”<br>“<value>.deform[<value>]”<br>“<value>.sequence[<value>]”<br>“<value> generator (<value>)”<br>“{bone "<value>" / bones[<value>]}”<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
|
|
7785
|
+
| <a id="CE_KEYS_UNKNOWN_FIELD"></a>`CE_KEYS_UNKNOWN_FIELD` | “<where>: <what> has <strays.length === 1 ? 'a key' : `${strays.length} keys`> this compiler does not read: <named.join(', ')>. 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: <[...known].sort().join(', ')>.}”<br>“{"<key>" / "<key>" (did you mean <near.map((n) => `"${n}"`).join(', ')>?)}”<br>Also: “skin "<value>" slot "<value>" attachment "<value>"”<br>“<value> generator.bones[<value>]”<br>“{constraint "<value>" / constraints[<value>]}”<br>“<value>.transform.envelope”<br>“<value> weights[<value>][<value>]”<br>“invariants.deformMayFold[<value>]”<br>“animations."<value>"”<br>“<value>.offsets[<value>]”<br>“{stop "<value>" / stops[<value>]}”<br>“<value> properties."<value>".to."<value>"”<br>“this rig spec”<br>“<value>.derive”<br>“setup."<value>"”<br>“physics."<value>"”<br>“<value>'s "lower"”<br>“<value> "sequence"”<br>“<value> generator.bias”<br>“<value> generator.depth”<br>“<value> depth”<br>“<value>.events[<value>]”<br>“<value>.tracks[<value>]”<br>“invariants.axes[<value>]”<br>“<value> generator.falloff”<br>“<value>.drawOrder[<value>]”<br>“<value> generator.influence”<br>“invariants.detached[<value>]”<br>“{slot "<value>" / slots[<value>]}”<br>“invariants.consumerDrivenMix[<value>]”<br>“event "<value>"”<br>“<value>'s "from"”<br>“this motion spec”<br>“{absent / <value>}”<br>“{region / <value>}”<br>“<value>.keys[<value>]”<br>“<value> generator.soft”<br>“{linkedmesh / <value>}”<br>“<value>.deform[<value>]”<br>“<value>.sequence[<value>]”<br>“<value> generator (<value>)”<br>“{bone "<value>" / bones[<value>]}”<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”.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦ alphaClip⟧<br>⟦alphaClip⟧ | Fix each printed stray key's spelling using the suggested or known names, or remove it. |
|
|
7785
7786
|
| <a id="CE_KEYS_NUMBER_FLOAT32_FINITE"></a>`CE_KEYS_NUMBER_FLOAT32_FINITE` | “<where>: <place(path)> is <String(node)>; a number in this file is finite at float32 precision, at most ±<String(FLOAT32_MAX)>, 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.<why>”<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. |
|
|
7786
7787
|
| <a id="CE_KEYS_FIELD_VALUE_TYPE"></a>`CE_KEYS_FIELD_VALUE_TYPE` | “<where>: <visit.name([key, ...wrong.tail])> is <typeFound(wrong.value)>; <wrong.required> 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. |
|
|
7787
7788
|
| <a id="CE_KEYS_ENUM_VALUE"></a>`CE_KEYS_ENUM_VALUE` | “<where>: <visit.name([key])> is <JSON.stringify(value) ?? String(value)>; one of <rule.set.map((name) => JSON.stringify(name)).join(', ')> — <rule.readAs>”<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. |
|
|
@@ -12523,6 +12524,92 @@ nonselected output is the stored source geometry through the unchanged setup
|
|
|
12523
12524
|
matrices; it does not borrow a different selection's deform. The control
|
|
12524
12525
|
checks both results and the source of the difference, without an epsilon.
|
|
12525
12526
|
|
|
12527
|
+
### Alpha-derived clipping
|
|
12528
|
+
|
|
12529
|
+
A clipping attachment may replace authored `vertexCount`/`vertices`/`weights` with
|
|
12530
|
+
`alphaClip`. The two forms are exclusive. This creates ordinary weighted clipping
|
|
12531
|
+
geometry; it introduces no runtime alpha mask or new projection model.
|
|
12532
|
+
|
|
12533
|
+
```json
|
|
12534
|
+
{"type":"clipping","end":"content","alphaClip":{
|
|
12535
|
+
"skin":"default","slot":"aperture","attachment":"aperture",
|
|
12536
|
+
"alpha":128,"tolerance":0.5,"maxVertices":256
|
|
12537
|
+
}}
|
|
12538
|
+
```
|
|
12539
|
+
|
|
12540
|
+
All six fields are required. `skin`, `slot`, `attachment` name the source's exact
|
|
12541
|
+
placeholder identity, independent of draw order; no current-skin/setup-attachment
|
|
12542
|
+
guess is made. `alpha` is an integer in 1..255, `tolerance` is a positive distance
|
|
12543
|
+
in the drawing's pixels, and `maxVertices` is an integer >=3. These example values
|
|
12544
|
+
are declarations, not defaults. The measured alpha is the decoded source plate,
|
|
12545
|
+
not the source slot's animated opacity. A hidden source slot may supply the plate.
|
|
12546
|
+
|
|
12547
|
+
Sources are regions, meshes (generated grid/contour included), or linked meshes
|
|
12548
|
+
whose ordinary source resolution succeeded. Atlas rotation/trim extraction and
|
|
12549
|
+
**declared** scale apply before tracing. The source must have one nonempty island,
|
|
12550
|
+
transparent exterior and no holes; a single clipping polygon cannot encode holes.
|
|
12551
|
+
A sequence has no single immutable plate and is refused. Missing or ambiguous UV
|
|
12552
|
+
carriers refuse the build instead of extrapolating the source surface.
|
|
12553
|
+
|
|
12554
|
+
The simplified outline is split at every source UV-triangle crossing. Every
|
|
12555
|
+
partitioned open interval also requires a unique carrier: carried endpoints alone
|
|
12556
|
+
cannot authorize a straight bridge across a hole in the source UV domain. UV
|
|
12557
|
+
barycentrics locate the source's setup position, per-bone weighted first moments
|
|
12558
|
+
and sampled depth. Those crossings count against `maxVertices`. The resulting
|
|
12559
|
+
named weighted bindings follow the source bones even if the clip slot belongs to
|
|
12560
|
+
a different bone. They do **not** inherit another attachment's deform timeline.
|
|
12561
|
+
For a source that deforms, explicitly give source and clip identical affine or
|
|
12562
|
+
`depth:true` yaw/pitch transform keys in compatible source/clip `DeformGeometry`
|
|
12563
|
+
frames. Existing
|
|
12564
|
+
`compileDeformTrack` evaluates both; clip depth comes from interpolation of the
|
|
12565
|
+
source's sampled vertex depths, not a second sample of a nonlinear depth image.
|
|
12566
|
+
The frame is derived by `deformGeometryOf`: a bind-space frame requires exactly
|
|
12567
|
+
one influence per vertex and exactly one bone; duplicate influences on that same
|
|
12568
|
+
bone instead evaluate in world space. A world-source/bind-clip mismatch is refused
|
|
12569
|
+
by name. Compatible world/world affine or depth transforms are accepted when
|
|
12570
|
+
source influence shares are constant across vertices, so interpolation commutes
|
|
12571
|
+
with both projection and moving bone matrices. Dense keys, envelopes/nonlinear
|
|
12572
|
+
transforms and varying world-space shares cannot silently leave a clip static;
|
|
12573
|
+
they receive a named representability refusal. Static multi-bone sources
|
|
12574
|
+
use their existing named weighted geometry without a new solver.
|
|
12575
|
+
|
|
12576
|
+
`build` on both entries and `explain` print a `CLIP` row: source/target identity,
|
|
12577
|
+
threshold/tolerance, traced/simplified/emitted vertex counts and budget,
|
|
12578
|
+
`coveredArt/artPixels`, coverage, false-positive/negative **pixel-centre cells**,
|
|
12579
|
+
maximum original trace-corner distance to the simplified outline, overshoot, and
|
|
12580
|
+
`maxBindingDeviation` (maximum world-space discrepancy from source-carrier setup
|
|
12581
|
+
positions after final float32 binding/world rounding).
|
|
12582
|
+
Trace/overshoot distances are drawing pixels, including on a scaled atlas;
|
|
12583
|
+
binding deviation explicitly uses world units. Final float32 adjacent duplicate,
|
|
12584
|
+
foldback and zero-area polygons are refused, not silently pruned. This is a threshold
|
|
12585
|
+
silhouette fit, not soft-alpha pixel equality. Coverage/distance scalars display
|
|
12586
|
+
six decimal places (display rounding envelope 0.5e-6); counts are exact. Do not
|
|
12587
|
+
compare the shortened display against exact float32 equality. No mesh-specific
|
|
12588
|
+
99.5% coverage cutoff or offset margin is applied to a clipping boundary.
|
|
12589
|
+
|
|
12590
|
+
The ordinary validation gate still precedes artifact writes. `A18` compares
|
|
12591
|
+
independent emits and `A33` reads clipping geometry; neither alone proves the
|
|
12592
|
+
posed mask is the intended aperture. The controlled proof separately compares
|
|
12593
|
+
the threshold plate on the actual posed source surface, clip boundary and both
|
|
12594
|
+
renderers at four gaze directions × depth yaw ±15°. It fixes viewport/background,
|
|
12595
|
+
uses output pixel centres and the existing premultiplied bilinear reader, and
|
|
12596
|
+
separates soft-edge quantization from simplification/projection/filtering error.
|
|
12597
|
+
A real licensed editor import/export and nonempty per-state pixel/binding checks
|
|
12598
|
+
remain separate requirements; editor harness exit 0 or parser tests are not a
|
|
12599
|
+
verdict on those figures.
|
|
12600
|
+
|
|
12601
|
+
| Code | Named refusal and repair |
|
|
12602
|
+
| --- | --- |
|
|
12603
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_PARAMETERS"></a>`CE_COMPILE_ALPHA_CLIP_PARAMETERS` | Alpha/tolerance/scale/budget names its actual value and required range. Declare every parameter; never choose a threshold by eye during a run.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: alphaClip.alpha is ⟧<br>⟦; required an integer in 1..255. Declare the threshold.⟧<br>⟦: alphaClip.tolerance=⟧<br>⟦, page scale=⟧<br>⟦required finite positive drawing-pixel tolerance and page scale.⟧<br>⟦: alphaClip.maxVertices is ⟧<br>⟦; required an integer >=3. ⟧<br>⟦Declare the complete mapped polygon budget.⟧ |
|
|
12604
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_NO_ALPHA"></a>`CE_COMPILE_ALPHA_CLIP_NO_ALPHA` | `slotwithnoalpha`: the actual pixel count above threshold is zero or the whole window. Supply a nonempty silhouette with transparent exterior. An opaque RGBA channel is not a silhouette.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: slotwithnoalpha: ⟧<br>⟦ of ⟧<br>⟦ pixels reach alpha ⟧<br>⟦; ⟧<br>⟦required a nonempty silhouette with transparent exterior. Supply alpha art or declare a threshold that measures a silhouette.⟧ |
|
|
12605
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_TOPOLOGY"></a>`CE_COMPILE_ALPHA_CLIP_TOPOLOGY` | Trace cannot form one simple outline, has holes/islands/pinches, or simplifies below three vertices. Repair the source or lower the declared tolerance; holes are never silently filled.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: clipping has ⟧<br>⟦ vertices; required >=3 after final coordinate rounding.⟧<br>⟦: adjacent clipping vertices ⟧<br>⟦ and ⟧<br>⟦[⟧<br>⟦] and [⟧<br>⟦]; required finite distinct neighbours after final coordinate rounding. ⟧<br>⟦Lower the source coordinate magnitude or simplify the outline.⟧<br>⟦: adjacent edges ⟧<br>⟦ fold back after rounding; ⟧<br>⟦required a nondegenerate simple clipping boundary.⟧<br>⟦: clipping doubled area is ⟧<br>⟦ after coordinate rounding; required nonzero area.⟧<br>⟦: alpha outline cannot be represented: ⟧<br>⟦bad part size ⟧<br>⟦x⟧<br>⟦the alpha mask holds ⟧<br>⟦ bytes for a ⟧<br>⟦ part⟧<br>⟦no pixel of the ⟧<br>⟦ part reaches alpha ⟧<br>⟦; there is no silhouette to trace⟧<br>⟦the alpha silhouette pinches to a single point at pixel corner (⟧<br>⟦), where two parts of the art ⟧<br>⟦meet diagonally — one outline cannot pass through one point twice. Move the alpha threshold so the ⟧<br>⟦pinch opens (raise it) or closes (lower it), or author the geometry as weights⟧<br>⟦the alpha trace did not close; the mask is not a closed region⟧<br>⟦the alpha trace reached a dead end at corner (⟧<br>⟦); the mask is not a closed region⟧<br>⟦; required one simple connected outline.⟧<br>⟦: alpha ⟧<br>⟦ islands and ⟧<br>⟦ hole pixels; ⟧<br>⟦required one island without holes. Separate the source or author clipping geometry.⟧ |
|
|
12606
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_SELF_INTERSECTION"></a>`CE_COMPILE_ALPHA_CLIP_SELF_INTERSECTION` | `selfintersectionaftersimplification`: names intersecting edges. Lower tolerance or repair the source topology. The forged-crossing helper control is not evidence of a traced raster counterexample.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: selfintersectionaftersimplification: edges ⟧<br>⟦ and ⟧<br>⟦intersect; required one simple outline. Lower tolerance or repair the source topology.⟧ |
|
|
12607
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_CARRIER"></a>`CE_COMPILE_ALPHA_CLIP_CARRIER` | Source UV carrier missing/ambiguous at the named outline vertex or partitioned open interval. Extend coverage or repair overlapping source triangles; no extrapolation.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: source UV carrier is ambiguous: ⟧<br>⟦COMPARE_UV_CARRIER_NOT_UNIQUE⟧<br>⟦required unique source geometry. Repair overlapping UV triangles.⟧<br>⟦: source has no UV carrier for outline vertex ⟧<br>⟦ at [⟧<br>⟦]; ⟧<br>⟦required coverage of the complete clipping outline. Extend the source geometry or lower tolerance.⟧<br>⟦: outline interval ⟧<br>⟦ from [⟧<br>⟦] to [⟧<br>⟦] ⟧<br>⟦has no source UV carrier at open midpoint [⟧<br>⟦]; required complete interval coverage. ⟧<br>⟦Fill the source UV gap; carried endpoints cannot authorize a bridge.⟧ |
|
|
12608
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_VERTEX_BUDGET"></a>`CE_COMPILE_ALPHA_CLIP_VERTEX_BUDGET` | `polygonoverdeclaredvertexbudget`: actual mapped count exceeds declared limit, including source-triangle crossings. Raise the budget or simplify the outline/source.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦ alphaClip from "⟧<br>⟦"⟧<br>⟦: polygonoverdeclaredvertexbudget: ⟧<br>⟦ vertices after source carrier crossings (⟧<br>⟦ simplified), required <=⟧<br>⟦. Raise the declared budget or simplify the source/outline.⟧ |
|
|
12609
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_SOURCE"></a>`CE_COMPILE_ALPHA_CLIP_SOURCE` | Names missing identity/plate, wrong source kind, sequence or unlocated atlas grid. Supply exact source identity and static plate geometry. A clipping source cannot serve as its own dependency.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: generated clipping target was not filed in its skin; required a resolved attachment.⟧<br>⟦: alphaClip.⟧<br>⟦ is ⟧<br>⟦; required an explicit nonempty source name.⟧<br>⟦ alphaClip from "⟧<br>⟦"⟧<br>⟦: source attachment is absent; required a declared slot/skin/attachment.⟧<br>⟦: source kind is ⟧<br>⟦; required a region or resolved mesh with pixels.⟧<br>⟦: linked source resolves to ⟧<br>⟦nothing⟧<br>⟦; required a resolved mesh.⟧<br>⟦: source is ⟧<br>⟦; required region or mesh geometry with a plate (a clipping source would be a dependency cycle).⟧<br>⟦: source has a ⟧<br>⟦-frame sequence; required one immutable source plate. Select a separate named attachment for the measured frame.⟧<br>⟦: region "⟧<br>⟦" has no source plate; required image/atlas pixels.⟧<br>⟦: source plate grid cannot be located. ⟧<br>⟦page "⟧<br>⟦" declares ⟧<br>⟦ and its PNG is ⟧<br>⟦ — ⟧<br>⟦ of the declared width and ⟧<br>⟦ of the declared height⟧<br>⟦A runtime does not read the file's own size anywhere in the region mapping — `TextureAtlas` computes every ⟧<br>⟦region's UVs as a fraction of the DECLARED size (`region.u = region.x / page.width`, spine-core ⟧<br>⟦`dist/TextureAtlas.js:162-171`) — so a page whose PNG is the declared page RESCALED draws the same picture at ⟧<br>⟦the file's resolution, and one whose PNG is anything else draws whatever sits at those fractions. What a ⟧<br>⟦declared size that disagrees with the file breaks is every reader that addresses the page in TEXELS: this ⟧<br>⟦validator's own `A19` alpha scan, the region lift rigc's mesh generators trace, and a canvas renderer that ⟧<br>⟦cuts each part out of the page by source rectangle.⟧<br>⟦The `scale:` header states one ratio for both axes, so a page whose axes differ cannot be ⟧<br>⟦declared honestly at all: re-export the page at ⟧<br>⟦, or repack it.⟧<br>⟦The format states coarser texels with the `scale:` header, but this page cannot be re-declared ⟧<br>⟦that way: ⟧<br>⟦region ⟧<br>⟦'s `⟧<br>⟦` of ⟧<br>⟦ becomes ⟧<br>⟦ on the file's own grid, which is not a whole texel⟧<br>⟦. Re-export the page at ⟧<br>⟦The format states coarser texels with the `scale:` header and rigc builds that: declare ⟧<br>⟦`size: ⟧<br>⟦` with `scale: ⟧<br>⟦` and multiply every ⟧<br>⟦`bounds`/`offsets` on this page by ⟧<br>⟦, and every part keeps the size it ⟧<br>⟦has now.⟧ |
|
|
12610
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_CONFLICT"></a>`CE_COMPILE_ALPHA_CLIP_CONFLICT` | Names authored fields or convex/inverse flags conflicting with generated inside-clipping geometry. Use one authored/generated form.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: alphaClip conflicts with [⟧<br>⟦vertexCount⟧<br>⟦vertices⟧<br>⟦weights⟧<br>⟦boneIndexing⟧<br>⟦, ⟧<br>⟦], convex=⟧<br>⟦, inverse=⟧<br>⟦; required ordinary inside clipping with generated geometry only. Remove authored geometry/flags.⟧ |
|
|
12611
|
+
| <a id="CE_COMPILE_ALPHA_CLIP_DEFORM"></a>`CE_COMPILE_ALPHA_CLIP_DEFORM` | Names animation and source/clip key/frame counts that cannot share the existing projection contract. Give matching explicit supported transform keys in compatible actual frames, with constant world shares where needed, or author the corresponding clip geometry and motion; weighted binding does not copy source deform.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦ alphaClip from "⟧<br>⟦"⟧<br>⟦: animation "⟧<br>⟦" has ⟧<br>⟦ source and ⟧<br>⟦ clip keys; source frame=⟧<br>⟦no vertex geometry⟧<br>⟦clip frame=⟧<br>⟦, constant world influence shares=⟧<br>⟦Required matching explicit affine/depth-yaw/pitch transforms in compatible DeformGeometry frames, ⟧<br>⟦with constant shares for world-space transfer. Fix the named frame/keys or author corresponding clip motion.⟧ |
|
|
12612
|
+
|
|
12526
12613
|
## 15. Posing in place for a player — the runtime poser (stage 1)
|
|
12527
12614
|
|
|
12528
12615
|
rigc poses a model document twice over, on purpose (issue #1591). The **oracle**
|
package/docs/FACE.md
CHANGED
|
@@ -1946,3 +1946,22 @@ a different document and a different measurement. Everything here is one head
|
|
|
1946
1946
|
turning, blinking and looking — and the one general lesson that might carry into
|
|
1947
1947
|
that work is §7's: **allocate the channels before the first key, because Spine
|
|
1948
1948
|
blends and does not add.**
|
|
1949
|
+
|
|
1950
|
+
### A measured alpha aperture
|
|
1951
|
+
|
|
1952
|
+
A named socket plate can supply an `alphaClip` clipping attachment (AUTHORING,
|
|
1953
|
+
**Alpha-derived clipping**). This also accepts a generated grid/contour source
|
|
1954
|
+
with a depth sheet: the clip is carried by the source's UV/setup bindings and
|
|
1955
|
+
sampled depths, independent of the clip slot's own bone. Give source and clip
|
|
1956
|
+
explicit matching existing depth-yaw transform keys; a weight does not inherit
|
|
1957
|
+
another attachment's deformation. Source-triangle crossings become clip vertices
|
|
1958
|
+
and count against the declared budget. Do not replace this turn with a cosine-only
|
|
1959
|
+
stand-in, fill a source hole, or infer threshold/tolerance from an appearance.
|
|
1960
|
+
|
|
1961
|
+
`tools/alpha_clip_proof.ts --out <evidence>` builds a controlled painted ellipse
|
|
1962
|
+
and measures four gazes at yaw ±15°, with separate threshold/soft-edge error and
|
|
1963
|
+
source-boundary checks beside core/SkeletonClipping pixel parity. Fixed framing
|
|
1964
|
+
prevents a shifted clip from being hidden by automatic crop changes. These are
|
|
1965
|
+
controlled CPU-render/geometry observations when actually run; they do not certify
|
|
1966
|
+
production art, editor operation or browser/GPU playback. The actual editor trip
|
|
1967
|
+
must preserve the clip's end, bindings and nonempty per-state render figures too.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.73.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": {
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/** Alpha-outline geometry and UV carriers, independent of emitters and posers. */
|
|
2
|
+
import { CompileError } from './errors.ts';
|
|
3
|
+
import {
|
|
4
|
+
earClip, findSelfIntersection, traceAlphaOutline, simplifyClosedPolygon,
|
|
5
|
+
measureAuthoredMeshFit, rasteriseTriangles, MeshError, type AlphaMask,
|
|
6
|
+
} from './mesh.ts';
|
|
7
|
+
import { uvCarriers, CONTAINS, type Carrier } from './meshcarriers.ts';
|
|
8
|
+
import { hypot2 } from './numeric.ts';
|
|
9
|
+
import type { RigAlphaClip } from './rig.ts';
|
|
10
|
+
import type { AlphaClipReport } from './types.ts';
|
|
11
|
+
|
|
12
|
+
export type ClipPoint = [number, number];
|
|
13
|
+
const cross = (a: ClipPoint, b: ClipPoint): number => a[0] * b[1] - a[1] * b[0];
|
|
14
|
+
const sub = (a: ClipPoint, b: ClipPoint): ClipPoint => [a[0] - b[0], a[1] - b[1]];
|
|
15
|
+
|
|
16
|
+
export function clipDistance(p: readonly number[], a: readonly number[], b: readonly number[]): number {
|
|
17
|
+
const dx = b[0] - a[0];
|
|
18
|
+
const dy = b[1] - a[1];
|
|
19
|
+
const length2 = dx * dx + dy * dy;
|
|
20
|
+
const projection = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (length2 || 1);
|
|
21
|
+
const t = Math.max(0, Math.min(1, projection));
|
|
22
|
+
return hypot2(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Neighbour degeneracies are not covered by the nonadjacent intersection check. */
|
|
26
|
+
export function refuseClipDegeneracy(points: ClipPoint[], where: string): void {
|
|
27
|
+
if (points.length < 3) {
|
|
28
|
+
throw new CompileError(
|
|
29
|
+
`${where}: clipping has ${points.length} vertices; required >=3 after final coordinate rounding.`,
|
|
30
|
+
'CE_COMPILE_ALPHA_CLIP_TOPOLOGY',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
let area2 = 0;
|
|
34
|
+
for (let i = 0; i < points.length; i++) {
|
|
35
|
+
const a = points[i];
|
|
36
|
+
const b = points[(i + 1) % points.length];
|
|
37
|
+
const c = points[(i + 2) % points.length];
|
|
38
|
+
if (!a.every(Number.isFinite) || (a[0] === b[0] && a[1] === b[1])) {
|
|
39
|
+
throw new CompileError(
|
|
40
|
+
`${where}: adjacent clipping vertices ${i} and ${(i + 1) % points.length} are ` +
|
|
41
|
+
`[${a}] and [${b}]; required finite distinct neighbours after final coordinate rounding. ` +
|
|
42
|
+
'Lower the source coordinate magnitude or simplify the outline.',
|
|
43
|
+
'CE_COMPILE_ALPHA_CLIP_TOPOLOGY',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const incoming = sub(b, a);
|
|
47
|
+
const outgoing = sub(c, b);
|
|
48
|
+
if (cross(incoming, outgoing) === 0 && incoming[0] * outgoing[0] + incoming[1] * outgoing[1] < 0) {
|
|
49
|
+
throw new CompileError(
|
|
50
|
+
`${where}: adjacent edges ${i} and ${(i + 1) % points.length} fold back after rounding; ` +
|
|
51
|
+
'required a nondegenerate simple clipping boundary.',
|
|
52
|
+
'CE_COMPILE_ALPHA_CLIP_TOPOLOGY',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
area2 += cross(a, b);
|
|
56
|
+
}
|
|
57
|
+
if (area2 === 0) {
|
|
58
|
+
throw new CompileError(
|
|
59
|
+
`${where}: clipping doubled area is ${area2} after coordinate rounding; required nonzero area.`,
|
|
60
|
+
'CE_COMPILE_ALPHA_CLIP_TOPOLOGY',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Check simplifier output and mapped source-triangle crossings before emission. */
|
|
66
|
+
export function refuseClipCrossing(points: ClipPoint[], where: string): void {
|
|
67
|
+
const crossing = findSelfIntersection(points);
|
|
68
|
+
if (crossing) {
|
|
69
|
+
throw new CompileError(
|
|
70
|
+
`${where}: selfintersectionaftersimplification: edges ${crossing[0]} and ${crossing[1]} ` +
|
|
71
|
+
'intersect; required one simple outline. Lower tolerance or repair the source topology.',
|
|
72
|
+
'CE_COMPILE_ALPHA_CLIP_SELF_INTERSECTION',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type OutlineReport = Omit<AlphaClipReport, 'target' | 'source' | 'vertices' | 'uvs' | 'maxBindingDeviationWorld'>;
|
|
78
|
+
|
|
79
|
+
export function alphaClipOutline(
|
|
80
|
+
mask: AlphaMask,
|
|
81
|
+
spec: RigAlphaClip,
|
|
82
|
+
where: string,
|
|
83
|
+
pageScale = 1,
|
|
84
|
+
): { points: ClipPoint[]; report: OutlineReport } {
|
|
85
|
+
if (!Number.isInteger(spec.alpha) || spec.alpha < 1 || spec.alpha > 255) {
|
|
86
|
+
throw new CompileError(
|
|
87
|
+
`${where}: alphaClip.alpha is ${String(spec.alpha)}; required an integer in 1..255. Declare the threshold.`,
|
|
88
|
+
'CE_COMPILE_ALPHA_CLIP_PARAMETERS',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isFinite(spec.tolerance) || spec.tolerance <= 0 || !Number.isFinite(pageScale) || pageScale <= 0) {
|
|
92
|
+
throw new CompileError(
|
|
93
|
+
`${where}: alphaClip.tolerance=${String(spec.tolerance)}, page scale=${pageScale}; ` +
|
|
94
|
+
'required finite positive drawing-pixel tolerance and page scale.',
|
|
95
|
+
'CE_COMPILE_ALPHA_CLIP_PARAMETERS',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!Number.isInteger(spec.maxVertices) || spec.maxVertices < 3) {
|
|
99
|
+
throw new CompileError(
|
|
100
|
+
`${where}: alphaClip.maxVertices is ${String(spec.maxVertices)}; required an integer >=3. ` +
|
|
101
|
+
'Declare the complete mapped polygon budget.',
|
|
102
|
+
'CE_COMPILE_ALPHA_CLIP_PARAMETERS',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
let below = 0;
|
|
106
|
+
let above = 0;
|
|
107
|
+
for (const alpha of mask.alpha) {
|
|
108
|
+
if (alpha < spec.alpha) below++;
|
|
109
|
+
else above++;
|
|
110
|
+
}
|
|
111
|
+
if (above === 0 || below === 0) {
|
|
112
|
+
throw new CompileError(
|
|
113
|
+
`${where}: slotwithnoalpha: ${above} of ${mask.width * mask.height} pixels reach alpha ${spec.alpha}; ` +
|
|
114
|
+
'required a nonempty silhouette with transparent exterior. Supply alpha art or declare a threshold that measures a silhouette.',
|
|
115
|
+
'CE_COMPILE_ALPHA_CLIP_NO_ALPHA',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
let traced: ReturnType<typeof traceAlphaOutline>;
|
|
119
|
+
try {
|
|
120
|
+
traced = traceAlphaOutline(mask, spec.alpha);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!(error instanceof MeshError)) throw error;
|
|
123
|
+
throw new CompileError(
|
|
124
|
+
`${where}: alpha outline cannot be represented: ${error.message}; required one simple connected outline.`,
|
|
125
|
+
'CE_COMPILE_ALPHA_CLIP_TOPOLOGY',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (traced.islands !== 1 || traced.holePixels !== 0) {
|
|
129
|
+
throw new CompileError(
|
|
130
|
+
`${where}: alpha ${spec.alpha} has ${traced.islands} islands and ${traced.holePixels} hole pixels; ` +
|
|
131
|
+
'required one island without holes. Separate the source or author clipping geometry.',
|
|
132
|
+
'CE_COMPILE_ALPHA_CLIP_TOPOLOGY',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const points = simplifyClosedPolygon(traced.outline, spec.tolerance * pageScale);
|
|
136
|
+
refuseClipCrossing(points, where);
|
|
137
|
+
refuseClipDegeneracy(points, where);
|
|
138
|
+
const triangles = clipTrianglesOf(points);
|
|
139
|
+
const fit = measureAuthoredMeshFit(mask, spec.alpha, points, triangles);
|
|
140
|
+
let maxTraceDeviation = 0;
|
|
141
|
+
for (const point of traced.outline) {
|
|
142
|
+
const distance = Math.min(...points.map((a, i) => clipDistance(point, a, points[(i + 1) % points.length])));
|
|
143
|
+
maxTraceDeviation = Math.max(maxTraceDeviation, distance);
|
|
144
|
+
}
|
|
145
|
+
const filled = rasteriseTriangles(points, triangles, mask.width, mask.height).reduce((sum, bit) => sum + bit, 0);
|
|
146
|
+
return {
|
|
147
|
+
points,
|
|
148
|
+
report: {
|
|
149
|
+
alpha: spec.alpha,
|
|
150
|
+
tolerance: spec.tolerance,
|
|
151
|
+
maxVertices: spec.maxVertices,
|
|
152
|
+
tracedVertices: traced.outline.length,
|
|
153
|
+
simplifiedVertices: points.length,
|
|
154
|
+
artPixels: fit.artPixels,
|
|
155
|
+
coveredArt: fit.coveredArt,
|
|
156
|
+
coverage: fit.coverage,
|
|
157
|
+
falsePositive: filled - fit.coveredArt,
|
|
158
|
+
falseNegative: fit.artPixels - fit.coveredArt,
|
|
159
|
+
maxTraceDeviation: maxTraceDeviation / pageScale,
|
|
160
|
+
overshoot: fit.overshoot / pageScale,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function clipTrianglesOf(points: ClipPoint[]): number[] {
|
|
166
|
+
return earClip(points);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Offline cell/reference reading, not a runtime alpha-mask feature. */
|
|
170
|
+
export function clipContains(points: readonly (readonly number[])[], x: number, y: number): boolean {
|
|
171
|
+
let inside = false;
|
|
172
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
173
|
+
const a = points[i];
|
|
174
|
+
const b = points[j];
|
|
175
|
+
if ((a[1] > y) !== (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
|
|
176
|
+
}
|
|
177
|
+
return inside;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function carriersOf(
|
|
181
|
+
points: ClipPoint[], uvs: readonly number[], triangles: readonly number[], where: string,
|
|
182
|
+
): Array<Carrier | null> {
|
|
183
|
+
try {
|
|
184
|
+
return uvCarriers(uvs, triangles, points.map((uv) => ({ uv })), where);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new CompileError(
|
|
187
|
+
`${where}: source UV carrier is ambiguous: ${error instanceof Error ? error.message : String(error)}; ` +
|
|
188
|
+
'required unique source geometry. Repair overlapping UV triangles.',
|
|
189
|
+
'CE_COMPILE_ALPHA_CLIP_CARRIER',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Split BEFORE mapping: an edge across source triangles is piecewise affine, not one chord. */
|
|
195
|
+
export function clipCarriers(
|
|
196
|
+
ring: ClipPoint[], uvs: readonly number[], triangles: readonly number[], where: string,
|
|
197
|
+
): { points: ClipPoint[]; carriers: Carrier[] } {
|
|
198
|
+
const edges = new Map<string, [number, number]>();
|
|
199
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
200
|
+
const triplet = triangles.slice(i, i + 3);
|
|
201
|
+
for (const [a, b] of [[triplet[0], triplet[1]], [triplet[1], triplet[2]], [triplet[2], triplet[0]]]) {
|
|
202
|
+
edges.set(`${Math.min(a, b)}/${Math.max(a, b)}`, [a, b]);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const points: ClipPoint[] = [];
|
|
206
|
+
for (let i = 0; i < ring.length; i++) {
|
|
207
|
+
const a = ring[i];
|
|
208
|
+
const b = ring[(i + 1) % ring.length];
|
|
209
|
+
const direction = sub(b, a);
|
|
210
|
+
const cuts = [0];
|
|
211
|
+
for (const [v, w] of edges.values()) {
|
|
212
|
+
const c: ClipPoint = [uvs[2 * v], uvs[2 * v + 1]];
|
|
213
|
+
const e: ClipPoint = [uvs[2 * w], uvs[2 * w + 1]];
|
|
214
|
+
const edge = sub(e, c);
|
|
215
|
+
const denominator = cross(direction, edge);
|
|
216
|
+
if (denominator === 0) continue;
|
|
217
|
+
const t = cross(sub(c, a), edge) / denominator;
|
|
218
|
+
const u = cross(sub(c, a), direction) / denominator;
|
|
219
|
+
if (t > CONTAINS && t < 1 - CONTAINS && u >= -CONTAINS && u <= 1 + CONTAINS) cuts.push(t);
|
|
220
|
+
}
|
|
221
|
+
cuts.sort((x, y) => x - y);
|
|
222
|
+
for (let k = 0; k < cuts.length; k++) {
|
|
223
|
+
if (k === 0 || cuts[k] - cuts[k - 1] > CONTAINS) {
|
|
224
|
+
points.push([a[0] + cuts[k] * direction[0], a[1] + cuts[k] * direction[1]]);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const carriers = carriersOf(points, uvs, triangles, where);
|
|
229
|
+
const missing = carriers.findIndex((carrier) => carrier === null);
|
|
230
|
+
if (missing >= 0) {
|
|
231
|
+
throw new CompileError(
|
|
232
|
+
`${where}: source has no UV carrier for outline vertex ${missing} at [${points[missing]}]; ` +
|
|
233
|
+
'required coverage of the complete clipping outline. Extend the source geometry or lower tolerance.',
|
|
234
|
+
'CE_COMPILE_ALPHA_CLIP_CARRIER',
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
// Endpoints can all be carried while an edge bridges a missing strip. Since all
|
|
238
|
+
// triangle-edge crossings partition the ring, a unique open midpoint identifies
|
|
239
|
+
// the actual affine source map throughout that entire open segment.
|
|
240
|
+
const midpoints = points.map((a, i): ClipPoint => {
|
|
241
|
+
const b = points[(i + 1) % points.length];
|
|
242
|
+
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
243
|
+
});
|
|
244
|
+
const intervals = carriersOf(midpoints, uvs, triangles, `${where} open outline intervals`);
|
|
245
|
+
const gap = intervals.findIndex((carrier) => carrier === null);
|
|
246
|
+
if (gap >= 0) {
|
|
247
|
+
throw new CompileError(
|
|
248
|
+
`${where}: outline interval ${gap} from [${points[gap]}] to [${points[(gap + 1) % points.length]}] ` +
|
|
249
|
+
`has no source UV carrier at open midpoint [${midpoints[gap]}]; required complete interval coverage. ` +
|
|
250
|
+
'Fill the source UV gap; carried endpoints cannot authorize a bridge.',
|
|
251
|
+
'CE_COMPILE_ALPHA_CLIP_CARRIER',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return { points, carriers: carriers as Carrier[] };
|
|
255
|
+
}
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { attachmentRegionJoins } from '../region_joins.ts';
|
|
|
50
50
|
import { cmdRepack } from './repack.ts';
|
|
51
51
|
import { writeWhole } from './write_whole.ts';
|
|
52
52
|
import { GUIDE_FILE, GUIDE_RULE_TABLES_ELSEWHERE, guideAnswer, refusalLine } from '../guide.ts';
|
|
53
|
-
import { ATLAS_ABSENT, COMMANDS, type CommandRun, DelegateError, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, GuideError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPageEdges, readPoserFlag, readSheetFormat, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
|
|
53
|
+
import { ATLAS_ABSENT, COMMANDS, type CommandRun, DelegateError, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, GuideError, printAlphaClips, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPageEdges, readPoserFlag, readSheetFormat, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
|
|
54
54
|
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
55
55
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
56
56
|
import { spawnSync } from 'node:child_process';
|
|
@@ -1886,6 +1886,7 @@ export function cmdExplain(flags: Record<string, string>): void {
|
|
|
1886
1886
|
}
|
|
1887
1887
|
}
|
|
1888
1888
|
|
|
1889
|
+
printAlphaClips(result);
|
|
1889
1890
|
if (result.meshes.length) {
|
|
1890
1891
|
console.log('\nmeshes');
|
|
1891
1892
|
for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
|
package/src/cli/shared.ts
CHANGED
|
@@ -2400,6 +2400,18 @@ export function readBuildInvocation(flags: Record<string, string>): { label: str
|
|
|
2400
2400
|
return { label, opts, profile, packing };
|
|
2401
2401
|
}
|
|
2402
2402
|
|
|
2403
|
+
/** Six decimal places: display error <=0.5e-6 per scalar; cells/counts remain integers. */
|
|
2404
|
+
export function printAlphaClips(result: Pick<CompileResult, 'alphaClips'>): void {
|
|
2405
|
+
for (const clip of result.alphaClips ?? []) console.log(
|
|
2406
|
+
` CLIP ${clip.target} from=${clip.source} alpha=${clip.alpha} tolerance=${clip.tolerance} drawing-px ` +
|
|
2407
|
+
`vertices=${clip.vertices}/${clip.maxVertices} traced=${clip.tracedVertices} simplified=${clip.simplifiedVertices} ` +
|
|
2408
|
+
`coverage=${clip.coverage.toFixed(6)} (${clip.coveredArt}/${clip.artPixels}) ` +
|
|
2409
|
+
`falsePositive=${clip.falsePositive} falseNegative=${clip.falseNegative} ` +
|
|
2410
|
+
`maxTraceDeviation=${clip.maxTraceDeviation.toFixed(6)}drawing-px ` +
|
|
2411
|
+
`maxBindingDeviation=${clip.maxBindingDeviationWorld.toFixed(6)}world-units overshoot=${clip.overshoot.toFixed(6)}drawing-px`,
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2403
2415
|
/** The lines a build opens on: the cut, then its two input files. */
|
|
2404
2416
|
export function printBuildHeader(label: string, opts: CompileOptions): void {
|
|
2405
2417
|
console.log(`rigc build ${label}`);
|
|
@@ -2412,7 +2424,7 @@ export function printBuildHeader(label: string, opts: CompileOptions): void {
|
|
|
2412
2424
|
}
|
|
2413
2425
|
|
|
2414
2426
|
/** What a compile measured, as `build` reports it before its gate: the parts, the dropped states, the absent parts, the meshes, the physics. */
|
|
2415
|
-
export function printCompiled(opts: CompileOptions, result: Pick<CompileResult, 'images' | 'droppedStates' | 'absentParts' | 'meshes' | 'rig' | 'physics'>): void {
|
|
2427
|
+
export function printCompiled(opts: CompileOptions, result: Pick<CompileResult, 'images' | 'droppedStates' | 'absentParts' | 'meshes' | 'rig' | 'physics' | 'alphaClips'>): void {
|
|
2416
2428
|
if (opts.atlasInPath !== undefined) console.log(` .. atlas-in ${opts.atlasInPath}`);
|
|
2417
2429
|
console.log(` .. ${result.images.length} part page(s):`);
|
|
2418
2430
|
for (const img of result.images) {
|
|
@@ -2447,6 +2459,7 @@ export function printCompiled(opts: CompileOptions, result: Pick<CompileResult,
|
|
|
2447
2459
|
for (const a of result.absentParts) {
|
|
2448
2460
|
console.log(` ABSENT ${a.slot}: ${a.why} — slot not emitted`);
|
|
2449
2461
|
}
|
|
2462
|
+
printAlphaClips(result);
|
|
2450
2463
|
for (const m of result.meshes) {
|
|
2451
2464
|
console.log(
|
|
2452
2465
|
` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
|
package/src/compile.ts
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
type DepthTone,
|
|
35
35
|
} from './depth.ts';
|
|
36
36
|
import { CompileError, NotImplementedError } from './errors.ts';
|
|
37
|
+
import { alphaClipOutline, clipCarriers, refuseClipCrossing, refuseClipDegeneracy } from './alpha_clip.ts';
|
|
38
|
+
import { regionCorners } from './core/vertices.ts';
|
|
37
39
|
import { parseJsonWithPosition } from './json-position.ts';
|
|
38
40
|
import { hypot2 } from './numeric.ts';
|
|
39
41
|
// The "did you mean" list on a missing atlas region, from the one implementation
|
|
@@ -60,6 +62,7 @@ import {
|
|
|
60
62
|
type RigBone,
|
|
61
63
|
type RigBoundingBoxAttachment,
|
|
62
64
|
type RigClippingAttachment,
|
|
65
|
+
type RigAlphaClip,
|
|
63
66
|
type RigEvent,
|
|
64
67
|
type RigLinkedMeshAttachment,
|
|
65
68
|
type RigMeshAttachment,
|
|
@@ -2296,7 +2299,7 @@ function manifestShapeVisits(manifest: unknown): ShapeVisit[] {
|
|
|
2296
2299
|
*/
|
|
2297
2300
|
export function compile(opts: CompileOptions): CompileResult {
|
|
2298
2301
|
return withDroppedStates((droppedStates) => {
|
|
2299
|
-
const { model, header, atlasText, declaredDurations } = compileInto(opts, droppedStates);
|
|
2302
|
+
const { model, header, atlasText, declaredDurations, alphaClips } = compileInto(opts, droppedStates);
|
|
2300
2303
|
// The skeleton: the Spine emitter's one entry (issue #922), which writes
|
|
2301
2304
|
// every section from the model, in the order and under the spellings the
|
|
2302
2305
|
// file has, and runs the parser-default and key-order passes on the
|
|
@@ -2314,6 +2317,7 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
2314
2317
|
atlasText,
|
|
2315
2318
|
declaredDurations,
|
|
2316
2319
|
...carriedOf(model),
|
|
2320
|
+
...(alphaClips?.length ? { alphaClips } : {}),
|
|
2317
2321
|
model,
|
|
2318
2322
|
};
|
|
2319
2323
|
});
|
|
@@ -2391,6 +2395,7 @@ export function headerBoundsOf(model: CompiledModel, atlasText: string): { box:
|
|
|
2391
2395
|
* beside it to gate it, write its document and — if it asks — emit it.
|
|
2392
2396
|
*/
|
|
2393
2397
|
export interface CompiledBuild {
|
|
2398
|
+
alphaClips?: CompileResult['alphaClips'];
|
|
2394
2399
|
/** The compiled model (`src/model.ts`), `stage` and `editorOrder` included (issue #1026). */
|
|
2395
2400
|
model: CompiledModel;
|
|
2396
2401
|
/** The rest of the Spine header the model does not hold (`SkeletonHeader`): what `compile` hands the emitter beside the model. */
|
|
@@ -3012,6 +3017,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
3012
3017
|
// `skin/slot/attachment` -> per-vertex z, for the attachments that named a
|
|
3013
3018
|
// depth map. Read at deform-key time; never emitted. See `AttachmentContext`.
|
|
3014
3019
|
const attachmentDepths = new Map<string, number[] | { z: number[]; geometry: DeformGeometry }>();
|
|
3020
|
+
const pendingAlphaClips: PendingAlphaClip[] = [];
|
|
3015
3021
|
// The same for a `soft` mask's per-vertex level, which a deform envelope's
|
|
3016
3022
|
// `mask` reads (issue #1413). Never emitted either.
|
|
3017
3023
|
const attachmentSoftMasks = new Map<string, DeformSoftMask>();
|
|
@@ -3212,6 +3218,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
3212
3218
|
const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
|
|
3213
3219
|
const built = buildRigAttachment(att, placeholder, where, {
|
|
3214
3220
|
images,
|
|
3221
|
+
pendingAlphaClips,
|
|
3215
3222
|
bones,
|
|
3216
3223
|
transforms,
|
|
3217
3224
|
exactFrames,
|
|
@@ -3241,6 +3248,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
3241
3248
|
// line a linked mesh is the one construct in a rig spec whose name has not been
|
|
3242
3249
|
// resolved yet.
|
|
3243
3250
|
resolveLinkedMeshes(pendingLinks, skinTables);
|
|
3251
|
+
const alphaClips = resolveAlphaClips(pendingAlphaClips, skinTables, rig, motion);
|
|
3244
3252
|
// 📐 The implicit budget of 0 is a statement about rigc's own GENERATORS:
|
|
3245
3253
|
// geometry rigc built is geometry rigc will not ship unmeasured, and
|
|
3246
3254
|
// `A13_MESH_BUDGET` has nothing to measure a generated mesh against until the
|
|
@@ -3920,6 +3928,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
3920
3928
|
header,
|
|
3921
3929
|
atlasText,
|
|
3922
3930
|
declaredDurations,
|
|
3931
|
+
...(alphaClips.length ? { alphaClips } : {}),
|
|
3923
3932
|
};
|
|
3924
3933
|
}
|
|
3925
3934
|
|
|
@@ -4342,6 +4351,7 @@ function refuseDefaultSkinContests(skinNames: readonly string[], skinParts: Map<
|
|
|
4342
4351
|
// ---------------------------------------------------------------------------
|
|
4343
4352
|
|
|
4344
4353
|
interface AttachmentContext {
|
|
4354
|
+
pendingAlphaClips: PendingAlphaClip[];
|
|
4345
4355
|
images: CompiledImage[];
|
|
4346
4356
|
bones: ModelBone[];
|
|
4347
4357
|
transforms: Map<string, BoneTransform>;
|
|
@@ -4933,6 +4943,273 @@ function buildRigBoundingBox(
|
|
|
4933
4943
|
return out;
|
|
4934
4944
|
}
|
|
4935
4945
|
|
|
4946
|
+
interface PendingAlphaClip {
|
|
4947
|
+
spec: RigAlphaClip;
|
|
4948
|
+
out: ModelClippingAttachment;
|
|
4949
|
+
where: string;
|
|
4950
|
+
ctx: AttachmentContext;
|
|
4951
|
+
}
|
|
4952
|
+
|
|
4953
|
+
/** Resolve after all sources/links exist: draw order cannot decide whether a source can be measured. */
|
|
4954
|
+
function resolveAlphaClips(
|
|
4955
|
+
pending: PendingAlphaClip[],
|
|
4956
|
+
tables: Map<string, SkinTable>,
|
|
4957
|
+
rig: RigSpec,
|
|
4958
|
+
motion: MotionSpec,
|
|
4959
|
+
): NonNullable<CompileResult['alphaClips']> {
|
|
4960
|
+
const reports: NonNullable<CompileResult['alphaClips']> = [];
|
|
4961
|
+
for (const { spec, out, where, ctx } of pending) {
|
|
4962
|
+
const targetTable = tables.get(ctx.skinName)?.[ctx.slotName];
|
|
4963
|
+
const target = Object.entries(targetTable ?? {}).find(([, value]) => value === out)?.[0];
|
|
4964
|
+
// withStatedName can return a fresh object when name was declared.
|
|
4965
|
+
const targetKey = target ?? Object.entries(targetTable ?? {}).find(([, value]) => value.kind === 'clipping' && value.vertices === out.vertices)?.[0];
|
|
4966
|
+
if (targetKey === undefined) {
|
|
4967
|
+
throw new CompileError(
|
|
4968
|
+
`${where}: generated clipping target was not filed in its skin; required a resolved attachment.`,
|
|
4969
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
4970
|
+
);
|
|
4971
|
+
}
|
|
4972
|
+
for (const key of ['slot', 'skin', 'attachment'] as const) {
|
|
4973
|
+
if (typeof spec[key] !== 'string' || spec[key].length === 0) {
|
|
4974
|
+
throw new CompileError(
|
|
4975
|
+
`${where}: alphaClip.${key} is ${JSON.stringify(spec[key])}; required an explicit nonempty source name.`,
|
|
4976
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
4977
|
+
);
|
|
4978
|
+
}
|
|
4979
|
+
}
|
|
4980
|
+
const sourceWhere = `${spec.skin}/${spec.slot}/${spec.attachment}`;
|
|
4981
|
+
const label = `${where} alphaClip from "${sourceWhere}"`;
|
|
4982
|
+
const source = tables.get(spec.skin)?.[spec.slot]?.[spec.attachment];
|
|
4983
|
+
const sourceSlot = rig.slots.find((slot) => slot.name === spec.slot);
|
|
4984
|
+
if (!source || !sourceSlot) {
|
|
4985
|
+
throw new CompileError(
|
|
4986
|
+
`${label}: source attachment is absent; required a declared slot/skin/attachment.`,
|
|
4987
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
4988
|
+
);
|
|
4989
|
+
}
|
|
4990
|
+
if (source.kind !== 'region' && source.kind !== 'mesh' && source.kind !== 'linkedmesh') {
|
|
4991
|
+
throw new CompileError(
|
|
4992
|
+
`${label}: source kind is ${source.kind}; required a region or resolved mesh with pixels.`,
|
|
4993
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
4994
|
+
);
|
|
4995
|
+
}
|
|
4996
|
+
let geometry: SkinTableEntry = source;
|
|
4997
|
+
let depthKey = sourceWhere;
|
|
4998
|
+
if (geometry.kind === 'linkedmesh') {
|
|
4999
|
+
depthKey = `${geometry.skin}/${geometry.slot}/${geometry.source}`;
|
|
5000
|
+
const base = tables.get(geometry.skin)?.[geometry.slot]?.[geometry.source];
|
|
5001
|
+
if (!base || base.kind !== 'mesh') {
|
|
5002
|
+
throw new CompileError(
|
|
5003
|
+
`${label}: linked source resolves to ${base?.kind ?? 'nothing'}; required a resolved mesh.`,
|
|
5004
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
5005
|
+
);
|
|
5006
|
+
}
|
|
5007
|
+
geometry = base;
|
|
5008
|
+
}
|
|
5009
|
+
if (geometry.kind !== 'region' && geometry.kind !== 'mesh') {
|
|
5010
|
+
throw new CompileError(
|
|
5011
|
+
`${label}: source is ${(geometry as SkinTableEntry).kind}; required region or mesh geometry with a plate (a clipping source would be a dependency cycle).`,
|
|
5012
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
5013
|
+
);
|
|
5014
|
+
}
|
|
5015
|
+
if (source.sequence !== undefined) {
|
|
5016
|
+
throw new CompileError(
|
|
5017
|
+
`${label}: source has a ${source.sequence.count}-frame sequence; required one immutable source plate. Select a separate named attachment for the measured frame.`,
|
|
5018
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
5019
|
+
);
|
|
5020
|
+
}
|
|
5021
|
+
const region = source.path ?? source.name ?? spec.attachment;
|
|
5022
|
+
const img = ctx.images.find((image) => image.region === region);
|
|
5023
|
+
if (!img) {
|
|
5024
|
+
throw new CompileError(
|
|
5025
|
+
`${label}: region "${region}" has no source plate; required image/atlas pixels.`,
|
|
5026
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
5027
|
+
);
|
|
5028
|
+
}
|
|
5029
|
+
if (img.pageGrid !== undefined) {
|
|
5030
|
+
throw new CompileError(
|
|
5031
|
+
`${label}: source plate grid cannot be located. ${img.pageGrid.sentence}`,
|
|
5032
|
+
'CE_COMPILE_ALPHA_CLIP_SOURCE',
|
|
5033
|
+
);
|
|
5034
|
+
}
|
|
5035
|
+
const plate = partPlate(img);
|
|
5036
|
+
const mask = { width: plate.width, height: plate.height, alpha: plateAlpha(plate) };
|
|
5037
|
+
const traced = alphaClipOutline(mask, spec, label, img.atlasScale ?? 1);
|
|
5038
|
+
const ring = traced.points.map(([x, y]): [number, number] => [x / plate.width, y / plate.height]);
|
|
5039
|
+
const anchor = ctx.transforms.get(sourceSlot.bone)!;
|
|
5040
|
+
let sourceBindings: ModelBinding[][];
|
|
5041
|
+
let uvs: number[];
|
|
5042
|
+
let triangles: number[];
|
|
5043
|
+
if (geometry.kind === 'region') {
|
|
5044
|
+
// Full restored plate, not trimmed page rectangle: source crop pixels remain the coordinates.
|
|
5045
|
+
const corners = regionCorners({ ...geometry, atlas: { width: img.width, height: img.height, originalWidth: img.width, originalHeight: img.height, offsetX: 0, offsetY: 0 } }, anchor);
|
|
5046
|
+
uvs = [0, 1, 0, 0, 1, 0, 1, 1];
|
|
5047
|
+
triangles = [0, 1, 2, 2, 3, 0];
|
|
5048
|
+
sourceBindings = Array.from({ length: 4 }, (_, i) => {
|
|
5049
|
+
const [x, y] = toBoneLocal(anchor, corners[2 * i], corners[2 * i + 1]);
|
|
5050
|
+
return [{ bone: sourceSlot.bone, x, y, weight: 1 }];
|
|
5051
|
+
});
|
|
5052
|
+
} else {
|
|
5053
|
+
uvs = geometry.uvs;
|
|
5054
|
+
triangles = geometry.triangles;
|
|
5055
|
+
const vertices = geometry.vertices;
|
|
5056
|
+
sourceBindings = vertices.weighted ? vertices.bindings :
|
|
5057
|
+
Array.from({ length: vertices.xy.length / 2 }, (_, i) => [{
|
|
5058
|
+
bone: sourceSlot.bone, x: vertices.xy[2 * i], y: vertices.xy[2 * i + 1], weight: 1,
|
|
5059
|
+
}]);
|
|
5060
|
+
}
|
|
5061
|
+
const carried = clipCarriers(ring, uvs, triangles, label);
|
|
5062
|
+
if (carried.points.length > spec.maxVertices) {
|
|
5063
|
+
throw new CompileError(
|
|
5064
|
+
`${label}: polygonoverdeclaredvertexbudget: ${carried.points.length} vertices after source carrier crossings (${traced.points.length} simplified), required <=${spec.maxVertices}. Raise the declared budget or simplify the source/outline.`,
|
|
5065
|
+
'CE_COMPILE_ALPHA_CLIP_VERTEX_BUDGET',
|
|
5066
|
+
);
|
|
5067
|
+
}
|
|
5068
|
+
// Per-bone first moments preserve Σβ Σw M(bind) for every bone matrix; no solver or new pose model.
|
|
5069
|
+
const bindings: ModelBinding[][] = carried.carriers.map((carrier) => {
|
|
5070
|
+
const moments = new Map<string, { weight: number; x: number; y: number }>();
|
|
5071
|
+
carrier.corners.forEach((index, corner) => {
|
|
5072
|
+
for (const binding of sourceBindings[index]) {
|
|
5073
|
+
const share = carrier.bary[corner] * binding.weight;
|
|
5074
|
+
const row = moments.get(binding.bone) ?? { weight: 0, x: 0, y: 0 };
|
|
5075
|
+
row.weight += share;
|
|
5076
|
+
row.x += share * binding.x;
|
|
5077
|
+
row.y += share * binding.y;
|
|
5078
|
+
moments.set(binding.bone, row);
|
|
5079
|
+
}
|
|
5080
|
+
});
|
|
5081
|
+
const result: ModelBinding[] = [];
|
|
5082
|
+
for (const [bone, row] of moments) {
|
|
5083
|
+
if (row.weight > 0) {
|
|
5084
|
+
result.push({ bone, weight: f32(row.weight), x: f32(row.x / row.weight), y: f32(row.y / row.weight) });
|
|
5085
|
+
}
|
|
5086
|
+
}
|
|
5087
|
+
return result;
|
|
5088
|
+
});
|
|
5089
|
+
out.vertices = { weighted: true, bindings };
|
|
5090
|
+
out.vertexCount = bindings.length;
|
|
5091
|
+
const mappedWorld = setupWorldVertices(out.vertices, anchor, ctx.transforms, label)
|
|
5092
|
+
.map(([x, y]): [number, number] => [Math.fround(x), Math.fround(y)]);
|
|
5093
|
+
refuseClipDegeneracy(mappedWorld, `${label} final float32 world coordinates`);
|
|
5094
|
+
refuseClipCrossing(mappedWorld, label);
|
|
5095
|
+
const sourceWorld = sourceBindings.map((vertex): [number, number] => {
|
|
5096
|
+
let x = 0;
|
|
5097
|
+
let y = 0;
|
|
5098
|
+
for (const binding of vertex) {
|
|
5099
|
+
const [wx, wy] = toWorld(ctx.transforms.get(binding.bone)!, binding.x, binding.y);
|
|
5100
|
+
x += binding.weight * wx;
|
|
5101
|
+
y += binding.weight * wy;
|
|
5102
|
+
}
|
|
5103
|
+
return [x, y];
|
|
5104
|
+
});
|
|
5105
|
+
let maxBindingDeviationWorld = 0;
|
|
5106
|
+
carried.carriers.forEach((carrier, index) => {
|
|
5107
|
+
let x = 0;
|
|
5108
|
+
let y = 0;
|
|
5109
|
+
carrier.corners.forEach((corner, k) => {
|
|
5110
|
+
x += carrier.bary[k] * sourceWorld[corner][0];
|
|
5111
|
+
y += carrier.bary[k] * sourceWorld[corner][1];
|
|
5112
|
+
});
|
|
5113
|
+
maxBindingDeviationWorld = Math.max(
|
|
5114
|
+
maxBindingDeviationWorld,
|
|
5115
|
+
hypot2(mappedWorld[index][0] - x, mappedWorld[index][1] - y),
|
|
5116
|
+
);
|
|
5117
|
+
});
|
|
5118
|
+
const filed = targetTable![targetKey];
|
|
5119
|
+
if (filed.kind === 'clipping') {
|
|
5120
|
+
filed.vertices = out.vertices;
|
|
5121
|
+
filed.vertexCount = out.vertexCount;
|
|
5122
|
+
}
|
|
5123
|
+
// Authored depth turns use setup-world geometry, even for a rigid source.
|
|
5124
|
+
// Preserve that frame on the clip's own vertex/influence roster, not the source's.
|
|
5125
|
+
const sourceDepth = ctx.depths.get(depthKey);
|
|
5126
|
+
const z = sourceDepth === undefined || Array.isArray(sourceDepth) ? sourceDepth : sourceDepth.z;
|
|
5127
|
+
let clipDepthGeometry: DeformGeometry | undefined;
|
|
5128
|
+
if (z !== undefined) {
|
|
5129
|
+
const depths = carried.carriers.map((carrier) =>
|
|
5130
|
+
carrier.corners.reduce((sum, vertex, i) => sum + carrier.bary[i] * z[vertex], 0));
|
|
5131
|
+
if (sourceDepth !== undefined && !Array.isArray(sourceDepth)) {
|
|
5132
|
+
clipDepthGeometry = {
|
|
5133
|
+
...deformGeometryOf(out, label, ctx.transforms, ctx.transforms.get(ctx.anchorBone)!),
|
|
5134
|
+
depth: depths,
|
|
5135
|
+
};
|
|
5136
|
+
}
|
|
5137
|
+
ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${targetKey}`,
|
|
5138
|
+
clipDepthGeometry === undefined ? depths : { z: depths, geometry: clipDepthGeometry });
|
|
5139
|
+
}
|
|
5140
|
+
// The helper, not the number of distinct bone names, defines the frame.
|
|
5141
|
+
// Duplicate influences on one bone also take the world-space path.
|
|
5142
|
+
const frameOf = (attachment: ModelVertexAttachment, unweightedBone: string, depthGeometry: DeformGeometry | undefined,
|
|
5143
|
+
key: MotionDeformTrack['keys'][number] | undefined): {
|
|
5144
|
+
name: string; geometry: DeformGeometry;
|
|
5145
|
+
} => {
|
|
5146
|
+
// Match compileDeformTrack's per-key choice, including authored depth:true.
|
|
5147
|
+
const geometry = (key?.transform !== undefined && 'depth' in key.transform && key.transform.depth === true
|
|
5148
|
+
? depthGeometry : undefined) ?? deformGeometryOf(attachment, label, ctx.transforms);
|
|
5149
|
+
const vertices = attachment.vertices;
|
|
5150
|
+
const bone = vertices.weighted ? vertices.bindings[0]?.[0]?.bone : unweightedBone;
|
|
5151
|
+
return { name: geometry.influenceBones === null ? `bone:${bone}` : 'world', geometry };
|
|
5152
|
+
};
|
|
5153
|
+
for (const [animationName, animation] of Object.entries(motion.animations ?? {})) {
|
|
5154
|
+
const sourceIdentity = source.kind === 'linkedmesh' && source.timelines ? depthKey : sourceWhere;
|
|
5155
|
+
const targetIdentity = `${ctx.skinName}/${ctx.slotName}/${targetKey}`;
|
|
5156
|
+
const sourceTrack = animation.deform?.find((track) =>
|
|
5157
|
+
`${track.skin ?? 'default'}/${track.slot}/${track.attachment}` === sourceIdentity);
|
|
5158
|
+
const clipTrack = animation.deform?.find((track) =>
|
|
5159
|
+
`${track.skin ?? 'default'}/${track.slot}/${track.attachment}` === targetIdentity);
|
|
5160
|
+
if (!sourceTrack && !clipTrack) continue;
|
|
5161
|
+
const sourceDepthGeometry = sourceDepth !== undefined && !Array.isArray(sourceDepth) ? sourceDepth.geometry : undefined;
|
|
5162
|
+
const frames = (sourceTrack?.keys.length ? sourceTrack.keys : [undefined]).map((key, index) => ({
|
|
5163
|
+
source: geometry.kind === 'mesh' ? frameOf(geometry, sourceSlot.bone, sourceDepthGeometry, key) : null,
|
|
5164
|
+
clip: frameOf(out, ctx.anchorBone, clipDepthGeometry, clipTrack?.keys[index]),
|
|
5165
|
+
}));
|
|
5166
|
+
const compatible = frames.every(({ source, clip }) => source !== null && source.geometry.setup !== null &&
|
|
5167
|
+
clip.geometry.setup !== null && source.name === clip.name);
|
|
5168
|
+
const { source: sourceFrame, clip: clipFrame } = frames.find(({ source, clip }) =>
|
|
5169
|
+
source === null || source.geometry.setup === null || clip.geometry.setup === null || source.name !== clip.name) ?? frames[0];
|
|
5170
|
+
// With a world-space deform, varying per-vertex influence shares would
|
|
5171
|
+
// correlate the deform with moving bone matrices. Constant shares commute
|
|
5172
|
+
// with the affine/depth projection; no new deformation transfer is implied.
|
|
5173
|
+
const sharesOf = (vertex: ModelBinding[]): string => {
|
|
5174
|
+
const shares = new Map<string, number>();
|
|
5175
|
+
for (const binding of vertex) {
|
|
5176
|
+
shares.set(binding.bone, (shares.get(binding.bone) ?? 0) + binding.weight);
|
|
5177
|
+
}
|
|
5178
|
+
return JSON.stringify([...shares].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
5179
|
+
};
|
|
5180
|
+
const constantWorldShares = !frames.some(({ source }) => source?.name === 'world') ||
|
|
5181
|
+
sourceBindings.every((vertex) => sharesOf(vertex) === sharesOf(sourceBindings[0]));
|
|
5182
|
+
const shared = sourceTrack !== undefined && clipTrack !== undefined &&
|
|
5183
|
+
JSON.stringify(sourceTrack.keys) === JSON.stringify(clipTrack.keys);
|
|
5184
|
+
const linear = sourceTrack?.keys.every((key) => {
|
|
5185
|
+
const transform = key.transform;
|
|
5186
|
+
return transform !== undefined && transform.envelope === undefined &&
|
|
5187
|
+
(transform.kind === 'affine' || ((transform.kind === 'yaw' || transform.kind === 'pitch') &&
|
|
5188
|
+
transform.depth === true && z !== undefined));
|
|
5189
|
+
});
|
|
5190
|
+
if (!shared || !linear || !compatible || !constantWorldShares) {
|
|
5191
|
+
throw new CompileError(
|
|
5192
|
+
`${label}: animation "${animationName}" has ${sourceTrack?.keys.length ?? 0} source and ` +
|
|
5193
|
+
`${clipTrack?.keys.length ?? 0} clip keys; source frame=${sourceFrame?.name ?? 'no vertex geometry'}, ` +
|
|
5194
|
+
`clip frame=${clipFrame.name}, constant world influence shares=${constantWorldShares}. ` +
|
|
5195
|
+
'Required matching explicit affine/depth-yaw/pitch transforms in compatible DeformGeometry frames, ' +
|
|
5196
|
+
'with constant shares for world-space transfer. Fix the named frame/keys or author corresponding clip motion.',
|
|
5197
|
+
'CE_COMPILE_ALPHA_CLIP_DEFORM',
|
|
5198
|
+
);
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
reports.push({
|
|
5202
|
+
target: `${ctx.skinName}/${ctx.slotName}/${targetKey}`,
|
|
5203
|
+
source: sourceWhere,
|
|
5204
|
+
...traced.report,
|
|
5205
|
+
vertices: out.vertexCount,
|
|
5206
|
+
uvs: carried.points,
|
|
5207
|
+
maxBindingDeviationWorld,
|
|
5208
|
+
});
|
|
5209
|
+
}
|
|
5210
|
+
return reports;
|
|
5211
|
+
}
|
|
5212
|
+
|
|
4936
5213
|
function buildRigClipping(
|
|
4937
5214
|
att: RigClippingAttachment,
|
|
4938
5215
|
where: string,
|
|
@@ -4956,9 +5233,15 @@ function buildRigClipping(
|
|
|
4956
5233
|
...(att.end !== undefined ? { end: att.end } : {}),
|
|
4957
5234
|
...(att.convex !== undefined ? { convex: att.convex } : {}),
|
|
4958
5235
|
...(att.inverse !== undefined ? { inverse: att.inverse } : {}),
|
|
4959
|
-
vertexCount: att.vertexCount,
|
|
4960
|
-
vertices: buildVertexGeometry(att, where, ctx),
|
|
5236
|
+
vertexCount: att.vertexCount ?? 0,
|
|
5237
|
+
vertices: att.alphaClip === undefined ? buildVertexGeometry({ ...att, vertexCount: att.vertexCount! }, where, ctx) : { weighted: false, xy: [] },
|
|
4961
5238
|
};
|
|
5239
|
+
if (att.alphaClip !== undefined) {
|
|
5240
|
+
const fields = ['vertexCount', 'vertices', 'weights', 'boneIndexing'] as const;
|
|
5241
|
+
const present = fields.filter((key) => att[key] !== undefined);
|
|
5242
|
+
if (present.length || att.convex === true || att.inverse === true) throw new CompileError(`${where}: alphaClip conflicts with [${present.join(', ')}], convex=${att.convex}, inverse=${att.inverse}; required ordinary inside clipping with generated geometry only. Remove authored geometry/flags.`, 'CE_COMPILE_ALPHA_CLIP_CONFLICT');
|
|
5243
|
+
ctx.pendingAlphaClips.push({ spec: att.alphaClip, out, where, ctx });
|
|
5244
|
+
}
|
|
4962
5245
|
if (att.color !== undefined) out.editorColor = att.color;
|
|
4963
5246
|
return out;
|
|
4964
5247
|
}
|
package/src/core/clipping.ts
CHANGED
|
@@ -633,7 +633,11 @@ export function clipThrough(
|
|
|
633
633
|
clip: TriangleClipper = clipTriangles,
|
|
634
634
|
reading: { dropRegion?: number } = {},
|
|
635
635
|
): ClipResult | null {
|
|
636
|
-
|
|
636
|
+
let plan = clipShapeOf(shape);
|
|
637
|
+
// CO11/CL01/CN06 and portal's convex rows need the original world doubles.
|
|
638
|
+
// Two 66-point region/authored up_minus15 pieces witnesses went from 2 to 0
|
|
639
|
+
// rendered pixel differences with Float32 input; SDK clipper storage was not observed.
|
|
640
|
+
if (plan.kind === 'pieces') plan = clipShapeOf({ ...shape, polygon: shape.polygon.map(Math.fround) });
|
|
637
641
|
if (plan.kind === 'refused') return null;
|
|
638
642
|
if (plan.kind === 'convex') return clip(plan.polygon, vertices, triangles, uvs);
|
|
639
643
|
const f = Math.fround;
|
package/src/mesh.ts
CHANGED
|
@@ -930,7 +930,9 @@ export function segmentsMeet(
|
|
|
930
930
|
const o2 = o(a, b, d);
|
|
931
931
|
const o3 = o(c, d, a);
|
|
932
932
|
const o4 = o(c, d, b);
|
|
933
|
-
|
|
933
|
+
// An epsilon-zero orientation is not a strict side of a line. In that case
|
|
934
|
+
// only the endpoint tests below can establish contact (including the bounds).
|
|
935
|
+
if (o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0 && o1 !== o2 && o3 !== o4) return true;
|
|
934
936
|
return onSegment(a, b, c) || onSegment(a, b, d) || onSegment(c, d, a) || onSegment(c, d, b);
|
|
935
937
|
}
|
|
936
938
|
|
package/src/rig.ts
CHANGED
|
@@ -1124,7 +1124,26 @@ export interface RigBoundingBoxAttachment extends RigVertexGeometry {
|
|
|
1124
1124
|
* never ends — it runs to the bottom of the draw order and takes every slot
|
|
1125
1125
|
* below it with it. rigc refuses a name the rig does not declare.
|
|
1126
1126
|
*/
|
|
1127
|
-
|
|
1127
|
+
/** A clipping outline measured from one named attachment's plate. No inferred parameters. */
|
|
1128
|
+
export interface RigAlphaClip {
|
|
1129
|
+
slot: string;
|
|
1130
|
+
skin: string;
|
|
1131
|
+
attachment: string;
|
|
1132
|
+
alpha: number;
|
|
1133
|
+
tolerance: number;
|
|
1134
|
+
maxVertices: number;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
export interface RigClippingAttachment {
|
|
1138
|
+
/** Authored geometry uses the same named/raw contract as RigVertexGeometry. */
|
|
1139
|
+
vertices?: number[];
|
|
1140
|
+
weights?: RigMeshBinding[][];
|
|
1141
|
+
boneIndexing?: 'name' | 'raw';
|
|
1142
|
+
color?: string;
|
|
1143
|
+
/** Required for authored geometry; derived for alphaClip. */
|
|
1144
|
+
vertexCount?: number;
|
|
1145
|
+
/** Exclusive with authored vertexCount/vertices/weights/boneIndexing. */
|
|
1146
|
+
alphaClip?: RigAlphaClip;
|
|
1128
1147
|
type: 'clipping';
|
|
1129
1148
|
/** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
|
|
1130
1149
|
name?: RigAttachmentName;
|
|
@@ -2089,7 +2108,8 @@ export const RIG_KEYS = {
|
|
|
2089
2108
|
RigMeshBinding: ['bone', 'x', 'y', 'weight'],
|
|
2090
2109
|
RigSequence: ['count', 'start', 'digits', 'setup'],
|
|
2091
2110
|
RigBoundingBoxAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name'],
|
|
2092
|
-
RigClippingAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'end', 'convex', 'inverse'],
|
|
2111
|
+
RigClippingAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'end', 'convex', 'inverse', 'alphaClip'],
|
|
2112
|
+
RigAlphaClip: ['slot', 'skin', 'attachment', 'alpha', 'tolerance', 'maxVertices'],
|
|
2093
2113
|
RigPathAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'closed', 'constantSpeed', 'lengths'],
|
|
2094
2114
|
RigRingGenerator: ['kind', 'hull', 'center', 'inner', 'size', 'bias', 'controls'],
|
|
2095
2115
|
RigRibbonGenerator: ['kind', 'size', 'rows', 'chain'],
|
|
@@ -2218,8 +2238,9 @@ export const RIG_TYPES = {
|
|
|
2218
2238
|
},
|
|
2219
2239
|
RigClippingAttachment: {
|
|
2220
2240
|
vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
|
|
2221
|
-
end: 'string', convex: 'boolean', inverse: 'boolean',
|
|
2241
|
+
end: 'string', convex: 'boolean', inverse: 'boolean', alphaClip: 'object',
|
|
2222
2242
|
},
|
|
2243
|
+
RigAlphaClip: { slot: 'string', skin: 'string', attachment: 'string', alpha: 'number', tolerance: 'number', maxVertices: 'number' },
|
|
2223
2244
|
RigPathAttachment: {
|
|
2224
2245
|
vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
|
|
2225
2246
|
closed: 'boolean', constantSpeed: 'boolean', lengths: 'number[]',
|
|
@@ -2550,6 +2571,7 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
|
|
|
2550
2571
|
}
|
|
2551
2572
|
const here = [...base, slot, placeholder];
|
|
2552
2573
|
at(att, shape, `${who} (${type})`, here);
|
|
2574
|
+
if (type === 'clipping') at(att.alphaClip, 'RigAlphaClip', `${who} alphaClip`, [...here, 'alphaClip']);
|
|
2553
2575
|
if (att.sequence !== undefined) {
|
|
2554
2576
|
at(att.sequence, 'RigSequence', `${who} "sequence"`, [...here, 'sequence']);
|
|
2555
2577
|
checkRigSequence(att, who, where);
|
package/src/types.ts
CHANGED
|
@@ -1562,7 +1562,33 @@ export interface DroppedState {
|
|
|
1562
1562
|
why?: string;
|
|
1563
1563
|
}
|
|
1564
1564
|
|
|
1565
|
+
/** Alpha-derived clipping fit, on the source plate's pixel-centre grid. */
|
|
1566
|
+
export interface AlphaClipReport {
|
|
1567
|
+
target: string;
|
|
1568
|
+
source: string;
|
|
1569
|
+
alpha: number;
|
|
1570
|
+
tolerance: number;
|
|
1571
|
+
maxVertices: number;
|
|
1572
|
+
tracedVertices: number;
|
|
1573
|
+
simplifiedVertices: number;
|
|
1574
|
+
vertices: number;
|
|
1575
|
+
artPixels: number;
|
|
1576
|
+
coveredArt: number;
|
|
1577
|
+
coverage: number;
|
|
1578
|
+
falsePositive: number;
|
|
1579
|
+
falseNegative: number;
|
|
1580
|
+
/** Original trace-corner distance to simplified outline, in drawing pixels, before binding. */
|
|
1581
|
+
maxTraceDeviation: number;
|
|
1582
|
+
/** Maximum setup-world displacement caused by final float32 bind/world rounding, in world units. */
|
|
1583
|
+
maxBindingDeviationWorld: number;
|
|
1584
|
+
overshoot: number;
|
|
1585
|
+
/** source normalized UVs in the emitted clipping vertex order, including carrier crossings. */
|
|
1586
|
+
uvs: Array<[number, number]>;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1565
1589
|
export interface CompileResult {
|
|
1590
|
+
/** Present only for generated clipping; does not alter emitted model/skeleton geometry. */
|
|
1591
|
+
alphaClips?: AlphaClipReport[];
|
|
1566
1592
|
/**
|
|
1567
1593
|
* The compiled model (`src/model.ts`): what the skeleton below was emitted
|
|
1568
1594
|
* from. This cut fills its `bones` and `setupWorld`; every other field is
|