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