partforge 0.155.0 → 0.157.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.
@@ -6,10 +6,11 @@
6
6
  //
7
7
  // Colours: `ctx.palette` (drawing.js routedPalette, built by the kit over EVERY routed
8
8
  // piece) when given, so one depth is one colour across the kit; without it — a direct
9
- // call — the piece ranks its own depths. Each named layer also carries its `depth` (mm,
9
+ // call — routedPalette over the piece alone. Each named layer also carries its `depth` (mm,
10
10
  // two decimals) and drills `through`, which the README's layer legend reads.
11
11
  import {
12
12
  refitRing, ringsOf, cutRings, drawingBounds, routedLayerName, depthToken, drillKey, ROUTED_COLORS,
13
+ routedPalette, FLIP_AXIS, mirrorRings,
13
14
  } from "../../export/drawing.js";
14
15
  import { KIT_OPTIONS_ERROR } from "../../export/formats.js";
15
16
 
@@ -18,57 +19,79 @@ const refitRegions = (regions) => regions.map((rg) => ({ outer: refitRing(rg.out
18
19
  const allRings = (regions) => { const r = ringsOf(regions); return [...r.outer, ...r.holes]; };
19
20
  const ringsOfShape = (shape) => allRings(refitRegions(shape.toContours())).map(closedPath);
20
21
 
21
- function drawing(s, o, k, ctx = {}) {
22
- const label = ctx.label ?? s.label ?? "routed part";
23
- if (o?.kerf > 0) throw new Error(`${KIT_OPTIONS_ERROR} kerf does not apply to a CNC router — the CAM applies the tool offset; set kerf to 0 ("${label}")`);
24
- const nominal = refitRegions(s.profile.toContours());
25
- if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
26
- const cut = cutRings(nominal);
27
- const { pockets, drills, extra } = s.routed;
28
-
22
+ // One face's operation layers: SECONDARY, then drills grouped by (d, depth, through) in
23
+ // first-seen order, then pockets grouped by two-decimal depth, shallowest first. `place`
24
+ // maps each layer's paths (identity for the near face, the flip's mirror for the far);
25
+ // a far layer's id is prefixed `far-` and it carries `face: "far"` — a near layer has
26
+ // no face key, so a one-sided piece draws exactly as before two-sided parts existed.
27
+ function opLayers({ pockets, drills, extra }, palette, place) {
28
+ const tag = place ? { face: "far" } : {};
29
+ const id = (base) => (place ? `far-${base}` : base);
30
+ const at = place ?? ((paths) => paths);
29
31
  const layers = [];
30
- const reference = [
31
- ...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
32
- ...s.score.shapes.flatMap(ringsOfShape),
33
- ];
34
- if (reference.length) layers.push({ id: "reference", name: "REFERENCE", ...ROUTED_COLORS.reference, paths: reference });
35
32
  const secondary = extra.flatMap((x) => ringsOfShape(x.footprint));
36
- if (secondary.length) layers.push({ id: "secondary", name: "SECONDARY", ...ROUTED_COLORS.secondary, paths: secondary });
33
+ if (secondary.length) layers.push({ id: id("secondary"), name: place ? "FAR_SECONDARY" : "SECONDARY", ...ROUTED_COLORS.secondary, ...tag, paths: at(secondary) });
37
34
 
38
- // Drills grouped by (d, depth, through) in first-seen order; each hole a closed ring
39
- // of two exact arcs, which dxf.js writes as a CIRCLE.
40
- const palette = ctx.palette ?? null;
35
+ // Each hole a closed ring of two exact arcs, which dxf.js writes as a CIRCLE.
41
36
  const drillGroups = new Map();
42
37
  drills.forEach((dr) => {
43
38
  const key = drillKey(dr);
44
- const colour = palette?.drill.get(key) ?? ROUTED_COLORS.drill[drillGroups.size % ROUTED_COLORS.drill.length];
45
- const g = drillGroups.get(key) ?? { id: `drill-${key}`, name: routedLayerName("drill", dr), ...colour,
46
- depth: Number(depthToken(dr.depth)), through: dr.through, paths: [] };
47
- g.paths.push(...ringsOfShape(dr.shape));
39
+ const g = drillGroups.get(key) ?? { id: id(`drill-${key}`), name: routedLayerName("drill", dr), ...palette.drill.get(key),
40
+ depth: Number(depthToken(dr.depth)), through: dr.through, ...tag, paths: [] };
41
+ g.paths.push(...at(ringsOfShape(dr.shape)));
48
42
  drillGroups.set(key, g);
49
43
  });
50
44
  layers.push(...drillGroups.values());
51
45
 
52
- // Pockets grouped by two-decimal depth, shallowest layer first; an empty region draws nothing.
46
+ // An empty region draws nothing.
53
47
  const pocketGroups = new Map();
54
48
  for (const pk of pockets) {
55
49
  const paths = ringsOfShape(pk.region);
56
50
  if (paths.length === 0) continue;
57
51
  const key = depthToken(pk.depth);
58
- const g = pocketGroups.get(key) ?? { id: `pocket-${key}`, name: routedLayerName("pocket", pk), depth: Number(key), paths: [] };
59
- g.paths.push(...paths);
52
+ const g = pocketGroups.get(key) ?? { id: id(`pocket-${key}`), name: routedLayerName("pocket", pk), depth: Number(key), ...tag, paths: [] };
53
+ g.paths.push(...at(paths));
60
54
  pocketGroups.set(key, g);
61
55
  }
62
- [...pocketGroups.values()].sort((a, b) => a.depth - b.depth).forEach((g, i) => {
63
- const colour = palette?.pocket.get(depthToken(g.depth)) ?? ROUTED_COLORS.pocket[i % ROUTED_COLORS.pocket.length];
64
- layers.push({ ...g, ...colour });
65
- });
56
+ [...pocketGroups.values()].sort((a, b) => a.depth - b.depth)
57
+ .forEach((g) => layers.push({ ...g, ...palette.pocket.get(depthToken(g.depth)) }));
58
+ return layers;
59
+ }
60
+
61
+ function drawing(s, o, k, ctx = {}) {
62
+ const label = ctx.label ?? s.label ?? "routed part";
63
+ if (o?.kerf > 0) throw new Error(`${KIT_OPTIONS_ERROR} kerf does not apply to a CNC router — the CAM applies the tool offset; set kerf to 0 ("${label}")`);
64
+ const nominal = refitRegions(s.profile.toContours());
65
+ if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
66
+ const cut = cutRings(nominal);
67
+ const { pockets, drills, extra } = s.routed;
68
+
69
+ const layers = [];
70
+ const reference = [
71
+ ...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
72
+ ...s.score.shapes.flatMap(ringsOfShape),
73
+ ];
74
+ if (reference.length) layers.push({ id: "reference", name: "REFERENCE", ...ROUTED_COLORS.reference, paths: reference });
75
+ // Near ops as they are; far ops (cut after the piece is turned over by the kit's flip)
76
+ // on their own `far-` layers, every ring mirrored about the piece's centre so the far
77
+ // face is drawn as the router sees it. One palette for both faces: a depth or drill
78
+ // setting is one colour whichever face it is on.
79
+ const palette = ctx.palette ?? routedPalette([s]);
80
+ const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
81
+ const axis = FLIP_AXIS[o?.flip ?? "left-right"];
82
+ const centre = axis === "x" ? (nb.min[0] + nb.max[0]) / 2 : (nb.min[1] + nb.max[1]) / 2;
83
+ const isFar = (op) => op.face === "far";
84
+ const near = opLayers({ pockets: pockets.filter((op) => !isFar(op)), drills: drills.filter((op) => !isFar(op)), extra: extra.filter((op) => !isFar(op)) }, palette);
85
+ const far = opLayers({ pockets: pockets.filter(isFar), drills: drills.filter(isFar), extra: extra.filter(isFar) }, palette,
86
+ (paths) => mirrorRings(paths, axis, centre));
87
+ layers.push(...near, ...far);
66
88
 
67
89
  if (cut.inner.length) layers.push({ id: "cut-inner", paths: cut.inner.map(closedPath) });
68
90
  layers.push({ id: "cut-outer", paths: cut.outer.map(closedPath) });
69
91
 
70
- const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
71
- return { layers, bounds: drawingBounds(layers), nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]], kerf: 0 };
92
+ const out = { layers, bounds: drawingBounds(layers), nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]], kerf: 0 };
93
+ if (far.length) out.farOutline = mirrorRings(ringsOf(nominal).outer, axis, centre).map(closedPath);
94
+ return out;
72
95
  }
73
96
 
74
97
  export default { drawing };
@@ -16,6 +16,13 @@ const depthOf = (sp, entry, who) => {
16
16
  if (!isNum(entry.depth) || entry.depth <= 0) throw fail(sp, `${who} depth must be a finite number > 0 (mm), got ${JSON.stringify(entry.depth)}`);
17
17
  return entry.depth;
18
18
  };
19
+ // Which face an op is machined from: "near" (z = thickness, the default) or "far" (z = 0).
20
+ const faceOf = (sp, entry, who) => {
21
+ const f = entry.face;
22
+ if (f === undefined || f === null) return "near";
23
+ if (f === "near" || f === "far") return f;
24
+ throw fail(sp, `${who} face must be "near" or "far", got ${JSON.stringify(f)}`);
25
+ };
19
26
  const labelOf = (entry) => (typeof entry.label === "string" && entry.label.trim() ? entry.label.trim() : null);
20
27
  const list = (sp, fn, args, what, shape) => {
21
28
  if (!fn) return [];
@@ -26,7 +33,9 @@ const list = (sp, fn, args, what, shape) => {
26
33
  };
27
34
 
28
35
  // `base` is resolveSheet's ResolvedSheet so far (profile, thickness, score…); the
29
- // return value is merged onto it as `routed`.
36
+ // return value is merged onto it as `routed`. Every op carries `face`: "near" (machined
37
+ // from z = thickness, the default) or "far" (from z = 0, the stock flipped over); depth
38
+ // is measured from that face either way.
30
39
  export function resolveRouted(k, sp, p, d, base) {
31
40
  const s = sp.sheet;
32
41
  const t = base.thickness;
@@ -35,8 +44,9 @@ export function resolveRouted(k, sp, p, d, base) {
35
44
  const who = name(e, i, "pocket");
36
45
  if (!e || typeof e !== "object" || e.region === null || e.region === undefined) throw fail(sp, `${who} needs a region (a Shape2D or profile)`);
37
46
  const depth = depthOf(sp, e, who);
47
+ const face = faceOf(sp, e, who);
38
48
  if (depth > t) throw fail(sp, `${who} is ${e.depth} mm deep in ${t} mm stock — a pocket may be at most the thickness; make it a through cut in \`profile\` if it should go through`);
39
- return { region: k.shape2d(e.region), depth, label: labelOf(e) };
49
+ return { region: k.shape2d(e.region), depth, label: labelOf(e), face };
40
50
  });
41
51
 
42
52
  const drills = list(sp, s.drills, [p, d], "drills(p, d)", "{ at, d, depth }").map((e, i) => {
@@ -44,18 +54,20 @@ export function resolveRouted(k, sp, p, d, base) {
44
54
  if (!e || typeof e !== "object" || !isPoint(e.at)) throw fail(sp, `${who} needs at: [x, y]`);
45
55
  if (!isNum(e.d) || e.d <= 0) throw fail(sp, `${who} d must be a finite number > 0 (mm), got ${JSON.stringify(e.d)}`);
46
56
  const depth = depthOf(sp, e, who);
57
+ const face = faceOf(sp, e, who);
47
58
  const through = depth >= t;
48
- return { shape: k.shape2d(circleRing(e.at, e.d / 2)), at: [e.at[0], e.at[1]], d: e.d, depth: through ? t : depth, through, label: labelOf(e) };
59
+ return { shape: k.shape2d(circleRing(e.at, e.d / 2)), at: [e.at[0], e.at[1]], d: e.d, depth: through ? t : depth, through, label: labelOf(e), face };
49
60
  });
50
61
 
51
62
  const extra = list(sp, s.extra, [k, p, d], "extra(k, p, d)", "{ solid, footprint, label, method }").map((e, i) => {
52
63
  const label = labelOf(e);
53
64
  if (!label) throw fail(sp, `extra op ${i + 1} needs a label — say what it is ("12° domino slot")`);
54
65
  const who = `extra op "${label}"`;
66
+ const face = faceOf(sp, e, who);
55
67
  if (typeof e.method !== "string" || !e.method.trim()) throw fail(sp, `${who} needs a method — say how the operator makes it ("table saw, blade tilted 12°")`);
56
68
  if (!isSolid(e.solid)) throw fail(sp, `${who} needs a solid — the geometry it removes, for the preview and the STEP`);
57
69
  if (e.footprint === null || e.footprint === undefined) throw fail(sp, `${who} needs a footprint — its outline on the machined face, for the drawing`);
58
- return { solid: e.solid, footprint: k.shape2d(e.footprint), label, method: e.method.trim() };
70
+ return { solid: e.solid, footprint: k.shape2d(e.footprint), label, method: e.method.trim(), face };
59
71
  });
60
72
 
61
73
  return { pockets, drills, extra };
@@ -338,12 +338,14 @@ export function sheetPart<P = ResolvedParams, D = Derived>(spec: SheetPartSpec<P
338
338
 
339
339
  // --- routed parts (docs/AUTHORING-PARTS.md "Routed parts") --------------------
340
340
 
341
- /** A flat-floored pocket machined `depth` mm down from the machined face. */
342
- export interface RoutedPocket { region: Shape2D | ProfileInput; depth: number; label?: string }
341
+ /** Which face of the stock an op is machined from: `"near"` (z = thickness, the default) or `"far"` (z = 0). */
342
+ export type RoutedFace = "near" | "far";
343
+ /** A flat-floored pocket machined `depth` mm into the stock from its face. */
344
+ export interface RoutedPocket { region: Shape2D | ProfileInput; depth: number; label?: string; face?: RoutedFace }
343
345
  /** A drilled hole; `depth` equal to the stock thickness drills through — deeper is a `routed-drill-depth` lint error. */
344
- export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string }
346
+ export interface RoutedDrill { at: Point2; d: number; depth: number; label?: string; face?: RoutedFace }
345
347
  /** An operation done with another tool after the flat milling: the solid is cut in the preview and the STEP, the footprint is drawn, label and method go in the README. */
346
- export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string }
348
+ export interface RoutedExtra { solid: Solid; footprint: Shape2D | ProfileInput; label: string; method: string; face?: RoutedFace }
347
349
 
348
350
  /**
349
351
  * What `routedPart` accepts: `sheetPart`'s stock, profile and pose, plus pockets,
package/types/gltf.d.ts CHANGED
@@ -23,7 +23,10 @@ export interface PreviewMesh {
23
23
  /**
24
24
  * Feature-edge segments, endpoint pairs `[x0,y0,z0, x1,y1,z1, …]` in the same posed
25
25
  * frame as `positions` (encoder version 2+). Written as a LINES primitive on the
26
- * sub-part's mesh. Empty or absent writes none, and decodes as "no feature edges".
26
+ * sub-part's mesh — since version 3, indices into the surface's own POSITION
27
+ * accessor (each endpoint snapped to the surface vertex at its position, or
28
+ * appended past the surface's vertices when it lies on none). Empty or absent
29
+ * writes none, and decodes as "no feature edges".
27
30
  */
28
31
  edges?: Float32Array | null;
29
32
  }
@@ -37,7 +40,7 @@ export interface EncodePreviewInput {
37
40
  view?: string | null;
38
41
  }
39
42
 
40
- /** Bumped whenever the encoder writes different bytes for the same input (2: feature edges). */
43
+ /** Bumped whenever the encoder writes different bytes for the same input (2: feature edges; 3: edges as indices into the surface's vertices). */
41
44
  export const PREVIEW_ENCODER_VERSION: number;
42
45
 
43
46
  /** Encode one view's posed meshes as a welded, quantized, meshopt-compressed GLB (Y-up, millimetres). Deterministic. */
package/types/index.d.ts CHANGED
@@ -67,6 +67,10 @@ export interface KitOptions {
67
67
  printFormat?: "stl" | "3mf";
68
68
  /** Whole number, 1–20. Default 1. */
69
69
  sets?: number;
70
+ /** Which way a two-sided board is turned over between sides — the axis far-side layers are mirrored about. Default `"left-right"`. */
71
+ flip?: "left-right" | "end-over-end";
72
+ /** Two-sided pieces: one layered file per piece (`"one-file"`, default), or a near file plus a `-far` file (`"two-files"`). */
73
+ twoSided?: "one-file" | "two-files";
70
74
  }
71
75
 
72
76
  /**
@@ -830,6 +834,8 @@ export const KIT_DEFAULTS: Readonly<{
830
834
  spacing: number;
831
835
  printFormat: "stl";
832
836
  sets: number;
837
+ flip: "left-right";
838
+ twoSided: "one-file";
833
839
  }>;
834
840
 
835
841
  /** Normalise kit options — defaults filled, `cutFormat` derived — or throw a `"cut kit options: …"` error. */
package/types/part.d.ts CHANGED
@@ -576,6 +576,8 @@ export interface SubPartExpectations {
576
576
  routedEmpty?: Expectation;
577
577
  /** Routed parts only: separate regions in the cut profile (volunteered check: `1`). */
578
578
  routedPieces?: Expectation;
579
+ /** Routed parts only: overlapping near- and far-face pocket pairs whose depths together exceed the stock (volunteered check: `0`). */
580
+ routedBreakthrough?: Expectation;
579
581
  }
580
582
 
581
583
  /**