Skills › Scientific Agent Skills › fictiv › check_cad_file.py
check_cad_file.py
265 lines · skills/fictiv/scripts/check_cad_file.py at 5f2bc03
1#!/usr/bin/env python3
2"""Pre-flight check for CAD files before uploading them to Fictiv.
4Catches the problems that most often make a Fictiv upload fail, go to manual
5review, or come back mis-scaled — before you spend a browser round-trip on it.
7Usage:
8 python3 check_cad_file.py PART.step [MORE FILES...] [--process cnc|3dp|sheet|urethane|im|compression|diecast] [--json]
10Checks performed (standard library only, no CAD kernel needed):
11 * documented format support for the chosen process (mesh = 3D printing only;
12 uploader acceptance alone is not manufacturing support)
13 * STEP: schema, declared length unit, number of solid bodies (Fictiv needs
14 one in most cases), surface-only models, assemblies, approximate bounding box
15 * STL: ascii/binary, triangle count, watertightness (every edge shared by
16 exactly two triangles), bounding box, and a units sanity guess
17 * PDF: reminds you a drawing must accompany a CAD file
18 * rough size vs. Fictiv's published max build envelopes
20This is a heuristic pre-flight, not CAD-kernel validation or export classification.
21Native files and PDF contents are not inspected. An OK result does not certify
22manufacturability, topology, units, or absence of export-controlled data.
23Exit code: 0 = no blocking problems detected, 1 = at least one blocking problem.
24"""
25from __future__ import annotations
27import argparse
28import json
29import os
30import re
31import struct
32import sys
33from collections import Counter
35# Manufacturing formats documented in the Help Center, reviewed 2026-09-30:
36# https://www.fictiv.com/help/uploading-and-organizing-parts/what-file-formats-does-fictiv-support
37# The upload widget's accept list also contains unsupported/reference-only types.
38PARAMETRIC = {
39 ".3dm", ".3dxml", ".cgr", ".catpart", ".catshape", ".dlv", ".exp", ".ifc",
40 ".ipt", ".jt", ".mf1", ".model", ".neu", ".par", ".prc", ".prt", ".sab",
41 ".sat", ".session", ".sldprt", ".step", ".stp", ".u3d", ".vda", ".x3dv",
42 ".x_b", ".x_t", ".xas", ".xpr",
43}
44UPLOAD_ONLY = {".3mf", ".arc", ".gts", ".ifczip", ".pkg", ".unv", ".xmt", ".xmt_txt"}
45# Mesh formats: 3D printing only.
46MESH = {".stl", ".3ds", ".collada", ".dae", ".obj", ".off", ".ply", ".v3d", ".pts",
47 ".tri", ".acs", ".x3d", ".wrl"}
48ASSEMBLY = {".sldasm", ".asm", ".iam", ".catproduct"}
49UNSUPPORTED = {".igs": "IGES", ".iges": "IGES", ".f3d": "Fusion 360 archive",
50 ".slddrw": "SolidWorks drawing", ".dxf": "DXF", ".dwg": "DWG",
51 ".catdrawing": "CATIA drawing", ".psm": "Solid Edge sheet metal",
52 ".pwd": "Solid Edge weldment"}
54# Published max envelopes in mm (largest first); used only for a soft warning.
55MAX_ENVELOPE_MM = {
56 "cnc": (1828, 500, 152), # mill, per Help Center; larger parts via sales
57 "3dp": (914, 609, 914), # largest FDM; other technologies are smaller
58 "urethane": (2200, 1200, 1000),
59}
60PROCESSES = ["cnc", "3dp", "sheet", "urethane", "im", "compression", "diecast"]
63def human(n: float) -> str:
64 return f"{n / 1e6:.1f} MB" if n >= 1e6 else f"{n / 1e3:.0f} KB"
67# ---------------------------------------------------------------- STEP ----
68def inspect_step(path: str) -> dict:
69 info: dict = {}
70 with open(path, "r", errors="ignore") as fh:
71 data = fh.read()
72 head = data[:5000]
73 m = re.search(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", head)
74 info["schema"] = m.group(1) if m else None
75 ents = Counter(re.findall(r"#\d+\s*=\s*([A-Z_0-9]+)\s*\(", data))
76 solids = ents["MANIFOLD_SOLID_BREP"] + ents["BREP_WITH_VOIDS"]
77 info["solid_bodies"] = solids
78 info["surface_models"] = ents["SHELL_BASED_SURFACE_MODEL"] + ents["OPEN_SHELL"]
79 info["products"] = ents["PRODUCT"]
80 info["assembly_links"] = ents["NEXT_ASSEMBLY_USAGE_OCCURRENCE"]
82 # Units: look for CONVERSION_BASED_UNIT('INCH'...) or SI_UNIT(.MILLI.,.METRE.)
83 if re.search(r"CONVERSION_BASED_UNIT\s*\(\s*'(INCH|IN)'", data, re.I):
84 info["length_unit"] = "inch"
85 elif re.search(r"SI_UNIT\s*\(\s*\.MILLI\.\s*,\s*\.METRE\.", data):
86 info["length_unit"] = "mm"
87 elif re.search(r"SI_UNIT\s*\(\s*\.CENTI\.\s*,\s*\.METRE\.", data):
88 info["length_unit"] = "cm"
89 elif re.search(r"SI_UNIT\s*\(\s*\$\s*,\s*\.METRE\.", data):
90 info["length_unit"] = "m"
91 else:
92 info["length_unit"] = "unknown"
94 # Approximate bounding box from the B-rep's vertex points. Axis placements
95 # and surface origins are also CARTESIAN_POINTs and can sit outside the
96 # solid, so only fall back to "all points" when there are no vertices.
97 # Parts bounded purely by curved faces (e.g. a sphere) can read small.
98 allpts = dict(re.findall(r"#(\d+)\s*=\s*CARTESIAN_POINT\s*\(\s*'[^']*'\s*,\s*\(\s*([-\d.E+]+\s*,\s*[-\d.E+]+\s*,\s*[-\d.E+]+)\s*\)", data))
99 vids = re.findall(r"VERTEX_POINT\s*\(\s*'[^']*'\s*,\s*#(\d+)\s*\)", data)
100 chosen = [allpts[i] for i in vids if i in allpts] or list(allpts.values())
101 pts = [tuple(s.strip() for s in p.split(",")) for p in chosen]
102 if pts:
103 xs, ys, zs = zip(*((float(a), float(b), float(c)) for a, b, c in pts))
104 scale = {"inch": 25.4, "mm": 1.0, "cm": 10.0, "m": 1000.0}.get(info["length_unit"])
105 dims = sorted((max(v) - min(v) for v in (xs, ys, zs)), reverse=True)
106 if scale is None:
107 info["approx_bbox_units"] = [round(d, 2) for d in dims]
108 else:
109 info["approx_bbox_mm"] = [round(d * scale, 2) for d in dims]
110 if re.search(r"\bITAR\b|ITAR[- ]CONTROLLED|22 CFR 120", data, re.I):
111 info["itar_marking"] = True
112 return info
115# ----------------------------------------------------------------- STL ----
116def inspect_stl(path: str, max_tris: int = 3_000_000) -> dict:
117 info: dict = {}
118 size = os.path.getsize(path)
119 with open(path, "rb") as fh:
120 header = fh.read(84)
121 is_binary = False
122 if len(header) == 84:
123 n = struct.unpack("<I", header[80:84])[0]
124 is_binary = 84 + n * 50 == size
125 tris = []
126 if is_binary:
127 info["encoding"] = "binary"
128 info["triangles"] = n
129 if n <= max_tris:
130 for _ in range(n):
131 rec = fh.read(50)
132 v = struct.unpack("<12f", rec[:48])
133 tris.append((v[3:6], v[6:9], v[9:12]))
134 else:
135 info["encoding"] = "ascii"
136 fh.seek(0)
137 verts = re.findall(rb"vertex\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)", fh.read())
138 vv = [tuple(float(c) for c in v) for v in verts]
139 tris = [tuple(vv[i:i + 3]) for i in range(0, len(vv) - 2, 3)]
140 info["triangles"] = len(tris)
141 if tris:
142 xs = [p[0] for t in tris for p in t]
143 ys = [p[1] for t in tris for p in t]
144 zs = [p[2] for t in tris for p in t]
145 info["bbox_units"] = sorted([round(max(a) - min(a), 3) for a in (xs, ys, zs)], reverse=True)
146 edges: Counter = Counter()
147 for t in tris:
148 k = [tuple(round(c, 5) for c in p) for p in t]
149 for a, b in ((k[0], k[1]), (k[1], k[2]), (k[2], k[0])):
150 edges[(a, b) if a < b else (b, a)] += 1
151 bad = sum(1 for c in edges.values() if c != 2)
152 info["non_manifold_or_open_edges"] = bad
153 info["watertight"] = bad == 0
154 elif info.get("triangles", 0) > max_tris:
155 info["note"] = "too many triangles to check watertightness quickly"
156 return info
159# -------------------------------------------------------------- driver ----
160def check(path: str, process: str) -> dict:
161 ext = os.path.splitext(path)[1].lower()
162 r = {"file": path, "extension": ext, "process": process, "blocking": [], "warnings": [], "info": {}}
163 if not os.path.isfile(path):
164 r["blocking"].append("file not found")
165 return r
166 size = os.path.getsize(path)
167 r["info"]["size"] = human(size)
168 if size == 0:
169 r["blocking"].append("file is empty")
170 if size > 100e6:
171 r["warnings"].append("very large file (>100 MB); Fictiv documents no limit but uploads may time out — "
172 "try STEP or a lighter mesh")
174 if ext in UNSUPPORTED:
175 r["blocking"].append(f"{UNSUPPORTED[ext]} ({ext}) is not accepted by Fictiv — export STEP (.step) "
176 "or a native part file instead")
177 elif ext in ASSEMBLY:
178 r["blocking"].append("assembly files are only accepted as BOM reference and cannot be quoted — "
179 "export each part as its own single-body STEP")
180 elif ext == ".pdf":
181 r["warnings"].append("PDF drawings cannot be quoted alone; attach it to its CAD part "
182 "(Configure > Technical drawing > Upload drawing) or upload it together with the CAD file")
183 elif ext in MESH:
184 if process != "3dp":
185 r["blocking"].append(f"mesh files ({ext}) are accepted for 3D printing only; "
186 f"{process} needs STEP or native CAD")
187 elif ext in UPLOAD_ONLY:
188 r["blocking"].append(f"{ext} appeared in the uploader but manufacturing support is unverified; "
189 "export STEP or a documented format before submitting")
190 elif ext not in PARAMETRIC:
191 r["blocking"].append(f"unrecognized extension {ext}; not in the verified Fictiv format list")
193 if ext == ".prt":
194 r["warnings"].append(".prt is ambiguous (Siemens NX vs PTC Creo); if upload fails, export STEP")
195 if ext == ".sldprt":
196 r["warnings"].append("SolidWorks files with multiple configurations are rejected — save a single configuration")
198 if ext in (".step", ".stp"):
199 s = inspect_step(path)
200 r["info"].update(s)
201 if s["solid_bodies"] == 0 and s["surface_models"]:
202 r["blocking"].append("STEP contains only surface geometry (no closed solid) — Fictiv needs a solid body")
203 elif s["solid_bodies"] == 0:
204 r["blocking"].append("no solid body found in STEP")
205 elif s["solid_bodies"] > 1:
206 msg = (f"{s['solid_bodies']} solid bodies in one file — split separate parts into separate files. "
207 "CNC modeled-in pins/inserts and functional 3DP interlinked bodies have exceptions; "
208 "this checker cannot determine eligibility. Separate floating 3DP bodies are not accepted")
209 (r["warnings"] if process == "3dp" else r["blocking"]).append(msg)
210 if s["assembly_links"]:
211 r["warnings"].append("STEP is structured as an assembly; export the single part instead")
212 if s["length_unit"] == "unknown":
213 r["warnings"].append("could not determine STEP length unit; check the size Fictiv shows after upload")
214 if s.get("itar_marking"):
215 r["blocking"].append("file text contains ITAR markings — Fictiv does not accept ITAR data; do not upload")
216 elif ext == ".stl":
217 s = inspect_stl(path)
218 r["info"].update(s)
219 if s.get("watertight") is False:
220 r["warnings"].append(f"mesh is not watertight ({s['non_manifold_or_open_edges']} open/non-manifold edges) — "
221 "repair it (e.g. Netfabb, Meshmixer) or re-export from the solid modeler")
222 bb = s.get("bbox_units")
223 if bb:
224 if bb[0] < 5:
225 r["warnings"].append(f"largest dimension is {bb[0]} units — if the part is really in inches, choose "
226 "inches at upload or it will be 25.4x too small")
227 if bb[0] > 1500:
228 r["warnings"].append(f"largest dimension is {bb[0]} units — check units (metres/mm mix-up?)")
229 r["info"]["bbox_if_mm"] = bb
230 r["info"]["bbox_if_inch_in_mm"] = [round(v * 25.4, 2) for v in bb]
232 bbox = r["info"].get("approx_bbox_mm")
233 env = MAX_ENVELOPE_MM.get(process)
234 # bbox is sorted largest-first, so compare against the envelope sorted the
235 # same way: the part may be oriented to fit, whatever the machine axes are.
236 if bbox and env and any(b > e for b, e in zip(bbox, sorted(env, reverse=True))):
237 r["warnings"].append(f"approx size {bbox} mm exceeds Fictiv's published {process} envelope {list(env)} mm — "
238 "expect manual review/sales involvement, or split the part")
239 return r
242def main() -> int:
243 ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
244 ap.add_argument("files", nargs="+")
245 ap.add_argument("--process", choices=PROCESSES, default="cnc")
246 ap.add_argument("--json", action="store_true", help="print machine-readable JSON")
247 a = ap.parse_args()
248 results = [check(f, a.process) for f in a.files]
249 if a.json:
250 print(json.dumps(results, indent=2))
251 else:
252 for r in results:
253 status = "BLOCKED" if r["blocking"] else ("WARN" if r["warnings"] else "OK")
254 print(f"[{status}] {r['file']} ({a.process})")
255 for k, v in r["info"].items():
256 print(f" {k}: {v}")
257 for b in r["blocking"]:
258 print(f" [FAIL] {b}")
259 for w in r["warnings"]:
260 print(f" ! {w}")
261 return 1 if any(r["blocking"] for r in results) else 0
264if __name__ == "__main__":
265 sys.exit(main())