Files
matthewtrahan 041448a71f plugins: nozzle size -- Cura and Orca read it from the part's extruder, CAD dialogs show it; the entry resets to 0.4 when a call leaves it out
options.json gains nozzle (hostSupplied: slicer, 0.2-0.8, default 0.4). The
entry sends it in tunables on every call, so a host's long-lived engine context
can't keep the last part's widths. Cura: machine_nozzle_size on the part's
extruder. Orca: nozzle_diameter, the wall extruder's entry. FreeCAD, Blender,
Fusion, SolidWorks and the CLI pick it up from the schema.
2026-10-13 13:28:16 -05:00

220 lines
11 KiB
JavaScript

// Support Fins -- engine entry point for the plugins.
//
// The one function a plugin needs from the web engine: take a posed part as
// triangle soup (exactly as it sits on the slicer's plate or in the CAD model, in
// mm, z up) and return the fin + bed-pad triangles to add under it.
//
// Nothing here re-implements geometry. It calls the SAME analyze()/buildFins()
// the website runs, with rot = identity because Orca has already applied the
// user's rotation (the user poses the part in the host app, e.g. Orca's rotate
// gizmo -- the "you pick the rotation" rule is kept, it just happens there).
//
// FRAME CONTRACT (plugins depend on this; e.g. Orca's slicing hook):
// analyze() seats the part with offset = (-cx, -cy, -minZ), i.e. the posed
// part's XY bounding-box centre moves to the origin and its lowest point to
// z = 0. buildFins() emits in that same seated frame. We return the triangles
// in that frame unchanged and report the offset we saw, so Python can map
// them back onto the part: fin_world = fin_seated - offset.
import { buildTopology, analyze, DEFAULT_THRESHOLD, IDENTITY3 } from '../../../web/overhangs.js';
import { buildFins } from '../../../web/fins.js';
import { seatSoup, clearances, flatten } from './seat.js';
import SCHEMA from './options.json' with { type: 'json' };
// The dialog settings (options.json) are the one source of their defaults: every
// plugin's dialog is built from the same file, so the entry can't disagree with it.
const OPTION = Object.fromEntries(SCHEMA.options.map((o) => [o.key, o]));
export const ENGINE_DEFAULTS = Object.freeze({
mode: 'auto', // the website's default fin mode
bedPad: true, // older hosts' switch; padStyle 'off' turns the pad off too
tines: OPTION.tines.default,
tineDensity: OPTION.tineDensity.default, // 0..1 (the site's slider / 100)
coverage: OPTION.coverage.default, // 0..1 (the site's slider / 100)
plateOnly: OPTION.plateOnly.default, // supports on the plate only, none on the part (#218)
layerHeight: OPTION.layerHeight.default, // a slicer host passes its own
nozzle: OPTION.nozzle.default, // mm; scales the widths (web/nozzle.js); a slicer host passes its own
threshold: DEFAULT_THRESHOLD, // degrees; options.json's default is pinned to it
material: OPTION.material.default, // key of web/materials.js MATERIAL
padStyle: OPTION.padStyle.default, // one of PAD_STYLES
cutout: OPTION.cutout.default, // one of web/cutout.js CUTOUT_PATTERNS
// Sway braces (web/sway.js), off by default exactly as on the website. Pass
// { on: true } to brace the tall sides, plus any of gripFrom / tineSpacing /
// reach / gap to match the host's own settings. Without this a plugin
// could not reach the feature at all: the options below are an explicit list,
// so anything absent from it never arrives at buildFins.
sway: null,
});
/**
* A plugin dialog's values -> computeFins options. Every host goes through this
* instead of assembling the object itself, so a dotted key, a percent or a bad
* value can't go wrong differently per host. Strict on purpose: a dialog built from
* options.json can't produce a bad value, so one means a host bug, and guessing
* (clamping 50 to 1 when the host forgot the /100) would print the wrong fins.
*
* @param {object} values {options.json key: value}, in the ENTRY's units: a percent
* control's value / 100. A key left out, null or '' takes the engine default.
* Bools may be true/false or "true"/"false" (settings stores hand back strings).
* @returns {object} options for computeFins; sway only when sway.on is set
*/
export function optionsFromDialog(values = {}) {
const out = {};
for (const [key, raw] of Object.entries(values)) {
const o = option(key);
if (raw === null || raw === undefined || raw === '') continue;
const bad = (why) => new Error(`${key} ${why}, got ${JSON.stringify(raw)}`);
let v = raw;
if (o.type === 'bool') {
v = asBool(raw);
if (v === undefined) throw bad('must be true or false');
} else if (o.type === 'number') {
v = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
if (!Number.isFinite(v)) throw bad('must be a number');
if (o.hostSupplied) {
// The host's own setting (a slicer's layer height): used as is, like the site
// does, since a clamped value would size the tines for the wrong layer.
if (!(v > 0)) throw bad('must be positive');
} else if (v < o.min || v > o.max) {
throw bad(`must be ${o.min}..${o.max}${o.percent ? ' (a percent control\'s value / 100)' : ''}`);
}
} else if (!o.choices.some((c) => c.value === raw)) {
throw bad(`must be one of ${o.choices.map((c) => c.value).join(', ')}`);
}
const [head, sub] = key.split('.');
if (sub) (out[head] ??= {})[sub] = v; else out[head] = v;
}
if (out.sway && !out.sway.on) delete out.sway;
return out;
}
/**
* Whether a dialog shows the control for `key`, given the current values (keys left
* out, null or '' read as their defaults) -- options.json's showIf, evaluated once
* for every host. Takes values the way optionsFromDialog does.
*/
export function optionVisible(key, values = {}) {
const get = (k) => {
const v = Object.hasOwn(values, k) ? values[k] : null;
if (v === null || v === undefined || v === '') return option(k).default;
return option(k).type === 'bool' ? asBool(v) : v;
};
const holds = (c) => (typeof c === 'string' ? get(c) === true
: c.any ? c.any.some(holds)
: c.in.includes(get(c.key)));
return (option(key).showIf || []).every(holds);
}
function option(key) {
if (!Object.hasOwn(OPTION, key)) throw new Error(`unknown dialog option ${key}`);
return OPTION[key];
}
function asBool(v) {
if (v === true || v === false) return v;
if (typeof v === 'string' && /^(true|false)$/i.test(v)) return v.toLowerCase() === 'true';
return undefined;
}
export { SCHEMA as OPTIONS_SCHEMA };
/**
* @param {Float64Array|Float32Array|number[]} positions triangle soup, 9 per face, mm,
* posed, anywhere on the plate. Pass float64 when you have it: Orca poses in double
* precision, and rounding back to float32 can flip a borderline tine.
* @param {object} [options] overrides for ENGINE_DEFAULTS
* @returns {{ triangles: Float32Array, offset: {x:number,y:number,z:number},
* pieces: {id:string, kind:string, ranges:number[][]}[], overFaces: Int32Array,
* smallFaces: Int32Array, stats: object }}
* pieces what each run of `triangles` is, so a host can make one object per fin:
* kind 'prop' (a wall) | 'wedge' | 'sway' | ... as web/fins.js tags it, and
* 'pad'; ranges are [first, end) TRIANGLE indices into `triangles`. Every
* triangle belongs to exactly one piece. ids are stable for the same part
* and options, not across edits.
* overFaces indices of the INPUT faces in the overhang regions the engine supports:
* the site paints these red (web/ui/part.js paintOverhangs, res.kept)
* smallFaces faces past the threshold in regions too small to fin, painted amber
* on the site: a host should show them too, not hide them
*/
export function computeFins(positions, options = {}) {
const opts = { ...ENGINE_DEFAULTS, ...options };
const { mat, tunables } = clearances(opts);
const { pos, shift } = seatSoup(positions);
const topo = buildTopology({ getAttribute: (k) => (k === 'position' ? { array: pos } : null) });
const result = analyze(topo, opts.threshold, IDENTITY3);
// Read now: `over` is the topology's own buffer, which a later analyze() refills.
const overFaces = [], smallFaces = [];
for (let f = 0; f < result.over.length; f++) {
if (result.kept[f]) overFaces.push(f); else if (result.over[f]) smallFaces.push(f);
}
const built = buildFins(topo, result, IDENTITY3, {
mode: opts.mode, bedPad: opts.bedPad && opts.padStyle !== 'off', tines: opts.tines,
tineDensity: opts.tineDensity, layerHeight: opts.layerHeight, coverage: opts.coverage,
plateOnly: opts.plateOnly,
// `sway` is forwarded whole, so a host passes the same object the website's
// options panel builds; buildFins ignores it unless `on` is set. Like the site
// (ui/finbuild.js swayOpts), braces take the material's gap unless the
// host sets its own. A null from the host (Python None) counts as unset, or it
// would drop sway.js back to PLA's numbers under PETG.
sway: opts.sway ? { gap: mat.propGap,
...Object.fromEntries(Object.entries(opts.sway).filter(([, v]) => v != null)) }
: undefined,
tunables,
});
const fin = flatten(built.triangles);
const pad = flatten(built.padTriangles || []);
const triangles = new Float32Array(fin.length + pad.length);
triangles.set(fin, 0);
triangles.set(pad, fin.length);
const pieces = piecesOf(built, fin.length / 9, pad.length / 9);
// Sway braces are appended to the fin soup as one block (web/fins.js): count them
// so a host can tell a brace from a fin.
const swayTris = pieces.filter((p) => p.kind === 'sway')
.reduce((n, p) => n + p.ranges.reduce((m, [a, b]) => m + b - a, 0), 0);
return {
triangles,
// seated = input + offset (so the caller maps fins back with input = seated - offset)
offset: { x: result.offset.x - shift.x, y: result.offset.y - shift.y, z: result.offset.z - shift.z },
pieces,
overFaces: Int32Array.from(overFaces),
smallFaces: Int32Array.from(smallFaces),
stats: {
overhangRegions: result.regions.length,
// The soup is fins | sway braces | pad, in these counts.
finTriangles: fin.length / 9 - swayTris,
swayTriangles: swayTris,
padTriangles: pad.length / 9,
// Tined fins (walls with tines); plain walls are `props`. Sway braces are neither.
braces: built.braceCount ?? 0,
// plain walls (no tines): every wall when Tines is off. walls = braces + props,
// as the site's readout counts them
props: built.propCount ?? 0,
tines: built.tines ?? 0,
// Sway braces are counted apart from the fins: they hold a tall part's
// sides rather than an overhang, so a readout that merged them would
// claim overhangs were served that nothing is under. `swayReason` says
// why none were placed, for a host that asked for them and got none.
swayBraces: built.sway?.count ?? 0,
swayTines: built.sway?.tines ?? 0,
swaySkipped: built.sway?.skipped ?? 0,
swayReason: built.sway?.reason ?? null,
unserved: built.unserved ?? null,
// pieces that start in mid-air (see overhangs.js floatingPieces), with the
// drop of the first: the plugins' readouts say so, as the site's does
floating: built.floating?.length ?? 0,
floatingDrop: built.floating?.[0]?.drop ?? 0,
},
};
}
// web/fins.js tags each fin with its runs of `built.triangles` (triRanges), counted
// in that array's elements: points ([x,y,z] triples) or, on a flat path, numbers.
// Turn them into triangle ranges of the flattened soup, then add the pad after them.
function piecesOf(built, finTris, padTris) {
const per = typeof built.triangles?.[0] === 'number' ? 9 : 3;
const pieces = (built.fins ?? []).map((f) => ({
id: String(f.id), kind: f.kind,
ranges: f.triRanges.map(([a, b]) => [a / per, b / per]),
}));
if (padTris) pieces.push({ id: 'pad', kind: 'pad', ranges: [[finTris, finTris + padTris]] });
return pieces;
}