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.
Files changed (63) hide show
  1. core/__init__.py +0 -0
  2. core/checks.py +381 -0
  3. core/export.py +432 -0
  4. core/geometry.py +239 -0
  5. core/model.py +85 -0
  6. core/modes/__init__.py +239 -0
  7. core/modes/curve.py +266 -0
  8. core/modes/folded.py +448 -0
  9. core/modes/interlocked.py +159 -0
  10. core/modes/radial.py +339 -0
  11. core/modes/stacked.py +439 -0
  12. core/nest.py +359 -0
  13. core/notch.py +115 -0
  14. core/plan.py +570 -0
  15. core/solid.py +343 -0
  16. core/split.py +140 -0
  17. core/testmodels.py +106 -0
  18. core/unfold.py +190 -0
  19. lamina3d-0.2.2.dist-info/METADATA +535 -0
  20. lamina3d-0.2.2.dist-info/RECORD +63 -0
  21. lamina3d-0.2.2.dist-info/WHEEL +4 -0
  22. lamina3d-0.2.2.dist-info/entry_points.txt +2 -0
  23. lamina3d-0.2.2.dist-info/licenses/LICENSE +661 -0
  24. web/CHANGELOG.md +309 -0
  25. web/app.py +428 -0
  26. web/browser.py +49 -0
  27. web/examples/README.md +18 -0
  28. web/examples/blob.stl +0 -0
  29. web/examples/bowl.stl +0 -0
  30. web/examples/bracket.stl +0 -0
  31. web/examples/bunny.stl +0 -0
  32. web/examples/cone.stl +0 -0
  33. web/examples/cow.stl +0 -0
  34. web/examples/cow_spot.stl +0 -0
  35. web/examples/cube.stl +0 -0
  36. web/examples/cylinder.stl +0 -0
  37. web/examples/dumbbell.stl +0 -0
  38. web/examples/egg.stl +0 -0
  39. web/examples/head_igea.stl +0 -0
  40. web/examples/horse.stl +0 -0
  41. web/examples/horse_statue.stl +0 -0
  42. web/examples/interlocked.png +0 -0
  43. web/examples/pear.stl +0 -0
  44. web/examples/presets.json +29 -0
  45. web/examples/pyramid.stl +0 -0
  46. web/examples/sheet_interlocked.svg +307 -0
  47. web/examples/snowman.stl +0 -0
  48. web/examples/sphere.stl +0 -0
  49. web/examples/stacked.png +0 -0
  50. web/examples/tall_bar.stl +0 -0
  51. web/examples/torus.stl +0 -0
  52. web/examples/tube.stl +0 -0
  53. web/examples/twisted.stl +0 -0
  54. web/examples/wavy_torus.stl +0 -0
  55. web/examples/wedge.stl +0 -0
  56. web/static/browser-worker.js +60 -0
  57. web/static/browser.js +50 -0
  58. web/static/feedback.js +171 -0
  59. web/static/index.html +1489 -0
  60. web/static/logo.svg +14 -0
  61. web/static/vendor/OrbitControls.js +1417 -0
  62. web/static/vendor/STLLoader.js +410 -0
  63. 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})