lamina3d 0.2.2__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.
- core/__init__.py +0 -0
- core/checks.py +381 -0
- core/export.py +432 -0
- core/geometry.py +239 -0
- core/model.py +85 -0
- core/modes/__init__.py +239 -0
- core/modes/curve.py +266 -0
- core/modes/folded.py +448 -0
- core/modes/interlocked.py +159 -0
- core/modes/radial.py +339 -0
- core/modes/stacked.py +439 -0
- core/nest.py +359 -0
- core/notch.py +115 -0
- core/plan.py +570 -0
- core/solid.py +343 -0
- core/split.py +140 -0
- core/testmodels.py +106 -0
- core/unfold.py +190 -0
- lamina3d-0.2.2.dist-info/METADATA +535 -0
- lamina3d-0.2.2.dist-info/RECORD +63 -0
- lamina3d-0.2.2.dist-info/WHEEL +4 -0
- lamina3d-0.2.2.dist-info/entry_points.txt +2 -0
- lamina3d-0.2.2.dist-info/licenses/LICENSE +661 -0
- web/CHANGELOG.md +309 -0
- web/app.py +428 -0
- web/browser.py +49 -0
- web/examples/README.md +18 -0
- web/examples/blob.stl +0 -0
- web/examples/bowl.stl +0 -0
- web/examples/bracket.stl +0 -0
- web/examples/bunny.stl +0 -0
- web/examples/cone.stl +0 -0
- web/examples/cow.stl +0 -0
- web/examples/cow_spot.stl +0 -0
- web/examples/cube.stl +0 -0
- web/examples/cylinder.stl +0 -0
- web/examples/dumbbell.stl +0 -0
- web/examples/egg.stl +0 -0
- web/examples/head_igea.stl +0 -0
- web/examples/horse.stl +0 -0
- web/examples/horse_statue.stl +0 -0
- web/examples/interlocked.png +0 -0
- web/examples/pear.stl +0 -0
- web/examples/presets.json +29 -0
- web/examples/pyramid.stl +0 -0
- web/examples/sheet_interlocked.svg +307 -0
- web/examples/snowman.stl +0 -0
- web/examples/sphere.stl +0 -0
- web/examples/stacked.png +0 -0
- web/examples/tall_bar.stl +0 -0
- web/examples/torus.stl +0 -0
- web/examples/tube.stl +0 -0
- web/examples/twisted.stl +0 -0
- web/examples/wavy_torus.stl +0 -0
- web/examples/wedge.stl +0 -0
- web/static/browser-worker.js +60 -0
- web/static/browser.js +50 -0
- web/static/feedback.js +171 -0
- web/static/index.html +1489 -0
- web/static/logo.svg +14 -0
- web/static/vendor/OrbitControls.js +1417 -0
- web/static/vendor/STLLoader.js +410 -0
- web/static/vendor/three.module.js +53044 -0
core/__init__.py
ADDED
|
File without changes
|
core/checks.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Physical-world checks on the finished 2D geometry. Errors = will not work; warnings = look at it.
|
|
2
|
+
Every message says what is wrong AND what to do about it; `suggest_fixes` turns the common ones into one-click options."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import shapely
|
|
9
|
+
from shapely.geometry import Point, Polygon
|
|
10
|
+
from shapely import affinity
|
|
11
|
+
|
|
12
|
+
from .geometry import dowel_leverage, hole_reach
|
|
13
|
+
|
|
14
|
+
HELD_GROUPS = ("X", "Y", "R", "C") # regions in these groups must be held by a slot / dowel / the core
|
|
15
|
+
STRUCTURAL = ("X", "Y", "R", "C", "S") # take part in the assembly-connectivity check (panels, pegs, strips, ribs do not)
|
|
16
|
+
LEVERAGE_WARN = 1.5 # placement aims for 0.8 (geometry.LEVERAGE_OK); past this a piece's far edge visibly wanders
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def min_dims(poly):
|
|
20
|
+
"""Side lengths of the minimum rotated bounding rectangle (w, h), sorted: the hull measured across each of its own
|
|
21
|
+
edge directions, keeping the smallest box. Not GEOS's minimum_rotated_rectangle, which comes back as a flat line
|
|
22
|
+
when the hull has several points on one straight edge (slot corners on a slanted side do that) — a 120 × 660 mm
|
|
23
|
+
part measured 0.0 × 125, failed as "too thin" and was never split to fit the sheet."""
|
|
24
|
+
hull = poly.convex_hull
|
|
25
|
+
if hull.geom_type != "Polygon": # a point or a line: no width
|
|
26
|
+
return 0.0, float(hull.length)
|
|
27
|
+
h = np.asarray(hull.exterior.coords)
|
|
28
|
+
e = np.diff(h, axis=0); e = e[np.hypot(*e.T) > 1e-12]; e /= np.hypot(*e.T)[:, None]
|
|
29
|
+
w, t = np.ptp(h @ e.T, axis=0), np.ptp(h @ np.c_[-e[:, 1], e[:, 0]].T, axis=0)
|
|
30
|
+
k = int(np.argmin(w * t))
|
|
31
|
+
return tuple(sorted((float(w[k]), float(t[k]))))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_slice(sl, p):
|
|
35
|
+
mf, mp = p["min_feature"], p["min_part"]
|
|
36
|
+
if sl.profile is None or sl.profile.is_empty:
|
|
37
|
+
sl.errors.append("empty — plane does not hit material"); return
|
|
38
|
+
raw_n = len(sl.raw.geoms)
|
|
39
|
+
regions = list(sl.profile.geoms)
|
|
40
|
+
if len(regions) > raw_n:
|
|
41
|
+
what = "holes" if sl.group in ("F", "J", "P") else "slots"
|
|
42
|
+
sl.errors.append(f"{what} cut the part into {len(regions)} loose regions (was {raw_n}) — move this slice a little (offset), "
|
|
43
|
+
f"lower the notch ratio, use fewer crossing slices, or make the connection holes smaller")
|
|
44
|
+
for i, reg in enumerate(regions):
|
|
45
|
+
tag = f"region {i + 1}" if len(regions) > 1 else "part"
|
|
46
|
+
small, large = min_dims(reg)
|
|
47
|
+
glued_island = sl.group == "S" and len(regions) > 1 # stacked islands (ears, horns) are kept: glue them carefully
|
|
48
|
+
# a stacked spacer or peg is drawn at the connector's size: the model's thickness and rounding never reach it
|
|
49
|
+
spacer = "it is drawn at the connector's size: use a bigger dowel_d" if sl.group == "P" else None
|
|
50
|
+
if large < mp:
|
|
51
|
+
(sl.warnings if glued_island else sl.errors).append(f"{tag} too small ({small:.1f}×{large:.1f} mm < {mp} mm) — {'a tiny glued island: fiddly to handle, round / thicken the model to merge it' if glued_island else 'too small to cut and handle: delete it, enlarge the model, or lower min_part'}")
|
|
52
|
+
elif small < mf:
|
|
53
|
+
(sl.warnings if glued_island else sl.errors).append(f"{tag} too thin ({small:.1f} mm < {mf} mm) — it would break: {spacer or 'thicken / round the model, use thicker material, or delete it'}")
|
|
54
|
+
# thin necks: erode by half the min feature; anything that vanishes or fragments is fragile
|
|
55
|
+
eroded = reg.buffer(-mf / 2)
|
|
56
|
+
if eroded.is_empty:
|
|
57
|
+
(sl.warnings if glued_island else sl.errors).append(f"{tag} thinner than {mf} mm everywhere — it would break: {spacer or 'thicken / round the model or lower min_feature'}")
|
|
58
|
+
elif eroded.geom_type == "MultiPolygon" and len(eroded.geoms) > 1:
|
|
59
|
+
sl.warnings.append(f"{tag} has a bridge thinner than {mf} mm (splits into {len(eroded.geoms)} pieces when eroded) — fragile: thicken / round the model, "
|
|
60
|
+
f"move the slice (offset), or accept and handle with care")
|
|
61
|
+
# engagement: a region must be held by something (a slot, a dowel, the core) unless it is the only region of a stacked slice
|
|
62
|
+
if sl.group in HELD_GROUPS or (sl.group == "S" and len(regions) > 1):
|
|
63
|
+
held = any(reg.intersects(c) for c in sl.cuts)
|
|
64
|
+
if not held and sl.group != "S":
|
|
65
|
+
sl.errors.append(f"{tag} floats — no slot or dowel holds it: add a crossing slice through it, move this slice, or delete it")
|
|
66
|
+
elif not held and p.get("space", 0) > 0: # stacked with a gap: nothing touches, so an island without a connector hangs in the air
|
|
67
|
+
sl.errors.append(f"{tag} floats — nothing holds it across the gap: add a connection point inside it (alt-click), use a smaller dowel, or set the gap to 0 and glue it")
|
|
68
|
+
elif not held:
|
|
69
|
+
sl.warnings.append(f"{tag} is a separate island — glued to its neighbours only (no dowel / peg through it); add a connection point inside it if it must be rigid")
|
|
70
|
+
# spread: two dowels bunched in the middle of a big piece locate it, but let its edge swing (reported: a 200 mm
|
|
71
|
+
# layer of a sphere held by two dowels 46 mm apart). One connector is a hinge someone asked for (n_points 1).
|
|
72
|
+
# Said only with the dowel that cures it: a piece too small for two of these dowels to sit farther apart (a
|
|
73
|
+
# horse's 25 mm hoof, held 1.6) has nothing better to be offered, and every smaller dowel only moved the flag.
|
|
74
|
+
if sl.group == "S" and p.get("connect") == "dowel":
|
|
75
|
+
outline = Polygon(reg.exterior)
|
|
76
|
+
pts = [(c.centroid.x, c.centroid.y) for c in sl.cuts if outline.contains(c.centroid)]
|
|
77
|
+
lev = dowel_leverage(reg, pts)
|
|
78
|
+
if len(pts) > 1 and lev > LEVERAGE_WARN:
|
|
79
|
+
d = p["dowel_d"] + p["slot_offset"]
|
|
80
|
+
# where the placer can cut a rod through this piece and a neighbour, a hair inside (spots are rounded)
|
|
81
|
+
room = shapely.union_all(getattr(sl, "dowel_room", [])).intersection(reg).buffer(-0.2)
|
|
82
|
+
spots = [tuple(q) for q in shapely.get_coordinates(room)
|
|
83
|
+
if min(np.hypot(q[0] - r[0], q[1] - r[1]) for r in pts) > d + mf]
|
|
84
|
+
best = min(spots, key=lambda q: dowel_leverage(reg, pts + [q]), default=None)
|
|
85
|
+
# the piece before its holes: a thin ring (a hollowed bunny's 5 mm wall) is wide enough for a dowel and
|
|
86
|
+
# its walls only at a bulge or two. Long enough to matter too: five of those widths across, or it is a
|
|
87
|
+
# small piece (a horse's 12 mm-wide hoof is narrow as well, and no wall would spread its dowels)
|
|
88
|
+
body = max(sl.raw.geoms, key=lambda g: g.intersection(reg).area)
|
|
89
|
+
need = 2 * (hole_reach(d, p["dowel_shape"]) + mf)
|
|
90
|
+
across = 2 * shapely.minimum_bounding_radius(reg)
|
|
91
|
+
said = f"{tag} ({reg.area / 100:.0f} cm², {across:.0f} mm across) is held by " \
|
|
92
|
+
f"{len(pts)} dowels bunched together — its far edge can shift {lev:.1f}× the play in their holes"
|
|
93
|
+
if best and dowel_leverage(reg, pts + [best]) <= LEVERAGE_WARN:
|
|
94
|
+
sl.warnings.append(
|
|
95
|
+
f"{said}. It needs one more, nearer its edge: a dowel at ({best[0]:.1f} mm, {best[1]:.1f} mm) brings that to "
|
|
96
|
+
f"{dowel_leverage(reg, pts + [best]):.1f}× "
|
|
97
|
+
+ {"aligned": "(alt-click there, or the fix below)", "lines": "(alt-click there for a line up through it)"}
|
|
98
|
+
.get(p.get("placement"), "(set placement to aligned to place your own)"))
|
|
99
|
+
elif across >= 5 * need and body.buffer(-need / 2).area < 0.1 * body.area:
|
|
100
|
+
sl.warnings.append(
|
|
101
|
+
f"{said}, and no dowel fits farther out: its wall is about {2 * body.area / body.length:.0f} mm, a "
|
|
102
|
+
f"{p['dowel_d']:g} mm dowel needs {need:.0f} mm with its walls — a thicker wall (less hollow, or "
|
|
103
|
+
f"thicken the model), or accept it and line this layer up by eye")
|
|
104
|
+
if sl.group in ("X", "Y") and not sl.engages:
|
|
105
|
+
sl.errors.append("no crossing slice — this slice touches nothing: add a slice of the other family through it, move it toward the centre, or delete it")
|
|
106
|
+
# hole wall check: a closed cut (hole, slot, dowel) must leave at least min_feature of material to the outline
|
|
107
|
+
if sl.cuts:
|
|
108
|
+
inner = sl.raw.buffer(-(mf - 1e-3)) # a hole placed exactly min_feature in is not "closer than" it
|
|
109
|
+
for c in sl.cuts:
|
|
110
|
+
if sl.raw.contains(c) and not inner.contains(c):
|
|
111
|
+
sl.warnings.append(f"a hole sits closer than {mf} mm to the outline (thin wall may tear) — move the connection point inward, use a smaller dowel, or accept")
|
|
112
|
+
break
|
|
113
|
+
# Slicer's "multiple / overlapping notches on one part": two cuts crossing each other inside the material
|
|
114
|
+
inside = [c for c in sl.cuts if sl.raw.intersects(c)]
|
|
115
|
+
if len(inside) > 1:
|
|
116
|
+
from shapely.strtree import STRtree
|
|
117
|
+
tree = STRtree(inside)
|
|
118
|
+
for i, a in enumerate(inside):
|
|
119
|
+
if any(j > i and a.intersection(inside[j]).intersection(sl.raw).area > 0.25 for j in tree.query(a)):
|
|
120
|
+
sl.warnings.append("two slots / holes overlap each other (the connection is weakened) — fewer connection points, move the slice (offset), change the angle, or delete one slice")
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_insertion(slices, p):
|
|
125
|
+
"""Slide every slotted part into place and look for what blocks it. A part crossing another can only travel
|
|
126
|
+
along their crossing line, in through the slot's mouth; so in the slotted part's plane, sweep the crossing
|
|
127
|
+
part's remaining material along that line out through the mouth — whatever material of the slotted part lies
|
|
128
|
+
in the path (the second wall of a hollow section the line crosses twice, a concave outline that closes over the
|
|
129
|
+
mouth) makes the assembly physically impossible, however good the slots look on paper."""
|
|
130
|
+
from shapely.geometry import LineString
|
|
131
|
+
from shapely.ops import unary_union
|
|
132
|
+
from .geometry import plane_plane_line, to_local, to_world, rect_along
|
|
133
|
+
by = {s.label: s for s in slices}
|
|
134
|
+
for a in slices:
|
|
135
|
+
if a.profile is None or a.profile.is_empty:
|
|
136
|
+
continue
|
|
137
|
+
for lb, o in a.slot_dirs.items():
|
|
138
|
+
b = by.get(lb)
|
|
139
|
+
if b is None or b.profile is None or b.profile.is_empty:
|
|
140
|
+
continue
|
|
141
|
+
ln = plane_plane_line(a.M, b.M)
|
|
142
|
+
if ln is None:
|
|
143
|
+
continue
|
|
144
|
+
p0, d = ln; o = np.asarray(o)
|
|
145
|
+
x0, y0, x1, y1 = a.profile.bounds; big = 4 * max(x1 - x0, y1 - y0, 1.0)
|
|
146
|
+
hit = LineString(to_local(b.M, [p0 - d * big, p0 + d * big])).intersection(b.profile)
|
|
147
|
+
strips = []
|
|
148
|
+
for g in getattr(hit, "geoms", [hit]):
|
|
149
|
+
if g.geom_type != "LineString" or g.length < 0.3:
|
|
150
|
+
continue
|
|
151
|
+
c = np.asarray(g.coords); w = to_world(b.M, [c[0], c[-1]])
|
|
152
|
+
w = w[np.argsort(w @ o)] # far end first: the sweep runs from it, through the near end, out
|
|
153
|
+
far, out = to_local(a.M, [w[0], w[1] + o * big])
|
|
154
|
+
strips.append(rect_along(far, out, b.thickness))
|
|
155
|
+
if not strips:
|
|
156
|
+
continue
|
|
157
|
+
blocked = a.profile.intersection(unary_union(strips))
|
|
158
|
+
if blocked.area > 6 * b.thickness: # a few thicknesses of path: a sliver at the mouth is sanded, a wall is not
|
|
159
|
+
a.errors.append(f"{b.label} cannot slide into its slot: {a.label}'s material is in its path ({blocked.area / b.thickness:.0f} mm of it) — "
|
|
160
|
+
f"the crossing line meets {a.label} twice (a hollow or concave section): move {b.label} (offset), lower the notch ratio, or delete {b.label}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def check_plan(slices, p, mesh=None, span=None):
|
|
164
|
+
for sl in slices:
|
|
165
|
+
sl.errors, sl.warnings = [], []
|
|
166
|
+
check_slice(sl, p)
|
|
167
|
+
check_insertion(slices, p)
|
|
168
|
+
check_assembly(slices, p)
|
|
169
|
+
if mesh is not None:
|
|
170
|
+
check_collisions(slices, mesh, span or 4 * max(mesh.extents))
|
|
171
|
+
errs = sum(len(s.errors) for s in slices); warns = sum(len(s.warnings) for s in slices)
|
|
172
|
+
return errs, warns
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def check_collisions(slices, mesh, span):
|
|
176
|
+
"""Two slices of the same family that cross inside the model (tilt / offset edits) have no slot for each other:
|
|
177
|
+
they would physically collide. Crossing families are joined by slots and are fine."""
|
|
178
|
+
from .geometry import plane_plane_line, line_segments_in_mesh, to_local
|
|
179
|
+
groups = {}
|
|
180
|
+
for s in slices:
|
|
181
|
+
if s.group in ("X", "Y", "S", "R", "C") and s.profile is not None:
|
|
182
|
+
groups.setdefault(s.group, []).append(s)
|
|
183
|
+
for members in groups.values():
|
|
184
|
+
for i, a in enumerate(members):
|
|
185
|
+
for b in members[i + 1:]:
|
|
186
|
+
ln = plane_plane_line(a.M, b.M)
|
|
187
|
+
if ln is None:
|
|
188
|
+
continue
|
|
189
|
+
p0, d = ln
|
|
190
|
+
for s_in, s_out in line_segments_in_mesh(mesh, p0, d, span):
|
|
191
|
+
m = p0 + d * (s_in + s_out) / 2
|
|
192
|
+
la, lb = to_local(a.M, [m])[0], to_local(b.M, [m])[0]
|
|
193
|
+
if a.profile.contains(Point(la)) and b.profile.contains(Point(lb)):
|
|
194
|
+
msg = f"crosses {b.label} inside the model and nothing joins them (they collide) — reduce the tilt / offset, move one, or delete one"
|
|
195
|
+
a.errors.append(msg); b.errors.append(f"crosses {a.label} inside the model and nothing joins them (they collide) — reduce the tilt / offset, move one, or delete one")
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------- assembly connectivity
|
|
200
|
+
def _regions_local(sl):
|
|
201
|
+
return list(sl.profile.geoms)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _into(a, b, geom):
|
|
205
|
+
"""Geometry in b's local plane → a's local plane (both z=0; exact for parallel frames, close enough for small tilts)."""
|
|
206
|
+
T = np.linalg.inv(a.M) @ b.M
|
|
207
|
+
return affinity.affine_transform(geom, [T[0, 0], T[0, 1], T[1, 0], T[1, 1], T[0, 3], T[1, 3]])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_assembly(slices, p):
|
|
211
|
+
"""Every structural region must reach the main assembly through slots, connectors or glued contact.
|
|
212
|
+
Nodes = (slice, region); edges = a slot / connector shared by two regions (matched by its world point), or two
|
|
213
|
+
glued stacked regions that overlap. Disconnected groups get an error with fixes (add slices / delete)."""
|
|
214
|
+
struct = [s for s in slices if s.group in STRUCTURAL and s.profile is not None and not s.profile.is_empty]
|
|
215
|
+
if len(struct) < 2:
|
|
216
|
+
return
|
|
217
|
+
idx = {s.label: i for i, s in enumerate(struct)}
|
|
218
|
+
nodes = [(i, r) for i, s in enumerate(struct) for r in range(len(s.profile.geoms))]
|
|
219
|
+
parent = {n: n for n in nodes}
|
|
220
|
+
|
|
221
|
+
def find(n):
|
|
222
|
+
while parent[n] != n:
|
|
223
|
+
parent[n] = parent[parent[n]]; n = parent[n]
|
|
224
|
+
return n
|
|
225
|
+
|
|
226
|
+
def union(a, b):
|
|
227
|
+
parent[find(a)] = find(b)
|
|
228
|
+
# slots and connectors: link a's regions touching cut A with b's regions touching cut B when A and B are the same connection
|
|
229
|
+
for i, a in enumerate(struct):
|
|
230
|
+
for lb, cut_a, pt in a.links:
|
|
231
|
+
if lb not in idx:
|
|
232
|
+
continue
|
|
233
|
+
j = idx[lb]; b = struct[j]
|
|
234
|
+
match = [(cb, pb) for (la, cb, pb) in b.links if la == a.label and np.linalg.norm(np.subtract(pb, pt)) < 1.0]
|
|
235
|
+
ra = [r for r, reg in enumerate(_regions_local(a)) if reg.intersects(cut_a)]
|
|
236
|
+
for cb, _ in match or []:
|
|
237
|
+
rb = [r for r, reg in enumerate(_regions_local(b)) if reg.intersects(cb)]
|
|
238
|
+
for x in ra:
|
|
239
|
+
for y in rb:
|
|
240
|
+
union((i, x), (j, y))
|
|
241
|
+
# glued contact between consecutive stacked slices (same group S, neighbouring in the stack order) — only when
|
|
242
|
+
# they touch: with a gap, the connectors are the only thing joining them
|
|
243
|
+
stack = [s for s in struct if s.group == "S"] if p.get("space", 0) == 0 else []
|
|
244
|
+
for a, b in zip(stack, stack[1:]):
|
|
245
|
+
i, j = idx[a.label], idx[b.label]
|
|
246
|
+
rb_local = [_into(a, b, reg) for reg in _regions_local(b)]
|
|
247
|
+
for x, ra in enumerate(_regions_local(a)):
|
|
248
|
+
for y, rb in enumerate(rb_local):
|
|
249
|
+
if ra.intersection(rb).area > p["min_feature"] ** 2:
|
|
250
|
+
union((i, x), (j, y))
|
|
251
|
+
comps = {}
|
|
252
|
+
for n in nodes:
|
|
253
|
+
comps.setdefault(find(n), []).append(n)
|
|
254
|
+
if len(comps) < 2:
|
|
255
|
+
return
|
|
256
|
+
main = max(comps.values(), key=lambda c: sum(struct[i].profile.geoms[r].area for i, r in c))
|
|
257
|
+
for comp in comps.values():
|
|
258
|
+
if comp is main:
|
|
259
|
+
continue
|
|
260
|
+
labels = sorted({struct[i].label for i, _ in comp})
|
|
261
|
+
for i, r in comp:
|
|
262
|
+
s = struct[i]; tag = f"region {r + 1} " if len(s.profile.geoms) > 1 else ""
|
|
263
|
+
s.errors.append(f"{tag}is not connected to the main assembly (separate group: {', '.join(labels)}) — nothing joins it to the rest: "
|
|
264
|
+
f"add crossing slices / connection points through it, or delete it")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def crossing_suggestions(slices, ctx, mode):
|
|
268
|
+
"""Parameter changes that would add a crossing slice through every floating / disconnected region (autofix = add):
|
|
269
|
+
the technique's own `hold` when it has one, else its crossing_fix through each region's middle."""
|
|
270
|
+
from .geometry import to_world
|
|
271
|
+
sets = mode.hold(slices, ctx)
|
|
272
|
+
if sets is not None:
|
|
273
|
+
return sets
|
|
274
|
+
sets = []
|
|
275
|
+
for sl in slices:
|
|
276
|
+
for e in sl.errors:
|
|
277
|
+
if not ("floats" in e or "no crossing" in e or "not connected" in e):
|
|
278
|
+
continue
|
|
279
|
+
regs = list(sl.profile.geoms)
|
|
280
|
+
r = int(e.split()[1]) - 1 if e.startswith("region") else 0
|
|
281
|
+
if r < len(regs):
|
|
282
|
+
c = regs[r].centroid
|
|
283
|
+
fx = mode.crossing_fix(ctx, sl, to_world(sl.M, [(c.x, c.y)])[0])
|
|
284
|
+
if fx:
|
|
285
|
+
sets.append(fx["set"])
|
|
286
|
+
return sets
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def suggest_fixes(slices, p, ctx, mode):
|
|
290
|
+
"""Per error, concrete parameter changes the UI can apply with one click."""
|
|
291
|
+
from .geometry import to_world
|
|
292
|
+
for sl in slices:
|
|
293
|
+
sl.fixes = []
|
|
294
|
+
t = sl.thickness
|
|
295
|
+
# A layer of a stack is the model at that height: take it out and the model has a slot missing through it and
|
|
296
|
+
# the layers above sit wrong. So a stacked layer is never offered for deletion — the fixes that keep it are.
|
|
297
|
+
stack = sl.group == "S"
|
|
298
|
+
def delete(lb, title=None, stack=stack):
|
|
299
|
+
return [] if stack else [{"title": title or f"delete {lb}", "set": {"skip": [lb] if isinstance(lb, str) else lb}}]
|
|
300
|
+
toward = float(np.sign(np.dot(ctx.mid - sl.M[:3, 3], sl.M[:3, 2])) or 1.0) # toward the centre along the normal
|
|
301
|
+
off = dict(p["offset"]).get(sl.label, 0.0)
|
|
302
|
+
for e in sl.errors:
|
|
303
|
+
opts = []
|
|
304
|
+
if "no crossing slice" in e or "floats" in e or "not connected" in e:
|
|
305
|
+
regs = list(sl.profile.geoms); r = int(e.split()[1]) - 1 if e.startswith("region") else 0
|
|
306
|
+
if r < len(regs):
|
|
307
|
+
c = regs[r].centroid; fx = mode.crossing_fix(ctx, sl, to_world(sl.M, [(c.x, c.y)])[0])
|
|
308
|
+
if fx:
|
|
309
|
+
opts.append(fx)
|
|
310
|
+
step = max(5.0, 0.1 * max(ctx.ext))
|
|
311
|
+
opts.append({"title": f"move {sl.label} {step:.0f} mm toward the centre", "set": {"offset": {sl.label: round(off + toward * step, 1)}}})
|
|
312
|
+
if "not connected" in e:
|
|
313
|
+
grp = e.split("separate group: ")[1].split(")")[0].split(", ")
|
|
314
|
+
opts += delete(grp, f"delete the group ({len(grp)} parts)")
|
|
315
|
+
opts.append({"title": "round the model 3 mm (merges thin gaps)", "set": {"round": 3}})
|
|
316
|
+
if stack: # keep the layer, join it instead: glue the stack or thin the pegs
|
|
317
|
+
opts.append({"title": "gap 0 (touching, glued)", "set": {"space": 0}})
|
|
318
|
+
opts.append({"title": f"smaller connectors ({p.get('dowel_d', 6) * 0.6:.1f} mm)", "set": {"dowel_d": round(p.get("dowel_d", 6) * 0.6, 1)}})
|
|
319
|
+
elif "across the gap" in e: # a stacked island the dowel does not fit in
|
|
320
|
+
opts.append({"title": f"smaller connectors ({p['dowel_d'] * 0.6:.1f} mm)", "set": {"dowel_d": round(p["dowel_d"] * 0.6, 1)}})
|
|
321
|
+
opts.append({"title": "gap 0 (touching, glued)", "set": {"space": 0}})
|
|
322
|
+
else:
|
|
323
|
+
opts += delete(sl.label)
|
|
324
|
+
elif "cannot slide into its slot" in e:
|
|
325
|
+
other = e.split()[0]
|
|
326
|
+
opts.append({"title": f"move {other} {2 * t:g} mm", "set": {"offset": {other: round(dict(p["offset"]).get(other, 0.0) + 2 * t, 1)}}})
|
|
327
|
+
opts.append({"title": f"move {other} −{2 * t:g} mm", "set": {"offset": {other: round(dict(p["offset"]).get(other, 0.0) - 2 * t, 1)}}})
|
|
328
|
+
opts.append({"title": "notch ratio 0.35", "set": {"notch_ratio": 0.35}})
|
|
329
|
+
opts.append({"title": "notch ratio 0.65", "set": {"notch_ratio": 0.65}})
|
|
330
|
+
opts += delete(other)
|
|
331
|
+
elif "cut the part into" in e:
|
|
332
|
+
opts.append({"title": f"move {sl.label} {2 * t:g} mm", "set": {"offset": {sl.label: round(off + 2 * t, 1)}}})
|
|
333
|
+
opts.append({"title": f"move {sl.label} −{2 * t:g} mm", "set": {"offset": {sl.label: round(off - 2 * t, 1)}}})
|
|
334
|
+
if mode.name in ("interlocked", "curve", "radial"):
|
|
335
|
+
opts.append({"title": "notch ratio 0.35", "set": {"notch_ratio": 0.35}})
|
|
336
|
+
opts.append({"title": "notch ratio 0.65", "set": {"notch_ratio": 0.65}})
|
|
337
|
+
opts += delete(sl.label)
|
|
338
|
+
elif sl.group == "P" and ("too small" in e or "too thin" in e or "thinner than" in e):
|
|
339
|
+
# a tab spacer is 0.6 of the connector wide (a peg, with no gap, all of it): the size whose narrowest
|
|
340
|
+
# part keeps the wall, to the next half millimetre. Growing it alone would no longer fit its slots.
|
|
341
|
+
d = math.ceil(2 * (p["min_feature"] / (0.6 if p.get("space", 0) > 0 else 1) + 1)) / 2
|
|
342
|
+
if d > p.get("dowel_d", 6):
|
|
343
|
+
opts.append({"title": f"bigger connectors ({d:g} mm)", "set": {"dowel_d": d}})
|
|
344
|
+
elif "too small" in e or "too thin" in e or "thinner than" in e:
|
|
345
|
+
opts.append({"title": f"grow {sl.label}'s outline {p['min_feature'] / 2:g} mm (bridges become ≥ {p['min_feature']:g} mm)", "set": {"grow": {sl.label: p["min_feature"] / 2}}})
|
|
346
|
+
opts += delete(sl.label)
|
|
347
|
+
opts.append({"title": "thicken the model 2 mm", "set": {"thicken": 2}})
|
|
348
|
+
opts.append({"title": "round the model 2 mm", "set": {"round": 2}})
|
|
349
|
+
elif "crosses" in e and "collide" in e:
|
|
350
|
+
other = e.split()[1]
|
|
351
|
+
opts.append({"title": f"reset tilt / roll / offset of {sl.label}", "set": {"tilt": {sl.label: 0}, "roll": {sl.label: 0}, "offset": {sl.label: 0}}})
|
|
352
|
+
opts += delete(other)
|
|
353
|
+
if opts:
|
|
354
|
+
sl.fixes.append({"error": e, "options": opts})
|
|
355
|
+
# warnings worth a one-click fix as well
|
|
356
|
+
grow_opt = {"title": f"grow {sl.label}'s outline {p['min_feature'] / 2:g} mm (bridges become ≥ {p['min_feature']:g} mm)", "set": {"grow": {sl.label: p["min_feature"] / 2}}}
|
|
357
|
+
for w in sl.warnings:
|
|
358
|
+
opts = []
|
|
359
|
+
if "bridge thinner" in w:
|
|
360
|
+
opts = [grow_opt, {"title": "round the model 2 mm", "set": {"round": 2}}]
|
|
361
|
+
elif "separate island" in w:
|
|
362
|
+
opts = [{"title": "round the model 3 mm (merges it into the body)", "set": {"round": 3}}]
|
|
363
|
+
if mode.name == "stacked" and p.get("connect") == "none":
|
|
364
|
+
opts.append({"title": "hold the stack with pegs", "set": {"connect": "tab"}})
|
|
365
|
+
elif "closer than" in w and "outline" in w:
|
|
366
|
+
# the setting that sizes this part's holes: a joint's hole on folded panels, the dowel everywhere else
|
|
367
|
+
key, d = ("hole_d", p.get("hole_d", 2.5)) if mode.name == "folded" else ("dowel_d", p.get("dowel_d", 6))
|
|
368
|
+
small = round(max(0.5 if key == "hole_d" else 2.0, d * 0.6), 1)
|
|
369
|
+
opts = [{"title": f"smaller {'holes' if key == 'hole_d' else 'connectors'} ({small:g} mm)", "set": {key: small}}]
|
|
370
|
+
elif "bunched together" in w and "a dowel at (" in w and p.get("placement") == "aligned":
|
|
371
|
+
# each coordinate with its mm, so the page's units switch turns the spot into inches with the rest
|
|
372
|
+
x, y = map(float, re.search(r"a dowel at \(([-\d.]+) mm, ([-\d.]+) mm\)", w).groups())
|
|
373
|
+
opts = [{"title": f"add a dowel at ({x:g} mm, {y:g} mm)", "set": {"dowels": [*map(list, p["dowels"]), [x, y]]}}]
|
|
374
|
+
elif "skipped" in w and "connection point" in w:
|
|
375
|
+
opts = [{"title": f"smaller connectors ({max(2.0, p.get('dowel_d', 6) * 0.6):g} mm)", "set": {"dowel_d": round(max(2.0, p.get("dowel_d", 6) * 0.6), 1)}},
|
|
376
|
+
{"title": "fewer connection points", "set": {"n_points": max(1, int(p.get("n_points", 2)) - 1)}}]
|
|
377
|
+
elif "without a" in w and "seam" in w:
|
|
378
|
+
opts = [{"title": f"bigger triangles (facet {p.get('facet', 15) * 1.5:g} mm)", "set": {"facet": round(p.get("facet", 15) * 1.5, 1)}},
|
|
379
|
+
{"title": "smaller joints", "set": {"hole_d": round(max(1.0, p.get("hole_d", 2.5) * 0.7), 1), "inset": round(max(1.0, p.get("inset", 4) * 0.7), 1)}}]
|
|
380
|
+
if opts:
|
|
381
|
+
sl.fixes.append({"error": w, "options": opts})
|