rig-parts 1.76.0 → 1.77.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/cli.ts CHANGED
@@ -16,7 +16,8 @@ import { runBuildProcess, processRigcRunner as rigcRunner } from './tools/build-
16
16
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
17
  import { tmpdir } from 'node:os';
18
18
  import { basename, dirname, extname, join, resolve } from 'node:path';
19
- import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, proposeSinglePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
19
+ import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, headGarmentOverlaps, headGarmentWarning, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, proposeSinglePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
20
+ import { proposeNeck } from './tools/neck-propose.ts';
20
21
  import { parseAltArg } from './tools/alternate-cli.ts';
21
22
  import { type AlternateCliInput, assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage, suppliedCheckFlag } from './src/build.ts';
22
23
  import { ACCEPTABLE_BARS, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, findRigc, PACK_SHAPES, rigcRunnerAt, type PackShape, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, rigcRanClause, type RigcRunner, NO_PARTS_SENTENCE, SEAM_MEAN_BAR, SOURCE_SENTENCE, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
@@ -88,6 +89,11 @@ usage:
88
89
  size is not its box, and anything else outside the input contract —
89
90
  never a WARN.
90
91
 
92
+ rig-parts layers <head-run> --full <full-run> --source <painting.png> --config <config.json>
93
+ Read PLAN_HEAD_GARMENT_OVERLAP on planned head parts against full-run
94
+ garments registered into the declared crop. Alpha >=250, any overlap;
95
+ names part, garment, count, half-open head-canvas box and source remedy.
96
+
91
97
  rig-parts layers --export <dir> <dir | layers.json | file.psd>
92
98
  Print the table, then write the input as the wrapper form into <dir>
93
99
  (absent or empty, LAYERS_EXPORT_DIR_EMPTY): layers.json and one RGBA PNG
@@ -203,6 +209,13 @@ usage:
203
209
  or a layer with no depth is refused by name. Without either flag nothing
204
210
  changes.
205
211
 
212
+ rig-parts propose … --full <run> --head <run> --write-config <existing.json>
213
+ When the target declares turn.body and one neck plan row, preserve its
214
+ sampled turn and authored rig, take a clean full-run neck, and draft one
215
+ rigid sibling unit under the body carrier. Reassembled art is written to
216
+ <out>/rig; depth comes from the existing scalar input, named in note:.
217
+ Missing depth, landmarks or clean source is refused, never invented.
218
+
206
219
  rig-parts propose … [--grid-rule figure|part]
207
220
  How each drafted lattice's spacing (meshes.<part>.grid) is chosen.
208
221
  figure, the default: round(H / ${FIGURE_GRID_DIVISOR}) rig px for every lattice, H the
@@ -1051,7 +1064,7 @@ function printLayerTable(set: LayerSet): void {
1051
1064
  * no row; `layers` takes one positional path beside its flags (issue #359).
1052
1065
  */
1053
1066
  const TAKES = {
1054
- layers: { valued: ['--export', '--template', '--canvas'], switches: [] },
1067
+ layers: { valued: ['--export', '--template', '--canvas', '--full', '--source', '--config'], switches: [] },
1055
1068
  sheet: { valued: ['--source', '--layers', '--out', '--cell', '--cols'], switches: [] },
1056
1069
  rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
1057
1070
  propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--head', '--canvas', '--keypoints', '--person', '--wrist-snap', '--grid-rule', '--cloth', '--write-config', '--from'], switches: ['--head-box', '--no-back-hair', '--only-missing'] },
@@ -1109,7 +1122,7 @@ function cmdLayers(args: string[]): number {
1109
1122
  }
1110
1123
  const template = flags.get('--template');
1111
1124
  if (template !== undefined) {
1112
- if (flags.has('--export') || paths.length > 0) return usage(`--template writes a PSD and reads nothing; it takes --canvas only, and got ${[...[...flags.keys()].filter((f) => f !== '--template' && f !== '--canvas'), ...paths].join(', ')}`);
1125
+ if (flags.has('--export') || paths.length > 0 || ['--full', '--source', '--config'].some(f => flags.has(f))) return usage(`--template writes a PSD and reads nothing; it takes --canvas only, and got ${[...[...flags.keys()].filter((f) => f !== '--template' && f !== '--canvas'), ...paths].join(', ')}`);
1113
1126
  const canvasArg = flags.get('--canvas');
1114
1127
  if (canvasArg === undefined) return usage('--template needs --canvas <W>x<H>, the canvas size in px (with one run, a square of seethrough.resolution)');
1115
1128
  const canvas = parseCanvas(canvasArg);
@@ -1127,6 +1140,14 @@ function cmdLayers(args: string[]): number {
1127
1140
  try {
1128
1141
  const set = readLayers(paths[0]);
1129
1142
  printLayerTable(set);
1143
+ const context = ['--full', '--source', '--config'];
1144
+ if (context.some(f => flags.has(f))) {
1145
+ if (!context.every(f => flags.has(f))) return usage('layers crop overlap inspection requires --full, --source and --config together; the positional path is the head run');
1146
+ const full = readLayers(flags.get('--full')!), source = readSource(flags.get('--source')!);
1147
+ const cfg = loadEarlyConfig(flags.get('--config')!, 'assemble');
1148
+ const g = { sourceW: source.width, sourceH: source.height, ...proposeFields(cfg) };
1149
+ for (const w of headGarmentOverlaps(full, set, g, cfg.assemble.plan)) console.log(` ${headGarmentWarning(w)}`);
1150
+ }
1130
1151
  const dir = flags.get('--export');
1131
1152
  if (dir !== undefined) {
1132
1153
  const w = exportLayers(set, dir);
@@ -1479,6 +1500,16 @@ function cmdPropose(args: string[]): number {
1479
1500
  if (!existsSync(source)) {
1480
1501
  throw new PartsError([{ code: 'PROPOSE_SOURCE_PRESENT', object: source, detail: 'no such file; the painting is drawn under the overlay' }]);
1481
1502
  }
1503
+ if (target !== null && typeof target.raw === 'object' && target.raw !== null && 'turn' in target.raw) {
1504
+ const turn = target.raw.turn;
1505
+ if (typeof turn === 'object' && turn !== null && 'body' in turn && (loadEarlyConfig(target.path, 'assemble').assemble.plan.some(([, , tag]) => tag === 'neck'))) {
1506
+ const unsupported = [...flags.keys()].filter(f => !['--parts', '--source', '--out', '--full', '--head', '--write-config'].includes(f));
1507
+ if (unsupported.length > 0) throw new PartsError([{ code: 'PROPOSE_NECK_MODE', object: unsupported.join(', '), detail: 'cannot be combined with the authored sampled-turn neck draft; use --parts, --source, --out, --full, --head and --write-config only; run comparison or landmark drafting separately' }]);
1508
+ if (onlyMissing) throw new PartsError([{ code: 'PROPOSE_NECK_MODE', object: '--only-missing', detail: 'cannot replace an attached neck; omit --only-missing for the explicit sampled body-turn neck draft' }]);
1509
+ proposeNeck(target, { source, parts: partsDir, out, full: flags.get('--full'), head: flags.get('--head') });
1510
+ return EXIT_OK;
1511
+ }
1512
+ }
1482
1513
  const P = readPartSet(partsDir);
1483
1514
  const painting = readPng(source);
1484
1515
  // Issue #75: the keypoint file, held to this painting's size and taken to rig px by the overlay's map.
package/docs/AUTHORING.md CHANGED
@@ -2885,6 +2885,67 @@ support. This warning retains the extended scalar sample, all motion arithmetic
2885
2885
  and existing refusal behavior; it is not a gate. Explicit `unitDepths` entries
2886
2886
  bypass that sample and its warning, including an explicit zero.
2887
2887
 
2888
+ ### A neck that moves on its own
2889
+
2890
+ If the head run carries a collar contour in its neck, select a clean `full:neck`
2891
+ first. `PLAN_HEAD_GARMENT_OVERLAP` is a source-alpha warning, not a semantic
2892
+ collar classifier: any alpha >=250 intersection with a planned full-run
2893
+ `topwear`, `neckwear` or `collar` layer is reported on the registered head-crop
2894
+ canvas. The half-open box and count locate pixels to inspect. A normal underlap
2895
+ may also warn; inspect the source, setup and moving boundaries.
2896
+
2897
+ For an existing config with `turn.body`, one neck plan row and named `neck` /
2898
+ `head` landmarks, run:
2899
+
2900
+ ```sh
2901
+ rig-parts layers head/ --full full/ --source painting.png --config config.json
2902
+ rig-parts propose --parts assembled/rig --source painting.png --full full/ --head head/ --out neck-draft/ --write-config config.json
2903
+ rig-parts rig --parts neck-draft/rig --config config.json --out neck-rig/
2904
+ ```
2905
+
2906
+ The existing config is the explicit body-turn context. Choose a fresh output
2907
+ whose `rig/` subdirectory does not exist. The draft preserves its
2908
+ authored rig and sampled turn, changes the neck's plan row to the full run,
2909
+ reassembles source pixels under `neck-draft/rig`, and creates `<part>_part` as a
2910
+ sibling of the anatomical neck under the body carrier. One segment binds the
2911
+ neck rigidly; its head role is removed and its body role is `unit`.
2912
+ Full-run provenance avoids the head-crop warning; alpha overlap does not
2913
+ certify semantic cleanliness, because a clean neck may still underlap a garment.
2914
+ Inspect the full source and the rendered extremes. If it still carries collar
2915
+ art, select another run or rerun See-through; no mask or cut is applied.
2916
+ Existing structural operations on other parts are replayed unchanged. A neck
2917
+ cut, neck limb split, neck extension or alternate sets require a separate
2918
+ assemble/config step; this bounded draft names that refusal. Reassembly uses the
2919
+ CLI defaults `seam=near-white` and `project=core`, explicitly stated in its note
2920
+ because `parts.json` does not record the source command's selected rules.
2921
+
2922
+ The `note:` line derives `turn.body.unitDepths.<part>` as
2923
+ `round4(bodyPivot + neckWorldMedian - firstBodySurfaceWorldMedian)`, using the
2924
+ existing scalar sheet, declared `near` polarity and `zScale`. If the new source
2925
+ has no sheet, only new opaque art overlapping the previous registered neck
2926
+ sheet is sampled. An absent sheet, empty overlap, missing body reference or
2927
+ missing pivot is refused by name; legacy `depth_median` is never converted to
2928
+ world units. Other proposal modes (`--keypoints`, `--compare`, `--grid-rule`,
2929
+ `--only-missing`) must run separately and are refused in this mode.
2930
+
2931
+ | rule | means | change |
2932
+ | --- | --- | --- |
2933
+ | <a id="PROPOSE_NECK_BODY_TURN"></a>`PROPOSE_NECK_BODY_TURN` | no authored sampled body turn | follow this recipe and supply the named input; the refusal writes no config |
2934
+ | <a id="PROPOSE_NECK_PLAN"></a>`PROPOSE_NECK_PLAN` | no unique neck plan row | follow this recipe and supply the named input; the refusal writes no config |
2935
+ | <a id="PROPOSE_NECK_FULL_SOURCE"></a>`PROPOSE_NECK_FULL_SOURCE` | no full-run neck source | follow this recipe and supply the named input; the refusal writes no config |
2936
+ | <a id="PROPOSE_NECK_DEPTH_INPUT"></a>`PROPOSE_NECK_DEPTH_INPUT` | missing scalar sheet, body reference, pivot or registered overlap | follow this recipe and supply the named input; the refusal writes no config |
2937
+ | <a id="PROPOSE_NECK_CARRIER"></a>`PROPOSE_NECK_CARRIER` | missing neck/head landmarks or body carrier | follow this recipe and supply the named input; the refusal writes no config |
2938
+ | <a id="PROPOSE_NECK_BONE_UNIQUE"></a>`PROPOSE_NECK_BONE_UNIQUE` | generated sibling name already belongs to another bone | follow this recipe and supply the named input; the refusal writes no config |
2939
+ | <a id="PROPOSE_NECK_REASSEMBLY"></a>`PROPOSE_NECK_REASSEMBLY` | neck structural operations or alternate sets require separate assembly | follow this recipe and supply the named input; the refusal writes no config |
2940
+ | <a id="PROPOSE_NECK_RUNS"></a>`PROPOSE_NECK_RUNS` | full/head runs not both supplied | follow this recipe and supply the named input; the refusal writes no config |
2941
+ | <a id="PROPOSE_NECK_OUT_FRESH"></a>`PROPOSE_NECK_OUT_FRESH` | output rig directory already exists or aliases input parts | choose a fresh output directory |
2942
+ | <a id="PROPOSE_NECK_MODE"></a>`PROPOSE_NECK_MODE` | incompatible proposal mode requested | follow this recipe and supply the named input; the refusal writes no config |
2943
+
2944
+ Build through the normal gate, then use `rigc render --extremes` on `body_turn`,
2945
+ `turn` and `tilt`. Read setup, min, max and absolute diff at native scale, then
2946
+ inspect neck boundaries at native and 4x nearest-neighbour scale for tears or
2947
+ ghosts. A green build alone does not certify the source art.
2948
+
2888
2949
  ## 4. The command order
2889
2950
 
2890
2951
  Two steps are external: the See-through runs (by any route; the optional `comfy seethrough` adapter is only a client for a ComfyUI box). Everything else is this tool.
@@ -3318,7 +3379,8 @@ loads, with `check/contact.png` and the idle frames to look at.
3318
3379
  ### Source before scissors (issue #819)
3319
3380
 
3320
3381
  For a tear, ghost, smudge, outline or seam, first select an appropriate existing
3321
- See-through layer/run or alternate; then measure a tighter See-through resolution,
3382
+ See-through layer/run or alternate (for a collar baked into the neck, see
3383
+ [A neck that moves on its own](#a-neck-that-moves-on-its-own)); then measure a tighter See-through resolution,
3322
3384
  crop or settings if needed. Next measure the declared depth/turn role and, for a
3323
3385
  `drift:` line with a matching opaque behind holder, opt in with
3324
3386
  `assemble.yield_refused: true`. This config step only clears the original refused
@@ -3664,7 +3726,8 @@ changes draw order. A tear, ghost, smudge, stray outline or seam is a source
3664
3726
  problem. Follow this repair ladder, recording what each step measured:
3665
3727
 
3666
3728
  1. Choose another See-through layer or run (`full` versus `head`, another tag,
3667
- or `alternates`).
3729
+ or `alternates`). For a collar baked into the neck, follow
3730
+ [A neck that moves on its own](#a-neck-that-moves-on-its-own).
3668
3731
  2. Re-run See-through with a tighter resolution or crop.
3669
3732
  3. Correct the declared depth or role in config.
3670
3733
 
@@ -3981,6 +4044,7 @@ over the whole union: a layer is part of it, so it could never be more than 1x.
3981
4044
 
3982
4045
  | rule | crossed when | [observed] on the two examples' 116 layers (four runs) |
3983
4046
  | --- | --- | --- |
4047
+ | <a id="PLAN_HEAD_GARMENT_OVERLAP"></a>`PLAN_HEAD_GARMENT_OVERLAP` | WARN: a planned head-run part has opaque crop pixels (alpha >=250) in common with a planned full-run garment after registration; count and half-open head-canvas box are named. It reports source overlap, not semantic line art | take the part from a clean full run; [A neck that moves on its own](#a-neck-that-moves-on-its-own) |
3984
4048
  | <a id="PLAN_LAYER_TRANSLUCENT"></a>`PLAN_LAYER_TRANSLUCENT` | judged, and `translucent` above 50 % | the most translucent judged layer is 17.8 % (reference-sample, full run, `front hair`) |
3985
4049
  | <a id="PLAN_LAYER_BACKGROUND"></a>`PLAN_LAYER_BACKGROUND` | judged, `background` above 50 %, and `translucent` above 25 % | the most background-coloured judged layer is 21.9 % (reference-demo, head run, `topwear`, a white dress); of judged layers above 10 % background, the most translucent is 5.6 % (reference-demo, full run, `handwear-r`) |
3986
4050
  | <a id="PLAN_LAYER_OVERSIZED"></a>`PLAN_LAYER_OVERSIZED` | `area` above 4x | the largest is 1.681x (reference-demo, head run, `back hair`, which fills the crop) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.76.0",
3
+ "version": "1.77.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": {
@@ -42,6 +42,7 @@
42
42
  "tools/configwrite.ts",
43
43
  "tools/build-process.ts",
44
44
  "tools/alternate-cli.ts",
45
+ "tools/neck-propose.ts",
45
46
  "src",
46
47
  "workflows",
47
48
  "docs/AUTHORING.md",
package/src/assemble.ts CHANGED
@@ -5916,6 +5916,42 @@ function fmt0(v: number): string {
5916
5916
  * before; each gains one such note per tag left out (demo 6, scarf 9,
5917
5917
  * reference-demo 7, reference-sample 9).
5918
5918
  */
5919
+ /** Source alpha intersection, not an art classifier. One opaque texel can carry a second contour. */
5920
+ export interface HeadGarmentOverlap { part: string; layer: string; px: number; box: [number, number, number, number] }
5921
+
5922
+ /** #812: register full garments onto the declared crop; count only planned source pairs. */
5923
+ export function headGarmentOverlaps(full: LayerSet, head: LayerSet, g: Geometry, plan: readonly PlanEntry[]): HeadGarmentOverlap[] {
5924
+ if (g.single === true) return [];
5925
+ const frame = checkGeometry(g, { full, head });
5926
+ const garments = plan.filter(([, run, tag]) => run === 'full' && ['topwear', 'neckwear', 'collar'].includes(tag));
5927
+ const out: HeadGarmentOverlap[] = [];
5928
+ for (const [part, run, tag] of plan) {
5929
+ if (run !== 'head') continue;
5930
+ const layer = head.layers.find(l => l.name === tag);
5931
+ if (layer === undefined) continue;
5932
+ const a = placeLayer(layer, g.resolution);
5933
+ for (const [, , name] of garments) {
5934
+ const garment = full.layers.find(l => l.name === name);
5935
+ if (garment === undefined) continue;
5936
+ const b = fullLayerToHead(placeLayer(garment, g.resolution), frame, g.resolution);
5937
+ let px = 0, left = g.resolution, top = g.resolution, right = 0, bottom = 0;
5938
+ for (let y = 0; y < g.resolution; y++) for (let x = 0; x < g.resolution; x++) {
5939
+ const k = (y * g.resolution + x) * 4 + 3;
5940
+ if (a.data[k] < CORE_ALPHA || b.data[k] < CORE_ALPHA) continue;
5941
+ px++; left = Math.min(left, x); top = Math.min(top, y); right = Math.max(right, x + 1); bottom = Math.max(bottom, y + 1);
5942
+ }
5943
+ if (px > 0) out.push({ part, layer: name, px, box: [left, top, right, bottom] });
5944
+ }
5945
+ }
5946
+ return out;
5947
+ }
5948
+
5949
+ export const HEAD_GARMENT_WARNING = 'PLAN_HEAD_GARMENT_OVERLAP';
5950
+
5951
+ export function headGarmentWarning(w: HeadGarmentOverlap): string {
5952
+ return `WARN ${HEAD_GARMENT_WARNING}: part "${w.part}" (head run) overlaps full-run layer "${w.layer}" — ${w.px} opaque crop px (alpha >= ${CORE_ALPHA}), box [${w.box.join(', ')}] (right/bottom exclusive, head-canvas px); take this part from the full run, then inspect the source and rebuild (AUTHORING: A neck that moves on its own)`;
5953
+ }
5954
+
5919
5955
  export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
5920
5956
  const frame = checkGeometry(g, { full: full0, head: head0 });
5921
5957
  // Issue #366: one run's file, padded to the resolution square as the stage pads it.
@@ -6055,7 +6091,7 @@ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx
6055
6091
  const bodyNotes = left.map((u) => uncoveredNote(u, plan, minBody));
6056
6092
  const unplanned = unplannedNotes(runs, plan, extend, noted, figures, minPx, (run, tag) => (find(run, tag)?.opaqueRunPx ?? 0) * (run === 'head' ? headArea : fullArea), new Set(left.map((u) => u.tag)));
6057
6093
  // The reference's notes, then the uncovered bodies, then the layers in no row (issue #308), then the plausibility drops — a drop note is last, as it was.
6058
- return { plan, extend_below_crop: extend, notes: [...notes, ...bodyNotes, ...unplanned, ...dropNotes] };
6094
+ return { plan, extend_below_crop: extend, notes: [...notes, ...bodyNotes, ...unplanned, ...dropNotes, ...headGarmentOverlaps(full, head, g, plan).map(headGarmentWarning)] };
6059
6095
  }
6060
6096
 
6061
6097
  /**
package/src/config.ts CHANGED
@@ -1794,6 +1794,14 @@ export function controlSkeletonOf(cfg: object): ControlSkeleton | undefined {
1794
1794
  return resolveControlSkeleton(gen, null);
1795
1795
  }
1796
1796
 
1797
+ /** Clone a config and its already resolved control pose, without reading a relative file again. */
1798
+ export function cloneConfig<T extends object>(cfg: T): T {
1799
+ const clone = structuredClone(cfg);
1800
+ const skeleton = controlSkeletonOf(cfg);
1801
+ if (skeleton !== undefined) controlSkeletons.set(clone, structuredClone(skeleton));
1802
+ return clone;
1803
+ }
1804
+
1797
1805
  /** The pose file a checked `generation` block's control names, read relative to `base`; null for a built-in, an absent control, or a block already refused. */
1798
1806
  function poseBeside(c: Check, gen: Json, base: string): ControlPose | null {
1799
1807
  if (!isPlainObject(gen) || !isPlainObject(gen.control)) return null;
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { PlanProposal } from './assemble.ts';
12
12
  import type { HeadBoxProposal } from './headbox.ts';
13
+ import type { CharacterConfig } from './config.ts';
13
14
  import type { Proposal, PROPOSAL_CONFIG_SECTIONS } from './propose.ts';
14
15
  import { PartsError } from './errors.ts';
15
16
  import { parseProposalSections } from './config.ts';
@@ -18,6 +19,7 @@ import type { ConfigEdit } from './meshdraft.ts';
18
19
  export type ConfigJson = null | boolean | number | string | ConfigJson[] | ConfigObject;
19
20
  export interface ConfigObject { [key: string]: ConfigJson }
20
21
  export type ConfigWritePatch =
22
+ | { kind: 'neck-unit'; config: CharacterConfig }
21
23
  | { kind: 'head-box'; head_box: HeadBoxProposal['head_box'] }
22
24
  | { kind: 'assemble-plan'; plan: PlanProposal['plan']; extend_below_crop: PlanProposal['extend_below_crop'] }
23
25
  | { kind: 'proposal'; proposal: Proposal; sections: typeof PROPOSAL_CONFIG_SECTIONS }
@@ -91,6 +93,18 @@ export function mergeConfigProposal(raw: unknown, patch: ConfigWritePatch): Conf
91
93
  const config = clone(before, 'config') as ConfigObject;
92
94
  const edits: ConfigEdit[] = [];
93
95
  switch (patch.kind) {
96
+ case 'neck-unit': {
97
+ const values = { bones: patch.config.bones, meshes: patch.config.meshes, regions: patch.config.regions, turn: patch.config.turn };
98
+ for (const [key, value] of Object.entries(values)) {
99
+ config[key] = clone(value, `neck.${key}`);
100
+ if (!equal(before[key], config[key])) edits.push({ path: [key], value: config[key] });
101
+ }
102
+ if (!object(config.assemble)) return refuse('config.assemble', 'must be an object before drafting the neck source');
103
+ config.assemble.plan = clone(patch.config.assemble.plan, 'neck.plan');
104
+ if (!equal((before.assemble as ConfigObject).plan, config.assemble.plan)) edits.push({ path: ['assemble', 'plan'], value: config.assemble.plan });
105
+ break;
106
+ }
107
+
94
108
  case 'head-box': {
95
109
  if (!object(config.seethrough)) return refuse('config.seethrough', 'is absent or not an object; the existing See-through settings are required before writing head_box');
96
110
  const previous = config.seethrough.head_box;
@@ -0,0 +1,82 @@
1
+ /** #812: source-first drafting into an explicitly authored sampled body turn. */
2
+ import { assemble, checkGeometry, fullLayerToHead, headGarmentOverlaps, headGarmentWarning, placeLayer, stageFields, type AssembleResult, type AssembleInput } from './assemble.ts';
3
+ import { cloneConfig, type CharacterConfig } from './config.ts';
4
+ import { type LayerSet } from './layers.ts';
5
+ import { PartsError } from './errors.ts';
6
+ import { extendDepthSilhouette } from './depthsheets.ts';
7
+ import { type PartsFile } from './parts.ts';
8
+ import type { Raster } from './raster/types.ts';
9
+ import { sampleUnitPartDepth } from './turn.ts';
10
+ import { pyRound } from './round.ts';
11
+ import { partGrid } from './propose.ts';
12
+
13
+ function refuse(code: string, object: string, detail: string): never { throw new PartsError([{ code, object, detail }]); }
14
+ export interface NeckDraft { config: CharacterConfig; assembled: AssembleResult; notes: string[] }
15
+
16
+ /** No inference of a body turn, depth units, source art or anatomical endpoints. */
17
+ export function draftIndependentNeck(input: { config: CharacterConfig; source: Raster; full: LayerSet; head: LayerSet; base: string; under?: AssembleInput['under']; caps?: AssembleInput['caps']; previous?: { parts: PartsFile; images: ReadonlyMap<string, Raster> } }): NeckDraft {
18
+ const cfg = cloneConfig(input.config), body = cfg.turn?.body;
19
+ if (body === undefined) return refuse('PROPOSE_NECK_BODY_TURN', 'config.turn.body', 'is absent; declare the sampled body turn before drafting an independent neck');
20
+ // Body overrides the entire fallback map; snapshot before removing the legacy neck entry.
21
+ const unitDepths = { ...(body.unitDepths ?? cfg.turn?.unitDepths) };
22
+ const rows = cfg.assemble.plan.filter(([, , tag]) => tag === 'neck');
23
+ if (rows.length !== 1) return refuse('PROPOSE_NECK_PLAN', 'config.assemble.plan', `has ${rows.length} neck rows; one unambiguous neck source is required`);
24
+ const [part] = rows[0];
25
+ const fullNeck = input.full.layers.find(l => l.name === 'neck');
26
+ if (fullNeck === undefined) return refuse('PROPOSE_NECK_FULL_SOURCE', `part "${part}"`, 'full-run neck layer is missing; supply --full with a clean neck layer, then rerun propose');
27
+ const fields = stageFields(cfg), g = { ...fields, sourceW: input.source.width, sourceH: input.source.height };
28
+ const frame = checkGeometry(g, input);
29
+ const notes = headGarmentOverlaps(input.full, input.head, g, cfg.assemble.plan).map(headGarmentWarning);
30
+ const projected = fullLayerToHead(placeLayer(fullNeck, fields.resolution), frame, fields.resolution);
31
+ notes.push('neck source: full-run provenance avoids the head-crop warning; opaque overlap alone does not identify collar line art or distinguish normal seam underlap. Inspect the full source and rendered setup/extremes before accepting the draft');
32
+ const pivot = body.pivot ?? cfg.turn?.pivot;
33
+ if (pivot === undefined) return refuse('PROPOSE_NECK_DEPTH_INPUT', 'config.turn.body.pivot', 'and config.turn.pivot are absent; declare the body pivot in the existing depth world units');
34
+ const neck = cfg.bones.find(b => 'name' in b && b.name === 'neck');
35
+ const head = cfg.bones.find(b => 'name' in b && b.name === 'head');
36
+ const carrier = body.bone ?? (cfg.bones.some(b => 'name' in b && b.name === 'chest') ? 'chest' : 'hip');
37
+ if (neck === undefined || !('at' in neck) || head === undefined || !('at' in head) || !cfg.bones.some(b => 'name' in b && b.name === carrier)) return refuse('PROPOSE_NECK_CARRIER', `part "${part}"`, `neck/head landmarks or body carrier "${carrier}" are missing; declare their named bones and coordinates`);
38
+ // This path changes a source, not structural cuts/alternate sets that require their own readers.
39
+ if (cfg.assemble.cuts?.some(c => c.from === part || c.into === part) || cfg.assemble.alternates?.length || Object.values(cfg.limbs ?? {}).some(l => l.segments.some(s => s.from === part)) || cfg.assemble.extend_below_crop?.some(e => e.part === part)) return refuse('PROPOSE_NECK_REASSEMBLY', `part "${part}"`, 'this neck has a cut, limb split or extension, or the config has alternate sets; first assemble the clean full-run neck with the existing assemble command and draft its base config without those neck operations or alternate sets');
40
+ cfg.assemble.plan = cfg.assemble.plan.map(row => row[0] === part ? [part, 'full', 'neck'] : row);
41
+ const unit = `${part}_part`;
42
+ const found = cfg.bones.find(b => 'name' in b && b.name === unit);
43
+ if (found !== undefined && (!('at' in found) || found.parent !== carrier || JSON.stringify(found.at) !== JSON.stringify(neck.at))) return refuse('PROPOSE_NECK_BONE_UNIQUE', unit, 'already names a different bone; rename that bone before drafting the neck unit');
44
+ if (found === undefined) cfg.bones.splice(cfg.bones.indexOf(neck) + 1, 0, { name: unit, parent: carrier, at: [...neck.at] });
45
+ const current = cfg.meshes[part];
46
+ const [grid, r] = partGrid(input.previous?.parts.parts.find(p => p.name === part) ?? { w: projected.width, h: projected.height });
47
+ cfg.meshes[part] = { grid: current !== undefined && 'grid' in current ? current.grid : grid, r: current !== undefined && 'r' in current ? current.r : r, segments: [[unit, [...neck.at], [...head.at]]] };
48
+ delete cfg.regions[part];
49
+ if (cfg.turn!.roles !== undefined) delete cfg.turn!.roles[part];
50
+ if (cfg.turn!.unitDepths !== undefined) delete cfg.turn!.unitDepths[part];
51
+ body.roles[part] = 'unit';
52
+ // Stage fields now see the new role before any color/fringe ownership operation.
53
+ const assembled = assemble({ source: input.source, full: input.full, head: input.head, ...stageFields(cfg), under: input.under, caps: input.caps, seamRule: 'near-white', projectRule: 'core', depth: cfg.depth, depthBase: input.base });
54
+ const rec = assembled.parts.parts.find(p => p.name === part)!;
55
+ const image = assembled.images.find(p => p.record.name === part)!.image;
56
+ const own = assembled.images.find(p => p.record.name === part)!;
57
+ let ref = rec.depth, scalar = own.depthImage, art = image, population = 'new full-run opaque neck texels';
58
+ if (ref === undefined || scalar === undefined) {
59
+ const old = input.previous?.parts.parts.find(p => p.name === part);
60
+ const oldSheet = old?.depth === undefined ? undefined : input.previous?.images.get(old.depth.image);
61
+ if (old?.depth === undefined || oldSheet === undefined) return refuse('PROPOSE_NECK_DEPTH_INPUT', `part "${part}"`, 'full-run scalar depth sheet and previous parts.json neck depth sheet are missing; supply config.depth with near/zScale and the source sheet, or reassemble parts with its existing scalar depth input');
62
+ ref = old.depth; scalar = oldSheet; art = { ...oldSheet, data: new Uint8ClampedArray(oldSheet.width * oldSheet.height * 4) };
63
+ let n = 0;
64
+ for (let y = 0; y < image.height; y++) for (let x = 0; x < image.width; x++) {
65
+ const ox = rec.x + x - old.x, oy = rec.y + y - old.y;
66
+ if (image.data[(y * image.width + x) * 4 + 3] < 128 || ox < 0 || oy < 0 || ox >= old.w || oy >= old.h) continue;
67
+ art.data[(oy * old.w + ox) * 4 + 3] = 255; n++;
68
+ }
69
+ if (n === 0) return refuse('PROPOSE_NECK_DEPTH_INPUT', `part "${part}"`, 'new art has no opaque overlap with the previous scalar sheet; supply a registered full-run neck scalar sheet');
70
+ population = `${n} new-art opaque texels overlapping previous registered neck sheet`;
71
+ }
72
+ const surface = Object.entries(body.roles).find(([, role]) => role === 'surface')?.[0];
73
+ const surf = assembled.images.find(p => p.record.name === surface);
74
+ if (surf?.record.depth === undefined || surf.depthImage === undefined) return refuse('PROPOSE_NECK_DEPTH_INPUT', `turn.body surface "${surface ?? '(none)'}"`, 'the first declared body surface or its scalar depth sheet is missing; declare a body surface and assemble its config.depth input');
75
+ const zNeck = sampleUnitPartDepth(scalar, art, ref);
76
+ const zRef = sampleUnitPartDepth(extendDepthSilhouette(surf.depthImage, surf.image), surf.image, surf.record.depth);
77
+ const value = pyRound(pivot + zNeck - zRef, 4);
78
+ body.unitDepths = { ...unitDepths, [part]: value };
79
+ notes.push(`neck: "${part}" takes full:neck; sibling bone "${unit}" under "${carrier}", one rigid segment from neck to head; reassembled with the CLI defaults seam=near-white/project=core (source rules are not recorded in parts.json); turn.body.roles.${part} = unit (no head role)`);
80
+ notes.push(`neck depth: turn.body.unitDepths.${part} = ${value} = round4(body pivot ${pivot} + neck world median ${zNeck} - first body surface "${surface}" world median ${zRef}); median over ${population}, sheet "${ref.image}", near=${ref.near}, zScale=${ref.zScale}; levelToDepthZ applies the declared polarity, not depth_median`);
81
+ return { config: cfg, assembled, notes };
82
+ }
@@ -0,0 +1,39 @@
1
+ /** Filesystem adapter for the explicit --write-config sampled-turn context. */
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { loadConfig, parseConfig } from '../src/config.ts';
5
+ import { draftIndependentNeck } from '../src/neckdraft.ts';
6
+ import { readRigInputs, readUnders, readCaps } from '../src/build.ts';
7
+ import { readLayers } from '../src/layers.ts';
8
+ import { readPng, writePng } from '../src/raster/png.ts';
9
+ import { writeParts } from '../src/parts.ts';
10
+ import { configWriteLines, separateConfigWriteTarget, stageConfigWrite, type ConfigWriteTarget } from './configwrite.ts';
11
+ import { PartsError } from '../src/errors.ts';
12
+
13
+ export function proposeNeck(target: ConfigWriteTarget, args: { source: string; full?: string; head?: string; parts: string; out: string }): void {
14
+ if (args.full === undefined || args.head === undefined) throw new PartsError([{ code: 'PROPOSE_NECK_RUNS', object: 'propose --write-config', detail: 'a sampled body turn with a neck requires --full and --head to inspect and reassemble its source; give both runs' }]);
15
+ const output = join(args.out, 'rig');
16
+ if (existsSync(output) || resolve(output) === resolve(args.parts)) throw new PartsError([{ code: 'PROPOSE_NECK_OUT_FRESH', object: output, detail: 'already exists or is the input parts directory; choose a fresh --out so source parts and previous draft evidence remain intact' }]);
17
+ const config = loadConfig(target.path);
18
+ const previous = readRigInputs(target.path, args.parts);
19
+ const draft = draftIndependentNeck({ config, source: readPng(args.source), full: readLayers(args.full), head: readLayers(args.head), base: dirname(target.path), under: readUnders(target.path, config.assemble.cuts ?? []), caps: config.limbs === undefined ? undefined : readCaps(target.path, config.limbs), previous });
20
+ parseConfig(draft.config);
21
+ separateConfigWriteTarget(target, [join(args.out, 'proposal.json'), join(output, 'parts.json'), ...draft.assembled.images.flatMap(p => [join(output, 'parts', `${p.record.name}.png`), ...(p.record.depth === undefined ? [] : [join(output, 'parts', p.record.depth.image)])])]);
22
+ const staged = stageConfigWrite(target, { kind: 'neck-unit', config: draft.config });
23
+ try {
24
+ const out = join(args.out, 'rig');
25
+ mkdirSync(join(out, 'parts'), { recursive: true });
26
+ writeParts(join(out, 'parts.json'), draft.assembled.parts);
27
+ for (const p of draft.assembled.images) {
28
+ writePng(join(out, 'parts', `${p.record.name}.png`), p.image);
29
+ if (p.depthImage !== undefined && p.record.depth !== undefined) writePng(join(out, 'parts', p.record.depth.image), p.depthImage);
30
+ }
31
+ const { bones, meshes, regions, motion, constraints, turn, proposed_rig_scale } = draft.config;
32
+ writeFileSync(join(args.out, 'proposal.json'), JSON.stringify({ bones, meshes, regions, motion, ...(constraints === undefined ? {} : { constraints }), turn, ...(proposed_rig_scale === undefined ? {} : { proposed_rig_scale }), notes: draft.notes }, null, 2) + '\n');
33
+ staged.commit();
34
+ for (const line of configWriteLines(target, staged.changes)) console.error(line);
35
+ for (const note of draft.notes) console.log(`note: ${note}`);
36
+ console.log(`wrote proposal.json and reassembled full-run neck pixels under ${out}; config body turn and other authored sections retained; run rig/build through the gate`);
37
+ } catch (error) { staged.abort(error); }
38
+ finally { staged.cancel(); }
39
+ }