Merge pull request 'feat: Geometry utils' (#5) from lib/geometry into main
Reviewed-on: #5
This commit is contained in:
commit
7f48c75975
|
@ -1 +1,2 @@
|
|||
from nhf.materials import Role, Material
|
||||
from nhf.materials import Material, Role
|
||||
from nhf.parts.item import Item
|
||||
|
|
|
@ -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"<target {self.name}.{self.kind.ext} {self._method}>"
|
||||
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"<assembly {self.name} {self._method}>"
|
||||
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"<submodel {self.name} {self._method}>"
|
||||
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))
|
|
@ -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
|
|
@ -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
|
|
@ -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_
|
|
@ -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()
|
|
@ -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,
|
||||
)
|
||||
)
|
|
@ -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("tip")
|
||||
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("tip")
|
||||
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("bot")
|
||||
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("bot")
|
||||
result.faces(">Z").tag("top")
|
||||
return result
|
|
@ -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
|
|
@ -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("mate1")
|
||||
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"), ("mate2", ">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").tag("base")
|
||||
result = (
|
||||
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("<Z").tag("base")
|
||||
if not self.simplify_geometry:
|
||||
thread = self.mount.external_thread(length=length).val()
|
||||
result = (
|
||||
result
|
||||
.union(thread)
|
||||
)
|
||||
return result
|
||||
|
||||
def connector_insertion_assembly(self):
|
||||
connector_color = Cq.Color(0.8,0.8,0.5,0.3)
|
||||
insertion_color = Cq.Color(0.7,0.7,0.7,0.3)
|
||||
result = (
|
||||
Cq.Assembly()
|
||||
.add(self.connector(), name="c", color=connector_color)
|
||||
.add(self.insertion(), name="i1", color=insertion_color)
|
||||
.add(self.insertion(), name="i2", color=insertion_color)
|
||||
.constrain("c?mate1", "i1?mate", "Plane")
|
||||
.constrain("c?mate2", "i2?mate", "Plane")
|
||||
.constrain("c?dir", "i1?dir", "Axis")
|
||||
.constrain("c?dir", "i2?dir", "Axis")
|
||||
.solve()
|
||||
)
|
||||
return result
|
||||
def connector_one_side_insertion_assembly(self):
|
||||
connector_color = Cq.Color(0.8,0.8,0.5,0.3)
|
||||
insertion_color = Cq.Color(0.7,0.7,0.7,0.3)
|
||||
result = (
|
||||
Cq.Assembly()
|
||||
.add(self.insertion(), name="i", color=connector_color)
|
||||
.add(self.one_side_connector(), name="c", color=insertion_color)
|
||||
.constrain("i?mate", "c?mate", "Plane")
|
||||
.constrain("c?dir", "i?dir", "Axis")
|
||||
.solve()
|
||||
)
|
||||
return result
|
|
@ -0,0 +1,67 @@
|
|||
from typing import Union, Optional
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
import cadquery as Cq
|
||||
from nhf.materials import Role, KEY_ROLE, KEY_ITEM
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Item:
|
||||
"""
|
||||
A pre-fabricated item
|
||||
"""
|
||||
mass: float
|
||||
|
||||
#@property
|
||||
#def mass(self) -> 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
|
|
@ -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("bot")
|
||||
# Insert directrix
|
||||
result.polyline(self._directrix(self.track_disk_height),
|
||||
forConstruction=True).tag("dir")
|
||||
return result
|
||||
|
||||
def rider(self, rider_slot_begin=None, reverse_directrix_label=False):
|
||||
if not rider_slot_begin:
|
||||
rider_slot_begin = self.rider_slot_begin
|
||||
def slot(loc):
|
||||
wire = Cq.Wire.makePolygon(self._slot_polygon(flip=False))
|
||||
face = Cq.Face.makeFromWires(wire)
|
||||
return face.located(loc)
|
||||
wall_profile = (
|
||||
Cq.Sketch()
|
||||
.circle(self.radius_rider, mode='a')
|
||||
.circle(self.spring.radius, mode='s')
|
||||
.parray(
|
||||
r=0,
|
||||
a1=rider_slot_begin,
|
||||
da=self.rider_slot_span,
|
||||
n=self.rider_n_slots)
|
||||
.each(slot, mode='s')
|
||||
#.circle(self._radius_wall, mode='a')
|
||||
)
|
||||
contact_profile = (
|
||||
Cq.Sketch()
|
||||
.circle(self.groove_radius_outer, mode='a')
|
||||
.circle(self.groove_radius_inner + self.groove_inner_gap, mode='s')
|
||||
)
|
||||
if not self.spring_hole_cover_rider:
|
||||
contact_profile = (
|
||||
contact_profile
|
||||
.parray(
|
||||
r=0,
|
||||
a1=rider_slot_begin,
|
||||
da=self.rider_slot_span,
|
||||
n=self.rider_n_slots)
|
||||
.each(slot, mode='s')
|
||||
.reset()
|
||||
)
|
||||
#.circle(self._radius_wall, mode='a')
|
||||
middle_height = self.spring.height - self.groove_depth - self.rider_gap - self.spring.thickness
|
||||
result = (
|
||||
Cq.Workplane('XY')
|
||||
.cylinder(
|
||||
radius=self.radius_rider,
|
||||
height=self.rider_disk_height,
|
||||
centered=(True, True, False))
|
||||
.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("<Z")
|
||||
.workplane()
|
||||
.hole(self.radius_axle * 2)
|
||||
)
|
||||
theta_begin = -math.radians(rider_slot_begin)
|
||||
theta_span = math.radians(self.rider_slot_span)
|
||||
if self.rider_n_slots <= 1:
|
||||
theta_step = 0
|
||||
elif abs(math.remainder(self.rider_slot_span, 360)) < TOL:
|
||||
theta_step = theta_span / self.rider_n_slots
|
||||
else:
|
||||
theta_step = theta_span / (self.rider_n_slots - 1)
|
||||
for i in range(self.rider_n_slots):
|
||||
theta = theta_begin - i * theta_step
|
||||
j = self.rider_n_slots - i - 1 if reverse_directrix_label else i
|
||||
result.polyline(self._directrix(self.rider_disk_height, theta),
|
||||
forConstruction=True).tag(f"dir{j}")
|
||||
return result
|
||||
|
||||
def rider_track_assembly(self, directrix: int = 0, deflection: float = 0):
|
||||
rider = self.rider()
|
||||
track = self.track()
|
||||
spring = self.spring.assembly(deflection=deflection)
|
||||
result = (
|
||||
Cq.Assembly()
|
||||
.addS(spring, name="spring", role=Role.DAMPING)
|
||||
.addS(track, name="track", role=Role.PARENT)
|
||||
.addS(rider, name="rider", role=Role.CHILD)
|
||||
)
|
||||
TorsionJoint.add_constraints(
|
||||
result,
|
||||
rider="rider", track="track", spring="spring",
|
||||
directrix=directrix)
|
||||
return result.solve()
|
||||
|
||||
@staticmethod
|
||||
def add_constraints(assembly: Cq.Assembly,
|
||||
spring: str,
|
||||
rider: Optional[str] = None,
|
||||
track: Optional[str] = None,
|
||||
directrix: int = 0):
|
||||
"""
|
||||
Add the necessary constraints to a RT assembly
|
||||
"""
|
||||
if track:
|
||||
(
|
||||
assembly
|
||||
.constrain(f"{track}?spring", f"{spring}?top", "Plane")
|
||||
.constrain(f"{track}?dir", f"{spring}?dir_top",
|
||||
"Axis", param=0)
|
||||
)
|
||||
if rider:
|
||||
(
|
||||
assembly
|
||||
.constrain(f"{rider}?spring", f"{spring}?bot", "Plane")
|
||||
.constrain(f"{rider}?dir{directrix}", f"{spring}?dir_bot",
|
||||
"Axis", param=0)
|
||||
)
|
|
@ -0,0 +1,422 @@
|
|||
# Copyright (c) 2020-2024, Nerius Anthony Landys. All rights reserved.
|
||||
# neri-engineering 'at' protonmail.com
|
||||
# https://svn.code.sf.net/p/nl10/code/cq-code/common/metric_threads.py
|
||||
# This file is public domain. Use it for any purpose, including commercial
|
||||
# applications. Attribution would be nice, but is not required. There is no
|
||||
# warranty of any kind, including its correctness, usefulness, or safety.
|
||||
#
|
||||
# Simple code example to create meshing M3x0.5 threads:
|
||||
###############################################################################
|
||||
#
|
||||
# male = external_metric_thread(3.0, 0.5, 4.0, z_start= -0.85,
|
||||
# top_lead_in=True)
|
||||
#
|
||||
# # Please note that the female thread is meant for a hole which has
|
||||
# # radius equal to metric_thread_major_radius(3.0, 0.5, internal=True),
|
||||
# # which is in fact very slightly larger than a 3.0 diameter hole.
|
||||
#
|
||||
# female = internal_metric_thread(3.0, 0.5, 1.5,
|
||||
# bottom_chamfer=True, base_tube_od= 4.5)
|
||||
#
|
||||
###############################################################################
|
||||
# Left hand threads can be created by employing one of the "mirror" operations.
|
||||
# Thanks for taking the time to understand and use this code!
|
||||
|
||||
import math
|
||||
import cadquery as cq
|
||||
|
||||
###############################################################################
|
||||
# The functions which have names preceded by '__' are not meant to be called
|
||||
# externally; the remaining functions are written with the intention that they
|
||||
# will be called by external code. The first section of code consists of
|
||||
# lightweight helper functions; the meat and potatoes of this library is last.
|
||||
###############################################################################
|
||||
|
||||
# Return value is in degrees, and currently it's fixed at 30. Essentially this
|
||||
# results in a typical 60 degree equilateral triangle cutting bit for threads.
|
||||
def metric_thread_angle():
|
||||
return 30
|
||||
|
||||
# Helper func. to make code more intuitive and succinct. Degrees --> 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
|
|
@ -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" if reverse else ">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
|
|
@ -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("<Z").tag("bot")
|
||||
|
||||
tail = Cq.Solid.makeCylinder(
|
||||
height=self.tail_length,
|
||||
radius=self.thickness / 2)
|
||||
# points cylinder to +X
|
||||
dy = self.radius - self.thickness / 2
|
||||
if self.right_handed:
|
||||
dy = -dy
|
||||
loc_dir_x = Cq.Location((0, 0, self.thickness / 2), (0, 1, 0), 90)
|
||||
loc_shift = Cq.Location((0, dy, 0))
|
||||
loc_top = Cq.Location((0, 0, self.height - self.thickness), (0, 0, 1), omega + 180)
|
||||
result = (
|
||||
base
|
||||
.cylinder(
|
||||
height=self.height,
|
||||
radius=self.radius - self.thickness,
|
||||
combine='s',
|
||||
centered=(True, True, True))
|
||||
.union(tail.located(loc_shift * loc_dir_x))
|
||||
.union(tail.located(loc_top * loc_shift.inverse * loc_dir_x))
|
||||
.clean()
|
||||
)
|
||||
r = -self.radius if self.right_handed else self.radius
|
||||
plane = result.copyWorkplane(Cq.Workplane('XY'))
|
||||
plane.polyline([(0, r, 0), (self.tail_length, r, 0)],
|
||||
forConstruction=True).tag("dir_bot")
|
||||
omega = math.radians(omega)
|
||||
c, s = math.cos(omega), math.sin(omega)
|
||||
l = -self.tail_length
|
||||
plane.polyline([
|
||||
(-s * r, c * r, self.height),
|
||||
(c * l - s * r, c * r + s * l, self.height)],
|
||||
forConstruction=True).tag("dir_top")
|
||||
return result
|
|
@ -0,0 +1,125 @@
|
|||
import unittest
|
||||
import cadquery as Cq
|
||||
from nhf.checks import binary_intersection, pairwise_intersection
|
||||
from nhf.parts import joints, handle, metric_threads, springs
|
||||
import nhf.parts.fasteners as fasteners
|
||||
|
||||
class TestFasteners(unittest.TestCase):
|
||||
|
||||
def test_hex_nut(self):
|
||||
width = 18.9
|
||||
height = 9.8
|
||||
item = fasteners.HexNut(
|
||||
mass=float('nan'),
|
||||
diam_thread=12.0,
|
||||
pitch=1.75,
|
||||
thickness=9.8,
|
||||
width=width,
|
||||
)
|
||||
obj = item.generate()
|
||||
self.assertEqual(len(obj.vals()), 1)
|
||||
bbox = obj.val().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.xlen, width)
|
||||
self.assertAlmostEqual(bbox.zlen, height)
|
||||
|
||||
class TestJoints(unittest.TestCase):
|
||||
|
||||
def test_joint_hirth(self):
|
||||
j = joints.HirthJoint()
|
||||
obj = j.generate()
|
||||
self.assertIsInstance(
|
||||
obj.val().solids(), Cq.Solid,
|
||||
msg="Hirth joint must be in one piece")
|
||||
|
||||
def test_joints_hirth_assembly(self):
|
||||
for n_tooth in [16, 20, 24]:
|
||||
with self.subTest(n_tooth=n_tooth):
|
||||
j = joints.HirthJoint()
|
||||
assembly = j.assembly()
|
||||
isect = binary_intersection(assembly)
|
||||
self.assertLess(isect.Volume(), 1e-6,
|
||||
"Hirth joint assembly must not have intersection")
|
||||
|
||||
def torsion_joint_case(self, joint: joints.TorsionJoint, slot: int):
|
||||
assert 0 <= slot and slot < joint.rider_n_slots
|
||||
assembly = joint.rider_track_assembly(slot)
|
||||
bbox = assembly.toCompound().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.zlen, joint.total_height)
|
||||
self.assertAlmostEqual(bbox.xlen, joint.radius * 2)
|
||||
self.assertAlmostEqual(bbox.ylen, joint.radius * 2)
|
||||
self.assertEqual(pairwise_intersection(assembly), [])
|
||||
|
||||
def test_torsion_joint(self):
|
||||
j = joints.TorsionJoint()
|
||||
for slot in range(j.rider_n_slots):
|
||||
with self.subTest(slot=slot, right_handed=False):
|
||||
self.torsion_joint_case(j, slot)
|
||||
def test_torsion_joint_right_handed(self):
|
||||
j = joints.TorsionJoint(springs.TorsionSpring(mass=float('nan'), right_handed=True))
|
||||
for slot in range(j.rider_n_slots):
|
||||
with self.subTest(slot=slot, right_handed=True):
|
||||
self.torsion_joint_case(j, slot)
|
||||
def test_torsion_joint_covered(self):
|
||||
j = joints.TorsionJoint(
|
||||
spring_hole_cover_track=True,
|
||||
spring_hole_cover_rider=True,
|
||||
)
|
||||
self.torsion_joint_case(j, 1)
|
||||
def test_torsion_joint_slot(self):
|
||||
j = joints.TorsionJoint(
|
||||
rider_slot_begin=90,
|
||||
)
|
||||
self.torsion_joint_case(j, 1)
|
||||
|
||||
|
||||
|
||||
class TestHandle(unittest.TestCase):
|
||||
|
||||
def test_threaded_collision(self):
|
||||
h = handle.Handle(mount=handle.ThreadedMount())
|
||||
assembly = h.connector_insertion_assembly()
|
||||
self.assertEqual(pairwise_intersection(assembly), [])
|
||||
def test_threaded_assembly(self):
|
||||
h = handle.Handle(mount=handle.ThreadedMount())
|
||||
assembly = h.connector_insertion_assembly()
|
||||
bbox = assembly.toCompound().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.xlen, h.diam)
|
||||
self.assertAlmostEqual(bbox.ylen, h.diam)
|
||||
def test_threaded_one_sided_insertion(self):
|
||||
h = handle.Handle(mount=handle.ThreadedMount())
|
||||
assembly = h.connector_one_side_insertion_assembly()
|
||||
bbox = assembly.toCompound().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.xlen, h.diam)
|
||||
self.assertAlmostEqual(bbox.ylen, h.diam)
|
||||
self.assertEqual(pairwise_intersection(assembly), [])
|
||||
def test_bayonet_collision(self):
|
||||
h = handle.Handle(mount=handle.BayonetMount())
|
||||
assembly = h.connector_insertion_assembly()
|
||||
self.assertEqual(pairwise_intersection(assembly), [])
|
||||
def test_bayonet_assembly(self):
|
||||
h = handle.Handle(mount=handle.BayonetMount())
|
||||
assembly = h.connector_insertion_assembly()
|
||||
bbox = assembly.toCompound().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.xlen, h.diam)
|
||||
self.assertAlmostEqual(bbox.ylen, h.diam)
|
||||
|
||||
def test_bayonet_one_sided_insertion(self):
|
||||
h = handle.Handle(mount=handle.BayonetMount())
|
||||
assembly = h.connector_one_side_insertion_assembly()
|
||||
bbox = assembly.toCompound().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.xlen, h.diam)
|
||||
self.assertAlmostEqual(bbox.ylen, h.diam)
|
||||
self.assertEqual(pairwise_intersection(assembly), [])
|
||||
|
||||
class TestMetricThreads(unittest.TestCase):
|
||||
|
||||
def test_major_radius(self):
|
||||
major = 3.0
|
||||
t = metric_threads.external_metric_thread(major, 0.5, 4.0, z_start=-0.85, top_lead_in=True)
|
||||
bbox = t.val().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.xlen, major, places=3)
|
||||
self.assertAlmostEqual(bbox.ylen, major, places=3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -0,0 +1,288 @@
|
|||
"""
|
||||
Unit tests for tooling
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import unittest
|
||||
import cadquery as Cq
|
||||
from nhf.build import Model, target
|
||||
from nhf.parts.item import Item
|
||||
import nhf.checks
|
||||
import nhf.geometry
|
||||
import nhf.utils
|
||||
|
||||
# Color presets for testing purposes
|
||||
color_parent = Cq.Color(0.7, 0.7, 0.5, 0.5)
|
||||
color_child = Cq.Color(0.5, 0.7, 0.7, 0.5)
|
||||
|
||||
def makeSphere(r: float) -> 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("<Z")
|
||||
.workplane()
|
||||
.pushPoints([(p4x, p4y)])
|
||||
.hole(4, depth=2)
|
||||
.pushPoints([(p3x, p3y)])
|
||||
.hole(3, depth=1.5)
|
||||
.pushPoints([(p2x, p2y)])
|
||||
.hole(2, depth=1)
|
||||
)
|
||||
board.moveTo(p4x, p4y).tagPoint("h4")
|
||||
board.moveTo(p3x, p3y).tagPoint("h3")
|
||||
board.moveTo(p2x, p2y).tagPoint("h2")
|
||||
|
||||
assembly = (
|
||||
Cq.Assembly()
|
||||
.add(board, name="board", color=color_parent)
|
||||
.add(makeSphere(2), name="s4", color=color_child)
|
||||
.add(makeSphere(1.5), name="s3", color=color_child)
|
||||
.add(makeSphere(1), name="s2", color=color_child)
|
||||
.constrain("board?h4", "s4", "Point")
|
||||
.constrain("board?h3", "s3", "Point")
|
||||
.constrain("board?h2", "s2", "Point")
|
||||
.solve()
|
||||
)
|
||||
self.assertEqual(nhf.checks.pairwise_intersection(assembly), [])
|
||||
bbox = assembly.toCompound().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.xlen, 15)
|
||||
self.assertAlmostEqual(bbox.ylen, 15)
|
||||
self.assertAlmostEqual(bbox.zlen, 7)
|
||||
|
||||
def test_tag_plane(self):
|
||||
p4x, p4y = 5, 5
|
||||
p3x, p3y = 0, 0
|
||||
p2x, p2y = -5, 0
|
||||
board = (
|
||||
Cq.Workplane('XY')
|
||||
.box(15, 15, 5)
|
||||
.faces("<Z")
|
||||
.workplane()
|
||||
.pushPoints([(p4x, p4y)])
|
||||
.hole(4, depth=2)
|
||||
.pushPoints([(p3x, p3y)])
|
||||
.hole(3, depth=1.5)
|
||||
.pushPoints([(p2x, p2y)])
|
||||
.hole(2, depth=1)
|
||||
)
|
||||
board.moveTo(p4x, p4y).tagPlane("h4")
|
||||
board.moveTo(p3x, p3y).tagPlane("h3")
|
||||
board.moveTo(p2x, p2y).tagPlane("h2")
|
||||
|
||||
def markedCylOf(r):
|
||||
cyl = (
|
||||
Cq.Workplane('XY')
|
||||
.cylinder(radius=r, height=r)
|
||||
)
|
||||
cyl.faces("<Z").tag("mate")
|
||||
return cyl
|
||||
|
||||
assembly = (
|
||||
Cq.Assembly()
|
||||
.add(board, name="board", color=color_parent)
|
||||
.add(markedCylOf(2), name="c4", color=color_child)
|
||||
.add(markedCylOf(1.5), name="c3", color=color_child)
|
||||
.add(markedCylOf(1), name="c2", color=color_child)
|
||||
.constrain("board?h4", "c4?mate", "Plane", param=0)
|
||||
.constrain("board?h3", "c3?mate", "Plane", param=0)
|
||||
.constrain("board?h2", "c2?mate", "Plane", param=0)
|
||||
.solve()
|
||||
)
|
||||
self.assertEqual(nhf.checks.pairwise_intersection(assembly), [])
|
||||
bbox = assembly.toCompound().BoundingBox()
|
||||
self.assertAlmostEqual(bbox.xlen, 15)
|
||||
self.assertAlmostEqual(bbox.ylen, 15)
|
||||
self.assertAlmostEqual(bbox.zlen, 5)
|
||||
|
||||
# FIXME: Absolute location
|
||||
#def test_abs_location(self):
|
||||
# box = Cq.Solid.makeBox(1, 1, 1)
|
||||
# assembly = (
|
||||
# Cq.Assembly()
|
||||
# .add(box, name="b1")
|
||||
# .add(box, name="b2", loc=Cq.Location((0,0,1)))
|
||||
# .add(box, name="b3", loc=Cq.Location((0,0,2)))
|
||||
# )
|
||||
# (x, y, z), _ = assembly.get_abs_location("b2@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()
|
|
@ -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("<Z").tag("dir_rev")
|
||||
return result
|
||||
|
||||
def to_marker_name(tag: str) -> 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
|
Loading…
Reference in New Issue