diff --git a/nhf/build.py b/nhf/build.py new file mode 100644 index 0000000..106577c --- /dev/null +++ b/nhf/build.py @@ -0,0 +1,307 @@ +""" +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) + + @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", verbose=1): + """ + Build all targets in this model and write the results to file + """ + output_dir = Path(output_dir) + 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)) diff --git a/nhf/checks.py b/nhf/checks.py new file mode 100644 index 0000000..1f1f4c3 --- /dev/null +++ b/nhf/checks.py @@ -0,0 +1,64 @@ +import cadquery as Cq + +def binary_intersection(a: Cq.Assembly) -> Cq.Shape: + objs = [s.toCompound() for _, s in a.traverse() + if isinstance(s, Cq.Assembly)] + obj1, obj2 = objs[:2] + return obj1.intersect(obj2) + +def visualize_intersection(assembly: Cq.Assembly, tol: float=1e-6) -> Cq.Shape: + """ + Given an assembly, test the pairwise intersection volume of its components. + Return the pairs whose intersection volume exceeds `tol`. + """ + m = {name: (i, shape.moved(loc)) + for i, (shape, name, loc, _) + in enumerate(assembly)} + for name, (i1, sh1) in m.items(): + for name2, (i2, sh2) in m.items(): + if name == name2: + assert i1 == i2 + continue + if i2 <= i1: + # Remove the upper diagonal + continue + head = name.split('/', 2)[1] + head2 = name2.split('/', 2)[1] + if head == head2: + # Do not test into subassemblies + continue + + isect = sh1.intersect(sh2) + vol = isect.Volume() + if vol > tol: + return isect + return None + +def pairwise_intersection(assembly: Cq.Assembly, tol: float=1e-6) -> list[(str, str, float)]: + """ + Given an assembly, test the pairwise intersection volume of its components. + Return the pairs whose intersection volume exceeds `tol`. + """ + m = {name: (i, shape.moved(loc)) + for i, (shape, name, loc, _) + in enumerate(assembly)} + result = [] + for name, (i1, sh1) in m.items(): + for name2, (i2, sh2) in m.items(): + if name == name2: + assert i1 == i2 + continue + if i2 <= i1: + # Remove the upper diagonal + continue + head = name.split('/', 2)[1] + head2 = name2.split('/', 2)[1] + if head == head2: + # Do not test into subassemblies + continue + + + vol = sh1.intersect(sh2).Volume() + if vol > tol: + result.append((name, name2, vol)) + return result diff --git a/nhf/diag.py b/nhf/diag.py new file mode 100644 index 0000000..1648383 --- /dev/null +++ b/nhf/diag.py @@ -0,0 +1,42 @@ +import cadquery as Cq + +def tidy_repr(obj): + """Shortens a default repr string""" + return repr(obj).split(".")[-1].rstrip(">") + + +def _ctx_str(self): + return ( + tidy_repr(self) + + ":\n" + + f" pendingWires: {self.pendingWires}\n" + + f" pendingEdges: {self.pendingEdges}\n" + + f" tags: {self.tags}" + ) + + +Cq.cq.CQContext.__str__ = _ctx_str + + +def _plane_str(self): + return ( + tidy_repr(self) + + ":\n" + + f" origin: {self.origin.toTuple()}\n" + + f" z direction: {self.zDir.toTuple()}" + ) + + +Cq.occ_impl.geom.Plane.__str__ = _plane_str + + +def _wp_str(self): + out = tidy_repr(self) + ":\n" + out += f" parent: {tidy_repr(self.parent)}\n" if self.parent else " no parent\n" + out += f" plane: {self.plane}\n" + out += f" objects: {self.objects}\n" + out += f" modelling context: {self.ctx}" + return out + + +Cq.Workplane.__str__ = _wp_str diff --git a/nhf/geometry.py b/nhf/geometry.py new file mode 100644 index 0000000..b4aa68f --- /dev/null +++ b/nhf/geometry.py @@ -0,0 +1,110 @@ +""" +Geometry functions +""" +from typing import Tuple, Optional +import math + +def min_radius_contraction_span_pos( + d_open: float, + d_closed: float, + theta: float, + ) -> Tuple[float, float]: + """ + Calculates the position of the two ends of an actuator, whose fully opened + length is `d_open`, closed length is `d_closed`, and whose motion spans a + range `theta` (in radians). Returns (r, phi): If one end of the actuator is + held at `(r, 0)`, then the other end will trace an arc `r` away from the + origin with span `theta` + + Let `P` (resp. `P'`) be the position of the front of the actuator when its + fully open (resp. closed), `Q` be the position of the back of the actuator, + we note that `OP = OP' = OQ`. + """ + assert d_open > d_closed + assert 0 < theta < math.pi + + pq2 = d_open * d_open + p_q2 = d_closed * d_closed + # angle of PQP' + psi = 0.5 * theta + # |P-P'|, via the triangle PQP' + pp_2 = pq2 + p_q2 - 2 * d_open * d_closed * math.cos(psi) + r2 = pp_2 / (2 - 2 * math.cos(theta)) + # Law of cosines on POQ: + phi = math.acos(1 - pq2 / 2 / r2) + return math.sqrt(r2), phi + +def min_tangent_contraction_span_pos( + d_open: float, + d_closed: float, + theta: float, + ) -> Tuple[float, float, float]: + """ + Returns `(r, phi, r')` where `r` is the distance of the arm to origin, `r'` + is the distance of the base to origin, and `phi` the angle in the open + state. + """ + assert d_open > d_closed + assert 0 < theta < math.pi + # Angle of OPQ = OPP' + pp_ = d_open - d_closed + pq = d_open + p_q = d_closed + + a = (math.pi - theta) / 2 + # Law of sines on POP' + r = math.sin(a) / math.sin(theta) * pp_ + # Law of cosine on OPQ + oq = math.sqrt(r * r + pq * pq - 2 * r * pq * math.cos(a)) + # Law of sines on OP'Q. Not using OPQ for numerical reasons since the angle + # `phi` could be very close to `pi/2` + phi_ = math.asin(math.sin(a) / oq * p_q) + phi = phi_ + theta + assert theta <= phi < math.pi + return r, phi, oq + +def contraction_span_pos_from_radius( + d_open: float, + d_closed: float, + theta: float, + r: Optional[float] = None, + smaller: bool = True, + ) -> Tuple[float, float, float]: + """ + Returns `(r, phi, r')` + + Set `smaller` to false to use the other solution, which has a larger + profile. + """ + if r is None: + return min_tangent_contraction_span_pos( + d_open=d_open, + d_closed=d_closed, + theta=theta) + assert 0 < theta < math.pi + assert d_open > d_closed + assert r > 0 + # Law of cosines + pp_ = r * math.sqrt(2 * (1 - math.cos(theta))) + d = d_open - d_closed + assert pp_ > d, f"Triangle inequality is violated. This joint is impossible: {pp_}, {d}" + assert d_open + d_closed > pp_, f"The span is too great to cover with this stroke length: {pp_}" + # Angle of PP'Q, via a numerically stable acos + beta = math.acos( + - d / pp_ * (1 + d / (2 * d_closed)) + + pp_ / (2 * d_closed)) + # Two solutions based on angle complementarity + if smaller: + contra_phi = beta - (math.pi - theta) / 2 + else: + # technically there's a 2pi in front + contra_phi = -(math.pi - theta) / 2 - beta + # Law of cosines, calculates `r'` + r_ = math.sqrt( + r * r + d_closed * d_closed - 2 * r * d_closed * math.cos(contra_phi) + ) + # sin phi_ / P'Q = sin contra_phi / r' + phi_ = math.asin(math.sin(contra_phi) / r_ * d_closed) + assert phi_ > 0, f"Actuator would need to traverse pass its minimal point, {math.degrees(phi_)}" + assert 0 <= theta + phi_ <= math.pi + return r, theta + phi_, r_ diff --git a/nhf/parts/__init__.py b/nhf/parts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nhf/parts/box.py b/nhf/parts/box.py new file mode 100644 index 0000000..7376826 --- /dev/null +++ b/nhf/parts/box.py @@ -0,0 +1,157 @@ +import cadquery as Cq +from dataclasses import dataclass, field +from typing import Tuple, Optional, Union, Callable +from nhf.build import Model, TargetKind, target +import nhf.utils + +def box_with_centre_holes( + length: float, + width: float, + height: float, + hole_loc: list[float], + hole_diam: float = 6.0, + ) -> Cq.Workplane: + """ + Creates a box with holes along the X axis, marked `conn0, conn1, ...`. The + box's y axis is centred + """ + result = ( + Cq.Workplane('XY') + .box(length, width, height, centered=(False, True, False)) + .faces(">Z") + .workplane() + ) + plane = result + for i, x in enumerate(hole_loc): + result = result.moveTo(x, 0).hole(hole_diam) + plane.moveTo(x, 0).tagPlane(f"conn{i}") + return result + +@dataclass +class Hole: + x: float + y: float = 0.0 + diam: Optional[float] = None + tag: Optional[str] = None + face: Optional[Cq.Face] = None + + @property + def rev_tag(self) -> str: + assert self.tag is not None + return self.tag + "_rev" + + def cutting_geometry(self, default_diam: Optional[float] = None) -> Cq.Face: + if self.face is not None: + return self.face + diam = self.diam if self.diam is not None else default_diam + assert diam is not None + return Cq.Face.makeFromWires(Cq.Wire.makeCircle(diam/2, Cq.Vector(), Cq.Vector(0,0,1))) + +@dataclass +class MountingBox(Model): + """ + Create a box with marked holes + """ + length: float = 100.0 + width: float = 60.0 + thickness: float = 1.0 + + # List of (x, y), diam + holes: list[Hole] = field(default_factory=lambda: []) + hole_diam: Optional[float] = None + + centred: Tuple[bool, bool] = (False, True) + + generate_side_tags: bool = True + # Generate tags on the opposite side + generate_reverse_tags: bool = False + + centre_bot_top_tags: bool = False + centre_left_right_tags: bool = False + + # Determines the position of side tags + flip_y: bool = False + + profile_callback: Optional[Callable[[Cq.Sketch], Cq.Sketch]] = None + + def __post_init__(self): + assert self.thickness > 0 + for i, hole in enumerate(self.holes): + if hole.tag is None: + hole.tag = f"conn{i}" + + @target(kind=TargetKind.DXF) + def profile(self) -> Cq.Sketch: + bx, by = 0, 0 + if not self.centred[0]: + bx = self.length / 2 + if not self.centred[1]: + by = self.width / 2 + result = ( + Cq.Sketch() + .push([(bx, by)]) + .rect(self.length, self.width) + ) + for hole in self.holes: + face = hole.cutting_geometry(default_diam=self.hole_diam) + result.push([(hole.x, hole.y)]).each(lambda l:face.moved(l), mode='s') + if self.profile_callback: + result = self.profile_callback(result) + return result + + def generate(self) -> Cq.Workplane: + """ + Creates box shape with markers + """ + result = ( + Cq.Workplane('XY') + .placeSketch(self.profile()) + .extrude(self.thickness) + ) + plane = result.copyWorkplane(Cq.Workplane('XY')).workplane(offset=self.thickness) + reverse_plane = result.copyWorkplane(Cq.Workplane('XY')) + for hole in self.holes: + assert hole.tag + plane.moveTo(hole.x, hole.y).tagPlane(hole.tag) + if self.generate_reverse_tags: + reverse_plane.moveTo(hole.x, hole.y).tagPlane(hole.rev_tag, '-Z') + + if self.generate_side_tags: + xn, xp = 0, self.length + if self.centred[0]: + xn -= self.length/2 + xp -= self.length/2 + yn, yp = 0, self.width + if self.centred[1]: + yn -= self.width/2 + yp -= self.width/2 + + tag_x = xn + (self.length/2 if self.centre_left_right_tags else 0) + result.copyWorkplane(Cq.Workplane('XZ', origin=(tag_x, yn, self.thickness))).tagPlane("left") + result.copyWorkplane(Cq.Workplane('ZX', origin=(tag_x, yp, self.thickness))).tagPlane("right") + + tag_y = yn + (self.width/2 if self.centre_bot_top_tags else 0) + result.copyWorkplane(Cq.Workplane('ZY', origin=(xn, tag_y, self.thickness))).tagPlane("bot") + result.copyWorkplane(Cq.Workplane('YZ', origin=(xp, tag_y, self.thickness))).tagPlane("top") + result.faces(">Z").tag("dir") + return result + + def marked_assembly(self) -> Cq.Assembly: + result = ( + Cq.Assembly() + .add(self.generate(), name="box") + ) + for hole in self.holes: + result.markPlane(f"box?{hole.tag}") + if self.generate_reverse_tags: + result.markPlane(f"box?{hole.rev_tag}") + if self.generate_side_tags: + ( + result + .markPlane("box?left") + .markPlane("box?right") + .markPlane("box?dir") + .markPlane("box?top") + .markPlane("box?bot") + ) + return result.solve() diff --git a/nhf/parts/electronics.py b/nhf/parts/electronics.py new file mode 100644 index 0000000..0a145b3 --- /dev/null +++ b/nhf/parts/electronics.py @@ -0,0 +1,135 @@ +from dataclasses import dataclass, field +from typing import Tuple +import cadquery as Cq +from nhf import Item, Role +from nhf.parts.box import Hole, MountingBox +import nhf.utils + +@dataclass(frozen=True) +class ArduinoUnoR3(Item): + # From datasheet + mass: float = 25.0 + length: float = 68.6 + width: float = 53.4 + + # with clearance + base_height: float = 5.0 + # aesthetic only for illustrating clearance + total_height: float = 50.0 + roof_height: float = 20.0 + + # This is labeled in mirrored coordinates from top down (i.e. unmirrored from bottom up) + holes: list[Tuple[float, float]] = field(default_factory=lambda: [ + (15.24, 2.54), + (15.24 - 1.270, 50.80), # x coordinate not labeled on schematic + (66.04, 17.78), + (66.04, 45.72), + ]) + hole_diam: float = 3.0 + + @property + def name(self) -> str: + return "Arduino Uno R3" + + @property + def role(self) -> Role: + return Role.ELECTRONIC + + def generate(self) -> Cq.Assembly: + sketch = ( + Cq.Sketch() + .polygon([ + (0,0), + (self.length, 0), + (self.length, self.width), + (0,self.width) + ]) + .push([(x, self.width - y) for x,y in self.holes]) + .circle(self.hole_diam / 2, mode='s') + ) + # pillar thickness + t = 3.0 + pillar_height = self.total_height - self.base_height + pillar = Cq.Solid.makeBox( + t, t, pillar_height + ) + roof = Cq.Solid.makeBox( + self.length, self.width, t + ) + result = ( + Cq.Workplane('XY') + .placeSketch(sketch) + .extrude(self.base_height) + .union(pillar.located(Cq.Location((0, 0, self.base_height)))) + .union(pillar.located(Cq.Location((self.length - t, 0, self.base_height)))) + .union(pillar.located(Cq.Location((self.length - t, self.width - t, self.base_height)))) + .union(pillar.located(Cq.Location((0, self.width - t, self.base_height)))) + .union(roof.located(Cq.Location((0, 0, self.total_height - t)))) + ) + plane = result.copyWorkplane(Cq.Workplane('XY')) + for i, (x, y) in enumerate(self.holes): + plane.moveTo(x, self.width - y).tagPlane(f"conn{i}", direction='-Z') + return result + + +@dataclass(frozen=True) +class BatteryBox18650(Item): + """ + A number of 18650 batteries in series + """ + mass: float = 17.4 + 68.80 * 3 + length: float = 75.70 + width_base: float = 61.46 - 18.48 - 20.18 * 2 + battery_dist: float = 20.18 + height: float = 19.66 + # space from bottom to battery begin + thickness: float = 1.66 + battery_diam: float = 18.48 + battery_height: float = 68.80 + n_batteries: int = 3 + + def __post_init__(self): + assert 2 * self.thickness < min(self.length, self.height) + + @property + def name(self) -> str: + return f"BatteryBox 18650*{self.n_batteries}" + + @property + def role(self) -> Role: + return Role.ELECTRONIC + + def generate(self) -> Cq.Workplane: + width = self.width_base + self.battery_dist * (self.n_batteries - 1) + self.battery_diam + return ( + Cq.Workplane('XY') + .box( + length=self.length, + width=width, + height=self.height, + centered=(True, True, False), + ) + .copyWorkplane(Cq.Workplane('XY', origin=(0, 0, self.thickness))) + .box( + length=self.length - self.thickness*2, + width=width - self.thickness*2, + height=self.height - self.thickness, + centered=(True, True, False), + combine='cut', + ) + .copyWorkplane(Cq.Workplane('XY', origin=(-self.battery_height/2, 0, self.thickness + self.battery_diam/2))) + .rarray( + xSpacing=1, + ySpacing=self.battery_dist, + xCount=1, + yCount=self.n_batteries, + center=True, + ) + .cylinder( + radius=self.battery_diam/2, + height=self.battery_height, + direct=(1, 0, 0), + centered=(True, True, False), + combine=True, + ) + ) diff --git a/nhf/parts/fasteners.py b/nhf/parts/fasteners.py new file mode 100644 index 0000000..ab79802 --- /dev/null +++ b/nhf/parts/fasteners.py @@ -0,0 +1,162 @@ +from dataclasses import dataclass +import math +import cadquery as Cq +from typing import Optional +from nhf import Item, Role +import nhf.utils + +@dataclass(frozen=True) +class FlatHeadBolt(Item): + diam_head: float + height_head: float + diam_thread: float + height_thread: float + + @property + def name(self) -> str: + return f"Bolt M{int(self.diam_thread)} h{int(self.height_thread)}mm" + + @property + def role(self) -> Role: + return Role.CONNECTION + + + def generate(self) -> Cq.Assembly: + head = Cq.Solid.makeCylinder( + radius=self.diam_head / 2, + height=self.height_head, + ) + rod = ( + Cq.Workplane('XY') + .cylinder( + radius=self.diam_thread/ 2, + height=self.height_thread, + centered=(True, True, False)) + ) + rod.faces("Z").tag("root") + rod = rod.union(head.located(Cq.Location((0, 0, self.height_thread)))) + return rod + + +@dataclass(frozen=True) +class ThreaddedKnob(Item): + """ + A threaded rod with knob on one side + """ + diam_thread: float + height_thread: float + diam_knob: float + + diam_neck: float + height_neck: float + height_knob: float + + @property + def name(self) -> str: + return f"Knob M{int(self.diam_thread)} h{int(self.height_thread)}mm" + + def generate(self) -> Cq.Assembly: + knob = Cq.Solid.makeCylinder( + radius=self.diam_knob / 2, + height=self.height_knob, + ) + neck = Cq.Solid.makeCylinder( + radius=self.diam_neck / 2, + height=self.height_neck, + ) + thread = ( + Cq.Workplane('XY') + .cylinder( + radius=self.diam_thread / 2, + height=self.height_thread, + centered=(True, True, False)) + ) + thread.faces("Z").tag("root") + + return ( + Cq.Assembly() + .addS(thread, name="thread", role=Role.CONNECTION) + .addS(neck, name="neck", role=Role.HANDLE, + loc=Cq.Location((0, 0, self.height_thread))) + .addS(knob, name="knob", role=Role.HANDLE, + loc=Cq.Location((0, 0, self.height_thread + self.height_neck))) + ) + + +@dataclass(frozen=True) +class HexNut(Item): + diam_thread: float + pitch: float + + thickness: float + width: float + + def __post_init__(self): + assert self.width > self.diam_thread + + @property + def name(self): + return f"HexNut M{int(self.diam_thread)}-{self.pitch}" + + @property + def role(self) -> Role: + return Role.CONNECTION + + @property + def radius(self) -> float: + return self.width / math.sqrt(3) + + def generate(self) -> Cq.Workplane: + result = ( + Cq.Workplane("XY") + .sketch() + .regularPolygon(r=self.radius, n=6) + .circle(r=self.diam_thread/2, mode='s') + .finalize() + .extrude(self.thickness) + ) + result.faces("Z").tag("top") + result.copyWorkplane(Cq.Workplane('XY')).tagPlane("dirX", direction="+X") + return result + + def cutting_face(self) -> Cq.Face: + return ( + Cq.Sketch() + .regularPolygon(r=self.radius, n=6) + ._faces + ) + +@dataclass(frozen=True) +class Washer(Item): + diam_thread: float + diam_outer: float + thickness: float + material_name: Optional[float] = None + + def __post_init__(self): + assert self.diam_outer > self.diam_thread + @property + def name(self): + suffix = (" " + self.material_name) if self.material_name else "" + return f"Washer M{int(self.diam_thread)}{suffix}" + + @property + def role(self) -> Role: + return Role.CONNECTION + + def generate(self) -> Cq.Workplane: + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.diam_outer/2, + height=self.thickness, + ) + .faces(">Z") + .hole(self.diam_thread) + ) + result.faces("Z").tag("top") + return result diff --git a/nhf/parts/fibre.py b/nhf/parts/fibre.py new file mode 100644 index 0000000..003e64b --- /dev/null +++ b/nhf/parts/fibre.py @@ -0,0 +1,58 @@ +""" +A fibre, for bearing tension +""" +import cadquery as Cq +from dataclasses import dataclass +import nhf.utils + +def tension_fibre( + length: float, + hole_diam: float, + hole_twist: float=0, + thickness: float=0.5) -> Cq.Workplane: + """ + A fibre which holds tension, with an eyes on each end. + + """ + eye_female = Cq.Solid.makeTorus( + radius1=hole_diam/2 + thickness/2, + radius2=thickness/2, + dir=(1,0,0), + ) + hole_length_male = hole_diam * 2.5 + hole_height_male = hole_diam * 1.2 + eye_male = Cq.Solid.makeBox( + length=hole_length_male + thickness * 2, + width=thickness, + height=hole_height_male + thickness * 2, + ).located( + Cq.Location((-hole_length_male/2-thickness, -thickness/2, -hole_height_male/2-thickness)) + ).cut(Cq.Solid.makeBox( + length=hole_length_male, + width=thickness, + height=hole_height_male, + ).located(Cq.Location((-hole_length_male/2, -thickness/2, -hole_height_male/2)))) + height = length - hole_diam - thickness + assert height > 0, "String is too short to support the given hole sizes" + h1 = length/2 - hole_diam/2 - thickness/2 + h2 = length/2 - hole_height_male - thickness/2 + result = ( + Cq.Workplane('XY') + .cylinder( + radius=thickness/2, + height=h1, + centered=(True, True, False), + ) + .copyWorkplane(Cq.Workplane('YX')) + .cylinder( + radius=thickness/2, + height=h2, + centered=(True, True, False), + ) + .union(eye_female.located(Cq.Location((0, 0,length/2)))) + .union(eye_male.located(Cq.Location((0, 0,-length/2+hole_height_male/2+thickness/2), (0,0,1), hole_twist))) + ) + result.copyWorkplane(Cq.Workplane(Cq.Plane(origin=(0,0,length/2), normal=(1,0,0)))).tagPlane("female") + conn1_normal, _ = (Cq.Location((0,0,0),(0,0,1),hole_twist) * Cq.Location((1,0,0))).toTuple() + result.copyWorkplane(Cq.Workplane(Cq.Plane(origin=(0,0,-length/2), normal=conn1_normal))).tagPlane("male") + return result diff --git a/nhf/parts/handle.py b/nhf/parts/handle.py new file mode 100644 index 0000000..d6b926c --- /dev/null +++ b/nhf/parts/handle.py @@ -0,0 +1,385 @@ +""" +This schematics file contains all designs related to tool handles +""" +from dataclasses import dataclass, field +from typing import Union, Optional +import cadquery as Cq +import nhf.parts.metric_threads as metric_threads +import nhf.utils + +class Mount: + """ + Describes the internal connection between two cylinders + """ + + def diam_insertion_internal(self) -> float: + """ + Diameter of the internal cavity in the insertion + """ + def diam_connector_external(self) -> float: + """ + Diameter of the external size of the connector + """ + def external_thread(self, length: float) -> Cq.Shape: + """ + Generates the external connector + """ + def internal_thread(self, length: float) -> Cq.Shape: + """ + Generates the internal connector + """ + +@dataclass +class ThreadedMount(Mount): + + pitch: float = 3 + + # Major diameter of the internal threads, following ISO metric screw thread + # standard. This determines the wall thickness of the insertion. + diam_threading: float = 27 + + def diam_insertion_internal(self) -> float: + r = metric_threads.metric_thread_major_radius( + self.diam_threading, + self.pitch, + internal=True) + return r * 2 + def diam_connector_external(self) -> float: + r = metric_threads.metric_thread_minor_radius( + self.diam_threading, + self.pitch) + return r * 2 + + def external_thread(self, length: float): + return metric_threads.external_metric_thread( + self.diam_threading, + self.pitch, + length, + top_lead_in=True) + def internal_thread(self, length: float): + return metric_threads.internal_metric_thread( + self.diam_threading, + self.pitch, + length) + +@dataclass +class BayonetMount(Mount): + """ + Bayonet type connection + """ + diam_outer: float = 30 + diam_inner: float = 27 + + # Angular span (in degrees) of the slider + pin_span: float = 15 + pin_height: float = 5 + # Wall at the bottom of the slot + gap: float = 3 + + # Angular span (in degrees) of the slot + slot_span: float = 90 + + # Number of pins equally distributed along a circle + n_pin: int = 2 + + + def __post_init__(self): + assert self.diam_outer > self.diam_inner + assert self.n_pin * self.slot_span < 360 + assert self.slot_span > self.pin_span + + def diam_insertion_internal(self) -> float: + return self.diam_outer + def diam_connector_external(self) -> float: + return self.diam_inner + + def external_thread(self, length: float): + assert length > self.pin_height + self.gap + pin = ( + Cq.Workplane('XY') + .cylinder( + height=self.pin_height, + radius=self.diam_outer / 2, + angle=self.pin_span, + centered=(True, True, False)) + .copyWorkplane(Cq.Workplane('XY')) + .cylinder( + height=self.pin_height, + radius=self.diam_inner / 2, + centered=(True, True, False), + combine="cut") + .val() + ) + result = ( + Cq.Workplane('XY') + .workplane(offset=self.gap) + .polarArray(radius=0, startAngle=0, angle=360, count=self.n_pin) + .eachpoint(lambda loc: pin.located(loc), combine='a') + .clean() + ) + return result + def internal_thread(self, length: float): + assert length > self.pin_height + self.gap + slot = ( + Cq.Workplane('XY') + .cylinder( + height=length - self.gap, + radius=self.diam_outer / 2, + angle=self.pin_span, + centered=(True, True, False) + ) + .copyWorkplane(Cq.Workplane('XY')) + .cylinder( + height=self.pin_height, + radius=self.diam_outer / 2, + angle=self.slot_span, + centered=(True, True, False) + ) + .val() + ) + result = ( + Cq.Workplane('XY') + .cylinder( + height=length, + radius=self.diam_outer / 2, + centered=(True, True, False), + ) + .copyWorkplane(Cq.Workplane('XY')) + .workplane(offset=self.gap) + .polarArray(radius=0, startAngle=self.slot_span, angle=360, count=self.n_pin) + .cutEach(lambda loc: slot.located(loc)) + .clean() + .copyWorkplane(Cq.Workplane('XY')) + .cylinder( + height=length, + radius=self.diam_inner / 2, + centered=(True, True, False), + combine="cut" + ) + ) + return result + + +@dataclass +class Handle: + """ + Characteristic of a tool handle + + This assumes the handle segment material does not have threads. Each segment + attaches to two insertions, which have threads on the inside. A connector + has threads on the outside and mounts two insertions. + + Note that all the radial sizes are diameters (in mm). + """ + + # Outer and inner radii for the handle usually come in standard sizes + diam: float = 38 + diam_inner: float = 33 + + mount: Optional[Mount] = field(default_factory=lambda: ThreadedMount()) + + # Internal cavity diameter. This determines the wall thickness of the connector + diam_connector_internal: float = 18.0 + + # If set to true, do not generate the connections + simplify_geometry: bool = True + + # Length for the rim on the female connector + rim_length: float = 5 + + insertion_length: float = 30 + + # Amount by which the connector goes into the segment + connector_length: float = 60 + + def __post_init__(self): + assert self.diam > self.diam_inner, "Material thickness cannot be <= 0" + if self.mount: + assert self.diam_inner > self.mount.diam_insertion_internal(), "Threading radius is too big" + assert self.mount.diam_insertion_internal() >= self.mount.diam_connector_external() + assert self.mount.diam_connector_external() > self.diam_connector_internal, "Internal diameter is too large" + assert self.insertion_length > self.rim_length + + def segment(self, length: float): + result = ( + Cq.Workplane() + .cylinder( + radius=self.diam / 2, + height=length) + .faces(">Z") + .hole(self.diam_inner) + ) + result.faces("Z").tag("mate2") + return result + + def insertion(self, holes=[]): + """ + This type of mount is used to connect two handlebar pieces. Each handlebar + piece is a tube which cannot be machined, so the mount connects to the + handle by glue. + + Tags: + * lip: Co-planar Mates to the rod + * mate: Mates to the connector + + WARNING: A tolerance lower than the defualt (maybe 5e-4) is required for + STL export. + + Set `holes` to the heights for drilling holes into the model for resin + to flow out. + """ + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.diam_inner / 2, + height=self.insertion_length - self.rim_length, + centered=[True, True, False]) + ) + result.faces(">Z").tag("rim") + if self.rim_length > 0: + result = ( + result.faces(">Z") + .workplane() + .circle(self.diam / 2) + .extrude(self.rim_length) + .faces(">Z") + .hole(self.mount.diam_insertion_internal()) + ) + result.faces(">Z").tag("mate") + result.copyWorkplane(Cq.Workplane('XY')).tagPlane("dir", "+X") + if not self.simplify_geometry: + thread = self.mount.internal_thread(self.insertion_length).val() + result = result.union(thread) + for h in holes: + cyl = Cq.Solid.makeCylinder( + radius=2, + height=self.diam * 2, + pnt=(-self.diam, 0, h), + dir=(1, 0, 0)) + result = result.cut(cyl) + return result + + def connector(self, solid: bool = True): + """ + Tags: + * mate{1,2}: Mates to the connector + + WARNING: A tolerance lower than the defualt (maybe 2e-4) is required for + STL export. + """ + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.diam / 2, + height=self.connector_length, + ) + ) + for (tag, selector) in [("mate1", "Z")]: + result.faces(selector).tag(tag) + result = ( + result + .faces(selector) + .workplane() + .circle(self.mount.diam_connector_external() / 2) + .extrude(self.insertion_length) + ) + if not solid: + result = result.faces(">Z").hole(self.diam_connector_internal) + if not self.simplify_geometry: + thread = self.mount.external_thread(self.insertion_length).val() + result = ( + result + .union( + thread + .located(Cq.Location((0, 0, -self.connector_length)))) + .union( + thread + .rotate((0,0,0), (1,0,0), angleDegrees=180) + .located(Cq.Location((0, 0, self.connector_length)))) + ) + result.copyWorkplane(Cq.Workplane('XY')).tagPlane("dir", "+X") + return result + + def one_side_connector(self, height=None): + if height is None: + height = self.rim_length + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.diam / 2, + height=height, + centered=(True, True, False) + ) + ) + result.faces(">Z").tag("mate") + result.faces("Z") + .workplane() + .circle(self.mount.diam_connector_external() / 2) + .extrude(self.insertion_length) + ) + result.copyWorkplane(Cq.Workplane('XY')).tagPlane("dir", "+X") + if not self.simplify_geometry: + thread = self.mount.external_thread(self.insertion_length).val() + result = ( + result + .union( + thread + # Avoids collision in some mating cases + .rotate((0,0,0), (1,0,0), angleDegrees=180) + .located(Cq.Location((0, 0, height + self.insertion_length)))) + ) + return result + + def threaded_core(self, length): + """ + Generates a threaded core for unioning with other components + """ + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.mount.diam_connector_external / 2, + height=length, + centered=(True, True, False), + ) + ) + result.faces(">Z").tag("mate") + result.faces(" float: + # """ + # Mass, in grams + # """ + # return self._mass + + #@mass.setter + #def mass(self, value): + # assert value >= 0, "Mass cannot be negative" + # self._mass = value + + @property + def name(self) -> str: + pass + + @property + def role(self) -> Optional[Role]: + return None + + def generate(self, **kwargs) -> Union[Cq.Solid, Cq.Assembly, Cq.Workplane]: + """ + Creates an assembly for this item. Subclass should implement this + """ + return Cq.Assembly() + + def assembly(self, **kwargs) -> Cq.Assembly: + """ + Interface for creating assembly with the necessary metadata + """ + a = self.generate(**kwargs) + if isinstance(a, Cq.Workplane) or isinstance(a, Cq.Solid): + a = Cq.Assembly(a) + if role := self.role: + a.metadata[KEY_ROLE] = role + a.color = role.color_avg() + assert isinstance(a, Cq.Assembly) + assert KEY_ITEM not in a.metadata + a.metadata[KEY_ITEM] = self + return a + + @staticmethod + def count(a: Cq.Assembly) -> Counter: + """ + Counts the number of items + """ + occ = Counter() + for _, obj in a.traverse(): + if KEY_ITEM not in obj.metadata: + continue + item = obj.metadata[KEY_ITEM] + assert isinstance(item, Item) + occ[item.name] += 1 + return occ diff --git a/nhf/parts/joints.py b/nhf/parts/joints.py new file mode 100644 index 0000000..4245eff --- /dev/null +++ b/nhf/parts/joints.py @@ -0,0 +1,431 @@ +from dataclasses import dataclass, field +from typing import Optional +import math +import cadquery as Cq +from nhf.parts.springs import TorsionSpring +from nhf import Role +import nhf.utils + +TOL = 1e-6 + +@dataclass(frozen=True) +class HirthJoint: + """ + A Hirth joint attached to a cylindrical base + """ + + # r + radius: float = 60 + # r_i + radius_inner: float = 40 + base_height: float = 20 + n_tooth: float = 16 + # h_o + tooth_height: float = 16 + + def __post_init__(self): + # Ensures tangent doesn't blow up + assert self.n_tooth >= 5 + assert self.radius > self.radius_inner + + @property + def tooth_angle(self): + return 360 / self.n_tooth + + @property + def total_height(self): + return self.base_height + self.tooth_height + + @property + def joint_height(self): + return 2 * self.base_height + self.tooth_height + + + def generate(self, is_mated=False, tol=0.01): + """ + is_mated: If set to true, rotate the teeth so they line up at 0 degrees. + + FIXME: Mate is not exact when number of tooth is low + """ + phi = math.radians(self.tooth_angle) + alpha = 2 * math.atan(self.radius / self.tooth_height * math.tan(phi/2)) + #alpha = math.atan(self.radius * math.radians(180 / self.n_tooth) / self.tooth_height) + gamma = math.radians(90 / self.n_tooth) + # Tooth half height + l = self.radius * math.cos(gamma) + a = self.radius * math.sin(gamma) + t = a / math.tan(alpha / 2) + beta = math.asin(t / l) + dx = self.tooth_height * math.tan(alpha / 2) + profile = ( + Cq.Workplane('YZ') + .polyline([ + (0, 0), + (dx, self.tooth_height), + (-dx, self.tooth_height), + ]) + .close() + .extrude(-self.radius) + .val() + .rotate((0, 0, 0), (0, 1, 0), math.degrees(beta)) + .moved(Cq.Location((0, 0, self.base_height))) + ) + core = Cq.Solid.makeCylinder( + radius=self.radius_inner, + height=self.tooth_height, + pnt=(0, 0, self.base_height), + ) + angle_offset = self.tooth_angle / 2 if is_mated else 0 + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.radius, + height=self.base_height + self.tooth_height, + centered=(True, True, False)) + .faces(">Z") + .tag("bore") + .cut(core) + .polarArray( + radius=self.radius, + startAngle=angle_offset, + angle=360, + count=self.n_tooth) + .cutEach( + lambda loc: profile.moved(loc), + ) + ) + ( + result + .polyline([ + (0, 0, self.base_height), + (0, 0, self.base_height + self.tooth_height) + ], forConstruction=True) + .tag("mate") + ) + ( + result + .polyline([(0, 0, 0), (1, 0, 0)], forConstruction=True) + .tag("dirX") + ) + ( + result + .polyline([(0, 0, 0), (0, 1, 0)], forConstruction=True) + .tag("dirY") + ) + return result + + def add_constraints(self, + assembly: Cq.Assembly, + parent: str, + child: str, + offset: int = 0): + angle = offset * self.tooth_angle + ( + assembly + .constrain(f"{parent}?mate", f"{child}?mate", "Plane") + .constrain(f"{parent}?dirX", f"{child}?dirX", + "Axis", param=angle) + .constrain(f"{parent}?dirY", f"{child}?dirX", + "Axis", param=90 - angle) + ) + + def assembly(self, offset: int = 1): + """ + Generate an example assembly + """ + tab = ( + Cq.Workplane('XY') + .box(100, 10, 2, centered=False) + ) + obj1 = ( + self.generate() + .faces(tag="bore") + .cboreHole( + diameter=10, + cboreDiameter=20, + cboreDepth=3) + .union(tab) + ) + obj2 = ( + self.generate(is_mated=True) + .union(tab) + ) + result = ( + Cq.Assembly() + .addS(obj1, name="obj1", role=Role.PARENT) + .addS(obj2, name="obj2", role=Role.CHILD) + ) + self.add_constraints( + result, + parent="obj1", + child="obj2", + offset=offset) + return result.solve() + +@dataclass +class TorsionJoint: + """ + This jonit consists of a rider puck on a track puck. IT is best suited if + the radius has to be small and vertical space is abundant. + + The rider part consists of: + 1. A cylinderical base + 2. A annular extrusion with the same radius as the base, but with slots + carved in + 3. An annular rider + + The track part consists of: + 1. A cylindrical base + 2. A slotted annular extrusion where the slot allows the spring to rest + 3. An outer and an inner annuli which forms a track the rider can move on + """ + spring: TorsionSpring = field(default_factory=lambda: TorsionSpring( + mass=float('nan'), + radius=10.0, + thickness=2.0, + height=15.0, + tail_length=35.0, + right_handed=False, + )) + + # Radius limit for rotating components + radius_track: float = 40 + radius_rider: float = 38 + track_disk_height: float = 10 + rider_disk_height: float = 8 + + radius_axle: float = 6 + + # If true, cover the spring hole. May make it difficult to insert the spring + # considering the stiffness of torsion spring steel. + spring_hole_cover_track: bool = False + spring_hole_cover_rider: bool = False + + groove_radius_outer: float = 35 + groove_radius_inner: float = 20 + # Gap on inner groove to ease movement + groove_inner_gap: float = 0.2 + groove_depth: float = 5 + rider_gap: float = 1 + rider_n_slots: float = 4 + + # Degrees of the first and last rider slots + rider_slot_begin: float = 0 + rider_slot_span: float = 90 + + + def __post_init__(self): + assert self.radius_track > self.groove_radius_outer + assert self.radius_rider > self.groove_radius_outer > self.groove_radius_inner + self.groove_inner_gap + assert self.groove_radius_inner > self.spring.radius > self.radius_axle + assert self.spring.height > self.groove_depth, "Groove is too deep" + assert self.groove_depth < self.spring.height - self.spring.thickness * 2 + if self.rider_n_slots == 1: + assert self.rider_slot_span == 0.0, "Non-zero span is impossible with multiple riders" + + @property + def total_height(self): + """ + Total height counting from bottom to top + """ + return self.track_disk_height + self.rider_disk_height + self.spring.height + + @property + def radius(self): + """ + Maximum radius of this joint + """ + return max(self.radius_rider, self.radius_track) + + def _slot_polygon(self, flip: bool=False): + r1 = self.spring.radius_inner + r2 = self.spring.radius + flip = flip != self.spring.right_handed + if flip: + r1 = -r1 + r2 = -r2 + return [ + (0, r2), + (self.spring.tail_length, r2), + (self.spring.tail_length, r1), + (0, r1), + ] + def _directrix(self, height, theta=0): + c, s = math.cos(theta), math.sin(theta) + r2 = self.spring.radius + l = self.spring.tail_length + if self.spring.right_handed: + r2 = -r2 + # This is (0, r2) and (l, r2) transformed by right handed rotation + # matrix `[[c, -s], [s, c]]` + return [ + (-s * r2, c * r2, height), + (c * l - s * r2, s * l + c * r2, height), + ] + + def track(self): + # TODO: Cover outer part of track only. Can we do this? + groove_profile = ( + Cq.Sketch() + .circle(self.radius_track) + .circle(self.groove_radius_outer, mode='s') + .circle(self.groove_radius_inner, mode='a') + .circle(self.spring.radius, mode='s') + ) + spring_hole_profile = ( + Cq.Sketch() + .circle(self.radius_track) + .circle(self.spring.radius, mode='s') + ) + slot_height = self.spring.thickness + if not self.spring_hole_cover_track: + slot_height += self.groove_depth + slot = ( + Cq.Workplane('XY') + .sketch() + .polygon(self._slot_polygon(flip=False)) + .finalize() + .extrude(slot_height) + .val() + ) + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.radius_track, + height=self.track_disk_height, + centered=(True, True, False)) + .faces('>Z') + .tag("spring") + .placeSketch(spring_hole_profile) + .extrude(self.spring.thickness) + # If the spring hole profile is not simply connected, this workplane + # will have to be created from the `spring-mate` face. + .faces('>Z') + .placeSketch(groove_profile) + .extrude(self.groove_depth) + .faces('>Z') + .hole(self.radius_axle * 2) + .cut(slot.moved(Cq.Location((0, 0, self.track_disk_height)))) + ) + result.faces("Z') + .tag("spring") + .workplane() + .placeSketch(wall_profile) + .extrude(middle_height) + .faces(tag="spring") + .workplane() + # The top face might not be in one piece. + .workplane(offset=middle_height) + .placeSketch(contact_profile) + .extrude(self.groove_depth + self.rider_gap) + .faces(tag="spring") + .workplane() + .circle(self.spring.radius_inner) + .extrude(self.spring.height) + .faces(" radians. +def __deg2rad(degrees): + return degrees * math.pi / 180 + +# In the absence of flat thread valley and flattened thread tip, returns the +# amount by which the thread "triangle" protrudes outwards (radially) from base +# cylinder in the case of external thread, or the amount by which the thread +# "triangle" protrudes inwards from base tube in the case of internal thread. +def metric_thread_perfect_height(pitch): + return pitch / (2 * math.tan(__deg2rad(metric_thread_angle()))) + +# Up the radii of internal (female) thread in order to provide a little bit of +# wiggle room around male thread. Right now input parameter 'diameter' is +# ignored. This function is only used for internal/female threads. Currently +# there is no practical way to adjust the male/female thread clearance besides +# to manually edit this function. This design route was chosen for the sake of +# code simplicity. +def __metric_thread_internal_radius_increase(diameter, pitch): + return 0.1 * metric_thread_perfect_height(pitch) + +# Returns the major radius of thread, which is always the greater of the two. +def metric_thread_major_radius(diameter, pitch, internal=False): + return (__metric_thread_internal_radius_increase(diameter, pitch) if + internal else 0.0) + (diameter / 2) + +# What portion of the total pitch is taken up by the angled thread section (and +# not the squared off valley and tip). The remaining portion (1 minus ratio) +# will be divided equally between the flattened valley and flattened tip. +def __metric_thread_effective_ratio(): + return 0.7 + +# Returns the minor radius of thread, which is always the lesser of the two. +def metric_thread_minor_radius(diameter, pitch, internal=False): + return (metric_thread_major_radius(diameter, pitch, internal) + - (__metric_thread_effective_ratio() * + metric_thread_perfect_height(pitch))) + +# What the major radius would be if the cuts were perfectly triangular, without +# flat spots in the valleys and without flattened tips. +def metric_thread_perfect_major_radius(diameter, pitch, internal=False): + return (metric_thread_major_radius(diameter, pitch, internal) + + ((1.0 - __metric_thread_effective_ratio()) * + metric_thread_perfect_height(pitch) / 2)) + +# What the minor radius would be if the cuts were perfectly triangular, without +# flat spots in the valleys and without flattened tips. +def metric_thread_perfect_minor_radius(diameter, pitch, internal=False): + return (metric_thread_perfect_major_radius(diameter, pitch, internal) + - metric_thread_perfect_height(pitch)) + +# Returns the lead-in and/or chamfer distance along the z axis of rotation. +# The lead-in/chamfer only depends on the pitch and is made with the same angle +# as the thread, that being 30 degrees offset from radial. +def metric_thread_lead_in(pitch, internal=False): + return (math.tan(__deg2rad(metric_thread_angle())) + * (metric_thread_major_radius(256.0, pitch, internal) + - metric_thread_minor_radius(256.0, pitch, internal))) + +# Returns the width of the flat spot in thread valley of a standard thread. +# This is also equal to the width of the flat spot on thread tip, on a standard +# thread. +def metric_thread_relief(pitch): + return (1.0 - __metric_thread_effective_ratio()) * pitch / 2 + + +############################################################################### +# A few words on modules external_metric_thread() and internal_metric_thread(). +# The parameter 'z_start' is added as a convenience in order to make the male +# and female threads align perfectly. When male and female threads are created +# having the same diameter, pitch, and n_starts (usually 1), then so long as +# they are not translated or rotated (or so long as they are subjected to the +# same exact translation and rotation), they will intermesh perfectly, +# regardless of the value of 'z_start' used on each. This is in order that +# assemblies be able to depict perfectly aligning threads. + +# Generates threads with base cylinder unless 'base_cylinder' is overridden. +# Please note that 'use_epsilon' is activated by default, which causes a slight +# budge in the minor radius, inwards, so that overlaps would be created with +# inner cylinders. (Does not affect thread profile outside of cylinder.) +############################################################################### +def external_metric_thread(diameter, # Required parameter, e.g. 3.0 for M3x0.5 + pitch, # Required parameter, e.g. 0.5 for M3x0.5 + length, # Required parameter, e.g. 2.0 + z_start=0.0, + n_starts=1, + bottom_lead_in=False, # Lead-in is at same angle as + top_lead_in =False, # thread, namely 30 degrees. + bottom_relief=False, # Add relief groove to start or + top_relief =False, # end of threads (shorten). + force_outer_radius=-1.0, # Set close to diameter/2. + use_epsilon=True, # For inner cylinder overlap. + base_cylinder=True, # Whether to include base cyl. + cyl_extend_bottom=-1.0, + cyl_extend_top=-1.0, + envelope=False): # Draw only envelope, don't cut. + + cyl_extend_bottom = max(0.0, cyl_extend_bottom) + cyl_extend_top = max(0.0, cyl_extend_top) + + z_off = (1.0 - __metric_thread_effective_ratio()) * pitch / 4 + t_start = z_start + t_length = length + if bottom_relief: + t_start = t_start + (2 * z_off) + t_length = t_length - (2 * z_off) + if top_relief: + t_length = t_length - (2 * z_off) + outer_r = (force_outer_radius if (force_outer_radius > 0.0) else + metric_thread_major_radius(diameter,pitch)) + inner_r = metric_thread_minor_radius(diameter,pitch) + epsilon = 0 + inner_r_adj = inner_r + inner_z_budge = 0 + if use_epsilon: + epsilon = (z_off/3) / math.tan(__deg2rad(metric_thread_angle())) + inner_r_adj = inner_r - epsilon + inner_z_budge = math.tan(__deg2rad(metric_thread_angle())) * epsilon + + if envelope: + threads = cq.Workplane("XZ") + threads = threads.moveTo(inner_r_adj, -pitch) + threads = threads.lineTo(outer_r, -pitch) + threads = threads.lineTo(outer_r, t_length + pitch) + threads = threads.lineTo(inner_r_adj, t_length + pitch) + threads = threads.close() + threads = threads.revolve() + + else: # Not envelope, cut the threads. + wire = cq.Wire.makeHelix(pitch=pitch*n_starts, + height=t_length+pitch, + radius=inner_r) + wire = wire.translate((0,0,-pitch/2)) + wire = wire.rotate(startVector=(0,0,0), endVector=(0,0,1), + angleDegrees=360*(-pitch/2)/(pitch*n_starts)) + d_mid = ((metric_thread_major_radius(diameter,pitch) - outer_r) + * math.tan(__deg2rad(metric_thread_angle()))) + thread = cq.Workplane("XZ") + thread = thread.moveTo(inner_r_adj, -pitch/2 + z_off - inner_z_budge) + thread = thread.lineTo(outer_r, -(z_off + d_mid)) + thread = thread.lineTo(outer_r, z_off + d_mid) + thread = thread.lineTo(inner_r_adj, pitch/2 - z_off + inner_z_budge) + thread = thread.close() + thread = thread.sweep(wire, isFrenet=True) + threads = thread + for addl_start in range(1, n_starts): + # TODO: Incremental/cumulative rotation may not be as accurate as + # keeping 'thread' intact and rotating it by correct amount + # on each iteration. However, changing the code in that + # regard may disrupt the delicate nature of workarounds + # with repsect to quirks in the underlying B-rep library. + thread = thread.rotate(axisStartPoint=(0,0,0), + axisEndPoint=(0,0,1), + angleDegrees=360/n_starts) + threads = threads.union(thread) + + square_shave = cq.Workplane("XY") + square_shave = square_shave.box(length=outer_r*3, width=outer_r*3, + height=pitch*2, centered=True) + square_shave = square_shave.translate((0,0,-pitch)) # Because centered. + # Always cut the top and bottom square. Otherwise things don't play nice. + threads = threads.cut(square_shave) + + if bottom_lead_in: + delta_r = outer_r - inner_r + rise = math.tan(__deg2rad(metric_thread_angle())) * delta_r + lead_in = cq.Workplane("XZ") + lead_in = lead_in.moveTo(inner_r - delta_r, -rise) + lead_in = lead_in.lineTo(outer_r + delta_r, 2 * rise) + lead_in = lead_in.lineTo(outer_r + delta_r, -pitch - rise) + lead_in = lead_in.lineTo(inner_r - delta_r, -pitch - rise) + lead_in = lead_in.close() + lead_in = lead_in.revolve() + threads = threads.cut(lead_in) + + # This was originally a workaround to the anomalous B-rep computation where + # the top of base cylinder is flush with top of threads, without the use of + # lead-in. It turns out that preferring the use of the 'render_cyl_early' + # strategy alleviates other problems as well. + render_cyl_early = (base_cylinder and ((not top_relief) and + (not (cyl_extend_top > 0.0)) and + (not envelope))) + render_cyl_late = (base_cylinder and (not render_cyl_early)) + if render_cyl_early: + cyl = cq.Workplane("XY") + cyl = cyl.circle(radius=inner_r) + cyl = cyl.extrude(until=length+pitch+cyl_extend_bottom) + # Make rotation of cylinder consistent with non-workaround case. + cyl = cyl.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=-(360*t_start/(pitch*n_starts))) + cyl = cyl.translate((0,0,-t_start+(z_start-cyl_extend_bottom))) + threads = threads.union(cyl) + + # Next, make cuts at the top. + square_shave = square_shave.translate((0,0,pitch*2+t_length)) + threads = threads.cut(square_shave) + + if top_lead_in: + delta_r = outer_r - inner_r + rise = math.tan(__deg2rad(metric_thread_angle())) * delta_r + lead_in = cq.Workplane("XZ") + lead_in = lead_in.moveTo(inner_r - delta_r, t_length + rise) + lead_in = lead_in.lineTo(outer_r + delta_r, t_length - (2 * rise)) + lead_in = lead_in.lineTo(outer_r + delta_r, t_length + pitch + rise) + lead_in = lead_in.lineTo(inner_r - delta_r, t_length + pitch + rise) + lead_in = lead_in.close() + lead_in = lead_in.revolve() + threads = threads.cut(lead_in) + + # Place the threads into position. + threads = threads.translate((0,0,t_start)) + if (not envelope): + threads = threads.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=360*t_start/(pitch*n_starts)) + + if render_cyl_late: + cyl = cq.Workplane("XY") + cyl = cyl.circle(radius=inner_r) + cyl = cyl.extrude(until=length+cyl_extend_bottom+cyl_extend_top) + cyl = cyl.translate((0,0,z_start-cyl_extend_bottom)) + threads = threads.union(cyl) + + return threads + + +############################################################################### +# Generates female threads without a base tube, unless 'base_tube_od' is set to +# something which is sufficiently greater than 'diameter' parameter. Please +# note that 'use_epsilon' is activated by default, which causes a slight budge +# in the major radius, outwards, so that overlaps would be created with outer +# tubes. (Does not affect thread profile inside of tube or beyond extents.) +############################################################################### +def internal_metric_thread(diameter, # Required parameter, e.g. 3.0 for M3x0.5 + pitch, # Required parameter, e.g. 0.5 for M3x0.5 + length, # Required parameter, e.g. 2.0. + z_start=0.0, + n_starts=1, + bottom_chamfer=False, # Chamfer is at same angle as + top_chamfer =False, # thread, namely 30 degrees. + bottom_relief=False, # Add relief groove to start or + top_relief =False, # end of threads (shorten). + use_epsilon=True, # For outer cylinder overlap. + # The base tube outer diameter must be sufficiently + # large for tube to be rendered. Otherwise ignored. + base_tube_od=-1.0, + tube_extend_bottom=-1.0, + tube_extend_top=-1.0, + envelope=False): # Draw only envelope, don't cut. + + tube_extend_bottom = max(0.0, tube_extend_bottom) + tube_extend_top = max(0.0, tube_extend_top) + + z_off = (1.0 - __metric_thread_effective_ratio()) * pitch / 4 + t_start = z_start + t_length = length + if bottom_relief: + t_start = t_start + (2 * z_off) + t_length = t_length - (2 * z_off) + if top_relief: + t_length = t_length - (2 * z_off) + outer_r = metric_thread_major_radius(diameter,pitch, + internal=True) + inner_r = metric_thread_minor_radius(diameter,pitch, + internal=True) + epsilon = 0 + outer_r_adj = outer_r + outer_z_budge = 0 + if use_epsilon: + # High values of 'epsilon' sometimes cause entire starts to disappear. + epsilon = (z_off/5) / math.tan(__deg2rad(metric_thread_angle())) + outer_r_adj = outer_r + epsilon + outer_z_budge = math.tan(__deg2rad(metric_thread_angle())) * epsilon + + if envelope: + threads = cq.Workplane("XZ") + threads = threads.moveTo(outer_r_adj, -pitch) + threads = threads.lineTo(inner_r, -pitch) + threads = threads.lineTo(inner_r, t_length + pitch) + threads = threads.lineTo(outer_r_adj, t_length + pitch) + threads = threads.close() + threads = threads.revolve() + + else: # Not envelope, cut the threads. + wire = cq.Wire.makeHelix(pitch=pitch*n_starts, + height=t_length+pitch, + radius=inner_r) + wire = wire.translate((0,0,-pitch/2)) + wire = wire.rotate(startVector=(0,0,0), endVector=(0,0,1), + angleDegrees=360*(-pitch/2)/(pitch*n_starts)) + thread = cq.Workplane("XZ") + thread = thread.moveTo(outer_r_adj, -pitch/2 + z_off - outer_z_budge) + thread = thread.lineTo(inner_r, -z_off) + thread = thread.lineTo(inner_r, z_off) + thread = thread.lineTo(outer_r_adj, pitch/2 - z_off + outer_z_budge) + thread = thread.close() + thread = thread.sweep(wire, isFrenet=True) + threads = thread + for addl_start in range(1, n_starts): + # TODO: Incremental/cumulative rotation may not be as accurate as + # keeping 'thread' intact and rotating it by correct amount + # on each iteration. However, changing the code in that + # regard may disrupt the delicate nature of workarounds + # with repsect to quirks in the underlying B-rep library. + thread = thread.rotate(axisStartPoint=(0,0,0), + axisEndPoint=(0,0,1), + angleDegrees=360/n_starts) + threads = threads.union(thread) + # Rotate so that the external threads would align. + threads = threads.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=180/n_starts) + + square_len = max(outer_r*3, base_tube_od*1.125) + square_shave = cq.Workplane("XY") + square_shave = square_shave.box(length=square_len, width=square_len, + height=pitch*2, centered=True) + square_shave = square_shave.translate((0,0,-pitch)) # Because centered. + # Always cut the top and bottom square. Otherwise things don't play nice. + threads = threads.cut(square_shave) + + if bottom_chamfer: + delta_r = outer_r - inner_r + rise = math.tan(__deg2rad(metric_thread_angle())) * delta_r + chamfer = cq.Workplane("XZ") + chamfer = chamfer.moveTo(inner_r - delta_r, 2 * rise) + chamfer = chamfer.lineTo(outer_r + delta_r, -rise) + chamfer = chamfer.lineTo(outer_r + delta_r, -pitch - rise) + chamfer = chamfer.lineTo(inner_r - delta_r, -pitch - rise) + chamfer = chamfer.close() + chamfer = chamfer.revolve() + threads = threads.cut(chamfer) + + # This was originally a workaround to the anomalous B-rep computation where + # the top of base tube is flush with top of threads w/o the use of chamfer. + # This is now being made consistent with the 'render_cyl_early' strategy in + # external_metric_thread() whereby we prefer the "render early" plan of + # action even in cases where a top chamfer or lead-in is used. + render_tube_early = ((base_tube_od > (outer_r * 2)) and + (not top_relief) and + (not (tube_extend_top > 0.0)) and + (not envelope)) + render_tube_late = ((base_tube_od > (outer_r * 2)) and + (not render_tube_early)) + if render_tube_early: + tube = cq.Workplane("XY") + tube = tube.circle(radius=base_tube_od/2) + tube = tube.circle(radius=outer_r) + tube = tube.extrude(until=length+pitch+tube_extend_bottom) + # Make rotation of cylinder consistent with non-workaround case. + tube = tube.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=-(360*t_start/(pitch*n_starts))) + tube = tube.translate((0,0,-t_start+(z_start-tube_extend_bottom))) + threads = threads.union(tube) + + # Next, make cuts at the top. + square_shave = square_shave.translate((0,0,pitch*2+t_length)) + threads = threads.cut(square_shave) + + if top_chamfer: + delta_r = outer_r - inner_r + rise = math.tan(__deg2rad(metric_thread_angle())) * delta_r + chamfer = cq.Workplane("XZ") + chamfer = chamfer.moveTo(inner_r - delta_r, t_length - (2 * rise)) + chamfer = chamfer.lineTo(outer_r + delta_r, t_length + rise) + chamfer = chamfer.lineTo(outer_r + delta_r, t_length + pitch + rise) + chamfer = chamfer.lineTo(inner_r - delta_r, t_length + pitch + rise) + chamfer = chamfer.close() + chamfer = chamfer.revolve() + threads = threads.cut(chamfer) + + # Place the threads into position. + threads = threads.translate((0,0,t_start)) + if (not envelope): + threads = threads.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=360*t_start/(pitch*n_starts)) + + if render_tube_late: + tube = cq.Workplane("XY") + tube = tube.circle(radius=base_tube_od/2) + tube = tube.circle(radius=outer_r) + tube = tube.extrude(until=length+tube_extend_bottom+tube_extend_top) + tube = tube.translate((0,0,z_start-tube_extend_bottom)) + threads = threads.union(tube) + + return threads diff --git a/nhf/parts/planar.py b/nhf/parts/planar.py new file mode 100644 index 0000000..9405943 --- /dev/null +++ b/nhf/parts/planar.py @@ -0,0 +1,36 @@ +""" +Operations on planar geometry (usually used for laser cutting parts) +""" +import math +from typing import Tuple +import cadquery as Cq + +def extrude_with_markers( + sketch: Cq.Sketch, + thickness: float, + tags: list[Tuple[str, Cq.Location]], + reverse: bool = False): + """ + Extrudes a sketch and place tags on the sketch for mating. + + Each tag is of the format `(name, loc)`, where the (must be 2d) location's + angle is specifies in degrees counterclockwise from +X. Two marks are + generated for each `name`, "{name}" for the location (with normal) and + "{name}_dir" for the directrix specified by the angle. + + This simulates a process of laser cutting and bonding (for wood and acrylic) + """ + result = ( + Cq.Workplane('XY') + .placeSketch(sketch) + .extrude(thickness) + ) + plane = result.faces("Z").workplane() + sign = -1 if reverse else 1 + for tag, p in tags: + (x, y), angle = p.to2d() + theta = sign * math.radians(angle) + direction = (math.cos(theta), math.sin(theta), 0) + plane.moveTo(x, sign * y).tagPlane(tag) + plane.moveTo(x, sign * y).tagPlane(f"{tag}_dir", direction) + return result diff --git a/nhf/parts/springs.py b/nhf/parts/springs.py new file mode 100644 index 0000000..0418122 --- /dev/null +++ b/nhf/parts/springs.py @@ -0,0 +1,79 @@ +import math +from typing import Optional +from dataclasses import dataclass +import cadquery as Cq +from nhf import Item, Role + +@dataclass(frozen=True) +class TorsionSpring(Item): + """ + A torsion spring with abridged geometry (since sweep is slow) + """ + # Outer radius + radius: float = 12.0 + height: float = 20.0 + thickness: float = 2.0 + + # Angle (in degrees) between the two legs at neutral position + angle_neutral: float = 90.0 + + tail_length: float = 25.0 + right_handed: bool = False + + torsion_rate: Optional[float] = None + + @property + def name(self) -> str: + return f"TorsionSpring-{int(self.radius)}-{int(self.height)}" + + @property + def radius_inner(self) -> float: + return self.radius - self.thickness + + def torque_at(self, theta: float) -> float: + return self.torsion_rate * theta + + def generate(self, deflection: float = 0) -> Cq.Workplane: + omega = self.angle_neutral + deflection + omega = -omega if self.right_handed else omega + base = ( + Cq.Workplane('XY') + .cylinder(height=self.height, radius=self.radius, + centered=(True, True, False)) + ) + base.faces(">Z").tag("top") + base.faces(" Cq.Solid: + """ + Makes a full sphere. The default function makes a hemisphere + """ + return Cq.Solid.makeSphere(r, angleDegrees1=-90) + +@dataclass(frozen=True) +class MassBall(Item): + """ + A ball with fixed mass + """ + radius: float = 0.2 + + @property + def name(self) -> str: + return f"MassBall {self.mass}" + def generate(self) -> Cq.Solid: + return makeSphere(self.radius) + + +class BuildScaffold(Model): + + def __init__(self): + super().__init__(name="scaffold") + + @target(name="obj1") + def o1(self): + return Cq.Solid.makeBox(10, 10, 10) + + def o2(self): + return Cq.Solid.makeCylinder(10, 20) + +class TestBuild(unittest.TestCase): + + def test_build_scaffold(self): + s = BuildScaffold() + names = ["obj1"] + self.assertEqual(s.target_names, names) + self.assertEqual(s.check_all(), len(names)) + +class TestChecks(unittest.TestCase): + + def intersect_test_case(self, offset): + assembly = ( + Cq.Assembly() + .add(Cq.Solid.makeBox(10, 10, 10), + name="c1", + loc=Cq.Location((0, 0, 0))) + .add(Cq.Solid.makeBox(10, 10, 10), + name="c2", + loc=Cq.Location((0, 0, offset))) + ) + coll = nhf.checks.pairwise_intersection(assembly) + if -10 < offset and offset < 10: + self.assertEqual(len(coll), 1) + else: + self.assertEqual(coll, []) + + def test_intersect(self): + for offset in [9, 10, 11, -10]: + with self.subTest(offset=offset): + self.intersect_test_case(offset) + +class TestGeometry(unittest.TestCase): + + def test_min_radius_contraction_span_pos(self): + sl = 50.0 + dc = 112.0 + do = dc + sl + theta = math.radians(60.0) + r, phi = nhf.geometry.min_radius_contraction_span_pos(do, dc, theta) + with self.subTest(state='open'): + x = r * math.cos(phi) + y = r * math.sin(phi) + d = math.sqrt((x - r) ** 2 + y ** 2) + self.assertAlmostEqual(d, do) + with self.subTest(state='closed'): + x = r * math.cos(phi - theta) + y = r * math.sin(phi - theta) + d = math.sqrt((x - r) ** 2 + y ** 2) + self.assertAlmostEqual(d, dc) + def test_min_tangent_contraction_span_pos(self): + sl = 50.0 + dc = 112.0 + do = dc + sl + theta = math.radians(60.0) + r, phi, rp = nhf.geometry.min_tangent_contraction_span_pos(do, dc, theta) + with self.subTest(state='open'): + x = r * math.cos(phi) + y = r * math.sin(phi) + d = math.sqrt((x - rp) ** 2 + y ** 2) + self.assertAlmostEqual(d, do) + with self.subTest(state='closed'): + x = r * math.cos(phi - theta) + y = r * math.sin(phi - theta) + d = math.sqrt((x - rp) ** 2 + y ** 2) + self.assertAlmostEqual(d, dc) + def test_contraction_span_pos_from_radius(self): + sl = 50.0 + dc = 112.0 + do = dc + sl + r = 70.0 + theta = math.radians(60.0) + for smaller in [False, True]: + with self.subTest(smaller=smaller): + r, phi, rp = nhf.geometry.contraction_span_pos_from_radius(do, dc, r=r, theta=theta, smaller=smaller) + with self.subTest(state='open'): + x = r * math.cos(phi) + y = r * math.sin(phi) + d = math.sqrt((x - rp) ** 2 + y ** 2) + self.assertAlmostEqual(d, do) + with self.subTest(state='closed'): + x = r * math.cos(phi - theta) + y = r * math.sin(phi - theta) + d = math.sqrt((x - rp) ** 2 + y ** 2) + self.assertAlmostEqual(d, dc) + def test_contraction_span_pos_from_radius_2(self): + sl = 40.0 + dc = 170.0 + do = dc + sl + r = 50.0 + theta = math.radians(120.0) + for smaller in [False, True]: + with self.subTest(smaller=smaller): + r, phi, rp = nhf.geometry.contraction_span_pos_from_radius(do, dc, r=r, theta=theta, smaller=smaller) + with self.subTest(state='open'): + x = r * math.cos(phi) + y = r * math.sin(phi) + d = math.sqrt((x - rp) ** 2 + y ** 2) + self.assertAlmostEqual(d, do) + with self.subTest(state='closed'): + x = r * math.cos(phi - theta) + y = r * math.sin(phi - theta) + d = math.sqrt((x - rp) ** 2 + y ** 2) + self.assertAlmostEqual(d, dc) + + +class TestUtils(unittest.TestCase): + + def test_2d_orientation(self): + l1 = Cq.Location.from2d(1.2, 0) + l2 = Cq.Location.from2d(0, 0, 90) + l3 = l2 * l1 + (x, y), r = l3.to2d() + self.assertAlmostEqual(x, 0) + self.assertAlmostEqual(y, 1.2) + self.assertAlmostEqual(r, 90) + + def test_2d_planar(self): + l1 = Cq.Location.from2d(1.2, 4.5, 67) + l2 = Cq.Location.from2d(98, 5.4, 36) + l3 = Cq.Location.from2d(10, 10, 0) + l = l3 * l2 * l1 + self.assertTrue(l.is2d()) + + def test_tag_point(self): + """ + A board with 3 holes of unequal sizes. Each hole is marked + """ + p4x, p4y = 5, 5 + p3x, p3y = 0, 0 + p2x, p2y = -5, 0 + board = ( + Cq.Workplane('XY') + .box(15, 15, 5) + .faces("Y").toTuple() + self.assertAlmostEqual(x, 0.5) + self.assertAlmostEqual(y, 1) + self.assertAlmostEqual(z, 1.5) + (rx, ry, rz), _ = assembly.get_abs_direction("b2@faces@>Y").toTuple() + self.assertAlmostEqual(rx, 0) + self.assertAlmostEqual(ry, 1) + self.assertAlmostEqual(rz, 0) + + def test_centre_of_mass(self): + assembly = ( + Cq.Assembly() + .add(MassBall(mass=3).assembly(), name="s1", loc=Cq.Location((0, 0, 0))) + .add(MassBall(mass=7).assembly(), name="s2", loc=Cq.Location((0, 0, 10))) + ) + com = assembly.centre_of_mass() + self.assertAlmostEqual(com.x, 0) + self.assertAlmostEqual(com.y, 0) + self.assertAlmostEqual(com.z, 7) + +if __name__ == '__main__': + unittest.main() diff --git a/nhf/utils.py b/nhf/utils.py new file mode 100644 index 0000000..13469fd --- /dev/null +++ b/nhf/utils.py @@ -0,0 +1,282 @@ +""" +Utility functions for cadquery objects +""" +import functools +import math +from typing import Optional +import cadquery as Cq +from cadquery.occ_impl.solver import ConstraintSpec +from nhf import Role +from typing import Union, Tuple, cast +from nhf.materials import KEY_ITEM, KEY_MATERIAL + +# Bug fixes +def _subloc(self, name: str) -> Tuple[Cq.Location, str]: + """ + Calculate relative location of an object in a subassembly. + + Returns the relative positions as well as the name of the top assembly. + """ + + rv = Cq.Location() + obj = self.objects[name] + name_out = name + + if obj not in self.children and obj is not self: + locs = [] + while not obj.parent is self: + locs.append(obj.loc) + obj = cast(Cq.Assembly, obj.parent) + name_out = obj.name + + rv = functools.reduce(lambda l1, l2: l2 * l1, locs) + + return (rv, name_out) +Cq.Assembly._subloc = _subloc + +### Vector arithmetic + +def location_sub(self: Cq.Location, rhs: Cq.Location) -> Cq.Vector: + (x1, y1, z1), _ = self.toTuple() + (x2, y2, z2), _ = rhs.toTuple() + return Cq.Vector(x1 - x2, y1 - y2, z1 - z2) +Cq.Location.__sub__ = location_sub + +def from2d(x: float, y: float, rotate: float=0.0) -> Cq.Location: + return Cq.Location((x, y, 0), (0, 0, 1), rotate) +Cq.Location.from2d = from2d + +def rot2d(angle: float) -> Cq.Location: + return Cq.Location((0, 0, 0), (0, 0, 1), angle) +Cq.Location.rot2d = rot2d + +def is2d(self: Cq.Location) -> bool: + (_, _, z), (rx, ry, _) = self.toTuple() + return z == 0 and rx == 0 and ry == 0 +Cq.Location.is2d = is2d + +def to2d(self: Cq.Location) -> Tuple[Tuple[float, float], float]: + """ + Returns position and angle + """ + (x, y, z), (rx, ry, rz) = self.toTuple() + assert z == 0 + assert rx == 0 + assert ry == 0 + return (x, y), rz +Cq.Location.to2d = to2d + +def to2d_pos(self: Cq.Location) -> Tuple[float, float]: + """ + Returns position and angle + """ + (x, y), _ = self.to2d() + return x, y +Cq.Location.to2d_pos = to2d_pos + +def to2d_rot(self: Cq.Location) -> float: + """ + Returns position and angle + """ + _, r = self.to2d() + return r +Cq.Location.to2d_rot = to2d_rot + + +def with_angle_2d(self: Cq.Location, angle: float) -> Tuple[float, float]: + """ + Returns position and angle + """ + x, y = self.to2d_pos() + return Cq.Location.from2d(x, y, angle) +Cq.Location.with_angle_2d = with_angle_2d + +def flip_x(self: Cq.Location) -> Cq.Location: + (x, y), a = self.to2d() + return Cq.Location.from2d(-x, y, 90 - a) +Cq.Location.flip_x = flip_x +def flip_y(self: Cq.Location) -> Cq.Location: + (x, y), a = self.to2d() + return Cq.Location.from2d(x, -y, -a) +Cq.Location.flip_y = flip_y + +def boolean(self: Cq.Sketch, obj, **kwargs) -> Cq.Sketch: + return ( + self + .reset() + .push([(0, 0)]) + .each(lambda _: obj, **kwargs) + ) +Cq.Sketch.boolean = boolean + +### Tags + +def tagPoint(self, tag: str): + """ + Adds a vertex that can be used in `Point` constraints. + """ + vertex = Cq.Vertex.makeVertex(0, 0, 0) + self.eachpoint(vertex.moved, useLocalCoordinates=True).tag(tag) + +Cq.Workplane.tagPoint = tagPoint + +def tagPlane(self, tag: str, + direction: Union[str, Cq.Vector, Tuple[float, float, float]] = '+Z'): + """ + Adds a phantom `Cq.Edge` in the given location which can be referenced in a + `Axis`, `Point`, or `Plane` constraint. + """ + if isinstance(direction, str): + x, y, z = 0, 0, 0 + assert len(direction) == 2 + sign, axis = direction + if axis in ('z', 'Z'): + z = 1 + elif axis in ('y', 'Y'): + y = 1 + elif axis in ('x', 'X'): + x = 1 + else: + assert False, "Axis must be one of x,y,z" + if sign == '+': + sign = 1 + elif sign == '-': + sign = -1 + else: + assert False, "Sign must be one of +/-" + v = Cq.Vector(x, y, z) * sign + else: + v = Cq.Vector(direction) + edge = Cq.Edge.makeLine(v * (-1), v) + return self.eachpoint(edge.located, useLocalCoordinates=True).tag(tag) + +Cq.Workplane.tagPlane = tagPlane + +def make_sphere(r: float = 2) -> Cq.Solid: + """ + Makes a full sphere. The default function makes a hemisphere + """ + return Cq.Solid.makeSphere(r, angleDegrees1=-90) +def make_arrow(size: float = 2) -> Cq.Workplane: + cone = Cq.Solid.makeCone( + radius1 = size, + radius2 = 0, + height=size) + result = ( + Cq.Workplane("XY") + .cylinder(radius=size / 2, height=size, centered=(True, True, False)) + .union(cone.located(Cq.Location((0, 0, size)))) + ) + result.faces(" str: + return tag.replace("?", "__T").replace("/", "__Z") + "_marker" + +COLOR_MARKER = Cq.Color(0, 1, 1, 1) + +def mark_point(self: Cq.Assembly, + tag: str, + size: float = 2, + color: Cq.Color = COLOR_MARKER) -> Cq.Assembly: + """ + Adds a marker to make a point visible + """ + name = to_marker_name(tag) + return ( + self + .add(make_sphere(size), name=name, color=color) + .constrain(tag, name, "Point") + ) + +Cq.Assembly.markPoint = mark_point + +def mark_plane(self: Cq.Assembly, + tag: str, + size: float = 2, + color: Cq.Color = COLOR_MARKER) -> Cq.Assembly: + """ + Adds a marker to make a plane visible + """ + name = to_marker_name(tag) + return ( + self + .add(make_arrow(size), name=name, color=color) + .constrain(tag, f"{name}?dir_rev", "Plane", param=180) + ) + +Cq.Assembly.markPlane = mark_plane + +def get_abs_location(self: Cq.Assembly, + tag: str) -> Cq.Location: + """ + Gets the location of a tag + + BUG: Currently bugged. See `nhf/test.py` for example + """ + name, shape = self._query(tag) + loc_self = Cq.Location(shape.Center()) + loc_parent, _ = self._subloc(name) + loc = loc_parent * loc_self + return loc + +Cq.Assembly.get_abs_location = get_abs_location + +def get_abs_direction(self: Cq.Assembly, + tag: str) -> Cq.Location: + """ + Gets the location of a tag + """ + name, shape = self._query(tag) + # Must match `cadquery.occ_impl.solver.ConstraintSpec._getAxis` + if isinstance(shape, Cq.Face): + vec_dir = shape.normalAt() + elif isinstance(shape, Cq.Edge) and shape.geomType() != "CIRCLE": + vec_dir = shape.tangentAt() + elif isinstance(shape, Cq.Edge) and shape.geomType() == "CIRCLE": + vec_dir = shape.normal() + else: + raise ValueError(f"Cannot construct Axis for {shape}") + loc_self = Cq.Location(vec_dir) + loc_parent, _ = self._subloc(name) + loc = loc_parent * loc_self + return loc +Cq.Assembly.get_abs_direction = get_abs_direction + + +# Tallying functions + +def assembly_this_mass(self: Cq.Assembly) -> Optional[float]: + """ + Gets the mass of an assembly, without considering its components. + """ + if item := self.metadata.get(KEY_ITEM): + return item.mass + elif material := self.metadata.get(KEY_MATERIAL): + vol = self.toCompound().Volume() + return (vol / 1000) * material.density + else: + return None + +def total_mass(self: Cq.Assembly) -> float: + """ + Calculates the total mass in units of g + """ + total = 0.0 + for _, a in self.traverse(): + if m := assembly_this_mass(a): + total += m + return total +Cq.Assembly.total_mass = total_mass + +def centre_of_mass(self: Cq.Assembly) -> Optional[float]: + moment = Cq.Vector() + total = 0.0 + for n, a in self.traverse(): + if m := assembly_this_mass(a): + moment += m * a.toCompound().Center() + total += m + if total == 0.0: + return None + return moment / total +Cq.Assembly.centre_of_mass = centre_of_mass