@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/core/raw.ts CHANGED
@@ -113,8 +113,8 @@
113
113
  * naming why (`CoreInputError`): a renderer handed a pose with a block
114
114
  * missing would draw a different picture in silence.
115
115
  */
116
- import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
- import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type CoreAnimationTimelines, type LocalAdjust, type TimelinePlant } from './animation.ts';
116
+ import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, shownAttachment, sourceOfDoc, underNoSkin, underSkin, viewColours, WHITE, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
+ import { posedBoneWorldAlone, posedSlots, type CoreAnimationTimelines, type LocalAdjust, type TimelinePlant } from './animation.ts';
118
118
  import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
119
119
  import { freshStepContext, rotatePhysics, steppedPreviousPassWhy, translatePhysics, DEFAULT_PHYSICS_ENVIRONMENT, type PhysicsEnvironment, type PhysicsStepContext } from './constraints_physics.ts';
120
120
  import { attachmentStates } from './deform.ts';
@@ -342,7 +342,7 @@ function scanBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>)
342
342
  */
343
343
  function drawnGeometry(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode): { rows: CoreAttachmentRow[]; clips: CoreClipRow[]; drawnClipped: CoreClippedRow[] } {
344
344
  const draw = drawWalkOf(doc, order, plant);
345
- const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices }, mode.loop && mode.live !== true && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
345
+ const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices, deformedVertices: plant.deformedVertices }, mode.loop && mode.live !== true && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
346
346
  if (geometry.attachments === null) throw new CoreInputError(`the raw pose leaves the attachments out: ${geometry.attachmentsWhy}`);
347
347
  // The drawn rows (issue #964): the oracle's `clipped` rows where it is posed, and a concave or inverse clip cut through the core's own decomposition.
348
348
  if (geometry.drawnClipped === null) throw new CoreInputError(`the raw pose leaves the clipped triangles out: ${geometry.drawnClippedWhy}`);
@@ -423,12 +423,20 @@ export interface ScanPlant {
423
423
 
424
424
  /** Every region and mesh the shown records draw, with what the renderer reads past the vertices (the header's `drawn`). */
425
425
  function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode = RAW_MODE): { drawn: RawDrawn[]; clips: RawClip[]; clipped: RawClipped[] } {
426
+ const stage = plant.stage;
427
+ if (stage !== undefined) stage.begin('geometry');
426
428
  const geometry = drawnGeometry(doc, shown, world, order, plant, mode);
429
+ if (stage !== undefined) {
430
+ stage.end('geometry');
431
+ stage.begin('readout');
432
+ }
427
433
  // Under a concave or inverse clip the oracle's `clipped` block is absent (the runtime's own triangle list); what the core draws is `drawnClipped`,
428
434
  // its own decomposition, and the render samples each drawn triangle at its source triangle's affine UV, so the pixels are the decomposition's
429
435
  // coverage alone (issue #964, `Mesh.source` in src/render.ts).
430
436
  const rows = geometry.rows;
431
437
  const drawn: RawDrawn[] = [];
438
+ // Each record's stated colour, read once per view (`viewColours`) and copied into the pose's own tuple, as the pose's arrays are its own.
439
+ const stated = viewColours(doc);
432
440
  let k = 0;
433
441
  for (const s of shown) {
434
442
  const g = s.geometry;
@@ -436,7 +444,8 @@ function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world:
436
444
  const row = rows[k++];
437
445
  if (row === undefined || row[0] !== s.slot) throw new CoreInputError(`slot "${s.slot}": the attachment rows are not in the shown records' order`);
438
446
  const record = doc.skins.find((x) => x.name === s.skin)?.attachments[s.slot]?.[s.placeholder];
439
- const colour = record?.color === undefined ? ([1, 1, 1, 1] as [number, number, number, number]) : readColour(record.color);
447
+ const read = record?.color === undefined ? WHITE : stated(record.color);
448
+ const colour: [number, number, number, number] = [read[0], read[1], read[2], read[3]];
440
449
  let uvs: readonly number[];
441
450
  let triangles: readonly number[];
442
451
  let hull: number | null = null;
@@ -454,6 +463,7 @@ function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world:
454
463
  }
455
464
  const clips = geometry.clips.map((c): RawClip => [c[0], c[1], c[2], numbersOf(c[3], `clip vertex of slot "${c[0]}"`, mode)]);
456
465
  const clipped = geometry.drawnClipped.map((c): RawClipped => [c[0], c[1], c[2], numbersOf(c[3], `clipped vertex of slot "${c[0]}"`, mode), numbersOf(c[4], `clipped uv of slot "${c[0]}"`, mode), c[5]]);
466
+ if (stage !== undefined) stage.end('readout');
457
467
  return { drawn, clips, clipped };
458
468
  }
459
469
 
@@ -785,12 +795,16 @@ interface WalkStep {
785
795
  * (`walkEach`) is one body: every number a pose carries is computed there and
786
796
  * in the two assemblers below, by the same calls, whatever the shape. A shape
787
797
  * chooses only what is assembled from what was posed — the scan drops the
788
- * readings, rows and copies nothing it reads is computed from — and keeps the
798
+ * readings and copies nothing it reads is computed from — and keeps the
789
799
  * order of every call that can refuse.
800
+ *
801
+ * No shape forms the oracle's bone rows: no walk pose carries them, and the
802
+ * rounding they are formed through refuses nothing, so every step poses the
803
+ * world alone (`posedBoneWorldAlone`). The rows are the sample's
804
+ * (`posedBoneRows`, `posedBoneWorld`), formed there as before. The core
805
+ * suite's `CO62` reads the walk's modules for a call that forms them.
790
806
  */
791
807
  interface PoseShape<P> {
792
- /** Whether a step forms the oracle's bone rows beside its world (`posedBoneWorld`), which no walk pose carries; `false` poses the world alone (`posedBoneWorldAlone`). */
793
- rows: boolean;
794
808
  /** The reset pose at the setup (`reset: 'setup'`). */
795
809
  setup: (doc: CompiledDocument, world: Map<string, CoreWorld>, slots: CoreSlotRow[], drawOrder: string[], shown: ShownGeometry[], plant: CorePlant, mode: WalkMode) => P;
796
810
  /** One step's pose. */
@@ -799,19 +813,21 @@ interface PoseShape<P> {
799
813
 
800
814
  /** The raw entry's pose, assembled as it always was: at the reset the bones, then the drawn rows; at a step the drawn rows, the events, then the bones. */
801
815
  const RAW_SHAPE: PoseShape<RawPose> = {
802
- rows: true,
803
816
  setup: (doc, world, slots, drawOrder, shown, plant, mode) => ({ trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots, drawOrder, ...rawDrawn(doc, shown, world, drawOrder, plant, mode), events: [], shown: drawOrderOf(shown, drawOrder) }),
804
817
  step: (doc, p, plant, mode) => {
805
818
  const drawn = rawDrawn(doc, p.shown, p.world, p.drawOrder, plant, mode);
819
+ const stage = plant.stage;
820
+ if (stage !== undefined) stage.begin('readout');
806
821
  const events = p.events();
807
- return { trackTime: p.trackTime, animationTime: p.animationTime, bones: rawBones(doc, p.world), slots: p.slots, drawOrder: p.drawOrder, ...drawn, events, shown: p.shown };
822
+ const pose: RawPose = { trackTime: p.trackTime, animationTime: p.animationTime, bones: rawBones(doc, p.world), slots: p.slots, drawOrder: p.drawOrder, ...drawn, events, shown: p.shown };
823
+ if (stage !== undefined) stage.end('readout');
824
+ return pose;
808
825
  },
809
826
  };
810
827
 
811
828
  /** The scan's pose (`ScanPose`), in the raw shape's order: the scan's walk is a looping one, which fires no event. */
812
829
  function scanShape(scanPlant: ScanPlant): PoseShape<ScanPose> {
813
830
  return {
814
- rows: false,
815
831
  setup: (doc, world, slots, drawOrder, shown, plant, mode) => {
816
832
  const bones = scanBones(doc, world);
817
833
  return { animationTime: 0, bones, slots, drawn: scanDrawn(doc, shown, world, drawOrder, plant, mode, scanPlant) };
@@ -908,7 +924,7 @@ function stepWalk<P>(track: TrackState<P>, dt: number, first = false): P {
908
924
  const keys = anim.timelines.events;
909
925
  const adjustment = adjust === undefined ? undefined : { callback: adjust, context: { trackTime, animation: anim.name, globalTime: trackTime } };
910
926
  const pose = poseStepFrom(doc, plant, resolve, shape, mode, ctx, first, trackTime, t, {
911
- world: (sliders) => (shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjustment }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjustment })),
927
+ world: (sliders) => posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjustment }),
912
928
  slots: (sliders, placeholders) => posedSlots(doc, anim.timelines, t, raw, sliders, placeholders),
913
929
  drawOrder: anim.timelines.drawOrder,
914
930
  at: t,
@@ -948,20 +964,30 @@ export interface StepPosers {
948
964
  * and a live tracks' step share.
949
965
  */
950
966
  function poseStepFrom<P>(doc: CompiledDocument, plant: TimelinePlant, resolve: NonNullable<TimelinePlant['shown']>, shape: PoseShape<P>, mode: WalkMode, ctx: ReturnType<typeof freshStepContext>, first: boolean, trackTime: number, animationTime: number, posers: StepPosers): P {
967
+ const stage = plant.stage;
951
968
  const sliders: SliderApplication[] = [];
952
969
  const world = posers.world(sliders);
953
970
  if (first) ctx.phase = 'update';
971
+ if (stage !== undefined) stage.begin('readout');
954
972
  const placeholders = new Map<string, string | null>();
955
973
  const slots = posers.slots(sliders, placeholders);
956
974
  if (slots.conflicts.length > 0) throw new CoreInputError(`the raw walk leaves the slots out: ${slots.conflicts.join('; ')}`);
957
975
  const evalOrder = plant.drawOrder ?? drawOrderAt;
958
- let order = evalOrder(doc.slots.length, posers.drawOrder, posers.at) ?? doc.slots.map((_s, k) => k);
976
+ const baseOrder = evalOrder(doc.slots.length, posers.drawOrder, posers.at) ?? doc.slots.map((_s, k) => k);
977
+ let order = baseOrder;
959
978
  for (const app of sliders) order = evalOrder(doc.slots.length, app.timelines.drawOrder, app.at) ?? order;
960
979
  const drawOrder = order.map((k) => doc.slots[k].name);
980
+ if (stage !== undefined) {
981
+ stage.end('readout');
982
+ stage.begin('timeline');
983
+ }
961
984
  const states = attachmentStates(doc, resolve, placeholders, posers.sample, sliders, plant, posers.mixed);
985
+ if (stage !== undefined) stage.end('timeline');
962
986
  if (states.why.length > 0) throw new CoreInputError(`the raw walk leaves the attachments out: ${states.why.join('; ')}`);
987
+ if (stage !== undefined) stage.begin('readout');
963
988
  const rank = new Map(drawOrder.map((n, r) => [n, r]));
964
989
  const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
990
+ if (stage !== undefined) stage.end('readout');
965
991
  const events = (): RawEvent[] => posers.events().map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]);
966
992
  return shape.step(doc, { trackTime, animationTime, world, slots: slots.rows, drawOrder, shown, events }, plant, mode);
967
993
  }
@@ -160,7 +160,7 @@
160
160
  * every later call refuses naming the call it was refused at.
161
161
  */
162
162
  import type { ModelBone, ModelSlot, ModelVertices } from '../model.ts';
163
- import { activeBones, CoreInputError, rawNumber, readColour, shownAttachment, type CompiledDocument, type CoreShown } from './index.ts';
163
+ import { activeBones, CoreInputError, rawNumber, shownAttachment, viewColours, WHITE, type CompiledDocument, type CoreShown, type StatedColour } from './index.ts';
164
164
  import { channelAt, keyIndexAt, SLOT_COLOUR_TIMELINES, slotRowsFrom, steppedLocalsWorld, type BoneTimelineKind, type CoreKey, type LocalAdjust, type SlotBase, type SlotColourKind, type TimelinePlant } from './animation.ts';
165
165
  import { deformAt, setupArray, type CoreAttachmentTimeline } from './deform.ts';
166
166
  import { eventsFired, type CoreEventKey, type CoreEventRow } from './events.ts';
@@ -440,6 +440,9 @@ function trackComplete(e: Entry): number {
440
440
  const signum = (v: number): number => (v > 0 ? 1 : v < 0 ? -1 : 0);
441
441
  const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
442
442
 
443
+ /** A slot's setup dark colour where it states none: never read, since such a slot carries no dark channel and a two-colour timeline returns before it (`applyColour`). */
444
+ const NO_DARK: readonly number[] = [0, 0, 0];
445
+
443
446
  // ---------------------------------------------------------------------------
444
447
  // one step's skeleton — the pose the entries are applied to
445
448
  // ---------------------------------------------------------------------------
@@ -473,6 +476,8 @@ class Pose {
473
476
  private readonly active: ReadonlySet<string>;
474
477
  private readonly live: boolean[];
475
478
  private readonly resolved = new Map<string, Resolved>();
479
+ /** The view's stated colours (`viewColours`). */
480
+ readonly stated: (hex: string) => StatedColour;
476
481
 
477
482
  constructor(
478
483
  private readonly doc: CompiledDocument,
@@ -480,12 +485,16 @@ class Pose {
480
485
  ) {
481
486
  this.bones = [...doc.bones];
482
487
  this.active = activeBones(doc);
483
- const colour = plant.colour ?? readColour;
488
+ // The view's stated colours, read once (`viewColours`) and copied into the step's own channels; a planted reader of the light colour is called as it always was.
489
+ const stated = viewColours(doc);
490
+ this.stated = stated;
491
+ const colour = plant.colour ?? stated;
484
492
  const gate = plant.slotTimelines ?? slotTimelinesApply;
485
493
  this.live = doc.slots.map((s) => gate(doc, s, this.active));
486
494
  this.slots = doc.slots.map((slot): SlotState => {
487
- const setupDark = slot.dark === undefined ? null : readColour(slot.dark);
488
- return { placeholder: slot.setup, light: slot.color === undefined ? [1, 1, 1, 1] : colour(slot.color), dark: setupDark === null ? null : [setupDark[0], setupDark[1], setupDark[2]], deform: null, seen: [], attachmentState: ATTACH_NONE };
495
+ const setupDark = slot.dark === undefined ? null : stated(slot.dark);
496
+ const setupLight = slot.color === undefined ? WHITE : colour(slot.color);
497
+ return { placeholder: slot.setup, light: [setupLight[0], setupLight[1], setupLight[2], setupLight[3]], dark: setupDark === null ? null : [setupDark[0], setupDark[1], setupDark[2]], deform: null, seen: [], attachmentState: ATTACH_NONE };
489
498
  });
490
499
  }
491
500
 
@@ -1103,9 +1112,9 @@ class Mixer {
1103
1112
  const s = pose.slots[tl.slot];
1104
1113
  const l = s.light;
1105
1114
  const d = s.dark;
1106
- const sl = slot.color === undefined ? [1, 1, 1, 1] : (this.plant.colour ?? readColour)(slot.color);
1107
- const sdFull = slot.dark === undefined ? null : readColour(slot.dark);
1108
- const sd = sdFull === null ? [0, 0, 0] : [sdFull[0], sdFull[1], sdFull[2]];
1115
+ // The setup colours, read only: the view's (`viewColours`), or a planted reader's light colour.
1116
+ const sl = slot.color === undefined ? WHITE : (this.plant.colour ?? pose.stated)(slot.color);
1117
+ const sd = slot.dark === undefined ? NO_DARK : pose.stated(slot.dark);
1109
1118
  const clampLight = (): void => {
1110
1119
  for (let k = 0; k < 4; k++) l[k] = clamp01(l[k]);
1111
1120
  };
@@ -1466,7 +1475,10 @@ class Tracks implements LiveTracks {
1466
1475
  if (typeof dt !== 'number' || !Number.isFinite(dt) || dt < 0) throw new CoreInputError(`the live tracks' step ${n} is ${String(dt)}, not a finite time at or above 0`);
1467
1476
  return this.guard(`step ${n}`, () => {
1468
1477
  this.time += dt;
1478
+ const stage = this.plant.stage;
1479
+ if (stage !== undefined) stage.begin('timeline');
1469
1480
  this.mixer.update(dt);
1481
+ if (stage !== undefined) stage.end('timeline');
1470
1482
  const pose = this.pose(dt, false);
1471
1483
  this.steps = n;
1472
1484
  return pose;
@@ -1489,6 +1501,8 @@ class Tracks implements LiveTracks {
1489
1501
  const ctx = path.ctx;
1490
1502
  const before = ctx.time;
1491
1503
  ctx.time += this.clock === undefined ? dt : this.clock(dt);
1504
+ const stage = this.plant.stage;
1505
+ if (stage !== undefined) stage.begin('timeline');
1492
1506
  const pose = new Pose(doc, this.plant);
1493
1507
  const animationTime = this.mixer.apply(pose);
1494
1508
  const entry = this.mixer.tracks[0] ?? null;
@@ -1497,8 +1511,9 @@ class Tracks implements LiveTracks {
1497
1511
  context: { trackTime: entry?.trackTime ?? null, animation: entry?.anim.name ?? null, globalTime: this.time },
1498
1512
  };
1499
1513
  const mixed = new Map(doc.slots.map((s, k) => [s.name, { deform: pose.slots[k].deform, seen: pose.slots[k].seen }]));
1514
+ if (stage !== undefined) stage.end('timeline');
1500
1515
  return path.step(first, this.time, animationTime, {
1501
- world: (sliders) => steppedLocalsWorld(doc, pose.bones, path.raw, sliders, { ctx, before, adjustment }, true),
1516
+ world: (sliders) => steppedLocalsWorld(doc, pose.bones, path.raw, sliders, { ctx, before, adjustment }),
1502
1517
  slots: (sliders, placeholders) => slotRowsFrom(doc, path.raw, sliders, placeholders, (_slot, _live): SlotBase => {
1503
1518
  const s = pose.slots[doc.slots.indexOf(_slot)];
1504
1519
  return { placeholder: s.placeholder, light: s.light, dark: s.dark };
@@ -191,6 +191,8 @@ export type CoreClipRow = [string, string, string | null, Array<number | null>];
191
191
  export type RegionPoser = (region: CoreRegionGeometry & { atlas: ModelAtlasRect }, bone: CoreWorld) => number[];
192
192
  /** What computes a vertex array's world positions: `worldVertices` unless a caller passes another. */
193
193
  export type VertexPoser = (vertices: ModelVertices, bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>, coords?: (v: number) => number) => number[];
194
+ /** What computes a deformed vertex array's world positions in one pass: `deformedWorldVertices` unless a caller passes another. */
195
+ export type DeformedVertexPoser = (vertices: ModelVertices, deform: readonly number[], bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>) => number[];
194
196
 
195
197
  /** A deformed array's coordinates are read as they are (`./deform.ts`, `deformedVertices`): they are not the stored float32s. */
196
198
  export const EXACT_COORDS = (v: number): number => v;
@@ -222,31 +224,157 @@ export function regionCorners(region: CoreRegionGeometry & { atlas: ModelAtlasRe
222
224
  return out;
223
225
  }
224
226
 
227
+ /** A weighted vertex array as the model states it. */
228
+ export type WeightedVertices = Extract<ModelVertices, { weighted: true }>;
229
+
225
230
  /**
226
- * A vertex array's world positions — the header's mesh rule, every stored
227
- * number through `Math.fround`; with `coords`, the coordinates through it
228
- * instead (a deformed array's are read as they are, `EXACT_COORDS`), the
229
- * weights through `Math.fround` still.
231
+ * One influence of a compiled weighted vertex (issue #1580): its bone as an
232
+ * index into `CompiledWeightedVertices.bones`, its raw coordinates (`x`, `y`)
233
+ * for a caller passing a custom `coords`, and its `Math.fround`-rounded
234
+ * coordinates (`fx`, `fy`) and `weight` for the default posing path.
230
235
  */
231
- export function worldVertices(vertices: ModelVertices, bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>, coords: (v: number) => number = Math.fround): number[] {
236
+ export interface CompiledBinding {
237
+ readonly bone: number;
238
+ readonly x: number;
239
+ readonly y: number;
240
+ readonly fx: number;
241
+ readonly fy: number;
242
+ readonly weight: number;
243
+ }
244
+
245
+ /**
246
+ * A weighted vertex array's bone bindings compiled to integer indices into
247
+ * `bones` (issue #1580): `bones` lists the distinct bone names in exact
248
+ * first-occurrence order across `vertices.bindings`, and `bindings` keeps the
249
+ * outer vertex order and inner influence order untouched so the summation
250
+ * order and every posed coordinate stay bit-for-bit (`Object.is`) identical.
251
+ */
252
+ export interface CompiledWeightedVertices {
253
+ readonly bones: readonly string[];
254
+ readonly bindings: ReadonlyArray<readonly CompiledBinding[]>;
255
+ }
256
+
257
+ /** Each weighted vertex array's compiled index table, keyed on the `WeightedVertices` object itself. */
258
+ const compiledWeightedByVertices = new WeakMap<WeightedVertices, CompiledWeightedVertices>();
259
+
260
+ /**
261
+ * Compile `vertices`'s bone names to integer indices in first-occurrence order
262
+ * (issue #1580). With `mapIndex`, each binding's index is passed through it
263
+ * instead (the core suite's wrong-index plant).
264
+ */
265
+ export function compileWeightedVertices(
266
+ vertices: WeightedVertices,
267
+ mapIndex?: (boneIndex: number, boneCount: number) => number,
268
+ ): CompiledWeightedVertices {
269
+ const bones: string[] = [];
270
+ const indexByName = new Map<string, number>();
271
+ for (const influences of vertices.bindings) {
272
+ for (const binding of influences) {
273
+ if (!indexByName.has(binding.bone)) {
274
+ indexByName.set(binding.bone, bones.length);
275
+ bones.push(binding.bone);
276
+ }
277
+ }
278
+ }
279
+ const count = bones.length;
280
+ const bindings: CompiledBinding[][] = vertices.bindings.map((influences) =>
281
+ influences.map((binding) => {
282
+ const idx = indexByName.get(binding.bone) as number;
283
+ return {
284
+ bone: mapIndex === undefined ? idx : mapIndex(idx, count),
285
+ x: binding.x,
286
+ y: binding.y,
287
+ fx: Math.fround(binding.x),
288
+ fy: Math.fround(binding.y),
289
+ weight: Math.fround(binding.weight),
290
+ };
291
+ }),
292
+ );
293
+ return { bones, bindings };
294
+ }
295
+
296
+ /**
297
+ * The cached `CompiledWeightedVertices` for `vertices`, compiled once per
298
+ * `WeightedVertices` object and never shared across two different vertex
299
+ * objects.
300
+ */
301
+ export function compiledWeightedOf(vertices: WeightedVertices): CompiledWeightedVertices {
302
+ let kept = compiledWeightedByVertices.get(vertices);
303
+ if (kept === undefined) {
304
+ kept = compileWeightedVertices(vertices);
305
+ compiledWeightedByVertices.set(vertices, kept);
306
+ }
307
+ return kept;
308
+ }
309
+
310
+ /**
311
+ * Resolve each distinct bone of `bones` once per pose call into a dense array
312
+ * indexed by `CompiledBinding.bone`. Because `bones` is in first-occurrence
313
+ * order across `vertices.bindings`, a missing bone throws the same error on
314
+ * the same bone name as a per-influence lookup.
315
+ */
316
+ function resolveBindingBones(bones: readonly string[], world: ReadonlyMap<string, CoreWorld>): CoreWorld[] {
317
+ const transforms = new Array<CoreWorld>(bones.length);
318
+ for (let i = 0; i < bones.length; i++) {
319
+ const name = bones[i];
320
+ const t = world.get(name);
321
+ if (t === undefined) throw new Error(`a binding names bone "${name}", which has no world transform (readModel refuses it first)`);
322
+ transforms[i] = t;
323
+ }
324
+ return transforms;
325
+ }
326
+
327
+ /**
328
+ * Evaluate a compiled weighted vertex table against `world` (issue #1580),
329
+ * looking up each distinct bone once per call and summing influences in their
330
+ * original order.
331
+ */
332
+ export function compiledWorldVertices(
333
+ compiled: CompiledWeightedVertices,
334
+ world: ReadonlyMap<string, CoreWorld>,
335
+ coords: (v: number) => number = Math.fround,
336
+ ): number[] {
337
+ const transforms = resolveBindingBones(compiled.bones, world);
232
338
  const out: number[] = [];
233
- if (!vertices.weighted) {
234
- for (let i = 0; i + 1 < vertices.xy.length; i += 2) {
235
- const x = coords(vertices.xy[i]);
236
- const y = coords(vertices.xy[i + 1]);
237
- out.push(x * bone.a + y * bone.b + bone.worldX, x * bone.c + y * bone.d + bone.worldY);
339
+ const fround = coords === Math.fround;
340
+ for (const influences of compiled.bindings) {
341
+ let wx = 0;
342
+ let wy = 0;
343
+ for (const binding of influences) {
344
+ const t = transforms[binding.bone];
345
+ const x = fround ? binding.fx : coords(binding.x);
346
+ const y = fround ? binding.fy : coords(binding.y);
347
+ const weight = binding.weight;
348
+ wx += (x * t.a + y * t.b + t.worldX) * weight;
349
+ wy += (x * t.c + y * t.d + t.worldY) * weight;
238
350
  }
239
- return out;
351
+ out.push(wx, wy);
240
352
  }
241
- for (const influences of vertices.bindings) {
353
+ return out;
354
+ }
355
+
356
+ /**
357
+ * Evaluate a compiled weighted vertex table with `deform` in one pass
358
+ * (issues #1530, #1580), looking up each distinct bone once per call and
359
+ * summing influences in their original order.
360
+ */
361
+ export function compiledDeformedWorldVertices(
362
+ compiled: CompiledWeightedVertices,
363
+ deform: readonly number[],
364
+ world: ReadonlyMap<string, CoreWorld>,
365
+ ): number[] {
366
+ const transforms = resolveBindingBones(compiled.bones, world);
367
+ const out: number[] = [];
368
+ let k = 0;
369
+ for (const influences of compiled.bindings) {
242
370
  let wx = 0;
243
371
  let wy = 0;
244
372
  for (const binding of influences) {
245
- const t = world.get(binding.bone);
246
- if (t === undefined) throw new Error(`a binding names bone "${binding.bone}", which has no world transform (readModel refuses it first)`);
247
- const x = coords(binding.x);
248
- const y = coords(binding.y);
249
- const weight = Math.fround(binding.weight);
373
+ const t = transforms[binding.bone];
374
+ const x = binding.fx + deform[k];
375
+ const y = binding.fy + deform[k + 1];
376
+ k += 2;
377
+ const weight = binding.weight;
250
378
  wx += (x * t.a + y * t.b + t.worldX) * weight;
251
379
  wy += (x * t.c + y * t.d + t.worldY) * weight;
252
380
  }
@@ -255,20 +383,42 @@ export function worldVertices(vertices: ModelVertices, bone: CoreWorld, world: R
255
383
  return out;
256
384
  }
257
385
 
386
+ /**
387
+ * A vertex array's world positions — the header's mesh rule, every stored
388
+ * number through `Math.fround`; with `coords`, the coordinates through it
389
+ * instead (a deformed array's are read as they are, `EXACT_COORDS`), the
390
+ * weights through `Math.fround` still. Weighted bone bindings are compiled once
391
+ * per vertex object to integer indices (`compiledWeightedOf`, issue #1580) so
392
+ * each pose looks up each distinct bone once rather than per influence.
393
+ */
394
+ export function worldVertices(vertices: ModelVertices, bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>, coords: (v: number) => number = Math.fround): number[] {
395
+ if (!vertices.weighted) {
396
+ const out: number[] = [];
397
+ for (let i = 0; i + 1 < vertices.xy.length; i += 2) {
398
+ const x = coords(vertices.xy[i]);
399
+ const y = coords(vertices.xy[i + 1]);
400
+ out.push(x * bone.a + y * bone.b + bone.worldX, x * bone.c + y * bone.d + bone.worldY);
401
+ }
402
+ return out;
403
+ }
404
+ return compiledWorldVertices(compiledWeightedOf(vertices), world, coords);
405
+ }
406
+
258
407
  /**
259
408
  * `worldVertices(deformedVertices(vertices, deform), bone, world, EXACT_COORDS)`
260
- * in one pass (issue #1530): the same operations on the same numbers in the
409
+ * in one pass (issues #1530, #1580): the same operations on the same numbers in the
261
410
  * same order — an unweighted vertex's coordinates are the deform array's,
262
411
  * read as they are; a weighted binding's are its float32 coordinate plus the
263
- * deform's pair, its weight through `Math.fround` — without building the
264
- * deformed array (and, weighted, one binding record per influence) on every
265
- * pose only to map it again. A planted vertex poser still reads the deformed
266
- * array (`poseGeometry`). Posed output is held equal by the core gate's
267
- * sample attachments and by `tools/posed_frames.ts` across the change.
412
+ * deform's pair, its weight through `Math.fround`, its bone transform indexed
413
+ * through `compiledWeightedOf` — without building the deformed array (and,
414
+ * weighted, one binding record per influence) on every pose only to map it
415
+ * again. A planted vertex poser still reads the deformed array (`poseGeometry`).
416
+ * Posed output is held equal by the core gate's sample attachments and by
417
+ * `tools/posed_frames.ts` across the change.
268
418
  */
269
419
  export function deformedWorldVertices(vertices: ModelVertices, deform: readonly number[], bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>): number[] {
270
- const out: number[] = [];
271
420
  if (!vertices.weighted) {
421
+ const out: number[] = [];
272
422
  for (let i = 0; i + 1 < deform.length; i += 2) {
273
423
  const x = deform[i];
274
424
  const y = deform[i + 1];
@@ -276,23 +426,7 @@ export function deformedWorldVertices(vertices: ModelVertices, deform: readonly
276
426
  }
277
427
  return out;
278
428
  }
279
- let k = 0;
280
- for (const influences of vertices.bindings) {
281
- let wx = 0;
282
- let wy = 0;
283
- for (const binding of influences) {
284
- const t = world.get(binding.bone);
285
- if (t === undefined) throw new Error(`a binding names bone "${binding.bone}", which has no world transform (readModel refuses it first)`);
286
- const x = Math.fround(binding.x) + deform[k];
287
- const y = Math.fround(binding.y) + deform[k + 1];
288
- k += 2;
289
- const weight = Math.fround(binding.weight);
290
- wx += (x * t.a + y * t.b + t.worldX) * weight;
291
- wy += (x * t.c + y * t.d + t.worldY) * weight;
292
- }
293
- out.push(wx, wy);
294
- }
295
- return out;
429
+ return compiledDeformedWorldVertices(compiledWeightedOf(vertices), deform, world);
296
430
  }
297
431
 
298
432
  const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -342,7 +476,10 @@ function readVertices(value: unknown, where: string, bones: ReadonlySet<string>,
342
476
  for (const key of ['x', 'y', 'weight'] as const) if (!finite(b[key])) problems.push(`${at}: ${key} is ${JSON.stringify(b[key]) ?? 'absent'}, not a finite number`);
343
477
  });
344
478
  });
345
- return problems.length === before ? { weighted: true, bindings: bindings as ModelBinding[][] } : undefined;
479
+ if (problems.length !== before) return undefined;
480
+ const weighted: WeightedVertices = { weighted: true, bindings: bindings as ModelBinding[][] };
481
+ compiledWeightedByVertices.set(weighted, compileWeightedVertices(weighted));
482
+ return weighted;
346
483
  }
347
484
 
348
485
  /**
@@ -529,11 +666,12 @@ export function poseGeometry(
529
666
  world: ReadonlyMap<string, CoreWorld>,
530
667
  sourceOf: SourceOf,
531
668
  round: (v: number) => number | null,
532
- plant: { region?: RegionPoser; vertices?: VertexPoser } = {},
669
+ plant: { region?: RegionPoser; vertices?: VertexPoser; deformedVertices?: DeformedVertexPoser } = {},
533
670
  draw?: DrawWalk,
534
671
  ): { attachments: CoreAttachmentRow[] | null; attachmentsWhy: string | null; clips: CoreClipRow[]; clipped: CoreClippedRow[] | null; clippedWhy: string | null; drawnClipped: CoreClippedRow[] | null; drawnClippedWhy: string | null } {
535
672
  const region = plant.region ?? regionCorners;
536
673
  const vertices = plant.vertices ?? worldVertices;
674
+ const deformPoser = plant.deformedVertices ?? deformedWorldVertices;
537
675
  const attachments: CoreAttachmentRow[] = [];
538
676
  const clips: CoreClipRow[] = [];
539
677
  const nulls: string[] = [];
@@ -548,7 +686,7 @@ export function poseGeometry(
548
686
  // A deform array replaces the stored vertices, its coordinates read as they are (`./deform.ts`).
549
687
  // Unplanted, the deformed positions are read in one pass (`deformedWorldVertices`, issue #1530) rather than through a deformed copy of the array.
550
688
  const deform = s.deform;
551
- const drawn = (v: ModelVertices): number[] => (deform === undefined ? vertices(v, bone, world) : plant.vertices === undefined ? deformedWorldVertices(v, deform, bone, world) : vertices(deformedVertices(v, deform), bone, world, EXACT_COORDS));
689
+ const drawn = (v: ModelVertices): number[] => (deform === undefined ? vertices(v, bone, world) : plant.vertices === undefined || plant.deformedVertices !== undefined ? deformPoser(v, deform, bone, world) : vertices(deformedVertices(v, deform), bone, world, EXACT_COORDS));
552
690
  if (g.kind === 'region') {
553
691
  const rect = s.frame === undefined ? g.region.atlas : (g.region.frames?.[s.frame] ?? g.region.atlas);
554
692
  if (rect === null) {
package/src/deformgen.ts CHANGED
@@ -121,6 +121,16 @@ export interface DeformTurn {
121
121
  degrees: number;
122
122
  /** Where the axis crosses the driving coordinate. Default 0. */
123
123
  about?: number;
124
+ /**
125
+ * Whether the displacement includes the foreshortening of the vertex's setup
126
+ * position `(u − about)·(cos t − 1)`. Default `true`.
127
+ *
128
+ * When `false`, each vertex moves by `−z·sin t` alone — for a rig whose plate
129
+ * proportions are held at their front-view width across the turn and whose
130
+ * triangles therefore fold at `sin t = A₀ / A_axis` (`asin`) rather than
131
+ * `tan t = A₀ / A_axis` (`atan`) (issue #1584).
132
+ */
133
+ narrowing?: boolean;
124
134
  /** Scales each vertex's offset by a weight in [0, 1] — see `DeformEnvelope`. */
125
135
  envelope?: DeformEnvelope;
126
136
  }
@@ -364,7 +374,7 @@ export function evaluateDeformTransform(
364
374
  round: Rounder,
365
375
  where: string,
366
376
  /**
367
- * 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.
368
378
  * Null when it named none — which is what makes `"depth": true` refusable by
369
379
  * name rather than silently falling back to a cylinder.
370
380
  */
@@ -438,7 +448,7 @@ export function evaluateDeformTransform(
438
448
  if (fromDepth && depth === null) {
439
449
  throw new CompileError(
440
450
  `${where}: transform ${kind} says "depth": true and this attachment has no depth map. The map is named on ` +
441
- '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 ' +
442
452
  'the art rather than this key. Name it there, or give this key a radius.', 'CE_DEFORM_DEPTH_MAP_REQUIRED',
443
453
  );
444
454
  }
@@ -457,6 +467,7 @@ export function evaluateDeformTransform(
457
467
  const rad = (degrees * Math.PI) / 180;
458
468
  const cosMinus1 = Math.cos(rad) - 1;
459
469
  const sin = Math.sin(rad);
470
+ const narrowing = t.narrowing !== false;
460
471
  // The driving coordinate is the one the axis is perpendicular to: x for a
461
472
  // yaw (a vertical axis), y for a pitch (a horizontal one). The other
462
473
  // component of every pair is 0, because a turn about an axis moves
@@ -481,11 +492,11 @@ export function evaluateDeformTransform(
481
492
  // mesh was built (`sampleContourDepth`) rather than here, where the
482
493
  // sheet is long out of reach.
483
494
  const z = fromDepth ? (depth as readonly number[])[v] : Math.sqrt(radius * radius - u * u);
484
- const d = u * cosMinus1 - z * sin;
495
+ const d = (narrowing ? u * cosMinus1 : 0) - z * sin;
485
496
  offsets[2 * v + along] = round(widen(d));
486
497
  offsets[2 * v + (1 - along)] = 0;
487
498
  }
488
- identity = round(cosMinus1) === 0 && round(sin) === 0;
499
+ identity = (narrowing ? round(cosMinus1) === 0 : true) && round(sin) === 0;
489
500
  identitySpelling = 'degrees 0';
490
501
  sampledTo = fromDepth
491
502
  ? `The turn is ${degrees}°, and a projection of it can only vanish where every vertex shares one ` +
@@ -494,15 +505,20 @@ export function evaluateDeformTransform(
494
505
  : `The turn is ${degrees}°, and a projection of it can only vanish where every vertex shares one ` +
495
506
  `${kind === 'yaw' ? 'x' : 'y'} — check that this attachment's setup geometry is the shape the radius says it is`;
496
507
  const c = kind === 'yaw' ? 'x' : 'y';
508
+ const narrowingNote = narrowing ? '' : ' narrowing=false';
497
509
  stated = fromDepth
498
- ? `depth=true degrees=${degrees}${t.about === undefined ? '' : ` about=${about}`}`
499
- : `radius=${radius} degrees=${degrees}${t.about === undefined ? '' : ` about=${about}`}`;
510
+ ? `depth=true degrees=${degrees}${t.about === undefined ? '' : ` about=${about}`}${narrowingNote}`
511
+ : `radius=${radius} degrees=${degrees}${t.about === undefined ? '' : ` about=${about}`}${narrowingNote}`;
500
512
  formula = fromDepth
501
- ? `d${c} = (${c}−about)·(cos t − 1) − z·sin t, z = the vertex's sampled depth`
502
- : `d${c} = (${c}−about)·(cos t − 1) − z·sin t, z = √(radius² − (${c}−about)²)`;
513
+ ? narrowing
514
+ ? `d${c} = (${c}−about)·(cos t − 1) − z·sin t, z = the vertex's sampled depth`
515
+ : `d${c} = −z·sin t, z = the vertex's sampled depth`
516
+ : narrowing
517
+ ? `d${c} = (${c}−about)·(cos t − 1) − z·sin t, z = √(radius² − (${c}−about)²)`
518
+ : `d${c} = −z·sin t, z = √(radius² − (${c}−about)²)`;
503
519
  derived = [
504
520
  `t = ${round(rad)} rad`,
505
- `cos t − 1 = ${round(cosMinus1)}`,
521
+ ...(narrowing ? [`cos t − 1 = ${round(cosMinus1)}`] : []),
506
522
  `sin t = ${round(sin)}`,
507
523
  ];
508
524
  if (fromDepth) {