@rig-c/parts 1.34.0 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING.md +10 -5
- package/docs/STABILITY.md +2 -1
- package/package.json +1 -1
- package/src/build.ts +63 -18
- package/src/check.ts +24 -5
- package/src/config.ts +418 -52
- package/src/limbs.ts +92 -0
- package/src/propose.ts +2 -1
- package/src/rig.ts +242 -6
- package/src/structure.ts +6 -3
package/src/limbs.ts
CHANGED
|
@@ -125,6 +125,98 @@ export function bonePlaces(bones: readonly BoneEntry[]): Map<string, BonePlace>
|
|
|
125
125
|
return out;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Issue #476: pose `bones` (in rig pixels, y down) so every bone named in
|
|
130
|
+
* `drawn` takes that world angle (in Spine degrees, CCW, y up) while keeping
|
|
131
|
+
* its base length and parent-to-child joint offsets. Used by `assemble` when
|
|
132
|
+
* an alternate set covers a limb chain at its own `drawn` angles, so `cutLimbs`
|
|
133
|
+
* cuts the set's painting at the base's joints posed to `drawn`.
|
|
134
|
+
*/
|
|
135
|
+
export function poseBoneEntriesForDrawn(bones: readonly BoneEntry[], drawn: Readonly<Record<string, number>>): BoneEntry[] {
|
|
136
|
+
if (Object.keys(drawn).length === 0) return [...bones];
|
|
137
|
+
interface FlatNode {
|
|
138
|
+
name: string;
|
|
139
|
+
parent: string;
|
|
140
|
+
at0: Pt;
|
|
141
|
+
end0: Pt | null;
|
|
142
|
+
theta0: number;
|
|
143
|
+
length: number;
|
|
144
|
+
inherit?: string;
|
|
145
|
+
}
|
|
146
|
+
const flat = new Map<string, FlatNode>();
|
|
147
|
+
for (const b of bones) {
|
|
148
|
+
if ('chain' in b) {
|
|
149
|
+
b.points.forEach((p, k) => {
|
|
150
|
+
const name = `${b.chain}${k}`;
|
|
151
|
+
const parent = k === 0 ? b.parent : `${b.chain}${k - 1}`;
|
|
152
|
+
const at0: Pt = [p[0], p[1]];
|
|
153
|
+
const end0: Pt = k + 1 < b.points.length ? [b.points[k + 1][0], b.points[k + 1][1]] : [b.tip[0], b.tip[1]];
|
|
154
|
+
const dx = end0[0] - at0[0];
|
|
155
|
+
const dy = end0[1] - at0[1];
|
|
156
|
+
const theta0 = (Math.atan2(-dy, dx) * 180) / Math.PI;
|
|
157
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
158
|
+
flat.set(name, { name, parent, at0, end0, theta0, length, inherit: b.link_inherit?.[name] });
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
const at0: Pt = [b.at[0], b.at[1]];
|
|
162
|
+
const end0: Pt | null = b.tip === undefined ? null : [b.tip[0], b.tip[1]];
|
|
163
|
+
const dx = end0 === null ? 0 : end0[0] - at0[0];
|
|
164
|
+
const dy = end0 === null ? 0 : end0[1] - at0[1];
|
|
165
|
+
const theta0 = end0 === null ? 0 : (Math.atan2(-dy, dx) * 180) / Math.PI;
|
|
166
|
+
const length = end0 === null ? 0 : Math.sqrt(dx * dx + dy * dy);
|
|
167
|
+
flat.set(b.name, { name: b.name, parent: b.parent, at0, end0, theta0, length, inherit: b.inherit });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const posed = new Map<string, { at: Pt; end: Pt | null; theta: number }>();
|
|
171
|
+
const solve = (name: string): { at: Pt; end: Pt | null; theta: number } => {
|
|
172
|
+
const cached = posed.get(name);
|
|
173
|
+
if (cached !== undefined) return cached;
|
|
174
|
+
const node = flat.get(name);
|
|
175
|
+
if (node === undefined) return { at: [0, 0], end: null, theta: 0 };
|
|
176
|
+
let at: Pt = [node.at0[0], node.at0[1]];
|
|
177
|
+
let dParent = 0;
|
|
178
|
+
const pNode = flat.get(node.parent);
|
|
179
|
+
if (pNode !== undefined) {
|
|
180
|
+
const pPosed = solve(node.parent);
|
|
181
|
+
dParent = pPosed.theta - pNode.theta0;
|
|
182
|
+
const rad = (dParent * Math.PI) / 180;
|
|
183
|
+
const cos = Math.cos(rad);
|
|
184
|
+
const sin = Math.sin(rad);
|
|
185
|
+
const dx0 = node.at0[0] - pNode.at0[0];
|
|
186
|
+
const dy0 = node.at0[1] - pNode.at0[1];
|
|
187
|
+
at = [pPosed.at[0] + dx0 * cos + dy0 * sin, pPosed.at[1] - dx0 * sin + dy0 * cos];
|
|
188
|
+
}
|
|
189
|
+
const inheritsRot = node.inherit !== 'noRotationOrReflection' && node.inherit !== 'onlyTranslation';
|
|
190
|
+
const theta = drawn[name] !== undefined ? drawn[name] : node.theta0 + (inheritsRot ? dParent : 0);
|
|
191
|
+
let end: Pt | null = null;
|
|
192
|
+
if (node.end0 !== null) {
|
|
193
|
+
const rad = (theta * Math.PI) / 180;
|
|
194
|
+
end = [at[0] + node.length * Math.cos(rad), at[1] - node.length * Math.sin(rad)];
|
|
195
|
+
}
|
|
196
|
+
const res = { at, end, theta };
|
|
197
|
+
posed.set(name, res);
|
|
198
|
+
return res;
|
|
199
|
+
};
|
|
200
|
+
for (const name of flat.keys()) solve(name);
|
|
201
|
+
return bones.map((b): BoneEntry => {
|
|
202
|
+
if ('chain' in b) {
|
|
203
|
+
const points = b.points.map((_, k): Pt => {
|
|
204
|
+
const s = solve(`${b.chain}${k}`);
|
|
205
|
+
return [s.at[0], s.at[1]];
|
|
206
|
+
});
|
|
207
|
+
const last = solve(`${b.chain}${b.points.length - 1}`);
|
|
208
|
+
const tip: Pt = last.end === null ? [b.tip[0], b.tip[1]] : [last.end[0], last.end[1]];
|
|
209
|
+
return { ...b, points, tip };
|
|
210
|
+
}
|
|
211
|
+
const s = solve(b.name);
|
|
212
|
+
return {
|
|
213
|
+
...b,
|
|
214
|
+
at: [s.at[0], s.at[1]],
|
|
215
|
+
...(b.tip === undefined || s.end === null ? {} : { tip: [s.end[0], s.end[1]] }),
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
128
220
|
/** A limb's polyline: its segments' bone origins in order, then the last bone's end when it has one. */
|
|
129
221
|
export function limbPolyline(limb: Limb, places: ReadonlyMap<string, BonePlace>): Pt[] {
|
|
130
222
|
const pts = limb.segments.map((s) => (places.get(s.bone) as BonePlace).at);
|
package/src/propose.ts
CHANGED
|
@@ -127,7 +127,8 @@
|
|
|
127
127
|
import { existsSync } from 'node:fs';
|
|
128
128
|
import { join } from 'node:path';
|
|
129
129
|
import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
|
|
130
|
-
import { type BoneEntry, type ConfigConstraint, type ConfigStop, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
130
|
+
import { type BoneEntry, type ConfigConstraint, type ConfigStop, contourRegionsOf, deriveBustRegions, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
131
|
+
export { contourRegionsOf, deriveBustRegions };
|
|
131
132
|
import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
|
|
132
133
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
133
134
|
import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
|
package/src/rig.ts
CHANGED
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
* touches the disk, and the same inputs give the same bytes (key order is the
|
|
133
133
|
* order the objects are built in, and every number is rounded by `pyRound`).
|
|
134
134
|
*/
|
|
135
|
-
import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, stopForRig, weightsABone } from './config.ts';
|
|
135
|
+
import { type AutoSpec, type BoneEntry, 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, weightsABone } from './config.ts';
|
|
136
136
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
|
|
137
137
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
138
138
|
import { eyeLineDegrees } from './headframe.ts';
|
|
@@ -164,6 +164,7 @@ import {
|
|
|
164
164
|
} from './automesh.ts';
|
|
165
165
|
import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
|
|
166
166
|
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
167
|
+
import { edgeLength } from './length.ts';
|
|
167
168
|
import { artCoverage, ART_ALPHA, latticeCells, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
|
|
168
169
|
import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
169
170
|
import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
@@ -1006,8 +1007,12 @@ export function deriveAlternateDrawn(
|
|
|
1006
1007
|
const limbPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
|
|
1007
1008
|
['sleeve_r0', pts.r_shoulder, pts.r_elbow],
|
|
1008
1009
|
['sleeve_r1', pts.r_elbow, pts.r_wrist],
|
|
1010
|
+
['arm_r0', pts.r_shoulder, pts.r_elbow],
|
|
1011
|
+
['arm_r1', pts.r_elbow, pts.r_wrist],
|
|
1009
1012
|
['sleeve_l0', pts.l_shoulder, pts.l_elbow],
|
|
1010
1013
|
['sleeve_l1', pts.l_elbow, pts.l_wrist],
|
|
1014
|
+
['arm_l0', pts.l_shoulder, pts.l_elbow],
|
|
1015
|
+
['arm_l1', pts.l_elbow, pts.l_wrist],
|
|
1011
1016
|
['leg_r0', pts.r_hip, pts.r_knee],
|
|
1012
1017
|
['leg_r1', pts.r_knee, pts.r_ankle],
|
|
1013
1018
|
['leg_l0', pts.l_hip, pts.l_knee],
|
|
@@ -1161,6 +1166,14 @@ export function buildRig(
|
|
|
1161
1166
|
(m.contour.regions ?? []).forEach((rg, i) => {
|
|
1162
1167
|
if (!B.has(rg.bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.contour.regions[${i}].bone`, `names the bone "${rg.bone}", which config.bones does not declare`);
|
|
1163
1168
|
});
|
|
1169
|
+
if (m.contour.bust !== undefined) {
|
|
1170
|
+
for (const [k, bone] of [
|
|
1171
|
+
['bone_r', m.contour.bust.bone_r ?? DEFAULT_BUST_BONE_R],
|
|
1172
|
+
['bone_l', m.contour.bust.bone_l ?? DEFAULT_BUST_BONE_L],
|
|
1173
|
+
] as const) {
|
|
1174
|
+
if (!B.has(bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.contour.bust.${k}`, `names the bone "${bone}", which config.bones does not declare`);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1164
1177
|
}
|
|
1165
1178
|
if ('auto' in m) {
|
|
1166
1179
|
(m.auto.regions ?? []).forEach((rg, i) => {
|
|
@@ -1274,7 +1287,7 @@ export function buildRig(
|
|
|
1274
1287
|
for (const segs of meshSegments.values()) for (const s of segs) meshBones.add(s.bone);
|
|
1275
1288
|
// A contour region's control bone is weighted to like a segment's bone (issue #84), so the idle's keys on it move
|
|
1276
1289
|
// to its control under `ctl` as theirs do. A config with no contour region adds nothing here.
|
|
1277
|
-
for (const m of Object.values(cfg.meshes)) if ('contour' in m) for (const rg of m.contour
|
|
1290
|
+
for (const m of Object.values(cfg.meshes)) if ('contour' in m) for (const rg of contourRegionsOf(m.contour)) meshBones.add(rg.bone);
|
|
1278
1291
|
for (const m of Object.values(cfg.meshes)) if ('auto' in m) for (const rg of m.auto.regions ?? []) if (weightsABone(rg)) meshBones.add(rg.bone);
|
|
1279
1292
|
const meshKeyed = controlledBones(motion, meshBones);
|
|
1280
1293
|
const controls = idleKeys === 'ctl' ? meshKeyed : [];
|
|
@@ -1600,7 +1613,7 @@ export function buildRig(
|
|
|
1600
1613
|
const h = img.height;
|
|
1601
1614
|
const alpha = new Uint8Array(w * h);
|
|
1602
1615
|
for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
|
|
1603
|
-
const regions = spec
|
|
1616
|
+
const regions = contourRegionsOf(spec);
|
|
1604
1617
|
const inPart: ContourRegion[] = regions.map((rg) =>
|
|
1605
1618
|
rg.shape === 'circle'
|
|
1606
1619
|
? { name: rg.name, shape: 'circle', cx: rg.cx - ox, cy: rg.cy - oy, r: rg.r, spacing: rg.spacing, band: rg.band }
|
|
@@ -1635,10 +1648,25 @@ export function buildRig(
|
|
|
1635
1648
|
const li = localInfluences([wx, wy], vSegs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
|
|
1636
1649
|
if ('first' in li) {
|
|
1637
1650
|
if (!overlapped) {
|
|
1651
|
+
let bustHint = '';
|
|
1652
|
+
if (spec.bust !== undefined) {
|
|
1653
|
+
const need: number[] = [];
|
|
1654
|
+
for (const rg of [regions[li.first], regions[li.second]]) {
|
|
1655
|
+
if (rg.name === 'bust_r' || rg.name === 'bust_l') {
|
|
1656
|
+
const apex = rg.name === 'bust_r' ? spec.bust.apex_r : spec.bust.apex_l;
|
|
1657
|
+
const d = edgeLength(spec.bust.cleavage[0] - apex[0], spec.bust.cleavage[1] - apex[1]);
|
|
1658
|
+
const dist = edgeLength(wx - apex[0], wy - apex[1]);
|
|
1659
|
+
need.push(pyRound(d - dist, 2));
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
if (need.length > 0) {
|
|
1663
|
+
bustHint = ` (contour.bust.clearance >= ${Math.min(...need)} clears vertex ${vi})`;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1638
1666
|
out.push({
|
|
1639
1667
|
code: 'RIG_CONTOUR_REGIONS_OVERLAP',
|
|
1640
1668
|
object: `config.meshes.${p.name}.contour.regions`,
|
|
1641
|
-
detail: `vertex ${vi} at rig (${wx}, ${wy}) takes weight ${pyRound(li.g1, 5)} from region "${regions[li.first].name}" and ${pyRound(li.g2, 5)} from region "${regions[li.second].name}"; each vertex may be reached by one region's falloff (its region and band) — move the regions apart or narrow a band`,
|
|
1669
|
+
detail: `vertex ${vi} at rig (${wx}, ${wy}) takes weight ${pyRound(li.g1, 5)} from region "${regions[li.first].name}" and ${pyRound(li.g2, 5)} from region "${regions[li.second].name}"; each vertex may be reached by one region's falloff (its region and band) — move the regions apart or narrow a band${bustHint}`,
|
|
1642
1670
|
});
|
|
1643
1671
|
}
|
|
1644
1672
|
overlapped = true;
|
|
@@ -2085,17 +2113,44 @@ export function buildRig(
|
|
|
2085
2113
|
for (const [altIdx, alt] of parts.alternates.entries()) {
|
|
2086
2114
|
const cfgAlt = cfg.alternates?.find((a) => a.name === alt.name);
|
|
2087
2115
|
const explicitDrawn = alt.drawn ?? cfgAlt?.drawn;
|
|
2116
|
+
const isSubsetSet = alt.parts !== undefined || cfgAlt?.parts !== undefined;
|
|
2117
|
+
let coveredBones: Set<string> | null = null;
|
|
2118
|
+
if (isSubsetSet) {
|
|
2119
|
+
coveredBones = new Set<string>();
|
|
2120
|
+
for (const pName of Object.keys(alt.slots)) {
|
|
2121
|
+
if (pName in cfg.regions) coveredBones.add(cfg.regions[pName]);
|
|
2122
|
+
for (const s of meshSegments.get(pName) ?? []) coveredBones.add(s.bone);
|
|
2123
|
+
const m = cfg.meshes[pName];
|
|
2124
|
+
if (m !== undefined) {
|
|
2125
|
+
if ('contour' in m) for (const rg of contourRegionsOf(m.contour)) coveredBones.add(rg.bone);
|
|
2126
|
+
if ('auto' in m) for (const rg of m.auto.regions ?? []) if (weightsABone(rg)) coveredBones.add(rg.bone);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2088
2130
|
if (explicitDrawn !== undefined) {
|
|
2089
2131
|
for (const boneName of Object.keys(explicitDrawn)) {
|
|
2090
2132
|
if (!B.has(boneName)) {
|
|
2091
2133
|
fail('RIG_NAME_RESOLVES', `config.alternates[${altIdx}].drawn.${boneName}`, `names the bone "${boneName}", which config.bones does not declare`);
|
|
2134
|
+
} else if (coveredBones !== null && !coveredBones.has(boneName)) {
|
|
2135
|
+
const coveredList = [...coveredBones].map((b) => `"${b}"`).join(', ') || 'none';
|
|
2136
|
+
fail(
|
|
2137
|
+
'RIG_SET_COVERS_BONE',
|
|
2138
|
+
`config.assemble.alternates[${altIdx}].drawn.${boneName}`,
|
|
2139
|
+
`names "${boneName}", which alternate set "${alt.name}" does not cover (covered bones: ${coveredList}); a subset set may state drawn angles only for the bones its covered parts ride`,
|
|
2140
|
+
);
|
|
2092
2141
|
}
|
|
2093
2142
|
}
|
|
2094
2143
|
}
|
|
2095
2144
|
refuseIfAny(problems);
|
|
2096
|
-
const
|
|
2145
|
+
const rawDerived = alt.skeleton === undefined ? undefined : deriveAlternateDrawn(alt.skeleton, B, turn, CX, H);
|
|
2146
|
+
const derived =
|
|
2147
|
+
rawDerived === undefined
|
|
2148
|
+
? undefined
|
|
2149
|
+
: coveredBones === null
|
|
2150
|
+
? rawDerived
|
|
2151
|
+
: Object.fromEntries(Object.entries(rawDerived).filter(([k]) => coveredBones.has(k)));
|
|
2097
2152
|
const drawn: Record<string, number> | undefined =
|
|
2098
|
-
derived === undefined && explicitDrawn === undefined
|
|
2153
|
+
(derived === undefined || Object.keys(derived).length === 0) && explicitDrawn === undefined
|
|
2099
2154
|
? undefined
|
|
2100
2155
|
: {
|
|
2101
2156
|
...(derived ?? {}),
|
|
@@ -2299,6 +2354,7 @@ export function buildRig(
|
|
|
2299
2354
|
const partAreas: Record<string, number> = {};
|
|
2300
2355
|
for (const p of parts.parts) partAreas[p.name] = p.opaque_px;
|
|
2301
2356
|
bindFaceSurfaceDeform(motion, (merged) => {
|
|
2357
|
+
expandSetAttachmentKeys(rig, sets, merged);
|
|
2302
2358
|
applyFaceSurfaceDeform(rig, merged, {
|
|
2303
2359
|
imageWidth: faceImg?.width,
|
|
2304
2360
|
imageHeight: faceImg?.height,
|
|
@@ -2334,6 +2390,186 @@ export function buildRig(
|
|
|
2334
2390
|
};
|
|
2335
2391
|
}
|
|
2336
2392
|
|
|
2393
|
+
/**
|
|
2394
|
+
* Issue #473: expand `{"t": …, "set": "<name>" | null}` keys on slot attachment
|
|
2395
|
+
* tracks in `merged.animations` into `{"t": …, "v": "<attachment>" | null}`
|
|
2396
|
+
* keys against `rig.slots` and `sets.json`. When no key carries `"set"`, nothing
|
|
2397
|
+
* is touched and no byte moves.
|
|
2398
|
+
*/
|
|
2399
|
+
export function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefined, merged: MotionSpec): void {
|
|
2400
|
+
let hasAnySetKey = false;
|
|
2401
|
+
for (const [animName, anim] of Object.entries(merged.animations)) {
|
|
2402
|
+
if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
|
|
2403
|
+
const tracks = (anim as Record<string, unknown>).tracks;
|
|
2404
|
+
if (!Array.isArray(tracks)) continue;
|
|
2405
|
+
for (const tr of tracks) {
|
|
2406
|
+
if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) continue;
|
|
2407
|
+
const keys = (tr as Record<string, unknown>).keys;
|
|
2408
|
+
if (!Array.isArray(keys)) continue;
|
|
2409
|
+
for (const k of keys) {
|
|
2410
|
+
if (typeof k === 'object' && k !== null && !Array.isArray(k) && 'set' in k) {
|
|
2411
|
+
hasAnySetKey = true;
|
|
2412
|
+
break;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
if (hasAnySetKey) break;
|
|
2416
|
+
}
|
|
2417
|
+
if (hasAnySetKey) break;
|
|
2418
|
+
}
|
|
2419
|
+
if (!hasAnySetKey) return;
|
|
2420
|
+
|
|
2421
|
+
const baseSlots = new Map<string, string | null>();
|
|
2422
|
+
const slotBoneMap = new Map<string, string>();
|
|
2423
|
+
for (const sl of rig.slots) {
|
|
2424
|
+
baseSlots.set(sl.name, sl.attachment ?? null);
|
|
2425
|
+
const rawBone = sl.bone.startsWith(`${sl.name}__`) ? sl.bone.slice(`${sl.name}__`.length) : sl.bone;
|
|
2426
|
+
slotBoneMap.set(sl.name, rawBone);
|
|
2427
|
+
}
|
|
2428
|
+
const setupRotByBone = new Map<string, number>();
|
|
2429
|
+
for (const b of rig.bones) setupRotByBone.set(b.name, b.rotation ?? 0);
|
|
2430
|
+
const setBoneDeltas = new Map<string, Map<string, number>>();
|
|
2431
|
+
if (sets !== undefined) {
|
|
2432
|
+
for (const [setName, entry] of Object.entries(sets.sets)) {
|
|
2433
|
+
if (entry.drawn === undefined || Object.keys(entry.drawn).length === 0) continue;
|
|
2434
|
+
const posed = poseRigBonesForDrawn(rig.bones, entry.drawn, rig.stops);
|
|
2435
|
+
const deltas = new Map<string, number>();
|
|
2436
|
+
for (const b of posed) {
|
|
2437
|
+
if (Object.hasOwn(entry.drawn, b.name)) {
|
|
2438
|
+
const d = places(normaliseDegrees((b.rotation ?? 0) - (setupRotByBone.get(b.name) ?? 0)));
|
|
2439
|
+
deltas.set(b.name, d);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
setBoneDeltas.set(setName, deltas);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
const declaredSets =
|
|
2446
|
+
sets === undefined || Object.keys(sets.sets).length === 0
|
|
2447
|
+
? 'none'
|
|
2448
|
+
: Object.keys(sets.sets)
|
|
2449
|
+
.map((n) => `"${n}"`)
|
|
2450
|
+
.join(', ');
|
|
2451
|
+
const problems: Problem[] = [];
|
|
2452
|
+
|
|
2453
|
+
for (const [animName, anim] of Object.entries(merged.animations)) {
|
|
2454
|
+
if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
|
|
2455
|
+
const tracks = (anim as Record<string, unknown>).tracks;
|
|
2456
|
+
if (!Array.isArray(tracks)) continue;
|
|
2457
|
+
const existingRotateBones = new Set<string>();
|
|
2458
|
+
for (const tr of tracks) {
|
|
2459
|
+
if (typeof tr === 'object' && tr !== null && !Array.isArray(tr)) {
|
|
2460
|
+
const r = tr as Record<string, unknown>;
|
|
2461
|
+
if (r.property === 'rotate' && typeof r.bone === 'string') existingRotateBones.add(r.bone);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
const synthesizedRotate = new Map<string, Array<Record<string, unknown>>>();
|
|
2465
|
+
tracks.forEach((tr, ti) => {
|
|
2466
|
+
if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) return;
|
|
2467
|
+
const trRec = tr as Record<string, unknown>;
|
|
2468
|
+
const keys = trRec.keys;
|
|
2469
|
+
if (!Array.isArray(keys)) return;
|
|
2470
|
+
const slot = typeof trRec.slot === 'string' && trRec.slot !== '' ? trRec.slot : null;
|
|
2471
|
+
const isAttachmentTrack = trRec.property === 'attachment' && slot !== null;
|
|
2472
|
+
const slotBone = slot === null ? undefined : slotBoneMap.get(slot);
|
|
2473
|
+
let trackTouchesDrawnBone = false;
|
|
2474
|
+
const rawSetKeysForBone: Array<{ t: unknown; setVal: string | null }> = [];
|
|
2475
|
+
keys.forEach((k, ki) => {
|
|
2476
|
+
if (typeof k !== 'object' || k === null || Array.isArray(k) || !('set' in k)) return;
|
|
2477
|
+
const kRec = k as Record<string, unknown>;
|
|
2478
|
+
const keyAt = `animations.${animName}.tracks[${ti}].keys[${ki}].set`;
|
|
2479
|
+
if (!isAttachmentTrack || slot === null) {
|
|
2480
|
+
problems.push({
|
|
2481
|
+
code: 'CONFIG_SET_COVERS_SLOT',
|
|
2482
|
+
object: keyAt,
|
|
2483
|
+
detail: `is ${JSON.stringify(kRec.set)} on a ${JSON.stringify(trRec.property)} track${slot === null ? '' : ` for slot "${slot}"`}; a "set" key belongs on a slot attachment track ({"slot": "<part>", "property": "attachment", "keys": [{"t": …, "set": "<name>" | null}]})`,
|
|
2484
|
+
});
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
if ('v' in kRec) {
|
|
2488
|
+
problems.push({
|
|
2489
|
+
code: 'CONFIG_FIELD_TYPE',
|
|
2490
|
+
object: keyAt,
|
|
2491
|
+
detail: `is declared beside v (${JSON.stringify(kRec.v)}); state either "set": "<name>" | null or "v": "<attachment>" | null on an attachment key, not both`,
|
|
2492
|
+
});
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
const setVal = kRec.set;
|
|
2496
|
+
let resolvedV: string | null = null;
|
|
2497
|
+
if (setVal === null) {
|
|
2498
|
+
if (!baseSlots.has(slot)) {
|
|
2499
|
+
problems.push({
|
|
2500
|
+
code: 'CONFIG_SET_COVERS_SLOT',
|
|
2501
|
+
object: keyAt,
|
|
2502
|
+
detail: `restores the base attachment on slot "${slot}", which is not a declared slot of the rig (declared slots: ${[...baseSlots.keys()].map((s) => `"${s}"`).join(', ')})`,
|
|
2503
|
+
});
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
resolvedV = baseSlots.get(slot) ?? null;
|
|
2507
|
+
rawSetKeysForBone.push({ t: kRec.t, setVal: null });
|
|
2508
|
+
} else if (typeof setVal !== 'string' || setVal === '') {
|
|
2509
|
+
problems.push({
|
|
2510
|
+
code: 'CONFIG_FIELD_TYPE',
|
|
2511
|
+
object: keyAt,
|
|
2512
|
+
detail: `is ${JSON.stringify(setVal)}; a non-empty alternate set name or null (the base pose) is required`,
|
|
2513
|
+
});
|
|
2514
|
+
return;
|
|
2515
|
+
} else {
|
|
2516
|
+
const entry = sets?.sets[setVal];
|
|
2517
|
+
if (entry === undefined) {
|
|
2518
|
+
problems.push({
|
|
2519
|
+
code: 'CONFIG_SET_COVERS_SLOT',
|
|
2520
|
+
object: keyAt,
|
|
2521
|
+
detail: `names alternate set "${setVal}" for slot "${slot}", which config.assemble.alternates does not declare (declared sets: ${declaredSets})`,
|
|
2522
|
+
});
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
if (Object.hasOwn(entry.slots, slot)) {
|
|
2526
|
+
resolvedV = entry.slots[slot];
|
|
2527
|
+
} else if (entry.hides?.includes(slot) === true) {
|
|
2528
|
+
resolvedV = null;
|
|
2529
|
+
} else {
|
|
2530
|
+
const covered = [...Object.keys(entry.slots), ...(entry.hides ?? [])].map((s) => `"${s}"`).join(', ') || 'none';
|
|
2531
|
+
problems.push({
|
|
2532
|
+
code: 'CONFIG_SET_COVERS_SLOT',
|
|
2533
|
+
object: keyAt,
|
|
2534
|
+
detail: `names alternate set "${setVal}" on slot "${slot}", which set "${setVal}" does not cover (set "${setVal}" covers: ${covered}); add "${slot}" to that set's parts (or hides) in config.assemble.alternates, or key a slot the set covers`,
|
|
2535
|
+
});
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
if (slotBone !== undefined && setBoneDeltas.get(setVal)?.has(slotBone) === true) {
|
|
2539
|
+
trackTouchesDrawnBone = true;
|
|
2540
|
+
}
|
|
2541
|
+
rawSetKeysForBone.push({ t: kRec.t, setVal });
|
|
2542
|
+
}
|
|
2543
|
+
const expanded: Record<string, unknown> = {};
|
|
2544
|
+
for (const [prop, val] of Object.entries(kRec)) {
|
|
2545
|
+
if (prop === 'set') expanded.v = resolvedV;
|
|
2546
|
+
else expanded[prop] = val;
|
|
2547
|
+
}
|
|
2548
|
+
keys[ki] = expanded;
|
|
2549
|
+
});
|
|
2550
|
+
if (
|
|
2551
|
+
problems.length === 0 &&
|
|
2552
|
+
trackTouchesDrawnBone &&
|
|
2553
|
+
slotBone !== undefined &&
|
|
2554
|
+
!existingRotateBones.has(slotBone) &&
|
|
2555
|
+
!synthesizedRotate.has(slotBone)
|
|
2556
|
+
) {
|
|
2557
|
+
const rotKeys = rawSetKeysForBone.map((rk, idx) => {
|
|
2558
|
+
const d = rk.setVal === null ? 0 : (setBoneDeltas.get(rk.setVal)?.get(slotBone) ?? 0);
|
|
2559
|
+
return idx + 1 < rawSetKeysForBone.length ? { t: rk.t, v: [d], curve: 'stepped' } : { t: rk.t, v: [d] };
|
|
2560
|
+
});
|
|
2561
|
+
synthesizedRotate.set(slotBone, rotKeys);
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
if (problems.length === 0) {
|
|
2565
|
+
for (const [boneName, rotKeys] of synthesizedRotate) {
|
|
2566
|
+
tracks.push({ bone: boneName, property: 'rotate', keys: rotKeys });
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
refuseIfAny(problems);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2337
2573
|
/**
|
|
2338
2574
|
* Re-parent every bone that hangs from a stopped link onto that link's drawn
|
|
2339
2575
|
* link `<link>_d`, move slots and mesh weights from a stopped link onto
|
package/src/structure.ts
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
import { existsSync, readFileSync } from 'node:fs';
|
|
73
73
|
import { CompileError } from 'rig-c/src/errors.ts';
|
|
74
74
|
import { parseRigSpec, RIG_SPEC_VERSION, type RigBone as RigcBone, type RigSpec as RigcSpec, splitRigSkin } from 'rig-c/src/rig.ts';
|
|
75
|
-
import { type ConfigConstraint, CONSTRAINT_FOLLOWS, parseConfig, parseProposalSections, type Point, ROOT_BONE, type SkeletonSections, weightsABone } from './config.ts';
|
|
75
|
+
import { type ConfigConstraint, CONSTRAINT_FOLLOWS, contourRegionsOf, parseConfig, parseProposalSections, type Point, ROOT_BONE, type SkeletonSections, weightsABone } from './config.ts';
|
|
76
76
|
import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
|
|
77
77
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
78
78
|
import { edgeLength } from './length.ts';
|
|
@@ -183,8 +183,11 @@ export function configRoles(s: SkeletonSections & { constraints?: readonly Confi
|
|
|
183
183
|
if (typeof sp === 'string') for (const b of chains.get(sp) ?? [sp]) push(bound, b, at);
|
|
184
184
|
else push(bound, sp[0], at);
|
|
185
185
|
});
|
|
186
|
-
// A contour mesh's region bone is weighted to, like a segment's (issue #84).
|
|
187
|
-
if ('contour' in m)
|
|
186
|
+
// A contour mesh's region bone is weighted to, like a segment's (issue #84; including contour.bust, issue #475).
|
|
187
|
+
if ('contour' in m) {
|
|
188
|
+
const explicitCount = (m.contour.regions ?? []).length;
|
|
189
|
+
contourRegionsOf(m.contour).forEach((rg, i) => push(bound, rg.bone, i < explicitCount ? `meshes.${part}.contour.regions[${i}]` : `meshes.${part}.contour.bust`));
|
|
190
|
+
}
|
|
188
191
|
if ('auto' in m) (m.auto.regions ?? []).forEach((rg, i) => {
|
|
189
192
|
// A density-only region binds no bone (issue #155).
|
|
190
193
|
if (weightsABone(rg)) push(bound, rg.bone, `meshes.${part}.auto.regions[${i}]`);
|