diff --git a/web/app.js b/web/app.js index 5a20d79..2fb52ba 100644 --- a/web/app.js +++ b/web/app.js @@ -34,7 +34,7 @@ import './ui/scale.js'; import { updateReadout } from './ui/readout.js'; import { drawnWalls, drawnTris, drawStart, selectedWall, drawActive, sizeMarkers, clearPreview, - selectWall, removeSelected, drawHover, drawClick, braceActive, braceHover, braceClick, dropBracePreview, + selectWall, removeSelected, drawHover, drawClick, braceActive, braceHover, braceClick, dropBracePreview, braceAway, } from './ui/walls.js'; import { finTris, padTris, lastBuilt } from './ui/finbuild.js'; import { initSettings } from './ui/settings.js'; @@ -96,9 +96,11 @@ renderer.domElement.addEventListener('pointermove', (ev) => { return; } // Auto / Full with Sway braces on: an upright side previews the brace a click stands. - // An orbit drag or the rotate rings own the pointer: the preview goes, not left behind. + // An orbit or ring drag owns the pointer: the preview goes, not left behind. Over + // the rings without a drag it lingers a moment, as when it slips off the side. if (!layActive() && braceActive()) { - if (gizmo.dragging || gizmo.axis || pressAt) dropBracePreview(); + if (gizmo.dragging || pressAt) dropBracePreview(); + else if (gizmo.axis) braceAway(); else if (braceHover(ev)) { hoverFace.visible = false; return; } } // Face-lay hover ONLY while armed: otherwise a highlighted, clickable-looking diff --git a/web/i18n/ar.js b/web/i18n/ar.js index ac3d62e..b305a79 100644 --- a/web/i18n/ar.js +++ b/web/i18n/ar.js @@ -419,7 +419,7 @@ export default { "Cross-rib height as a percentage of sway brace height. 10–90%, default 50%. Requires Brace tall walls and the brace to exceed its start height. Does not change the horizontal reach.": "ارتفاع الأضلاع العرضية كنسبة مئوية من ارتفاع دعامة منع التمايل. 10–90%، الافتراضي 50%. يتطلب «تدعيم الجدران العالية» ودعامة أعلى من ارتفاع البدء الخاص به. لا يغيّر الامتداد على السرير.", "{n} brace ribs": { one: "{n} ضلع دعامة", other: "{n} أضلاع دعامة" }, "that’s too low for a brace — move up the side": "هذا منخفض جدًا لدعامة — تحرّك إلى أعلى الجانب", - "Sway brace, full height: {mm} mm": "دعامة منع التمايل، بالارتفاع الكامل: {mm} مم", - "Sway brace, {mm} mm tall — move up the side for full height": "دعامة منع التمايل، بارتفاع {mm} مم — تحرّك إلى أعلى الجانب للارتفاع الكامل", + "Brace · full height {mm} mm": "دعامة · الارتفاع الكامل {mm} مم", + "Brace · {mm} mm · move up for full height": "دعامة · {mm} مم · تحرّك لأعلى للارتفاع الكامل", "Click two points across an overhang — straight onto the red faces — to lay a breakaway wall along that line. Hover an upright side to preview a sway brace: its top follows the cursor, and near the top of the side it runs full height. Click to stand it. Esc or right-click cancels.": "انقر على نقطتين عبر بروز معلّق — مباشرةً على الأوجه الحمراء — لوضع جدار قابل للكسر على امتداد الخط. ومرّر المؤشر فوق جانب رأسي لمعاينة دعامة تمنع التمايل: يتبع أعلاها المؤشر، وقرب أعلى الجانب تمتد بالارتفاع الكامل. انقر لإضافتها. اضغط Esc أو انقر بزر الفأرة الأيمن للإلغاء.", }; diff --git a/web/i18n/de.js b/web/i18n/de.js index 55fef02..ecd4b26 100644 --- a/web/i18n/de.js +++ b/web/i18n/de.js @@ -435,7 +435,7 @@ export default { "Cross-rib height as a percentage of sway brace height. 10–90%, default 50%. Requires Brace tall walls and the brace to exceed its start height. Does not change the horizontal reach.": "Höhe der Kreuzrippen in Prozent der Stützrippen-Höhe. 10–90 %, Standard 50 %. Braucht „Hohe Wände versteifen“ und eine Stützrippe über dessen Starthöhe. Ändert die Reichweite auf dem Druckbett nicht.", "{n} brace ribs": { one: "{n} Kreuzrippe", other: "{n} Kreuzrippen" }, "that’s too low for a brace — move up the side": "zu niedrig für eine Stützrippe – weiter oben an der Seite", - "Sway brace, full height: {mm} mm": "Stützrippe, volle Höhe: {mm} mm", - "Sway brace, {mm} mm tall — move up the side for full height": "Stützrippe, {mm} mm hoch – weiter oben an der Seite für volle Höhe", + "Brace · full height {mm} mm": "Stützrippe · volle Höhe {mm} mm", + "Brace · {mm} mm · move up for full height": "Stützrippe · {mm} mm · höher für volle Höhe", "Click two points across an overhang — straight onto the red faces — to lay a breakaway wall along that line. Hover an upright side to preview a sway brace: its top follows the cursor, and near the top of the side it runs full height. Click to stand it. Esc or right-click cancels.": "Klicken Sie zwei Punkte über einen Überhang – direkt auf die roten Flächen –, um eine Stützwand zu ziehen. Fahren Sie mit der Maus über eine aufrechte Seite, um eine Stützrippe vorab zu sehen: Ihre Oberkante folgt dem Mauszeiger, und nahe dem oberen Ende der Seite reicht sie über die volle Höhe. Klicken setzt sie. Esc oder Rechtsklick bricht ab.", }; diff --git a/web/i18n/nl.js b/web/i18n/nl.js index 7560be1..489001b 100644 --- a/web/i18n/nl.js +++ b/web/i18n/nl.js @@ -415,7 +415,7 @@ export default { "Cross-rib height as a percentage of sway brace height. 10–90%, default 50%. Requires Brace tall walls and the brace to exceed its start height. Does not change the horizontal reach.": "Hoogte van de kruisribben als percentage van de hoogte van de verstevigingsrib. 10–90%, standaard 50%. Werkt alleen met ribben voor hoge wanden aan en een verstevigingsrib boven de beginhoogte daarvan. Verandert het bereik op het printbed niet.", "{n} brace ribs": { one: "{n} kruisrib", other: "{n} kruisribben" }, "that’s too low for a brace — move up the side": "te laag voor een verstevigingsrib — ga hoger op de zijde", - "Sway brace, full height: {mm} mm": "Verstevigingsrib, volle hoogte: {mm} mm", - "Sway brace, {mm} mm tall — move up the side for full height": "Verstevigingsrib, {mm} mm hoog — ga hoger op de zijde voor volle hoogte", + "Brace · full height {mm} mm": "Rib · volle hoogte {mm} mm", + "Brace · {mm} mm · move up for full height": "Rib · {mm} mm · hoger voor volle hoogte", "Click two points across an overhang — straight onto the red faces — to lay a breakaway wall along that line. Hover an upright side to preview a sway brace: its top follows the cursor, and near the top of the side it runs full height. Click to stand it. Esc or right-click cancels.": "Klik op twee punten aan weerszijden van een overhang — rechtstreeks op de rode vlakken — om langs die lijn een afbreekbare steunwand te plaatsen. Beweeg over een rechtopstaand zijvlak om een verstevigingsrib vooraf te zien: de bovenkant volgt de cursor, en vlak bij de bovenkant van de zijde loopt hij over de volle hoogte. Klik om hem te plaatsen. Esc of rechtsklikken annuleert.", }; diff --git a/web/i18n/pt.js b/web/i18n/pt.js index ac5da0e..c4a6e7d 100644 --- a/web/i18n/pt.js +++ b/web/i18n/pt.js @@ -485,7 +485,7 @@ export default { "Cross-rib height as a percentage of sway brace height. 10–90%, default 50%. Requires Brace tall walls and the brace to exceed its start height. Does not change the horizontal reach.": "Altura das nervuras cruzadas como porcentagem da altura da escora lateral. 10–90%, padrão 50%. Exige Reforçar paredes altas e uma escora acima da altura inicial dele. Não muda o alcance na mesa.", "{n} brace ribs": { one: "{n} nervura de escora", other: "{n} nervuras de escora" }, "that’s too low for a brace — move up the side": "baixo demais para uma escora — suba pelo lado", - "Sway brace, full height: {mm} mm": "Escora lateral, altura total: {mm} mm", - "Sway brace, {mm} mm tall — move up the side for full height": "Escora lateral, {mm} mm de altura — suba pelo lado para a altura total", + "Brace · full height {mm} mm": "Escora · altura total {mm} mm", + "Brace · {mm} mm · move up for full height": "Escora · {mm} mm · suba para altura total", "Click two points across an overhang — straight onto the red faces — to lay a breakaway wall along that line. Hover an upright side to preview a sway brace: its top follows the cursor, and near the top of the side it runs full height. Click to stand it. Esc or right-click cancels.": "Clique em dois pontos atravessando um balanço — direto nas faces vermelhas — para criar uma parede destacável ao longo dessa linha. Passe o mouse sobre um lado vertical para ver a prévia de uma escora lateral: o topo segue o cursor, e perto do alto do lado ela vai até a altura total. Clique para colocá-la. Esc ou botão direito cancela.", }; diff --git a/web/i18n/zh.js b/web/i18n/zh.js index f27a9c0..4ea9e0c 100644 --- a/web/i18n/zh.js +++ b/web/i18n/zh.js @@ -529,7 +529,7 @@ export default { "Cross-rib height as a percentage of sway brace height. 10–90%, default 50%. Requires Brace tall walls and the brace to exceed its start height. Does not change the horizontal reach.": "十字肋高度占防晃撑高度的百分比。可调范围 10%~90%,默认 50%。需勾选「加固高墙」,且防晃撑超过加固起始高度才生效。不改变横向伸出长度。", "{n} brace ribs": { other: "{n} 条撑肋" }, "that’s too low for a brace — move up the side": "此处太低,放不下防晃撑——请沿侧面上移", - "Sway brace, full height: {mm} mm": "防晃撑,全高:{mm} mm", - "Sway brace, {mm} mm tall — move up the side for full height": "防晃撑,高 {mm} mm——沿侧面上移可得全高", + "Brace · full height {mm} mm": "防晃撑 · 全高 {mm} mm", + "Brace · {mm} mm · move up for full height": "防晃撑 · {mm} mm · 上移可得全高", "Click two points across an overhang — straight onto the red faces — to lay a breakaway wall along that line. Hover an upright side to preview a sway brace: its top follows the cursor, and near the top of the side it runs full height. Click to stand it. Esc or right-click cancels.": "在悬垂面上点击两点(直接点在红色面上),即可沿该线生成一堵易拆墙。将鼠标悬停在直立侧面上可预览防晃撑:撑顶跟随光标,靠近侧面顶部时为全高。点击即可放置。按 Esc 或右键取消。", }; diff --git a/web/style.css b/web/style.css index 0ca7d11..fca420d 100644 --- a/web/style.css +++ b/web/style.css @@ -818,11 +818,13 @@ html, body { transition: opacity .4s ease, visibility 0s linear .4s; } .vb-pop.vb-tip.show { opacity: 1; visibility: visible; transition: none; } -/* Draw's sway brace preview label (ui/walls.js): beside the cursor, glass like the - view bar's popovers, amber when the spot can't take a brace. */ +/* The sway brace preview label (ui/walls.js, eased by ui/braceghost.js): beside the + cursor, glass like the view bar's popovers, amber when the spot can't take a brace. + It fades rather than pops, and a min-width keeps it from resizing as the text changes. */ #brace-tip { position: fixed; z-index: 4; + min-width: 13rem; max-width: 18rem; padding: 4px 9px; border: 1px solid color-mix(in srgb, #fff 9%, transparent); @@ -833,9 +835,11 @@ html, body { font-size: 12px; font-variant-numeric: tabular-nums; pointer-events: none; + opacity: 0; visibility: hidden; + transition: opacity .12s ease, color .12s ease, visibility 0s linear .12s; } -#brace-tip.show { visibility: visible; } +#brace-tip.show { opacity: 1; visibility: visible; transition: opacity .12s ease, color .12s ease; } #brace-tip.bad { color: #ffb454; } /* any popover open (Suggest left open during a drag, Rotate opened mid-fade) wins */ #viewbar:has(.vb-pop:not([hidden]):not(.vb-tip)) #drag-angle { display: none; } diff --git a/web/ui/braceghost.js b/web/ui/braceghost.js new file mode 100644 index 0000000..05b7ab3 --- /dev/null +++ b/web/ui/braceghost.js @@ -0,0 +1,166 @@ +/** + * The sway brace hover preview's motion (walls.js decides WHAT it shows): the ghost + * fades in and out instead of popping, a big jump in height cross-fades, a pointer + * slipping off the upright side for a moment (an edge, a small facet) doesn't blink + * it, the label eases after the cursor, and the cursor dot eases to amber on a spot + * that can't take a brace. Nothing here changes which brace a click stands. + */ +import * as THREE from 'three'; +import { scene, meshFrom, MODEL, clip, requestRender } from './scene.js'; +import { el } from './dom.js'; + +const FADE_MS = 120; // ghost fade in / out +const GRACE_MS = 150; // off the side this long before the preview starts to fade +const OPACITY = 0.45; // the wall ghost's opacity +const CROSS_MM = 4; // a height change past this cross-fades; smaller ones swap in place +const TIP_EASE = 0.35; // share of the gap the label closes each frame +const CURSOR_OK = new THREE.Color(0xcffbe4), CURSOR_BAD = new THREE.Color(0xffb454); + +const tip = el('brace-tip'); +const ghostMat = () => new THREE.MeshStandardMaterial({ + color: 0x8ff0bd, roughness: 0.7, transparent: true, opacity: 0, + side: THREE.DoubleSide, clippingPlanes: clip, +}); +let ghosts = []; // { mesh, on }: `on` fades it in, otherwise out and away +let height = null; // the shown brace's height, to tell a step from a jump +let cursorMat = null, cursorBad = false; +let tipAt = null, tipTo = null; +let grace = 0, graceDone = null; +let running = false, lastT = 0; + +/** walls.js hands over its cursor dot's material, whose colour this eases. */ +export function initBraceGhost(material) { cursorMat = material; } + +/** Show the brace `tris` (height `h` mm); `tris` null = this spot is refused. */ +export function showBrace(tris, h, text, bad) { + cancelGrace(); + const cur = ghosts.find((g) => g.on); + if (!tris) { + if (cur) cur.on = false; + height = null; + } else if (cur && Math.abs(h - height) <= CROSS_MM) { + // a step: same mesh, new shape, opacity untouched + cur.mesh.geometry.dispose(); + cur.mesh.geometry = shape(tris); + height = h; + } else { + if (cur) cur.on = false; + const mesh = meshFrom(tris, ghostMat()); + if (mesh) { mesh.layers.disable(MODEL); ghosts.push({ mesh, on: true }); } + height = h; + } + cursorBad = bad; + tip.textContent = text; + tip.classList.toggle('bad', bad); + tip.classList.add('show'); + kick(); +} + +/** Where the label should sit (screen px); it eases there, or jumps if hidden. */ +export function moveTip(x, y) { + tipTo = [x, y]; + if (!tip.classList.contains('show') || !tipAt) { tipAt = [x, y]; place(); } + kick(); +} + +/** Fade the preview out, after GRACE_MS when `soft` (the pointer may come straight + * back). `then` runs when the fade starts, e.g. to hide the cursor dot. */ +export function hideBrace(soft, then) { + if (soft) { + if (grace) return; + graceDone = then; + grace = setTimeout(() => { grace = 0; const f = graceDone; graceDone = null; fadeAll(); f?.(); }, GRACE_MS); + return; + } + cancelGrace(); + fadeAll(); + then?.(); +} + +/** Gone this frame: the geometry is stale (a rebuild) or a click just placed it. */ +export function dropBrace() { + cancelGrace(); + for (const g of ghosts) remove(g); + ghosts = []; + height = null; + cursorBad = false; + if (cursorMat) cursorMat.color.copy(CURSOR_OK); + tip.classList.remove('show'); + tipAt = null; +} + +export const braceGrace = () => !!grace; + +function cancelGrace() { + if (grace) clearTimeout(grace); + grace = 0; + graceDone = null; +} + +function fadeAll() { + for (const g of ghosts) g.on = false; + height = null; + cursorBad = false; + tip.classList.remove('show'); + tipAt = null; + kick(); +} + +function shape(tris) { + const arr = new Float32Array(tris.length * 3); + for (let i = 0; i < tris.length; i++) arr.set(tris[i].slice(0, 3), i * 3); + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.BufferAttribute(arr, 3)); + g.computeVertexNormals(); + return g; +} + +function remove(g) { + scene.remove(g.mesh); + g.mesh.geometry.dispose(); + g.mesh.material.dispose(); +} + +function place() { + tip.style.left = `${tipAt[0]}px`; + tip.style.top = `${tipAt[1]}px`; +} + +function kick() { + if (running) return; + running = true; + lastT = performance.now(); + requestAnimationFrame(frame); +} + +/** One animation frame: step the fades, the label and the cursor colour; stop when all settle. */ +function frame(now) { + const step = Math.min(1, (now - lastT) / FADE_MS) * OPACITY; + lastT = now; + let busy = false; + for (const g of ghosts) { + const m = g.mesh.material, to = g.on ? OPACITY : 0; + m.opacity = to > m.opacity ? Math.min(to, m.opacity + step) : Math.max(to, m.opacity - step); + if (m.opacity !== to) busy = true; + } + for (const g of ghosts) if (!g.on && g.mesh.material.opacity === 0) remove(g); + ghosts = ghosts.filter((g) => g.on || g.mesh.material.opacity > 0); + if (tipAt && tipTo) { + const dx = tipTo[0] - tipAt[0], dy = tipTo[1] - tipAt[1]; + if (Math.abs(dx) + Math.abs(dy) > 0.5) { + tipAt = [tipAt[0] + dx * TIP_EASE, tipAt[1] + dy * TIP_EASE]; + busy = true; + } else tipAt = [...tipTo]; + place(); + } + if (cursorMat) { + const to = cursorBad ? CURSOR_BAD : CURSOR_OK, c = cursorMat.color; + if (Math.abs(c.r - to.r) + Math.abs(c.g - to.g) + Math.abs(c.b - to.b) > 0.01) { + c.lerp(to, Math.min(1, step / OPACITY)); + busy = true; + } else c.copy(to); + } + requestRender(100); + if (busy) requestAnimationFrame(frame); + else running = false; +} diff --git a/web/ui/walls.js b/web/ui/walls.js index d85a4c4..4beddfb 100644 --- a/web/ui/walls.js +++ b/web/ui/walls.js @@ -9,6 +9,7 @@ import * as THREE from 'three'; import { drawnWall } from '../draw.js'; import { planGrid } from '../fins/tall.js'; import { swayAtFace, faceIsUpright } from '../sway.js'; +import { initBraceGhost, showBrace, moveTip, hideBrace, dropBrace, braceGrace } from './braceghost.js'; import { el } from './dom.js'; import { viewport, renderer, scene, camera, meshFrom, ifaceMaterial, raycaster, pointer, clip, MODEL } from './scene.js'; import { keptHit } from './section.js'; @@ -63,7 +64,7 @@ const guideMat = (color) => new THREE.MeshBasicMaterial( const drawDot = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 14), guideMat(0x59d98e)); // The cursor is see-through so the surface under it stays readable; the OS // crosshair is the precise aim point, this dot just shows the surface hit. -const CURSOR_OK = 0xcffbe4, CURSOR_BAD = 0xffb454; +const CURSOR_OK = 0xcffbe4; // braceghost.js eases it to amber on a refused brace spot const drawCursor = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 14), new THREE.MeshBasicMaterial({ color: CURSOR_OK, depthTest: false, transparent: true, opacity: 0.6 })); const bandGeom = new THREE.BufferGeometry() @@ -71,6 +72,7 @@ const bandGeom = new THREE.BufferGeometry() const drawBand = new THREE.Line(bandGeom, new THREE.LineBasicMaterial({ color: 0x6dffab, depthTest: false, transparent: true })); drawBand.frustumCulled = false; // its endpoints move every frame; stale bounds would cull it +initBraceGhost(drawCursor.material); for (const o of [drawDot, drawCursor, drawBand]) { o.visible = false; o.renderOrder = 11; // above the hoverFace (renderOrder 1) and the part @@ -132,8 +134,7 @@ export function clearPreview() { function dropGhost() { if (ghostMesh) { scene.remove(ghostMesh); ghostMesh.geometry.dispose(); ghostMesh = null; } braceShown = null; - braceTip.classList.remove('show'); - drawCursor.material.color.setHex(CURSOR_OK); + dropBrace(); } /** Rebuild the committed drawn walls for the current orientation. */ @@ -365,11 +366,11 @@ function autoSupports() { // The ghost IS the brace the click builds (both go through braceAt). Its top follows // the cursor; in the top quarter of what a brace could reach there it snaps to the // whole face, the height a brace always had before the clicked-height cap. A spot -// that can't take one turns the cursor red and says why, before any click. +// that can't take one turns the cursor amber and says why, before any click. +// braceghost.js fades and eases what this shows, so moving about doesn't flash. const SNAP_TOP = 0.75; -const STEP_Z = 2; // capped tops in 2 mm steps, so a creeping cursor reuses its ghost +const STEP_Z = 1; // capped tops in 1 mm steps, so a creeping cursor reuses its ghost const MEMO_MAX = 200; -const braceTip = el('brace-tip'); let braceMemo = { stamp: null, map: new Map() }; let braceShown = null; // the braceAt key the ghost and tip show now let braceQueued = null; @@ -415,8 +416,7 @@ function updateBracePreview(hit, ev) { drawCursor.position.copy(hit.point); drawCursor.visible = true; drawBand.visible = drawDot.visible = false; - braceTip.style.left = `${ev.clientX + 16}px`; - braceTip.style.top = `${ev.clientY + 16}px`; + moveTip(ev.clientX + 16, ev.clientY + 16); // at most one brace build per frame, as the wall ghost const already = !!braceQueued; braceQueued = { face: hit.faceIndex, p: [hit.point.x, hit.point.y, hit.point.z] }; @@ -427,26 +427,32 @@ function updateBracePreview(hit, ev) { if (!q || drawStart || !(drawActive() || braceActive())) return; const b = braceAt(q.face, q.p); if (b.key === braceShown) return; - dropGhost(); braceShown = b.key; - if (b.r.ok) { - ghostMesh = meshFrom(b.r.tris, ghostMaterial); - ghostMesh?.layers.disable(MODEL); - } else drawCursor.material.color.setHex(CURSOR_BAD); const mm = Math.round(b.r.height ?? 0); - braceTip.textContent = !b.r.ok ? t(b.r.reason) - : b.full ? t('Sway brace, full height: {mm} mm', { mm }) - : t('Sway brace, {mm} mm tall — move up the side for full height', { mm }); - braceTip.classList.toggle('bad', !b.r.ok); - braceTip.classList.add('show'); + showBrace(b.r.ok ? b.r.tris : null, b.r.height ?? 0, + !b.r.ok ? t(b.r.reason) + : b.full ? t('Brace · full height {mm} mm', { mm }) + : t('Brace · {mm} mm · move up for full height', { mm }), + !b.r.ok); }); } renderer.domElement.addEventListener('pointerleave', () => { braceQueued = null; - if (!drawStart) { drawCursor.visible = false; dropGhost(); } + if (!drawStart) { drawCursor.visible = false; braceShown = null; hideBrace(false); } }); +/** The pointer is on the part but off an upright side: the dot follows it, and the + * brace stays a moment (an edge or a small facet on the way) before it fades. */ +function slipOff(hit, keepCursor) { + braceQueued = null; + if (!braceShown) return false; + braceShown = null; + drawCursor.position.copy(hit.point); + hideBrace(true, keepCursor ? null : () => { drawCursor.visible = false; }); + return true; +} + /** Stand a sway brace on the upright face the user clicked: the one its preview showed. */ function placeSway(hit) { braceQueued = null; @@ -503,14 +509,19 @@ export function drawHover(ev) { updateBracePreview(hit, ev); renderer.domElement.style.cursor = 'crosshair'; } else if (hit) { - braceQueued = null; - if (braceShown) dropGhost(); + slipOff(hit, true); // Draw's own cursor carries on over the rest of the part updatePreview(hit.point); renderer.domElement.style.cursor = 'crosshair'; + } else if (!drawStart) { + drawBand.visible = false; + braceAway(); // off the part: a gap crossed on the way doesn't blink the brace + renderer.domElement.style.cursor = ''; } else { braceQueued = null; drawCursor.visible = drawBand.visible = false; - dropGhost(); + braceShown = null; + hideBrace(false); + if (ghostMesh) { scene.remove(ghostMesh); ghostMesh.geometry.dispose(); ghostMesh = null; } renderer.domElement.style.cursor = ''; } } @@ -524,15 +535,33 @@ export function braceHover(ev) { renderer.domElement.style.cursor = 'crosshair'; return true; } - dropBracePreview(); + if (hit && (slipOff(hit, false) || braceGrace())) { + drawCursor.position.copy(hit.point); // still fading in its grace: keep following + return false; + } + if (!hit) braceAway(); return false; } -/** Hide braceActive's cursor, ghost and tip (off an upright side, or while an orbit - * or a ring drag has the pointer). */ +/** The pointer left the part (a gap, the background) or is over the rotate rings + * without dragging: the same grace as slipOff, then the preview and its dot go. */ +export function braceAway() { + braceQueued = null; + if (!braceShown && !braceGrace()) { + if (drawCursor.visible) { drawCursor.visible = false; renderer.domElement.style.cursor = ''; } + return; + } + braceShown = null; + hideBrace(true, () => { drawCursor.visible = false; renderer.domElement.style.cursor = ''; }); +} + +/** Fade braceActive's ghost and tip and hide its cursor (an orbit or a ring drag + * has the pointer). */ export function dropBracePreview() { braceQueued = null; - if (drawCursor.visible) { drawCursor.visible = false; dropGhost(); renderer.domElement.style.cursor = ''; } + braceShown = null; + hideBrace(false); + if (drawCursor.visible) { drawCursor.visible = false; renderer.domElement.style.cursor = ''; } } /** braceActive's click: select a support placed by hand, or stand a brace on an