Files
b07801698d i18n: Brazilian Portuguese, and the inGerman() status lines through t() (#238)
* 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>
2026-10-13 08:10:07 -05:00

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();