@rig-c/parts 1.9.1 → 1.11.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/src/contour.ts CHANGED
@@ -35,7 +35,9 @@
35
35
  * pixels, so its trace cannot cross itself; a notch narrower than about
36
36
  * twice the margin grows shut. A margin above 0 and below 1 px adds no
37
37
  * pixel and is refused by name. This module does not raise a margin by
38
- * itself: a margin that clips art is refused (`CONTOUR_COVERAGE`).
38
+ * itself: a margin that clips art is refused (`CONTOUR_COVERAGE`). With
39
+ * `maxEdge` declared (issue #265), every simplified edge longer than it is
40
+ * then cut into equal pieces ({@link capOutlineEdges}); undeclared, nothing is.
39
41
  * 4. **Interior vertices are declared**, never inferred: see
40
42
  * {@link interiorCandidates} for the candidates and {@link KEEP_FRACTION}
41
43
  * for the one rule that keeps or drops each. Where the outline passes
@@ -141,6 +143,7 @@
141
143
  * automatic mode hands these triangles to rig-c as they are.
142
144
  */
143
145
  import { type Problem } from './errors.ts';
146
+ import { edgeLength } from './length.ts';
144
147
  import { counterClockwiseInSpineWorld } from './mesh.ts';
145
148
  import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
146
149
  import {
@@ -224,6 +227,12 @@ export interface ContourParams {
224
227
  margin: number;
225
228
  /** The background interior spacing, px, above 0. */
226
229
  spacing: number;
230
+ /**
231
+ * The longest outline edge, px (issue #265): every edge of the simplified outline longer than this is cut into
232
+ * `ceil(length / maxEdge)` equal pieces ({@link capOutlineEdges}), before any region or rib touches the outline.
233
+ * Absent, nothing is cut and the mesh is byte for byte the one it was before the field existed; there is no default.
234
+ */
235
+ maxEdge?: number;
227
236
  /** Refuse a mesh with more vertices than this; absent is no budget. */
228
237
  budget?: number;
229
238
  /**
@@ -330,6 +339,8 @@ export interface ContourReport {
330
339
  grownHolePixels: number;
331
340
  /** Only when ribs were declared (issue #188): every rib, in placement order, with its vertices. Absent otherwise, so a mesh without ribs reports what it always reported. */
332
341
  ribs?: ContourRib[];
342
+ /** Only when `maxEdge` was declared (issue #265): the outline vertices the cap added. Absent otherwise, so a mesh without it reports what it always reported. */
343
+ outlineCut?: number;
333
344
  }
334
345
 
335
346
  export interface ContourMesh {
@@ -382,6 +393,10 @@ function parameterProblems(part: string, mask: AlphaMask, p: ContourParams): Pro
382
393
  else if (keepRadius(s) > MAX_RADIUS) bad(field, `is ${s}; its keep radius is ${keepRadius(s)} grid units, past the ${MAX_RADIUS} (${MAX_RADIUS / GRID} px) whose square is exact in a double`);
383
394
  };
384
395
  spacingOk('spacing', p.spacing);
396
+ // issue #265: a cut piece must be longer than one grid unit, so two cut points never snap onto one another.
397
+ if (p.maxEdge !== undefined && (!finite(p.maxEdge) || p.maxEdge * GRID < 2)) {
398
+ bad('maxEdge', `is ${p.maxEdge}; a number of px of at least 2/${GRID} is required — each edge is cut into pieces longer than half of it, and a piece must be longer than one 1/${GRID} px grid unit for its two ends to stay apart once snapped`);
399
+ }
385
400
  if (p.budget !== undefined && (!Number.isInteger(p.budget) || p.budget < 3)) bad('budget', `is ${p.budget}; a whole number of vertices, 3 or more, is required`);
386
401
  if (p.fitConnectivity !== undefined && p.fitConnectivity !== 4 && p.fitConnectivity !== 8) bad('fitConnectivity', `is ${JSON.stringify(p.fitConnectivity)}; 4 or 8 is required (how the background is flooded when overshoot is measured)`);
387
402
  if (p.stray !== undefined && (!Number.isInteger(p.stray) || p.stray < 0)) bad('stray', `is ${p.stray}; a whole number of art pixels, 0 or more, is required (the largest island that may be left out)`);
@@ -736,6 +751,26 @@ export function outlineInRegions(outline: ReadonlyArray<readonly [number, number
736
751
  return out;
737
752
  }
738
753
 
754
+ /**
755
+ * An outline with every edge longer than `maxEdge` px cut into `n = ceil(length / maxEdge)` equal pieces (issue
756
+ * #265): between each edge's two ends, the points `a + (b − a)·k/n` for k = 1..n−1, each snapped to the {@link GRID}.
757
+ * Every vertex of the outline is kept, in the same cyclic order and position from index 0, and an edge at or under
758
+ * `maxEdge` gains nothing. The length is {@link edgeLength}'s. The snap moves a cut point off its edge by at most
759
+ * half a grid unit (1/512 px); the mesh's topology and coverage checks read the cut outline as they read any other.
760
+ * The measured reference is `capOutline` in `tools/outline_survey.ts` (issue #265, (G′)), which this states in `src/`.
761
+ */
762
+ export function capOutlineEdges(outline: ReadonlyArray<readonly [number, number]>, maxEdge: number): Array<[number, number]> {
763
+ const out: Array<[number, number]> = [];
764
+ for (let i = 0; i < outline.length; i++) {
765
+ const [ax, ay] = outline[i];
766
+ const [bx, by] = outline[(i + 1) % outline.length];
767
+ out.push([ax, ay]);
768
+ const n = Math.ceil(edgeLength(bx - ax, by - ay) / maxEdge);
769
+ for (let k = 1; k < n; k++) out.push([snap(ax + ((bx - ax) * k) / n) / GRID, snap(ay + ((by - ay) * k) / n) / GRID]);
770
+ }
771
+ return out;
772
+ }
773
+
739
774
  /**
740
775
  * The keep rule ({@link KEEP_FRACTION}) over the candidates in order, against
741
776
  * an outline in grid units. Exact: inside by the crossing rule on integers,
@@ -1767,7 +1802,9 @@ export function contourMesh(part: string, mask: AlphaMask, params: ContourParams
1767
1802
  const stage = contourOutline(part, filled, threshold, tolerance, margin);
1768
1803
  if (Array.isArray(stage)) return stage;
1769
1804
  const { grown } = stage;
1770
- let outline = outlineInRegions(stage.outline, params.regions);
1805
+ // issue #265: the cap cuts the simplified outline's long edges first; undeclared, the outline is the stage's own.
1806
+ const capped = params.maxEdge === undefined ? stage.outline : capOutlineEdges(stage.outline, params.maxEdge);
1807
+ let outline = outlineInRegions(capped, params.regions);
1771
1808
  // issue #188: the ribs' ends join the outline, and their interior points come first among the interior vertices.
1772
1809
  // None declared, nothing here runs and every list below is the one it always was.
1773
1810
  let ribs: RibPlacement | null = null;
@@ -1854,6 +1891,7 @@ export function contourMesh(part: string, mask: AlphaMask, params: ContourParams
1854
1891
  pinchFilledPixels: grown.pinchFilledPixels,
1855
1892
  grownHolePixels: stage.grownHolePixels,
1856
1893
  ...(ribs === null ? {} : { ribs: ribs.ribs }),
1894
+ ...(params.maxEdge === undefined ? {} : { outlineCut: capped.length - stage.outline.length }),
1857
1895
  },
1858
1896
  };
1859
1897
  }
package/src/layers.ts CHANGED
@@ -25,6 +25,11 @@
25
25
  * and for a PSD it is the stacking order itself, with `depth` null because a
26
26
  * PSD carries none. Every refusal is collected and thrown once, so one run
27
27
  * names every missing file.
28
+ *
29
+ * A layer is named by a See-through v3 tag or, for a layer the author made
30
+ * by hand and added to the run (issue #256), by `author:<label>`
31
+ * (`src/tags.ts`, `AUTHOR_PREFIX`): read with `group` "author", it has no
32
+ * role, and its place in the draw order is read like any other layer's.
28
33
  */
29
34
  import { existsSync, readFileSync, statSync } from 'node:fs';
30
35
  import { basename, dirname, join } from 'node:path';
@@ -32,7 +37,7 @@ import { initializeCanvas, type Layer as PsdLayer, readPsd } from 'ag-psd';
32
37
  import { type Problem, refuseIfAny } from './errors.ts';
33
38
  import { decodePngBytes } from './raster/png.ts';
34
39
  import type { Raster } from './raster/types.ts';
35
- import { acceptedTagNames, readTag, type TagReading } from './tags.ts';
40
+ import { type LayerTagReading, layerTagRequirement, readLayerTag } from './tags.ts';
36
41
 
37
42
  /** Alpha strictly above this counts as painted. The reference uses the same cut for its ghost clean-up and its sheets. */
38
43
  export const OPAQUE_ALPHA_ABOVE = 8;
@@ -45,9 +50,9 @@ export const OPAQUE_ALPHA_ABOVE = 8;
45
50
  export const NEAR_WHITE_MIN = 235;
46
51
 
47
52
  export interface Layer {
48
- /** As the input names it; always a v3 tag, possibly with a `-r`/`-l` side. */
53
+ /** As the input names it: a v3 tag, possibly with a `-r`/`-l` side, or an author-made layer's `author:<label>`. */
49
54
  name: string;
50
- tag: TagReading;
55
+ tag: LayerTagReading;
51
56
  /** The file the pixels came from, or null for a PSD layer. */
52
57
  file: string | null;
53
58
  /** The layer's own pixels, `(right-left)x(bottom-top)`, straight alpha. */
@@ -197,9 +202,9 @@ export function readWrapperLayers(path: string): LayerSet {
197
202
  return;
198
203
  }
199
204
  const name = e.name;
200
- const tag = readTag(name);
205
+ const tag = readLayerTag(name);
201
206
  if (tag === null) {
202
- refuse('LAYERS_TAG_KNOWN', `layer "${name}"`, `is not a See-through v3 tag; one of ${acceptedTagNames().join(', ')} is required`);
207
+ refuse('LAYERS_TAG_KNOWN', `layer "${name}"`, layerTagRequirement(name));
203
208
  ok = false;
204
209
  }
205
210
  for (const key of ['left', 'top', 'right', 'bottom'] as const) {
@@ -369,9 +374,9 @@ export function readPsdLayers(path: string): LayerSet {
369
374
  const layers: Layer[] = [];
370
375
  leaves.forEach((layer) => {
371
376
  const name = layer.name ?? '';
372
- const tag = readTag(name);
377
+ const tag = readLayerTag(name);
373
378
  if (tag === null) {
374
- refuse('LAYERS_TAG_KNOWN', `PSD layer "${name}"`, `is not a See-through v3 tag; one of ${acceptedTagNames().join(', ')} is required`);
379
+ refuse('LAYERS_TAG_KNOWN', `PSD layer "${name}"`, layerTagRequirement(name));
375
380
  return;
376
381
  }
377
382
  if (seen.has(name)) {