rig-parts 1.79.0 → 1.79.1

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 CHANGED
@@ -5772,7 +5772,14 @@ Assembly uses the actual run registration, source-copy/composite coordinates,
5772
5772
  merge ownership and final crop. Scalar bilinear sampling at pixel centres is
5773
5773
  quantized half up at each intermediate copy/composite grid, then again at
5774
5774
  final part registration. PAD4 repeats the resulting u8 edge values. There is no
5775
- universal half-level total-error bound across those operators. No art-color
5775
+ universal half-level total-error bound across those operators. The drawing warp rounds its inverse coordinates to
5776
+ 1/32 px and uses bilinear or bicubic support; scalar sampling keeps its existing
5777
+ unrounded inverse map plus the half-pixel centre. Visible final-grid samples
5778
+ less than one source pixel outside a registered crop retain the edge texel.
5779
+ At one pixel or more outside on either axis, `DEPTH_PART_SUPPORT` still refuses.
5780
+ Assembly logs and neck-draft notes report the clamped visible sample count per
5781
+ part, counting a corner once. Transparent cells are excluded from that count.
5782
+ No source sheet is padded or inferred. No art-color
5776
5783
  premultiplication or local min/max rescaling is applied. The rig sheet receives the drawing's PAD4 with
5777
5784
  explicit rectangular edge clamping. Original source hashes, rectangles, medians
5778
5785
  and declared units accompany the optional `parts.json` part `depth` reference.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.79.0",
3
+ "version": "1.79.1",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assemble.ts CHANGED
@@ -4445,6 +4445,8 @@ export interface AssembleResult {
4445
4445
  seamRule: SeamRule;
4446
4446
  projectRule: ProjectRule;
4447
4447
  depthReadings?: Array<{ from: string; reading: import('./depthsheets.ts').DepthReading }>;
4448
+ /** Visible registered samples clamped within <1 source pixel of a crop. */
4449
+ depthClamps?: Array<{ part: string; samples: number }>;
4448
4450
  /** Issue #492: head-canvas pixels filled by `config.assemble.head_face_underfill: true` when > 0. */
4449
4451
  headFaceUnderfillPx?: number;
4450
4452
  /** Issue #587: what each `exclude` entry took, in entry order; empty without one. */
@@ -5397,6 +5399,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5397
5399
  });
5398
5400
 
5399
5401
  const planned: Array<PlacedPart | null> = plan.map(() => null);
5402
+ const depthClamps: NonNullable<AssembleResult['depthClamps']> = [];
5400
5403
  const cutPlaced: Array<PlacedPart | null> = cuts.map(() => null);
5401
5404
  // A cut's piece records the plan part it was cut from (issue #230): parts.json is in draw order, so a piece drawn
5402
5405
  // "back" comes before its base, and nothing else in the file says which of the two is the piece.
@@ -5472,6 +5475,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5472
5475
  ...((limb?.cap?.image_px ?? 0) > 0 || (limb?.cap?.lower_px ?? 0) > 0 ? { unsupported: `cap copies ${(limb?.cap?.image_px ?? 0) + (limb?.cap?.lower_px ?? 0)} pixels from a separate cap image whose scalar grid is undeclared` } : {}),
5473
5476
  });
5474
5477
  if (scalar !== undefined) record.depth = scalar.reference;
5478
+ if (scalar !== undefined && scalar.clampedSamples > 0) depthClamps.push({ part: name, samples: scalar.clampedSamples });
5475
5479
  set({ record, image, ...(scalar === undefined ? {} : { depthImage: scalar.image }) });
5476
5480
  });
5477
5481
  refuseIfAny(empty);
@@ -5528,6 +5532,7 @@ export function assemble(input: AssembleInput): AssembleResult {
5528
5532
  },
5529
5533
  images: placed,
5530
5534
  ...(registeredDepth.size === 0 ? {} : { depthReadings: ([['full', depthFullInput], ...(single ? [] : [['head', depthHeadInput]])] as Array<['full' | 'head', LayerSet]>).flatMap(([run, set]) => set.layers.flatMap(layer => layer.depthSheet === undefined ? [] : [{ from: `${run}:${layer.name}`, reading: layer.depthSheet.reading }])) }),
5535
+ ...(depthClamps.length === 0 ? {} : { depthClamps }),
5531
5536
  recomposite: can,
5532
5537
  errorMap: recompositeErrorMap(can, srcr, placed, inside),
5533
5538
  figure: inside === null ? null : figureImage(inside, W, H),
package/src/build.ts CHANGED
@@ -384,7 +384,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
384
384
  }
385
385
 
386
386
  const altProblems: Problem[] = [];
387
- const altBuilt: Array<{ record: AlternatePartsRecord; images: Array<{ name: string; image: Raster }>; excluded: ExcludedCount[]; islands: IslandCount[]; splits: SplitCount[]; capSplits: SplitCount[] }> = [];
387
+ const altBuilt: Array<{ record: AlternatePartsRecord; images: Array<{ name: string; image: Raster }>; excluded: ExcludedCount[]; islands: IslandCount[]; splits: SplitCount[]; capSplits: SplitCount[]; depthClamps: AssembleResult['depthClamps'] }> = [];
388
388
  const baseSlots = result.parts.parts.map((p) => p.name);
389
389
  // Issue #664: each limb segment part with a declared front slot -> the part its front slot follows.
390
390
  const frontAfter = new Map<string, string>();
@@ -724,6 +724,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
724
724
  islands: altRes.islands,
725
725
  splits: altRes.splits,
726
726
  capSplits: altRes.capSplits,
727
+ depthClamps: altRes.depthClamps,
727
728
  });
728
729
  }
729
730
  const emittedAssets = [
@@ -762,6 +763,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
762
763
  if (depthImage !== undefined && record.depth !== undefined) writePng(join(outs.partsDir, record.depth.image), depthImage);
763
764
  }
764
765
  for (const row of result.depthReadings ?? []) log(` depth reading ${row.from}: ${row.reading.relation}; ordering median ${row.reading.orderingMedian}, source-sheet median ${row.reading.sheetMedian} (half-level quantization; reading only, polarity declared)`);
766
+ for (const row of result.depthClamps ?? []) log(` depth edge clamp ${row.part}: ${row.samples} visible samples less than 1 source pixel outside the crop; edge texels retained`);
767
+ for (const b of altBuilt) for (const row of b.depthClamps ?? []) log(` set "${b.record.name}" depth edge clamp ${row.part}: ${row.samples} visible samples less than 1 source pixel outside the crop; edge texels retained`);
765
768
  for (const b of altBuilt) {
766
769
  for (const { name, image } of b.images) writePng(join(outs.partsDir, `${name}.png`), image);
767
770
  }
@@ -207,6 +207,10 @@ export interface DepthTransport {
207
207
  }
208
208
  /** Resample on the FINAL unpadded part grid. Visible synthesized/unsupported
209
209
  * material refuses; transparent cells and later PAD4 use explicit edge clamp.
210
+ * Registered art filtering can reach less than one source pixel beyond its
211
+ * crop. Keep that edge texel; a visible sample >=1px outside still refuses.
212
+ * clampedSamples counts visible samples outside the half-open crop, once per
213
+ * output pixel even at a corner. It is a reading, not emitted provenance.
210
214
  * No geometry, art pixels, source field or calibration are changed here.
211
215
  */
212
216
  export function transportPartDepth(
@@ -214,13 +218,14 @@ export function transportPartDepth(
214
218
  art: Raster,
215
219
  registered: ReadonlyMap<string, RegisteredDepth>,
216
220
  transport: DepthTransport,
217
- ): { reference: PartDepthReference; image: Raster } | undefined {
221
+ ): { reference: PartDepthReference; image: Raster; clampedSamples: number } | undefined {
218
222
  const keys = [part.from, ...transport.extend];
219
223
  const initial = keys.map(k => registered.get(k)).find((s): s is RegisteredDepth => s !== undefined);
220
224
  if (initial === undefined) return undefined;
221
225
  const fail = (detail: string): never => { throw new PartsError([{ code: 'DEPTH_PART_SUPPORT', object: `part "${part.name}"`, detail }]); };
222
226
  if (transport.unsupported !== undefined) fail(`${transport.unsupported}; an exact source-bound scalar transport is required`);
223
227
  const image = newRaster(part.w, part.h), used = new Map<string, RegisteredDepth>();
228
+ let clampedSamples = 0;
224
229
  for (let y = 0; y < part.h; y++) for (let x = 0; x < part.w; x++) {
225
230
  const rx = part.x + x, ry = part.y + y, pos = ry * transport.width + rx;
226
231
  const visible = art.data[(y * part.w + x) * 4 + 3] > 0;
@@ -233,12 +238,13 @@ export function transportPartDepth(
233
238
  const sourceKey = source === undefined ? keys.find(k => registered.get(k) === initial) as string : key;
234
239
  const run = sourceKey.startsWith('head:') ? 'head' : 'full';
235
240
  const m = transport.map(run), px = (rx - m.tx) / m.sx - input.layer.left + 0.5, py = (ry - m.ty) / m.sx - input.layer.top + 0.5;
236
- if (visible && (px < 0 || py < 0 || px > input.sheet.pixels.width || py > input.sheet.pixels.height)) fail(`rig pixel (${rx},${ry}) maps to source (${px},${py}) outside ${input.sheet.pixels.width}x${input.sheet.pixels.height}; source art filtering/generated material has no declared depth there`);
241
+ if (visible && (px <= -1 || py <= -1 || px >= input.sheet.pixels.width + 1 || py >= input.sheet.pixels.height + 1)) fail(`rig pixel (${rx},${ry}) maps to source (${px},${py}) outside ${input.sheet.pixels.width}x${input.sheet.pixels.height}; source art filtering/generated material has no declared depth there`);
242
+ if (visible && (px < 0 || py < 0 || px >= input.sheet.pixels.width || py >= input.sheet.pixels.height)) clampedSamples++;
237
243
  const level = sampleDepthLevel(input.sheet.pixels, px, py);
238
244
  image.data.set([level, level, level, 255], (y * part.w + x) * 4);
239
245
  if (visible && source !== undefined) used.set(key, source);
240
246
  }
241
- return { image, reference: {
247
+ return { image, clampedSamples, reference: {
242
248
  image: `${part.name}_depth.png`, near: initial.near, zScale: initial.zScale,
243
249
  sources: [...new Map([...used].flatMap(([from, s]) => s.sheet.sources ?? [{ from, image: s.sheet.file, sha256: s.sheet.sha256, crop: [s.layer.left, s.layer.top, s.layer.right, s.layer.bottom] as [number, number, number, number], reading: s.sheet.reading }]).map(s => [`${s.from}:${s.sha256}`, s])).values()],
244
250
  } };
package/src/neckdraft.ts CHANGED
@@ -51,6 +51,7 @@ export function draftIndependentNeck(input: { config: CharacterConfig; source: R
51
51
  body.roles[part] = 'unit';
52
52
  // Stage fields now see the new role before any color/fringe ownership operation.
53
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
+ for (const row of assembled.depthClamps ?? []) notes.push(`depth edge clamp ${row.part}: ${row.samples} visible samples less than 1 source pixel outside the crop; edge texels retained`);
54
55
  const rec = assembled.parts.parts.find(p => p.name === part)!;
55
56
  const image = assembled.images.find(p => p.record.name === part)!.image;
56
57
  const own = assembled.images.find(p => p.record.name === part)!;