@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/docs/AUTHORING.md +222 -13
- package/package.json +2 -1
- package/src/assertions/report.ts +1 -1
- package/src/compile.ts +126 -24
- package/src/core/animation.ts +56 -24
- package/src/core/events.ts +17 -5
- package/src/core/index.ts +62 -1
- package/src/core/raw.ts +38 -12
- package/src/core/tracks.ts +23 -8
- package/src/core/vertices.ts +182 -44
- package/src/deformgen.ts +25 -9
- package/src/depth.ts +17 -4
- package/src/meshcompare.ts +146 -22
- package/src/motion.ts +4 -4
- package/src/rig.ts +7 -4
- package/src/runtime/curves.ts +106 -0
- package/src/runtime/index.ts +84 -0
- package/src/runtime/model.ts +736 -0
- package/src/runtime/pose.ts +1054 -0
- package/src/trackgen.ts +27 -4
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
|
|
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
|
|
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];
|
package/src/meshcompare.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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):
|
|
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) =>
|
|
1126
|
+
let motionOf: (b: Build, isReference: boolean) => MotionRead = () => null;
|
|
1127
|
+
let motionData: PreparedMotionData | null = null;
|
|
1013
1128
|
if (schedule !== null) {
|
|
1014
|
-
const
|
|
1015
|
-
const
|
|
1016
|
-
const
|
|
1017
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
|
475
|
-
*
|
|
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';
|