@rig-c/core 2.71.0 → 2.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.ts CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  type DepthTone,
35
35
  } from './depth.ts';
36
36
  import { CompileError, NotImplementedError } from './errors.ts';
37
+ import { alphaClipOutline, clipCarriers, refuseClipCrossing, refuseClipDegeneracy } from './alpha_clip.ts';
38
+ import { regionCorners } from './core/vertices.ts';
37
39
  import { parseJsonWithPosition } from './json-position.ts';
38
40
  import { hypot2 } from './numeric.ts';
39
41
  // The "did you mean" list on a missing atlas region, from the one implementation
@@ -60,6 +62,7 @@ import {
60
62
  type RigBone,
61
63
  type RigBoundingBoxAttachment,
62
64
  type RigClippingAttachment,
65
+ type RigAlphaClip,
63
66
  type RigEvent,
64
67
  type RigLinkedMeshAttachment,
65
68
  type RigMeshAttachment,
@@ -2296,7 +2299,7 @@ function manifestShapeVisits(manifest: unknown): ShapeVisit[] {
2296
2299
  */
2297
2300
  export function compile(opts: CompileOptions): CompileResult {
2298
2301
  return withDroppedStates((droppedStates) => {
2299
- const { model, header, atlasText, declaredDurations } = compileInto(opts, droppedStates);
2302
+ const { model, header, atlasText, declaredDurations, alphaClips } = compileInto(opts, droppedStates);
2300
2303
  // The skeleton: the Spine emitter's one entry (issue #922), which writes
2301
2304
  // every section from the model, in the order and under the spellings the
2302
2305
  // file has, and runs the parser-default and key-order passes on the
@@ -2314,6 +2317,7 @@ export function compile(opts: CompileOptions): CompileResult {
2314
2317
  atlasText,
2315
2318
  declaredDurations,
2316
2319
  ...carriedOf(model),
2320
+ ...(alphaClips?.length ? { alphaClips } : {}),
2317
2321
  model,
2318
2322
  };
2319
2323
  });
@@ -2391,6 +2395,7 @@ export function headerBoundsOf(model: CompiledModel, atlasText: string): { box:
2391
2395
  * beside it to gate it, write its document and — if it asks — emit it.
2392
2396
  */
2393
2397
  export interface CompiledBuild {
2398
+ alphaClips?: CompileResult['alphaClips'];
2394
2399
  /** The compiled model (`src/model.ts`), `stage` and `editorOrder` included (issue #1026). */
2395
2400
  model: CompiledModel;
2396
2401
  /** The rest of the Spine header the model does not hold (`SkeletonHeader`): what `compile` hands the emitter beside the model. */
@@ -3011,7 +3016,8 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3011
3016
  const meshes: CompileResult['meshes'] = [];
3012
3017
  // `skin/slot/attachment` -> per-vertex z, for the attachments that named a
3013
3018
  // depth map. Read at deform-key time; never emitted. See `AttachmentContext`.
3014
- const attachmentDepths = new Map<string, number[]>();
3019
+ const attachmentDepths = new Map<string, number[] | { z: number[]; geometry: DeformGeometry }>();
3020
+ const pendingAlphaClips: PendingAlphaClip[] = [];
3015
3021
  // The same for a `soft` mask's per-vertex level, which a deform envelope's
3016
3022
  // `mask` reads (issue #1413). Never emitted either.
3017
3023
  const attachmentSoftMasks = new Map<string, DeformSoftMask>();
@@ -3212,6 +3218,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3212
3218
  const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
3213
3219
  const built = buildRigAttachment(att, placeholder, where, {
3214
3220
  images,
3221
+ pendingAlphaClips,
3215
3222
  bones,
3216
3223
  transforms,
3217
3224
  exactFrames,
@@ -3241,6 +3248,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3241
3248
  // line a linked mesh is the one construct in a rig spec whose name has not been
3242
3249
  // resolved yet.
3243
3250
  resolveLinkedMeshes(pendingLinks, skinTables);
3251
+ const alphaClips = resolveAlphaClips(pendingAlphaClips, skinTables, rig, motion);
3244
3252
  // 📐 The implicit budget of 0 is a statement about rigc's own GENERATORS:
3245
3253
  // geometry rigc built is geometry rigc will not ship unmeasured, and
3246
3254
  // `A13_MESH_BUDGET` has nothing to measure a generated mesh against until the
@@ -3729,6 +3737,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3729
3737
  if (deformTimelines.get(skinName)?.get(track.slot)?.has(track.attachment)) {
3730
3738
  throw new CompileError(`${at}: two deform timelines on one attachment; merge them into one`, 'CE_COMPILE_DEFORM_TIMELINE_DUPLICATE');
3731
3739
  }
3740
+ const sampledDepth = attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`);
3732
3741
  const keys = compileDeformTrack(
3733
3742
  track,
3734
3743
  motion,
@@ -3736,10 +3745,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3736
3745
  anim.duration,
3737
3746
  {
3738
3747
  ...deformGeometryOf(attachment, at, transforms),
3739
- depth: attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3748
+ depth: (Array.isArray(sampledDepth) ? sampledDepth : sampledDepth?.z) ?? null,
3740
3749
  soft: attachmentSoftMasks.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3741
3750
  },
3742
3751
  deformTransforms,
3752
+ sampledDepth !== undefined && !Array.isArray(sampledDepth) ? sampledDepth.geometry : null,
3743
3753
  );
3744
3754
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time);
3745
3755
  attachmentTimelinesOf(skinName, track.slot).set(track.attachment, { deform: keys });
@@ -3918,6 +3928,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3918
3928
  header,
3919
3929
  atlasText,
3920
3930
  declaredDurations,
3931
+ ...(alphaClips.length ? { alphaClips } : {}),
3921
3932
  };
3922
3933
  }
3923
3934
 
@@ -4340,6 +4351,7 @@ function refuseDefaultSkinContests(skinNames: readonly string[], skinParts: Map<
4340
4351
  // ---------------------------------------------------------------------------
4341
4352
 
4342
4353
  interface AttachmentContext {
4354
+ pendingAlphaClips: PendingAlphaClip[];
4343
4355
  images: CompiledImage[];
4344
4356
  bones: ModelBone[];
4345
4357
  transforms: Map<string, BoneTransform>;
@@ -4367,7 +4379,7 @@ interface AttachmentContext {
4367
4379
  * round trip and then vanish — and the whole point of this table is to be
4368
4380
  * read by `evaluateDeformTransform` at key time and never emitted at all.
4369
4381
  */
4370
- depths: Map<string, number[]>;
4382
+ depths: Map<string, number[] | { z: number[]; geometry: DeformGeometry }>;
4371
4383
  /**
4372
4384
  * A `soft` mask's file and its sampled level per vertex, keyed like `depths`
4373
4385
  * — what a deform envelope's `mask` reads at key time (issue #1413). Never
@@ -4931,6 +4943,273 @@ function buildRigBoundingBox(
4931
4943
  return out;
4932
4944
  }
4933
4945
 
4946
+ interface PendingAlphaClip {
4947
+ spec: RigAlphaClip;
4948
+ out: ModelClippingAttachment;
4949
+ where: string;
4950
+ ctx: AttachmentContext;
4951
+ }
4952
+
4953
+ /** Resolve after all sources/links exist: draw order cannot decide whether a source can be measured. */
4954
+ function resolveAlphaClips(
4955
+ pending: PendingAlphaClip[],
4956
+ tables: Map<string, SkinTable>,
4957
+ rig: RigSpec,
4958
+ motion: MotionSpec,
4959
+ ): NonNullable<CompileResult['alphaClips']> {
4960
+ const reports: NonNullable<CompileResult['alphaClips']> = [];
4961
+ for (const { spec, out, where, ctx } of pending) {
4962
+ const targetTable = tables.get(ctx.skinName)?.[ctx.slotName];
4963
+ const target = Object.entries(targetTable ?? {}).find(([, value]) => value === out)?.[0];
4964
+ // withStatedName can return a fresh object when name was declared.
4965
+ const targetKey = target ?? Object.entries(targetTable ?? {}).find(([, value]) => value.kind === 'clipping' && value.vertices === out.vertices)?.[0];
4966
+ if (targetKey === undefined) {
4967
+ throw new CompileError(
4968
+ `${where}: generated clipping target was not filed in its skin; required a resolved attachment.`,
4969
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
4970
+ );
4971
+ }
4972
+ for (const key of ['slot', 'skin', 'attachment'] as const) {
4973
+ if (typeof spec[key] !== 'string' || spec[key].length === 0) {
4974
+ throw new CompileError(
4975
+ `${where}: alphaClip.${key} is ${JSON.stringify(spec[key])}; required an explicit nonempty source name.`,
4976
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
4977
+ );
4978
+ }
4979
+ }
4980
+ const sourceWhere = `${spec.skin}/${spec.slot}/${spec.attachment}`;
4981
+ const label = `${where} alphaClip from "${sourceWhere}"`;
4982
+ const source = tables.get(spec.skin)?.[spec.slot]?.[spec.attachment];
4983
+ const sourceSlot = rig.slots.find((slot) => slot.name === spec.slot);
4984
+ if (!source || !sourceSlot) {
4985
+ throw new CompileError(
4986
+ `${label}: source attachment is absent; required a declared slot/skin/attachment.`,
4987
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
4988
+ );
4989
+ }
4990
+ if (source.kind !== 'region' && source.kind !== 'mesh' && source.kind !== 'linkedmesh') {
4991
+ throw new CompileError(
4992
+ `${label}: source kind is ${source.kind}; required a region or resolved mesh with pixels.`,
4993
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
4994
+ );
4995
+ }
4996
+ let geometry: SkinTableEntry = source;
4997
+ let depthKey = sourceWhere;
4998
+ if (geometry.kind === 'linkedmesh') {
4999
+ depthKey = `${geometry.skin}/${geometry.slot}/${geometry.source}`;
5000
+ const base = tables.get(geometry.skin)?.[geometry.slot]?.[geometry.source];
5001
+ if (!base || base.kind !== 'mesh') {
5002
+ throw new CompileError(
5003
+ `${label}: linked source resolves to ${base?.kind ?? 'nothing'}; required a resolved mesh.`,
5004
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
5005
+ );
5006
+ }
5007
+ geometry = base;
5008
+ }
5009
+ if (geometry.kind !== 'region' && geometry.kind !== 'mesh') {
5010
+ throw new CompileError(
5011
+ `${label}: source is ${(geometry as SkinTableEntry).kind}; required region or mesh geometry with a plate (a clipping source would be a dependency cycle).`,
5012
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
5013
+ );
5014
+ }
5015
+ if (source.sequence !== undefined) {
5016
+ throw new CompileError(
5017
+ `${label}: source has a ${source.sequence.count}-frame sequence; required one immutable source plate. Select a separate named attachment for the measured frame.`,
5018
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
5019
+ );
5020
+ }
5021
+ const region = source.path ?? source.name ?? spec.attachment;
5022
+ const img = ctx.images.find((image) => image.region === region);
5023
+ if (!img) {
5024
+ throw new CompileError(
5025
+ `${label}: region "${region}" has no source plate; required image/atlas pixels.`,
5026
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
5027
+ );
5028
+ }
5029
+ if (img.pageGrid !== undefined) {
5030
+ throw new CompileError(
5031
+ `${label}: source plate grid cannot be located. ${img.pageGrid.sentence}`,
5032
+ 'CE_COMPILE_ALPHA_CLIP_SOURCE',
5033
+ );
5034
+ }
5035
+ const plate = partPlate(img);
5036
+ const mask = { width: plate.width, height: plate.height, alpha: plateAlpha(plate) };
5037
+ const traced = alphaClipOutline(mask, spec, label, img.atlasScale ?? 1);
5038
+ const ring = traced.points.map(([x, y]): [number, number] => [x / plate.width, y / plate.height]);
5039
+ const anchor = ctx.transforms.get(sourceSlot.bone)!;
5040
+ let sourceBindings: ModelBinding[][];
5041
+ let uvs: number[];
5042
+ let triangles: number[];
5043
+ if (geometry.kind === 'region') {
5044
+ // Full restored plate, not trimmed page rectangle: source crop pixels remain the coordinates.
5045
+ const corners = regionCorners({ ...geometry, atlas: { width: img.width, height: img.height, originalWidth: img.width, originalHeight: img.height, offsetX: 0, offsetY: 0 } }, anchor);
5046
+ uvs = [0, 1, 0, 0, 1, 0, 1, 1];
5047
+ triangles = [0, 1, 2, 2, 3, 0];
5048
+ sourceBindings = Array.from({ length: 4 }, (_, i) => {
5049
+ const [x, y] = toBoneLocal(anchor, corners[2 * i], corners[2 * i + 1]);
5050
+ return [{ bone: sourceSlot.bone, x, y, weight: 1 }];
5051
+ });
5052
+ } else {
5053
+ uvs = geometry.uvs;
5054
+ triangles = geometry.triangles;
5055
+ const vertices = geometry.vertices;
5056
+ sourceBindings = vertices.weighted ? vertices.bindings :
5057
+ Array.from({ length: vertices.xy.length / 2 }, (_, i) => [{
5058
+ bone: sourceSlot.bone, x: vertices.xy[2 * i], y: vertices.xy[2 * i + 1], weight: 1,
5059
+ }]);
5060
+ }
5061
+ const carried = clipCarriers(ring, uvs, triangles, label);
5062
+ if (carried.points.length > spec.maxVertices) {
5063
+ throw new CompileError(
5064
+ `${label}: polygonoverdeclaredvertexbudget: ${carried.points.length} vertices after source carrier crossings (${traced.points.length} simplified), required <=${spec.maxVertices}. Raise the declared budget or simplify the source/outline.`,
5065
+ 'CE_COMPILE_ALPHA_CLIP_VERTEX_BUDGET',
5066
+ );
5067
+ }
5068
+ // Per-bone first moments preserve Σβ Σw M(bind) for every bone matrix; no solver or new pose model.
5069
+ const bindings: ModelBinding[][] = carried.carriers.map((carrier) => {
5070
+ const moments = new Map<string, { weight: number; x: number; y: number }>();
5071
+ carrier.corners.forEach((index, corner) => {
5072
+ for (const binding of sourceBindings[index]) {
5073
+ const share = carrier.bary[corner] * binding.weight;
5074
+ const row = moments.get(binding.bone) ?? { weight: 0, x: 0, y: 0 };
5075
+ row.weight += share;
5076
+ row.x += share * binding.x;
5077
+ row.y += share * binding.y;
5078
+ moments.set(binding.bone, row);
5079
+ }
5080
+ });
5081
+ const result: ModelBinding[] = [];
5082
+ for (const [bone, row] of moments) {
5083
+ if (row.weight > 0) {
5084
+ result.push({ bone, weight: f32(row.weight), x: f32(row.x / row.weight), y: f32(row.y / row.weight) });
5085
+ }
5086
+ }
5087
+ return result;
5088
+ });
5089
+ out.vertices = { weighted: true, bindings };
5090
+ out.vertexCount = bindings.length;
5091
+ const mappedWorld = setupWorldVertices(out.vertices, anchor, ctx.transforms, label)
5092
+ .map(([x, y]): [number, number] => [Math.fround(x), Math.fround(y)]);
5093
+ refuseClipDegeneracy(mappedWorld, `${label} final float32 world coordinates`);
5094
+ refuseClipCrossing(mappedWorld, label);
5095
+ const sourceWorld = sourceBindings.map((vertex): [number, number] => {
5096
+ let x = 0;
5097
+ let y = 0;
5098
+ for (const binding of vertex) {
5099
+ const [wx, wy] = toWorld(ctx.transforms.get(binding.bone)!, binding.x, binding.y);
5100
+ x += binding.weight * wx;
5101
+ y += binding.weight * wy;
5102
+ }
5103
+ return [x, y];
5104
+ });
5105
+ let maxBindingDeviationWorld = 0;
5106
+ carried.carriers.forEach((carrier, index) => {
5107
+ let x = 0;
5108
+ let y = 0;
5109
+ carrier.corners.forEach((corner, k) => {
5110
+ x += carrier.bary[k] * sourceWorld[corner][0];
5111
+ y += carrier.bary[k] * sourceWorld[corner][1];
5112
+ });
5113
+ maxBindingDeviationWorld = Math.max(
5114
+ maxBindingDeviationWorld,
5115
+ hypot2(mappedWorld[index][0] - x, mappedWorld[index][1] - y),
5116
+ );
5117
+ });
5118
+ const filed = targetTable![targetKey];
5119
+ if (filed.kind === 'clipping') {
5120
+ filed.vertices = out.vertices;
5121
+ filed.vertexCount = out.vertexCount;
5122
+ }
5123
+ // Authored depth turns use setup-world geometry, even for a rigid source.
5124
+ // Preserve that frame on the clip's own vertex/influence roster, not the source's.
5125
+ const sourceDepth = ctx.depths.get(depthKey);
5126
+ const z = sourceDepth === undefined || Array.isArray(sourceDepth) ? sourceDepth : sourceDepth.z;
5127
+ let clipDepthGeometry: DeformGeometry | undefined;
5128
+ if (z !== undefined) {
5129
+ const depths = carried.carriers.map((carrier) =>
5130
+ carrier.corners.reduce((sum, vertex, i) => sum + carrier.bary[i] * z[vertex], 0));
5131
+ if (sourceDepth !== undefined && !Array.isArray(sourceDepth)) {
5132
+ clipDepthGeometry = {
5133
+ ...deformGeometryOf(out, label, ctx.transforms, ctx.transforms.get(ctx.anchorBone)!),
5134
+ depth: depths,
5135
+ };
5136
+ }
5137
+ ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${targetKey}`,
5138
+ clipDepthGeometry === undefined ? depths : { z: depths, geometry: clipDepthGeometry });
5139
+ }
5140
+ // The helper, not the number of distinct bone names, defines the frame.
5141
+ // Duplicate influences on one bone also take the world-space path.
5142
+ const frameOf = (attachment: ModelVertexAttachment, unweightedBone: string, depthGeometry: DeformGeometry | undefined,
5143
+ key: MotionDeformTrack['keys'][number] | undefined): {
5144
+ name: string; geometry: DeformGeometry;
5145
+ } => {
5146
+ // Match compileDeformTrack's per-key choice, including authored depth:true.
5147
+ const geometry = (key?.transform !== undefined && 'depth' in key.transform && key.transform.depth === true
5148
+ ? depthGeometry : undefined) ?? deformGeometryOf(attachment, label, ctx.transforms);
5149
+ const vertices = attachment.vertices;
5150
+ const bone = vertices.weighted ? vertices.bindings[0]?.[0]?.bone : unweightedBone;
5151
+ return { name: geometry.influenceBones === null ? `bone:${bone}` : 'world', geometry };
5152
+ };
5153
+ for (const [animationName, animation] of Object.entries(motion.animations ?? {})) {
5154
+ const sourceIdentity = source.kind === 'linkedmesh' && source.timelines ? depthKey : sourceWhere;
5155
+ const targetIdentity = `${ctx.skinName}/${ctx.slotName}/${targetKey}`;
5156
+ const sourceTrack = animation.deform?.find((track) =>
5157
+ `${track.skin ?? 'default'}/${track.slot}/${track.attachment}` === sourceIdentity);
5158
+ const clipTrack = animation.deform?.find((track) =>
5159
+ `${track.skin ?? 'default'}/${track.slot}/${track.attachment}` === targetIdentity);
5160
+ if (!sourceTrack && !clipTrack) continue;
5161
+ const sourceDepthGeometry = sourceDepth !== undefined && !Array.isArray(sourceDepth) ? sourceDepth.geometry : undefined;
5162
+ const frames = (sourceTrack?.keys.length ? sourceTrack.keys : [undefined]).map((key, index) => ({
5163
+ source: geometry.kind === 'mesh' ? frameOf(geometry, sourceSlot.bone, sourceDepthGeometry, key) : null,
5164
+ clip: frameOf(out, ctx.anchorBone, clipDepthGeometry, clipTrack?.keys[index]),
5165
+ }));
5166
+ const compatible = frames.every(({ source, clip }) => source !== null && source.geometry.setup !== null &&
5167
+ clip.geometry.setup !== null && source.name === clip.name);
5168
+ const { source: sourceFrame, clip: clipFrame } = frames.find(({ source, clip }) =>
5169
+ source === null || source.geometry.setup === null || clip.geometry.setup === null || source.name !== clip.name) ?? frames[0];
5170
+ // With a world-space deform, varying per-vertex influence shares would
5171
+ // correlate the deform with moving bone matrices. Constant shares commute
5172
+ // with the affine/depth projection; no new deformation transfer is implied.
5173
+ const sharesOf = (vertex: ModelBinding[]): string => {
5174
+ const shares = new Map<string, number>();
5175
+ for (const binding of vertex) {
5176
+ shares.set(binding.bone, (shares.get(binding.bone) ?? 0) + binding.weight);
5177
+ }
5178
+ return JSON.stringify([...shares].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
5179
+ };
5180
+ const constantWorldShares = !frames.some(({ source }) => source?.name === 'world') ||
5181
+ sourceBindings.every((vertex) => sharesOf(vertex) === sharesOf(sourceBindings[0]));
5182
+ const shared = sourceTrack !== undefined && clipTrack !== undefined &&
5183
+ JSON.stringify(sourceTrack.keys) === JSON.stringify(clipTrack.keys);
5184
+ const linear = sourceTrack?.keys.every((key) => {
5185
+ const transform = key.transform;
5186
+ return transform !== undefined && transform.envelope === undefined &&
5187
+ (transform.kind === 'affine' || ((transform.kind === 'yaw' || transform.kind === 'pitch') &&
5188
+ transform.depth === true && z !== undefined));
5189
+ });
5190
+ if (!shared || !linear || !compatible || !constantWorldShares) {
5191
+ throw new CompileError(
5192
+ `${label}: animation "${animationName}" has ${sourceTrack?.keys.length ?? 0} source and ` +
5193
+ `${clipTrack?.keys.length ?? 0} clip keys; source frame=${sourceFrame?.name ?? 'no vertex geometry'}, ` +
5194
+ `clip frame=${clipFrame.name}, constant world influence shares=${constantWorldShares}. ` +
5195
+ 'Required matching explicit affine/depth-yaw/pitch transforms in compatible DeformGeometry frames, ' +
5196
+ 'with constant shares for world-space transfer. Fix the named frame/keys or author corresponding clip motion.',
5197
+ 'CE_COMPILE_ALPHA_CLIP_DEFORM',
5198
+ );
5199
+ }
5200
+ }
5201
+ reports.push({
5202
+ target: `${ctx.skinName}/${ctx.slotName}/${targetKey}`,
5203
+ source: sourceWhere,
5204
+ ...traced.report,
5205
+ vertices: out.vertexCount,
5206
+ uvs: carried.points,
5207
+ maxBindingDeviationWorld,
5208
+ });
5209
+ }
5210
+ return reports;
5211
+ }
5212
+
4934
5213
  function buildRigClipping(
4935
5214
  att: RigClippingAttachment,
4936
5215
  where: string,
@@ -4954,9 +5233,15 @@ function buildRigClipping(
4954
5233
  ...(att.end !== undefined ? { end: att.end } : {}),
4955
5234
  ...(att.convex !== undefined ? { convex: att.convex } : {}),
4956
5235
  ...(att.inverse !== undefined ? { inverse: att.inverse } : {}),
4957
- vertexCount: att.vertexCount,
4958
- vertices: buildVertexGeometry(att, where, ctx),
5236
+ vertexCount: att.vertexCount ?? 0,
5237
+ vertices: att.alphaClip === undefined ? buildVertexGeometry({ ...att, vertexCount: att.vertexCount! }, where, ctx) : { weighted: false, xy: [] },
4959
5238
  };
5239
+ if (att.alphaClip !== undefined) {
5240
+ const fields = ['vertexCount', 'vertices', 'weights', 'boneIndexing'] as const;
5241
+ const present = fields.filter((key) => att[key] !== undefined);
5242
+ if (present.length || att.convex === true || att.inverse === true) throw new CompileError(`${where}: alphaClip conflicts with [${present.join(', ')}], convex=${att.convex}, inverse=${att.inverse}; required ordinary inside clipping with generated geometry only. Remove authored geometry/flags.`, 'CE_COMPILE_ALPHA_CLIP_CONFLICT');
5243
+ ctx.pendingAlphaClips.push({ spec: att.alphaClip, out, where, ctx });
5244
+ }
4960
5245
  if (att.color !== undefined) out.editorColor = att.color;
4961
5246
  return out;
4962
5247
  }
@@ -5755,7 +6040,12 @@ function buildRigMesh(
5755
6040
  if (authored && att.generator) {
5756
6041
  throw new CompileError(`${where}: a mesh is either authored geometry or a generator, never both`, 'CE_COMPILE_MESH_AUTHORED_AND_GENERATED');
5757
6042
  }
5758
- if (att.generator) return buildGeneratedMesh(att, att.generator, placeholder, where, ctx);
6043
+ if (att.generator) {
6044
+ if (att.depth !== undefined) {
6045
+ throw new CompileError(`${where}: attachment "depth" describes authored geometry; a generated mesh states its depth on the contour or grid generator instead`, 'CE_COMPILE_DEPTH_AUTHORED_REQUIRED');
6046
+ }
6047
+ return buildGeneratedMesh(att, att.generator, placeholder, where, ctx);
6048
+ }
5759
6049
  if (att.vertices && att.weights) {
5760
6050
  throw new CompileError(
5761
6051
  `${where}: a mesh gives geometry as "vertices" or as "weights", never both — "weights" is the by-name form of the same data`, 'CE_COMPILE_MESH_GEOMETRY_SOURCES_CONFLICT',
@@ -5833,6 +6123,35 @@ function buildRigMesh(
5833
6123
  height: f32(height),
5834
6124
  };
5835
6125
  if (att.sequence !== undefined) out.sequence = buildSequence(att.sequence);
6126
+ let depth: ReturnType<typeof sampleMeshDepth> | undefined;
6127
+ if (att.depth !== undefined) {
6128
+ if (img === undefined) {
6129
+ throw new CompileError(`${where}: an authored mesh with "depth" needs an "image" to locate the part's pixel grid`, 'CE_COMPILE_DEPTH_IMAGE_REQUIRED');
6130
+ }
6131
+ const anchor = ctx.transforms.get(ctx.anchorBone);
6132
+ if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`, 'CE_COMPILE_SLOT_SETUP_TRANSFORM_MISSING');
6133
+ const geometry = deformGeometryOf(out, where, ctx.transforms, anchor);
6134
+ if (geometry.setup === null) {
6135
+ throw new CompileError(`${where}: depth needs resolved setup positions — ${geometry.setupWhy}`, 'CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED');
6136
+ }
6137
+ const world = geometry.setup;
6138
+ const plate = partPlate(img);
6139
+ const toArt = img.width / plate.width;
6140
+ // The generator's window is centred on the slot bone's setup WORLD origin.
6141
+ // UVs describe texture placement, not a vertex's position in that window.
6142
+ const points: Array<[number, number]> = [];
6143
+ for (let v = 0; v < world.length; v += 2) {
6144
+ points.push([(world[v] - anchor.worldX + img.width / 2) / toArt,
6145
+ (img.height / 2 - (world[v + 1] - anchor.worldY)) / toArt]);
6146
+ }
6147
+ depth = sampleMeshDepth(att.depth, 'authored', points, att.triangles,
6148
+ (px, py) => [anchor.worldX + px * toArt - img.width / 2, anchor.worldY + img.height / 2 - py * toArt],
6149
+ img.pageGrid === undefined ? plateAlpha(plate) : { unlocated: img.pageGrid.said },
6150
+ sheetGridOf(img, plate), where, ctx,
6151
+ Array.from({ length: world.length / 2 }, (_, v) => [world[2 * v], world[2 * v + 1]]));
6152
+ geometry.depth = depth.z;
6153
+ ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, { z: depth.z, geometry });
6154
+ }
5836
6155
  // Register it as `authored`: geometry rigc did not build and whose topology it
5837
6156
  // therefore gets to assume nothing about. The generator-topology assertions
5838
6157
  // read this and skip rather than measuring a ring that was never a ring.
@@ -5843,6 +6162,7 @@ function buildRigMesh(
5843
6162
  ctx.meshes.push({
5844
6163
  slot: ctx.slotName,
5845
6164
  kind: 'authored',
6165
+ ...(depth === undefined ? {} : { depth: depth.summary }),
5846
6166
  attachments: [ctx.slotName],
5847
6167
  vertices: uvCount / 2,
5848
6168
  triangles: att.triangles.length / 3,
@@ -6204,8 +6524,8 @@ function buildGeneratedMesh(
6204
6524
  * not invent tolerances.
6205
6525
  */
6206
6526
  function sampleMeshDepth(
6207
- spec: NonNullable<Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'contour' }>['depth']>,
6208
- kind: 'contour' | 'grid',
6527
+ spec: RigDepthMap,
6528
+ kind: 'contour' | 'grid' | 'authored',
6209
6529
  points: ReadonlyArray<readonly [number, number]>,
6210
6530
  /** The mesh's own triangle list, for the turn ceiling. */
6211
6531
  triangles: ReadonlyArray<number>,
@@ -6244,6 +6564,8 @@ function sampleMeshDepth(
6244
6564
  grid: SheetGrid,
6245
6565
  where: string,
6246
6566
  ctx: AttachmentContext,
6567
+ /** Authored setup-world vertices, already in the depth turn's evaluation space. */
6568
+ turnPoints?: ReadonlyArray<readonly [number, number]>,
6247
6569
  ): {
6248
6570
  z: number[];
6249
6571
  /** The tone-curved 0..1 nearness per vertex, which `bind` thresholds. */
@@ -6327,7 +6649,9 @@ function sampleMeshDepth(
6327
6649
  kind === 'contour'
6328
6650
  ? 'A contour mesh puts every vertex ON the silhouette and pushes it out by the margin, so a sheet cut to ' +
6329
6651
  'the art stops short of every one of them. Dilate the sheet past the mesh margin, or lower the margin.'
6330
- : 'A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the ' +
6652
+ : kind === 'authored'
6653
+ ? 'The authored setup vertices reach outside the sheet coverage. Extend the sheet to cover their bilinear taps, or correct the setup geometry.'
6654
+ : 'A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the ' +
6331
6655
  'lattice reaches past it wherever the art does not fill the window. Dilate the sheet to the window, or ' +
6332
6656
  'state "us"/"vs" that keep the lattice inside the art.';
6333
6657
  throw new CompileError(
@@ -6368,7 +6692,7 @@ function sampleMeshDepth(
6368
6692
  range: [f32(lo), f32(hi)],
6369
6693
  ...(partAlpha instanceof Uint8Array ? { undrawn } : { undrawn: null, unlocated: partAlpha.unlocated }),
6370
6694
  ceiling: turnCeiling(
6371
- points.map(([px, py]) => toBind(px, py)),
6695
+ turnPoints ?? points.map(([px, py]) => toBind(px, py)),
6372
6696
  z,
6373
6697
  triangles,
6374
6698
  ctx.turnNarrowing === undefined ? undefined : { narrowing: ctx.turnNarrowing },
@@ -8737,7 +9061,7 @@ export interface DeformGeometry {
8737
9061
  /** Why `setup` is null, phrased for the refusal. Null when it is not. */
8738
9062
  setupWhy: string | null;
8739
9063
  /**
8740
- * Per-vertex `z`, when this attachment's generator named a depth map.
9064
+ * Per-vertex `z`, when this attachment or its generator named a depth map.
8741
9065
  *
8742
9066
  * Not measured from the attachment like everything else here — there
8743
9067
  * is nowhere in the format to measure it FROM, which is the whole reason the
@@ -8771,6 +9095,8 @@ export function deformGeometryOf(
8771
9095
  where: string,
8772
9096
  /** Every bone's setup world transform, by name. */
8773
9097
  transforms: Map<string, BoneTransform>,
9098
+ /** Force the world-offset path for an authored depth turn, including rigid meshes. */
9099
+ depthAnchor?: BoneTransform,
8774
9100
  ): DeformGeometry {
8775
9101
  const type = att.kind;
8776
9102
  let worldVerticesLength: number;
@@ -8816,6 +9142,15 @@ export function deformGeometryOf(
8816
9142
  const vertices = att.vertices;
8817
9143
  const weighted = vertices.weighted;
8818
9144
  if (!vertices.weighted) {
9145
+ if (depthAnchor !== undefined) {
9146
+ return {
9147
+ weighted, deformLength: worldVerticesLength, vertexCount: worldVerticesLength / 2,
9148
+ boneCounts: Array(worldVerticesLength / 2).fill(1),
9149
+ setup: setupWorldVertices(vertices, depthAnchor, transforms, where).flat(),
9150
+ influenceBones: Array(worldVerticesLength / 2).fill(depthAnchor),
9151
+ setupWhy: null, depth: null, soft: null,
9152
+ };
9153
+ }
8819
9154
  return {
8820
9155
  weighted,
8821
9156
  deformLength: worldVerticesLength,
@@ -8879,7 +9214,7 @@ export function deformGeometryOf(
8879
9214
  // the runtime's last bits (`b = −2.3e-8` on an unrotated bone) into its
8880
9215
  // offset, about 1e-6 on gallery/look's hair locks. Evaluating on the authored
8881
9216
  // positions instead would need them carried here from the binder.
8882
- if (boneCounts.every((n) => n === 1) && bones.size === 1) {
9217
+ if (depthAnchor === undefined && boneCounts.every((n) => n === 1) && bones.size === 1) {
8883
9218
  return { ...common, setup: bindSpace, influenceBones: null, setupWhy: null };
8884
9219
  }
8885
9220
  // -- several influences, evaluated in world (issue #389) -------------------
@@ -9055,6 +9390,7 @@ function compileDeformTrack(
9055
9390
  duration: number,
9056
9391
  geometry: DeformGeometry,
9057
9392
  generated: CompileResult['deformTransforms'],
9393
+ depthWorld: DeformGeometry | null = null,
9058
9394
  ): ModelKey[] {
9059
9395
  const skin = track.skin ?? 'default';
9060
9396
  const where = `animation "${animName}" deform ${skin}/${track.slot}/${track.attachment}`;
@@ -9080,6 +9416,7 @@ function compileDeformTrack(
9080
9416
  // definite, which the arithmetic does for any weighting that closes at 1 and
9081
9417
  // for no weighting that does not (issue #389).
9082
9418
  if (key.transform !== undefined) {
9419
+ const evaluated = ('depth' in key.transform && key.transform.depth === true ? depthWorld : null) ?? geometry;
9083
9420
  if (key.vertices !== undefined && key.vertices !== null) {
9084
9421
  throw new CompileError(
9085
9422
  `${where} (t=${key.t}): the key carries both a "transform" and a "vertices" run, and they are two answers to ` +
@@ -9090,33 +9427,33 @@ function compileDeformTrack(
9090
9427
  if (key.offset !== undefined || key.fromVertex !== undefined) {
9091
9428
  throw new CompileError(
9092
9429
  `${where} (t=${key.t}): the key states a "transform" and a start index. A transform is a model of the whole ` +
9093
- `attachment and is evaluated over all ${geometry.vertexCount} of its vertices, so it always starts at deform ` +
9430
+ `attachment and is evaluated over all ${evaluated.vertexCount} of its vertices, so it always starts at deform ` +
9094
9431
  'index 0. A model applied to part of a run leaves a step at the run\'s edge — write the partial run by hand ' +
9095
9432
  'if that is what you mean.', 'CE_COMPILE_DEFORM_TRANSFORM_START_INDEX',
9096
9433
  );
9097
9434
  }
9098
- if (geometry.setup === null) {
9435
+ if (evaluated.setup === null) {
9099
9436
  throw new CompileError(
9100
9437
  `${where} (t=${key.t}): a "transform" states where every vertex of this attachment goes, and this one has a ` +
9101
- `vertex the arithmetic cannot place — ${geometry.setupWhy}. Fix the weights, or write the bind-space pairs ` +
9438
+ `vertex the arithmetic cannot place — ${evaluated.setupWhy}. Fix the weights, or write the bind-space pairs ` +
9102
9439
  'yourself and start the run with "offset".', 'CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED',
9103
9440
  );
9104
9441
  }
9105
9442
  const report = evaluateDeformTransform(
9106
9443
  key.transform,
9107
- geometry.setup,
9444
+ evaluated.setup,
9108
9445
  onModelGrid,
9109
9446
  `${where} (t=${key.t})`,
9110
- geometry.depth,
9111
- geometry.soft,
9447
+ evaluated.depth,
9448
+ evaluated.soft,
9112
9449
  );
9113
- if (report.offsets.length !== geometry.vertexCount * 2) {
9450
+ if (report.offsets.length !== evaluated.vertexCount * 2) {
9114
9451
  // Unreachable while `setup` is one pair per vertex, which is the whole
9115
9452
  // reason both are derived from the same walk. Stated rather than
9116
9453
  // assumed: a silent mismatch here is the overrun A35 exists for.
9117
9454
  throw new CompileError(
9118
9455
  `${where} (t=${key.t}): the transform produced ${report.offsets.length} numbers for ` +
9119
- `${geometry.vertexCount} vertices`, 'CE_COMPILE_DEFORM_TRANSFORM_OFFSET_COUNT',
9456
+ `${evaluated.vertexCount} vertices`, 'CE_COMPILE_DEFORM_TRANSFORM_OFFSET_COUNT',
9120
9457
  );
9121
9458
  }
9122
9459
  // -- one displacement per vertex -> one pair per influence (issue #389) -
@@ -9128,11 +9465,11 @@ function compileDeformTrack(
9128
9465
  // displacement it expands is the ROUNDED one the report prints, so a
9129
9466
  // reader can reproduce every emitted pair from the audit rather than from
9130
9467
  // a second evaluation.
9131
- const run = expandDeformToInfluences(report.offsets, geometry);
9132
- if (run.length !== geometry.deformLength) {
9468
+ const run = expandDeformToInfluences(report.offsets, evaluated);
9469
+ if (run.length !== evaluated.deformLength) {
9133
9470
  throw new CompileError(
9134
9471
  `${where} (t=${key.t}): the transform expanded to ${run.length} numbers and this attachment's deform ` +
9135
- `array is ${geometry.deformLength} long`, 'CE_COMPILE_DEFORM_TRANSFORM_RUN_LENGTH',
9472
+ `array is ${evaluated.deformLength} long`, 'CE_COMPILE_DEFORM_TRANSFORM_RUN_LENGTH',
9136
9473
  );
9137
9474
  }
9138
9475
  generated.push({
@@ -9148,14 +9485,14 @@ function compileDeformTrack(
9148
9485
  // other way to know they were not evaluated alike. Said only on the path
9149
9486
  // where it is not the attachment's own space, so every existing report
9150
9487
  // prints exactly the lines it printed before.
9151
- ...(geometry.influenceBones === null
9488
+ ...(evaluated.influenceBones === null
9152
9489
  ? {}
9153
9490
  : {
9154
9491
  expanded: run,
9155
9492
  derived: [
9156
9493
  ...report.derived,
9157
- `evaluated at setup WORLD positions, because ${geometry.vertexCount} vertices carry ` +
9158
- `${geometry.deformLength / 2} influences; each displacement D is written into every influence as ` +
9494
+ `evaluated at setup WORLD positions, because ${evaluated.vertexCount} vertices carry ` +
9495
+ `${evaluated.deformLength / 2} influences; each displacement D is written into every influence as ` +
9159
9496
  'Mᵢ⁻¹·D, so the runtime composes Σwᵢ·Mᵢ·Mᵢ⁻¹·D = D',
9160
9497
  ],
9161
9498
  }),
@@ -633,7 +633,11 @@ export function clipThrough(
633
633
  clip: TriangleClipper = clipTriangles,
634
634
  reading: { dropRegion?: number } = {},
635
635
  ): ClipResult | null {
636
- const plan = clipShapeOf(shape);
636
+ let plan = clipShapeOf(shape);
637
+ // CO11/CL01/CN06 and portal's convex rows need the original world doubles.
638
+ // Two 66-point region/authored up_minus15 pieces witnesses went from 2 to 0
639
+ // rendered pixel differences with Float32 input; SDK clipper storage was not observed.
640
+ if (plan.kind === 'pieces') plan = clipShapeOf({ ...shape, polygon: shape.polygon.map(Math.fround) });
637
641
  if (plan.kind === 'refused') return null;
638
642
  if (plan.kind === 'convex') return clip(plan.polygon, vertices, triangles, uvs);
639
643
  const f = Math.fround;