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 +8 -1
- package/docs/AUTHORING.md +83 -1
- package/package.json +1 -1
- package/src/rig.ts +43 -3
- package/src/turn.ts +150 -20
- package/src/turnfield.ts +36 -0
- package/src/turnoverlap.ts +95 -0
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.
|
|
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="
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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) !==
|
|
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
|
|
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
|
} };
|
package/src/turnfield.ts
ADDED
|
@@ -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
|
+
}
|