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.
- package/docs/AUTHORING-PARTS.md +56 -17
- package/package.json +1 -1
- package/src/framework/export/bundle.js +47 -26
- package/src/framework/export/drawing.js +35 -2
- package/src/framework/export/formats.js +10 -2
- package/src/framework/export/readme.js +20 -7
- package/src/framework/geometry/probe.js +28 -9
- package/src/framework/gltf/decode-preview.js +38 -14
- package/src/framework/gltf/encode-preview.js +263 -17
- package/src/framework/lint/rules-build.js +18 -4
- package/src/framework/mount-preview.js +2 -1
- package/src/framework/process/cnc-router/descriptor.js +41 -19
- package/src/framework/process/cnc-router/export.js +54 -31
- package/src/framework/sheet/routed-resolve.js +16 -4
- package/types/geometry.d.ts +6 -4
- package/types/gltf.d.ts +5 -2
- package/types/index.d.ts +6 -0
- package/types/part.d.ts +2 -0
|
@@ -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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const {
|
|
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
|
-
//
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
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}
|
|
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)
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
71
|
-
|
|
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 };
|
package/types/geometry.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
342
|
-
export
|
|
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
|
|
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
|
/**
|