@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/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, or take the bone out of segments`,
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; 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`,
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 `rule` as the lattice entry states them.
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
- /** Dust: an extra island of at most this many art px (Stage B: 1–2 px dots were the measured case) … */
94
- export const DUST_MAX_PX = 4;
95
-
96
- /** … and at most this share of the part's art px. */
97
- export const DUST_MAX_SHARE = 0.001;
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 <= DUST_MAX_PX && n <= DUST_MAX_SHARE * art));
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 { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type Segment } from './weights.ts';
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 (each to 5 places, the last `1 − sum(others)`),
313
- * one a region reaches by {@link roundShares}.
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
- const shares = li.influences.map(({ bone, weight }) => ({ bone, weight: pyRound(weight, 5) }));
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, DEFAULT_LIMITS, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], DEFAULT_LIMITS));
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 art = alphaAbove(img, ART_ALPHA);
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
- const heat = mesh.rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, latticeSilhouette(art), segs, ox, oy) : null;
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
- for (const [vx0, vy0] of lm.vertices) {
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 ent: WeightEntry[] = (heat === null ? influences([wx, wy], segs, mesh.r, undefined, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], DEFAULT_LIMITS)).map(({ bone, weight }) => {
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: pyRound(weight, 5) };
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, art), 5),
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
  }