mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-22 03:42:40 -05:00
* i18n: status lines go through t()/tn() instead of inGerman() branches -- a third language can translate them
The German strings these branches carried move into de.js unchanged (21 entries; "{n} region(s)",
"{n} sliver(s)", "none" and the amber swatch title were already there and are now used), so the
page reads the same in English and German: checked by rendering every converted line, old branch
vs new call, in en and de at n = 1, 2 and 7 (150 cases, all identical). One deliberate difference:
the German sliver count loses the extra space before it, which the swatch's own margin already
gives, as in English. inGerman() has no callers left and goes. deno test -A: 449 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n: Brazilian Portuguese -- web/i18n/pt.js, the same keys as de.js, picked from the Language menu or ?lang=pt
pt.js carries every de.js key (300 after norm(), none missing or extra, every {placeholder} kept,
plural entries as { one, other } like de.js). ui/i18n.js builds each dictionary through one
normalized() helper instead of a German-only loop. Checked in Chromium against main, page by page
(start, Export / Calibrate / Plugins / Language menus, a part loaded, fins on, every section open):
English and German render the same text as main apart from the new menu item; Portuguese leaves
nothing in English that German translates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Lucas Poentes <lucas.poentes@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
9.1 KiB
JavaScript
237 lines
9.1 KiB
JavaScript
import { setLanguage, updateDomTranslations } from "./ui/i18n.js";
|
|
/**
|
|
* Support Fins: the page's entry point. Every feature lives in web/ui/ (one
|
|
* module each: part, pose, settings, finbuild, walls, readout, remove, strength,
|
|
* suggest, history, io, volume, export, scene). What stays here is the wiring:
|
|
* the pointer and keyboard dispatch between the modes, startup, and window.__sf.
|
|
*
|
|
* COORDINATES: Z up, millimetres, bed plane at z = 0, plate centred on the
|
|
* origin in XY. This is the printer's frame and the same one the Python
|
|
* generators use (tools/support/breakaway.py) -- three.js defaults to Y up, so
|
|
* that is overridden here rather than converting at every later step.
|
|
*/
|
|
import * as THREE from 'three';
|
|
import { analyze } from './overhangs.js';
|
|
import { buildFins } from './fins.js';
|
|
import { findWallPatches } from './planes.js';
|
|
import { drawnWall } from './draw.js';
|
|
import { el } from './ui/dom.js';
|
|
import {
|
|
renderer, scene, camera, controls, frame, resize,
|
|
} from './ui/scene.js';
|
|
import {
|
|
removeActive, cancelRemove, clearFinHover, hoverRemove, clickRemove,
|
|
} from './ui/remove.js';
|
|
import { undo, redo } from './ui/history.js';
|
|
import { loadURL } from './ui/io.js';
|
|
import { applyVolume } from './ui/volume.js';
|
|
import { buildExportGeometry } from './ui/export.js';
|
|
import './ui/plugins.js';
|
|
import './ui/calibrate.js';
|
|
import './ui/topbar.js';
|
|
import './ui/navigation.js';
|
|
import { updateReadout } from './ui/readout.js';
|
|
import {
|
|
drawnWalls, drawnTris, drawStart, selectedWall, drawActive, sizeMarkers, clearPreview,
|
|
selectWall, removeSelected, drawHover, drawClick,
|
|
} from './ui/walls.js';
|
|
import { finTris, padTris, lastBuilt } from './ui/finbuild.js';
|
|
import { initSettings } from './ui/settings.js';
|
|
import { part, topology, rotM3, lastResult, threshold } from './ui/part.js';
|
|
import { gizmo, hoverFace, layActive, cancelLay, layHover, layClick } from './ui/pose.js';
|
|
|
|
// ------------------------------------------------------------------ keyboard
|
|
|
|
// Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z (or Ctrl+Y) redoes. Ignored while typing
|
|
// in a field so it never eats a text-edit undo.
|
|
addEventListener('keydown', (e) => {
|
|
if (!part) return;
|
|
const tag = e.target.tagName;
|
|
// Undo/redo from a slider, checkbox or menu too: focus stays on one after you use it,
|
|
// and ⌘Z then did nothing. A text or number field keeps its own text undo.
|
|
const textField = tag === 'TEXTAREA' || (tag === 'INPUT' && !['range', 'checkbox', 'radio'].includes(e.target.type));
|
|
const histKey = (e.metaKey || e.ctrlKey) && ['z', 'y'].includes(e.key.toLowerCase());
|
|
if (textField || ((tag === 'INPUT' || tag === 'SELECT') && !histKey)) return;
|
|
if (selectedWall && (e.key === 'Delete' || e.key === 'Backspace')) {
|
|
e.preventDefault();
|
|
removeSelected();
|
|
return;
|
|
}
|
|
if (!(e.metaKey || e.ctrlKey)) return;
|
|
const k = e.key.toLowerCase();
|
|
if (k === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
|
else if (k === 'y') { e.preventDefault(); redo(); }
|
|
});
|
|
|
|
// ---------------------------------------------------------- pointer dispatch
|
|
// One set of viewport listeners, routed by mode: remove, then draw, then lay flat.
|
|
|
|
let pressAt = null;
|
|
renderer.domElement.addEventListener('pointermove', (ev) => {
|
|
// Remove-fins mode: hover lights the fin a click would drop, in red.
|
|
if (removeActive()) {
|
|
hoverFace.visible = false;
|
|
hoverRemove(ev);
|
|
return;
|
|
}
|
|
// Draw / Suggest "+ Add": the pointer places wall endpoints ON the overhang, so
|
|
// face-lay hover is off and the cursor / band / ghost track the surface instead.
|
|
// Draw picks ANY surface point the user aims at -- including the red overhang
|
|
// faces themselves -- because a drawn breakaway wall sweeps under the line you
|
|
// draw, wherever you draw it; there is no "grippable face" gate to fight.
|
|
if (drawActive()) {
|
|
hoverFace.visible = false;
|
|
drawHover(ev);
|
|
return;
|
|
}
|
|
// Face-lay hover ONLY while armed: otherwise a highlighted, clickable-looking
|
|
// face invites the stray click that silently re-lays the part. Off by default,
|
|
// clicks just orbit.
|
|
if (!layActive() || !part || gizmo.dragging || gizmo.axis || pressAt) {
|
|
hoverFace.visible = false;
|
|
return;
|
|
}
|
|
layHover(ev);
|
|
});
|
|
|
|
renderer.domElement.addEventListener('pointerleave', () => {
|
|
hoverFace.visible = false;
|
|
clearFinHover();
|
|
});
|
|
|
|
renderer.domElement.addEventListener('pointerdown', (e) => {
|
|
pressAt = { x: e.clientX, y: e.clientY };
|
|
});
|
|
|
|
renderer.domElement.addEventListener('pointerup', (e) => {
|
|
const from = pressAt;
|
|
pressAt = null;
|
|
if (!from || !part || !topology) return;
|
|
|
|
// a drag is an orbit, not a pick
|
|
if (Math.hypot(e.clientX - from.x, e.clientY - from.y) > 4) return;
|
|
|
|
// A right CLICK cancels; a right DRAG is an orbit or pan (the Onshape preset
|
|
// orbits on it) and leaves the wall in progress alone. Decided here, on release,
|
|
// not in contextmenu: macOS fires that on the press, before any drag.
|
|
// Ctrl+click is the Mac's right click (one-button trackpads), and nothing else
|
|
// uses a Ctrl+click here, so it cancels on every platform.
|
|
if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { cancelArmed(); return; }
|
|
// Only the left button picks: a middle click is navigation in the CAD presets.
|
|
if (e.button !== 0) return;
|
|
|
|
// Remove-fins mode: one click drops just the fin under the pointer.
|
|
if (removeActive()) {
|
|
clickRemove(e);
|
|
return;
|
|
}
|
|
|
|
// Draw / Suggest "+ Add": first click sets the start of a wall on the overhang,
|
|
// second commits it. A breakaway wall sweeps under the line between the two
|
|
// points, so the user draws it straight onto the red overhang -- no face gate.
|
|
if (drawActive()) {
|
|
drawClick(e);
|
|
return;
|
|
}
|
|
|
|
if (gizmo.dragging || gizmo.axis) return;
|
|
|
|
// Lay a face flat -- ONLY when armed via the button. Off by default so a stray
|
|
// viewport click orbits instead of silently discarding a careful rotation.
|
|
if (!layActive()) return;
|
|
layClick(e);
|
|
});
|
|
|
|
// Cancel an armed mode / wall-in-progress: Escape, or a right-click in the viewport.
|
|
addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Escape') return;
|
|
if (removeActive()) { cancelRemove(); return; }
|
|
if (layActive()) { cancelLay(); return; }
|
|
if (drawActive() && drawStart) {
|
|
clearPreview();
|
|
updateReadout(lastBuilt);
|
|
} else if (selectedWall) {
|
|
selectWall(selectedWall); // toggles it off
|
|
}
|
|
});
|
|
/** Right-click (pointerup above): the same cancel as Escape for an armed mode. */
|
|
function cancelArmed() {
|
|
if (removeActive()) { cancelRemove(); return; }
|
|
if (layActive()) { cancelLay(); return; }
|
|
if (drawActive() && drawStart) { clearPreview(); updateReadout(lastBuilt); }
|
|
}
|
|
// No browser menu over the viewport while a mode is armed (the orbit controls
|
|
// suppress it too, but not while a ring drag has them disabled).
|
|
renderer.domElement.addEventListener('contextmenu', (e) => {
|
|
if (removeActive() || layActive() || drawActive()) e.preventDefault();
|
|
});
|
|
|
|
// ----------------------------------------------------------------- main loop
|
|
|
|
const fpsEl = el('fps');
|
|
let frames = 0, last = performance.now();
|
|
|
|
function tick(now) {
|
|
requestAnimationFrame(tick);
|
|
controls.update();
|
|
sizeMarkers();
|
|
renderer.render(scene, camera);
|
|
if (++frames >= 20) {
|
|
fpsEl.textContent = `${Math.round((frames * 1000) / (now - last))} fps`;
|
|
frames = 0;
|
|
last = now;
|
|
}
|
|
}
|
|
|
|
initSettings();
|
|
applyVolume();
|
|
resize();
|
|
frame(new THREE.Vector3(60, 60, 60));
|
|
requestAnimationFrame(tick);
|
|
|
|
// debug surface, used to cross-check against the Python probes
|
|
window.__sf = { get part() { return part; }, camera, get topo() { return topology; },
|
|
analyze, get threshold() { return threshold; },
|
|
get rot() { return rotM3.elements; },
|
|
get result() { return lastResult; },
|
|
get finTris() { return finTris; },
|
|
get padTris() { return padTris; },
|
|
get drawnTris() { return drawnTris; },
|
|
get drawnWalls() { return drawnWalls; },
|
|
buildFins, findWallPatches, drawnWall, buildExportGeometry };
|
|
|
|
const wanted = new URLSearchParams(location.search).get('stl');
|
|
if (wanted) loadURL(wanted).catch((err) => console.error('?stl=', err));
|
|
|
|
// Language Dropdown Menu
|
|
const langBtn = document.getElementById('lang-btn');
|
|
const langMenu = document.getElementById('lang-menu');
|
|
if (langBtn && langMenu) {
|
|
langBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const open = !langMenu.hidden;
|
|
langMenu.hidden = open;
|
|
langBtn.setAttribute('aria-expanded', String(!open));
|
|
});
|
|
document.addEventListener('click', () => {
|
|
langMenu.hidden = true;
|
|
langBtn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
document.getElementById('lang-opt-en')?.addEventListener('click', () => {
|
|
setLanguage('en');
|
|
langMenu.hidden = true;
|
|
langBtn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
document.getElementById('lang-opt-de')?.addEventListener('click', () => {
|
|
setLanguage('de');
|
|
langMenu.hidden = true;
|
|
langBtn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
document.getElementById('lang-opt-pt')?.addEventListener('click', () => {
|
|
setLanguage('pt');
|
|
langMenu.hidden = true;
|
|
langBtn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
}
|
|
updateDomTranslations();
|