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,1400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rigc's own core: it reads the compiled model as `build` writes it
|
|
3
|
+
* (`rigc-compiled/3`, and `/2` before issue #1026 and `/1` before issue #1016;
|
|
4
|
+
* `skeleton.model.json`)
|
|
5
|
+
* and poses it (issue #925, step 2a
|
|
6
|
+
* of issue #380). It is the second dumper `tools/pose_oracle.ts` was shaped
|
|
7
|
+
* for: what it poses is written into the same `pose-oracle/2` document the
|
|
8
|
+
* runtime's dump is, and `compare` holds the two to each other.
|
|
9
|
+
*
|
|
10
|
+
* ## What it poses, and what it does not yet
|
|
11
|
+
*
|
|
12
|
+
* Three constructs are here, in the runtime's own order (§5 of the design on
|
|
13
|
+
* issue #380): **the setup pose of every bone** — its world origin and matrix,
|
|
14
|
+
* its active flag and its parent (issue #925) — **the slots at the setup
|
|
15
|
+
* pose** — what each shows, its colour and dark colour, the region path
|
|
16
|
+
* the shown attachment names (issue #928, below) and its blend mode (issue
|
|
17
|
+
* #933) — and **every drawn
|
|
18
|
+
* attachment's world vertices at the setup pose** — a region's corners, a
|
|
19
|
+
* mesh's vertices and a clipping polygon (issue #931, `./vertices.ts`, whose
|
|
20
|
+
* header states each measured rule). All are rounded as the oracle rounds
|
|
21
|
+
* (`gridRound`). A fourth, **every animation's bone and slot timelines at the
|
|
22
|
+
* oracle's sample times** (issue #936), is its own module, `./animation.ts`,
|
|
23
|
+
* which this reader calls for each animation record. Its remainder (issue
|
|
24
|
+
* #955) — the draw order at setup and at a sample (`./draw_order.ts`), the
|
|
25
|
+
* deform and sequence timelines, which move what the attachments and clips
|
|
26
|
+
* draw at setup (under a slider) and at a sample (`./deform.ts`), and the
|
|
27
|
+
* events a sample fires (`./events.ts`) — is posed too. With the physics
|
|
28
|
+
* parameters (issue #956, `./constraints_physics.ts`) no block of the
|
|
29
|
+
* oracle's document is left: `NOT_ADMITTED` is empty. The `clipped` block
|
|
30
|
+
* (issue #964, `./clipping.ts`) — the triangles drawn under a clip, as the
|
|
31
|
+
* runtime's clipper returns them — is posed at setup and at every sample,
|
|
32
|
+
* and left out by name where a clip that is not strictly convex, or an
|
|
33
|
+
* inverse one, starts.
|
|
34
|
+
*
|
|
35
|
+
* **Constraints (construct 5, issue #938, `./constraints.ts`).** The oracle
|
|
36
|
+
* poses the setup pose with every constraint applied, so a bone a constraint
|
|
37
|
+
* moves is not where its hierarchy alone puts it. The core applies the ik,
|
|
38
|
+
* transform, path, physics and slider constraints in the document's order
|
|
39
|
+
* after the hierarchy, as the runtime's update order does (`./constraints.ts`'s
|
|
40
|
+
* header states it with its measurements, `./constraints_path.ts`'s the
|
|
41
|
+
* path's; a physics constraint under `Physics.none` applies nothing, and
|
|
42
|
+
* under the stepped phase — `poseSetup`'s `physics` context, the reset —
|
|
43
|
+
* integrates, `./constraints_physics.ts`, whose `poseSteppedAnimations`
|
|
44
|
+
* walks the oracle's `--physics step` schedule; a slider applies an animation,
|
|
45
|
+
* `./constraints_slider.ts`); a path the core cannot pose exactly (skins
|
|
46
|
+
* that disagree over the curve it walks) leaves the setup bones out naming
|
|
47
|
+
* it (`constraintsAbsentWhy`). Before issue #938, measured over the nineteen
|
|
48
|
+
* recipes `tools/emit_hashes.ts` generates: seven declared a constraint, and
|
|
49
|
+
* on each the runtime's setup pose differed from the hierarchy's alone; all
|
|
50
|
+
* seven read IDENTICAL now. The `paths` and `pathAttachments` blocks — each
|
|
51
|
+
* path constraint's settings and each path attachment's flags and
|
|
52
|
+
* `lengths`, as the runtime reads them — are written from the model
|
|
53
|
+
* (`pathRows`, `pathAttachmentRows`).
|
|
54
|
+
*
|
|
55
|
+
* 🔸 **Active** is measured rather than assumed. Posed through the runtime by
|
|
56
|
+
* `tools/pose_oracle.ts dump` on a hand-written skeleton (issue #925's
|
|
57
|
+
* report; held by the core suite's `CO07`): a bone that is not skin-required
|
|
58
|
+
* is active; a skin-required bone is active exactly when the applied skin
|
|
59
|
+
* names it or names a bone below it — a skin-required parent of a named bone
|
|
60
|
+
* is active, and a bone that is not skin-required stays active under an
|
|
61
|
+
* inactive parent. The oracle's `--skin all` applies every skin at once, so
|
|
62
|
+
* under it `active` means: not skin-required, or named — itself or a bone
|
|
63
|
+
* below it — by ANY skin's `bones` list. Under `--skin <name>` (issue #932,
|
|
64
|
+
* `underSkin`) it is the named skin's list alone — the default skin's does
|
|
65
|
+
* not count (`./skins.ts`, measured). An inactive bone is not posed
|
|
66
|
+
* (`worldTransforms` in `./world.ts`).
|
|
67
|
+
*
|
|
68
|
+
## The slots at the setup pose (issue #928)
|
|
69
|
+
*
|
|
70
|
+
* Every rule below was measured by posing a hand-written skeleton through
|
|
71
|
+
* `tools/pose_oracle.ts dump` (spine-core 4.3.13, `--skin all`) and reading
|
|
72
|
+
* the slot rows it printed; the core suite's `CO11` holds the same skeleton
|
|
73
|
+
* against the core at tolerance 0.
|
|
74
|
+
*
|
|
75
|
+
* - **Which attachment a slot shows.** A slot whose setup placeholder is
|
|
76
|
+
* `null` shows nothing (`null`). Otherwise the placeholder is looked up in
|
|
77
|
+
* every skin; one skin filling it — the default or a named one alone —
|
|
78
|
+
* shows that record. A placeholder no skin fills shows nothing: the row
|
|
79
|
+
* reads `null` for the attachment and for the path, not the placeholder's
|
|
80
|
+
* name.
|
|
81
|
+
* - ⚠️ **Several skins filling one placeholder: the LAST in the Spine FILE's
|
|
82
|
+
* skin order wins — and the model does not hold that order.** Three skins
|
|
83
|
+
* filling one placeholder with three names, in three file orders
|
|
84
|
+
* (`default, s1, s2`; `s2, s1, default`; `s1, default, s2`), showed `s2`,
|
|
85
|
+
* `default` and `s2`'s name: the last one listed, every time — the oracle's
|
|
86
|
+
* `--skin all` merges the skins in file order, a later one replacing an
|
|
87
|
+
* earlier one's entry. The file's order is not the model's: the Spine
|
|
88
|
+
* emitter writes `default` first and the rest in the editor's order
|
|
89
|
+
* (`editorSkinOrder` in `src/compile.ts`), so a rig built with skins
|
|
90
|
+
* `default, zulu, alpha` has a model listing them so and a file listing
|
|
91
|
+
* `default, alpha, zulu`; with `zulu` and `alpha` filling one placeholder,
|
|
92
|
+
* spine-core showed `zulu`'s attachment, and "last in the model's order"
|
|
93
|
+
* would have shown `alpha`'s (measured through `ingest` and `build`; the
|
|
94
|
+
* core suite's `CO12` builds it). That order is a Spine spelling the model
|
|
95
|
+
* rightly does not carry (census §4's class), so the core does not guess it:
|
|
96
|
+
* where every skin filling a slot's setup placeholder gives the same row —
|
|
97
|
+
* the same shown name and path — the order cannot matter and the row is
|
|
98
|
+
* posed; where they differ, the whole `setup.slots` block is absent, naming
|
|
99
|
+
* the slot, the placeholder and the skins. Posing one skin at a time
|
|
100
|
+
* (`--skin <name>`, `underSkin`, issue #932) has no order to know — the
|
|
101
|
+
* named skin's record, else the default skin's (`./skins.ts`) — so the
|
|
102
|
+
* per-skin dumps judge what the merged view leaves out, and
|
|
103
|
+
* `tools/core_gate.ts` runs one per skin on a row declaring several.
|
|
104
|
+
* - **The name shown** is the record's own `name` where the model states one,
|
|
105
|
+
* else the placeholder: a region filed under `p` with `path: "other"` shows
|
|
106
|
+
* `p`; one stating `name: "n"` shows `n`.
|
|
107
|
+
* - **The region path** is the record's `path` where stated, else the name
|
|
108
|
+
* shown — for a region, a mesh and a linked mesh (a region stating
|
|
109
|
+
* `name: "n"` and no path reads path `n`; a linked mesh stating
|
|
110
|
+
* `path: "lm"` reads `lm`; a region or mesh with a `sequence` reads its
|
|
111
|
+
* stated `path` as written, `seq` and not a numbered frame). A bounding
|
|
112
|
+
* box, a clipping polygon and a path attachment read `null`, as does a
|
|
113
|
+
* point, which rigc does not emit.
|
|
114
|
+
* - **Colour.** No colour stated reads `1, 1, 1, 1`. A stated colour reads
|
|
115
|
+
* each channel as its two hex digits over 255 (`ff800040` reads
|
|
116
|
+
* `1, 0.501961, 0, 0.25098`; upper case reads the same); six digits read
|
|
117
|
+
* an alpha of 1. The attachment's own colour does not enter the slot's row
|
|
118
|
+
* (a region tinted `00000080` under an unstated slot colour read white).
|
|
119
|
+
* Every one of the 256 byte values in every channel is held by `CO11`.
|
|
120
|
+
* - **Dark colour** is `null` when the slot states none, else its first
|
|
121
|
+
* three channels read the same way; eight digits read the same three, the
|
|
122
|
+
* fourth unread.
|
|
123
|
+
* - 🚫 **Any other spelling is refused by `readModel`, by name.** The
|
|
124
|
+
* runtime's reading of one is not a colour: `ff80004` (seven digits) read
|
|
125
|
+
* alpha 1, `zz800040` read a red of NaN, `f` read red 0.058824 and green
|
|
126
|
+
* NaN, and an empty colour read white while an empty dark colour read none.
|
|
127
|
+
* Reproducing that would be copying a parser's accidents, not posing a slot.
|
|
128
|
+
* - **A slot on an inactive bone still shows its attachment** (a slot on a
|
|
129
|
+
* skin-required bone no skin names read its region and path).
|
|
130
|
+
* - ⚠️ **A slider poses slots at setup; the other four constraint kinds do
|
|
131
|
+
* not.** A slider applies an animation, and the oracle's setup applies
|
|
132
|
+
* constraints: a hand-written slider whose animation keys a slot's `rgba`
|
|
133
|
+
* to `ff000080` and its attachment to `q` turned that slot's setup row from
|
|
134
|
+
* `r, 1, 1, 1, 1` to `q, 1, 0, 0, 0.501961`, with its dial bone and without
|
|
135
|
+
* one. Issue #928 left `setup.slots` absent wherever a slider keys a slot;
|
|
136
|
+
* since issue #938 each slider's slot timelines are applied after the
|
|
137
|
+
* bones, in constraint order, at the time and mix it was applied with
|
|
138
|
+
* (`applySliderSlots` in `./constraints_slider.ts`, which states the blend).
|
|
139
|
+
* The slots are still absent, naming the slider, where the bones its time
|
|
140
|
+
* is read from are absent. An ik, transform, path or physics constraint
|
|
141
|
+
* moves bones only.
|
|
142
|
+
* - **The blend mode** (issue #933) is the row's last cell, as the runtime's
|
|
143
|
+
* enum names it: `Normal`, `Additive`, `Multiply`, `Screen`. A slot that
|
|
144
|
+
* states none reads `Normal`. The runtime reads a stated blend by
|
|
145
|
+
* upper-casing its first letter and looking the result up: `additive` and
|
|
146
|
+
* `Additive` both read `Additive`, while `ADDITIVE` and `mUlTiPlY` read no
|
|
147
|
+
* mode at all (the dump's `null`) and an empty string threw on load. So the
|
|
148
|
+
* core reads exactly the eight spellings whose first letter folds to one of
|
|
149
|
+
* the four names and `readModel` refuses every other by name. The mode is
|
|
150
|
+
* the slot's data, not its pose: an animation keying the slot's colour left
|
|
151
|
+
* it where setup put it, so a sample's row carries the setup's mode.
|
|
152
|
+
*
|
|
153
|
+
* ## The evaluator
|
|
154
|
+
*
|
|
155
|
+
* World transforms come from the core's own evaluator, `worldTransforms` in
|
|
156
|
+
* `./world.ts`, written from what a bone's fields mean and from measurement
|
|
157
|
+
* against the runtime's dump; its header states each measured choice. It is
|
|
158
|
+
* not reached through `src/transform.ts`'s `computeWorldTransforms`: that is
|
|
159
|
+
* the compiler's adapter over this same evaluator (issues #1015 and #1021 —
|
|
160
|
+
* the same arithmetic since #1021), and the core importing the compiler would
|
|
161
|
+
* be a cycle. The compiler's evaluator before #1015, called as it was, read
|
|
162
|
+
* IDENTICAL on 1 of the 12 recipes the core poses (DIFF on 11, worst 61
|
|
163
|
+
* millionths against a tolerance of one — issue #925's report). The owner's
|
|
164
|
+
* decision is recorded on issue #380. `poseSetup` takes a
|
|
165
|
+
* `CorePlant` — the evaluator, the skin resolution, the colour and blend readings — so
|
|
166
|
+
* the suite's plants can pass a copy of one of them; nothing else passes one.
|
|
167
|
+
*
|
|
168
|
+
* ## Purity
|
|
169
|
+
*
|
|
170
|
+
* `src/` is pure and this directory is held to it by the core suite's tree
|
|
171
|
+
* rule (`selftest.ts`, band `CO`): no clock, no randomness, no network, no
|
|
172
|
+
* child process, no file system — `readModel` takes the document's TEXT — and
|
|
173
|
+
* nothing from the Spine runtime package, as a value or as a type.
|
|
174
|
+
*/
|
|
175
|
+
import type { ModelAtlasRect, ModelBone, ModelEditorOrder, ModelPage, ModelPageRegion, ModelSlot, ModelStage, ModelVertices, SkinTableEntry } from '../model.ts';
|
|
176
|
+
import { worldTransforms, type CoreWorld } from './world.ts';
|
|
177
|
+
import { readAnimationTimelines, type CoreAnimationTimelines } from './animation.ts';
|
|
178
|
+
import { readEventDefs, type CoreEventDef } from './events.ts';
|
|
179
|
+
import { poseGeometry, readGeometry, type CoreAttachmentRow, type CoreClipRow, type CoreGeometry, type DrawWalk, type RegionPoser, type ShownGeometry, type VertexPoser } from './vertices.ts';
|
|
180
|
+
import type { CoreClippedRow, ShapeClipper, TriangleClipper } from './clipping.ts';
|
|
181
|
+
import { applyConstraints, constraintsAbsentWhy, readConstraintRecord, readConstraintTimelines, solverRules, type ConstraintPlant, type CoreConstraintRecord, type CoreConstraintTimelines, type SolverRules } from './constraints.ts';
|
|
182
|
+
import { readPathRecord } from './constraints_path.ts';
|
|
183
|
+
import { readPhysicsRecord, type PhysicsStepContext, type PhysicsStepper } from './constraints_physics.ts';
|
|
184
|
+
import { appliedSkins, CORE_ALL_SKINS, fillingSkins, listedByAppliedSkin, lookupSkins, slotTimelinesApply, type SlotTimelineGate } from './skins.ts';
|
|
185
|
+
import { applySliderSlots, readSliderRecord, type SliderApplication, type SlotPoseState } from './constraints_slider.ts';
|
|
186
|
+
import { attachmentStates, type DeformBlender, type DeformEvaluator, type SequenceEvaluator } from './deform.ts';
|
|
187
|
+
import { drawOrderAt, type DrawOrderEvaluator } from './draw_order.ts';
|
|
188
|
+
import type { EventsFired } from './events.ts';
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The document spec `build` writes today (issue #1026): the `pages` section
|
|
192
|
+
* (issue #1016) with each page's `pma` and `scale`, and the `stage` and
|
|
193
|
+
* `editorOrder` sections — what only the Spine files beside a `/2` document
|
|
194
|
+
* state.
|
|
195
|
+
*/
|
|
196
|
+
export const CORE_DOCUMENT_SPEC = 'rigc-compiled/3';
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The spec before issue #1026: `/3` without `stage`, `editorOrder` and the
|
|
200
|
+
* pages' `pma` and `scale`. Read as before, with `stated` null — a reader that
|
|
201
|
+
* needs those reads them off the Spine files beside the document, and says so
|
|
202
|
+
* (`src/render.ts`'s poser line, the survey's order).
|
|
203
|
+
*/
|
|
204
|
+
export const CORE_DOCUMENT_SPEC_2 = 'rigc-compiled/2';
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The spec before issue #1016: `/2` without `pages`. Read as before — the
|
|
208
|
+
* draw then takes where each region sits from the atlas beside the document
|
|
209
|
+
* (`corePoser` in `src/render_core.ts` says so by name), because a `/1`
|
|
210
|
+
* document does not state it.
|
|
211
|
+
*/
|
|
212
|
+
export const CORE_DOCUMENT_SPEC_1 = 'rigc-compiled/1';
|
|
213
|
+
|
|
214
|
+
/** Every spec this reader takes, newest first. */
|
|
215
|
+
export const CORE_DOCUMENT_SPECS: readonly string[] = [CORE_DOCUMENT_SPEC, CORE_DOCUMENT_SPEC_2, CORE_DOCUMENT_SPEC_1];
|
|
216
|
+
|
|
217
|
+
/** Who posed a dump the core wrote — the oracle document's `dumper`. */
|
|
218
|
+
export const CORE_DUMPER = 'rigc-core';
|
|
219
|
+
|
|
220
|
+
/** A refusal about the document read. Every problem is collected and named in one throw. */
|
|
221
|
+
export class CoreInputError extends Error {}
|
|
222
|
+
|
|
223
|
+
/** The document's sections after `spec`, in its key order (`modelDocument` in `src/model.ts`). */
|
|
224
|
+
export const CORE_SECTIONS = [
|
|
225
|
+
// Issue #1026: `stage` (the header's setup-pose box, or null) and `editorOrder` (the order the Spine file lists skins, slot keys and animations in). `/3` only.
|
|
226
|
+
'referenceScale', 'stage', 'bones', 'slots', 'skins', 'constraints', 'events', 'animations', 'editorOrder',
|
|
227
|
+
'images', 'pageGrids', 'droppedStates', 'absentParts', 'meshBones', 'meshes', 'physics', 'deformTransforms', 'trackDerivations', 'rig',
|
|
228
|
+
// Issue #1016: where each region sits on its page, in the atlas written beside the document (`pagesOfAtlas` in `src/model.ts`). `/2` only.
|
|
229
|
+
'pages',
|
|
230
|
+
// Issue #968: the digest of the `skeleton.json` written beside the document (`spineFileSha256` in `src/model.ts`).
|
|
231
|
+
'spine',
|
|
232
|
+
] as const;
|
|
233
|
+
|
|
234
|
+
/** The sections of a `rigc-compiled/2` document: `CORE_SECTIONS` without `stage` and `editorOrder`. */
|
|
235
|
+
export const CORE_SECTIONS_2: readonly string[] = CORE_SECTIONS.filter((key) => key !== 'stage' && key !== 'editorOrder');
|
|
236
|
+
|
|
237
|
+
/** The sections of a `rigc-compiled/1` document: `CORE_SECTIONS_2` without `pages`. */
|
|
238
|
+
export const CORE_SECTIONS_1: readonly string[] = CORE_SECTIONS_2.filter((key) => key !== 'pages');
|
|
239
|
+
|
|
240
|
+
/** The fields of a page and of a region in the `pages` section, as the writer lists them (`MODEL_PAGE_FIELDS`, `MODEL_PAGE_REGION_FIELDS` in `src/model.ts`, mirrored). */
|
|
241
|
+
export const CORE_PAGE_FIELDS = ['name', 'width', 'height', 'pma', 'scale', 'regions'] as const;
|
|
242
|
+
/** A `rigc-compiled/2` page's fields: `CORE_PAGE_FIELDS` without `pma` and `scale` (issue #1026). */
|
|
243
|
+
export const CORE_PAGE_FIELDS_2: readonly string[] = CORE_PAGE_FIELDS.filter((key) => key !== 'pma' && key !== 'scale');
|
|
244
|
+
/** The stage's fields, as the writer lists them (`MODEL_STAGE_FIELDS` in `src/model.ts`, mirrored). */
|
|
245
|
+
export const CORE_STAGE_FIELDS = ['x', 'y', 'width', 'height'] as const;
|
|
246
|
+
/** The stage box's fields (issue #1168), as the writer lists them (`MODEL_STAGE_BOX_FIELDS` in `src/model.ts`, mirrored). */
|
|
247
|
+
export const CORE_STAGE_BOX_FIELDS = ['slot', 'attachment'] as const;
|
|
248
|
+
export const CORE_PAGE_REGION_FIELDS = ['name', 'x', 'y', 'width', 'height', 'offsetX', 'offsetY', 'originalWidth', 'originalHeight', 'degrees', 'index'] as const;
|
|
249
|
+
|
|
250
|
+
/** The fields a bone record may carry, as the writer lists them. A field outside this list is refused. */
|
|
251
|
+
export const CORE_BONE_FIELDS = ['name', 'parent', 'length', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'inheritMode', 'skinRequired', 'editor'] as const;
|
|
252
|
+
const BONE_NUMBERS = ['length', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY'] as const;
|
|
253
|
+
/** The fields a slot record may carry. */
|
|
254
|
+
export const CORE_SLOT_FIELDS = ['name', 'bone', 'setup', 'color', 'dark', 'blend'] as const;
|
|
255
|
+
/** The fields a skin record may carry. */
|
|
256
|
+
export const CORE_SKIN_FIELDS = ['name', 'bones', 'constraints', 'attachments'] as const;
|
|
257
|
+
/**
|
|
258
|
+
* The fields each attachment kind's record may carry, as the writer lists them
|
|
259
|
+
* (`ATTACHMENT_FIELDS` in `src/model.ts`, mirrored as the bone and slot lists
|
|
260
|
+
* are). A field outside its kind's list is refused.
|
|
261
|
+
*/
|
|
262
|
+
export const CORE_ATTACHMENT_FIELDS: Readonly<Record<SkinTableEntry['kind'], readonly string[]>> = {
|
|
263
|
+
mesh: ['kind', 'name', 'path', 'color', 'uvs', 'triangles', 'vertices', 'hull', 'edges', 'width', 'height', 'sequence'],
|
|
264
|
+
boundingbox: ['kind', 'name', 'vertexCount', 'vertices', 'editorColor'],
|
|
265
|
+
clipping: ['kind', 'name', 'end', 'convex', 'inverse', 'vertexCount', 'vertices', 'editorColor'],
|
|
266
|
+
path: ['kind', 'name', 'closed', 'constantSpeed', 'vertexCount', 'vertices', 'lengths', 'editorColor'],
|
|
267
|
+
region: ['kind', 'name', 'path', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'width', 'height', 'color', 'sequence', 'atlas'],
|
|
268
|
+
linkedmesh: ['kind', 'name', 'path', 'source', 'skin', 'slot', 'timelines', 'width', 'height', 'color', 'sequence'],
|
|
269
|
+
};
|
|
270
|
+
/** The kinds whose shown record names an atlas region — the ones the oracle's row gives a path (the header's measurement). */
|
|
271
|
+
export const CORE_REGION_KINDS: ReadonlySet<SkinTableEntry['kind']> = new Set(['region', 'mesh', 'linkedmesh']);
|
|
272
|
+
/** A colour the core reads: six or eight hex digits, either case (the header's measurement; anything else is refused). */
|
|
273
|
+
const HEX_COLOUR = /^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
|
|
274
|
+
/** The blend modes, as the runtime's enum names them (the header's measurement). */
|
|
275
|
+
export const CORE_BLEND_MODES = ['Normal', 'Additive', 'Multiply', 'Screen'] as const;
|
|
276
|
+
export type CoreBlendMode = (typeof CORE_BLEND_MODES)[number];
|
|
277
|
+
/** A stated blend's reading: its first letter upper-cased, when that is one of the four names; `null` otherwise. */
|
|
278
|
+
export function foldBlend(stated: string): CoreBlendMode | null {
|
|
279
|
+
const folded = `${stated.slice(0, 1).toUpperCase()}${stated.slice(1)}`;
|
|
280
|
+
return (CORE_BLEND_MODES as readonly string[]).includes(folded) ? (folded as CoreBlendMode) : null;
|
|
281
|
+
}
|
|
282
|
+
/** The five constraint kinds a document names. */
|
|
283
|
+
export const CORE_CONSTRAINT_KINDS = ['ik', 'transform', 'path', 'physics', 'slider'] as const;
|
|
284
|
+
export type CoreConstraintKind = (typeof CORE_CONSTRAINT_KINDS)[number];
|
|
285
|
+
/** The five inherit modes, as the rig spec spells them once its first letter is lower-cased. */
|
|
286
|
+
export const CORE_INHERIT_MODES = ['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection'] as const;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The fields of a region's atlas rectangle (`ModelAtlasRect` in `src/model.ts`,
|
|
290
|
+
* issue #935), every one required and a finite number.
|
|
291
|
+
*/
|
|
292
|
+
export const CORE_ATLAS_RECT_FIELDS = ['width', 'height', 'offsetX', 'offsetY', 'originalWidth', 'originalHeight'] as const;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* One attachment record, as far as the slots read it: its kind, and its own
|
|
296
|
+
* name and region path where stated — and a region's atlas rectangle where the
|
|
297
|
+
* record carries one, `null` where the build had no source for it (issue #935);
|
|
298
|
+
* and, for the world vertices (issue #931, `./vertices.ts`), the geometry the
|
|
299
|
+
* record carries, checked field by field (`readGeometry`).
|
|
300
|
+
*/
|
|
301
|
+
export interface CoreAttachment {
|
|
302
|
+
kind: SkinTableEntry['kind'];
|
|
303
|
+
name?: string;
|
|
304
|
+
path?: string;
|
|
305
|
+
atlas?: ModelAtlasRect | null;
|
|
306
|
+
geometry?: CoreGeometry;
|
|
307
|
+
/** How many frames the record's series holds, where it states a `sequence` — what a sequence key's index is checked against (`./deform.ts`). */
|
|
308
|
+
sequenceCount?: number;
|
|
309
|
+
/** A linked mesh's `timelines`: whether it plays its source's deform and sequence timelines (`./deform.ts`). */
|
|
310
|
+
timelines?: boolean;
|
|
311
|
+
/** The record's `color`, six or eight hex digits, where stated (issue #966): what the raw entry's attachment colour reads (`./raw.ts`); white where unstated. */
|
|
312
|
+
color?: string;
|
|
313
|
+
/** The frame its `sequence` shows at setup (`sequence.setup`, 0 unstated), where it states one (issue #966): the raw entry's sequence index when no timeline set another. */
|
|
314
|
+
sequenceSetup?: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** One skin, as far as these constructs read it: its name, the bones and constraints it activates, and its table — slot, then placeholder. */
|
|
318
|
+
export interface CoreSkin {
|
|
319
|
+
name: string;
|
|
320
|
+
bones: string[];
|
|
321
|
+
/** The constraints it activates, by kind — a kind the document leaves out lists none (issue #932, `./skins.ts`). */
|
|
322
|
+
constraints: Record<CoreConstraintKind, string[]>;
|
|
323
|
+
attachments: Record<string, Record<string, CoreAttachment>>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** One constraint, as far as these constructs read it: its kind and name, a slider's animation, and an ik or transform constraint's record (`./constraints.ts`). */
|
|
327
|
+
export interface CoreConstraint {
|
|
328
|
+
kind: CoreConstraintKind;
|
|
329
|
+
name: string;
|
|
330
|
+
/** The animation a slider applies — the one constraint kind measured to pose a slot (the slots' ⚠️). */
|
|
331
|
+
animation?: string;
|
|
332
|
+
/** The constraint read field by field (construct 5, issue #938): ik and transform (`./constraints.ts`), physics (`./constraints_physics.ts`), slider (`./constraints_slider.ts`). */
|
|
333
|
+
record?: CoreConstraintRecord;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** One animation: its name, the slots its timelines key, its timelines as construct 4 reads them (`./animation.ts`), and its constraint timelines (`./constraints.ts`). */
|
|
337
|
+
export interface CoreAnimation {
|
|
338
|
+
name: string;
|
|
339
|
+
slots: string[];
|
|
340
|
+
timelines: CoreAnimationTimelines;
|
|
341
|
+
constraints: CoreConstraintTimelines;
|
|
342
|
+
/** Every attachment a `deform` timeline keys, as `skin/slot/attachment` — what a path constraint walking a deformed path needs to know (`./constraints_path.ts`). */
|
|
343
|
+
deforms: string[];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* A `rigc-compiled/3` (or `/2`, or `/1`) document, read. `bones` and `slots` are checked field by
|
|
348
|
+
* field against the writer's own records; skins and constraints are read as
|
|
349
|
+
* far as their names and memberships, and every other section is only
|
|
350
|
+
* required to be present — no construct this card admits reads it.
|
|
351
|
+
*/
|
|
352
|
+
export interface CompiledDocument {
|
|
353
|
+
spec: string;
|
|
354
|
+
/**
|
|
355
|
+
* The skin the document is posed under (issue #932, `./skins.ts`): `all` —
|
|
356
|
+
* every skin merged, the oracle's `--skin all` and `readModel`'s reading —
|
|
357
|
+
* one skin's name, `underSkin`'s, or `null` — no skin set, the runtime's
|
|
358
|
+
* `Skeleton.skin` before `setSkin` is called (`underNoSkin`, issue #1051).
|
|
359
|
+
*/
|
|
360
|
+
skin: string | null;
|
|
361
|
+
/** The skeleton's reference scale (issue #958), which every physics record carries too (`CorePhysicsRecord.referenceScale`). */
|
|
362
|
+
referenceScale: number;
|
|
363
|
+
bones: ModelBone[];
|
|
364
|
+
slots: ModelSlot[];
|
|
365
|
+
skins: CoreSkin[];
|
|
366
|
+
constraints: CoreConstraint[];
|
|
367
|
+
animations: CoreAnimation[];
|
|
368
|
+
/** The digest of the `skeleton.json` `build` wrote beside the document (issue #968) — what a render holds the file beside it to before posing it here. */
|
|
369
|
+
spine: { sha256: string };
|
|
370
|
+
/**
|
|
371
|
+
* Every page of the atlas `build` wrote beside the document and every region
|
|
372
|
+
* on it, in file order (issue #1016) — where each drawing sits, which the
|
|
373
|
+
* draw's page UVs read. `null` for a `rigc-compiled/1` document, which does
|
|
374
|
+
* not state it. A `rigc-compiled/3` document's pages carry `pma` and `scale`
|
|
375
|
+
* too (issue #1026); a `/2` document's carry neither.
|
|
376
|
+
*/
|
|
377
|
+
pages: ModelPage[] | null;
|
|
378
|
+
/**
|
|
379
|
+
* What a `rigc-compiled/3` document states that only the Spine files beside
|
|
380
|
+
* a `/2` or `/1` document hold (issue #1026): the setup-pose box the header
|
|
381
|
+
* declares, or `null` for none, and the order the editor lists skins, their
|
|
382
|
+
* slot keys and animations in. `null` for a `/2` or `/1` document.
|
|
383
|
+
*/
|
|
384
|
+
stated: CoreStated | null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A `rigc-compiled/3` document's `stage` and `editorOrder`, read (`CompiledDocument.stated`). */
|
|
388
|
+
export interface CoreStated {
|
|
389
|
+
stage: ModelStage | null;
|
|
390
|
+
editorOrder: ModelEditorOrder;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The `pages` section, checked: a list of pages, each holding exactly
|
|
395
|
+
* `CORE_PAGE_FIELDS` — a non-empty name, a size of two positive finite
|
|
396
|
+
* numbers, a list of at least one region (issue #1054) — and each region exactly
|
|
397
|
+
* `CORE_PAGE_REGION_FIELDS`, a non-empty name and nine finite numbers. Every
|
|
398
|
+
* fault is named by its path. Nothing is defaulted: a field the writer always
|
|
399
|
+
* writes is required here.
|
|
400
|
+
*/
|
|
401
|
+
function readPages(value: unknown, problems: string[], flags: boolean): ModelPage[] {
|
|
402
|
+
if (!Array.isArray(value)) {
|
|
403
|
+
problems.push(`pages is ${JSON.stringify(value) ?? 'absent'}, not a list of pages`);
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
const out: ModelPage[] = [];
|
|
407
|
+
value.forEach((raw, i) => {
|
|
408
|
+
const where = `pages[${i}]`;
|
|
409
|
+
if (!isRecord(raw)) {
|
|
410
|
+
problems.push(`${where} is not an object`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const before = problems.length;
|
|
414
|
+
const label = typeof raw.name === 'string' ? `${where} "${raw.name}"` : where;
|
|
415
|
+
unknownFields(raw, flags ? CORE_PAGE_FIELDS : CORE_PAGE_FIELDS_2, label, problems);
|
|
416
|
+
if (typeof raw.name !== 'string' || raw.name === '') problems.push(`${where}: name is ${JSON.stringify(raw.name) ?? 'absent'}, not a non-empty string`);
|
|
417
|
+
for (const key of ['width', 'height'] as const) {
|
|
418
|
+
const v = raw[key];
|
|
419
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) problems.push(`${label}: ${key} is ${JSON.stringify(v) ?? 'absent'}, not a positive finite number — a page UV divides by it`);
|
|
420
|
+
}
|
|
421
|
+
// Issue #1026: a /3 page states its `pma:` as the runtime reads it and the number its own `scale:` line states, or null for none.
|
|
422
|
+
if (flags) {
|
|
423
|
+
if (typeof raw.pma !== 'boolean') problems.push(`${label}: pma is ${JSON.stringify(raw.pma) ?? 'absent'}, not true or false — whether the page's texels are premultiplied`);
|
|
424
|
+
if (raw.scale !== null && (typeof raw.scale !== 'number' || !Number.isFinite(raw.scale))) problems.push(`${label}: scale is ${JSON.stringify(raw.scale) ?? 'absent'}, not a finite number or null — the number the page's scale: line states, null where it has none`);
|
|
425
|
+
}
|
|
426
|
+
const regions: ModelPageRegion[] = [];
|
|
427
|
+
if (!Array.isArray(raw.regions)) problems.push(`${label}: regions is not a list`);
|
|
428
|
+
// Issue #1054: a page holding no region is one no build writes — the atlas text's block for it would end before any region line, which
|
|
429
|
+
// the round trip refuses (A07_ATLAS_TEXT_SHAPE: "the last page block declares no region") — so a document stating one is refused here by
|
|
430
|
+
// the page's name rather than read, where nothing on the model side would name it.
|
|
431
|
+
else if (raw.regions.length === 0) problems.push(`${label}: regions is an empty list — a page holds at least one region; the atlas text cannot state a page with none (its block would end before a region line, which A07_ATLAS_TEXT_SHAPE refuses), so no build writes one`);
|
|
432
|
+
else {
|
|
433
|
+
raw.regions.forEach((region, j) => {
|
|
434
|
+
const at = `${label}.regions[${j}]`;
|
|
435
|
+
if (!isRecord(region)) {
|
|
436
|
+
problems.push(`${at} is not an object`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const named = typeof region.name === 'string' ? `${at} "${region.name}"` : at;
|
|
440
|
+
unknownFields(region, CORE_PAGE_REGION_FIELDS, named, problems);
|
|
441
|
+
if (typeof region.name !== 'string' || region.name === '') problems.push(`${at}: name is ${JSON.stringify(region.name) ?? 'absent'}, not a non-empty string`);
|
|
442
|
+
for (const key of CORE_PAGE_REGION_FIELDS) {
|
|
443
|
+
if (key === 'name') continue;
|
|
444
|
+
const v = region[key];
|
|
445
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) problems.push(`${named}: ${key} is ${JSON.stringify(v) ?? 'absent'}, not a finite number`);
|
|
446
|
+
}
|
|
447
|
+
regions.push(region as unknown as ModelPageRegion);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
if (problems.length === before) {
|
|
451
|
+
out.push(
|
|
452
|
+
flags
|
|
453
|
+
? { name: raw.name as string, width: raw.width as number, height: raw.height as number, pma: raw.pma as boolean, scale: raw.scale as number | null, regions }
|
|
454
|
+
: { name: raw.name as string, width: raw.width as number, height: raw.height as number, regions },
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** The `spine` section's value, checked: `{ "sha256": <64 lowercase hex> }` and nothing else, each fault named by path (issue #968). */
|
|
462
|
+
function readSpineDigest(value: unknown, problems: string[]): string {
|
|
463
|
+
if (value === undefined) return '';
|
|
464
|
+
if (!isRecord(value)) {
|
|
465
|
+
problems.push(`spine is ${JSON.stringify(value)}, not { "sha256": "<64 lowercase hex digits>" }`);
|
|
466
|
+
return '';
|
|
467
|
+
}
|
|
468
|
+
for (const key of Object.keys(value)) if (key !== 'sha256') problems.push(`spine: field "${key}" is not one this reader knows; it reads [sha256]`);
|
|
469
|
+
const sha = value.sha256;
|
|
470
|
+
if (typeof sha !== 'string' || !/^[0-9a-f]{64}$/.test(sha)) {
|
|
471
|
+
problems.push(`spine.sha256 is ${JSON.stringify(sha) ?? 'absent'}, not 64 lowercase hex digits — the SHA-256 of the skeleton.json written beside the document`);
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
return sha;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The `stage` section, checked (issue #1026): `null` — the header declares no
|
|
479
|
+
* stage — or exactly `CORE_STAGE_FIELDS`, four finite numbers, each fault
|
|
480
|
+
* named by path. Nothing is defaulted: the writer always writes all four.
|
|
481
|
+
*/
|
|
482
|
+
function readStage(value: unknown, problems: string[]): ModelStage | null {
|
|
483
|
+
if (value === null) return null;
|
|
484
|
+
if (!isRecord(value)) {
|
|
485
|
+
problems.push(`stage is ${JSON.stringify(value) ?? 'absent'}, not null or { x, y, width, height } — the setup-pose box the Spine header declares`);
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const before = problems.length;
|
|
489
|
+
unknownFields(value, [...CORE_STAGE_FIELDS, 'box'], 'stage', problems);
|
|
490
|
+
for (const key of CORE_STAGE_FIELDS) {
|
|
491
|
+
const v = value[key];
|
|
492
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) problems.push(`stage: ${key} is ${JSON.stringify(v) ?? 'absent'}, not a finite number`);
|
|
493
|
+
}
|
|
494
|
+
// The stage box (issue #1168): written only where the rig asked for one, and then exactly its two names.
|
|
495
|
+
const box = value.box;
|
|
496
|
+
if (box !== undefined) {
|
|
497
|
+
if (!isRecord(box)) problems.push(`stage.box is ${JSON.stringify(box)}, not { slot, attachment }`);
|
|
498
|
+
else {
|
|
499
|
+
unknownFields(box, CORE_STAGE_BOX_FIELDS, 'stage.box', problems);
|
|
500
|
+
for (const key of CORE_STAGE_BOX_FIELDS) if (typeof box[key] !== 'string' || box[key] === '') problems.push(`stage.box: ${key} is ${JSON.stringify(box[key]) ?? 'absent'}, not a non-empty string`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (problems.length !== before) return null;
|
|
504
|
+
const stage: ModelStage = { x: value.x as number, y: value.y as number, width: value.width as number, height: value.height as number };
|
|
505
|
+
if (isRecord(box)) stage.box = { slot: box.slot as string, attachment: box.attachment as string };
|
|
506
|
+
return stage;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The `editorOrder` section, checked against the records the document holds
|
|
511
|
+
* (issue #1026): `skins`, every skin exactly once as `{ name, slots }` with
|
|
512
|
+
* `slots` its table's slot keys exactly once each, and `animations`, every
|
|
513
|
+
* animation's name exactly once. An order naming a skin, slot key or
|
|
514
|
+
* animation the document does not hold, or leaving one out, is refused by
|
|
515
|
+
* path — it would be another rig's order.
|
|
516
|
+
*/
|
|
517
|
+
function readEditorOrder(value: unknown, skins: readonly CoreSkin[], animations: readonly CoreAnimation[], problems: string[]): ModelEditorOrder {
|
|
518
|
+
const out: ModelEditorOrder = { skins: [], animations: [] };
|
|
519
|
+
if (!isRecord(value)) {
|
|
520
|
+
problems.push(`editorOrder is ${JSON.stringify(value) ?? 'absent'}, not { skins, animations }`);
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
unknownFields(value, ['skins', 'animations'], 'editorOrder', problems);
|
|
524
|
+
const names = (list: unknown, where: string): string[] | null => {
|
|
525
|
+
if (!Array.isArray(list) || list.some((n) => typeof n !== 'string')) {
|
|
526
|
+
problems.push(`${where} is ${JSON.stringify(list) ?? 'absent'}, not a list of names`);
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
return list as string[];
|
|
530
|
+
};
|
|
531
|
+
/** `stated` lists exactly the names of `held`, each once — else one problem naming the first difference. */
|
|
532
|
+
const permutation = (stated: readonly string[], held: readonly string[], where: string, what: string): void => {
|
|
533
|
+
const seen = new Set<string>();
|
|
534
|
+
for (const name of stated) {
|
|
535
|
+
if (seen.has(name)) return void problems.push(`${where} lists ${what} "${name}" twice`);
|
|
536
|
+
seen.add(name);
|
|
537
|
+
if (!held.includes(name)) return void problems.push(`${where} lists ${what} "${name}", which the document does not hold`);
|
|
538
|
+
}
|
|
539
|
+
const missing = held.find((name) => !seen.has(name));
|
|
540
|
+
if (missing !== undefined) problems.push(`${where} leaves out ${what} "${missing}"`);
|
|
541
|
+
};
|
|
542
|
+
if (!Array.isArray(value.skins)) problems.push(`editorOrder.skins is ${JSON.stringify(value.skins) ?? 'absent'}, not a list`);
|
|
543
|
+
else {
|
|
544
|
+
value.skins.forEach((entry, i) => {
|
|
545
|
+
const at = `editorOrder.skins[${i}]`;
|
|
546
|
+
if (!isRecord(entry) || typeof entry.name !== 'string') return void problems.push(`${at} is not { name, slots }`);
|
|
547
|
+
unknownFields(entry, ['name', 'slots'], at, problems);
|
|
548
|
+
const slots = names(entry.slots, `${at}.slots`);
|
|
549
|
+
const skin = skins.find((k) => k.name === entry.name);
|
|
550
|
+
if (slots !== null && skin !== undefined) permutation(slots, Object.keys(skin.attachments), `${at}.slots`, 'slot key');
|
|
551
|
+
out.skins.push({ name: entry.name, slots: slots ?? [] });
|
|
552
|
+
});
|
|
553
|
+
permutation(out.skins.map((k) => k.name), skins.map((k) => k.name), 'editorOrder.skins', 'skin');
|
|
554
|
+
}
|
|
555
|
+
const order = names(value.animations, 'editorOrder.animations');
|
|
556
|
+
if (order !== null) {
|
|
557
|
+
permutation(order, animations.map((a) => a.name), 'editorOrder.animations', 'animation');
|
|
558
|
+
out.animations = order;
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
564
|
+
|
|
565
|
+
/** The mode a stated `inheritMode` folds to — the first letter lower-cased and nothing else — or null. */
|
|
566
|
+
export function foldInheritMode(value: string): (typeof CORE_INHERIT_MODES)[number] | null {
|
|
567
|
+
const folded = value.length === 0 ? value : value[0].toLowerCase() + value.slice(1);
|
|
568
|
+
return CORE_INHERIT_MODES.find((m) => m === folded) ?? null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function unknownFields(record: Record<string, unknown>, known: readonly string[], where: string, problems: string[]): void {
|
|
572
|
+
for (const key of Object.keys(record)) {
|
|
573
|
+
if (!known.includes(key)) problems.push(`${where}: field "${key}" is not one this reader knows; it reads [${known.join(', ')}]`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function readBones(value: unknown, problems: string[]): ModelBone[] {
|
|
578
|
+
if (!Array.isArray(value)) {
|
|
579
|
+
problems.push('bones is not a list');
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
const out: ModelBone[] = [];
|
|
583
|
+
const seen = new Set<string>();
|
|
584
|
+
value.forEach((raw, i) => {
|
|
585
|
+
const where = `bones[${i}]`;
|
|
586
|
+
if (!isRecord(raw)) {
|
|
587
|
+
problems.push(`${where} is not an object`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const label = typeof raw.name === 'string' ? `${where} "${raw.name}"` : where;
|
|
591
|
+
unknownFields(raw, CORE_BONE_FIELDS, label, problems);
|
|
592
|
+
if (typeof raw.name !== 'string' || raw.name === '') problems.push(`${where}: name is ${JSON.stringify(raw.name)}, not a non-empty string`);
|
|
593
|
+
else if (seen.has(raw.name)) problems.push(`${label}: the name is declared twice`);
|
|
594
|
+
if (raw.parent !== undefined) {
|
|
595
|
+
if (typeof raw.parent !== 'string') problems.push(`${label}: parent is ${JSON.stringify(raw.parent)}, not a bone name`);
|
|
596
|
+
else if (!seen.has(raw.parent)) problems.push(`${label}: parent "${raw.parent}" is not declared before it; bones are parents first`);
|
|
597
|
+
}
|
|
598
|
+
for (const key of BONE_NUMBERS) {
|
|
599
|
+
const v = raw[key];
|
|
600
|
+
if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v))) problems.push(`${label}: ${key} is ${JSON.stringify(v)}, not a finite number`);
|
|
601
|
+
}
|
|
602
|
+
if (raw.inheritMode !== undefined) {
|
|
603
|
+
if (typeof raw.inheritMode !== 'string' || foldInheritMode(raw.inheritMode) === null) {
|
|
604
|
+
problems.push(`${label}: inheritMode is ${JSON.stringify(raw.inheritMode)}, which folds to none of ${CORE_INHERIT_MODES.join(', ')}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (raw.skinRequired !== undefined && typeof raw.skinRequired !== 'boolean') problems.push(`${label}: skinRequired is ${JSON.stringify(raw.skinRequired)}, not a boolean`);
|
|
608
|
+
if (raw.editor !== undefined) {
|
|
609
|
+
if (!isRecord(raw.editor)) problems.push(`${label}: editor is not an object`);
|
|
610
|
+
else {
|
|
611
|
+
unknownFields(raw.editor, ['color', 'icon'], `${label}.editor`, problems);
|
|
612
|
+
for (const key of ['color', 'icon'] as const) {
|
|
613
|
+
if (raw.editor[key] !== undefined && typeof raw.editor[key] !== 'string') problems.push(`${label}.editor: ${key} is not a string`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (typeof raw.name === 'string' && raw.name !== '') seen.add(raw.name);
|
|
618
|
+
out.push(raw as unknown as ModelBone);
|
|
619
|
+
});
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function readSlots(value: unknown, bones: ReadonlySet<string>, problems: string[]): ModelSlot[] {
|
|
624
|
+
if (!Array.isArray(value)) {
|
|
625
|
+
problems.push('slots is not a list');
|
|
626
|
+
return [];
|
|
627
|
+
}
|
|
628
|
+
const out: ModelSlot[] = [];
|
|
629
|
+
value.forEach((raw, i) => {
|
|
630
|
+
const where = `slots[${i}]`;
|
|
631
|
+
if (!isRecord(raw)) {
|
|
632
|
+
problems.push(`${where} is not an object`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const label = typeof raw.name === 'string' ? `${where} "${raw.name}"` : where;
|
|
636
|
+
unknownFields(raw, CORE_SLOT_FIELDS, label, problems);
|
|
637
|
+
if (typeof raw.name !== 'string' || raw.name === '') problems.push(`${where}: name is ${JSON.stringify(raw.name)}, not a non-empty string`);
|
|
638
|
+
if (typeof raw.bone !== 'string') problems.push(`${label}: bone is ${JSON.stringify(raw.bone)}, not a bone name`);
|
|
639
|
+
else if (!bones.has(raw.bone)) problems.push(`${label}: bone "${raw.bone}" is not a bone of this document`);
|
|
640
|
+
if (raw.setup !== null && typeof raw.setup !== 'string') problems.push(`${label}: setup is ${JSON.stringify(raw.setup)}, not a placeholder name or null`);
|
|
641
|
+
for (const key of ['color', 'dark', 'blend'] as const) {
|
|
642
|
+
if (raw[key] !== undefined && typeof raw[key] !== 'string') problems.push(`${label}: ${key} is ${JSON.stringify(raw[key])}, not a string`);
|
|
643
|
+
}
|
|
644
|
+
for (const key of ['color', 'dark'] as const) {
|
|
645
|
+
const v = raw[key];
|
|
646
|
+
if (typeof v === 'string' && !HEX_COLOUR.test(v)) problems.push(`${label}: ${key} is ${JSON.stringify(v)}, not six or eight hex digits (rrggbb or rrggbbaa) — the only spellings whose reading was measured to be a colour`);
|
|
647
|
+
}
|
|
648
|
+
if (typeof raw.blend === 'string' && foldBlend(raw.blend) === null) {
|
|
649
|
+
problems.push(`${label}: blend is ${JSON.stringify(raw.blend)}, which the runtime reads as no mode — one of ${CORE_BLEND_MODES.join(', ')}, first letter in either case, is the only spelling measured to be one`);
|
|
650
|
+
}
|
|
651
|
+
out.push(raw as unknown as ModelSlot);
|
|
652
|
+
});
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function readAttachments(value: Record<string, unknown>, bones: ReadonlySet<string>, slots: ReadonlySet<string>, label: string, problems: string[]): Record<string, Record<string, CoreAttachment>> {
|
|
657
|
+
const out: Record<string, Record<string, CoreAttachment>> = {};
|
|
658
|
+
for (const [slot, table] of Object.entries(value)) {
|
|
659
|
+
const at = `${label}.attachments["${slot}"]`;
|
|
660
|
+
if (!slots.has(slot)) problems.push(`${at}: "${slot}" is not a slot of this document`);
|
|
661
|
+
if (!isRecord(table)) {
|
|
662
|
+
problems.push(`${at} is not an object`);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const entries: Record<string, CoreAttachment> = {};
|
|
666
|
+
for (const [placeholder, raw] of Object.entries(table)) {
|
|
667
|
+
const where = `${at}["${placeholder}"]`;
|
|
668
|
+
if (!isRecord(raw)) {
|
|
669
|
+
problems.push(`${where} is not an object`);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const kinds = Object.keys(CORE_ATTACHMENT_FIELDS) as Array<SkinTableEntry['kind']>;
|
|
673
|
+
const kind = kinds.find((k) => k === raw.kind);
|
|
674
|
+
if (kind === undefined) {
|
|
675
|
+
problems.push(`${where}: kind is ${JSON.stringify(raw.kind)}, none of ${kinds.join(', ')}`);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
unknownFields(raw, CORE_ATTACHMENT_FIELDS[kind], where, problems);
|
|
679
|
+
const record: CoreAttachment = { kind };
|
|
680
|
+
for (const key of ['name', 'path'] as const) {
|
|
681
|
+
const v = raw[key];
|
|
682
|
+
if (v === undefined) continue;
|
|
683
|
+
if (typeof v !== 'string' || v === '') problems.push(`${where}: ${key} is ${JSON.stringify(v)}, not a non-empty string`);
|
|
684
|
+
else record[key] = v;
|
|
685
|
+
}
|
|
686
|
+
if (kind === 'region' && raw.atlas !== undefined) {
|
|
687
|
+
const rect = readAtlasRect(raw.atlas, `${where}.atlas`, problems);
|
|
688
|
+
if (rect !== undefined) record.atlas = rect;
|
|
689
|
+
}
|
|
690
|
+
const geometry = readGeometry(raw, kind, where, bones, slots, problems);
|
|
691
|
+
if (geometry !== undefined) record.geometry = geometry;
|
|
692
|
+
// The series' length, whatever the kind: a sequence key's index is a frame of it (`./deform.ts`).
|
|
693
|
+
if (isRecord(raw.sequence)) {
|
|
694
|
+
const count = raw.sequence.count;
|
|
695
|
+
if (typeof count === 'number' && Number.isInteger(count) && count >= 1) record.sequenceCount = count;
|
|
696
|
+
else if (kind !== 'region') problems.push(`${where}.sequence: count is ${JSON.stringify(count)}, not a whole number of at least 1`);
|
|
697
|
+
const setupFrame = raw.sequence.setup ?? 0;
|
|
698
|
+
if (typeof setupFrame === 'number' && Number.isInteger(setupFrame) && setupFrame >= 0 && (typeof count !== 'number' || setupFrame < count)) record.sequenceSetup = setupFrame;
|
|
699
|
+
else if (kind !== 'region') problems.push(`${where}.sequence: setup is ${JSON.stringify(raw.sequence.setup)}, not a frame of the ${JSON.stringify(count)}`);
|
|
700
|
+
}
|
|
701
|
+
// The attachment's own colour (issue #966): the raw entry forms the tint from it and the slot's.
|
|
702
|
+
if (raw.color !== undefined) {
|
|
703
|
+
if (typeof raw.color !== 'string' || !HEX_COLOUR.test(raw.color)) problems.push(`${where}: color is ${JSON.stringify(raw.color)}, not six or eight hex digits`);
|
|
704
|
+
else record.color = raw.color;
|
|
705
|
+
}
|
|
706
|
+
if (kind === 'linkedmesh') {
|
|
707
|
+
if (typeof raw.timelines !== 'boolean') problems.push(`${where}: timelines is ${JSON.stringify(raw.timelines) ?? 'absent'}, not a boolean — the writer states whether the link plays its source's timelines`);
|
|
708
|
+
else record.timelines = raw.timelines;
|
|
709
|
+
}
|
|
710
|
+
entries[placeholder] = record;
|
|
711
|
+
}
|
|
712
|
+
out[slot] = entries;
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** A region's `atlas`: `null`, or an object holding exactly the six fields, each a finite number. `undefined` when refused. */
|
|
718
|
+
function readAtlasRect(value: unknown, where: string, problems: string[]): ModelAtlasRect | null | undefined {
|
|
719
|
+
if (value === null) return null;
|
|
720
|
+
if (!isRecord(value)) {
|
|
721
|
+
problems.push(`${where} is ${JSON.stringify(value)}, neither null nor an object`);
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
const before = problems.length;
|
|
725
|
+
unknownFields(value, CORE_ATLAS_RECT_FIELDS, where, problems);
|
|
726
|
+
for (const key of CORE_ATLAS_RECT_FIELDS) {
|
|
727
|
+
const v = value[key];
|
|
728
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) problems.push(`${where}: ${key} is ${JSON.stringify(v) ?? 'absent'}, not a finite number`);
|
|
729
|
+
}
|
|
730
|
+
return problems.length === before ? (value as unknown as ModelAtlasRect) : undefined;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function readSkins(value: unknown, bones: ReadonlySet<string>, slots: ReadonlySet<string>, problems: string[]): CoreSkin[] {
|
|
734
|
+
if (!Array.isArray(value)) {
|
|
735
|
+
problems.push('skins is not a list');
|
|
736
|
+
return [];
|
|
737
|
+
}
|
|
738
|
+
const out: CoreSkin[] = [];
|
|
739
|
+
value.forEach((raw, i) => {
|
|
740
|
+
const where = `skins[${i}]`;
|
|
741
|
+
if (!isRecord(raw)) {
|
|
742
|
+
problems.push(`${where} is not an object`);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const label = typeof raw.name === 'string' ? `${where} "${raw.name}"` : where;
|
|
746
|
+
unknownFields(raw, CORE_SKIN_FIELDS, label, problems);
|
|
747
|
+
if (typeof raw.name !== 'string' || raw.name === '') problems.push(`${where}: name is ${JSON.stringify(raw.name)}, not a non-empty string`);
|
|
748
|
+
const members: string[] = [];
|
|
749
|
+
if (!Array.isArray(raw.bones)) problems.push(`${label}: bones is not a list`);
|
|
750
|
+
else {
|
|
751
|
+
raw.bones.forEach((b, j) => {
|
|
752
|
+
if (typeof b !== 'string') problems.push(`${label}: bones[${j}] is not a bone name`);
|
|
753
|
+
else if (!bones.has(b)) problems.push(`${label}: bones[${j}] "${b}" is not a bone of this document`);
|
|
754
|
+
else members.push(b);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
const lists = Object.fromEntries(CORE_CONSTRAINT_KINDS.map((k) => [k, [] as string[]])) as Record<CoreConstraintKind, string[]>;
|
|
758
|
+
if (!isRecord(raw.constraints)) problems.push(`${label}: constraints is not an object`);
|
|
759
|
+
else {
|
|
760
|
+
for (const [kind, list] of Object.entries(raw.constraints)) {
|
|
761
|
+
const known = CORE_CONSTRAINT_KINDS.find((k) => k === kind);
|
|
762
|
+
if (known === undefined) problems.push(`${label}.constraints: "${kind}" is not a constraint kind; a skin lists ${CORE_CONSTRAINT_KINDS.join(', ')}`);
|
|
763
|
+
else if (!Array.isArray(list) || list.some((n) => typeof n !== 'string')) problems.push(`${label}.constraints.${kind} is not a list of constraint names`);
|
|
764
|
+
else lists[known] = list as string[];
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
let attachments: Record<string, Record<string, CoreAttachment>> = {};
|
|
768
|
+
if (!isRecord(raw.attachments)) problems.push(`${label}: attachments is not an object`);
|
|
769
|
+
else attachments = readAttachments(raw.attachments, bones, slots, label, problems);
|
|
770
|
+
out.push({ name: typeof raw.name === 'string' ? raw.name : '', bones: members, constraints: lists, attachments });
|
|
771
|
+
});
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function readAnimations(value: unknown, bones: ReadonlySet<string>, slotRecords: readonly ModelSlot[], skins: readonly CoreSkin[], events: ReadonlyMap<string, CoreEventDef>, problems: string[]): CoreAnimation[] {
|
|
776
|
+
const slots = new Set(slotRecords.map((s) => s.name));
|
|
777
|
+
if (!Array.isArray(value)) {
|
|
778
|
+
problems.push('animations is not a list');
|
|
779
|
+
return [];
|
|
780
|
+
}
|
|
781
|
+
const out: CoreAnimation[] = [];
|
|
782
|
+
value.forEach((raw, i) => {
|
|
783
|
+
const where = `animations[${i}]`;
|
|
784
|
+
if (!isRecord(raw)) {
|
|
785
|
+
problems.push(`${where} is not an object`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (typeof raw.name !== 'string' || raw.name === '') problems.push(`${where}: name is ${JSON.stringify(raw.name)}, not a non-empty string`);
|
|
789
|
+
const label = typeof raw.name === 'string' ? `${where} "${raw.name}"` : where;
|
|
790
|
+
const keyed: string[] = [];
|
|
791
|
+
if (!Array.isArray(raw.slots)) problems.push(`${label}: slots is not a list`);
|
|
792
|
+
else {
|
|
793
|
+
raw.slots.forEach((entry, j) => {
|
|
794
|
+
if (!isRecord(entry) || typeof entry.name !== 'string') problems.push(`${label}: slots[${j}] names no slot`);
|
|
795
|
+
else if (!slots.has(entry.name)) problems.push(`${label}: slots[${j}] "${entry.name}" is not a slot of this document`);
|
|
796
|
+
else keyed.push(entry.name);
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
const deforms: string[] = [];
|
|
800
|
+
// The structure is checked with the key times (`laterKeyTimes` in `./animation.ts`); here only the names are taken.
|
|
801
|
+
const list = (v: unknown): Array<Record<string, unknown>> => (Array.isArray(v) ? v.filter(isRecord) : []);
|
|
802
|
+
for (const skin of list(raw.attachments)) for (const slot of list(skin.slots)) for (const att of list(slot.attachments)) if (att.deform !== undefined) deforms.push(`${String(skin.name)}/${String(slot.name)}/${String(att.name)}`);
|
|
803
|
+
out.push({ name: typeof raw.name === 'string' ? raw.name : '', slots: keyed, timelines: readAnimationTimelines(raw, label, bones, slotRecords, problems, { skins, events }), constraints: { ik: [], transform: [], path: [], physics: 0, slider: [] }, deforms });
|
|
804
|
+
});
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function readConstraints(value: unknown, animations: readonly CoreAnimation[], bones: readonly ModelBone[], slots: readonly ModelSlot[], referenceScale: number, problems: string[]): CoreConstraint[] {
|
|
809
|
+
const names = new Set(bones.map((b) => b.name));
|
|
810
|
+
const slotBones = new Map(slots.map((s) => [s.name, s.bone]));
|
|
811
|
+
const parents = new Map(bones.map((b) => [b.name, b.parent]));
|
|
812
|
+
if (!Array.isArray(value)) {
|
|
813
|
+
problems.push('constraints is not a list');
|
|
814
|
+
return [];
|
|
815
|
+
}
|
|
816
|
+
const out: CoreConstraint[] = [];
|
|
817
|
+
value.forEach((raw, i) => {
|
|
818
|
+
const where = `constraints[${i}]`;
|
|
819
|
+
if (!isRecord(raw)) {
|
|
820
|
+
problems.push(`${where} is not an object`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const kind = CORE_CONSTRAINT_KINDS.find((k) => k === raw.kind);
|
|
824
|
+
if (kind === undefined) problems.push(`${where}: kind is ${JSON.stringify(raw.kind)}, none of ${CORE_CONSTRAINT_KINDS.join(', ')}`);
|
|
825
|
+
if (typeof raw.name !== 'string' || raw.name === '') problems.push(`${where}: name is ${JSON.stringify(raw.name)}, not a non-empty string`);
|
|
826
|
+
if (raw.declaredIn !== 'rig' && raw.declaredIn !== 'motion') problems.push(`${where}: declaredIn is ${JSON.stringify(raw.declaredIn)}, not "rig" or "motion"`);
|
|
827
|
+
const at = `${where} "${String(raw.name)}"`;
|
|
828
|
+
let record: CoreConstraintRecord | undefined;
|
|
829
|
+
if (typeof raw.name === 'string') {
|
|
830
|
+
if (kind === 'ik' || kind === 'transform') record = readConstraintRecord(raw, kind, raw.name, at, names, parents, problems);
|
|
831
|
+
else if (kind === 'path') record = readPathRecord(raw, raw.name, at, names, slotBones, problems);
|
|
832
|
+
else if (kind === 'physics') record = readPhysicsRecord(raw, raw.name, at, names, referenceScale, problems);
|
|
833
|
+
else if (kind === 'slider') record = readSliderRecord(raw, raw.name, at, bones, animations, problems);
|
|
834
|
+
}
|
|
835
|
+
if (kind !== undefined && typeof raw.name === 'string') out.push({ kind, name: raw.name, ...(kind === 'slider' && typeof raw.animation === 'string' ? { animation: raw.animation } : {}), ...(record !== undefined ? { record } : {}) });
|
|
836
|
+
});
|
|
837
|
+
return out;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Read a `rigc-compiled/3` document — or a `rigc-compiled/2` one, which has
|
|
842
|
+
* no `stage`, `editorOrder` or page `pma`/`scale` and is read with `stated:
|
|
843
|
+
* null`, or a `rigc-compiled/1` one, which has no `pages` either and is read
|
|
844
|
+
* with `pages: null` — from its text, refusing by name a text
|
|
845
|
+
* that is not JSON, a wrong `spec`, a missing section (`referenceScale`
|
|
846
|
+
* among them, issue #958), a section the document does not have, a
|
|
847
|
+
* `referenceScale` that is not a finite number, and — in the records these constructs read (bones, slots,
|
|
848
|
+
* skins and their attachment tables) — a field the writer does not write (the
|
|
849
|
+
* writer's own rule, `ordered` in `src/model.ts`, mirrored), a value of the
|
|
850
|
+
* wrong type, a colour spelled other than six or eight hex digits, a parent
|
|
851
|
+
* declared after its child and a name that resolves to nothing. Every problem
|
|
852
|
+
* is collected and thrown once.
|
|
853
|
+
*/
|
|
854
|
+
export function readModel(text: string, where = 'the model document'): CompiledDocument {
|
|
855
|
+
let value: unknown;
|
|
856
|
+
try {
|
|
857
|
+
value = JSON.parse(text);
|
|
858
|
+
} catch (err) {
|
|
859
|
+
throw new CoreInputError(`${where}: not JSON — ${(err as Error).message}`);
|
|
860
|
+
}
|
|
861
|
+
if (!isRecord(value)) throw new CoreInputError(`${where}: not a JSON object`);
|
|
862
|
+
if (typeof value.spec !== 'string' || !CORE_DOCUMENT_SPECS.includes(value.spec)) {
|
|
863
|
+
throw new CoreInputError(
|
|
864
|
+
`${where}: spec is ${JSON.stringify(value.spec)}, not "${CORE_DOCUMENT_SPEC}" (or "${CORE_DOCUMENT_SPEC_2}", read without its stage, editor order and page flags, or "${CORE_DOCUMENT_SPEC_1}", read without its pages too)`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
const spec = value.spec;
|
|
868
|
+
const sections: readonly string[] = spec === CORE_DOCUMENT_SPEC ? CORE_SECTIONS : spec === CORE_DOCUMENT_SPEC_2 ? CORE_SECTIONS_2 : CORE_SECTIONS_1;
|
|
869
|
+
const problems: string[] = [];
|
|
870
|
+
for (const key of sections) if (!(key in value)) problems.push(`section "${key}" is missing`);
|
|
871
|
+
for (const key of Object.keys(value)) {
|
|
872
|
+
if (key !== 'spec' && !sections.includes(key)) problems.push(`section "${key}" is not one a ${spec} document has`);
|
|
873
|
+
}
|
|
874
|
+
const pages = spec !== CORE_DOCUMENT_SPEC_1 && 'pages' in value ? readPages(value.pages, problems, spec === CORE_DOCUMENT_SPEC) : null;
|
|
875
|
+
const stage = spec === CORE_DOCUMENT_SPEC && 'stage' in value ? readStage(value.stage, problems) : null;
|
|
876
|
+
// The skeleton's reference scale (issue #958): wind and gravity act over it, so a missing one is the section refusal above, and a value the runtime could not read as a number is refused here by name.
|
|
877
|
+
const referenceScale = typeof value.referenceScale === 'number' && Number.isFinite(value.referenceScale) ? value.referenceScale : NaN;
|
|
878
|
+
if ('referenceScale' in value && Number.isNaN(referenceScale)) problems.push(`referenceScale is ${JSON.stringify(value.referenceScale)}, not a finite number — wind and gravity act over it`);
|
|
879
|
+
const spine = readSpineDigest(value.spine, problems);
|
|
880
|
+
const bones = readBones(value.bones, problems);
|
|
881
|
+
const names = new Set(bones.map((b) => b.name));
|
|
882
|
+
const slots = readSlots(value.slots, names, problems);
|
|
883
|
+
const slotNames = new Set(slots.map((x) => x.name));
|
|
884
|
+
const skins = readSkins(value.skins, names, slotNames, problems);
|
|
885
|
+
problems.push(...linkProblems(skins));
|
|
886
|
+
const events = readEventDefs(value.events, problems);
|
|
887
|
+
const animations = readAnimations(value.animations, names, slots, skins, events, problems);
|
|
888
|
+
const constraints = readConstraints(value.constraints, animations, bones, slots, referenceScale, problems);
|
|
889
|
+
// Each skin's constraint lists name a constraint of that kind: the runtime's loader refuses any other name (`./skins.ts`).
|
|
890
|
+
for (const skin of skins) {
|
|
891
|
+
for (const kind of CORE_CONSTRAINT_KINDS) {
|
|
892
|
+
for (const name of skin.constraints[kind]) {
|
|
893
|
+
if (!constraints.some((c) => c.kind === kind && c.name === name)) problems.push(`skin "${skin.name}".constraints.${kind}: "${name}" is not a ${kind} constraint of this document — the runtime refuses the file ("Couldn't find … constraint ${name} for skin ${skin.name}.")`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (Array.isArray(value.animations)) {
|
|
898
|
+
value.animations.forEach((raw, i) => {
|
|
899
|
+
if (isRecord(raw) && animations[i] !== undefined) animations[i].constraints = readConstraintTimelines(raw.constraints, `animations[${i}] "${animations[i].name}"`, constraints, problems);
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
// A slider's physics timelines are its animation's (issue #1049), read only now.
|
|
903
|
+
for (const c of constraints) {
|
|
904
|
+
const r = c.record;
|
|
905
|
+
if (r?.kind === 'slider') r.physics = animations.find((a) => a.name === r.animation)?.constraints.physicsKeyed ?? [];
|
|
906
|
+
}
|
|
907
|
+
const editorOrder = spec === CORE_DOCUMENT_SPEC && 'editorOrder' in value ? readEditorOrder(value.editorOrder, skins, animations, problems) : null;
|
|
908
|
+
if (problems.length > 0) throw new CoreInputError(`${where}: ${problems.length} problem(s): ${problems.join('; ')}`);
|
|
909
|
+
const stated: CoreStated | null = editorOrder === null ? null : { stage, editorOrder };
|
|
910
|
+
const doc: CompiledDocument = { spec, skin: CORE_ALL_SKINS, referenceScale, bones, slots, skins, constraints, animations, spine: { sha256: spine }, pages, stated };
|
|
911
|
+
resolveSkinView(doc);
|
|
912
|
+
return doc;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* The document posed under one skin (issue #932): `name` is `all` — every
|
|
917
|
+
* skin merged, `readModel`'s reading — or a skin of the document, refused by
|
|
918
|
+
* name otherwise. The skins, rosters and records are the document's own; the
|
|
919
|
+
* constraint records are copied, so each view carries its own reading of
|
|
920
|
+
* which constraints apply and what a path walks (`resolveSkinView`).
|
|
921
|
+
*/
|
|
922
|
+
export function underSkin(doc: CompiledDocument, name: string): CompiledDocument {
|
|
923
|
+
if (name !== CORE_ALL_SKINS && !doc.skins.some((k) => k.name === name)) {
|
|
924
|
+
throw new CoreInputError(`--skin ${JSON.stringify(name)}: no such skin; this document declares [${doc.skins.map((k) => k.name).join(', ') || 'none'}] (or pass ${CORE_ALL_SKINS})`);
|
|
925
|
+
}
|
|
926
|
+
const view: CompiledDocument = { ...doc, skin: name, constraints: doc.constraints.map((c) => (c.record === undefined ? c : { ...c, record: { ...c.record } as CoreConstraintRecord })) };
|
|
927
|
+
resolveSkinView(view);
|
|
928
|
+
return view;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* The document posed with no skin set (issue #1051): no skin's `bones` or
|
|
933
|
+
* constraint lists applied — the default skin's included — and every slot
|
|
934
|
+
* resolved through the default skin alone, or through nothing where the
|
|
935
|
+
* document declares none (`./skins.ts`, *No skin set*, for the measurement).
|
|
936
|
+
* The view a fresh skeleton is in: `render` without `--skin`, A10's walk and
|
|
937
|
+
* `validate()`'s poses (`noSkinView` in `src/render_core.ts`).
|
|
938
|
+
*/
|
|
939
|
+
export function underNoSkin(doc: CompiledDocument): CompiledDocument {
|
|
940
|
+
const view: CompiledDocument = { ...doc, skin: null, constraints: doc.constraints.map((c) => (c.record === undefined ? c : { ...c, record: { ...c.record } as CoreConstraintRecord })) };
|
|
941
|
+
resolveSkinView(view);
|
|
942
|
+
return view;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* What a skin view decides on the constraint records: whether an applied
|
|
947
|
+
* skin's list names each (`listedBySkin`, `./skins.ts`), and what each path
|
|
948
|
+
* constraint walks — the curve its slot shows at setup, or why that cannot
|
|
949
|
+
* be told (`unresolved`) — and which bones its slot reads (`slotDeps`).
|
|
950
|
+
*/
|
|
951
|
+
function resolveSkinView(doc: CompiledDocument): void {
|
|
952
|
+
for (const c of doc.constraints) if (c.record !== undefined) c.record.listedBySkin = listedByAppliedSkin(doc, c.kind, c.name);
|
|
953
|
+
// A path constraint walks what its slot shows at setup (construct 5's second cut, `./constraints_path.ts`).
|
|
954
|
+
for (const c of doc.constraints) {
|
|
955
|
+
const r = c.record;
|
|
956
|
+
if (r?.kind !== 'path') continue;
|
|
957
|
+
r.path = null;
|
|
958
|
+
r.unresolved = null;
|
|
959
|
+
const slot = doc.slots.find((s) => s.name === r.slot) as ModelSlot;
|
|
960
|
+
const shown = shownAttachment(doc, slot);
|
|
961
|
+
// Under `all`, every skin filling the slot's setup placeholder: the curve walked is the LAST of them in the Spine file's skin order, which the model does not hold (the slots' ⚠️), so skins stating two curves leave it unresolved. Under a named skin one resolves (`fillingSkins`).
|
|
962
|
+
const fills = slot.setup === null ? [] : fillingSkins(doc, slot.name, slot.setup).flatMap((name) => {
|
|
963
|
+
const g = doc.skins.find((k) => k.name === name)?.attachments[slot.name]?.[slot.setup as string]?.geometry;
|
|
964
|
+
return g === undefined ? [] : [{ skin: name, g: JSON.stringify(g) }];
|
|
965
|
+
});
|
|
966
|
+
if (fills.length > 1 && fills.some((f) => f.g !== fills[0].g)) r.unresolved = `path constraint "${r.name}" walks slot "${r.slot}", whose placeholder "${slot.setup}" skins ${fills.map((x) => `"${x.skin}"`).join(', ')} fill differently — which one --skin all shows is the Spine file's skin order, not the model's; the per-skin dumps (--skin <name>) judge it`;
|
|
967
|
+
else if (shown !== null && 'conflict' in shown) r.unresolved = `path constraint "${r.name}" walks slot "${r.slot}", whose placeholder "${slot.setup}" skins ${shown.conflict.map((x) => `"${x.skin}"`).join(', ')} fill differently — which one --skin all shows is the Spine file's skin order, not the model's; the per-skin dumps (--skin <name>) judge it`;
|
|
968
|
+
else if (shown !== null && shown.record.geometry?.kind === 'path') {
|
|
969
|
+
const g = shown.record.geometry;
|
|
970
|
+
r.path = { vertices: g.vertices, closed: g.closed, constantSpeed: g.constantSpeed, lengths: g.lengths };
|
|
971
|
+
}
|
|
972
|
+
const deps: string[] = [];
|
|
973
|
+
for (const skin of lookupSkins(doc)) {
|
|
974
|
+
for (const record of Object.values(skin.attachments[r.slot] ?? {})) {
|
|
975
|
+
const g = record.geometry;
|
|
976
|
+
if (g?.kind !== 'path') continue;
|
|
977
|
+
for (const b of g.vertices.weighted ? g.vertices.bindings.flatMap((v) => v.map((x) => x.bone)) : [r.slotBone]) if (!deps.includes(b)) deps.push(b);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
r.slotDeps = deps;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** Every linked mesh whose `skin`, `slot` and `source` do not resolve to a mesh record, named — the runtime refuses such a file too. */
|
|
985
|
+
function linkProblems(skins: readonly CoreSkin[]): string[] {
|
|
986
|
+
const out: string[] = [];
|
|
987
|
+
skins.forEach((skin, i) => {
|
|
988
|
+
for (const [slot, table] of Object.entries(skin.attachments)) {
|
|
989
|
+
for (const [placeholder, record] of Object.entries(table)) {
|
|
990
|
+
const g = record.geometry;
|
|
991
|
+
if (g?.kind !== 'linkedmesh') continue;
|
|
992
|
+
const why = sourceProblem(skins, g.skin, g.slot, g.source);
|
|
993
|
+
if (why !== null) out.push(`skins[${i}] "${skin.name}".attachments["${slot}"]["${placeholder}"]: ${why}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
return out;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Why a linked mesh's source does not resolve, or null when it is a mesh record. */
|
|
1001
|
+
function sourceProblem(skins: readonly CoreSkin[], skin: string, slot: string, source: string): string | null {
|
|
1002
|
+
const found = skins.find((k) => k.name === skin);
|
|
1003
|
+
if (found === undefined) return `the linked mesh's skin "${skin}" is not a skin of this document`;
|
|
1004
|
+
const record = found.attachments[slot]?.[source];
|
|
1005
|
+
if (record === undefined) return `the linked mesh's source "${source}" is not in skin "${skin}" slot "${slot}"`;
|
|
1006
|
+
if (record.kind !== 'mesh') return `the linked mesh's source "${source}" in skin "${skin}" slot "${slot}" is a ${record.kind}, not a mesh`;
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* The oracle's rounding, stated in its header: six decimals, half up
|
|
1012
|
+
* (`Math.round(v * 1e6) / 1e6`), `null` for a value that is not finite, and a
|
|
1013
|
+
* `-0` written as `0`. `tools/pose_oracle.ts` rounds with this function, so the
|
|
1014
|
+
* two dumpers cannot round two ways.
|
|
1015
|
+
*/
|
|
1016
|
+
export function gridRound(v: number): number | null {
|
|
1017
|
+
if (!Number.isFinite(v)) return null;
|
|
1018
|
+
const out = Math.round(v * 1e6) / 1e6;
|
|
1019
|
+
return out === 0 ? 0 : out;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* The raw entry's number (issue #966): the double as computed, `null` for a
|
|
1024
|
+
* value that is not finite, and a `-0` written as `0` — what `JSON.stringify`
|
|
1025
|
+
* writes for it anyway, so an in-process row and its document agree. No grid:
|
|
1026
|
+
* `pose_oracle.ts dump --raw` writes both dumpers' numbers through this.
|
|
1027
|
+
*/
|
|
1028
|
+
export function rawNumber(v: number): number | null {
|
|
1029
|
+
if (!Number.isFinite(v)) return null;
|
|
1030
|
+
return v === 0 ? 0 : v;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** One bone of a posed setup: `[name, worldX, worldY, a, b, c, d, active, parent]`, the oracle's row. */
|
|
1034
|
+
export type CoreBoneRow = [string, number | null, number | null, number | null, number | null, number | null, number | null, 0 | 1, string | null];
|
|
1035
|
+
|
|
1036
|
+
/** What computes the world transforms: the core's own `worldTransforms` (`./world.ts`) unless a caller passes another. */
|
|
1037
|
+
export type SetupEvaluator = (bones: readonly ModelBone[], active: ReadonlySet<string>) => Map<string, CoreWorld>;
|
|
1038
|
+
|
|
1039
|
+
/** One slot of a posed setup: `[name, attachment, r, g, b, a, dark, path, blend]`, the oracle's row. */
|
|
1040
|
+
export type CoreSlotRow = [string, string | null, number | null, number | null, number | null, number | null, [number | null, number | null, number | null] | null, string | null, string | null];
|
|
1041
|
+
|
|
1042
|
+
/** The record a slot's setup placeholder resolves to, and the skin whose table holds it. */
|
|
1043
|
+
export interface CoreShown {
|
|
1044
|
+
skin: string;
|
|
1045
|
+
placeholder: string;
|
|
1046
|
+
record: CoreAttachment;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* What a slot shows at setup: `null` for nothing, the record shown, or — when
|
|
1051
|
+
* the skins filling its placeholder disagree about the row — `conflict`,
|
|
1052
|
+
* naming each skin with the name it would show (the header's ⚠️).
|
|
1053
|
+
*/
|
|
1054
|
+
export type ShownResolution = CoreShown | null | { conflict: Array<{ skin: string; shown: string; path: string | null }> };
|
|
1055
|
+
|
|
1056
|
+
/** What resolves a slot's setup attachment: `shownAttachment` unless a caller passes another. */
|
|
1057
|
+
export type ShownResolver = (doc: CompiledDocument, slot: ModelSlot) => ShownResolution;
|
|
1058
|
+
|
|
1059
|
+
/** What reads a slot's blend mode: `readBlend` unless a caller passes another. */
|
|
1060
|
+
export type BlendReader = (slot: ModelSlot) => CoreBlendMode;
|
|
1061
|
+
|
|
1062
|
+
/** What reads a stated light colour into its four channels: `readColour` unless a caller passes another. */
|
|
1063
|
+
export type ColourReader = (hex: string) => [number, number, number, number];
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* What the core poses with, each part replaceable. The core suite's plants
|
|
1067
|
+
* pass a copy of one of them — a mode's sign flipped, the wrong skin, a
|
|
1068
|
+
* channel misread — and nothing else passes any.
|
|
1069
|
+
*/
|
|
1070
|
+
export interface CorePlant {
|
|
1071
|
+
evaluate?: SetupEvaluator;
|
|
1072
|
+
shown?: ShownResolver;
|
|
1073
|
+
colour?: ColourReader;
|
|
1074
|
+
/** A slot's blend mode (`readBlend`). */
|
|
1075
|
+
blend?: BlendReader;
|
|
1076
|
+
/** A region's corners (`regionCorners` in `./vertices.ts`). */
|
|
1077
|
+
region?: RegionPoser;
|
|
1078
|
+
/** A vertex array's world positions (`worldVertices` in `./vertices.ts`). */
|
|
1079
|
+
vertices?: VertexPoser;
|
|
1080
|
+
/** The ik, transform and path constraints as posed, rewritten before they are applied (`./constraints.ts`). */
|
|
1081
|
+
constraints?: ConstraintPlant;
|
|
1082
|
+
/** Issue #979's solver rules, one planted back to the reading before it (`RUNTIME_SOLVER_RULES` in `./constraints.ts`). */
|
|
1083
|
+
solver?: Partial<SolverRules>;
|
|
1084
|
+
/** A deform timeline's array at a time (`deformAt` in `./deform.ts`). */
|
|
1085
|
+
deform?: DeformEvaluator;
|
|
1086
|
+
/** A slider's deform over the current one (`blendDeform` in `./deform.ts`). */
|
|
1087
|
+
deformBlend?: DeformBlender;
|
|
1088
|
+
/** A sequence timeline's frame at a time (`sequenceFrameAt` in `./deform.ts`). */
|
|
1089
|
+
sequence?: SequenceEvaluator;
|
|
1090
|
+
/** A draw-order timeline's order at a time (`drawOrderAt` in `./draw_order.ts`). */
|
|
1091
|
+
drawOrder?: DrawOrderEvaluator;
|
|
1092
|
+
/** The events fired between two samples (`eventsFired` in `./events.ts`). */
|
|
1093
|
+
events?: EventsFired;
|
|
1094
|
+
/** The stepped phase's step of one physics constraint (`stepPhysics` in `./constraints_physics.ts`). */
|
|
1095
|
+
physicsStep?: PhysicsStepper;
|
|
1096
|
+
/** One attachment's triangles against a clip polygon (`clipTriangles` in `./clipping.ts`). */
|
|
1097
|
+
clip?: TriangleClipper;
|
|
1098
|
+
/** One attachment's triangles against any clip the core draws (`clipThrough` in `./clipping.ts`) — the drawn rows the raw entry hands the render. */
|
|
1099
|
+
through?: ShapeClipper;
|
|
1100
|
+
/** Whether a slot's timelines apply (`slotTimelinesApply` in `./skins.ts`). */
|
|
1101
|
+
slotTimelines?: SlotTimelineGate;
|
|
1102
|
+
/**
|
|
1103
|
+
* Not a plant: what every number of a row is written as — `gridRound` (the
|
|
1104
|
+
* oracle's grid) unless the raw entry (`./raw.ts`, issue #966) passes
|
|
1105
|
+
* `rawNumber`, the unrounded double.
|
|
1106
|
+
*/
|
|
1107
|
+
round?: (v: number) => number | null;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** The document's ik, transform and path constraint records, in its order — what `applyConstraints` runs. */
|
|
1111
|
+
export function constraintRecords(doc: CompiledDocument): CoreConstraintRecord[] {
|
|
1112
|
+
return doc.constraints.flatMap((c) => (c.record === undefined ? [] : [c.record]));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The blocks of the oracle's document the core does not produce, each with the
|
|
1117
|
+
* construct that has to be admitted first (§5 of the design on issue #380), in the
|
|
1118
|
+
* document's key order. Empty since issue #956 wrote the `physics` block, the
|
|
1119
|
+
* last one; kept so a block left to a later construct has one place to be
|
|
1120
|
+
* named. `setup.*` blocks are not here: each is produced, or absent for the
|
|
1121
|
+
* reason `poseSetup` gives.
|
|
1122
|
+
*/
|
|
1123
|
+
export const NOT_ADMITTED: ReadonlyArray<readonly [string, string]> = [];
|
|
1124
|
+
|
|
1125
|
+
/** The setup blocks the core poses, in the document's order. */
|
|
1126
|
+
const POSED_BLOCKS = ['setup.bones', 'setup.slots', 'setup.drawOrder', 'setup.attachments', 'setup.clips', 'setup.clipped'] as const;
|
|
1127
|
+
|
|
1128
|
+
/** Each view's active bones, kept for the view (`activeBones`). */
|
|
1129
|
+
const activeOfView = new WeakMap<CompiledDocument, ReadonlySet<string>>();
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Bones active under the skin view posed — the rule measured in the header
|
|
1133
|
+
* (`all`) and in `./skins.ts` (a named skin). Derived once per view object and
|
|
1134
|
+
* kept with it (issue #1134): the answer reads the view's bones and skins
|
|
1135
|
+
* only, a view is never written once it is posed, and every pose of a walk
|
|
1136
|
+
* asked again. Read-only to every caller; a view built anew (`underSkin`,
|
|
1137
|
+
* `underNoSkin`, a plant's) derives its own.
|
|
1138
|
+
*/
|
|
1139
|
+
export function activeBones(doc: CompiledDocument): ReadonlySet<string> {
|
|
1140
|
+
const kept = activeOfView.get(doc);
|
|
1141
|
+
if (kept !== undefined) return kept;
|
|
1142
|
+
const made = deriveActiveBones(doc);
|
|
1143
|
+
activeOfView.set(doc, made);
|
|
1144
|
+
return made;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function deriveActiveBones(doc: CompiledDocument): ReadonlySet<string> {
|
|
1148
|
+
const named = new Set(appliedSkins(doc).flatMap((s) => s.bones));
|
|
1149
|
+
const parentOf = new Map(doc.bones.map((b) => [b.name, b.parent]));
|
|
1150
|
+
const reached = new Set<string>();
|
|
1151
|
+
for (const name of named) {
|
|
1152
|
+
for (let at: string | undefined = name; at !== undefined && !reached.has(at); at = parentOf.get(at)) reached.add(at);
|
|
1153
|
+
}
|
|
1154
|
+
return new Set(doc.bones.filter((b) => b.skinRequired !== true || reached.has(b.name)).map((b) => b.name));
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* A stated light colour's four channels: each pair of hex digits over 255,
|
|
1159
|
+
* and an alpha of 1 when six digits are stated (the header's measurement).
|
|
1160
|
+
* `readModel` has already refused every other spelling.
|
|
1161
|
+
*/
|
|
1162
|
+
export function readColour(hex: string): [number, number, number, number] {
|
|
1163
|
+
const channel = (i: number): number => Number.parseInt(hex.slice(2 * i, 2 * i + 2), 16) / 255;
|
|
1164
|
+
return [channel(0), channel(1), channel(2), hex.length === 8 ? channel(3) : 1];
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* A slot's blend mode: `Normal` when it states none, else the stated
|
|
1169
|
+
* spelling folded (the header's measurement). `readModel` has already refused
|
|
1170
|
+
* every spelling that folds to no mode.
|
|
1171
|
+
*/
|
|
1172
|
+
export function readBlend(slot: ModelSlot): CoreBlendMode {
|
|
1173
|
+
if (slot.blend === undefined) return 'Normal';
|
|
1174
|
+
const mode = foldBlend(slot.blend);
|
|
1175
|
+
if (mode === null) throw new CoreInputError(`slot "${slot.name}": blend ${JSON.stringify(slot.blend)} is no mode the runtime reads`);
|
|
1176
|
+
return mode;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** The name a record shows and the region path it names — the header's two measured rules. */
|
|
1180
|
+
export function shownRow(shown: CoreShown): { name: string; path: string | null } {
|
|
1181
|
+
const name = shown.record.name ?? shown.placeholder;
|
|
1182
|
+
return { name, path: CORE_REGION_KINDS.has(shown.record.kind) ? (shown.record.path ?? name) : null };
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Each view's resolutions, by slot and placeholder, kept for the view (`shownAttachment`). */
|
|
1186
|
+
const shownOfView = new WeakMap<CompiledDocument, Map<string, Map<string, ShownResolution>>>();
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* What a slot shows at setup under every skin at once — the header's rule:
|
|
1190
|
+
* nothing for a `null` placeholder or one no skin fills; the one record where
|
|
1191
|
+
* every skin filling the placeholder gives the same row; a `conflict`, naming
|
|
1192
|
+
* each skin in the model's order, where they differ.
|
|
1193
|
+
*
|
|
1194
|
+
* The answer reads the view's skins and the slot's name and placeholder only,
|
|
1195
|
+
* so it is resolved once per view, slot and placeholder and kept (issue
|
|
1196
|
+
* #1134: every sample of a walk resolved every slot again). Read-only to every
|
|
1197
|
+
* caller; a view built anew resolves its own.
|
|
1198
|
+
*/
|
|
1199
|
+
export function shownAttachment(doc: CompiledDocument, slot: ModelSlot): ShownResolution {
|
|
1200
|
+
if (slot.setup === null) return null;
|
|
1201
|
+
let bySlot = shownOfView.get(doc);
|
|
1202
|
+
if (bySlot === undefined) {
|
|
1203
|
+
bySlot = new Map();
|
|
1204
|
+
shownOfView.set(doc, bySlot);
|
|
1205
|
+
}
|
|
1206
|
+
let byPlaceholder = bySlot.get(slot.name);
|
|
1207
|
+
if (byPlaceholder === undefined) {
|
|
1208
|
+
byPlaceholder = new Map();
|
|
1209
|
+
bySlot.set(slot.name, byPlaceholder);
|
|
1210
|
+
}
|
|
1211
|
+
if (byPlaceholder.has(slot.setup)) return byPlaceholder.get(slot.setup) as ShownResolution;
|
|
1212
|
+
const resolved = resolveShown(doc, slot.name, slot.setup);
|
|
1213
|
+
byPlaceholder.set(slot.setup, resolved);
|
|
1214
|
+
return resolved;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function resolveShown(doc: CompiledDocument, slotName: string, placeholder: string): ShownResolution {
|
|
1218
|
+
// Under a named skin: that skin's record, else the default skin's, else nothing (`./skins.ts`).
|
|
1219
|
+
if (doc.skin !== CORE_ALL_SKINS) {
|
|
1220
|
+
for (const skin of lookupSkins(doc)) {
|
|
1221
|
+
const record = skin.attachments[slotName]?.[placeholder];
|
|
1222
|
+
if (record !== undefined) return { skin: skin.name, placeholder, record };
|
|
1223
|
+
}
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
const filling: CoreShown[] = [];
|
|
1227
|
+
for (const skin of doc.skins) {
|
|
1228
|
+
const record = skin.attachments[slotName]?.[placeholder];
|
|
1229
|
+
if (record !== undefined) filling.push({ skin: skin.name, placeholder, record });
|
|
1230
|
+
}
|
|
1231
|
+
if (filling.length === 0) return null;
|
|
1232
|
+
const rows = filling.map((f) => ({ skin: f.skin, ...shownRow(f) }));
|
|
1233
|
+
const agree = rows.every((r) => r.name === rows[0].name && r.path === rows[0].path);
|
|
1234
|
+
return agree ? filling[0] : { conflict: rows.map((r) => ({ skin: r.skin, shown: r.name, path: r.path })) };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** The oracle's `setup` block as the core writes it: bones, slots, attachments and clips posed or absent, the draw order absent. */
|
|
1238
|
+
export interface CoreSetup {
|
|
1239
|
+
bones: CoreBoneRow[] | null;
|
|
1240
|
+
slots: CoreSlotRow[] | null;
|
|
1241
|
+
drawOrder: string[] | null;
|
|
1242
|
+
attachments: CoreAttachmentRow[] | null;
|
|
1243
|
+
clips: CoreClipRow[] | null;
|
|
1244
|
+
/** The triangles drawn under a clip, as the clipper returns them (issue #964, `./clipping.ts`). */
|
|
1245
|
+
clipped: CoreClippedRow[] | null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* The setup pose of every bone and every slot, in the oracle's row shapes and
|
|
1250
|
+
* rounding, and every block the core leaves absent with its reason (`absent`,
|
|
1251
|
+
* in document order). Bones are absent when the document declares a
|
|
1252
|
+
* constraint (the header's ⚠️), with the kinds and counts named; slots are
|
|
1253
|
+
* absent when a slot's setup placeholder is filled by skins that disagree
|
|
1254
|
+
* (the slots' ⚠️), with each such slot named. The attachments' world vertices
|
|
1255
|
+
* and the clipping polygons (`./vertices.ts`, issue #931) are absent when
|
|
1256
|
+
* either of those is — they go through the bones' world matrices and follow
|
|
1257
|
+
* what each slot shows — and the attachments also when a shown region's atlas
|
|
1258
|
+
* rectangle is `null`, every such slot named.
|
|
1259
|
+
*/
|
|
1260
|
+
export function poseSetup(doc: CompiledDocument, plant: CorePlant = {}, physics?: PhysicsStepContext): { setup: CoreSetup; absent: Array<[string, string]>; world: Map<string, CoreWorld> | null; shown: ShownGeometry[] | null } {
|
|
1261
|
+
const evaluate = plant.evaluate ?? worldTransforms;
|
|
1262
|
+
const resolve = plant.shown ?? shownAttachment;
|
|
1263
|
+
const colour = plant.colour ?? readColour;
|
|
1264
|
+
const blend = plant.blend ?? readBlend;
|
|
1265
|
+
const round = plant.round ?? gridRound;
|
|
1266
|
+
const bonesWhy = constraintsAbsentWhy(doc, solverRules(plant.solver));
|
|
1267
|
+
let bones: CoreBoneRow[] | null = null;
|
|
1268
|
+
let setupWorld: Map<string, CoreWorld> | null = null;
|
|
1269
|
+
// Each slider as it was applied, in constraint order — what the slots are posed from (`./constraints_slider.ts`).
|
|
1270
|
+
const applied: SliderApplication[] = [];
|
|
1271
|
+
if (bonesWhy === null) {
|
|
1272
|
+
const active = activeBones(doc);
|
|
1273
|
+
const records = constraintRecords(doc);
|
|
1274
|
+
setupWorld = applyConstraints(doc.bones, evaluate(doc.bones, active), active, plant.constraints ? plant.constraints(records) : records, null, applied, physics, undefined, solverRules(plant.solver));
|
|
1275
|
+
const world = setupWorld;
|
|
1276
|
+
bones = doc.bones.map((b): CoreBoneRow => {
|
|
1277
|
+
const t = world.get(b.name);
|
|
1278
|
+
if (t === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
|
|
1279
|
+
return [b.name, round(t.worldX), round(t.worldY), round(t.a), round(t.b), round(t.c), round(t.d), active.has(b.name) ? 1 : 0, b.parent ?? null];
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
const conflicts: string[] = [];
|
|
1283
|
+
const slotRows: CoreSlotRow[] = [];
|
|
1284
|
+
const liveBones = activeBones(doc);
|
|
1285
|
+
const slotGate = plant.slotTimelines ?? slotTimelinesApply;
|
|
1286
|
+
for (const slot of doc.slots) {
|
|
1287
|
+
const pose: SlotPoseState = {
|
|
1288
|
+
placeholder: slot.setup,
|
|
1289
|
+
light: slot.color === undefined ? [1, 1, 1, 1] : [...colour(slot.color)],
|
|
1290
|
+
dark: slot.dark === undefined ? null : readColour(slot.dark).slice(0, 3),
|
|
1291
|
+
};
|
|
1292
|
+
// A slot on an inactive bone is not animated, by a slider either (`./skins.ts`).
|
|
1293
|
+
if (slotGate(doc, slot, liveBones)) applySliderSlots(slot.name, pose, applied);
|
|
1294
|
+
const posedRecord: ModelSlot = { ...slot, setup: pose.placeholder };
|
|
1295
|
+
const shown = pose.placeholder === null ? null : resolve(doc, posedRecord);
|
|
1296
|
+
if (shown !== null && 'conflict' in shown) {
|
|
1297
|
+
conflicts.push(`slot "${slot.name}" placeholder "${pose.placeholder}" is filled by skins ${shown.conflict.map((c) => `"${c.skin}" (shows ${JSON.stringify(c.shown)}, path ${JSON.stringify(c.path)})`).join(', ')}`);
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
const row = shown === null ? null : shownRow(shown);
|
|
1301
|
+
const [r, g, b, a] = pose.light;
|
|
1302
|
+
const dark = pose.dark;
|
|
1303
|
+
slotRows.push([
|
|
1304
|
+
slot.name,
|
|
1305
|
+
row === null ? null : row.name,
|
|
1306
|
+
round(r), round(g), round(b), round(a),
|
|
1307
|
+
dark === null ? null : [round(dark[0]), round(dark[1]), round(dark[2])],
|
|
1308
|
+
row === null ? null : row.path,
|
|
1309
|
+
blend(slot),
|
|
1310
|
+
]);
|
|
1311
|
+
}
|
|
1312
|
+
const slotsWhy = (bonesWhy === null ? null : slidersWhy(doc)) ?? (conflicts.length === 0
|
|
1313
|
+
? null
|
|
1314
|
+
: `${conflicts.join('; ')} — under --skin all the LAST of them in the Spine file's skin order wins, and that order is the emitter's (default first, the rest in the editor's order), not the model's; the per-skin dumps (--skin <name>) judge it`);
|
|
1315
|
+
const slots = slotsWhy === null ? slotRows : null;
|
|
1316
|
+
const upstream = [bonesWhy === null ? null : `setup.bones is absent (${bonesWhy}), and every vertex goes through a bone's world matrix`, slotsWhy === null ? null : 'setup.slots is absent, so what a slot shows is not posed'].filter((x): x is string => x !== null);
|
|
1317
|
+
// The draw order: the slot order, then each slider's draw-order key (`./draw_order.ts`), which needs the sliders' times, read off the bones.
|
|
1318
|
+
const orderWhy = bonesWhy === null ? null : slidersKeyingWhy(doc, 'drawOrder');
|
|
1319
|
+
let drawOrder: string[] | null = null;
|
|
1320
|
+
if (orderWhy === null) {
|
|
1321
|
+
let order: number[] = doc.slots.map((_s, i) => i);
|
|
1322
|
+
for (const app of applied) order = (plant.drawOrder ?? drawOrderAt)(doc.slots.length, app.timelines.drawOrder, app.at) ?? order;
|
|
1323
|
+
drawOrder = order.map((i) => doc.slots[i].name);
|
|
1324
|
+
}
|
|
1325
|
+
let attachments: CoreAttachmentRow[] | null = null;
|
|
1326
|
+
let clips: CoreClipRow[] | null = null;
|
|
1327
|
+
let clipsWhy: string | null = upstream.length === 0 ? null : upstream.join('; ');
|
|
1328
|
+
let attachmentsWhy: string | null = clipsWhy;
|
|
1329
|
+
let clipped: CoreClippedRow[] | null = null;
|
|
1330
|
+
let clippedWhy: string | null = clipsWhy ?? orderWhy;
|
|
1331
|
+
// What the raw entry reads past the rows (issue #966, `./raw.ts`): the unrounded world transforms and the shown records in draw order.
|
|
1332
|
+
let shownInOrder: ShownGeometry[] | null = null;
|
|
1333
|
+
if (clipsWhy === null && setupWorld !== null && drawOrder !== null) {
|
|
1334
|
+
const world = setupWorld;
|
|
1335
|
+
// Each slot's shown record, with what the sliders' deform and sequence timelines set on it (`./deform.ts`), in the draw order.
|
|
1336
|
+
// From the setup placeholders; `attachmentStates` applies each slider's attachment key, then its deform and sequence keys, in order.
|
|
1337
|
+
const placeholders = new Map(doc.slots.map((sl) => [sl.name, sl.setup]));
|
|
1338
|
+
const states = attachmentStates(doc, resolve, placeholders, null, applied, plant);
|
|
1339
|
+
const rank = new Map(drawOrder.map((n, i) => [n, i]));
|
|
1340
|
+
const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
|
|
1341
|
+
shownInOrder = states.why.length === 0 ? shown : null;
|
|
1342
|
+
const posed = poseGeometry(shown, world, sourceOfDoc(doc), round, { region: plant.region, vertices: plant.vertices }, drawWalkOf(doc, drawOrder, plant));
|
|
1343
|
+
const stateWhy = states.why.length === 0 ? null : `${states.why.join('; ')}`;
|
|
1344
|
+
attachments = stateWhy === null ? posed.attachments : null;
|
|
1345
|
+
attachmentsWhy = stateWhy ?? posed.attachmentsWhy;
|
|
1346
|
+
clips = stateWhy === null ? posed.clips : null;
|
|
1347
|
+
clipsWhy = stateWhy;
|
|
1348
|
+
clipped = stateWhy === null ? posed.clipped : null;
|
|
1349
|
+
clippedWhy = stateWhy ?? posed.clippedWhy;
|
|
1350
|
+
}
|
|
1351
|
+
// `NOT_ADMITTED` is in document order; bones and slots stand before `setup.drawOrder`, attachments and clips after it.
|
|
1352
|
+
const why: Record<(typeof POSED_BLOCKS)[number], string | null> = { 'setup.bones': bonesWhy, 'setup.slots': slotsWhy, 'setup.drawOrder': orderWhy, 'setup.attachments': attachmentsWhy, 'setup.clips': clipsWhy, 'setup.clipped': clippedWhy };
|
|
1353
|
+
// `NOT_ADMITTED` (empty since issue #956) stands before the setup blocks in the document's order.
|
|
1354
|
+
const absent: Array<[string, string]> = NOT_ADMITTED.map(([block, reason]): [string, string] => [block, reason]);
|
|
1355
|
+
for (const block of POSED_BLOCKS) if (why[block] !== null) absent.push([block, why[block] as string]);
|
|
1356
|
+
return { setup: { bones, slots, drawOrder, attachments, clips, clipped }, absent, world: setupWorld, shown: shownInOrder };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** What the clipped block's draw walk reads (`DrawWalk` in `./vertices.ts`): the draw order, the active bones, and a linked mesh's source triangles. */
|
|
1360
|
+
export function drawWalkOf(doc: CompiledDocument, order: readonly string[], plant: CorePlant = {}): DrawWalk {
|
|
1361
|
+
const active = activeBones(doc);
|
|
1362
|
+
return {
|
|
1363
|
+
order,
|
|
1364
|
+
active,
|
|
1365
|
+
meshOf: (skin, slot, source) => {
|
|
1366
|
+
const g = doc.skins.find((k) => k.name === skin)?.attachments[slot]?.[source]?.geometry;
|
|
1367
|
+
return g?.kind === 'mesh' ? { uvs: g.uvs, triangles: g.triangles } : undefined;
|
|
1368
|
+
},
|
|
1369
|
+
...(plant.clip === undefined ? {} : { clip: plant.clip }),
|
|
1370
|
+
...(plant.through === undefined ? {} : { through: plant.through }),
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** A linked mesh's source vertices, or why they do not resolve — what `poseGeometry` reads a linked mesh through. */
|
|
1375
|
+
export function sourceOfDoc(doc: CompiledDocument): (skin: string, slot: string, source: string) => ModelVertices | string {
|
|
1376
|
+
return (skin, slot, source) => {
|
|
1377
|
+
const g = doc.skins.find((k) => k.name === skin)?.attachments[slot]?.[source]?.geometry;
|
|
1378
|
+
return g?.kind === 'mesh' ? g.vertices : (sourceProblem(doc.skins, skin, slot, source) ?? `the linked mesh's source "${source}" carries no vertices`);
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** Why a block a slider's animation keys cannot be posed because the bones its time is read from are absent, or null: `drawOrder` for the draw order. */
|
|
1383
|
+
export function slidersKeyingWhy(doc: CompiledDocument, group: 'drawOrder'): string | null {
|
|
1384
|
+
const keyed = doc.constraints.flatMap((c) => {
|
|
1385
|
+
if (c.kind !== 'slider') return [];
|
|
1386
|
+
const anim = doc.animations.find((a) => a.name === c.animation);
|
|
1387
|
+
return anim !== undefined && anim.timelines[group].length > 0 ? [`slider "${c.name}" applies animation "${c.animation}", which keys the draw order`] : [];
|
|
1388
|
+
});
|
|
1389
|
+
return keyed.length === 0 ? null : `${keyed.join('; ')} — a slider's time is read off the bones, which are absent`;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** Why the setup slots cannot be posed because a slider poses them and the bones its time is read from are absent, or null when no slider's animation keys a slot. */
|
|
1393
|
+
function slidersWhy(doc: CompiledDocument): string | null {
|
|
1394
|
+
const keyed = doc.constraints.flatMap((c) => {
|
|
1395
|
+
if (c.kind !== 'slider') return [];
|
|
1396
|
+
const slots = doc.animations.find((a) => a.name === c.animation)?.slots ?? [];
|
|
1397
|
+
return slots.length === 0 ? [] : [`slider "${c.name}" applies animation "${c.animation}", which keys slot(s) ${slots.map((x) => `"${x}"`).join(', ')}`];
|
|
1398
|
+
});
|
|
1399
|
+
return keyed.length === 0 ? null : `${keyed.join('; ')} — the oracle's setup applies sliders, and a slider poses the slots its animation keys at a time read off the bones, which are absent`;
|
|
1400
|
+
}
|