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.
- simcon_toolkit/__init__.py +40 -0
- simcon_toolkit/__main__.py +7 -0
- simcon_toolkit/_kit/LICENSE +202 -0
- simcon_toolkit/_kit/NOTICE +37 -0
- simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
- simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
- simcon_toolkit/_kit/packages/.ruff.toml +10 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
- simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
- simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
- simcon_toolkit/_kit/pyproject.toml +232 -0
- simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
- simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
- simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
- simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
- simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
- simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
- simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
- simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
- simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
- simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
- simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
- simcon_toolkit/_kit/templates/process-window/README.md +176 -0
- simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
- simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
- simcon_toolkit/_kit/templates/process-window/main.py +82 -0
- simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
- simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
- simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
- simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
- simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
- simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
- simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
- simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
- simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
- simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
- simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
- simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
- simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
- simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
- simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
- simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
- simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
- simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
- simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
- simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
- simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
- simcon_toolkit/_kit/templates/quoting/README.md +158 -0
- simcon_toolkit/_kit/templates/quoting/main.py +484 -0
- simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
- simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
- simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
- simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
- simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
- simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
- simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
- simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
- simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
- simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
- simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
- simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
- simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
- simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
- simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
- simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
- simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
- simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
- simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
- simcon_toolkit/_kit/templates.toml +83 -0
- simcon_toolkit/choices.py +11 -0
- simcon_toolkit/cli.py +381 -0
- simcon_toolkit/generate.py +590 -0
- simcon_toolkit/instructions.py +152 -0
- simcon_toolkit/manifest.py +86 -0
- simcon_toolkit/project.py +356 -0
- simcon_toolkit/wizard.py +160 -0
- simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
- simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
- simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
- 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))
|