// ==UserScript== // @name 酷家乐编辑器:Firefox滚轮修复 // @namespace https://www.kujiale.com/ // @version 1.2 // @description Firefox 滚轮缩放与 Edge 一致(不受系统滚动行数影响);中键双击=缩放到最佳可见;2D 左键框选=放大框内区域;画布竖向缩放控件(2D 按钮上方) // @author hejie13250 // @match *://*.kujiale.com/vc/modeleditor/* // @run-at document-start // @grant none // ==/UserScript== /* 作用: 一、酷家乐参数化编辑器在 Firefox 中,滚轮缩放受系统中鼠标滚动行(默认是3行)数影响,而 Chrome/Edge 则不受这个影响,这就导致在 Firefox 中每次使用滚轮就等同于在 Chrome/Edge 中滚动三次,现通过脚本修复这个问题,与 Chrome/Edge 一致; 二、在 Firefox 中,切换到 2D 模式,缩放完全失效,原因是官方没作适配,在脚本中添加上这个功能,并且改成 10 倍缩放系数,默认值太小,滚轮要滚到冒烟; 三、实验功能: 1、中键双击=缩放到最佳可见(不好用); 2、2D 左键框选=放大框内区域(不好用); 3、画布竖向缩放控件(2D 按钮上方,已隐藏)。 ============================ 一、滚轮修复(仅 Firefox) ============================ 应用里有两类滚轮缩放控件: A. 3D 轨道控制(TrackballControls):读 wheelDelta / detail。 B. 平面(正投影)控制 orthographicCameraControls:onMousewheel = d => onZoom(-d.deltaY),读 deltaY。 onZoom: scaleRatio ±= 0.1*f(有 min/max 夹取);updateZoom 里 camera.zoom = scaleRatio。 Chrome/Edge 的 mousewheel 同时有 wheelDelta(恒 ±120) 和 deltaY(±100),所以正常; Firefox 不派发 mousewheel,只派发 MozMousePixelScroll(既无 wheelDelta 也无 deltaY,只有 detail ≈ 系统「一次滚动行数」× ~21),于是 A 系数被放大、B 直接拿到 undefined 不动。 修法:① 注册 mousewheel 时同步注册 MozMousePixelScroll;② 捕获阶段就地改写三个字段为「一格固定量」。 ============================ 二、画布导航(所有浏览器都装) ============================ ★ 关键事实(本次逆向确认): 1) three.js 的 scene / renderer / 透视相机 **都不在页面里** —— React fiber 树里只有 props.orthographicCameraControls。所以「从 scene 走 mesh 算包围盒」这条路是死的, 这也是旧版 fitView 一直返回 false 的根因。 2) 模型包围盒改从应用自曝的调试接口取: window.testGetSceneInfo() → { success, data: { modelInstances: [ { name, bbox:{min,max} } ] } } 同步的 getCutModelBoundingSize 走的是 webpack 模块内部,页面拿不到;这个接口 12ms 就有结果。 注意:脏数据 bbox 会是 ±1e37(three.js 空盒),必须过滤。 3) 2D 视图的可用杠杆(已实测能触发重绘): ctl.scaleRatio = z; // 保存缩放值 ctl.camera.zoom = z; updateProjectionMatrix(); ctl.camera.position —— 直接改(正交相机下,相机在自身基向量上的投影就等于视口中心) ctl.config.onControlChange(); // 真正的重绘触发器 ⚠ 不要用 ctl.update():它内部 updatePan() 会读 O[this.mode],而 mode 未设置时会抛错, 抛在 onControlChange() 之前 → 数值改了但画面不刷新。所以这里一律自己算、自己调 onControlChange。 ⚠ 不要用 ctl.panOffset:那要靠 updatePan 消费,同样受 mode 影响。直接改 camera.position 最稳。 4) 正交相机下「平移与缩放无关」:要让模型中心落到画布中心,只需 camera.position += R*(cR - camPosR) + U*(cU - camU) // R/U = 相机世界右/上方向 不需要知道 zoom。 5) 3D:PARAM_MODEL_TOOL.test.setCamera(p) 在本版本改的是一个陈旧相机对象 —— 实测无任何画面变化。 因此 3D 分支只能尽力而为(见 FIT3D_*),失败会在面板里说明原因。 ==================================================================================== */ (function () { 'use strict'; var DEBUG = false; // 右上角调试面板 + `window.__NAV_LOG`(要排查时改 true) var ZOOM_CTL = false; // 画布缩放控件(+ / 竖向滑块 / − / 适应)。要显示就改 true var STEP_BASE = 0.02; // 应用在 2D 下每格滚轮改多少 scaleRatio(实测值,一般不用改) var STEP_MULT = 10; // ★ 2D 滚轮缩放步长倍数(改这一个数字即可;1 = 恢复原始手感) var IS_FIREFOX = /\bFirefox\//.test(navigator.userAgent); /* ===================================================================== ③ 画布导航增强 ===================================================================== */ var FIT_MARGIN_2D = 0.92; // 2D 适应留白:模型占画布 92% var BOX_MIN_PX = 8; // 框选小于这个尺寸不算 var DRAG_THRESHOLD = 6; // 判定为拖动的最小位移 var DBL_MS = 450; // 中键双击间隔 (function canvasNav() { var panel = null, hideTimer = 0; function showDebug(text) { if (DEBUG) { var lg = window.__NAV_LOG || (window.__NAV_LOG = []); lg.push(new Date().toTimeString().slice(0, 8) + '.' + String(Date.now() % 1000) + ' ' + String(text).split('\n')[0]); if (lg.length > 40) lg.shift(); } if (!DEBUG) return; if (!panel) { var oldPanel = document.getElementById('kjlNavPanel'); if (oldPanel && oldPanel.parentNode) oldPanel.parentNode.removeChild(oldPanel); panel = document.createElement('div'); panel.id = 'kjlNavPanel'; panel.style.cssText = 'position:fixed;top:8px;right:8px;z-index:2147483647;background:rgba(0,0,0,.78);' + 'color:#fff;font:12px/1.5 Consolas,monospace;padding:6px 8px;border-radius:4px;pointer-events:none;white-space:pre;max-width:470px'; (document.body || document.documentElement).appendChild(panel); } panel.textContent = text; panel.style.display = 'block'; clearTimeout(hideTimer); hideTimer = setTimeout(function () { if (panel) panel.style.display = 'none'; }, 3200); } window.__NAV_SHOWDEBUG = showDebug; /* ---------- 1. 通过 React fiber 定位应用对象 ---------- */ function fiberRoot() { var sels = ['.three-view-container', '.three-view', 'canvas']; for (var i = 0; i < sels.length; i++) { var n = document.querySelector(sels[i]); while (n) { var k = Object.keys(n).find(function (k) { return k.indexOf('__reactFiber$') === 0 || k.indexOf('__reactInternalInstance$') === 0; }); if (k) return n[k]; n = n.parentElement; } } return null; } function collectAll() { var out = [], seen = []; function push(o, p) { if (o && typeof o === 'object' && seen.indexOf(o) < 0) { seen.push(o); out.push({ o: o, p: p }); } } var f = fiberRoot(), steps = 0; while (f && steps < 40) { if (f.memoizedProps) push(f.memoizedProps, 'props#' + steps); var h = f.memoizedState, hi = 0; while (h && hi < 30) { if (h.memoizedState && typeof h.memoizedState === 'object') push(h.memoizedState, 'hook' + hi + '#' + steps); h = h.next; hi++; } if (f.stateNode && typeof f.stateNode === 'object') push(f.stateNode, 'node#' + steps); f = f.return; steps++; } out.slice().forEach(function (rec) { try { Object.keys(rec.o).forEach(function (k) { if (k === 'children' || k === '_owner' || k === 'ref' || k === 'type') return; var v = rec.o[k]; if (v && typeof v === 'object' && seen.indexOf(v) < 0) { seen.push(v); out.push({ o: v, p: rec.p + '.' + k }); } }); } catch (_) {} }); return out; } // 2D 正投影控制器:必须有 camera.isOrthographicCamera + updateZoom/updatePan function findOrtho() { var all = collectAll(); for (var i = 0; i < all.length; i++) { var o = all[i].o; try { if (o.camera && o.camera.isOrthographicCamera && o.mount && (typeof o.updateZoom === 'function' || typeof o.updatePan === 'function')) return o; } catch (_) {} } return null; } // 3D:轨道控制持有者(有 cameraControls 的对象)或裸透视相机 function findPersp() { var all = collectAll(); for (var i = 0; i < all.length; i++) { var o = all[i].o; try { if (o.cameraControls) return { ctl: o, cam: o.camera || o.cameraControls.object }; } catch (_) {} try { if (o.camera && o.camera.isPerspectiveCamera) return { ctl: o, cam: o.camera }; } catch (_) {} try { if (o.isPerspectiveCamera) return { ctl: null, cam: o }; } catch (_) {} } return null; } /* ---------- 2. 模型世界包围盒(应用自带调试接口) ---------- */ var boxCache = { t: 0, v: null }; function boxFromMinMax(min, max, n) { return { min: min, max: max, count: n, center: { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 }, size: { x: max.x - min.x, y: max.y - min.y, z: max.z - min.z }, radius: 0.5 * Math.sqrt(Math.pow(max.x - min.x, 2) + Math.pow(max.y - min.y, 2) + Math.pow(max.z - min.z, 2)) }; } // 同步版:只用缓存(点击瞬间要立刻出结果时用) function boxSync() { return boxCache.v; } // 给第三方 Promise 加超时,避免应用接口卡住时把调用链吊死 function withTimeout(p, ms, fallback) { return new Promise(function (res) { var done = false; var t = setTimeout(function () { if (!done) { done = true; res(fallback); } }, ms); Promise.resolve(p).then( function (v) { if (!done) { done = true; clearTimeout(t); res(v); } }, function () { if (!done) { done = true; clearTimeout(t); res(fallback); } } ); }); } // 异步版:向应用要最新包围盒 function modelWorldBox() { var now = Date.now(); if (boxCache.v && now - boxCache.t < 1000) return Promise.resolve(boxCache.v); var fn = window.testGetSceneInfo; if (typeof fn !== 'function') return Promise.resolve(boxCache.v); var p; try { p = fn(); } catch (_) { return Promise.resolve(boxCache.v); } return withTimeout(p, 1500, null).then(function (r) { var arr = r && r.data && r.data.modelInstances; if (!arr || !arr.length) return boxCache.v; var min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity }; var ok = 0, bad = 0, i, k; for (i = 0; i < arr.length; i++) { var bb = arr[i] && arr[i].bbox; if (!bb || !bb.min || !bb.max) { bad++; continue; } var vs = [bb.min.x, bb.min.y, bb.min.z, bb.max.x, bb.max.y, bb.max.z], dirty = false; for (k = 0; k < 6; k++) { if (typeof vs[k] !== 'number' || !isFinite(vs[k]) || Math.abs(vs[k]) > 1e6) { dirty = true; break; } } if (dirty) { bad++; continue; } // ±1e37 之类的空盒 ok++; if (bb.min.x < min.x) min.x = bb.min.x; if (bb.max.x > max.x) max.x = bb.max.x; if (bb.min.y < min.y) min.y = bb.min.y; if (bb.max.y > max.y) max.y = bb.max.y; if (bb.min.z < min.z) min.z = bb.min.z; if (bb.max.z > max.z) max.z = bb.max.z; } if (!ok) return boxCache.v; var box = boxFromMinMax(min, max, ok); box.skipped = bad; boxCache = { t: Date.now(), v: box }; return box; }).catch(function () { return boxCache.v; }); } /* ---------- 3. 应用正交视图(本版本唯一实测有效的杠杆) ---------- */ // newZoom: 目标 scaleRatio;shiftXPx / shiftYPx: 希望「模型」在屏幕上移动多少像素(+右 / +下) // 返回 null 表示成功,否则返回失败原因字符串 function applyOrtho(newZoom, shiftXPx, shiftYPx) { var ctl = findOrtho(); if (!ctl) return '找不到正交控制器'; var cam = ctl.camera, mount = ctl.mount; if (!cam || !mount) return '控制器缺少 camera/mount'; var W = mount.clientWidth, H = mount.clientHeight; if (!W || !H) return '画布尺寸为 0'; var z0 = cam.zoom || ctl.scaleRatio || 1; cam.updateMatrixWorld(); var e = cam.matrixWorld.elements; var R = { x: e[0], y: e[1], z: e[2] }; // 相机世界「右」 var U = { x: e[4], y: e[5], z: e[6] }; // 相机世界「上」 // 正交投影:屏幕 1px = 1/zoom 世界单位。要让模型右移 sx → 相机沿 R 反向移动 sx/zoom; // 让模型下移 sy → 相机沿 U 反向移动 sy/zoom。 var dR = -(shiftXPx || 0) / z0, dU = (shiftYPx || 0) / z0; cam.position.x += R.x * dR + U.x * dU; cam.position.y += R.y * dR + U.y * dU; cam.position.z += R.z * dR + U.z * dU; cam.zoom = newZoom; cam.updateProjectionMatrix(); cam.updateMatrixWorld(); ctl.scaleRatio = newZoom; ctl.panOffset = { x: 0, y: 0 }; // 防止后续 updatePan 拿 undefined mode 去索引 try { if (ctl.config && ctl.config.onControlChange) ctl.config.onControlChange(); } catch (_) {} return null; } /* ---------- 4. 「缩放到当前画布可见最大」(2D) ---------- */ var baseZoom = 0; // 「整模刚好铺满」时的 zoom,作为缩放控件的 100% 基准 // 纯计算:给定包围盒 → 目标 zoom + 需要的屏幕位移(不改相机) function fitParams(box) { var ctl = findOrtho(); if (!ctl || !ctl.camera || !ctl.mount) return { err: '找不到正交控制器' }; var cam = ctl.camera, W = ctl.mount.clientWidth, H = ctl.mount.clientHeight; if (!W || !H) return { err: '画布尺寸为 0' }; cam.updateMatrixWorld(); var e = cam.matrixWorld.elements; var R = { x: e[0], y: e[1], z: e[2] }, U = { x: e[4], y: e[5], z: e[6] }; // 包围盒 8 个角在相机基上的投影范围 = 模型在屏幕两个轴上的世界跨度 var minR = Infinity, maxR = -Infinity, minU = Infinity, maxU = -Infinity; var xs = [box.min.x, box.max.x], ys = [box.min.y, box.max.y], zs = [box.min.z, box.max.z]; for (var a = 0; a < 2; a++) for (var b = 0; b < 2; b++) for (var c2 = 0; c2 < 2; c2++) { var px = xs[a], py = ys[b], pz = zs[c2]; var dr = px * R.x + py * R.y + pz * R.z; var du = px * U.x + py * U.y + pz * U.z; if (dr < minR) minR = dr; if (dr > maxR) maxR = dr; if (du < minU) minU = du; if (du > maxU) maxU = du; } var extR = Math.max(1e-4, maxR - minR), extU = Math.max(1e-4, maxU - minU); var z0 = cam.zoom || ctl.scaleRatio || 1; var camR = cam.position.x * R.x + cam.position.y * R.y + cam.position.z * R.z; var camU = cam.position.x * U.x + cam.position.y * U.y + cam.position.z * U.z; var cR = (minR + maxR) / 2, cU = (minU + maxU) / 2; return { zoom: Math.min(W * FIT_MARGIN_2D / extR, H * FIT_MARGIN_2D / extU), ext: [extR, extU], sx: -(cR - camR) * z0, // 模型当前右偏的负值 sy: (cU - camU) * z0 // 模型当前「屏幕下偏」的负值(屏幕 y 与相机 up 反向) }; } // 返回 Promise<{ok, why, ...}> function fit2D() { return modelWorldBox().then(function (box) { if (!box) return { ok: false, why: '取不到模型包围盒(testGetSceneInfo 无有效实例)' }; var p = fitParams(box); if (p.err) return { ok: false, why: p.err }; var err = applyOrtho(p.zoom, p.sx, p.sy); if (err) return { ok: false, why: err }; baseZoom = p.zoom; return { ok: true, zoom: p.zoom, ext: p.ext, box: box, shift: [Math.round(p.sx), Math.round(p.sy)] }; }); } /* ---------- 5. 框选放大(2D):让选框内区域占满画布 ---------- */ function zoomBox2D(rect) { var ctl = findOrtho(); if (!ctl || !ctl.camera || !ctl.mount) return { ok: false, why: '找不到正交控制器' }; var cam = ctl.camera, W = ctl.mount.clientWidth, H = ctl.mount.clientHeight; var bw = Math.abs(rect.w), bh = Math.abs(rect.h); if (bw < BOX_MIN_PX || bh < BOX_MIN_PX || !W || !H) return { ok: false, why: '选框太小' }; var z0 = cam.zoom || ctl.scaleRatio || 1; var factor = Math.min(W / bw, H / bh); // 屏幕尺寸 ∝ zoom,线性放大 var newZoom = z0 * factor; var dx = rect.x + rect.w / 2 - W / 2; // 选框中心相对画布中心的偏移 var dy = rect.y + rect.h / 2 - H / 2; // 放大 factor 倍后该偏移也变成 factor 倍 → 反推需要把模型移回去 var err = applyOrtho(newZoom, -dx * factor, -dy * factor); if (err) return { ok: false, why: err }; return { ok: true, zoom: newZoom, factor: factor }; } /* ---------- 6. 3D 适应(本版本 3D 相机不可达,尽力而为) ---------- */ function fit3D() { var box = boxSync(); if (!box) return { ok: false, why: '取不到模型包围盒' }; var hit = findPersp(); if (!hit || !hit.cam) return { ok: false, why: '3D 相机不可达(fiber 树里没有透视相机)' }; var cam = hit.cam; if (!cam.isPerspectiveCamera && !cam.isCamera) return { ok: false, why: '取到的不是相机' }; var mount = document.querySelector('.three-view'); var W = mount ? mount.clientWidth : 0, H = mount ? mount.clientHeight : 0; var aspect = cam.aspect || (W && H ? W / H : 1); var vFov = (cam.fov || 45) * Math.PI / 180; var hFov = 2 * Math.atan(Math.tan(vFov / 2) * aspect); var half = Math.min(vFov, hFov) / 2; var dist = box.radius / Math.sin(half) * FIT_MARGIN_2D; // 保持当前视线方向:dir = normalize(cam.position - 当前注视点) var tgt = (hit.ctl && hit.ctl.cameraControls && hit.ctl.cameraControls.target) || box.center; var dx = cam.position.x - tgt.x, dy = cam.position.y - tgt.y, dz = cam.position.z - tgt.z; var len = Math.sqrt(dx * dx + dy * dy + dz * dz); if (!(len > 1e-6)) { var s = 1 / Math.sqrt(3); dx = s; dy = s; dz = s; len = 1; } dx /= len; dy /= len; dz /= len; cam.position.x = box.center.x + dx * dist; cam.position.y = box.center.y + dy * dist; cam.position.z = box.center.z + dz * dist; if (hit.ctl && hit.ctl.cameraControls) { var t = hit.ctl.cameraControls.target; if (t && t.set) t.set(box.center.x, box.center.y, box.center.z); } try { cam.updateProjectionMatrix(); cam.updateMatrixWorld(); } catch (_) {} try { if (hit.ctl && hit.ctl.cameraControls && hit.ctl.cameraControls.update) hit.ctl.cameraControls.update(); } catch (_) {} // 借一对反向滚轮事件强制重绘(净缩放为 0) var cv = document.querySelector('.three-view'); if (cv) { var r = cv.getBoundingClientRect(); var x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2); [1, -1].forEach(function (d) { try { cv.dispatchEvent(new WheelEvent('mousewheel', { deltaY: d, bubbles: true, cancelable: true, clientX: x, clientY: y })); } catch (_) {} }); } return { ok: true, dist: dist, dir: [dx, dy, dz] }; } /* ---------- 6.5 2D 滚轮步长放大(不影响 3D) ---------- */ // 应用 2D 的正交控制器 onZoom 是 `s<0 ? scaleRatio-=.1*f : s>0 && (scaleRatio+=.1*f)`, // **只看 s 的符号**(实测 .1*f = 0.02),所以改 deltaY 的大小毫无作用。 // 这里在应用自己那一格落地之后,再往正交相机上补 (MULT-1) 格 → 2D 实际步长变成 MULT 倍。 // ★ 只动正交相机:3D 下它是休眠相机,画面完全不受影响 —— 所以「3D 不变」是天然的,不需要判模式。 var boostPending = null; // 连续滚动时合并成一次设置 function wheelBoost(e) { if (STEP_MULT <= 1) return; if (isConfirmed3D()) return; // 确认 3D 时完全不动手 → 3D 行为一个字都不变 var t = e.target; if (!t || !t.closest || !t.closest('.three-view-container')) return; var d = 0; if (typeof e.deltaY === 'number' && e.deltaY !== 0) d = e.deltaY; else if (typeof e.wheelDelta === 'number' && e.wheelDelta !== 0) d = -e.wheelDelta; else if (typeof e.detail === 'number') d = e.detail; if (!d) return; // ★ 以「事件到达之前」的值为基准算绝对目标,避免依赖应用那一格先落地 // (实测 Edge 的程序化滚轮只派发 wheel、不派发 mousewheel,应用的正交控制器收不到) var ctl = findOrtho(); if (!ctl || typeof ctl.scaleRatio !== 'number') return; var base = (boostPending !== null) ? boostPending : ctl.scaleRatio; var sign = d < 0 ? 1 : -1; // deltaY<0(滚轮上)= 放大,与应用一致 var next = base + STEP_BASE * STEP_MULT * sign; if (!(next > 0.001 && next < 1e4)) return; // 防越界 boostPending = next; setTimeout(function () { var target = boostPending; boostPending = null; if (target === null) return; if (findOrtho()) applyOrtho(target, 0, 0); }, 0); } ['mousewheel', 'MozMousePixelScroll', 'DOMMouseScroll', 'wheel'].forEach(function (type) { window.addEventListener(type, wheelBoost, { capture: true, passive: true }); }); /* ---------- 7. 2D / 3D 模式判定 ---------- */ // ★ 这一节是被坑最久的地方,结论写在前面: // 应用的显示模式**无法从外部可靠读出**。逐条验证过并全部否掉: // · 底部两个按钮的 computed color —— 常态色固定,不透明的 rgb(26,122,248) 其实是 hover 色 // · 按钮 class/属性 —— 只有 tuipro-bottombar-viewswitch[-active],而 -active 会来回变,不表示当前模式 // · 全文档类名集合 / .three-view-container 子树逐节点 diff —— 2D 与 3D 只差「3D 按钮有没有 -active」一处,且不可靠 // · localStorage / sessionStorage / cookie —— 切模式前后零变化(模式只在内存里) // · fiber 树(含 rootState)—— 没有任何值等于 2D/3D 或 2/3 的字段 // · 控制器是否 awaken(登记 addEventListener)—— 正交控制器的滚轮处理器压根没登记过 // · 改正交缩放后画布是否重绘(截图 / canvas.toDataURL 指纹)—— 两个模式都会变,分不开 // 唯一 100% 可靠的信号:**用户点了哪个按钮**。 // ★ 策略「安全优先」:userMode 初始 null(未知)→ 左键绝不抢(3D 下左键是旋转视角,抢了就坏); // 用户点过 2D 之后才启用框选。缩放控件不依赖它(非确认 3D 时走正交、确认 3D 时借滚轮)。 var userMode = null; function noteMode(m) { if (m === '2D' || m === '3D') userMode = m; } function modeFromClick(e) { var b = e.target && e.target.closest && e.target.closest('.tuipro-bottombar-viewswitch'); if (!b) { // 两个视图菜单里的视角项也是可靠的模式信号: // view2d-menu-content-item = 顶/底/左/右/前/后(正投影,仅 2D 有意义) // view3d-menu-content-item = 左前/左后/右前/右后(等轴测,仅 3D 有意义) var it = e.target && e.target.closest && e.target.closest('.view2d-menu-content-item,.view3d-menu-content-item'); if (it) noteMode(it.className.indexOf('view3d-') >= 0 ? '3D' : '2D'); return; } var t = (b.textContent || '').trim(); if (t === '2D' || t === '3D') noteMode(t); } window.addEventListener('mousedown', modeFromClick, { capture: true, passive: true }); window.addEventListener('click', modeFromClick, { capture: true, passive: true }); // 仅供调试面板显示 function modeCandidates() { try { return [].slice.call(document.querySelectorAll('.tuipro-bottombar-viewswitch')) .filter(function (b) { var t = b.textContent.trim(); return t === '2D' || t === '3D'; }) .map(function (b) { var el = b.querySelector('.tuipro-bottombar-viewswitch-text') || b; return { t: b.textContent.trim(), color: getComputedStyle(el).color || '' }; }); } catch (_) { return []; } } function currentMode() { return userMode; } // 严格 2D:只有用户明确点过 2D 才算(3D 下左键是旋转模型,绝不能被框选抢走) function isPure2D() { return userMode === '2D'; } // 明确 3D(用户点过 3D)——缩放控件在他模式下借滚轮 function isConfirmed3D() { return userMode === '3D'; } /* ---------- 8. 中键双击 → 缩放到最佳可见 ---------- */ var lastMid = 0, busy = false; function onMidDown(e) { if (!(e.button === 1 || e.which === 2)) return; // 中键(Firefox=1,个别环境=2) var t = e.target; if (!t || !t.closest || !t.closest('.three-view-container')) return; if (e.__navMidV3) return; // window/document 双监听去重 e.__navMidV3 = 1; e.preventDefault(); var now = Date.now(); if (now - lastMid < DBL_MS) { lastMid = 0; if (busy) return; busy = true; // 兜底:无论成败 5s 后一定解锁,避免一次异常把后续双击全废掉 var release = function () { busy = false; }; setTimeout(release, 5000); var mode = currentMode() || '未知'; Promise.resolve().then(fit2D).then(function (r2) { var r3 = { ok: false, why: '未执行' }; try { r3 = fit3D(); } catch (err) { r3 = { ok: false, why: '异常 ' + err.message }; } var lines = ['中键双击 模式=' + mode]; lines.push(r2.ok ? '2D ✓ zoom=' + r2.zoom.toFixed(4) + ' 跨度=' + r2.ext[0].toFixed(0) + '×' + r2.ext[1].toFixed(0) : '2D ✗ ' + r2.why); lines.push(r3.ok ? '3D ✓ dist=' + r3.dist.toFixed(0) : '3D ✗ ' + r3.why); if (mode !== '2D' && r2.ok && !r3.ok) lines.push('(3D 相机不可达,本版本 3D 适应暂不可用)'); showDebug(lines.join('\n')); }).catch(function (err) { showDebug('中键双击异常:' + (err && err.message)); }).then(release, release); } else { lastMid = now; showDebug('中键第 1 下(再按一下 = 缩放到最佳可见)'); } } window.addEventListener('mousedown', onMidDown, { capture: true, passive: false }); document.addEventListener('mousedown', onMidDown, { capture: true, passive: false }); /* ---------- 9. Ctrl+左键框选 → 放大框内区域 ---------- */ // ★ 触发条件:**只有 Ctrl+左键**(也接受 Mac 的 Cmd)。 // ⚠️ 千万不要把「普通左键」也纳进来:mousedown 里会 preventDefault + stopImmediatePropagation, // 一旦纳进来,处于 2D 时画布上的普通左键点击会被整个吃掉(应用收不到 mousedown)→ 点击全失效。 // 好处:与应用「左键拖动旋转/选择」彻底解耦,**完全不需要判断 2D/3D**。 var marquee = null, start = null, dragging = false; function ensureMarquee() { if (marquee) return marquee; marquee = document.createElement('div'); marquee.style.cssText = 'position:fixed;z-index:2147483646;border:1px solid #1a7af8;background:rgba(26,122,248,.12);' + 'pointer-events:none;display:none'; (document.body || document.documentElement).appendChild(marquee); return marquee; } function boxTriggered(e) { return !!(e.ctrlKey || e.metaKey); } window.addEventListener('mousedown', function (e) { if (e.button !== 0 || start) return; var t = e.target; if (!t || !t.closest || !t.closest('.three-view-container')) return; if (!boxTriggered(e)) return; var cv = t.closest('.three-view-container').getBoundingClientRect(); start = { x: e.clientX, y: e.clientY, left: cv.left, top: cv.top, w: cv.width, h: cv.height }; dragging = false; e.preventDefault(); e.stopImmediatePropagation(); // 把左键从应用手里夺过来给框选 }, { capture: true, passive: false }); window.addEventListener('mousemove', function (e) { if (!start) return; var dx = e.clientX - start.x, dy = e.clientY - start.y; if (!dragging && Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD) dragging = true; if (!dragging) return; e.preventDefault(); e.stopImmediatePropagation(); // 拖起来之后才拦应用自己的框选 var el = ensureMarquee(); el.style.display = 'block'; el.style.left = Math.min(start.x, e.clientX) + 'px'; el.style.top = Math.min(start.y, e.clientY) + 'px'; el.style.width = Math.abs(dx) + 'px'; el.style.height = Math.abs(dy) + 'px'; }, { capture: true, passive: false }); window.addEventListener('mouseup', function (e) { if (!start) return; var st = start; start = null; if (marquee) marquee.style.display = 'none'; if (!dragging) return; // 只是单击 → 放行 e.preventDefault(); e.stopImmediatePropagation(); var rect = { x: Math.min(st.x, e.clientX) - st.left, y: Math.min(st.y, e.clientY) - st.top, w: Math.abs(e.clientX - st.x), h: Math.abs(e.clientY - st.y) }; if (isConfirmed3D()) { showDebug('3D 下框选放大不可用(3D 相机不可达)\n切到 2D 后即可使用'); return; } var r = zoomBox2D(rect); showDebug(r.ok ? '框选放大 ×' + r.factor.toFixed(2) + ' zoom=' + r.zoom.toFixed(4) : '框选失败:' + r.why); }, { capture: true, passive: false }); /* ---------- 10. 画布缩放控件 ---------- */ var ZOOM_ANCHOR = 'btn2d'; // 位置: // 'btn2d' = 竖向贴在底部「2D」按钮正上方(默认) // 'br' / 'bl' / 'tr' / 'tl' = 画布四角 var ZOOM_ALIGN = 'left'; // 与「2D」按钮的水平对齐:'left' = 左边缘对齐 / 'center' = 居中于按钮 var ZMIN = 0.02, ZMAX = 80; // 2D scaleRatio 滑块范围(对数) var Z_NOTCH = 0.02; // 2D 下应用每格滚轮改多少 scaleRatio(实测 0.1*f,f=0.2) // 三个按钮统一成 22×22 的居中方形按钮(文字 +/− 与图标都用它) var ZBTN = 'cursor:pointer;display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;' + 'border:1px solid #dfe3e8;border-radius:4px;background:#fff;font-size:15px;line-height:1;color:#333'; // 滑块配色/尺寸照抄底部「爆炸距离」那个 tui-slider(实测值): // .tui-slider-rail = rgba(3,9,17,0.08),粗 4px,圆角 0 // .tui-slider-track = rgb(26,122,248) // .tui-slider-handle= 13×13 圆、rgb(26,122,248)、1.6px 白边、0 2px 8px rgba(3,9,17,.08) var ZRAIL = 'rgba(3,9,17,.08)', ZFILL = 'rgb(26,122,248)'; // 「适应」图标(用户提供):四角括号 + 四条对角箭头 var FIT_ICON = ''; var zoomUI = null, zoomPoll = 0, zoomDragging = false; function zCanvasRect() { var el = document.querySelector('.three-view-container'); if (!el) return null; var r = el.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, w: r.width, h: r.height }; } function zCur() { var ctl = findOrtho(); // 以 scaleRatio 为准(那才是应用记录的值),camera.zoom 只作兜底 return ctl ? (ctl.scaleRatio || ctl.camera.zoom || 1) : 1; } function zToSlider(z) { var t = Math.log(Math.max(ZMIN, Math.min(ZMAX, z)) / ZMIN) / Math.log(ZMAX / ZMIN); return Math.max(0, Math.min(1, t)); } function zFromSlider(v) { return ZMIN * Math.pow(ZMAX / ZMIN, Math.max(0, Math.min(1, v))); } // 3D 没有可用的控制器对象,只能借一个合成滚轮来推一档 function zWheel(deltaY, n) { var cv = document.querySelector('.three-view'); if (!cv) return; var r = cv.getBoundingClientRect(); var cx = Math.round(r.left + r.width / 2), cy = Math.round(r.top + r.height / 2); for (var i = 0; i < (n || 1); i++) { try { cv.dispatchEvent(new WheelEvent('mousewheel', { deltaY: deltaY, bubbles: true, cancelable: true, clientX: cx, clientY: cy })); } catch (_) {} } } // 一档缩放 / 设定缩放: // 明确 3D → 只能借滚轮(正交相机在 3D 下不起作用); // 其余(明确 2D 或模式未知)→ 直接改正交相机,精确、也不会因为模式判错而反向。 // ★ 方向:应用里两个控制器的约定相反 —— // 2D: onMousewheel = d => onZoom(-d.deltaY) → deltaY<0(滚轮上)= 放大 // 3D: onMousewheel = d => onZoom( d.deltaY) → deltaY<0 = 缩小 // ★「双管齐下」,不需要判对模式也能用: // ① 先借一格滚轮 —— 3D 下这是唯一有效的杠杆(2D 下只是微调一格) // ② 非「确认 3D」时再直接用正交相机精确设到 base*f —— 2D 下正好覆盖掉①的偏移,得到精确 ×f // 模式未知时两条都走:2D 得精确值,3D 得滚轮缩放。 function zoomStep(f) { var base = zCur(); zWheel(f > 1 ? 120 : -120, 1); if (!isConfirmed3D()) applyOrtho(base * f, 0, 0); zSync(); } function zoomSet(z) { if (isConfirmed3D()) { // 3D 读不到绝对缩放值 → 折算成滚轮格数 var d = z - zCur(); var n = Math.max(1, Math.min(60, Math.round(Math.abs(d) / Z_NOTCH))); zWheel(d > 0 ? 120 : -120, n); } else { applyOrtho(z, 0, 0); } zSync(); } function zSync() { if (!zoomUI) return; var t = zToSlider(zCur()); var track = zoomUI.querySelector('[data-k="track"]'); var fill = zoomUI.querySelector('[data-k="fill"]'); var knob = zoomUI.querySelector('[data-k="knob"]'); var H = (track && track.clientHeight) || 118; if (fill) fill.style.height = Math.round(t * H) + 'px'; if (knob) knob.style.bottom = (t * H - 6.5) + 'px'; // 手柄圆心落在刻度线上 if (track) { // 两个模式都能用,不再禁用 track.style.opacity = '1'; track.style.cursor = 'pointer'; track.style.pointerEvents = 'auto'; } var fit = zoomUI.querySelector('[data-k="fit"]'); if (fit) fit.style.opacity = isConfirmed3D() ? '.5' : '1'; } // 应用的弹出层(下拉菜单等,portal 到 body)在 z-index 上高于控件;虽然已经压不到了, // 但为了视觉干净,只要它和控件重叠就把控件整块淡出避让,关掉后自动恢复。 function zBehindPopup() { if (!zoomUI) return false; var r = zoomUI.getBoundingClientRect(); if (!r.width || !r.height) return false; var list = document.querySelectorAll('.tui-dropdown,.tui-menu,.tui-popup,.tui-tooltip,.tui-select-dropdown'); for (var i = 0; i < list.length; i++) { var e = list[i], s = getComputedStyle(e); if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) < 0.05 || s.pointerEvents === 'none') continue; var b = e.getBoundingClientRect(); if (b.width < 8 || b.height < 8) continue; if (b.left < r.right && b.right > r.left && b.top < r.bottom && b.bottom > r.top) return true; } return false; } function zPlace() { if (!zoomUI) return; var s = zoomUI.style; // 与弹出层重叠时整块淡出(不抢它的视线,也不吃它的点击) var behind = zBehindPopup(); s.opacity = behind ? '0' : '1'; s.pointerEvents = behind ? 'none' : 'auto'; // 首选:竖向贴在底部「2D」按钮正上方(水平居中于该按钮) if (ZOOM_ANCHOR === 'btn2d') { var btn = null, list = document.querySelectorAll('.tuipro-bottombar-viewswitch'); for (var i = 0; i < list.length; i++) { if (list[i].textContent.trim() === '2D') { btn = list[i]; break; } } var br = btn && btn.getBoundingClientRect(); if (br && br.width > 0) { var left = (ZOOM_ALIGN === 'center') ? (br.left + br.width / 2 - zoomUI.offsetWidth / 2) // 居中于按钮 : br.left; // 左边缘与 2D 按钮组对齐 s.left = Math.round(left) + 'px'; s.right = 'auto'; s.top = Math.max(4, Math.round(br.top - zoomUI.offsetHeight - 8)) + 'px'; s.bottom = 'auto'; return; } } var r = zCanvasRect(); if (!r) return; var m = 14; var a = (ZOOM_ANCHOR === 'btn2d') ? 'bl' : ZOOM_ANCHOR; // 找不到 2D 按钮就退回左下角 if (a.charAt(0) === 'b') { s.top = 'auto'; s.bottom = Math.max(4, window.innerHeight - r.bottom + m) + 'px'; } else { s.bottom = 'auto'; s.top = Math.round(r.top + m) + 'px'; } if (a.charAt(1) === 'r') { s.left = 'auto'; s.right = Math.max(4, window.innerWidth - r.right + m) + 'px'; } else { s.right = 'auto'; s.left = Math.round(r.left + m) + 'px'; } } function buildZoomUI() { if (zoomUI) return; if (!document.querySelector('.three-view-container')) return; var el = document.createElement('div'); el.id = 'kjlZoomCtl'; // ★ z-index 只用 100: // 画布各层 = floating-area 1 / bottombar-new 2 / sidebar-container 8 → 100 足够压在它们上面; // 应用的弹出层(.tui-dropdown / .tui-menu,portal 到 body)z-index ≈ 1000~1045 // → 控件必须在 1000 以下,否则会挡住 2D/3D 按钮向上弹出的菜单。 el.style.cssText = 'position:fixed;z-index:100;display:flex;flex-direction:column;align-items:center;gap:6px;' + 'background:rgba(255,255,255,.97);border:1px solid #dfe3e8;border-radius:6px;padding:7px 6px;' + 'box-shadow:0 2px 8px rgba(0,0,0,.14);font:12px/1.4 "Microsoft YaHei",Arial,sans-serif;color:#333;' + 'user-select:none;-webkit-user-select:none;transition:opacity .12s'; // 竖向排布:+ / 竖向滑块 / − / 适应(图标) // 滑块用「横向 range 旋转 -90°」实现:旋转后元素的 max 端在屏幕上方(向上拖 = 放大), // 且浏览器按变换后的几何做命中测试,所以不会误触到旁边画布。 el.innerHTML = '+' + '