@rig-c/parts 1.10.0 → 1.12.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 -7
- package/docs/AUTHORING.md +159 -24
- package/docs/STABILITY.md +3 -2
- package/package.json +2 -2
- package/src/assemble.ts +85 -2
- package/src/build.ts +5 -1
- package/src/config.ts +51 -1
- package/src/heat.ts +42 -6
- package/src/mesh.ts +61 -0
- package/src/meshdraft.ts +10 -9
- package/src/rig.ts +101 -20
- package/src/weights.ts +69 -11
package/src/config.ts
CHANGED
|
@@ -92,6 +92,7 @@ import { type Problem, refuseIfAny } from './errors.ts';
|
|
|
92
92
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
93
93
|
import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
|
|
94
94
|
import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
|
|
95
|
+
import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
|
|
95
96
|
|
|
96
97
|
export type Point = [number, number];
|
|
97
98
|
|
|
@@ -275,6 +276,13 @@ export interface LatticeMeshSpec {
|
|
|
275
276
|
exponent?: number;
|
|
276
277
|
/** Absent: `"distance"`. */
|
|
277
278
|
rule?: WeightRule;
|
|
279
|
+
/** The cap and the floor's form (issue #284); absent is 4 and the cut at 0.03, the rule as it always was. */
|
|
280
|
+
influences?: MeshInfluences;
|
|
281
|
+
/**
|
|
282
|
+
* The largest island, in art pixels, that may be left out of the lattice (issue #303) — the contour mode's
|
|
283
|
+
* `contour.stray`, with the same meaning (`latticeStray`, `src/mesh.ts`); absent leaves none out.
|
|
284
|
+
*/
|
|
285
|
+
stray?: number;
|
|
278
286
|
}
|
|
279
287
|
|
|
280
288
|
export interface ContourMeshSpec {
|
|
@@ -285,6 +293,8 @@ export interface ContourMeshSpec {
|
|
|
285
293
|
exponent?: number;
|
|
286
294
|
/** Absent: `"distance"`. */
|
|
287
295
|
rule?: WeightRule;
|
|
296
|
+
/** As the lattice's (issue #284). */
|
|
297
|
+
influences?: MeshInfluences;
|
|
288
298
|
/** Rows of vertices across the part along the chains named here (issue #188); absent places none. */
|
|
289
299
|
ribs?: RibsSpec;
|
|
290
300
|
}
|
|
@@ -1660,7 +1670,7 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
|
|
|
1660
1670
|
const at = `config.meshes.${part}`;
|
|
1661
1671
|
// The known keys keep their old order (grid, r, segments) with contour after them, and `r` and `segments` are
|
|
1662
1672
|
// required in the place and words the object check uses, so a lattice mesh's refusals read as they did.
|
|
1663
|
-
const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs']);
|
|
1673
|
+
const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs', 'influences', 'stray']);
|
|
1664
1674
|
if (m === null) continue;
|
|
1665
1675
|
for (const key of ['r', 'segments']) if (!(key in m)) c.fail('CONFIG_FIELD_PRESENT', `${at}.${key}`, 'is absent and required');
|
|
1666
1676
|
const modes = ['grid', 'contour', 'auto'].filter((k) => k in m);
|
|
@@ -1701,6 +1711,8 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
|
|
|
1701
1711
|
});
|
|
1702
1712
|
}
|
|
1703
1713
|
if ('ribs' in m) checkRibs(c, `${at}.ribs`, m, chains);
|
|
1714
|
+
if ('influences' in m) checkMeshInfluences(c, `${at}.influences`, m);
|
|
1715
|
+
if ('stray' in m) checkLatticeStray(c, `${at}.stray`, m);
|
|
1704
1716
|
if ('auto' in m && typeof m.auto === 'object' && m.auto !== null && !Array.isArray(m.auto) && 'stimulus' in m.auto) {
|
|
1705
1717
|
checkStimulus(c, `${at}.auto.stimulus`, (m.auto as Record<string, Json>).stimulus, m, chains);
|
|
1706
1718
|
}
|
|
@@ -1746,6 +1758,44 @@ function checkStimulus(c: Check, at: string, v: Json, m: Record<string, Json>, c
|
|
|
1746
1758
|
});
|
|
1747
1759
|
}
|
|
1748
1760
|
|
|
1761
|
+
/**
|
|
1762
|
+
* `meshes.<part>.influences` (issue #284): the lattice and contour modes' cut, the author's per mesh —
|
|
1763
|
+
* `maxInfluences`, a whole number 1 or more or `null` (no cap), and `floor`, `"cut"` or `"continuous"` (`FloorForm`,
|
|
1764
|
+
* `src/weights.ts`). Either may be absent (4, and the cut); an object naming neither is refused, since it would
|
|
1765
|
+
* declare nothing. The automatic mode states its cap and floor in `auto.influences`, which rig-c's reduction is
|
|
1766
|
+
* handed, so this field beside `auto` is refused by name (`CONFIG_INFLUENCES_MODE`) rather than read by nothing.
|
|
1767
|
+
*/
|
|
1768
|
+
function checkMeshInfluences(c: Check, at: string, m: Record<string, Json>): void {
|
|
1769
|
+
if ('auto' in m) {
|
|
1770
|
+
c.fail('CONFIG_INFLUENCES_MODE', at, `is ${show(m.influences)} on an automatic mesh (auto); the automatic mode states its cap and floor in auto.influences (maxInfluences, minWeight), which rig-c's reduction is handed — declare them there, or remove influences`);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const l = c.object(at, m.influences, [], ['maxInfluences', 'floor']);
|
|
1774
|
+
if (l === null) return;
|
|
1775
|
+
if (!('maxInfluences' in l) && !('floor' in l)) c.fail('CONFIG_FIELD_TYPE', at, `is ${show(m.influences)}, which names neither maxInfluences nor floor; remove influences to keep the cap of 4 and the cut at 0.03`);
|
|
1776
|
+
if ('maxInfluences' in l && l.maxInfluences !== null && !(typeof l.maxInfluences === 'number' && Number.isInteger(l.maxInfluences) && l.maxInfluences >= 1)) {
|
|
1777
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.maxInfluences`, `is ${show(l.maxInfluences)}; an integer at or above 1, or null (no cap: every bone the mesh's segments name), is required`);
|
|
1778
|
+
}
|
|
1779
|
+
if ('floor' in l && !FLOOR_FORMS.includes(l.floor as FloorForm)) {
|
|
1780
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.floor`, `is ${show(l.floor)}; "cut" (shares under 0.03 dropped, what an absent floor means) or "continuous" (0.03 subtracted from every share, those at or below 0 dropped) is required`);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* `meshes.<part>.stray` (issue #303): the lattice's largest island, in art pixels, that may be left out — a whole
|
|
1786
|
+
* number, 0 or more, as the contour mode's `contour.stray` is. The contour and automatic modes already state it in
|
|
1787
|
+
* their own place (`contour.stray`, `auto.source.stray`), so beside either it is refused by name
|
|
1788
|
+
* (`CONFIG_STRAY_MODE`) rather than read by nothing.
|
|
1789
|
+
*/
|
|
1790
|
+
function checkLatticeStray(c: Check, at: string, m: Record<string, Json>): void {
|
|
1791
|
+
const elsewhere = 'contour' in m ? ['a contour mesh (contour)', 'contour.stray'] : 'auto' in m ? ['an automatic mesh (auto)', 'auto.source.stray'] : null;
|
|
1792
|
+
if (elsewhere !== null && !('grid' in m)) {
|
|
1793
|
+
c.fail('CONFIG_STRAY_MODE', at, `is ${show(m.stray)} on ${elsewhere[0]}; that mode states the largest island it may leave out in ${elsewhere[1]}, which its trace reads — declare it there, or remove stray`);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
c.int(at, m.stray, 0);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1749
1799
|
/**
|
|
1750
1800
|
* `meshes.<part>.ribs` (issue #188): an object of chain name → `{ stations }`.
|
|
1751
1801
|
* Each key must be a chain this mesh's `segments` name (a rib is placed along
|
package/src/heat.ts
CHANGED
|
@@ -44,10 +44,19 @@
|
|
|
44
44
|
* silhouette, or one whose every reached pixel is nearer another bone. A
|
|
45
45
|
* bone named in `segments` is a bone the author said may pull the layer;
|
|
46
46
|
* giving it 0 everywhere would be a silent answer to a question the
|
|
47
|
-
* config asked.
|
|
47
|
+
* config asked. The rule is chosen per mesh (`meshes.<part>.rule`), so the
|
|
48
|
+
* refusal names the third remedy too (issue #303): this part keeps the
|
|
49
|
+
* distance rule — the one an author whose bone anchors the part from
|
|
50
|
+
* outside its art (a torso bone on a neck) can take without moving a bone
|
|
51
|
+
* another part placed or dropping the anchor.
|
|
48
52
|
* - `RIG_HEAT_ISLAND_SOURCE`: a 4-connected island of the silhouette that no
|
|
49
53
|
* bone has a source in. With no fixed temperature the equation has no
|
|
50
|
-
* answer there; nothing nearest is borrowed.
|
|
54
|
+
* answer there; nothing nearest is borrowed. When the caller can say the
|
|
55
|
+
* island is dust that its `stray` would leave out ({@link HeatDust}: the
|
|
56
|
+
* lattice, issue #303) the refusal names that field and the figure that
|
|
57
|
+
* leaves the island out instead of a segment nobody would place on it; an
|
|
58
|
+
* island above the dust line keeps the segment remedy, a real piece being
|
|
59
|
+
* a real authoring question.
|
|
51
60
|
* - `RIG_HEAT_CONVERGED`: above.
|
|
52
61
|
*
|
|
53
62
|
* Two runs on the same input write the same bytes: the claims, the sweep
|
|
@@ -55,6 +64,8 @@
|
|
|
55
64
|
*/
|
|
56
65
|
import type { Point } from './config.ts';
|
|
57
66
|
import { artMask, growSilhouette } from './contour.ts';
|
|
67
|
+
import { DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
|
|
68
|
+
import { pyRound } from './round.ts';
|
|
58
69
|
import type { Problem } from './errors.ts';
|
|
59
70
|
import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
|
|
60
71
|
import { capAndFloor, type Influence, type InfluenceLimits, type Segment, segmentDistance } from './weights.ts';
|
|
@@ -120,11 +131,34 @@ export interface HeatRow {
|
|
|
120
131
|
|
|
121
132
|
const fmt = (v: number): string => String(Math.round(v * 1000) / 1000);
|
|
122
133
|
|
|
134
|
+
/**
|
|
135
|
+
* What a mesh's `stray` can say about a silhouette island no bone reaches (issue #303), handed in by the mode that has
|
|
136
|
+
* the field (the lattice): the field's path, its declared figure (null when absent), and the part's art islands it
|
|
137
|
+
* reads — the art px (`isDust`'s share is of them) and the largest island's area, which `stray` never leaves out. It
|
|
138
|
+
* is read only when an island is refused, so a build that solves never computes it.
|
|
139
|
+
*/
|
|
140
|
+
export interface HeatDust {
|
|
141
|
+
field: string;
|
|
142
|
+
declared: number | null;
|
|
143
|
+
artPixels: number;
|
|
144
|
+
largest: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The refusal's remedy for an island of `area` px no bone reaches: the stray field when the island is dust it would leave out, else a segment. */
|
|
148
|
+
function islandRemedy(area: number, dust: HeatDust | null): string {
|
|
149
|
+
if (dust !== null && isDust(area, dust.artPixels) && area < dust.largest) {
|
|
150
|
+
const now = dust.declared === null ? 'it is absent' : `it is ${dust.declared}`;
|
|
151
|
+
return `the island is dust — at most ${DUST_MAX_PX} px and at most ${pyRound(DUST_MAX_SHARE * 100, 6)} % of the part's ${dust.artPixels} art px, mesh-draft's dust line — which no bone is meant to sit on: declare ${dust.field} at ${area} or more (${now}), which leaves an island that small out of the mesh and the silhouette heat solves over`;
|
|
152
|
+
}
|
|
153
|
+
return `under rule "heat" every island needs a segment within ${HEAT_SOURCE_BAND} px of one of its pixel centres — nothing is borrowed from the nearest island`;
|
|
154
|
+
}
|
|
155
|
+
|
|
123
156
|
/**
|
|
124
157
|
* Solve bone heat over `sil` (part-image pixels) for `segs` (rig px; the image is the rig less `(ox, oy)`).
|
|
125
|
-
* `object` names the mesh in every refusal. Every problem found is returned at once.
|
|
158
|
+
* `object` names the mesh in every refusal. Every problem found is returned at once. `dust`, when the mesh's mode has
|
|
159
|
+
* a `stray` field, is read only to word an island refusal ({@link HeatDust}).
|
|
126
160
|
*/
|
|
127
|
-
export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox: number, oy: number, limits: HeatLimits = HEAT_LIMITS): HeatField | Problem[] {
|
|
161
|
+
export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox: number, oy: number, limits: HeatLimits = HEAT_LIMITS, dust: (() => HeatDust) | null = null): HeatField | Problem[] {
|
|
128
162
|
const { width: w, height: h, data } = sil;
|
|
129
163
|
const n = w * h;
|
|
130
164
|
const bones: string[] = [];
|
|
@@ -166,19 +200,21 @@ export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox
|
|
|
166
200
|
problems.push({
|
|
167
201
|
code: 'RIG_HEAT_BONE_SOURCE',
|
|
168
202
|
object,
|
|
169
|
-
detail: `bone "${bone}" — its segment ${where} (rig px) ${why}, so it holds no source pixel; under rule "heat" every bound bone needs at least one: a silhouette pixel (${silPixels} px) whose centre lies within ${HEAT_SOURCE_BAND} px of the segment and nearer it than any other bound bone's. A bone with no source is not weighted 0 — move its segment onto the art,
|
|
203
|
+
detail: `bone "${bone}" — its segment ${where} (rig px) ${why}, so it holds no source pixel; under rule "heat" every bound bone needs at least one: a silhouette pixel (${silPixels} px) whose centre lies within ${HEAT_SOURCE_BAND} px of the segment and nearer it than any other bound bone's. A bone with no source is not weighted 0 — move its segment onto the art, take the bone out of segments, or keep this part on the distance rule (rule "distance", or no rule, on this mesh: the rule is chosen per mesh)`,
|
|
170
204
|
});
|
|
171
205
|
});
|
|
172
206
|
const cc = connectedComponents(sil, 4);
|
|
173
207
|
const sourced = new Uint8Array(cc.count);
|
|
174
208
|
for (let i = 0; i < n; i++) if (owner[i] >= 0) sourced[cc.labels[i]] = 1;
|
|
209
|
+
let dustRead: HeatDust | null = null;
|
|
175
210
|
for (let c = 1; c < cc.count; c++) {
|
|
176
211
|
if (sourced[c]) continue;
|
|
177
212
|
const st = cc.stats[c];
|
|
213
|
+
if (dust !== null && dustRead === null) dustRead = dust();
|
|
178
214
|
problems.push({
|
|
179
215
|
code: 'RIG_HEAT_ISLAND_SOURCE',
|
|
180
216
|
object,
|
|
181
|
-
detail: `the silhouette's 4-connected island of ${st.area} px at rig (${st.left + ox}, ${st.top + oy}) holds no bound bone's source pixel, so the heat equation has no fixed temperature there and no answer;
|
|
217
|
+
detail: `the silhouette's 4-connected island of ${st.area} px at rig (${st.left + ox}, ${st.top + oy}) holds no bound bone's source pixel, so the heat equation has no fixed temperature there and no answer; ${islandRemedy(st.area, dustRead)}`,
|
|
182
218
|
});
|
|
183
219
|
}
|
|
184
220
|
if (problems.length > 0) return problems;
|
package/src/mesh.ts
CHANGED
|
@@ -360,3 +360,64 @@ export function latticeMesh(part: string, art: Mask, grid: number, maxPasses: nu
|
|
|
360
360
|
}
|
|
361
361
|
return mesh;
|
|
362
362
|
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Dust (issue #265, Stage B's rule; moved here from `src/meshdraft.ts` by issue #303, so the rig stage's heat refusal
|
|
366
|
+
* and `mesh-draft` read one line): an extra 4-connected island of the art of at most this many art px …
|
|
367
|
+
*/
|
|
368
|
+
export const DUST_MAX_PX = 4;
|
|
369
|
+
|
|
370
|
+
/** … and at most this share of the part's art px. */
|
|
371
|
+
export const DUST_MAX_SHARE = 0.001;
|
|
372
|
+
|
|
373
|
+
/** Whether an island of `area` art px is dust in a part of `artPixels` art px ({@link DUST_MAX_PX}, {@link DUST_MAX_SHARE}). */
|
|
374
|
+
export function isDust(area: number, artPixels: number): boolean {
|
|
375
|
+
return area <= DUST_MAX_PX && area <= DUST_MAX_SHARE * artPixels;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What a declared `meshes.<part>.stray` left out of a lattice's art (issue #303). */
|
|
379
|
+
export interface LatticeStray {
|
|
380
|
+
/** The art the lattice is built over: `art` itself when nothing was left out. */
|
|
381
|
+
art: Mask;
|
|
382
|
+
/** Islands left out, and their art px. */
|
|
383
|
+
islands: number;
|
|
384
|
+
pixels: number;
|
|
385
|
+
/** The art px of the part before anything was left out. */
|
|
386
|
+
artPixels: number;
|
|
387
|
+
/** The area of the largest island; an island this large is never left out. */
|
|
388
|
+
largest: number;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The lattice's `stray` (issue #303), the contour mode's field with the same meaning — the largest island, in art
|
|
393
|
+
* pixels, that may be left out of the mesh — read over the same islands: the 4-connected islands of the art (alpha
|
|
394
|
+
* above {@link ART_ALPHA}). Every island of `stray` art px or fewer is cleared from the art the lattice and its heat
|
|
395
|
+
* silhouette are built over, except an island as large as the largest, which is never left out (two equal largest
|
|
396
|
+
* pieces both stay: leaving one would be a choice the declaration does not make). Unlike the contour mode, an island
|
|
397
|
+
* above the figure is not refused: the lattice bridges every island it keeps (step 2 above), so it is meshed as it
|
|
398
|
+
* always was. The part's image is not touched; `artCoverage` is read over all the art, the islands left out included.
|
|
399
|
+
*/
|
|
400
|
+
export function latticeStray(art: Mask, stray: number): LatticeStray {
|
|
401
|
+
const comp = connectedComponents(art, 4);
|
|
402
|
+
let largest = 0;
|
|
403
|
+
let artPixels = 0;
|
|
404
|
+
for (let l = 1; l < comp.count; l++) {
|
|
405
|
+
largest = Math.max(largest, comp.stats[l].area);
|
|
406
|
+
artPixels += comp.stats[l].area;
|
|
407
|
+
}
|
|
408
|
+
const out = new Uint8Array(comp.count);
|
|
409
|
+
let islands = 0;
|
|
410
|
+
let pixels = 0;
|
|
411
|
+
for (let l = 1; l < comp.count; l++) {
|
|
412
|
+
const a = comp.stats[l].area;
|
|
413
|
+
if (a <= stray && a < largest) {
|
|
414
|
+
out[l] = 1;
|
|
415
|
+
islands++;
|
|
416
|
+
pixels += a;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (islands === 0) return { art, islands, pixels, artPixels, largest };
|
|
420
|
+
const data = new Uint8Array(art.data);
|
|
421
|
+
for (let i = 0; i < data.length; i++) if (out[comp.labels[i]] === 1) data[i] = 0;
|
|
422
|
+
return { art: { width: art.width, height: art.height, data }, islands, pixels, artPixels, largest };
|
|
423
|
+
}
|
package/src/meshdraft.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - dust left out: an extra 4-connected island of the art (alpha above `ART_ALPHA`) of at most {@link DUST_MAX_PX} px
|
|
15
15
|
* and at most {@link DUST_MAX_SHARE} of the part's art is dust, and a `stray` equal to the largest such island leaves
|
|
16
16
|
* it out. A part with any other extra island holds a real piece, and keeps its lattice ({@link dustOf});
|
|
17
|
-
* - `r`, `segments`, `exponent` and `
|
|
17
|
+
* - `r`, `segments`, `exponent`, `rule` and `influences` as the lattice entry states them.
|
|
18
18
|
*
|
|
19
19
|
* The constants are the ones issue #265's stages measured (Stage A passed every bar at c = 37 on three examples; T 2
|
|
20
20
|
* at the trailer's 1108 px figure is Stage A2's point; the dust rule is Stage B's). They are calibrated on three
|
|
@@ -68,7 +68,7 @@ import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner
|
|
|
68
68
|
import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
|
|
69
69
|
import { PartsError, type Problem, problemLine } from './errors.ts';
|
|
70
70
|
import { IDLE_FPS } from './motion.ts';
|
|
71
|
-
import { ART_ALPHA } from './mesh.ts';
|
|
71
|
+
import { ART_ALPHA, DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
|
|
72
72
|
import { type PartsFile, readParts } from './parts.ts';
|
|
73
73
|
import { alphaAbove, connectedComponents } from './raster/index.ts';
|
|
74
74
|
import { readPng } from './raster/png.ts';
|
|
@@ -90,11 +90,11 @@ export const TOLERANCE_FIGURE_DIVISOR = 554;
|
|
|
90
90
|
/** The heights of the three figures the two divisors were measured on, rig px (demo, the trailer, scarf). */
|
|
91
91
|
export const CALIBRATION_HEIGHTS: readonly number[] = [1106, 1108, 1185];
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
export
|
|
93
|
+
/**
|
|
94
|
+
* Dust (Stage B: 1–2 px dots were the measured case): `src/mesh.ts` holds the line since issue #303, which the rig
|
|
95
|
+
* stage's heat refusal reads too; re-exported here, where it was first written.
|
|
96
|
+
*/
|
|
97
|
+
export { DUST_MAX_PX, DUST_MAX_SHARE } from './mesh.ts';
|
|
98
98
|
|
|
99
99
|
/** The bend set's turn per link, degrees: the fallback Stage C stated, a physics constraint's reach not being derivable from it. */
|
|
100
100
|
export const BEND_DEGREES = 15;
|
|
@@ -182,7 +182,7 @@ export function dustOf(image: Raster): DustReading {
|
|
|
182
182
|
if (islands.length === 0) return { keep: `its image has no pixel with alpha above ${ART_ALPHA}`, islands };
|
|
183
183
|
if (islands.length > 1 && islands[0] === islands[1]) return { keep: `its art is ${islands.length} islands (${islands.join(', ')} px) of which two share the largest count, so no one island is the largest`, islands };
|
|
184
184
|
const extra = islands.slice(1);
|
|
185
|
-
const pieces = extra.filter((n) => !(n
|
|
185
|
+
const pieces = extra.filter((n) => !isDust(n, art));
|
|
186
186
|
if (pieces.length > 0) {
|
|
187
187
|
return {
|
|
188
188
|
keep: `its art is ${islands.length} islands (${islands.join(', ')} px); ${pieces.length} beside the largest ${pieces.length === 1 ? 'is a real piece' : 'are real pieces'}, not dust (at most ${DUST_MAX_PX} px and at most ${pyRound(DUST_MAX_SHARE * 100, 6)} % of the part's ${art} art px), and the contour mode meshes one island`,
|
|
@@ -200,6 +200,7 @@ export function candidateMesh(lattice: LatticeMeshSpec, rules: Pick<FigureRules,
|
|
|
200
200
|
segments: lattice.segments,
|
|
201
201
|
...(lattice.exponent === undefined ? {} : { exponent: lattice.exponent }),
|
|
202
202
|
...(lattice.rule === undefined ? {} : { rule: lattice.rule }),
|
|
203
|
+
...(lattice.influences === undefined ? {} : { influences: lattice.influences }),
|
|
203
204
|
};
|
|
204
205
|
}
|
|
205
206
|
|
|
@@ -692,7 +693,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
692
693
|
const denseMeshes = new Map<string, LatticeMeshSpec>();
|
|
693
694
|
for (const part of names) {
|
|
694
695
|
const l = cfg.meshes[part] as LatticeMeshSpec;
|
|
695
|
-
denseMeshes.set(part, { grid: denseGrid(artPixels(images.get(part) as Raster)), r: l.r, segments: l.segments, ...(l.exponent === undefined ? {} : { exponent: l.exponent }), ...(l.rule === undefined ? {} : { rule: l.rule }) });
|
|
696
|
+
denseMeshes.set(part, { grid: denseGrid(artPixels(images.get(part) as Raster)), r: l.r, segments: l.segments, ...(l.exponent === undefined ? {} : { exponent: l.exponent }), ...(l.rule === undefined ? {} : { rule: l.rule }), ...(l.influences === undefined ? {} : { influences: l.influences }) });
|
|
696
697
|
}
|
|
697
698
|
const denseRig = buildRig({ ...cfg, meshes: { ...cfg.meshes, ...Object.fromEntries(denseMeshes) } }, parts, images, undefined, input.idleKeys).rig;
|
|
698
699
|
const pngs: Array<[string, Uint8Array]> = readdirSync(join(draftedRig.rigDir, 'images')).sort().map((f) => [f, new Uint8Array(readFileSync(join(draftedRig.rigDir, 'images', f)))]);
|
package/src/rig.ts
CHANGED
|
@@ -153,15 +153,15 @@ import {
|
|
|
153
153
|
} from './automesh.ts';
|
|
154
154
|
import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
|
|
155
155
|
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
156
|
-
import { artCoverage, ART_ALPHA, latticeMesh, ONE_LOOP_PASSES } from './mesh.ts';
|
|
156
|
+
import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
|
|
157
157
|
import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
158
158
|
import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
159
159
|
import { EYE_GROUP_TAGS } from './propose.ts';
|
|
160
160
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
161
161
|
import { pyRound } from './round.ts';
|
|
162
|
-
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
162
|
+
import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
163
163
|
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
164
|
-
import {
|
|
164
|
+
import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
|
|
165
165
|
|
|
166
166
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
167
167
|
export const PAD = 4;
|
|
@@ -309,12 +309,47 @@ export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>):
|
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
311
|
* The weights a contour vertex is written with: a vertex no region reaches is
|
|
312
|
-
* rounded as the lattice rounds (
|
|
313
|
-
*
|
|
312
|
+
* rounded as the lattice rounds ({@link latticeShares}), one a region reaches
|
|
313
|
+
* by {@link roundShares} — and so is every vertex of a mesh that declares the
|
|
314
|
+
* continuous floor (issue #284), whose shares, like a region's ramp, reach
|
|
315
|
+
* below 0.03 and may round to 0.
|
|
314
316
|
*/
|
|
315
|
-
export function writtenShares(li: LocalInfluence): Influence[] {
|
|
316
|
-
if (li.region >= 0) return roundShares(li.influences);
|
|
317
|
-
|
|
317
|
+
export function writtenShares(li: LocalInfluence, floor?: FloorForm): Influence[] {
|
|
318
|
+
if (li.region >= 0 || floor === 'continuous') return roundShares(li.influences);
|
|
319
|
+
return latticeShares(li.influences);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The refusal for a mesh some of whose vertices the cut leaves with no bone (issue #284): the first such vertex, with
|
|
324
|
+
* its capped shares before the floor, and how many more there are.
|
|
325
|
+
*
|
|
326
|
+
* When it can happen, by arithmetic: the capped shares of a vertex are normalised, so over k kept bones they sum to 1
|
|
327
|
+
* and the largest is at least 1/k. The cut keeps a share of at least 0.03 and the continuous floor one above 0.03, so a
|
|
328
|
+
* vertex keeps no bone only if 1/k ≤ 0.03, that is k ≥ 34 (1/33 = 0.0303… keeps its largest under both forms). At the
|
|
329
|
+
* default cap of 4 the largest share is at least 0.25, so a mesh that declares nothing never reaches this; only a
|
|
330
|
+
* declared cap of 34 or more, or `null`, over 34 or more bones whose shares are all at or below the floor does — and a
|
|
331
|
+
* share that is not a number (raw values summing to 0) lands here too rather than in the file. Rare, so refused by
|
|
332
|
+
* name rather than left to a crash in the rounding.
|
|
333
|
+
*/
|
|
334
|
+
export function nothingKeptProblem(part: string, floor: FloorForm | undefined, first: { vertex: number; at: Point; capped: readonly Influence[] }, more: number): Problem {
|
|
335
|
+
const largest = first.capped.length === 0 ? null : Math.max(...first.capped.map((e) => e.weight));
|
|
336
|
+
const form = floor === 'continuous' ? 'the continuous floor (0.03 subtracted from every share) keeps none at or below 0.03' : 'the cut drops every share below 0.03';
|
|
337
|
+
return {
|
|
338
|
+
code: 'RIG_INFLUENCES_KEPT',
|
|
339
|
+
object: `config.meshes.${part}.influences`,
|
|
340
|
+
detail: `vertex ${first.vertex} at rig (${first.at[0]}, ${first.at[1]})${more > 0 ? ` and ${more} more` : ''} keeps no bone: its ${first.capped.length} capped share(s) have the largest ${largest === null || !Number.isFinite(largest) ? String(largest) : pyRound(largest, 6)}, and ${form}; a largest share above 0.03 is required, which every vertex has when 33 or fewer bones are kept (the largest of k shares is at least 1/k) — declare maxInfluences 33 or less, or name fewer bones in segments`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The lattice's rounding: each weight to 5 places, the last entry written as
|
|
346
|
+
* `1 − sum(others)`. Safe because under the cut every kept entry is at least
|
|
347
|
+
* `MIN_WEIGHT`, so the last cannot be taken negative; a mesh that declares the
|
|
348
|
+
* continuous floor is rounded by {@link roundShares} instead (issue #284, as
|
|
349
|
+
* Stage E wrote it: `docs/evidence/weight-cap.md`).
|
|
350
|
+
*/
|
|
351
|
+
export function latticeShares(list: readonly Influence[]): Influence[] {
|
|
352
|
+
const shares = list.map(({ bone, weight }) => ({ bone, weight: pyRound(weight, 5) }));
|
|
318
353
|
let others = 0;
|
|
319
354
|
for (let k = 0; k < shares.length - 1; k++) others += shares[k].weight;
|
|
320
355
|
shares[shares.length - 1].weight = pyRound(1 - others, 5);
|
|
@@ -613,6 +648,12 @@ export interface LatticeMeshReport {
|
|
|
613
648
|
grid: number;
|
|
614
649
|
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
615
650
|
exponent?: number;
|
|
651
|
+
/** The cap and floor form the mesh declared, as declared (issue #284); absent when it declared none and ran at 4 and the cut. */
|
|
652
|
+
influences?: MeshInfluences;
|
|
653
|
+
/** The `stray` the mesh declared (issue #303); absent when it declared none. */
|
|
654
|
+
stray?: number;
|
|
655
|
+
/** Only beside a declared `stray` (issue #303): the islands it left out of the lattice, their art px, and the part's art px. */
|
|
656
|
+
stray_cleared?: StrayCleared;
|
|
616
657
|
/** Only under `rule: "heat"` (issue #161): the rule and the solve it ran. Absent otherwise, so a distance mesh writes the row it always wrote. */
|
|
617
658
|
heat?: HeatRow;
|
|
618
659
|
}
|
|
@@ -636,6 +677,8 @@ export interface ContourMeshReport {
|
|
|
636
677
|
mode: 'contour';
|
|
637
678
|
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
638
679
|
exponent?: number;
|
|
680
|
+
/** As the lattice row's (issue #284). */
|
|
681
|
+
influences?: MeshInfluences;
|
|
639
682
|
/** Only when the mesh declared ribs (issue #188): what it declared and what was placed. Absent otherwise, so a mesh without ribs writes the row it always wrote. */
|
|
640
683
|
ribs?: RibsRow;
|
|
641
684
|
/** `maxEdge` only when the mesh declared it (issue #265), so a mesh without it writes the row it always wrote. */
|
|
@@ -1321,8 +1364,10 @@ export function buildRig(
|
|
|
1321
1364
|
segs: Segment[],
|
|
1322
1365
|
rule: WeightRule,
|
|
1323
1366
|
ribSpec: { segments: ReadonlyArray<string | readonly [string, Point, Point]>; ribs?: RibsSpec },
|
|
1367
|
+
declared: MeshInfluences | undefined,
|
|
1324
1368
|
out: Problem[],
|
|
1325
1369
|
): { attachment: MeshAttachment; report: ContourMeshReport } | null => {
|
|
1370
|
+
const limits = meshLimits(declared);
|
|
1326
1371
|
const ox = p.x - PAD;
|
|
1327
1372
|
const oy = p.y - PAD;
|
|
1328
1373
|
const w = img.width;
|
|
@@ -1354,10 +1399,12 @@ export function buildRig(
|
|
|
1354
1399
|
let infl = 0;
|
|
1355
1400
|
let maxInfl = 0;
|
|
1356
1401
|
let overlapped = false;
|
|
1402
|
+
let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
|
|
1403
|
+
let emptiedMore = 0;
|
|
1357
1404
|
cm.vertices.forEach(([vx, vy], vi) => {
|
|
1358
1405
|
const wx = vx + ox;
|
|
1359
1406
|
const wy = vy + oy;
|
|
1360
|
-
const li = localInfluences([wx, wy], segs, r, regions,
|
|
1407
|
+
const li = localInfluences([wx, wy], segs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
|
|
1361
1408
|
if ('first' in li) {
|
|
1362
1409
|
if (!overlapped) {
|
|
1363
1410
|
out.push({
|
|
@@ -1369,11 +1416,17 @@ export function buildRig(
|
|
|
1369
1416
|
overlapped = true;
|
|
1370
1417
|
return;
|
|
1371
1418
|
}
|
|
1419
|
+
if (li.influences.length === 0) {
|
|
1420
|
+
// issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be); a region's bone would be kept.
|
|
1421
|
+
if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx, vy], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
|
|
1422
|
+
else emptiedMore++;
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1372
1425
|
if (li.region >= 0) {
|
|
1373
1426
|
reached[li.region]++;
|
|
1374
1427
|
if (li.g >= 1) whole[li.region]++;
|
|
1375
1428
|
}
|
|
1376
|
-
const shares = writtenShares(li);
|
|
1429
|
+
const shares = writtenShares(li, limits.floor);
|
|
1377
1430
|
const ent: WeightEntry[] = shares.map(({ bone, weight }) => {
|
|
1378
1431
|
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1379
1432
|
return { bone, x: places(x), y: places(y), weight };
|
|
@@ -1383,6 +1436,10 @@ export function buildRig(
|
|
|
1383
1436
|
maxInfl = Math.max(maxInfl, ent.length);
|
|
1384
1437
|
});
|
|
1385
1438
|
if (overlapped) return null;
|
|
1439
|
+
if (emptied !== null) {
|
|
1440
|
+
out.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1386
1443
|
const uvs: number[] = [];
|
|
1387
1444
|
for (const [x, y] of cm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
1388
1445
|
const bones = new Set(segs.map((s) => s.bone));
|
|
@@ -1401,6 +1458,7 @@ export function buildRig(
|
|
|
1401
1458
|
art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels), 5),
|
|
1402
1459
|
mode: 'contour',
|
|
1403
1460
|
...(exponent === undefined ? {} : { exponent }),
|
|
1461
|
+
...(declared === undefined ? {} : { influences: declared }),
|
|
1404
1462
|
...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, cm.ribs ?? []) }),
|
|
1405
1463
|
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
1406
1464
|
contour: rep,
|
|
@@ -1684,14 +1742,18 @@ export function buildRig(
|
|
|
1684
1742
|
continue;
|
|
1685
1743
|
}
|
|
1686
1744
|
if ('contour' in mesh) {
|
|
1687
|
-
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, problems);
|
|
1745
|
+
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, mesh.influences, problems);
|
|
1688
1746
|
if (row === null) continue;
|
|
1689
1747
|
skin[p.name] = { [p.name]: row.attachment };
|
|
1690
1748
|
slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
|
|
1691
1749
|
meshReport.push(row.report);
|
|
1692
1750
|
continue;
|
|
1693
1751
|
}
|
|
1694
|
-
const
|
|
1752
|
+
const allArt = alphaAbove(img, ART_ALPHA);
|
|
1753
|
+
// issue #303: under a declared `stray`, the islands it leaves out are cleared from the art the lattice and its heat
|
|
1754
|
+
// silhouette are built over; absent, `art` is `allArt` itself and nothing downstream moves.
|
|
1755
|
+
const stray = mesh.stray === undefined ? null : latticeStray(allArt, mesh.stray);
|
|
1756
|
+
const art = stray === null ? allArt : stray.art;
|
|
1695
1757
|
const lm = latticeMesh(p.name, art, mesh.grid, maxLoopPasses);
|
|
1696
1758
|
if ('code' in lm) {
|
|
1697
1759
|
problems.push(lm);
|
|
@@ -1701,7 +1763,12 @@ export function buildRig(
|
|
|
1701
1763
|
const ox = p.x - PAD;
|
|
1702
1764
|
const oy = p.y - PAD;
|
|
1703
1765
|
// issue #161: under rule "heat", the field over the lattice's art (alpha above 8) with its holes filled.
|
|
1704
|
-
|
|
1766
|
+
// issue #303: an island no bone reaches is worded against the lattice's own `stray` when it is dust.
|
|
1767
|
+
const dust = (): HeatDust => {
|
|
1768
|
+
const read = stray ?? latticeStray(allArt, 0);
|
|
1769
|
+
return { field: `config.meshes.${p.name}.stray`, declared: mesh.stray ?? null, artPixels: read.artPixels, largest: read.largest };
|
|
1770
|
+
};
|
|
1771
|
+
const heat = mesh.rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, latticeSilhouette(art), segs, ox, oy, HEAT_LIMITS, dust) : null;
|
|
1705
1772
|
if (Array.isArray(heat)) {
|
|
1706
1773
|
problems.push(...heat);
|
|
1707
1774
|
continue;
|
|
@@ -1711,20 +1778,32 @@ export function buildRig(
|
|
|
1711
1778
|
const weights: WeightEntry[][] = [];
|
|
1712
1779
|
let infl = 0;
|
|
1713
1780
|
let maxInfl = 0;
|
|
1714
|
-
|
|
1781
|
+
// issue #284: the mesh's declared cap and floor form, or DEFAULT_LIMITS itself when it declares none.
|
|
1782
|
+
const limits: InfluenceLimits = meshLimits(mesh.influences);
|
|
1783
|
+
let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
|
|
1784
|
+
let emptiedMore = 0;
|
|
1785
|
+
for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
|
|
1715
1786
|
const wx = vx0 + ox;
|
|
1716
1787
|
const wy = vy0 + oy;
|
|
1717
|
-
const
|
|
1788
|
+
const shares = heat === null ? influences([wx, wy], segs, mesh.r, limits, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], limits);
|
|
1789
|
+
if (shares.length === 0) {
|
|
1790
|
+
// issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be).
|
|
1791
|
+
if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, mesh.r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
|
|
1792
|
+
else emptiedMore++;
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
const ent: WeightEntry[] = (limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares)).map(({ bone, weight }) => {
|
|
1718
1796
|
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1719
|
-
return { bone, x: places(x), y: places(y), weight
|
|
1797
|
+
return { bone, x: places(x), y: places(y), weight };
|
|
1720
1798
|
});
|
|
1721
|
-
let others = 0;
|
|
1722
|
-
for (let k = 0; k < ent.length - 1; k++) others += ent[k].weight;
|
|
1723
|
-
ent[ent.length - 1].weight = pyRound(1 - others, 5);
|
|
1724
1799
|
weights.push(ent);
|
|
1725
1800
|
infl += ent.length;
|
|
1726
1801
|
maxInfl = Math.max(maxInfl, ent.length);
|
|
1727
1802
|
}
|
|
1803
|
+
if (emptied !== null) {
|
|
1804
|
+
problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1728
1807
|
const uvs: number[] = [];
|
|
1729
1808
|
for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
1730
1809
|
skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
|
|
@@ -1737,9 +1816,11 @@ export function buildRig(
|
|
|
1737
1816
|
bones: [...new Set(segs.map((s) => s.bone))].sort(),
|
|
1738
1817
|
max_influences: maxInfl,
|
|
1739
1818
|
mean_influences: pyRound(infl / lm.vertices.length, 2),
|
|
1740
|
-
art_coverage: pyRound(artCoverage(lm,
|
|
1819
|
+
art_coverage: pyRound(artCoverage(lm, allArt), 5),
|
|
1741
1820
|
grid: mesh.grid,
|
|
1742
1821
|
...(mesh.exponent === undefined ? {} : { exponent: mesh.exponent }),
|
|
1822
|
+
...(mesh.influences === undefined ? {} : { influences: mesh.influences }),
|
|
1823
|
+
...(stray === null ? {} : { stray: mesh.stray as number, stray_cleared: { islands: stray.islands, pixels: stray.pixels, art_pixels: stray.artPixels } }),
|
|
1743
1824
|
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1744
1825
|
});
|
|
1745
1826
|
}
|