rig-parts 1.72.0 → 1.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/README.md CHANGED
@@ -592,10 +592,17 @@ controls; see [`CONFIG_TURN_ROLE_UNKNOWN`](docs/AUTHORING.md#CONFIG_TURN_ROLE_UN
592
592
  and [`CONFIG_TURN_SURFACE_REQUIRED`](docs/AUTHORING.md#CONFIG_TURN_SURFACE_REQUIRED).
593
593
  `turn.body.roles` adds independent body yaw. A weighted mesh declared as
594
594
  `"surface"` in either group and blending head and torso bones receives both
595
- fields in proportion to its skin weights. Each sibling island takes only its
595
+ fields in proportion to its skin weights. Inside the existing setup alpha
596
+ overlap with a single-group neighbour, a blended attachment's vertex takes
597
+ that neighbour's existing emitted triangle field at its setup position (#804), without changing skin weights,
598
+ setup or idle. Conflicting neighbour groups are refused by name. Each sibling island takes only its
596
599
  own group; parts without a blended attachment keep their existing role behavior
597
600
  and output bytes. Unlisted meshes do not consume turn depth sheets. See the
598
601
  [blended surface contract](docs/AUTHORING.md#per-layer-depth-sheets-773).
602
+ This copies the field at vertices; different triangle partitions can still
603
+ differ between vertices. The [corrected #804 acceptance](https://github.com/rig-c/parts/issues/804#issuecomment-6478778123)
604
+ checks vertex correspondence and reports those residuals per fixture; the
605
+ contract records the measured grid residual and preserves its seam readings.
599
606
  No-image builds keep the legacy output bytes. `comfy seethrough --config <file> --depth-run <full|head>`
600
607
  uses the same opt-in; its default captures art only. Standalone authored part
601
608
  references remain active declarations. See [the input contract and refusals](docs/AUTHORING.md#per-layer-depth-sheets-773).
package/docs/AUTHORING.md CHANGED
@@ -4518,7 +4518,10 @@ and stay absent).
4518
4518
  | <a id="CONFIG_TURN_HEAD_BONE"></a>`CONFIG_TURN_HEAD_BONE` | `config.turn.roles` is declared without a single bone `"head"` in `config.bones`, or `config.turn.body` without a single bone `"chest"` / `"hip"` (or its declared `bone`) | declare `"head"` (or the body turn root bone) as a single bone in `config.bones` |
4519
4519
  | <a id="CONFIG_TURN_SLIDER_NAME_FREE"></a>`CONFIG_TURN_SLIDER_NAME_FREE` | `config.turn.body.slider` (or the default `"body_yaw_dial"`) collides with a head turn dial (`"yaw"`, `"pitch"`, `"yaw_dial"`, `"pitch_dial"`) or an existing slider constraint in `config.constraints` (#787) | choose a distinct slider name in `config.turn.body.slider` |
4520
4520
  | <a id="RIG_TURN_SURFACE_MESH_GROUPS"></a>`RIG_TURN_SURFACE_MESH_GROUPS` | a surface part contains a blended attachment but lacks one 2.5D group, includes an ungrouped positive influence in any attachment, or also declares a unit/rear role | declare both `turn.roles` and `turn.body`, a body root outside head, surface in one or both groups, and bindings wholly inside those two subtrees |
4521
- | <a id="RIG_TURN_SURFACE_MESH_BINDINGS"></a>`RIG_TURN_SURFACE_MESH_BINDINGS` | an authored surface mesh has unresolved/nonclosing weights or shares a frame moved by a unit/rear turn carrier | provide weighted bindings closing at 1 in the authored frame; use independent chains for parts with different roles |
4521
+ | <a id="RIG_TURN_SURFACE_OVERLAP_GROUPS"></a>`RIG_TURN_SURFACE_OVERLAP_GROUPS` | a blended surface vertex overlaps incompatible single-group neighbours, or a neighbour without a blended attachment declares both turn groups | keep one neighbour group at that source-alpha overlap, or separate the overlapping art; the refusal names the vertex and neighbours |
4522
+ | <a id="RIG_TURN_SURFACE_OVERLAP_SUPPORT"></a>`RIG_TURN_SURFACE_OVERLAP_SUPPORT` | a selected neighbour has missing/invalid geometry, unresolved emitted vectors, or no triangle covering the requested setup point | supply its actual shown setup triangles and existing emitted field; no nearest triangle, extrapolation or target-field fallback is used; an unrelated disconnected drawing with no actual overlap is not selected |
4523
+ | <a id="RIG_TURN_SURFACE_OVERLAP_DEPTH"></a>`RIG_TURN_SURFACE_OVERLAP_DEPTH` | same-group overlap neighbours demand incompatible emitted offsets at one target setup point | separate the conflicting support or provide coincident neighbour fields; the refusal names the fields and target vertex |
4524
+ | <a id="RIG_TURN_SURFACE_MESH_BINDINGS"></a>`RIG_TURN_SURFACE_MESH_BINDINGS` | an authored surface mesh has unresolved/nonclosing weights or shares a frame moved by a unit/rear turn carrier; declared stop draw aliases resolve to their links for setup sampling | provide weighted bindings closing at 1 in the authored frame; `<link>_d` resolves only for links declared in `config.stops`; use independent chains for parts with different roles |
4522
4525
  | <a id="RIG_TURN_SURFACE_MESH_ATTACHMENT"></a>`RIG_TURN_SURFACE_MESH_ATTACHMENT` | a surface mesh has an alternate drawing without its own registered depth sampling frame | use the base attachment; explicit islands retain their own slot, attachment and bindings |
4523
4526
  | <a id="RIG_TURN_SURFACE_DEPTH_REQUIRED"></a>`RIG_TURN_SURFACE_DEPTH_REQUIRED` | a part with role `"surface"` in `config.turn.roles` or `config.turn.body.roles` carries no depth sheet in `parts.json` | run `assemble` with `config.depth` or provide `<part>_depth.png` in `parts.json` |
4524
4527
  | <a id="RIG_TURN_GRID_BUDGET"></a>`RIG_TURN_GRID_BUDGET` | a `"surface"` grid exceeds 2,400 triangles on one slot or 4,000 triangles across all `"surface"` slots | lower `config.turn.grid` / `config.turn.body.grid` `[cols, rows]` within the per-slot and total budgets |
@@ -5575,6 +5578,13 @@ dial; see [`CONFIG_TURN_ROLE_UNKNOWN`](#CONFIG_TURN_ROLE_UNKNOWN),
5575
5578
  [`RIG_TURN_SURFACE_MESH_BINDINGS`](#RIG_TURN_SURFACE_MESH_BINDINGS),
5576
5579
  [`CONFIG_TURN_UNIT_MESH_BONES`](#CONFIG_TURN_UNIT_MESH_BONES) and
5577
5580
  [`CONFIG_TURN_SLIDER_NAME_FREE`](#CONFIG_TURN_SLIDER_NAME_FREE).
5581
+ Weighted surfaces may bind chains declared in `config.stops` (#800). The
5582
+ sampler resolves each declared `<link>_d` draw alias and any child parent using
5583
+ it to the original link's setup frame, as `flattenRig` does. Weights, child
5584
+ parents and stops keep their emitted names; Core creates the draw bones later.
5585
+ A name ending in `_d` alone is not an alias: a missing non-stop bone still
5586
+ raises `RIG_TURN_SURFACE_MESH_BINDINGS`. No new config field is required.
5587
+
5578
5588
  A weighted surface spanning the head and body subtrees (#796) takes both
5579
5589
  fields. Declare it as `"surface"` in `turn.roles` or `turn.body.roles` (both is
5580
5590
  also accepted), with both 2.5D groups present. Head descendants belong to the
@@ -5596,6 +5606,78 @@ role behavior and output bytes, even if separate single-group islands bind
5596
5606
  different subtrees. Generated meshes and explicit mixed attachments use the
5597
5607
  same split.
5598
5608
 
5609
+ Inside the setup alpha overlap with a single-group neighbour (#804), a
5610
+ vertex of an individually blended attachment takes that neighbour's existing
5611
+ triangle field, interpolated barycentrically at the target's setup position.
5612
+ Direct sheet sampling is a different operator from the field the neighbour mesh actually emits.
5613
+ The band is the undilated intersection at alpha 128, the
5614
+ half-opacity threshold used by Core's seam reader: both registered source
5615
+ drawings must cover the source cell containing the vertex's setup position.
5616
+ Weighted neighbour attachments additionally require that setup point inside
5617
+ their own triangles, including triangle edges; disconnected siblings do not
5618
+ lend one another a mask. This includes weighted unit/rear neighbours: their
5619
+ actual shown setup attachments are supplied separately as support, never
5620
+ promoted to surface targets. Missing weighted geometry is refused rather than
5621
+ treated as a region. A mixed neighbour supplies no single-group anchor.
5622
+ Multiple neighbours must agree on their emitted field; conflicting groups, or a pure
5623
+ neighbour declaring both fields, raise
5624
+ [`RIG_TURN_SURFACE_OVERLAP_GROUPS`](#RIG_TURN_SURFACE_OVERLAP_GROUPS).
5625
+
5626
+ For a body-owned overlap, head yaw/pitch offsets are zero and body yaw is
5627
+ written into every positive binding's inverse frame. Spine still applies each
5628
+ original weight once, so the full body field replaces the skin-weight split.
5629
+ The symmetric rule holds for a head-owned overlap. Zero-weight entries retain
5630
+ zero offsets. A wholly inactive timeline is omitted. Outside the band the
5631
+ original split stands, including transparent padding; no distance, bounding
5632
+ box, dilation or nearest-neighbour fallback enlarges it. Exact support is read
5633
+ from the existing setup frames without a classification tolerance. Each
5634
+ neighbour field is frozen before overlap targets are rewritten: the producer's
5635
+ sheet-derived per-binding offsets are composed into one world vector per
5636
+ neighbour vertex, with its original skin weights applied once. The existing
5637
+ setup triangles interpolate those vectors at the target point. Different
5638
+ triangulations keep their different fields; a sheet resample, a bounding-box
5639
+ interpolator or an updated target cannot replace that source. The lowest-index
5640
+ triangle owns a shared edge. A selected point outside every neighbour triangle
5641
+ raises [`RIG_TURN_SURFACE_OVERLAP_SUPPORT`](#RIG_TURN_SURFACE_OVERLAP_SUPPORT).
5642
+ An unrelated disconnected sibling with no actual overlap stays unselected.
5643
+ Two same-group fields with incompatible emitted offsets raise
5644
+ [`RIG_TURN_SURFACE_OVERLAP_DEPTH`](#RIG_TURN_SURFACE_OVERLAP_DEPTH).
5645
+
5646
+ Unit/rear neighbours contribute their actual emitted rigid carrier motion,
5647
+ including its resolved unit depth or rear counter field. Their own sheet is
5648
+ not resampled to invent a spatial bend or a new refusal. Generated surface
5649
+ grids keep their Core generator: Core's grid builder and transform evaluator
5650
+ provide inspection metadata for the same existing topology and vertex field.
5651
+ The original role's depth requirements and the mandatory Core gate remain.
5652
+ The target's own sheet and the #796 split remain in use outside the overlap.
5653
+
5654
+ This operator matches the neighbour field at each selected vertex. It does
5655
+ not make two different triangle partitions represent the same field between
5656
+ vertices. The [corrected acceptance for #804](https://github.com/rig-c/parts/issues/804#issuecomment-6478778123)
5657
+ replaces the earlier inference of per-frame seam non-regression: an independent
5658
+ actual-Core rim vertex oracle must agree within 1.1e-6 px, and the
5659
+ differing-depth synthetic scanline must coincide on all 87 sampled turn,
5660
+ tilt and body-turn frames. Between-vertex residuals are reported per fixture;
5661
+ they do not add a topology refusal or change the existing mesh.
5662
+
5663
+ For the synthetic generated-grid neighbour, 65 evenly spaced setup points
5664
+ along rim segment 2--3 over 31 body-turn frames measured a maximum field
5665
+ difference of 0.16825740877976836 px between vertices; the endpoint discrepancy
5666
+ was at most 1.022663678185154e-6 px. On that same fixture the seam's worst
5667
+ ghost count decreased from 5 to 2 cells, but six individual frames increased
5668
+ by one cell; pair gap stayed zero. Those readings remain reported residuals.
5669
+ The authored differing-depth neighbour measured 3.059309099384677e-7 px on
5670
+ the same segment and sampling schedule. These are fixture-specific sampled
5671
+ maxima, not a continuous-mesh bound, a universal tolerance, or a repair claim
5672
+ for other drawings. The original red measurement is retained beside the
5673
+ corrected acceptance result.
5674
+
5675
+ There is no new config field. Only turn deform changes: skin weights, setup,
5676
+ idle, physics, art and topology are preserved. Parts without a blended
5677
+ attachment retain their existing emitted bytes. Synthetic overlap controls
5678
+ establish this rule; they do not establish an all-frame repair of a consumer
5679
+ demo.
5680
+
5599
5681
  An unlisted mesh does not opt in merely by spanning the subtrees. A blended
5600
5682
  surface requires its own depth sheet (`RIG_TURN_SURFACE_DEPTH_REQUIRED`) and
5601
5683
  both groups, with every positive influence in one of their subtrees, including
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.72.0",
3
+ "version": "1.73.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/rig.ts CHANGED
@@ -138,14 +138,14 @@ import { explicitAttachment, type ExplicitMeshReport } from './explicitmesh.ts';
138
138
  * order the objects are built in, and every number is rounded by `pyRound`).
139
139
  */
140
140
  import { type AutoSpec, type BoneEntry, type InfluenceMeshSpec, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, contourRegionsOf, type ContourSpec, DEFAULT_BUST_BONE_L, DEFAULT_BUST_BONE_R, type Point, type RibsSpec, ROOT_BONE, stopForRig, turnRidersByBone, weightsABone } from './config.ts';
141
- import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, paintingToSetupPoint, toBoneLocal, toWorld } from './coords.ts';
141
+ import { type BoneTransform, computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, paintingToSetupPoint, setupToPaintingPoint, toBoneLocal, toWorld } from './coords.ts';
142
142
  import { type Problem, refuseIfAny, PartsError } from './errors.ts';
143
143
  import { recordedFocusProblems } from './setfocus.ts';
144
144
  import { eyeLineDegrees } from './headframe.ts';
145
145
  import type { RigBoneInherit, RigStop } from 'rig-c/src/rig.ts';
146
146
  import { f32 } from 'rig-c/compile';
147
147
  import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
148
- import { buildSegmentsLattice, type EntrySegment, influenceShares, segmentDistance as rigcSegmentDistance, type SegmentsLattice, type SegmentsLatticeReport } from 'rig-c/mesh';
148
+ import { bindWeightedVertices, buildGridMesh, buildSegmentsLattice, type EntrySegment, influenceShares, segmentDistance as rigcSegmentDistance, type SegmentsLattice, type SegmentsLatticeReport } from 'rig-c/mesh';
149
149
  import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
150
150
  import { type ResidualRow, residualRow, slotEnvelope, withSkinning } from './autoenvelope.ts';
151
151
  import { type AutoMotionCase, type AutoSearch, DEFORM_MAY_FOLD_WHY, type MotionDeformation, type StimulusPose, stimulusAnimationNames } from './automotion.ts';
@@ -3662,6 +3662,7 @@ export function buildWaistPairSource(
3662
3662
  boneLandmarks,
3663
3663
  hasAlternateFaceSet,
3664
3664
  surfaceMeshes: turnSurfaceMeshes(cfg, rig, parts.rig_size),
3665
+ neighbourMeshes: turnNeighbourMeshes(cfg, rig, parts),
3665
3666
  });
3666
3667
  }
3667
3668
  bindFaceSurfaceDeform(motion, (merged) => {
@@ -3678,6 +3679,7 @@ export function buildWaistPairSource(
3678
3679
  boneLandmarks,
3679
3680
  hasAlternateFaceSet,
3680
3681
  surfaceMeshes: turnSurfaceMeshes(cfg, rig, parts.rig_size),
3682
+ neighbourMeshes: turnNeighbourMeshes(cfg, rig, parts),
3681
3683
  });
3682
3684
  }
3683
3685
  if (cfg.turn?.roles === undefined && (cfg.turn === undefined || cfg.turn.body === undefined || cfg.turn.depths !== undefined)) {
@@ -4190,7 +4192,45 @@ function turnSurfaceMeshes(cfg: CharacterConfig, rig: RigSpec, canvas: readonly
4190
4192
  if (attachment !== part && !slot.name.startsWith(`${part}__island`)) {
4191
4193
  throw new PartsError([{ code: 'RIG_TURN_SURFACE_MESH_ATTACHMENT', object: `surface mesh "${slot.name}/${attachment}"`, detail: `requires the base drawing "${part}"; alternate drawings need their own registered depth sampling frame` }]);
4192
4194
  }
4193
- meshes.push({ part, slot: slot.name, attachment, weights: att.weights, canvas });
4195
+ meshes.push({ part, slot: slot.name, attachment, weights: att.weights, triangles: att.triangles, canvas });
4196
+ }
4197
+ }
4198
+ }
4199
+ return meshes;
4200
+ }
4201
+
4202
+ /** Actual shown support; Core's own grid topology is inspection metadata only. */
4203
+ function turnNeighbourMeshes(cfg: CharacterConfig, rig: RigSpec, parts: PartsFile) {
4204
+ const meshes: NonNullable<Parameters<typeof buildDepth25DTurn>[2]['neighbourMeshes']>[number][] = [];
4205
+ const canvas = parts.rig_size;
4206
+ const aliases = new Map<string, string>((rig.stops ?? []).flatMap(s => s.links.map(link => [`${link}_d`, link])));
4207
+ const world = computeWorldTransforms(rig.bones.map(b => b.parent === undefined ? b : { ...b, parent: aliases.get(b.parent) ?? b.parent }));
4208
+ for (const part of new Set([...Object.keys(cfg.turn?.roles ?? {}), ...Object.keys(cfg.turn?.body?.roles ?? {})])) {
4209
+ if (cfg.meshes[part] !== undefined && [cfg.turn?.roles?.[part], cfg.turn?.body?.roles?.[part]].includes('surface')) continue;
4210
+ const record = parts.parts.find(p => p.name === part);
4211
+ if (record === undefined) continue;
4212
+ for (const slot of rig.slots) {
4213
+ if (slot.name !== part && !slot.name.startsWith(`${part}__island`)) continue;
4214
+ for (const [attachment, att] of Object.entries(rig.skins.default[slot.name] ?? {})) {
4215
+ if (attachment !== slot.attachment) continue;
4216
+ if ('type' in att && att.weights !== undefined && att.weights.length > 0) { meshes.push({ part, slot: slot.name, attachment, weights: att.weights, triangles: att.triangles, canvas }); continue; }
4217
+ const frame = world.get(aliases.get(slot.bone) ?? slot.bone);
4218
+ if (frame === undefined) throw new PartsError([{ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `turn neighbour "${slot.name}/${attachment}"`, detail: `has no resolved setup frame for "${slot.bone}"; supply the actual attachment frame` }]);
4219
+ if ('type' in att && att.generator?.kind === 'grid') {
4220
+ const gen = att.generator, cols = gen.cols, rows = gen.rows;
4221
+ const us = Array.from({ length: cols }, (_, i) => i / (cols - 1));
4222
+ const vs = Array.from({ length: rows }, (_, i) => i / (rows - 1));
4223
+ const geometry = buildGridMesh({ size: [att.width, att.height], us, vs });
4224
+ const place = (x: number, y: number): [number, number] => [frame.worldX + x - att.width / 2, frame.worldY + cropToSpineY(y, att.height) - att.height / 2];
4225
+ const bound = bindWeightedVertices(geometry, place, { anchor: { name: slot.bone, toBind: (x, y) => toBoneLocal(frame, x, y) }, controls: [] });
4226
+ meshes.push({ part, slot: slot.name, attachment, canvas, triangles: geometry.triangles,
4227
+ weights: bound.bindings.map(row => row.map(w => ({ bone: w.bone, x: f32(w.x), y: f32(w.y), weight: f32(w.weight) }))),
4228
+ gridSamples: geometry.points.map(([x, y]) => setupToPaintingPoint(place(x, y), canvas, canvas)),
4229
+ });
4230
+ } else if (!('type' in att)) {
4231
+ const points = [[record.x - PAD, record.y - PAD], [record.x - PAD, record.y + record.h + PAD], [record.x + record.w + PAD, record.y + record.h + PAD], [record.x + record.w + PAD, record.y - PAD]];
4232
+ meshes.push({ part, slot: slot.name, attachment, canvas, triangles: [0, 1, 2, 0, 2, 3], weights: points.map(p => { const q = paintingToSetupPoint(p, canvas, canvas), local = toBoneLocal(frame, q[0], q[1]); return [{ bone: slot.bone, x: f32(local[0]), y: f32(local[1]), weight: 1 }]; }) });
4233
+ } else throw new PartsError([{ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `turn neighbour "${slot.name}/${attachment}"`, detail: 'has neither final weighted geometry nor a supported Core grid; supply actual setup support, not a source rectangle' }]);
4194
4234
  }
4195
4235
  }
4196
4236
  }
package/src/turn.ts CHANGED
@@ -33,6 +33,11 @@ import { extendDepthSilhouette, sampleDepthFloat, type PartDepthReference } from
33
33
  import { type Problem, refuseIfAny } from './errors.ts';
34
34
  import type { Raster } from './raster/index.ts';
35
35
  import { pyRound } from './round.ts';
36
+ import { turnOverlapOwners } from './turnoverlap.ts';
37
+ import { sampleTurnTriangleField, turnTriangleCoordinates } from './turnfield.ts';
38
+ import { PartsError } from './errors.ts';
39
+ import { f32 } from 'rig-c/compile';
40
+ import { evaluateDeformTransform } from 'rig-c/src/deformgen.ts';
36
41
 
37
42
  /**
38
43
  * The layers that ride on the face (issue #271): See-through makes no depth
@@ -1487,9 +1492,17 @@ export interface Depth25DTurnOptions {
1487
1492
  attachment: string;
1488
1493
  weights: ReadonlyArray<ReadonlyArray<{ bone: string; x: number; y: number; weight: number }>>;
1489
1494
  canvas: readonly [number, number];
1495
+ /** Existing setup topology resolves neighbour islands without a part-wide bbox. */
1496
+ triangles?: readonly number[];
1497
+ /** Core-generated grid sample positions, support only; never authored bindings. */
1498
+ gridSamples?: ReadonlyArray<readonly [number, number]>;
1499
+ /** Derived only from existing setup alpha overlap; never an authored switch. */
1500
+ overlapOwners?: ReadonlyArray<{ group: 'head' | 'body'; neighbours: readonly string[] } | undefined>;
1490
1501
  /** Every attachment of a part containing a blended mesh carries its own group mask. */
1491
1502
  groups?: ReadonlyMap<string, 'head' | 'body'>;
1492
1503
  }>;
1504
+ /** Final weighted unit/rear drawings are support only, never surface targets. */
1505
+ neighbourMeshes?: Depth25DTurnOptions['surfaceMeshes'];
1493
1506
  }
1494
1507
 
1495
1508
  interface Depth25DGroupSpec {
@@ -1512,12 +1525,25 @@ interface Depth25DGroupSpec {
1512
1525
  }>;
1513
1526
  }
1514
1527
 
1528
+ interface Depth25DTurnRig {
1529
+ bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number }>;
1530
+ slots: Array<{ name: string; bone: string; attachment: string | null }>;
1531
+ constraints?: Array<Record<string, unknown>>;
1532
+ stops?: ReadonlyArray<{ links: readonly string[] }>;
1533
+ }
1534
+
1535
+ /** Stop draw bones share their link's setup frame; Core emits them later. */
1536
+ function surfaceSetupGraph(rig: Depth25DTurnRig) {
1537
+ const aliases = new Map<string, string>();
1538
+ for (const stop of rig.stops ?? []) for (const link of stop.links) aliases.set(`${link}_d`, link);
1539
+ const resolve = (name: string): string => aliases.get(name) ?? name;
1540
+ // Like flattenRig: resolve parents as well as weights, without changing the rig.
1541
+ const bones = rig.bones.map(b => b.parent === undefined ? b : { ...b, parent: resolve(b.parent) });
1542
+ return { bones, resolve };
1543
+ }
1544
+
1515
1545
  function buildDepth25DTurnGroup(
1516
- rig: {
1517
- bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number }>;
1518
- slots: Array<{ name: string; bone: string; attachment: string | null }>;
1519
- constraints?: Array<Record<string, unknown>>;
1520
- },
1546
+ rig: Depth25DTurnRig,
1521
1547
  motion: {
1522
1548
  groups?: Record<string, string[]>;
1523
1549
  animations: Record<string, unknown>;
@@ -1814,27 +1840,29 @@ function buildDepth25DTurnGroup(
1814
1840
  for (const [part, { rec, ext, zRef }] of surfaceSheets) {
1815
1841
  const authored = opts.surfaceMeshes?.filter((m) => m.part === part) ?? [];
1816
1842
  if (authored.length > 0) {
1817
- const world = computeWorldTransforms(rig.bones);
1843
+ const setup = surfaceSetupGraph(rig);
1844
+ const world = computeWorldTransforms(setup.bones);
1818
1845
  for (const mesh of authored) {
1819
1846
  // An all-zero timeline can overwrite another slider's deform (Core A40).
1820
1847
  // A sibling with no positive binding in this group owns no timeline here.
1821
- const active = mesh.groups === undefined || [...mesh.groups.values()].includes(group.fieldPrefix === 'config.turn' ? 'head' : 'body');
1848
+ const field = group.fieldPrefix === 'config.turn' ? 'head' : 'body';
1849
+ const active = mesh.groups === undefined || mesh.weights.some((row, i) => row.some(w => w.weight > 0 && (mesh.overlapOwners?.[i] === undefined ? mesh.groups?.get(w.bone) === field : mesh.overlapOwners[i]?.group === field)));
1822
1850
  const vertices = mesh.weights.map((row, index) => {
1823
1851
  let wx = 0, wy = 0, total = 0;
1824
1852
  for (const w of row) {
1825
- const frame = world.get(w.bone);
1853
+ const frame = world.get(setup.resolve(w.bone));
1826
1854
  if (frame === undefined) {
1827
1855
  problems.push({ code: 'RIG_TURN_SURFACE_MESH_BINDINGS', object: `surface mesh "${mesh.slot}/${mesh.attachment}" vertex ${index}`, detail: `names missing bone "${w.bone}"; every influence must resolve in the final setup` });
1828
1856
  continue;
1829
1857
  }
1830
1858
  // A unit/rear mesh sharing this root would move our bindings too.
1831
- let ancestor = rig.bones.find((b) => b.name === w.bone);
1859
+ let ancestor = setup.bones.find((b) => b.name === setup.resolve(w.bone));
1832
1860
  while (ancestor !== undefined) {
1833
1861
  if ([FACESHIFT_BONE, REAR_SHIFT_BONE, BODYSHIFT_BONE, BODY_REAR_SHIFT_BONE].includes(ancestor.name)) {
1834
1862
  problems.push({ code: 'RIG_TURN_SURFACE_MESH_BINDINGS', object: `${group.fieldPrefix}.roles.${part}`, detail: `binds "${w.bone}" through turn carrier "${ancestor.name}" shared with another role; surface mesh bindings must retain their authored frame` });
1835
1863
  break;
1836
1864
  }
1837
- ancestor = rig.bones.find((b) => b.name === ancestor?.parent);
1865
+ ancestor = setup.bones.find((b) => b.name === ancestor?.parent);
1838
1866
  }
1839
1867
  const [x, y] = toWorld(frame, w.x, w.y);
1840
1868
  wx += x * w.weight; wy += y * w.weight; total += w.weight;
@@ -1857,8 +1885,8 @@ function buildDepth25DTurnGroup(
1857
1885
  vertices: vertices.flatMap(({ row, depth }) => {
1858
1886
  const d = -depth * Math.sin(deg * Math.PI / 180);
1859
1887
  return row.flatMap((w) => {
1860
- if (mesh.groups !== undefined && (w.weight === 0 || mesh.groups.get(w.bone) !== (group.fieldPrefix === 'config.turn' ? 'head' : 'body'))) return [0, 0];
1861
- const frame = world.get(w.bone)!; // every influence resolved before emitting keys
1888
+ if (mesh.groups !== undefined && (w.weight === 0 || mesh.groups.get(w.bone) !== field)) return [0, 0];
1889
+ const frame = world.get(setup.resolve(w.bone))!; // every influence resolved before emitting keys
1862
1890
  const [x, y] = toBoneLocalVector(frame, kind === 'yaw' ? d : 0, kind === 'pitch' ? d : 0);
1863
1891
  return [pyRound(x, 6), pyRound(y, 6)];
1864
1892
  });
@@ -1887,6 +1915,103 @@ function buildDepth25DTurnGroup(
1887
1915
  });
1888
1916
  }
1889
1917
 
1918
+ // Snapshot the emitted fields BEFORE any overlap target is rewritten.
1919
+ // Mixed targets never become another target's source by traversal order.
1920
+ if (opts.surfaceMeshes?.some(m => m.overlapOwners !== undefined)) {
1921
+ const setup = surfaceSetupGraph(rig), world = computeWorldTransforms(setup.bones);
1922
+ const parents = new Map(setup.bones.map(b => [b.name, b.parent]));
1923
+ const field = group.fieldPrefix === 'config.turn' ? 'head' : 'body';
1924
+ const supports = [...(opts.surfaceMeshes ?? []), ...(opts.neighbourMeshes ?? [])];
1925
+ const raw = deform.map(d => ({ ...d, keys: d.keys.map(k => ({ ...k, vertices: k.vertices?.slice() })) }));
1926
+ const points = (mesh: typeof supports[number]) => mesh.weights.map(row => {
1927
+ let x = 0, y = 0;
1928
+ for (const w of row) { const frame = world.get(setup.resolve(w.bone)); if (frame === undefined) return [NaN, NaN]; const q = toWorld(frame, w.x, w.y); x += q[0] * w.weight; y += q[1] * w.weight; }
1929
+ return [x, y];
1930
+ });
1931
+ for (const [ki, { deg }] of keyList.entries()) {
1932
+ if (deg === 0) continue;
1933
+ // All tracks this producer emits are local translations. Sum their
1934
+ // actual emitted world vectors along each binding's ancestor chain.
1935
+ const translations = new Map<string, [number, number]>();
1936
+ for (const track of tracks) {
1937
+ const names = track.bone === undefined ? motion.groups?.[track.group!] : [track.bone];
1938
+ if (names === undefined) { problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `animation "${animName}"`, detail: `emitted group "${track.group}" has no resolved bones; an unresolved track is not a zero field` }); continue; }
1939
+ for (const bone of names) {
1940
+ const key = track.keys[ki], derived = key.derive;
1941
+ const depth = typeof derived?.depth === 'number' ? derived.depth : derived?.depth?.[bone];
1942
+ const stated = Array.isArray(key.v) ? key.v[0] : depth === undefined ? undefined : -(depth - (derived?.carried ?? 0)) * Math.sin(deg * Math.PI / 180);
1943
+ if (stated === undefined || !Number.isFinite(stated)) { problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `animation "${animName}" bone "${bone}"`, detail: 'has no finite emitted translation; unresolved depth is not replaced by zero' }); continue; }
1944
+ const value = f32(stated);
1945
+ const parent = parents.get(bone), frame = parent === undefined ? undefined : world.get(setup.resolve(parent));
1946
+ if (parent !== undefined && frame === undefined) { problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `animation "${animName}" bone "${bone}"`, detail: `has no resolved parent frame "${parent}"; supply its setup transform` }); continue; }
1947
+ translations.set(bone, frame === undefined ? [track.property === 'translatex' ? value : 0, track.property === 'translatey' ? value : 0]
1948
+ : track.property === 'translatex' ? [frame.a * value, frame.c * value] : [frame.b * value, frame.d * value]);
1949
+ }
1950
+ }
1951
+ const carried = (bone: string): [number, number] => {
1952
+ let x = 0, y = 0; const seen = new Set<string>();
1953
+ for (let b: string | undefined = setup.resolve(bone); b !== undefined && !seen.has(b); b = parents.get(b)) { seen.add(b); const v = translations.get(b); if (v !== undefined) { x += v[0]; y += v[1]; } }
1954
+ return [x, y];
1955
+ };
1956
+ const snapshots = supports.filter(m => roles[m.part] !== undefined && (m.groups === undefined || [...m.groups.values()].includes(field))).map(mesh => {
1957
+ const positions = points(mesh), key = raw.find(d => d.slot === mesh.slot && d.attachment === mesh.attachment)?.keys[ki];
1958
+ let generated: number[] | undefined;
1959
+ if (key?.transform !== undefined && mesh.gridSamples !== undefined) {
1960
+ const sheet = surfaceSheets.get(mesh.part);
1961
+ if (sheet !== undefined) generated = evaluateDeformTransform(key.transform, positions.flat(), f32, `turn neighbour "${mesh.slot}/${mesh.attachment}"`, mesh.gridSamples.map(([x, y]) => levelToDepthZ(sampleDepthFloat(sheet.ext, x - sheet.rec.x, y - sheet.rec.y), sheet.rec.depth))).offsets;
1962
+ }
1963
+ let at = 0;
1964
+ const vectors = mesh.weights.map((row, vi) => {
1965
+ let x = 0, y = 0;
1966
+ for (const w of row) {
1967
+ const frame = world.get(setup.resolve(w.bone));
1968
+ if (frame === undefined) return [NaN, NaN];
1969
+ const v = generated === undefined ? [key?.vertices?.[at], key?.vertices?.[at + 1]] : toBoneLocalVector(frame, generated[2 * vi], generated[2 * vi + 1]);
1970
+ // Surface fields require an emitted vertex run (or Core's grid
1971
+ // transform); absence is never interpreted as target depth.
1972
+ if (roles[mesh.part] === 'surface' && (v[0] === undefined || v[1] === undefined)) return [NaN, NaN];
1973
+ const dx = f32(v[0] ?? 0), dy = f32(v[1] ?? 0), c = carried(w.bone);
1974
+ x += (frame.a * dx + frame.b * dy + c[0]) * w.weight;
1975
+ y += (frame.c * dx + frame.d * dy + c[1]) * w.weight; at += 2;
1976
+ }
1977
+ return [x, y];
1978
+ });
1979
+ return { mesh, label: `${mesh.part}/${mesh.slot}/${mesh.attachment}`, points: positions, triangles: mesh.triangles ?? [], vectors };
1980
+ });
1981
+ for (const mesh of opts.surfaceMeshes ?? []) {
1982
+ if (mesh.overlapOwners === undefined) continue;
1983
+ const target = deform.find(d => d.slot === mesh.slot && d.attachment === mesh.attachment)?.keys[ki];
1984
+ if (target?.vertices === undefined) continue;
1985
+ let at = 0; const positions = points(mesh);
1986
+ for (const [vi, row] of mesh.weights.entries()) {
1987
+ const owner = mesh.overlapOwners[vi];
1988
+ if (owner === undefined) { at += 2 * row.length; continue; }
1989
+ const encode = (v: readonly number[]) => row.flatMap(w => { if (w.weight === 0) return [0, 0]; const q = toBoneLocalVector(world.get(setup.resolve(w.bone))!, v[0], v[1]); return q.map(n => pyRound(n, 6)); });
1990
+ let values: number[] = Array<number>(2 * row.length).fill(0);
1991
+ if (owner.group === field) {
1992
+ const samples: Array<{ name: string; values: number[] }> = [];
1993
+ for (const name of owner.neighbours) {
1994
+ const candidates = snapshots.filter(s => s.mesh.part === name), point = positions[vi];
1995
+ const covering = candidates.filter(s => turnTriangleCoordinates(s.points, s.triangles, point[0], point[1]) !== null);
1996
+ const chosen = covering.length === 0 ? candidates.slice(0, 1) : covering;
1997
+ if (chosen.length === 0) problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `surface mesh "${mesh.slot}/${mesh.attachment}" vertex ${vi}`, detail: `selected neighbour "${name}" has no existing triangle field; supply actual setup support; target depth is not a fallback` });
1998
+ for (const source of chosen) {
1999
+ try { samples.push({ name: source.label, values: encode(sampleTurnTriangleField(source, point, `surface mesh "${mesh.slot}/${mesh.attachment}" vertex ${vi}`)) }); }
2000
+ catch (e) { if (e instanceof PartsError) problems.push(...e.problems); else throw e; }
2001
+ }
2002
+ }
2003
+ if (samples.length > 0) {
2004
+ values = samples[0].values;
2005
+ if (samples.some(s => s.values.some((v, i) => v !== values[i]))) problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_DEPTH', object: `surface mesh "${mesh.slot}/${mesh.attachment}" vertex ${vi}`, detail: `neighbours ${samples.map(s => `"${s.name}"`).join(', ')} require incompatible emitted fields at this setup point; separate the conflicting support or supply coincident fields` });
2006
+ }
2007
+ }
2008
+ target.vertices.splice(at, 2 * row.length, ...values); at += 2 * row.length;
2009
+ }
2010
+ }
2011
+ }
2012
+ for (let i = deform.length - 1; i >= 0; i--) if (opts.surfaceMeshes.some(m => m.slot === deform[i].slot && m.attachment === deform[i].attachment && m.overlapOwners !== undefined) && deform[i].keys.every(k => k.vertices === undefined || k.vertices.every(v => v === 0))) deform.splice(i, 1);
2013
+ }
2014
+
1890
2015
  motion.animations[animName] = {
1891
2016
  duration,
1892
2017
  loop: false,
@@ -1947,11 +2072,7 @@ function buildDepth25DTurnGroup(
1947
2072
  * `derive` and `deform` `transform` (`depth: true, narrowing: false`).
1948
2073
  */
1949
2074
  export function buildDepth25DTurn(
1950
- rig: {
1951
- bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number }>;
1952
- slots: Array<{ name: string; bone: string; attachment: string | null }>;
1953
- constraints?: Array<Record<string, unknown>>;
1954
- },
2075
+ rig: Depth25DTurnRig,
1955
2076
  motion: {
1956
2077
  groups?: Record<string, string[]>;
1957
2078
  animations: Record<string, unknown>;
@@ -1960,14 +2081,17 @@ export function buildDepth25DTurn(
1960
2081
  ): void {
1961
2082
  const problems: Problem[] = [];
1962
2083
  const { yaw: yawMax, pitch: pitchMax } = resolveTurnBands(opts.turn);
2084
+ // Ownership is derived here, never accepted as an authored producer switch.
2085
+ opts = { ...opts, surfaceMeshes: opts.surfaceMeshes?.map(({ overlapOwners: _owners, groups: _groups, ...mesh }) => mesh) };
1963
2086
 
1964
2087
  // Only a surface opt-in spanning both authored subtrees changes paths (#796).
1965
2088
  // Single-group meshes keep #795's role and bytes, including explicit islands.
1966
- const parents = new Map(rig.bones.map((b) => [b.name, b.parent]));
2089
+ const setup = surfaceSetupGraph(rig);
2090
+ const parents = new Map(setup.bones.map((b) => [b.name, b.parent]));
1967
2091
  const bodyRoot = opts.turn.body?.bone ?? (parents.has('chest') ? 'chest' : 'hip');
1968
2092
  const descends = (bone: string, root: string): boolean => {
1969
2093
  const seen = new Set<string>();
1970
- for (let cur: string | undefined = bone; cur !== undefined && !seen.has(cur); cur = parents.get(cur)) {
2094
+ for (let cur: string | undefined = setup.resolve(bone); cur !== undefined && !seen.has(cur); cur = parents.get(cur)) {
1971
2095
  if (cur === root) return true;
1972
2096
  seen.add(cur);
1973
2097
  }
@@ -2008,8 +2132,14 @@ export function buildDepth25DTurn(
2008
2132
  });
2009
2133
  refuseIfAny(problems);
2010
2134
  if (blended.size > 0) {
2135
+ const owners = turnOverlapOwners(rig, opts, classified ?? [], blended, problems);
2136
+ refuseIfAny(problems);
2137
+ const overlapped = surfaceMeshes?.map((mesh, i) => {
2138
+ const overlapOwners = owners.get(classified![i].mesh);
2139
+ return overlapOwners === undefined ? mesh : { ...mesh, overlapOwners };
2140
+ });
2011
2141
  const surfaceRoles = Object.fromEntries([...blended].map(part => [part, 'surface' as const]));
2012
- opts = { ...opts, surfaceMeshes, turn: { ...opts.turn,
2142
+ opts = { ...opts, surfaceMeshes: overlapped, turn: { ...opts.turn,
2013
2143
  roles: { ...opts.turn.roles, ...surfaceRoles },
2014
2144
  body: { ...opts.turn.body!, roles: { ...opts.turn.body!.roles, ...surfaceRoles } },
2015
2145
  } };
@@ -0,0 +1,36 @@
1
+ /** Sampling an existing emitted triangle field is not resampling its source sheet. */
2
+ import { PartsError } from './errors.ts';
3
+
4
+ export interface TurnTriangleField {
5
+ label: string;
6
+ points: ReadonlyArray<readonly number[]>;
7
+ triangles: readonly number[];
8
+ vectors: ReadonlyArray<readonly number[]>;
9
+ }
10
+
11
+ /** Lowest triangle index on a shared edge; no epsilon or extrapolation. */
12
+ export function turnTriangleCoordinates(points: ReadonlyArray<readonly number[]>, triangles: readonly number[], x: number, y: number): { ids: number[]; weights: number[] } | null {
13
+ for (let i = 0; i < triangles.length; i += 3) {
14
+ const ids = triangles.slice(i, i + 3), [a, b, c] = ids.map(j => points[j]);
15
+ if (a === undefined || b === undefined || c === undefined) continue;
16
+ const cross = (p: readonly number[], q: readonly number[]) => (p[0] - x) * (q[1] - y) - (p[1] - y) * (q[0] - x);
17
+ const area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
18
+ if (area === 0) continue;
19
+ const weights = [cross(b, c) / area, cross(c, a) / area, cross(a, b) / area];
20
+ if (weights.every(w => w >= 0)) return { ids, weights };
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** Named refusal outside support; never fall back to target depth or nearest geometry. */
26
+ export function sampleTurnTriangleField(field: TurnTriangleField, point: readonly number[], object: string): [number, number] {
27
+ const fail = (detail: string): never => { throw new PartsError([{ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object, detail: `neighbour "${field.label}" ${detail}` }]); };
28
+ if (point.length !== 2 || !point.every(Number.isFinite) || field.points.length !== field.vectors.length
29
+ || field.points.some(p => p.length !== 2 || !p.every(Number.isFinite)) || field.vectors.some(v => v.length !== 2 || !v.every(Number.isFinite))
30
+ || field.triangles.length === 0 || field.triangles.length % 3 !== 0 || field.triangles.some(i => !Number.isSafeInteger(i) || i < 0 || i >= field.points.length)) return fail('requires finite setup points, one emitted vector per point and existing triangle indices; supply the actual attachment field');
31
+ const hit = turnTriangleCoordinates(field.points, field.triangles, point[0], point[1]);
32
+ if (hit === null) return fail(`has no triangle covering setup (${point[0]}, ${point[1]}); provide actual covering support; no extrapolation or target-field fallback is permitted`);
33
+ const [a, b, c] = hit.ids.map(i => field.vectors[i]), [u, v] = hit.weights;
34
+ // Translation-invariant form: constant fields remain exactly constant.
35
+ return [c[0] + u * (a[0] - c[0]) + v * (b[0] - c[0]), c[1] + u * (a[1] - c[1]) + v * (b[1] - c[1])];
36
+ }
@@ -0,0 +1,95 @@
1
+ /** Source-alpha overlap selects turn fields, never skin bindings. */
2
+ import { DRAWN_ALPHA } from 'rig-c/src/passthrough.ts';
3
+ import { computeWorldTransforms, setupToPaintingPoint, toWorld } from './coords.ts';
4
+ import type { Depth25DTurnOptions } from './turn.ts';
5
+ import type { Problem } from './errors.ts';
6
+ import { turnTriangleCoordinates } from './turnfield.ts';
7
+
8
+ type Group = 'head' | 'body';
9
+ type Surface = NonNullable<Depth25DTurnOptions['surfaceMeshes']>[number];
10
+ type Owner = NonNullable<Surface['overlapOwners']>[number];
11
+ export interface ClassifiedTurnSurface {
12
+ mesh: Surface;
13
+ groups: ReadonlyMap<string, Group>;
14
+ }
15
+
16
+ /**
17
+ * The seam reader's band is the undilated setup intersection at DRAWN_ALPHA.
18
+ * Here parts are registered one painting pixel per texel: test the source cell
19
+ * containing the setup vertex. No bbox, nearest island, falloff or art > 0 test
20
+ * substitutes for positive half-opacity support on BOTH drawings.
21
+ */
22
+ export function turnOverlapOwners(
23
+ rig: { bones: Array<{ name: string; parent?: string; x: number; y: number; rotation?: number; length?: number }>; stops?: ReadonlyArray<{ links: readonly string[] }> },
24
+ opts: Depth25DTurnOptions,
25
+ classified: readonly ClassifiedTurnSurface[],
26
+ blended: ReadonlySet<string>,
27
+ problems: Problem[],
28
+ ): ReadonlyMap<Surface, ReadonlyArray<Owner>> {
29
+ // Declared Core stop draw aliases (including parents) share the link frame.
30
+ // This lookup does not rewrite the emitted rig or the caller's bindings.
31
+ const aliases = new Map<string, string>((rig.stops ?? []).flatMap(s => s.links.map(link => [`${link}_d`, link] as const)));
32
+ const resolve = (bone: string) => aliases.get(bone) ?? bone;
33
+ const world = computeWorldTransforms(rig.bones.map(b => b.parent === undefined ? b : { ...b, parent: resolve(b.parent) }));
34
+ const points = new Map<Surface, number[][]>();
35
+ const neighbours = [...classified, ...(opts.neighbourMeshes ?? []).map(mesh => ({ mesh, groups: new Map<string, Group>() }))];
36
+ for (const { mesh } of neighbours) {
37
+ points.set(mesh, mesh.weights.map(row => {
38
+ let x = 0, y = 0;
39
+ for (const w of row) {
40
+ if (w.weight === 0) continue;
41
+ const frame = world.get(resolve(w.bone));
42
+ if (frame === undefined) return [NaN, NaN]; // the binding reader names this refusal
43
+ const [wx, wy] = toWorld(frame, w.x, w.y); x += wx * w.weight; y += wy * w.weight;
44
+ }
45
+ return setupToPaintingPoint([x, y], mesh.canvas, mesh.canvas);
46
+ }));
47
+ }
48
+ const opaque = (part: Depth25DTurnOptions['parts'][number], x: number, y: number): boolean => {
49
+ const art = opts.images.get(part.name), px = Math.floor(x - part.x), py = Math.floor(y - part.y);
50
+ return art !== undefined && px >= 0 && py >= 0 && px < art.width && py < art.height && art.data[(py * art.width + px) * 4 + 3] >= DRAWN_ALPHA;
51
+ };
52
+ const result = new Map<Surface, ReadonlyArray<Owner>>();
53
+ for (const target of classified) {
54
+ // A part-level blend must not change a pure sibling attachment's legacy mask.
55
+ if (!blended.has(target.mesh.part) || new Set(target.groups.values()).size !== 2) continue;
56
+ const own = opts.parts.find(p => p.name === target.mesh.part);
57
+ if (own === undefined) continue;
58
+ const owners = points.get(target.mesh)!.map(([x, y], index): Owner => {
59
+ if (!opaque(own, x, y)) return undefined;
60
+ const anchors: Array<{ part: string; group: Group }> = [];
61
+ for (const neighbour of opts.parts) {
62
+ if (neighbour.name === own.name || !opaque(neighbour, x, y)) continue;
63
+ const roles = [opts.turn.roles?.[neighbour.name], opts.turn.body?.roles[neighbour.name]];
64
+ if (roles.every(r => r === undefined)) continue;
65
+ const meshes = neighbours.filter(c => c.mesh.part === neighbour.name);
66
+ const rolesGroups: Group[] = [...(roles[0] === undefined ? [] : ['head' as const]), ...(roles[1] === undefined ? [] : ['body' as const])];
67
+ if (meshes.length === 0) {
68
+ if (roles.some(r => r === 'unit' || r === 'rear') && opts.regions[neighbour.name] === undefined) problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `surface mesh "${target.mesh.slot}/${target.mesh.attachment}" vertex ${index}`, detail: `neighbour "${neighbour.name}" has no region declaration or weighted setup geometry; provide its actual weighted attachments, not source alpha alone` });
69
+ else if (rolesGroups.length === 1) anchors.push({ part: neighbour.name, group: rolesGroups[0] });
70
+ else problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_GROUPS', object: `surface mesh "${target.mesh.slot}/${target.mesh.attachment}" vertex ${index}`, detail: `overlaps "${neighbour.name}" in both turn groups without a single attachment group; declare one neighbour turn group or separate the overlapping art` });
71
+ continue;
72
+ }
73
+ for (const candidate of meshes) {
74
+ // A genuinely mixed neighbour has no single field to lend.
75
+ if (new Set(candidate.groups.values()).size === 2) continue;
76
+ if (candidate.mesh.triangles === undefined) {
77
+ problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `surface mesh "${target.mesh.slot}/${target.mesh.attachment}" vertex ${index}`, detail: `overlaps "${neighbour.name}" without setup triangles for "${candidate.mesh.slot}/${candidate.mesh.attachment}"; supply the existing attachment triangles so disconnected islands can be resolved` });
78
+ continue;
79
+ }
80
+ const vertices = points.get(candidate.mesh)!;
81
+ if (vertices.some(p => !p.every(Number.isFinite))) { problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_SUPPORT', object: `turn neighbour "${candidate.mesh.slot}/${candidate.mesh.attachment}"`, detail: 'has unresolved setup bindings; every positive influence must resolve before overlap is classified' }); continue; }
82
+ if (turnTriangleCoordinates(vertices, candidate.mesh.triangles, x, y) === null) continue;
83
+ const groups = blended.has(neighbour.name) ? [...new Set(candidate.groups.values())] : rolesGroups;
84
+ if (groups.length === 1) anchors.push({ part: neighbour.name, group: groups[0] });
85
+ else problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_GROUPS', object: `surface mesh "${target.mesh.slot}/${target.mesh.attachment}" vertex ${index}`, detail: `overlaps "${neighbour.name}" with ${groups.length} active turn groups; one neighbour group is required, or separate the overlapping art` });
86
+ }
87
+ }
88
+ const groups = [...new Set(anchors.map(a => a.group))];
89
+ if (groups.length > 1) problems.push({ code: 'RIG_TURN_SURFACE_OVERLAP_GROUPS', object: `surface mesh "${target.mesh.slot}/${target.mesh.attachment}" vertex ${index}`, detail: `setup alpha overlap at (${x}, ${y}) receives conflicting fields from ${anchors.map(a => `"${a.part}" (${a.group})`).join(', ')}; one neighbour group is required, or separate the overlapping art` });
90
+ return groups.length === 1 ? { group: groups[0], neighbours: [...new Set(anchors.map(a => a.part))] } : undefined;
91
+ });
92
+ if (owners.some(owner => owner !== undefined)) result.set(target.mesh, owners);
93
+ }
94
+ return result;
95
+ }