bambu-studio-ai 踩坑实战笔记

目录

功能介绍

修复模型:


功能介绍

bash 复制代码
- 11 项可打印性检查:包括壁厚、悬垂角度、悬空部件等。
- 自动网格修复:修复模型中的破洞、法线错误等,也包含你之前关心的"非流形边修复"。
- 智能摆放与缩放:自动为模型找到最佳打印方向和尺寸。


分析与修复	
- 11 项可打印性检查:包括壁厚、悬垂角度、悬空部件等。
- 自动网格修复:修复模型中的破洞、法线错误等,也包含你之前关心的"非流形边修复"。
- 智能摆放与缩放:自动为模型找到最佳打印方向和尺寸。	这相当于给模型做"体检"和"手术",确保它能被顺利打印。

多色处理	

- 纹理转顶点色:将带纹理的 3D 模型(如 GLB 格式)自动转换为 Bambu Studio 能识别的多色模型。
- AMS 耗材映射:自动分析模型颜色,并推荐最接近的 Bambu Lab 官方耗材颜色。	让多色打印的准备工作变得极其简单。

打印与控制	- 完整打印机控制:查看状态、开始/暂停/取消打印、切换速度模式、控制灯光等。
- AI 打印监控:通过摄像头画面分析,自动检测拉丝、翘边、炒面等异常并自动暂停。	将你的打印机变成一个可远程对话和监控的智能设备。
⚠️ 安全与交互	- 强制人工确认:在进行切片和打印这两个关键步骤前,AI 必须等待你的明确指令,不会自作主张。

修复模型:

bambu-studio-ai-main\scripts\analyze.py

矫正方向:矫正的不对

yotown_clear_oriented.stl

非流行边的错误有的解决了,有的没有。

开放边没有解决

yotown_clear_repaired.stl

非流行边会优化减少,没有解决。

yotown_clear_cleaned.stl

python 复制代码
#!/usr/bin/env python3

import argparse
import json
import math
import os
import sys

from common import (
    SKILL_DIR, BUILD_VOLUMES, MATERIALS, ENCLOSED_PRINTERS, HIGH_TEMP_PRINTERS,
    MAX_FACES_NO_SIMPLIFY, load_config, safe_split_mesh,
)


def _safe_split(mesh, timeout_sec=30):
    """Split mesh into connected components with cross-platform timeout."""
    return safe_split_mesh(mesh, timeout_sec=timeout_sec)


def auto_orient(mesh):
    """Auto-orient model for optimal 3D printing position.
    Finds the orientation with the largest flat surface on the build plate,
    minimizes overhangs, and places the model on the floor (z=0).
    """
    try:
        import trimesh
        best_score = -1
        best_transform = None

        # Try principal orientations + stable poses
        # Method 1: Use trimesh's stable poses (decimate first if too large)
        try:
            orient_mesh = mesh
            if len(mesh.faces) > MAX_FACES_NO_SIMPLIFY:
                print(f"   Large mesh ({len(mesh.faces):,} faces) --- using decimated proxy for orientation...")
                try:
                    orient_mesh = mesh.simplify_quadric_decimation(100000)
                    if orient_mesh is None or len(orient_mesh.faces) == 0:
                        orient_mesh = mesh
                except Exception:
                    orient_mesh = mesh
            transforms, probs = trimesh.poses.compute_stable_poses(orient_mesh, n_samples=50)
            for i, (T, p) in enumerate(zip(transforms, probs)):
                candidate = mesh.copy()
                candidate.apply_transform(T)
                # Score: probability * base area
                bounds = candidate.bounds
                base_area = (bounds[1][0] - bounds[0][0]) * (bounds[1][1] - bounds[0][1])
                height = bounds[1][2] - bounds[0][2]
                # Prefer: high probability, large base, low height (less supports)
                score = p * base_area / max(height, 0.001)
                if score > best_score:
                    best_score = score
                    best_transform = T
        except Exception:
            # Fallback: try 6 cardinal orientations
            import numpy as np
            rotations = [
                np.eye(4),  # original
                trimesh.transformations.rotation_matrix(np.pi/2, [1, 0, 0]),   # +90 X
                trimesh.transformations.rotation_matrix(-np.pi/2, [1, 0, 0]),  # -90 X
                trimesh.transformations.rotation_matrix(np.pi/2, [0, 1, 0]),   # +90 Y
                trimesh.transformations.rotation_matrix(-np.pi/2, [0, 1, 0]),  # -90 Y
                trimesh.transformations.rotation_matrix(np.pi, [1, 0, 0]),     # 180 X
            ]
            for T in rotations:
                candidate = mesh.copy()
                candidate.apply_transform(T)
                bounds = candidate.bounds
                base_area = (bounds[1][0] - bounds[0][0]) * (bounds[1][1] - bounds[0][1])
                height = bounds[1][2] - bounds[0][2]
                # Count downward-facing faces (potential base)
                normals = candidate.face_normals
                down_faces = normals[normals[:, 2] < -0.9]
                base_coverage = len(down_faces) / max(len(normals), 1)
                score = base_area * (1 + base_coverage * 5) / max(height, 0.001)
                if score > best_score:
                    best_score = score
                    best_transform = T

        if best_transform is not None:
            mesh.apply_transform(best_transform)

        # Drop to floor (z=0)
        bounds = mesh.bounds
        mesh.apply_translation([0, 0, -bounds[0][2]])

        print(f"🔄 Auto-oriented: base area optimized, placed on build plate (z=0)")
        bounds = mesh.bounds
        dims = bounds[1] - bounds[0]
        # Detect if still in meters or already mm
        max_d = max(dims)
        if max_d < 10:  # Still meters
            print(f"   Dimensions: {dims[0]*1000:.1f} × {dims[1]*1000:.1f} × {dims[2]*1000:.1f} mm")
        else:
            print(f"   Dimensions: {dims[0]:.1f} × {dims[1]:.1f} × {dims[2]:.1f} mm")
        return mesh
    except Exception as e:
        print(f"⚠️ Auto-orient failed: {e}")
        # At least drop to floor
        bounds = mesh.bounds
        mesh.apply_translation([0, 0, -bounds[0][2]])
        return mesh

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))


def analyze_mesh(mesh, printer_model, material, purpose="general"):
    """Run printability checks + geometry analysis.
    Score is based on 9 real checks (tolerance, wall, load, overhang, orientation,
    floating parts, material compat, mesh quality, build volume fit).
    Recommendation-only checks (layer height, infill, walls, top layers) are
    reported but do NOT affect the score."""
    report = {
        "file": None,
        "printer": printer_model,
        "material": material,
        "purpose": purpose,
        "geometry": {},
        "checks": [],
        "issues": [],      # ❌ Must fix
        "warnings": [],    # ⚠️ Should review
        "suggestions": [], # 💡 Optional improvements
        "print_settings": {},
        "score": 0,
    }

    bounds = mesh.bounds
    dims = mesh.extents if mesh.extents is not None else [0, 0, 0]  # [x, y, z] dimensions in mm
    # Check if model is too complex (may be too large for printer SD card)
    if len(mesh.faces) > MAX_FACES_NO_SIMPLIFY:
        report["warnings"].append(
            f"Very high triangle count ({len(mesh.faces):,}). "
            f"Consider simplifying: open in Bambu Studio → right-click → Simplify Model, "
            f"or use: trimesh.simplify_quadric_decimation(mesh, face_count=100000)"
        )
    
    report["geometry"] = {
        "dimensions_mm": [round(d, 2) for d in dims] if dims is not None else [0, 0, 0],
        "volume_cm3": round(mesh.volume / 1000, 2),
        "surface_area_cm2": round(mesh.area / 100, 2),
        "triangle_count": len(mesh.faces),
        "is_watertight": mesh.is_watertight,
        "is_manifold": mesh.is_volume,
        "center_of_mass": [round(c, 2) for c in mesh.center_mass],
    }

    mat_props = MATERIALS.get(material, MATERIALS["PLA"])
    build_vol = BUILD_VOLUMES.get(printer_model, (230, 230, 230))
    checks_passed = 0
    total_checks = 0  # only count checks that genuinely test the model

    # === CHECK 1: Tolerance / Dimensions ===
    total_checks += 1
    check1 = {"name": "Dimensional tolerance", "status": "pass"}
    if any(d < 2.0 for d in dims):
        check1["status"] = "warn"
        report["warnings"].append("Very small dimension detected (<2mm). Ensure tolerance of +0.2mm for mating surfaces.")
    else:
        check1["note"] = "Dimensions OK. Remember +0.2mm tolerance for snap-fit or sliding parts."
        checks_passed += 1
    report["checks"].append(check1)

    # === CHECK 2: Wall Thickness ===
    total_checks += 1
    check2 = {"name": "Wall thickness", "status": "pass", "min_required": mat_props["min_wall"]}
    min_dim = min(dims)
    if min_dim < mat_props["min_wall"]:
        check2["status"] = "fail"
        report["issues"].append(f"Minimum dimension ({min_dim:.1f}mm) is below minimum wall thickness ({mat_props['min_wall']}mm) for {material}.")
    else:
        checks_passed += 1
    report["checks"].append(check2)

    # === CHECK 3: Load direction vs layer lines ===
    total_checks += 1
    check3 = {"name": "Load direction analysis", "status": "info"}
    aspect = max(dims) / (min(dims) + 0.001)
    if aspect > 5:
        check3["status"] = "warn"
        report["warnings"].append(f"High aspect ratio ({aspect:.1f}:1). If load-bearing, orient strongest axis along X/Y (not Z) to avoid layer delamination.")
    else:
        check3["note"] = "Aspect ratio OK for standard orientation."
        checks_passed += 1
    report["checks"].append(check3)

    # === CHECK 4: Overhang Detection (area-weighted, material-aware) ===
    total_checks += 1
    check4 = {"name": "Overhang analysis", "status": "pass"}
    face_normals = mesh.face_normals
    face_areas = mesh.area_faces
    total_area = mesh.area if mesh.area > 0 else 1.0
    
    # Material-aware thresholds
    overhang_thresholds = {
        "PLA": 50, "PETG": 45, "ABS": 45, "ASA": 45,
        "TPU": 60, "PA": 45, "PC": 45,
    }
    threshold_deg = overhang_thresholds.get(material, 45)
    threshold_cos = -math.cos(math.radians(threshold_deg))
    
    # Area-weighted overhang calculation (excludes near-horizontal bridging faces)
    overhang_mask = face_normals[:, 2] < threshold_cos
    # Exclude likely bridges: near-horizontal faces (|normal.z| < 0.1)
    bridge_mask = abs(face_normals[:, 2]) < 0.1
    overhang_mask = overhang_mask & ~bridge_mask
    
    overhang_area = face_areas[overhang_mask].sum()
    overhang_pct = round(overhang_area / total_area * 100, 1)
    # Express absolute area in cm² for context (20% of a tiny model ≠ 20% of a large one)
    overhang_area_cm2 = round(overhang_area / 100, 1)
    check4["overhang_area_pct"] = overhang_pct
    check4["overhang_area_cm2"] = overhang_area_cm2
    check4["threshold_deg"] = threshold_deg
    if overhang_pct > 20:
        check4["status"] = "fail"
        report["issues"].append(
            f"{overhang_pct}% surface area ({overhang_area_cm2}cm²) exceeds {threshold_deg}° overhang. "
            f"Needs supports or reorientation."
        )
    elif overhang_pct > 5:
        check4["status"] = "warn"
        report["warnings"].append(
            f"{overhang_pct}% surface area ({overhang_area_cm2}cm²) has >{threshold_deg}° overhang. "
            f"Consider tree supports or rotating the model."
        )
        checks_passed += 1
    else:
        checks_passed += 1
    report["checks"].append(check4)

    # === CHECK 5: Print Orientation ===
    total_checks += 1
    check5 = {"name": "Print orientation", "status": "pass"}
    # Check if model has a flat base
    z_min_faces = (abs(face_normals[:, 2] + 1.0) < 0.1).sum()  # faces pointing straight down
    flat_base_pct = round(z_min_faces / len(face_normals) * 100, 1)
    if flat_base_pct < 1:
        check5["status"] = "warn"
        report["warnings"].append("No clear flat base detected. Model may need rotation for bed adhesion.")
    else:
        check5["note"] = f"Flat base detected ({flat_base_pct}% bottom faces). Good bed adhesion expected."
        checks_passed += 1
    report["checks"].append(check5)

    # === CHECK 5b: Floating Parts Detection ===
    total_checks += 1
    check5b = {"name": "Floating/disconnected parts", "status": "pass"}
    try:
        bodies, split_timeout = _safe_split(mesh)
        if split_timeout:
            check5b["status"] = "warning"
            check5b["detail"] = "Mesh too complex for split analysis (timed out)"
            report["warnings"].append("Could not analyze disconnected parts --- mesh topology too complex. Visual check recommended.")
        elif len(bodies) > 1:
            sizes = sorted([b.volume for b in bodies], reverse=True)
            check5b["status"] = "fail"
            check5b["components"] = len(bodies)
            report["issues"].append(
                f"Model has {len(bodies)} disconnected parts! "
                f"Floating parts will fall during printing. "
                f"Merge into single mesh or remove small floating pieces. "
                f"Largest part: {sizes[0]:.1f}mm³, smallest: {sizes[-1]:.1f}mm³."
            )
        else:
            check5b["note"] = "Single connected body --- no floating parts."
            checks_passed += 1
    except Exception:
        check5b["status"] = "info"
        check5b["note"] = "Could not check connectivity."
        checks_passed += 1
    report["checks"].append(check5b)

    # === Recommendations (informational only --- not scored) ===
    check6 = {"name": "Layer height recommendation", "status": "info"}
    if min_dim < 10:
        check6["recommended"] = "0.12mm (fine detail)"
        report["suggestions"].append("Small features detected. Use 0.12mm layer height for detail.")
    elif max(dims) > 200:
        check6["recommended"] = "0.28mm (fast, large model)"
        report["suggestions"].append("Large model. Consider 0.28mm layer height to save time.")
    else:
        check6["recommended"] = "0.20mm (default, good balance)"
    report["checks"].append(check6)

    check7 = {"name": "Infill recommendation", "status": "info"}
    if purpose == "decorative":
        check7["recommended"] = f"{mat_props['infill_deco']}%"
    elif purpose == "functional":
        check7["recommended"] = f"{mat_props['infill_func']}%"
    else:
        check7["recommended"] = "15-30% (ask user about purpose)"
    report["checks"].append(check7)

    check8 = {"name": "Wall count recommendation", "status": "info"}
    check8["recommended"] = "≥3 walls (≥4 for functional parts)"
    if purpose == "functional":
        report["suggestions"].append("Functional part: use 4 walls for strength.")
    report["checks"].append(check8)

    check9 = {"name": "Top layers recommendation", "status": "info"}
    check9["recommended"] = "≥5 top layers for clean surface"
    report["checks"].append(check9)

    # === CHECK 10: Material Compatibility ===
    total_checks += 1
    check10 = {"name": "Material compatibility", "status": "pass"}
    if mat_props.get("enclosed") and printer_model not in ENCLOSED_PRINTERS:
        check10["status"] = "fail"
        report["issues"].append(f"{material} requires an enclosed printer. {printer_model} is open-frame.")
    elif material in ("PEEK", "PEI", "PPSU") and printer_model not in HIGH_TEMP_PRINTERS:
        check10["status"] = "fail"
        report["issues"].append(f"{material} requires 350°C nozzle. {printer_model} doesn't support it.")
    else:
        check10["note"] = f"{material} is compatible with {printer_model}."
        checks_passed += 1
    report["checks"].append(check10)

    # === MESH QUALITY (affects score) ===
    total_checks += 1
    if not mesh.is_watertight:
        report["issues"].append("Mesh is NOT watertight. May cause slicing errors. Use Fix Model in Bambu Studio.")
    elif not mesh.is_volume:
        report["warnings"].append("Non-manifold geometry detected. Bambu Studio may auto-repair, but review in preview.")
        checks_passed += 1  # warning only, partial credit
    else:
        checks_passed += 1

    # === FIT CHECK (affects score) ===
    total_checks += 1
    fits = True
    for i, (dim, vol) in enumerate(zip(dims, build_vol)):
        axis = ["X", "Y", "Z"][i]
        if dim > vol:
            fits = False
            report["issues"].append(f"Model {axis} dimension ({dim:.1f}mm) exceeds {printer_model} build volume ({vol}mm). Scale down or split.")
    if fits:
        checks_passed += 1

    # === PRINT SETTINGS RECOMMENDATION ===
    report["print_settings"] = {
        "layer_height": check6.get("recommended", "0.20mm"),
        "infill": check7.get("recommended", "15-30%"),
        "walls": "≥3" if purpose != "functional" else "≥4",
        "top_layers": "≥5",
        "material": material,
        "nozzle_temp": f"{mat_props['min_temp']}-{mat_props['max_temp']}°C",
        "bed_temp": f"{mat_props['bed']}°C",
        "supports": "needed" if overhang_pct > 10 else "likely not needed",
    }

    # === SCORE ===
    score = round(checks_passed / total_checks * 10, 1)
    if not fits:
        score = min(score, 5.0)
    report["score"] = score

    return report


def render_views(mesh, output_dir):
    """Render 4 views of the model for visual inspection."""
    try:
        import trimesh.viewer
        from PIL import Image
        import io

        views = {
            "front": [0, 0, 1],
            "side": [1, 0, 0],
            "top": [0, 0.001, 1],  # near-top
            "iso": [1, 1, 1],
        }

        rendered = []
        scene = mesh.scene()

        for name, direction in views.items():
            try:
                png = scene.save_image(resolution=(800, 600))
                path = os.path.join(output_dir, f"view_{name}.png")
                with open(path, "wb") as f:
                    f.write(png)
                rendered.append(path)
            except Exception:
                pass  # Rendering may not work headless

        return rendered
    except ImportError:
        return []


def auto_simplify(mesh, max_dim=None):
    """Auto-simplify mesh if face count is very high. Returns (mesh, simplified_bool)."""
    import trimesh
    face_count = len(mesh.faces)
    
    if face_count <= MAX_FACES_NO_SIMPLIFY:
        return mesh, False
    
    # Determine target
    if face_count > 2_000_000:
        target = 200_000
        print(f"⚠️ Very high face count ({face_count:,}) --- auto-simplifying to {target:,}")
    else:
        target = 100_000
        print(f"💡 High face count ({face_count:,}) --- simplifying to {target:,}")
    
    try:
        simplified = mesh.simplify_quadric_decimation(target)
        reduction = (1 - len(simplified.faces) / face_count) * 100
        print(f"✅ Simplified: {face_count:,} → {len(simplified.faces):,} faces ({reduction:.0f}% reduction)")
        return simplified, True
    except Exception as e:
        print(f"⚠️ Simplification failed: {e}")
        return mesh, False


def clean_floating_parts(mesh, min_volume_pct=1.0, keep_top_n=None):
    """Remove disconnected parts smaller than min_volume_pct of total volume.
    If keep_top_n is set (e.g. 1), keep only the largest N components.

    Safety: uses FACE COUNT as the primary metric (not volume) because
    trimesh computes unreliable volumes for non-watertight AI meshes.
    Also refuses to remove parts if the "kept" set would lose >50% of faces
    --- this catches cases where trimesh.split() mis-fragments a solid mesh.

    Returns (cleaned_mesh, removed_count)."""
    import trimesh

    bodies, timed_out = _safe_split(mesh)
    if timed_out or len(bodies) <= 1:
        return mesh, 0

    face_counts = [len(b.faces) for b in bodies]
    total_faces = sum(face_counts) or 1

    if keep_top_n is not None:
        sorted_pairs = sorted(zip(bodies, face_counts), key=lambda x: -x[1])
        kept_pairs = sorted_pairs[:keep_top_n]
        kept_faces = sum(fc for _, fc in kept_pairs)

        # Safety: if keeping top-N would discard >50% of faces, warn and bail
        if kept_faces < total_faces * 0.5:
            pct = kept_faces / total_faces * 100
            print(f"⚠️ Largest {keep_top_n} component(s) = {pct:.0f}% of faces --- "
                  f"split may be unreliable. Skipping clean to avoid data loss.")
            print(f"   💡 If model truly has floating junk, open in Blender and manually delete")
            return mesh, 0

        removed = len(bodies) - len(kept_pairs)
        if removed == 0:
            return mesh, 0
        print(f"🗑️ Removed {removed} floating part(s) "
              f"(kept {kept_faces:,}/{total_faces:,} faces, {kept_faces/total_faces*100:.0f}%)")
        if len(kept_pairs) == 1:
            return kept_pairs[0][0], removed
        return trimesh.util.concatenate([b for b, _ in kept_pairs]), removed
    else:
        # Volume-based threshold (original behavior) with face-count safety
        volumes = [b.volume for b in bodies]
        total_vol = sum(volumes)
        if total_vol <= 0:
            return mesh, 0
        threshold = total_vol * (min_volume_pct / 100.0)
        kept = [(b, fc) for b, v, fc in zip(bodies, volumes, face_counts) if v >= threshold]
        removed = len(bodies) - len(kept)

        if removed == 0:
            return mesh, 0

        kept_faces = sum(fc for _, fc in kept)
        # Safety: don't remove if we'd lose >40% of geometry
        if kept_faces < total_faces * 0.6:
            print(f"⚠️ Cleaning would remove {100 - kept_faces/total_faces*100:.0f}% of faces --- "
                  f"skipping (split may be unreliable on this mesh)")
            return mesh, 0

        removed_vol = total_vol - sum(b.volume for b, _ in kept)
        print(f"🗑️ Removed {removed} floating part(s) "
              f"({removed_vol:.1f}mm³, {removed_vol/total_vol*100:.1f}% of volume)")
        if len(kept) == 1:
            return kept[0][0], removed
        return trimesh.util.concatenate([b for b, _ in kept]), removed


def repair_mesh(mesh, output_path=None):
    """Attempt to repair mesh with tiered strategy: trimesh → PyMeshLab fallback."""
    import trimesh
    
    issues = []
    if not mesh.is_watertight:
        issues.append("not watertight")
    if not mesh.is_volume:
        issues.append("non-manifold edges")
    
    if not issues:
        print("✅ Mesh is clean --- no repair needed.")
        return mesh, False
    
    severity = "major" if "non-manifold" in " ".join(issues) else "minor"
    print(f"🔧 Repairing mesh ({', '.join(issues)}, severity: {severity})...")
    
    # --- Stage 1: trimesh basic repair ---
    trimesh.repair.fix_normals(mesh)
    trimesh.repair.fix_winding(mesh)
    trimesh.repair.fix_inversion(mesh)
    trimesh.repair.fill_holes(mesh)
    mesh.update_faces(mesh.nondegenerate_faces())
    mesh.merge_vertices()
    
    if mesh.is_watertight and mesh.is_volume:
        print(f"✅ Repaired (trimesh). Watertight: ✅ Manifold: ✅")
        if output_path:
            mesh.export(output_path)
            print(f"💾 Saved: {output_path}")
        return mesh, True
    
    # --- Stage 2: PyMeshLab advanced repair (if available) ---
    if severity == "major" or not mesh.is_watertight:
        try:
            import pymeshlab
            import tempfile
            
            print(f"   trimesh insufficient --- trying PyMeshLab...")
            
            # Save to temp, process in PyMeshLab, reload
            with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as tmp:
                tmp_path = tmp.name
                mesh.export(tmp_path)
            
            ms = pymeshlab.MeshSet()
            ms.load_new_mesh(tmp_path)
            # xxx

            # xxx
            # PyMeshLab repair pipeline
            ms.meshing_remove_duplicate_vertices()
            ms.meshing_remove_duplicate_faces()
            ms.meshing_repair_non_manifold_edges()
            ms.meshing_repair_non_manifold_vertices()
            ms.meshing_close_holes(maxholesize=30)

            # ms.meshing_remove_duplicate_vertices()
            # ms.meshing_remove_duplicate_faces()
            # ms.meshing_remove_unreferenced_vertices()
            # ms.meshing_repair_non_manifold_edges(method='UNFOLD')  # 改用 UNFOLD 方法增加非流行边
            # ms.meshing_repair_non_manifold_vertices()
            # ms.meshing_close_holes(maxholesize=50)  # 增大孔洞尺寸
            # ms.meshing_remove_tiny_components()  # 删除微小碎片 增加非流行边
            # ms.meshing_repair_watertight_mesh()#增加非流行边

            ms.save_current_mesh(tmp_path)
            mesh = trimesh.load(tmp_path, force="mesh")
            
            os.unlink(tmp_path)
            
            if mesh.is_watertight and mesh.is_volume:
                print(f"✅ Repaired (PyMeshLab). Watertight: ✅ Manifold: ✅")
            else:
                print(f"⚠️ Partial repair (PyMeshLab). Watertight: {mesh.is_watertight}, Manifold: {mesh.is_volume}")
        except ImportError:
            print(f"   💡 Install pymeshlab for better repair: pip3 install pymeshlab")
        except Exception as e:
            print(f"   ⚠️ PyMeshLab repair failed: {e}")
    
    if not (mesh.is_watertight and mesh.is_volume):
        print(f"⚠️ Partial repair. Watertight: {mesh.is_watertight}, Manifold: {mesh.is_volume}")
        print(f"   Try: Bambu Studio → right-click model → Fix Model")
    
    if output_path:
        mesh.export(output_path)
        print(f"💾 Saved repaired model: {output_path}")
    
    return mesh, True


def format_report(report):
    """Format report as human-readable text."""
    lines = []
    lines.append("=" * 50)
    lines.append("🔍 3D MODEL ANALYSIS REPORT")
    lines.append("=" * 50)
    lines.append("")

    g = report["geometry"]
    lines.append(f"📐 Dimensions: {g['dimensions_mm'][0]} × {g['dimensions_mm'][1]} × {g['dimensions_mm'][2]} mm")
    lines.append(f"📦 Volume: {g['volume_cm3']} cm³")
    lines.append(f"🔺 Triangles: {g['triangle_count']:,}")
    lines.append(f"💧 Watertight: {'✅' if g['is_watertight'] else '❌'}")
    lines.append(f"🖨️ Printer: {report['printer']}")
    lines.append(f"🧵 Material: {report['material']}")
    lines.append("")

    # Score
    score = report["score"]
    emoji = "🟢" if score >= 8 else "🟡" if score >= 6 else "🔴"
    lines.append(f"{emoji} Printability Score: {score}/10")
    lines.append("")

    if report["issues"]:
        lines.append("❌ ISSUES (must fix):")
        for i, issue in enumerate(report["issues"], 1):
            lines.append(f"  {i}. {issue}")
        lines.append("")

    if report["warnings"]:
        lines.append("⚠️ WARNINGS (review):")
        for i, warn in enumerate(report["warnings"], 1):
            lines.append(f"  {i}. {warn}")
        lines.append("")

    if report["suggestions"]:
        lines.append("💡 SUGGESTIONS:")
        for i, sug in enumerate(report["suggestions"], 1):
            lines.append(f"  {i}. {sug}")
        lines.append("")

    ps = report["print_settings"]
    lines.append("⚙️ RECOMMENDED SETTINGS:")
    lines.append(f"  Layer height: {ps['layer_height']}")
    lines.append(f"  Infill: {ps['infill']}")
    lines.append(f"  Walls: {ps['walls']}")
    lines.append(f"  Top layers: {ps['top_layers']}")
    lines.append(f"  Nozzle temp: {ps['nozzle_temp']}")
    lines.append(f"  Bed temp: {ps['bed_temp']}")
    lines.append(f"  Supports: {ps['supports']}")
    lines.append("")

    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Analyze 3D model for printability")
    # parser.add_argument("--file",default=r"C:\Users\ChanJing-01\Documents\out\part_yotown-wawa_1.stl", help="Path to 3D model (.3mf, .stl, .obj, .step)")
    parser.add_argument("--file",default=r"C:\Users\ChanJing-01\Documents\3d_glb\yotown_clear.stl", help="Path to 3D model (.3mf, .stl, .obj, .step)")
    parser.add_argument("--printer", default=None, help="Printer model (e.g., H2D, A1 Mini)")
    parser.add_argument("--material", default="PLA", help="Material (PLA, PETG, TPU, ABS, etc.)")
    parser.add_argument("--purpose", default="general", choices=["general", "decorative", "functional"],
                        help="Purpose affects infill/wall recommendations")
    parser.add_argument("--render", action="store_true", help="Render preview images")
    parser.add_argument("--json", action="store_true", help="Output raw JSON")
    parser.add_argument("--unit", choices=["mm", "cm", "in", "m", "auto"], default="auto", help="Model unit (default: auto-detect)")
    parser.add_argument("--height", type=float, default=0, help="Target height in mm (auto-scale model)")
    parser.add_argument("--orient", action="store_true",default=True, help="Auto-orient for optimal print position")
    parser.add_argument("--repair",default=True,action="store_true", help="Auto-repair non-manifold mesh before analysis")
    parser.add_argument("--no-auto-repair", action="store_true",
                        help="Skip auto-repair of minor mesh issues (holes/normals) that are applied by default")
    parser.add_argument("--no-simplify",default=True, action="store_true", help="Skip auto-simplification of high-poly meshes")
    parser.add_argument("--no-clean",default=True, action="store_true", help="Skip auto-removal of floating parts")
    parser.add_argument("--keep_main", action="store_true",default=True,
                        help="Keep only the largest component (remove all floating pieces, even if large)")
    parser.add_argument("--output_dir", default="./out", help="Directory for rendered images")
    args = parser.parse_args()

    # Load config for defaults
    config = load_config()
    printer = args.printer or config.get("model", "A1")
    material = args.material.upper()

    if material not in MATERIALS:
        print(f"⚠️ Unknown material '{material}'. Using PLA defaults.", file=sys.stderr)
        material = "PLA"

    if printer not in BUILD_VOLUMES:
        print(f"⚠️ Unknown printer '{printer}'. Using 230mm³ default volume.", file=sys.stderr)

    # Load mesh
    try:
        import trimesh
    except ImportError:
        print("ERROR: trimesh not installed. Run: pip3 install trimesh", file=sys.stderr)
        sys.exit(1)

    try:
        mesh = trimesh.load(args.file, force="mesh")
    except Exception as e:
        print(f"ERROR: Failed to load '{args.file}': {e}", file=sys.stderr)
        sys.exit(1)

    # Auto-detect units: glTF models are in meters, need conversion to mm
    bounds = mesh.bounds
    if bounds is None:
        print("❌ Cannot determine model dimensions. File may be corrupt.")
        sys.exit(1)
    dims = bounds[1] - bounds[0]
    max_dim = max(dims)
    converted_to_mm = False

    # Manual unit override
    unit = getattr(args, 'unit', 'auto')
    if unit != 'auto':
        scale_map = {"mm": 1, "cm": 10, "in": 25.4, "m": 1000}
        scale = scale_map[unit]
        if scale != 1:
            mesh.apply_scale(scale)
            converted_to_mm = True
            dims = mesh.bounds[1] - mesh.bounds[0]
            print(f"📐 Manual unit: {unit} → mm (×{scale}): {dims[0]:.1f} × {dims[1]:.1f} × {dims[2]:.1f} mm")
    elif max_dim < 0.5:  # Very likely meters (high confidence)
        print(f"📐 Detected meters (confidence: HIGH, max dim: {max_dim:.4f}m)")
        mesh.apply_scale(1000)
        converted_to_mm = True
        dims = mesh.bounds[1] - mesh.bounds[0]
        print(f"   Converted to mm: {dims[0]:.1f} × {dims[1]:.1f} × {dims[2]:.1f} mm")
    elif max_dim < 5:  # Could be meters or cm (medium confidence)
        print(f"⚠️ Ambiguous scale (max dim: {max_dim:.2f}). Assuming meters (confidence: MEDIUM)")
        print(f"   Override with --unit mm/cm/in if wrong")
        mesh.apply_scale(1000)
        converted_to_mm = True
        dims = mesh.bounds[1] - mesh.bounds[0]
        print(f"   Converted to mm: {dims[0]:.1f} × {dims[1]:.1f} × {dims[2]:.1f} mm")
    elif max_dim < 30:  # Likely cm or small mm
        print(f"⚠️ Small model (max dim: {max_dim:.1f}). Assuming mm. Use --unit cm if wrong.")

    # Auto-scale if target height specified
    height_scaled = False
    if args.height and args.height > 0:
        bounds = mesh.bounds
        current_h = (bounds[1][2] - bounds[0][2])
        if current_h < 0.01:
            print(f"⚠️ Model height near zero ({current_h:.6f}). Skipping scale.")
        else:
            scale = args.height / current_h
            mesh.apply_scale(scale)
            height_scaled = True
            print(f"📏 Scaled to {args.height}mm height (scale factor: {scale:.2f}x)")
            bounds = mesh.bounds
            dims = bounds[1] - bounds[0]
            print(f"   New dimensions: {dims[0]:.1f} × {dims[1]:.1f} × {dims[2]:.1f} mm")

    # Auto-orient if requested
    if args.orient:
        mesh = auto_orient(mesh)
        # Export oriented model
        orient_path = os.path.splitext(args.file)[0] + "_oriented.stl"  # Always STL after unit conversion
        mesh.export(orient_path)
        print(f"📁 Oriented model: {orient_path}")

    # ─── Auto-simplify if too many faces ───
    if not args.no_simplify:
        mesh, was_simplified = auto_simplify(mesh)
        if was_simplified:
            simp_path = os.path.splitext(args.file)[0] + "_simplified" + os.path.splitext(args.file)[1]
            mesh.export(simp_path)
            print(f"💾 Simplified model: {simp_path}")

    # ─── Floating parts handling ───
    # Only aggressive cleaning when user explicitly requests --keep-main.
    # Default: report only (trimesh.split is unreliable on AI-generated non-manifold meshes).
    if getattr(args, "keep_main", False):
        mesh, removed_parts = clean_floating_parts(
            mesh, min_volume_pct=1.0, keep_top_n=1,
        )
        if removed_parts > 0:
            clean_path = os.path.splitext(args.file)[0] + "_cleaned" + os.path.splitext(args.file)[1]
            mesh.export(clean_path)
            print(f"💾 Cleaned model: {clean_path}")

    # ─── Export scaled mesh if --height or unit conversion changed it ───
    if height_scaled or converted_to_mm:
        scaled_path = os.path.splitext(args.file)[0] + "_scaled" + os.path.splitext(args.file)[1]
        mesh.export(scaled_path)
        print(f"💾 Scaled model: {scaled_path}")

    # ─── Run analysis on ORIGINAL mesh first ───
    original_mesh = mesh.copy()

    # Tiered repair: don't over-process good models
    has_holes = not mesh.is_watertight
    has_nonmanifold = not mesh.is_volume
    bodies, split_timeout = _safe_split(mesh)
    has_disconnected = len(bodies) > 1 and not split_timeout

    if has_holes or has_nonmanifold or has_disconnected:
        severity = "minor" if (has_holes and not has_nonmanifold) else "major" if has_nonmanifold else "disconnected"
        print(f"\n🔍 Mesh issues detected (severity: {severity}):")
        if has_holes: print(f"   - Not watertight (has holes)")
        if has_nonmanifold: print(f"   - Non-manifold edges")
        if has_disconnected: print(f"   - {len(bodies)} disconnected parts")

        if severity == "minor":
            # Holes only --- hole-filling + normal-fixing is low-risk and always improves quality.
            # Auto-apply unless user opts out with --no-auto-repair.
            if not getattr(args, 'no_auto_repair', False):
                print(f"\n🔧 Auto-repairing minor issues (holes + normals --- low risk)...")
                repair_path = os.path.splitext(args.file)[0] + "_repaired" + os.path.splitext(args.file)[1]
                mesh, was_repaired = repair_mesh(mesh, repair_path)
                if not was_repaired:
                    print(f"   ℹ️ Pass --no-auto-repair to skip this step.")
            elif args.repair:
                print(f"\n🔧 Light repair (filling holes, fixing normals)...")
                repair_path = os.path.splitext(args.file)[0] + "_repaired" + os.path.splitext(args.file)[1]
                mesh, was_repaired = repair_mesh(mesh, repair_path)
            else:
                print(f"\n💡 Minor issues found. Will auto-repair on next run (or pass --repair).")
        elif severity == "major":
            # Non-manifold --- full repair, requires explicit --repair (more destructive)
            print(f"\n🔧 Full repair needed (non-manifold edges).")
            print(f"   💡 If auto-repair fails, try in Blender:")
            print(f"      Remesh modifier → Voxel (size: 0.15-0.25mm) → Smooth")
            print(f"      ⚠️ Use smallest voxel size that preserves detail")
            if args.repair:
                repair_path = os.path.splitext(args.file)[0] + "_repaired" + os.path.splitext(args.file)[1]
                mesh, was_repaired = repair_mesh(mesh, repair_path)
            else:
                print(f"\n💡 Major issues found. Run with --repair to attempt auto-fix.")
        else:
            print(f"\n⚠️ Disconnected parts detected.")
            print(f"   Auto-remove floating pieces: python3 scripts/analyze.py {args.file} --repair")
            print(f"   Or re-generate with a prompt that says 'single solid piece, no floating parts'.")
            if args.repair:
                repair_path = os.path.splitext(args.file)[0] + "_repaired" + os.path.splitext(args.file)[1]
                mesh, was_repaired = repair_mesh(mesh, repair_path)
    elif args.repair:
        print(f"\n✅ Mesh is clean --- no repair needed.")
    # If no issues and no --repair flag, skip entirely

    # Analyze
    report = analyze_mesh(mesh, printer, material, args.purpose)
    report["file"] = args.file

    # Render views
    if args.render:
        rendered = render_views(mesh, args.output_dir)
        report["rendered_views"] = rendered

    # Output
    if args.json:
        print(json.dumps(report, indent=2))
    else:
        print(format_report(report))


if __name__ == "__main__":

    main()
相关推荐
小小毛桃1 小时前
腾讯会议观看时黑屏,有声音,观看别人画面黑,自己摄像头/共享没问题
人工智能
CCYe、1 小时前
限流与配额:企业AI网关的工程实践
前端·人工智能
火山引擎开发者社区1 小时前
火山引擎开源 Agent 驱动的搜索自迭代技术
人工智能
董员外2 小时前
RAG 系统进化论(七):Multimodal RAG(多模态 RAG),当知识存在于表格、图片和页面中
人工智能·后端·设计模式
QYR-分析2 小时前
RISC-V AI加速器SoC行业研究报告:开放架构赋能AI芯片,高增赛道开启国产化新机遇
人工智能·架构·risc-v
沐籽李2 小时前
HuDiff在抗体人源化项目中的工程化落地
人工智能·算法·aidd·抗体设计
IvorySQL2 小时前
PostgreSQL 日报| GiST 索引扫描可见性缺陷(8 月 3 日)
数据库·人工智能·postgresql·开源
众人皆醒我独醉2 小时前
KServe:Kubernetes 原生的模型推理平台——把 vLLM/TGI/Triton 变成 Serverless
人工智能·ci/cd·面试
studyrunner2 小时前
【AI开源】reverse-skill 实战教程:为 Claude Code、Cursor、Cline、Codex 配置 AI 逆向与安全技能路由
人工智能·安全·开源