mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-22 11:53:02 -05:00
Deno or Node 20+, or a deno compile binary. Poses the part (--rot, X then Y then Z), runs the plugins' computeFins, writes a 3MF (part + fins), a merged STL, or the fins alone. Flags come from options.json in the site's units. Reports overhangs it couldn't reach; exit 1 only for a file that failed (the others still run), exit 2 for bad arguments. Tests: CLI fins equal computeFins on the posed part and the website's own path on lbracket@35.
243 lines
11 KiB
JavaScript
243 lines
11 KiB
JavaScript
// Support Fins from a terminal: the command line's logic, with no file system of
|
|
// its own. support-fins.js hands it the arguments and a small I/O object (Deno or
|
|
// Node), and the tests hand it an in-memory one.
|
|
//
|
|
// Same engine as every plugin: the part is posed here (--rot), then
|
|
// plugins/shared/engine computeFins places the fins exactly as it does for Orca,
|
|
// Cura or Fusion. Flags come from options.json, so a flag is a site setting is a
|
|
// plugin dialog control.
|
|
import { computeFins, optionsFromDialog, OPTIONS_SCHEMA } from '../shared/engine/fins_entry.js';
|
|
import { reportLine } from '../shared/engine/report.js';
|
|
import { readSTL, writeBinarySTL } from '../../web/stl.js';
|
|
import { readThreeMF, writeThreeMF } from '../../web/threemf.js';
|
|
|
|
// --- flags from options.json -------------------------------------------------
|
|
|
|
// sway.on -> --sway, sway.gripFrom -> --sway-grip-from, tineDensity -> --tine-density
|
|
const flagOf = (key) => key.replace(/\.on$/, '').replace(/\./g, '-')
|
|
.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
const OPTION_FLAGS = new Map(OPTIONS_SCHEMA.options.map((o) => [flagOf(o.key), o]));
|
|
|
|
// A percent option is typed as the site's slider shows it (0-100), not the entry's 0-1.
|
|
const shown = (o, v) => (o.percent ? +(v * 100).toFixed(6) : v);
|
|
|
|
export function helpText() {
|
|
const lines = [
|
|
'support-fins -- bake breakaway support fins into a 3D print (printfins.com, from a terminal)',
|
|
'',
|
|
'usage: support-fins <part.stl|part.3mf>... [options]',
|
|
'',
|
|
' -o, --output <file> where to write (.3mf or .stl); one input only.',
|
|
' default: <part>-fins.3mf next to the input',
|
|
' --rot <x,y,z> pose the part first: degrees about the plate\'s X, then Y, then Z',
|
|
' --fins-only write only the fins + bed pad (an .stl that lines up with the part)',
|
|
' --object <n|name> which object of a multi-object 3MF to fin (1-based)',
|
|
' --json one JSON line per file instead of the summary',
|
|
' -h, --help this text',
|
|
'',
|
|
'settings (the site\'s, same defaults):',
|
|
];
|
|
for (const [flag, o] of OPTION_FLAGS) {
|
|
let arg = '';
|
|
if (o.type === 'choice') arg = ` <${o.choices.map((c) => c.value).join('|')}>`;
|
|
else if (o.type === 'number') arg = o.percent ? ' <0-100>' : ` <${o.min}-${o.max}>`;
|
|
const name = o.type === 'bool' ? (o.default ? `--no-${flag}` : `--${flag}`) : `--${flag}${arg}`;
|
|
// Units only ("mm", "%"): the site's other hints ("light ⟶ firm") are slider ends.
|
|
const unit = o.hint && /^(mm|°|%)/.test(o.hint) ? ` ${o.hint}` : '';
|
|
const what = o.type === 'bool'
|
|
? `${o.default ? 'turn off' : 'turn on'} ${o.label.toLowerCase()}${o.hint ? ` (${o.hint})` : ''}`
|
|
: `${o.label}${unit}, default ${shown(o, o.default)}`;
|
|
lines.push(` ${name.padEnd(38)} ${what}`);
|
|
}
|
|
lines.push('', 'Overhangs a fin can\'t reach are reported, not errors: exit 0. Exit 1 = a file',
|
|
'failed; exit 2 = bad arguments.');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
class UsageError extends Error {}
|
|
|
|
/** argv (without the program name) -> { inputs, output, rot, finsOnly, object, json, help, dialog } */
|
|
export function parseArgs(argv) {
|
|
const out = { inputs: [], output: null, rot: null, finsOnly: false, object: null,
|
|
json: false, help: false, dialog: {} };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
let a = argv[i];
|
|
let value = null;
|
|
const eq = a.startsWith('--') ? a.indexOf('=') : -1;
|
|
if (eq > 0) { value = a.slice(eq + 1); a = a.slice(0, eq); }
|
|
const next = () => {
|
|
if (value !== null) return value;
|
|
if (i + 1 >= argv.length) throw new UsageError(`${a} needs a value`);
|
|
return argv[++i];
|
|
};
|
|
if (a === '-h' || a === '--help') out.help = true;
|
|
else if (a === '-o' || a === '--output') out.output = next();
|
|
else if (a === '--rot') out.rot = parseRot(next());
|
|
else if (a === '--fins-only') out.finsOnly = true;
|
|
else if (a === '--object') out.object = next();
|
|
else if (a === '--json') out.json = true;
|
|
else if (a.startsWith('--')) {
|
|
const neg = a.startsWith('--no-');
|
|
const flag = a.slice(neg ? 5 : 2);
|
|
const o = OPTION_FLAGS.get(flag);
|
|
if (!o) throw new UsageError(`unknown option ${a} (see --help)`);
|
|
if (o.type === 'bool') {
|
|
if (value !== null) throw new UsageError(`${a} takes no value`);
|
|
out.dialog[o.key] = !neg;
|
|
} else {
|
|
if (neg) throw new UsageError(`unknown option ${a} (see --help)`);
|
|
const v = next();
|
|
if (o.type === 'number') {
|
|
const n = Number(v);
|
|
if (v.trim() === '' || !Number.isFinite(n)) throw new UsageError(`--${flag} must be a number, got ${JSON.stringify(v)}`);
|
|
out.dialog[o.key] = o.percent ? n / 100 : n;
|
|
} else {
|
|
out.dialog[o.key] = v;
|
|
}
|
|
}
|
|
} else if (a.startsWith('-') && a !== '-') {
|
|
throw new UsageError(`unknown option ${a} (see --help)`);
|
|
} else {
|
|
out.inputs.push(a);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function parseRot(s) {
|
|
const v = s.split(',').map((t) => Number(t.trim()));
|
|
if (v.length !== 3 || v.some((n) => !Number.isFinite(n))) {
|
|
throw new UsageError(`--rot takes three angles in degrees, x,y,z -- got ${JSON.stringify(s)}`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
// --- geometry ---------------------------------------------------------------
|
|
|
|
/** Column-major 3x3 (three.js Matrix3.elements) for X, then Y, then Z about the plate axes. */
|
|
export function rotationMatrix([ax, ay, az]) {
|
|
const r = (d) => (d * Math.PI) / 180;
|
|
const [cx, sx, cy, sy, cz, sz] = [Math.cos(r(ax)), Math.sin(r(ax)), Math.cos(r(ay)),
|
|
Math.sin(r(ay)), Math.cos(r(az)), Math.sin(r(az))];
|
|
// R = Rz * Ry * Rx, stored by column
|
|
return [
|
|
cz * cy, sz * cy, -sy,
|
|
cz * sy * sx - sz * cx, sz * sy * sx + cz * cx, cy * sx,
|
|
cz * sy * cx + sz * sx, sz * sy * cx - cz * sx, cy * cx,
|
|
];
|
|
}
|
|
|
|
/** Pose a soup in float64 (Orca poses in double; float32 can flip a borderline tine). */
|
|
export function pose(pos, rot) {
|
|
const out = new Float64Array(pos.length);
|
|
if (!rot) { out.set(pos); return out; }
|
|
const m = rotationMatrix(rot);
|
|
for (let i = 0; i < pos.length; i += 3) {
|
|
const x = pos[i], y = pos[i + 1], z = pos[i + 2];
|
|
out[i] = m[0] * x + m[3] * y + m[6] * z;
|
|
out[i + 1] = m[1] * x + m[4] * y + m[7] * z;
|
|
out[i + 2] = m[2] * x + m[5] * y + m[8] * z;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const triples = (flat, add = [0, 0, 0]) => {
|
|
const out = new Array(flat.length / 3);
|
|
for (let i = 0, j = 0; i < flat.length; i += 3, j++) {
|
|
out[j] = [flat[i] + add[0], flat[i + 1] + add[1], flat[i + 2] + add[2]];
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// --- one file ---------------------------------------------------------------
|
|
|
|
async function readPart(path, bytes, object) {
|
|
if (/\.3mf$/i.test(path)) {
|
|
const m = await readThreeMF(bytes);
|
|
const objs = m.objects || [];
|
|
if (objs.length <= 1 && object === null) return m.positions;
|
|
if (object === null) {
|
|
throw new Error(`has ${objs.length} objects (${objs.map((o, i) => `${i + 1} ${o.name}`).join(', ')}); `
|
|
+ 'pick one with --object');
|
|
}
|
|
const byIndex = /^\d+$/.test(object) ? objs[Number(object) - 1] : null;
|
|
const pick = byIndex || objs.find((o) => o.name === object);
|
|
if (!pick) throw new Error(`no object ${JSON.stringify(object)}; it has ${objs.map((o) => o.name).join(', ')}`);
|
|
return pick.positions;
|
|
}
|
|
if (/\.stl$/i.test(path)) return readSTL(bytes);
|
|
throw new Error('reads .stl and .3mf only');
|
|
}
|
|
|
|
function defaultOutput(input, finsOnly) {
|
|
const base = input.replace(/\.(stl|3mf)$/i, '');
|
|
return finsOnly ? `${base}-fins-only.stl` : `${base}-fins.3mf`;
|
|
}
|
|
|
|
/**
|
|
* Fin one file. The part is written as the engine sees it: posed, centred over the
|
|
* plate origin, on z = 0 -- the site's export frame -- with the fins in that frame.
|
|
*/
|
|
export async function finFile(input, args, io, engineOptions) {
|
|
const output = args.output ?? defaultOutput(input, args.finsOnly);
|
|
if (!/\.(3mf|stl)$/i.test(output)) throw new Error(`output ${output}: write .3mf or .stl`);
|
|
if (args.finsOnly && !/\.stl$/i.test(output)) throw new Error('--fins-only writes an .stl');
|
|
const posed = pose(await readPart(input, await io.read(input), args.object), args.rot);
|
|
const { triangles, offset, stats } = computeFins(posed, engineOptions);
|
|
const fins = triples(triangles);
|
|
const part = triples(posed, [offset.x, offset.y, offset.z]);
|
|
const name = (input.split(/[\\/]/).pop() || 'part').replace(/\.(stl|3mf)$/i, '');
|
|
const blob = args.finsOnly ? writeBinarySTL(fins, `${name} fins`)
|
|
: /\.3mf$/i.test(output) ? writeThreeMF(part, fins, name)
|
|
: writeBinarySTL([...part, ...fins], name);
|
|
await io.write(output, new Uint8Array(await blob.arrayBuffer()));
|
|
return { input, output, stats, report: reportLine(stats) };
|
|
}
|
|
|
|
// --- the command ------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {string[]} argv arguments after the program name
|
|
* @param {{read(path):Promise<Uint8Array>, write(path, bytes):Promise<void>,
|
|
* out(line):void, err(line):void}} io
|
|
* @returns {Promise<number>} exit code: 0 ok, 1 a file failed, 2 bad arguments
|
|
*/
|
|
export async function run(argv, io) {
|
|
let args, engineOptions;
|
|
try {
|
|
args = parseArgs(argv);
|
|
if (args.help) { io.out(helpText()); return 0; }
|
|
if (args.inputs.length === 0) throw new UsageError('no input file (see --help)');
|
|
if (args.output && args.inputs.length > 1) throw new UsageError('-o takes one input; with several, each is written next to its input');
|
|
engineOptions = optionsFromDialog(args.dialog);
|
|
} catch (e) {
|
|
// optionsFromDialog speaks in the entry's units; say it in the flag's.
|
|
io.err(`support-fins: ${e instanceof UsageError ? e.message : flagMessage(e.message)}`);
|
|
return 2;
|
|
}
|
|
let failed = 0;
|
|
for (const input of args.inputs) {
|
|
try {
|
|
const r = await finFile(input, args, io, engineOptions);
|
|
io.out(args.json ? JSON.stringify(r) : `${r.input} -> ${r.output}: ${r.report}`);
|
|
} catch (e) {
|
|
failed++;
|
|
io.err(args.json ? JSON.stringify({ input, error: e.message }) : `support-fins: ${input}: ${e.message}`);
|
|
}
|
|
}
|
|
return failed ? 1 : 0;
|
|
}
|
|
|
|
// "coverage must be 0..1 (a percent control's value / 100), got 1.5" -> "--coverage must be 0..100, got 150"
|
|
function flagMessage(msg) {
|
|
const m = msg.match(/^(\S+) must be (\S+?)\.\.(\S+?)(?: \(a percent[^)]*\))?, got (.*)$/);
|
|
const o = m && OPTIONS_SCHEMA.options.find((x) => x.key === m[1]);
|
|
if (!o) {
|
|
const k = msg.match(/^(\S+) /);
|
|
const opt = k && OPTIONS_SCHEMA.options.find((x) => x.key === k[1]);
|
|
return opt ? `--${flagOf(opt.key)}${msg.slice(k[1].length)}` : msg;
|
|
}
|
|
const got = Number(m[4]);
|
|
return `--${flagOf(o.key)} must be ${shown(o, o.min)}..${shown(o, o.max)}, got ${Number.isFinite(got) ? shown(o, got) : m[4]}`;
|
|
}
|