rig-parts 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/docs/AUTHORING.md +1 -1
- package/docs/STABILITY.md +2 -2
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/automesh.ts +8 -3
- package/src/config.ts +31 -1
- package/src/heat.ts +313 -0
- package/src/localweights.ts +17 -5
- package/src/rig.ts +50 -7
- package/src/weights.ts +33 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# rig-parts
|
|
2
2
|
|
|
3
|
-
**AI-authored
|
|
3
|
+
**AI-authored 2D character rigs from one anime painting, verified before they
|
|
4
4
|
are written.** rig-parts takes a single character painting and the layers
|
|
5
5
|
[See-through](https://github.com/shitagaki-lab/see-through) decomposed it into,
|
|
6
6
|
merges them into measured rig-space parts, authors weighted meshes, bone chains
|
|
@@ -15,7 +15,7 @@ input is silent.
|
|
|
15
15
|
|
|
16
16
|
<p align="center">
|
|
17
17
|
<img src="https://raw.githubusercontent.com/firejune/rig-parts/main/assets/demo-source.png" alt="The demo painting: a generated full-body character in a white and pink frilled dress with long pink twin tails, standing with her hands clasped" height="420" />
|
|
18
|
-
<img src="https://raw.githubusercontent.com/firejune/rig-parts/main/assets/demo-idle.png" alt="The same character
|
|
18
|
+
<img src="https://raw.githubusercontent.com/firejune/rig-parts/main/assets/demo-idle.png" alt="The same character rigged, breathing, blinking once and swaying her hair, sleeves and skirt in a four-second loop" height="420" />
|
|
19
19
|
</p>
|
|
20
20
|
|
|
21
21
|
<p align="center">
|
|
@@ -150,7 +150,7 @@ atlas written by anything else would have no oracle behind it.
|
|
|
150
150
|
| --- | --- | --- |
|
|
151
151
|
| painting + See-through layers → parts and specs | **rig-parts** | the merge of two runs, the measurements, the rig spec and motion spec |
|
|
152
152
|
| specs → Spine skeleton data | **[rig-c](https://github.com/firejune/rigc)** | compile, the gate (rigc's own validator in an install; the round trip through `spine-core` where the runtime is beside it, as in this repository's CI), the named assertions, the packer, the renderer |
|
|
153
|
-
| skeleton data → a page | **[
|
|
153
|
+
| skeleton data → a page | **[rig-play](https://github.com/firejune/rig-play)** (formerly spine-html), a sibling project | a DOM renderer; rigc's `spine-html` profile is its policy, and every build here is gated under that profile as well as under `spine` |
|
|
154
154
|
|
|
155
155
|
rig-parts never writes Spine data itself. Everything on disk under `check/build/`
|
|
156
156
|
was written by rig-c after its own gate passed.
|
package/docs/AUTHORING.md
CHANGED
|
@@ -83,7 +83,7 @@ read by no CPU stage).
|
|
|
83
83
|
| `assemble.extend_below_crop` | assemble | **proposed** with the plan: `{part, run, tag}` — a head-run part that reaches the bottom of the head crop is continued from a full-run layer, whole connected components |
|
|
84
84
|
| `assemble.patches` | assemble; `meshes`/`regions` keys may name them too | **authored**, optional: `[{name, box, alpha, draw}]` — an extra part cut from the **painting** itself, for figure no See-through layer holds (a hem both runs dropped). `box` is `[x0, y0, x1, y1]` in **rig** pixels, `x1`/`y1` exclusive — the space of `parts.json` and `recomposite_rig.png`, where the hole is found; `alpha` is `"silhouette"` (the painting's figure silhouette inside the box, the one `--seam silhouette` uses) or `"box"` (the whole box); `draw` is `"back"`, `"front"` or `{"before": "<plan part>"}`. A patch is always a region: its bone is `regions.<name>`, in the one place every region's bone lives, and a `meshes` entry for it is refused. See §5 for how to place one |
|
|
85
85
|
| `bones` | rig; `propose --from-config` | **proposed** by `propose`, then corrected. A single bone `{name, parent, at, tip?}` or a chain `{chain, parent, points, tip}` whose links are named `<chain>0 … <chain>n`. Parents come before children. In `rig.json` each link is **turned along its chain** — `rotation` is the direction from its origin to the next link's (the last link's to `tip`) and `length` that distance (issue #73), so a physics constraint added downstream finds a lever; under `--idle-keys ctl` the link's `<link>_ctl` carries the turn and the length and the link sits at local rotation 0 beneath it. A single bone is never turned; one whose parent is a link is turned back upright |
|
|
86
|
-
| `meshes.<part>` | rig | **proposed**, then corrected. Exactly one of `grid` (the lattice: cell size, px — what `propose` writes), `contour` (the outline mode, authored; the next row and "A contour mesh" below) or `auto` (the automatic mode, authored; "An automatic mesh" below) — two, or none, is `CONFIG_MESH_MODE`. In every mode: `r` (added to every distance before weighting: `w = 1/(d + r)²`), `segments` (a chain name, a bone name with a `tip`, or `[bone, [x0,y0], [x1,y1]]`). The segment list is the one authored decision about a layer: which bones may pull it. The slot's bone is the first segment's |
|
|
86
|
+
| `meshes.<part>` | rig | **proposed**, then corrected. Exactly one of `grid` (the lattice: cell size, px — what `propose` writes), `contour` (the outline mode, authored; the next row and "A contour mesh" below) or `auto` (the automatic mode, authored; "An automatic mesh" below) — two, or none, is `CONFIG_MESH_MODE`. In every mode: `r` (added to every distance before weighting: `w = 1/(d + r)²`), `segments` (a chain name, a bone name with a `tip`, or `[bone, [x0,y0], [x1,y1]]`), and, **authored**, optional, `exponent` (issue #161): a number above 0 that replaces the 2 in that rule, `w = 1/(d + r)^exponent` — a larger one hands each vertex more fully to its nearest bone; absent is 2 and changes no byte, a value that is not a number above 0 is `CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`, and a declared one is echoed as `exponent` in the mesh's `mesh_report.json` row; the exponent is the distance rule's, so declared beside `rule: "heat"` it is refused (`CONFIG_FIELD_TYPE` at `meshes.<part>.exponent`). The segment list is the one authored decision about a layer: which bones may pull it. The slot's bone is the first segment's. Optional `rule` (issue #161): `"distance"` — the rule above, and what an absent `rule` means (every byte is the same) — or `"heat"`, bone heat over the part's own silhouette, in every mode: the lattice's art (alpha above 8) with holes filled, or for `contour` and `auto` the silhouette their outline is traced from (that mode's art — alpha above 8 for `contour`, alpha 1 and above for `auto` — less any island a declared `stray` left out, holes filled, grown by its `margin`); each bound bone's sources are the silhouette pixels whose centre lies within 0.75 px of its segment (a pixel two bones reach goes to the nearer, a tie to the first listed), held at 1 for that bone and 0 for the others; the field is solved by row-major Gauss–Seidel over-relaxation to a sweep change under 1e-7 within 20000 sweeps; a vertex reads the nearest silhouette pixel and takes the same cap and floor (and `regions`) as the distance rule; `r` is not read. The mesh's `mesh_report.json` row gains `heat` (the constants, each bone's sources and sweeps, the residual). Any other value is `CONFIG_FIELD_TYPE`; a bound bone with no source pixel is `RIG_HEAT_BONE_SOURCE`, an island no bone has a source in `RIG_HEAT_ISLAND_SOURCE`, a solve past its sweep bound `RIG_HEAT_CONVERGED` — none is weighted 0 |
|
|
87
87
|
| `meshes.<part>.contour` | rig | **authored**, optional (issue #84): `{tolerance, margin, spacing, budget?, stray?, regions?}`. `tolerance` — rig-c's Douglas–Peucker tolerance on the traced outline, px, 0 or more; `margin` — how far the silhouette grows before it is traced, px: 0, or 1 or more (a pixel joins when its centre lies within `margin` of an art pixel's centre; a value above 0 and below 1 adds nothing and is refused, `CONFIG_FIELD_TYPE`); `spacing` — the background interior spacing, px, above 0; `budget` — the most vertices the mesh may have (refused above it, nothing thinned); `stray` — the largest island, in art pixels, that may be left out of the mesh (absent: none is); `regions` — local deformation regions, each `{name, shape, bone, spacing, band}` with `shape` `"circle"` (`cx, cy, r`) or `"polygon"` (`points`, 3 or more): `bone` is the region's control bone (any bone `bones` declares), `spacing` the interior spacing inside the region and its band, `band` the width the bone's weight falls across. Every length and position is **rig px**, like every other in the config; the part image is the rig less `(x − 4, y − 4)` (its box less the pad), a translation, so a length is the same number in both. A region's `cx`, `cy`, `r`, `band` and `points` are multiples of 1/256 px (`CONFIG_FIELD_TYPE` otherwise); region names are unique per mesh (`CONFIG_REGION_NAME_UNIQUE`). The alpha threshold is not a field: art is alpha above 8, the lattice's and `check`'s |
|
|
88
88
|
| `regions.<part>` | rig | **proposed**: the bone a rigid part rides. Every plan part is exactly one of a mesh or a region (`CONFIG_PART_ATTACHED`) |
|
|
89
89
|
| `motion.duration` | rig; check (the loop is measured at this time) | proposed as 4 s; a whole number of 1/12 s ticks, because `check` renders at 12 fps |
|
package/docs/STABILITY.md
CHANGED
|
@@ -111,7 +111,7 @@ part name, `[i]` an entry of a list):
|
|
|
111
111
|
- **assemble.patches[i].draw**, when an object — required: `before`
|
|
112
112
|
- **bones[i]**, a single bone — required: `name`, `parent`, `at`; optional: `tip`
|
|
113
113
|
- **bones[i]**, a chain — required: `chain`, `parent`, `points`, `tip`
|
|
114
|
-
- **meshes.<part>** — exactly one of `grid`, `contour`, `auto`; required in every mode: `r`, `segments`
|
|
114
|
+
- **meshes.<part>** — exactly one of `grid`, `contour`, `auto`; required in every mode: `r`, `segments`; optional in every mode: `exponent`, `rule`
|
|
115
115
|
- **meshes.<part>.contour** — required: `tolerance`, `margin`, `spacing`; optional: `budget`, `stray`, `regions`
|
|
116
116
|
- **meshes.<part>.contour.regions[i]** — required: `name`, `shape`, `bone`, `spacing`, `band`, and `cx`, `cy`, `r` for a circle or `points` for a polygon
|
|
117
117
|
- **meshes.<part>.auto** — required: `source`, `sourceBounds`, `targets`, `influences`, `budget`, `minArtSamples`, `motion`; optional: `protect`, `regions`, and the three Stage B opt-ins `boundaryRuns`, `retriangulate`, `removalOrder`
|
|
@@ -151,7 +151,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
|
|
|
151
151
|
| --- | --- | --- |
|
|
152
152
|
| `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from`, the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite` |
|
|
153
153
|
| `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts |
|
|
154
|
-
| `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions`; or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`) |
|
|
154
|
+
| `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions`; or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`) |
|
|
155
155
|
| `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES`, `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
|
|
156
156
|
| `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections and `notes` |
|
|
157
157
|
| `basis.json` | `propose` | spec `spine-parts-basis/1` |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "AI-authored
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"rig-parts": "./bin/rig-parts.cjs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: rig-parts
|
|
3
|
-
description: Turn one anime character painting and its See-through layer decomposition into a verified Spine 4.3
|
|
3
|
+
description: Turn one anime character painting and its See-through layer decomposition into a verified 2D character rig (Spine 4.3 skeleton data) — measured parts, weighted meshes, bone chains and a looping idle — with rig-parts, which gates everything through rig-c before it is written. Use for a request to rig or animate a single character painting, to run or read rig-parts layers, sheet, assemble, propose, rig, check, loop or build, or to write or fix a rig-parts config.json. Not for cutting art into parts by hand, for authoring a rig from loose PNGs (that is the rigc skill), for expressions or lip-sync, or for running See-through itself.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires Bun 1.2 or later. The tool is the npm package rig-parts (bunx rig-parts, or bun add -d rig-parts); rig-c is installed with it. See-through is run separately.
|
|
6
6
|
---
|
package/src/automesh.ts
CHANGED
|
@@ -85,11 +85,12 @@ import {
|
|
|
85
85
|
} from 'rig-c/mesh';
|
|
86
86
|
import { type AutoRegionSpec, type AutoSpec, type AutoWeightRegionSpec, type Point, weightsABone } from './config.ts';
|
|
87
87
|
import { type ContourMesh, contourMesh, type ContourReport, GRID } from './contour.ts';
|
|
88
|
+
import { type HeatField, heatInfluences } from './heat.ts';
|
|
88
89
|
import type { Problem } from './errors.ts';
|
|
89
90
|
import { type LocalInfluence, localInfluences, type RegionOverlap } from './localweights.ts';
|
|
90
91
|
import { ART_ALPHA } from './mesh.ts';
|
|
91
92
|
import { pyRound } from './round.ts';
|
|
92
|
-
import type
|
|
93
|
+
import { DISTANCE_EXPONENT, type Influence, type Segment } from './weights.ts';
|
|
93
94
|
|
|
94
95
|
/** The final threshold of the automatic mode: art is alpha 1 and above (P4). `contourMesh` reads "above", so it is handed one less. */
|
|
95
96
|
export const AUTO_THRESHOLD = 1;
|
|
@@ -185,7 +186,9 @@ export function autoSource(part: string, mask: AlphaMask, spec: AutoSpec): Conto
|
|
|
185
186
|
* that weight a bone are read (issue #155): a density-only region gives no vertex a `g`, so a source vertex
|
|
186
187
|
* inside it is weighted as one no region reaches, it can never be one of an overlap's two, and the weights are
|
|
187
188
|
* the same numbers the part has with the region left out. Region indices in the result (`local[].region`, the
|
|
188
|
-
* overlap's `first` and `second`) are indices into `spec.regions`, every form counted.
|
|
189
|
+
* overlap's `first` and `second`) are indices into `spec.regions`, every form counted. `exponent` is the mesh's
|
|
190
|
+
* distance-rule exponent (issue #161), which the rig stage always passes. With `heat` (issue #161,
|
|
191
|
+
* `rule: "heat"`), each vertex's segment shares are bone heat's at its pixel, under the author's influence limits.
|
|
189
192
|
*/
|
|
190
193
|
export function sourceWeights(
|
|
191
194
|
vertices: ReadonlyArray<readonly [number, number]>,
|
|
@@ -194,6 +197,8 @@ export function sourceWeights(
|
|
|
194
197
|
segs: readonly Segment[],
|
|
195
198
|
r: number,
|
|
196
199
|
spec: AutoSpec,
|
|
200
|
+
exponent: number = DISTANCE_EXPONENT,
|
|
201
|
+
heat: HeatField | null = null,
|
|
197
202
|
): { weights: Influence[][]; local: LocalInfluence[] } | { overlap: RegionOverlap; vertex: number; at: Point } {
|
|
198
203
|
const all = spec.regions ?? [];
|
|
199
204
|
const index: number[] = [];
|
|
@@ -207,7 +212,7 @@ export function sourceWeights(
|
|
|
207
212
|
const local: LocalInfluence[] = [];
|
|
208
213
|
for (let v = 0; v < vertices.length; v++) {
|
|
209
214
|
const at: Point = [vertices[v][0] + ox, vertices[v][1] + oy];
|
|
210
|
-
const li = localInfluences(at, segs, r, regions, spec.influences);
|
|
215
|
+
const li = localInfluences(at, segs, r, regions, spec.influences, exponent, heat === null ? undefined : heatInfluences(heat, [vertices[v][0], vertices[v][1]], spec.influences));
|
|
211
216
|
if ('first' in li) return { overlap: { ...li, first: index[li.first], second: index[li.second] }, vertex: v, at };
|
|
212
217
|
weights.push(li.influences.map((e) => ({ bone: e.bone, weight: e.weight })));
|
|
213
218
|
local.push(li.region < 0 ? li : { ...li, region: index[li.region] });
|
package/src/config.ts
CHANGED
|
@@ -185,16 +185,33 @@ export type Segment = string | [string, Point, Point];
|
|
|
185
185
|
*/
|
|
186
186
|
export type MeshSpec = LatticeMeshSpec | ContourMeshSpec | AutoMeshSpec;
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* `meshes.<part>.rule` (issue #161): how a mesh's vertices are weighted. `"distance"` is `influences()`
|
|
190
|
+
* (`src/weights.ts`), and what an absent field means; `"heat"` is bone heat over the part's silhouette
|
|
191
|
+
* (`src/heat.ts`), in every mode. Nothing else is a rule.
|
|
192
|
+
*/
|
|
193
|
+
export type WeightRule = 'distance' | 'heat';
|
|
194
|
+
|
|
195
|
+
export const WEIGHT_RULES: readonly WeightRule[] = ['distance', 'heat'];
|
|
196
|
+
|
|
188
197
|
export interface LatticeMeshSpec {
|
|
189
198
|
grid: number;
|
|
190
199
|
r: number;
|
|
191
200
|
segments: Segment[];
|
|
201
|
+
/** The distance rule's exponent, `w = 1/(d + r)^exponent`, above 0 (issue #161); absent is 2, the rule as it always was. */
|
|
202
|
+
exponent?: number;
|
|
203
|
+
/** Absent: `"distance"`. */
|
|
204
|
+
rule?: WeightRule;
|
|
192
205
|
}
|
|
193
206
|
|
|
194
207
|
export interface ContourMeshSpec {
|
|
195
208
|
contour: ContourSpec;
|
|
196
209
|
r: number;
|
|
197
210
|
segments: Segment[];
|
|
211
|
+
/** The distance rule's exponent, `w = 1/(d + r)^exponent`, above 0 (issue #161); absent is 2, the rule as it always was. */
|
|
212
|
+
exponent?: number;
|
|
213
|
+
/** Absent: `"distance"`. */
|
|
214
|
+
rule?: WeightRule;
|
|
198
215
|
}
|
|
199
216
|
|
|
200
217
|
/**
|
|
@@ -246,6 +263,10 @@ export interface AutoMeshSpec {
|
|
|
246
263
|
auto: AutoSpec;
|
|
247
264
|
r: number;
|
|
248
265
|
segments: Segment[];
|
|
266
|
+
/** The distance rule's exponent, `w = 1/(d + r)^exponent`, above 0 (issue #161); absent is 2, the rule as it always was. */
|
|
267
|
+
exponent?: number;
|
|
268
|
+
/** Absent: `"distance"`. */
|
|
269
|
+
rule?: WeightRule;
|
|
249
270
|
}
|
|
250
271
|
|
|
251
272
|
export interface AutoSpec {
|
|
@@ -1151,7 +1172,7 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
|
|
|
1151
1172
|
const at = `config.meshes.${part}`;
|
|
1152
1173
|
// The known keys keep their old order (grid, r, segments) with contour after them, and `r` and `segments` are
|
|
1153
1174
|
// required in the place and words the object check uses, so a lattice mesh's refusals read as they did.
|
|
1154
|
-
const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto']);
|
|
1175
|
+
const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule']);
|
|
1155
1176
|
if (m === null) continue;
|
|
1156
1177
|
for (const key of ['r', 'segments']) if (!(key in m)) c.fail('CONFIG_FIELD_PRESENT', `${at}.${key}`, 'is absent and required');
|
|
1157
1178
|
const modes = ['grid', 'contour', 'auto'].filter((k) => k in m);
|
|
@@ -1166,6 +1187,15 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
|
|
|
1166
1187
|
if ('contour' in m) checkContour(c, `${at}.contour`, m.contour, bones);
|
|
1167
1188
|
if ('auto' in m) checkAuto(c, `${at}.auto`, m.auto, bones);
|
|
1168
1189
|
if ('r' in m) c.number(`${at}.r`, m.r, 'non-negative');
|
|
1190
|
+
// issue #161: the distance rule's exponent, in every mode; absent is 2 (src/weights.ts), nothing is filled in here.
|
|
1191
|
+
if ('exponent' in m) c.number(`${at}.exponent`, m.exponent, 'positive');
|
|
1192
|
+
if ('rule' in m && !WEIGHT_RULES.includes(m.rule as WeightRule)) {
|
|
1193
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.rule`, `is ${show(m.rule)}; "distance" (the distance rule, what an absent rule means) or "heat" (bone heat over the part's silhouette) is required`);
|
|
1194
|
+
}
|
|
1195
|
+
// issue #161: the exponent is the distance rule's; bone heat has none, so declaring both is refused rather than ignored.
|
|
1196
|
+
if ('exponent' in m && m.rule === 'heat') {
|
|
1197
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.exponent`, `is ${show(m.exponent)} on a mesh that declares rule "heat"; the exponent belongs to the distance rule (w = 1/(d + r)^exponent) and bone heat reads none — remove exponent, or declare rule "distance"`);
|
|
1198
|
+
}
|
|
1169
1199
|
if ('segments' in m && c.array(`${at}.segments`, m.segments, true)) {
|
|
1170
1200
|
(m.segments as Json[]).forEach((s, i) => {
|
|
1171
1201
|
const sp = `${at}.segments[${i}]`;
|
package/src/heat.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bone heat: a mesh's weights as the heat equilibrium over the part's own
|
|
3
|
+
* silhouette (issue #161, the reduced shape). An author opts a mesh in with
|
|
4
|
+
* `meshes.<part>.rule: "heat"`; absent, or `"distance"`, the mesh is weighted
|
|
5
|
+
* by `influences()` (`src/weights.ts`) and nothing here runs.
|
|
6
|
+
*
|
|
7
|
+
* ## The rule, as a definition
|
|
8
|
+
*
|
|
9
|
+
* 1. **The domain** is a set of the part image's pixels, the silhouette the
|
|
10
|
+
* mesh's mode reads (the rig stage hands it in): the lattice's art — alpha
|
|
11
|
+
* above 8 — with its holes filled ({@link latticeSilhouette}); for the
|
|
12
|
+
* contour and the automatic modes, the silhouette the outline was traced
|
|
13
|
+
* from, which is the kept art with its holes filled and grown by the
|
|
14
|
+
* declared margin ({@link tracedSilhouette}, the same calls
|
|
15
|
+
* `contourOutline` makes). Neighbours are 4-connected; a neighbour off the
|
|
16
|
+
* silhouette is no neighbour, so no heat crosses the boundary.
|
|
17
|
+
* 2. **Sources.** A silhouette pixel whose centre lies within
|
|
18
|
+
* {@link HEAT_SOURCE_BAND} px of one of a bound bone's segments is that
|
|
19
|
+
* bone's source; a pixel two bones reach goes to the bone whose segment is
|
|
20
|
+
* nearer its centre, a tie to the bone listed first among the segments.
|
|
21
|
+
* The band is above √2 / 2, so every pixel a segment passes through is
|
|
22
|
+
* reached. Per bone, the field is 1 on its own sources, 0 on every other
|
|
23
|
+
* bone's, and the discrete Laplace equation (each free pixel the mean of
|
|
24
|
+
* its neighbours) everywhere else.
|
|
25
|
+
* 3. **The solve** is successive over-relaxation — Gauss–Seidel, sweeping the
|
|
26
|
+
* free pixels in row-major order, each update over-relaxed by
|
|
27
|
+
* `omega = 2 / (1 + π / L)`, L the image's longer side (the small-angle
|
|
28
|
+
* form of the optimum `2 / (1 + sin(π / L))`: `sin`'s last bit is the
|
|
29
|
+
* engine's, a division is IEEE's) — from 0, until the largest change in
|
|
30
|
+
* one sweep is under {@link HEAT_TOLERANCE}. A bone that has not got there
|
|
31
|
+
* in {@link HEAT_MAX_ITERATIONS} sweeps is refused (`RIG_HEAT_CONVERGED`),
|
|
32
|
+
* never written half-solved. Float64 throughout; distances are
|
|
33
|
+
* `segmentDistance`'s, not `Math.hypot`.
|
|
34
|
+
* 4. **A vertex** reads every bone's field at the silhouette pixel whose
|
|
35
|
+
* centre is nearest it (a tie to the first in raster order) — a vertex on a
|
|
36
|
+
* pixel corner, as every lattice vertex is, touches four — keeps the bones
|
|
37
|
+
* whose value there is above 0, and takes the cap and the floor the distance
|
|
38
|
+
* rule takes (`capAndFloor`, the mesh's `InfluenceLimits`).
|
|
39
|
+
*
|
|
40
|
+
* ## What is refused rather than weighted 0
|
|
41
|
+
*
|
|
42
|
+
* - `RIG_HEAT_BONE_SOURCE`: a bound bone that holds no source pixel — a
|
|
43
|
+
* zero-length control bone off the art, a segment wholly off the
|
|
44
|
+
* silhouette, or one whose every reached pixel is nearer another bone. A
|
|
45
|
+
* bone named in `segments` is a bone the author said may pull the layer;
|
|
46
|
+
* giving it 0 everywhere would be a silent answer to a question the
|
|
47
|
+
* config asked.
|
|
48
|
+
* - `RIG_HEAT_ISLAND_SOURCE`: a 4-connected island of the silhouette that no
|
|
49
|
+
* bone has a source in. With no fixed temperature the equation has no
|
|
50
|
+
* answer there; nothing nearest is borrowed.
|
|
51
|
+
* - `RIG_HEAT_CONVERGED`: above.
|
|
52
|
+
*
|
|
53
|
+
* Two runs on the same input write the same bytes: the claims, the sweep
|
|
54
|
+
* order and the arithmetic are fixed, and nothing reads a clock.
|
|
55
|
+
*/
|
|
56
|
+
import type { Point } from './config.ts';
|
|
57
|
+
import { artMask, growSilhouette } from './contour.ts';
|
|
58
|
+
import type { Problem } from './errors.ts';
|
|
59
|
+
import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
|
|
60
|
+
import { capAndFloor, type Influence, type InfluenceLimits, type Segment, segmentDistance } from './weights.ts';
|
|
61
|
+
|
|
62
|
+
/** The solve stops when the largest change in one sweep is under this — Stage A's figure (`tools/weight_rule_survey.ts`). */
|
|
63
|
+
export const HEAT_TOLERANCE = 1e-7;
|
|
64
|
+
|
|
65
|
+
/** The most sweeps one bone's field may take; past it the mesh is refused (`RIG_HEAT_CONVERGED`). Stage A's figure. */
|
|
66
|
+
export const HEAT_MAX_ITERATIONS = 20000;
|
|
67
|
+
|
|
68
|
+
/** A pixel is a bone's source when its centre lies within this of the bone's segment, px: above √2 / 2, so every pixel a segment crosses is reached. Stage A's figure. */
|
|
69
|
+
export const HEAT_SOURCE_BAND = 0.75;
|
|
70
|
+
|
|
71
|
+
/** The sweep, in the words the report row echoes. */
|
|
72
|
+
export const HEAT_SWEEP = 'gauss-seidel, row-major, over-relaxed';
|
|
73
|
+
|
|
74
|
+
export interface HeatLimits {
|
|
75
|
+
tolerance: number;
|
|
76
|
+
maxIterations: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const HEAT_LIMITS: HeatLimits = { tolerance: HEAT_TOLERANCE, maxIterations: HEAT_MAX_ITERATIONS };
|
|
80
|
+
|
|
81
|
+
/** The lattice's silhouette: its art (alpha above 8, the mask `latticeMesh` reads) with holes filled, as the lattice fills them. */
|
|
82
|
+
export function latticeSilhouette(art: Mask): Mask {
|
|
83
|
+
return fillHoles(art);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The contour and automatic modes' silhouette: the traced mask's art at `threshold`, holes filled, grown by `margin` — the silhouette `contourOutline` traces. */
|
|
87
|
+
export function tracedSilhouette(mask: { width: number; height: number; alpha: Uint8Array }, threshold: number, margin: number): Mask {
|
|
88
|
+
return growSilhouette(fillHoles(artMask(mask, threshold)), margin).mask;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One mesh's solved fields. */
|
|
92
|
+
export interface HeatField {
|
|
93
|
+
silhouette: Mask;
|
|
94
|
+
/** Bound bones, in first-appearance order among the segments. */
|
|
95
|
+
bones: string[];
|
|
96
|
+
/** One field per bone, row-major over the image; 0 off the silhouette. */
|
|
97
|
+
fields: Float64Array[];
|
|
98
|
+
/** Per bone: source pixels held, and sweeps taken. */
|
|
99
|
+
sources: number[];
|
|
100
|
+
iterations: number[];
|
|
101
|
+
omega: number;
|
|
102
|
+
/** The largest |value − mean of its neighbours| over the free pixels, over every bone, after the solve. */
|
|
103
|
+
residual: number;
|
|
104
|
+
islands: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The row a heat mesh adds to its `mesh_report.json` entry, so the declaration and the solve it ran are read back. */
|
|
108
|
+
export interface HeatRow {
|
|
109
|
+
rule: 'heat';
|
|
110
|
+
tolerance: number;
|
|
111
|
+
max_iterations: number;
|
|
112
|
+
source_band: number;
|
|
113
|
+
sweep: string;
|
|
114
|
+
omega: number;
|
|
115
|
+
silhouette_pixels: number;
|
|
116
|
+
islands: number;
|
|
117
|
+
bones: Array<{ bone: string; sources: number; iterations: number }>;
|
|
118
|
+
residual: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fmt = (v: number): string => String(Math.round(v * 1000) / 1000);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 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.
|
|
126
|
+
*/
|
|
127
|
+
export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox: number, oy: number, limits: HeatLimits = HEAT_LIMITS): HeatField | Problem[] {
|
|
128
|
+
const { width: w, height: h, data } = sil;
|
|
129
|
+
const n = w * h;
|
|
130
|
+
const bones: string[] = [];
|
|
131
|
+
for (const s of segs) if (!bones.includes(s.bone)) bones.push(s.bone);
|
|
132
|
+
const K = bones.length;
|
|
133
|
+
const local = segs.map((s) => ({ k: bones.indexOf(s.bone), a: [s.a[0] - ox, s.a[1] - oy] as Point, b: [s.b[0] - ox, s.b[1] - oy] as Point }));
|
|
134
|
+
const owner = new Int32Array(n).fill(-1);
|
|
135
|
+
const ownD = new Float64Array(n).fill(Infinity);
|
|
136
|
+
const reached = new Array<number>(K).fill(0);
|
|
137
|
+
let silPixels = 0;
|
|
138
|
+
for (let i = 0; i < n; i++) {
|
|
139
|
+
if (data[i] !== 1) continue;
|
|
140
|
+
silPixels++;
|
|
141
|
+
const p: Point = [(i % w) + 0.5, Math.floor(i / w) + 0.5];
|
|
142
|
+
const near = new Float64Array(K).fill(Infinity);
|
|
143
|
+
for (const s of local) {
|
|
144
|
+
const d = segmentDistance(p, s.a, s.b);
|
|
145
|
+
if (d < near[s.k]) near[s.k] = d;
|
|
146
|
+
}
|
|
147
|
+
for (let k = 0; k < K; k++) {
|
|
148
|
+
if (near[k] > HEAT_SOURCE_BAND) continue;
|
|
149
|
+
reached[k]++;
|
|
150
|
+
if (near[k] < ownD[i]) {
|
|
151
|
+
owner[i] = k;
|
|
152
|
+
ownD[i] = near[k];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const sources = new Array<number>(K).fill(0);
|
|
157
|
+
for (let i = 0; i < n; i++) if (owner[i] >= 0) sources[owner[i]]++;
|
|
158
|
+
const problems: Problem[] = [];
|
|
159
|
+
bones.forEach((bone, k) => {
|
|
160
|
+
if (sources[k] > 0) return;
|
|
161
|
+
const where = local
|
|
162
|
+
.filter((s) => s.k === k)
|
|
163
|
+
.map((s) => `(${fmt(s.a[0] + ox)}, ${fmt(s.a[1] + oy)})->(${fmt(s.b[0] + ox)}, ${fmt(s.b[1] + oy)})`)
|
|
164
|
+
.join(', ');
|
|
165
|
+
const why = reached[k] === 0 ? 'reaches no pixel of the silhouette' : `reaches ${reached[k]} silhouette pixel(s), every one nearer another bound bone's segment`;
|
|
166
|
+
problems.push({
|
|
167
|
+
code: 'RIG_HEAT_BONE_SOURCE',
|
|
168
|
+
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`,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
const cc = connectedComponents(sil, 4);
|
|
173
|
+
const sourced = new Uint8Array(cc.count);
|
|
174
|
+
for (let i = 0; i < n; i++) if (owner[i] >= 0) sourced[cc.labels[i]] = 1;
|
|
175
|
+
for (let c = 1; c < cc.count; c++) {
|
|
176
|
+
if (sourced[c]) continue;
|
|
177
|
+
const st = cc.stats[c];
|
|
178
|
+
problems.push({
|
|
179
|
+
code: 'RIG_HEAT_ISLAND_SOURCE',
|
|
180
|
+
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`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (problems.length > 0) return problems;
|
|
185
|
+
|
|
186
|
+
// The free pixels in raster order, each with its 4-neighbours on the silhouette (left, right, up, down: the order the sum is taken in).
|
|
187
|
+
const free: number[] = [];
|
|
188
|
+
const start: number[] = [0];
|
|
189
|
+
const nbr: number[] = [];
|
|
190
|
+
for (let i = 0; i < n; i++) {
|
|
191
|
+
if (data[i] !== 1 || owner[i] >= 0) continue;
|
|
192
|
+
const x = i % w;
|
|
193
|
+
const y = Math.floor(i / w);
|
|
194
|
+
if (x > 0 && data[i - 1] === 1) nbr.push(i - 1);
|
|
195
|
+
if (x < w - 1 && data[i + 1] === 1) nbr.push(i + 1);
|
|
196
|
+
if (y > 0 && data[i - w] === 1) nbr.push(i - w);
|
|
197
|
+
if (y < h - 1 && data[i + w] === 1) nbr.push(i + w);
|
|
198
|
+
free.push(i);
|
|
199
|
+
start.push(nbr.length);
|
|
200
|
+
}
|
|
201
|
+
const F = Int32Array.from(free);
|
|
202
|
+
const S = Int32Array.from(start);
|
|
203
|
+
const N = Int32Array.from(nbr);
|
|
204
|
+
const omega = 2 / (1 + Math.PI / Math.max(w, h));
|
|
205
|
+
const fields: Float64Array[] = [];
|
|
206
|
+
const iterations: number[] = [];
|
|
207
|
+
let residual = 0;
|
|
208
|
+
for (let k = 0; k < K; k++) {
|
|
209
|
+
const x = new Float64Array(n);
|
|
210
|
+
for (let i = 0; i < n; i++) if (owner[i] === k) x[i] = 1;
|
|
211
|
+
let it = 0;
|
|
212
|
+
let done = F.length === 0;
|
|
213
|
+
let last = 0;
|
|
214
|
+
while (!done && it < limits.maxIterations) {
|
|
215
|
+
it++;
|
|
216
|
+
let maxD = 0;
|
|
217
|
+
for (let j = 0; j < F.length; j++) {
|
|
218
|
+
const i = F[j];
|
|
219
|
+
const a = S[j];
|
|
220
|
+
const b = S[j + 1];
|
|
221
|
+
let s = 0;
|
|
222
|
+
for (let q = a; q < b; q++) s += x[N[q]];
|
|
223
|
+
const nx = x[i] + omega * (s / (b - a) - x[i]);
|
|
224
|
+
const d = Math.abs(nx - x[i]);
|
|
225
|
+
if (d > maxD) maxD = d;
|
|
226
|
+
x[i] = nx;
|
|
227
|
+
}
|
|
228
|
+
last = maxD;
|
|
229
|
+
if (maxD < limits.tolerance) done = true;
|
|
230
|
+
}
|
|
231
|
+
if (!done) {
|
|
232
|
+
problems.push({
|
|
233
|
+
code: 'RIG_HEAT_CONVERGED',
|
|
234
|
+
object,
|
|
235
|
+
detail: `bone "${bones[k]}"'s field still changed by ${last} in its sweep ${it}; the solve stops at a change under ${limits.tolerance} within ${limits.maxIterations} sweeps (${F.length} free pixel(s), omega ${omega}). Nothing half-solved is written — a smaller part, or the distance rule, is what remains`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
for (let j = 0; j < F.length; j++) {
|
|
239
|
+
let s = 0;
|
|
240
|
+
for (let q = S[j]; q < S[j + 1]; q++) s += x[N[q]];
|
|
241
|
+
const r = Math.abs(x[F[j]] - s / (S[j + 1] - S[j]));
|
|
242
|
+
if (r > residual) residual = r;
|
|
243
|
+
}
|
|
244
|
+
fields.push(x);
|
|
245
|
+
iterations.push(it);
|
|
246
|
+
}
|
|
247
|
+
if (problems.length > 0) return problems;
|
|
248
|
+
return { silhouette: sil, bones, fields, sources, iterations, omega, residual, islands: cc.count - 1 };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The silhouette pixel whose centre is nearest `p` (part-image px), a tie to the first in raster order; -1 on an empty silhouette. */
|
|
252
|
+
export function nearestSilhouettePixel(sil: Mask, p: Point): number {
|
|
253
|
+
const { width: w, height: h, data } = sil;
|
|
254
|
+
const fx = Math.floor(p[0]);
|
|
255
|
+
const fy = Math.floor(p[1]);
|
|
256
|
+
let best = -1;
|
|
257
|
+
let bd = Infinity;
|
|
258
|
+
// A pixel in the ring at Chebyshev distance k from (fx, fy) lies at least k − 0.5 from p, so once the best is
|
|
259
|
+
// strictly nearer than that, no ring further out can hold a nearer or an equally near pixel.
|
|
260
|
+
const reach = w + h + Math.abs(fx) + Math.abs(fy);
|
|
261
|
+
for (let k = 0; k <= reach; k++) {
|
|
262
|
+
if (best >= 0 && k > 0 && bd < (k - 0.5) * (k - 0.5)) break;
|
|
263
|
+
for (let y = fy - k; y <= fy + k; y++) {
|
|
264
|
+
if (y < 0 || y >= h) continue;
|
|
265
|
+
const edgeRow = y === fy - k || y === fy + k;
|
|
266
|
+
for (let x = fx - k; x <= fx + k; x += edgeRow ? 1 : 2 * k) {
|
|
267
|
+
if (x >= 0 && x < w) {
|
|
268
|
+
const i = y * w + x;
|
|
269
|
+
if (data[i] === 1) {
|
|
270
|
+
const dx = x + 0.5 - p[0];
|
|
271
|
+
const dy = y + 0.5 - p[1];
|
|
272
|
+
const d = dx * dx + dy * dy;
|
|
273
|
+
if (d < bd || (d === bd && i < best)) {
|
|
274
|
+
bd = d;
|
|
275
|
+
best = i;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (k === 0) break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return best;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Bone heat's influences on the vertex at `p` (part-image px): the fields at its pixel above 0, capped and floored as the distance rule's are. */
|
|
287
|
+
export function heatInfluences(field: HeatField, p: Point, limits: InfluenceLimits): Influence[] {
|
|
288
|
+
const i = nearestSilhouettePixel(field.silhouette, p);
|
|
289
|
+
const raw: Array<[string, number]> = [];
|
|
290
|
+
if (i >= 0) field.bones.forEach((bone, k) => {
|
|
291
|
+
const v = field.fields[k][i];
|
|
292
|
+
if (v > 0) raw.push([bone, v]);
|
|
293
|
+
});
|
|
294
|
+
return capAndFloor(raw, limits);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The report row of a solved field. */
|
|
298
|
+
export function heatRow(field: HeatField, limits: HeatLimits = HEAT_LIMITS): HeatRow {
|
|
299
|
+
let px = 0;
|
|
300
|
+
for (const v of field.silhouette.data) if (v === 1) px++;
|
|
301
|
+
return {
|
|
302
|
+
rule: 'heat',
|
|
303
|
+
tolerance: limits.tolerance,
|
|
304
|
+
max_iterations: limits.maxIterations,
|
|
305
|
+
source_band: HEAT_SOURCE_BAND,
|
|
306
|
+
sweep: HEAT_SWEEP,
|
|
307
|
+
omega: field.omega,
|
|
308
|
+
silhouette_pixels: px,
|
|
309
|
+
islands: field.islands,
|
|
310
|
+
bones: field.bones.map((bone, k) => ({ bone, sources: field.sources[k], iterations: field.iterations[k] })),
|
|
311
|
+
residual: field.residual,
|
|
312
|
+
};
|
|
313
|
+
}
|
package/src/localweights.ts
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
*
|
|
28
28
|
* ## The vertex's weights (see {@link localInfluences})
|
|
29
29
|
*
|
|
30
|
-
* With `S = influences(p, segments, r, limits)` — today's,
|
|
30
|
+
* With `S = influences(p, segments, r, limits, exponent)` — today's, under the
|
|
31
|
+
* mesh's declared exponent (issue #161; 2 where none is declared). The
|
|
31
32
|
* cap and the floor are `limits`: `MAX_INFLUENCES` (4) and `MIN_WEIGHT`
|
|
32
33
|
* (0.03) for the contour mode, the author's `influences` for the automatic
|
|
33
34
|
* mode (issue #126, P19):
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
*/
|
|
65
66
|
import type { Point } from './config.ts';
|
|
66
67
|
import { GRID } from './contour.ts';
|
|
67
|
-
import { DEFAULT_LIMITS, type Influence, influences, type InfluenceLimits, type Segment, segmentDistance } from './weights.ts';
|
|
68
|
+
import { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type InfluenceLimits, type Segment, segmentDistance } from './weights.ts';
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
71
|
* A region as the weights read it: rig px (or any frame, as long as the point
|
|
@@ -170,12 +171,23 @@ export interface LocalInfluence {
|
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
/**
|
|
173
|
-
* A vertex's weights under the mesh's regions (module header).
|
|
174
|
+
* A vertex's weights under the mesh's regions (module header). `shares`, when
|
|
175
|
+
* given, is `S` from another rule — bone heat's shares at the vertex
|
|
176
|
+
* (`src/heat.ts`, issue #161), already capped and floored by `limits` — and
|
|
177
|
+
* the regions apply to it exactly as to the distance rule's. Returns the
|
|
174
178
|
* overlap instead when two regions both reach the vertex. Order: with no
|
|
175
179
|
* region, `S`'s own order; with a region, the region's bone first, then `S`'s
|
|
176
180
|
* bones in `S`'s order.
|
|
177
181
|
*/
|
|
178
|
-
export function localInfluences(
|
|
182
|
+
export function localInfluences(
|
|
183
|
+
p: Point,
|
|
184
|
+
segments: readonly Segment[],
|
|
185
|
+
r: number,
|
|
186
|
+
regions: readonly WeightRegion[],
|
|
187
|
+
limits: InfluenceLimits = DEFAULT_LIMITS,
|
|
188
|
+
exponent: number = DISTANCE_EXPONENT,
|
|
189
|
+
shares?: Influence[],
|
|
190
|
+
): LocalInfluence | RegionOverlap {
|
|
179
191
|
let region = -1;
|
|
180
192
|
let g = 0;
|
|
181
193
|
for (let k = 0; k < regions.length; k++) {
|
|
@@ -185,7 +197,7 @@ export function localInfluences(p: Point, segments: readonly Segment[], r: numbe
|
|
|
185
197
|
region = k;
|
|
186
198
|
g = gk;
|
|
187
199
|
}
|
|
188
|
-
const S = influences(p, segments, r, limits);
|
|
200
|
+
const S = shares ?? influences(p, segments, r, limits, exponent);
|
|
189
201
|
if (region < 0) return { influences: S, region, g: 0 };
|
|
190
202
|
const bone = regions[region].bone;
|
|
191
203
|
if (g >= 1) return { influences: [{ bone, weight: 1 }], region, g };
|
package/src/rig.ts
CHANGED
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
* touches the disk, and the same inputs give the same bytes (key order is the
|
|
82
82
|
* order the objects are built in, and every number is rounded by `pyRound`).
|
|
83
83
|
*/
|
|
84
|
-
import { type AutoSpec, type BoneEntry, type CharacterConfig, type ConfigConstraint, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, ROOT_BONE, weightsABone } from './config.ts';
|
|
84
|
+
import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, ROOT_BONE, weightsABone } from './config.ts';
|
|
85
85
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
|
|
86
86
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
87
87
|
import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
|
|
@@ -115,7 +115,8 @@ import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDL
|
|
|
115
115
|
import { PAINTING_RUN, type PartsFile, readFrom } from './parts.ts';
|
|
116
116
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
117
117
|
import { pyRound } from './round.ts';
|
|
118
|
-
import {
|
|
118
|
+
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
119
|
+
import { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type Segment } from './weights.ts';
|
|
119
120
|
|
|
120
121
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
121
122
|
export const PAD = 4;
|
|
@@ -356,6 +357,10 @@ export interface LatticeMeshReport {
|
|
|
356
357
|
mean_influences: number;
|
|
357
358
|
art_coverage: number;
|
|
358
359
|
grid: number;
|
|
360
|
+
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
361
|
+
exponent?: number;
|
|
362
|
+
/** 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. */
|
|
363
|
+
heat?: HeatRow;
|
|
359
364
|
}
|
|
360
365
|
|
|
361
366
|
/**
|
|
@@ -375,9 +380,13 @@ export interface ContourMeshReport {
|
|
|
375
380
|
mean_influences: number;
|
|
376
381
|
art_coverage: number;
|
|
377
382
|
mode: 'contour';
|
|
383
|
+
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
384
|
+
exponent?: number;
|
|
378
385
|
params: { tolerance: number; margin: number; spacing: number; budget: number | null; stray: number | null };
|
|
379
386
|
contour: ContourReport;
|
|
380
387
|
regions: Array<{ name: string; bone: string; reached: number; whole: number }>;
|
|
388
|
+
/** Only under `rule: "heat"` (issue #161), as the lattice row's. */
|
|
389
|
+
heat?: HeatRow;
|
|
381
390
|
}
|
|
382
391
|
|
|
383
392
|
/** What a declared `source.stray` cleared from an automatic part's art (issue #172): islands, their pixels, and the art they were part of. */
|
|
@@ -413,6 +422,8 @@ export interface AutoMeshReport {
|
|
|
413
422
|
mean_influences: number;
|
|
414
423
|
art_coverage: number;
|
|
415
424
|
mode: 'auto';
|
|
425
|
+
/** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
|
|
426
|
+
exponent?: number;
|
|
416
427
|
settings: {
|
|
417
428
|
threshold: number;
|
|
418
429
|
source: { tolerance: number; margin: number; spacing: number; stray: number | null };
|
|
@@ -483,6 +494,8 @@ export interface AutoMeshReport {
|
|
|
483
494
|
* was not measured and passed is refused.
|
|
484
495
|
*/
|
|
485
496
|
deformation: string | MotionDeformation;
|
|
497
|
+
/** Only under `rule: "heat"` (issue #161): the rule and the solve the source's weights came from. */
|
|
498
|
+
heat?: HeatRow;
|
|
486
499
|
quality_report: unknown;
|
|
487
500
|
motion_report?: unknown;
|
|
488
501
|
}
|
|
@@ -860,7 +873,9 @@ export function buildRig(
|
|
|
860
873
|
file: string,
|
|
861
874
|
spec: ContourSpec,
|
|
862
875
|
r: number,
|
|
876
|
+
exponent: number | undefined,
|
|
863
877
|
segs: Segment[],
|
|
878
|
+
rule: WeightRule,
|
|
864
879
|
out: Problem[],
|
|
865
880
|
): { attachment: MeshAttachment; report: ContourMeshReport } | null => {
|
|
866
881
|
const ox = p.x - PAD;
|
|
@@ -880,6 +895,13 @@ export function buildRig(
|
|
|
880
895
|
out.push(...cm);
|
|
881
896
|
return null;
|
|
882
897
|
}
|
|
898
|
+
// issue #161: under rule "heat", the field over the silhouette the outline was traced from (art above 8, holes
|
|
899
|
+
// filled, grown by the margin), and every vertex's segment shares read off it instead of the distance rule.
|
|
900
|
+
const heat = rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, tracedSilhouette(cm.mask, ART_ALPHA, spec.margin), segs, ox, oy) : null;
|
|
901
|
+
if (Array.isArray(heat)) {
|
|
902
|
+
out.push(...heat);
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
883
905
|
const weights: WeightEntry[][] = [];
|
|
884
906
|
const reached = regions.map(() => 0);
|
|
885
907
|
const whole = regions.map(() => 0);
|
|
@@ -889,7 +911,7 @@ export function buildRig(
|
|
|
889
911
|
cm.vertices.forEach(([vx, vy], vi) => {
|
|
890
912
|
const wx = vx + ox;
|
|
891
913
|
const wy = vy + oy;
|
|
892
|
-
const li = localInfluences([wx, wy], segs, r, regions);
|
|
914
|
+
const li = localInfluences([wx, wy], segs, r, regions, DEFAULT_LIMITS, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], DEFAULT_LIMITS));
|
|
893
915
|
if ('first' in li) {
|
|
894
916
|
if (!overlapped) {
|
|
895
917
|
out.push({
|
|
@@ -932,9 +954,11 @@ export function buildRig(
|
|
|
932
954
|
mean_influences: pyRound(infl / cm.vertices.length, 2),
|
|
933
955
|
art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels), 5),
|
|
934
956
|
mode: 'contour',
|
|
957
|
+
...(exponent === undefined ? {} : { exponent }),
|
|
935
958
|
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
936
959
|
contour: rep,
|
|
937
960
|
regions: regions.map((rg, k) => ({ name: rg.name, bone: rg.bone, reached: reached[k], whole: whole[k] })),
|
|
961
|
+
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
938
962
|
},
|
|
939
963
|
};
|
|
940
964
|
};
|
|
@@ -959,7 +983,9 @@ export function buildRig(
|
|
|
959
983
|
file: string,
|
|
960
984
|
spec: AutoSpec,
|
|
961
985
|
r: number,
|
|
986
|
+
exponent: number | undefined,
|
|
962
987
|
segs: Segment[],
|
|
988
|
+
rule: WeightRule,
|
|
963
989
|
out: Problem[],
|
|
964
990
|
): { attachment: MeshAttachment; report: AutoMeshReport; motion: AutoMotionCase } | null => {
|
|
965
991
|
const ox = p.x - PAD;
|
|
@@ -981,7 +1007,14 @@ export function buildRig(
|
|
|
981
1007
|
// `contourMesh` traced. Nothing left out, it is `mask` itself, so a part without stray reads what it always read.
|
|
982
1008
|
// The image written for the part is not touched (the reason is in docs/AUTHORING.md's `stray` row).
|
|
983
1009
|
const art = source.mask;
|
|
984
|
-
|
|
1010
|
+
// issue #161: under rule "heat", the field over the silhouette the source was traced from (the same mask, at the
|
|
1011
|
+
// automatic mode's alpha 1 and above, holes filled, grown by source.margin).
|
|
1012
|
+
const heat = rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, tracedSilhouette(art, AUTO_THRESHOLD - 1, spec.source.margin), segs, ox, oy) : null;
|
|
1013
|
+
if (Array.isArray(heat)) {
|
|
1014
|
+
out.push(...heat);
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
const sw = sourceWeights(source.vertices, ox, oy, segs, r, spec, exponent ?? DISTANCE_EXPONENT, heat);
|
|
985
1018
|
if ('overlap' in sw) {
|
|
986
1019
|
out.push({
|
|
987
1020
|
code: 'RIG_CONTOUR_REGIONS_OVERLAP',
|
|
@@ -1078,6 +1111,7 @@ export function buildRig(
|
|
|
1078
1111
|
mean_influences: pyRound(infl / mesh.points.length, 2),
|
|
1079
1112
|
art_coverage: pyRound(legacyArtCoverage(art, mesh.points, mesh.triangles), 5),
|
|
1080
1113
|
mode: 'auto',
|
|
1114
|
+
...(exponent === undefined ? {} : { exponent }),
|
|
1081
1115
|
settings: {
|
|
1082
1116
|
threshold: input.art.threshold,
|
|
1083
1117
|
source: { tolerance: spec.source.tolerance, margin: spec.source.margin, spacing: spec.source.spacing, stray: spec.source.stray ?? null },
|
|
@@ -1112,6 +1146,7 @@ export function buildRig(
|
|
|
1112
1146
|
...(residualSpec === undefined || envelope === null ? {} : { skinning_residual: residualRow(residualSpec.maxResidual, envelope.reference, envelope.derivation, report, segs[0].bone) }),
|
|
1113
1147
|
termination: report.termination as Termination,
|
|
1114
1148
|
weights: { sharesDroppedOnGrid: candidate.changes?.sharesDroppedOnGrid ?? 0, sharesPruned: candidate.changes?.sharesPruned ?? 0, droppedAtFivePlaces },
|
|
1149
|
+
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1115
1150
|
regions: regions.map((rg, k) => ({ name: rg.name, bone: weightsABone(rg) ? rg.bone : null, reached: reached[k], whole: whole[k], bound_in_result: boundTo[k] })),
|
|
1116
1151
|
deformation: DEFORMATION_UNMEASURED,
|
|
1117
1152
|
quality_report: qualityDocument(report),
|
|
@@ -1184,7 +1219,7 @@ export function buildRig(
|
|
|
1184
1219
|
}
|
|
1185
1220
|
const segs = meshSegments.get(p.name) as Segment[];
|
|
1186
1221
|
if ('auto' in mesh) {
|
|
1187
|
-
const row = autoAttachment(p, img, file, mesh.auto, mesh.r, segs, problems);
|
|
1222
|
+
const row = autoAttachment(p, img, file, mesh.auto, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', problems);
|
|
1188
1223
|
if (row === null) continue;
|
|
1189
1224
|
skin[p.name] = { [p.name]: row.attachment };
|
|
1190
1225
|
slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
|
|
@@ -1193,7 +1228,7 @@ export function buildRig(
|
|
|
1193
1228
|
continue;
|
|
1194
1229
|
}
|
|
1195
1230
|
if ('contour' in mesh) {
|
|
1196
|
-
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, segs, problems);
|
|
1231
|
+
const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', problems);
|
|
1197
1232
|
if (row === null) continue;
|
|
1198
1233
|
skin[p.name] = { [p.name]: row.attachment };
|
|
1199
1234
|
slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
|
|
@@ -1209,6 +1244,12 @@ export function buildRig(
|
|
|
1209
1244
|
loopPasses[p.name] = lm.passes;
|
|
1210
1245
|
const ox = p.x - PAD;
|
|
1211
1246
|
const oy = p.y - PAD;
|
|
1247
|
+
// issue #161: under rule "heat", the field over the lattice's art (alpha above 8) with its holes filled.
|
|
1248
|
+
const heat = mesh.rule === 'heat' ? boneHeat(`config.meshes.${p.name}`, latticeSilhouette(art), segs, ox, oy) : null;
|
|
1249
|
+
if (Array.isArray(heat)) {
|
|
1250
|
+
problems.push(...heat);
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1212
1253
|
const w = img.width;
|
|
1213
1254
|
const h = img.height;
|
|
1214
1255
|
const weights: WeightEntry[][] = [];
|
|
@@ -1217,7 +1258,7 @@ export function buildRig(
|
|
|
1217
1258
|
for (const [vx0, vy0] of lm.vertices) {
|
|
1218
1259
|
const wx = vx0 + ox;
|
|
1219
1260
|
const wy = vy0 + oy;
|
|
1220
|
-
const ent: WeightEntry[] = influences([wx, wy], segs, mesh.r).map(({ bone, weight }) => {
|
|
1261
|
+
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 }) => {
|
|
1221
1262
|
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1222
1263
|
return { bone, x: places(x), y: places(y), weight: pyRound(weight, 5) };
|
|
1223
1264
|
});
|
|
@@ -1242,6 +1283,8 @@ export function buildRig(
|
|
|
1242
1283
|
mean_influences: pyRound(infl / lm.vertices.length, 2),
|
|
1243
1284
|
art_coverage: pyRound(artCoverage(lm, art), 5),
|
|
1244
1285
|
grid: mesh.grid,
|
|
1286
|
+
...(mesh.exponent === undefined ? {} : { exponent: mesh.exponent }),
|
|
1287
|
+
...(heat === null ? {} : { heat: heatRow(heat) }),
|
|
1245
1288
|
});
|
|
1246
1289
|
}
|
|
1247
1290
|
refuseIfAny(problems);
|
package/src/weights.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* one authored decision about a layer: which bones may pull it. The weights
|
|
8
8
|
* then follow from the geometry and nothing else:
|
|
9
9
|
*
|
|
10
|
-
* w = 1 / (d + r)^
|
|
10
|
+
* w = 1 / (d + r)^e d = distance from the vertex to the segment
|
|
11
11
|
*
|
|
12
|
-
* per candidate
|
|
12
|
+
* per candidate, with `e` the mesh's declared `exponent` (issue #161) and 2 —
|
|
13
|
+
* {@link DISTANCE_EXPONENT}, the rule as it always was — where none is
|
|
14
|
+
* declared; a bone named by two segments keeps the larger; the top
|
|
13
15
|
* {@link MAX_INFLUENCES} are kept, normalised, any below {@link MIN_WEIGHT}
|
|
14
16
|
* dropped, the rest normalised again, rounded to 5 decimals, and the last
|
|
15
17
|
* weight written as `1 - sum(others)` so every vertex sums to exactly 1 after
|
|
@@ -35,6 +37,16 @@ export const MAX_INFLUENCES = 4;
|
|
|
35
37
|
/** A normalised weight under this is dropped before the final normalisation — the reference's `>= 0.03`. */
|
|
36
38
|
export const MIN_WEIGHT = 0.03;
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The exponent of the distance rule where a mesh declares none: `1/(d + r)²`,
|
|
42
|
+
* the reference's rule. At this value the weight is computed as
|
|
43
|
+
* `(d + r) * (d + r)`, the reference's operations, and never through
|
|
44
|
+
* `Math.pow`, which the language does not promise to round as one multiply
|
|
45
|
+
* does — so a mesh that declares nothing, or declares 2, writes the bytes it
|
|
46
|
+
* always wrote.
|
|
47
|
+
*/
|
|
48
|
+
export const DISTANCE_EXPONENT = 2;
|
|
49
|
+
|
|
38
50
|
export interface Segment {
|
|
39
51
|
bone: string;
|
|
40
52
|
a: Point;
|
|
@@ -78,16 +90,31 @@ export interface InfluenceLimits {
|
|
|
78
90
|
|
|
79
91
|
export const DEFAULT_LIMITS: InfluenceLimits = { maxInfluences: MAX_INFLUENCES, minWeight: MIN_WEIGHT };
|
|
80
92
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
93
|
+
/**
|
|
94
|
+
* The influences on one vertex at `p` (rig pixels), before rounding, under
|
|
95
|
+
* `w = 1/(d + r)^exponent`. The rig stage passes every mesh's exponent
|
|
96
|
+
* explicitly; the default is for readers of today's rule (the tools, the
|
|
97
|
+
* selftest's older controls).
|
|
98
|
+
*/
|
|
99
|
+
export function influences(p: Point, segments: readonly Segment[], r: number, limits: InfluenceLimits = DEFAULT_LIMITS, exponent: number = DISTANCE_EXPONENT): Influence[] {
|
|
83
100
|
const agg = new Map<string, number>();
|
|
84
101
|
for (const s of segments) {
|
|
85
102
|
const d = segmentDistance(p, s.a, s.b);
|
|
86
|
-
|
|
103
|
+
// 2 is the reference's multiply, bit for bit (DISTANCE_EXPONENT); any other declared exponent is Math.pow.
|
|
104
|
+
const w = 1.0 / (exponent === DISTANCE_EXPONENT ? (d + r) * (d + r) : Math.pow(d + r, exponent));
|
|
87
105
|
const was = agg.get(s.bone);
|
|
88
106
|
agg.set(s.bone, was === undefined ? w : Math.max(was, w));
|
|
89
107
|
}
|
|
90
|
-
|
|
108
|
+
return capAndFloor([...agg.entries()], limits);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The cap and the floor, on raw per-bone values in first-appearance order: sorted (stable), the top
|
|
113
|
+
* `maxInfluences` normalised, those under `minWeight` dropped, the rest normalised again. The distance rule
|
|
114
|
+
* above and bone heat (`src/heat.ts`, issue #161) both end here, so the two rules share one cap and one floor.
|
|
115
|
+
*/
|
|
116
|
+
export function capAndFloor(raw: ReadonlyArray<readonly [string, number]>, limits: InfluenceLimits): Influence[] {
|
|
117
|
+
const top = [...raw].sort((x, y) => y[1] - x[1]).slice(0, limits.maxInfluences);
|
|
91
118
|
let s = 0;
|
|
92
119
|
for (const [, v] of top) s += v;
|
|
93
120
|
const kept = top.map(([bone, v]) => ({ bone, weight: v / s })).filter((e) => e.weight >= limits.minWeight);
|