simcon-toolkit 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.
Files changed (97) hide show
  1. simcon_toolkit/__init__.py +40 -0
  2. simcon_toolkit/__main__.py +7 -0
  3. simcon_toolkit/_kit/LICENSE +202 -0
  4. simcon_toolkit/_kit/NOTICE +37 -0
  5. simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
  6. simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
  7. simcon_toolkit/_kit/packages/.ruff.toml +10 -0
  8. simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
  9. simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
  10. simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
  11. simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
  12. simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
  13. simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
  14. simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
  15. simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
  16. simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
  17. simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
  18. simcon_toolkit/_kit/pyproject.toml +232 -0
  19. simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
  20. simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
  21. simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
  22. simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
  23. simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
  24. simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
  25. simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
  26. simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
  27. simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
  28. simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
  29. simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
  30. simcon_toolkit/_kit/templates/process-window/README.md +176 -0
  31. simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
  32. simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
  33. simcon_toolkit/_kit/templates/process-window/main.py +82 -0
  34. simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
  35. simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
  36. simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
  37. simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
  38. simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
  39. simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
  40. simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
  41. simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
  42. simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
  43. simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
  44. simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
  45. simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
  46. simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
  47. simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
  48. simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
  49. simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
  50. simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
  51. simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
  52. simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
  53. simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
  54. simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
  55. simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
  56. simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
  57. simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
  58. simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
  59. simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
  60. simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
  61. simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
  62. simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
  63. simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
  64. simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
  65. simcon_toolkit/_kit/templates/quoting/README.md +158 -0
  66. simcon_toolkit/_kit/templates/quoting/main.py +484 -0
  67. simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
  68. simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
  69. simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
  70. simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
  71. simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
  72. simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
  73. simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
  74. simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
  75. simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
  76. simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
  77. simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
  78. simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
  79. simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
  80. simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
  81. simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
  82. simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
  83. simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
  84. simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
  85. simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
  86. simcon_toolkit/_kit/templates.toml +83 -0
  87. simcon_toolkit/choices.py +11 -0
  88. simcon_toolkit/cli.py +381 -0
  89. simcon_toolkit/generate.py +590 -0
  90. simcon_toolkit/instructions.py +152 -0
  91. simcon_toolkit/manifest.py +86 -0
  92. simcon_toolkit/project.py +356 -0
  93. simcon_toolkit/wizard.py +160 -0
  94. simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
  95. simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
  96. simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
  97. simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,398 @@
1
+ """Everything a moldmaker reads off the geometry before quoting - offline, no cloud, no licence.
2
+
3
+ The quote's biggest cost drivers are decided here, not in the simulation:
4
+
5
+ * **projected area** in the draw direction -> clamp force -> which press -> the hourly rate.
6
+ * **wall thickness** -> cooling time -> cycle time -> the piece price.
7
+ * **undercuts** -> slides and lifters -> a four-figure line each on the tool.
8
+ * **part volume** -> shot size, material cost, and the press's shot-capacity window.
9
+
10
+ All of it is geometric, all of it is free, and all of it is wrong often enough that the numbers
11
+ are reported with their spread rather than as single values. Run this first: it costs seconds
12
+ and it tells you whether the part is even quotable before a single cloud run is spent.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import trimesh
22
+
23
+ from cadmould_geometry.stl import open_edge_count, read_stl, surface_area_mm2, volume_mm3
24
+
25
+ AXES = "xyz"
26
+
27
+
28
+ # ==========================================================================
29
+ # the mesh
30
+ # ==========================================================================
31
+ def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
32
+ """Read an STL into the triangle soup every measurement below works from.
33
+
34
+ Read through ``cadmould_geometry.stl``, the kit's one STL reader, so a gate index
35
+ here means the same vertex it means everywhere else. ``process=False`` keeps that
36
+ guarantee: trimesh would otherwise merge and reorder vertices on load.
37
+
38
+ An STL's winding decides which way a normal points, and every measurement below
39
+ asks "is this facet's outward side", so a file wound inside-out is corrected once
40
+ here rather than inverting undercuts and wall thickness silently. Measured on the
41
+ sample part, an uncorrected inside-out file reads a 3.21 mm median wall against the
42
+ true 1.33 mm, and 14 side actions against 4 - a quote inflated at both ends.
43
+
44
+ ⚠️ The test must use trimesh's SIGNED ``volume``. ``cadmould_geometry``'s
45
+ ``volume_mm3`` applies ``abs()`` deliberately, because its other callers want a
46
+ positive number, so comparing it against zero can never be true. Only a closed
47
+ surface has a meaningful sign; an open one is left alone and already carries a note
48
+ saying its measurements are estimates.
49
+ """
50
+ vertices, faces = read_stl(stl_path)
51
+ mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
52
+ if open_edge_count(vertices, faces) == 0 and mesh.volume < 0:
53
+ mesh.invert()
54
+ return mesh
55
+
56
+
57
+ def _facets(mesh: trimesh.Trimesh) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
58
+ """Triangle centres, unit outward normals and areas."""
59
+ return (
60
+ np.asarray(mesh.triangles_center, dtype=np.float64),
61
+ np.asarray(mesh.face_normals, dtype=np.float64),
62
+ np.asarray(mesh.area_faces, dtype=np.float64),
63
+ )
64
+
65
+
66
+ def through_hole_count(mesh: trimesh.Trimesh) -> int:
67
+ """Number of through-holes, from the surface genus (Euler characteristic).
68
+
69
+ For a closed triangulated surface every edge is shared by two faces, so E = 3F/2 and
70
+ ``chi = V - E + F``; the genus ``(2 - chi)/2`` is the number of handles, which for a moulded
71
+ part is the number of holes the melt has to flow around.
72
+
73
+ This matters twice on a quote, and neither is obvious from looking at the part list: each hole
74
+ needs a **core pin** in every cavity, and each hole **splits the flow front**, which knits back
75
+ together downstream and leaves a weld line there. Returns 0 for an open (non-watertight) mesh,
76
+ where the count would be meaningless.
77
+ """
78
+ if open_edge_count(mesh.vertices, mesh.faces) != 0:
79
+ return 0
80
+ faces, verts = int(len(mesh.faces)), int(len(mesh.vertices))
81
+ chi = verts - (3 * faces) // 2 + faces
82
+ return max(0, (2 - chi) // 2)
83
+
84
+
85
+ def _area_weighted_sample(areas: np.ndarray, n: int, rng: np.random.Generator) -> np.ndarray:
86
+ """Sample facets in proportion to their area.
87
+
88
+ Uniform-by-facet sampling is the trap here: an STL's rim is meshed with many tiny triangles,
89
+ and a ray cast inward from a rim facet measures the part's *span*, not its wall. Weighting by
90
+ area makes every mm2 of surface equally likely, which is also the quantity we actually want -
91
+ "how much of the part is thin" rather than "how many triangles are thin".
92
+ """
93
+ p = areas / max(float(areas.sum()), 1e-12)
94
+ return rng.choice(len(areas), size=min(n, len(areas) * 4), replace=True, p=p)
95
+
96
+
97
+ # ==========================================================================
98
+ # the part
99
+ # ==========================================================================
100
+ @dataclass
101
+ class PartGeometry:
102
+ """Geometric facts about one part, in mm / cm3 / cm2."""
103
+
104
+ path: Path
105
+ bbox_mm: tuple[float, float, float]
106
+ volume_cm3: float
107
+ surface_area_cm2: float
108
+ draw_axis: int
109
+ projected_area_cm2: float # shadow in the draw direction (per cavity)
110
+ draw_depth_mm: float
111
+ wall_mm: dict # min / p05 / median / mean / p95 / max
112
+ undercut_area_mm2: float
113
+ side_actions: int
114
+ zero_draft_area_pct: float
115
+ watertight: bool
116
+ n_triangles: int
117
+ through_holes: int = 0
118
+ notes: list[str] = field(default_factory=list)
119
+
120
+ # -- derived -----------------------------------------------------------
121
+ @property
122
+ def nominal_wall_mm(self) -> float:
123
+ """The wall the part *flows* through - median is robust to ribs and slivers."""
124
+ return float(self.wall_mm["median"])
125
+
126
+ @property
127
+ def governing_wall_mm(self) -> float:
128
+ """The wall that governs **ejection**: the slowest-cooling section (p95, not max).
129
+
130
+ The maximum sits on a boss or a sliver and would inflate the cycle; the 95th percentile
131
+ is the same physical quantity without the outlier. See the cooling model in costing.py.
132
+ """
133
+ return float(self.wall_mm["p95"])
134
+
135
+ @property
136
+ def flow_length_ratio(self) -> float:
137
+ """L/t on the longest bbox edge - the classic fillability screen."""
138
+ return float(max(self.bbox_mm) / max(self.nominal_wall_mm, 1e-6))
139
+
140
+ @property
141
+ def surface_to_volume(self) -> float:
142
+ """cm^-1. Informative, but do not price with it - it mostly measures wall thinness."""
143
+ return float(self.surface_area_cm2 / max(self.volume_cm3, 1e-9))
144
+
145
+ @property
146
+ def shape_factor(self) -> float:
147
+ """Surface area relative to the bounding box's - "how much shape is in there".
148
+
149
+ This is the honest complexity proxy for a cavity-machining estimate. Surface-to-volume
150
+ looks like one and is not: a plain 1.5 mm plate scores 15 cm^-1 on it purely for being
151
+ thin, which would price a trivial cavity as a hard one. A flat part sits near or below
152
+ 1.0 here; ribs, bosses and pockets push it above 1.
153
+ """
154
+ a, b, c = self.bbox_mm
155
+ bbox_cm2 = 2.0 * (a * b + b * c + a * c) / 100.0
156
+ return float(self.surface_area_cm2 / max(bbox_cm2, 1e-9))
157
+
158
+ def as_dict(self) -> dict:
159
+ return {
160
+ "path": str(self.path),
161
+ "bbox_mm": [round(v, 2) for v in self.bbox_mm],
162
+ "volume_cm3": round(self.volume_cm3, 3),
163
+ "surface_area_cm2": round(self.surface_area_cm2, 2),
164
+ "draw_axis": AXES[self.draw_axis],
165
+ "projected_area_cm2": round(self.projected_area_cm2, 2),
166
+ "draw_depth_mm": round(self.draw_depth_mm, 2),
167
+ "wall_mm": {k: round(v, 3) for k, v in self.wall_mm.items()},
168
+ "nominal_wall_mm": round(self.nominal_wall_mm, 3),
169
+ "governing_wall_mm": round(self.governing_wall_mm, 3),
170
+ "flow_length_ratio": round(self.flow_length_ratio, 1),
171
+ "surface_to_volume_cm-1": round(self.surface_to_volume, 2),
172
+ "shape_factor": round(self.shape_factor, 2),
173
+ "undercut_area_mm2": round(self.undercut_area_mm2, 1),
174
+ "side_actions": self.side_actions,
175
+ "zero_draft_area_pct": round(self.zero_draft_area_pct, 1),
176
+ "through_holes": self.through_holes,
177
+ "watertight": self.watertight,
178
+ "n_triangles": self.n_triangles,
179
+ "notes": self.notes,
180
+ }
181
+
182
+ def report(self) -> str:
183
+ w = self.wall_mm
184
+ lines = [
185
+ f" part {self.path.name}",
186
+ f" bbox {self.bbox_mm[0]:.1f} x {self.bbox_mm[1]:.1f} x {self.bbox_mm[2]:.1f} mm",
187
+ f" volume {self.volume_cm3:.2f} cm3 surface {self.surface_area_cm2:.1f} cm2",
188
+ f" draw direction {AXES[self.draw_axis]} (depth {self.draw_depth_mm:.1f} mm)",
189
+ f" projected area {self.projected_area_cm2:.2f} cm2 <- clamp force basis",
190
+ f" wall thickness median {w['median']:.2f} mean {w['mean']:.2f} "
191
+ f"p05 {w['p05']:.2f} p95 {w['p95']:.2f} mm",
192
+ f" flow length/t {self.flow_length_ratio:.0f} shape factor {self.shape_factor:.2f} "
193
+ f"(surface/volume {self.surface_to_volume:.2f} cm-1)",
194
+ f" undercuts {self.undercut_area_mm2:.0f} mm2 -> {self.side_actions} side action(s)",
195
+ f" through-holes {self.through_holes} (core pins per cavity; each knits a weld behind it)",
196
+ f" zero-draft area {self.zero_draft_area_pct:.1f} % watertight: {self.watertight}",
197
+ ]
198
+ lines += [f" ! {n}" for n in self.notes]
199
+ return "\n".join(lines)
200
+
201
+
202
+ # ==========================================================================
203
+ # analysis
204
+ # ==========================================================================
205
+ def _projected_area_mm2(mesh: trimesh.Trimesh, axis: int) -> float:
206
+ """Shadow area along ``axis``.
207
+
208
+ For a closed surface every ray crosses the body an even number of times, so the summed
209
+ |projected| facet area is exactly twice the silhouette area. Robust, and far cheaper than
210
+ rasterising the silhouette.
211
+ """
212
+ _, normals, areas = _facets(mesh)
213
+ return float(0.5 * np.sum(areas * np.abs(normals[:, axis])))
214
+
215
+
216
+ def _wall_thickness(mesh: trimesh.Trimesh, idx: np.ndarray) -> np.ndarray:
217
+ """Local wall thickness at each sampled facet: the largest sphere that fits inside.
218
+
219
+ The inscribed-sphere measure is the one a moldmaker means by "wall": at a point on the
220
+ surface it is the biggest ball that fits in the material touching there, so it reads a
221
+ 1.5 mm plate as 1.5 mm from the face and from the rim alike. That last part is why it
222
+ replaced a bundle of inward rays - a ray cast inward from a rim facet measures the
223
+ plate's *span*, and no amount of tilting the bundle fixes the general case.
224
+
225
+ It is also the reason this module no longer builds a VTK OBB tree. That tree came out
226
+ different on every build from the same file, so the same part measured between 1 and
227
+ 461 usable samples run to run, and reported a 31 mm median wall on a part 21.9 mm deep.
228
+ Both numbers feed cooling time, and through it the piece price.
229
+ """
230
+ _, normals, _ = _facets(mesh)
231
+ thickness = trimesh.proximity.thickness(
232
+ mesh,
233
+ mesh.triangles_center[idx],
234
+ exterior=False,
235
+ normals=normals[idx],
236
+ method="max_sphere",
237
+ )
238
+ thickness = np.asarray(thickness, dtype=np.float64)
239
+ return thickness[np.isfinite(thickness) & (thickness > 0)]
240
+
241
+
242
+ def _undercuts(mesh: trimesh.Trimesh, idx: np.ndarray, axis: int) -> tuple[float, int, float, np.ndarray]:
243
+ """Facet area that releases in neither draw direction, clustered into side actions.
244
+
245
+ A facet releases along +d if its normal points that way *and* nothing blocks the path out.
246
+ Facets nearly parallel to the draw (|n.d| < 0.05) are near-zero-draft walls - a draft problem,
247
+ not an undercut - and are counted separately. Sampling is area-weighted, so the undercut
248
+ *fraction* of the samples is an unbiased estimate of the undercut *fraction of the surface*.
249
+
250
+ Every ray is cast in one vectorised call rather than one at a time, which is both faster
251
+ and what keeps the answer identical run to run.
252
+ """
253
+ centers, normals, areas = _facets(mesh)
254
+ total = float(areas.sum())
255
+ eps = max(float(np.linalg.norm(mesh.bounds[1] - mesh.bounds[0])) * 1e-5, 1e-4)
256
+
257
+ d = np.zeros(3)
258
+ d[axis] = 1.0
259
+ nd = normals[idx] @ d
260
+ drafted = np.abs(nd) >= 0.05
261
+ n_zero_draft = int((~drafted).sum())
262
+
263
+ stuck = np.zeros((0, 3))
264
+ if drafted.any():
265
+ out_dirs = np.where(nd[drafted, None] > 0, d, -d)
266
+ origins = centers[idx][drafted] + out_dirs * eps
267
+ blocked = np.asarray(mesh.ray.intersects_any(origins, out_dirs), dtype=bool)
268
+ stuck = origins[blocked] - out_dirs[blocked] * eps
269
+
270
+ n = max(len(idx), 1)
271
+ return total * len(stuck) / n, len(stuck), 100.0 * n_zero_draft / n, stuck
272
+
273
+
274
+ def _cluster(points: np.ndarray, radius: float) -> int:
275
+ """Greedy spatial clustering - one cluster is one side action."""
276
+ if len(points) == 0:
277
+ return 0
278
+ unassigned = list(range(len(points)))
279
+ clusters = 0
280
+ while unassigned:
281
+ seed = unassigned.pop(0)
282
+ members = [seed]
283
+ grew = True
284
+ while grew:
285
+ grew = False
286
+ for j in list(unassigned):
287
+ if np.min(np.linalg.norm(points[members] - points[j], axis=1)) <= radius:
288
+ members.append(j)
289
+ unassigned.remove(j)
290
+ grew = True
291
+ clusters += 1
292
+ return clusters
293
+
294
+
295
+ def analyse(
296
+ stl_path: str | Path,
297
+ *,
298
+ draw_axis: int | None = None,
299
+ samples: int = 900,
300
+ undercut_area_per_action_mm2: float = 400.0,
301
+ seed: int = 7,
302
+ logger=print,
303
+ ) -> PartGeometry:
304
+ """Read the STL and derive everything the quote needs from the shape alone."""
305
+ stl_path = Path(stl_path)
306
+ mesh = load_mesh(stl_path)
307
+ rng = np.random.default_rng(seed)
308
+ lo, hi = mesh.bounds
309
+ dims = tuple(float(v) for v in (hi - lo))
310
+ volume_cm3 = abs(volume_mm3(mesh.vertices, mesh.faces)) / 1000.0
311
+ surface_cm2 = surface_area_mm2(mesh.vertices, mesh.faces) / 100.0
312
+ notes: list[str] = []
313
+
314
+ open_edges = open_edge_count(mesh.vertices, mesh.faces)
315
+ watertight = open_edges == 0
316
+ if not watertight:
317
+ notes.append(
318
+ f"the STL is not watertight ({open_edges} open edges) - volume, wall thickness "
319
+ "and undercuts are estimates; ask for a clean solid before the quote is binding"
320
+ )
321
+
322
+ # Draw direction: the axis whose shadow is largest. That is the plate face on almost every
323
+ # technical part, and it is the conservative choice for clamp force. Override when the
324
+ # customer's drawing shows the parting line elsewhere.
325
+ projected = [_projected_area_mm2(mesh, a) for a in range(3)]
326
+ auto_axis = int(np.argmax(projected))
327
+ if draw_axis is None:
328
+ draw_axis = auto_axis
329
+ elif int(draw_axis) != auto_axis:
330
+ notes.append(f"draw direction forced to {AXES[draw_axis]}; the largest shadow is on {AXES[auto_axis]}")
331
+
332
+ # One area-weighted sample drives both measurements, so the undercut fraction and the
333
+ # wall spread describe the same set of facets rather than two different draws.
334
+ idx = _area_weighted_sample(np.asarray(mesh.area_faces, dtype=np.float64), samples, rng)
335
+ logger(f" measuring {len(idx)} samples for wall thickness and undercuts ...")
336
+ thickness = _wall_thickness(mesh, idx)
337
+ if len(thickness) < 10:
338
+ raise RuntimeError("wall-thickness sampling failed - is the STL a closed solid?")
339
+ wall = {
340
+ "min": float(np.min(thickness)),
341
+ "p05": float(np.percentile(thickness, 5)),
342
+ "median": float(np.median(thickness)),
343
+ "mean": float(np.mean(thickness)),
344
+ "p95": float(np.percentile(thickness, 95)),
345
+ "max": float(np.max(thickness)),
346
+ }
347
+
348
+ uc_area, n_stuck, zero_draft_pct, uc_pts = _undercuts(mesh, idx, draw_axis)
349
+ # Cluster at a tenth of the part's longest edge: separate features, not separate facets.
350
+ n_clusters = _cluster(uc_pts, radius=max(dims) * 0.10) if n_stuck else 0
351
+ # Two independent estimates of the side-action count; take the larger, because a missed
352
+ # slide is a five-figure surprise and an extra one is a four-figure conservatism.
353
+ by_area = int(np.ceil(uc_area / undercut_area_per_action_mm2)) if uc_area > 0 else 0
354
+ side_actions = max(n_clusters, by_area)
355
+
356
+ if wall["median"] < 0.8:
357
+ notes.append(f"nominal wall {wall['median']:.2f} mm is below the 0.8 mm we are cautious about")
358
+ if wall["p95"] / max(wall["median"], 1e-6) > 2.5:
359
+ notes.append(
360
+ f"wall varies strongly ({wall['p05']:.2f}-{wall['p95']:.2f} mm) - sink marks and "
361
+ "differential shrinkage are a real risk; cooling time is set by the thick sections"
362
+ )
363
+ if side_actions:
364
+ notes.append(
365
+ f"{side_actions} side action(s) estimated from {uc_area:.0f} mm2 of undercut - verify against the drawing"
366
+ )
367
+ holes = through_hole_count(mesh)
368
+ if holes:
369
+ notes.append(
370
+ f"{holes} through-hole(s): each needs a core pin per cavity, and each splits the flow "
371
+ "front so a weld line knits behind it. The weld detector resolves head-on front "
372
+ "collisions and under-reports these, so treat the predicted weld count as a LOWER BOUND"
373
+ )
374
+
375
+ return PartGeometry(
376
+ path=stl_path,
377
+ bbox_mm=dims,
378
+ volume_cm3=volume_cm3,
379
+ surface_area_cm2=surface_cm2,
380
+ draw_axis=int(draw_axis),
381
+ projected_area_cm2=projected[draw_axis] / 100.0,
382
+ draw_depth_mm=dims[draw_axis],
383
+ wall_mm=wall,
384
+ undercut_area_mm2=uc_area,
385
+ side_actions=side_actions,
386
+ zero_draft_area_pct=zero_draft_pct,
387
+ watertight=watertight,
388
+ n_triangles=int(len(mesh.faces)),
389
+ through_holes=holes,
390
+ notes=notes,
391
+ )
392
+
393
+
394
+ if __name__ == "__main__":
395
+ import sys
396
+
397
+ geo = analyse(sys.argv[1] if len(sys.argv) > 1 else "../../assets/parts/clip_frame.stl")
398
+ print(geo.report())
@@ -0,0 +1,193 @@
1
+ """Read the shop's own context out of ``shop/*.md``.
2
+
3
+ The shop files are markdown for the agent (prose, judgement, house rules) with one fenced
4
+ JSON block per file for the code::
5
+
6
+ ```json machines
7
+ {"machines": [...]}
8
+ ```
9
+
10
+ Prose and block are the same source of truth, kept side by side deliberately: the agent needs
11
+ the reasoning, the cost model needs the numbers, and splitting them into a separate config file
12
+ is how the two drift apart.
13
+
14
+ Only the fenced blocks are parsed. The block's name is the word after ``json`` on the opening
15
+ fence; everything else in the file is ignored by the code and read by the agent.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ SHOP_DIR = Path(__file__).resolve().parents[1] / "shop"
27
+
28
+ # Defaults for the lessons.md calibration block. Every key here is a knob the review-and-learn
29
+ # loop is allowed to move; anything not in this dict is a typo, not a new knob (load() says so).
30
+ EXAMPLE_SHOP_NAME = "Feldmann Formenbau & Spritzguss GmbH"
31
+
32
+ CALIBRATION_DEFAULTS: dict[str, Any] = {
33
+ "clamp_safety_factor": 1.25,
34
+ "use_simulated_clamp_pressure": True,
35
+ "sim_peak_to_mean_pressure": 0.50,
36
+ "cooling_time_factor": 1.00,
37
+ "pack_time_s_per_mm_wall": 2.0,
38
+ "cycle_time_factor": 1.00,
39
+ "complexity_bonus": 0.0,
40
+ "tooling_price_factor": 1.00,
41
+ "piece_price_factor": 1.00,
42
+ }
43
+
44
+ # file stem -> the block names expected in it
45
+ _BLOCKS = {
46
+ "shop-profile": ["shop"],
47
+ "machines": ["machines", "cycle"],
48
+ "materials": ["materials"],
49
+ "tooling": ["tooling"],
50
+ "commercial": ["commercial"],
51
+ "lessons": ["calibration"],
52
+ }
53
+
54
+
55
+ def read_block(md_path: Path, name: str) -> dict:
56
+ """Parse the ```` ```json <name> ```` fenced block out of a markdown file."""
57
+ text = Path(md_path).read_text(encoding="utf-8")
58
+ pat = re.compile(r"^```json[ \t]+" + re.escape(name) + r"[ \t]*\r?\n(.*?)^```", re.S | re.M)
59
+ m = pat.search(text)
60
+ if not m:
61
+ raise ValueError(f"{md_path.name}: no ```json {name} block found")
62
+ try:
63
+ return json.loads(m.group(1))
64
+ except json.JSONDecodeError as exc:
65
+ raise ValueError(f"{md_path.name}: the '{name}' block is not valid JSON — {exc}") from exc
66
+
67
+
68
+ def write_block(md_path: Path, name: str, data: dict) -> None:
69
+ """Replace a fenced JSON block in place, keeping the surrounding prose untouched."""
70
+ path = Path(md_path)
71
+ text = path.read_text(encoding="utf-8")
72
+ body = json.dumps(data, indent=2, ensure_ascii=False)
73
+ pat = re.compile(r"(^```json[ \t]+" + re.escape(name) + r"[ \t]*\r?\n)(.*?)(^```)", re.S | re.M)
74
+ if not pat.search(text):
75
+ raise ValueError(f"{md_path.name}: no ```json {name} block found")
76
+ path.write_text(pat.sub(lambda m: m.group(1) + body + "\n" + m.group(3), text, count=1), encoding="utf-8")
77
+
78
+
79
+ @dataclass
80
+ class ShopContext:
81
+ """Everything the quote needs to know about this shop."""
82
+
83
+ root: Path
84
+ shop: dict
85
+ machines: dict
86
+ cycle: dict
87
+ materials: dict
88
+ tooling: dict
89
+ commercial: dict
90
+ calibration: dict[str, Any]
91
+ sources: list[Path]
92
+
93
+ # -- lookups ------------------------------------------------------------
94
+ def cal(self, key: str) -> Any:
95
+ """A calibration value (lessons.md), falling back to the shipped default."""
96
+ return self.calibration.get(key, CALIBRATION_DEFAULTS[key])
97
+
98
+ def grades(self) -> list[dict]:
99
+ return list(self.materials.get("grades", []))
100
+
101
+ def find_grade(self, name: str | None) -> dict:
102
+ """Resolve a house grade by name (exact, then partial); default grade if None."""
103
+ grades = self.grades()
104
+ if not grades:
105
+ raise ValueError("shop/materials.md lists no grades")
106
+ if not name:
107
+ name = self.materials.get("default_grade") or grades[0]["name"]
108
+ low = name.lower()
109
+ for g in grades:
110
+ if g["name"].lower() == low:
111
+ return g
112
+ partial = [g for g in grades if low in g["name"].lower() or g["name"].lower() in low]
113
+ if partial:
114
+ return partial[0]
115
+ raise ValueError(
116
+ f"'{name}' is not a house grade. Known: {', '.join(g['name'] for g in grades)}. "
117
+ "Add it to shop/materials.md (with a price) before quoting it."
118
+ )
119
+
120
+ def volume_ladder(self) -> list[int]:
121
+ return list(self.shop.get("volume_ladder_parts_per_year", [10000, 50000, 250000]))
122
+
123
+ def currency(self) -> str:
124
+ return str(self.shop.get("currency", "EUR"))
125
+
126
+ @property
127
+ def is_example(self) -> bool:
128
+ """True while the shop context is still the shipped fictional shop.
129
+
130
+ Quoting a real part against invented presses and rates produces a confident, precise,
131
+ completely wrong number - the worst possible output. Every stage checks this and says so,
132
+ and it rides in ``quote.json`` as an assumption, until the interview replaces the values and
133
+ sets ``provenance`` to the real shop's name.
134
+ """
135
+ return str(self.shop.get("provenance", "example")).strip().lower() == "example"
136
+
137
+ def example_warning(self) -> str:
138
+ return (
139
+ "!! shop/ is still the SHIPPED EXAMPLE (a fictional shop): its presses, rates, "
140
+ "material prices and mould cost model are invented. Any quote from it is a format "
141
+ "demonstration, not a price. Run `python main.py setup` and work through INTERVIEW.md "
142
+ "before quoting anything real."
143
+ )
144
+
145
+ def summary(self) -> str:
146
+ return (
147
+ f"{self.shop.get('name', 'shop')} - {len(self.machines.get('machines', []))} presses, "
148
+ f"{len(self.grades())} house grades, {len(self.calibration)} calibration keys "
149
+ f"(read {len(self.sources)} files from {self.root.name}/)"
150
+ )
151
+
152
+
153
+ def load(root: Path = SHOP_DIR) -> ShopContext:
154
+ """Load every shop file. Fails loudly — a missing file means an unpriced quote."""
155
+ root = Path(root)
156
+ blocks: dict[str, dict] = {}
157
+ sources: list[Path] = []
158
+ for stem, names in _BLOCKS.items():
159
+ path = root / f"{stem}.md"
160
+ if not path.exists():
161
+ raise FileNotFoundError(f"missing shop context file: {path}")
162
+ sources.append(path)
163
+ for name in names:
164
+ blocks[name] = read_block(path, name)
165
+
166
+ calibration = dict(blocks["calibration"])
167
+ unknown = sorted(set(calibration) - set(CALIBRATION_DEFAULTS))
168
+ if unknown:
169
+ raise ValueError(
170
+ f"shop/lessons.md calibration has unknown key(s): {', '.join(unknown)}. "
171
+ f"Known knobs: {', '.join(sorted(CALIBRATION_DEFAULTS))}. "
172
+ "A knob the cost model doesn't read would silently do nothing."
173
+ )
174
+ merged = dict(CALIBRATION_DEFAULTS)
175
+ merged.update(calibration)
176
+
177
+ return ShopContext(
178
+ root=root,
179
+ shop=blocks["shop"],
180
+ machines=blocks["machines"],
181
+ cycle=blocks["cycle"],
182
+ materials=blocks["materials"],
183
+ tooling=blocks["tooling"],
184
+ commercial=blocks["commercial"],
185
+ calibration=merged,
186
+ sources=sources,
187
+ )
188
+
189
+
190
+ if __name__ == "__main__":
191
+ ctx = load()
192
+ print(ctx.summary())
193
+ print(" calibration:", json.dumps(ctx.calibration, indent=2))