Merge pull request 'feat: Geometry utils' (#5) from lib/geometry into main

Reviewed-on: #5
This commit is contained in:
Leni Aniva 2024-10-21 22:36:00 -07:00
commit 7f48c75975
19 changed files with 3152 additions and 1 deletions

View File

@ -1 +1,2 @@
from nhf.materials import Role, Material
from nhf.materials import Material, Role
from nhf.parts.item import Item

307
nhf/build.py Normal file
View File

@ -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))

64
nhf/checks.py Normal file
View File

@ -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

42
nhf/diag.py Normal file
View File

@ -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

110
nhf/geometry.py Normal file
View File

@ -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
nhf/parts/__init__.py Normal file
View File

157
nhf/parts/box.py Normal file
View File

@ -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()

135
nhf/parts/electronics.py Normal file
View File

@ -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,
)
)

162
nhf/parts/fasteners.py Normal file
View File

@ -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

58
nhf/parts/fibre.py Normal file
View File

@ -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

385
nhf/parts/handle.py Normal file
View File

@ -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

67
nhf/parts/item.py Normal file
View File

@ -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

431
nhf/parts/joints.py Normal file
View File

@ -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)
)

422
nhf/parts/metric_threads.py Normal file
View File

@ -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

36
nhf/parts/planar.py Normal file
View File

@ -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

79
nhf/parts/springs.py Normal file
View File

@ -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

125
nhf/parts/test.py Normal file
View File

@ -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()

288
nhf/test.py Normal file
View File

@ -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()

282
nhf/utils.py Normal file
View File

@ -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