woodwright 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- woodwright/__init__.py +124 -0
- woodwright/assembly.py +173 -0
- woodwright/cuts.py +791 -0
- woodwright/examples/__init__.py +93 -0
- woodwright/examples/__main__.py +42 -0
- woodwright/examples/_common.py +17 -0
- woodwright/examples/cuts.py +145 -0
- woodwright/examples/frames.py +133 -0
- woodwright/examples/joints.py +454 -0
- woodwright/examples/projects.py +457 -0
- woodwright/export.py +68 -0
- woodwright/joints.py +915 -0
- woodwright/part.py +230 -0
- woodwright/placement.py +177 -0
- woodwright/py.typed +0 -0
- woodwright/rules.py +49 -0
- woodwright/stock.py +15 -0
- woodwright-0.1.0.dist-info/METADATA +121 -0
- woodwright-0.1.0.dist-info/RECORD +21 -0
- woodwright-0.1.0.dist-info/WHEEL +4 -0
- woodwright-0.1.0.dist-info/licenses/LICENSE +21 -0
woodwright/__init__.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Semantic CAD library for woodworking."""
|
|
2
|
+
|
|
3
|
+
from woodwright.assembly import Assembly, ResolvedModel, ResolvedPart
|
|
4
|
+
from woodwright.cuts import (
|
|
5
|
+
Allowance,
|
|
6
|
+
BoxCut,
|
|
7
|
+
Chamfer,
|
|
8
|
+
Cut,
|
|
9
|
+
Dado,
|
|
10
|
+
DovetailTongue,
|
|
11
|
+
EndRabbet,
|
|
12
|
+
Groove,
|
|
13
|
+
Mitre,
|
|
14
|
+
Mortise,
|
|
15
|
+
Pocket,
|
|
16
|
+
Rabbet,
|
|
17
|
+
ShapeCut,
|
|
18
|
+
SlotMortise,
|
|
19
|
+
Taper,
|
|
20
|
+
Tenon,
|
|
21
|
+
Tongue,
|
|
22
|
+
)
|
|
23
|
+
from woodwright.joints import (
|
|
24
|
+
Bridle,
|
|
25
|
+
Butt,
|
|
26
|
+
Button,
|
|
27
|
+
CrossLap,
|
|
28
|
+
DadoJoint,
|
|
29
|
+
GrooveJoint,
|
|
30
|
+
HalfBlindDovetail,
|
|
31
|
+
HalfLap,
|
|
32
|
+
Housing,
|
|
33
|
+
Joint,
|
|
34
|
+
MitreCorner,
|
|
35
|
+
MitreSplice,
|
|
36
|
+
MortiseTenon,
|
|
37
|
+
RabbetJoint,
|
|
38
|
+
SlidingDovetail,
|
|
39
|
+
ThroughDovetail,
|
|
40
|
+
TongueAndGroove,
|
|
41
|
+
)
|
|
42
|
+
from woodwright.part import ArrisRef, Board, Side, SideRef
|
|
43
|
+
from woodwright.placement import (
|
|
44
|
+
BACK,
|
|
45
|
+
DOWN,
|
|
46
|
+
FLUSH,
|
|
47
|
+
FRONT,
|
|
48
|
+
LEFT,
|
|
49
|
+
RIGHT,
|
|
50
|
+
UP,
|
|
51
|
+
Centered,
|
|
52
|
+
Direction,
|
|
53
|
+
Offset,
|
|
54
|
+
Orientation,
|
|
55
|
+
Position,
|
|
56
|
+
centered,
|
|
57
|
+
flush_with,
|
|
58
|
+
mate,
|
|
59
|
+
offset,
|
|
60
|
+
orient,
|
|
61
|
+
)
|
|
62
|
+
from woodwright.stock import Stock
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"BACK",
|
|
66
|
+
"DOWN",
|
|
67
|
+
"FLUSH",
|
|
68
|
+
"FRONT",
|
|
69
|
+
"LEFT",
|
|
70
|
+
"RIGHT",
|
|
71
|
+
"UP",
|
|
72
|
+
"Allowance",
|
|
73
|
+
"ArrisRef",
|
|
74
|
+
"Assembly",
|
|
75
|
+
"Board",
|
|
76
|
+
"BoxCut",
|
|
77
|
+
"Bridle",
|
|
78
|
+
"Butt",
|
|
79
|
+
"Button",
|
|
80
|
+
"Centered",
|
|
81
|
+
"Chamfer",
|
|
82
|
+
"CrossLap",
|
|
83
|
+
"Cut",
|
|
84
|
+
"Dado",
|
|
85
|
+
"DadoJoint",
|
|
86
|
+
"Direction",
|
|
87
|
+
"DovetailTongue",
|
|
88
|
+
"EndRabbet",
|
|
89
|
+
"Groove",
|
|
90
|
+
"GrooveJoint",
|
|
91
|
+
"HalfBlindDovetail",
|
|
92
|
+
"HalfLap",
|
|
93
|
+
"Housing",
|
|
94
|
+
"Joint",
|
|
95
|
+
"Mitre",
|
|
96
|
+
"MitreCorner",
|
|
97
|
+
"MitreSplice",
|
|
98
|
+
"Mortise",
|
|
99
|
+
"MortiseTenon",
|
|
100
|
+
"Offset",
|
|
101
|
+
"Orientation",
|
|
102
|
+
"Pocket",
|
|
103
|
+
"Position",
|
|
104
|
+
"Rabbet",
|
|
105
|
+
"RabbetJoint",
|
|
106
|
+
"ResolvedModel",
|
|
107
|
+
"ResolvedPart",
|
|
108
|
+
"ShapeCut",
|
|
109
|
+
"Side",
|
|
110
|
+
"SideRef",
|
|
111
|
+
"SlidingDovetail",
|
|
112
|
+
"SlotMortise",
|
|
113
|
+
"Stock",
|
|
114
|
+
"Taper",
|
|
115
|
+
"Tenon",
|
|
116
|
+
"ThroughDovetail",
|
|
117
|
+
"Tongue",
|
|
118
|
+
"TongueAndGroove",
|
|
119
|
+
"centered",
|
|
120
|
+
"flush_with",
|
|
121
|
+
"mate",
|
|
122
|
+
"offset",
|
|
123
|
+
"orient",
|
|
124
|
+
]
|
woodwright/assembly.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Assemblies of parts and joints, and their resolution."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
|
|
6
|
+
import build123d as bd
|
|
7
|
+
|
|
8
|
+
from woodwright.cuts import Cut
|
|
9
|
+
from woodwright.joints import Joint
|
|
10
|
+
from woodwright.part import Board, Point
|
|
11
|
+
from woodwright.placement import Orientation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Assembly:
|
|
15
|
+
"""Parts and the joints between them. The first part added is the root.
|
|
16
|
+
|
|
17
|
+
The root is placed by `root_location`: an `Orientation` (see `orient`)
|
|
18
|
+
or a build123d `Location` of its part frame.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, name: str, root_location: Orientation | bd.Location | None = None) -> None:
|
|
22
|
+
self.name = name
|
|
23
|
+
self.root_location = root_location or bd.Location()
|
|
24
|
+
self.parts: list[Board] = []
|
|
25
|
+
self.joints: list[Joint] = []
|
|
26
|
+
|
|
27
|
+
def add(self, part: Board) -> Board:
|
|
28
|
+
self.parts.append(part)
|
|
29
|
+
return part
|
|
30
|
+
|
|
31
|
+
def join[J: Joint](self, joint: J) -> J:
|
|
32
|
+
self.joints.append(joint)
|
|
33
|
+
return joint
|
|
34
|
+
|
|
35
|
+
def resolve(self) -> ResolvedModel:
|
|
36
|
+
"""Fill in joint defaults, size the blanks, place the parts, collect cuts."""
|
|
37
|
+
if not self.parts:
|
|
38
|
+
raise ValueError(f"assembly {self.name} has no parts")
|
|
39
|
+
joints = [joint.with_defaults() for joint in self.joints]
|
|
40
|
+
for joint in joints:
|
|
41
|
+
for part in joint.parts():
|
|
42
|
+
if part not in self.parts:
|
|
43
|
+
raise ValueError(f"{part.name} is joined but not added to {self.name}")
|
|
44
|
+
|
|
45
|
+
blanks = {part: (list(part.box[0]), list(part.box[1])) for part in self.parts}
|
|
46
|
+
allowances = [a for joint in joints for a in joint.allowances()]
|
|
47
|
+
allowances += [a for part in self.parts for cut in part.cuts for a in cut.allowances()]
|
|
48
|
+
for allowance in allowances:
|
|
49
|
+
lo, hi = blanks[allowance.side.part]
|
|
50
|
+
axis = allowance.side.axis
|
|
51
|
+
if allowance.side.side.sign > 0:
|
|
52
|
+
hi[axis] += allowance.amount
|
|
53
|
+
else:
|
|
54
|
+
lo[axis] -= allowance.amount
|
|
55
|
+
|
|
56
|
+
placements = [joint.placement() for joint in joints]
|
|
57
|
+
locations = self._place(joints, placements)
|
|
58
|
+
|
|
59
|
+
cuts = {part: list(part.cuts) for part in self.parts}
|
|
60
|
+
for joint, placement in zip(joints, placements, strict=True):
|
|
61
|
+
for cut in joint.cuts(placement):
|
|
62
|
+
cuts[cut.part].append(cut)
|
|
63
|
+
|
|
64
|
+
parts = [
|
|
65
|
+
ResolvedPart(part, (tuple(lo), tuple(hi)), cuts[part], locations[part])
|
|
66
|
+
for part, (lo, hi) in blanks.items()
|
|
67
|
+
]
|
|
68
|
+
return ResolvedModel(self.name, parts, joints)
|
|
69
|
+
|
|
70
|
+
def _place(
|
|
71
|
+
self, joints: list[Joint], placements: list[bd.Location]
|
|
72
|
+
) -> dict[Board, bd.Location]:
|
|
73
|
+
"""Walk the joint graph from the root, then check joints closing loops."""
|
|
74
|
+
root = self.parts[0]
|
|
75
|
+
root_location = self.root_location
|
|
76
|
+
if isinstance(root_location, Orientation):
|
|
77
|
+
root_location = root_location.location(root)
|
|
78
|
+
locations = {root: root_location}
|
|
79
|
+
pending = list(range(len(joints)))
|
|
80
|
+
while pending:
|
|
81
|
+
for i in pending:
|
|
82
|
+
a, b = joints[i].parts()
|
|
83
|
+
if a in locations and b in locations:
|
|
84
|
+
_check_loop(joints[i], locations[a] * placements[i], locations[b])
|
|
85
|
+
elif a in locations:
|
|
86
|
+
locations[b] = locations[a] * placements[i]
|
|
87
|
+
elif b in locations:
|
|
88
|
+
locations[a] = locations[b] * placements[i].inverse()
|
|
89
|
+
else:
|
|
90
|
+
continue
|
|
91
|
+
pending.remove(i)
|
|
92
|
+
break
|
|
93
|
+
else:
|
|
94
|
+
break # no progress: the remaining joints are not connected to the root
|
|
95
|
+
unplaced = [part.name for part in self.parts if part not in locations]
|
|
96
|
+
if unplaced:
|
|
97
|
+
raise ValueError(f"not connected to {root.name} by joints: {', '.join(unplaced)}")
|
|
98
|
+
return locations
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check_loop(joint: Joint, expected: bd.Location, actual: bd.Location) -> None:
|
|
102
|
+
points = [(0, 0, 0), (1000, 0, 0), (0, 1000, 0)]
|
|
103
|
+
error = max(
|
|
104
|
+
((expected * bd.Location(p)).position - (actual * bd.Location(p)).position).length
|
|
105
|
+
for p in points
|
|
106
|
+
)
|
|
107
|
+
if error > 1e-6:
|
|
108
|
+
a, b = joint.parts()
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"{type(joint).__name__} between {a.name} and {b.name} doesn't close "
|
|
111
|
+
f"the loop: off by up to {error:.3g} mm"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class ResolvedPart:
|
|
117
|
+
"""A part with its blank, its cuts and its location in the assembly."""
|
|
118
|
+
|
|
119
|
+
part: Board
|
|
120
|
+
blank: tuple[Point, Point] # min and max corners in the part frame
|
|
121
|
+
cuts: list[Cut]
|
|
122
|
+
location: bd.Location
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def size(self) -> tuple[float, float, float]:
|
|
126
|
+
"""Cut dimensions: length, width, thickness."""
|
|
127
|
+
lo, hi = self.blank
|
|
128
|
+
return hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]
|
|
129
|
+
|
|
130
|
+
@cached_property
|
|
131
|
+
def shape(self) -> bd.Part:
|
|
132
|
+
"""Blank minus all cuts, in the part frame."""
|
|
133
|
+
lo = self.blank[0]
|
|
134
|
+
shape = bd.Pos(*lo) * bd.Box(*self.size, align=(bd.Align.MIN,) * 3)
|
|
135
|
+
for cut in self.cuts:
|
|
136
|
+
shape -= cut.removal()
|
|
137
|
+
shape.label = self.part.name
|
|
138
|
+
return shape
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def placed_shape(self) -> bd.Part:
|
|
142
|
+
shape = self.location * self.shape
|
|
143
|
+
shape.label = self.part.name
|
|
144
|
+
return shape
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class ResolvedModel:
|
|
149
|
+
name: str
|
|
150
|
+
parts: list[ResolvedPart]
|
|
151
|
+
joints: list[Joint]
|
|
152
|
+
|
|
153
|
+
def __getitem__(self, part: Board) -> ResolvedPart:
|
|
154
|
+
for resolved in self.parts:
|
|
155
|
+
if resolved.part is part:
|
|
156
|
+
return resolved
|
|
157
|
+
raise KeyError(part.name)
|
|
158
|
+
|
|
159
|
+
def cut_list(self) -> str:
|
|
160
|
+
"""Parts with their cut dimensions and the cuts made into them."""
|
|
161
|
+
name_width = max(len(resolved.part.name) for resolved in self.parts)
|
|
162
|
+
lines = []
|
|
163
|
+
for resolved in self.parts:
|
|
164
|
+
part = resolved.part
|
|
165
|
+
size = " x ".join(f"{x:g}" for x in resolved.size)
|
|
166
|
+
line = f"{part.name:<{name_width}} {size} mm {part.stock.species}".rstrip()
|
|
167
|
+
lines.append(line + ", mirror image" if part.mirror else line)
|
|
168
|
+
lines.extend(f"{'':<{name_width}} - {cut.describe()}" for cut in resolved.cuts)
|
|
169
|
+
return "\n".join(lines)
|
|
170
|
+
|
|
171
|
+
def compound(self) -> bd.Compound:
|
|
172
|
+
"""All parts placed in the assembly."""
|
|
173
|
+
return bd.Compound(label=self.name, children=[p.placed_shape for p in self.parts])
|