""" The NHF build system Usage: For any parametric assembly, inherit the `Model` class, and mark the output objects with the `@target` decorator. Each marked function should only take `self` as an argument. ```python class BuildScaffold(Model): @target(name="obj1") def o1(self): return Cq.Solid.makeBox(10, 10, 10) def o2(self): return Cq.Solid.makeCylinder(10, 20) ``` """ from enum import Enum from pathlib import Path from typing import Union, Optional from functools import wraps import traceback from colorama import Fore, Style import cadquery as Cq import nhf.checks as NC import nhf.utils TOL=1e-6 class TargetKind(Enum): STL = "stl", DXF = "dxf", def __init__(self, ext: str): self.ext = ext class Target: """ Marks a function's output for serialization """ def __init__(self, method, name: Optional[str] = None, prototype: bool = False, kind: TargetKind = TargetKind.STL, **kwargs): self._method = method self.name = name self.prototype = prototype self.kind = kind self.kwargs = kwargs def __str__(self): return f"" def __call__(self, obj, *args, **kwargs): """ Raw call function which passes arguments directly to `_method` """ return self._method(obj, *args, **kwargs) @property def file_name(self) -> Optional[str]: """ Output file name """ if self.name: return f"{self.name}.{self.kind.ext}" else: return None def write_to(self, obj, path: str) -> bool: """ Returns false if target is `None` """ x = self._method(obj) if x is None: return False if self.kind == TargetKind.STL: assert isinstance(x, Union[ Cq.Workplane, Cq.Shape, Cq.Compound, Cq.Assembly]) if isinstance(x, Cq.Workplane): x = x.val() if isinstance(x, Cq.Assembly): x = x.toCompound().fuse(tol=TOL) x.exportStl(path, **self.kwargs) elif self.kind == TargetKind.DXF: if isinstance(x, Cq.Sketch): # https://github.com/CadQuery/cadquery/issues/1575 x = ( Cq.Workplane() .add(x._faces) .add(x._wires) .add(x._edges) ) assert isinstance(x, Cq.Workplane) Cq.exporters.exportDXF(x, path, **self.kwargs) else: assert False, f"Invalid kind: {self.kind}" return True @classmethod def methods(cls, subject): """ List of all methods of a class or objects annotated with this decorator. """ def g(): for name in dir(subject): if name == 'target_names': continue method = getattr(subject, name) if hasattr(method, '_target'): yield method._target return {method.name: method for method in g()} def target(**deco_kwargs): """ Decorator for annotating a build output """ def f(method): @wraps(method) def wrapper(self, *args, **kwargs): return method(self, *args, **kwargs) wrapper._target = Target(method, **deco_kwargs) return wrapper return f class Assembly: """ Marks a function's output for assembly property checking """ def __init__(self, method, collision_check: bool = True, **kwargs): self._method = method self.name = method.__name__ self.collision_check = collision_check self.kwargs = kwargs def __str__(self): return f"" def __call__(self, obj, *args, **kwargs): """ Raw call function which passes arguments directly to `_method` """ return self._method(obj, *args, **kwargs) def check(self, obj): x = self._method(obj) assert isinstance(x, Cq.Assembly) if self.collision_check: intersections = NC.pairwise_intersection(x) assert not intersections, f"In {self}, collision detected: {intersections}" @classmethod def methods(cls, subject): """ List of all methods of a class or objects annotated with this decorator. """ def g(): for name in dir(subject): if name == 'target_names': continue method = getattr(subject, name) if hasattr(method, '_assembly'): yield method._assembly return {method.name: method for method in g()} def assembly(**deco_kwargs): """ Decorator for annotating an assembly output """ def f(method): @wraps(method) def wrapper(self, *args, **kwargs): return method(self, *args, **kwargs) wrapper._assembly = Assembly(method, **deco_kwargs) return wrapper return f class Submodel: """ Marks a function's output as a submodel """ def __init__(self, method, name: str, prototype: bool = False, **kwargs): self._method = method self.name = name self.prototype = prototype self.kwargs = kwargs def __str__(self): return f"" def __call__(self, obj, *args, **kwargs): """ Raw call function which passes arguments directly to `_method` """ return self._method(obj, *args, **kwargs) @property def file_name(self): """ Output file name """ return self.name def write_to(self, obj, path: str): x = self._method(obj) assert isinstance(x, Model), f"Unexpected type: {type(x)}" x.build_all(path, prefix=False) @classmethod def methods(cls, subject): """ List of all methods of a class or objects annotated with this decorator. """ def g(): for name in dir(subject): if name == 'target_names': continue method = getattr(subject, name) if hasattr(method, '_submodel'): yield method._submodel return {method.name: method for method in g()} def submodel(name, **deco_kwargs): """ Decorator for annotating a build output """ def f(method): @wraps(method) def wrapper(self, *args, **kwargs): return method(self, *args, **kwargs) wrapper._submodel = Submodel(method, name, **deco_kwargs) return wrapper return f class Model: """ Base class for a parametric assembly """ def __init__(self, name: Optional[str] = None): self.name = name @property def target_names(self) -> list[str]: """ List of decorated target functions """ return list(Target.methods(self).keys()) def check_all(self) -> int: """ Build all models and run all the checks. Return number of checks passed """ total = 0 for t in Target.methods(self).values(): result = t(self) if result: total += 1 for t in Assembly.methods(self).values(): t.check(self) total += 1 return total def build_all( self, output_dir: Union[Path, str] = "build", prefix: bool = True, verbose=1): """ Build all targets in this model and write the results to file """ output_dir = Path(output_dir) if prefix: output_dir = output_dir / self.name targets = Target.methods(self) for t in targets.values(): file_name = t.file_name if file_name is None: assert len(targets) == 1, "Only one anonymous target is permitted" output_file = output_dir.with_suffix('.' + t.kind.ext) else: output_file = output_dir / file_name if output_file.is_file(): if verbose >= 1: print(f"{Fore.GREEN}Skipping{Style.RESET_ALL} {output_file}") continue output_file.parent.mkdir(exist_ok=True, parents=True) if verbose >= 1: print(f"{Fore.BLUE}Building{Style.RESET_ALL} {output_file}") try: flag = t.write_to(self, str(output_file)) if flag and verbose >= 1: print(f"{Fore.GREEN}Built{Style.RESET_ALL} {output_file}") except Exception as e: print(f"{Fore.RED}Failed to build{Style.RESET_ALL} {output_file}: {e}") traceback.print_exc() for t in Submodel.methods(self).values(): d = output_dir / t.name t.write_to(self, str(d))