@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/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; jointsOffArt: LimbJointOffArtFinding[] } {
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 LimbJointOffArtFinding[] = [],
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 { DEFAULT_IDLE_KEYS, type IdleKeys, rigJsonText } from './rig.ts';
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: string;
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: { part: string; mesh: unknown; mesh_sha256: string; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape };
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 ${String(att.type)} attachment` : 'not an attachment'}; a mesh attachment is required — only a mesh is remeshed` });
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
- const request = readMeshRequest(input.mesh);
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
- const after = meshCounts(targetAttachment(cand.rig, part));
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, targetAttachment(cand.rig, part), candRow);
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 pngs = [...revision].filter(([k]) => k.startsWith('images/')).map(([k, b]) => [k.slice('images/'.length), b] as 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: request.entry, mesh_sha256: sha256(request.bytes), idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
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: [`skins.default.${part}.${part}`, `images/${part}.png`, `mesh_report.json row "${part}"`, 'bones', ...(auto ? ['motion.json'] : [])],
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 },