cdcad 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cdcad/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """CDCAD Python API for parametric, command-line driven CAD."""
2
+ from ._common import CADError, VERSION as __version__
3
+
4
+ __all__ = ["CADError", "Engine", "build_recipe", "read_recipe", "resolve_recipe"]
5
+
6
+
7
+ def __getattr__(name):
8
+ # Keep package import and CLI help independent of geometry dependencies.
9
+ if name == "Engine":
10
+ from .engine import Engine
11
+ return Engine
12
+ if name == "build_recipe":
13
+ from .build import build
14
+ return build
15
+ if name in {"read_recipe", "resolve_recipe"}:
16
+ from . import recipe
17
+ return getattr(recipe, name)
18
+ raise AttributeError(name)
cdcad/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run CDCAD through python -m cdcad."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
cdcad/_common.py ADDED
@@ -0,0 +1,80 @@
1
+ """Shared serialization, file operations, and runtime configuration."""
2
+ import functools
3
+ import importlib.metadata
4
+ import json
5
+ import os
6
+ from pathlib import Path, PureWindowsPath
7
+ import secrets
8
+ import sys
9
+
10
+ VERSION = "0.4.0"
11
+ UNITS = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "in": 25.4}
12
+ EXPORT_FORMATS = ("stl", "ply", "obj", "glb", "off")
13
+ GEOMETRY_DEPENDENCIES = (
14
+ "numpy", "trimesh", "shapely", "manifold3d", "mapbox-earcut",
15
+ "svgelements", "ezdxf", "Pillow", "fast-simplification", "scipy", "networkx",
16
+ )
17
+
18
+
19
+ class CADError(Exception):
20
+ """Invalid input or an operation that could not produce a valid result."""
21
+
22
+
23
+ @functools.lru_cache(maxsize=1)
24
+ def dependency_versions():
25
+ return {name: importlib.metadata.version(name) for name in GEOMETRY_DEPENDENCIES}
26
+
27
+
28
+ def parse_sets(settings):
29
+ values = {}
30
+ for setting in settings or ():
31
+ key, sep, raw = setting.partition("=")
32
+ if not sep or not key:
33
+ raise CADError("--set expects NAME=NUMBER or NAME==EXPRESSION")
34
+ try:
35
+ values[key] = json.loads(raw)
36
+ except json.JSONDecodeError:
37
+ values[key] = raw
38
+ return values
39
+
40
+
41
+ def document_path(value):
42
+ """Interpret portable paths and relative paths saved by Windows clients."""
43
+ windows = PureWindowsPath(value)
44
+ if "\\" in value and not windows.anchor:
45
+ return Path(*windows.parts)
46
+ return Path(value)
47
+
48
+
49
+ def read_stdin_json():
50
+ stream = getattr(sys.stdin, "buffer", None)
51
+ data = stream.read().decode("utf-8-sig") if stream is not None else sys.stdin.read().lstrip("\ufeff")
52
+ return json.loads(data)
53
+
54
+
55
+ def read_json(path):
56
+ return json.loads(Path(path).read_text(encoding="utf-8-sig"))
57
+
58
+
59
+ def atomic_bytes(path, data):
60
+ """Replace path with data in one step; readers see the old or the new file, never a part."""
61
+ path = Path(path)
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ # Create the temporary file with the mode a normal write would get (0o666 minus
64
+ # the umask). tempfile.NamedTemporaryFile makes it 0o600, and that mode would
65
+ # survive os.replace, leaving every model and project file owner-only on POSIX.
66
+ tmp = path.parent/f".cdcad-{os.getpid():x}-{secrets.token_hex(6)}"
67
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
68
+ fd = os.open(tmp, flags, 0o666)
69
+ try:
70
+ with os.fdopen(fd, "wb") as f:
71
+ f.write(data)
72
+ f.flush()
73
+ os.fsync(f.fileno()) # the recovery journal is only useful if it reached the disk
74
+ os.replace(tmp, path)
75
+ finally:
76
+ tmp.unlink(missing_ok=True)
77
+
78
+
79
+ def json_bytes(value):
80
+ return (json.dumps(value, indent=2, allow_nan=False)+"\n").encode()
cdcad/_profiles.py ADDED
@@ -0,0 +1,219 @@
1
+ """Planar contours, drawing imports, and structural profiles."""
2
+ import math
3
+
4
+ import numpy as np
5
+ from shapely import affinity, make_valid
6
+ from shapely.geometry import Polygon, MultiPolygon, GeometryCollection, LineString, Point, box
7
+ from shapely.ops import unary_union
8
+
9
+ from ._common import CADError
10
+
11
+
12
+ def polygonal(geom):
13
+ if not geom.is_valid:
14
+ raise CADError("Invalid 2D geometry; fix self intersections or hole boundaries")
15
+ if isinstance(geom, Polygon):
16
+ polys = [geom]
17
+ elif isinstance(geom, (MultiPolygon, GeometryCollection)):
18
+ polys = [p for p in geom.geoms if isinstance(p, Polygon) and p.area > 0]
19
+ else:
20
+ polys = []
21
+ if not polys or sum(p.area for p in polys) <= 1e-12:
22
+ raise CADError("Profile is empty or has zero area")
23
+ return unary_union(polys)
24
+
25
+
26
+ def polygons(geom):
27
+ return [geom] if isinstance(geom, Polygon) else list(geom.geoms)
28
+
29
+
30
+ def loops_fill(loops, rule="evenodd"):
31
+ """Polygonize nested contours with even-odd or true winding-number fill."""
32
+ from shapely.ops import polygonize
33
+ rings = []
34
+ for pts in loops:
35
+ xy = np.asarray(pts, dtype=float)
36
+ tolerance = max(float(np.ptp(xy, axis=0).max())*1e-12, 1e-12)
37
+ # SVG curves often finish a few ulps away from their start. Do not let
38
+ # the tiny closing edge turn into collapsed triangles after triangulation.
39
+ keep = np.r_[True, np.linalg.norm(np.diff(xy, axis=0), axis=1) > tolerance]
40
+ xy = xy[keep]
41
+ while len(xy) > 1 and np.linalg.norm(xy[-1]-xy[0]) <= tolerance:
42
+ xy = xy[:-1]
43
+ if len(xy) < 3:
44
+ raise CADError("Closed contour has fewer than three distinct points")
45
+ p = Polygon(xy).simplify(tolerance, preserve_topology=True)
46
+ if not p.is_valid or p.area <= 1e-12:
47
+ raise CADError("Input contains a degenerate or self-intersecting closed contour")
48
+ xy = np.asarray(p.exterior.coords)
49
+ signed = np.sum(xy[:-1, 0]*xy[1:, 1] - xy[1:, 0]*xy[:-1, 1])
50
+ rings.append((p, 1 if signed > 0 else -1))
51
+ if not rings:
52
+ raise CADError("No closed contours found")
53
+ regions = polygonize(unary_union([p.boundary for p, _ in rings]))
54
+ selected = []
55
+ for region in regions:
56
+ test = region.representative_point()
57
+ winding = sum(sign if rule == "nonzero" else 1 for p, sign in rings if p.covers(test))
58
+ if (winding != 0 if rule == "nonzero" else winding % 2):
59
+ selected.append(region)
60
+ return polygonal(unary_union(selected))
61
+
62
+
63
+ def svg_path_geometry(path, step=0.5, width=None, rule="evenodd"):
64
+ from svgelements import Close, Move
65
+ loops, strokes = [], []
66
+ for sub in path.as_subpaths():
67
+ points = []
68
+ closed = False
69
+ for seg in sub:
70
+ if isinstance(seg, Move):
71
+ points.append([seg.end.x, seg.end.y])
72
+ continue
73
+ length = seg.length(error=1e-5)
74
+ count = max(1, math.ceil(length / step))
75
+ if count > 200000:
76
+ raise CADError("SVG segment exceeds sampling limit; increase step")
77
+ if not points:
78
+ points.append([seg.start.x, seg.start.y])
79
+ for i in range(1, count + 1):
80
+ p = seg.point(i/count)
81
+ points.append([p.x, p.y])
82
+ closed = closed or isinstance(seg, Close)
83
+ if len(points) < 2:
84
+ continue
85
+ if width is not None:
86
+ strokes.append(LineString(points).buffer(width / 2, quad_segs=16))
87
+ elif closed or np.allclose(points[0], points[-1], atol=1e-9, rtol=0):
88
+ loops.append(points)
89
+ else:
90
+ raise CADError("Open SVG path; close it with Z or use a path profile with width")
91
+ return polygonal(unary_union(strokes)) if width is not None else loops_fill(loops, rule)
92
+
93
+
94
+ def read_svg(file, node):
95
+ from svgelements import SVG, Shape, Path as SVGPath
96
+ svg = SVG.parse(str(file), reify=True, ppi=96)
97
+ selected = set(node.get("ids", []))
98
+ found, geometries = set(), []
99
+ for element in svg.elements():
100
+ if not isinstance(element, Shape):
101
+ continue
102
+ eid = element.values.get("id")
103
+ if selected and eid not in selected:
104
+ continue
105
+ # Hidden and unfilled shapes aren't treated as filled CAD outlines.
106
+ if element.values.get("visibility") == "hidden" or element.values.get("display") == "none":
107
+ continue
108
+ if getattr(element.fill, "value", None) is None:
109
+ continue
110
+ if eid:
111
+ found.add(eid)
112
+ rule = node.get("fill_rule", element.values.get("fill-rule", "nonzero"))
113
+ if rule not in ("evenodd", "nonzero"):
114
+ raise CADError(f"Unsupported SVG fill rule: {rule}")
115
+ geometries.append(svg_path_geometry(SVGPath(element), node.get("step", .5), rule=rule))
116
+ if selected - found:
117
+ raise CADError(f"SVG filled shape IDs not found: {sorted(selected-found)}")
118
+ geom = polygonal(unary_union(geometries))
119
+ if node.get("flip_y", True):
120
+ geom = affinity.scale(geom, yfact=-1, origin=(0, 0))
121
+ return geom
122
+
123
+
124
+ def read_dxf(file, node):
125
+ import ezdxf
126
+ from ezdxf.path import make_path
127
+ from shapely.ops import polygonize_full
128
+ doc = ezdxf.readfile(str(file))
129
+ layer_filter = set(node.get("layers", []))
130
+ lines = []
131
+ allowed = {"LWPOLYLINE", "POLYLINE", "LINE", "ARC", "CIRCLE", "ELLIPSE", "SPLINE"}
132
+ for entity in doc.modelspace():
133
+ if layer_filter and entity.dxf.layer not in layer_filter:
134
+ continue
135
+ if entity.dxftype() not in allowed:
136
+ raise CADError(f"Unsupported DXF entity {entity.dxftype()}; select contour layers or explode it first")
137
+ pts = np.asarray([tuple(p) for p in make_path(entity).flattening(node.get("tolerance", .05))])
138
+ if len(pts) < 2:
139
+ continue
140
+ if np.max(np.abs(pts[:, 2])) > 1e-7:
141
+ raise CADError("DXF must lie in the XY plane at Z=0")
142
+ lines.append(LineString(pts[:, :2]))
143
+ if not lines:
144
+ raise CADError("No supported DXF contours in selected layers")
145
+ # polygonize accepts line chains; reject dangling/open geometry instead of losing it.
146
+ result, cuts, dangles, invalid = polygonize_full(unary_union(lines))
147
+ if any(not g.is_empty for g in (cuts, dangles, invalid)):
148
+ raise CADError("DXF has open, dangling or invalid contour segments")
149
+ rings = {}
150
+ for p in result.geoms:
151
+ for ring in [p.exterior, *p.interiors]:
152
+ poly = Polygon(ring)
153
+ rings[poly.normalize().wkb_hex] = list(ring.coords)
154
+ return loops_fill(list(rings.values()))
155
+
156
+
157
+ def arch_opening(node):
158
+ span = node["span"]
159
+ rise, leg = node.get("rise", span/2), node.get("leg", 0)
160
+ angles = np.unique(np.r_[np.linspace(0, np.pi, int(node.get("segments", 64))+1), np.pi/2])[::-1]
161
+ curve = np.column_stack([span/2*np.cos(angles), leg+rise*np.sin(angles)])
162
+ curve[0], curve[-1] = [-span/2, leg], [span/2, leg]
163
+ return polygonal(Polygon([[-span/2, 0], *curve.tolist(), [span/2, 0]]))
164
+
165
+
166
+ def arch_profile(node):
167
+ opening = arch_opening(node)
168
+ t = node["thickness"]
169
+ # Explicit outer ellipse gives predictable overall dimensions. Thickness
170
+ # adds to the horizontal/vertical radii; elliptical normal thickness varies.
171
+ outer = arch_opening({**node, "span": node["span"]+2*t,
172
+ "rise": node.get("rise", node["span"]/2)+t})
173
+ return polygonal(outer.difference(opening))
174
+
175
+
176
+ def frame_profile(size, border):
177
+ w, h = size
178
+ if border*2 >= min(w, h):
179
+ raise CADError("Frame border must be less than half its smaller dimension")
180
+ return box(-w/2, -h/2, w/2, h/2).difference(box(-w/2+border, -h/2+border, w/2-border, h/2-border))
181
+
182
+
183
+ def slot_profile(size, center=(0, 0)):
184
+ w, h = size
185
+ r = min(w, h)/2
186
+ a, b = ([-w/2+r, 0], [w/2-r, 0]) if w >= h else ([0, -h/2+r], [0, h/2-r])
187
+ geom = Point(0, 0).buffer(r, quad_segs=24) if w == h else LineString([a, b]).buffer(r, quad_segs=24)
188
+ return affinity.translate(geom, *center)
189
+
190
+
191
+ def panel_cutouts(geom, cutouts):
192
+ holes = []
193
+ for cut in cutouts:
194
+ shape = cut["shape"]
195
+ if shape == "rectangle":
196
+ if "size" not in cut:
197
+ raise CADError("Rectangle cutout requires size")
198
+ w, h = cut["size"]
199
+ r = cut.get("radius", 0)
200
+ if r > min(w, h)/2:
201
+ raise CADError("Cutout radius is too large")
202
+ hole = slot_profile([w, h]) if r == min(w, h)/2 else box(-w/2+r, -h/2+r, w/2-r, h/2-r).buffer(r) if r else box(-w/2, -h/2, w/2, h/2)
203
+ elif shape == "circle":
204
+ if "radius" not in cut:
205
+ raise CADError("Circle cutout requires radius")
206
+ hole = Point(0, 0).buffer(cut["radius"], quad_segs=24)
207
+ elif shape == "slot":
208
+ if "size" not in cut:
209
+ raise CADError("Slot cutout requires size")
210
+ hole = slot_profile(cut["size"])
211
+ else:
212
+ if "span" not in cut:
213
+ raise CADError("Arch cutout requires span")
214
+ hole = arch_opening(cut)
215
+ h = cut.get("leg", 0)+cut.get("rise", cut["span"]/2)
216
+ hole = affinity.translate(hole, yoff=-h/2)
217
+ hole = affinity.rotate(hole, cut.get("rotate", 0), origin=(0, 0))
218
+ holes.append(affinity.translate(hole, *cut.get("center", [0, 0])))
219
+ return polygonal(geom.difference(unary_union(holes))) if holes else polygonal(geom)
cdcad/build.py ADDED
@@ -0,0 +1,51 @@
1
+ """Plan and build recipe exports."""
2
+ import hashlib
3
+ from pathlib import Path
4
+
5
+ from ._common import CADError, EXPORT_FORMATS, UNITS, VERSION, atomic_bytes, document_path
6
+ from .engine import Engine
7
+ from .mesh import export_bytes, mesh_report
8
+
9
+
10
+ def recipe_source_paths(recipe, base):
11
+ return {(Path(base)/document_path(node["path"])).resolve()
12
+ for kind in ("profiles", "solids")
13
+ for node in recipe.get(kind, {}).values() if "path" in node}
14
+
15
+
16
+ def build(recipe, base, out_dir=None, targets=None, force=False, dry_run=False, cache_dir=None, *, protected_paths=()):
17
+ engine = Engine(recipe, base, cache_dir=cache_dir)
18
+ exports = [x for x in recipe["exports"] if not targets or x["solid"] in targets]
19
+ if targets and set(targets)-{x["solid"] for x in exports}:
20
+ raise CADError("--target must name an exported solid")
21
+ sources = recipe_source_paths(recipe, base) | {Path(p).resolve() for p in protected_paths}
22
+ paths = []
23
+ for exp in exports:
24
+ path = (Path(out_dir or base)/document_path(exp["path"])).resolve()
25
+ if out_dir and not path.is_relative_to(Path(out_dir).resolve()):
26
+ raise CADError("Export path escapes --out-dir")
27
+ if path in sources:
28
+ raise CADError(f"Export would overwrite a source file: {path}")
29
+ if path in paths:
30
+ raise CADError("Multiple exports resolve to the same path")
31
+ if path.exists() and not force and not dry_run:
32
+ raise CADError(f"Output exists: {path}; use --force to replace")
33
+ if path.suffix.lower()[1:] not in EXPORT_FORMATS:
34
+ raise CADError(f"Unsupported export extension: {path.suffix}")
35
+ paths.append(path)
36
+ if dry_run:
37
+ return {"ok": True, "dry_run": True, "units": engine.unit,
38
+ "exports": [{"solid": e["solid"], "path": str(p)} for e, p in zip(exports, paths)],
39
+ "note": "Schema and references checked; geometry and source files have not been evaluated."}
40
+ pending, reports = [], []
41
+ for exp, path in zip(exports, paths):
42
+ mesh = engine.get("solids", exp["solid"]).copy()
43
+ mesh.apply_scale(UNITS[engine.unit])
44
+ data = export_bytes(mesh, path, exp.get("allow_open", False))
45
+ pending.append((path, data))
46
+ reports.append({"solid": exp["solid"], "path": str(path), "sha256": hashlib.sha256(data).hexdigest(),
47
+ **mesh_report(mesh)})
48
+ # Evaluate and validate every selected export before writing any model.
49
+ for path, data in pending:
50
+ atomic_bytes(path, data)
51
+ return {"ok": True, "version": VERSION, "units": "mm", "exports": reports, "trace": engine.trace}
cdcad/cli.py ADDED
@@ -0,0 +1,75 @@
1
+ """CDCAD command discovery, dispatch, and output."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ from ._common import CADError, EXPORT_FORMATS, UNITS, VERSION, dependency_versions
8
+ from .commands import mesh, model, project, recipe
9
+ from .schema import PROFILE_OPS, SOLID_OPS
10
+
11
+
12
+ class ArgumentParser(argparse.ArgumentParser):
13
+ """Use full option names so adding a flag cannot break existing scripts."""
14
+
15
+ def __init__(self, *args, **kwargs):
16
+ super().__init__(*args, allow_abbrev=False, **kwargs)
17
+
18
+
19
+ def parser():
20
+ application = ArgumentParser(
21
+ prog="cdcad",
22
+ description="CDCAD | Command-line driven CAD for parametric models, assemblies, and mesh editing.",
23
+ epilog="Run cdcad COMMAND --help for options and examples.",
24
+ )
25
+ application.add_argument("--version", action="version", version=f"CDCAD {VERSION}")
26
+ application.add_argument("--debug", action="store_true", help="Show Python traceback on errors")
27
+ application.add_argument("--pretty", action="store_true", help="Indent JSON output for reading")
28
+ commands = application.add_subparsers(dest="command", required=True, metavar="COMMAND")
29
+ model.register(commands)
30
+ recipe.register(commands)
31
+ mesh.register(commands)
32
+ project_commands = project.add_parser(commands)
33
+ discovery = commands.add_parser("capabilities", help="List commands, modeling operations, and supported formats")
34
+ discovery.set_defaults(handler=capabilities, command_choices=commands.choices,
35
+ project_choices=project_commands.choices)
36
+ commands.add_parser("serve", help="Run a persistent JSON-lines process on stdin/stdout")
37
+ return application
38
+
39
+
40
+ def capabilities(args):
41
+ return {
42
+ "ok": True, "version": VERSION, "package": str(Path(__file__).resolve().parent),
43
+ "recipe_version": 1, "project_version": 1,
44
+ "profiles": list(PROFILE_OPS), "solids": list(SOLID_OPS),
45
+ "commands": list(args.command_choices), "project_commands": list(args.project_choices),
46
+ "units": dict(UNITS), "export_formats": list(EXPORT_FORMATS),
47
+ "dependencies": dependency_versions(),
48
+ }
49
+
50
+
51
+ def execute(args):
52
+ if args.command == "serve":
53
+ raise CADError("serve is a top-level process mode and cannot be nested")
54
+ return args.handler(args)
55
+
56
+
57
+ def main(argv=None):
58
+ application = parser()
59
+ args = application.parse_args(argv)
60
+ try:
61
+ if args.command == "serve":
62
+ from .worker import serve
63
+ return serve(application, execute)
64
+ indent = 2 if args.pretty else None
65
+ separators = None if args.pretty else (",", ":")
66
+ print(json.dumps(execute(args), indent=indent, separators=separators, allow_nan=False))
67
+ return 0
68
+ except KeyboardInterrupt:
69
+ print(json.dumps({"ok": False, "error": "Interrupted", "type": "KeyboardInterrupt"}), file=sys.stderr)
70
+ return 130
71
+ except Exception as exc:
72
+ if args.debug:
73
+ raise
74
+ print(json.dumps({"ok": False, "error": str(exc), "type": type(exc).__name__}), file=sys.stderr)
75
+ return 2
@@ -0,0 +1 @@
1
+ """Command registration and CLI handlers."""
@@ -0,0 +1,13 @@
1
+ """Shared options for commands exporting one model."""
2
+
3
+
4
+ def add_output(parser):
5
+ parser.add_argument("-o", "--output", required=True)
6
+ parser.add_argument("--force", action="store_true", help="Replace an existing output")
7
+ parser.add_argument("--allow-open", action="store_true", help="Permit export without positive-volume validation")
8
+
9
+
10
+ def add_units(parser):
11
+ from .._common import UNITS
12
+
13
+ parser.add_argument("--units", choices=UNITS, default="mm", help="Input dimensions (default: mm)")
cdcad/commands/mesh.py ADDED
@@ -0,0 +1,164 @@
1
+ """Mesh inspection, editing, boolean, component, and section commands."""
2
+ from pathlib import Path
3
+
4
+ from ._output import add_output
5
+ from .._common import CADError, atomic_bytes
6
+ from ..schema import TRANSFORM
7
+
8
+
9
+ def register(s):
10
+ i = s.add_parser("inspect", help="Print mesh topology, bounds and volume; assumes coordinates are mm")
11
+ i.set_defaults(handler=inspect)
12
+ i.add_argument("input")
13
+ i.add_argument("--require-solid", action="store_true")
14
+ i.add_argument("--bed", nargs=3, type=float, metavar=("X", "Y", "Z"),
15
+ help="Build volume in mm; reports whether the part fits as modeled and in any axis-aligned orientation")
16
+ i.add_argument("--require-fit", action="store_true", help="With --bed, fail unless the part fits in some axis-aligned orientation")
17
+ edit = s.add_parser("edit", help="Transform/repair/cut/simplify an existing mesh in that order")
18
+ edit.set_defaults(handler=edit_mesh)
19
+ edit.add_argument("input")
20
+ edit.add_argument("--scale", nargs=3, type=float, metavar=("X", "Y", "Z"))
21
+ edit.add_argument("--rotate", nargs=3, type=float, metavar=("X", "Y", "Z"), help="Euler degrees")
22
+ edit.add_argument("--translate", nargs=3, type=float, metavar=("X", "Y", "Z"))
23
+ edit.add_argument("--repair", action="store_true")
24
+ edit.add_argument("--cut-origin", nargs=3, type=float)
25
+ edit.add_argument("--cut-normal", nargs=3, type=float)
26
+ edit.add_argument("--no-cap", action="store_true")
27
+ edit.add_argument("--faces", type=int)
28
+ edit.add_argument("--hull", action="store_true")
29
+ add_output(edit)
30
+ bo = s.add_parser("boolean", help="Solid mesh union/difference/intersection; first input is base for difference")
31
+ bo.set_defaults(handler=boolean_meshes)
32
+ bo.add_argument("operation", choices=["union", "difference", "intersection"])
33
+ bo.add_argument("inputs", nargs="+")
34
+ add_output(bo)
35
+ sp = s.add_parser("split", help="Export disconnected components as separate STLs")
36
+ sp.set_defaults(handler=split_mesh)
37
+ sp.add_argument("input")
38
+ sp.add_argument("--out-dir", required=True)
39
+ sp.add_argument("--force", action="store_true")
40
+ sp.add_argument("--allow-open", action="store_true")
41
+ sec = s.add_parser("section", help="Export a horizontal cross section as SVG (coordinates assumed mm)")
42
+ sec.set_defaults(handler=section_mesh)
43
+ sec.add_argument("input")
44
+ sec.add_argument("--z", required=True, type=float)
45
+ sec.add_argument("--output", "-o", required=True)
46
+ sec.add_argument("--force", action="store_true")
47
+
48
+
49
+ def single_export(mesh, args):
50
+ from ..mesh import export_bytes, mesh_report
51
+
52
+ path = Path(args.output).resolve()
53
+ if path.exists() and not args.force:
54
+ raise CADError(f"Output exists: {path}; use --force")
55
+ data = export_bytes(mesh, path, args.allow_open)
56
+ atomic_bytes(path, data)
57
+ return {"ok": True, "path": str(path), "units": "mm", **mesh_report(mesh)}
58
+
59
+
60
+ def inspect(args):
61
+ from ..mesh import load_mesh, mesh_report, valid_mesh
62
+
63
+ if args.require_fit and not args.bed:
64
+ raise CADError("--require-fit needs --bed")
65
+ mesh = load_mesh(Path(args.input))
66
+ if args.require_solid:
67
+ valid_mesh(mesh)
68
+ report = {"ok": True, "units": "mm", **mesh_report(mesh)}
69
+ if args.bed:
70
+ report["bed_fit"] = bed_fit(report["extents"], args.bed)
71
+ if args.require_fit and not report["bed_fit"]["fits_any_orientation"]:
72
+ raise CADError(f"Part {[round(e, 3) for e in report['extents']]} mm does not fit a {args.bed} mm build volume "
73
+ "in any axis-aligned orientation")
74
+ return report
75
+
76
+
77
+ FIT_TOLERANCE = 1e-4 # mm; STL stores float32, so a 140 mm part can read 140.00001
78
+
79
+
80
+ def bed_fit(extents, bed):
81
+ """Compare part extents with a build volume, as modeled and with the axes reassigned freely."""
82
+ if any(size <= 0 for size in bed):
83
+ raise CADError("--bed sizes must be positive")
84
+ as_modeled = all(e <= b + FIT_TOLERANCE for e, b in zip(extents, bed))
85
+ # Axis-aligned orientations are permutations of the axes; the part fits in one
86
+ # of them exactly when its sorted extents fit the sorted build volume.
87
+ any_orientation = all(e <= b + FIT_TOLERANCE for e, b in zip(sorted(extents), sorted(bed)))
88
+ return {"bed": list(bed), "fits": as_modeled, "fits_any_orientation": any_orientation,
89
+ "clearance": [round(b - e, 6) for e, b in zip(extents, bed)]}
90
+
91
+
92
+ def boolean_meshes(args):
93
+ from ..mesh import boolean, load_mesh
94
+
95
+ if len(args.inputs) < 2:
96
+ raise CADError("Boolean command requires at least two input files")
97
+ return single_export(boolean(args.operation, [load_mesh(Path(x)) for x in args.inputs]), args)
98
+
99
+
100
+ def edit_mesh(args):
101
+ from ..mesh import clean_mesh, cut_mesh, load_mesh, transform_mesh
102
+
103
+ mesh = load_mesh(Path(args.input))
104
+ mesh = transform_mesh(mesh, {k: getattr(args, k) for k in TRANSFORM if getattr(args, k, None) is not None})
105
+ if args.repair:
106
+ mesh = clean_mesh(mesh)
107
+ if bool(args.cut_origin) != bool(args.cut_normal):
108
+ raise CADError("Supply both --cut-origin and --cut-normal")
109
+ if args.cut_origin:
110
+ mesh = cut_mesh(mesh, args.cut_origin, args.cut_normal, cap=not args.no_cap)
111
+ if args.faces is not None:
112
+ if args.faces < 4:
113
+ raise CADError("--faces must be >= 4")
114
+ if args.faces < len(mesh.faces):
115
+ mesh = mesh.simplify_quadric_decimation(face_count=args.faces)
116
+ if args.hull:
117
+ mesh = mesh.convex_hull
118
+ return single_export(mesh, args)
119
+
120
+
121
+ def split_mesh(args):
122
+ from ..mesh import export_bytes, load_mesh
123
+
124
+ parts = load_mesh(Path(args.input)).split(only_watertight=False)
125
+ parts = sorted(parts, key=lambda m: (-abs(m.volume), *m.centroid))
126
+ pending = []
127
+ for i, part in enumerate(parts):
128
+ path = Path(args.out_dir).resolve()/f"part_{i:03d}.stl"
129
+ if path.exists() and not args.force:
130
+ raise CADError(f"Output exists: {path}; use --force")
131
+ pending.append((path, export_bytes(part, path, args.allow_open)))
132
+ for path, data in pending:
133
+ atomic_bytes(path, data)
134
+ return {"ok": True, "parts": [str(p) for p, _ in pending]}
135
+
136
+
137
+ def section_mesh(args):
138
+ from shapely import affinity
139
+
140
+ from .._profiles import loops_fill, polygons
141
+ from ..mesh import load_mesh
142
+
143
+ if Path(args.output).exists() and not args.force:
144
+ raise CADError("Section output exists; use --force")
145
+ section = load_mesh(Path(args.input)).section(plane_origin=[0, 0, args.z], plane_normal=[0, 0, 1])
146
+ if section is None:
147
+ raise CADError("Plane does not intersect mesh")
148
+ xy_loops = [loop[:, :2] for loop in section.discrete]
149
+ geom = loops_fill(xy_loops)
150
+ # SVG's Y axis points down while the mesh's points up. Write -Y so the drawing
151
+ # looks like the part seen from +Z, and so the SVG importer (flip_y by default)
152
+ # restores the original handedness instead of returning a mirror image.
153
+ drawing = affinity.scale(geom, yfact=-1, origin=(0, 0))
154
+ xmin, ymin, xmax, ymax = drawing.bounds
155
+ paths = []
156
+ for p in polygons(drawing):
157
+ for ring in [p.exterior, *p.interiors]:
158
+ xy = list(ring.coords)
159
+ paths.append("M " + " L ".join(f"{x:.10g},{y:.10g}" for x, y in xy) + " Z")
160
+ svg = (f'<svg xmlns="http://www.w3.org/2000/svg" width="{xmax-xmin}mm" height="{ymax-ymin}mm" '
161
+ f'viewBox="{xmin} {ymin} {xmax-xmin} {ymax-ymin}">'
162
+ f'<path fill-rule="evenodd" d="{" ".join(paths)}"/></svg>\n')
163
+ atomic_bytes(args.output, svg.encode())
164
+ return {"ok": True, "path": str(Path(args.output).resolve()), "z": args.z, "area": geom.area}