Files

398 lines
16 KiB
JavaScript

/**
* Section view (#250): a plane the user moves and turns that hides everything on
* one side of it -- the part and the supports -- so the inside of a bore or a pipe
* can be seen and drawn on. View only: it never reaches the build or the export,
* and it is not saved in sessions or undo.
*
* The cut is three.js local clipping: the model materials share scene.js's `clip`
* array, and switching the section on puts `plane` in it. Picks skip what the cut
* hides (keptHit), so a click lands on the surface you can see, not the one cut away.
* Ghost (the default) draws the cut-away half again as faint glass (renderGhost),
* so the part keeps its context; Hide leaves it out. Clicks go through the glass.
*
* One handle, no modes: an arrow pushes the cut along its normal, two rings tilt
* it. Spinning it about its own normal changes nothing, so that ring is left off.
* The sheet itself pushes too: grab it anywhere outside the part's outline (over
* the part, a click is the part's) and drag.
*
* `plane` is in world space, but the cut is held in the PART's frame (followPart):
* turn the part and the cut turns with it, so it stays on the bore it was opening.
* Its normal points at the side that is KEPT (three.js discards where the signed
* distance is < 0).
*/
import * as THREE from 'three';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { el } from './dom.js';
import { renderer, scene, camera, controls, clip, MODEL } from './scene.js';
import { part } from './part.js';
import { setGizmo } from './pose.js';
import { addBadge } from './toolbar.js';
export const plane = new THREE.Plane();
const ghostPlane = new THREE.Plane(); // the same cut, keeping the other side
export let sectionOn = false;
const GHOST_KEY = 'sf.sectionGhost';
let ghost = true;
try { ghost = localStorage.getItem(GHOST_KEY) !== '0'; } catch { /* storage off */ }
// The plane's handle: a translucent square plus its outline. Its local +Z is the
// kept side. Not clipped (it is the tool, not the model); no pick lands on it,
// but it can be grabbed and dragged (below).
const handle = new THREE.Group();
const sheet = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({
color: 0x4da3ff, transparent: true, opacity: 0.08, side: THREE.DoubleSide, depthWrite: false,
}));
const rim = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-0.5, -0.5, 0), new THREE.Vector3(0.5, -0.5, 0),
new THREE.Vector3(0.5, 0.5, 0), new THREE.Vector3(-0.5, 0.5, 0),
]),
new THREE.LineBasicMaterial({ color: 0x4da3ff, transparent: true, opacity: 0.7 }));
handle.add(sheet, rim);
handle.visible = false;
scene.add(handle);
/** One TransformControls per job, both on the handle, both in its local frame. */
function grip(mode) {
const g = new TransformControls(camera, renderer.domElement);
g.setMode(mode);
g.setSpace('local');
g.setSize(0.7);
g.attach(handle);
g.enabled = false;
// Hidden until the section is on: attached to the (hidden) handle, its rings
// would otherwise draw at the origin over the empty page.
const helper = g.getHelper ? g.getHelper() : g;
helper.visible = false;
scene.add(helper);
g.addEventListener('dragging-changed', (e) => { controls.enabled = !e.value; });
g.addEventListener('objectChange', () => { syncPlane(); holdInPart(); });
return g;
}
const push = grip('translate'); // the normal arrow only
push.showX = push.showY = false;
const tilt = grip('rotate'); // the two in-plane rings only
tilt.showZ = false;
tilt.showXYZE = false;
const grips = [push, tilt];
// Same 5 degree snap as the part's rings; Shift frees it.
const SNAP = THREE.MathUtils.degToRad(5);
tilt.setRotationSnap(SNAP);
addEventListener('keydown', (e) => { if (e.key === 'Shift') tilt.setRotationSnap(null); });
addEventListener('keyup', (e) => { if (e.key === 'Shift') tilt.setRotationSnap(SNAP); });
// Where the arrow crosses a ring, the arrow wins. `push` was created first, so its
// pointerdown runs first; once it has grabbed the arrow, the rings sit this press
// out. (Read from its own event, not a hover axis: touch never hovers.)
push.addEventListener('mouseDown', () => { tilt.enabled = false; });
for (const type of ['pointerup', 'pointercancel']) {
renderer.domElement.addEventListener(type, () => { tilt.enabled = sectionOn; }, { capture: true });
}
// Shift + scroll pushes the cut 1 mm a notch, scrolling away pushing it deeper.
// The arrow can't do it when you look square at the cut (TransformControls hides
// an axis pointing at the eye) -- the very view the default aim gives a bore seen
// from the side. Captured ahead of OrbitControls so it doesn't zoom as well.
renderer.domElement.addEventListener('wheel', (e) => {
if (!sectionOn || !e.shiftKey) return;
e.preventDefault();
e.stopImmediatePropagation();
const d = e.deltaY || e.deltaX; // macOS turns Shift+scroll sideways
if (!d) return;
const away = camera.getWorldDirection(new THREE.Vector3()).dot(plane.normal) >= 0 ? 1 : -1;
handle.translateZ(-Math.sign(d) * away); // scrolling up/away: deeper
syncPlane();
holdInPart();
}, { capture: true, passive: false });
const nrm = new THREE.Vector3();
/** Read the handle's pose into the clipping plane (and its ghost twin). */
function syncPlane() {
handle.updateMatrixWorld();
nrm.set(0, 0, 1).applyQuaternion(handle.quaternion);
plane.setFromNormalAndCoplanarPoint(nrm, handle.position);
ghostPlane.copy(plane).negate();
}
// The handle's pose in the part's frame, so the cut follows the part when it turns.
const inPart = new THREE.Matrix4();
const m4 = new THREE.Matrix4();
/** Remember where the handle sits on the part (after the user or a button moved it). */
function holdInPart() {
if (!part) return;
part.updateMatrixWorld();
handle.updateMatrix();
inPart.copy(part.matrixWorld).invert().multiply(handle.matrix);
}
/** The part turned or reseated (part.js shade): carry the cut along with it. */
export function followPart() {
if (!sectionOn || !part || grips.some((g) => g.dragging)) return;
part.updateMatrixWorld();
m4.multiplyMatrices(part.matrixWorld, inPart);
m4.decompose(handle.position, handle.quaternion, handle.scale);
syncPlane();
}
/** The part's world-space box: where the plane starts and how big it draws. */
function partBox() {
return new THREE.Box3().setFromObject(part);
}
/** Size the square to the part, so it reads as a cut through it at any scale. */
function sizeSheet() {
const s = partBox().getSize(new THREE.Vector3()).length() * 1.2;
sheet.scale.set(s, s, 1);
rim.scale.set(s, s, 1);
}
/**
* Aim the cut at the view: through the part's centre, square to whichever of the
* PART's own axes points most nearly where the camera looks, keeping the far half.
* Bores in a CAD part run along its axes, so looking at the side of one and
* switching the section on opens it lengthwise -- not the slice across it that a
* cut square to the screen would make.
*/
function aimAtView() {
const look = camera.getWorldDirection(new THREE.Vector3());
part.updateMatrixWorld();
let best = null, most = -1;
for (let i = 0; i < 3; i++) {
const a = new THREE.Vector3().setFromMatrixColumn(part.matrixWorld, i).normalize();
const d = a.dot(look);
if (Math.abs(d) > most) { most = Math.abs(d); best = d < 0 ? a.negate() : a; }
}
partBox().getCenter(handle.position);
handle.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), best);
syncPlane();
holdInPart();
}
/** The first hit the cut leaves visible (hits come sorted nearest first). */
export function keptHit(hits) {
if (!sectionOn) return hits[0];
return hits.find((h) => plane.distanceToPoint(h.point) >= 0);
}
/** The plane's handle is up: the part's rings step aside for it. */
export const planeHandleShown = () => sectionOn;
/** What a click on the cut's handle hits (ui/plates.js: not a click through to a bed). */
export const sectionPick = () => (sectionOn ? [handle] : []);
/** The pointer is on (or dragging) a plane handle: app.js leaves the click to it. */
export const planeBusy = () => sectionOn && (!!sheetDrag || grips.some((g) => g.dragging || !!g.axis));
// ---- the sheet: grab it and drag to push the cut ---------------------------
// Only where the sheet is NOT over the solid part: there a press is the part's
// (Draw, select, Lay flat), and clicks go through the glass to it. Nor near the
// handle's centre, where the arrow and rings are. Mouse and pen only: the sheet
// covers most of the space around the part, and on touch -- no hover to warn you --
// one-finger orbit there matters more.
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const SHEET = 0.08, SHEET_HOT = 0.18;
const HANDLE_PX = 80;
let sheetDrag = null; // { id, p0, h0, n, y0, wpp, orbit } while dragging
let hot = false;
function aim(e) {
const r = renderer.domElement.getBoundingClientRect();
ndc.set((e.clientX - r.left) / r.width * 2 - 1, -(e.clientY - r.top) / r.height * 2 + 1);
ray.setFromCamera(ndc, camera);
return r;
}
/** Where a press would grab the sheet, or null (off it, over the part, at the handle). */
function sheetPoint(e) {
if (!sectionOn || e.pointerType === 'touch' || grips.some((g) => g.dragging || g.axis)) return null;
const r = aim(e);
const c = handle.position.clone().project(camera);
const cx = r.left + (c.x + 1) / 2 * r.width, cy = r.top + (1 - c.y) / 2 * r.height;
if (Math.hypot(e.clientX - cx, e.clientY - cy) < HANDLE_PX) return null;
ray.layers.set(0);
const hit = ray.intersectObject(sheet)[0];
if (!hit) return null;
ray.layers.set(MODEL);
if (keptHit(ray.intersectObjects(scene.children, true))) return null;
return hit.point;
}
/** Light the sheet (and the grab cursor) while a press there would grab it. */
function setHot(v) {
if (v === hot) return;
hot = v;
sheet.material.opacity = v ? SHEET_HOT : SHEET;
const st = renderer.domElement.style;
if (v) st.cursor = 'grab';
else if (st.cursor === 'grab' || st.cursor === 'grabbing') st.cursor = '';
}
// Hover: at most one model raycast a frame, not one per mouse event.
let hoverEv = null;
function hoverSoon(e) {
if (!hoverEv) requestAnimationFrame(() => { setHot(!sheetDrag && !!sheetPoint(hoverEv)); hoverEv = null; });
hoverEv = e;
}
// Capture phase, so it runs ahead of OrbitControls, TransformControls and app.js.
// A grab parks the orbit for the press (the event still reaches everyone else:
// app.js reads planeBusy() and leaves the click alone; open menus still close).
// Ctrl+click is the Mac's right click: that cancels, it doesn't grab.
renderer.domElement.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || e.ctrlKey || sheetDrag) return;
const p0 = sheetPoint(e);
if (!p0) return;
const r = renderer.domElement.getBoundingClientRect();
const dist = camera.position.distanceTo(p0);
sheetDrag = {
id: e.pointerId, p0, h0: handle.position.clone(), n: plane.normal.clone(), y0: e.clientY,
// mm per pixel at the sheet, for the face-on case below
wpp: 2 * dist * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) / r.height,
orbit: controls.enabled,
};
controls.enabled = false;
renderer.domElement.setPointerCapture(e.pointerId);
setHot(true);
renderer.domElement.style.cursor = 'grabbing';
}, { capture: true });
const dragPlane = new THREE.Plane();
const v3 = new THREE.Vector3();
const mid = new THREE.Vector3();
renderer.domElement.addEventListener('pointermove', (e) => {
if (!sheetDrag) { if (!e.buttons) hoverSoon(e); else setHot(false); return; } // not mid-orbit
if (e.pointerId !== sheetDrag.id) return;
const { p0, h0, n } = sheetDrag;
const look = camera.getWorldDirection(new THREE.Vector3());
// Slide along the normal: on the plane through it that faces the camera most.
// Looking (nearly) square at the sheet there is no such plane -- then up the
// screen pushes it away from you, like Shift + scroll.
const across = look.clone().addScaledVector(n, -look.dot(n));
let t;
if (across.length() > 0.25) {
aim(e);
dragPlane.setFromNormalAndCoplanarPoint(across.normalize(), p0);
if (!ray.ray.intersectPlane(dragPlane, v3)) return;
t = v3.sub(p0).dot(n);
} else {
t = (sheetDrag.y0 - e.clientY) * sheetDrag.wpp * Math.sign(look.dot(n));
}
// Keep the cut within the part's reach: near the drag plane's horizon t runs off.
const box = partBox(), reach = box.getSize(v3).length() / 2;
const off = h0.clone().sub(box.getCenter(mid)).dot(n);
t = THREE.MathUtils.clamp(t, -reach - off, reach - off);
handle.position.copy(h0).addScaledVector(n, t);
syncPlane();
holdInPart();
}, { capture: true });
function endSheetDrag(e) {
if (!sheetDrag || e.pointerId !== sheetDrag.id) return;
controls.enabled = sheetDrag.orbit;
sheetDrag = null;
renderer.domElement.style.cursor = 'grab';
setHot(!!sheetPoint(e));
}
renderer.domElement.addEventListener('pointerup', endSheetDrag, { capture: true });
renderer.domElement.addEventListener('pointercancel', endSheetDrag, { capture: true });
renderer.domElement.addEventListener('pointerleave', () => { if (!sheetDrag) setHot(false); });
// Draw / Lay flat / Remove set their own cursor on the same move, after this
// module's capture listener: put the grab hand back once they're done.
addEventListener('pointermove', () => {
if (hot && !sheetDrag) renderer.domElement.style.cursor = 'grab';
});
// ---- ghost: the cut-away half as glass --------------------------------------
// A second pass over the model only (scene.js's MODEL layer: part, fins, pad, walls),
// clipped to the other side, translucent, not writing depth -- so the solid half
// still hides what is behind it and the glass never blocks a later pick.
const ghostMat = new THREE.MeshStandardMaterial({
color: 0xb9c2d0, roughness: 0.7, transparent: true, opacity: 0.14,
depthWrite: false, side: THREE.DoubleSide, clippingPlanes: [ghostPlane],
});
/** After the main render: draw the cut-away half as glass, if Ghost is on. */
export function renderGhost() {
if (!sectionOn || !ghost) return;
const bg = scene.background, autoClear = renderer.autoClear;
scene.background = null;
renderer.autoClear = false;
scene.overrideMaterial = ghostMat;
camera.layers.set(MODEL);
renderer.render(scene, camera);
camera.layers.set(0);
scene.overrideMaterial = null;
renderer.autoClear = autoClear;
scene.background = bg;
}
// ---- the controls -----------------------------------------------------------
function syncGizmo() {
for (const g of grips) {
g.enabled = sectionOn;
if (!sectionOn) g.axis = null;
(g.getHelper ? g.getHelper() : g).visible = sectionOn;
}
handle.visible = sectionOn;
if (!sectionOn) {
if (sheetDrag) controls.enabled = sheetDrag.orbit;
sheetDrag = null;
setHot(false);
}
}
function syncUI() {
syncBadge();
for (const [id, on] of [['section-ghost', ghost], ['section-hide', !ghost]]) {
el(id).classList.toggle('primary', on);
el(id).setAttribute('aria-pressed', String(on));
}
}
/** The cut, kept with a plate that is not being edited (ui/plates.js): where it sits
* on the part, or null when it is off. */
export const sectionState = () => (sectionOn ? inPart.clone() : null);
/** Put a kept cut back once its part is live again. */
export function restoreSection(m) {
if (!m || !part) return;
setSection(true);
inPart.copy(m);
followPart();
}
/** Switch the cut on (aimed at the current view) or off. */
export function setSection(on) {
sectionOn = !!on && !!part;
clip.length = 0;
if (sectionOn) {
sizeSheet();
aimAtView();
clip.push(plane);
}
syncGizmo();
syncUI();
setGizmo();
}
// Its badge in the view toolbar (toolbar.js): on aims the cut at the view and
// opens Ghost/Hide, Flip and Re-aim above it.
const syncBadge = addBadge('section', {
pop: 'section-pop', isOn: () => sectionOn, setOn: (v) => setSection(v),
});
for (const [id, v] of [['section-ghost', true], ['section-hide', false]]) {
el(id).addEventListener('click', () => {
ghost = v;
try { localStorage.setItem(GHOST_KEY, ghost ? '1' : '0'); } catch { /* storage off */ }
syncUI();
});
}
el('section-flip').addEventListener('click', () => {
handle.rotateX(Math.PI);
syncPlane();
holdInPart();
});
el('section-reset').addEventListener('click', () => { if (sectionOn) aimAtView(); });