spine-rigc 2.56.0 → 2.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/docs/AUTHORING.md +103 -2
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +4 -1
- package/src/cli/core_commands.ts +52 -0
- package/src/cli/shared.ts +117 -1
- package/src/compile.ts +24 -2
- package/src/meshsplit.ts +683 -0
package/README.md
CHANGED
|
@@ -587,7 +587,8 @@ material, like the gallery: a clone runs them.
|
|
|
587
587
|
|
|
588
588
|
## Commands
|
|
589
589
|
|
|
590
|
-
Every command takes its paths explicitly. `rigc <command> --help` prints its flags
|
|
590
|
+
Every command takes its paths explicitly. `rigc <command> --help` prints its flags —
|
|
591
|
+
`rigc help <command>` prints the same page — and
|
|
591
592
|
[AUTHORING.md §0](docs/AUTHORING.md) is the same list with what each flag means, which
|
|
592
593
|
commands take it and what its default is.
|
|
593
594
|
|
|
@@ -600,6 +601,7 @@ commands take it and what its default is.
|
|
|
600
601
|
| `repack <dir> --out <dir>` | a packed build **repacked from its own output** — `skeleton.json`, `skeleton.atlas` and the pages, with no parts kept — under the packing flags `build --pack` takes, so a packer improvement reaches a build whose parts are gone. Every region is lifted off its page, the skeleton read back with `ingest`, and `build --pack` run over the lifted parts with the same gate; it writes only after every region is shown pixel-identical to the input's, the skeleton byte-identical and the gate green, and refuses by name an atlas it cannot lift exactly. A skeleton the rebuild does not reproduce byte for byte — a build written before 2.2.0, whose header box was its stage — is refused naming every differing path, and written, every path printed, only under `--accept-skeleton-differences`. `--stage-box <slot>` reads the stage from the box a build carries for it (`skeleton.stageBox`), the one way the three files alone carry the stage exactly ([AUTHORING §0.4](docs/AUTHORING.md)) |
|
|
601
602
|
| `repack <dir> --out <dir> --keep-pages` | **`skeleton.model.json` alone, for the pages already in place** — for a player that reads the document beside an atlas and pages that already shipped. The same read and stage rules, then `build --atlas-in` over the input atlas in place of the lift and the pack; it writes the document, and nothing else, only after its `pages` are shown to state the atlas as it stands, the rebuilt skeleton byte-identical to the one on disk (so `spine.sha256` names it — no acceptance flag here) and the gate green. `--out` is usually the build's own directory and must not already hold a `skeleton.model.json`; a page or region line the document has no field for is refused by name ([AUTHORING §0.4](docs/AUTHORING.md)) |
|
|
602
603
|
| `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
|
|
604
|
+
| `split --rig … --motion … --slot <name> --out <dir>` | the answer to a mesh refused because its triangles are **several pieces that share no vertex** — two legs after the triangles bridging them were deleted. It reads the two specs and writes them back into an absent or empty `--out` with one mesh per piece: piece 1 stays in the slot, piece k gets a new slot `<slot>_<k>` after it, each with the original's uvs and weights unchanged, its triangles renumbered outline first where the hull rule needs it, and every deform key re-sliced to the new indices — plus `split.json`, where every original vertex and triangle went. One piece is said so and nothing is written; what it cannot carry is refused by name ([AUTHORING §3.4.11](docs/AUTHORING.md)) |
|
|
603
605
|
| `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
|
|
604
606
|
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
|
|
605
607
|
| `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
|
package/docs/AUTHORING.md
CHANGED
|
@@ -156,6 +156,9 @@ name the commands that need the runtime (its `--help` lists the ones it runs).
|
|
|
156
156
|
gate before it wrote, and which rules that gate is on this entry, before the install
|
|
157
157
|
route; on a Spine export it says what it always said (`RC36`).
|
|
158
158
|
`rigc --version` names the entry that ran.
|
|
159
|
+
`rigc help <command>` prints what `rigc <command> --help` prints and exits the same
|
|
160
|
+
way, on either entry, and `rigc help` alone is `rigc --help`; a name that is no
|
|
161
|
+
command is refused naming the nearest one, exit 2, as an unknown command is.
|
|
159
162
|
|
|
160
163
|
That entry's `build` takes the same flags and writes the same files, byte for byte:
|
|
161
164
|
`skeleton.json`, `skeleton.atlas`, `skeleton.model.json`, and the pages `--pack` or
|
|
@@ -2304,7 +2307,7 @@ these messages prints the outline walk, because the walk is the fix:
|
|
|
2304
2307
|
| a stated `hull` that disagrees | `hull 25 disagrees with the triangles, whose outline has 16 vertices (0 → 1 → 2 → …). Delete "hull" and rigc derives it, or state 16` |
|
|
2305
2308
|
| outline vertices that are not the first `hull` of the list | `hull vertices must come first; vertex 19 is on the boundary and vertex 6 is not. The triangles' outline runs 0 → 1 → 2 → 3 → 4 → 9 → 14 → 19 → 24 → 23 → 22 → 21 → 20 → 15 → 10 → 5: list those 16 vertices first, in that order, then the 9 interior vertices` |
|
|
2306
2309
|
| outline vertices that are first but zigzag | `hull vertices must trace the outline in order; the triangles' outline runs 0 → 1 → 3 → 5 → … → 4 → 2, so vertex 3 has to follow vertex 1 in the list, and vertex 2 does. Renumber the vertices along that walk` |
|
|
2307
|
-
| triangles with no single outline | `the triangles do not tile the outline: 25 vertices with a 16-vertex outline tile as 32 triangles and there are 33` — an unused vertex or a doubled interior triangle; or `the triangles' outline is not one closed loop: vertex 4 has 4 boundary edges` — a pinch, a hole, or a doubled triangle on the perimeter |
|
|
2310
|
+
| triangles with no single outline | `the triangles do not tile the outline: 25 vertices with a 16-vertex outline tile as 32 triangles and there are 33` — an unused vertex or a doubled interior triangle; or `the triangles' outline is not one closed loop: vertex 4 has 4 boundary edges` — a pinch, a hole, or a doubled triangle on the perimeter. Triangles in pieces that share no vertex get the same sentence and then `— the triangles are 2 pieces that share no vertex …` naming `rigc split`, which writes one mesh per piece (§3.4.11) |
|
|
2308
2311
|
|
|
2309
2312
|
🚨 **A row-major grid is the case this catches**: a 5×5 grid's perimeter is 16 of
|
|
2310
2313
|
its 25 vertices, interleaved with the interior, so no `hull` can describe that
|
|
@@ -3552,6 +3555,104 @@ packed region is still refused.
|
|
|
3552
3555
|
`A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` (§5.2) holds the block and
|
|
3553
3556
|
every frame the timelines show against the file.
|
|
3554
3557
|
|
|
3558
|
+
#### 3.4.11 `split` — one mesh per piece, when the triangles are several
|
|
3559
|
+
|
|
3560
|
+
One mesh is one outline (the `hull` rule above), so a mesh whose triangles fall into
|
|
3561
|
+
pieces that share no vertex is refused at compile — typically after the transparent
|
|
3562
|
+
triangles that bridged two legs were deleted:
|
|
3563
|
+
|
|
3564
|
+
```
|
|
3565
|
+
rigc compile error: skin "default" slot "legs" attachment "legs": the triangles' outline is not one closed
|
|
3566
|
+
loop: the walk from vertex 0 closes after 8 of 16 boundary vertices — the triangles are 2 pieces that share no
|
|
3567
|
+
vertex, and one mesh is one outline: `rigc split --rig <rig> --motion <motion> --slot legs --attachment legs
|
|
3568
|
+
--out <dir>` writes one mesh per piece, its deform keys renumbered (AUTHORING §3.4.11) (see rigc guide §5.1)
|
|
3569
|
+
```
|
|
3570
|
+
|
|
3571
|
+
Each name in that command is one shell word: a slot, placeholder or skin whose name
|
|
3572
|
+
holds a space or a character a shell reads (`'`, `$`, `;`, …) is printed
|
|
3573
|
+
single-quoted, a `'` inside it as `'\''` — `--slot 'left legs'` — and a plain name
|
|
3574
|
+
is printed as it is. So the command copies into a shell as printed, with only
|
|
3575
|
+
`<rig>`, `<motion>` and `<dir>` to fill in.
|
|
3576
|
+
|
|
3577
|
+
The fix is one mesh per piece, and every number in it is a renumbering: each piece's
|
|
3578
|
+
`uvs`, its `weights` (or `vertices`), its `triangles`, and every `deform` key that
|
|
3579
|
+
moved it (§4.11). `rigc split` writes that proposal from the two spec files — not from
|
|
3580
|
+
a build, because a mesh of several pieces has none:
|
|
3581
|
+
|
|
3582
|
+
```bash
|
|
3583
|
+
rigc split --rig rig.json --motion motion.json --slot legs --out split/
|
|
3584
|
+
# rigc split /abs/rig.json
|
|
3585
|
+
# .. motion /abs/motion.json
|
|
3586
|
+
# .. mesh skin "default" slot "legs" attachment "legs": 18 vertices, 16 triangles, 2 pieces (triangles that share a vertex are one piece)
|
|
3587
|
+
# .. piece 1 slot "legs" (the original slot): 9 vertices, 8 triangles, hull 8 — original order kept: [3, 4, 5, 6, 7, 8, 9, 16, 17]
|
|
3588
|
+
# .. piece 2 slot "legs_2" (new, drawn after "legs"): 9 vertices, 8 triangles, hull 8 — renumbered outline first, as the hull rule needs: piece vertex 0.. is original 0 → 1 → 2 → 15 → 10 → 11 → 12 → 13, then the interior [14]
|
|
3589
|
+
# .. deform animation "walk": 5 key(s) re-sliced into 2 timelines — "legs" 3 with a run, "legs_2" 2 with a run
|
|
3590
|
+
# .. track animation "walk": slot "legs" "rgba" copied to "legs_2"
|
|
3591
|
+
# .. images ../ (the rig spec's own, restated from /abs/split — it resolves to /abs)
|
|
3592
|
+
# rigc: wrote /abs/split/rig.json
|
|
3593
|
+
# rigc: wrote /abs/split/motion.json
|
|
3594
|
+
# rigc: wrote /abs/split/split.json
|
|
3595
|
+
# rigc: build it with rigc build --rig /abs/split/rig.json --motion /abs/split/motion.json --out <dir>
|
|
3596
|
+
```
|
|
3597
|
+
|
|
3598
|
+
What it decides, and the one reason for each:
|
|
3599
|
+
|
|
3600
|
+
| | |
|
|
3601
|
+
| --- | --- |
|
|
3602
|
+
| a piece | triangles that share a vertex — nothing else: no distance, no art, no threshold. Two triangles touching at one vertex are one piece, and that piece is refused for its pinch as the compiler would refuse it |
|
|
3603
|
+
| which piece is 1 | the one whose first triangle is listed first. Spine draws a mesh's triangles in list order, so that is the piece the original drew first; when the pieces' triangles interleave in the list, an `order` line says the split draws each piece whole and where they overlap the stacking can differ |
|
|
3604
|
+
| where the pieces go | piece 1 stays in the slot under its placeholder, so everything naming the slot keeps its meaning; piece k gets a new slot **`<slot>_<k>`** on the same bone with the same colour, dark colour and blend, directly after the original in `slots` — the draw order — filled under the **same placeholder**, so `name`, `path` and `image` resolve as before. A derived name the rig already declares is refused by name |
|
|
3605
|
+
| a piece's vertex order | the original's relative order when it already lists the piece's outline first and in order; otherwise the outline walk, then the interior in original order — the order the `hull` refusals ask for, so the piece builds without a second edit |
|
|
3606
|
+
| `hull`, `edges` | `hull` is left out (rigc derives it); stated `edges` are split with their vertices, and an edge joining two pieces is refused — so is one from a piece to a vertex no triangle names, the refusal saying `(in no piece: no triangle names it)` for that end. An edge whose **two** vertices no triangle names has no piece to go in: it is dropped, and named on a `dropped` line — `dropped 1 stated edge(s) between vertices no triangle names, edge 2 (vertex 18 to vertex 19): …` — so the pieces' `edges` are the original's minus exactly the ones that line names |
|
|
3607
|
+
| a deform key | its run re-sliced: each number goes to its vertex's piece at the vertex's new index, written as `offset` + `vertices`, and an index inside a piece's new run that the original run did not cover is written **`0`** — what the parser held there, because a key is a sparse edit and both encodings are offsets from setup. A piece the run did not reach gets the key with no `vertices`, the setup pose, which is what the original did to it. Every piece keeps the key's time and curve |
|
|
3608
|
+
| colour and attachment tracks on the slot | copied to every new slot; an `attachment` key showing the split placeholder shows each piece, any other shows nothing in the new slots. `setup` (or the slot's `attachment`) the same way |
|
|
3609
|
+
| `invariants.deformMayFold` naming the slot | its entry copied to the new slots |
|
|
3610
|
+
| `invariants.meshSlots` | left as stated, and the line does the arithmetic: the mesh slots the split adds, the mesh attachments the proposal's skins then hold (every `type: "mesh"` without a `source`, as the compile counts them), and — when that is over the budget — the figure to raise it by: `invariant invariants.meshSlots states 1, and the split adds 1 mesh slot(s): the proposal's skins hold 2 mesh attachment(s), 1 over the budget, so its build is refused at compile ("2 mesh slot(s) emitted") unless the rig spec raises invariants.meshSlots by 1, to 2; …`. Within the budget it says so. The refusal it predicts is the compile's (§3.7), which stops before `A13_MESH_BUDGET` runs |
|
|
3611
|
+
| a vertex no triangle names | no piece can hold it and still build, and it draws nothing in either rig: dropped, and named on a `dropped` line and in `split.json` |
|
|
3612
|
+
| `images` | restated from `--out`, so every `image` resolves to the file it resolved to before |
|
|
3613
|
+
|
|
3614
|
+
It writes three files into an `--out` that is **absent or empty** — `rig.json` and
|
|
3615
|
+
`motion.json`, your two specs with the mesh split and nothing else changed but
|
|
3616
|
+
`images`, and `split.json` (`rigc-split/1`): for each piece its slot, placeholder,
|
|
3617
|
+
`vertices` (piece vertex `j` is original vertex `vertices[j]`), `triangles` (original
|
|
3618
|
+
triangle indices) and `hull`, and the `dropped` vertices. There is no in-place flag:
|
|
3619
|
+
your specs are the only statement of what the proposal must reproduce, so it is
|
|
3620
|
+
written beside them, never over them. Build it and look before adopting it.
|
|
3621
|
+
|
|
3622
|
+
A mesh of one piece is said so on one line — `rigc split: skin "default" slot "legs"
|
|
3623
|
+
attachment "legs" is one piece — …; nothing to split, and nothing written` — exit 0.
|
|
3624
|
+
A slot, attachment or skin the specs do not have is refused with the names they do,
|
|
3625
|
+
exit 2. What the split cannot carry is refused by name, exit 1, because each needs a
|
|
3626
|
+
decision the specs do not state:
|
|
3627
|
+
|
|
3628
|
+
| Refused | Why |
|
|
3629
|
+
| --- | --- |
|
|
3630
|
+
| a `generator` mesh | a generator builds one outline by construction; there are no authored triangles |
|
|
3631
|
+
| a `sequence` on the mesh, or a sequence timeline on it | which frames each piece shows is not stated |
|
|
3632
|
+
| `"boneIndexing": "raw"` | write the weights by name (`weights`) and split that |
|
|
3633
|
+
| a deform key stated as a `transform` (§4.11.1) | the model is evaluated over the whole attachment's geometry, and splitting changes the geometry it reads; state it as a `vertices` run |
|
|
3634
|
+
| a `fromVertex` key over a vertex with two or more bones, a run past the array | the compiler refuses the key; so does the split |
|
|
3635
|
+
| the slot filled in another skin | the new slots would show this skin's pieces under that skin too |
|
|
3636
|
+
| a linked mesh borrowing the mesh | its geometry would have to be one of the pieces |
|
|
3637
|
+
| a clip whose range ends at the slot | the new slots would fall outside it |
|
|
3638
|
+
| a draw-order key, or a slot-property track on a `group`, naming the slot | where the new slots go then is not stated |
|
|
3639
|
+
| a piece that is not one mesh either (a pinch, a hole) | splitting does not fix it; the message is the outline refusal for that piece |
|
|
3640
|
+
|
|
3641
|
+
⭐ **What makes the proposal exact rather than plausible**: a piece's vertices carry
|
|
3642
|
+
the original's entries unchanged, so posing the split rig puts every piece vertex where
|
|
3643
|
+
the original put its vertex. The selftest holds that at tolerance 0 — the setup pose and
|
|
3644
|
+
every frame of every animation, deform keys, colour and attachment keys included, on
|
|
3645
|
+
the core and on spine-core — on a public probe of two legs, against the same mesh with
|
|
3646
|
+
its bridge still in (deleting triangles moves no vertex, so that mesh is the original
|
|
3647
|
+
every frame is held to), and plants a renumbering off by one and a deform key left
|
|
3648
|
+
un-renumbered to see each go red (`SPL01`–`SPL11`). The three lines above are held
|
|
3649
|
+
the same way: the printed command on a slot, placeholder and skin named with spaces
|
|
3650
|
+
and shell characters runs in `/bin/sh` and splits that mesh, and printed bare it does
|
|
3651
|
+
not (`SPL12`); the proposal's edges map back to the original's minus the named one
|
|
3652
|
+
(`SPL13`); and at an exact `meshSlots` budget the raise the line states is the build
|
|
3653
|
+
refusal's own count minus its budget, raised by it the proposal builds green, and one
|
|
3654
|
+
more slot of budget prints no raise (`SPL14`).
|
|
3655
|
+
|
|
3555
3656
|
### 3.5 `constraints` — 4.3's single typed array
|
|
3556
3657
|
|
|
3557
3658
|
Spine 4.3 folds every constraint into one `constraints` array with a `type`
|
|
@@ -6892,7 +6993,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
|
|
|
6892
6993
|
| `hull N disagrees with the triangles, whose outline has K vertices (0 → …)` | §3.4 — delete `hull`, or state K |
|
|
6893
6994
|
| `hull vertices must come first; vertex i is on the boundary and vertex j is not. The triangles' outline runs …: list those K vertices first, in that order, then the M interior vertices` | §3.4 — renumber the vertices: the printed walk first, then the interior |
|
|
6894
6995
|
| `hull vertices must trace the outline in order; the triangles' outline runs …, so vertex a has to follow vertex b in the list, and vertex c does` | §3.4 — renumber along the printed walk |
|
|
6895
|
-
| `the triangles do not tile the outline: …` / `the triangles' outline is not one closed loop: …` | §3.4 — a doubled triangle, an unused vertex, a pinch or a hole in `triangles` |
|
|
6996
|
+
| `the triangles do not tile the outline: …` / `the triangles' outline is not one closed loop: …` | §3.4 — a doubled triangle, an unused vertex, a pinch or a hole in `triangles`. When the message goes on `— the triangles are N pieces that share no vertex …`, the mesh is several meshes: `rigc split` writes one per piece (§3.4.11) |
|
|
6896
6997
|
| `vertex N binds bone "X", which the rig does not declare as a bone` | §3.4 — an authored mesh's `weights` bind by NAME, like everything else in a rig spec. Fix the spelling, or declare the bone. The message names the skin, the slot, the placeholder and the vertex, because an index would name none of them |
|
|
6897
6998
|
| `vertex N binds bone index I, which is not in the bone list` | §3.4 — a `"boneIndexing": "raw"` run on a mesh, bounding box, clipping polygon or path names a bone by its place in the bone array, and I is no place in it: past the end, negative or not a whole number. Count the rig's `bones` from 0, or give the bindings by name as `weights`, which cannot miss this way. ⚠️ **Refused earlier, not newly** (issue #917): the run is decoded to bone names once, when the attachment is built, and this is where an index that names no bone stops. Before that a mesh compiled and `A20` named the index at the gate, a bounding box, clipping polygon or path with its `lengths` stated compiled and `A33` named it, and only a path whose `lengths` rigc measures refused here, in this sentence. `MV04` plants it on each of the four kinds |
|
|
6898
6999
|
| `the raw weighted run has a bone count of C at index I` / `the raw weighted run is truncated — vertex N claims C bone(s) and the array ends first` / `the raw weighted run decodes to N vertices but there are M uv pairs` | §3.4 — a mesh's `"boneIndexing": "raw"` run is not `boneCount, (index, x, y, weight) × boneCount` per vertex, one vertex per uv pair. These are the polygon's three sentences (whose last clause there reads `but vertexCount is M`), now asked of a mesh as well. Fix the run, or give the bindings by name as `weights`. ⚠️ **Refused earlier, not newly** (issue #917): the mesh run is decoded to bone names when the mesh is built, and a run that does not decode cannot be. Before that it compiled and `A20` named it at the gate (`has weights for 5 vertices but 4 uv pairs`, `vertex 3 has weight NaN`). `MV04` plants each |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.57.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skills/rigc/SKILL.md
CHANGED
|
@@ -100,7 +100,10 @@ row for each.
|
|
|
100
100
|
parts were not kept, and writes only after every region and the skeleton are
|
|
101
101
|
shown unchanged (`--accept-skeleton-differences` writes a rebuild whose skeleton
|
|
102
102
|
differs, every difference printed; `--stage-box <slot>` reads a build's stage box)
|
|
103
|
-
— AUTHORING §0.4.
|
|
103
|
+
— AUTHORING §0.4. A mesh refused because its triangles are several pieces that
|
|
104
|
+
share no vertex: `rigc split --rig … --motion … --slot <slot> --out <dir>` writes
|
|
105
|
+
both specs back with one mesh per piece and every deform key renumbered — build
|
|
106
|
+
that — AUTHORING §3.4.11.
|
|
104
107
|
7. Every finished unit ends with `rigc preview --candidate <out>`, and the report
|
|
105
108
|
names the `.html` it wrote. The hand-off to a person is part of the work.
|
|
106
109
|
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { findingLine, ingest, type IngestFinding, IngestSpecRefused, type Ingest
|
|
|
31
31
|
import { PARSER_DEFAULTS, parserReading } from '../keyorder.ts';
|
|
32
32
|
import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
|
|
33
33
|
import { parseMotionSpec } from '../motion.ts';
|
|
34
|
+
import { splitMesh, SplitError } from '../meshsplit.ts';
|
|
35
|
+
import { parseRigSpec } from '../rig.ts';
|
|
34
36
|
import { type InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, readTimes, readZoom } from '../inspect.ts';
|
|
35
37
|
import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine } from '../folds.ts';
|
|
36
38
|
import { AngleRangeError, angleRangeLine, type AngleRangeReading, angleRangesOf, type BonePair, readBonePairs } from '../anglerange.ts';
|
|
@@ -2100,6 +2102,55 @@ export const MODEL_AND_TEXT_GATE: BuildGate = {
|
|
|
2100
2102
|
look: (outDir) => `rigc: look at it: rigc render --candidate ${outDir}`,
|
|
2101
2103
|
};
|
|
2102
2104
|
|
|
2105
|
+
/**
|
|
2106
|
+
* split — one authored mesh's pieces, proposed as one mesh each (issue #1414).
|
|
2107
|
+
*
|
|
2108
|
+
* Reads the two spec files and nothing else: a mesh of several pieces does not
|
|
2109
|
+
* compile, so there is no build to read. Writes the proposal — both specs and
|
|
2110
|
+
* `split.json` — into an `--out` that is absent or empty, which is the whole of
|
|
2111
|
+
* how it never overwrites anything: there is no in-place flag, because the
|
|
2112
|
+
* specs it read are the only statement of what the proposal must reproduce,
|
|
2113
|
+
* and a proposal nobody has built yet is not something to put over them.
|
|
2114
|
+
*/
|
|
2115
|
+
function cmdSplit(flags: Record<string, string>): void {
|
|
2116
|
+
for (const flag of ['rig', 'motion', 'slot', 'out'] as const) {
|
|
2117
|
+
if (flags[flag] === undefined) throw new UsageError(`split needs --${flag}`);
|
|
2118
|
+
}
|
|
2119
|
+
const rigPath = resolve(flags.rig);
|
|
2120
|
+
const motionPath = resolve(flags.motion);
|
|
2121
|
+
const outDir = resolve(flags.out);
|
|
2122
|
+
for (const [flag, path] of [['rig', rigPath], ['motion', motionPath]] as const) {
|
|
2123
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new SplitError(`--${flag} ${path}: no file there`, 2);
|
|
2124
|
+
}
|
|
2125
|
+
if (existsSync(outDir) && (!statSync(outDir).isDirectory() || readdirSync(outDir).length > 0)) {
|
|
2126
|
+
throw new SplitError(
|
|
2127
|
+
`--out ${outDir} ${statSync(outDir).isDirectory() ? `holds ${readdirSync(outDir).length} entr${readdirSync(outDir).length === 1 ? 'y' : 'ies'}` : 'is a file'}; ` +
|
|
2128
|
+
'the proposal is written into an absent or empty directory only, so it never lands on top of a file — the specs it read included',
|
|
2129
|
+
2,
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
const rig = parseRigSpec(readJsonFile(rigPath), rigPath);
|
|
2133
|
+
const motion = parseMotionSpec(readJsonFile(motionPath), motionPath);
|
|
2134
|
+
// The rig spec's `images` is relative to the rig file, and the proposal is written somewhere else: restate it from
|
|
2135
|
+
// there, so every `image` resolves to the file it resolved to before.
|
|
2136
|
+
const imagesDir = resolve(dirname(rigPath), rig.images ?? '.');
|
|
2137
|
+
const images = relativeImagesPath(outDir, imagesDir);
|
|
2138
|
+
console.log(`rigc split ${rigPath}`);
|
|
2139
|
+
console.log(` .. motion ${motionPath}`);
|
|
2140
|
+
const result = splitMesh({ rig, motion, slot: flags.slot, ...(flags.attachment === undefined ? {} : { attachment: flags.attachment }), ...(flags.skin === undefined ? {} : { skin: flags.skin }), images });
|
|
2141
|
+
if (result.kind === 'whole') {
|
|
2142
|
+
console.log(`rigc split: ${result.line}`);
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
for (const line of result.lines) console.log(` .. ${line}`);
|
|
2146
|
+
console.log(` .. images ${images} (the rig spec's own, restated from ${outDir} — it resolves to ${imagesDir})`);
|
|
2147
|
+
mkdirSync(outDir, { recursive: true });
|
|
2148
|
+
writeJson(join(outDir, 'rig.json'), result.rig);
|
|
2149
|
+
writeJson(join(outDir, 'motion.json'), result.motion);
|
|
2150
|
+
writeJson(join(outDir, 'split.json'), result.record);
|
|
2151
|
+
console.log(`rigc: build it with rigc build --rig ${join(outDir, 'rig.json')} --motion ${join(outDir, 'motion.json')} --out <dir>`);
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2103
2154
|
/**
|
|
2104
2155
|
* The bodies the second entry runs under a name whose full-entry body is the
|
|
2105
2156
|
* runtime's (`runtime.core` in `COMMANDS`, issue #1060) — registered by
|
|
@@ -2121,6 +2172,7 @@ export const CORE_ENTRY_RUNS: Readonly<Record<string, CommandRun>> = {
|
|
|
2121
2172
|
export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
|
|
2122
2173
|
explain: ({ flags }) => cmdExplain(flags),
|
|
2123
2174
|
ingest: ({ flags, positional }) => cmdIngest(flags, positional),
|
|
2175
|
+
split: ({ flags }) => cmdSplit(flags),
|
|
2124
2176
|
diff: ({ flags, lists, positional }) => cmdDiff(flags, lists, positional),
|
|
2125
2177
|
check: ({ flags }) => cmdCheck(flags),
|
|
2126
2178
|
render: ({ flags }) => cmdRender(flags),
|
package/src/cli/shared.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { readViewportBox, ViewportError } from '../viewportpin.ts';
|
|
|
43
43
|
import { parseJsonWithPosition } from '../json-position.ts';
|
|
44
44
|
import { RUNG_IDS } from '../ladder.ts';
|
|
45
45
|
import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
|
|
46
|
-
import { compileErrorLine } from '../guide.ts';
|
|
46
|
+
import { compileErrorLine, nearestNames } from '../guide.ts';
|
|
47
47
|
import { PACKAGE_ROOT, readPackageMeta, readVersion } from '../package_meta.ts';
|
|
48
48
|
import { DEFAULT_MAX_RESIDUAL, DEFAULT_SCALE_MAX, DEFAULT_SCALE_MIN } from '../pose.ts';
|
|
49
49
|
import {
|
|
@@ -65,6 +65,7 @@ import { InspectError, DEFAULT_SHOW, DIM_KEEP, INSPECT_DEFAULT_FPS, ON_GRID_FRAM
|
|
|
65
65
|
import { NotAPngError } from '../png.ts';
|
|
66
66
|
import { PoseError } from '../pose.ts';
|
|
67
67
|
import { RepackError } from '../repack.ts';
|
|
68
|
+
import { SplitError } from '../meshsplit.ts';
|
|
68
69
|
import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
|
|
69
70
|
import type { CompileResult, DroppedState } from '../types.ts';
|
|
70
71
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
@@ -1098,6 +1099,9 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1098
1099
|
`printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
|
|
1099
1100
|
'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
|
|
1100
1101
|
animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
|
|
1102
|
+
attachment:
|
|
1103
|
+
"the placeholder the mesh is filed under in its slot, as the rig spec's skin names it. Absent, the slot's one " +
|
|
1104
|
+
'authored mesh in that skin; a slot holding none or several is refused naming them',
|
|
1101
1105
|
skin:
|
|
1102
1106
|
'pose under this skin, by the name the skeleton declares. Without it NO skin is set — every slot resolves ' +
|
|
1103
1107
|
'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
|
|
@@ -1254,6 +1258,7 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1254
1258
|
'anchor-residual': '<0..1>',
|
|
1255
1259
|
'inward-lever': '<px>',
|
|
1256
1260
|
animation: '<name>',
|
|
1261
|
+
attachment: '<name>',
|
|
1257
1262
|
skin: '<name>',
|
|
1258
1263
|
slot: '<name[,name…]>',
|
|
1259
1264
|
hide: '<name[,name…]>',
|
|
@@ -1540,6 +1545,46 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1540
1545
|
},
|
|
1541
1546
|
},
|
|
1542
1547
|
},
|
|
1548
|
+
{
|
|
1549
|
+
name: 'split',
|
|
1550
|
+
runtime: false,
|
|
1551
|
+
spineFormat: false,
|
|
1552
|
+
usage: ['rigc split --rig <path> --motion <path> --slot <name> [--attachment <name>] [--skin <name>] --out <dir>'],
|
|
1553
|
+
flags: ['rig', 'motion', 'slot', 'attachment', 'skin', 'out'],
|
|
1554
|
+
overrides: {
|
|
1555
|
+
out: {
|
|
1556
|
+
value: '<dir>',
|
|
1557
|
+
meaning:
|
|
1558
|
+
'directory to write the proposal into — rig.json and motion.json, the two specs with the mesh split, and ' +
|
|
1559
|
+
'split.json, where every original vertex and triangle went. Absent or empty, and refused otherwise: the ' +
|
|
1560
|
+
"proposal never lands on top of a file, the author's own specs included",
|
|
1561
|
+
},
|
|
1562
|
+
slot: {
|
|
1563
|
+
value: '<name>',
|
|
1564
|
+
meaning: 'the slot whose mesh to split, as the rig spec declares it. A name it does not declare is refused with every one it does',
|
|
1565
|
+
},
|
|
1566
|
+
skin: {
|
|
1567
|
+
meaning:
|
|
1568
|
+
'the skin the mesh is filed under in the rig spec (default: default). A name the rig spec does not declare ' +
|
|
1569
|
+
'is refused with the ones it does',
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1572
|
+
notes: [
|
|
1573
|
+
'split reads the two specs — not a build: a mesh of several pieces does not build —',
|
|
1574
|
+
'and finds the pieces of one authored mesh: triangles that share a vertex are one piece.',
|
|
1575
|
+
'A mesh of one piece is said so on one line, exit 0, and nothing is written. Otherwise it',
|
|
1576
|
+
'writes one mesh per piece: piece 1 (the piece whose first triangle is listed first) stays',
|
|
1577
|
+
'in the slot, piece k gets a new slot <slot>_<k> on the same bone, after it in the draw',
|
|
1578
|
+
"order, under the same placeholder. Each piece carries the original's uvs and weights (or",
|
|
1579
|
+
'vertices) unchanged and its triangles renumbered — outline first where the hull rule',
|
|
1580
|
+
'needs it — and every deform key is re-sliced to the new indices; colour and attachment',
|
|
1581
|
+
'tracks on the slot are copied to the new slots. Nothing is invented: a deform index the',
|
|
1582
|
+
'original run did not cover is written 0, which is what the parser held there. What the',
|
|
1583
|
+
'split cannot carry is refused by name, exit 1 (docs/AUTHORING.md §3.4.11 lists it); a',
|
|
1584
|
+
'name the specs do not have is refused with the names they do, exit 2. Nothing is ever',
|
|
1585
|
+
'written over the specs it read: build the proposal, look at it, then adopt it.',
|
|
1586
|
+
],
|
|
1587
|
+
},
|
|
1543
1588
|
{
|
|
1544
1589
|
name: 'diff',
|
|
1545
1590
|
runtime: false,
|
|
@@ -1945,6 +1990,16 @@ const USAGE_PARAGRAPHS: ReadonlyArray<{ about: readonly string[] | { flag: strin
|
|
|
1945
1990
|
'is missing from it beats no spec at all.',
|
|
1946
1991
|
],
|
|
1947
1992
|
},
|
|
1993
|
+
{
|
|
1994
|
+
about: ['split'],
|
|
1995
|
+
lines: () => [
|
|
1996
|
+
"split answers the refusal a mesh of several pieces meets at compile — the triangles'",
|
|
1997
|
+
'outline is not one closed loop — with one mesh per piece, its uvs, weights, triangles',
|
|
1998
|
+
'and deform keys renumbered, written beside the specs and never over them:',
|
|
1999
|
+
' rigc split --rig rig.json --motion motion.json --slot legs --out split/',
|
|
2000
|
+
'Build what it wrote. See `rigc split --help`.',
|
|
2001
|
+
],
|
|
2002
|
+
},
|
|
1948
2003
|
{
|
|
1949
2004
|
about: ['pose'],
|
|
1950
2005
|
lines: () => [
|
|
@@ -2030,6 +2085,7 @@ export function usageText(docs: readonly CommandDoc[], checkout: string, rules:
|
|
|
2030
2085
|
...docs.flatMap((c) => c.usage.map((u) => ` ${u}`)),
|
|
2031
2086
|
'',
|
|
2032
2087
|
' rigc <command> --help that command\'s own flag table',
|
|
2088
|
+
...HELP_DOC.usage.map((u) => ` ${u}`),
|
|
2033
2089
|
' rigc --version print the installed version (-v works too)',
|
|
2034
2090
|
];
|
|
2035
2091
|
// Read only by the --profile paragraph, which only an entry running a command that takes --profile prints.
|
|
@@ -2783,6 +2839,46 @@ function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[], args: rea
|
|
|
2783
2839
|
);
|
|
2784
2840
|
}
|
|
2785
2841
|
|
|
2842
|
+
/**
|
|
2843
|
+
* `rigc help` documented as a command is (issue #1440): on the usage of both
|
|
2844
|
+
* entries, and its own page for `rigc help --help` and `rigc help help`, as
|
|
2845
|
+
* every listed command answers `--help`. Not a row of `COMMANDS`, because it
|
|
2846
|
+
* has no body of its own: it is the dispatch reading another spelling of
|
|
2847
|
+
* `--help` (`helpArgv`), on every entry alike.
|
|
2848
|
+
*/
|
|
2849
|
+
const HELP_DOC: CommandDoc = {
|
|
2850
|
+
name: 'help',
|
|
2851
|
+
usage: ['rigc help [<command>] the same page as rigc [<command>] --help, and the same exit'],
|
|
2852
|
+
flags: [],
|
|
2853
|
+
runtime: false,
|
|
2854
|
+
spineFormat: false,
|
|
2855
|
+
};
|
|
2856
|
+
|
|
2857
|
+
/**
|
|
2858
|
+
* `rigc help [<command>]` (issue #1440): the command line it stands for, which
|
|
2859
|
+
* the dispatch then runs in its place — `rigc --help` for `help` alone,
|
|
2860
|
+
* `rigc <command> --help` for a command the table documents — or `null` for
|
|
2861
|
+
* `help`'s own page (`help --help`, `help help`). Rewritten rather than
|
|
2862
|
+
* printed here, so the page and the exit code are the ones that spelling gets
|
|
2863
|
+
* on this entry and cannot come apart from them: a command this entry does not
|
|
2864
|
+
* run is refused as `<command> --help` is.
|
|
2865
|
+
*
|
|
2866
|
+
* ⚠️ A name the table does not document is refused by name with the nearest
|
|
2867
|
+
* one, as a `UsageError` — printed and exited as an unknown command is, exit 2
|
|
2868
|
+
* — and so is more than one name, rather than a guess at which was meant.
|
|
2869
|
+
*/
|
|
2870
|
+
export function helpArgv(rest: readonly string[]): string[] | null {
|
|
2871
|
+
if (rest.length === 0) return ['--help'];
|
|
2872
|
+
if (rest.length > 1) {
|
|
2873
|
+
throw new UsageError(`help takes one command name and was given ${rest.length}: ${rest.join(' ')} — \`rigc help <command>\` is \`rigc <command> --help\``);
|
|
2874
|
+
}
|
|
2875
|
+
const [name] = rest;
|
|
2876
|
+
if (name === HELP_DOC.name || name === '--help' || name === '-h') return null;
|
|
2877
|
+
if (KNOWN_COMMANDS.includes(name)) return [name, '--help'];
|
|
2878
|
+
const [nearest] = nearestNames(name, [...KNOWN_COMMANDS, HELP_DOC.name], 1);
|
|
2879
|
+
throw new UsageError(`unknown command: ${name} — the nearest command is ${nearest} (\`rigc help ${nearest}\`)`);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2786
2882
|
/**
|
|
2787
2883
|
* Run `argv` (`process.argv` without the runtime and the script) through
|
|
2788
2884
|
* `entry`: the usage, `--version`, a command's `--help`, the command, and
|
|
@@ -2797,6 +2893,20 @@ export function runCli(entry: CliEntry, argv: readonly string[]): void {
|
|
|
2797
2893
|
throw new Error(`internal: this entry runs [${known.join(', ')}] by the command table and registers [${registered.join(', ')}]`);
|
|
2798
2894
|
}
|
|
2799
2895
|
const USAGE = usageText(docs, entry.checkout, entry.profileRules);
|
|
2896
|
+
if (argv[0] === 'help') {
|
|
2897
|
+
let target: string[] | null;
|
|
2898
|
+
try {
|
|
2899
|
+
target = helpArgv(argv.slice(1));
|
|
2900
|
+
} catch (err) {
|
|
2901
|
+
refuse(err, 'help', USAGE, entry);
|
|
2902
|
+
}
|
|
2903
|
+
if (target === null) {
|
|
2904
|
+
console.log(commandHelp(HELP_DOC.name, [HELP_DOC]));
|
|
2905
|
+
process.exit(0);
|
|
2906
|
+
}
|
|
2907
|
+
runCli(entry, target);
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2800
2910
|
const [command, ...rest] = argv;
|
|
2801
2911
|
try {
|
|
2802
2912
|
if (command === undefined) {
|
|
@@ -2913,6 +3023,12 @@ function refuse(err: unknown, command: string | undefined, USAGE: string, entry:
|
|
|
2913
3023
|
console.error(`rigc repack: ${err.message}`);
|
|
2914
3024
|
process.exit(err.status);
|
|
2915
3025
|
}
|
|
3026
|
+
// A split refused (issue #1414): a name the specs do not have (2 — the invocation has to change), or something
|
|
3027
|
+
// the specs hold that the split cannot carry (1). The message names it, and the usage under it would bury that.
|
|
3028
|
+
if (err instanceof SplitError) {
|
|
3029
|
+
console.error(`rigc split: ${err.message}`);
|
|
3030
|
+
process.exit(err.status);
|
|
3031
|
+
}
|
|
2916
3032
|
// A page or frame that is not a PNG rigc can read, from any command that
|
|
2917
3033
|
// opens one (`render`, `check`, `preview`, …) — issue #732. The sentence is
|
|
2918
3034
|
// the one reader's and already names the file, what it is and what rigc
|
package/src/compile.ts
CHANGED
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
type MeshGeometry,
|
|
100
100
|
type MeshVertexWeight,
|
|
101
101
|
} from './mesh.ts';
|
|
102
|
+
import { meshPieces, shellWord } from './meshsplit.ts';
|
|
102
103
|
import { Plate, readPlate } from '../tools/plate.ts';
|
|
103
104
|
import {
|
|
104
105
|
extractRegion,
|
|
@@ -5499,6 +5500,7 @@ function authoredHullAndEdges(
|
|
|
5499
5500
|
vertexCount: number,
|
|
5500
5501
|
triangles: number[],
|
|
5501
5502
|
where: string,
|
|
5503
|
+
named: { skin: string; slot: string; placeholder: string },
|
|
5502
5504
|
): { hull: number; edges: number[] } {
|
|
5503
5505
|
try {
|
|
5504
5506
|
const outline = traceOutline(vertexCount, triangles);
|
|
@@ -5511,11 +5513,31 @@ function authoredHullAndEdges(
|
|
|
5511
5513
|
checkHullOrder(outline, vertexCount);
|
|
5512
5514
|
return { hull: outline.hull, edges: att.edges ?? meshEdges(vertexCount, triangles, outline.hull) };
|
|
5513
5515
|
} catch (err) {
|
|
5514
|
-
if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}`);
|
|
5516
|
+
if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}${splitPointer(vertexCount, triangles, named)}`);
|
|
5515
5517
|
throw err;
|
|
5516
5518
|
}
|
|
5517
5519
|
}
|
|
5518
5520
|
|
|
5521
|
+
/**
|
|
5522
|
+
* The command that answers an outline refusal whose cause is pieces that share
|
|
5523
|
+
* no vertex (issue #1414): one mesh is one outline, so the fix is one mesh per
|
|
5524
|
+
* piece, and `rigc split` writes that proposal. Empty for every other cause —
|
|
5525
|
+
* a pinch, a hole, a doubled triangle — whose fix is the triangles, and for a
|
|
5526
|
+
* list the refusal is about because its indices are not vertices at all.
|
|
5527
|
+
*/
|
|
5528
|
+
function splitPointer(vertexCount: number, triangles: readonly number[], named: { skin: string; slot: string; placeholder: string }): string {
|
|
5529
|
+
if (triangles.length % 3 !== 0 || triangles.some((v) => !Number.isInteger(v) || v < 0 || v >= vertexCount)) return '';
|
|
5530
|
+
const count = meshPieces(vertexCount, triangles).pieces.length;
|
|
5531
|
+
if (count < 2) return '';
|
|
5532
|
+
// Each name one shell word (issue #1442): the command is copied into a shell, and a bare `left legs` is two arguments.
|
|
5533
|
+
const skin = named.skin === 'default' ? '' : ` --skin ${shellWord(named.skin)}`;
|
|
5534
|
+
return (
|
|
5535
|
+
` — the triangles are ${count} pieces that share no vertex, and one mesh is one outline: ` +
|
|
5536
|
+
`\`rigc split --rig <rig> --motion <motion> --slot ${shellWord(named.slot)} --attachment ${shellWord(named.placeholder)}${skin} --out <dir>\` ` +
|
|
5537
|
+
'writes one mesh per piece, its deform keys renumbered (AUTHORING §3.4.11)'
|
|
5538
|
+
);
|
|
5539
|
+
}
|
|
5540
|
+
|
|
5519
5541
|
/**
|
|
5520
5542
|
* The same two fields for geometry rigc built. The generators already put the
|
|
5521
5543
|
* outline first and in order — a contour is all outline, a ring's outer ring
|
|
@@ -5599,7 +5621,7 @@ function buildRigMesh(
|
|
|
5599
5621
|
vertices = { weighted: false, xy: raw.map(f32) };
|
|
5600
5622
|
}
|
|
5601
5623
|
}
|
|
5602
|
-
const { hull, edges } = authoredHullAndEdges(att, uvCount / 2, att.triangles, where);
|
|
5624
|
+
const { hull, edges } = authoredHullAndEdges(att, uvCount / 2, att.triangles, where, { skin: ctx.skinName, slot: ctx.slotName, placeholder });
|
|
5603
5625
|
// `width`/`height` are the size of the image the mesh is drawn on. Stated
|
|
5604
5626
|
// wins; otherwise the PNG the attachment names, already measured for the
|
|
5605
5627
|
// atlas — the same `CompiledImage.width` a region reads, so a page with a
|
package/src/meshsplit.ts
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splitting an authored mesh into its pieces (issue #1414, item 2).
|
|
3
|
+
*
|
|
4
|
+
* One mesh is one outline: `hull` is read off the triangles, and a triangle list
|
|
5
|
+
* whose outline is not one closed loop is refused at compile (`traceOutline`). So
|
|
6
|
+
* an author who deletes the triangles that bridged two islands — two legs joined
|
|
7
|
+
* by transparent cells, say — is left with a mesh no build accepts, and the only
|
|
8
|
+
* way forward is one mesh per island: its UVs, its weights, its triangles and
|
|
9
|
+
* every deform key that moved it, renumbered. This module writes that proposal
|
|
10
|
+
* from the two spec files, and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* ⭐ **A piece is a set of triangles connected through shared vertices**, and
|
|
13
|
+
* nothing else decides it: no distance, no art, no threshold. Two triangles that
|
|
14
|
+
* touch at one vertex are one piece (and that piece is then refused for its pinch
|
|
15
|
+
* by the outline rule, exactly as the compiler would refuse it); two that share
|
|
16
|
+
* nothing are two. A vertex no triangle names draws nothing in either rig, so it
|
|
17
|
+
* belongs to no piece and is dropped, by name.
|
|
18
|
+
*
|
|
19
|
+
* 🔒 **Nothing in the proposal is a value the spec did not state.** Every piece's
|
|
20
|
+
* vertices carry the original's `uvs` and `weights` (or `vertices`) entries
|
|
21
|
+
* unchanged; its triangles are the original's, renumbered; its deform runs are the
|
|
22
|
+
* original's numbers at their new indices, with a `0` wherever the new array has
|
|
23
|
+
* a slot the original run did not cover — which is what the original array held
|
|
24
|
+
* there, because the parser fills everything outside a run with zero (AUTHORING
|
|
25
|
+
* §4.11: a key is a sparse edit, and both encodings are offsets from setup). So a
|
|
26
|
+
* piece poses exactly as its vertices did inside the original mesh; the selftest
|
|
27
|
+
* holds that at tolerance 0, frame by frame, on both posers.
|
|
28
|
+
*
|
|
29
|
+
* Where the pieces go, and why there:
|
|
30
|
+
*
|
|
31
|
+
* - **The first piece stays in the original slot, under the original
|
|
32
|
+
* placeholder.** Everything that names the slot — its setup attachment, its
|
|
33
|
+
* colour and attachment timelines, a clip's range, a stage box — keeps
|
|
34
|
+
* meaning what it meant, so the proposal edits the fewest lines it can.
|
|
35
|
+
* - **Piece k (k ≥ 2) gets a new slot `<slot>_<k>`** (`pieceSlotName`), on the
|
|
36
|
+
* same bone with the same colour, dark colour and blend, placed directly after
|
|
37
|
+
* the original in the slot list — which is the draw order — and filled under
|
|
38
|
+
* the SAME placeholder, so the attachment's `name`, `path` and `image` resolve
|
|
39
|
+
* exactly as before. A derived name that is already a slot is refused by name;
|
|
40
|
+
* the compiler would refuse the duplicate, and a different spelling would be a
|
|
41
|
+
* name this module made up.
|
|
42
|
+
* - **Pieces are numbered by their first triangle in the list**, which is the
|
|
43
|
+
* order the original drew them in: Spine draws a mesh's triangles in list
|
|
44
|
+
* order, so piece 1 is under piece 2 wherever they overlap. When the pieces'
|
|
45
|
+
* triangles interleave in the list that is not the whole truth, and the
|
|
46
|
+
* proposal says so rather than staying silent about it.
|
|
47
|
+
*
|
|
48
|
+
* A piece's vertices keep their original relative order when that order already
|
|
49
|
+
* lists the piece's outline first and in order; otherwise they are renumbered the
|
|
50
|
+
* way the hull refusal asks — the outline walk first, then the interior in
|
|
51
|
+
* original order — so every piece builds without a second hand edit.
|
|
52
|
+
*
|
|
53
|
+
* ⛔ What the proposal refuses rather than guesses, each by name: a generated
|
|
54
|
+
* mesh (its generator builds one outline by construction), a `sequence`, a raw
|
|
55
|
+
* `boneIndexing` run, a deform key stated as a `transform` model (it is evaluated
|
|
56
|
+
* over the attachment's whole geometry, and splitting changes the geometry it
|
|
57
|
+
* reads), the slot filled in another skin, a linked mesh borrowing this one, a
|
|
58
|
+
* clip whose range ends at this slot, a draw-order key or a slot-property group
|
|
59
|
+
* track naming the slot, and a sequence timeline on the attachment. Each would
|
|
60
|
+
* need a decision the spec does not state.
|
|
61
|
+
*
|
|
62
|
+
* Pure: no clock, no randomness, no file system. The CLI (`rigc split`) reads and
|
|
63
|
+
* writes the files; this module maps one pair of parsed specs to another.
|
|
64
|
+
*/
|
|
65
|
+
import { checkHullOrder, formatWalk, MeshError, traceOutline } from './mesh.ts';
|
|
66
|
+
import { splitRigSkin, type RigAttachment, type RigMeshAttachment, type RigMeshBinding, type RigSlot, type RigSpec } from './rig.ts';
|
|
67
|
+
import type { MotionDeformKey, MotionDeformTrack, MotionSetupSlot, MotionSpec, MotionTrack } from './types.ts';
|
|
68
|
+
|
|
69
|
+
/** A split refused: `status` 2 when the invocation names something the specs do not have, 1 when the specs hold something the split cannot carry. */
|
|
70
|
+
export class SplitError extends Error {
|
|
71
|
+
constructor(
|
|
72
|
+
message: string,
|
|
73
|
+
readonly status: 1 | 2,
|
|
74
|
+
) {
|
|
75
|
+
super(message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The record `rigc split` writes beside the two proposed specs. */
|
|
80
|
+
export const SPLIT_SPEC = 'rigc-split/1';
|
|
81
|
+
|
|
82
|
+
/** The slot piece `k` (1-based) of a split of `slot` is filed under. Piece 1 keeps the slot itself. */
|
|
83
|
+
export function pieceSlotName(slot: string, k: number): string {
|
|
84
|
+
return k === 1 ? slot : `${slot}_${k}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A name as one POSIX shell word (issue #1442): unchanged when every character
|
|
89
|
+
* is one no shell treats specially, otherwise single-quoted with each `'`
|
|
90
|
+
* written `'\''`. A command line a refusal prints is copied into a shell, and
|
|
91
|
+
* a slot named `left legs` printed bare is two arguments there — the second
|
|
92
|
+
* refusal would then be about a slot nobody named.
|
|
93
|
+
*/
|
|
94
|
+
export function shellWord(name: string): string {
|
|
95
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(name) ? name : `'${name.replaceAll("'", "'\\''")}'`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The mesh attachments a build of `rig` (with no manifest beside it) counts
|
|
100
|
+
* against `invariants.meshSlots`: every `type: "mesh"` in every skin that does
|
|
101
|
+
* not borrow its geometry through `source` — a linked mesh is not counted — the
|
|
102
|
+
* authored ones included, because a stated budget counts them. The selftest
|
|
103
|
+
* holds this to the compile refusal's own count (SPL14).
|
|
104
|
+
*/
|
|
105
|
+
function budgetedMeshCount(rig: RigSpec): number {
|
|
106
|
+
let count = 0;
|
|
107
|
+
for (const [name, skin] of Object.entries(rig.skins ?? {})) {
|
|
108
|
+
for (const map of Object.values(splitRigSkin(skin, `skin "${name}"`).attachments)) {
|
|
109
|
+
for (const entry of Object.values(map)) {
|
|
110
|
+
const att: unknown = entry;
|
|
111
|
+
if (isRecord(att) && att.type === 'mesh' && att.source === undefined) count++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return count;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The properties a track keys on a slot, as opposed to a bone or a constraint. */
|
|
119
|
+
const SLOT_PROPERTIES = new Set<string>(['rgba', 'rgb', 'alpha', 'rgba2', 'rgb2', 'attachment']);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The connected pieces of a triangulation: triangles that share a vertex are one
|
|
123
|
+
* piece. Each piece lists its triangles (indices into the triangle list, in list
|
|
124
|
+
* order) and its vertices (ascending); pieces are ordered by their first
|
|
125
|
+
* triangle. `orphans` are the vertices no triangle names, ascending.
|
|
126
|
+
*/
|
|
127
|
+
export interface MeshPieces {
|
|
128
|
+
pieces: Array<{ triangles: number[]; vertices: number[] }>;
|
|
129
|
+
orphans: number[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Read the pieces off a triangle list over `vertexCount` vertices. Indices are assumed in range (the caller checks). */
|
|
133
|
+
export function meshPieces(vertexCount: number, triangles: readonly number[]): MeshPieces {
|
|
134
|
+
const parent = Array.from({ length: vertexCount }, (_v, i) => i);
|
|
135
|
+
const find = (v: number): number => {
|
|
136
|
+
let root = v;
|
|
137
|
+
while (parent[root] !== root) root = parent[root];
|
|
138
|
+
while (parent[v] !== root) {
|
|
139
|
+
const next = parent[v];
|
|
140
|
+
parent[v] = root;
|
|
141
|
+
v = next;
|
|
142
|
+
}
|
|
143
|
+
return root;
|
|
144
|
+
};
|
|
145
|
+
const used = new Array<boolean>(vertexCount).fill(false);
|
|
146
|
+
for (let i = 0; i + 2 < triangles.length; i += 3) {
|
|
147
|
+
const [a, b, c] = [triangles[i], triangles[i + 1], triangles[i + 2]];
|
|
148
|
+
used[a] = used[b] = used[c] = true;
|
|
149
|
+
// Union toward the smaller root, so the root is a deterministic function of the list.
|
|
150
|
+
for (const [p, q] of [[a, b], [b, c]]) {
|
|
151
|
+
const rp = find(p);
|
|
152
|
+
const rq = find(q);
|
|
153
|
+
if (rp !== rq) parent[Math.max(rp, rq)] = Math.min(rp, rq);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const byRoot = new Map<number, { triangles: number[]; vertices: number[] }>();
|
|
157
|
+
for (let t = 0; 3 * t + 2 < triangles.length; t++) {
|
|
158
|
+
const root = find(triangles[3 * t]);
|
|
159
|
+
let piece = byRoot.get(root);
|
|
160
|
+
if (piece === undefined) {
|
|
161
|
+
piece = { triangles: [], vertices: [] };
|
|
162
|
+
byRoot.set(root, piece);
|
|
163
|
+
}
|
|
164
|
+
piece.triangles.push(t);
|
|
165
|
+
}
|
|
166
|
+
for (let v = 0; v < vertexCount; v++) if (used[v]) byRoot.get(find(v))?.vertices.push(v);
|
|
167
|
+
// Map insertion order is first-triangle order, because triangles were visited in list order.
|
|
168
|
+
return { pieces: [...byRoot.values()], orphans: [...Array(vertexCount).keys()].filter((v) => !used[v]) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What the caller asks to split. */
|
|
172
|
+
export interface SplitRequest {
|
|
173
|
+
rig: RigSpec;
|
|
174
|
+
motion: MotionSpec;
|
|
175
|
+
slot: string;
|
|
176
|
+
/** The placeholder; absent, the slot's one authored mesh in the skin. */
|
|
177
|
+
attachment?: string;
|
|
178
|
+
/** The skin the attachment is filed under. Absent: `default`. */
|
|
179
|
+
skin?: string;
|
|
180
|
+
/** The rig spec's `images` as the proposal must state it (it is written somewhere else); absent, the field is left as it is. */
|
|
181
|
+
images?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Two plausible wrong renumberings, planted by the selftest's controls and by
|
|
186
|
+
* nothing else: the vertex data placed one position along from where the record
|
|
187
|
+
* says it is, and every deform key copied to every piece as it stood — its start
|
|
188
|
+
* and its numbers unmoved, cut where the piece's shorter array ends, which is
|
|
189
|
+
* what a hand split that copied the keys and trimmed them until the build
|
|
190
|
+
* passed would hold (untrimmed, the compiler refuses the overrun first, and the
|
|
191
|
+
* pose reading this plant exists to test never runs).
|
|
192
|
+
*/
|
|
193
|
+
export interface SplitPlant {
|
|
194
|
+
offByOne?: boolean;
|
|
195
|
+
deformUnrenumbered?: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One proposed piece, as the record states it. */
|
|
199
|
+
export interface SplitPieceRecord {
|
|
200
|
+
slot: string;
|
|
201
|
+
attachment: string;
|
|
202
|
+
/** The original vertex index of each of this piece's vertices, in the piece's order: piece vertex `j` is original vertex `vertices[j]`. */
|
|
203
|
+
vertices: number[];
|
|
204
|
+
/** The original triangle index of each of this piece's triangles, in the piece's order. */
|
|
205
|
+
triangles: number[];
|
|
206
|
+
/** The piece's outline vertex count — the `hull` its build derives. */
|
|
207
|
+
hull: number;
|
|
208
|
+
/** Whether the piece's vertices had to be renumbered outline first, rather than kept in original order. */
|
|
209
|
+
renumbered: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `split.json`: what was split and where each original vertex and triangle went. */
|
|
213
|
+
export interface SplitRecord {
|
|
214
|
+
spec: typeof SPLIT_SPEC;
|
|
215
|
+
skin: string;
|
|
216
|
+
slot: string;
|
|
217
|
+
attachment: string;
|
|
218
|
+
vertices: number;
|
|
219
|
+
triangles: number;
|
|
220
|
+
pieces: SplitPieceRecord[];
|
|
221
|
+
/** Original vertices no triangle names: in no piece, and drawn by neither rig. */
|
|
222
|
+
dropped: number[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The answer: a proposal, or the statement that the mesh is one piece. */
|
|
226
|
+
export type SplitResult =
|
|
227
|
+
| { kind: 'split'; rig: RigSpec; motion: MotionSpec; record: SplitRecord; lines: string[] }
|
|
228
|
+
| { kind: 'whole'; line: string };
|
|
229
|
+
|
|
230
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
231
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function quoteList(names: readonly string[]): string {
|
|
235
|
+
return names.length === 0 ? 'none' : names.map((n) => `"${n}"`).join(', ');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `[0, 1, 2, …, 9]` with the tail elided past `max` entries — a vertex list as a line prints it. */
|
|
239
|
+
function shortList(values: readonly number[], max = 12): string {
|
|
240
|
+
return values.length <= max ? `[${values.join(', ')}]` : `[${values.slice(0, max).join(', ')}, … ${values.length - max} more]`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Rebuild `record` with `entries` inserted directly after `after`'s key, preserving every other key's order. */
|
|
244
|
+
function insertAfter<T>(record: Record<string, T>, after: string, entries: ReadonlyArray<[string, T]>): Record<string, T> {
|
|
245
|
+
const out: Record<string, T> = {};
|
|
246
|
+
for (const [key, value] of Object.entries(record)) {
|
|
247
|
+
out[key] = value;
|
|
248
|
+
if (key === after) for (const [k, v] of entries) out[k] = v;
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Propose the split of one authored mesh into its pieces.
|
|
255
|
+
*
|
|
256
|
+
* The specs are not edited: the proposal is built on copies. A mesh of one piece
|
|
257
|
+
* answers `whole` with the line that says so; anything the split cannot carry
|
|
258
|
+
* throws `SplitError` naming it.
|
|
259
|
+
*/
|
|
260
|
+
export function splitMesh(request: SplitRequest, plant: SplitPlant = {}): SplitResult {
|
|
261
|
+
const rig = structuredClone(request.rig);
|
|
262
|
+
const motion = structuredClone(request.motion);
|
|
263
|
+
const skinName = request.skin ?? 'default';
|
|
264
|
+
const slotName = request.slot;
|
|
265
|
+
const where = (placeholder: string): string => `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`;
|
|
266
|
+
|
|
267
|
+
// --- what the invocation names ----------------------------------------------------------------------------
|
|
268
|
+
const slotNames = rig.slots.map((s) => s.name);
|
|
269
|
+
const slotIndex = slotNames.indexOf(slotName);
|
|
270
|
+
if (slotIndex < 0) throw new SplitError(`the rig spec declares no slot "${slotName}"; it declares ${quoteList(slotNames)}`, 2);
|
|
271
|
+
const skins = rig.skins ?? {};
|
|
272
|
+
const skin = skins[skinName];
|
|
273
|
+
if (skin === undefined) throw new SplitError(`the rig spec declares no skin "${skinName}"; it declares ${quoteList(Object.keys(skins))}`, 2);
|
|
274
|
+
const attachments = splitRigSkin(skin, `skin "${skinName}"`).attachments;
|
|
275
|
+
const filled = attachments[slotName];
|
|
276
|
+
if (filled === undefined) {
|
|
277
|
+
const fillers = Object.keys(skins).filter((name) => splitRigSkin(skins[name], `skin "${name}"`).attachments[slotName] !== undefined);
|
|
278
|
+
throw new SplitError(`skin "${skinName}" fills nothing in slot "${slotName}"; the skins that fill it: ${quoteList(fillers)}`, 2);
|
|
279
|
+
}
|
|
280
|
+
// Any mesh, generated ones included, so that the one mesh a slot holds is named — and refused for its generator by
|
|
281
|
+
// name below — rather than reported missing.
|
|
282
|
+
const isMesh = (att: unknown): boolean => isRecord(att) && att.type === 'mesh';
|
|
283
|
+
let placeholder = request.attachment;
|
|
284
|
+
if (placeholder === undefined) {
|
|
285
|
+
const meshes = Object.keys(filled).filter((p) => isMesh(filled[p]));
|
|
286
|
+
if (meshes.length !== 1) {
|
|
287
|
+
throw new SplitError(
|
|
288
|
+
`slot "${slotName}" in skin "${skinName}" holds ${meshes.length} mesh(es) (${quoteList(meshes)}) among ${quoteList(Object.keys(filled))}; name the one to split with --attachment`,
|
|
289
|
+
2,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
placeholder = meshes[0];
|
|
293
|
+
}
|
|
294
|
+
const original = filled[placeholder];
|
|
295
|
+
if (original === undefined) {
|
|
296
|
+
throw new SplitError(`slot "${slotName}" in skin "${skinName}" has no attachment "${placeholder}"; it has ${quoteList(Object.keys(filled))}`, 2);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- what the attachment is --------------------------------------------------------------------------------
|
|
300
|
+
const at = where(placeholder);
|
|
301
|
+
const fields: unknown = original;
|
|
302
|
+
const type = isRecord(fields) && typeof fields.type === 'string' ? fields.type : 'region';
|
|
303
|
+
if (type !== 'mesh') throw new SplitError(`${at} is a "${type}" attachment; only a mesh has triangles to split`, 1);
|
|
304
|
+
const mesh = original as RigMeshAttachment;
|
|
305
|
+
if (mesh.generator !== undefined) {
|
|
306
|
+
throw new SplitError(`${at} is built by a "${mesh.generator.kind}" generator, which builds one outline by construction; there are no authored triangles to split`, 1);
|
|
307
|
+
}
|
|
308
|
+
if (mesh.sequence !== undefined) {
|
|
309
|
+
throw new SplitError(`${at} carries a "sequence"; a numbered series over one triangulation would have to become one per piece, and which frames each piece shows is not something the spec states`, 1);
|
|
310
|
+
}
|
|
311
|
+
if (mesh.boneIndexing === 'raw') {
|
|
312
|
+
throw new SplitError(`${at} states its weights as a raw index run ("boneIndexing": "raw"); write them as "weights", by name, and split that`, 1);
|
|
313
|
+
}
|
|
314
|
+
if (mesh.uvs === undefined || mesh.triangles === undefined || (mesh.vertices === undefined) === (mesh.weights === undefined)) {
|
|
315
|
+
throw new SplitError(`${at} needs uvs, triangles and exactly one of vertices or weights to be split`, 1);
|
|
316
|
+
}
|
|
317
|
+
const uvs = mesh.uvs;
|
|
318
|
+
const triangles = mesh.triangles;
|
|
319
|
+
if (uvs.length % 2 !== 0) throw new SplitError(`${at} has ${uvs.length} uv numbers, which is not a whole number of pairs`, 1);
|
|
320
|
+
const vertexCount = uvs.length / 2;
|
|
321
|
+
if (triangles.length % 3 !== 0) throw new SplitError(`${at} has ${triangles.length} triangle indices, which is not a whole number of triangles`, 1);
|
|
322
|
+
for (let i = 0; i < triangles.length; i++) {
|
|
323
|
+
const v = triangles[i];
|
|
324
|
+
if (!Number.isInteger(v) || v < 0 || v >= vertexCount) {
|
|
325
|
+
throw new SplitError(`${at}: triangle ${Math.floor(i / 3)} names vertex ${v}, and the mesh has vertices 0..${vertexCount - 1}`, 1);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const weights = mesh.weights;
|
|
329
|
+
const unweighted = mesh.vertices;
|
|
330
|
+
if (weights !== undefined && weights.length !== vertexCount) {
|
|
331
|
+
throw new SplitError(`${at}: weights cover ${weights.length} vertices but there are ${vertexCount} uv pairs`, 1);
|
|
332
|
+
}
|
|
333
|
+
if (unweighted !== undefined && unweighted.length !== uvs.length) {
|
|
334
|
+
throw new SplitError(`${at}: "vertices" is ${unweighted.length} numbers and the ${vertexCount} uv pairs need ${uvs.length}; a weighted run is written as "weights"`, 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- the pieces ------------------------------------------------------------------------------------------
|
|
338
|
+
const { pieces, orphans } = meshPieces(vertexCount, triangles);
|
|
339
|
+
if (pieces.length === 0) throw new SplitError(`${at} has no triangles`, 1);
|
|
340
|
+
if (pieces.length === 1) {
|
|
341
|
+
return {
|
|
342
|
+
kind: 'whole',
|
|
343
|
+
line:
|
|
344
|
+
`${at} is one piece — its ${triangles.length / 3} triangle(s) over ${vertexCount - orphans.length} vertices each reach every other through shared vertices` +
|
|
345
|
+
`${orphans.length === 0 ? '' : ` (and ${orphans.length} vertex(es) no triangle names: ${shortList(orphans)})`}; nothing to split, and nothing written`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const names = pieces.map((_p, i) => pieceSlotName(slotName, i + 1));
|
|
349
|
+
for (const name of names.slice(1)) {
|
|
350
|
+
if (slotNames.includes(name)) {
|
|
351
|
+
throw new SplitError(`${at} is ${pieces.length} pieces, and piece ${names.indexOf(name) + 1}'s slot would be "${name}", which the rig spec already declares; rename that slot and split again`, 1);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- what the rest of the two specs says about the slot ---------------------------------------------------
|
|
356
|
+
const refusals: string[] = [];
|
|
357
|
+
for (const [name, other] of Object.entries(skins)) {
|
|
358
|
+
if (name === skinName) continue;
|
|
359
|
+
if (splitRigSkin(other, `skin "${name}"`).attachments[slotName] !== undefined) {
|
|
360
|
+
refusals.push(`skin "${name}" also fills slot "${slotName}", so the new slots would show this skin's pieces under that skin too`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const [name, other] of Object.entries(skins)) {
|
|
364
|
+
for (const [ownSlot, map] of Object.entries(splitRigSkin(other, `skin "${name}"`).attachments)) {
|
|
365
|
+
for (const [ph, entry] of Object.entries(map)) {
|
|
366
|
+
const att: unknown = entry;
|
|
367
|
+
if (!isRecord(att)) continue;
|
|
368
|
+
if (att.type === 'linkedmesh' && att.source === placeholder && (att.slot ?? ownSlot) === slotName && (att.skin ?? 'default') === skinName) {
|
|
369
|
+
refusals.push(`skin "${name}" slot "${ownSlot}" attachment "${ph}" is a linked mesh borrowing this mesh's geometry`);
|
|
370
|
+
}
|
|
371
|
+
if (att.type === 'clipping' && att.end === slotName) {
|
|
372
|
+
refusals.push(`skin "${name}" slot "${ownSlot}" attachment "${ph}" is a clip whose range ends at slot "${slotName}", and the new slots would fall outside it`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const groups = motion.groups ?? {};
|
|
378
|
+
for (const [animation, anim] of Object.entries(motion.animations)) {
|
|
379
|
+
for (const track of anim.tracks) {
|
|
380
|
+
if (track.group !== undefined && SLOT_PROPERTIES.has(track.property) && (groups[track.group] ?? []).includes(slotName)) {
|
|
381
|
+
refusals.push(`animation "${animation}" keys "${track.property}" on group "${track.group}", which holds slot "${slotName}"; the new slots are in no group`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const key of anim.drawOrder ?? []) {
|
|
385
|
+
if ((key.offsets ?? []).some((o) => o.slot === slotName)) {
|
|
386
|
+
refusals.push(`animation "${animation}" moves slot "${slotName}" in its draw order at t=${key.t}; where the new slots go then is not stated`);
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const track of anim.sequence ?? []) {
|
|
391
|
+
if ((track.skin ?? 'default') === skinName && track.slot === slotName && track.attachment === placeholder) {
|
|
392
|
+
refusals.push(`animation "${animation}" keys a sequence on ${at}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const track of anim.deform ?? []) {
|
|
396
|
+
if ((track.skin ?? 'default') !== skinName || track.slot !== slotName || track.attachment !== placeholder) continue;
|
|
397
|
+
track.keys.forEach((key, i) => {
|
|
398
|
+
if (key.transform !== undefined) {
|
|
399
|
+
refusals.push(`animation "${animation}" deform key ${i} (t=${key.t}) states a "${key.transform.kind}" transform, which is evaluated over the whole attachment's geometry; state it as a "vertices" run to split it`);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (refusals.length > 0) throw new SplitError(`${at} is ${pieces.length} pieces, and the split cannot carry what else names it: ${refusals.join('; ')}`, 1);
|
|
405
|
+
|
|
406
|
+
// --- each piece's vertex order ------------------------------------------------------------------------------
|
|
407
|
+
const orders: number[][] = [];
|
|
408
|
+
const hulls: number[] = [];
|
|
409
|
+
const renumbered: boolean[] = [];
|
|
410
|
+
pieces.forEach((piece, p) => {
|
|
411
|
+
const ascending = piece.vertices;
|
|
412
|
+
const localOf = new Map(ascending.map((v, j) => [v, j]));
|
|
413
|
+
const local = piece.triangles.flatMap((t) => [triangles[3 * t], triangles[3 * t + 1], triangles[3 * t + 2]].map((v) => localOf.get(v) as number));
|
|
414
|
+
let outline;
|
|
415
|
+
try {
|
|
416
|
+
outline = traceOutline(ascending.length, local);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (err instanceof MeshError) {
|
|
419
|
+
throw new SplitError(`${at}: piece ${p + 1} (vertices ${shortList(ascending)}) is not one mesh either: ${err.message}`, 1);
|
|
420
|
+
}
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
let order = ascending;
|
|
424
|
+
let moved = false;
|
|
425
|
+
try {
|
|
426
|
+
checkHullOrder(outline, ascending.length);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (!(err instanceof MeshError)) throw err;
|
|
429
|
+
const onOutline = new Set(outline.walk);
|
|
430
|
+
order = [...outline.walk.map((j) => ascending[j]), ...ascending.filter((_v, j) => !onOutline.has(j))];
|
|
431
|
+
moved = true;
|
|
432
|
+
}
|
|
433
|
+
orders.push(order);
|
|
434
|
+
hulls.push(outline.hull);
|
|
435
|
+
renumbered.push(moved);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// --- the deform array's layout: influences per vertex, and where each vertex starts --------------------------
|
|
439
|
+
const influences = (v: number): number => (weights === undefined ? 1 : weights[v].length);
|
|
440
|
+
const base: number[] = [];
|
|
441
|
+
let length = 0;
|
|
442
|
+
for (let v = 0; v < vertexCount; v++) {
|
|
443
|
+
base.push(length);
|
|
444
|
+
length += 2 * influences(v);
|
|
445
|
+
}
|
|
446
|
+
const pieceOf = new Array<number>(vertexCount).fill(-1);
|
|
447
|
+
const positionOf = new Array<number>(vertexCount).fill(-1);
|
|
448
|
+
const pieceBase: number[][] = [];
|
|
449
|
+
const pieceLength: number[] = [];
|
|
450
|
+
orders.forEach((order, p) => {
|
|
451
|
+
let at2 = 0;
|
|
452
|
+
const bases: number[] = [];
|
|
453
|
+
order.forEach((v, j) => {
|
|
454
|
+
pieceOf[v] = p;
|
|
455
|
+
positionOf[v] = j;
|
|
456
|
+
bases.push(at2);
|
|
457
|
+
at2 += 2 * influences(v);
|
|
458
|
+
});
|
|
459
|
+
pieceBase.push(bases);
|
|
460
|
+
pieceLength.push(at2);
|
|
461
|
+
});
|
|
462
|
+
/** The vertex that owns original deform index `x`. */
|
|
463
|
+
const ownerOf: number[] = [];
|
|
464
|
+
for (let v = 0; v < vertexCount; v++) for (let k = 0; k < 2 * influences(v); k++) ownerOf.push(v);
|
|
465
|
+
|
|
466
|
+
// --- stated edges between two dropped vertices: no piece holds them, so they are dropped too, by name -----------
|
|
467
|
+
const droppedEdges: string[] = [];
|
|
468
|
+
if (Array.isArray(mesh.edges)) {
|
|
469
|
+
for (let i = 0; i + 1 < mesh.edges.length; i += 2) {
|
|
470
|
+
const [a, b] = [mesh.edges[i] / 2, mesh.edges[i + 1] / 2];
|
|
471
|
+
if (pieceOf[a] < 0 && pieceOf[b] < 0) droppedEdges.push(`edge ${i / 2} (vertex ${a} to vertex ${b})`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/** Where a stated edge's vertex went, as a refusal names it. */
|
|
475
|
+
const pieceLabel = (v: number): string => (pieceOf[v] < 0 ? 'in no piece: no triangle names it' : `piece ${pieceOf[v] + 1}`);
|
|
476
|
+
|
|
477
|
+
// --- the pieces' attachments ---------------------------------------------------------------------------------
|
|
478
|
+
const pieceAttachments: RigMeshAttachment[] = orders.map((order, p) => {
|
|
479
|
+
// The plant: the data one position along from where the record says it is.
|
|
480
|
+
const source = plant.offByOne === true ? order.map((_v, j) => order[(j + 1) % order.length]) : order;
|
|
481
|
+
const local = new Map(order.map((v, j) => [v, j]));
|
|
482
|
+
const out: Record<string, unknown> = {};
|
|
483
|
+
for (const [key, value] of Object.entries(mesh)) {
|
|
484
|
+
if (key === 'hull') continue;
|
|
485
|
+
if (key === 'uvs') out.uvs = source.flatMap((v) => [uvs[2 * v], uvs[2 * v + 1]]);
|
|
486
|
+
else if (key === 'triangles') out.triangles = pieces[p].triangles.flatMap((t) => [0, 1, 2].map((c) => local.get(triangles[3 * t + c]) as number));
|
|
487
|
+
else if (key === 'weights' && weights !== undefined) out.weights = source.map((v) => weights[v].map((b: RigMeshBinding) => ({ ...b })));
|
|
488
|
+
else if (key === 'vertices' && unweighted !== undefined) out.vertices = source.flatMap((v) => [unweighted[2 * v], unweighted[2 * v + 1]]);
|
|
489
|
+
else if (key === 'edges' && Array.isArray(value)) {
|
|
490
|
+
const edges: number[] = [];
|
|
491
|
+
for (let i = 0; i + 1 < value.length; i += 2) {
|
|
492
|
+
const [a, b] = [value[i] / 2, value[i + 1] / 2];
|
|
493
|
+
if (pieceOf[a] !== pieceOf[b]) throw new SplitError(`${at}: stated edge ${i / 2} joins vertex ${a} (${pieceLabel(a)}) to vertex ${b} (${pieceLabel(b)}); delete it, or delete "edges" and rigc derives them`, 1);
|
|
494
|
+
if (pieceOf[a] === p) edges.push(2 * (local.get(a) as number), 2 * (local.get(b) as number));
|
|
495
|
+
}
|
|
496
|
+
out.edges = edges;
|
|
497
|
+
} else out[key] = structuredClone(value);
|
|
498
|
+
}
|
|
499
|
+
return out as unknown as RigMeshAttachment;
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// --- the deform keys -----------------------------------------------------------------------------------------
|
|
503
|
+
const splitKey = (key: MotionDeformKey, animation: string, i: number): MotionDeformKey[] => {
|
|
504
|
+
const timing = (k: MotionDeformKey): MotionDeformKey => ({ t: k.t, ...(k.ease === undefined ? {} : { ease: k.ease }), ...(k.curve === undefined ? {} : { curve: k.curve }) });
|
|
505
|
+
if (plant.deformUnrenumbered === true) {
|
|
506
|
+
return pieces.map((_piece, p) => {
|
|
507
|
+
const copy = structuredClone(key);
|
|
508
|
+
if (copy.vertices === undefined || copy.vertices === null || copy.fromVertex !== undefined) return copy;
|
|
509
|
+
const from = copy.offset ?? 0;
|
|
510
|
+
if (from >= pieceLength[p]) return timing(key);
|
|
511
|
+
copy.vertices = copy.vertices.slice(0, pieceLength[p] - from);
|
|
512
|
+
return copy;
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
const run = key.vertices;
|
|
516
|
+
if (run === undefined || run === null) return pieces.map(() => timing(key));
|
|
517
|
+
let start = key.offset ?? 0;
|
|
518
|
+
if (key.fromVertex !== undefined) {
|
|
519
|
+
const fv = key.fromVertex;
|
|
520
|
+
if (!Number.isInteger(fv) || fv < 0 || fv >= vertexCount) throw new SplitError(`animation "${animation}" deform key ${i} (t=${key.t}): "fromVertex" ${fv} is not a vertex of ${at} (0..${vertexCount - 1})`, 1);
|
|
521
|
+
start = base[fv];
|
|
522
|
+
if (weights !== undefined) {
|
|
523
|
+
for (let x = start; x < start + run.length && x < length; x++) {
|
|
524
|
+
if (influences(ownerOf[x]) > 1) {
|
|
525
|
+
throw new SplitError(
|
|
526
|
+
`animation "${animation}" deform key ${i} (t=${key.t}): "fromVertex" counts VERTICES, and this attachment is weighted — vertex ${ownerOf[x]} has ${influences(ownerOf[x])} of them; the compiler refuses this key, so the split does too`,
|
|
527
|
+
1,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (start < 0 || start + run.length > length) {
|
|
534
|
+
throw new SplitError(`animation "${animation}" deform key ${i} (t=${key.t}): the run starts at deform index ${start} and is ${run.length} long, which ends at ${start + run.length}; ${at}'s deform array is ${length} long`, 1);
|
|
535
|
+
}
|
|
536
|
+
const runs = pieces.map(() => new Map<number, number>());
|
|
537
|
+
run.forEach((value, k) => {
|
|
538
|
+
const x = start + k;
|
|
539
|
+
const v = ownerOf[x];
|
|
540
|
+
const p = pieceOf[v];
|
|
541
|
+
if (p < 0) return; // a dropped vertex: no triangle draws it in either rig
|
|
542
|
+
runs[p].set(pieceBase[p][positionOf[v]] + (x - base[v]), value);
|
|
543
|
+
});
|
|
544
|
+
return runs.map((entries) => {
|
|
545
|
+
if (entries.size === 0) return timing(key);
|
|
546
|
+
const indices = [...entries.keys()];
|
|
547
|
+
const lo = Math.min(...indices);
|
|
548
|
+
const hi = Math.max(...indices);
|
|
549
|
+
// A gap is an index the original run did not cover, and the parser left it at zero there too.
|
|
550
|
+
const vertices = Array.from({ length: hi - lo + 1 }, (_v, k) => entries.get(lo + k) ?? 0);
|
|
551
|
+
return { t: key.t, offset: lo, vertices, ...(key.ease === undefined ? {} : { ease: key.ease }), ...(key.curve === undefined ? {} : { curve: key.curve }) };
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
const deformLines: string[] = [];
|
|
555
|
+
for (const [animation, anim] of Object.entries(motion.animations)) {
|
|
556
|
+
if (anim.deform === undefined) continue;
|
|
557
|
+
const next: MotionDeformTrack[] = [];
|
|
558
|
+
for (const track of anim.deform) {
|
|
559
|
+
if ((track.skin ?? 'default') !== skinName || track.slot !== slotName || track.attachment !== placeholder) {
|
|
560
|
+
next.push(track);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const perKey = track.keys.map((key, i) => splitKey(key, animation, i));
|
|
564
|
+
names.forEach((name, p) => next.push({ ...track, slot: name, keys: perKey.map((keys) => keys[p]) }));
|
|
565
|
+
const moving = names.map((_n, p) => perKey.filter((keys) => keys[p].vertices !== undefined).length);
|
|
566
|
+
deformLines.push(`deform animation "${animation}": ${track.keys.length} key(s) re-sliced into ${names.length} timelines — ${names.map((n, p) => `"${n}" ${moving[p]} with a run`).join(', ')}`);
|
|
567
|
+
}
|
|
568
|
+
anim.deform = next;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// --- the slot tracks the new slots copy --------------------------------------------------------------------
|
|
572
|
+
const trackLines: string[] = [];
|
|
573
|
+
for (const [animation, anim] of Object.entries(motion.animations)) {
|
|
574
|
+
const next: MotionTrack[] = [];
|
|
575
|
+
for (const track of anim.tracks) {
|
|
576
|
+
next.push(track);
|
|
577
|
+
if (track.slot !== slotName || !SLOT_PROPERTIES.has(track.property)) continue;
|
|
578
|
+
for (const name of names.slice(1)) {
|
|
579
|
+
const copy = structuredClone(track);
|
|
580
|
+
copy.slot = name;
|
|
581
|
+
if (track.property === 'attachment') copy.keys = copy.keys.map((key) => ({ ...key, v: key.v === placeholder ? placeholder : null }));
|
|
582
|
+
next.push(copy);
|
|
583
|
+
}
|
|
584
|
+
trackLines.push(
|
|
585
|
+
`track animation "${animation}": slot "${slotName}" "${track.property}" copied to ${quoteList(names.slice(1))}` +
|
|
586
|
+
(track.property === 'attachment' ? ` — a key showing "${placeholder}" shows its piece there, any other key shows nothing there` : ''),
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
anim.tracks = next;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --- the rig spec ------------------------------------------------------------------------------------------
|
|
593
|
+
const slotS = rig.slots[slotIndex];
|
|
594
|
+
const newSlots: RigSlot[] = names.slice(1).map((name) => {
|
|
595
|
+
const copy: RigSlot = { ...structuredClone(slotS), name };
|
|
596
|
+
if (slotS.attachment !== undefined) copy.attachment = slotS.attachment === placeholder ? placeholder : null;
|
|
597
|
+
return copy;
|
|
598
|
+
});
|
|
599
|
+
rig.slots.splice(slotIndex + 1, 0, ...newSlots);
|
|
600
|
+
// The skin, short or long form: `splitRigSkin` hands back the live table inside this copy.
|
|
601
|
+
const liveSkin = rig.skins?.[skinName];
|
|
602
|
+
if (liveSkin === undefined) throw new Error('internal: the skin vanished from the copy');
|
|
603
|
+
const table = splitRigSkin(liveSkin, `skin "${skinName}"`).attachments;
|
|
604
|
+
table[slotName] = { ...table[slotName], [placeholder]: pieceAttachments[0] };
|
|
605
|
+
const rebuilt = insertAfter<Record<string, RigAttachment>>(table, slotName, names.slice(1).map((name, i): [string, Record<string, RigAttachment>] => [name, { [placeholder]: pieceAttachments[i + 1] }]));
|
|
606
|
+
for (const key of Object.keys(table)) delete table[key];
|
|
607
|
+
Object.assign(table, rebuilt);
|
|
608
|
+
const folds = rig.invariants?.deformMayFold;
|
|
609
|
+
if (folds !== undefined && rig.invariants !== undefined) {
|
|
610
|
+
rig.invariants.deformMayFold = folds.flatMap((entry) => (entry.slot === slotName ? [entry, ...names.slice(1).map((name) => ({ ...structuredClone(entry), slot: name }))] : [entry]));
|
|
611
|
+
}
|
|
612
|
+
if (request.images !== undefined) rig.images = request.images;
|
|
613
|
+
|
|
614
|
+
// --- the motion spec's setup ------------------------------------------------------------------------------
|
|
615
|
+
const setup = motion.setup;
|
|
616
|
+
const setupS = setup?.[slotName];
|
|
617
|
+
if (setup !== undefined && setupS !== undefined) {
|
|
618
|
+
const entries = names.slice(1).map((name): [string, MotionSetupSlot] => {
|
|
619
|
+
const copy: MotionSetupSlot = structuredClone(setupS);
|
|
620
|
+
if (setupS.attachment !== undefined) copy.attachment = setupS.attachment === placeholder ? placeholder : null;
|
|
621
|
+
return [name, copy];
|
|
622
|
+
});
|
|
623
|
+
motion.setup = insertAfter(setup, slotName, entries);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// --- the record and the lines --------------------------------------------------------------------------------
|
|
627
|
+
const record: SplitRecord = {
|
|
628
|
+
spec: SPLIT_SPEC,
|
|
629
|
+
skin: skinName,
|
|
630
|
+
slot: slotName,
|
|
631
|
+
attachment: placeholder,
|
|
632
|
+
vertices: vertexCount,
|
|
633
|
+
triangles: triangles.length / 3,
|
|
634
|
+
pieces: pieces.map((piece, p) => ({
|
|
635
|
+
slot: names[p],
|
|
636
|
+
attachment: placeholder,
|
|
637
|
+
vertices: orders[p],
|
|
638
|
+
triangles: piece.triangles,
|
|
639
|
+
hull: hulls[p],
|
|
640
|
+
renumbered: renumbered[p],
|
|
641
|
+
})),
|
|
642
|
+
dropped: orphans,
|
|
643
|
+
};
|
|
644
|
+
const lines: string[] = [
|
|
645
|
+
`mesh ${at}: ${vertexCount} vertices, ${triangles.length / 3} triangles, ${pieces.length} pieces (triangles that share a vertex are one piece)`,
|
|
646
|
+
...pieces.map((piece, p) =>
|
|
647
|
+
`piece ${p + 1} slot "${names[p]}"${p === 0 ? ' (the original slot)' : ` (new, drawn after "${names[p - 1]}")`}: ${orders[p].length} vertices, ${piece.triangles.length} triangles, hull ${hulls[p]} — ` +
|
|
648
|
+
(renumbered[p]
|
|
649
|
+
? `renumbered outline first, as the hull rule needs: piece vertex 0.. is original ${formatWalk(orders[p].slice(0, hulls[p]))}, then the interior ${shortList(orders[p].slice(hulls[p]))}`
|
|
650
|
+
: `original order kept: ${shortList(orders[p])}`),
|
|
651
|
+
),
|
|
652
|
+
];
|
|
653
|
+
// Interleaving: a later piece's triangle listed before an earlier piece's last one.
|
|
654
|
+
for (let p = 1; p < pieces.length; p++) {
|
|
655
|
+
const earlierLast = Math.max(...pieces.slice(0, p).map((q) => q.triangles[q.triangles.length - 1]));
|
|
656
|
+
const first = pieces[p].triangles[0];
|
|
657
|
+
if (first < earlierLast) {
|
|
658
|
+
lines.push(
|
|
659
|
+
`order piece ${p + 1}'s triangle ${first} is listed before triangle ${earlierLast} of an earlier piece: the original drew their triangles interleaved, and the split draws each piece whole, after the one before it — where they overlap on screen, the stacking can differ`,
|
|
660
|
+
);
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (orphans.length > 0) lines.push(`dropped ${orphans.length} vertex(es) no triangle names, ${shortList(orphans)}: no piece holds them and neither rig draws them`);
|
|
665
|
+
if (droppedEdges.length > 0) {
|
|
666
|
+
const shown = droppedEdges.length <= 12 ? droppedEdges.join(', ') : `${droppedEdges.slice(0, 12).join(', ')}, … ${droppedEdges.length - 12} more`;
|
|
667
|
+
lines.push(`dropped ${droppedEdges.length} stated edge(s) between vertices no triangle names, ${shown}: no piece holds them, so no piece's "edges" states them`);
|
|
668
|
+
}
|
|
669
|
+
lines.push(...deformLines, ...trackLines);
|
|
670
|
+
if (folds?.some((entry) => entry.slot === slotName)) lines.push(`invariant invariants.deformMayFold names slot "${slotName}"; its entry is copied to ${quoteList(names.slice(1))}`);
|
|
671
|
+
const budget = rig.invariants?.meshSlots;
|
|
672
|
+
if (budget !== undefined) {
|
|
673
|
+
// Counted over the proposal itself, the new slots' meshes included: the arithmetic the build's own refusal does.
|
|
674
|
+
const total = budgetedMeshCount(rig);
|
|
675
|
+
const head = `invariant invariants.meshSlots states ${budget}, and the split adds ${names.length - 1} mesh slot(s): the proposal's skins hold ${total} mesh attachment(s)`;
|
|
676
|
+
lines.push(
|
|
677
|
+
total > budget
|
|
678
|
+
? `${head}, ${total - budget} over the budget, so its build is refused at compile ("${total} mesh slot(s) emitted") unless the rig spec raises invariants.meshSlots by ${total - budget}, to ${total}; the split leaves the budget as the spec states it`
|
|
679
|
+
: `${head}, within the budget; the split leaves it as the spec states it`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
return { kind: 'split', rig, motion, record, lines };
|
|
683
|
+
}
|