@rig-c/parts 1.25.0 → 1.26.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 +1 -1
- package/cli.ts +20 -5
- package/docs/AUTHORING.md +54 -35
- package/docs/MIGRATION.md +1 -1
- package/docs/STABILITY.md +7 -7
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/assemble.ts +117 -23
- package/src/build.ts +23 -9
- package/src/check.ts +220 -40
- package/src/cloth.ts +17 -5
- package/src/config.ts +58 -10
- package/src/headbox.ts +68 -34
- package/src/propose.ts +81 -19
- package/src/rig.ts +74 -6
- package/src/scene.ts +87 -7
- package/src/squeeze.ts +1 -1
package/src/headbox.ts
CHANGED
|
@@ -42,6 +42,20 @@ export const HEAD_LAYER_MIN_PX = 150;
|
|
|
42
42
|
|
|
43
43
|
export type Box = [number, number, number, number];
|
|
44
44
|
|
|
45
|
+
export interface HeadBoxOptions {
|
|
46
|
+
/** Whether `"back hair"` is included in the union (issue #351); default `true`. */
|
|
47
|
+
backHair?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HeadBoxWithoutBackHair {
|
|
51
|
+
/** The square box's side in source px without `"back hair"`. */
|
|
52
|
+
edge: number;
|
|
53
|
+
/** The box without `"back hair"` before clamping to the painting. */
|
|
54
|
+
unclamped: Box;
|
|
55
|
+
/** The box without `"back hair"` held inside the painting, or `null` when `edge` exceeds a canvas side. */
|
|
56
|
+
head_box: Box | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
export interface HeadBoxProposal {
|
|
46
60
|
/** The square box, inside the painting: what goes into `seethrough.head_box`. */
|
|
47
61
|
head_box: Box;
|
|
@@ -49,8 +63,10 @@ export interface HeadBoxProposal {
|
|
|
49
63
|
unclamped: Box;
|
|
50
64
|
/** How far the box was moved to stay inside, in source px. */
|
|
51
65
|
shift: [number, number];
|
|
52
|
-
/** The layers that contributed, with their opaque pixel counts after the clean-up. */
|
|
66
|
+
/** The layers that contributed, with their opaque pixel counts after the ghost clean-up. */
|
|
53
67
|
layers: Array<[string, number]>;
|
|
68
|
+
/** Present when `"back hair"` contributed (with `backHair !== false`, issue #351): the box without `"back hair"`. */
|
|
69
|
+
without_back_hair?: HeadBoxWithoutBackHair;
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
/**
|
|
@@ -90,7 +106,7 @@ function cleanedOpaque(pixels: { width: number; height: number; data: Uint8Clamp
|
|
|
90
106
|
* `source.w` x `source.h` pixels. Every refusal names the value found and the
|
|
91
107
|
* value required.
|
|
92
108
|
*/
|
|
93
|
-
export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }): HeadBoxProposal {
|
|
109
|
+
export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }, opts?: HeadBoxOptions): HeadBoxProposal {
|
|
94
110
|
const problems: Problem[] = [];
|
|
95
111
|
if (!(Number.isInteger(source.w) && Number.isInteger(source.h) && source.w > 0 && source.h > 0)) {
|
|
96
112
|
problems.push({ code: 'HEADBOX_CANVAS_SIZE', object: '--canvas', detail: `is ${source.w}x${source.h}; the painting's size in positive integer pixels is required` });
|
|
@@ -102,12 +118,14 @@ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number })
|
|
|
102
118
|
|
|
103
119
|
const pad = squarePad(source.w, source.h);
|
|
104
120
|
const k = pad.side / full.canvas.w;
|
|
121
|
+
const includeBackHair = opts?.backHair !== false;
|
|
105
122
|
// The union does not depend on the order the layers are read in; a name
|
|
106
123
|
// that appears twice is last-wins in draw order (the reference's dict was
|
|
107
124
|
// last-wins in manifest order, and no wrapper output read so far repeats one).
|
|
108
125
|
const boxes = new Map<string, { left: number; top: number; xs: number[]; ys: number[] }>();
|
|
109
126
|
for (const L of full.layers) {
|
|
110
127
|
if (!(HEAD_LAYERS as readonly string[]).includes(L.name)) continue;
|
|
128
|
+
if (!includeBackHair && L.name === 'back hair') continue;
|
|
111
129
|
const { xs, ys } = cleanedOpaque(L.pixels);
|
|
112
130
|
if (xs.length < HEAD_LAYER_MIN_PX) continue;
|
|
113
131
|
boxes.set(L.name, { left: L.left, top: L.top, xs, ys });
|
|
@@ -130,44 +148,60 @@ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number })
|
|
|
130
148
|
if (f.top + y > fy1) fy1 = f.top + y;
|
|
131
149
|
}
|
|
132
150
|
const cut = fy1 + 0.35 * (fy1 - fy0);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
for (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
const measureUnion = (entries: Iterable<{ left: number; top: number; xs: number[]; ys: number[] }>): { edge: number; unclamped: Box; shift: [number, number]; head_box: Box | null } => {
|
|
152
|
+
let x0 = Infinity;
|
|
153
|
+
let x1 = -Infinity;
|
|
154
|
+
let y0 = Infinity;
|
|
155
|
+
let y1 = -Infinity;
|
|
156
|
+
for (const b of entries) {
|
|
157
|
+
for (let i = 0; i < b.xs.length; i++) {
|
|
158
|
+
const y = b.top + b.ys[i];
|
|
159
|
+
if (y > cut) continue;
|
|
160
|
+
const x = b.left + b.xs[i];
|
|
161
|
+
if (x < x0) x0 = x;
|
|
162
|
+
if (x > x1) x1 = x;
|
|
163
|
+
if (y < y0) y0 = y;
|
|
164
|
+
if (y > y1) y1 = y;
|
|
165
|
+
}
|
|
146
166
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
const size = Math.max(x1 - x0, y1 - y0) * 1.12;
|
|
168
|
+
const cx = (x0 + x1) / 2;
|
|
169
|
+
const cy = (y0 + y1) / 2;
|
|
170
|
+
// Each edge rounded as the reference rounds it; then the side is the larger
|
|
171
|
+
// of the two rounded extents, so the box is square, never holds less than
|
|
172
|
+
// the reference's, and equals it wherever the reference's was square.
|
|
173
|
+
const ex = [pyRound((cx - size / 2) * k), pyRound((cx + size / 2) * k)];
|
|
174
|
+
const ey = [pyRound((cy - size / 2) * k), pyRound((cy + size / 2) * k)];
|
|
175
|
+
const edge = Math.max(ex[1] - ex[0], ey[1] - ey[0]);
|
|
176
|
+
const bx = ex[0] - pad.left;
|
|
177
|
+
const by = ey[0] - pad.top;
|
|
178
|
+
const unclamped: Box = [bx, by, bx + edge, by + edge];
|
|
179
|
+
if (edge > source.w || edge > source.h) return { edge, unclamped, shift: [0, 0], head_box: null };
|
|
180
|
+
const hold = (lo: number, extent: number): number => (lo < 0 ? -lo : lo + edge > extent ? extent - edge - lo : 0);
|
|
181
|
+
const shift: [number, number] = [hold(bx, source.w), hold(by, source.h)];
|
|
182
|
+
const head_box: Box = [bx + shift[0], by + shift[1], bx + shift[0] + edge, by + shift[1] + edge];
|
|
183
|
+
return { edge, unclamped, shift, head_box };
|
|
184
|
+
};
|
|
185
|
+
const main = measureUnion(boxes.values());
|
|
186
|
+
const without = includeBackHair && boxes.has('back hair') ? measureUnion([...boxes.entries()].filter(([n]) => n !== 'back hair').map(([, b]) => b)) : null;
|
|
187
|
+
if (main.head_box === null) {
|
|
188
|
+
const hairNote =
|
|
189
|
+
without !== null && without.edge < main.edge
|
|
190
|
+
? ` ("back hair" grew the side from ${without.edge} to ${main.edge} px; without it${without.head_box !== null ? `, [${without.head_box.join(', ')}] (${without.edge}x${without.edge}) fits —` : ''} pass --no-back-hair)`
|
|
191
|
+
: '';
|
|
161
192
|
refuseIfAny([
|
|
162
193
|
{
|
|
163
194
|
code: 'HEADBOX_FITS_CANVAS',
|
|
164
195
|
object: 'head box',
|
|
165
|
-
detail: `is ${edge}x${edge} source px, proposed at [${unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head`,
|
|
196
|
+
detail: `is ${main.edge}x${main.edge} source px, proposed at [${main.unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head${hairNote}`,
|
|
166
197
|
},
|
|
167
198
|
]);
|
|
168
199
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
200
|
+
return {
|
|
201
|
+
head_box: main.head_box as Box,
|
|
202
|
+
unclamped: main.unclamped,
|
|
203
|
+
shift: main.shift,
|
|
204
|
+
layers: [...boxes.entries()].map(([n, b]) => [n, b.xs.length]),
|
|
205
|
+
...(without === null ? {} : { without_back_hair: { edge: without.edge, unclamped: without.unclamped, head_box: without.head_box } }),
|
|
206
|
+
};
|
|
173
207
|
}
|
package/src/propose.ts
CHANGED
|
@@ -148,7 +148,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
|
|
|
148
148
|
import { edgeLength } from './length.ts';
|
|
149
149
|
import { limbWidth } from './limbs.ts';
|
|
150
150
|
import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
|
|
151
|
-
import { RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
|
|
151
|
+
import { RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
|
|
152
152
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
153
153
|
|
|
154
154
|
// ---------------------------------------------------------------------------
|
|
@@ -278,20 +278,23 @@ export interface Proposal {
|
|
|
278
278
|
constraints?: ConfigConstraint[];
|
|
279
279
|
/** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
|
|
280
280
|
turn?: TurnSpec;
|
|
281
|
+
/** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) its coordinates were drafted at (issue #354). */
|
|
282
|
+
proposed_rig_scale: number;
|
|
281
283
|
notes: string[];
|
|
282
284
|
}
|
|
283
285
|
|
|
284
286
|
/**
|
|
285
287
|
* Every section of a proposal that is a config section, in the order the
|
|
286
288
|
* proposal writes them: what an author copies into the config (issue #375).
|
|
287
|
-
* `notes` is the proposal's own and is copied nowhere.
|
|
288
|
-
* only on request — `limbs` under `--keypoints`, `
|
|
289
|
-
* — and a `limbs` block left behind is refused
|
|
290
|
-
* every part it cuts. {@link checkProposal} reads
|
|
291
|
-
* loader by this list, the type below refuses a
|
|
292
|
-
* hold, and the selftest's `TY18` holds every
|
|
289
|
+
* `notes` is the proposal's own and is copied nowhere. Three of them are written
|
|
290
|
+
* only on request — `limbs` under `--keypoints`, `constraints` under `--cloth`,
|
|
291
|
+
* `turn` under `--full`/`--head` — and a `limbs` block left behind is refused
|
|
292
|
+
* at the next step, by the name of every part it cuts. {@link checkProposal} reads
|
|
293
|
+
* the proposal through the config loader by this list, the type below refuses a
|
|
294
|
+
* {@link Proposal} key it does not hold, and the selftest's `TY18` holds every
|
|
295
|
+
* paste sentence in the documents to it.
|
|
293
296
|
*/
|
|
294
|
-
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
297
|
+
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn', 'proposed_rig_scale'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
295
298
|
|
|
296
299
|
/** A {@link Proposal} key that is neither `notes` nor in {@link PROPOSAL_CONFIG_SECTIONS}; `never` while the list is whole. */
|
|
297
300
|
type UnlistedProposalSection = Exclude<keyof Proposal, 'notes' | (typeof PROPOSAL_CONFIG_SECTIONS)[number]>;
|
|
@@ -414,7 +417,7 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
|
414
417
|
const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
|
|
415
418
|
const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
|
|
416
419
|
return {
|
|
417
|
-
rig_scale:
|
|
420
|
+
rig_scale: P.rigScale,
|
|
418
421
|
plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
|
|
419
422
|
patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
|
|
420
423
|
...(pieces.length === 0
|
|
@@ -611,7 +614,7 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
|
|
|
611
614
|
}
|
|
612
615
|
turn = { pivot: turn.pivot, depths };
|
|
613
616
|
}
|
|
614
|
-
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
|
|
617
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), proposed_rig_scale: p.proposed_rig_scale, notes: [...p.notes, ...draft.notes] };
|
|
615
618
|
}
|
|
616
619
|
|
|
617
620
|
// ---------------------------------------------------------------------------
|
|
@@ -622,12 +625,14 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
|
|
|
622
625
|
export class PartSet {
|
|
623
626
|
readonly W: number;
|
|
624
627
|
readonly H: number;
|
|
628
|
+
readonly rigScale: number;
|
|
625
629
|
readonly recs: readonly PartRecord[];
|
|
626
630
|
readonly images: ReadonlyMap<string, Raster>;
|
|
627
631
|
private readonly masks = new Map<string, Mask>();
|
|
628
632
|
|
|
629
633
|
constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
|
|
630
634
|
[this.W, this.H] = file.rig_size;
|
|
635
|
+
this.rigScale = file.scale_rig_per_source;
|
|
631
636
|
this.recs = file.parts;
|
|
632
637
|
this.images = images;
|
|
633
638
|
}
|
|
@@ -1377,7 +1382,7 @@ export function proposeWithBasis(
|
|
|
1377
1382
|
const tails: Tail[] = [];
|
|
1378
1383
|
const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
|
|
1379
1384
|
const trees = draftTrees(input, r);
|
|
1380
|
-
const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
1385
|
+
const constants = [konst('cloth_chains', r.chains ?? RECIPE_BACK_HAIR_CHAINS), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
1381
1386
|
for (const t of trees) {
|
|
1382
1387
|
C(
|
|
1383
1388
|
t.chain,
|
|
@@ -1534,13 +1539,69 @@ export function proposeWithBasis(
|
|
|
1534
1539
|
} else if (recipeOf('back hair') !== null) {
|
|
1535
1540
|
// Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
|
|
1536
1541
|
// 4 px above the part's bottom, in place of the two keyed chains.
|
|
1537
|
-
|
|
1542
|
+
// Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
|
|
1543
|
+
// on each side, and every tree root inside the 15 px dilated art); a declared count that does not fit is refused
|
|
1544
|
+
// by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
|
|
1545
|
+
const rawRcp = recipeOf('back hair') as ClothRecipe;
|
|
1538
1546
|
const mask = P.alpha(p);
|
|
1547
|
+
const dilated = dilate(mask, 31);
|
|
1548
|
+
const y0 = pyRound(eyeY);
|
|
1549
|
+
const sideOpaqueWidth = (s: 'r' | 'l'): number => {
|
|
1550
|
+
if (y0 < 0 || y0 >= mask.height) return 0;
|
|
1551
|
+
let w = 0;
|
|
1552
|
+
for (let x = 0; x < mask.width; x++) {
|
|
1553
|
+
if ((s === 'r' ? x < axis : x >= axis) && mask.data[y0 * mask.width + x]) w++;
|
|
1554
|
+
}
|
|
1555
|
+
return w;
|
|
1556
|
+
};
|
|
1557
|
+
const prefixOf = (s: 'r' | 'l'): string => (P.headFirst('back hair').length === 1 ? `hairback_${s}` : `hairback_${p.name}_${s}`);
|
|
1558
|
+
const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
|
|
1559
|
+
const pref = prefixOf(s);
|
|
1560
|
+
const im = P.images.get(p.name) as Raster;
|
|
1561
|
+
const off: Array<{ bone: string; at: Point; alpha: number }> = [];
|
|
1562
|
+
for (let k = 0; k < cnt; k++) {
|
|
1563
|
+
const fc = (k + 0.5) / cnt;
|
|
1564
|
+
const rx = pyRound(bandX(mask, eyeY, { frac: fc, side: s, axis }) ?? axis);
|
|
1565
|
+
if (!isOn(dilated, rx, y0)) {
|
|
1566
|
+
const lx = rx - p.x;
|
|
1567
|
+
const ly = y0 - p.y;
|
|
1568
|
+
const alpha = lx >= 0 && lx < p.w && ly >= 0 && ly < p.h ? im.data[(ly * p.w + lx) * 4 + 3] : 0;
|
|
1569
|
+
off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return off;
|
|
1573
|
+
};
|
|
1574
|
+
const wR = sideOpaqueWidth('r');
|
|
1575
|
+
const wL = sideOpaqueWidth('l');
|
|
1576
|
+
const widthCap = Math.max(1, Math.min(Math.floor(wR / g), Math.floor(wL / g)));
|
|
1577
|
+
let maxFit = 1;
|
|
1578
|
+
for (let cnt = widthCap; cnt >= 1; cnt--) {
|
|
1579
|
+
if (offRootsAt(cnt, 'r').length === 0 && offRootsAt(cnt, 'l').length === 0) {
|
|
1580
|
+
maxFit = cnt;
|
|
1581
|
+
break;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
if (rawRcp.chains !== undefined) {
|
|
1585
|
+
const offRoots = [...offRootsAt(rawRcp.chains, 'r'), ...offRootsAt(rawRcp.chains, 'l')];
|
|
1586
|
+
if (rawRcp.chains > widthCap || offRoots.length > 0) {
|
|
1587
|
+
throw new PartsError([
|
|
1588
|
+
{
|
|
1589
|
+
code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
|
|
1590
|
+
object: 'config.cloth."back hair".chains',
|
|
1591
|
+
detail:
|
|
1592
|
+
`declares ${rawRcp.chains} tree(s) per side on "${p.name}", and at the root row y=${y0} the tail is ${wR} px wide on the r side and ${wL} px on the l side on a ${g} px mesh grid (at most ${maxFit} tree(s) per side fit inside the art` +
|
|
1593
|
+
(offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
|
|
1594
|
+
`) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
|
|
1595
|
+
},
|
|
1596
|
+
]);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
|
|
1539
1600
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
1540
1601
|
const all: TreeChain[] = [];
|
|
1541
1602
|
let n = 0;
|
|
1542
1603
|
for (const [side] of SIDES) {
|
|
1543
|
-
const prefix =
|
|
1604
|
+
const prefix = prefixOf(side);
|
|
1544
1605
|
const trees = T(p, 'back hair', rcp, {
|
|
1545
1606
|
prefix,
|
|
1546
1607
|
parent: 'head',
|
|
@@ -2119,9 +2180,10 @@ export function proposeWithBasis(
|
|
|
2119
2180
|
throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
|
|
2120
2181
|
};
|
|
2121
2182
|
const q = P.recs.find((x) => x.name === name);
|
|
2122
|
-
if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part whose art hangs below the hip (a dress) is required`);
|
|
2123
|
-
|
|
2124
|
-
|
|
2183
|
+
if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part or its cut skirt piece whose art hangs below the hip (a dress) is required`);
|
|
2184
|
+
// An explicit name may select a topwear cut piece; automatic tag rules still exclude pieces.
|
|
2185
|
+
const [run, tag] = splitFrom(q.from);
|
|
2186
|
+
if (run === PAINTING_RUN || tag !== 'topwear') {
|
|
2125
2187
|
return refuse(
|
|
2126
2188
|
`names "${name}", from ${q.from}; ${splitFrom(q.from)[1] === 'bottomwear' ? 'a bottomwear part takes the recipe by its tag already — leave part out' : "only a topwear part takes the skirt's recipe by name (a skirt See-through returned inside topwear, a dress)"}`,
|
|
2127
2189
|
);
|
|
@@ -2161,8 +2223,8 @@ export function proposeWithBasis(
|
|
|
2161
2223
|
['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
|
|
2162
2224
|
['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
|
|
2163
2225
|
];
|
|
2164
|
-
if (bw.length > 0 && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
|
|
2165
|
-
// A bodice
|
|
2226
|
+
if ((bw.length > 0 || namedSkirt?.cut_from === p.name) && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
|
|
2227
|
+
// A bodice above bottomwear, including its explicitly named cut skirt: robe chains would fall off the art.
|
|
2166
2228
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2167
2229
|
notes.push(`${p.name} ends at y=${bot} (hip ${pyFixed(hip[1], 0)}): no robe chains -- add a sash chain by hand if one hangs`);
|
|
2168
2230
|
continue;
|
|
@@ -2515,7 +2577,7 @@ export function proposeWithBasis(
|
|
|
2515
2577
|
}
|
|
2516
2578
|
const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
|
|
2517
2579
|
return {
|
|
2518
|
-
proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, notes },
|
|
2580
|
+
proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes },
|
|
2519
2581
|
basis: { frame, bones: records },
|
|
2520
2582
|
};
|
|
2521
2583
|
}
|
package/src/rig.ts
CHANGED
|
@@ -388,6 +388,57 @@ export function roundShares(list: readonly Influence[]): Influence[] {
|
|
|
388
388
|
return kept;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Issue #392: on a lattice mesh whose segments include sibling branch chains
|
|
393
|
+
* forked from the same chain link with `r >= grid` (`split.hold_tip: true`),
|
|
394
|
+
* the distance rule still concentrates weights along the fork row where the
|
|
395
|
+
* trunk splits into branches and across adjacent trees' ruffled hem steps.
|
|
396
|
+
* Four Laplacian passes (`FORK_SMOOTH_PASSES`, self share 0.5, floor 0.005, cap
|
|
397
|
+
* 8 influences) diffuse each vertex's shares across its triangle neighbours
|
|
398
|
+
* before rounding with {@link roundShares}.
|
|
399
|
+
*/
|
|
400
|
+
export const FORK_SMOOTH_PASSES = 4;
|
|
401
|
+
export const FORK_SMOOTH_SELF = 0.5;
|
|
402
|
+
export const FORK_SMOOTH_FLOOR = 0.005;
|
|
403
|
+
export const FORK_SMOOTH_MAX_INFLUENCES = 8;
|
|
404
|
+
|
|
405
|
+
export function smoothForkShares(raw: ReadonlyArray<readonly Influence[]>, triangles: readonly number[]): Influence[][] {
|
|
406
|
+
const adj: number[][] = raw.map(() => []);
|
|
407
|
+
const addEdge = (u: number, v: number): void => {
|
|
408
|
+
if (!adj[u].includes(v)) adj[u].push(v);
|
|
409
|
+
if (!adj[v].includes(u)) adj[v].push(u);
|
|
410
|
+
};
|
|
411
|
+
for (let i = 0; i + 2 < triangles.length; i += 3) {
|
|
412
|
+
const a = triangles[i];
|
|
413
|
+
const b = triangles[i + 1];
|
|
414
|
+
const c = triangles[i + 2];
|
|
415
|
+
addEdge(a, b);
|
|
416
|
+
addEdge(b, c);
|
|
417
|
+
addEdge(c, a);
|
|
418
|
+
}
|
|
419
|
+
let cur: Influence[][] = raw.map((s) => [...s]);
|
|
420
|
+
for (let p = 0; p < FORK_SMOOTH_PASSES; p++) {
|
|
421
|
+
cur = cur.map((own, vi) => {
|
|
422
|
+
const nb = adj[vi];
|
|
423
|
+
if (nb.length === 0) return own;
|
|
424
|
+
const m = new Map<string, number>();
|
|
425
|
+
for (const { bone, weight } of own) m.set(bone, weight * FORK_SMOOTH_SELF);
|
|
426
|
+
const nbShare = (1 - FORK_SMOOTH_SELF) / nb.length;
|
|
427
|
+
for (const u of nb) {
|
|
428
|
+
for (const { bone, weight } of cur[u]) m.set(bone, (m.get(bone) ?? 0) + weight * nbShare);
|
|
429
|
+
}
|
|
430
|
+
const kept = [...m.entries()]
|
|
431
|
+
.filter(([, w]) => w >= FORK_SMOOTH_FLOOR)
|
|
432
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
433
|
+
.slice(0, FORK_SMOOTH_MAX_INFLUENCES);
|
|
434
|
+
const sum = kept.reduce((s, [, w]) => s + w, 0);
|
|
435
|
+
return roundShares(kept.map(([bone, w]) => ({ bone, weight: w / sum })));
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return cur;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
391
442
|
interface Bone {
|
|
392
443
|
name: string;
|
|
393
444
|
parent: string | null;
|
|
@@ -957,7 +1008,8 @@ export function buildRig(
|
|
|
957
1008
|
}
|
|
958
1009
|
e.points.forEach(([x, y], k) => {
|
|
959
1010
|
const n = `${e.chain}${k}`;
|
|
960
|
-
|
|
1011
|
+
const inherit = e.link_inherit?.[n];
|
|
1012
|
+
B.set(n, { name: n, parent: k === 0 ? e.parent : names[names.length - 1], x, y, ...(inherit === undefined ? {} : { inherit }) });
|
|
961
1013
|
if (names.length > 0) child.set(names[names.length - 1], [x, y]);
|
|
962
1014
|
names.push(n);
|
|
963
1015
|
});
|
|
@@ -1854,6 +1906,7 @@ export function buildRig(
|
|
|
1854
1906
|
const segsAt = dressSegsAt(p, segs);
|
|
1855
1907
|
let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
|
|
1856
1908
|
let emptiedMore = 0;
|
|
1909
|
+
const rawShares: Influence[][] = [];
|
|
1857
1910
|
for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
|
|
1858
1911
|
const wx = vx0 + ox;
|
|
1859
1912
|
const wy = vy0 + oy;
|
|
@@ -1865,7 +1918,26 @@ export function buildRig(
|
|
|
1865
1918
|
else emptiedMore++;
|
|
1866
1919
|
continue;
|
|
1867
1920
|
}
|
|
1868
|
-
|
|
1921
|
+
rawShares.push(limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares));
|
|
1922
|
+
}
|
|
1923
|
+
if (emptied !== null) {
|
|
1924
|
+
problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
const allChainLinks = new Set([...chains.values()].flat());
|
|
1928
|
+
const forkCounts = new Map<string, number>();
|
|
1929
|
+
for (const sp of mesh.segments) {
|
|
1930
|
+
if (typeof sp !== 'string') continue;
|
|
1931
|
+
const links = chains.get(sp);
|
|
1932
|
+
const par = links !== undefined && links.length > 0 ? (B.get(links[0])?.parent ?? null) : null;
|
|
1933
|
+
if (par !== null && allChainLinks.has(par)) forkCounts.set(par, (forkCounts.get(par) ?? 0) + 1);
|
|
1934
|
+
}
|
|
1935
|
+
const heldFork = heat === null && mesh.r >= mesh.grid && mesh.influences === undefined && [...forkCounts.values()].some((c) => c >= 2);
|
|
1936
|
+
const finalShares = heldFork ? smoothForkShares(rawShares, lm.triangles) : rawShares;
|
|
1937
|
+
for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
|
|
1938
|
+
const wx = vx0 + ox;
|
|
1939
|
+
const wy = vy0 + oy;
|
|
1940
|
+
const ent: WeightEntry[] = finalShares[vi].map(({ bone, weight }) => {
|
|
1869
1941
|
const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
|
|
1870
1942
|
return { bone, x: places(x), y: places(y), weight };
|
|
1871
1943
|
});
|
|
@@ -1873,10 +1945,6 @@ export function buildRig(
|
|
|
1873
1945
|
infl += ent.length;
|
|
1874
1946
|
maxInfl = Math.max(maxInfl, ent.length);
|
|
1875
1947
|
}
|
|
1876
|
-
if (emptied !== null) {
|
|
1877
|
-
problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
|
|
1878
|
-
continue;
|
|
1879
|
-
}
|
|
1880
1948
|
const uvs: number[] = [];
|
|
1881
1949
|
for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
|
|
1882
1950
|
skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
|