@rig-c/core 2.71.0 → 2.73.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.
@@ -33,6 +33,10 @@ import { gridRound } from './index.ts';
33
33
 
34
34
  /** An event definition, as the model states it (`ModelEvent`): the payload a key falls back to. */
35
35
  export interface CoreEventDef {
36
+ /** Audio metadata retained additively; the five-column oracle row is unchanged. */
37
+ audio?: string;
38
+ volume?: number;
39
+ balance?: number;
36
40
  name: string;
37
41
  int: number;
38
42
  float: number;
@@ -41,6 +45,9 @@ export interface CoreEventDef {
41
45
 
42
46
  /** One event key, as read: its time (float32) and the payload it fires with, each field resolved. */
43
47
  export interface CoreEventKey {
48
+ /** Audio delivery values, as observed from the public EventTimeline API. */
49
+ volume?: number;
50
+ balance?: number;
44
51
  time: number;
45
52
  name: string;
46
53
  int: number;
@@ -55,8 +62,8 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'obj
55
62
  const EVENT_DEF_FIELDS = ['name', 'int', 'float', 'string', 'audio', 'volume', 'balance'] as const;
56
63
  const EVENT_KEY_FIELDS = ['time', 'name', 'int', 'float', 'string', 'volume', 'balance'] as const;
57
64
 
58
- function payload(raw: Record<string, unknown>, at: string, problems: string[]): { int?: number; float?: number; string?: string } {
59
- const out: { int?: number; float?: number; string?: string } = {};
65
+ function payload(raw: Record<string, unknown>, at: string, problems: string[]): { int?: number; float?: number; string?: string; volume?: number; balance?: number } {
66
+ const out: { int?: number; float?: number; string?: string; volume?: number; balance?: number } = {};
60
67
  if (raw.int !== undefined) {
61
68
  if (typeof raw.int !== 'number' || !Number.isInteger(raw.int)) problems.push(`${at}: int is ${JSON.stringify(raw.int)}, not a whole number`);
62
69
  else out.int = raw.int;
@@ -69,7 +76,10 @@ function payload(raw: Record<string, unknown>, at: string, problems: string[]):
69
76
  if (typeof raw.string !== 'string') problems.push(`${at}: string is ${JSON.stringify(raw.string)}, not a string`);
70
77
  else out.string = raw.string;
71
78
  }
72
- for (const key of ['volume', 'balance'] as const) if (raw[key] !== undefined && (typeof raw[key] !== 'number' || !Number.isFinite(raw[key]))) problems.push(`${at}: ${key} is ${JSON.stringify(raw[key])}, not a finite number`);
79
+ for (const key of ['volume', 'balance'] as const) if (raw[key] !== undefined) {
80
+ if (typeof raw[key] !== 'number' || !Number.isFinite(raw[key])) problems.push(`${at}: ${key} is ${JSON.stringify(raw[key])}, not a finite number`);
81
+ else out[key] = raw[key];
82
+ }
73
83
  return out;
74
84
  }
75
85
 
@@ -90,7 +100,7 @@ export function readEventDefs(value: unknown, problems: string[]): Map<string, C
90
100
  if (raw.audio !== undefined && typeof raw.audio !== 'string') problems.push(`${at} "${raw.name}": audio is ${JSON.stringify(raw.audio)}, not a string`);
91
101
  if (out.has(raw.name)) problems.push(`${at}: event "${raw.name}" is declared twice`);
92
102
  const p = payload(raw, `${at} "${raw.name}"`, problems);
93
- out.set(raw.name, { name: raw.name, int: p.int ?? 0, float: p.float ?? 0, string: p.string ?? '' });
103
+ out.set(raw.name, { audio: typeof raw.audio === 'string' ? raw.audio : '', volume: p.volume ?? 0, balance: p.balance ?? 0, name: raw.name, int: p.int ?? 0, float: p.float ?? 0, string: p.string ?? '' });
94
104
  });
95
105
  return out;
96
106
  }
@@ -121,7 +131,9 @@ export function readEventKeys(value: unknown, label: string, defs: ReadonlyMap<s
121
131
  return;
122
132
  }
123
133
  const p = payload(raw, at, problems);
124
- out.push({ time: Math.fround(typeof t === 'number' && Number.isFinite(t) ? t : 0), name: def.name, int: p.int ?? def.int, float: p.float ?? def.float, string: p.string ?? def.string });
134
+ // Observed oracle behavior: a voiced key's omitted balance falls back to
135
+ // the definition's volume, not its balance. A silent event keeps both at 0.
136
+ out.push({ volume: def.audio ? p.volume ?? def.volume ?? 0 : 0, balance: def.audio ? p.balance ?? def.volume ?? 0 : 0, time: Math.fround(typeof t === 'number' && Number.isFinite(t) ? t : 0), name: def.name, int: p.int ?? def.int, float: p.float ?? def.float, string: p.string ?? def.string });
125
137
  });
126
138
  return out;
127
139
  }
package/src/deformgen.ts CHANGED
@@ -374,7 +374,7 @@ export function evaluateDeformTransform(
374
374
  round: Rounder,
375
375
  where: string,
376
376
  /**
377
- * Per-vertex `z` for this attachment, when its generator named a depth map.
377
+ * Per-vertex `z` for this attachment, when it or its generator named a depth map.
378
378
  * Null when it named none — which is what makes `"depth": true` refusable by
379
379
  * name rather than silently falling back to a cylinder.
380
380
  */
@@ -448,7 +448,7 @@ export function evaluateDeformTransform(
448
448
  if (fromDepth && depth === null) {
449
449
  throw new CompileError(
450
450
  `${where}: transform ${kind} says "depth": true and this attachment has no depth map. The map is named on ` +
451
- 'the attachment\'s generator, as `"depth": { "image": …, "near": …, "zScale": … }`, because it describes ' +
451
+ 'an authored attachment or its contour/grid generator, as `"depth": { "image": …, "near": …, "zScale": … }`, because it describes ' +
452
452
  'the art rather than this key. Name it there, or give this key a radius.', 'CE_DEFORM_DEPTH_MAP_REQUIRED',
453
453
  );
454
454
  }
package/src/mesh.ts CHANGED
@@ -930,7 +930,9 @@ export function segmentsMeet(
930
930
  const o2 = o(a, b, d);
931
931
  const o3 = o(c, d, a);
932
932
  const o4 = o(c, d, b);
933
- if (o1 !== o2 && o3 !== o4) return true;
933
+ // An epsilon-zero orientation is not a strict side of a line. In that case
934
+ // only the endpoint tests below can establish contact (including the bounds).
935
+ if (o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0 && o1 !== o2 && o3 !== o4) return true;
934
936
  return onSegment(a, b, c) || onSegment(a, b, d) || onSegment(c, d, a) || onSegment(c, d, b);
935
937
  }
936
938
 
@@ -107,6 +107,7 @@ import {
107
107
  type SourceMesh,
108
108
  type WorstSample,
109
109
  } from './meshquality.ts';
110
+ import { createHash } from 'node:crypto';
110
111
  import { areaBand, stretchSingularValues, triangleAreas } from './areaband.ts';
111
112
  import { uvCarriers, uvCarriersWith, type Carrier, type CarrierPlant } from './meshcarriers.ts';
112
113
  import { CoreInputError, readModel, underNoSkin, underSkin, type CompiledDocument } from './core/index.ts';
@@ -187,6 +188,12 @@ export interface MotionComparisonInput {
187
188
  overBound?: OverBoundRequest;
188
189
  }
189
190
 
191
+ /** The candidate list is supplied on each use; every other input is bound to the reference. */
192
+ export type MotionReferenceInput = Omit<MotionComparisonInput, 'candidates'>;
193
+
194
+ /** An opaque reference prepared by {@link prepareMotionReference}. Its data is held outside the returned object. */
195
+ export interface PreparedMotionReference { readonly kind: 'prepared-motion-reference/1' }
196
+
190
197
  /**
191
198
  * Issue #1291's faults in carrying `motionAmplitude` into the setup measurement, for the `mesh-compare` suite's
192
199
  * negative controls: the amplitude not handed over; `null` handed over as the field left out; the field not validated
@@ -896,6 +903,110 @@ function foldingSlots(raw: unknown, who: string): Set<string> {
896
903
  return new Set(list as string[]);
897
904
  }
898
905
 
906
+ type GeometryRead = { geometry: EvidenceSection | null; counts: MeshCounts | null };
907
+ type MotionRead = { section: EvidenceSection & { schedule: ScheduleUsed }; perFrame: NonNullable<CandidateReport['perFrame']>; overBound: OverBoundReport | null } | null;
908
+ interface PreparedMotionData {
909
+ frames: Planned[];
910
+ walks: Walk[];
911
+ folding: Set<string>;
912
+ scheduleUsed: ScheduleUsed;
913
+ samplesOf: Array<{ samples: Sample[]; art: number }>;
914
+ refCarriers: Array<Array<Carrier | null>>;
915
+ posedRef: Posed | { refused: string };
916
+ }
917
+ interface PreparedState {
918
+ reference: Build;
919
+ refs: AttachmentRef[];
920
+ order: number[];
921
+ refGeometry: GeometryRead;
922
+ motionData: PreparedMotionData | null;
923
+ referenceMotion: MotionRead;
924
+ digests: Record<string, string>;
925
+ }
926
+
927
+ const preparedReferences = new WeakMap<PreparedMotionReference, PreparedState>();
928
+
929
+ /** One digest per bound field, so a mismatch names the field instead of accepting a stale pose or mask. */
930
+ function boundDigests(input: MotionReferenceInput): Record<string, string> {
931
+ // JSON.stringify maps -0 to 0, drops undefined object fields, and maps non-finite numbers to null. A bound
932
+ // option's signed zero can survive validation and is echoed in a report, so these are not safe digest aliases.
933
+ const exact = (value: unknown, visiting = new Set<object>()): unknown => {
934
+ if (value === null) return ['null'];
935
+ if (typeof value === 'number') return ['number', Object.is(value, -0) ? '-0' : String(value)];
936
+ if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'undefined') return [typeof value, value ?? null];
937
+ if (typeof value !== 'object') return [typeof value, String(value)];
938
+ if (visiting.has(value)) throw new Error('cyclic bound input');
939
+ visiting.add(value);
940
+ const encoded = Array.isArray(value)
941
+ ? ['array', Array.from({ length: value.length }, (_, i) => Object.prototype.hasOwnProperty.call(value, i) ? exact(value[i], visiting) : ['hole'])]
942
+ : ['object', Object.keys(value).map((key) => [key, exact((value as Record<string, unknown>)[key], visiting)])];
943
+ visiting.delete(value);
944
+ return encoded;
945
+ };
946
+ const digest = (value: unknown): string => createHash('sha256').update(JSON.stringify(exact(value))).digest('hex');
947
+ return {
948
+ reference: digest(input.reference),
949
+ schedule: digest(input.schedule),
950
+ attachments: digest(input.attachments.map((a) => ({
951
+ attachment: a.attachment,
952
+ art: { threshold: a.art.threshold, frame: a.art.frame, mask: {
953
+ width: a.art.mask.width,
954
+ height: a.art.mask.height,
955
+ alpha: createHash('sha256').update(a.art.mask.alpha).digest('hex'),
956
+ } },
957
+ finalThreshold: a.finalThreshold,
958
+ minArtSamples: a.minArtSamples,
959
+ regions: a.regions,
960
+ }))),
961
+ referenceArtFit: digest(input.referenceArtFit),
962
+ candidateArtFit: digest(input.candidateArtFit),
963
+ bounds: digest(input.bounds),
964
+ motionRequired: digest(input.motionRequired),
965
+ perFrame: digest(input.perFrame),
966
+ motionAmplitude: digest({ present: Object.prototype.hasOwnProperty.call(input, 'motionAmplitude'), value: input.motionAmplitude }),
967
+ overBound: digest({ present: Object.prototype.hasOwnProperty.call(input, 'overBound'), value: input.overBound }),
968
+ };
969
+ }
970
+
971
+ /** Prepare the source once for sequential candidates on the same schedule and the same art. */
972
+ export function prepareMotionReference(input: MotionReferenceInput): PreparedMotionReference {
973
+ if (!isObject(input)) missing('the prepared reference input', input, 'a MotionReferenceInput object');
974
+ const ref: unknown = input.reference;
975
+ let id = 'prepared-candidate';
976
+ while (isObject(ref) && id === ref.id) id += '-';
977
+ const model = isObject(ref) && typeof ref.model === 'string' ? ref.model : '';
978
+ validateInput({ ...input, candidates: [{ id, model }] }, null);
979
+ // No caller-owned array enters the cache: a later mutation is detected against this snapshot at use.
980
+ let snapshot: MotionReferenceInput;
981
+ try { snapshot = structuredClone(input); }
982
+ catch (e) { refuse('COMPARE_INPUT_MISSING', `the prepared reference input cannot be copied (${(e as Error).message}); required cloneable model, schedule, art and options`); }
983
+ let state: PreparedState | null = null;
984
+ const asComparison: MotionComparisonInput = { ...snapshot, candidates: [{ id, model: snapshot.reference.model }] };
985
+ compareMeshesInMotionWith(asComparison, null, undefined, (ready) => { state = ready; });
986
+ if (state === null) throw new Error('prepareMotionReference: reference preparation produced no state');
987
+ const ready: PreparedState = state;
988
+ try { ready.digests = boundDigests(snapshot); }
989
+ catch (e) { refuse('COMPARE_INPUT_MISSING', `the prepared reference input cannot be digested (${(e as Error).message}); required a complete mask and comparison options`); }
990
+ const handle: PreparedMotionReference = Object.freeze({ kind: 'prepared-motion-reference/1' });
991
+ preparedReferences.set(handle, ready);
992
+ return handle;
993
+ }
994
+
995
+ /** Compare a candidate against the prepared source; every bound input must still have the prepared bytes. */
996
+ export function comparePreparedMotion(handle: PreparedMotionReference, input: MotionComparisonInput): MeshQualityReport {
997
+ const ready = preparedReferences.get(handle);
998
+ if (ready === undefined) refuse('COMPARE_PREPARED_HANDLE', 'the prepared reference handle was not made by prepareMotionReference; required its opaque handle');
999
+ let now: Record<string, string>;
1000
+ try { now = boundDigests(input); }
1001
+ catch { refuse('COMPARE_PREPARED_MISMATCH', 'the bound input cannot be digested; required the reference, schedule, attachments and options used to prepare the handle'); }
1002
+ for (const [field, digest] of Object.entries(ready.digests)) {
1003
+ if (now[field] !== digest) refuse('COMPARE_PREPARED_MISMATCH', `${field} changed after prepareMotionReference; required the same reference, schedule, art and options for this handle`);
1004
+ }
1005
+ const report = compareMeshesInMotionWith(input, null, ready);
1006
+ if (report === null) throw new Error('comparePreparedMotion: comparison produced no report');
1007
+ return report;
1008
+ }
1009
+
899
1010
  /**
900
1011
  * Compare one or more candidate builds with a reference build in motion — §3
901
1012
  * and §4's motion rows — and measure each build's art fit at the setup pose
@@ -907,7 +1018,9 @@ function foldingSlots(raw: unknown, who: string): Set<string> {
907
1018
  * undercut at the setup pose fails `referenceArtFit`, and `measureMeshQuality`'s own codes for the art).
908
1019
  */
909
1020
  export function compareMeshesInMotion(input: MotionComparisonInput): MeshQualityReport {
910
- return compareMeshesInMotionWith(input, null);
1021
+ const report = compareMeshesInMotionWith(input, null);
1022
+ if (report === null) throw new Error('compareMeshesInMotion: comparison produced no report');
1023
+ return report;
911
1024
  }
912
1025
 
913
1026
  /**
@@ -916,18 +1029,20 @@ export function compareMeshesInMotion(input: MotionComparisonInput): MeshQuality
916
1029
  * surface*).
917
1030
  */
918
1031
  export function compareMeshesInMotionPlanted(input: MotionComparisonInput, plant: ComparePlant): MeshQualityReport {
919
- return compareMeshesInMotionWith(input, plant);
1032
+ const report = compareMeshesInMotionWith(input, plant);
1033
+ if (report === null) throw new Error('compareMeshesInMotionPlanted: comparison produced no report');
1034
+ return report;
920
1035
  }
921
1036
 
922
- function compareMeshesInMotionWith(input: MotionComparisonInput, plant: ComparePlant | null): MeshQualityReport {
1037
+ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: ComparePlant | null, prepared?: PreparedState, capture?: (state: PreparedState) => void): MeshQualityReport | null {
923
1038
  validateInput(input, plant);
924
1039
  const amplitude = input.motionAmplitude;
925
1040
  // What the setup measurement is handed: the field as the caller set it — left out as left out, `null` as `null`.
926
1041
  const handed: { motionAmplitude?: MotionAmplitude | null } =
927
1042
  plant === 'amplitude-not-carried' || amplitude === undefined || (plant === 'null-read-as-left-out' && amplitude === null) ? {} : { motionAmplitude: amplitude };
928
- const refs = input.attachments.map((a) => a.attachment);
929
- const reference = readBuild(input.reference, 'reference', refs);
930
- const candidates = input.candidates.map((c, i) => readBuild(c, `candidates[${i}]`, refs));
1043
+ const refs = prepared?.refs ?? input.attachments.map((a) => a.attachment);
1044
+ const reference = prepared?.reference ?? readBuild(input.reference, 'reference', refs);
1045
+ const candidates = capture === undefined ? input.candidates.map((c, i) => readBuild(c, `candidates[${i}]`, refs)) : [];
931
1046
  for (const c of candidates) {
932
1047
  const d = firstDifference(reference.raw, c.raw, [], refs);
933
1048
  if (d !== null) {
@@ -939,13 +1054,13 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
939
1054
  }
940
1055
  // The slots in skeleton order fix the order attachments are reported in (§2).
941
1056
  const slotRank = new Map(reference.doc.slots.map((s, i) => [s.name, i]));
942
- const order = input.attachments.map((_a, i) => i).sort((p, q) => (slotRank.get(refs[p].slot) ?? 0) - (slotRank.get(refs[q].slot) ?? 0) || (refs[p].attachment < refs[q].attachment ? -1 : refs[p].attachment > refs[q].attachment ? 1 : 0));
1057
+ const order = prepared?.order ?? input.attachments.map((_a, i) => i).sort((p, q) => (slotRank.get(refs[p].slot) ?? 0) - (slotRank.get(refs[q].slot) ?? 0) || (refs[p].attachment < refs[q].attachment ? -1 : refs[p].attachment > refs[q].attachment ? 1 : 0));
943
1058
  for (const ref of refs) {
944
1059
  if (!slotRank.has(ref.slot)) missing(`attachment ${nameOf(ref)}'s slot`, ref.slot, `one of the reference's slots [${[...slotRank.keys()].join(', ')}]`);
945
1060
  }
946
1061
 
947
1062
  // --- the setup art fit, each build under its own bounds (§3 (a)) ----------------------------
948
- const geometryOf = (b: Build, fit: ArtFitBounds): { geometry: EvidenceSection | null; counts: MeshCounts | null } => {
1063
+ const geometryOf = (b: Build, fit: ArtFitBounds): GeometryRead => {
949
1064
  const sections: EvidenceSection[] = [];
950
1065
  const counts: MeshCounts[] = [];
951
1066
  for (const i of order) {
@@ -987,7 +1102,7 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
987
1102
  };
988
1103
  return { geometry: joinSections(sections), counts: total };
989
1104
  };
990
- const refGeometry = geometryOf(reference, input.referenceArtFit);
1105
+ const refGeometry = prepared?.refGeometry ?? geometryOf(reference, input.referenceArtFit);
991
1106
  // §3 *Independent evidence* (a), P8: a reference that fails its own art fit — coverage, overshoot or undercut against
992
1107
  // its own mask under `referenceArtFit` — is refused AS A REFERENCE: a deviation from a reference that does not carry
993
1108
  // its own art is not evidence, so no candidate is measured against it. (a) is those three rows and no other: winding
@@ -1006,24 +1121,26 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1006
1121
 
1007
1122
  // --- motion ---------------------------------------------------------------------------------
1008
1123
  const schedule = input.schedule;
1009
- type Motion = { section: EvidenceSection & { schedule: ScheduleUsed }; perFrame: NonNullable<CandidateReport['perFrame']>; overBound: OverBoundReport | null } | null;
1010
1124
  // Issue #1315: the request as the caller set it; `?? undefined` only matters under the unvalidated plant.
1011
1125
  const overBound: OverBoundRequest | undefined = input.overBound ?? undefined;
1012
- let motionOf: (b: Build, isReference: boolean) => Motion = () => null;
1126
+ let motionOf: (b: Build, isReference: boolean) => MotionRead = () => null;
1127
+ let motionData: PreparedMotionData | null = null;
1013
1128
  if (schedule !== null) {
1014
- const physicsConstraints = reference.doc.constraints.filter((c) => c.kind === 'physics').length;
1015
- const { frames, walks, walkSteps } = planSchedule(schedule, reference.doc, physicsConstraints);
1016
- const folding = foldingSlots(reference.raw, `reference "${reference.id}"`);
1017
- const scheduleUsed: ScheduleUsed = {
1129
+ const existing = prepared?.motionData;
1130
+ const planned = existing === undefined ? planSchedule(schedule, reference.doc, reference.doc.constraints.filter((c) => c.kind === 'physics').length) : null;
1131
+ const frames = existing?.frames ?? planned!.frames;
1132
+ const walks = existing?.walks ?? planned!.walks;
1133
+ const folding = existing?.folding ?? foldingSlots(reference.raw, `reference "${reference.id}"`);
1134
+ const scheduleUsed: ScheduleUsed = existing?.scheduleUsed ?? {
1018
1135
  ...schedule,
1019
1136
  walked: frames.map((f) => f.ref),
1020
1137
  roles: (['baseline', 'selection', 'held-out'] as const).filter((r) => frames.some((f) => f.ref.role === r)),
1021
1138
  heldOutClaim: frames.some((f) => f.ref.role === 'held-out'),
1022
1139
  reset: 'physics reset at time 0',
1023
- walks: walks.map((w, i) => ({ animation: w.animation, phase: w.phase, steps: walkSteps[i] })),
1140
+ walks: walks.map((w, i) => ({ animation: w.animation, phase: w.phase, steps: planned!.walkSteps[i] })),
1024
1141
  };
1025
1142
  // The samples, per attachment: art pixel centres at the final threshold, then the reference hull's UVs (§3).
1026
- const samplesOf = order.map((i) => {
1143
+ const samplesOf = existing?.samplesOf ?? order.map((i) => {
1027
1144
  const a = input.attachments[i];
1028
1145
  const { mask, frame } = a.art;
1029
1146
  const bits = artOf(mask, a.finalThreshold);
@@ -1046,9 +1163,10 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1046
1163
  const carrierPlant: CarrierPlant | null = plant === 'slack-support-unread' || plant === 'narrowest-slack' || plant === 'any-shared-vertex' ? plant : null;
1047
1164
  const carriersOf = (uvs: readonly number[], triangles: readonly number[], samples: ReadonlyArray<{ uv: [number, number] }>, who: string): Array<Carrier | null> =>
1048
1165
  uvCarriersWith(uvs, triangles, samples, who, carrierPlant);
1049
- const refCarriers = order.map((i, k) => carriersOf(reference.meshes[i].uvs, reference.meshes[i].triangles, samplesOf[k].samples, `reference "${reference.id}", attachment ${nameOf(refs[i])}`));
1166
+ const refCarriers = existing?.refCarriers ?? order.map((i, k) => carriersOf(reference.meshes[i].uvs, reference.meshes[i].triangles, samplesOf[k].samples, `reference "${reference.id}", attachment ${nameOf(refs[i])}`));
1050
1167
  const clock = plant === 'frame-physics-clock' ? 'frame' : 'render';
1051
- const posedRef = poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks, clock);
1168
+ const posedRef = existing?.posedRef ?? poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks, clock);
1169
+ motionData = { frames, walks, folding, scheduleUsed, samplesOf, refCarriers, posedRef };
1052
1170
 
1053
1171
  motionOf = (b: Build, isReference: boolean) => {
1054
1172
  const carriers = isReference ? refCarriers : order.map((i, k) => carriersOf(b.meshes[i].uvs, b.meshes[i].triangles, samplesOf[k].samples, `candidate "${b.id}", attachment ${nameOf(refs[i])}`));
@@ -1311,9 +1429,13 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1311
1429
  return { section: { ...sectionOf(grouped), schedule: scheduleUsed }, perFrame, overBound: located };
1312
1430
  };
1313
1431
  }
1314
- const referenceMotion = motionOf(reference, true);
1432
+ const referenceMotion = prepared?.referenceMotion ?? motionOf(reference, true);
1433
+ if (capture !== undefined) {
1434
+ capture({ reference, refs, order, refGeometry, motionData, referenceMotion, digests: {} });
1435
+ return null;
1436
+ }
1315
1437
  const candidateMotions = candidates.map((c) => motionOf(c, false));
1316
- const reportOf = (b: Build, geometry: { geometry: EvidenceSection | null; counts: MeshCounts | null }, measured: Motion, isCandidate: boolean): CandidateReport => {
1438
+ const reportOf = (b: Build, geometry: GeometryRead, measured: MotionRead, isCandidate: boolean): CandidateReport => {
1317
1439
  const motion = measured === null ? null : measured.section;
1318
1440
  // P6: the geometry verdict pass (every required row measured and passing), and — when motion is required — the motion verdict pass.
1319
1441
  const accepted = geometry.geometry !== null && geometry.geometry.verdict === 'pass' && (!input.motionRequired || (motion !== null && motion.verdict === 'pass'));
@@ -1354,7 +1476,7 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1354
1476
  : {}
1355
1477
  : { overBound: { maxSamples: overBound.maxSamples, triangles: overBound.triangles } }),
1356
1478
  };
1357
- return {
1479
+ const report: MeshQualityReport = {
1358
1480
  spec: MESH_QUALITY_REPORT_SPEC,
1359
1481
  operation: 'compare',
1360
1482
  effective,
@@ -1365,4 +1487,6 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
1365
1487
  candidates: candidates.map((c, i) => reportOf(c, candGeometry[i], candidateMotions[i], true)),
1366
1488
  termination: null,
1367
1489
  };
1490
+ // The prepared geometry, pose and reference rows live across calls. A caller may edit a returned report.
1491
+ return prepared === undefined ? report : structuredClone(report);
1368
1492
  }
package/src/rig.ts CHANGED
@@ -471,8 +471,8 @@ export interface RigDepthMap {
471
471
  */
472
472
  near: 'white' | 'black';
473
473
  /**
474
- * How many world units the map's full 0..1 range spans, in the attachment's
475
- * own units — the number `radius` used to carry.
474
+ * How many units the map's full 0..1 range spans: setup world units on an
475
+ * authored mesh, the generator's bind units on a generated mesh.
476
476
  *
477
477
  * Authored, never measured: 8 bits of level say nothing about scale, so a
478
478
  * compiler that picked one would be inventing the depth of the art.
@@ -971,6 +971,8 @@ export interface RigMeshAttachment {
971
971
  color?: string;
972
972
  /** Build the geometry instead of authoring it — see `RigMeshGenerator`. */
973
973
  generator?: RigMeshGenerator;
974
+ /** A setup-world depth turn over authored geometry; sampled in the part's pixel grid. */
975
+ depth?: RigDepthMap;
974
976
  /** A numbered image series over this one triangulation — see `RigSequence`. */
975
977
  sequence?: RigSequence;
976
978
  }
@@ -1122,7 +1124,26 @@ export interface RigBoundingBoxAttachment extends RigVertexGeometry {
1122
1124
  * never ends — it runs to the bottom of the draw order and takes every slot
1123
1125
  * below it with it. rigc refuses a name the rig does not declare.
1124
1126
  */
1125
- export interface RigClippingAttachment extends RigVertexGeometry {
1127
+ /** A clipping outline measured from one named attachment's plate. No inferred parameters. */
1128
+ export interface RigAlphaClip {
1129
+ slot: string;
1130
+ skin: string;
1131
+ attachment: string;
1132
+ alpha: number;
1133
+ tolerance: number;
1134
+ maxVertices: number;
1135
+ }
1136
+
1137
+ export interface RigClippingAttachment {
1138
+ /** Authored geometry uses the same named/raw contract as RigVertexGeometry. */
1139
+ vertices?: number[];
1140
+ weights?: RigMeshBinding[][];
1141
+ boneIndexing?: 'name' | 'raw';
1142
+ color?: string;
1143
+ /** Required for authored geometry; derived for alphaClip. */
1144
+ vertexCount?: number;
1145
+ /** Exclusive with authored vertexCount/vertices/weights/boneIndexing. */
1146
+ alphaClip?: RigAlphaClip;
1126
1147
  type: 'clipping';
1127
1148
  /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
1128
1149
  name?: RigAttachmentName;
@@ -2077,7 +2098,7 @@ export const RIG_KEYS = {
2077
2098
  RigRegionAttachment: ['type', 'name', 'path', 'image', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'width', 'height', 'color', 'sequence'],
2078
2099
  RigMeshAttachment: [
2079
2100
  'type', 'name', 'path', 'image', 'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges',
2080
- 'width', 'height', 'color', 'generator', 'sequence',
2101
+ 'width', 'height', 'color', 'generator', 'depth', 'sequence',
2081
2102
  ],
2082
2103
  RigLinkedMeshAttachment: [
2083
2104
  'type', 'name', 'path', 'image', 'source', 'slot', 'skin', 'timelines', 'width', 'height', 'color', 'sequence',
@@ -2087,7 +2108,8 @@ export const RIG_KEYS = {
2087
2108
  RigMeshBinding: ['bone', 'x', 'y', 'weight'],
2088
2109
  RigSequence: ['count', 'start', 'digits', 'setup'],
2089
2110
  RigBoundingBoxAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name'],
2090
- RigClippingAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'end', 'convex', 'inverse'],
2111
+ RigClippingAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'end', 'convex', 'inverse', 'alphaClip'],
2112
+ RigAlphaClip: ['slot', 'skin', 'attachment', 'alpha', 'tolerance', 'maxVertices'],
2091
2113
  RigPathAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'closed', 'constantSpeed', 'lengths'],
2092
2114
  RigRingGenerator: ['kind', 'hull', 'center', 'inner', 'size', 'bias', 'controls'],
2093
2115
  RigRibbonGenerator: ['kind', 'size', 'rows', 'chain'],
@@ -2201,7 +2223,7 @@ export const RIG_TYPES = {
2201
2223
  RigMeshAttachment: {
2202
2224
  type: 'enum', name: 'string', path: 'string', image: 'string', uvs: 'number[]', triangles: 'number[]',
2203
2225
  vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', hull: 'number', edges: 'number[]',
2204
- width: 'number', height: 'number', color: 'string', generator: 'object', sequence: 'object',
2226
+ width: 'number', height: 'number', color: 'string', generator: 'object', depth: 'object', sequence: 'object',
2205
2227
  },
2206
2228
  RigLinkedMeshAttachment: {
2207
2229
  type: 'enum', name: 'string', path: 'string', image: 'string', source: 'string', slot: 'string', skin: 'string',
@@ -2216,8 +2238,9 @@ export const RIG_TYPES = {
2216
2238
  },
2217
2239
  RigClippingAttachment: {
2218
2240
  vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
2219
- end: 'string', convex: 'boolean', inverse: 'boolean',
2241
+ end: 'string', convex: 'boolean', inverse: 'boolean', alphaClip: 'object',
2220
2242
  },
2243
+ RigAlphaClip: { slot: 'string', skin: 'string', attachment: 'string', alpha: 'number', tolerance: 'number', maxVertices: 'number' },
2221
2244
  RigPathAttachment: {
2222
2245
  vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
2223
2246
  closed: 'boolean', constantSpeed: 'boolean', lengths: 'number[]',
@@ -2548,6 +2571,7 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
2548
2571
  }
2549
2572
  const here = [...base, slot, placeholder];
2550
2573
  at(att, shape, `${who} (${type})`, here);
2574
+ if (type === 'clipping') at(att.alphaClip, 'RigAlphaClip', `${who} alphaClip`, [...here, 'alphaClip']);
2551
2575
  if (att.sequence !== undefined) {
2552
2576
  at(att.sequence, 'RigSequence', `${who} "sequence"`, [...here, 'sequence']);
2553
2577
  checkRigSequence(att, who, where);
@@ -2557,6 +2581,7 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
2557
2581
  at(binding, 'RigMeshBinding', `${who} weights[${i}][${j}]`, [...here, 'weights', i, j]);
2558
2582
  }
2559
2583
  }
2584
+ at(att.depth, 'RigDepthMap', `${who} depth`, [...here, 'depth']);
2560
2585
  if (!isObj(att.generator)) continue;
2561
2586
  const gen = att.generator;
2562
2587
  const kind = RIG_GENERATOR_KINDS.find((k) => k === gen.kind);
@@ -21,7 +21,7 @@
21
21
  * corpus through both at the oracle's samples and compares full doubles at
22
22
  * tolerance 0 (*The runtime poser* there). The oracle is not changed for it.
23
23
  *
24
- * ## Stage 0: what it poses, and what it refuses
24
+ * ## Stage 1: what it poses, and what it refuses
25
25
  *
26
26
  * Bones and their five inherit modes; the rotate, translate, scale and shear
27
27
  * timelines; a slot's attachment and colour (`rgba`, `rgb`, `alpha`)
@@ -32,10 +32,14 @@
32
32
  * looping track. Everything else is refused at load by name with the list of
33
33
  * what is missing (`./model.ts`'s header and `RUNTIME_FEATURES`); a consumer
34
34
  * falls back to the oracle for a refused model or animation, so nothing breaks
35
- * while coverage grows.
35
+ * while coverage grows. Named skin views and their required bones are posed;
36
+ * to switch, compile the new underSkin/underNoSkin view at load and restore the
37
+ * animation/time and event cursor with newPose.restore(name, old.trackTime,
38
+ * old.time). Restoration delivers no already-passed cue. `pose.events` borrows immutable seven-field
39
+ * deliveries over the last interval, in oracle order, until the next apply/step.
36
40
  *
37
41
  * ```ts
38
- * const model = compileRuntime(underNoSkin(readModel(text)), { ignore: ['events'] });
42
+ * const model = compileRuntime(underNoSkin(readModel(text)));
39
43
  * const pose = new RuntimePose(model);
40
44
  * pose.setAnimation('walk');
41
45
  * pose.step(1 / 60); // or pose.apply(t)
@@ -76,5 +80,5 @@ export {
76
80
  L_SHEAR_X,
77
81
  L_SHEAR_Y,
78
82
  } from './model.ts';
79
- export type { RuntimeAnimation, RuntimeAttachment, RuntimeFeature, RuntimeMissing, RuntimeModel, RuntimeOptions, RuntimePlant, RuntimeSlot } from './model.ts';
83
+ export type { RuntimeAnimation, RuntimeEvent, RuntimeAttachment, RuntimeFeature, RuntimeMissing, RuntimeModel, RuntimeOptions, RuntimePlant, RuntimeSlot } from './model.ts';
80
84
  export { RuntimePose } from './pose.ts';