mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-19 02:10:52 -05:00
1023 lines
47 KiB
JavaScript
1023 lines
47 KiB
JavaScript
/**
|
|
* 3MF writer (core spec, 2015/02 namespace).
|
|
*
|
|
* Why 3MF alongside the STL: it carries the two things a raw STL cannot, both
|
|
* of which matter to this tool specifically --
|
|
* 1. UNITS. STL is unitless, so a slicer has to guess millimeters; a mis-guess
|
|
* is the classic "my part imported at 1/25 scale" bug. 3MF states mm.
|
|
* 2. SEPARATE BODIES, two ways (opts.separate):
|
|
* - LOCKED (default here; the CLI and plugins): the part and the fins are
|
|
* two meshes assembled by <components> into ONE build item, so they stay
|
|
* locked in place. But a slicer unions the parts of one object, so it runs
|
|
* one perimeter through part and tine -- the tine welds (GitHub #38).
|
|
* - SEPARATE (the site's default 3MF): two build items, two objects, the
|
|
* way Clough42 splits his support ("Split to objects"). Each keeps its own
|
|
* perimeter and a tine only touches the part: a cleaner release (the grip
|
|
* coupon's split print, prototype/calibration/README.md). Both items carry
|
|
* the same transform, so they open in register (Arrange can pull them
|
|
* apart; the export's tooltip says so). With the interface crest on
|
|
* (GitHub #21) the supports object is an assembly of two parts, the body
|
|
* and "<name> interface" (the walls' tops and the tines), so a toolchanger
|
|
* can give the interface its own filament.
|
|
*
|
|
* We do NOT embed slicer-specific print profiles (Bambu/Orca bind "supports off"
|
|
* to a full printer-specific project config, which breaks across printers and
|
|
* slicers). Bambu and Prusa factory profiles default to no supports, and the
|
|
* fins are ordinary model geometry -- so the exported file prints as intended
|
|
* without reaching into any slicer's settings.
|
|
*
|
|
* Geometry in, same as the STL path: flat arrays of [x,y,z], three vertices per
|
|
* triangle, already in print space (oriented, seated on the plate).
|
|
*/
|
|
|
|
import { zipStore, unzip } from './zip.js';
|
|
|
|
const NS_CORE = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
|
const REL_3DMODEL = 'http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel';
|
|
const CT_MODEL = 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml';
|
|
const CT_RELS = 'application/vnd.openxmlformats-package.relationships+xml';
|
|
|
|
// Trim a coordinate to a compact decimal string. 6 decimals is well under the
|
|
// ~1um that matters for a print and keeps the model file small.
|
|
function fmt(n) {
|
|
if (!Number.isFinite(n)) return '0';
|
|
let s = n.toFixed(6);
|
|
if (s.indexOf('.') >= 0) s = s.replace(/0+$/, '').replace(/\.$/, '');
|
|
return s === '-0' ? '0' : s;
|
|
}
|
|
|
|
/**
|
|
* Build an indexed <mesh> from a flat triangle-soup array. Vertices that share
|
|
* exact coordinates are merged (they do, because every vertex is produced by the
|
|
* identical transform of the same source point), which restores shared topology
|
|
* and shrinks the file; anything that doesn't merge is left as-is and slices fine.
|
|
*/
|
|
function meshXML(tris, triAttrs = null, kept = null) {
|
|
const index = new Map();
|
|
const verts = [];
|
|
const faces = [];
|
|
for (let t = 0; t < tris.length; t += 3) {
|
|
const idx = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
const p = tris[t + i];
|
|
const key = `${p[0]},${p[1]},${p[2]}`;
|
|
let vi = index.get(key);
|
|
if (vi === undefined) {
|
|
vi = verts.length;
|
|
index.set(key, vi);
|
|
verts.push(p);
|
|
}
|
|
idx.push(vi);
|
|
}
|
|
// Drop any triangle that collapsed to a line/point after the merge; a
|
|
// degenerate face is invalid 3MF and some readers reject the whole model.
|
|
if (idx[0] !== idx[1] && idx[1] !== idx[2] && idx[0] !== idx[2]) {
|
|
if (triAttrs) idx.push(triAttrs(t / 3));
|
|
faces.push(idx);
|
|
kept?.push(t / 3);
|
|
}
|
|
}
|
|
|
|
const v = new Array(verts.length);
|
|
for (let i = 0; i < verts.length; i++) {
|
|
const p = verts[i];
|
|
v[i] = `<vertex x="${fmt(p[0])}" y="${fmt(p[1])}" z="${fmt(p[2])}"/>`;
|
|
}
|
|
const f = new Array(faces.length);
|
|
for (let i = 0; i < faces.length; i++) {
|
|
const t = faces[i];
|
|
f[i] = `<triangle v1="${t[0]}" v2="${t[1]}" v3="${t[2]}"${t[3] || ''}/>`;
|
|
}
|
|
return `<mesh><vertices>${v.join('')}</vertices><triangles>${f.join('')}</triangles></mesh>`;
|
|
}
|
|
|
|
const esc = (s) => String(s).replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
|
|
|
/**
|
|
* A part's colours (GitHub #236), so a painted part keeps them through the export.
|
|
* PartColors = { paint, rgb }, both sparse arrays by triangle index (a hole is an
|
|
* uncoloured triangle):
|
|
* paint[i] { name, v }: a slicer's painted-colour attribute, verbatim -- Bambu
|
|
* Studio / OrcaSlicer write paint_color, PrusaSlicer its own
|
|
* slic3rpe:mmu_segmentation. Each reads only its own name, so it goes
|
|
* back out under the name it came in with.
|
|
* rgb[i] '#RRGGBB[AA]': the core spec's colour (a <basematerials> or
|
|
* <m:colorgroup> entry), written back as one <basematerials> table.
|
|
* The fins stay uncoloured: they are their own object, on whatever filament the
|
|
* slicer gives them.
|
|
*/
|
|
export const PAINT_ATTRS = ['paint_color', 'slic3rpe:mmu_segmentation'];
|
|
const NS_SLIC3RPE = 'http://schemas.slic3r.org/3mf/2017/06';
|
|
const MATERIALS_ID = 5; // past the objects' ids 1-4 (one id space per file)
|
|
|
|
/** Several objects' PartColors as one, in the order their triangles are merged;
|
|
* null when none of them has any. */
|
|
export function mergeColors(objs) {
|
|
if (!objs.some((o) => o.colors)) return null;
|
|
const all = { paint: [], rgb: [] };
|
|
let off = 0;
|
|
for (const o of objs) {
|
|
if (o.colors) {
|
|
o.colors.paint.forEach((p, i) => { all.paint[off + i] = p; });
|
|
o.colors.rgb.forEach((c, i) => { all.rgb[off + i] = c; });
|
|
}
|
|
off += o.positions.length / 9;
|
|
}
|
|
return all;
|
|
}
|
|
|
|
/** The part's colours as a <basematerials> table (resource `id`) + a per-triangle
|
|
* attribute string. */
|
|
function colorXML(colors, id = MATERIALS_ID) {
|
|
if (!colors) return { table: '', pid: '', triAttrs: null };
|
|
const palette = [...new Set(colors.rgb.filter(Boolean))];
|
|
const at = new Map(palette.map((c, i) => [c, i]));
|
|
return {
|
|
table: palette.length ? `<basematerials id="${id}">` +
|
|
palette.map((c, i) => `<base name="Color ${i + 1}" displaycolor="${esc(c)}"/>`).join('') +
|
|
'</basematerials>' : '',
|
|
pid: palette.length ? ` pid="${id}" pindex="0"` : '',
|
|
triAttrs: (i) => {
|
|
const c = colors.rgb[i], p = colors.paint[i];
|
|
return (c ? ` p1="${at.get(c)}"` : '') + (p ? ` ${p.name}="${esc(p.v)}"` : '');
|
|
},
|
|
};
|
|
}
|
|
|
|
// Where a SEPARATE export opens: two objects keep their 3MF positions (a single one
|
|
// gets centred by the slicer), and print space is centred on the origin, so the pair
|
|
// would sit half off the bed. Shift both by one transform: the footprint's centre to
|
|
// (90, 90) -- on any bed 180 mm or bigger -- but never closer than 5 mm to the bed's
|
|
// 0 edge, so a footprint wider than 170 mm still starts on the bed.
|
|
export const SEPARATE_CENTRE = 90;
|
|
function bedShift(lists) {
|
|
let lx = Infinity, ly = Infinity, hx = -Infinity, hy = -Infinity;
|
|
for (const tris of lists) {
|
|
for (const p of tris) {
|
|
if (p[0] < lx) lx = p[0]; if (p[0] > hx) hx = p[0];
|
|
if (p[1] < ly) ly = p[1]; if (p[1] > hy) hy = p[1];
|
|
}
|
|
}
|
|
if (!Number.isFinite(lx)) return [0, 0];
|
|
return [Math.max(SEPARATE_CENTRE - (lx + hx) / 2, 5 - lx), Math.max(SEPARATE_CENTRE - (ly + hy) / 2, 5 - ly)];
|
|
}
|
|
|
|
/**
|
|
* The model file for a plate's `models` (writePlate3MF). Each model's ids are a
|
|
* block of their own past the one before: the first keeps the ids a one-model file
|
|
* always had (1 the part, 2-4 the supports, MATERIALS_ID its colours, then its
|
|
* slicer parts), so a one-model file is what it always was. Every item of a plate
|
|
* of several shares one bed shift, so the models keep their places to each other.
|
|
* @returns { xml, configs }: configs the slicer config files (extrasXML), each
|
|
* written once with an entry for every model that has slicer parts
|
|
*/
|
|
function modelXML(models, title, separate) {
|
|
const tables = [], objects = [], items = [], footprint = [];
|
|
const cfg = { prusa2: [], bambu: [], prusa3: [] };
|
|
let base = 0, prusa2 = false, slic3rpe = false;
|
|
for (const m of models) {
|
|
const id = (k) => base + k;
|
|
const name = esc(m.name);
|
|
const col = colorXML(m.colors, id(MATERIALS_ID));
|
|
const iface = m.iface ?? [];
|
|
let finTris = m.finTris;
|
|
// a negative part cuts every part of its object: only apart from the supports
|
|
// can it ride along without cutting the walls built in its hole
|
|
const slicer = separate && m.extras?.length ? extrasXML(m.partTris, m.extras, m.name, col, base) : null;
|
|
tables.push(col.table);
|
|
objects.push(slicer?.prusa2 ?? `<object id="${id(1)}" type="model" name="${name}"${col.pid}>${meshXML(m.partTris, col.triAttrs)}</object>`);
|
|
// the part with its slicer parts is an assembly of them (extrasXML)
|
|
if (slicer?.objects) objects.push(...slicer.objects);
|
|
if (slicer?.prusa2) { prusa2 = true; cfg.prusa2.push(slicer.config); }
|
|
else if (slicer) { cfg.bambu.push(slicer.bambu); cfg.prusa3.push(slicer.prusa3); }
|
|
if (m.colors?.paint.some((p) => p.name.startsWith('slic3rpe:'))) slic3rpe = true;
|
|
const partId = slicer?.root ?? id(1);
|
|
let own = [partId];
|
|
// the interface crest is its own object only in the separate form; locked, it
|
|
// is just more of the supports
|
|
if (iface.length && !separate) finTris = [...finTris, ...iface];
|
|
|
|
if (finTris && finTris.length) {
|
|
objects.push(`<object id="${id(2)}" type="model" name="${name} supports">${meshXML(finTris)}</object>`);
|
|
if (separate) {
|
|
// two objects (three with the interface crest), one shared transform: in
|
|
// register, sliced apart
|
|
own = [partId, id(2)];
|
|
if (iface.length) {
|
|
// The crest is a PART of the supports object, not an object of its own: a
|
|
// slicer drops every object to the plate or refuses it (Orca: "empty first
|
|
// layer"), and the crest starts in mid-air. As a second component of the
|
|
// supports it stands on the plate with them, and takes its own filament
|
|
// per part.
|
|
objects.push(`<object id="${id(3)}" type="model" name="${name} interface">${meshXML(iface)}</object>`,
|
|
`<object id="${id(4)}" type="model" name="${name} supports"><components>` +
|
|
`<component objectid="${id(2)}"/><component objectid="${id(3)}"/></components></object>`);
|
|
own = [partId, id(4)];
|
|
}
|
|
} else {
|
|
// An assembly object so the part and fins import as one locked unit while
|
|
// remaining two distinct meshes.
|
|
objects.push(
|
|
`<object id="${id(3)}" type="model" name="${name}"><components>` +
|
|
`<component objectid="${id(1)}"/><component objectid="${id(2)}"/></components></object>`);
|
|
own = [id(3)];
|
|
}
|
|
}
|
|
// the slicer's parts too: a modifier reaching past the part stays on the bed
|
|
footprint.push(m.partTris, finTris ?? [], slicer?.tris ?? []);
|
|
items.push(...own);
|
|
base = slicer?.root ?? id(MATERIALS_ID);
|
|
}
|
|
|
|
// One model in the separate form, with supports, or several in either: the items
|
|
// keep their 3MF positions (a single object gets centred by the slicer)
|
|
let t = '';
|
|
if (models.length > 1 || (separate && models.some((m) => m.finTris?.length))) {
|
|
const [tx, ty] = bedShift(footprint);
|
|
t = ` transform="1 0 0 0 1 0 0 0 1 ${fmt(tx)} ${fmt(ty)} 0"`;
|
|
}
|
|
const build = items.map((i) => `<item objectid="${i}"${t}/>`).join('');
|
|
|
|
const ns = prusa2 || slic3rpe ? ` xmlns:slic3rpe="${NS_SLIC3RPE}"` : '';
|
|
const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
`<model unit="millimeter" xml:lang="en-US" xmlns="${NS_CORE}"${ns}>` +
|
|
'<metadata name="Application">Support Fins</metadata>' +
|
|
(prusa2 ? '<metadata name="slic3rpe:Version3mf">1</metadata>' : '') +
|
|
`<metadata name="Title">${esc(title)}</metadata>` +
|
|
`<resources>${tables.join('')}${objects.join('')}</resources>` +
|
|
`<build>${build}</build></model>`;
|
|
const configs = [];
|
|
if (cfg.prusa2.length) {
|
|
configs.push({ name: 'Metadata/Slic3r_PE_model.config', data: XML_HEAD + `<config>${cfg.prusa2.join('')}</config>` });
|
|
}
|
|
if (cfg.bambu.length) {
|
|
configs.push({ name: 'Metadata/model_settings.config', data: XML_HEAD + `<config>${cfg.bambu.join('')}</config>` },
|
|
{ name: 'Metadata/PrusaSlicer3_project.json', data: JSON.stringify({ objects: cfg.prusa3 }) });
|
|
}
|
|
return { xml, configs };
|
|
}
|
|
|
|
const CONTENT_TYPES = '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
`<Default Extension="rels" ContentType="${CT_RELS}"/>` +
|
|
`<Default Extension="model" ContentType="${CT_MODEL}"/>` +
|
|
'<Default Extension="json" ContentType="application/json"/>' +
|
|
'<Default Extension="config" ContentType="application/xml"/></Types>';
|
|
|
|
/**
|
|
* The site's own state, carried beside the model so a 3MF it exported can be
|
|
* opened again with the supports the user placed by hand (#214): the drawn walls
|
|
* as points, the fin mode and the settings. A slicer ignores an extra part in the
|
|
* package; a re-save in one drops it, and the file then opens as a plain part.
|
|
*/
|
|
export const SESSION_PART = 'Metadata/support_fins.json';
|
|
|
|
const ROOT_RELS = '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
`<Relationship Id="rel0" Target="/3D/3dmodel.model" Type="${REL_3DMODEL}"/></Relationships>`;
|
|
|
|
/**
|
|
* @param partTris the model geometry, print space
|
|
* @param finTris the fins + pad, print space (may be empty)
|
|
* @param name written as the model Title (and the objects' names)
|
|
* @param opts { separate, session }: separate true = part and fins as two
|
|
* objects (see the header), default false = one locked object;
|
|
* session = the site's state, written to SESSION_PART
|
|
* { iface }: the interface crest's triangles (prop/crest.js),
|
|
* written separate as a second part of the supports object,
|
|
* "<name> interface", for the slicer to give a second filament
|
|
* { colors }: the part's PartColors, by triangle of partTris
|
|
* { extras }: a slicer's parts to write back beside the part
|
|
* (readThreeMF's, with `tris` in print space; extrasXML), in
|
|
* the separate form only
|
|
* @returns Blob a .3mf package
|
|
*/
|
|
export function writeThreeMF(partTris, finTris, name = 'Support Fins',
|
|
{ separate = false, session = null, iface = [], colors = null, extras = [] } = {}) {
|
|
return writePlate3MF([{ partTris, finTris, name, iface, colors, extras }], { title: name, separate, session });
|
|
}
|
|
|
|
/**
|
|
* Several models on one plate in one 3MF: `models` is a list of { partTris, finTris,
|
|
* name, iface, colors, extras }, as writeThreeMF takes one, each already where it
|
|
* stands on the plate (print space). Each keeps its own objects -- the part, its
|
|
* supports, its slicer parts -- and every item one shared bed shift: the arrangement opens with its spacing kept, its footprint
|
|
* centred on the bed as a one-model file's is (bedShift).
|
|
* @param opts { title, separate, session }: as writeThreeMF's
|
|
* @returns Blob a .3mf package
|
|
*/
|
|
export function writePlate3MF(models, { title = models[0]?.name ?? 'Support Fins', separate = false, session = null } = {}) {
|
|
const { xml, configs } = modelXML(models, title, separate);
|
|
const entries = [
|
|
{ name: '[Content_Types].xml', data: CONTENT_TYPES },
|
|
{ name: '_rels/.rels', data: ROOT_RELS },
|
|
{ name: '3D/3dmodel.model', data: xml },
|
|
...configs,
|
|
];
|
|
if (session) entries.push({ name: SESSION_PART, data: JSON.stringify(session) });
|
|
return zipStore(entries);
|
|
}
|
|
|
|
/**
|
|
* A slicer's non-printing parts written back (GitHub #104), each in the same
|
|
* kind it came in as, its settings verbatim (PLACEMENT_KEY's note), its mesh in
|
|
* print space beside the part's. Two layouts, by where they came from:
|
|
* - Bambu Studio, OrcaSlicer, PrusaSlicer 3: the part object is an assembly,
|
|
* one component per part, and BOTH configs name them -- each slicer reads
|
|
* its own (a Bambu file opened in PrusaSlicer 3 keeps its negative parts;
|
|
* its modifiers come without settings, which are in Bambu's own words).
|
|
* Neither config is read for a file whose Application is the slicer's own
|
|
* but has no printer profile beside it (PrusaSlicer 3 refuses it, Bambu and
|
|
* Orca leave the parts solid), so the Application stays "Support Fins".
|
|
* - PrusaSlicer 2: one mesh holding every part, each a triangle range in
|
|
* Slic3r_PE_model.config, the layout PrusaSlicer 2 writes and reads.
|
|
* The part keeps its colours; the slicer parts are uncoloured. Ids, past the
|
|
* model's `base` (modelXML): 1 the part, 2-4 the supports, MATERIALS_ID the
|
|
* colours, then the slicer parts, then their assembly.
|
|
* @returns { root, objects, tris, bambu, prusa3 } for the assembly, or { prusa2,
|
|
* tris, config }; `tris` every slicer part's, for the bed shift; bambu / prusa3 /
|
|
* config this model's entry in its slicer's config, which modelXML writes once
|
|
* for every model of the plate
|
|
*/
|
|
const BAMBU_SUBTYPE = { negative: 'negative_part', modifier: 'modifier_part', blocker: 'support_blocker', enforcer: 'support_enforcer' };
|
|
const PRUSA_TYPE = { negative: 'NegativeVolume', modifier: 'ParameterModifier', blocker: 'SupportBlocker', enforcer: 'SupportEnforcer' };
|
|
const XML_HEAD = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
const metaXML = (pairs, type = '') =>
|
|
pairs.map(([k, v]) => `<metadata${type} key="${esc(k)}" value="${esc(v)}"/>`).join('');
|
|
|
|
function extrasXML(partTris, extras, title, col, base = 0) {
|
|
const name = esc(title);
|
|
const settingsOf = (e, from) => e.settings?.[from] ?? null;
|
|
if (extras.some((e) => e.settings?.prusa2)) {
|
|
const kept = [];
|
|
const all = [...partTris];
|
|
const ends = [partTris.length / 3];
|
|
for (const e of extras) { all.push(...e.tris); ends.push(all.length / 3); }
|
|
const mesh = meshXML(all, (i) => (i < ends[0] && col.triAttrs ? col.triAttrs(i) : ''), kept);
|
|
// the ranges are of the triangles WRITTEN (meshXML drops degenerate ones)
|
|
const firsts = ends.map((end) => kept.findIndex((t) => t >= end)).map((i) => (i < 0 ? kept.length : i));
|
|
const vols = [{ kind: null, name: title, settings: [] }, ...extras].map((e, k) => {
|
|
const first = k ? firsts[k - 1] : 0, last = firsts[k] - 1;
|
|
if (last < first) return '';
|
|
const pairs = [['name', e.name || title], ['volume_type', e.kind ? PRUSA_TYPE[e.kind] : 'ModelPart'],
|
|
...(settingsOf(e, 'prusa2') ?? [])];
|
|
return `<volume firstid="${first}" lastid="${last}">${metaXML(pairs, ' type="volume"')}</volume>`;
|
|
}).join('');
|
|
return {
|
|
tris: extras.flatMap((e) => e.tris),
|
|
prusa2: `<object id="${base + 1}" type="model" name="${name}"${col.pid}>${mesh}</object>`,
|
|
config: `<object id="${base + 1}" instances_count="1">${metaXML([['name', title]], ' type="object"')}${vols}</object>`,
|
|
};
|
|
}
|
|
|
|
// past the model's part, supports (base + 1-4) and colour table (base + MATERIALS_ID)
|
|
const ids = extras.map((_, k) => base + MATERIALS_ID + 1 + k);
|
|
const root = base + MATERIALS_ID + 1 + extras.length;
|
|
const objects = extras.map((e, k) =>
|
|
`<object id="${ids[k]}" type="model" name="${esc(e.name || e.kind)}">${meshXML(e.tris)}</object>`);
|
|
objects.push(`<object id="${root}" type="model" name="${name}"><components><component objectid="${base + 1}"/>` +
|
|
ids.map((id) => `<component objectid="${id}"/>`).join('') + '</components></object>');
|
|
const bambu = `<object id="${root}">${metaXML([['name', title]])}` +
|
|
`<part id="${base + 1}" subtype="normal_part">${metaXML([['name', title]])}</part>` +
|
|
extras.map((e, k) => `<part id="${ids[k]}" subtype="${BAMBU_SUBTYPE[e.kind]}">` +
|
|
metaXML([['name', e.name || e.kind], ...(settingsOf(e, 'bambu') ?? [])]) + '</part>').join('') +
|
|
'</object>';
|
|
// PrusaSlicer 3 asserts on a volume without its volume_settings object
|
|
const prusa3 = { id: root, object_settings: {}, volumes: [
|
|
{ id: base + 1, type: 'ModelPart', volume_settings: {} },
|
|
...extras.map((e, k) => ({ id: ids[k], type: PRUSA_TYPE[e.kind], volume_settings: settingsOf(e, 'prusa3') ?? {} })),
|
|
] };
|
|
return { root, objects, tris: extras.flatMap((e) => e.tris), bambu, prusa3 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 3MF reader
|
|
|
|
/**
|
|
* Reading a 3MF back in. A user who models in Fusion, Bambu Studio or FreeCAD
|
|
* has a .3mf in hand, not an STL, and telling them to go re-export is the wrong
|
|
* answer -- especially since a 3MF is the better input: it states its units, so
|
|
* the 1/25-scale guess never happens on the way in either.
|
|
*
|
|
* Parsed with a small tag scanner rather than DOMParser, for two reasons: the
|
|
* test suite runs outside a browser (no DOM), and a scanner cannot be talked
|
|
* into resolving an external entity from a file a stranger sent us.
|
|
*
|
|
* Four things here are easy to get wrong and are what separate "works on our
|
|
* own export" from "works on a real Bambu / Orca / MakerWorld file":
|
|
* 1. UNITS. The unit attribute is authoritative and is not always millimetre
|
|
* (inch and centimetre both turn up). Everything downstream is mm.
|
|
* 2. TRANSFORMS. Geometry lives in object space; a <build><item> and every
|
|
* <component> may carry a matrix, and they compose. Ignoring them is the
|
|
* "part imports offset from the plate / mirrored" bug.
|
|
* 3. THE PRODUCTION EXTENSION. Bambu Studio, OrcaSlicer and everything on
|
|
* MakerWorld/Printables split each object into its own part inside the zip
|
|
* (3D/Objects/object_N.model), referenced from the root 3dmodel.model by a
|
|
* <component p:path="..."/>. A reader that only parses the root part throws
|
|
* on these -- which is most real multi-object files (see issue #14).
|
|
* 4. PER-FILE ID SCOPING. Object ids are scoped to the part file they live in,
|
|
* NOT global -- Bambu reuses id="1" in every object_N.model. Resolving a
|
|
* component's objectid in one global id->object map (as three.js's own
|
|
* ThreeMFLoader does) silently assembles the WRONG geometry when two parts
|
|
* collide on an id. So an object is keyed by (partName, id), never id alone.
|
|
*
|
|
* A plate can hold several objects; this reader returns them SEPARATELY (each
|
|
* <build><item> is one entry in `objects`) so the caller can let the user pick
|
|
* which to fin, and also a merged `positions` for the simple single-object path.
|
|
*/
|
|
|
|
// 3MF unit vocabulary -> millimetres.
|
|
const UNIT_MM = {
|
|
micron: 0.001, millimeter: 1, centimeter: 10, inch: 25.4, foot: 304.8, meter: 1000,
|
|
};
|
|
|
|
/**
|
|
* Walk XML, calling onOpen(name, attrs, selfClosing) and onClose(name).
|
|
* Attribute parsing honours quotes, so a value containing '>' (legal, and Bambu
|
|
* writes object names verbatim) does not cut the tag short.
|
|
*/
|
|
function scanXML(xml, onOpen, onClose) {
|
|
let i = 0;
|
|
const n = xml.length;
|
|
while (i < n) {
|
|
const lt = xml.indexOf('<', i);
|
|
if (lt < 0) break;
|
|
// Declarations, comments, CDATA: skip wholesale, none carry geometry.
|
|
if (xml.startsWith('<!--', lt)) { i = xml.indexOf('-->', lt); i = i < 0 ? n : i + 3; continue; }
|
|
if (xml.startsWith('<![CDATA[', lt)) { i = xml.indexOf(']]>', lt); i = i < 0 ? n : i + 3; continue; }
|
|
if (xml.startsWith('<?', lt) || xml.startsWith('<!', lt)) {
|
|
i = xml.indexOf('>', lt); i = i < 0 ? n : i + 1; continue;
|
|
}
|
|
if (xml[lt + 1] === '/') {
|
|
const gt = xml.indexOf('>', lt);
|
|
if (gt < 0) break;
|
|
onClose(xml.slice(lt + 2, gt).trim());
|
|
i = gt + 1;
|
|
continue;
|
|
}
|
|
|
|
// Element name, then attributes up to an unquoted '>' or '/>'.
|
|
let p = lt + 1;
|
|
while (p < n && !/[\s/>]/.test(xml[p])) p++;
|
|
const name = xml.slice(lt + 1, p);
|
|
const attrs = {};
|
|
let selfClosing = false;
|
|
while (p < n) {
|
|
while (p < n && /\s/.test(xml[p])) p++;
|
|
if (xml[p] === '/' && xml[p + 1] === '>') { selfClosing = true; p += 2; break; }
|
|
if (xml[p] === '>') { p++; break; }
|
|
const nameStart = p;
|
|
while (p < n && !/[\s=/>]/.test(xml[p])) p++;
|
|
const attr = xml.slice(nameStart, p);
|
|
while (p < n && /\s/.test(xml[p])) p++;
|
|
if (xml[p] !== '=') { if (attr) attrs[attr] = ''; continue; }
|
|
p++;
|
|
while (p < n && /\s/.test(xml[p])) p++;
|
|
const q = xml[p];
|
|
let value;
|
|
if (q === '"' || q === "'") {
|
|
const end = xml.indexOf(q, p + 1);
|
|
value = xml.slice(p + 1, end < 0 ? n : end);
|
|
p = end < 0 ? n : end + 1;
|
|
} else {
|
|
const start = p;
|
|
while (p < n && !/[\s/>]/.test(xml[p])) p++;
|
|
value = xml.slice(start, p);
|
|
}
|
|
attrs[attr] = value;
|
|
}
|
|
onOpen(name, attrs, selfClosing);
|
|
i = p;
|
|
}
|
|
}
|
|
|
|
// Strip any namespace prefix: a writer may emit <m:object>, and the core
|
|
// elements are unambiguous by local name.
|
|
const local = (tag) => {
|
|
const c = tag.indexOf(':');
|
|
return c < 0 ? tag : tag.slice(c + 1);
|
|
};
|
|
|
|
/**
|
|
* An attribute by LOCAL name. The production extension is conventionally the
|
|
* `p:` prefix (p:path, p:UUID), but the prefix is the file's to choose, so we
|
|
* match on the part after the colon rather than assume `p:`.
|
|
*/
|
|
function attrLocal(attrs, lname) {
|
|
if (attrs[lname] !== undefined) return attrs[lname];
|
|
for (const k in attrs) if (local(k) === lname) return attrs[k];
|
|
return undefined;
|
|
}
|
|
|
|
// A p:path / relationship Target -> a package part name (zip entries have no
|
|
// leading slash; a 3MF path is absolute from the package root).
|
|
const partName = (path) => path.replace(/^\//, '');
|
|
|
|
/**
|
|
* A 3MF matrix is 12 numbers, row-major, translation last, applied to a ROW
|
|
* vector: p' = p * M3 + t. Kept as that same flat 12 for composition.
|
|
*/
|
|
function parseMatrix(s) {
|
|
if (!s) return null;
|
|
const v = s.trim().split(/\s+/).map(Number);
|
|
if (v.length !== 12 || v.some((x) => !Number.isFinite(x))) return null;
|
|
return v;
|
|
}
|
|
|
|
/** b applied after a, as one matrix (row-vector convention). */
|
|
function compose(a, b) {
|
|
if (!a) return b;
|
|
if (!b) return a;
|
|
const m = new Array(12);
|
|
for (let r = 0; r < 3; r++) {
|
|
for (let c = 0; c < 3; c++) {
|
|
m[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];
|
|
}
|
|
}
|
|
for (let c = 0; c < 3; c++) {
|
|
m[9 + c] = a[9] * b[c] + a[10] * b[3 + c] + a[11] * b[6 + c] + b[9 + c];
|
|
}
|
|
return m;
|
|
}
|
|
|
|
/**
|
|
* Object types that are not printable solid geometry. `support` and `surface`
|
|
* are not closed bodies and `other` is explicitly non-printable, so pulling
|
|
* them in as part geometry would poison the overhang analysis. `model` and
|
|
* `solidsupport` are kept; a missing type means `model` per the spec.
|
|
*/
|
|
const SKIP_TYPES = new Set(['support', 'surface', 'other']);
|
|
|
|
/** Parse one 3D/*.model part into { objects, items, unit }. */
|
|
function parseModelXML(xml) {
|
|
const objects = new Map(); // id -> { type, name, verts, tris, components, paint, rgb }
|
|
const items = []; // { objectid, transform }
|
|
const groups = new Map(); // material/colour group id -> ['#RRGGBB', ...]
|
|
const codes = new Map(); // paint string -> its own copy, shared by every repeat
|
|
let unit = 'millimeter';
|
|
let cur = null; // object being filled
|
|
let group = null; // <basematerials>/<colorgroup> being filled
|
|
let inVertices = false;
|
|
|
|
scanXML(xml, (tag, a, selfClosing) => {
|
|
switch (local(tag)) {
|
|
case 'model':
|
|
if (a.unit) unit = a.unit;
|
|
break;
|
|
// Colour as the core spec states it (a material table and an index per
|
|
// triangle). Resources precede the objects that use them, so a triangle's
|
|
// colour resolves as it is read. Ids are per part file, like object ids.
|
|
case 'basematerials':
|
|
case 'colorgroup':
|
|
group = [];
|
|
groups.set(String(a.id), group);
|
|
if (selfClosing) group = null;
|
|
break;
|
|
case 'base':
|
|
if (group) group.push(a.displaycolor || '');
|
|
break;
|
|
case 'color':
|
|
if (group) group.push(a.color || '');
|
|
break;
|
|
case 'object':
|
|
cur = { type: a.type || 'model', name: a.name || '', verts: [], tris: [], components: [],
|
|
pid: a.pid, pindex: a.pindex, paint: null, rgb: null };
|
|
objects.set(String(a.id), cur);
|
|
if (selfClosing) cur = null;
|
|
break;
|
|
case 'vertices':
|
|
inVertices = true;
|
|
break;
|
|
case 'vertex':
|
|
// Guard on inVertices: the beam-lattice extension has its own <v> refs,
|
|
// and only <vertices> children are mesh points.
|
|
if (cur && inVertices) cur.verts.push(+a.x || 0, +a.y || 0, +a.z || 0);
|
|
break;
|
|
case 'triangle': {
|
|
if (!cur) break;
|
|
const t = cur.tris.length / 3;
|
|
cur.tris.push(+a.v1, +a.v2, +a.v3);
|
|
// A slicer's painted colours (PAINT_ATTRS), kept verbatim: the string
|
|
// subdivides THIS triangle against its own vertex order, which the whole
|
|
// import -> export path keeps, so it stays valid however the part turns.
|
|
for (const name of PAINT_ATTRS) {
|
|
const v = a[name];
|
|
if (!v) continue;
|
|
// A scanned value is a slice that keeps the WHOLE decoded model file
|
|
// alive while it lives (a V8 sliced string) -- and the colours live for
|
|
// the session. ' ' + v flattens into a fresh string; the map shares it.
|
|
let own = codes.get(v);
|
|
if (own === undefined) codes.set(v, own = { name, v: (' ' + v).slice(1) });
|
|
(cur.paint ??= [])[t] = own;
|
|
break;
|
|
}
|
|
const pid = a.pid ?? cur.pid, pi = a.p1 ?? cur.pindex;
|
|
const c = pid != null && groups.get(String(pid))?.[+(pi ?? 0)];
|
|
if (c) (cur.rgb ??= [])[t] = c;
|
|
break;
|
|
}
|
|
case 'component':
|
|
// p:path (production extension) points the objectid at a DIFFERENT part
|
|
// file; absent, the objectid resolves within this same file.
|
|
if (cur) cur.components.push({
|
|
objectid: String(a.objectid),
|
|
path: attrLocal(a, 'path') || null,
|
|
transform: parseMatrix(a.transform),
|
|
});
|
|
break;
|
|
case 'item':
|
|
items.push({ objectid: String(a.objectid), transform: parseMatrix(a.transform) });
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}, (tag) => {
|
|
const t = local(tag);
|
|
if (t === 'object') cur = null;
|
|
else if (t === 'vertices') inVertices = false;
|
|
else if (t === 'basematerials' || t === 'colorgroup') group = null;
|
|
});
|
|
|
|
return { objects, items, unit };
|
|
}
|
|
|
|
/**
|
|
* A slicer's non-printing parts (GitHub #104). Bambu Studio, OrcaSlicer and
|
|
* PrusaSlicer let a user add a negative part (subtracted from the object), a
|
|
* modifier (settings for the region it covers) or a support blocker/enforcer --
|
|
* and every one of them is an ordinary type="model" mesh in the 3D model. What
|
|
* makes it one is a slicer config beside it:
|
|
* Bambu/Orca Metadata/model_settings.config, <object id=ROOT><part id=N
|
|
* subtype="negative_part">, N being the objectid of one of the root
|
|
* object's <component>s (each part is a component);
|
|
* PrusaSlicer 3 Metadata/PrusaSlicer3_project.json, the same shape as Bambu's
|
|
* as JSON: objects[{ id: ROOT, volumes[{ id: N, type: "NegativeVolume" }] }];
|
|
* PrusaSlicer 2 Metadata/Slic3r_PE_model.config, <object id=ROOT><volume
|
|
* firstid lastid> + metadata volume_type="NegativeVolume", a range
|
|
* of the root object's OWN triangles (one mesh holds every part).
|
|
* Read as part geometry, a negative part comes in as solid -- the hole it cut is
|
|
* filled, the fins are analysed against it, and the export prints it. So these
|
|
* are kept apart from the part, by kind. A missing or unknown type is a part.
|
|
*/
|
|
const PART_KIND = {
|
|
negative_part: 'negative', modifier_part: 'modifier',
|
|
support_blocker: 'blocker', support_enforcer: 'enforcer',
|
|
NegativeVolume: 'negative', ParameterModifier: 'modifier',
|
|
SupportBlocker: 'blocker', SupportEnforcer: 'enforcer',
|
|
};
|
|
|
|
const stripExt = (s) => s.replace(/\.(stl|3mf|obj|step|stp)$/i, '');
|
|
|
|
/**
|
|
* A non-printing part's own settings ride along with it, verbatim, so the export
|
|
* can hand it back to the slicer it came from (a modifier IS its settings: the
|
|
* infill or walls it sets for its region). `settings` holds them by the config
|
|
* they are in -- each slicer has its own words for them: { bambu:
|
|
* model_settings.config's [key, value] pairs, prusa3: PrusaSlicer3_project.json's
|
|
* volume_settings object, prusa2: Slic3r_PE_model.config's [key, value] pairs },
|
|
* only the ones the file had. Where the part sits and what it is called are not
|
|
* settings: the export writes those afresh, the part in print space.
|
|
*/
|
|
const PLACEMENT_KEY = /^(name|matrix|volume_type|modifier|source_\w+)$/;
|
|
|
|
/**
|
|
* Bambu/Orca's Metadata/model_settings.config: the human object names, keyed by
|
|
* the ROOT object id (the same id the build item references), and each root
|
|
* object's non-printing parts, keyed by the part id (= the component objectid).
|
|
* Best-effort: absent config or unknown id just falls back to a generic label /
|
|
* a printing part at the call site.
|
|
* { names: id -> name, parts: rootId -> (partId -> { kind, name }) }
|
|
*/
|
|
function parseBambuConfig(configXML) {
|
|
const names = new Map(), parts = new Map();
|
|
if (!configXML) return { names, parts };
|
|
let curId = null, curPart = null;
|
|
scanXML(configXML, (tag, a, selfClosing) => {
|
|
const t = local(tag);
|
|
if (t === 'object') curId = a.id != null ? String(a.id) : null;
|
|
else if (t === 'part' && curId) {
|
|
const kind = PART_KIND[a.subtype];
|
|
curPart = kind ? { kind, name: '', settings: { bambu: [] } } : null;
|
|
if (curPart) {
|
|
if (!parts.has(curId)) parts.set(curId, new Map());
|
|
parts.get(curId).set(String(a.id), curPart);
|
|
}
|
|
if (selfClosing) curPart = null;
|
|
} else if (t === 'metadata' && curId && a.key === 'name' && a.value) {
|
|
if (curPart) curPart.name ||= stripExt(a.value);
|
|
// The object-level <metadata key="name"> comes before any <part>'s, and we
|
|
// keep only the first, so a part filename never shadows the object name.
|
|
else if (!names.has(curId)) names.set(curId, stripExt(a.value));
|
|
} else if (t === 'metadata' && curPart && a.key && !PLACEMENT_KEY.test(a.key)) {
|
|
curPart.settings.bambu.push([a.key, a.value ?? '']);
|
|
}
|
|
}, (tag) => {
|
|
const t = local(tag);
|
|
if (t === 'object') curId = null;
|
|
else if (t === 'part') curPart = null;
|
|
});
|
|
return { names, parts };
|
|
}
|
|
|
|
/**
|
|
* PrusaSlicer 3's Metadata/PrusaSlicer3_project.json: objects[{ id, volumes[{ id,
|
|
* type }] }], the same shape as Bambu's -- the object is the build item's, each
|
|
* volume one of its components. Merged into `parts` (parseBambuConfig's). The
|
|
* name is not in the JSON; emitObject takes it from the volume's own object.
|
|
*/
|
|
function addPrusa3Parts(json, parts) {
|
|
if (!json) return;
|
|
let d;
|
|
try { d = JSON.parse(json); } catch { return; }
|
|
for (const o of Array.isArray(d?.objects) ? d.objects : []) {
|
|
for (const v of Array.isArray(o?.volumes) ? o.volumes : []) {
|
|
const kind = PART_KIND[v?.type];
|
|
if (!kind || o.id == null || v.id == null) continue;
|
|
const id = String(o.id);
|
|
if (!parts.has(id)) parts.set(id, new Map());
|
|
const settings = v.volume_settings && typeof v.volume_settings === 'object' && !Array.isArray(v.volume_settings)
|
|
? v.volume_settings : {};
|
|
// our own export names a part in both configs: one part, both settings
|
|
const had = parts.get(id).get(String(v.id));
|
|
if (had) had.settings.prusa3 = settings;
|
|
else parts.get(id).set(String(v.id), { kind, name: '', settings: { prusa3: settings } });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PrusaSlicer's Metadata/Slic3r_PE_model.config: each root object's non-printing
|
|
* volumes as triangle ranges (firstid..lastid, inclusive) of its own mesh.
|
|
* rootId -> [{ first, last, kind, name }]
|
|
* Older files mark a modifier with key="modifier" value="1" and no volume_type.
|
|
*/
|
|
function parsePrusaConfig(configXML) {
|
|
const vols = new Map();
|
|
if (!configXML) return vols;
|
|
let curId = null, cur = null;
|
|
scanXML(configXML, (tag, a) => {
|
|
const t = local(tag);
|
|
if (t === 'object') curId = a.id != null ? String(a.id) : null;
|
|
else if (t === 'volume' && curId) {
|
|
cur = { first: +a.firstid, last: +a.lastid, kind: null, name: '', legacy: null, settings: [] };
|
|
if (!vols.has(curId)) vols.set(curId, []);
|
|
vols.get(curId).push(cur);
|
|
} else if (t === 'metadata' && cur) {
|
|
if (a.key === 'volume_type') cur.kind = PART_KIND[a.value] ?? null;
|
|
else if (a.key === 'modifier' && a.value === '1') cur.legacy = 'modifier';
|
|
else if (a.key === 'name' && a.value) cur.name = stripExt(a.value);
|
|
else if (a.key && !PLACEMENT_KEY.test(a.key)) cur.settings.push([a.key, a.value ?? '']);
|
|
}
|
|
}, (tag) => {
|
|
const t = local(tag);
|
|
if (t === 'object') curId = null;
|
|
else if (t === 'volume') cur = null;
|
|
});
|
|
for (const [id, list] of vols) {
|
|
const kept = list
|
|
.map((v) => ({ first: v.first, last: v.last, kind: v.kind ?? v.legacy, name: v.name, settings: { prusa2: v.settings } }))
|
|
.filter((v) => v.kind && Number.isFinite(v.first) && Number.isFinite(v.last));
|
|
if (kept.length) vols.set(id, kept); else vols.delete(id);
|
|
}
|
|
return vols;
|
|
}
|
|
|
|
/**
|
|
* Flatten one object (mesh or assembly) into `out` as a triangle soup, applying
|
|
* `m`. `getPart(name)` returns a parsed part's { objects } (lazily, cached);
|
|
* `path` is the part file the id lives in. A component may point at a DIFFERENT
|
|
* part via p:path, so id lookup and cycle detection are both keyed by (path,id),
|
|
* never id alone -- two Bambu parts legally reuse id="1".
|
|
*
|
|
* `seen` breaks a component cycle: a malformed file can reference itself, the
|
|
* spec forbids it, so bailing is correct rather than recursing forever.
|
|
*
|
|
* `sink(role)` is where a triangle goes: role null is the part, any other is one
|
|
* non-printing part's { kind, name } (PART_KIND), its own soup. `role` is the
|
|
* one this subtree inherits. `roles` is what the slicer configs say: `parts`,
|
|
* only at a BUILD ITEM's object, names its components by objectid (Bambu/Orca,
|
|
* PrusaSlicer 3); `ranges(path, id)` names a mesh's triangles by index at any
|
|
* depth (PrusaSlicer 2: a second instance is a wrapper object pointing at the
|
|
* first one's mesh, and the config names only the mesh's object).
|
|
* A part used twice is two extras, each its own shell, as the slicer has them.
|
|
*/
|
|
// a config's role, copied per use: each occurrence gets its own soup (and name)
|
|
const named = (r) => r && { ...r };
|
|
|
|
function emitObject(getPart, path, id, m, sink, seen, stats, role = null, roles = null) {
|
|
const part = getPart(path);
|
|
const obj = part && part.objects.get(id);
|
|
if (!obj) { stats.missing++; return; } // dangling ref: a broken file
|
|
const key = `${path} |