three-bvh-csg 方形球形卡扣 自动生成源代码

qie_all_o.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>Three.js 模型切割 - 统一缩放系数(完整版)</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }
        #info {
            position: absolute;
            top: 20px;
            left: 20px;
            background: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 12px 20px;
            border-radius: 8px;
            backdrop-filter: blur(8px);
            pointer-events: none;
            z-index: 10;
            font-size: 14px;
            border-left: 4px solid #ff4757;
        }
        #controls-panel {
            position: absolute;
            bottom: 20px;
            left: 20px;
            right: 20px;
            background: rgba(30, 30, 40, 0.95);
            backdrop-filter: blur(12px);
            border-radius: 16px;
            padding: 15px 20px;
            color: white;
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            justify-content: space-between;
            align-items: center;
            z-index: 20;
            pointer-events: auto;
            border: 1px solid rgba(255, 255, 255, 0.2);
            font-family: monospace;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
        }
        .group {
            display: flex;
            gap: 12px;
            align-items: center;
            background: rgba(0, 0, 0, 0.5);
            padding: 6px 16px;
            border-radius: 40px;
        }
        .group label {
            font-size: 13px;
            font-weight: bold;
            letter-spacing: 1px;
        }
        button {
            background: #ff4757;
            border: none;
            color: white;
            padding: 8px 20px;
            border-radius: 40px;
            cursor: pointer;
            font-weight: bold;
            transition: 0.2s;
            font-size: 14px;
        }
        button:hover {
            background: #ff6b81;
            transform: scale(1.02);
        }
        .reset-btn {
            background: #2ed573;
        }
        .reset-btn:hover {
            background: #5fdd9a;
        }
        .mode-btn {
            background: #1e90ff;
        }
        .mode-btn.active {
            background: #ffa502;
            box-shadow: 0 0 8px rgba(255, 165, 2, 0.5);
        }
        .export-stl-btn {
            background: #00b894;
        }
        .export-stl-btn:hover {
            background: #55efc4;
            color: #2d3436;
        }
        .export-glb-btn {
            background: #0984e3;
        }
        .export-glb-btn:hover {
            background: #74b9ff;
            color: #2d3436;
        }
        .tenon-btn {
            background: #ff6348;
        }
        .tenon-btn:hover {
            background: #ff7f50;
        }
        .start-cut-btn {
            background: #00d2d3;
        }
        .start-cut-btn:hover {
            background: #48dbfb;
            color: #2d3436;
        }
        input {
            width: 140px;
            cursor: pointer;
        }
        .value-display {
            background: #000000aa;
            padding: 4px 12px;
            border-radius: 20px;
            min-width: 70px;
            text-align: center;
            font-size: 12px;
        }
        .loading-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.7);
            display: none;
            justify-content: center;
            align-items: center;
            z-index: 1000;
            flex-direction: column;
        }
        .loading-spinner {
            width: 50px;
            height: 50px;
            border: 5px solid #f3f3f3;
            border-top: 5px solid #00b894;
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }
        @keyframes spin {
            0% {
                transform: rotate(0deg);
            }
            100% {
                transform: rotate(360deg);
            }
        }
        .loading-text {
            color: white;
            margin-top: 20px;
            font-size: 16px;
        }
        @media (max-width: 800px) {
            .group {
                padding: 4px 10px;
            }
            input {
                width: 80px;
            }
            button {
                padding: 6px 12px;
                font-size: 12px;
            }
        }
        .status {
            font-size: 11px;
            opacity: 0.7;
        }
        .auto-size {
            color: #ffa502;
            font-weight: bold;
        }
        .upload-label {
            background: #f39c12;
            padding: 8px 16px;
            border-radius: 40px;
            cursor: pointer;
            font-weight: bold;
            transition: 0.2s;
            font-size: 14px;
            display: inline-block;
        }
        .upload-label:hover {
            background: #f1c40f;
        }
        #file-input {
            display: none;
        }
        .click-hint {
            position: absolute;
            bottom: 80px;
            left: 20px;
            background: rgba(0, 0, 0, 0.5);
            color: #ffaa66;
            padding: 4px 12px;
            border-radius: 20px;
            font-size: 12px;
            pointer-events: none;
            z-index: 10;
        }
    </style>
</head>
<body>
    <div id="info">实体切割+自动补面 | 凹面与凸面统一缩放系数(取较小值)</div>
    <div id="loading-overlay" class="loading-overlay"><div class="loading-spinner"></div><div class="loading-text">正在处理...</div></div>
    <div id="controls-panel">
        <div class="group model-selector"><span>📋 模型</span><button id="delete-model-btn" style="background:#e74c3c;">🗑️ 删除</button></div>
        <div class="group"><span>🛠️</span><button id="mode-translate" class="mode-btn">移动</button><button id="mode-rotate" class="mode-btn active">旋转</button><button id="mode-scale" class="mode-btn">缩放</button></div>
        <div class="group"><span>📏 切面</span><input type="range" id="plane-scale" min="0.5" max="4.0" step="0.01" value="1.5"><span id="scale-value" class="value-display">1.50</span></div>
        <div class="group"><span>⚙️ 公差</span><input type="range" id="tolerance" min="0.005" max="0.02" step="0.001" value="0.005"><span id="tolerance-value" class="value-display">0.005mm</span></div>
        <div class="group"><span>🔩 榫头</span><span id="tenon-display" class="value-display auto-size">自动</span><span id="mortise-display" class="value-display auto-size" style="color:#ff6348;">卯眼+公差</span></div>
        <div class="group">
            <button id="start-cut-btn" class="start-cut-btn">🪚 插入切面</button>
            <button id="qiu-btn" style="background:#ff6b6b;">✂️ 球形卡扣</button>
            <button id="add-tenon-btn" class="tenon-btn">🔩 方形卡扣</button>
            <button id="reset-btn" class="reset-btn">🔄 重置</button>
        </div>
        <div class="group export-group">
            <label for="file-input" class="upload-label">📁 导入GLB</label>
            <input type="file" id="file-input" accept=".glb,.gltf">
            <button id="export-stl-btn" class="export-stl-btn">📄 导出 STL</button>
            <button id="export-glb-btn" class="export-glb-btn">📦 导出 GLB</button>
        </div>
        <div class="status">💡 点击任意模型/部件就近选中 | C/X/R/E 快捷键</div>
    </div>
    <div class="click-hint">✨ 凹面和凸面将分别计算缩放系数,取较小值统一应用</div>

    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.128.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.128.0/examples/jsm/",
                "three-bvh-csg": "https://unpkg.com/three-bvh-csg@0.0.18/index.module.js"
            }
        }
    </script>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { TransformControls } from 'three/addons/controls/TransformControls.js';
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        import { STLExporter } from 'three/addons/exporters/STLExporter.js';
        import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
        import { Brush, Evaluator, INTERSECTION, SUBTRACTION, ADDITION } from 'three-bvh-csg';
        import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
        import { BufferAttribute } from 'three';

        // ==================== 常量 ====================
        const CUT_PLANE_THICKNESS = 0.002;
        const HALF_SPACE_SIZE = 10.0;
        const DEFAULT_PLANE_SIZE = 1.5;
        const DEFAULT_RATIO = 0.36;
        const DEFAULT_TOLERANCE = 0.002;
        const TENON_OFFSET_RATIO = 0.30;

        // ==================== 工具函数 ====================
        function cleanGeometry(geometry) {
            if (!geometry || !geometry.attributes.position) return geometry;
            let geo = geometry.clone();
            try { geo = mergeVertices(geo, 0.0001);
                geo.computeVertexNormals(); } catch (e) { console.warn("几何体清理失败:", e); }
            return geo;
        }

        class TimeStr {
            constructor() { this.lastTime = '';
                this.counter = 0; }
            _formatDate(fmt) {
                const now = new Date();
                const map = { 'YYYY': now.getFullYear(), 'MM': String(now.getMonth() + 1).padStart(2, '0'), 'DD': String(
                        now.getDate()).padStart(2, '0'), 'HH': String(now.getHours()).padStart(2, '0'), 'mm': String(now
                        .getMinutes()).padStart(2, '0'), 'ss': String(now.getSeconds()).padStart(2, '0') };
                let result = fmt;
                for (const [key, value] of Object.entries(map)) result = result.replace(key, value);
                return result;
            }
            getTime(fmt = "MMDD_HH_ss", baseFmt = "MMDDHH") {
                const nowTime = this._formatDate(fmt),
                    baseTime = this._formatDate(baseFmt);
                if (baseTime !== this.lastTime) { this.lastTime = baseTime;
                    this.counter = 0; } else this.counter++;
                return `${baseTime}_${this.counter}`;
            }
        }

        function centerGeometry(mesh) {
            const g = mesh.geometry;
            if (!g?.attributes.position) return;
            const p = g.attributes.position.array;
            if (!p.length) return;
            let mn = [Infinity, Infinity, Infinity],
                mx = [-Infinity, -Infinity, -Infinity];
            for (let i = 0; i < p.length; i += 3)
                for (let j = 0; j < 3; j++) { mn[j] = Math.min(mn[j], p[i + j]);
                    mx[j] = Math.max(mx[j], p[i + j]); }
            const c = [(mn[0] + mx[0]) / 2, (mn[1] + mx[1]) / 2, (mn[2] + mx[2]) / 2];
            for (let i = 0; i < p.length; i += 3) { p[i] -= c[0];
                p[i + 1] -= c[1];
                p[i + 2] -= c[2]; }
            g.attributes.position.needsUpdate = true;
            g.computeVertexNormals();
            mesh.position.x += c[0];
            mesh.position.y += c[1];
            mesh.position.z += c[2];
            mesh.updateMatrixWorld();
        }

        function getPlaneAxes(cutPlaneMesh) {
            const pos = cutPlaneMesh.position.clone();
            const quat = cutPlaneMesh.quaternion.clone();
            const normal = new THREE.Vector3(0, 1, 0).applyQuaternion(quat).normalize();
            const right = new THREE.Vector3(1, 0, 0).applyQuaternion(quat).normalize();
            const up = new THREE.Vector3(0, 0, 1).applyQuaternion(quat).normalize();
            return { pos, quat, normal, right, up };
        }

        function worldToPlaneLocal(worldPoint, planeAxes) {
            const rel = worldPoint.clone().sub(planeAxes.pos);
            return { x: rel.dot(planeAxes.right), y: rel.dot(planeAxes.normal), z: rel.dot(planeAxes.up) };
        }

        function checkIntersectionWithPlane(obj, planeMesh) {
            if (!obj || !planeMesh) return false;
            return new THREE.Box3().setFromObject(obj).intersectsBox(new THREE.Box3().setFromObject(planeMesh));
        }

        function findIntersectingObjects(currentModelObject, cutParts, cutPlaneMesh) {
            const r = [];
            if (currentModelObject && checkIntersectionWithPlane(currentModelObject, cutPlaneMesh)) r.push({ object: currentModelObject,
                index: -1, type: 'model' });
            cutParts.forEach((p, i) => { if (checkIntersectionWithPlane(p, cutPlaneMesh)) r.push({ object: p, index: i,
                    type: 'part' }); });
            return r;
        }

        function calculateIntersectionInfo(geometry, cutPlaneMesh, currentCutPlaneSize) {
            const result = { center: cutPlaneMesh.position.clone(), sizeX: 0.1, sizeY: 0.1, area: 0.01, tenonSize: 0.08,
                valid: false };
            if (!geometry?.attributes.position || !cutPlaneMesh) return result;
            const pos = geometry.attributes.position.array;
            const planeAxes = getPlaneAxes(cutPlaneMesh);
            const planeSize = currentCutPlaneSize;
            const tolerance = Math.max(0.002, planeSize * 0.02);
            const rawPoints = [];
            for (let i = 0; i < pos.length; i += 3) {
                const wp = new THREE.Vector3(pos[i], pos[i + 1], pos[i + 2]);
                const local = worldToPlaneLocal(wp, planeAxes);
                if (Math.abs(local.y) <= tolerance && Math.abs(local.x) <= planeSize * 0.7 && Math.abs(local.z) <=
                    planeSize * 0.7) rawPoints.push({ x: local.x, z: local.z });
            }
            const dedupPoints = [];
            const eps = tolerance * 0.5;
            for (const p of rawPoints) { let dup = false;
                for (const q of dedupPoints)
                    if (Math.abs(p.x - q.x) < eps && Math.abs(p.z - q.z) < eps) { dup = true; break; } if (!dup) dedupPoints
                    .push(p); }

            function convexHull(points) {
                if (points.length < 3) return points;
                points.sort((a, b) => a.x - b.x || a.z - b.z);
                const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
                const lower = [];
                for (let i = 0; i < points.length; i++) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[
                        lower.length - 1], points[i]) <= 0) lower.pop();
                    lower.push(points[i]); }
                const upper = [];
                for (let i = points.length - 1; i >= 0; i--) { while (upper.length >= 2 && cross(upper[upper.length - 2],
                        upper[upper.length - 1], points[i]) <= 0) upper.pop();
                    upper.push(points[i]); }
                return lower.slice(0, -1).concat(upper.slice(0, -1));
            }
            let hull = convexHull(dedupPoints);
            if (hull.length < 3) {
                const bbox = new THREE.Box3().setFromBufferAttribute(geometry.attributes.position);
                const corners = [new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.min.z), new THREE.Vector3(bbox.min.x, bbox
                    .min.y, bbox.max.z), new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.min.z), new THREE.Vector3(bbox.min
                    .x, bbox.max.y, bbox.max.z), new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.min.z), new THREE.Vector3(
                    bbox.max.x, bbox.min.y, bbox.max.z), new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.min.z),
                new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.max.z)];
                const localCorners = corners.map(c => worldToPlaneLocal(c, planeAxes));
                const edges = [
                    [0, 1],
                    [0, 2],
                    [0, 4],
                    [1, 3],
                    [1, 5],
                    [2, 3],
                    [2, 6],
                    [3, 7],
                    [4, 5],
                    [4, 6],
                    [5, 7],
                    [6, 7]
                ];
                const intersections = [];
                for (const [i, j] of edges) { const d1 = localCorners[i].y,
                        d2 = localCorners[j].y; if ((d1 >= 0 && d2 <= 0) || (d1 <= 0 && d2 >= 0)) { if (Math.abs(d1 -
                            d2) > 1e-8) { const t = d1 / (d1 - d2);
                        intersections.push({ x: localCorners[i].x + t * (localCorners[j].x - localCorners[i].x), z: localCorners[
                                i].z + t * (localCorners[j].z - localCorners[i].z) }); } } }
                const dedupIntersections = [];
                const eps2 = 0.001;
                for (const p of intersections) { let dup = false;
                    for (const q of dedupIntersections)
                        if (Math.abs(p.x - q.x) < eps2 && Math.abs(p.z - q.z) < eps2) { dup = true; break; } if (!dup)
                        dedupIntersections.push(p); }
                hull = convexHull(dedupIntersections);
                if (hull.length < 3) return result;
            }
            let area = 0;
            for (let i = 0; i < hull.length; i++) { const j = (i + 1) % hull.length;
                area += hull[i].x * hull[j].z - hull[j].x * hull[i].z; }
            area = Math.abs(area) / 2;
            let cx = 0,
                cz = 0;
            for (let i = 0; i < hull.length; i++) { const j = (i + 1) % hull.length;
                const crossTerm = hull[i].x * hull[j].z - hull[j].x * hull[i].z;
                cx += (hull[i].x + hull[j].x) * crossTerm;
                cz += (hull[i].z + hull[j].z) * crossTerm; }
            const area6 = area * 6;
            if (area6 > 1e-8) { cx /= area6;
                cz /= area6; } else { cx = hull[0].x;
                cz = hull[0].z; }
            const intersectionCenter = planeAxes.pos.clone().add(planeAxes.right.clone().multiplyScalar(cx)).add(planeAxes
                .up.clone().multiplyScalar(cz));
            const minX = Math.min(...hull.map(p => p.x)),
                maxX = Math.max(...hull.map(p => p.x)),
                minZ = Math.min(...hull.map(p => p.z)),
                maxZ = Math.max(...hull.map(p => p.z));
            const sizeX = maxX - minX,
                sizeZ = maxZ - minZ;
            let tenonSize = Math.sqrt(area * DEFAULT_RATIO);
            tenonSize = Math.max(0.02, Math.min(tenonSize, Math.min(sizeX, sizeZ) * 0.33));
            Object.assign(result, { center: intersectionCenter, sizeX, sizeY: sizeZ, area, tenonSize, valid: true });
            return result;
        }

        function getPartSide(part, planePos, planeNormal) {
            const c = new THREE.Box3().setFromObject(part).getCenter(new THREE.Vector3());
            return c.clone().sub(planePos).dot(planeNormal) > 0 ? 'positive' : 'negative';
        }

        function createHalfSpaceBrush(point, normal, positiveSide, sizeHalf = HALF_SPACE_SIZE) {
            const axisZ = normal.clone().normalize(),
                upRef = new THREE.Vector3(0, 1, 0),
                axisX = new THREE.Vector3().copy(upRef).cross(axisZ);
            if (axisX.length() < 0.0001) axisX.set(1, 0, 0);
            else axisX.normalize();
            const axisY = axisZ.clone().cross(axisX).normalize(),
                m = new THREE.Matrix4().makeBasis(axisX, axisY, axisZ);
            const off = positiveSide ? sizeHalf : -sizeHalf,
                cw = point.clone().add(axisZ.clone().multiplyScalar(off)),
                fm = m.clone().setPosition(cw);
            const b = new Brush(new THREE.BoxGeometry(sizeHalf * 2, sizeHalf * 2, sizeHalf * 2), new THREE
        .MeshStandardMaterial());
            b.applyMatrix4(fm);
            b.updateMatrixWorld();
            b.prepareGeometry();
            return b;
        }

        function extractWorldGeometry(obj) {
            if (obj.isMesh) { let g = obj.geometry.clone();
                obj.updateWorldMatrix(true, false);
                g.applyMatrix4(obj.matrixWorld); if (g.index) g = g.toNonIndexed();
                g.computeVertexNormals(); return g; }
            if (obj.isGroup) { let tg = null;
                obj.traverse(c => { if (c.isMesh && !tg) { let g = c.geometry.clone();
                        c.updateWorldMatrix(true, false);
                        g.applyMatrix4(c.matrixWorld); if (g.index) g = g.toNonIndexed();
                        g.computeVertexNormals();
                        tg = g; } }); return tg; }
            return null;
        }

        function createVisualCutPlane(size, scene, existingMesh) {
            if (existingMesh) { scene.remove(existingMesh);
                existingMesh.geometry?.dispose();
                existingMesh.material?.dispose(); }
            const p = new THREE.Mesh(new THREE.BoxGeometry(size, CUT_PLANE_THICKNESS, size), new THREE.MeshPhongMaterial({
                color: 0xff66aa,
                emissive: 0x331133,
                transparent: true,
                opacity: 0.45,
                side: THREE.DoubleSide
            }));
            p.userData.isCutPlane = true;
            p.position.set(0, 0, 0);
            p.scale.set(1, 1, 1);
            scene.add(p);
            return p;
        }

        // ==================== ModelManager ====================
        class ModelManager {
            constructor(scene, transformControls, orbitControls, camera) {
                this.scene = scene;
                this.transformControls = transformControls;
                this.orbitControls = orbitControls;
                this.camera = camera;
                this.models = new Map();
                this.activeModelId = null;
                this.loader = new GLTFLoader();
                this.csgEvaluator = new Evaluator();
                this.timeStr = new TimeStr();
            }
            _generateId(type = 'obj') { return type + "_" + this.timeStr.getTime(); }
            _hasValidMeshes(model) { let has = false;
                model.traverse(c => { if (c.isMesh) has = true; }); return has; }
            async loadModel(url, type = "obj", modelId = null, position = null) {
                return new Promise((resolve, reject) => {
                    this.loader.load(url, gltf => {
                        const model = gltf.scene;
                        const id = modelId || this._generateId(type);
                        model.traverse(c => { if (c.isMesh) { c.castShadow = true;
                                c.receiveShadow = true; } });
                        if (!this._hasValidMeshes(model)) { reject(new Error("No mesh")); return; }
                        const pos = position || { x: this.models.size * 1.8, y: -0.3, z: 0 };
                        model.position.set(pos.x, pos.y, pos.z);
                        model.userData = { id, url, type: 'loaded' };
                        this.scene.add(model);
                        this.models.set(id, { model, cutParts: [], cutPlaneMesh: null, currentCutPlaneSize: DEFAULT_PLANE_SIZE,
                            url, originalModelGroup: model.clone(), selectedPartIndex: -1, selectedObject: 'model' });
                        this.setActiveModel(id);
                        console.log(`✅ 加载模型: ${id}`);
                        resolve({ modelId: id, model });
                    }, undefined, err => { console.error(err);
                        reject(err); });
                });
            }
            setActiveModel(modelId) {
                if (!this.models.has(modelId)) return false;
                const data = this.models.get(modelId);
				if (data.model && data.model.parent !== this.scene) {
					console.log(`scene add:${data.model.name}`);
					this.scene.add(data.model);
				}
                this.activeModelId = modelId;
                Object.assign(window, { currentModelObject: data.model, cutParts: data.cutParts, currentCutPlaneSize: data
                        .currentCutPlaneSize, selectedPartIndex: data.selectedPartIndex, selectedObject: data
                        .selectedObject });
                if (data.cutPlaneMesh) { if (window.cutPlaneMesh && window.cutPlaneMesh !== data.cutPlaneMesh) this.scene
                        .remove(window.cutPlaneMesh);
                    window.cutPlaneMesh = data.cutPlaneMesh; } else { if (window.cutPlaneMesh) { this.scene.remove(window
                            .cutPlaneMesh);
                        window.cutPlaneMesh = null; } }
                this.transformControls.detach();
                if (data.selectedObject === 'plane' && data.cutPlaneMesh) safeAttach(data.cutPlaneMesh);
                else if (data.cutParts.length && data.selectedPartIndex >= 0) safeAttach(data.cutParts[Math.min(data
                    .selectedPartIndex, data.cutParts.length - 1)]);
                else if (data.model) safeAttach(data.model);
                return true;
            }
            getActiveModelData() { return this.models.get(this.activeModelId) || null; }
            updateActiveModelData(updates) {
                const data = this.getActiveModelData();
                if (!data) return false;
                Object.assign(data, updates);
				if ('model' in updates) {
					data.model = updates.model;
					window.currentModelObject = updates.model; // 同步全局变量
				}
                this.models.set(this.activeModelId, data);
                if ('cutParts' in updates) window.cutParts = updates.cutParts;
                if ('cutPlaneMesh' in updates) window.cutPlaneMesh = updates.cutPlaneMesh;
                if ('currentCutPlaneSize' in updates) window.currentCutPlaneSize = updates.currentCutPlaneSize;
                if ('selectedPartIndex' in updates) window.selectedPartIndex = updates.selectedPartIndex;
                if ('selectedObject' in updates) window.selectedObject = updates.selectedObject;
                return true;
            }
            removeModel(modelId) {
                const data = this.models.get(modelId);
                if (!data) return false;
                [data.model, ...data.cutParts, data.cutPlaneMesh].filter(Boolean).forEach(obj => { this.scene.remove(obj);
                    obj.traverse?.(c => { if (c.geometry) c.geometry.dispose(); if (c.material)(Array.isArray(c.material) ?
                        c.material : [c.material]).forEach(m => m.dispose()); }); });
                this.models.delete(modelId);
                if (this.activeModelId === modelId) { const keys = Array.from(this.models.keys());
                    keys.length ? this.setActiveModel(keys[0]) : (this.activeModelId = null, window.currentModelObject =
                        null, window.cutParts = [], window.cutPlaneMesh = null, this.transformControls.detach()); }
                return true;
            }
            getAllSelectableCandidates() {
                const arr = [];
                for (const [id, d] of this.models) {
                    if (d.model && d.model.parent === this.scene) arr.push({ object: d.model, modelId: id, type: 'model' });
                    d.cutParts.forEach((p, i) => { if (p && p.parent === this.scene) arr.push({ object: p, modelId: id,
                            type: 'part', partIndex: i }); });
                }
                return arr;
            }
        }

        // ==================== 场景初始化 ====================
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x2a2a3e);
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(innerWidth, innerHeight);
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        document.body.appendChild(renderer.domElement);
        scene.add(new THREE.AmbientLight(0xffffff, 0.3));
        const dl = new THREE.DirectionalLight(0xffffff, 0.8);
        dl.position.set(1, 2, 1);
        dl.castShadow = true;
        dl.shadow.mapSize.set(2048, 2048);
        scene.add(dl);
        const pmrem = new THREE.PMREMGenerator(renderer);
        const canvas = document.createElement('canvas');
        canvas.width = 512;
        canvas.height = 256;
        const ctx = canvas.getContext('2d');
        const grad = ctx.createLinearGradient(0, 0, 0, 256);
        grad.addColorStop(0, '#e8edf5');
        grad.addColorStop(0.5, '#bcc6d4');
        grad.addColorStop(1, '#4a5568');
        ctx.fillStyle = grad;
        ctx.fillRect(0, 0, 512, 256);
        scene.environment = pmrem.fromEquirectangular(new THREE.CanvasTexture(canvas)).texture;
        const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 1000);
        camera.position.set(3.5, 2.5, 4);
        camera.lookAt(0, 0, 0);
        const orbitControls = new OrbitControls(camera, renderer.domElement);
        orbitControls.target.set(0, 0, 0);
        orbitControls.update();
        const transformControls = new TransformControls(camera, renderer.domElement);
        transformControls.addEventListener('dragging-changed', e => { orbitControls.enabled = !e.value; });
        scene.add(transformControls);
        scene.add(new THREE.GridHelper(200, 40, 0xaabbff, 0x446688).translateY(-1));
        scene.add(new THREE.HemisphereLight(0xffffff, 0x444466, 0.8));
        const pl1 = new THREE.PointLight(0x6688cc, 1);
        pl1.position.set(-2, 2, 3);
        scene.add(pl1);
        const pl2 = new THREE.PointLight(0xff9966, 0.8);
        pl2.position.set(1, 1.5, -2);
        scene.add(pl2);

        // 全局变量
        let currentModelObject = null,
            cutParts = [],
            cutPlaneMesh = null,
            currentCutPlaneSize = DEFAULT_PLANE_SIZE,
            selectedObject = 'model',
            selectedPartIndex = -1;
        const modelManager = new ModelManager(scene, transformControls, orbitControls, camera);
        window.modelManager = modelManager;
        window.currentModelObject = currentModelObject;
        window.cutParts = cutParts;
        window.cutPlaneMesh = cutPlaneMesh;
        window.currentCutPlaneSize = currentCutPlaneSize;
        window.csgEvaluator = new Evaluator();
        window.scene = scene;
        window.camera = camera;
        window.orbitControls = orbitControls;
        window.transformControls = transformControls;

        function getActiveData() {
            const d = modelManager.getActiveModelData();
            if (!d) return null;
            currentModelObject = d.model;
            cutParts = d.cutParts;
            cutPlaneMesh = d.cutPlaneMesh;
            currentCutPlaneSize = d.currentCutPlaneSize;
            window.currentModelObject = currentModelObject;
            window.cutParts = cutParts;
            window.cutPlaneMesh = cutPlaneMesh;
            window.currentCutPlaneSize = currentCutPlaneSize;
            return d;
        }

        function syncToActiveModel() {
            const d = modelManager.getActiveModelData();
            if (d) modelManager.updateActiveModelData({ cutParts, cutPlaneMesh, currentCutPlaneSize, selectedPartIndex,
                selectedObject });
        }

        function getModelCenterAndSize(model) {
            if (!model) return { center: new THREE.Vector3(0, 0, 0), size: 1 };
            const box = new THREE.Box3().setFromObject(model);
            const center = box.getCenter(new THREE.Vector3());
            const size = box.getSize(new THREE.Vector3());
            return { center, size: Math.max(size.x, size.y, size.z) };
        }

        function startCut() {
            getActiveData();
            if (!currentModelObject && !cutParts.length) { alert("请先加载模型"); return; }
            const target = cutParts[0] || currentModelObject;
            if (!target) return;
            const { center, size } = getModelCenterAndSize(target);
            const planeSize = Math.max(size * 1.2, 0.8);
            currentCutPlaneSize = planeSize;
            document.getElementById('plane-scale').value = planeSize;
            document.getElementById('scale-value').innerText = planeSize.toFixed(2);
            if (cutPlaneMesh) { cutPlaneMesh.geometry.dispose();
                cutPlaneMesh.geometry = new THREE.BoxGeometry(planeSize, CUT_PLANE_THICKNESS, planeSize); } else
                cutPlaneMesh = createVisualCutPlane(planeSize, scene, cutPlaneMesh);
            cutPlaneMesh.position.copy(center);
            cutPlaneMesh.quaternion.identity();
            selectObject('plane');
            syncToActiveModel();
        }

        function calculateCapArea(partMesh, cutPlaneMesh, angleThreshold = 0.3) {
            if (!partMesh?.geometry || !cutPlaneMesh) return { area: 0, sizeX: 0, sizeZ: 0, center: null };
            const geo = extractWorldGeometry(partMesh);
            if (!geo?.attributes.position || !geo?.attributes.normal) return { area: 0, sizeX: 0, sizeZ: 0, center: null };
            const posArr = geo.attributes.position.array,
                normArr = geo.attributes.normal.array;
            const planeAxes = getPlaneAxes(cutPlaneMesh),
                refNormal = planeAxes.normal.clone().normalize(),
                cosThr = Math.cos(angleThreshold);
            let minX = Infinity,
                maxX = -Infinity,
                minZ = Infinity,
                maxZ = -Infinity,
                area = 0,
                weightedCenter = new THREE.Vector3();
            for (let i = 0; i < posArr.length; i += 9) {
                const v1 = new THREE.Vector3(posArr[i], posArr[i + 1], posArr[i + 2]),
                    v2 = new THREE.Vector3(posArr[i + 3], posArr[i + 4], posArr[i + 5]),
                    v3 = new THREE.Vector3(posArr[i + 6], posArr[i + 7], posArr[i + 8]);
                const faceArea = v2.clone().sub(v1).cross(v3.clone().sub(v1)).length() / 2;
                const avgNormal = new THREE.Vector3(normArr[i], normArr[i + 1], normArr[i + 2]).add(new THREE.Vector3(
                    normArr[i + 3], normArr[i + 4], normArr[i + 5])).add(new THREE.Vector3(normArr[i + 6], normArr[i +
                    7], normArr[i + 8])).normalize();
                if (Math.abs(refNormal.dot(avgNormal)) > cosThr) {
                    area += faceArea;
                    const centroid = v1.clone().add(v2).add(v3).multiplyScalar(1 / 3);
                    weightedCenter.add(centroid.clone().multiplyScalar(faceArea));
                    [v1, v2, v3].forEach(v => { const loc = worldToPlaneLocal(v, planeAxes); if (loc.x < minX) minX = loc
                            .x; if (loc.x > maxX) maxX = loc.x; if (loc.z < minZ) minZ = loc.z; if (loc.z > maxZ)
                            maxZ = loc.z; });
                }
            }
            let center = null;
            if (area > 0) { weightedCenter.divideScalar(area);
                center = weightedCenter; }
            return { area, sizeX: maxX - minX, sizeZ: maxZ - minZ, center };
        }

        // ==================== 核心:performCut(统一缩放系数 - 取较小值) ====================
        async function performCut() {
		  getActiveData();
		  if (!cutPlaneMesh) {
			alert("请先插入切面");
			return null;   // 返回 null 表示失败
		  }

		  const intersecting = findIntersectingObjects(currentModelObject, cutParts, cutPlaneMesh);
		  if (!intersecting.length) {
			alert("切面无交集");
			return null;
		  }

		  let target, idx;
		  const pis = intersecting.filter(o => o.type === 'part');
		  if (pis.length) {
			target = pis[0].object;
			idx = pis[0].index;
		  } else {
			target = intersecting[0].object;
			idx = -1;
		  }

		  const sg = extractWorldGeometry(target);
		  if (!sg?.attributes.position?.count) return null;

		  const sb = new Brush(sg, new THREE.MeshStandardMaterial({
			color: 0xaa99ff, roughness: 0.3, metalness: 0.6
		  }));
		  sb.updateMatrixWorld();
		  sb.prepareGeometry();

		  const planeAxes = getPlaneAxes(cutPlaneMesh);

		  try {
			const bbox = new THREE.Box3().setFromObject(target);
			const maxDim = Math.max(
			  bbox.getSize(new THREE.Vector3()).x,
			  bbox.getSize(new THREE.Vector3()).y,
			  bbox.getSize(new THREE.Vector3()).z
			);
			const positiveBrush = createHalfSpaceBrush(planeAxes.pos, planeAxes.normal, true, maxDim * 3);
			const negativeBrush = createHalfSpaceBrush(planeAxes.pos, planeAxes.normal, false, maxDim * 3);

			if (!sb.geometry.attributes.uv) {
			  sb.geometry.setAttribute('uv', new BufferAttribute(new Float32Array([]), 1));
			}

			const negPart = window.csgEvaluator.evaluate(sb, positiveBrush, INTERSECTION);
			const posPart = window.csgEvaluator.evaluate(sb, negativeBrush, INTERSECTION);

			const newParts = [];
			[negPart, posPart].forEach(p => {
			  if (p?.geometry?.attributes.position.count > 0) {
				const m = new THREE.Mesh(
				  cleanGeometry(p.geometry),
				  new THREE.MeshStandardMaterial({ color: 0xaa99ff, roughness: 0.3, metalness: 0.6 })
				);
				m.castShadow = m.receiveShadow = true;
				scene.add(m);
				newParts.push(m);
			  }
			});

			if (!newParts.length) {
			  alert("切割无效");
			  return null;
			}

			scene.remove(target);
			target.geometry?.dispose();
			if (target.material) {
			  (Array.isArray(target.material) ? target.material : [target.material])
				.forEach(m => m.dispose());
			}

			if (idx >= 0) cutParts.splice(idx, 1, ...newParts);
			else {
			  if (currentModelObject) {
				console.log(`remove:${currentModelObject.userData.id}`);
				scene.remove(currentModelObject);
				currentModelObject.geometry?.dispose();
			  }
			  cutParts = newParts;

			  currentModelObject = null;
			  modelManager.updateActiveModelData({ model: null });
			}

			selectedPartIndex = 0;
			selectedObject = 'model';
			if (cutParts.length) safeAttach(cutParts[0]);
			else transformControls.detach();
			syncToActiveModel();

			// ---- 识别上/下部件,返回给调用方 ----
			const planePos = planeAxes.pos;
			let partUpper = null, partLower = null;
			cutParts.forEach(p => {
			  const s = getPartSide(p, planePos, planeAxes.normal);
			  if (s === 'positive' && !partUpper) partUpper = p;
			  if (s === 'negative' && !partLower) partLower = p;
			});

			console.log(`✅ 切割完成,${newParts.length}个部件`);

			// 返回结构化结果
			return {
			  partUpper,partLower, newParts,cutParts,
			  planeAxes        // 方便卡扣函数使用切面信息
			};
		  } catch (e) {
			console.error(e);
			alert("切割失败: " + e.message);
			return null;
		  }
		}

		async function qiu_conn() {
			  const cutResult = await performCut();   // 必须 await
			  if (!cutResult) return;

			  let { partUpper, partLower, planeAxes } = cutResult;
			  if (!partUpper || !partLower) {
				cutParts.forEach(p => p?.geometry && centerGeometry(p));
				return;
			  }
				if (!partUpper || !partLower) { cutParts.forEach(p => p?.geometry && centerGeometry(p)); return; }

				const planeNormal = planeAxes.normal,planePos = planeAxes.pos;
                // ---- 计算 coverDiameter ----
                const capInfo = calculateCapArea(partUpper, cutPlaneMesh);
                const partUpperSize = new THREE.Box3().setFromObject(partUpper).getSize(new THREE.Vector3());
                let coverDiameter = Math.min(capInfo.sizeX, capInfo.sizeZ) * 0.6;
                if (coverDiameter < 0.1) coverDiameter = currentCutPlaneSize * 0.5;
                coverDiameter = Math.min(coverDiameter, Math.max(partUpperSize.x, partUpperSize.y, partUpperSize.z) * 0.8);
                console.log(`目标拼接直径: ${coverDiameter.toFixed(4)}`);

                // ==================== 分别计算凹面和凸面的缩放系数,取较小值 ====================
                let unifiedScale = null;
                let aoScale = 10;
                let tuScale = 10;

			    const qiuAoModel = modelManager.models.get('qiu_ao'),
                    qiuTuModel = modelManager.models.get('qiu_tu');
                // 计算凹面缩放系数
                if (qiuAoModel?.model) {
                    const aoObj = qiuAoModel.model;
                    const origScaleAo = aoObj.scale.clone();
                    aoObj.scale.set(1, 1, 1);
                    aoObj.updateMatrixWorld();
                    const aoOrigSize = new THREE.Box3().setFromObject(aoObj).getSize(new THREE.Vector3());
                    const aoOrigDiam = Math.max(aoOrigSize.x, aoOrigSize.z);
                    aoScale = aoOrigDiam > 0 ? coverDiameter / aoOrigDiam : 1;
                    aoObj.scale.copy(origScaleAo);
                    aoObj.updateMatrixWorld();
                    console.log(`凹面缩放系数: ${aoScale.toFixed(4)} (原始直径: ${aoOrigDiam.toFixed(4)}, 目标直径: ${coverDiameter.toFixed(4)})`);
                }

                // 计算凸面缩放系数
                if (qiuTuModel?.model) {
                    const tuObj = qiuTuModel.model;
                    const origScaleTu = tuObj.scale.clone();
                    tuObj.scale.set(1, 1, 1);
                    tuObj.updateMatrixWorld();
                    const tuOrigSize = new THREE.Box3().setFromObject(tuObj).getSize(new THREE.Vector3());
                    const tuOrigDiam = Math.max(tuOrigSize.x, tuOrigSize.z);
                    tuScale = tuOrigDiam > 0 ? coverDiameter / tuOrigDiam : 1;
                    tuObj.scale.copy(origScaleTu);
                    tuObj.updateMatrixWorld();
                    console.log(`凸面缩放系数: ${tuScale.toFixed(4)} (原始直径: ${tuOrigDiam.toFixed(4)}, 目标直径: ${coverDiameter.toFixed(4)})`);
                }
                unifiedScale = Math.min(aoScale, tuScale);
                // 取较小值作为统一缩放系数
                if (aoScale !== null && tuScale !== null) {
                    console.log(`🔧 统一缩放系数(取较小值): ${unifiedScale.toFixed(4)} (凹面:${aoScale.toFixed(4)}, 凸面:${tuScale.toFixed(4)})`);
                } else if (aoScale !== null) {
                    console.log(`🔧 统一缩放系数(仅凹面): ${unifiedScale.toFixed(4)}`);
                } else if (tuScale !== null) {
                    console.log(`🔧 统一缩放系数(仅凸面): ${unifiedScale.toFixed(4)}`);
                } else {
                    console.warn("⚠️ 无法计算缩放系数,凹面和凸面模型均不存在");
                }

                // ---- 凹面处理 ----
                if (qiuAoModel?.model && partUpper && unifiedScale !== null) {
                    const aoObj = qiuAoModel.model;
                    aoObj.position.set(0, 0, 0);
                    aoObj.quaternion.identity();
                    aoObj.scale.set(1, 1, 1);
                    aoObj.updateMatrixWorld();

					const localBox = new THREE.Box3().setFromObject(aoObj);

					const localHeight = localBox.max.y - localBox.min.y;

					const actualAoHeight =localHeight * unifiedScale;

                    // const aoOrigSize = new THREE.Box3().setFromObject(aoObj).getSize(new THREE.Vector3());
                    console.log(`凹面应用统一缩放系数: ${unifiedScale.toFixed(4)}`);
                    const coverHeight = actualAoHeight;
                    // 挖槽
                    const coverCylinder = new THREE.Mesh(new THREE.CylinderGeometry(coverDiameter / 2, coverDiameter / 2,
                        coverHeight*1.005, 64), new THREE.MeshStandardMaterial({ color: 0xffffff, transparent: true,
                        opacity: 0.3, depthWrite: false }));
                    coverCylinder.userData.isCoverCylinder = true;
                    const cylQuat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), planeNormal
                        .clone());
                    coverCylinder.quaternion.copy(cylQuat);
                    coverCylinder.position.copy(planePos.clone().add(planeNormal.clone().multiplyScalar(coverHeight / 2)));
                    coverCylinder.updateMatrixWorld();
                    scene.add(coverCylinder);
                    const mergedWithCover = await mergeMeshes(partUpper, coverCylinder, SUBTRACTION);
                    scene.remove(coverCylinder);
                    if (mergedWithCover) { scene.remove(partUpper);
                        scene.add(mergedWithCover);
                        const idxP = cutParts.indexOf(partUpper); if (idxP !== -1) cutParts[idxP] = mergedWithCover;
                        partUpper = mergedWithCover;
                        console.log("✅ 已挖出凹槽"); }

                    // 嵌入凹面
                    aoObj.position.set(0, 0, 0);
                    aoObj.quaternion.identity();
                    aoObj.scale.set(1, 1, 1);
                    aoObj.updateMatrixWorld();

                    const quatAo = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), planeNormal.clone()
                        .negate());
                    aoObj.quaternion.copy(quatAo);
                    aoObj.scale.set(unifiedScale, unifiedScale, unifiedScale);
                    aoObj.updateMatrixWorld();

					const topLocal =  new THREE.Vector3(0, localBox.max.y, 0 );

                    const worldTop =  topLocal.clone().applyMatrix4(aoObj.matrixWorld);

					aoObj.position.add(planePos.clone().sub(worldTop)	.add(planeNormal.clone().multiplyScalar(-actualAoHeight * 0.01))	);

                    // aoObj.position.add(planePos.clone().sub(worldGap).add(planeNormal.clone().multiplyScalar(0.98 * actualAoHeight)));
                    aoObj.updateMatrixWorld();
                    scene.add(aoObj);
                    const mergedAo = await mergeMeshes(partUpper, aoObj, ADDITION);
                    if (mergedAo) { scene.remove(partUpper);
                        scene.remove(aoObj);
                        scene.add(mergedAo);
                        const idxP = cutParts.indexOf(partUpper); if (idxP !== -1) cutParts[idxP] = mergedAo;
                        partUpper = mergedAo;
                        console.log("✅ 凹面已合并(使用统一缩放系数)"); }
                }

                // ---- 凸面处理 ----
                if (qiuTuModel?.model && partLower && unifiedScale !== null) {
                    const tuObj = qiuTuModel.model;
                    tuObj.position.set(0, 0, 0);
                    tuObj.quaternion.identity();
                    tuObj.scale.set(1, 1, 1);
                    tuObj.updateMatrixWorld();
                    tuObj.scale.set(unifiedScale, unifiedScale, unifiedScale);
                    tuObj.updateMatrixWorld();
                    console.log(`凸面应用统一缩放: ${unifiedScale.toFixed(4)}`);
                    // 对齐(跳过内部缩放)
                    alignModelToCutPlane(tuObj, cutPlaneMesh, 'tu', partLower, null, true);
                    await new Promise(r => setTimeout(r, 50));
                    const mergedTu = await mergeMeshes(partLower, tuObj);
                    if (mergedTu) { scene.remove(partLower);
                        scene.remove(tuObj);
                        scene.add(mergedTu);
                        const idxP = cutParts.indexOf(partLower); if (idxP !== -1) cutParts[idxP] = mergedTu;
                        tuObj.visible = false;
                        tuObj.userData.isMerged = true;
                        console.log("✅ 凸面已合并(使用统一缩放系数)"); }
                }

                cutParts.forEach(p => p?.geometry && centerGeometry(p));
                if (cutParts.length) safeAttach(cutParts[0]);
                syncToActiveModel();

        }

        function updateCutPlaneSize(size) {
            currentCutPlaneSize = size;
            if (cutPlaneMesh) { const p = cutPlaneMesh.position.clone(),
                    r = cutPlaneMesh.quaternion.clone();
                cutPlaneMesh.geometry.dispose();
                cutPlaneMesh.geometry = new THREE.BoxGeometry(size, CUT_PLANE_THICKNESS, size);
                cutPlaneMesh.position.copy(p);
                cutPlaneMesh.quaternion.copy(r);
                syncToActiveModel(); }
        }

        let _safeAttachLock = false;

        function safeAttach(obj) { if (_safeAttachLock) return;
            _safeAttachLock = true; try { if (!obj) transformControls.detach();
                else { let root = obj; while (root.parent) root = root.parent;
                    root === scene ? transformControls.attach(obj) : transformControls.detach(); } } finally {
                _safeAttachLock = false; } }

        function selectObject(type) {
            selectedObject = type;
            if (type === 'plane' && cutPlaneMesh) { safeAttach(cutPlaneMesh);
                cutPlaneMesh.material.emissive.setHex(0x884488);
                cutPlaneMesh.material.opacity = 0.65; } else if (type === 'model') { if (cutParts.length && selectedPartIndex >=
                    0) safeAttach(cutParts[selectedPartIndex]);
                else if (currentModelObject) safeAttach(currentModelObject);
                else if (cutParts.length) { selectedPartIndex = 0;
                    safeAttach(cutParts[0]); } else transformControls.detach(); if (cutPlaneMesh) { cutPlaneMesh.material
                        .emissive.setHex(0x331133);
                    cutPlaneMesh.material.opacity = 0.35; } }
            syncToActiveModel();
        }

        function alignModelToCutPlane(model, cutPlaneMesh, type = 'ao', targetPart = null, targetDiameter = null, skipScale =
            false) {
            if (!model || !cutPlaneMesh) return;
            const planePos = cutPlaneMesh.position.clone(),
                planeQuat = cutPlaneMesh.quaternion.clone(),
                planeNormal = new THREE.Vector3(0, 1, 0).applyQuaternion(planeQuat).normalize();
            if (!skipScale) {
                const finalDiameter = targetDiameter || currentCutPlaneSize * 0.3;
                model.scale.set(1, 1, 1);
                model.updateMatrixWorld();
                const bboxOrig = new THREE.Box3().setFromObject(model),
                    origSize = bboxOrig.getSize(new THREE.Vector3()),
                    origDiameter = Math.max(origSize.x, origSize.z);
                const scale = origDiameter > 0 ? finalDiameter / origDiameter : 1;
                model.scale.set(scale, scale, scale);
                model.updateMatrixWorld();
            }
            model.updateMatrixWorld();
            const bbox = new THREE.Box3().setFromObject(model);
            const contactLocal = new THREE.Vector3(0, bbox.min.y, 0);
            const isAo = type === 'ao';
            let offsetDist, targetNormal, offsetVector;
            if (isAo) {
                const height = bbox.max.y - bbox.min.y;
                offsetDist = height * 0.5;
                targetNormal = planeNormal.clone();
                offsetVector = planeNormal.clone().negate().multiplyScalar(-offsetDist);
            } else {
                offsetDist = Math.min(0.3, Math.max(0.001, (targetDiameter || currentCutPlaneSize * 0.3) * 0.005));
                targetNormal = planeNormal.clone();
                offsetVector = planeNormal.clone().multiplyScalar(-offsetDist);
            }
            const quatAlign = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), targetNormal);
            model.quaternion.copy(quatAlign);
            const worldContact = contactLocal.clone().applyQuaternion(model.quaternion);
            const targetPoint = planePos.clone().add(offsetVector);
            model.position.copy(targetPoint.sub(worldContact));
            model.updateMatrixWorld();
            model.visible = true;
        }

        async function mergeMeshes(meshA, meshB, operation = ADDITION) {
            if (!meshA || !meshB) return null;
            const geoA = extractWorldGeometry(meshA),
                geoB = extractWorldGeometry(meshB);
            if (!geoA || !geoB) return null;
            const brushA = new Brush(geoA, new THREE.MeshStandardMaterial()),
                brushB = new Brush(geoB, new THREE.MeshStandardMaterial());
            brushA.updateMatrixWorld();
            brushB.updateMatrixWorld();
            brushA.prepareGeometry();
            brushB.prepareGeometry();
            brushA.geometry.setAttribute('uv', new BufferAttribute(new Float32Array([]), 1));
            brushB.geometry.setAttribute('uv', new BufferAttribute(new Float32Array([]), 1));
            const result = new Evaluator().evaluate(brushA, brushB, operation);
            if (!result?.geometry?.attributes.position?.count) return null;
            const cleaned = cleanGeometry(result.geometry);
            const merged = new THREE.Mesh(cleaned, new THREE.MeshStandardMaterial({ color: 0xaa99ff, roughness: 0.3,
                metalness: 0.5 }));
            merged.castShadow = merged.receiveShadow = true;
            centerGeometry(merged);
            return merged;
        }


        // ==================== 重置模型 ====================
        function resetModel() {
            const activeData = getActiveData();
            if (!activeData) return;
            [currentModelObject, ...cutParts].forEach(obj => { if (obj) { scene.remove(obj);
                    obj.geometry?.dispose(); if (obj.material)(Array.isArray(obj.material) ? obj.material : [obj.material])
                    .forEach(m => m.dispose()); } });
            if (activeData.originalModelGroup) {
                const c = activeData.originalModelGroup.clone();
                c.traverse(ch => { if (ch.isMesh) { ch.castShadow = true;
                        ch.receiveShadow = true; } });
                scene.add(c);
                currentModelObject = c;
                cutParts = [];
                if (cutPlaneMesh) { scene.remove(cutPlaneMesh);
                    cutPlaneMesh = null; }
                selectedPartIndex = -1;
                selectedObject = 'model';
                safeAttach(c);
                const box = new THREE.Box3().setFromObject(c);
                const ct = box.getCenter(new THREE.Vector3());
                const sz = box.getSize(new THREE.Vector3());
                const md = Math.max(sz.x, sz.y, sz.z);
                camera.position.set(ct.x + md * 1.3, ct.y + md * 0.9, ct.z + md * 1.6);
                orbitControls.target.copy(ct);
                orbitControls.update();
            }
            syncToActiveModel();
        }

		async function generateTenonJoint() {
			if (!cutPlaneMesh) { alert("请先加载模型"); return; }

			const tolerance = parseFloat(document.getElementById('tolerance').value) || DEFAULT_TOLERANCE;
			console.log(`间隔tolerance: ${tolerance}`);

			// ---------- 1. 获取上下部件 ----------
			let partUpper = null, partLower = null, planeAxes = null;

			// 先尝试从现有 cutParts 中找(避免重复切割)
			if (cutParts.length >= 2) {
				const tempAxes = getPlaneAxes(cutPlaneMesh);
				for (let i = 0; i < cutParts.length; i++) {
					const side = getPartSide(cutParts[i], tempAxes.pos, tempAxes.normal);
					if (side === 'positive' && !partUpper) partUpper = cutParts[i];
					if (side === 'negative' && !partLower) partLower = cutParts[i];
				}
				if (partUpper && partLower) {
					planeAxes = tempAxes;
					console.log("📦 使用现有切割部件");
				}
			}

			// 如果找不到有效上下部件,则调用通用切割
			if (!partUpper || !partLower) {
				console.log("🔄 部件无效,执行自动切割...");
				const cutResult = await performCut();
				if (!cutResult || !cutResult.partUpper || !cutResult.partLower) {
					alert("无法获取有效的上下部件,请检查切面位置");
					return;
				}
				partUpper = cutResult.partUpper;
				partLower = cutResult.partLower;
				planeAxes = cutResult.planeAxes;
			}

			const planePos = planeAxes.pos;
			const planeNormal = planeAxes.normal;
			const planeQuat = planeAxes.quat;

			// ---------- 2. 计算榫卯参数 ----------
			const lowerGeo = extractWorldGeometry(partLower);
			const upperGeo = extractWorldGeometry(partUpper);
			if (!lowerGeo || !upperGeo) { alert("无法提取几何体"); return; }

			const info = calculateIntersectionInfo(lowerGeo, cutPlaneMesh);
			if (!info.valid) { alert("切面与部件相交区域太小"); return; }

			const tenonSize = info.tenonSize;
			const mortiseSize = tenonSize + tolerance;
			const intersectionCenter = info.center;

			console.log("🔩 自适应榫卯:");
			console.log(`  榫头边长: ${tenonSize.toFixed(3)}mm, 卯眼边长: ${mortiseSize.toFixed(3)}mm`);
			console.log(`  公差: ${tolerance.toFixed(3)}mm`);

			document.getElementById('tenon-display').innerText = tenonSize.toFixed(3);
			document.getElementById('mortise-display').innerText = `卯眼 ${mortiseSize.toFixed(3)}`;

			// ---------- 3. 生成榫卯刷子 ----------
			const offsetDistance = tenonSize * TENON_OFFSET_RATIO;
			const tenonCenter = intersectionCenter.clone().add(planeNormal.clone().multiplyScalar(offsetDistance));

			const tenonBoxGeo = new THREE.BoxGeometry(tenonSize, tenonSize, tenonSize);
			const tenonBrush = new Brush(tenonBoxGeo, new THREE.MeshStandardMaterial({color: 0xff6348}));
			tenonBrush.applyMatrix4(new THREE.Matrix4().compose(tenonCenter, planeQuat, new THREE.Vector3(1,1,1)));
			tenonBrush.updateMatrixWorld(); tenonBrush.prepareGeometry();

			const mortiseBoxGeo = new THREE.BoxGeometry(mortiseSize, mortiseSize, mortiseSize);
			const mortiseBrush = new Brush(mortiseBoxGeo, new THREE.MeshStandardMaterial({color: 0xff6348}));
			mortiseBrush.applyMatrix4(new THREE.Matrix4().compose(tenonCenter, planeQuat, new THREE.Vector3(1,1,1)));
			mortiseBrush.updateMatrixWorld(); mortiseBrush.prepareGeometry();

			// ---------- 4. 布尔运算 ----------
			const lBrush = new Brush(lowerGeo, new THREE.MeshStandardMaterial({color: 0xaa99ff}));
			lBrush.updateMatrixWorld(); lBrush.prepareGeometry();
			const uBrush = new Brush(upperGeo, new THREE.MeshStandardMaterial({color: 0xaa99ff}));
			uBrush.updateMatrixWorld(); uBrush.prepareGeometry();

			const lowerWithTenon = window.csgEvaluator.evaluate(lBrush, tenonBrush, ADDITION);
			const upperWithMortise = window.csgEvaluator.evaluate(uBrush, mortiseBrush, SUBTRACTION);

			if (!lowerWithTenon?.geometry?.attributes.position?.count) { alert("下部榫头生成失败"); return; }
			if (!upperWithMortise?.geometry?.attributes.position?.count) { alert("上部卯眼生成失败"); return; }

			const cleanedLower = cleanGeometry(lowerWithTenon.geometry);
			const cleanedUpper = cleanGeometry(upperWithMortise.geometry);

			// ---------- 5. 更新场景 ----------
			const newLower = new THREE.Mesh(cleanedLower, new THREE.MeshStandardMaterial({ color: 0x66cc99, roughness: 0.3, metalness: 0.5 }));
			newLower.castShadow = true; newLower.receiveShadow = true; centerGeometry(newLower);

			const newUpper = new THREE.Mesh(cleanedUpper, new THREE.MeshStandardMaterial({ color: 0xaa88dd, roughness: 0.3, metalness: 0.5 }));
			newUpper.castShadow = true; newUpper.receiveShadow = true; centerGeometry(newUpper);

			// 临时标记(原有逻辑保留)
			const mk = new THREE.Mesh(new THREE.SphereGeometry(0.015,16,16), new THREE.MeshBasicMaterial({color:0xffff00}));
			mk.position.copy(intersectionCenter); mk.userData.isMarker=true; scene.add(mk);
			const tf = new THREE.Mesh(new THREE.BoxGeometry(tenonSize,tenonSize,tenonSize), new THREE.MeshBasicMaterial({color:0x00ff00,wireframe:true,transparent:true,opacity:0.7}));
			tf.position.copy(tenonCenter); tf.quaternion.copy(planeQuat); tf.userData.isMarker=true; scene.add(tf);
			const mf = new THREE.Mesh(new THREE.BoxGeometry(mortiseSize,mortiseSize,mortiseSize), new THREE.MeshBasicMaterial({color:0xff0000,wireframe:true,transparent:true,opacity:0.5}));
			mf.position.copy(tenonCenter); mf.quaternion.copy(planeQuat); mf.userData.isMarker=true; scene.add(mf);

			setTimeout(()=>{[mk,tf,mf].forEach(m=>{scene.remove(m);m.geometry?.dispose();m.material?.dispose();});},5000);

			// 替换场景中的旧部件
			scene.remove(partLower); scene.remove(partUpper);
			partLower.geometry?.dispose(); partLower.material?.dispose();
			partUpper.geometry?.dispose(); partUpper.material?.dispose();

			scene.add(newLower); scene.add(newUpper);

			const li = cutParts.indexOf(partLower), ui = cutParts.indexOf(partUpper);
			if (li !== -1 && ui !== -1) { cutParts[li] = newLower; cutParts[ui] = newUpper; }

			console.log("✅ 榫卯生成完成");
			selectedPartIndex = cutParts.indexOf(newLower);
			if (selectedPartIndex >= 0) transformControls.attach(cutParts[selectedPartIndex]);
		}
        async function generateTenonJoint_1() {
            if (!cutPlaneMesh) { alert("请先加载模型"); return; }
            if (cutParts.length < 2) { alert("请先切割"); return; }

            const planeAxes = getPlaneAxes(cutPlaneMesh);
            const planePos = planeAxes.pos;
            const planeNormal = planeAxes.normal;
            const planeQuat = planeAxes.quat;
            const tolerance = parseFloat(document.getElementById('tolerance').value) || DEFAULT_TOLERANCE;
            console.log(`间隔tolerance: ${tolerance}`);
            try {
                let partUpper = null, partLower = null;
                for (let i = 0; i < cutParts.length; i++) {
                    const side = getPartSide(cutParts[i], planePos, planeNormal);
                    if (side === 'positive' && !partUpper) partUpper = cutParts[i];
                    if (side === 'negative' && !partLower) partLower = cutParts[i];
                }
                if (!partUpper || !partLower) { alert("无法区分上下部件"); return; }

                const lowerGeo = extractWorldGeometry(partLower);
                const upperGeo = extractWorldGeometry(partUpper);
                if (!lowerGeo || !upperGeo) { alert("无法提取几何体"); return; }

                const info = calculateIntersectionInfo(lowerGeo, cutPlaneMesh);
                if (!info.valid) { alert("切面与部件相交区域太小"); return; }

                const tenonSize = info.tenonSize;
                const mortiseSize = tenonSize + tolerance;
                const intersectionCenter = info.center;

                console.log("🔩 自适应榫卯:");
                console.log(`  榫头边长: ${tenonSize.toFixed(3)}mm, 卯眼边长: ${mortiseSize.toFixed(3)}mm`);
                console.log(`  公差: ${tolerance.toFixed(3)}mm`);

                document.getElementById('tenon-display').innerText = tenonSize.toFixed(3);
                document.getElementById('mortise-display').innerText = `卯眼 ${mortiseSize.toFixed(3)}`;

                const offsetDistance = tenonSize * TENON_OFFSET_RATIO;
                const tenonCenter = intersectionCenter.clone().add(planeNormal.clone().multiplyScalar(offsetDistance));

                const tenonBoxGeo = new THREE.BoxGeometry(tenonSize, tenonSize, tenonSize);
                const tenonBrush = new Brush(tenonBoxGeo, new THREE.MeshStandardMaterial({color: 0xff6348}));
                tenonBrush.applyMatrix4(new THREE.Matrix4().compose(tenonCenter, planeQuat, new THREE.Vector3(1,1,1)));
                tenonBrush.updateMatrixWorld(); tenonBrush.prepareGeometry();

                const mortiseBoxGeo = new THREE.BoxGeometry(mortiseSize, mortiseSize, mortiseSize);
                const mortiseBrush = new Brush(mortiseBoxGeo, new THREE.MeshStandardMaterial({color: 0xff6348}));
                mortiseBrush.applyMatrix4(new THREE.Matrix4().compose(tenonCenter, planeQuat, new THREE.Vector3(1,1,1)));
                mortiseBrush.updateMatrixWorld(); mortiseBrush.prepareGeometry();

                const lBrush = new Brush(lowerGeo, new THREE.MeshStandardMaterial({color: 0xaa99ff}));
                lBrush.updateMatrixWorld(); lBrush.prepareGeometry();
                const uBrush = new Brush(upperGeo, new THREE.MeshStandardMaterial({color: 0xaa99ff}));
                uBrush.updateMatrixWorld(); uBrush.prepareGeometry();

                const lowerWithTenon = csgEvaluator.evaluate(lBrush, tenonBrush, ADDITION);
                const upperWithMortise = csgEvaluator.evaluate(uBrush, mortiseBrush, SUBTRACTION);

                if (!lowerWithTenon?.geometry?.attributes.position?.count) { alert("下部榫头生成失败"); return; }
                if (!upperWithMortise?.geometry?.attributes.position?.count) { alert("上部卯眼生成失败"); return; }

                const cleanedLower = cleanGeometry(lowerWithTenon.geometry);  // 使用简单清理
                const cleanedUpper = cleanGeometry(upperWithMortise.geometry); // 使用简单清理

                const newLower = new THREE.Mesh(cleanedLower, new THREE.MeshStandardMaterial({ color: 0x66cc99, roughness: 0.3, metalness: 0.5 }));
                newLower.castShadow = true; newLower.receiveShadow = true; centerGeometry(newLower);

                const newUpper = new THREE.Mesh(cleanedUpper, new THREE.MeshStandardMaterial({ color: 0xaa88dd, roughness: 0.3, metalness: 0.5 }));
                newUpper.castShadow = true; newUpper.receiveShadow = true; centerGeometry(newUpper);

                const mk = new THREE.Mesh(new THREE.SphereGeometry(0.015,16,16), new THREE.MeshBasicMaterial({color:0xffff00}));
                mk.position.copy(intersectionCenter); mk.userData.isMarker=true; scene.add(mk);
                const tf = new THREE.Mesh(new THREE.BoxGeometry(tenonSize,tenonSize,tenonSize), new THREE.MeshBasicMaterial({color:0x00ff00,wireframe:true,transparent:true,opacity:0.7}));
                tf.position.copy(tenonCenter); tf.quaternion.copy(planeQuat); tf.userData.isMarker=true; scene.add(tf);
                const mf = new THREE.Mesh(new THREE.BoxGeometry(mortiseSize,mortiseSize,mortiseSize), new THREE.MeshBasicMaterial({color:0xff0000,wireframe:true,transparent:true,opacity:0.5}));
                mf.position.copy(tenonCenter); mf.quaternion.copy(planeQuat); mf.userData.isMarker=true; scene.add(mf);

                setTimeout(()=>{[mk,tf,mf].forEach(m=>{scene.remove(m);m.geometry?.dispose();m.material?.dispose();});},5000);

                scene.remove(partLower); scene.remove(partUpper);
                partLower.geometry?.dispose(); partLower.material?.dispose();
                partUpper.geometry?.dispose(); partUpper.material?.dispose();

                scene.add(newLower); scene.add(newUpper);

                const li=cutParts.indexOf(partLower), ui=cutParts.indexOf(partUpper);
                if (li!==-1&&ui!==-1) { cutParts[li]=newLower; cutParts[ui]=newUpper; }

                console.log("✅ 榫卯生成完成");
                selectedPartIndex=cutParts.indexOf(newLower);
                if (selectedPartIndex>=0) transformControls.attach(cutParts[selectedPartIndex]);
                // updateSelectedText();

            } catch(err) { console.error("生成榫卯失败:",err); alert("生成榫卯失败: "+err.message); }
        }
        // ==================== 导出功能 ====================
        function exportToSTL() {
            const loadingOverlay = document.getElementById('loading-overlay');
            if (loadingOverlay) loadingOverlay.style.display = 'flex';
            setTimeout(() => {
                try {
                    const meshesToExport = [];
                    const collect = (obj) => { if (!obj || obj.userData?.isCutPlane || obj === transformControls)
                            return; if (obj.isMesh) meshesToExport.push(obj); if (obj.children) obj.children.forEach(
                            c => collect(c)); };
                    cutParts.forEach(p => { if (p.parent === scene) collect(p); });
                    if (currentModelObject?.parent === scene) collect(currentModelObject);
                    if (!meshesToExport.length) { alert("无模型");
                        loadingOverlay.style.display = 'none'; return; }
                    let stl = "solid model\n";
                    meshesToExport.forEach(mesh => {
                        const mw = mesh.matrixWorld.clone();
                        const geo = mesh.geometry.clone();
                        const pos = geo.attributes.position.array,
                            idx = geo.index?.array,
                            nml = geo.attributes.normal?.array;
                        if (idx) {
                            for (let i = 0; i < idx.length; i += 3) {
                                const i1 = idx[i],
                                    i2 = idx[i + 1],
                                    i3 = idx[i + 2];
                                const v1 = new THREE.Vector3(pos[i1 * 3], pos[i1 * 3 + 1], pos[i1 * 3 + 2])
                                    .applyMatrix4(mw);
                                const v2 = new THREE.Vector3(pos[i2 * 3], pos[i2 * 3 + 1], pos[i2 * 3 + 2])
                                    .applyMatrix4(mw);
                                const v3 = new THREE.Vector3(pos[i3 * 3], pos[i3 * 3 + 1], pos[i3 * 3 + 2])
                                    .applyMatrix4(mw);
                                let normal;
                                if (nml) {
                                    const n1 = new THREE.Vector3(nml[i1 * 3], nml[i1 * 3 + 1], nml[i1 * 3 + 2]),
                                        n2 = new THREE.Vector3(nml[i2 * 3], nml[i2 * 3 + 1], nml[i2 * 3 + 2]),
                                        n3 = new THREE.Vector3(nml[i3 * 3], nml[i3 * 3 + 1], nml[i3 * 3 + 2]);
                                    normal = n1.clone().add(n2).add(n3).normalize();
                                } else {
                                    const e1 = new THREE.Vector3().subVectors(v2, v1),
                                        e2 = new THREE.Vector3().subVectors(v3, v1);
                                    normal = new THREE.Vector3().crossVectors(e1, e2).normalize();
                                }
                                stl +=
                                    `  facet normal ${normal.x.toFixed(6)} ${normal.y.toFixed(6)} ${normal.z.toFixed(6)}\n    outer loop\n      vertex ${v1.x.toFixed(6)} ${v1.y.toFixed(6)} ${v1.z.toFixed(6)}\n      vertex ${v2.x.toFixed(6)} ${v2.y.toFixed(6)} ${v2.z.toFixed(6)}\n      vertex ${v3.x.toFixed(6)} ${v3.y.toFixed(6)} ${v3.z.toFixed(6)}\n    endloop\n  endfacet\n`;
                            }
                        } else {
                            for (let i = 0; i < pos.length; i += 9) {
                                const v1 = new THREE.Vector3(pos[i], pos[i + 1], pos[i + 2]).applyMatrix4(mw),
                                    v2 = new THREE.Vector3(pos[i + 3], pos[i + 4], pos[i + 5]).applyMatrix4(mw),
                                    v3 = new THREE.Vector3(pos[i + 6], pos[i + 7], pos[i + 8]).applyMatrix4(mw);
                                let normal;
                                if (nml) {
                                    const n1 = new THREE.Vector3(nml[i], nml[i + 1], nml[i + 2]),
                                        n2 = new THREE.Vector3(nml[i + 3], nml[i + 4], nml[i + 5]),
                                        n3 = new THREE.Vector3(nml[i + 6], nml[i + 7], nml[i + 8]);
                                    normal = n1.clone().add(n2).add(n3).normalize();
                                } else {
                                    const e1 = new THREE.Vector3().subVectors(v2, v1),
                                        e2 = new THREE.Vector3().subVectors(v3, v1);
                                    normal = new THREE.Vector3().crossVectors(e1, e2).normalize();
                                }
                                stl +=
                                    `  facet normal ${normal.x.toFixed(6)} ${normal.y.toFixed(6)} ${normal.z.toFixed(6)}\n    outer loop\n      vertex ${v1.x.toFixed(6)} ${v1.y.toFixed(6)} ${v1.z.toFixed(6)}\n      vertex ${v2.x.toFixed(6)} ${v2.y.toFixed(6)} ${v2.z.toFixed(6)}\n      vertex ${v3.x.toFixed(6)} ${v3.y.toFixed(6)} ${v3.z.toFixed(6)}\n    endloop\n  endfacet\n`;
                            }
                        }
                    });
                    stl += "endsolid model\n";
                    const blob = new Blob([stl], { type: 'text/plain' });
                    const a = document.createElement('a');
                    a.href = URL.createObjectURL(blob);
                    a.download = `model_${Date.now()}.stl`;
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                    URL.revokeObjectURL(a.href);
                } catch (e) { alert("导出出错: " + e.message); } finally { loadingOverlay.style.display = 'none'; }
            }, 100);
        }
        function exportToGLB() {
            document.getElementById('loading-overlay').style.display = 'flex';
            setTimeout(() => {
                try {
                    const eg = new THREE.Group();
                    const addMesh = (mesh, pg) => {
                        mesh.updateWorldMatrix(true, false);
                        let cg = cleanGeometry(mesh.geometry.clone());
                        cg.computeBoundingSphere();
                        let cm = Array.isArray(mesh.material) ? mesh.material.map(m => new THREE.MeshStandardMaterial({
                            color: m.color?.getHex() || 0xccaa88,
                            roughness: 0.5,
                            metalness: 0.5
                        })) : new THREE.MeshStandardMaterial({ color: mesh.material?.color?.getHex() || 0xccaa88,
                            roughness: 0.5, metalness: 0.5 });
                        const cmesh = new THREE.Mesh(cg, cm);
                        const wm = mesh.matrixWorld.clone();
                        const wp = new THREE.Vector3(),
                            wq = new THREE.Quaternion(),
                            ws = new THREE.Vector3();
                        wm.decompose(wp, wq, ws);
                        cmesh.position.copy(wp);
                        cmesh.quaternion.copy(wq);
                        cmesh.scale.copy(ws);
                        pg.add(cmesh);
                    };
                    const collect = (obj, pg) => { if (!obj || obj.userData?.isCutPlane || obj === transformControls)
                            return; if (obj.isMesh) addMesh(obj, pg); if (obj.children) obj.children.forEach(c =>
                            collect(c, pg)); };
                    cutParts.forEach(p => { if (p.parent === scene) collect(p, eg); });
                    if (currentModelObject?.parent === scene) collect(currentModelObject, eg);
                    if (!eg.children.length) { alert("无模型");
                        document.getElementById('loading-overlay').style.display = 'none'; return; }
                    new GLTFExporter().parse(eg, result => {
                        const blob = result instanceof ArrayBuffer ? new Blob([result], { type: 'application/octet-stream' }) :
                            new Blob([JSON.stringify(result)], { type: 'application/json' });
                        const a = document.createElement('a');
                        a.href = URL.createObjectURL(blob);
                        a.download = `model_${Date.now()}.glb`;
                        document.body.appendChild(a);
                        a.click();
                        document.body.removeChild(a);
                        document.getElementById('loading-overlay').style.display = 'none';
                    }, err => { alert("导出失败");
                        document.getElementById('loading-overlay').style.display = 'none'; }, { binary: true,
                        onlyVisible: true, trs: true });
                } catch (e) { alert("导出出错: " + e.message);
                    document.getElementById('loading-overlay').style.display = 'none'; }
            }, 100);
        }

        // ==================== UI 交互 ====================
        const raycaster = new THREE.Raycaster(),
            mouse = new THREE.Vector2();
        let isDragging = false,
            mouseDownPos = new THREE.Vector2();
        renderer.domElement.addEventListener('mousedown', e => { mouseDownPos.set(e.clientX, e.clientY);
            isDragging = false; });
        renderer.domElement.addEventListener('mousemove', e => { if (Math.abs(e.clientX - mouseDownPos.x) > 3 || Math.abs(e
                .clientY - mouseDownPos.y) > 3) isDragging = true; });
        renderer.domElement.addEventListener('mouseup', e => {
            if (isDragging || e.target !== renderer.domElement) return;
            mouse.set(e.clientX / innerWidth * 2 - 1, -e.clientY / innerHeight * 2 + 1);
            const candidates = [];
            if (cutPlaneMesh) candidates.push({ object: cutPlaneMesh, type: 'plane' });
            modelManager.getAllSelectableCandidates().forEach(c => candidates.push(c));
            let closest = Infinity,
                closestCand = null;
            const centerVec = new THREE.Vector3(),
                screenVec = new THREE.Vector3();
            candidates.forEach(c => { if (!c.object || c.object.parent !== scene) return;
                new THREE.Box3().setFromObject(c.object).getCenter(centerVec);
                screenVec.copy(centerVec).project(camera);
                const d = (screenVec.x - mouse.x) ** 2 + (screenVec.y - mouse.y) ** 2; if (d < closest) { closest =
                        d;
                    closestCand = c; } });
            if (!closestCand) return;
            if (closestCand.type === 'plane') { selectObject('plane'); return; }
            if (closestCand.modelId && modelManager.activeModelId !== closestCand.modelId) modelManager.setActiveModel(
                closestCand.modelId);
            if (closestCand.type === 'part') { selectedPartIndex = closestCand.partIndex;
                selectedObject = 'model'; if (cutParts[selectedPartIndex]) safeAttach(cutParts[selectedPartIndex]); } else if (
                closestCand.type === 'model') { selectedPartIndex = -1;
                selectedObject = 'model'; if (currentModelObject) safeAttach(currentModelObject); }
            syncToActiveModel();
        });

        // 事件监听
        document.getElementById('mode-translate').onclick = () => transformControls.setMode('translate');
        document.getElementById('mode-rotate').onclick = () => transformControls.setMode('rotate');
        document.getElementById('mode-scale').onclick = () => transformControls.setMode('scale');
        document.getElementById('start-cut-btn').onclick = startCut;
        document.getElementById('plane-scale').oninput = e => { document.getElementById('scale-value').innerText = parseFloat(e
                .target.value).toFixed(2);
            updateCutPlaneSize(parseFloat(e.target.value)); };
        document.getElementById('tolerance').oninput = e => document.getElementById('tolerance-value').innerText = parseFloat(e
            .target.value).toFixed(3) + 'mm';
        document.getElementById('qiu-btn').onclick = qiu_conn;
        document.getElementById('add-tenon-btn').onclick = generateTenonJoint;
        document.getElementById('reset-btn').onclick = resetModel;
        document.getElementById('export-stl-btn').onclick = exportToSTL;
        document.getElementById('export-glb-btn').onclick = exportToGLB;
        document.getElementById('delete-model-btn').onclick = () => modelManager.activeModelId && modelManager.removeModel(
            modelManager.activeModelId);
        document.getElementById('file-input').onchange = async e => { const file = e.target.files[0]; if (!file) return;
            const url = URL.createObjectURL(file); try { await modelManager.loadModel(url, "obj", null, { x: modelManager
                    .models.size * 1.8, y: -1.3, z: 0 }); } catch (err) { console.error(err); }
            URL.revokeObjectURL(url);
            e.target.value = ''; };

        window.addEventListener('keydown', e => {
            if (e.ctrlKey || e.metaKey) return;
            switch (e.key.toLowerCase()) {
                case 'c':
                    performCut();
                    break;
                case 'x':
                    generateTenonJoint();
                    break;
                case 'r':
                    resetModel();
                    break;
                case 'e':
                    exportToGLB();
                    break;
                case 's':
                    startCut();
                    break;
                case 't':
                    exportToSTL();
                    break;
            }
        });

        // ==================== 初始化模型 ====================
        (async () => {
            await modelManager.loadModel('./kakou_n/qiu_ao.glb', "qiu_ao", "qiu_ao", { x: 0, y: -1, z: 0 });
            await modelManager.loadModel('./kakou_n/qiu_tu_di.glb', "qiu_tu", "qiu_tu", { x: -0, y: -0.3, z: 0 });
            // await modelManager.loadModel('./kakou_s/zft.glb', "obj", null, { x: 12, y: -0.3, z: 0 });
            console.log("✅ 初始化完成,凹面和凸面将分别计算缩放系数,取较小值统一应用");
        })();

        function animate() {
            requestAnimationFrame(animate);
            if (transformControls.object && !scene.children.includes(transformControls.object)) transformControls.detach();
            orbitControls.update();
            renderer.render(scene, camera);
        }
        animate();

        window.onresize = () => { camera.aspect = innerWidth / innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(innerWidth, innerHeight); };
    </script>
</body>
</html>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AI算法网奇

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值