418 lines
12 KiB
Python
418 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
Internal STEP sheet metal analyser
|
|
|
|
- Input: ./test.step
|
|
- Unfold with K-factor = 0.5
|
|
- Auto-detect thickness if not provided
|
|
- Export:
|
|
- flat.dxf
|
|
- result.json
|
|
- debug_last.FCStd
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import traceback
|
|
from datetime import datetime
|
|
|
|
|
|
# -----------------------------
|
|
# Configuration
|
|
# -----------------------------
|
|
|
|
K_FACTOR = 0.5
|
|
K_STANDARD = "ansi"
|
|
|
|
DENSITY_KG_M3 = {
|
|
"alu": 2700.0,
|
|
"aluminum": 2700.0,
|
|
"stainless": 8000.0,
|
|
"edelstahl": 8000.0,
|
|
"copper": 8960.0,
|
|
"kupfer": 8960.0,
|
|
}
|
|
|
|
|
|
# -----------------------------
|
|
# Helpers
|
|
# -----------------------------
|
|
|
|
def mm2_to_m2(x):
|
|
return x / 1_000_000.0
|
|
|
|
|
|
def mm_to_m(x):
|
|
return x / 1000.0
|
|
|
|
|
|
def pick_main_object(doc):
|
|
candidates = []
|
|
for obj in doc.Objects:
|
|
if hasattr(obj, "Shape") and obj.Shape:
|
|
try:
|
|
if obj.Shape.Solids:
|
|
candidates.append((float(obj.Shape.Volume), obj))
|
|
except Exception:
|
|
pass
|
|
if not candidates:
|
|
raise RuntimeError("No solid object found in STEP.")
|
|
candidates.sort(key=lambda x: x[0], reverse=True)
|
|
return candidates[0][1]
|
|
|
|
|
|
def pick_root_face_index(shape):
|
|
planar = []
|
|
all_faces = []
|
|
|
|
for i, face in enumerate(shape.Faces, start=1):
|
|
try:
|
|
area = float(face.Area)
|
|
except Exception:
|
|
area = 0.0
|
|
|
|
all_faces.append((area, i, face))
|
|
try:
|
|
surf = face.Surface
|
|
if surf and "plane" in surf.__class__.__name__.lower():
|
|
planar.append((area, i, face))
|
|
except Exception:
|
|
pass
|
|
|
|
if planar:
|
|
planar.sort(key=lambda x: x[0], reverse=True)
|
|
return planar[0][1], True
|
|
|
|
all_faces.sort(key=lambda x: x[0], reverse=True)
|
|
return all_faces[0][1], False
|
|
|
|
|
|
def compute_bbox_mm(shape):
|
|
bb = shape.BoundBox
|
|
return float(bb.XLength), float(bb.YLength)
|
|
|
|
|
|
def estimate_thickness_mm_from_solid(part_obj, max_faces=60):
|
|
"""
|
|
Robust thickness estimation for sheet-metal solids:
|
|
- Collect planar faces
|
|
- Cluster by (normalized) face normal direction
|
|
- For each cluster, compute plane offsets d = n·p for face points
|
|
- Thickness candidates are small positive differences between distinct d values
|
|
- Fallback: use distToShape on representative face pairs
|
|
|
|
Returns thickness in mm or None.
|
|
"""
|
|
import math
|
|
import Part
|
|
|
|
shape = part_obj.Shape
|
|
|
|
# --- helpers ---
|
|
def vec_to_tuple(v):
|
|
return (float(v.x), float(v.y), float(v.z))
|
|
|
|
def norm(v):
|
|
l = math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z)
|
|
if l <= 1e-12:
|
|
return None
|
|
return v.multiply(1.0 / l)
|
|
|
|
def canonical_normal(n):
|
|
"""
|
|
Make the normal direction canonical so +n and -n map consistently:
|
|
Flip so the first significant component is positive.
|
|
"""
|
|
# convert to tuple for easier checks
|
|
x, y, z = float(n.x), float(n.y), float(n.z)
|
|
# find first component with magnitude > eps
|
|
eps = 1e-9
|
|
if abs(x) > eps:
|
|
if x < 0: return n.multiply(-1)
|
|
elif abs(y) > eps:
|
|
if y < 0: return n.multiply(-1)
|
|
elif abs(z) > eps:
|
|
if z < 0: return n.multiply(-1)
|
|
return n
|
|
|
|
def angle_close(n1, n2, cos_tol):
|
|
# cos(angle) = n1·n2
|
|
return (n1.dot(n2) >= cos_tol)
|
|
|
|
def face_midpoint(face):
|
|
u0, u1, v0, v1 = face.ParameterRange
|
|
return face.valueAt((u0+u1)*0.5, (v0+v1)*0.5), face.normalAt((u0+u1)*0.5, (v0+v1)*0.5)
|
|
|
|
# --- collect planar faces ---
|
|
planar = []
|
|
for face in shape.Faces:
|
|
try:
|
|
surf = face.Surface
|
|
if not (surf and "plane" in surf.__class__.__name__.lower()):
|
|
continue
|
|
area = float(face.Area)
|
|
if area < 1.0: # mm², ignore tiny faces
|
|
continue
|
|
p, n = face_midpoint(face)
|
|
n = norm(n)
|
|
if n is None:
|
|
continue
|
|
n = canonical_normal(n)
|
|
d = float(n.dot(p)) # plane offset for n·x = d
|
|
planar.append((area, face, n, d))
|
|
except Exception:
|
|
continue
|
|
|
|
if not planar:
|
|
return None
|
|
|
|
planar.sort(key=lambda x: x[0], reverse=True)
|
|
planar = planar[:max_faces]
|
|
|
|
# --- cluster by normal direction ---
|
|
# Tolerance: within ~2 degrees
|
|
cos_tol = math.cos(math.radians(2.0))
|
|
|
|
clusters = [] # each: {"n": normal, "faces": [(area, face, d), ...]}
|
|
for area, face, n, d in planar:
|
|
placed = False
|
|
for c in clusters:
|
|
if angle_close(n, c["n"], cos_tol):
|
|
c["faces"].append((area, face, d))
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
clusters.append({"n": n, "faces": [(area, face, d)]})
|
|
|
|
# --- build thickness candidates from d-values inside each cluster ---
|
|
# For a sheet, same-normal planes occur at (outer) and (inner) offsets,
|
|
# so distinct d-values differ ~thickness.
|
|
candidates = []
|
|
|
|
def add_candidate(val):
|
|
if 0.05 <= val <= 20.0: # mm range guard (tune if needed)
|
|
candidates.append(val)
|
|
|
|
for c in clusters:
|
|
ds = [d for _a, _f, d in c["faces"]]
|
|
if len(ds) < 2:
|
|
continue
|
|
ds.sort()
|
|
|
|
# unique d-values with binning (0.01 mm)
|
|
uniq = []
|
|
for d in ds:
|
|
b = round(d / 0.01) * 0.01
|
|
if not uniq or abs(b - uniq[-1]) > 1e-9:
|
|
uniq.append(b)
|
|
|
|
if len(uniq) < 2:
|
|
continue
|
|
|
|
# candidate: smallest positive gap between uniq planes
|
|
# Often thickness is the smallest meaningful separation.
|
|
for i in range(1, len(uniq)):
|
|
gap = abs(uniq[i] - uniq[i-1])
|
|
add_candidate(gap)
|
|
|
|
# --- if candidates exist, pick most frequent bin (mode-ish) ---
|
|
def pick_mode(vals, bin_size=0.01):
|
|
bins = {}
|
|
for x in vals:
|
|
b = round(x / bin_size) * bin_size
|
|
bins.setdefault(b, []).append(x)
|
|
best_bin = max(bins.items(), key=lambda kv: len(kv[1]))[0]
|
|
bucket = sorted(bins[best_bin])
|
|
return bucket[len(bucket)//2]
|
|
|
|
if candidates:
|
|
return pick_mode(candidates, 0.01)
|
|
|
|
# --- Fallback: distToShape between face pairs in same normal cluster ---
|
|
# Slower but can rescue cases where d-values are too noisy.
|
|
# We try only top clusters and top faces.
|
|
try:
|
|
for c in clusters[:6]:
|
|
faces = sorted(c["faces"], key=lambda t: t[0], reverse=True)[:8]
|
|
# compare each face to others in same cluster; minimal non-zero distance tends to thickness
|
|
for i in range(len(faces)):
|
|
fi = faces[i][1]
|
|
for j in range(i+1, len(faces)):
|
|
fj = faces[j][1]
|
|
dist = fi.distToShape(fj)[0] # returns (dist, pts, info)
|
|
if dist and dist > 0.05 and dist <= 20.0:
|
|
candidates.append(float(dist))
|
|
if candidates:
|
|
return pick_mode(candidates, 0.01)
|
|
except Exception:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
|
|
# -----------------------------
|
|
# Main
|
|
# -----------------------------
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--material", required=True, help="alu | stainless | copper")
|
|
parser.add_argument("--thickness-mm", required=False, type=float, default=None,
|
|
help="Optional sheet thickness in mm (auto-detect if omitted)")
|
|
args = parser.parse_args()
|
|
|
|
material_key = args.material.strip().lower()
|
|
if material_key not in DENSITY_KG_M3:
|
|
raise SystemExit(f"Unknown material '{args.material}'")
|
|
|
|
density = DENSITY_KG_M3[material_key]
|
|
|
|
cwd = os.getcwd()
|
|
step_path = os.path.join(cwd, "03341791-01_01.step")
|
|
out_dxf = os.path.join(cwd, "03341791-01_01.dxf")
|
|
out_json = os.path.join(cwd, "03341791-01_01-result.json")
|
|
out_fcstd = os.path.join(cwd, "debug_last.FCStd")
|
|
|
|
print("=== STEPANALYSER START ===", flush=True)
|
|
print("Material:", material_key, "Density:", density, flush=True)
|
|
|
|
if not os.path.exists(step_path):
|
|
raise SystemExit("STEP File not found not found in working directory")
|
|
|
|
import FreeCAD as App
|
|
import Import
|
|
import importDXF
|
|
|
|
try:
|
|
import SheetMetalNewUnfolder
|
|
from SheetMetalNewUnfolder import BendAllowanceCalculator
|
|
HAS_V2 = True
|
|
except Exception:
|
|
HAS_V2 = False
|
|
|
|
try:
|
|
import SheetMetalUnfolder
|
|
HAS_V1 = True
|
|
except Exception:
|
|
HAS_V1 = False
|
|
|
|
if not HAS_V1 and not HAS_V2:
|
|
raise SystemExit("No SheetMetal unfolder available")
|
|
|
|
doc = App.newDocument("StepAnalyser")
|
|
warnings = []
|
|
|
|
try:
|
|
Import.insert(step_path, doc.Name)
|
|
doc.recompute()
|
|
|
|
part_obj = pick_main_object(doc)
|
|
face_idx, planar = pick_root_face_index(part_obj.Shape)
|
|
base_face = f"Face{face_idx}"
|
|
|
|
thickness_mm = args.thickness_mm
|
|
if thickness_mm is None:
|
|
print("Auto-detecting thickness...", flush=True)
|
|
thickness_mm = estimate_thickness_mm_from_solid(part_obj)
|
|
if thickness_mm is None:
|
|
raise RuntimeError("Could not auto-detect thickness")
|
|
print(f"Detected thickness: {thickness_mm:.3f} mm", flush=True)
|
|
|
|
unfolded_shape = None
|
|
sketches = []
|
|
|
|
if HAS_V2:
|
|
try:
|
|
bac = BendAllowanceCalculator.from_single_value(K_FACTOR, K_STANDARD)
|
|
sel_face, unfolded_shape, bend_lines, root_normal = \
|
|
SheetMetalNewUnfolder.getUnfold(bac, part_obj, base_face)
|
|
sketches = SheetMetalNewUnfolder.getUnfoldSketches(
|
|
"Unfold", sel_face, unfolded_shape, bend_lines,
|
|
root_normal, [], False, "#000080", "#c00000", "#ff5733"
|
|
)
|
|
except Exception:
|
|
warnings.append("V2 unfold failed")
|
|
|
|
if unfolded_shape is None and HAS_V1:
|
|
ktable = {1: K_FACTOR}
|
|
unfolded_shape, foldComp, norm, *_ = \
|
|
SheetMetalUnfolder.getUnfold(ktable, part_obj, base_face, K_STANDARD)
|
|
sketches = SheetMetalUnfolder.getUnfoldSketches(
|
|
"Unfold", unfolded_shape, foldComp.Edges,
|
|
norm, [], False, "#000080",
|
|
bendSketchColor="#ff5733", internalSketchColor="#c00000"
|
|
)
|
|
|
|
if unfolded_shape is None or not sketches:
|
|
raise RuntimeError("Unfold failed")
|
|
|
|
importDXF.export(sketches, out_dxf)
|
|
|
|
bbox_w, bbox_h = compute_bbox_mm(unfolded_shape)
|
|
area_bbox_mm2 = bbox_w * bbox_h
|
|
area_net_mm2 = float(unfolded_shape.Area)
|
|
|
|
t_m = mm_to_m(thickness_mm)
|
|
area_bbox_m2 = mm2_to_m2(area_bbox_mm2)
|
|
area_net_m2 = mm2_to_m2(area_net_mm2)
|
|
|
|
mass_bbox_kg = area_bbox_m2 * t_m * density
|
|
mass_net_kg = area_net_m2 * t_m * density
|
|
|
|
result = {
|
|
"ok": True,
|
|
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
|
"input": {
|
|
"material": material_key,
|
|
"density_kg_m3": density,
|
|
"thickness_mm": thickness_mm,
|
|
"k_factor": K_FACTOR
|
|
},
|
|
"flat": {
|
|
"bbox_mm": {"width": bbox_w, "height": bbox_h},
|
|
"area_bbox_mm2": area_bbox_mm2,
|
|
"area_net_mm2": area_net_mm2
|
|
},
|
|
"weight": {
|
|
"bbox_kg": mass_bbox_kg,
|
|
"net_kg": mass_net_kg
|
|
},
|
|
"warnings": warnings
|
|
}
|
|
|
|
with open(out_json, "w") as f:
|
|
json.dump(result, f, indent=2)
|
|
|
|
doc.saveAs(out_fcstd)
|
|
|
|
print("OK: flat.dxf + result.json written", flush=True)
|
|
|
|
except Exception as e:
|
|
try:
|
|
doc.saveAs(out_fcstd)
|
|
except Exception:
|
|
pass
|
|
|
|
err = {
|
|
"ok": False,
|
|
"error": str(e),
|
|
"traceback": traceback.format_exc()
|
|
}
|
|
with open(out_json, "w") as f:
|
|
json.dump(err, f, indent=2)
|
|
|
|
print("ERROR:", e, flush=True)
|
|
print(traceback.format_exc(), flush=True)
|
|
os._exit(1)
|
|
|
|
os._exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|