spine-rigc 1.2.2 → 1.3.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 +7 -5
- package/cli.ts +63 -6
- package/docs/AUTHORING.md +309 -18
- package/docs/FACE.md +15 -0
- package/docs/INGEST.md +1 -1
- package/package.json +25 -3
- package/src/atlas.ts +120 -2
- package/src/compile.ts +335 -0
- package/src/mesh.ts +405 -1
- package/src/pose.ts +213 -0
- package/src/rig.ts +140 -7
- package/src/types.ts +30 -0
- package/src/validate.ts +101 -24
package/src/atlas.ts
CHANGED
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
* `regionWidth` and `pageWidth` are integers and the page is a power of two,
|
|
52
52
|
* so `u · pageWidth` recovers `regionX + localTexel` with nothing lost —
|
|
53
53
|
* which is why the eleven region-only rigs are already byte-identical, and
|
|
54
|
-
* why `PK18` can assert exactness rather than a bound
|
|
54
|
+
* why `PK18` can assert exactness rather than a bound. That holds for the
|
|
55
|
+
* default power-of-two page only: `pageEdges: 'free'` gives it up, and the
|
|
56
|
+
* region attachments of a free page sit at the mesh's bound of 1 (`PK72`,
|
|
57
|
+
* and `packAtlas`, *free*);
|
|
55
58
|
* * on a **mesh** it is **not** 0 — 88 of 88 coordinates on `gallery/squash`'s
|
|
56
59
|
* ball, worst 3.15e-5 texels — because `spine-core` stores mesh page UVs in a
|
|
57
60
|
* **`Float32Array`**. `MeshAttachment.updateRegion` computes `u +
|
|
@@ -503,6 +506,18 @@ export interface EmitPage {
|
|
|
503
506
|
* Rotation buys page area on a set of parts whose aspect ratios differ a lot. It
|
|
504
507
|
* is not free and it is not implemented; a page that runs out of room spills to a
|
|
505
508
|
* second page instead.
|
|
509
|
+
*
|
|
510
|
+
* 🔬 **Measured for issue #860, and held back on the measurement.** A pack that
|
|
511
|
+
* turned regions `rotate: 90` lifted every one of them back byte for byte through
|
|
512
|
+
* `extractRegion` (22 of 22 and 20 of 20 on two painting rigs, 12 turned on each),
|
|
513
|
+
* and rigc's renderer drew the turned region attachments with 0 differing pixels
|
|
514
|
+
* against the unturned pack. But on neither rig did a turn shrink the page: both
|
|
515
|
+
* were already on the smallest power-of-two page their area allows, and a turn
|
|
516
|
+
* that buys no area only moves bytes. Shipping it would also need two clauses
|
|
517
|
+
* changed that say rigc never turns a region — `A06`'s under `spine-html`
|
|
518
|
+
* ([`src/validate.ts`](validate.ts)) and `artUvsOf`
|
|
519
|
+
* ([`src/render.ts`](render.ts)), which returns no art UVs for a turned region
|
|
520
|
+
* attachment, so `check`'s texture substitution would report it unmatched.
|
|
506
521
|
*/
|
|
507
522
|
export const PACK_NO_ROTATE = 0;
|
|
508
523
|
|
|
@@ -578,6 +593,21 @@ export const DEFAULT_PAGE_SIZE = 2048;
|
|
|
578
593
|
/** What `--padding` defaults to. See `extrudeCell` for why it is not 0 and not 1. */
|
|
579
594
|
export const DEFAULT_PADDING = 2;
|
|
580
595
|
|
|
596
|
+
/**
|
|
597
|
+
* What a packed page's edges may be — `--page-edges`. See `packAtlas`, *Page size*.
|
|
598
|
+
*
|
|
599
|
+
* `pot` is the default and the only value until issue #860: both edges are powers
|
|
600
|
+
* of two. `free` lets the width be any multiple of `FREE_EDGE_STEP` and the height
|
|
601
|
+
* whatever the placement needs. What `free` costs is measured rather than
|
|
602
|
+
* conceded: a region attachment's sampling coordinate stops being exact and moves
|
|
603
|
+
* to `PK05`'s bound of one least significant bit, the bound a mesh already had.
|
|
604
|
+
*/
|
|
605
|
+
export const PAGE_EDGES = ['pot', 'free'] as const;
|
|
606
|
+
export type PageEdges = (typeof PAGE_EDGES)[number];
|
|
607
|
+
export const DEFAULT_PAGE_EDGES: PageEdges = 'pot';
|
|
608
|
+
/** The step a `free` page's width is tried on. See `packAtlas`, *Page size*. */
|
|
609
|
+
export const FREE_EDGE_STEP = 32;
|
|
610
|
+
|
|
581
611
|
/** The part a page is packed from: its region name and its pixels. */
|
|
582
612
|
export interface PackInput {
|
|
583
613
|
region: string;
|
|
@@ -594,6 +624,8 @@ export interface PackOptions {
|
|
|
594
624
|
padding?: number;
|
|
595
625
|
/** Page filenames are `<stem>.png`, `<stem>2.png`, … — libgdx's own numbering. */
|
|
596
626
|
pageStem?: string;
|
|
627
|
+
/** What the page's edges may be (default `pot`). See `PAGE_EDGES`. */
|
|
628
|
+
pageEdges?: PageEdges;
|
|
597
629
|
}
|
|
598
630
|
|
|
599
631
|
/** Where one region landed. `x`/`y` are the REGION's own corner, not its cell's. */
|
|
@@ -666,7 +698,9 @@ function floorPowerOfTwo(n: number): number {
|
|
|
666
698
|
function smallestPageFor(
|
|
667
699
|
cells: Array<{ w: number; h: number }>,
|
|
668
700
|
maxEdge: number,
|
|
701
|
+
pageEdges: PageEdges,
|
|
669
702
|
): { width: number; height: number; rects: Rect[] } | null {
|
|
703
|
+
if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge);
|
|
670
704
|
const edges: number[] = [];
|
|
671
705
|
for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
|
|
672
706
|
const candidates: Array<{ w: number; h: number }> = [];
|
|
@@ -680,6 +714,60 @@ function smallestPageFor(
|
|
|
680
714
|
return null;
|
|
681
715
|
}
|
|
682
716
|
|
|
717
|
+
/**
|
|
718
|
+
* The smallest `free` page that holds these cells, and where they land on it —
|
|
719
|
+
* or `null` when they do not fit `maxEdge x maxEdge` at all.
|
|
720
|
+
*
|
|
721
|
+
* The rule, which `docs/AUTHORING.md` §0.1 states in the same words:
|
|
722
|
+
*
|
|
723
|
+
* * the candidate WIDTHS are the multiples of `FREE_EDGE_STEP` from the widest
|
|
724
|
+
* cell up to `maxEdge`, plus `maxEdge` itself (it is a power of two, so this
|
|
725
|
+
* only adds anything when it is smaller than the step);
|
|
726
|
+
* * at each width the cells are placed by the same MaxRects pass as a `pot`
|
|
727
|
+
* page, on a page `maxEdge` tall, and the HEIGHT is the bottom edge of the
|
|
728
|
+
* lowest cell — what that placement needs, not rounded;
|
|
729
|
+
* * the page with the least area wins, then the squarer (smaller |width −
|
|
730
|
+
* height|), then the narrower. Every candidate has a distinct width, so that
|
|
731
|
+
* last key makes the order total and the answer a function of the cells.
|
|
732
|
+
*
|
|
733
|
+
* ⚠️ The height is read off one placement rather than searched for, and that is
|
|
734
|
+
* a definition, not an approximation of one: MaxRects' success is not monotonic
|
|
735
|
+
* in the page height (a shorter page changes which free rectangle scores best),
|
|
736
|
+
* so "the least height that fits" is not a quantity a bisection could find, and a
|
|
737
|
+
* definition that could not be computed exactly would not be deterministic.
|
|
738
|
+
*/
|
|
739
|
+
function smallestFreePageFor(
|
|
740
|
+
cells: Array<{ w: number; h: number }>,
|
|
741
|
+
maxEdge: number,
|
|
742
|
+
): { width: number; height: number; rects: Rect[] } | null {
|
|
743
|
+
let widest = 0;
|
|
744
|
+
for (const cell of cells) widest = Math.max(widest, cell.w);
|
|
745
|
+
const widths: number[] = [];
|
|
746
|
+
for (let w = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; w <= maxEdge; w += FREE_EDGE_STEP) widths.push(w);
|
|
747
|
+
if (widths[widths.length - 1] !== maxEdge && widest <= maxEdge) widths.push(maxEdge);
|
|
748
|
+
let best: { width: number; height: number; rects: Rect[] } | null = null;
|
|
749
|
+
for (const width of widths) {
|
|
750
|
+
const attempt = packOnePage(cells, width, maxEdge);
|
|
751
|
+
if (attempt.some((r) => r === null)) continue;
|
|
752
|
+
const rects = attempt as Rect[];
|
|
753
|
+
let height = 0;
|
|
754
|
+
for (const r of rects) height = Math.max(height, r.y + r.h);
|
|
755
|
+
if (best !== null) {
|
|
756
|
+
const area = width * height;
|
|
757
|
+
const bestArea = best.width * best.height;
|
|
758
|
+
if (area > bestArea) continue;
|
|
759
|
+
if (area === bestArea) {
|
|
760
|
+
const square = Math.abs(width - height);
|
|
761
|
+
const bestSquare = Math.abs(best.width - best.height);
|
|
762
|
+
if (square > bestSquare) continue;
|
|
763
|
+
if (square === bestSquare && width >= best.width) continue;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
best = { width, height, rects };
|
|
767
|
+
}
|
|
768
|
+
return best;
|
|
769
|
+
}
|
|
770
|
+
|
|
683
771
|
/**
|
|
684
772
|
* MaxRects with Best Short Side Fit, no rotation.
|
|
685
773
|
*
|
|
@@ -856,11 +944,40 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
|
|
|
856
944
|
* transparency — 4 MiB of decoded RAM at the 2048 default. A single part whose
|
|
857
945
|
* cell is bigger than the maximum is refused by name — silently splitting one
|
|
858
946
|
* drawing across two pages is not a thing the format can express.
|
|
947
|
+
*
|
|
948
|
+
* ## `pageEdges: 'free'` — a page sized to the parts rather than to a power of two (issue #860)
|
|
949
|
+
*
|
|
950
|
+
* Opt-in, and the default stays `pot`: every runtime accepts a power-of-two page
|
|
951
|
+
* and the editor's own packer writes one by default — 9 of the 10 atlases in the
|
|
952
|
+
* fetched `examples/` corpus are power-of-two on both edges. Under `free` the
|
|
953
|
+
* search is `smallestFreePageFor`: widths on a `FREE_EDGE_STEP` grid, the height
|
|
954
|
+
* the placement needs, least area first, then squarer, then narrower. Everything
|
|
955
|
+
* else is shared — the MaxRects pass, the packing order, the spill rule (which
|
|
956
|
+
* parts share a page is still decided at `maxEdge x maxEdge`) and `--page-size`
|
|
957
|
+
* as the ceiling on both edges, floored to a power of two exactly as under `pot`.
|
|
958
|
+
*
|
|
959
|
+
* What it buys and what it costs, observed on two 20- and 22-part painting rigs:
|
|
960
|
+
* the page went from 1024x2048 to 1888x697 (2,097,152 to 1,315,936 texels,
|
|
961
|
+
* -37.3 %) and from 512x2048 to 480x1166 (1,048,576 to 559,680, -46.6 %). The cost is the exactness the paragraph above describes:
|
|
962
|
+
* `x / pageWidth` is no longer exact, so a REGION attachment's sampling
|
|
963
|
+
* coordinate joins a mesh's at `PK05`'s bound of one least significant bit
|
|
964
|
+
* against the loose build. It does not go past it, and no region's bytes change
|
|
965
|
+
* (`PK02`'s lift-back holds under both).
|
|
966
|
+
*
|
|
967
|
+
* ⚠️ `pot` is not the smaller answer's poor relation, and its search was never
|
|
968
|
+
* "double until it fits": it tries every power-of-two pair in order of area.
|
|
969
|
+
* On both rigs above the cells' own area (1,206,755 and 540,793 texels at
|
|
970
|
+
* padding 2) already exceeds the next power-of-two page down, so no `pot`
|
|
971
|
+
* packer could do better; the only lever left was the power of two itself.
|
|
859
972
|
*/
|
|
860
973
|
export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResult {
|
|
861
974
|
const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
862
975
|
const padding = opts.padding ?? DEFAULT_PADDING;
|
|
863
976
|
const stem = opts.pageStem ?? 'skeleton';
|
|
977
|
+
const pageEdges = opts.pageEdges ?? DEFAULT_PAGE_EDGES;
|
|
978
|
+
if (!PAGE_EDGES.includes(pageEdges)) {
|
|
979
|
+
throw new CompileError(`--page-edges ${JSON.stringify(String(opts.pageEdges))}; known values: ${PAGE_EDGES.join(', ')}`);
|
|
980
|
+
}
|
|
864
981
|
if (!Number.isInteger(pageSize) || pageSize < 1) {
|
|
865
982
|
throw new CompileError(`--page-size must be a positive integer, got ${String(opts.pageSize)}`);
|
|
866
983
|
}
|
|
@@ -884,7 +1001,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
884
1001
|
);
|
|
885
1002
|
}
|
|
886
1003
|
|
|
887
|
-
const single = smallestPageFor(cells, maxEdge);
|
|
1004
|
+
const single = smallestPageFor(cells, maxEdge, pageEdges);
|
|
888
1005
|
|
|
889
1006
|
/** page index -> the placements on it, in packing order. */
|
|
890
1007
|
const perPage: Placement[][] = [];
|
|
@@ -943,6 +1060,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
943
1060
|
const shrunk = smallestPageFor(
|
|
944
1061
|
onPage.map((r) => r.cell),
|
|
945
1062
|
maxEdge,
|
|
1063
|
+
pageEdges,
|
|
946
1064
|
);
|
|
947
1065
|
if (shrunk === null) {
|
|
948
1066
|
// Unreachable for the same reason as the stall above: these cells were
|
package/src/compile.ts
CHANGED
|
@@ -83,8 +83,11 @@ import {
|
|
|
83
83
|
meshEdges,
|
|
84
84
|
MeshError,
|
|
85
85
|
ringControlAngles,
|
|
86
|
+
buildSegmentsLattice,
|
|
87
|
+
segmentShares,
|
|
86
88
|
traceOutline,
|
|
87
89
|
type MeshBoneRef,
|
|
90
|
+
type WorldSegment,
|
|
88
91
|
type MeshFitReport,
|
|
89
92
|
type MeshGeometry,
|
|
90
93
|
type MeshVertexWeight,
|
|
@@ -5047,6 +5050,7 @@ function buildGeneratedMesh(
|
|
|
5047
5050
|
): SpineMeshAttachment {
|
|
5048
5051
|
if (generator.kind === 'contour') return buildContourAttachment(att, generator, placeholder, where, ctx);
|
|
5049
5052
|
if (generator.kind === 'grid') return buildGridAttachment(att, generator, placeholder, where, ctx);
|
|
5053
|
+
if (generator.kind === 'segments') return buildSegmentsAttachment(att, generator, placeholder, where, ctx);
|
|
5050
5054
|
const controls = generator.kind === 'ring' ? generator.controls : generator.chain;
|
|
5051
5055
|
// Resolving the bone list and the setup transform is one step with two named
|
|
5052
5056
|
// refusals, because the ring's control angles need the transform and the encode
|
|
@@ -5690,6 +5694,335 @@ function buildGridAttachment(
|
|
|
5690
5694
|
return out;
|
|
5691
5695
|
}
|
|
5692
5696
|
|
|
5697
|
+
/**
|
|
5698
|
+
* Defaults for the `segments` generator's optional parameters, stated once.
|
|
5699
|
+
*
|
|
5700
|
+
* `power`, `maxBones` and `minWeight` are the constants the algorithm was
|
|
5701
|
+
* ported with, and `alpha` is `contour`'s own. `cell` and `falloff.radius` have
|
|
5702
|
+
* none: both are lengths on this part's art, and a length rigc chose would be a
|
|
5703
|
+
* judgement about a drawing it has not been told anything about.
|
|
5704
|
+
*/
|
|
5705
|
+
const SEGMENTS_DEFAULTS = { power: 2, maxBones: 4, minWeight: 0.03, alpha: 1 } as const;
|
|
5706
|
+
|
|
5707
|
+
/** The smallest share a `minWeight` may keep: one step of the 6-decimal grid weights are written on. */
|
|
5708
|
+
const SEGMENTS_WEIGHT_STEP = 0.000001;
|
|
5709
|
+
|
|
5710
|
+
/** `[x, y]` as two finite numbers, or null. */
|
|
5711
|
+
function finitePair(v: unknown): [number, number] | null {
|
|
5712
|
+
return Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number' && Number.isFinite(n))
|
|
5713
|
+
? [v[0] as number, v[1] as number]
|
|
5714
|
+
: null;
|
|
5715
|
+
}
|
|
5716
|
+
|
|
5717
|
+
/**
|
|
5718
|
+
* Build a `segments` mesh: a lattice over the part's alpha, weighted by distance
|
|
5719
|
+
* to the bone segments `bones` names.
|
|
5720
|
+
*
|
|
5721
|
+
* ⭐ `bones` is the one decision the author makes, and everything else is read
|
|
5722
|
+
* off something already in front of the compiler: the lattice off the image's
|
|
5723
|
+
* alpha, each segment off the skeleton's setup pose (a bone's origin, the next
|
|
5724
|
+
* chain link's origin, a bone's `length` tip) or off the two points a span
|
|
5725
|
+
* states, and every weight off the distance between a vertex and those
|
|
5726
|
+
* segments. No segment is invented — a bone with no `length` and no next link
|
|
5727
|
+
* has an origin and no segment, and is refused by name.
|
|
5728
|
+
*
|
|
5729
|
+
* The placement is the one every generator on this route uses — the slot bone
|
|
5730
|
+
* at the window's centre — unless `anchor` says where in the part's pixels the
|
|
5731
|
+
* slot bone sits, which is what a part drawn somewhere other than centred on
|
|
5732
|
+
* its bone needs, because unlike a pinned generator this one's weights depend
|
|
5733
|
+
* on where the art is relative to the bones.
|
|
5734
|
+
*/
|
|
5735
|
+
function buildSegmentsAttachment(
|
|
5736
|
+
att: RigMeshAttachment,
|
|
5737
|
+
generator: Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'segments' }>,
|
|
5738
|
+
placeholder: string,
|
|
5739
|
+
where: string,
|
|
5740
|
+
ctx: AttachmentContext,
|
|
5741
|
+
): SpineMeshAttachment {
|
|
5742
|
+
if (att.image === undefined) {
|
|
5743
|
+
throw new CompileError(
|
|
5744
|
+
`${where}: a "segments" generator lays its lattice over the part's own alpha, so the attachment needs an ` +
|
|
5745
|
+
'"image" — there is nothing else here that says which pixels are art',
|
|
5746
|
+
);
|
|
5747
|
+
}
|
|
5748
|
+
const img = atlasedImage(att.image, where, ctx);
|
|
5749
|
+
if (img.pageGrid !== undefined) {
|
|
5750
|
+
throw new CompileError(
|
|
5751
|
+
`${where}: a "segments" generator lays its lattice over the part's own alpha, and "${att.image}" is lifted ` +
|
|
5752
|
+
'off a packed page at the coordinates the atlas states, which on this file are not where its texels are — ' +
|
|
5753
|
+
`so there is no art here to cover, only another part of the page. ${img.pageGrid.sentence}`,
|
|
5754
|
+
);
|
|
5755
|
+
}
|
|
5756
|
+
if (img.atlasScale !== undefined) {
|
|
5757
|
+
throw new CompileError(
|
|
5758
|
+
`${where}: a "segments" generator measures its "cell" and its falloff "radius" in the part's pixels, and ` +
|
|
5759
|
+
`"${att.image}" sits on a page that declares scale: ${img.atlasScale}, where a pixel of the drawing is not a ` +
|
|
5760
|
+
'texel of the page. This generator does not convert between the two; build it from the loose part, or ' +
|
|
5761
|
+
'from a pack at scale 1',
|
|
5762
|
+
);
|
|
5763
|
+
}
|
|
5764
|
+
|
|
5765
|
+
// -- the falloff -----------------------------------------------------------
|
|
5766
|
+
const falloffSpec: unknown = generator.falloff;
|
|
5767
|
+
if (typeof falloffSpec !== 'object' || falloffSpec === null || Array.isArray(falloffSpec)) {
|
|
5768
|
+
throw new CompileError(
|
|
5769
|
+
`${where}: the "segments" generator states no "falloff" block. Its "radius" — added to every distance, in ` +
|
|
5770
|
+
"the part's pixels — has no default, because it is a length on this art: " +
|
|
5771
|
+
'"falloff": { "radius": <pixels> } is the least it takes',
|
|
5772
|
+
);
|
|
5773
|
+
}
|
|
5774
|
+
const f = generator.falloff;
|
|
5775
|
+
if (typeof f.radius !== 'number' || !Number.isFinite(f.radius) || !(f.radius > 0)) {
|
|
5776
|
+
throw new CompileError(
|
|
5777
|
+
`${where}: "falloff.radius" is ${JSON.stringify(f.radius)}; it is added to every distance before the power is ` +
|
|
5778
|
+
"taken, a positive number of the part's pixels — at 0 a vertex on a segment would carry an infinite pull",
|
|
5779
|
+
);
|
|
5780
|
+
}
|
|
5781
|
+
const power = f.power ?? SEGMENTS_DEFAULTS.power;
|
|
5782
|
+
if (typeof power !== 'number' || !Number.isFinite(power) || !(power > 0)) {
|
|
5783
|
+
throw new CompileError(
|
|
5784
|
+
`${where}: "falloff.power" is ${JSON.stringify(power)}; it is the exponent a pull falls off with distance by, ` +
|
|
5785
|
+
'a positive number — at 0 or below the far segment pulls as hard as the near one, or harder',
|
|
5786
|
+
);
|
|
5787
|
+
}
|
|
5788
|
+
const maxBones = f.maxBones ?? SEGMENTS_DEFAULTS.maxBones;
|
|
5789
|
+
if (typeof maxBones !== 'number' || !Number.isInteger(maxBones) || maxBones < 1) {
|
|
5790
|
+
throw new CompileError(
|
|
5791
|
+
`${where}: "falloff.maxBones" is ${JSON.stringify(maxBones)}; it is how many bones may pull one vertex, a ` +
|
|
5792
|
+
'whole number of at least 1',
|
|
5793
|
+
);
|
|
5794
|
+
}
|
|
5795
|
+
const minWeight = f.minWeight ?? SEGMENTS_DEFAULTS.minWeight;
|
|
5796
|
+
if (typeof minWeight !== 'number' || !Number.isFinite(minWeight) || minWeight < SEGMENTS_WEIGHT_STEP || minWeight >= 1) {
|
|
5797
|
+
throw new CompileError(
|
|
5798
|
+
`${where}: "falloff.minWeight" is ${JSON.stringify(minWeight)}; it is the share under which a bone's pull ` +
|
|
5799
|
+
`is dropped, so it lies in ${SEGMENTS_WEIGHT_STEP}..1 with 1 excluded — at 1 or above every share is dropped, ` +
|
|
5800
|
+
`and under ${SEGMENTS_WEIGHT_STEP}, one step of the 6-decimal grid weights are written on, a kept binding ` +
|
|
5801
|
+
'could be written as 0',
|
|
5802
|
+
);
|
|
5803
|
+
}
|
|
5804
|
+
const threshold = generator.alpha ?? SEGMENTS_DEFAULTS.alpha;
|
|
5805
|
+
|
|
5806
|
+
// -- the lattice -----------------------------------------------------------
|
|
5807
|
+
const plate = partPlate(img);
|
|
5808
|
+
let lattice;
|
|
5809
|
+
try {
|
|
5810
|
+
lattice = buildSegmentsLattice({
|
|
5811
|
+
mask: { width: plate.width, height: plate.height, alpha: plateAlpha(plate) },
|
|
5812
|
+
threshold,
|
|
5813
|
+
cell: generator.cell,
|
|
5814
|
+
});
|
|
5815
|
+
} catch (err) {
|
|
5816
|
+
if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}`);
|
|
5817
|
+
throw err;
|
|
5818
|
+
}
|
|
5819
|
+
const w = img.width;
|
|
5820
|
+
const h = img.height;
|
|
5821
|
+
if (att.width !== undefined && att.width !== w) {
|
|
5822
|
+
throw new CompileError(`${where}: the spec says width ${att.width} and "${att.image}" measures ${w}`);
|
|
5823
|
+
}
|
|
5824
|
+
if (att.height !== undefined && att.height !== h) {
|
|
5825
|
+
throw new CompileError(`${where}: the spec says height ${att.height} and "${att.image}" measures ${h}`);
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5828
|
+
// -- the placement ---------------------------------------------------------
|
|
5829
|
+
const slotBone = ctx.transforms.get(ctx.anchorBone);
|
|
5830
|
+
if (!slotBone) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
|
|
5831
|
+
let anchorAt: [number, number] = [w / 2, h / 2];
|
|
5832
|
+
if (generator.anchor !== undefined) {
|
|
5833
|
+
const stated = finitePair(generator.anchor);
|
|
5834
|
+
if (stated === null) {
|
|
5835
|
+
throw new CompileError(
|
|
5836
|
+
`${where}: "anchor" is ${JSON.stringify(generator.anchor)}; it is where the slot bone sits in the part's ` +
|
|
5837
|
+
'pixels, y down, as [x, y] — two finite numbers',
|
|
5838
|
+
);
|
|
5839
|
+
}
|
|
5840
|
+
anchorAt = stated;
|
|
5841
|
+
}
|
|
5842
|
+
const [ax, ay] = anchorAt;
|
|
5843
|
+
const place = (px: number, py: number): [number, number] => [
|
|
5844
|
+
slotBone.worldX + (px - ax),
|
|
5845
|
+
slotBone.worldY + (cropToSpineY(py, h) - cropToSpineY(ay, h)),
|
|
5846
|
+
];
|
|
5847
|
+
|
|
5848
|
+
// -- the segments ----------------------------------------------------------
|
|
5849
|
+
if (!Array.isArray(generator.bones) || generator.bones.length === 0) {
|
|
5850
|
+
throw new CompileError(
|
|
5851
|
+
`${where}: the "segments" generator names no bones. "bones" is the one decision this generator takes — which ` +
|
|
5852
|
+
'segments may pull the part — so it lists at least one: a bone name, a chain as a list of bone names, or ' +
|
|
5853
|
+
'{ "bone", "from", "to" }',
|
|
5854
|
+
);
|
|
5855
|
+
}
|
|
5856
|
+
const parentOf = new Map(ctx.bones.map((b) => [b.name, b.parent ?? null]));
|
|
5857
|
+
const named: string[] = [];
|
|
5858
|
+
const refs: MeshBoneRef[] = [];
|
|
5859
|
+
const segments: WorldSegment[] = [];
|
|
5860
|
+
const boneOf = (name: unknown, at: string): { index: number; transform: BoneTransform } => {
|
|
5861
|
+
if (typeof name !== 'string' || !parentOf.has(name)) {
|
|
5862
|
+
throw new CompileError(`${where}: ${at} names mesh bone ${JSON.stringify(name)}, which is not in the rig's bone list`);
|
|
5863
|
+
}
|
|
5864
|
+
let index = named.indexOf(name);
|
|
5865
|
+
if (index < 0) {
|
|
5866
|
+
index = named.length;
|
|
5867
|
+
named.push(name);
|
|
5868
|
+
refs.push(meshBoneRef(name, where, ctx));
|
|
5869
|
+
}
|
|
5870
|
+
return { index, transform: ctx.transforms.get(name)! };
|
|
5871
|
+
};
|
|
5872
|
+
const tipOf = (name: string, transform: BoneTransform, at: string, chained: boolean): [number, number] => {
|
|
5873
|
+
const length = ctx.bones.find((b) => b.name === name)?.length ?? 0;
|
|
5874
|
+
if (!(length > 0)) {
|
|
5875
|
+
throw new CompileError(
|
|
5876
|
+
`${where}: ${at} names bone "${name}", which has no "length"${chained ? ' and is the last link of its chain' : ''}, ` +
|
|
5877
|
+
'so it has an origin and no segment. Give the bone a "length" (its segment runs from its origin to that ' +
|
|
5878
|
+
'tip, along its setup rotation), ' +
|
|
5879
|
+
(chained ? 'end the chain on a bone that has one, ' : 'name it inside a chain whose next link says where it ends, ') +
|
|
5880
|
+
'or state the segment outright as { "bone", "from", "to" }',
|
|
5881
|
+
);
|
|
5882
|
+
}
|
|
5883
|
+
return toWorld(transform, length, 0);
|
|
5884
|
+
};
|
|
5885
|
+
generator.bones.forEach((entry: unknown, i: number) => {
|
|
5886
|
+
const at = `"bones"[${i}]`;
|
|
5887
|
+
if (typeof entry === 'string') {
|
|
5888
|
+
const { index, transform } = boneOf(entry, at);
|
|
5889
|
+
segments.push({ bone: index, a: [transform.worldX, transform.worldY], b: tipOf(entry, transform, at, false) });
|
|
5890
|
+
return;
|
|
5891
|
+
}
|
|
5892
|
+
if (Array.isArray(entry)) {
|
|
5893
|
+
if (entry.length === 0) {
|
|
5894
|
+
throw new CompileError(`${where}: ${at} is an empty chain; a chain lists its bones root first, at least one`);
|
|
5895
|
+
}
|
|
5896
|
+
const links = entry.map((name: unknown, k: number) => ({ name, ...boneOf(name, `${at}[${k}]`) }));
|
|
5897
|
+
links.forEach((link, k) => {
|
|
5898
|
+
const name = link.name as string;
|
|
5899
|
+
const origin: [number, number] = [link.transform.worldX, link.transform.worldY];
|
|
5900
|
+
if (k === links.length - 1) {
|
|
5901
|
+
segments.push({ bone: link.index, a: origin, b: tipOf(name, link.transform, `${at}[${k}]`, true) });
|
|
5902
|
+
return;
|
|
5903
|
+
}
|
|
5904
|
+
const next = links[k + 1];
|
|
5905
|
+
const nextName = next.name as string;
|
|
5906
|
+
let cursor = parentOf.get(nextName) ?? null;
|
|
5907
|
+
while (cursor !== null && cursor !== name) cursor = parentOf.get(cursor) ?? null;
|
|
5908
|
+
if (cursor === null) {
|
|
5909
|
+
throw new CompileError(
|
|
5910
|
+
`${where}: ${at} chains "${name}" to "${nextName}", and "${nextName}" is not under "${name}" in the bone ` +
|
|
5911
|
+
`tree — a chain runs down the skeleton, each link a descendant of the one before it. List the chain ` +
|
|
5912
|
+
'root first, or name the two bones as separate entries',
|
|
5913
|
+
);
|
|
5914
|
+
}
|
|
5915
|
+
const b: [number, number] = [next.transform.worldX, next.transform.worldY];
|
|
5916
|
+
if (b[0] === origin[0] && b[1] === origin[1]) {
|
|
5917
|
+
throw new CompileError(
|
|
5918
|
+
`${where}: ${at} chains "${name}" to "${nextName}", and the two bones share their setup origin ` +
|
|
5919
|
+
`(${origin[0]}, ${origin[1]}), so the link has no length and no direction to weight along. Give ` +
|
|
5920
|
+
`"${nextName}" its own position, or name "${name}" alone with a "length"`,
|
|
5921
|
+
);
|
|
5922
|
+
}
|
|
5923
|
+
segments.push({ bone: link.index, a: origin, b });
|
|
5924
|
+
});
|
|
5925
|
+
return;
|
|
5926
|
+
}
|
|
5927
|
+
if (typeof entry === 'object' && entry !== null) {
|
|
5928
|
+
const span = entry as { bone?: unknown; from?: unknown; to?: unknown };
|
|
5929
|
+
const { index } = boneOf(span.bone, `${at}.bone`);
|
|
5930
|
+
const from = finitePair(span.from);
|
|
5931
|
+
const to = finitePair(span.to);
|
|
5932
|
+
if (from === null || to === null) {
|
|
5933
|
+
throw new CompileError(
|
|
5934
|
+
`${where}: ${at} states a segment whose ${from === null ? '"from"' : '"to"'} is ` +
|
|
5935
|
+
`${JSON.stringify(from === null ? span.from : span.to)}; both ends are [x, y] in the part's pixels, y down`,
|
|
5936
|
+
);
|
|
5937
|
+
}
|
|
5938
|
+
segments.push({ bone: index, a: place(from[0], from[1]), b: place(to[0], to[1]) });
|
|
5939
|
+
return;
|
|
5940
|
+
}
|
|
5941
|
+
throw new CompileError(
|
|
5942
|
+
`${where}: ${at} is ${JSON.stringify(entry)}; an entry is a bone name, a chain as a list of bone names, or ` +
|
|
5943
|
+
'{ "bone", "from", "to" }',
|
|
5944
|
+
);
|
|
5945
|
+
});
|
|
5946
|
+
|
|
5947
|
+
// -- the weights -----------------------------------------------------------
|
|
5948
|
+
const falloff = { power, radius: f.radius, maxBones, minWeight };
|
|
5949
|
+
const weights: MeshVertexWeight[][] = lattice.points.map(([px, py], v) => {
|
|
5950
|
+
const got = segmentShares(place(px, py), segments, falloff);
|
|
5951
|
+
if ('weights' in got) return got.weights;
|
|
5952
|
+
throw new CompileError(
|
|
5953
|
+
`${where}: vertex ${v}, at (${px}, ${py}) in the part's pixels, keeps no bone — the ${got.dropped.length} ` +
|
|
5954
|
+
`strongest pull(s) share it as ${got.dropped.map((d) => `"${named[d.bone]}" ${d.share.toFixed(4)}`).join(', ')}, ` +
|
|
5955
|
+
`and every one is under "minWeight" ${minWeight}. A vertex needs a bone, and rigc will not pick one the ` +
|
|
5956
|
+
`falloff dropped: lower "minWeight" to ${(1 / maxBones).toFixed(4)} or under (1 / "maxBones", which the ` +
|
|
5957
|
+
'strongest share can never fall below), or name a segment nearer this part of the art',
|
|
5958
|
+
);
|
|
5959
|
+
});
|
|
5960
|
+
const geometry: MeshGeometry = {
|
|
5961
|
+
kind: 'segments',
|
|
5962
|
+
points: lattice.points,
|
|
5963
|
+
uvs: lattice.uvs,
|
|
5964
|
+
triangles: lattice.triangles,
|
|
5965
|
+
weights,
|
|
5966
|
+
hullVertices: lattice.hullVertices,
|
|
5967
|
+
};
|
|
5968
|
+
const vertices = encodeWeightedVertices(geometry, place, {
|
|
5969
|
+
anchor: meshBoneRef(ctx.anchorBone, where, ctx),
|
|
5970
|
+
controls: refs,
|
|
5971
|
+
});
|
|
5972
|
+
|
|
5973
|
+
// -- the report ------------------------------------------------------------
|
|
5974
|
+
let maxInfluence = 0;
|
|
5975
|
+
let totalInfluence = 0;
|
|
5976
|
+
let singleBone = 0;
|
|
5977
|
+
const bound = new Set<number>();
|
|
5978
|
+
for (const vertex of weights) {
|
|
5979
|
+
maxInfluence = Math.max(maxInfluence, vertex.length);
|
|
5980
|
+
totalInfluence += vertex.length;
|
|
5981
|
+
if (vertex.length === 1) singleBone++;
|
|
5982
|
+
for (const e of vertex) bound.add(e.control ?? 0);
|
|
5983
|
+
}
|
|
5984
|
+
const fit = measureAuthoredMeshFit(
|
|
5985
|
+
{ width: plate.width, height: plate.height, alpha: plateAlpha(plate) },
|
|
5986
|
+
threshold,
|
|
5987
|
+
lattice.points,
|
|
5988
|
+
lattice.triangles,
|
|
5989
|
+
);
|
|
5990
|
+
ctx.meshBones.add(ctx.anchorBone);
|
|
5991
|
+
for (const k of [...bound].sort((a, b) => a - b)) ctx.meshBones.add(named[k]);
|
|
5992
|
+
ctx.meshes.push({
|
|
5993
|
+
slot: ctx.slotName,
|
|
5994
|
+
kind: 'segments',
|
|
5995
|
+
attachments: [placeholder],
|
|
5996
|
+
vertices: lattice.points.length,
|
|
5997
|
+
triangles: lattice.triangles.length / 3,
|
|
5998
|
+
bones: [...named],
|
|
5999
|
+
coverage: f32(fit.coverage),
|
|
6000
|
+
overshoot: fit.overshoot,
|
|
6001
|
+
influence: {
|
|
6002
|
+
maxBones: maxInfluence,
|
|
6003
|
+
meanBones: f32(totalInfluence / weights.length),
|
|
6004
|
+
singleBone,
|
|
6005
|
+
bound: named.filter((_, k) => bound.has(k)),
|
|
6006
|
+
cell: generator.cell,
|
|
6007
|
+
cols: lattice.report.cols,
|
|
6008
|
+
rows: lattice.report.rows,
|
|
6009
|
+
artCells: lattice.report.artCells,
|
|
6010
|
+
keptCells: lattice.report.keptCells,
|
|
6011
|
+
islands: lattice.report.islands,
|
|
6012
|
+
},
|
|
6013
|
+
});
|
|
6014
|
+
return {
|
|
6015
|
+
type: 'mesh',
|
|
6016
|
+
...meshTextureKeys(att, placeholder),
|
|
6017
|
+
uvs: geometry.uvs.map(f32),
|
|
6018
|
+
triangles: geometry.triangles,
|
|
6019
|
+
vertices: vertices.map(f32),
|
|
6020
|
+
...generatedHullAndEdges(geometry, where),
|
|
6021
|
+
width: f32(w),
|
|
6022
|
+
height: f32(h),
|
|
6023
|
+
};
|
|
6024
|
+
}
|
|
6025
|
+
|
|
5693
6026
|
/** Defaults for the `contour` generator's optional parameters, stated once. */
|
|
5694
6027
|
const CONTOUR_DEFAULTS = { margin: 1, maxVertices: 64, alpha: 1 } as const;
|
|
5695
6028
|
|
|
@@ -6548,6 +6881,8 @@ function buildRigInfo(
|
|
|
6548
6881
|
editorRoundTrip: rig.invariants?.editorRoundTrip === true,
|
|
6549
6882
|
// `parseRigSpec` resolved every entry against the rig's own constraints.
|
|
6550
6883
|
consumerDrivenMix: (rig.invariants?.consumerDrivenMix ?? []).map((e) => ({ type: e.type, constraint: e.constraint, why: e.why })),
|
|
6884
|
+
// `parseRigSpec` refused every shape but a non-blank `why`.
|
|
6885
|
+
idleDrivesMeshes: rig.invariants?.idleDrivesMeshes?.why ?? null,
|
|
6551
6886
|
// `isBase` is set on the manifest route only; every other image says false.
|
|
6552
6887
|
basePlates: images.filter((img) => img.isBase).map((img) => img.region),
|
|
6553
6888
|
meshSlotBudget: rig.invariants?.meshSlots ?? null,
|