mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-19 02:10:52 -05:00
324 lines
14 KiB
JavaScript
324 lines
14 KiB
JavaScript
import { t } from './i18n.js';
|
|
/**
|
|
* The pose: the rotate gizmo, the 90-degree and reset buttons, and "Lay a face
|
|
* flat" with its hovered-face highlight. app.js's pointer dispatch calls
|
|
* layHover / layClick while layActive().
|
|
*/
|
|
import * as THREE from 'three';
|
|
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
|
import { el } from './dom.js';
|
|
import { addBadge } from './toolbar.js';
|
|
import { renderer, scene, camera, controls, raycaster, pointer, clip } from './scene.js';
|
|
import { keptHit, planeHandleShown } from './section.js';
|
|
import { removeActive } from './remove.js';
|
|
import { histPush, beginGesture, commitGesture } from './history.js';
|
|
import { hideSuggestions, clearSuggestionMark } from './suggest.js';
|
|
import { initRingFade } from './ringfade.js';
|
|
import { drawActive, clearPreview } from './walls.js';
|
|
import { refreshFins } from './finbuild.js';
|
|
import { finsVisible } from './settings.js';
|
|
import { part, topology, shade } from './part.js';
|
|
|
|
// The user rotates. Always. Auto-orientation may suggest, never apply -- the
|
|
// spike's strength-optimal pose for one hub was 155mm tall balanced on a needle:
|
|
// geometrically valid, unprintable.
|
|
export const gizmo = new TransformControls(camera, renderer.domElement);
|
|
gizmo.setMode('rotate');
|
|
gizmo.setSize(0.85);
|
|
initRingFade(gizmo); // faint while a sway brace is aimed (ringfade.js)
|
|
scene.add(gizmo.getHelper ? gizmo.getHelper() : gizmo);
|
|
const dragFrom = new THREE.Quaternion(); // pose at drag start: did the drag turn it?
|
|
let dragSnap = null; // undo state at drag start (history.js)
|
|
// Did the last ring press turn the part? A press under the 4 px click threshold
|
|
// can still snap a ring 5 deg; app.js then won't take it as a click (Sway braces).
|
|
let pressTurned = false;
|
|
export const ringPressTurned = () => pressTurned;
|
|
export function clearRingPress() { pressTurned = false; }
|
|
gizmo.addEventListener('dragging-changed', (e) => {
|
|
controls.enabled = !e.value;
|
|
if (e.value && part) { dragFrom.copy(part.quaternion); dragSnap = beginGesture(); }
|
|
ringDrag = e.value;
|
|
if (e.value) showDragAngle();
|
|
if (!e.value) {
|
|
el('rot-delta').textContent = '';
|
|
// a turn by the rings is one undo step, like the 90deg buttons (it never was one)
|
|
const turned = !!part && !part.quaternion.equals(dragFrom);
|
|
pressTurned = turned;
|
|
if (turned) clearSuggestionMark();
|
|
commitGesture(dragSnap, turned);
|
|
dragSnap = null;
|
|
// Drag released: reseat onto the plate now the pivot is allowed to move again
|
|
// (shade() holds part.position steady WHILE dragging -- see the note there --
|
|
// so this is the frame that actually drops the turned part back down).
|
|
shadeQueued = false; // this full shade stands in for any spaced-out one
|
|
shade();
|
|
endDragAngle(); // the settled angle, then fade
|
|
// Fins are rebuilt when the drag ENDS, not during it. Placement runs the
|
|
// exact confirmation passes -- containment, clearance, per-tine bite -- and
|
|
// costs ~100ms on a 43k-face part, which is fine once and unusable at 60fps.
|
|
// The overhang shading still updates live at 1-6ms, so the diagnosis the
|
|
// user is steering by never stalls.
|
|
if (finsVisible) refreshFins();
|
|
}
|
|
});
|
|
gizmo.addEventListener('objectChange', () => {
|
|
showDelta(gizmo.axis, gizmo.rotationAngle);
|
|
requestShade();
|
|
});
|
|
|
|
// 5 degrees, not 15: a coarse snap is what makes a drag feel like it is
|
|
// juddering rather than turning. Shift releases it entirely for fine work.
|
|
const SNAP = THREE.MathUtils.degToRad(5);
|
|
gizmo.setRotationSnap(SNAP);
|
|
addEventListener('keydown', (e) => { if (e.key === 'Shift') gizmo.setRotationSnap(null); });
|
|
addEventListener('keyup', (e) => { if (e.key === 'Shift') gizmo.setRotationSnap(SNAP); });
|
|
|
|
/**
|
|
* Coalesce re-analysis to one per frame. A high-polling-rate mouse fires
|
|
* pointermove (and so objectChange) well above 60Hz, so an un-throttled drag
|
|
* runs the classify pass several times per displayed frame and stutters.
|
|
*
|
|
* And on a heavy part, leave frames between them. The ring turns the part on
|
|
* pointer move and the scene renders every frame, but a shade that takes 50 ms
|
|
* (a 1.56M-face mini) run every frame held the whole page at ~15 fps. Waiting
|
|
* SHADE_GAP x the last shade's cost before the next keeps the turn smooth and
|
|
* the colours a beat behind; a part whose shade takes under a third of a frame
|
|
* (a few ms at 60 Hz) still shades every frame.
|
|
* Release always shades in full (the dragging-changed handler above).
|
|
*/
|
|
const SHADE_GAP = 2;
|
|
let shadeQueued = false, lastShadeEnd = -Infinity, lastShadeMs = 0;
|
|
function requestShade() {
|
|
if (shadeQueued) return;
|
|
shadeQueued = true;
|
|
requestAnimationFrame(function due(now) {
|
|
if (!shadeQueued) return; // a release shaded already
|
|
if (now - lastShadeEnd < lastShadeMs * SHADE_GAP) { requestAnimationFrame(due); return; }
|
|
shadeQueued = false;
|
|
const t = performance.now();
|
|
shade();
|
|
syncDragNow(); // shade() just wrote the pose
|
|
lastShadeEnd = performance.now();
|
|
lastShadeMs = lastShadeEnd - t;
|
|
});
|
|
}
|
|
|
|
function showDelta(axis, radians) {
|
|
if (!axis || !radians) return;
|
|
const label = axis.length > 1 ? 'free' : axis; // 'XYZE' / 'E' are screen-space
|
|
const deg = THREE.MathUtils.radToDeg(radians);
|
|
el('rot-delta').textContent =
|
|
`${label} ${deg >= 0 ? '+' : ''}${deg.toFixed(deg % 1 ? 1 : 0)}°`;
|
|
if (ringDrag) showDragAngle(); // the 90deg buttons write the delta too
|
|
}
|
|
|
|
// The angle readout lives in the Rotate popover; a ring drag with it folded showed
|
|
// no angle at all. This small label above the Rotate badge mirrors it (the turn so
|
|
// far, then the pose) while the rings move, and fades a moment after release. Not
|
|
// shown when the popover is open: the readout there already says it.
|
|
const DRAG_FADE_MS = 1200;
|
|
let dragFadeTimer = 0;
|
|
let ringDrag = false; // the rings are being dragged right now
|
|
function showDragAngle() {
|
|
clearTimeout(dragFadeTimer);
|
|
const tip = el('drag-angle');
|
|
if (!el('rotate-pop').hidden) { tip.classList.remove('show'); return; }
|
|
el('drag-delta').textContent = el('rot-delta').textContent;
|
|
el('drag-now').textContent = el('rot-now').textContent;
|
|
const badge = el('rotate-badge'); // the row scrolls sideways on a phone
|
|
tip.style.left = `${Math.max(0, badge.offsetLeft - badge.parentElement.scrollLeft)}px`;
|
|
tip.classList.add('show');
|
|
}
|
|
function syncDragNow() {
|
|
if (el('drag-angle').classList.contains('show')) el('drag-now').textContent = el('rot-now').textContent;
|
|
}
|
|
function endDragAngle() {
|
|
if (!el('drag-angle').classList.contains('show')) return;
|
|
syncDragNow(); // the pose it settled at
|
|
dragFadeTimer = setTimeout(() => el('drag-angle').classList.remove('show'), DRAG_FADE_MS);
|
|
}
|
|
|
|
export let layPlacing = false; // true while "lay a face flat" is armed -- gated behind a
|
|
// button so a stray viewport click can't re-lay the part
|
|
/** Undo/redo and a new part disarm lay-flat directly (no setGizmo: they re-run it
|
|
* themselves). Drops the hover highlight and cursor only if it WAS armed, so a
|
|
* Draw crosshair is left alone, and puts the button back either way. */
|
|
export function setLayPlacing(v) {
|
|
if (layPlacing && !v) { hoverFace.visible = false; renderer.domElement.style.cursor = ''; }
|
|
layPlacing = v;
|
|
syncLayUI();
|
|
}
|
|
|
|
/** "Lay a face flat" is armed: a face click lays the part on that face. Off by
|
|
* default so casual clicks orbit instead of silently re-laying the part. */
|
|
export const layActive = () => layPlacing;
|
|
|
|
// The Rotate badge in the view toolbar (toolbar.js) holds the 90deg steps, Reset,
|
|
// Lay a face flat and the rings switch below. Folding it disarms Lay a face flat:
|
|
// its button is the only sign it is armed, and an armed click re-lays the part.
|
|
addBadge('rotate-badge', { pop: 'rotate-pop', onFold: () => { if (layPlacing) cancelLay(); } });
|
|
|
|
// "Show rotate rings": off hides the rings until turned back on (they cover the
|
|
// part, #53); the 90deg buttons, Reset and Lay a face flat still turn it.
|
|
// Remembered, storage optional, like "Highlight small overhangs".
|
|
const RINGS_KEY = 'sf.showRings';
|
|
let showRings = true;
|
|
try { showRings = localStorage.getItem(RINGS_KEY) !== '0'; } catch { /* storage off */ }
|
|
el('show-rings').checked = showRings;
|
|
el('rings-hint').hidden = !showRings;
|
|
el('show-rings').addEventListener('change', (e) => {
|
|
showRings = e.target.checked;
|
|
try { localStorage.setItem(RINGS_KEY, showRings ? '1' : '0'); } catch { /* storage off */ }
|
|
el('rings-hint').hidden = !showRings;
|
|
setGizmo();
|
|
});
|
|
|
|
/** Enable the rotate gizmo only when NOT drawing or laying a face flat --
|
|
* its handles would otherwise swallow the clicks those modes need -- and
|
|
* only while "Show rotate rings" is on. The section plane's handle takes
|
|
* its place while that is up: two sets of handles on one part is a misclick. */
|
|
export function setGizmo() {
|
|
const on = showRings && !!part && !drawActive() && !layActive() && !removeActive()
|
|
&& !planeHandleShown();
|
|
gizmo.enabled = on;
|
|
// A disabled gizmo stops tracking hover, so a ring hovered last would stay
|
|
// "under the pointer" and app.js would swallow the next pick for it.
|
|
if (!on) gizmo.axis = null;
|
|
const helper = gizmo.getHelper ? gizmo.getHelper() : gizmo;
|
|
helper.visible = on;
|
|
}
|
|
|
|
// ---- Lay a face flat (armed behind a button so stray clicks can't re-lay) -------
|
|
|
|
/** Arm face-lay: the next face click lays the part on that face. Modal so a click
|
|
* isn't swallowed by the rotate gizmo; hovering highlights the face. */
|
|
function beginLay() {
|
|
if (!part || !topology) return;
|
|
layPlacing = true;
|
|
clearPreview();
|
|
setGizmo(); // hides the rotate rings so they don't eat the pick
|
|
syncLayUI();
|
|
}
|
|
|
|
/** Disarm face-lay (Esc / right-click / re-toggle / after a lay). */
|
|
export function cancelLay() {
|
|
layPlacing = false;
|
|
hoverFace.visible = false;
|
|
renderer.domElement.style.cursor = '';
|
|
setGizmo();
|
|
syncLayUI();
|
|
}
|
|
|
|
function syncLayUI() {
|
|
el('lay-face').textContent = layPlacing ? t('Click a face to lay it flat — Esc cancels')
|
|
: t('Lay a face flat');
|
|
el('lay-face').classList.toggle('active', layPlacing);
|
|
}
|
|
|
|
/** Rotate 90 degrees about a world axis, keeping the part seated. */
|
|
function rotate90(name, axis) {
|
|
if (!part) return;
|
|
histPush();
|
|
const q = new THREE.Quaternion().setFromAxisAngle(axis, Math.PI / 2);
|
|
part.quaternion.premultiply(q); // premultiply = about the WORLD axis
|
|
showDelta(name.toUpperCase(), Math.PI / 2);
|
|
clearSuggestionMark();
|
|
shade();
|
|
}
|
|
|
|
/**
|
|
* Click a face to lay it flat on the plate. This is the fastest way to reach a
|
|
* sane orientation -- far quicker than hunting for it on the rings -- and it is
|
|
* how you actually think about the problem: "put THAT face down".
|
|
*/
|
|
const DOWN = new THREE.Vector3(0, 0, -1);
|
|
const layQuat = new THREE.Quaternion();
|
|
const faceNormal = new THREE.Vector3();
|
|
|
|
/**
|
|
* The hovered face, drawn as a single highlighted triangle. Without this,
|
|
* click-to-lay is invisible: nothing on screen suggests the part is clickable,
|
|
* so a first-time user never discovers the fastest control in the app.
|
|
* Parented to the part so it inherits the orientation for free.
|
|
*/
|
|
const hoverGeom = new THREE.BufferGeometry();
|
|
hoverGeom.setAttribute(
|
|
'position', new THREE.BufferAttribute(new Float32Array(9), 3));
|
|
export const hoverFace = new THREE.Mesh(hoverGeom, new THREE.MeshBasicMaterial({
|
|
color: 0x4da3ff, transparent: true, opacity: 0.6, side: THREE.DoubleSide,
|
|
depthTest: true, polygonOffset: true,
|
|
polygonOffsetFactor: -4, polygonOffsetUnits: -4, clippingPlanes: clip,
|
|
}));
|
|
hoverFace.visible = false;
|
|
hoverFace.renderOrder = 1;
|
|
|
|
/** Ray the pointer into the part; returns the intersection or null. */
|
|
export function pickFace(ev) {
|
|
if (!part || !topology) return null;
|
|
const r = renderer.domElement.getBoundingClientRect();
|
|
pointer.set(((ev.clientX - r.left) / r.width) * 2 - 1,
|
|
-((ev.clientY - r.top) / r.height) * 2 + 1);
|
|
raycaster.setFromCamera(pointer, camera);
|
|
const hit = keptHit(raycaster.intersectObject(part, false));
|
|
return hit && hit.faceIndex != null ? hit : null;
|
|
}
|
|
|
|
const AXES = { x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0),
|
|
z: new THREE.Vector3(0, 0, 1) };
|
|
for (const a of ['x', 'y', 'z']) {
|
|
el(`rot-${a}`).addEventListener('click', () => rotate90(a, AXES[a]));
|
|
}
|
|
el('rot-reset').addEventListener('click', () => {
|
|
if (!part) return;
|
|
histPush();
|
|
part.quaternion.identity();
|
|
el('rot-delta').textContent = '';
|
|
hideSuggestions(); // a manual turn invalidates the ranking's "active" mark
|
|
shade();
|
|
});
|
|
|
|
// Lay a face flat -- armed behind a button so a stray click can't re-lay the part.
|
|
el('lay-face').addEventListener('click', () => {
|
|
if (layPlacing) cancelLay();
|
|
else beginLay();
|
|
});
|
|
|
|
// ---- the lay-flat pointer (app.js dispatches here while layActive()) -------
|
|
|
|
/** Pointer move while armed: highlight the face a click would lay down. */
|
|
export function layHover(ev) {
|
|
const hit = pickFace(ev);
|
|
hoverFace.visible = !!hit;
|
|
hoverFace.material.color.setHex(0x4da3ff); // reset from Draw's green/grey tint
|
|
renderer.domElement.style.cursor = hit ? 'pointer' : '';
|
|
if (!hit) return;
|
|
|
|
const pos = hoverGeom.getAttribute('position');
|
|
const src = part.geometry.getAttribute('position').array;
|
|
const o = hit.faceIndex * 9;
|
|
for (let i = 0; i < 9; i++) pos.array[i] = src[o + i];
|
|
pos.needsUpdate = true;
|
|
hoverGeom.computeBoundingSphere();
|
|
}
|
|
|
|
/** A click while armed: lay the part on that face, then disarm. */
|
|
export function layClick(e) {
|
|
const hit = pickFace(e);
|
|
if (!hit) return;
|
|
|
|
// Snapshot before laying so Ctrl-Z brings the old pose back.
|
|
histPush();
|
|
// Use OUR winding-derived normal, not the STL's stored one, for the same
|
|
// reason the analysis does: exported normals are not trustworthy.
|
|
const i = hit.faceIndex * 3;
|
|
faceNormal.set(topology.nrm[i], topology.nrm[i + 1], topology.nrm[i + 2])
|
|
.applyQuaternion(part.quaternion);
|
|
layQuat.setFromUnitVectors(faceNormal, DOWN);
|
|
part.quaternion.premultiply(layQuat);
|
|
clearSuggestionMark();
|
|
cancelLay(); // one-shot: disarm after a lay so the next click is safe
|
|
shade();
|
|
}
|
|
|
|
window.addEventListener('languagechange', () => { el('lay-face').textContent = layPlacing ? t(t('Click a face to lay it flat — Esc cancels')) : t(t('Lay a face flat')); });
|