mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-19 02:10:52 -05:00
424 lines
17 KiB
JavaScript
424 lines
17 KiB
JavaScript
import { t, tn } from './i18n.js';
|
||
/**
|
||
* File import: sniff the format, read STL / 3MF / STEP / OBJ into one flat position
|
||
* array, let the user pick objects from a multi-object file, and hand the
|
||
* geometry to setPart(). Also the file input and drag-and-drop.
|
||
*/
|
||
import * as THREE from 'three';
|
||
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||
import { readThreeMF, mergeColors } from '../threemf.js';
|
||
import { cutNegatives } from '../cut.js';
|
||
import { isStep, readStep, warmStep } from '../step.js';
|
||
import { isObj, readOBJ } from '../obj.js';
|
||
import { el } from './dom.js';
|
||
import { part, setPart } from './part.js';
|
||
import { partObjectOf, restoreSession } from './session.js';
|
||
|
||
export let importNote = '';
|
||
export let lastImportMeta = null;
|
||
// A session saved in the 3MF just read (ui/session.js), restored once setPart has
|
||
// the part: taken by afterLoad, so it never reaches a later file.
|
||
let pendingSession = null;
|
||
|
||
export function renderImportNote() {
|
||
if (!lastImportMeta) return '';
|
||
const m = lastImportMeta;
|
||
const notes = [];
|
||
if (m.type === '3MF') {
|
||
if (m.objectsLength > 1) {
|
||
notes.push(m.chosenLength === 1
|
||
? t('imported “{name}” of {total} objects', { name: m.chosenName, total: m.objectsLength })
|
||
: t('merged {n} of {total} objects into one part', { n: m.chosenLength, total: m.objectsLength }));
|
||
} else if (m.meshes > 1) {
|
||
notes.push(t('merged {n} bodies into one part', { n: m.meshes }));
|
||
}
|
||
if (m.skipped) notes.push(tn('ignored {n} support/non-printable body', 'ignored {n} support/non-printable bodies', m.skipped));
|
||
// a slicer's negative parts are cut out, so the fins are for what prints; they
|
||
// and its modifiers / support blockers go back out in the 3MF export (#104)
|
||
if (m.cut) notes.push(tn('cut out {n} negative part', 'cut out {n} negative parts', m.cut));
|
||
// the export carries the negative parts too, and a slicer draws one over the
|
||
// walls standing in its cut until it slices (#104)
|
||
if (m.cut) notes.push(t('in your slicer, walls inside a negative part show once you slice'));
|
||
if (m.why === 'open') notes.push(t('negative parts not cut: the part is not a closed solid, so its fins are for the uncut shape'));
|
||
else if (m.why === 'whole') notes.push(t('negative parts not cut: they would remove the whole part'));
|
||
else if (m.why === 'load') notes.push(t('negative parts not cut: the cutter did not load (try opening the file again)'));
|
||
else if (m.uncut) notes.push(tn('{n} negative part not cut: it is not a closed solid',
|
||
'{n} negative parts not cut: they are not closed solids', m.uncut));
|
||
if (m.extras) notes.push(tn('kept {n} modifier/support blocker for the 3MF export',
|
||
'kept {n} modifiers/support blockers for the 3MF export', m.extras));
|
||
if (m.unit && m.unit !== 'millimeter') notes.push(t('converted from {unit} to mm', { unit: m.unit }));
|
||
if (m.session === 'restored') {
|
||
notes.push(m.restored
|
||
? tn('restored {n} hand-placed support and the settings', 'restored {n} hand-placed supports and the settings', m.restored)
|
||
: t('restored the settings'));
|
||
} else if (m.session === 'changed') {
|
||
notes.push(t('saved supports not loaded: the part changed since it was exported'));
|
||
} else if (m.session === 'unreadable') {
|
||
notes.push(t('saved supports not loaded: the saved session is damaged'));
|
||
}
|
||
return notes.length ? `3MF: ${notes.join('; ')}.` : '';
|
||
}
|
||
return importNote;
|
||
} // what the 3MF/STEP reader had to decide (merge, unit, skips)
|
||
|
||
/** The part was rescaled (ui/scale.js): the supports a saved session brought back
|
||
* are gone with it, so the note no longer says they were restored. */
|
||
/** The part was taken off the plate (part.js clearPart): its import note goes too. */
|
||
export function forgetImport() {
|
||
importNote = '';
|
||
lastImportMeta = null;
|
||
pendingSession = null;
|
||
el('s-import-note').textContent = '';
|
||
}
|
||
|
||
export function forgetSessionNote() {
|
||
if (!lastImportMeta?.session) return;
|
||
lastImportMeta.session = null;
|
||
lastImportMeta.restored = null;
|
||
importNote = renderImportNote();
|
||
el('s-import-note').textContent = importNote;
|
||
}
|
||
|
||
const loader = new STLLoader();
|
||
|
||
/**
|
||
* Sniff the format from the CONTENT, not the extension. A 3MF is an OPC package,
|
||
* so it opens with the ZIP magic; an STL never does. Going by bytes means a file
|
||
* saved as .stl by a slicer that actually wrote a 3MF (it happens), or a .3mf the
|
||
* user renamed, still lands in the right parser.
|
||
*/
|
||
function isZip(buffer) {
|
||
if (buffer.byteLength < 4) return false;
|
||
const b = new Uint8Array(buffer, 0, 4);
|
||
return b[0] === 0x50 && b[1] === 0x4b && b[2] === 0x03 && b[3] === 0x04;
|
||
}
|
||
|
||
/** A three.js BufferGeometry from a flat mm position array (the STL layout). */
|
||
function geometryFromPositions(positions) {
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
return geometry;
|
||
}
|
||
|
||
/** Concatenate several picked objects' position arrays into one part. */
|
||
function mergeObjectPositions(objs) {
|
||
if (objs.length === 1) return objs[0].positions;
|
||
let total = 0;
|
||
for (const o of objs) total += o.positions.length;
|
||
const all = new Float32Array(total);
|
||
let off = 0;
|
||
for (const o of objs) { all.set(o.positions, off); off += o.positions.length; }
|
||
return all;
|
||
}
|
||
|
||
/**
|
||
* A 3MF plate can hold several distinct objects (a Bambu/MakerWorld download
|
||
* usually does) and this tool fins ONE part. Show the list and resolve to the
|
||
* chosen objects, or null on cancel. Largest by triangle count is pre-selected
|
||
* (the model, not its base/skirt); checking several merges them. Esc cancels,
|
||
* Enter loads.
|
||
*/
|
||
function pickObjects(objects) {
|
||
const modal = el('picker');
|
||
const list = el('picker-list');
|
||
const hint = el('picker-hint');
|
||
const loadBtn = el('picker-load');
|
||
const cancelBtn = el('picker-cancel');
|
||
|
||
// Largest first -- but our own 3MF export's "<name> supports" object (the separate
|
||
// form, threemf.js) goes last, so re-importing an export pre-selects the part, not
|
||
// a tine-heavy support object that happens to have more triangles.
|
||
const ours = (o) => / supports$/.test(o.name ?? '') ? 1 : 0;
|
||
const order = objects.map((_, i) => i)
|
||
.sort((a, b) => ours(objects[a]) - ours(objects[b]) || objects[b].tris - objects[a].tris);
|
||
list.replaceChildren();
|
||
const boxes = [];
|
||
order.forEach((idx, rank) => {
|
||
const o = objects[idx];
|
||
const li = document.createElement('li');
|
||
const label = document.createElement('label');
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.checked = rank === 0; // largest (not our supports) pre-selected
|
||
cb.dataset.idx = String(idx);
|
||
cb.addEventListener('change', refresh);
|
||
const name = document.createElement('span');
|
||
name.className = 'pk-name';
|
||
name.textContent = o.name;
|
||
const meta = document.createElement('span');
|
||
meta.className = 'pk-meta';
|
||
const s = o.bbox.size.map((v) => Math.round(v));
|
||
meta.textContent = `${o.tris.toLocaleString()} ${t('tris')} · ${s[0]}×${s[1]}×${s[2]} mm`;
|
||
label.append(cb, name, meta);
|
||
li.append(label);
|
||
list.append(li);
|
||
boxes.push(cb);
|
||
});
|
||
|
||
const selected = () => boxes.filter((b) => b.checked).map((b) => objects[+b.dataset.idx]);
|
||
function refresh() {
|
||
const n = selected().length;
|
||
loadBtn.disabled = n === 0;
|
||
loadBtn.textContent = n > 1 ? t('Merge {n} & load', { n }) : t('Load');
|
||
hint.textContent = n > 1 ? t('{n} selected — merged into one part', { n }) : '';
|
||
}
|
||
refresh();
|
||
modal.hidden = false;
|
||
|
||
return new Promise((resolve) => {
|
||
function close(result) {
|
||
modal.hidden = true;
|
||
loadBtn.removeEventListener('click', onLoad);
|
||
cancelBtn.removeEventListener('click', onCancel);
|
||
removeEventListener('keydown', onKey);
|
||
resolve(result);
|
||
}
|
||
function onLoad() { const sel = selected(); if (sel.length) close(sel); }
|
||
function onCancel() { close(null); }
|
||
function onKey(e) {
|
||
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
||
else if (e.key === 'Enter') { e.preventDefault(); onLoad(); }
|
||
}
|
||
loadBtn.addEventListener('click', onLoad);
|
||
cancelBtn.addEventListener('click', onCancel);
|
||
addEventListener('keydown', onKey);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The last guess: an STL, or nothing we read. The picker lets any file through on
|
||
* Android (index.html #file), and STLLoader reads junk as a DataView range error
|
||
* or an empty part.
|
||
*/
|
||
function parseStl(buffer) {
|
||
let geometry = null;
|
||
try { geometry = loader.parse(buffer); } catch { /* not an STL */ }
|
||
if (!geometry?.getAttribute('position')?.count) throw new Error('not an STL, 3MF, STEP or OBJ file');
|
||
return geometry;
|
||
}
|
||
|
||
/**
|
||
* Bytes in, three.js geometry out, for any format (or null if the user cancels
|
||
* the object picker). The 3MF and STEP readers hand back the same flat mm
|
||
* position array STLLoader produces, so everything downstream (setPart, the
|
||
* weld, the whole engine) is unchanged.
|
||
*/
|
||
async function parseModel(buffer) {
|
||
importNote = '';
|
||
if (!isZip(buffer)) {
|
||
const geometry = isStep(buffer) ? await parseStep(buffer)
|
||
: isObj(buffer) ? await parseObj(buffer)
|
||
: parseStl(buffer);
|
||
// A 3MF read before this one no longer speaks for the part (a language switch
|
||
// re-renders its note); a cancelled picker keeps the part, and its note.
|
||
if (geometry) lastImportMeta = null;
|
||
return geometry;
|
||
}
|
||
|
||
const { objects, unit, skipped, session } = await readThreeMF(new Uint8Array(buffer));
|
||
|
||
// One object loads straight in; a plate of several goes to the picker so the
|
||
// user chooses which body to fin rather than us merging distinct models. A file
|
||
// the site exported (it carries a session) is the part and its supports: the part
|
||
// loads, and the supports come back from the session, not as a mesh.
|
||
const own = session ? partObjectOf(objects) : null;
|
||
let chosen = own ? [own] : objects;
|
||
if (!own && objects.length > 1) {
|
||
chosen = await pickObjects(objects);
|
||
if (!chosen) return null; // cancelled: keep the current part
|
||
}
|
||
|
||
const cuts = await cutChosen(chosen);
|
||
const geometry = geometryFromPositions(mergeObjectPositions(cuts.objects));
|
||
// the part's painted/material colours, by triangle, for the 3MF export (#236);
|
||
// the triangles keep their order all the way there
|
||
geometry.userData.colors = mergeColors(cuts.objects);
|
||
// A slicer's own parts go back out in the 3MF export, beside the part AS THE FILE
|
||
// HAD IT: the slicer makes the cut again, so moving or deleting a negative part
|
||
// there does what it says, and no paint is lost to the cut (#104).
|
||
const extras = chosen.flatMap((o) => o.extras ?? []);
|
||
if (extras.length) {
|
||
geometry.userData.slicer = { positions: mergeObjectPositions(chosen), colors: mergeColors(chosen), extras };
|
||
}
|
||
chosen = cuts.objects;
|
||
|
||
// Say what we decided for them: which/how many bodies, any support bodies left
|
||
// on the plate, and any unit conversion.
|
||
const notes = [];
|
||
if (objects.length > 1) {
|
||
notes.push(chosen.length === 1
|
||
? t('imported “{name}” of {total} objects', { name: chosen[0].name, total: objects.length })
|
||
: t('merged {n} of {total} objects into one part', { n: chosen.length, total: objects.length }));
|
||
} else if (chosen[0].meshes > 1) {
|
||
notes.push(t('merged {n} bodies into one part', { n: chosen[0].meshes }));
|
||
}
|
||
if (skipped) notes.push(tn('ignored {n} support/non-printable body', 'ignored {n} support/non-printable bodies', skipped));
|
||
if (unit && unit !== 'millimeter') notes.push(t('converted from {unit} to mm', { unit }));
|
||
lastImportMeta = {
|
||
type: '3MF',
|
||
objectsLength: objects.length,
|
||
chosenLength: chosen.length,
|
||
chosenName: chosen[0]?.name,
|
||
meshes: chosen[0]?.meshes ?? 1,
|
||
skipped,
|
||
extras: chosen.reduce((n, o) => n + (o.extras?.filter((e) => e.kind !== 'negative').length ?? 0), 0),
|
||
cut: cuts.cut,
|
||
uncut: cuts.uncut,
|
||
why: cuts.why,
|
||
unit,
|
||
session: own ? 'pending' : null,
|
||
};
|
||
pendingSession = own ? session : null;
|
||
importNote = renderImportNote();
|
||
|
||
return geometry;
|
||
}
|
||
|
||
/**
|
||
* Cut each chosen object's negative parts out of it (cut.js), so the fins are
|
||
* built for the shape that prints. Up to a few seconds on a scanned negative,
|
||
* so under the spinner; a file with none costs nothing.
|
||
*/
|
||
async function cutChosen(chosen) {
|
||
const tally = { objects: chosen, cut: 0, uncut: 0, why: null };
|
||
if (!chosen.some((o) => o.extras?.some((e) => e.kind === 'negative'))) return tally;
|
||
const spinner = el('spinner');
|
||
const label = spinner.lastChild.textContent;
|
||
spinner.lastChild.textContent = t('cutting negative parts…');
|
||
spinner.classList.add('show');
|
||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r))); // let it paint first
|
||
try {
|
||
tally.objects = [];
|
||
for (const o of chosen) {
|
||
const c = await cutNegatives(o);
|
||
tally.cut += c.cut; tally.uncut += c.uncut; tally.why ||= c.why;
|
||
tally.objects.push({ ...o, positions: c.positions, colors: c.colors });
|
||
}
|
||
} finally {
|
||
spinner.classList.remove('show');
|
||
spinner.lastChild.textContent = label;
|
||
}
|
||
return tally;
|
||
}
|
||
|
||
/**
|
||
* STEP goes through the CAD kernel (lazy: the first STEP of a session pays for
|
||
* a ~7.6 MB download), then through the same picker as a multi-object 3MF.
|
||
*/
|
||
async function parseStep(buffer) {
|
||
const spinner = el('spinner');
|
||
const label = spinner.lastChild.textContent;
|
||
spinner.lastChild.textContent = t('reading STEP…');
|
||
spinner.classList.add('show');
|
||
let objects;
|
||
try {
|
||
({ objects } = await readStep(new Uint8Array(buffer)));
|
||
} finally {
|
||
spinner.classList.remove('show');
|
||
spinner.lastChild.textContent = label;
|
||
}
|
||
|
||
let chosen = objects;
|
||
if (objects.length > 1) {
|
||
chosen = await pickObjects(objects);
|
||
if (!chosen) return null;
|
||
}
|
||
const notes = [t('tessellated at 0.01 mm')];
|
||
if (objects.length > 1) {
|
||
notes.unshift(chosen.length === 1
|
||
? t('imported “{name}” of {total} objects', { name: chosen[0].name, total: objects.length })
|
||
: t('merged {n} of {total} objects into one part', { n: chosen.length, total: objects.length }));
|
||
}
|
||
importNote = `STEP: ${notes.join('; ')}.`;
|
||
return geometryFromPositions(mergeObjectPositions(chosen));
|
||
}
|
||
|
||
/** OBJ: text, no unit (read as mm), and a file of several `o` objects goes
|
||
* through the same picker as a multi-object 3MF. */
|
||
async function parseObj(buffer) {
|
||
const { objects } = readOBJ(new Uint8Array(buffer));
|
||
let chosen = objects;
|
||
if (objects.length > 1) {
|
||
chosen = await pickObjects(objects);
|
||
if (!chosen) return null;
|
||
}
|
||
if (objects.length > 1) {
|
||
importNote = chosen.length === 1
|
||
? `OBJ: ${t('imported “{name}” of {total} objects', { name: chosen[0].name, total: objects.length })}.`
|
||
: `OBJ: ${t('merged {n} of {total} objects into one part', { n: chosen.length, total: objects.length })}.`;
|
||
}
|
||
return geometryFromPositions(mergeObjectPositions(chosen));
|
||
}
|
||
|
||
/** setPart, then the saved session if the file carried one (the import note says
|
||
* how many supports came back, or why none did). */
|
||
function afterLoad(geometry, name) {
|
||
const s = pendingSession;
|
||
pendingSession = null;
|
||
setPart(geometry, name);
|
||
if (!s || !lastImportMeta) return;
|
||
let r = { why: 'unreadable' };
|
||
try { r = restoreSession(s); } catch (err) { console.error(err); }
|
||
lastImportMeta.restored = r.n ?? null;
|
||
lastImportMeta.session = r.why ?? 'restored';
|
||
importNote = renderImportNote();
|
||
el('s-import-note').textContent = importNote; // the load's report already ran
|
||
}
|
||
|
||
async function loadFile(file) {
|
||
if (!file) return;
|
||
try {
|
||
const geometry = await parseModel(await file.arrayBuffer());
|
||
if (geometry) afterLoad(geometry, file.name);
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert(`Could not read ${file.name}:\n${err.message}`);
|
||
}
|
||
}
|
||
|
||
// Clear the input once the file is taken: otherwise choosing the SAME file again
|
||
// (after cancelling its object picker, say) changes nothing and fires no change.
|
||
el('file').addEventListener('change', (e) => {
|
||
loadFile(e.target.files[0]);
|
||
e.target.value = '';
|
||
});
|
||
// Reaching for a file is the cue to start loading the STEP kernel: it downloads
|
||
// while the user is still in the file dialog (or mid-drag), not after they drop.
|
||
el('file').addEventListener('click', warmStep);
|
||
|
||
/** Load a model that is already on the web (the sample model, a demo link). */
|
||
export async function loadURL(url) {
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||
const geometry = await parseModel(await res.arrayBuffer());
|
||
if (!geometry) return; // picker cancelled
|
||
afterLoad(geometry, url.split('/').pop());
|
||
// Drop ?stl= once it has been consumed: the path is nobody's business but the
|
||
// user's, and a stale one in the address bar is misleading after they open a
|
||
// different file.
|
||
history.replaceState(null, '', location.pathname);
|
||
}
|
||
|
||
const drop = el('drop');
|
||
let dragDepth = 0;
|
||
addEventListener('dragenter', (e) => {
|
||
e.preventDefault();
|
||
warmStep();
|
||
if (dragDepth++ === 0) drop.classList.remove('hidden'), drop.classList.add('armed');
|
||
});
|
||
addEventListener('dragover', (e) => e.preventDefault());
|
||
addEventListener('dragleave', () => {
|
||
if (--dragDepth <= 0) {
|
||
dragDepth = 0;
|
||
drop.classList.remove('armed');
|
||
if (part) drop.classList.add('hidden');
|
||
}
|
||
});
|
||
addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dragDepth = 0;
|
||
drop.classList.remove('armed');
|
||
if (part) drop.classList.add('hidden');
|
||
loadFile(e.dataTransfer.files[0]);
|
||
});
|