rig-c 0.0.0-stage → 2.20.4
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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-colour tint (issue #1025, cut 4c-3 of step 4c of #380) — the
|
|
3
|
+
* census's F02, F18, F22 and R4 as A43 reads them: the dark colour each slot
|
|
4
|
+
* states and the one the runtime loaded for it, the slot timelines the file
|
|
5
|
+
* keys, and a slot's light and dark colour posed at a time.
|
|
6
|
+
*
|
|
7
|
+
* ⭐ **The file's spelling where A43 prints it, the runtime's reading where it
|
|
8
|
+
* compares.** A43 parses the stated hex itself — a check that read the
|
|
9
|
+
* required value out of the parser it checks would agree with it whatever it
|
|
10
|
+
* did — so the stated `dark` and every key are handed over as the file spells
|
|
11
|
+
* them; the loaded and the posed colours are the values the runtime holds.
|
|
12
|
+
*
|
|
13
|
+
* The slot timelines are `SlotColourFacts`' (`./slot_colour.ts`): one walk of
|
|
14
|
+
* the file's animations, in its order and its spelling, which A45 reads too.
|
|
15
|
+
*
|
|
16
|
+
* Links nothing from the runtime: `validate()` reads it off the skeleton JSON
|
|
17
|
+
* and the loaded skeleton (`spineTwoColourFacts`), the model side off the
|
|
18
|
+
* document and the core (`../model/two_colour.ts`).
|
|
19
|
+
*/
|
|
20
|
+
import type { SlotTimelines } from './slot_colour.ts';
|
|
21
|
+
|
|
22
|
+
/** A dark colour as loaded or posed: three channels, NaN where the reading was no number. */
|
|
23
|
+
export interface DarkColour {
|
|
24
|
+
readonly r: number;
|
|
25
|
+
readonly g: number;
|
|
26
|
+
readonly b: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A slot's two colours, posed. */
|
|
30
|
+
export interface PosedTint {
|
|
31
|
+
readonly light: { readonly r: number; readonly g: number; readonly b: number; readonly a: number };
|
|
32
|
+
/** `null` where the posed slot holds no dark colour. */
|
|
33
|
+
readonly dark: DarkColour | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What A43 reads. */
|
|
37
|
+
export interface TwoColourFacts {
|
|
38
|
+
/** Every slot whose entry states `dark` as a string, in the file's slot order, name and value as the file spells them. */
|
|
39
|
+
readonly slotDarks: ReadonlyArray<{ readonly slot: string; readonly dark: string }>;
|
|
40
|
+
/** The setup dark colour the loaded skeleton holds for the slot, or `null` for none (or no such slot). */
|
|
41
|
+
loadedDark(slot: string): DarkColour | null;
|
|
42
|
+
/** Every (animation, slot) pair whose timelines the file keys, in the file's order and spelling. */
|
|
43
|
+
readonly slotTimelines: readonly SlotTimelines[];
|
|
44
|
+
/** Whether the skeleton holds an animation of this name. */
|
|
45
|
+
hasAnimation(name: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* The slot's light and dark colour with the animation applied at `time` on
|
|
48
|
+
* a fresh, non-looping track from the setup pose, no skin set — or
|
|
49
|
+
* `undefined` for a slot the skeleton does not have.
|
|
50
|
+
*/
|
|
51
|
+
posedTint(animation: string, slot: string, time: number): PosedTint | undefined;
|
|
52
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The polygons — bounding boxes, clipping attachments and paths — and the
|
|
3
|
+
* slot a clip ends at, as `A33` reads them (issue #1025, step 4c of #380):
|
|
4
|
+
* the census's F07 and F09.
|
|
5
|
+
*
|
|
6
|
+
* ⭐ **Two orders, each the one its walk always had.** The polygons are in
|
|
7
|
+
* the loaded skins' order (`./skin_entries.ts`'s ⭐), which is the order A33
|
|
8
|
+
* walked spine-core's `getAttachments()`. The clip ends are in the order the
|
|
9
|
+
* FILE spells them — the skins in the file's order, each skin's slot keys as
|
|
10
|
+
* the file keys them, each slot's entries in table order — because A33 read
|
|
11
|
+
* them off the skeleton JSON: a `null` end and an `end` never written are the
|
|
12
|
+
* same loaded object. The model side produces both by calling the emitter's
|
|
13
|
+
* own orders (`editorSkinOrder`, `editorSlotKeyOrder`).
|
|
14
|
+
*
|
|
15
|
+
* 🔒 **`encoding` is the round trip's**, as in `./mesh_attachments.ts`: the
|
|
16
|
+
* weighted run's decode — a vertex claiming no bone, a binding indexing past
|
|
17
|
+
* the roster, a run decoding to another vertex count, a weight array of the
|
|
18
|
+
* wrong length — and an unweighted array of the wrong length have no subject
|
|
19
|
+
* in a document that states the weighted form outright and names bones.
|
|
20
|
+
* `src/validate.ts` keeps those clauses and hands their findings here, in the
|
|
21
|
+
* order the body printed them; the model side supplies none.
|
|
22
|
+
*
|
|
23
|
+
* Links nothing from the runtime.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One polygon: how A33 names it, the vertex count it loaded, and a path's own fields. */
|
|
27
|
+
export interface PolygonEntry {
|
|
28
|
+
/** `bounding box "x"`, `clipping attachment "x"` or `path "x"` — the attachment's own name. */
|
|
29
|
+
readonly what: string;
|
|
30
|
+
/** `worldVerticesLength`: two numbers per vertex. */
|
|
31
|
+
readonly worldVerticesLength: number;
|
|
32
|
+
/** A path's `closed` and cumulative curve `lengths`, as the runtime holds them; `null` for the other two kinds. */
|
|
33
|
+
readonly path: { readonly closed: boolean; readonly lengths: readonly number[] } | null;
|
|
34
|
+
/** The kept clauses' findings about this polygon's vertex run, in order; never on the model side. */
|
|
35
|
+
readonly encoding: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One clipping attachment that states an `end`, as the file states it. */
|
|
39
|
+
export interface ClipEnd {
|
|
40
|
+
readonly placeholder: string;
|
|
41
|
+
readonly slot: string;
|
|
42
|
+
readonly end: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What A33 reads. */
|
|
46
|
+
export interface PolygonFacts {
|
|
47
|
+
/** Every bounding box, clipping attachment and path of every skin, in the loaded walk's order. */
|
|
48
|
+
readonly polygons: readonly PolygonEntry[];
|
|
49
|
+
/** Every clipping attachment that states an `end`, in the file's order. */
|
|
50
|
+
readonly clipEnds: readonly ClipEnd[];
|
|
51
|
+
/** The skeleton's slot names. */
|
|
52
|
+
readonly slots: readonly string[];
|
|
53
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each region of an atlas draws, in page texels, and whether two of those
|
|
3
|
+
* drawings overlap — the reading `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` makes
|
|
4
|
+
* (issue #1099, the gate half of `--pack-shape polygon`).
|
|
5
|
+
*
|
|
6
|
+
* ## The footprint
|
|
7
|
+
*
|
|
8
|
+
* A region's footprint is the part of its page some attachment samples:
|
|
9
|
+
*
|
|
10
|
+
* * a region a **mesh** names (its `path`, else its name, and every frame of
|
|
11
|
+
* its `sequence` — the lookups `../region_lookups.ts` makes, the loader's
|
|
12
|
+
* own) draws the mesh's hull loop — its first `hullLength / 2` UVs, closed
|
|
13
|
+
* last to first — and each of its triangles; a **linked mesh** draws its
|
|
14
|
+
* source's, which is what `MeshEntry.regionUVs` and `triangles` already
|
|
15
|
+
* hold for one. A UV `(u, v)` lands on the page where
|
|
16
|
+
* `MeshAttachment.computeUVs` puts it, times the page size: the default
|
|
17
|
+
* case of `computeUvs` in `src/core/uvs.ts`, which the core suite holds to
|
|
18
|
+
* the runtime — `x = region.x − offsetX + u · originalWidth`,
|
|
19
|
+
* `y = region.y − (originalHeight − offsetY − height) + v · originalHeight`;
|
|
20
|
+
* * a region a **region attachment** names draws its whole rectangle, and so
|
|
21
|
+
* does a region **nothing** names (a frame no attachment reaches, the second
|
|
22
|
+
* of two regions with one name — the runtime's lookup returns the first);
|
|
23
|
+
* * a region is its rectangle, too, wherever the mesh cannot be read as a
|
|
24
|
+
* polygon over it: a mesh whose UVs leave 0..1, a hull or triangle list
|
|
25
|
+
* that does not index its vertices, a region turned by `rotate:` (the hull
|
|
26
|
+
* is not turned here; A06 refuses rotation under the profile this runs
|
|
27
|
+
* in), and every region at all when a skin entry's `sequence` is one the
|
|
28
|
+
* lookup walk will not guess at — then which regions a mesh reaches is not
|
|
29
|
+
* known, and the rectangle is the reading that assumes nothing.
|
|
30
|
+
*
|
|
31
|
+
* Those fallbacks are the packer's (`packFootprints` in `src/atlas.ts`), and
|
|
32
|
+
* every one of them is the rectangle — the footprint A06's tiling clause gave
|
|
33
|
+
* every region before this reading existed.
|
|
34
|
+
*
|
|
35
|
+
* ## The overlap, and why it is exactly A06's statement for rectangles
|
|
36
|
+
*
|
|
37
|
+
* Two regions on one page are refused when their rectangles' interiors overlap
|
|
38
|
+
* **and** their footprints' interiors overlap. Touching is not overlapping, as
|
|
39
|
+
* A06 never refused two rectangles that share an edge. A hull lies inside its
|
|
40
|
+
* rectangle wherever it is read as a hull (UVs in 0..1, offsets 0, which is
|
|
41
|
+
* every region rigc packs), so a footprint overlap implies a rectangle overlap;
|
|
42
|
+
* the rectangle test runs first anyway, so that "nothing A06 accepted is
|
|
43
|
+
* refused" is a property of this code rather than of the art.
|
|
44
|
+
*
|
|
45
|
+
* Each footprint is cut into convex pieces — a rectangle is one, a triangle is
|
|
46
|
+
* one, a hull loop is ear-clipped into triangles — and two footprints overlap
|
|
47
|
+
* when some piece of one and some piece of the other do. Two convex pieces are
|
|
48
|
+
* apart when an edge normal of either separates their projections (the
|
|
49
|
+
* separating-axis theorem, complete for two convex polygons in the plane).
|
|
50
|
+
* Every such comparison is made `FOOTPRINT_SLACK` texels in favour of
|
|
51
|
+
* "apart", the packer's own constant: the coordinates are doubles (a UV as the
|
|
52
|
+
* file writes it times an integer size), so a pair that merely touches may
|
|
53
|
+
* compute as overlapping by a few ulps, and a touch is what A06 accepts.
|
|
54
|
+
* Integer rectangles are exact either way.
|
|
55
|
+
*
|
|
56
|
+
* Links nothing from the runtime.
|
|
57
|
+
*/
|
|
58
|
+
import { FOOTPRINT_SLACK, pageFootprint } from '../atlas.ts';
|
|
59
|
+
import type { AtlasRegionEntry, AtlasRegionFacts } from './facts/atlas_regions.ts';
|
|
60
|
+
import type { MeshEntry, MeshFacts } from './facts/mesh_attachments.ts';
|
|
61
|
+
import type { RegionJoinFacts } from './facts/region_joins.ts';
|
|
62
|
+
|
|
63
|
+
/** A point in page texels, x right and y down from the page's top-left. */
|
|
64
|
+
export type PagePoint = readonly [number, number];
|
|
65
|
+
|
|
66
|
+
/** A region's rectangle on its page, as `pageFootprint` derives it (a turned region's is transposed). */
|
|
67
|
+
export interface PageRect {
|
|
68
|
+
readonly x: number;
|
|
69
|
+
readonly y: number;
|
|
70
|
+
readonly width: number;
|
|
71
|
+
readonly height: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What one region draws. */
|
|
75
|
+
export interface RegionFootprint {
|
|
76
|
+
readonly region: AtlasRegionEntry;
|
|
77
|
+
readonly rect: PageRect;
|
|
78
|
+
/** `true` when the footprint is the whole rectangle. */
|
|
79
|
+
readonly whole: boolean;
|
|
80
|
+
/** What draws it, as the failure sentence states it. */
|
|
81
|
+
readonly drawn: string;
|
|
82
|
+
/** Its convex pieces, each a loop of page points; one piece, the rectangle, when `whole`. */
|
|
83
|
+
readonly pieces: ReadonlyArray<readonly PagePoint[]>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The rectangle of a region on its page. */
|
|
87
|
+
export function pageRectOf(region: AtlasRegionEntry): PageRect {
|
|
88
|
+
const foot = pageFootprint(region);
|
|
89
|
+
return { x: region.x, y: region.y, width: foot.width, height: foot.height };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rectPiece(r: PageRect): PagePoint[] {
|
|
93
|
+
return [
|
|
94
|
+
[r.x, r.y],
|
|
95
|
+
[r.x + r.width, r.y],
|
|
96
|
+
[r.x + r.width, r.y + r.height],
|
|
97
|
+
[r.x, r.y + r.height],
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cross = (o: PagePoint, a: PagePoint, b: PagePoint): number => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
102
|
+
|
|
103
|
+
function signedArea(loop: readonly PagePoint[]): number {
|
|
104
|
+
let sum = 0;
|
|
105
|
+
for (let i = 0; i < loop.length; i++) {
|
|
106
|
+
const a = loop[i];
|
|
107
|
+
const b = loop[(i + 1) % loop.length];
|
|
108
|
+
sum += a[0] * b[1] - b[0] * a[1];
|
|
109
|
+
}
|
|
110
|
+
return sum / 2;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A closed loop cut into triangles by ear clipping. A loop with no area gives
|
|
115
|
+
* no piece (it draws nothing a neighbour could sit in). A vertex on a straight
|
|
116
|
+
* run, or the tip of a zero-area spike, is dropped, since it bounds no area.
|
|
117
|
+
* On a loop that is not simple the clipping still ends, and every triangle it
|
|
118
|
+
* gives has three of the loop's own vertices — so it never reaches outside the
|
|
119
|
+
* loop's convex hull, which is inside the region's rectangle.
|
|
120
|
+
*/
|
|
121
|
+
export function earClip(loop: readonly PagePoint[]): PagePoint[][] {
|
|
122
|
+
const points: PagePoint[] = [];
|
|
123
|
+
for (const p of loop) {
|
|
124
|
+
const last = points[points.length - 1];
|
|
125
|
+
if (last === undefined || last[0] !== p[0] || last[1] !== p[1]) points.push(p);
|
|
126
|
+
}
|
|
127
|
+
while (points.length > 1 && points[0][0] === points[points.length - 1][0] && points[0][1] === points[points.length - 1][1]) points.pop();
|
|
128
|
+
if (points.length < 3) return [];
|
|
129
|
+
const area = signedArea(points);
|
|
130
|
+
if (area === 0) return [];
|
|
131
|
+
const ring = area > 0 ? points : points.slice().reverse();
|
|
132
|
+
const idx = ring.map((_, i) => i);
|
|
133
|
+
const out: PagePoint[][] = [];
|
|
134
|
+
let guard = 0;
|
|
135
|
+
while (idx.length > 3 && guard++ < 4 * ring.length * ring.length) {
|
|
136
|
+
let clipped = false;
|
|
137
|
+
for (let k = 0; k < idx.length; k++) {
|
|
138
|
+
const prev = ring[idx[(k + idx.length - 1) % idx.length]];
|
|
139
|
+
const cur = ring[idx[k]];
|
|
140
|
+
const next = ring[idx[(k + 1) % idx.length]];
|
|
141
|
+
const turn = cross(prev, cur, next);
|
|
142
|
+
if (turn === 0) {
|
|
143
|
+
idx.splice(k, 1);
|
|
144
|
+
clipped = true;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
if (turn < 0) continue;
|
|
148
|
+
let blocked = false;
|
|
149
|
+
for (const j of idx) {
|
|
150
|
+
const p = ring[j];
|
|
151
|
+
if (p === prev || p === cur || p === next) continue;
|
|
152
|
+
if (cross(prev, cur, p) >= 0 && cross(cur, next, p) >= 0 && cross(next, prev, p) >= 0) {
|
|
153
|
+
blocked = true;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (blocked) continue;
|
|
158
|
+
out.push([prev, cur, next]);
|
|
159
|
+
idx.splice(k, 1);
|
|
160
|
+
clipped = true;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
// No ear on a loop that is not simple: take the first convex corner anyway,
|
|
164
|
+
// which keeps every triangle on the loop's own vertices (header).
|
|
165
|
+
if (!clipped) {
|
|
166
|
+
const k = idx.findIndex((_, n) => cross(ring[idx[(n + idx.length - 1) % idx.length]], ring[idx[n]], ring[idx[(n + 1) % idx.length]]) > 0);
|
|
167
|
+
const at = k < 0 ? 0 : k;
|
|
168
|
+
out.push([ring[idx[(at + idx.length - 1) % idx.length]], ring[idx[at]], ring[idx[(at + 1) % idx.length]]]);
|
|
169
|
+
idx.splice(at, 1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (idx.length === 3) {
|
|
173
|
+
const tri: PagePoint[] = [ring[idx[0]], ring[idx[1]], ring[idx[2]]];
|
|
174
|
+
if (cross(tri[0], tri[1], tri[2]) !== 0) out.push(tri);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A mesh's UV polygons over its own drawing — the hull loop, then each triangle — or why they cannot be read as polygons over its rectangle. */
|
|
180
|
+
function meshPolygons(mesh: MeshEntry): { loops: number[][] } | { why: string } {
|
|
181
|
+
const uvs = mesh.regionUVs;
|
|
182
|
+
const vertices = uvs.length / 2;
|
|
183
|
+
const hull = mesh.hullLength / 2;
|
|
184
|
+
if (uvs.length % 2 !== 0 || !Number.isInteger(hull) || hull < 3 || hull > vertices) return { why: `mesh "${mesh.placeholder}" states a hull of ${hull} over ${vertices} vertex(es)` };
|
|
185
|
+
if (uvs.some((n) => !(n >= 0 && n <= 1))) return { why: `mesh "${mesh.placeholder}"'s UVs leave 0..1, so it samples outside its own rectangle` };
|
|
186
|
+
if (mesh.triangles.length % 3 !== 0 || mesh.triangles.some((t) => !Number.isInteger(t) || t < 0 || t >= vertices)) {
|
|
187
|
+
return { why: `mesh "${mesh.placeholder}"'s triangles do not index its ${vertices} vertex(es)` };
|
|
188
|
+
}
|
|
189
|
+
const loops: number[][] = [uvs.slice(0, 2 * hull)];
|
|
190
|
+
for (let t = 0; t < mesh.triangles.length; t += 3) {
|
|
191
|
+
const [a, b, c] = [mesh.triangles[t], mesh.triangles[t + 1], mesh.triangles[t + 2]];
|
|
192
|
+
loops.push([uvs[2 * a], uvs[2 * a + 1], uvs[2 * b], uvs[2 * b + 1], uvs[2 * c], uvs[2 * c + 1]]);
|
|
193
|
+
}
|
|
194
|
+
return { loops };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const keyOf = (skin: string, slot: string, placeholder: string): string => `${skin}\u0000${slot}\u0000${placeholder}`;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Every region's footprint, in the atlas's region order. `meshes` is `null`
|
|
201
|
+
* where the skeleton did not load (the round trip's own failure is A00's): then
|
|
202
|
+
* which region a mesh draws is not known, and every footprint is its rectangle.
|
|
203
|
+
*/
|
|
204
|
+
export function regionFootprints({ atlas }: AtlasRegionFacts, { joins }: RegionJoinFacts, meshes: MeshFacts | null): RegionFootprint[] {
|
|
205
|
+
if (atlas === null) return [];
|
|
206
|
+
const rectOf = (region: AtlasRegionEntry, drawn: string): RegionFootprint => {
|
|
207
|
+
const rect = pageRectOf(region);
|
|
208
|
+
return { region, rect, whole: true, drawn, pieces: [rectPiece(rect)] };
|
|
209
|
+
};
|
|
210
|
+
if (meshes === null || joins === null) {
|
|
211
|
+
const why = meshes === null ? 'the skeleton did not load, so what draws it is not known' : 'the skeleton did not parse, so what draws it is not known';
|
|
212
|
+
return atlas.regions.map((region) => rectOf(region, `its whole rectangle (${why})`));
|
|
213
|
+
}
|
|
214
|
+
const unread = joins.find((join) => join.lookups === null);
|
|
215
|
+
if (unread !== undefined) {
|
|
216
|
+
const why = `skin "${unread.skin}" slot "${unread.slot}" entry "${unread.placeholder}" states a sequence whose frames cannot be read, so which regions a mesh reaches is not known`;
|
|
217
|
+
return atlas.regions.map((region) => rectOf(region, `its whole rectangle (${why})`));
|
|
218
|
+
}
|
|
219
|
+
const meshAt = new Map<string, MeshEntry>();
|
|
220
|
+
for (const mesh of meshes.meshes) {
|
|
221
|
+
const key = keyOf(mesh.skin, mesh.slot, mesh.placeholder);
|
|
222
|
+
if (!meshAt.has(key)) meshAt.set(key, mesh);
|
|
223
|
+
}
|
|
224
|
+
/** Per region drawn: the meshes over it, or the reason it is a rectangle. */
|
|
225
|
+
const uses = new Map<AtlasRegionEntry, { meshes: MeshEntry[]; rect: string | null }>();
|
|
226
|
+
for (const join of joins) {
|
|
227
|
+
const mesh = meshAt.get(keyOf(join.skin, join.slot, join.placeholder));
|
|
228
|
+
for (const name of join.lookups ?? []) {
|
|
229
|
+
const region = atlas.findRegion(name);
|
|
230
|
+
if (region === null) continue; // A08 names a lookup that finds no region
|
|
231
|
+
const use = uses.get(region) ?? { meshes: [], rect: null };
|
|
232
|
+
uses.set(region, use);
|
|
233
|
+
if (mesh === undefined) use.rect ??= `its whole rectangle, which region attachment "${join.placeholder}" (skin "${join.skin}", slot "${join.slot}") draws`;
|
|
234
|
+
else use.meshes.push(mesh);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return atlas.regions.map((region) => {
|
|
238
|
+
const use = uses.get(region);
|
|
239
|
+
if (use === undefined) return rectOf(region, 'its whole rectangle, which no attachment draws');
|
|
240
|
+
if (use.rect !== null) return rectOf(region, use.rect);
|
|
241
|
+
if (region.degrees !== 0) return rectOf(region, `its whole rectangle (it is turned ${region.degrees} degrees, and a hull is not turned here)`);
|
|
242
|
+
const pieces: PagePoint[][] = [];
|
|
243
|
+
const left = region.x - region.offsetX;
|
|
244
|
+
const top = region.y - (region.originalHeight - region.offsetY - region.height);
|
|
245
|
+
for (const mesh of use.meshes) {
|
|
246
|
+
const read = meshPolygons(mesh);
|
|
247
|
+
if ('why' in read) return rectOf(region, `its whole rectangle (${read.why})`);
|
|
248
|
+
read.loops.forEach((loop, i) => {
|
|
249
|
+
const points: PagePoint[] = [];
|
|
250
|
+
for (let k = 0; k + 1 < loop.length; k += 2) points.push([left + loop[k] * region.originalWidth, top + loop[k + 1] * region.originalHeight]);
|
|
251
|
+
if (i === 0) pieces.push(...earClip(points));
|
|
252
|
+
else if (cross(points[0], points[1], points[2]) !== 0) pieces.push(points);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const named = use.meshes.map((m) => `"${m.placeholder}" (skin "${m.skin}", slot "${m.slot}")`);
|
|
256
|
+
const drawn = named.length === 1 ? `the hull of mesh ${named[0]}` : `the hulls of meshes ${named.join(', ')}`;
|
|
257
|
+
return { region, rect: pageRectOf(region), whole: false, drawn, pieces };
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Whether two convex pieces' interiors overlap by more than `FOOTPRINT_SLACK` along every edge normal of either. */
|
|
262
|
+
export function piecesOverlap(a: readonly PagePoint[], b: readonly PagePoint[]): boolean {
|
|
263
|
+
for (const poly of [a, b]) {
|
|
264
|
+
for (let i = 0; i < poly.length; i++) {
|
|
265
|
+
const p = poly[i];
|
|
266
|
+
const q = poly[(i + 1) % poly.length];
|
|
267
|
+
const nx = q[1] - p[1];
|
|
268
|
+
const ny = p[0] - q[0];
|
|
269
|
+
const len = Math.hypot(nx, ny);
|
|
270
|
+
if (len === 0) continue;
|
|
271
|
+
let minA = Infinity;
|
|
272
|
+
let maxA = -Infinity;
|
|
273
|
+
let minB = Infinity;
|
|
274
|
+
let maxB = -Infinity;
|
|
275
|
+
for (const v of a) {
|
|
276
|
+
const d = (v[0] * nx + v[1] * ny) / len;
|
|
277
|
+
minA = Math.min(minA, d);
|
|
278
|
+
maxA = Math.max(maxA, d);
|
|
279
|
+
}
|
|
280
|
+
for (const v of b) {
|
|
281
|
+
const d = (v[0] * nx + v[1] * ny) / len;
|
|
282
|
+
minB = Math.min(minB, d);
|
|
283
|
+
maxB = Math.max(maxB, d);
|
|
284
|
+
}
|
|
285
|
+
if (maxA <= minB + FOOTPRINT_SLACK || maxB <= minA + FOOTPRINT_SLACK) return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The convex polygon two convex pieces share (Sutherland–Hodgman), for the sentence that names the overlap. */
|
|
292
|
+
function sharedPolygon(subject: readonly PagePoint[], clip: readonly PagePoint[]): PagePoint[] {
|
|
293
|
+
const sign = signedArea(clip) >= 0 ? 1 : -1;
|
|
294
|
+
let out: PagePoint[] = subject.slice();
|
|
295
|
+
for (let i = 0; i < clip.length && out.length > 0; i++) {
|
|
296
|
+
const a = clip[i];
|
|
297
|
+
const b = clip[(i + 1) % clip.length];
|
|
298
|
+
const input = out;
|
|
299
|
+
out = [];
|
|
300
|
+
const inside = (p: PagePoint): boolean => sign * cross(a, b, p) >= 0;
|
|
301
|
+
for (let k = 0; k < input.length; k++) {
|
|
302
|
+
const p = input[k];
|
|
303
|
+
const q = input[(k + 1) % input.length];
|
|
304
|
+
const pin = inside(p);
|
|
305
|
+
const qin = inside(q);
|
|
306
|
+
if (pin) out.push(p);
|
|
307
|
+
if (pin !== qin) {
|
|
308
|
+
const dp = cross(a, b, p);
|
|
309
|
+
const dq = cross(a, b, q);
|
|
310
|
+
const t = dp / (dp - dq);
|
|
311
|
+
out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** What two footprints share: the largest area any two pieces share and the box around every shared part — `null` when they do not overlap. */
|
|
319
|
+
export interface FootprintOverlap {
|
|
320
|
+
readonly area: number;
|
|
321
|
+
readonly box: { readonly x0: number; readonly y0: number; readonly x1: number; readonly y1: number };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const overlaps = (a: PageRect, b: PageRect): boolean => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
325
|
+
|
|
326
|
+
function boxOf(piece: readonly PagePoint[]): { x0: number; y0: number; x1: number; y1: number } {
|
|
327
|
+
let x0 = Infinity;
|
|
328
|
+
let y0 = Infinity;
|
|
329
|
+
let x1 = -Infinity;
|
|
330
|
+
let y1 = -Infinity;
|
|
331
|
+
for (const [x, y] of piece) {
|
|
332
|
+
x0 = Math.min(x0, x);
|
|
333
|
+
y0 = Math.min(y0, y);
|
|
334
|
+
x1 = Math.max(x1, x);
|
|
335
|
+
y1 = Math.max(y1, y);
|
|
336
|
+
}
|
|
337
|
+
return { x0, y0, x1, y1 };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Whether the rectangles' interiors overlap — A06's own test, strict, so two rectangles that share an edge do not. */
|
|
341
|
+
export function rectanglesOverlap(a: RegionFootprint, b: RegionFootprint): boolean {
|
|
342
|
+
return overlaps(a.rect, b.rect);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** What two footprints' interiors share, or `null` when they do not overlap. Asked only of a pair whose rectangles overlap. */
|
|
346
|
+
export function footprintOverlap(a: RegionFootprint, b: RegionFootprint): FootprintOverlap | null {
|
|
347
|
+
let area = 0;
|
|
348
|
+
let box: { x0: number; y0: number; x1: number; y1: number } | null = null;
|
|
349
|
+
const boxesA = a.pieces.map(boxOf);
|
|
350
|
+
const boxesB = b.pieces.map(boxOf);
|
|
351
|
+
for (let i = 0; i < a.pieces.length; i++) {
|
|
352
|
+
for (let j = 0; j < b.pieces.length; j++) {
|
|
353
|
+
const ba = boxesA[i];
|
|
354
|
+
const bb = boxesB[j];
|
|
355
|
+
if (ba.x1 <= bb.x0 || bb.x1 <= ba.x0 || ba.y1 <= bb.y0 || bb.y1 <= ba.y0) continue;
|
|
356
|
+
if (!piecesOverlap(a.pieces[i], b.pieces[j])) continue;
|
|
357
|
+
// The verdict is the separating-axis test's; the clipped polygon only
|
|
358
|
+
// measures it for the sentence, and where rounding leaves it degenerate
|
|
359
|
+
// the two pieces' boxes stand in for where they meet.
|
|
360
|
+
const shared = sharedPolygon(a.pieces[i], b.pieces[j]);
|
|
361
|
+
if (shared.length >= 3) area = Math.max(area, Math.abs(signedArea(shared)));
|
|
362
|
+
const sb = shared.length >= 3 ? boxOf(shared) : { x0: Math.max(ba.x0, bb.x0), y0: Math.max(ba.y0, bb.y0), x1: Math.min(ba.x1, bb.x1), y1: Math.min(ba.y1, bb.y1) };
|
|
363
|
+
box = box === null ? sb : { x0: Math.min(box.x0, sb.x0), y0: Math.min(box.y0, sb.y0), x1: Math.max(box.x1, sb.x1), y1: Math.max(box.y1, sb.y1) };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return box === null ? null : { area, box };
|
|
367
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness an assertion body runs in, and the report it fills (issue
|
|
3
|
+
* #1025, step 4c of #380).
|
|
4
|
+
*
|
|
5
|
+
* One harness for both sides. `validate()` runs a body over the facts it reads
|
|
6
|
+
* off what spine-core loaded; the model side (`./model/index.ts`) runs the same
|
|
7
|
+
* body over the facts it reads off the model document and the core. What makes
|
|
8
|
+
* a line the same line on both sides is that the body is one function and the
|
|
9
|
+
* harness deciding PASS, SKIP, FAIL and PROF around it is one function too — a
|
|
10
|
+
* second copy of `check` would be a second place a verdict could be decided
|
|
11
|
+
* differently.
|
|
12
|
+
*
|
|
13
|
+
* Moved here from `validate()`, the rule unchanged: an assertion is recorded
|
|
14
|
+
* as passed only when its body neither failed nor skipped, a body that throws
|
|
15
|
+
* is a FAIL whose detail is `threw: <message>`, and a rule the profile does not
|
|
16
|
+
* carry is not run at all and is recorded as out of profile.
|
|
17
|
+
*
|
|
18
|
+
* This module links nothing from the runtime, directly or through what it
|
|
19
|
+
* imports — the condition for the model side to use it, held by the selftest.
|
|
20
|
+
*/
|
|
21
|
+
import { kindRunsUnder, type AssertionKind, type AssertionProfile } from './kinds.ts';
|
|
22
|
+
|
|
23
|
+
/** One failure: the assertion's code and the sentence naming the object, the value found and the value required. */
|
|
24
|
+
export interface Failure {
|
|
25
|
+
assertion: string;
|
|
26
|
+
detail: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a body is handed: how to fail, how to declare it had nothing to measure, and the stats line it may add to. */
|
|
30
|
+
export interface Verdicts {
|
|
31
|
+
fail: (assertion: string, detail: string) => void;
|
|
32
|
+
skip: (assertion: string, reason: string) => void;
|
|
33
|
+
stats: Record<string, number | string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The four lists and the stats a run of bodies leaves — the shape `validate()` returns, less the profile. */
|
|
37
|
+
export interface VerdictLists {
|
|
38
|
+
failures: Failure[];
|
|
39
|
+
/** Assertions that ran and passed, in order. */
|
|
40
|
+
passed: string[];
|
|
41
|
+
/** Assertions that had nothing to run against, with the reason. */
|
|
42
|
+
skipped: Array<{ assertion: string; reason: string }>;
|
|
43
|
+
/** Assertions the profile does not apply, with their kind. */
|
|
44
|
+
profileSkipped: Array<{ assertion: string; kind: 'renderer' | 'archetype' }>;
|
|
45
|
+
stats: Record<string, number | string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A harness: the lists it fills, the two calls a body makes, and `check`, which runs a body. */
|
|
49
|
+
export interface VerdictHarness extends VerdictLists {
|
|
50
|
+
fail: Verdicts['fail'];
|
|
51
|
+
skip: Verdicts['skip'];
|
|
52
|
+
/**
|
|
53
|
+
* Run one assertion; record it as passed only if it neither failed nor skipped,
|
|
54
|
+
* and do not run it at all when the profile does not carry that kind of rule.
|
|
55
|
+
*
|
|
56
|
+
* The unknown-assertion throw is deliberate: an assertion with no entry in
|
|
57
|
+
* the kind table would otherwise pick a profile by accident, and picking
|
|
58
|
+
* wrong means either a rule that never runs or a rule that fires on
|
|
59
|
+
* everybody's data.
|
|
60
|
+
*
|
|
61
|
+
* The body's return value is handed back — `undefined` when the assertion did
|
|
62
|
+
* not run or threw. `validate()`'s A00 uses it so that the loaded atlas and
|
|
63
|
+
* skeleton can be `const`: assigning them from inside this callback leaves
|
|
64
|
+
* the type checker unable to see that they were ever set, and every later read
|
|
65
|
+
* of `atlas.pages` or `data.bones` becomes a property of `never`.
|
|
66
|
+
*/
|
|
67
|
+
check: <T>(assertion: string, body: () => T) => T | undefined;
|
|
68
|
+
/** `fail`, `skip` and `stats`, as one value to hand a body. */
|
|
69
|
+
verdicts: Verdicts;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A fresh harness for one run under `profile`. `kinds` is the kind table the
|
|
74
|
+
* run's codes are looked up in, and `who` names the run in the throw for a code
|
|
75
|
+
* the table lacks — `validate` for the gate, so its message is the one it was.
|
|
76
|
+
*/
|
|
77
|
+
export function verdictHarness(profile: AssertionProfile, kinds: Readonly<Record<string, AssertionKind>>, who: string): VerdictHarness {
|
|
78
|
+
const failures: Failure[] = [];
|
|
79
|
+
const passed: string[] = [];
|
|
80
|
+
const skipped: VerdictLists['skipped'] = [];
|
|
81
|
+
const profileSkipped: VerdictLists['profileSkipped'] = [];
|
|
82
|
+
const stats: Record<string, number | string> = {};
|
|
83
|
+
const fail = (assertion: string, detail: string): void => {
|
|
84
|
+
failures.push({ assertion, detail });
|
|
85
|
+
};
|
|
86
|
+
/** Declare that an assertion had nothing to check, and why. */
|
|
87
|
+
const skip = (assertion: string, reason: string): void => {
|
|
88
|
+
skipped.push({ assertion, reason });
|
|
89
|
+
};
|
|
90
|
+
const check = <T>(assertion: string, body: () => T): T | undefined => {
|
|
91
|
+
const kind = kinds[assertion];
|
|
92
|
+
if (!kind) throw new Error(`${who}: assertion "${assertion}" has no ASSERTION_KIND entry`);
|
|
93
|
+
if (!kindRunsUnder(kind, profile)) {
|
|
94
|
+
profileSkipped.push({ assertion, kind: kind === 'renderer' ? 'renderer' : 'archetype' });
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const before = failures.length;
|
|
98
|
+
const skippedBefore = skipped.length;
|
|
99
|
+
let result: T | undefined;
|
|
100
|
+
try {
|
|
101
|
+
result = body();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
fail(assertion, `threw: ${(err as Error).message}`);
|
|
104
|
+
}
|
|
105
|
+
if (failures.length === before && skipped.length === skippedBefore) passed.push(assertion);
|
|
106
|
+
return result;
|
|
107
|
+
};
|
|
108
|
+
return { failures, passed, skipped, profileSkipped, stats, fail, skip, check, verdicts: { fail, skip, stats } };
|
|
109
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deepest inward advance the animation data asks for, in axis pixels —
|
|
3
|
+
* shared by A29 and A30 (issue #1025, cut 4c-4 of #380), moved here from
|
|
4
|
+
* `src/validate.ts` unchanged but for what it reads: the bone timelines are a
|
|
5
|
+
* fact (`./facts/bone_timelines.ts`) rather than the raw JSON.
|
|
6
|
+
*
|
|
7
|
+
* Shared by A29 and A30 because they bound the SAME quantity against two
|
|
8
|
+
* different measured facts. Two things spend the same clearance and so are added:
|
|
9
|
+
*
|
|
10
|
+
* * the stroke — a translateX on a bone in the axis subtree. A24 guarantees
|
|
11
|
+
* there is no hidden screen-space component to miss.
|
|
12
|
+
* * the mass bone's own inward keys. It typically hangs outside the axis
|
|
13
|
+
* subtree, so its keys are screen-space by design and get PROJECTED onto the
|
|
14
|
+
* axis rather than read as axis coordinates. Ignoring them would let a rig
|
|
15
|
+
* pass while a recoil key closed the last few pixels.
|
|
16
|
+
*
|
|
17
|
+
* Links nothing from the runtime.
|
|
18
|
+
*/
|
|
19
|
+
import type { RigInfo } from '../types.ts';
|
|
20
|
+
import type { BoneTimelineFacts } from './facts/bone_timelines.ts';
|
|
21
|
+
import { isObj, type Json } from './values.ts';
|
|
22
|
+
|
|
23
|
+
export function deepestInwardAdvance({ boneTimelines }: BoneTimelineFacts, rig: RigInfo): { total: number; describe: () => string } {
|
|
24
|
+
const subtree = new Set(rig.axisSubtree);
|
|
25
|
+
let strokeMax = 0;
|
|
26
|
+
let strokeWhere = '';
|
|
27
|
+
let massMax = 0;
|
|
28
|
+
let massWhere = '';
|
|
29
|
+
for (const { animation: animName, bone: boneName, timelines } of boneTimelines) {
|
|
30
|
+
if (!isObj(timelines)) continue;
|
|
31
|
+
const keys = (timelines as Json).translate;
|
|
32
|
+
if (!Array.isArray(keys)) continue;
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
if (!isObj(key)) continue;
|
|
35
|
+
if (subtree.has(boneName)) {
|
|
36
|
+
// +x is inward along the axis; a retracted key is negative and spends
|
|
37
|
+
// no clearance, so only the inward extreme matters.
|
|
38
|
+
const x = Number(key.x ?? 0);
|
|
39
|
+
if (Number.isFinite(x) && x > strokeMax) {
|
|
40
|
+
strokeMax = x;
|
|
41
|
+
strokeWhere = `${animName}.${boneName} t=${String(key.time ?? 0)}`;
|
|
42
|
+
}
|
|
43
|
+
} else if (boneName === rig.massBone && rig.inwardUnit) {
|
|
44
|
+
const inward = Number(key.x ?? 0) * rig.inwardUnit[0] + Number(key.y ?? 0) * rig.inwardUnit[1];
|
|
45
|
+
if (Number.isFinite(inward) && inward > massMax) {
|
|
46
|
+
massMax = inward;
|
|
47
|
+
massWhere = `${animName}.${boneName} t=${String(key.time ?? 0)}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
total: strokeMax + massMax,
|
|
54
|
+
describe: () =>
|
|
55
|
+
`deepest inward advance is ${(strokeMax + massMax).toFixed(3)}px (stroke ${strokeMax.toFixed(3)} at ${strokeWhere}` +
|
|
56
|
+
`${massMax > 0 ? ` + mass ${massMax.toFixed(3)} at ${massWhere}` : ''})`,
|
|
57
|
+
};
|
|
58
|
+
}
|