spine-rigc 1.7.0 → 1.9.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/cli.ts +88 -21
- package/docs/AUTHORING.md +18 -12
- package/package.json +1 -1
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a06.ts +195 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a19.ts +295 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +255 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a41.ts +83 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a47.ts +59 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/constraint_words.ts +177 -0
- package/src/assertions/facts/animated_bones.ts +28 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/constraints.ts +131 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +71 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/skin_meshes.ts +49 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +24 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/kinds.ts +102 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +30 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/constraints.ts +207 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +247 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +90 -0
- package/src/assertions/model/parse.ts +138 -0
- package/src/assertions/model/region_joins.ts +59 -0
- package/src/assertions/model/skin_entries.ts +59 -0
- package/src/assertions/model/skin_members.ts +62 -0
- package/src/assertions/model/skin_meshes.ts +90 -0
- package/src/assertions/model/slot_colour.ts +80 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/vertex_polygons.ts +71 -0
- package/src/assertions/reasons.ts +127 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/values.ts +39 -0
- package/src/check.ts +36 -9
- package/src/compile.ts +204 -57
- package/src/core/index.ts +161 -29
- package/src/core/world.ts +21 -20
- package/src/deformmeasure.ts +377 -154
- package/src/deformreport.ts +3 -0
- package/src/deformstructure.ts +344 -0
- package/src/emit_spine.ts +21 -41
- package/src/keyorder.ts +40 -0
- package/src/model.ts +188 -22
- package/src/render.ts +243 -54
- package/src/render_core.ts +101 -13
- package/src/transform.ts +79 -30
- package/src/validate.ts +742 -2781
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A28, the body (issue #1025, step 4c of #380): a ribbon's rows share their
|
|
3
|
+
* weights. An `archetype` rule.
|
|
4
|
+
*
|
|
5
|
+
* This is what makes "length without width" a property of the file rather than
|
|
6
|
+
* a hope. Both vertices of a row carry the same bones at the same weights, so
|
|
7
|
+
* whatever the chain does to one it does to the other and their separation can
|
|
8
|
+
* only rotate — the strip curves and stretches, and never gets fatter. Give one
|
|
9
|
+
* side a different weight and the strip develops a taper that grows with its
|
|
10
|
+
* travel, which is the sort of thing that reads as bad art rather than as a bug.
|
|
11
|
+
*
|
|
12
|
+
* Moved out of `src/validate.ts` whole: both clauses are about the rig — a
|
|
13
|
+
* document's ribbon states every vertex's bindings — and the row's shape is
|
|
14
|
+
* printed as the file holds it, each bone by its index in the roster and each
|
|
15
|
+
* weight as the runtime's float32. The guard on a skeleton the round trip did
|
|
16
|
+
* not load stays in `validate()`, which has no facts to hand over then.
|
|
17
|
+
*/
|
|
18
|
+
import type { Verdicts } from '../harness.ts';
|
|
19
|
+
import type { MeshFacts } from '../facts/mesh_attachments.ts';
|
|
20
|
+
import type { RigInfo } from '../../types.ts';
|
|
21
|
+
|
|
22
|
+
export function a28RibbonRowsShareWeights({ fail, skip }: Verdicts, facts: MeshFacts | null, rig: RigInfo | undefined): void {
|
|
23
|
+
if (facts === null) return skip('A28_RIBBON_ROWS_SHARE_WEIGHTS', 'the skeleton did not load (A00 owns that failure)');
|
|
24
|
+
if (!rig) return skip('A28_RIBBON_ROWS_SHARE_WEIGHTS', 'no rig info (validating a bare directory), so no mesh is known to be a ribbon');
|
|
25
|
+
const kinds = rig.meshKinds;
|
|
26
|
+
if (!Object.values(kinds).includes('ribbon')) {
|
|
27
|
+
// Say WHICH kind of "no ribbon" this is. "declares no ribbon" is true of a
|
|
28
|
+
// cut with no mesh at all, of one whose meshes are authored geometry, and
|
|
29
|
+
// of one whose meshes are contours — and the last two are cases where a
|
|
30
|
+
// reader should know that a mesh went unmeasured on purpose.
|
|
31
|
+
const unpaired = Object.entries(kinds)
|
|
32
|
+
.filter(([, kind]) => kind === 'authored' || kind === 'contour' || kind === 'segments')
|
|
33
|
+
.map(([slot, kind]) => `"${slot}" (${kind})`);
|
|
34
|
+
return skip(
|
|
35
|
+
'A28_RIBBON_ROWS_SHARE_WEIGHTS',
|
|
36
|
+
unpaired.length
|
|
37
|
+
? `the rig "${rig.archetype}" declares no ribbon mesh on this cut — its mesh slot(s) ${unpaired.join(', ')} have no rows to pair: authored geometry is somebody else's topology, a contour is one silhouette loop, and a segments lattice has cells rather than cross rows`
|
|
38
|
+
: `the rig "${rig.archetype}" declares no ribbon mesh on this cut`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
for (const mesh of facts.meshes) {
|
|
42
|
+
if (!mesh.weights) continue;
|
|
43
|
+
// 🔗 A LINKED mesh's rows are its source's and are paired there. Skipped
|
|
44
|
+
// for A21's reason and with A21's failure mode behind it: this reads
|
|
45
|
+
// `meshKinds` at the LINK's slot, which says nothing about the geometry
|
|
46
|
+
// the link borrowed, so a link sitting in a ribbon's slot from another
|
|
47
|
+
// skin would be measured twice and a link to a NON-ribbon in a ribbon's
|
|
48
|
+
// slot would be measured as a strip it was never built as (issue #691).
|
|
49
|
+
if (mesh.link !== null) continue;
|
|
50
|
+
if (rig.meshKinds[mesh.slot] !== 'ribbon') continue;
|
|
51
|
+
const perVertex = mesh.weights;
|
|
52
|
+
if (perVertex.length % 2 !== 0) {
|
|
53
|
+
fail('A28_RIBBON_ROWS_SHARE_WEIGHTS', `ribbon "${mesh.name}" has ${perVertex.length} vertices; a strip has an even count`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Perimeter order: left row i is index i, right row i is its mirror.
|
|
57
|
+
const rows = perVertex.length / 2;
|
|
58
|
+
for (let i = 0; i < rows; i++) {
|
|
59
|
+
const left = perVertex[i];
|
|
60
|
+
const right = perVertex[perVertex.length - 1 - i];
|
|
61
|
+
const shape = (v: typeof left) => v.map((w) => `${w.bone}:${w.weight.toFixed(6)}`).join(',');
|
|
62
|
+
if (shape(left) !== shape(right)) {
|
|
63
|
+
fail(
|
|
64
|
+
'A28_RIBBON_ROWS_SHARE_WEIGHTS',
|
|
65
|
+
`ribbon "${mesh.name}" row ${i} has [${shape(left)}] on one side and [${shape(right)}] on the other; its width would change with the chain`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A33, the body (issue #1025, step 4c of #380): bounding boxes, clipping
|
|
3
|
+
* polygons and paths hold a real polygon, a path's curve lengths strictly
|
|
4
|
+
* increase, and a clip ends at a slot of the skeleton.
|
|
5
|
+
*
|
|
6
|
+
* These types are the same shape — a polygon and nothing else — and they
|
|
7
|
+
* fail the same ways, all silent:
|
|
8
|
+
*
|
|
9
|
+
* 1. **A missing or wrong `vertexCount`.** The parser reads
|
|
10
|
+
* `map.vertexCount << 1` and hands it to `readVertices` as the length to
|
|
11
|
+
* expect (`:552`, `:632`). `undefined << 1` is 0, so an omission makes
|
|
12
|
+
* the coordinate array read as a WEIGHTED run: it decodes numbers as
|
|
13
|
+
* bone counts and weights, and the attachment ends up with no vertices
|
|
14
|
+
* at all. Nothing throws, and neither type draws a pixel, so nothing
|
|
15
|
+
* downstream notices either.
|
|
16
|
+
* 2. **A weighted run that does not decode to that many vertices.** Same
|
|
17
|
+
* trap as a mesh's (A04), minus the uvs that would have caught it.
|
|
18
|
+
* 3. **A clipping `end` naming a slot that is not there.**
|
|
19
|
+
* `skeletonData.findSlot` returns null on a miss and `:626-627` assigns
|
|
20
|
+
* the null, so the clip does not end where it was told to — it runs to
|
|
21
|
+
* the bottom of the draw order and takes every slot below it with it.
|
|
22
|
+
* Checked on what the file states, because a null `endSlot` and an `end`
|
|
23
|
+
* that was never written are the same loaded object.
|
|
24
|
+
*
|
|
25
|
+
* Moved out of `src/validate.ts` clause by clause. The path clauses, the
|
|
26
|
+
* clip's end and a polygon's vertex count are about the rig — a document's
|
|
27
|
+
* polygon states its count and its vertices, a path its `closed` and
|
|
28
|
+
* `lengths`, a clip its `end` — and moved. The decode of a weighted run and
|
|
29
|
+
* the length of an unweighted array (the second failure above) are about the
|
|
30
|
+
* encoding: the document states the weighted form outright and names bones.
|
|
31
|
+
* They stay with the round trip, which hands their findings in as each
|
|
32
|
+
* polygon's `encoding`, printed where the body always printed them.
|
|
33
|
+
*/
|
|
34
|
+
import type { Verdicts } from '../harness.ts';
|
|
35
|
+
import type { PolygonFacts } from '../facts/vertex_polygons.ts';
|
|
36
|
+
|
|
37
|
+
export function a33VertexAttachmentGeometry({ fail, skip }: Verdicts, { polygons, clipEnds, slots }: PolygonFacts): void {
|
|
38
|
+
const paths = polygons.filter((p) => p.path !== null);
|
|
39
|
+
for (const path of paths) {
|
|
40
|
+
const what = path.what;
|
|
41
|
+
const vertexCount = path.worldVerticesLength / 2;
|
|
42
|
+
if (vertexCount % 3 !== 0) {
|
|
43
|
+
fail(
|
|
44
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
45
|
+
`${what} has ${vertexCount} vertices, which is not a multiple of 3 — a path is knots and Bezier ` +
|
|
46
|
+
'handles read in groups of three, and `Utils.newArray(vertexCount / 3, 0)` accepts the fractional ' +
|
|
47
|
+
'size without a word, so the curves straddle the knots',
|
|
48
|
+
);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Curves: 3K + 1 chain points. An open path drops the first and last
|
|
52
|
+
// vertex (the end knots' outer handles), a closed one repeats the first.
|
|
53
|
+
const curves = path.path?.closed ? vertexCount / 3 : vertexCount / 3 - 1;
|
|
54
|
+
if (curves < 1) {
|
|
55
|
+
fail('A33_VERTEX_ATTACHMENT_GEOMETRY', `${what} has ${vertexCount} vertices, which is not one whole curve`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// `lengths` is what a `constantSpeed: false` traversal measures with:
|
|
59
|
+
// `lengths[curve]` bounds each curve and the last entry IS the path
|
|
60
|
+
// length. The parser sizes the array from `vertexCount / 3` and copies
|
|
61
|
+
// whatever the file gave, so a short array leaves trailing zeros — and a
|
|
62
|
+
// zero-length curve makes the parser divide the position by it.
|
|
63
|
+
const lengths = path.path?.lengths ?? [];
|
|
64
|
+
let previous = 0;
|
|
65
|
+
for (let c = 0; c < curves; c++) {
|
|
66
|
+
const value = lengths[c];
|
|
67
|
+
if (!Number.isFinite(value) || value <= previous) {
|
|
68
|
+
fail(
|
|
69
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
70
|
+
`${what} lengths[${c}] is ${String(value)}, and the entry before it was ${previous}. The array is the ` +
|
|
71
|
+
'CUMULATIVE arc length at the end of each of the ' +
|
|
72
|
+
`${curves} curve(s), so it strictly increases; a value that does not means either a zero-length ` +
|
|
73
|
+
'curve (the position is divided by it) or an array shorter than the geometry (the tail reads as 0)',
|
|
74
|
+
);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
previous = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const slotNames = new Set(slots);
|
|
81
|
+
let endsChecked = 0;
|
|
82
|
+
for (const { placeholder, slot: slotName, end } of clipEnds) {
|
|
83
|
+
endsChecked++;
|
|
84
|
+
if (typeof end !== 'string' || !slotNames.has(end)) {
|
|
85
|
+
fail(
|
|
86
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
87
|
+
`clipping attachment "${placeholder}" on slot "${slotName}" ends at ${JSON.stringify(end)}, ` +
|
|
88
|
+
'which is not a slot of this skeleton — the clip would run to the bottom of the draw order',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (polygons.length === 0 && endsChecked === 0) {
|
|
93
|
+
return skip(
|
|
94
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
95
|
+
'the skeleton carries no bounding box, clipping attachment or path',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
for (const { what, worldVerticesLength: length, encoding } of polygons) {
|
|
99
|
+
if (!Number.isInteger(length) || length < 6 || length % 2 !== 0) {
|
|
100
|
+
fail(
|
|
101
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
102
|
+
`${what} loaded worldVerticesLength ${length}; a polygon is an even count of at least 6 (3 vertices). ` +
|
|
103
|
+
'A missing "vertexCount" reads as 0 and takes the polygon with it',
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// The vertex run's own coherence is the encoding's, kept with the round trip (the header).
|
|
108
|
+
for (const detail of encoding) fail('A33_VERTEX_ATTACHMENT_GEOMETRY', detail);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A36, the body (issue #1025, step 4c of #380): a path constraint that
|
|
3
|
+
* follows nothing, quietly.
|
|
4
|
+
*
|
|
5
|
+
* 🚨 The first failure here is the quietest in the whole constraint half of
|
|
6
|
+
* the format. `PathConstraint.update` opens with
|
|
7
|
+
*
|
|
8
|
+
* const attachment = this.slot.appliedPose.attachment;
|
|
9
|
+
* if (!(attachment instanceof PathAttachment)) return;
|
|
10
|
+
*
|
|
11
|
+
* so a path constraint aimed at a slot that never shows a path loads
|
|
12
|
+
* perfectly, reports every mix it was given, appears in the update cache —
|
|
13
|
+
* and moves nothing, forever. Nothing in the file is wrong on its face: the
|
|
14
|
+
* slot exists, the constraint resolves, the mixes are 1.
|
|
15
|
+
*
|
|
16
|
+
* The rest are the same shape as A23's: a constraint that parses and does
|
|
17
|
+
* nothing. All three mixes at 0 is only a finding when no animation keys one
|
|
18
|
+
* of them above 0 (`switchedOn` over the `mix` timelines, judged by
|
|
19
|
+
* `mixLive`), and a chain with no bones on it is one whether or not anything
|
|
20
|
+
* is keyed.
|
|
21
|
+
*
|
|
22
|
+
* Moved out of `src/validate.ts` whole: the bones, the slot and the skins'
|
|
23
|
+
* paths, the setup mixes and the keyed ones are all the rig's, and a
|
|
24
|
+
* document's records state each.
|
|
25
|
+
*/
|
|
26
|
+
import type { Verdicts } from '../harness.ts';
|
|
27
|
+
import type { ConstraintFacts } from '../facts/constraints.ts';
|
|
28
|
+
import { mixLive, noneKeysItsMixAbove0, switchedOn } from '../constraint_words.ts';
|
|
29
|
+
|
|
30
|
+
export function a36PathConstraintEffective({ fail, skip, stats }: Verdicts, facts: ConstraintFacts): void {
|
|
31
|
+
const constraints = facts.constraints.flatMap((c, index) => (c.path === undefined ? [] : [{ name: c.name, path: c.path, index }]));
|
|
32
|
+
if (!constraints.length) return skip('A36_PATH_CONSTRAINT_EFFECTIVE', 'the skeleton declares no path constraint');
|
|
33
|
+
/**
|
|
34
|
+
* Which path constraints an animation switches ON — `switchedOn` over their
|
|
35
|
+
* `mix` timelines, judged by `mixLive`, the predicate their setup pose is
|
|
36
|
+
* judged by below.
|
|
37
|
+
*
|
|
38
|
+
* ⭐ The reason this is needed: a constraint whose mixes are all 0 at setup
|
|
39
|
+
* is **the idiom**, not a defect — spineboy's aim rig is exactly that, and
|
|
40
|
+
* issue #88 landed the timelines that turn one on. So "muted" is only a
|
|
41
|
+
* finding when nothing turns it on, and that question lives in the
|
|
42
|
+
* animations rather than in the constraint.
|
|
43
|
+
*
|
|
44
|
+
* 🔑 "Turns it on" is a VALUE question, and until issue #752 this asked a
|
|
45
|
+
* weaker one than `A23` — whether a `mix` key array was non-empty — so a
|
|
46
|
+
* timeline keying 0 only was a rescue for a constraint it leaves exactly as
|
|
47
|
+
* muted as no timeline does. Now a path constraint is switched on by a key
|
|
48
|
+
* posing any of its three mixes above 0, which is the runtime's own
|
|
49
|
+
* condition: `PathConstraint.update` returns when `mixRotate`, `mixX` and
|
|
50
|
+
* `mixY` are all 0 (`PathConstraint.js:73-75`).
|
|
51
|
+
*/
|
|
52
|
+
const pathSwitchedOn = switchedOn(facts, (timeline) => timeline.kind === 'path' && timeline.word === 'mix', 3, (_timeline, value) => mixLive(value));
|
|
53
|
+
/** The slots some skin gives a path attachment. */
|
|
54
|
+
const pathsBySlot = new Set(facts.pathSlots);
|
|
55
|
+
for (const { name, path: constraint, index } of constraints) {
|
|
56
|
+
const where = `path constraint "${name}"`;
|
|
57
|
+
if (!constraint.bones.length) {
|
|
58
|
+
fail('A36_PATH_CONSTRAINT_EFFECTIVE', `${where} constrains no bone; it parses and does nothing`);
|
|
59
|
+
}
|
|
60
|
+
const slot = constraint.slot;
|
|
61
|
+
if (!pathsBySlot.has(slot)) {
|
|
62
|
+
fail(
|
|
63
|
+
'A36_PATH_CONSTRAINT_EFFECTIVE',
|
|
64
|
+
`${where} follows slot "${slot}", and no skin gives that slot a path attachment — ` +
|
|
65
|
+
"PathConstraint.update returns immediately unless the slot's attachment is a path, so this " +
|
|
66
|
+
'constraint reports its mixes and moves nothing',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const pose = constraint.setup;
|
|
70
|
+
const muted = ![pose.mixRotate, pose.mixX, pose.mixY].some(mixLive);
|
|
71
|
+
if (muted && !pathSwitchedOn.has(index)) {
|
|
72
|
+
fail(
|
|
73
|
+
'A36_PATH_CONSTRAINT_EFFECTIVE',
|
|
74
|
+
`${where} has mixRotate ${pose.mixRotate}, mixX ${pose.mixX} and mixY ${pose.mixY} at setup and ` +
|
|
75
|
+
`${noneKeysItsMixAbove0(facts.animations)}; update() returns on all-zero mixes, so nothing ever ` +
|
|
76
|
+
'puts a bone on the path — rest one of the three above 0, or key its mix above 0 in an animation',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
stats.pathConstraints = constraints.length;
|
|
81
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A37, the body (issue #1025, step 4c of #380): a slider that applies
|
|
3
|
+
* nothing, quietly.
|
|
4
|
+
*
|
|
5
|
+
* A slider is the only constraint that applies an ANIMATION, so its failure
|
|
6
|
+
* modes are about that animation rather than about a transform:
|
|
7
|
+
*
|
|
8
|
+
* 1. **An animation with no timelines.** `animation.apply` walks an empty
|
|
9
|
+
* array. The slider is in the update cache, its time moves, and the
|
|
10
|
+
* skeleton never changes.
|
|
11
|
+
* 2. **`loop` on a zero-length animation.** `Slider.update` computes
|
|
12
|
+
* `animation.duration + (p.time % animation.duration)` when looping, so
|
|
13
|
+
* a duration of 0 makes the time **NaN** — and it then applies the
|
|
14
|
+
* animation at NaN, which is a pose nobody can predict and no error.
|
|
15
|
+
* Only reachable with a bone, because that is the branch the loop
|
|
16
|
+
* arithmetic lives in.
|
|
17
|
+
* 3. **`scale` 0 with a bone.** `time = offset + (value - offset) * 0`, so
|
|
18
|
+
* the dial turns and the slider holds one frame.
|
|
19
|
+
* 4. **`mix` 0** with nothing keying it — the same rule as A36's.
|
|
20
|
+
*
|
|
21
|
+
* Moved out of `src/validate.ts` whole: the animation it applies (its
|
|
22
|
+
* timeline count and duration), its dial, `loop`, `scale` and mix, and the
|
|
23
|
+
* keys that raise its mix are the rig's, and a document's records state each.
|
|
24
|
+
* ⚠️ `scale` is read behind `bone` only, and that is where the one known
|
|
25
|
+
* disagreement between the suppliers lives: on a bone-less slider spine-core
|
|
26
|
+
* leaves `scale` at 0 and the document states 1 (#1027). Clause 3 never reads
|
|
27
|
+
* a bone-less slider's scale, so no line can differ on it.
|
|
28
|
+
*/
|
|
29
|
+
import type { Verdicts } from '../harness.ts';
|
|
30
|
+
import type { ConstraintFacts } from '../facts/constraints.ts';
|
|
31
|
+
import { mixLive, noneKeysItsMixAbove0, REST_OR_KEY_ITS_MIX, switchedOn } from '../constraint_words.ts';
|
|
32
|
+
|
|
33
|
+
export function a37SliderConstraintEffective({ fail, skip, stats }: Verdicts, facts: ConstraintFacts): void {
|
|
34
|
+
const sliders = facts.constraints.flatMap((c, index) => (c.slider === undefined ? [] : [{ name: c.name, slider: c.slider, index }]));
|
|
35
|
+
if (!sliders.length) return skip('A37_SLIDER_CONSTRAINT_EFFECTIVE', 'the skeleton declares no slider constraint');
|
|
36
|
+
/** Which sliders an animation switches ON — `switchedOn` over their `mix` timelines, judged by `mixLive` (A36's ⭐ and 🔑; `Slider.update` returns when `mix` is 0, `Slider.js:53-54`). */
|
|
37
|
+
const sliderSwitchedOn = switchedOn(facts, (timeline) => timeline.kind === 'slider' && timeline.word === 'mix', 1, (_timeline, value) => mixLive(value));
|
|
38
|
+
for (const { name, slider, index } of sliders) {
|
|
39
|
+
const where = `slider "${name}"`;
|
|
40
|
+
const animation = slider.animation;
|
|
41
|
+
if (!animation) {
|
|
42
|
+
// The parser's second pass throws on a miss, so this is only reachable
|
|
43
|
+
// on an artifact that never went through it.
|
|
44
|
+
fail('A37_SLIDER_CONSTRAINT_EFFECTIVE', `${where} applies no animation`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (animation.timelines === 0) {
|
|
48
|
+
fail(
|
|
49
|
+
'A37_SLIDER_CONSTRAINT_EFFECTIVE',
|
|
50
|
+
`${where} applies animation "${animation.name}", which carries no timeline at all; the slider runs and ` +
|
|
51
|
+
'the skeleton never changes',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (slider.bone && slider.loop && !(animation.duration > 0)) {
|
|
55
|
+
fail(
|
|
56
|
+
'A37_SLIDER_CONSTRAINT_EFFECTIVE',
|
|
57
|
+
`${where} loops animation "${animation.name}", whose duration is ${animation.duration} — ` +
|
|
58
|
+
'Slider.update computes `duration + (time % duration)` when looping, so the applied time is NaN',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (slider.bone && slider.scale === 0) {
|
|
62
|
+
fail(
|
|
63
|
+
'A37_SLIDER_CONSTRAINT_EFFECTIVE',
|
|
64
|
+
`${where} drives off bone "${slider.bone}" with scale 0, so the property cannot move the slider's time`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const mix = slider.mix;
|
|
68
|
+
if (!mixLive(mix) && !sliderSwitchedOn.has(index)) {
|
|
69
|
+
fail(
|
|
70
|
+
'A37_SLIDER_CONSTRAINT_EFFECTIVE',
|
|
71
|
+
`${where} has mix ${mix} at setup and ${noneKeysItsMixAbove0(facts.animations)}; update() returns ` +
|
|
72
|
+
`on mix 0 — ${REST_OR_KEY_ITS_MIX}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
stats.sliderConstraints = sliders.length;
|
|
77
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A38, the body (issue #1025, step 4c of #380): a skin's member list and its
|
|
3
|
+
* members' `skin: true` flag agree.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the bones,
|
|
6
|
+
* the constraints and the skins' lists are a fact (`../facts/skin_members.ts`)
|
|
7
|
+
* — which spine-core's loaded `SkeletonData` satisfies as it stands. Why both
|
|
8
|
+
* halves of the switch are dead data alone stays above the `check` call in
|
|
9
|
+
* `validate()`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Verdicts } from '../harness.ts';
|
|
12
|
+
import type { MemberBone, SkinMemberFacts } from '../facts/skin_members.ts';
|
|
13
|
+
|
|
14
|
+
export function a38SkinMembersAreSkinRequired({ fail, skip, stats }: Verdicts, data: SkinMemberFacts): void {
|
|
15
|
+
/** Every bone a skin can switch on, including the ancestors it drags in. */
|
|
16
|
+
const activatable = new Set<string>();
|
|
17
|
+
// The constraint OBJECTS a skin lists, never their names: a name is not a
|
|
18
|
+
// constraint in this format, and two constraints of one name under two
|
|
19
|
+
// kinds are two objects a skin may list separately (issue #692). Keyed by
|
|
20
|
+
// name, a `transform` `leg` that no skin lists read as listed because an
|
|
21
|
+
// `ik` `leg` was — a skinRequired constraint that never runs, reported
|
|
22
|
+
// green by the one assertion that looks for exactly that.
|
|
23
|
+
const listedConstraints = new Set(data.skins.flatMap((skin) => skin.constraints));
|
|
24
|
+
let listed = 0;
|
|
25
|
+
for (const skin of data.skins) {
|
|
26
|
+
for (const bone of skin.bones) {
|
|
27
|
+
listed++;
|
|
28
|
+
for (let cursor: MemberBone | null = bone; cursor; cursor = cursor.parent) activatable.add(cursor.name);
|
|
29
|
+
if (!bone.skinRequired) {
|
|
30
|
+
fail(
|
|
31
|
+
'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
|
|
32
|
+
`skin "${skin.name}" activates bone "${bone.name}", which is not skinRequired — updateCache starts it ` +
|
|
33
|
+
'active anyway, so it poses under every skin and this list changes nothing',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const constraint of skin.constraints) {
|
|
38
|
+
listed++;
|
|
39
|
+
if (!constraint.skinRequired) {
|
|
40
|
+
fail(
|
|
41
|
+
'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
|
|
42
|
+
`skin "${skin.name}" activates constraint "${constraint.name}", which is not skinRequired — it runs ` +
|
|
43
|
+
'under every skin, so this list changes nothing',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const required = data.bones.filter((b) => b.skinRequired).length + data.constraints.filter((c) => c.skinRequired).length;
|
|
49
|
+
if (listed === 0 && required === 0) {
|
|
50
|
+
return skip(
|
|
51
|
+
'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
|
|
52
|
+
'no skin activates a bone or a constraint, and nothing declares itself skinRequired',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
for (const bone of data.bones) {
|
|
56
|
+
if (bone.skinRequired && !activatable.has(bone.name)) {
|
|
57
|
+
fail(
|
|
58
|
+
'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
|
|
59
|
+
`bone "${bone.name}" is skinRequired and no skin's bones list reaches it, so it is never active — ` +
|
|
60
|
+
'it and its subtree hold the setup pose under every skin',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const constraint of data.constraints) {
|
|
65
|
+
if (constraint.skinRequired && !listedConstraints.has(constraint)) {
|
|
66
|
+
fail(
|
|
67
|
+
'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
|
|
68
|
+
`constraint "${constraint.name}" is skinRequired and no skin lists it, so it never runs`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
stats.skinMembers = listed;
|
|
73
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A41, the body (issue #1025, step 4c of #380): a physics component the Spine
|
|
3
|
+
* editor cannot hold, refused on a rig declared for the editor.
|
|
4
|
+
*
|
|
5
|
+
* 🚨 The silence this converts is not in the artifact — it is one consumer
|
|
6
|
+
* downstream of it. An author builds a rig whose cowlick jiggles, opens it
|
|
7
|
+
* in the editor to move an eyebrow, saves, exports, and the hair has stopped
|
|
8
|
+
* moving. The returned file says nothing: the component is simply absent,
|
|
9
|
+
* and absent parses as 0. `gallery/look` is exactly that rig.
|
|
10
|
+
*
|
|
11
|
+
* 🔑 **rigc's output is correct, so the refusal is opt-in.** `rotate` on a
|
|
12
|
+
* physics constraint is valid Spine 4.3 that every runtime plays, and
|
|
13
|
+
* refusing it by default would be refusing correct data on behalf of a
|
|
14
|
+
* pipeline rigc was never told about. What rigc can see is the object; which
|
|
15
|
+
* consumers it is for is the rig's to say, and it says it with
|
|
16
|
+
* `invariants.editorRoundTrip` (`src/rig.ts`).
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **The SKIP is the other half of the product and is not a shrug.** A rig
|
|
19
|
+
* that declares nothing is not gated — but the reason names the constraint
|
|
20
|
+
* and the components a round trip would drop, so the one thing that must not
|
|
21
|
+
* happen (nobody finds out) does not happen either. That is why this reads
|
|
22
|
+
* the whole skeleton before it reads the declaration, rather than returning
|
|
23
|
+
* early on a rig that asked for nothing.
|
|
24
|
+
*
|
|
25
|
+
* 🔸 **Against A23, on the far side of the same trip.** A23 refuses a
|
|
26
|
+
* constraint driving NOTHING — which is what the editor hands back, after
|
|
27
|
+
* the loss — and it is what fires today on a round-tripped `look`. This
|
|
28
|
+
* refuses a constraint driving something the editor will not keep, before
|
|
29
|
+
* the trip. They cannot both fire on one constraint: A23's condition is an
|
|
30
|
+
* empty driven set and this one's is a non-empty one (see
|
|
31
|
+
* `PHYSICS_COMPONENTS`), so the two are disjoint by construction rather than
|
|
32
|
+
* by agreement.
|
|
33
|
+
*
|
|
34
|
+
* Moved out of `src/validate.ts` whole: the components are the document's
|
|
35
|
+
* physics records, the declaration the rig's.
|
|
36
|
+
*/
|
|
37
|
+
import type { Verdicts } from '../harness.ts';
|
|
38
|
+
import type { ConstraintFacts } from '../facts/constraints.ts';
|
|
39
|
+
import { EDITOR_PHYSICS_COMPONENTS, PHYSICS_COMPONENTS } from '../constraint_words.ts';
|
|
40
|
+
import type { RigInfo } from '../../types.ts';
|
|
41
|
+
|
|
42
|
+
export function a41PhysicsSurvivesEditorRoundTrip({ fail, skip }: Verdicts, facts: ConstraintFacts, rig: RigInfo | undefined): void {
|
|
43
|
+
// Counted here rather than read off `stats.physicsConstraints`: that entry
|
|
44
|
+
// is written by A23, and a rule whose SKIP depends on another rule having
|
|
45
|
+
// run is a rule that reports "nothing to measure" when its neighbour threw.
|
|
46
|
+
const physics = facts.constraints.flatMap((c) => (c.physics === undefined ? [] : [{ name: c.name, components: c.physics.components }]));
|
|
47
|
+
const dropped: string[] = [];
|
|
48
|
+
for (const constraint of physics) {
|
|
49
|
+
const lost = PHYSICS_COMPONENTS.filter((k) => constraint.components[k] > 0 && !EDITOR_PHYSICS_COMPONENTS.has(k));
|
|
50
|
+
if (lost.length) dropped.push(`physics "${constraint.name}" drives ${lost.join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
const editorKeeps = [...EDITOR_PHYSICS_COMPONENTS].join(' and ');
|
|
53
|
+
const found =
|
|
54
|
+
dropped.length === 0
|
|
55
|
+
? 'no physics constraint here drives a component it would discard'
|
|
56
|
+
: `${dropped.join('; ')}, and the editor's physics model holds ${editorKeeps} only, so a round trip ` +
|
|
57
|
+
'returns that constraint driving nothing at all (issue #540)';
|
|
58
|
+
if (rig?.editorRoundTrip !== true) {
|
|
59
|
+
return skip(
|
|
60
|
+
'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
|
|
61
|
+
`${
|
|
62
|
+
rig
|
|
63
|
+
? `the rig "${rig.archetype}" does not declare \`invariants.editorRoundTrip\``
|
|
64
|
+
: 'this is a bare directory, with no rig info to declare `invariants.editorRoundTrip`'
|
|
65
|
+
}, so nothing here is gated against the Spine editor. What is here: ${found}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (physics.length === 0) {
|
|
69
|
+
return skip(
|
|
70
|
+
'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
|
|
71
|
+
`the rig "${rig.archetype}" is declared for the editor, but it carries no physics constraint — ` +
|
|
72
|
+
'there is nothing here whose components could be lost',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
for (const one of dropped) {
|
|
76
|
+
fail(
|
|
77
|
+
'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
|
|
78
|
+
`${one}; the editor's physics model holds ${editorKeeps} only, and this ` +
|
|
79
|
+
'rig declares `invariants.editorRoundTrip` — drive it in x/y, or drop the declaration if this rig never ' +
|
|
80
|
+
'goes through the editor (issue #540)',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A42, the body (issue #1025, step 4c of #380): a constraint a slider's
|
|
3
|
+
* animation keys updates after that slider.
|
|
4
|
+
*
|
|
5
|
+
* `Skeleton.updateCache` walks the `constraints` array in order and each
|
|
6
|
+
* constraint's `sort` pushes itself as it is reached — `Slider.sort` included,
|
|
7
|
+
* which pushes bones and never a constraint — so the array IS the update order,
|
|
8
|
+
* for every kind. Every constraint then opens its `update` by reading its own
|
|
9
|
+
* applied pose, so a slider that keys any property of a constraint is read by
|
|
10
|
+
* that constraint only when it comes LATER in the array; written the other way
|
|
11
|
+
* round the value lands in a pose whose only reader has already run, and
|
|
12
|
+
* `Posed.resetConstrained` puts the pose back before the next frame (the
|
|
13
|
+
* argument and its measurements sit above the `check` call in `validate()`).
|
|
14
|
+
*
|
|
15
|
+
* Moved out of `src/validate.ts` whole: the array's order, the slider's
|
|
16
|
+
* animation and the constraint timelines it holds — which constraint each
|
|
17
|
+
* names, what it keys, and whom a physics timeline naming none reaches — are
|
|
18
|
+
* the rig's, and a document's records state each. The words the sentence
|
|
19
|
+
* prints are the facts' own: what a timeline keys (`word`, the motion spec's
|
|
20
|
+
* word for it) and the runtime class whose `update` reads it (`runtimeClass`).
|
|
21
|
+
*/
|
|
22
|
+
import type { Verdicts } from '../harness.ts';
|
|
23
|
+
import type { ConstraintFacts } from '../facts/constraints.ts';
|
|
24
|
+
|
|
25
|
+
export function a42DrivenConstraintsUpdateAfterTheirDriver({ fail, skip, stats }: Verdicts, facts: ConstraintFacts): void {
|
|
26
|
+
const sliders = facts.constraints.flatMap((c, index) => (c.slider === undefined ? [] : [{ name: c.name, slider: c.slider, index }]));
|
|
27
|
+
if (!sliders.length) {
|
|
28
|
+
return skip('A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER', 'the skeleton declares no slider constraint');
|
|
29
|
+
}
|
|
30
|
+
let pairs = 0;
|
|
31
|
+
let resets = 0;
|
|
32
|
+
for (const driver of sliders) {
|
|
33
|
+
const driverIndex = driver.index;
|
|
34
|
+
const animationName = driver.slider.animation?.name;
|
|
35
|
+
for (const timeline of facts.timelines) {
|
|
36
|
+
if (animationName === undefined || timeline.animation !== animationName) continue;
|
|
37
|
+
if (timeline.kind === 'physics' && timeline.word === 'reset') {
|
|
38
|
+
resets++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const word = timeline.word;
|
|
42
|
+
const everyPhysics = timeline.constraint < 0;
|
|
43
|
+
// A physics timeline whose animation names no constraint reaches every
|
|
44
|
+
// ACTIVE physics constraint whose own data declares that property
|
|
45
|
+
// global — the timeline's `reach`, the one reading of it A23 and A34
|
|
46
|
+
// share (issue #726).
|
|
47
|
+
for (const drivenIndex of timeline.reach) {
|
|
48
|
+
const driven = facts.constraints[drivenIndex];
|
|
49
|
+
if (driven === undefined) continue;
|
|
50
|
+
pairs++;
|
|
51
|
+
if (drivenIndex > driverIndex) continue;
|
|
52
|
+
const kind = { word: driven.kind, runtime: driven.runtimeClass };
|
|
53
|
+
const animation = `animation "${animationName}"`;
|
|
54
|
+
const reference = `\`${kind.word}.${driven.name}${word === kind.word ? '' : `.${word}`}\``;
|
|
55
|
+
fail(
|
|
56
|
+
'A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER',
|
|
57
|
+
drivenIndex === driverIndex
|
|
58
|
+
? `slider "${driver.name}" (constraints[${driverIndex}]) keys its own \`${word}\` in ${animation}. ` +
|
|
59
|
+
'`Slider.update` reads `appliedPose.' +
|
|
60
|
+
`${word}\` as the ${word === 'mix' ? 'alpha it applies that animation with' : 'time it applies that animation at'}, ` +
|
|
61
|
+
'before the animation runs, so the key is written after its only reader and `Posed.resetConstrained` ' +
|
|
62
|
+
`puts the pose back before the next frame${
|
|
63
|
+
word === 'mix'
|
|
64
|
+
? ' — and at `mix` 0 `update` returns before applying anything at all, so the key that would raise it is unreachable'
|
|
65
|
+
: ''
|
|
66
|
+
}. Key ${reference} from a slider EARLIER in \`constraints\`, or state the ` +
|
|
67
|
+
`\`${word}\` this slider should start at in the rig spec`
|
|
68
|
+
: `slider "${driver.name}" (constraints[${driverIndex}]) keys ${
|
|
69
|
+
word === kind.word ? `the \`${word}\` timeline` : `\`${word}\``
|
|
70
|
+
} of ${kind.word} constraint ` +
|
|
71
|
+
`"${driven.name}" (constraints[${drivenIndex}]) in ${animation}${
|
|
72
|
+
everyPhysics ? ' — the timeline names no constraint, which the runtime reads as every physics constraint declaring that property global —' : ''
|
|
73
|
+
}, and "${driven.name}" updates FIRST. The \`constraints\` array is the update order ` +
|
|
74
|
+
'(`Skeleton.updateCache` walks it and each constraint\'s `sort` pushes itself as it is reached) and ' +
|
|
75
|
+
`\`${kind.runtime}.update\` reads its own \`appliedPose\` before applying anything, so that key is ` +
|
|
76
|
+
'written after the only read of it and `Posed.resetConstrained` discards it before the next frame: ' +
|
|
77
|
+
`what "${driven.name}" drives is dead at every reading of "${driver.name}"'s dial, although its pose ` +
|
|
78
|
+
`still holds the number. Move "${driver.name}" before "${driven.name}" in \`constraints\`, or key ` +
|
|
79
|
+
`${reference} from a slider that already is`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (pairs === 0) {
|
|
85
|
+
return skip(
|
|
86
|
+
'A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER',
|
|
87
|
+
`no animation applied by one of the ${sliders.length} slider constraint${sliders.length === 1 ? '' : 's'} keys a ` +
|
|
88
|
+
`property of a constraint, so no slider here drives a constraint${
|
|
89
|
+
resets === 0
|
|
90
|
+
? ''
|
|
91
|
+
: ` — the ${resets} \`physics\` \`reset\` key(s) they do carry are not a pose write, and [measured] a slider ` +
|
|
92
|
+
'applies its animation at one instant, so `PhysicsConstraintResetTimeline` never fires from one in either array order'
|
|
93
|
+
}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
stats.sliderDrivenConstraints = pairs;
|
|
97
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A44, the body (issue #1025, step 4c of #380): a linked mesh states no
|
|
3
|
+
* geometry of its own.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` by its subject only. Which links the file
|
|
6
|
+
* declares is the rig's — a document's `linkedmesh` record is one — so the
|
|
7
|
+
* roster moved, and a rig with no link SKIPs on both sides. What A44 refuses
|
|
8
|
+
* is the encoding's: a link record holds no geometry field, and `uvs`,
|
|
9
|
+
* `triangles`, `vertices`, `hull` and `edges` written on a link exist only in
|
|
10
|
+
* the Spine text (the census's row for A44). That clause, and the sentence it
|
|
11
|
+
* prints, stay with the round trip, which hands its finding about each link in
|
|
12
|
+
* as the link's `encoding`, printed in the order the body always printed it.
|
|
13
|
+
*/
|
|
14
|
+
import type { Verdicts } from '../harness.ts';
|
|
15
|
+
import type { LinkFacts } from '../facts/linked_meshes.ts';
|
|
16
|
+
import { SKIP_NO_LINKED_MESH } from '../reasons.ts';
|
|
17
|
+
|
|
18
|
+
export function a44LinkedMeshStatesNoGeometryOfItsOwn({ fail, skip }: Verdicts, { links }: LinkFacts): void {
|
|
19
|
+
if (links.length === 0) return skip('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', SKIP_NO_LINKED_MESH);
|
|
20
|
+
for (const link of links) {
|
|
21
|
+
for (const detail of link.encoding) fail('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', detail);
|
|
22
|
+
}
|
|
23
|
+
}
|