@rig-c/parts 1.41.1 → 1.43.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/NOTICE.md +10 -8
- package/README.md +8 -4
- package/cli.ts +27 -8
- package/docs/AUTHORING.md +50 -4
- package/docs/STABILITY.md +3 -3
- package/package.json +1 -1
- package/src/cloth.ts +18 -0
- package/src/config.ts +12 -1
- package/src/propose.ts +97 -6
- package/src/remesh.ts +155 -45
- package/src/remeshdelta.ts +669 -0
- package/src/rig.ts +24 -0
package/src/propose.ts
CHANGED
|
@@ -142,6 +142,8 @@ import { type Mask, newMask, newRaster, type Raster } from './raster/types.ts';
|
|
|
142
142
|
import { npMean, npMedian, npPercentile, pyFixed, pyInt, pyIntList, pyRepr, pyRound, pyStrList } from './pyfmt.ts';
|
|
143
143
|
import type { JointState, RigJoint, RigJoints } from './keypoints.ts';
|
|
144
144
|
import { IDLE_FPS } from './motion.ts';
|
|
145
|
+
import { influenceFarthest } from './rig.ts';
|
|
146
|
+
import { type Segment as WeightSegment } from './weights.ts';
|
|
145
147
|
import { pyRound as roundTo } from './round.ts';
|
|
146
148
|
import { KEYPOINT_NAMES, type KeypointName } from './skeleton.ts';
|
|
147
149
|
import { configRoles, type RoleOf } from './structure.ts';
|
|
@@ -150,7 +152,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
|
|
|
150
152
|
import { edgeLength } from './length.ts';
|
|
151
153
|
import { bonePlaces, jointLine, limbCrossing, limbPolyline, limbRun, limbWidth, nearestLimb, nearestOnLine, offLineFigure, type Pt } from './limbs.ts';
|
|
152
154
|
import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
|
|
153
|
-
import { backHairMasses, type BackHairMass, backHairMassTrees, RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput, type TreeMassInput } from './cloth.ts';
|
|
155
|
+
import { backHairMasses, type BackHairMass, backHairMassTrees, INFLUENCE_RADIUS_CELLS, RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput, type TreeMassInput } from './cloth.ts';
|
|
154
156
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
155
157
|
|
|
156
158
|
// ---------------------------------------------------------------------------
|
|
@@ -1392,7 +1394,7 @@ export function proposeWithBasis(
|
|
|
1392
1394
|
gridRule: GridRule = DEFAULT_GRID_RULE,
|
|
1393
1395
|
cloth: ClothRecipes | null = null,
|
|
1394
1396
|
chainRule = true,
|
|
1395
|
-
): { proposal: Proposal; basis: ProposalBasis;
|
|
1397
|
+
): { proposal: Proposal; basis: ProposalBasis; draftLint: DraftLintFinding[] } {
|
|
1396
1398
|
// Issue #230: a piece is not given its base's rule a second time, which
|
|
1397
1399
|
// needs to know which part is the piece. A file that cannot say is refused
|
|
1398
1400
|
// here, and only here: every other reader of parts.json still takes it.
|
|
@@ -1559,6 +1561,8 @@ export function proposeWithBasis(
|
|
|
1559
1561
|
// Issue #364: the trees a cloth recipe drafts, their constraints, and the tags it was used on.
|
|
1560
1562
|
const clothConstraints: ConfigConstraint[] = [];
|
|
1561
1563
|
const clothUsed = new Set<ClothTarget>();
|
|
1564
|
+
/** Issue #518: each part a recipe meshes, by the target whose recipe drafted its mesh. */
|
|
1565
|
+
const clothMeshed = new Map<string, ClothTarget>();
|
|
1562
1566
|
const multiPartClaimed = new Set<string>();
|
|
1563
1567
|
const recipeOf = (tag: ClothTarget): ClothRecipe | null => cloth?.[tag] ?? null;
|
|
1564
1568
|
const declaredPartsOf = (tag: ClothTarget): { field: 'parts' | 'part'; list: PartRecord[] } | null => {
|
|
@@ -1954,6 +1958,7 @@ export function proposeWithBasis(
|
|
|
1954
1958
|
linkSummary.push(`${q.name}: ${crossedDesc.join(', ')}`);
|
|
1955
1959
|
delete regions[q.name];
|
|
1956
1960
|
meshes[q.name] = { grid: gq, r: rcp.split?.hold_tip === true ? Math.max(rq, gq) : rq, segments: qSegs };
|
|
1961
|
+
clothMeshed.set(q.name, 'back hair');
|
|
1957
1962
|
multiPartClaimed.add(q.name);
|
|
1958
1963
|
}
|
|
1959
1964
|
notes.push(
|
|
@@ -2121,6 +2126,7 @@ export function proposeWithBasis(
|
|
|
2121
2126
|
),
|
|
2122
2127
|
);
|
|
2123
2128
|
meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
2129
|
+
clothMeshed.set(p.name, 'back hair');
|
|
2124
2130
|
// Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
|
|
2125
2131
|
const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
|
|
2126
2132
|
for (const t of all) {
|
|
@@ -2297,6 +2303,7 @@ export function proposeWithBasis(
|
|
|
2297
2303
|
}
|
|
2298
2304
|
const frontRcp = recipeOf('front hair');
|
|
2299
2305
|
meshes[p.name] = { grid: g, r: frontRcp?.split?.hold_tip === true && k > 0 ? Math.max(r, g) : r, segments: segs };
|
|
2306
|
+
if (frontRcp !== null && clothUsed.has('front hair')) clothMeshed.set(p.name, 'front hair');
|
|
2300
2307
|
}
|
|
2301
2308
|
|
|
2302
2309
|
// ---- accessories: rigid body + pendant chain where the part narrows, or one pendulum chain per hanging strand
|
|
@@ -3102,6 +3109,7 @@ export function proposeWithBasis(
|
|
|
3102
3109
|
linkSummary.push(`${q.name}: ${crossedDesc.join(', ')}`);
|
|
3103
3110
|
delete regions[q.name];
|
|
3104
3111
|
meshes[q.name] = { grid: gq, r: dressRecipe.split?.hold_tip === true ? Math.max(rq, gq) : rq, segments: qSegs };
|
|
3112
|
+
clothMeshed.set(q.name, 'bottomwear');
|
|
3105
3113
|
multiPartClaimed.add(q.name);
|
|
3106
3114
|
}
|
|
3107
3115
|
notes.push(
|
|
@@ -3172,6 +3180,7 @@ export function proposeWithBasis(
|
|
|
3172
3180
|
),
|
|
3173
3181
|
);
|
|
3174
3182
|
meshes[p.name] = { grid: g, r: skirtRecipe.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
|
|
3183
|
+
clothMeshed.set(p.name, 'bottomwear');
|
|
3175
3184
|
continue;
|
|
3176
3185
|
}
|
|
3177
3186
|
for (const [tag, f, ph] of [
|
|
@@ -3365,11 +3374,34 @@ export function proposeWithBasis(
|
|
|
3365
3374
|
);
|
|
3366
3375
|
}
|
|
3367
3376
|
}
|
|
3377
|
+
// Issue #518: a recipe that declares `influence` has the parts it meshes drafted in the influence mode, each at the
|
|
3378
|
+
// cell its lattice had, and — unless the recipe states the radius — at the radius INFLUENCE_RADIUS_CELLS cells past
|
|
3379
|
+
// the farthest lattice vertex from its nearest link, measured as the rig stage measures it (influenceFarthest).
|
|
3380
|
+
const radii: InfluenceRadiusFinding[] = [];
|
|
3381
|
+
for (const [name, tag] of clothMeshed) {
|
|
3382
|
+
const decl = recipeOf(tag)?.influence;
|
|
3383
|
+
const m = meshes[name];
|
|
3384
|
+
if (decl === undefined || m === undefined || !('grid' in m)) continue;
|
|
3385
|
+
const segments = m.segments;
|
|
3386
|
+
const cell = m.grid;
|
|
3387
|
+
let radius = decl.radius;
|
|
3388
|
+
if (radius === undefined) {
|
|
3389
|
+
const rec = P.recs.find((q) => q.name === name) as PartRecord;
|
|
3390
|
+
const farthest = influenceFarthest(P.images.get(name) as Raster, rec.x, rec.y, meshSegmentsOf(bones, segments), cell);
|
|
3391
|
+
if (farthest === null) {
|
|
3392
|
+
notes.push(`${name}: config.cloth.${JSON.stringify(tag)}.influence is declared, and rig-c's segments lattice refuses the part at cell ${cell}, so no radius is measured and it keeps its lattice mesh`);
|
|
3393
|
+
continue;
|
|
3394
|
+
}
|
|
3395
|
+
radius = roundTo(farthest + INFLUENCE_RADIUS_CELLS * cell, 4);
|
|
3396
|
+
radii.push({ kind: 'influence-radius', part: name, tag, radius, farthest: roundTo(farthest, 4), cells: INFLUENCE_RADIUS_CELLS, cell });
|
|
3397
|
+
}
|
|
3398
|
+
meshes[name] = { influence: { cell, radius, across: decl.across, max: decl.max, shape: decl.shape }, segments };
|
|
3399
|
+
}
|
|
3368
3400
|
const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
|
|
3369
3401
|
return {
|
|
3370
3402
|
proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes },
|
|
3371
3403
|
basis: { frame, bones: records },
|
|
3372
|
-
jointsOffArt,
|
|
3404
|
+
draftLint: [...jointsOffArt, ...radii],
|
|
3373
3405
|
};
|
|
3374
3406
|
}
|
|
3375
3407
|
|
|
@@ -3695,6 +3727,39 @@ function anklesOffArt(P: PartSet, legs: readonly LegChain[], feet: readonly Part
|
|
|
3695
3727
|
return out;
|
|
3696
3728
|
}
|
|
3697
3729
|
|
|
3730
|
+
/**
|
|
3731
|
+
* Issue #518: a mesh's `segments` resolved to lines on the bones `bones` declares, as the rig stage resolves them
|
|
3732
|
+
* (`src/rig.ts`): a chain's name to each of its links, from the link's origin to the next link's or the tip; a single
|
|
3733
|
+
* bone to its origin and tip; an explicit `[bone, from, to]` as written. A name with no line resolves to nothing — the
|
|
3734
|
+
* rig stage names it (`RIG_SEGMENT_DEFINED`, `RIG_NAME_RESOLVES`).
|
|
3735
|
+
*/
|
|
3736
|
+
function meshSegmentsOf(bones: readonly BoneEntry[], segments: readonly Segment[]): WeightSegment[] {
|
|
3737
|
+
const out: WeightSegment[] = [];
|
|
3738
|
+
for (const sp of segments) {
|
|
3739
|
+
if (typeof sp !== 'string') {
|
|
3740
|
+
out.push({ bone: sp[0], a: [sp[1][0], sp[1][1]], b: [sp[2][0], sp[2][1]] });
|
|
3741
|
+
continue;
|
|
3742
|
+
}
|
|
3743
|
+
const chain = bones.find((e) => 'chain' in e && e.chain === sp);
|
|
3744
|
+
if (chain !== undefined && 'chain' in chain) {
|
|
3745
|
+
chain.points.forEach((q, k) => {
|
|
3746
|
+
const end = k + 1 < chain.points.length ? chain.points[k + 1] : chain.tip;
|
|
3747
|
+
out.push({ bone: `${sp}${k}`, a: [q[0], q[1]], b: [end[0], end[1]] });
|
|
3748
|
+
});
|
|
3749
|
+
continue;
|
|
3750
|
+
}
|
|
3751
|
+
const link = bones.flatMap((e) => ('chain' in e ? e.points.map((q, k) => ({ name: `${e.chain}${k}`, a: q, b: k + 1 < e.points.length ? e.points[k + 1] : e.tip })) : []));
|
|
3752
|
+
const l = link.find((x) => x.name === sp);
|
|
3753
|
+
if (l !== undefined) {
|
|
3754
|
+
out.push({ bone: sp, a: [l.a[0], l.a[1]], b: [l.b[0], l.b[1]] });
|
|
3755
|
+
continue;
|
|
3756
|
+
}
|
|
3757
|
+
const single = bones.find((e) => !('chain' in e) && e.name === sp);
|
|
3758
|
+
if (single !== undefined && !('chain' in single) && single.tip !== undefined) out.push({ bone: sp, a: [single.at[0], single.at[1]], b: [single.tip[0], single.tip[1]] });
|
|
3759
|
+
}
|
|
3760
|
+
return out;
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3698
3763
|
interface LegChain {
|
|
3699
3764
|
chain: string;
|
|
3700
3765
|
side: 'r' | 'l';
|
|
@@ -4393,6 +4458,24 @@ export interface LimbJointOffArtFinding {
|
|
|
4393
4458
|
nearest: { distance: number; at: Pt } | null;
|
|
4394
4459
|
}
|
|
4395
4460
|
|
|
4461
|
+
/**
|
|
4462
|
+
* Issue #518: an influence radius `propose --cloth` drafted (no radius stated in the recipe): the part, the recipe's
|
|
4463
|
+
* target, and the rule's figures — `radius = farthest + cells × cell`, `farthest` the part's farthest lattice vertex
|
|
4464
|
+
* from its nearest link as `RIG_INFLUENCE_REACH` measures it. A LINT so the author sees what was drawn and from what.
|
|
4465
|
+
*/
|
|
4466
|
+
export interface InfluenceRadiusFinding {
|
|
4467
|
+
kind: 'influence-radius';
|
|
4468
|
+
part: string;
|
|
4469
|
+
tag: ClothTarget;
|
|
4470
|
+
radius: number;
|
|
4471
|
+
farthest: number;
|
|
4472
|
+
cells: number;
|
|
4473
|
+
cell: number;
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
/** What `propose` hands `lint` beside the proposal: findings of the draft itself, not of a check over the spec. */
|
|
4477
|
+
export type DraftLintFinding = LimbJointOffArtFinding | InfluenceRadiusFinding;
|
|
4478
|
+
|
|
4396
4479
|
export type LintFinding =
|
|
4397
4480
|
| OffArtFinding
|
|
4398
4481
|
| OffLockFinding
|
|
@@ -4408,7 +4491,8 @@ export type LintFinding =
|
|
|
4408
4491
|
| OverlappingArmsFinding
|
|
4409
4492
|
| CrossedLimbsFinding
|
|
4410
4493
|
| StopTouchAboveRootFinding
|
|
4411
|
-
| LimbJointOffArtFinding
|
|
4494
|
+
| LimbJointOffArtFinding
|
|
4495
|
+
| InfluenceRadiusFinding;
|
|
4412
4496
|
|
|
4413
4497
|
/** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
|
|
4414
4498
|
export interface LintBasis {
|
|
@@ -4510,7 +4594,7 @@ export function lint(
|
|
|
4510
4594
|
P: PartSet,
|
|
4511
4595
|
spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[]; limbs?: Readonly<Record<string, Limb>>; stops?: readonly ConfigStop[] },
|
|
4512
4596
|
joints?: RigJoints,
|
|
4513
|
-
drafted: readonly
|
|
4597
|
+
drafted: readonly DraftLintFinding[] = [],
|
|
4514
4598
|
): LintResult {
|
|
4515
4599
|
const { byName } = expand(spec.bones);
|
|
4516
4600
|
const parts = new Map(P.recs.map((p) => [p.name, p]));
|
|
@@ -4523,7 +4607,7 @@ export function lint(
|
|
|
4523
4607
|
const list = read.get(n);
|
|
4524
4608
|
if (list !== undefined && !list.includes(check)) list.push(check);
|
|
4525
4609
|
};
|
|
4526
|
-
for (const f of drafted) readBy(f.link, `limb-joint-off-art on ${f.limb}`);
|
|
4610
|
+
for (const f of drafted) if (f.kind === 'limb-joint-off-art') readBy(f.link, `limb-joint-off-art on ${f.limb}`);
|
|
4527
4611
|
for (const [mname, m] of Object.entries(spec.meshes)) {
|
|
4528
4612
|
const part = parts.get(mname);
|
|
4529
4613
|
if (part === undefined) {
|
|
@@ -4963,6 +5047,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
|
|
|
4963
5047
|
return [f.bone];
|
|
4964
5048
|
case 'limb-joint-off-art':
|
|
4965
5049
|
return [f.link];
|
|
5050
|
+
case 'influence-radius':
|
|
5051
|
+
return [];
|
|
4966
5052
|
case 'hip-not-below-chest':
|
|
4967
5053
|
case 'hip-not-past-chest':
|
|
4968
5054
|
return ['hip', 'chest'];
|
|
@@ -5136,6 +5222,11 @@ export function basisLines(b: LintBasis): string[] {
|
|
|
5136
5222
|
*/
|
|
5137
5223
|
export function lintLine(f: LintFinding): string {
|
|
5138
5224
|
switch (f.kind) {
|
|
5225
|
+
case 'influence-radius':
|
|
5226
|
+
return (
|
|
5227
|
+
`LINT ${f.part} on mesh ${pyRepr(f.part)}: influence radius ${f.radius} = farthest ${f.farthest} + ${f.cells} × cell ${f.cell} — drafted by config.cloth.${JSON.stringify(f.tag)}.influence, ` +
|
|
5228
|
+
`the farthest lattice vertex from its nearest link (the figure RIG_INFLUENCE_REACH holds the radius above) plus ${f.cells} cells; a radius at the floor folds more than the lattice and a larger one is not safer (AUTHORING, influence.radius) — state influence.radius in the recipe to keep your own`
|
|
5229
|
+
);
|
|
5139
5230
|
case 'limb-joint-off-art':
|
|
5140
5231
|
return (
|
|
5141
5232
|
`LINT ${f.part} at ${pyIntList(f.at)}, the ${f.keypoint} joint and ${f.link}'s tip, lies on none of ${f.limb}'s art within 1 px along its joint line (${offLineFigure(f.nearest)}), ` +
|
package/src/remesh.ts
CHANGED
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* `motion` gate; for `contour` its tolerance, margin, spacing and `maxEdge`; for `grid` its spacing. There is no
|
|
16
16
|
* second vocabulary for them, so there is no second loader.
|
|
17
17
|
*
|
|
18
|
+
* `--delta <mesh-edit.json>` (issue #526), in place of `--mesh`: rig-c/review's mesh edit, ops over the input's own
|
|
19
|
+
* mesh, replayed by `src/remeshdelta.ts`. That path builds no config entry — steps 3 and 4 become the source checks
|
|
20
|
+
* (the edit's counts and `source.hash`, the SHA-256 of the part's entry in rigc's model document over the input) and
|
|
21
|
+
* the replay — and steps 5 to 8 are the same.
|
|
22
|
+
*
|
|
18
23
|
* ## The steps, each one a refusal by name
|
|
19
24
|
*
|
|
20
25
|
* 1. **The input** ({@link readTree}): `--rig` holds `rig.json`, `motion.json`, `mesh_report.json` and `images/`, as
|
|
@@ -55,12 +60,16 @@
|
|
|
55
60
|
import { createHash } from 'node:crypto';
|
|
56
61
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
57
62
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
58
|
-
import { checkStage, gateThroughRigc, type Log, rigStage } from './build.ts';
|
|
63
|
+
import { checkStage, gateThroughRigc, type Log, RIGC_MODEL_DOCUMENT, rigStage } from './build.ts';
|
|
59
64
|
import { barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackShape, type PageEdges, rigcRanClause, type RigcRunner } from './check.ts';
|
|
60
65
|
import { loadConfigAndAnimations } from './config.ts';
|
|
61
66
|
import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
|
|
62
67
|
import { jsonValueSpan, scratchConfig } from './meshdraft.ts';
|
|
63
|
-
import {
|
|
68
|
+
import { computeExactFrameTransforms } from './coords.ts';
|
|
69
|
+
import { type PartsFile } from './parts.ts';
|
|
70
|
+
import { applyMeshEdit, type DeltaApplied, type DeltaPlant, deltaSourceProblems, type MeshEditDocument, readMeshEdit } from './remeshdelta.ts';
|
|
71
|
+
import { DEFAULT_IDLE_KEYS, type IdleKeys, type MeshAttachment, rigJsonText, type RigSpec } from './rig.ts';
|
|
72
|
+
import { pyRound } from './round.ts';
|
|
64
73
|
|
|
65
74
|
/** The provenance file's spec. */
|
|
66
75
|
export const REMESH_SPEC = 'rig-parts-remesh/1';
|
|
@@ -88,8 +97,10 @@ export interface RemeshInput {
|
|
|
88
97
|
parts: string;
|
|
89
98
|
/** The part to remesh: its slot and its attachment, as the rig stage names both. */
|
|
90
99
|
part: string;
|
|
91
|
-
/** The request's mesh entry: a JSON file holding what `config.json` would hold at `meshes.<part>`. */
|
|
92
|
-
mesh
|
|
100
|
+
/** The request's mesh entry: a JSON file holding what `config.json` would hold at `meshes.<part>`. Exactly one of `mesh`, `delta`. */
|
|
101
|
+
mesh?: string;
|
|
102
|
+
/** The request as rig-c/review's mesh edit (`rig-review-mesh-edit/1`, issue #526): ops over the input's own mesh. Exactly one of `mesh`, `delta`. */
|
|
103
|
+
delta?: string;
|
|
93
104
|
/** The new revision's directory; it must not exist. */
|
|
94
105
|
out: string;
|
|
95
106
|
idleKeys?: IdleKeys;
|
|
@@ -106,6 +117,8 @@ export interface RemeshInput {
|
|
|
106
117
|
export interface RemeshPlant {
|
|
107
118
|
perturb?: string;
|
|
108
119
|
skipPreserve?: boolean;
|
|
120
|
+
/** Issue #526's mutants, handed to the delta's replay (`src/remeshdelta.ts`). */
|
|
121
|
+
delta?: DeltaPlant;
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
/** The versions the provenance names: this package's and the rigc that gated the revision (its `--version` line). */
|
|
@@ -143,7 +156,9 @@ export interface CheckSide {
|
|
|
143
156
|
/** `remesh.json` (spec {@link REMESH_SPEC}). */
|
|
144
157
|
export interface RemeshProvenance {
|
|
145
158
|
spec: typeof REMESH_SPEC;
|
|
146
|
-
request:
|
|
159
|
+
request:
|
|
160
|
+
| { part: string; mesh: unknown; mesh_sha256: string; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape }
|
|
161
|
+
| { part: string; delta: MeshEditDocument; delta_sha256: string; ops: number; applied: Omit<DeltaApplied, 'ops'>; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape };
|
|
147
162
|
versions: RemeshVersions;
|
|
148
163
|
input: { rig: HashRow[]; config_sha256: string; parts_json_sha256: string };
|
|
149
164
|
baseline: { reproduced: string[]; not_compared: string };
|
|
@@ -232,7 +247,7 @@ export function targetProblems(rig: Json, motion: Json, configMeshes: Readonly<R
|
|
|
232
247
|
const out: Problem[] = [];
|
|
233
248
|
const att = targetAttachment(rig, part);
|
|
234
249
|
if (!isRecord(att) || att.type !== 'mesh') {
|
|
235
|
-
out.push({ code: 'REMESH_TARGET_MESH', object: `rig.json skins.default.${part}.${part}`, detail: `is ${att === undefined ? 'absent' : isRecord(att) ? `a ${
|
|
250
|
+
out.push({ code: 'REMESH_TARGET_MESH', object: `rig.json skins.default.${part}.${part}`, detail: `is ${att === undefined ? 'absent' : isRecord(att) ? `a ${typeof att.type === 'string' ? att.type : 'region'} attachment` : 'not an attachment'}; a mesh attachment is required — only a mesh is remeshed` });
|
|
236
251
|
}
|
|
237
252
|
if (!(part in configMeshes)) {
|
|
238
253
|
out.push({ code: 'REMESH_TARGET_MESH', object: `config meshes.${part}`, detail: `is absent (the config meshes ${Object.keys(configMeshes).join(', ') || 'nothing'}); the input's target must be a mesh the config states, so the baseline can reproduce it` });
|
|
@@ -474,6 +489,28 @@ export function readMeshRequest(path: string): { entry: Json; bytes: Uint8Array
|
|
|
474
489
|
return { entry, bytes };
|
|
475
490
|
}
|
|
476
491
|
|
|
492
|
+
/** The request as rig-c/review's mesh edit (issue #526), read and held to its shape (`src/remeshdelta.ts`). */
|
|
493
|
+
export function readDeltaRequest(path: string): { doc: MeshEditDocument; bytes: Uint8Array } {
|
|
494
|
+
if (!existsSync(path)) throw new PartsError([{ code: 'REMESH_DELTA_JSON', object: path, detail: 'no such file; the request\'s mesh edit (rig-c/review\'s rig-review-mesh-edit/1 document) is required' }]);
|
|
495
|
+
const bytes = new Uint8Array(readFileSync(path));
|
|
496
|
+
const { doc, problems } = readMeshEdit(text(bytes), path);
|
|
497
|
+
refuseIfAny(problems);
|
|
498
|
+
return { doc: doc as MeshEditDocument, bytes };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The hash rig-c/review binds a mesh edit to (`source.hash`): SHA-256 of `JSON.stringify` of the attachment's entry in
|
|
503
|
+
* rigc's model document (`skins[name "default"].attachments.<part>.<part>`), or null when the document holds none.
|
|
504
|
+
*/
|
|
505
|
+
export function modelAttachmentHash(modelText: string, part: string): string | null {
|
|
506
|
+
const model = JSON.parse(modelText) as { skins?: unknown };
|
|
507
|
+
const skin = Array.isArray(model.skins) ? (model.skins as unknown[]).find((s) => isRecord(s) && s.name === 'default') : undefined;
|
|
508
|
+
const atts = isRecord(skin) && isRecord(skin.attachments) ? skin.attachments : undefined;
|
|
509
|
+
const slot = atts !== undefined && isRecord(atts[part]) ? (atts[part] as Json) : undefined;
|
|
510
|
+
const entry = slot?.[part];
|
|
511
|
+
return entry === undefined ? null : createHash('sha256').update(JSON.stringify(entry)).digest('hex');
|
|
512
|
+
}
|
|
513
|
+
|
|
477
514
|
/** Step 1's tree rules: the files `rig` writes are present, and the two the splice rewrites are this writer's bytes. */
|
|
478
515
|
export function inputTreeProblems(tree: FileTree, root: string): Problem[] {
|
|
479
516
|
const out: Problem[] = [];
|
|
@@ -496,6 +533,35 @@ export function inputTreeProblems(tree: FileTree, root: string): Problem[] {
|
|
|
496
533
|
return out;
|
|
497
534
|
}
|
|
498
535
|
|
|
536
|
+
/** A tree's images as the gate takes them (name under `images/`, bytes). */
|
|
537
|
+
const pngsOf = (tree: FileTree): Array<readonly [string, Uint8Array]> => [...tree].filter(([k]) => k.startsWith('images/')).map(([k, b]) => [k.slice('images/'.length), b] as const);
|
|
538
|
+
/** A tree's other files as text. */
|
|
539
|
+
const textsOf = (tree: FileTree): Array<readonly [string, string]> => [...tree].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The delta's `mesh_report.json` row (issue #526): the input's row, key for key, with `vertices`, `triangles`, `hull`,
|
|
543
|
+
* `bones` (the sorted set the new weights name), `max_influences` and `mean_influences` recomputed from the new
|
|
544
|
+
* attachment and `mode` "delta" (in place, or last when the row had none). Every other key is the input's and
|
|
545
|
+
* describes the mesh the delta was drawn over.
|
|
546
|
+
*/
|
|
547
|
+
export function deltaRow(row: Json, att: MeshAttachment): Json {
|
|
548
|
+
const n = att.uvs.length / 2;
|
|
549
|
+
const entries = att.weights.reduce((s, w) => s + w.length, 0);
|
|
550
|
+
const fresh: Json = {
|
|
551
|
+
vertices: n,
|
|
552
|
+
triangles: att.triangles.length / 3,
|
|
553
|
+
hull: att.hull,
|
|
554
|
+
bones: [...new Set(att.weights.flatMap((w) => w.map((e) => e.bone)))].sort(),
|
|
555
|
+
max_influences: Math.max(...att.weights.map((w) => w.length)),
|
|
556
|
+
mean_influences: pyRound(entries / n, 2),
|
|
557
|
+
mode: 'delta',
|
|
558
|
+
};
|
|
559
|
+
const out: Json = {};
|
|
560
|
+
for (const [k, v] of Object.entries(row)) out[k] = k in fresh ? fresh[k] : v;
|
|
561
|
+
if (!('mode' in out)) out.mode = 'delta';
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
499
565
|
/**
|
|
500
566
|
* The stage: steps 1–8 of the module header. Writes `input.out` only when every step is green; refuses with every
|
|
501
567
|
* problem of the first step that found one.
|
|
@@ -513,7 +579,9 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
|
|
|
513
579
|
if (outAt.startsWith(`${rigAt}${sep}`)) throw new PartsError([{ code: 'REMESH_OUT_FRESH', object: input.out, detail: `is inside the input ${input.rig}; a revision is written beside the input, never into it` }]);
|
|
514
580
|
const tree = readTree(rigAt);
|
|
515
581
|
refuseIfAny(inputTreeProblems(tree, input.rig));
|
|
516
|
-
|
|
582
|
+
if ((input.mesh === undefined) === (input.delta === undefined)) throw new Error('remeshStage: exactly one of mesh, delta is required');
|
|
583
|
+
const meshReq = input.mesh === undefined ? null : readMeshRequest(input.mesh);
|
|
584
|
+
const deltaReq = input.delta === undefined ? null : readDeltaRequest(input.delta);
|
|
517
585
|
const configText = readFileSync(input.config, 'utf8');
|
|
518
586
|
const { config: cfg } = loadConfigAndAnimations(input.config);
|
|
519
587
|
const inRig = JSON.parse(text(tree.get('rig.json') as Uint8Array)) as Json;
|
|
@@ -521,41 +589,85 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
|
|
|
521
589
|
const inReport = JSON.parse(text(tree.get('mesh_report.json') as Uint8Array)) as Json[];
|
|
522
590
|
// 2. The target.
|
|
523
591
|
refuseIfAny(targetProblems(inRig, JSON.parse(inMotionText) as Json, cfg.meshes, part));
|
|
524
|
-
// The request is the config's to judge: spliced in and loaded, before anything is built.
|
|
525
|
-
const candConfig = scratchConfig(input.config, configText, join(scratch, 'candidate'), new Map([[part, request.entry]]));
|
|
526
|
-
loadConfigAndAnimations(candConfig);
|
|
527
|
-
const auto = 'auto' in request.entry;
|
|
528
|
-
const build = (name: string, config: string): Built => {
|
|
529
|
-
const out = join(scratch, name, 'rig');
|
|
530
|
-
rigStage({ config, parts: input.parts, out, idleKeys, pageEdges, packShape }, rigc, join(scratch, name, 'gate'), (l) => log(`[${name}:rig] ${l}`));
|
|
531
|
-
return readBuilt(out, part);
|
|
532
|
-
};
|
|
533
|
-
// 3. The baseline.
|
|
534
|
-
log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the baseline is the config as written`);
|
|
535
|
-
let base: Built;
|
|
536
|
-
try {
|
|
537
|
-
base = build('baseline', scratchConfig(input.config, configText, join(scratch, 'baseline'), new Map()));
|
|
538
|
-
} catch (err) {
|
|
539
|
-
if (!(err instanceof PartsError)) throw err;
|
|
540
|
-
throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_BASELINE_MISMATCH', object: `${input.config} as written`, detail: `does not build, so it cannot reproduce the input: ${problemLine(p)}` })));
|
|
541
|
-
}
|
|
542
|
-
refuseIfAny(baselineProblems({ rig: inRig, motionText: inMotionText, report: inReport, image: tree.get(`images/${part}.png`) }, base, part, auto));
|
|
543
|
-
log(` baseline: the config as written reproduces the input's ${part} (attachment, image, report row) and its bones${auto ? ' and motion' : ''}`);
|
|
544
|
-
// 4. The candidate.
|
|
545
|
-
let cand: Built;
|
|
546
|
-
try {
|
|
547
|
-
cand = build('candidate', candConfig);
|
|
548
|
-
} catch (err) {
|
|
549
|
-
if (!(err instanceof PartsError)) throw err;
|
|
550
|
-
throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_CANDIDATE_REFUSED', object: `the request's meshes.${part} (${input.mesh})`, detail: `the producer refuses it, nothing is written: ${problemLine(p)}` })));
|
|
551
|
-
}
|
|
552
|
-
refuseIfAny(requestHeldProblems(base, cand, part));
|
|
553
|
-
const candRow = rowOf(cand.report, part);
|
|
554
|
-
if (candRow === undefined) throw new Error(`remesh: the candidate build wrote no mesh_report row for "${part}"`);
|
|
555
592
|
const before = meshCounts(targetAttachment(inRig, part));
|
|
556
|
-
|
|
593
|
+
let candAtt: unknown;
|
|
594
|
+
let candRow: Json;
|
|
595
|
+
let reproduced: string[];
|
|
596
|
+
let deltaRequest: RemeshProvenance['request'] | null = null;
|
|
597
|
+
if (meshReq !== null) {
|
|
598
|
+
const meshPath = input.mesh as string;
|
|
599
|
+
// The request is the config's to judge: spliced in and loaded, before anything is built.
|
|
600
|
+
const candConfig = scratchConfig(input.config, configText, join(scratch, 'candidate'), new Map([[part, meshReq.entry]]));
|
|
601
|
+
loadConfigAndAnimations(candConfig);
|
|
602
|
+
const auto = 'auto' in meshReq.entry;
|
|
603
|
+
const build = (name: string, config: string): Built => {
|
|
604
|
+
const out = join(scratch, name, 'rig');
|
|
605
|
+
rigStage({ config, parts: input.parts, out, idleKeys, pageEdges, packShape }, rigc, join(scratch, name, 'gate'), (l) => log(`[${name}:rig] ${l}`));
|
|
606
|
+
return readBuilt(out, part);
|
|
607
|
+
};
|
|
608
|
+
// 3. The baseline.
|
|
609
|
+
log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the baseline is the config as written`);
|
|
610
|
+
let base: Built;
|
|
611
|
+
try {
|
|
612
|
+
base = build('baseline', scratchConfig(input.config, configText, join(scratch, 'baseline'), new Map()));
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (!(err instanceof PartsError)) throw err;
|
|
615
|
+
throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_BASELINE_MISMATCH', object: `${input.config} as written`, detail: `does not build, so it cannot reproduce the input: ${problemLine(p)}` })));
|
|
616
|
+
}
|
|
617
|
+
refuseIfAny(baselineProblems({ rig: inRig, motionText: inMotionText, report: inReport, image: tree.get(`images/${part}.png`) }, base, part, auto));
|
|
618
|
+
log(` baseline: the config as written reproduces the input's ${part} (attachment, image, report row) and its bones${auto ? ' and motion' : ''}`);
|
|
619
|
+
// 4. The candidate.
|
|
620
|
+
let cand: Built;
|
|
621
|
+
try {
|
|
622
|
+
cand = build('candidate', candConfig);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (!(err instanceof PartsError)) throw err;
|
|
625
|
+
throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_CANDIDATE_REFUSED', object: `the request's meshes.${part} (${meshPath})`, detail: `the producer refuses it, nothing is written: ${problemLine(p)}` })));
|
|
626
|
+
}
|
|
627
|
+
refuseIfAny(requestHeldProblems(base, cand, part));
|
|
628
|
+
const row = rowOf(cand.report, part);
|
|
629
|
+
if (row === undefined) throw new Error(`remesh: the candidate build wrote no mesh_report row for "${part}"`);
|
|
630
|
+
candAtt = targetAttachment(cand.rig, part);
|
|
631
|
+
candRow = row;
|
|
632
|
+
reproduced = [`skins.default.${part}.${part}`, `images/${part}.png`, `mesh_report.json row "${part}"`, 'bones', ...(auto ? ['motion.json'] : [])];
|
|
633
|
+
} else {
|
|
634
|
+
// 3–4 on the delta path (issue #526): no config entry to build, so the baseline is the input itself, held to the
|
|
635
|
+
// delta by its source counts and its source.hash, and the candidate is the delta replayed over the input's mesh.
|
|
636
|
+
const deltaPath = input.delta as string;
|
|
637
|
+
const { doc, bytes } = deltaReq as { doc: MeshEditDocument; bytes: Uint8Array };
|
|
638
|
+
const inAtt = targetAttachment(inRig, part) as MeshAttachment;
|
|
639
|
+
refuseIfAny(deltaSourceProblems(doc, part, inAtt, deltaPath));
|
|
640
|
+
log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the request is a mesh edit of ${doc.ops.length} op(s), the baseline is the input itself`);
|
|
641
|
+
let model = null as string | null;
|
|
642
|
+
const inGate = gateThroughRigc(pngsOf(tree), textsOf(tree), rigc, join(scratch, 'gate-input'), { pageEdges, packShape }, (dir) => {
|
|
643
|
+
const p = join(dir, RIGC_MODEL_DOCUMENT);
|
|
644
|
+
model = existsSync(p) ? readFileSync(p, 'utf8') : null;
|
|
645
|
+
});
|
|
646
|
+
refuseIfAny(inGate.filter((g) => g.status !== 0).map((g) => ({ code: 'REMESH_RIGC_GREEN', object: `rigc ${g.label} over the input`, detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(g)}; the delta's source.hash is read off the input's own build, and nothing was written` })));
|
|
647
|
+
if (model === null) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `rigc's build over the input`, detail: `wrote no ${RIGC_MODEL_DOCUMENT}, so the delta's source.hash cannot be verified; nothing was written` }]);
|
|
648
|
+
const hash = modelAttachmentHash(model, part);
|
|
649
|
+
if (hash !== doc.source.hash) {
|
|
650
|
+
throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${deltaPath} source.hash`, detail: `is ${doc.source.hash.slice(0, 16)}…; the input's ${part} in its ${RIGC_MODEL_DOCUMENT} hashes to ${hash === null ? 'nothing (no such entry)' : `${hash.slice(0, 16)}…`} — the delta was drawn over another mesh` }]);
|
|
651
|
+
}
|
|
652
|
+
log(` source: ${part}'s entry in the input's ${RIGC_MODEL_DOCUMENT} hashes to ${hash.slice(0, 16)}…, the delta's source.hash; counts v=${before.vertices} t=${before.triangles} hull=${before.hull} as it states`);
|
|
653
|
+
const partsFile = JSON.parse(readFileSync(join(input.parts, 'parts.json'), 'utf8')) as PartsFile;
|
|
654
|
+
const rec = partsFile.parts.find((p) => p.name === part);
|
|
655
|
+
if (rec === undefined) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${input.parts}/parts.json`, detail: `names no part "${part}"; its box is where rig placed the image, which the delta's crop px are read against` }]);
|
|
656
|
+
const world = computeExactFrameTransforms((inRig as unknown as RigSpec).bones);
|
|
657
|
+
const r = applyMeshEdit({ part, attachment: inAtt, box: [rec.x, rec.y], rigSize: [partsFile.rig_size[0], partsFile.rig_size[1]], world }, doc, deltaPath, plant.delta);
|
|
658
|
+
if (Array.isArray(r)) throw new PartsError(r);
|
|
659
|
+
const row = rowOf(inReport, part);
|
|
660
|
+
if (row === undefined) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${input.rig}/mesh_report.json`, detail: `holds no row "${part}"; the delta's row is the input's with its counts recomputed` }]);
|
|
661
|
+
candAtt = r.attachment;
|
|
662
|
+
candRow = deltaRow(row, r.attachment);
|
|
663
|
+
const { ops, ...applied } = r.applied;
|
|
664
|
+
log(` delta: ${ops} op(s) — moved ${applied.moved}, added ${applied.added}, removed ${applied.removed}, constrained ${applied.constrained}; ${r.untouched.length} source vertex(es) untouched; interior edges not locally Delaunay ${r.delaunay.before} -> ${r.delaunay.after}`);
|
|
665
|
+
reproduced = ['none built: the delta path builds no config entry, so the baseline is the input itself, held to the delta by source.vertexCount, source.triangleCount, source.hullLength and source.hash'];
|
|
666
|
+
deltaRequest = { part, delta: doc, delta_sha256: sha256(bytes), ops, applied, idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape };
|
|
667
|
+
}
|
|
668
|
+
const after = meshCounts(candAtt);
|
|
557
669
|
// 5–6. The splice and the preserve assertion.
|
|
558
|
-
let revision = splice(tree, part,
|
|
670
|
+
let revision = splice(tree, part, candAtt, candRow);
|
|
559
671
|
if (plant.perturb !== undefined) revision = perturbed(revision, plant.perturb);
|
|
560
672
|
if (plant.skipPreserve !== true) refuseIfAny(preservedProblems(tree, revision, part));
|
|
561
673
|
const spliced = ['rig.json', 'mesh_report.json'];
|
|
@@ -563,9 +675,7 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
|
|
|
563
675
|
log(` ${part}: v=${before.vertices} t=${before.triangles} hull=${before.hull} -> v=${after.vertices} t=${after.triangles} hull=${after.hull}`);
|
|
564
676
|
log(` preserved: ${identical.length} file(s) byte-identical to the input; rig.json identical outside skins.default.${part}.${part}; mesh_report.json identical but the "${part}" row`);
|
|
565
677
|
// 7. The gate and check.
|
|
566
|
-
const
|
|
567
|
-
const texts = [...revision].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
|
|
568
|
-
const gate = gateThroughRigc(pngs, texts, rigc, join(scratch, 'gate'), { pageEdges, packShape });
|
|
678
|
+
const gate = gateThroughRigc(pngsOf(revision), textsOf(revision), rigc, join(scratch, 'gate'), { pageEdges, packShape });
|
|
569
679
|
for (const g of gate) {
|
|
570
680
|
log(` rigc ${g.label}, the revision: exit ${g.status}`);
|
|
571
681
|
for (const l of g.lines) log(` ${l.trim()}`);
|
|
@@ -587,11 +697,11 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
|
|
|
587
697
|
// 8. The write.
|
|
588
698
|
const provenance: RemeshProvenance = {
|
|
589
699
|
spec: REMESH_SPEC,
|
|
590
|
-
request: { part, mesh:
|
|
700
|
+
request: deltaRequest ?? { part, mesh: (meshReq as { entry: Json }).entry, mesh_sha256: sha256((meshReq as { bytes: Uint8Array }).bytes), idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
|
|
591
701
|
versions,
|
|
592
702
|
input: { rig: hashRows(tree), config_sha256: sha256(bytesOf(configText)), parts_json_sha256: sha256(new Uint8Array(readFileSync(join(input.parts, 'parts.json')))) },
|
|
593
703
|
baseline: {
|
|
594
|
-
reproduced
|
|
704
|
+
reproduced,
|
|
595
705
|
not_compared: 'every other attachment, image and report row: carried over from the input as bytes, whether or not the config reproduces it',
|
|
596
706
|
},
|
|
597
707
|
target: { before, after },
|