@rig-c/core 2.70.0 → 2.72.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/depth.ts CHANGED
@@ -211,8 +211,8 @@ export function depthDigest(map: DepthMap): string {
211
211
  * be a different model. `u` is the point's offset from the axis; `z` is how far
212
212
  * in front of that axis it sits.
213
213
  */
214
- export function turnDisplacement(u: number, z: number, radians: number): number {
215
- return u * (Math.cos(radians) - 1) - z * Math.sin(radians);
214
+ export function turnDisplacement(u: number, z: number, radians: number, narrowing = true): number {
215
+ return (narrowing ? u * (Math.cos(radians) - 1) : 0) - z * Math.sin(radians);
216
216
  }
217
217
 
218
218
  /** What a field comparison measured. Pixels, in the part's own grid. */
@@ -676,10 +676,16 @@ export function turnCeiling(
676
676
  points: ReadonlyArray<readonly [number, number]>,
677
677
  z: readonly number[],
678
678
  triangles: ReadonlyArray<number>,
679
+ options?: { narrowing?: boolean | { yaw?: boolean; pitch?: boolean } },
679
680
  ): TurnCeiling {
680
681
  if (z.length !== points.length) {
681
682
  throw new DepthError(`the mesh has ${points.length} vertices and ${z.length} depths were supplied for it`);
682
683
  }
684
+ const narrowingOpt = options?.narrowing;
685
+ const narrowingByAxis = {
686
+ yaw: typeof narrowingOpt === 'object' ? narrowingOpt.yaw !== false : narrowingOpt !== false,
687
+ pitch: typeof narrowingOpt === 'object' ? narrowingOpt.pitch !== false : narrowingOpt !== false,
688
+ };
683
689
  const out: TurnCeiling = {
684
690
  yaw: { positive: null, negative: null },
685
691
  pitch: { positive: null, negative: null },
@@ -745,10 +751,17 @@ export function turnCeiling(
745
751
  ['pitch', dxb * dzc - dxc * dzb],
746
752
  ] as const) {
747
753
  // A zero here is a triangle the axis cannot fold at all: its area stays
748
- // `A₀·cos t`, which only reaches zero at a right angle.
754
+ // `A₀·cos t` (or `A₀` when `narrowing` is false), which only reaches zero
755
+ // at a right angle or never.
749
756
  if (aAxis === 0) continue;
750
757
  const ratio = a0 / aAxis;
751
- const degrees = (Math.atan(Math.abs(ratio)) * 180) / Math.PI;
758
+ const narrowing = narrowingByAxis[axis];
759
+ // With `narrowing: false`, `2A(t) = 2A₀ − 2A_axis·sin t`, which crosses
760
+ // zero at `sin t = A₀ / A_axis = ratio`. A ratio with `|ratio| >= 1` never
761
+ // crosses zero short of 90° (`|sin t| < 1` on `(0, 90°)`), so it is
762
+ // skipped for the same reason `aAxis === 0` is.
763
+ if (!narrowing && Math.abs(ratio) >= 1) continue;
764
+ const degrees = ((narrowing ? Math.atan(Math.abs(ratio)) : Math.asin(Math.abs(ratio))) * 180) / Math.PI;
752
765
  const side = ratio > 0 ? 'positive' : 'negative';
753
766
  angles[axis][side].push(degrees);
754
767
  const held = out[axis][side];
@@ -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/motion.ts CHANGED
@@ -146,8 +146,8 @@ export const MOTION_KEYS = {
146
146
  MotionDrawOrderKey: ['t', 'offsets'],
147
147
  MotionDrawOrderOffset: ['slot', 'offset'],
148
148
  MotionEventKey: ['t', 'name', 'int', 'float', 'string', 'volume', 'balance'],
149
- TrackDeriveTurn: ['kind', 'degrees', 'depth', 'carried', 'about'],
150
- DeformTurn: ['kind', 'radius', 'depth', 'degrees', 'about', 'envelope'],
149
+ TrackDeriveTurn: ['kind', 'degrees', 'depth', 'carried', 'about', 'narrowing'],
150
+ DeformTurn: ['kind', 'radius', 'depth', 'degrees', 'about', 'narrowing', 'envelope'],
151
151
  DeformAffine: ['kind', 'scale', 'about', 'envelope'],
152
152
  DeformWave: ['kind', 'amplitude', 'wavelength', 'phase', 'along', 'axis', 'envelope'],
153
153
  DeformBend: ['kind', 'amount', 'from', 'to', 'power', 'along', 'axis', 'envelope'],
@@ -220,8 +220,8 @@ export const MOTION_TYPES = {
220
220
  MotionEventKey: {
221
221
  t: 'number', name: 'string', int: 'number', float: 'number', string: 'string', volume: 'number', balance: 'number',
222
222
  },
223
- TrackDeriveTurn: { kind: 'enum', degrees: 'number', depth: 'mixed', carried: 'number', about: 'number' },
224
- DeformTurn: { kind: 'enum', radius: 'number', depth: 'boolean', degrees: 'number', about: 'number', envelope: 'object' },
223
+ TrackDeriveTurn: { kind: 'enum', degrees: 'number', depth: 'mixed', carried: 'number', about: 'number', narrowing: 'boolean' },
224
+ DeformTurn: { kind: 'enum', radius: 'number', depth: 'boolean', degrees: 'number', about: 'number', narrowing: 'boolean', envelope: 'object' },
225
225
  DeformAffine: { kind: 'enum', scale: 'number[]', about: 'number[]', envelope: 'object' },
226
226
  DeformWave: {
227
227
  kind: 'enum', amplitude: 'number', wavelength: 'number', phase: 'number', along: 'enum', axis: 'enum', envelope: 'object',
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
  }
@@ -2077,7 +2079,7 @@ export const RIG_KEYS = {
2077
2079
  RigRegionAttachment: ['type', 'name', 'path', 'image', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'width', 'height', 'color', 'sequence'],
2078
2080
  RigMeshAttachment: [
2079
2081
  'type', 'name', 'path', 'image', 'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges',
2080
- 'width', 'height', 'color', 'generator', 'sequence',
2082
+ 'width', 'height', 'color', 'generator', 'depth', 'sequence',
2081
2083
  ],
2082
2084
  RigLinkedMeshAttachment: [
2083
2085
  'type', 'name', 'path', 'image', 'source', 'slot', 'skin', 'timelines', 'width', 'height', 'color', 'sequence',
@@ -2201,7 +2203,7 @@ export const RIG_TYPES = {
2201
2203
  RigMeshAttachment: {
2202
2204
  type: 'enum', name: 'string', path: 'string', image: 'string', uvs: 'number[]', triangles: 'number[]',
2203
2205
  vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', hull: 'number', edges: 'number[]',
2204
- width: 'number', height: 'number', color: 'string', generator: 'object', sequence: 'object',
2206
+ width: 'number', height: 'number', color: 'string', generator: 'object', depth: 'object', sequence: 'object',
2205
2207
  },
2206
2208
  RigLinkedMeshAttachment: {
2207
2209
  type: 'enum', name: 'string', path: 'string', image: 'string', source: 'string', slot: 'string', skin: 'string',
@@ -2557,6 +2559,7 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
2557
2559
  at(binding, 'RigMeshBinding', `${who} weights[${i}][${j}]`, [...here, 'weights', i, j]);
2558
2560
  }
2559
2561
  }
2562
+ at(att.depth, 'RigDepthMap', `${who} depth`, [...here, 'depth']);
2560
2563
  if (!isObj(att.generator)) continue;
2561
2564
  const gen = att.generator;
2562
2565
  const kind = RIG_GENERATOR_KINDS.find((k) => k === gen.kind);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * A keyed timeline compiled once for the runtime poser (issue #1591): its key
3
+ * times and channel values as the oracle stores them (float32, `CoreKey.time`
4
+ * and `.values`), each segment's curve kind, and every Bézier segment's
5
+ * polyline evaluated at load by the oracle's own `bezierPolyline`, so that a
6
+ * pose evaluates the segment the oracle evaluates and nothing else.
7
+ *
8
+ * The two reads a pose makes are the oracle's (`keyIndexAt` and `channelAt`
9
+ * in `src/core/animation.ts`), operation for operation:
10
+ *
11
+ * - **The key at `t`** is the last key whose time is at or before `t`, −1
12
+ * before the first. The oracle scans from key 0 every time; the runtime
13
+ * walks a per-pose cursor from where the previous pose left it, backwards
14
+ * while the key's time is after `t` and forwards while the next one's is
15
+ * not. Over key times that never decrease (the reader refuses a key not
16
+ * after the one before, and float32 can only make two equal), both find the
17
+ * same key for every finite `t`. A `t` that is not finite is refused before
18
+ * any search (`RuntimeInputError`): the scan reads −1 for NaN and the
19
+ * cursor would read wherever it stood.
20
+ * - **A channel's value** on the segment from key `i`: the key's own value on
21
+ * the last key and after a hold; `v0 + ((t − t0) / (t1 − t0)) · (v1 − v0)`
22
+ * on a linear segment; and on a Bézier segment the linear piece of the
23
+ * polyline whose end is the first point at or after `t`.
24
+ *
25
+ * Nothing here allocates once compiled.
26
+ */
27
+ import { bezierPolyline, BEZIER_PIECES, type CoreKey } from '../core/animation.ts';
28
+
29
+ /** A segment's curve, per key. */
30
+ export const CURVE_LINEAR = 0;
31
+ export const CURVE_STEPPED = 1;
32
+ export const CURVE_BEZIER = 2;
33
+
34
+ /** Numbers in one channel's polyline: the segment's two ends and the nine interior points, each a time and a value. */
35
+ export const POLYLINE = 2 * (BEZIER_PIECES + 1);
36
+
37
+ /** One compiled timeline. `values` and `polylines` are by key, then by channel. */
38
+ export interface RuntimeCurve {
39
+ readonly keys: number;
40
+ readonly channels: number;
41
+ readonly times: Float64Array;
42
+ readonly values: Float64Array;
43
+ readonly kinds: Uint8Array;
44
+ /** `POLYLINE` numbers per key and channel; filled only where the key starts a Bézier segment. */
45
+ readonly polylines: Float64Array;
46
+ }
47
+
48
+ /** A timeline's keys compiled; `channels` is how many values each key carries (0 for an attachment timeline). */
49
+ export function compileCurve(keys: readonly CoreKey[], channels: number): RuntimeCurve {
50
+ const n = keys.length;
51
+ const times = new Float64Array(n);
52
+ const values = new Float64Array(n * channels);
53
+ const kinds = new Uint8Array(n);
54
+ const polylines = new Float64Array(n * channels * POLYLINE);
55
+ for (let i = 0; i < n; i++) {
56
+ const a = keys[i];
57
+ times[i] = a.time;
58
+ for (let c = 0; c < channels; c++) values[i * channels + c] = a.values[c];
59
+ const curve = a.curve;
60
+ kinds[i] = curve === 'linear' ? CURVE_LINEAR : curve === 'stepped' ? CURVE_STEPPED : CURVE_BEZIER;
61
+ if (typeof curve === 'string' || i + 1 >= n) continue;
62
+ const b = keys[i + 1];
63
+ for (let c = 0; c < channels; c++) {
64
+ // The oracle's polyline (`channelAt`): the segment's float32 ends around the recurrence over the numbers as the document states them.
65
+ const inner = bezierPolyline(a.stated.time, a.stated.values[c], curve[c * 4], curve[c * 4 + 1], curve[c * 4 + 2], curve[c * 4 + 3], b.stated.time, b.stated.values[c]);
66
+ const o = (i * channels + c) * POLYLINE;
67
+ polylines[o] = a.time;
68
+ polylines[o + 1] = a.values[c];
69
+ for (let q = 0; q < inner.length; q++) polylines[o + 2 + q] = inner[q];
70
+ polylines[o + POLYLINE - 2] = b.time;
71
+ polylines[o + POLYLINE - 1] = b.values[c];
72
+ }
73
+ }
74
+ return { keys: n, channels, times, values, kinds, polylines };
75
+ }
76
+
77
+ /** The last key at or before `t` (−1 before the first), found from `cursors[slot]` and left there — the header's first read. */
78
+ export function keyAt(curve: RuntimeCurve, cursors: Int32Array, slot: number, t: number): number {
79
+ const times = curve.times;
80
+ let i = cursors[slot];
81
+ while (i >= 0 && times[i] > t) i--;
82
+ while (i + 1 < curve.keys && times[i + 1] <= t) i++;
83
+ cursors[slot] = i;
84
+ return i;
85
+ }
86
+
87
+ /** Channel `c` of the segment starting at key `i` (at or after 0), at `t` — the header's second read. */
88
+ export function channelValue(curve: RuntimeCurve, i: number, c: number, t: number): number {
89
+ const ch = curve.channels;
90
+ const values = curve.values;
91
+ const v0 = values[i * ch + c];
92
+ if (i + 1 >= curve.keys) return v0;
93
+ const kind = curve.kinds[i];
94
+ if (kind === CURVE_STEPPED) return v0;
95
+ if (kind === CURVE_LINEAR) {
96
+ const t0 = curve.times[i];
97
+ return v0 + ((t - t0) / (curve.times[i + 1] - t0)) * (values[(i + 1) * ch + c] - v0);
98
+ }
99
+ const p = curve.polylines;
100
+ const o = (i * ch + c) * POLYLINE;
101
+ let q = 2;
102
+ while (q < POLYLINE - 2 && p[o + q] < t) q += 2;
103
+ const x0 = p[o + q - 2];
104
+ const y0 = p[o + q - 1];
105
+ return y0 + ((t - x0) / (p[o + q] - x0)) * (p[o + q + 1] - y0);
106
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * rigc's runtime poser (issue #1591): the model document posed in place, for
3
+ * a player drawing many skeletons a frame — separate from the verification
4
+ * oracle (`src/core/`) and held equal to it at tolerance 0.
5
+ *
6
+ * ## Why there are two posers
7
+ *
8
+ * The oracle is the reference. Each of its poses is a pure function of the
9
+ * document, the skin and the time, returned as a fresh, name-keyed, detached
10
+ * record — the shape a comparison needs, and the shape the core gate holds to
11
+ * spine-core 4.3.13 to the last bit. A player that consumes that record every
12
+ * frame pays for the record: the audit behind issue #1591 measured 175–251
13
+ * KiB allocated per skeleton per frame, against under half a KiB for the
14
+ * official runtime. The runtime poser keeps one pose per skeleton, resolves
15
+ * every name to an index once at load (`compileRuntime`) and overwrites typed
16
+ * arrays in place (`RuntimePose`), so a steady-state frame allocates nothing.
17
+ *
18
+ * It does not re-derive the oracle's rules. Every formula is the oracle's, in
19
+ * the oracle's order of operations (`./pose.ts`'s header), and the gate is the
20
+ * oracle itself: `tools/core_gate.ts`'s third dumper poses every model of the
21
+ * corpus through both at the oracle's samples and compares full doubles at
22
+ * tolerance 0 (*The runtime poser* there). The oracle is not changed for it.
23
+ *
24
+ * ## Stage 1: what it poses, and what it refuses
25
+ *
26
+ * Bones and their five inherit modes; the rotate, translate, scale and shear
27
+ * timelines; a slot's attachment and colour (`rgba`, `rgb`, `alpha`)
28
+ * timelines; region, mesh, weighted mesh and linked mesh vertices; one- and
29
+ * two-bone ik and world-space, absolute transform constraints with their
30
+ * timelines; skin-required bones and constraints under the view's skin. One
31
+ * animation at full weight from the setup pose, at a time or stepped as a
32
+ * looping track. Everything else is refused at load by name with the list of
33
+ * what is missing (`./model.ts`'s header and `RUNTIME_FEATURES`); a consumer
34
+ * falls back to the oracle for a refused model or animation, so nothing breaks
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.
40
+ *
41
+ * ```ts
42
+ * const model = compileRuntime(underNoSkin(readModel(text)));
43
+ * const pose = new RuntimePose(model);
44
+ * pose.setAnimation('walk');
45
+ * pose.step(1 / 60); // or pose.apply(t)
46
+ * pose.computeVertices(); // pose.world, pose.light, pose.shown, pose.vertices
47
+ * ```
48
+ *
49
+ * This module is the `rig-c/runtime` entry (`exports` in package.json), which
50
+ * is **experimental**: not promised until the poser stabilises (RELEASING.md,
51
+ * *The import surface*). Pure, as all of `src/`, linking nothing from the Spine
52
+ * runtime package (`RP10`), and safe to bundle for a browser: its value closure
53
+ * is this directory and `src/core/` (`CUR136`).
54
+ */
55
+ export {
56
+ compileRuntime,
57
+ missingText,
58
+ RuntimeInputError,
59
+ RuntimeRefusal,
60
+ RUNTIME_FEATURES,
61
+ RUNTIME_STAGE,
62
+ DRAW_MESH,
63
+ DRAW_NONE,
64
+ DRAW_REGION,
65
+ DRAW_WEIGHTED,
66
+ LOCAL_FIELD_NAMES,
67
+ LOCALS,
68
+ WORLDS,
69
+ W_A,
70
+ W_B,
71
+ W_C,
72
+ W_D,
73
+ W_X,
74
+ W_Y,
75
+ L_X,
76
+ L_Y,
77
+ L_ROTATION,
78
+ L_SCALE_X,
79
+ L_SCALE_Y,
80
+ L_SHEAR_X,
81
+ L_SHEAR_Y,
82
+ } from './model.ts';
83
+ export type { RuntimeAnimation, RuntimeEvent, RuntimeAttachment, RuntimeFeature, RuntimeMissing, RuntimeModel, RuntimeOptions, RuntimePlant, RuntimeSlot } from './model.ts';
84
+ export { RuntimePose } from './pose.ts';