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,235 @@
1
+ """Thin live REST client for the Cloud Solver Service (Simulation Platform).
2
+
3
+ Mirrors the endpoints exercised by the canonical runner
4
+ ``cloud-solver/scripts/run_ai_filling.py`` and adds the **project / group**
5
+ endpoints the one-click demo needs. Every call goes over the wire to a real
6
+ deployed (or local) platform; there is no dummy mode here -- ``ai_demo.py`` is a
7
+ *live* end-to-end demo.
8
+
9
+ Endpoints (all under the ``/api/v1`` prefix, which the caller bakes into
10
+ ``base_url``):
11
+
12
+ GET /projects -> list (dedupe "AI DEMO" by name)
13
+ POST /projects -> create project
14
+ POST /projects/{id}/groups -> create group
15
+ GET /projects/{id}/runs -> runs linked to the project
16
+ POST /projects/{id}/events -> append a note to the decision log
17
+ POST /geometries/upload-url -> {geometry_id, presigned_put_url}
18
+ PUT <presigned S3 url> -> upload the .cfex bytes
19
+ GET /materials?q=... -> material catalogue search
20
+ GET /material-versions/{version_id} -> temps + process recommendations
21
+ POST /simulations/filling -> AI filling (SYNCHRONOUS; carries project_id/group_id)
22
+ GET /simulations/filling/{id}/download-url -> fresh presigned result download
23
+ GET <presigned S3 url> -> the result HDF5 bytes
24
+
25
+ Auth: an Auth0 Bearer JWT (see ``cloud_auth.get_access_token``) attached to every
26
+ platform call. Presigned S3 PUT/GET URLs are absolute and need no bearer; the PUT
27
+ must send ``Content-Type: application/octet-stream`` exactly or S3 rejects it.
28
+
29
+ Units follow the API: temperatures Kelvin, pressure Pascals, spatial mm, flow cm3/s.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Callable
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ import httpx
40
+
41
+ # Filling is synchronous server-side: warm ~2-3 s, cold start can take longer.
42
+ # Give each POST generous headroom (the API gateway caps it earlier anyway).
43
+ FILLING_TIMEOUT_S = 120.0
44
+ DEFAULT_TIMEOUT_S = 60.0
45
+
46
+
47
+ @dataclass
48
+ class PlatformAPI:
49
+ """REST client for the cloud platform, holding one connection and one bearer token."""
50
+
51
+ base_url: str # includes the /api/v1 prefix
52
+ token: str
53
+ logger: Callable[[str], None] = print
54
+
55
+ def __post_init__(self) -> None:
56
+ """Open the connection the rest of this object reuses."""
57
+ self._client = httpx.Client(
58
+ base_url=self.base_url.rstrip("/"),
59
+ headers={"Authorization": f"Bearer {self.token}"},
60
+ timeout=DEFAULT_TIMEOUT_S,
61
+ )
62
+
63
+ # -- plumbing ------------------------------------------------------------
64
+ def _req(self, method: str, path: str, *, timeout: float | None = None, **kw: object) -> dict:
65
+ self.logger(f" -> {method} {path}")
66
+ resp = self._client.request(method, path, timeout=timeout or DEFAULT_TIMEOUT_S, **kw)
67
+ self.logger(f" <- {resp.status_code} {resp.reason_phrase}")
68
+ resp.raise_for_status()
69
+ return resp.json() if resp.content else {}
70
+
71
+ def close(self) -> None:
72
+ """Close the underlying connection."""
73
+ self._client.close()
74
+
75
+ def __enter__(self) -> PlatformAPI:
76
+ """Enter a ``with`` block; the client is already open."""
77
+ return self
78
+
79
+ def __exit__(self, *exc: object) -> None:
80
+ """Leave a ``with`` block, closing the connection. Exceptions propagate."""
81
+ self.close()
82
+
83
+ # -- projects / groups ---------------------------------------------------
84
+ def list_projects(self) -> list[dict]:
85
+ """Every project this token can see."""
86
+ return self._req("GET", "/projects").get("projects", [])
87
+
88
+ def get_or_create_project(self, name: str, *, customer: str | None = None, notes: str | None = None) -> dict:
89
+ """Reuse an existing project with this exact name, else create one.
90
+
91
+ Keeps re-runs of the demo from piling up duplicate "AI DEMO" projects.
92
+ """
93
+ for p in self.list_projects():
94
+ if p.get("name") == name:
95
+ self.logger(f" reusing existing project '{name}' (id={p['id']})")
96
+ return p
97
+ body: dict[str, Any] = {"name": name}
98
+ if customer:
99
+ body["customer"] = customer
100
+ if notes:
101
+ body["notes"] = notes
102
+ proj = self._req("POST", "/projects", json=body)
103
+ self.logger(f" created project '{name}' (id={proj['id']})")
104
+ return proj
105
+
106
+ def create_group(self, project_id: str, name: str, *, description: str | None = None) -> dict:
107
+ """Create a run group inside a project, so a study's phases stay separable."""
108
+ body: dict[str, Any] = {"name": name}
109
+ if description:
110
+ body["description"] = description
111
+ grp = self._req("POST", f"/projects/{project_id}/groups", json=body)
112
+ self.logger(f" created group '{name}' (id={grp['id']})")
113
+ return grp
114
+
115
+ def list_project_runs(self, project_id: str) -> dict:
116
+ """Every run recorded against a project."""
117
+ return self._req("GET", f"/projects/{project_id}/runs")
118
+
119
+ def add_note(self, project_id: str, message: str, *, title: str | None = None) -> dict | None:
120
+ """Record a decision as a project note, which the web UI shows on the project page.
121
+
122
+ The platform's request model is a single required field, ``note``. Extra keys are
123
+ ignored rather than refused, so a body that omits ``note`` is a 422 no matter what
124
+ else it carries - which is why a title is prefixed into the text rather than sent
125
+ beside it, where it would be silently dropped. Never let a refused note abort the
126
+ run that produced it: the decision is worth less than the result.
127
+ """
128
+ note = f"{title}: {message}" if title else message
129
+ try:
130
+ return self._req("POST", f"/projects/{project_id}/events", json={"note": note})
131
+ # Deliberately broad: one dead run must not kill the batch.
132
+ except Exception as exc:
133
+ self.logger(f" (project note not recorded: {type(exc).__name__}: {exc})")
134
+ return None
135
+
136
+ # -- geometry ------------------------------------------------------------
137
+ def request_geometry_upload(self, filename: str, description: str | None = None) -> dict:
138
+ """Register a geometry and get the presigned URL to upload its ``.cfex`` to."""
139
+ body: dict[str, Any] = {"filename": filename}
140
+ if description:
141
+ body["description"] = description
142
+ return self._req("POST", "/geometries/upload-url", json=body)
143
+
144
+ def put_cfex(self, presigned_put_url: str, cfex_path: str | Path) -> None:
145
+ """Upload a ``.cfex`` to the presigned URL from ``request_geometry_upload``."""
146
+ data = Path(cfex_path).read_bytes()
147
+ self.logger(f" -> PUT (presigned S3) {len(data)} bytes")
148
+ # The geometry service presigns Content-Type: application/octet-stream;
149
+ # the PUT must send that exact header or S3 returns 403 SignatureDoesNotMatch.
150
+ r = httpx.put(
151
+ presigned_put_url, content=data, headers={"Content-Type": "application/octet-stream"}, timeout=300
152
+ )
153
+ self.logger(f" <- {r.status_code} (uploaded)")
154
+ r.raise_for_status()
155
+
156
+ # -- materials -----------------------------------------------------------
157
+ def search_materials(self, query: str, *, limit: int = 20) -> list[dict]:
158
+ """Catalogue materials matching a free-text query."""
159
+ return self._req("GET", "/materials", params={"q": query, "limit": limit}).get("materials", [])
160
+
161
+ def find_material(self, trade_name: str) -> dict:
162
+ """Find a catalogue material by trade name (exact, case-insensitive; else partial)."""
163
+ candidates = self.search_materials(trade_name)
164
+ exact = [m for m in candidates if m.get("trade_name", "").lower() == trade_name.lower()]
165
+ match = exact or [m for m in candidates if trade_name.lower() in m.get("trade_name", "").lower()] or candidates
166
+ if not match:
167
+ raise RuntimeError(f"No material matching '{trade_name}' in the catalogue.")
168
+ return match[0]
169
+
170
+ def get_material_version(self, version_id: str) -> dict:
171
+ """One catalogue material version, by id."""
172
+ return self._req("GET", f"/material-versions/{version_id}")
173
+
174
+ # -- filling simulation --------------------------------------------------
175
+ def run_filling(
176
+ self,
177
+ *,
178
+ geometry_id: str,
179
+ material_id: str,
180
+ gate_positions_mm: list[list[float]],
181
+ melt_temperature_K: float,
182
+ wall_temperature_K: float,
183
+ flow_rate_cm3_s: float,
184
+ estimated_filling_time_s: float,
185
+ model_version: str,
186
+ num_timesteps: int = 50,
187
+ htc_W_m2K: float = 1000.0,
188
+ label: str | None = None,
189
+ project_id: str | None = None,
190
+ group_id: str | None = None,
191
+ solver_type: str = "ai",
192
+ ) -> dict:
193
+ """POST /simulations/filling -- AI path is synchronous, returns the full result.
194
+
195
+ Setting ``project_id`` / ``group_id`` links the run to the project + group at
196
+ submit time (no separate /runs/link call needed). ``solver_type`` defaults to
197
+ the synchronous "ai" path; "numerical" requests the physics solver (which may
198
+ run async / be unavailable on this endpoint).
199
+ """
200
+ body: dict[str, Any] = {
201
+ "solver_type": solver_type,
202
+ "model_version": model_version,
203
+ "geometry_id": geometry_id,
204
+ "material_id": material_id,
205
+ "process_params": {
206
+ "melt_temperature_K": melt_temperature_K,
207
+ "wall_temperature_K": wall_temperature_K,
208
+ "flow_rate_cm3_s": flow_rate_cm3_s,
209
+ },
210
+ "gate_positions_mm": gate_positions_mm,
211
+ "estimated_filling_time_s": estimated_filling_time_s,
212
+ "num_timesteps": num_timesteps,
213
+ "htc_W_m2K": htc_W_m2K,
214
+ "label": label,
215
+ "project_id": project_id,
216
+ "group_id": group_id,
217
+ }
218
+ return self._req("POST", "/simulations/filling", json=body, timeout=FILLING_TIMEOUT_S)
219
+
220
+ def refresh_download_url(self, simulation_id: str) -> dict:
221
+ """A fresh presigned GET for the result (the URL from the POST may have expired)."""
222
+ return self._req("GET", f"/simulations/filling/{simulation_id}/download-url")
223
+
224
+ def download_to(self, url: str, dest: str | Path) -> Path:
225
+ """Stream a presigned URL to a local file, creating parent directories."""
226
+ dest = Path(dest)
227
+ dest.parent.mkdir(parents=True, exist_ok=True)
228
+ self.logger(f" -> GET (presigned S3) {dest.name}")
229
+ with httpx.stream("GET", url, timeout=300) as r:
230
+ r.raise_for_status()
231
+ with open(dest, "wb") as fh:
232
+ for chunk in r.iter_bytes(1 << 20):
233
+ fh.write(chunk)
234
+ self.logger(f" <- 200 saved {dest.name} [{dest.stat().st_size} bytes]")
235
+ return dest
@@ -0,0 +1,5 @@
1
+ """Geometry reading shared by every template.
2
+
3
+ Deliberately empty of imports: a file that wants one module should pay for one
4
+ module, not for every module in the package and their dependencies.
5
+ """
@@ -0,0 +1,210 @@
1
+ """Mesh an STL into a ``.cfex`` and place gates on real mesh nodes.
2
+
3
+ The AI filling solver requires every gate to sit on an actual mesh node, never an
4
+ interpolated point, and the SDK's mesh objects expose no coordinate getter — so the
5
+ written ``.cfex`` is the only place the node positions can be read back from.
6
+
7
+ ``cadmould`` is imported inside the functions that need it, so the pure-geometry
8
+ helpers here stay usable on a machine with no licence and no wheel.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Sequence
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ from cadmould_geometry import stl
20
+
21
+ __all__ = ["Geometry", "gate_line", "parse_cfex_nodes", "prepare_geometry", "snap"]
22
+
23
+
24
+ def _sdk_classes() -> dict:
25
+ """Resolve the SDK entry points this module uses, across wheel layouts.
26
+
27
+ rc2 grouped meshing under ``cadmould.mesh`` (``mesh.Mesh``, ``mesh.Mesher``,
28
+ ``mesh.io.CfexWriter``); rc3 moved them to ``cadmould.domain`` / ``cadmould.tools``
29
+ / ``cadmould.io``. Same constructors and call chains, just relocated.
30
+
31
+ This is the module's only import of the licensed wheel, so it is also where a
32
+ machine without it is told so.
33
+
34
+ Returns:
35
+ The five SDK entry points this module uses, keyed by name.
36
+
37
+ Raises:
38
+ ModuleNotFoundError: If the licensed wheel is not installed.
39
+ """
40
+ try:
41
+ # Imported here, not at module level: the licensed wheel is absent on offline
42
+ # machines, and everything else in this module must still work without it.
43
+ import cadmould
44
+ except ImportError as exc:
45
+ raise ModuleNotFoundError(
46
+ "meshing needs the licensed cadmould wheel, which is not installed. "
47
+ "See docs/SETUP.md. Reading an STL and placing gates on an existing mesh "
48
+ "need no licence; only writing a .cfex does."
49
+ ) from exc
50
+
51
+ mesh_mod = getattr(cadmould, "mesh", None)
52
+ io_mod = getattr(mesh_mod, "io", None) if mesh_mod else None
53
+ return {
54
+ "Mesh": getattr(mesh_mod, "Mesh", None) or cadmould.domain.Mesh,
55
+ "Mesher": getattr(mesh_mod, "Mesher", None) or cadmould.tools.Mesher,
56
+ "CfexWriter": getattr(io_mod, "CfexWriter", None) or cadmould.io.CfexWriter,
57
+ "suggest_gates": cadmould.tools.suggest_gates,
58
+ "Session": cadmould.Session,
59
+ }
60
+
61
+
62
+ def parse_cfex_nodes(cfex_path: str | Path) -> np.ndarray:
63
+ """Read the node coordinates from the ``#NODDAT`` block of a ``.cfex``.
64
+
65
+ Args:
66
+ cfex_path: The ``.cfex`` file to read.
67
+
68
+ Returns:
69
+ An ``(N, 3)`` array of node coordinates in millimetres.
70
+ """
71
+ coords: list[tuple[float, float, float]] = []
72
+ n_expected = None
73
+ with open(cfex_path) as fh:
74
+ it = iter(fh)
75
+ for line in it:
76
+ if line.startswith("#NODDAT"):
77
+ n_expected = int(line.strip().split(",")[1])
78
+ break
79
+ for line in it:
80
+ if line.startswith("#"):
81
+ break
82
+ parts = line.split(",")
83
+ if len(parts) < 4:
84
+ break
85
+ coords.append((float(parts[1]), float(parts[2]), float(parts[3])))
86
+ if n_expected and len(coords) >= n_expected:
87
+ break
88
+ return np.asarray(coords, dtype=np.float64)
89
+
90
+
91
+ @dataclass
92
+ class Geometry:
93
+ """A meshed part: where its nodes are, how big it is, and which way it runs."""
94
+
95
+ cfex_path: Path
96
+ node_coords: np.ndarray
97
+ suggested_gate: list[float]
98
+ volume_cm3: float
99
+ long_axis: int
100
+
101
+ @property
102
+ def bounds(self) -> np.ndarray:
103
+ """The axis-aligned bounding box as a ``(3, 2)`` min/max array."""
104
+ c = self.node_coords
105
+ return np.array([c.min(0), c.max(0)]).T
106
+
107
+
108
+ def prepare_geometry(
109
+ stl_path: str | Path,
110
+ cfex_out: str | Path,
111
+ scale: float = 1.0,
112
+ logger: Callable[[str], None] = print,
113
+ ) -> Geometry:
114
+ """Mesh an STL, write the ``.cfex``, and read its node coordinates back.
115
+
116
+ Args:
117
+ stl_path: The surface mesh to start from.
118
+ cfex_out: Where to write the meshed ``.cfex``.
119
+ scale: Multiplier applied to the STL coordinates, for files not in millimetres.
120
+ logger: Called with one progress line.
121
+
122
+ Returns:
123
+ The meshed geometry, including the SDK's own suggested gate.
124
+ """
125
+ stl_path, cfex_out = Path(stl_path), Path(cfex_out)
126
+ # Resolved before the STL is parsed, so an unlicensed machine is told immediately
127
+ # rather than after the read.
128
+ cm = _sdk_classes()
129
+
130
+ raw_pts, faces = stl.read_stl(stl_path)
131
+ pts = raw_pts * scale
132
+ volume_cm3 = stl.volume_mm3(raw_pts, faces) / 1000.0 * (scale**3)
133
+
134
+ with cm["Session"].user_based():
135
+ logger(" cadmould licence session opened (user_based); a failed sign-in prints above")
136
+ raw = cm["Mesh"](pts.ravel(), faces.ravel())
137
+ meshed = cm["Mesher"](raw).with_surface_triangulation().with_thickness_and_w2w().build()
138
+ gates = cm["suggest_gates"](meshed)
139
+ suggested = [[float(p.x), float(p.y), float(p.z)] for p in gates.points][0]
140
+ cfex_out.parent.mkdir(parents=True, exist_ok=True)
141
+ cm["CfexWriter"]().save(meshed, str(cfex_out))
142
+
143
+ node_coords = parse_cfex_nodes(cfex_out)
144
+ dims = node_coords.max(0) - node_coords.min(0)
145
+ long_axis = int(np.argmax(dims))
146
+ logger(
147
+ f" meshed {meshed.node_count()} nodes / {meshed.element_count()} elems; "
148
+ f"cfex has {len(node_coords)} nodes; volume {volume_cm3:.1f} cm^3; "
149
+ f"long axis = {'xyz'[long_axis]} ({dims[long_axis]:.0f} mm)"
150
+ )
151
+ return Geometry(cfex_out, node_coords, suggested, volume_cm3, long_axis)
152
+
153
+
154
+ def snap(targets: np.ndarray, node_coords: np.ndarray) -> list[list[float]]:
155
+ """Move each target point onto the nearest real mesh node, dropping duplicates.
156
+
157
+ Args:
158
+ targets: The points to snap, as an ``(M, 3)`` array or a single point.
159
+ node_coords: The mesh nodes to snap onto.
160
+
161
+ Returns:
162
+ One position per distinct node, in the order their targets were given. Two targets
163
+ whose nearest node is the same node yield one position, because two gates cannot
164
+ share a node.
165
+ """
166
+ out: list[list[float]] = []
167
+ seen: set[int] = set()
168
+ for t in np.atleast_2d(targets):
169
+ j = int(np.argmin(np.linalg.norm(node_coords - t, axis=1)))
170
+ # Keyed on the node index. Keying on the rounded position merges two nodes that
171
+ # agree to a micrometre, which silently returns fewer gates than were asked for.
172
+ if j not in seen:
173
+ seen.add(j)
174
+ out.append([float(v) for v in node_coords[j]])
175
+ return out
176
+
177
+
178
+ def gate_line(
179
+ geo: Geometry,
180
+ fractions: Sequence[float],
181
+ ref_xz: tuple[float, float] | None = None,
182
+ ) -> list[list[float]]:
183
+ """Place gates along the part's long axis at the given fractional stations.
184
+
185
+ The off-axis coordinates default to the SDK-suggested gate's, so every gate sits on
186
+ the same feed face and width centre line and only its length station varies.
187
+
188
+ Args:
189
+ geo: The meshed part.
190
+ fractions: Positions along the long axis, 0.0 at one end and 1.0 at the other.
191
+ ref_xz: Fixed off-axis coordinates, overriding the suggested gate's.
192
+
193
+ Returns:
194
+ One position per fraction, each snapped to a real mesh node.
195
+ """
196
+ axis = geo.long_axis
197
+ lo, hi = geo.node_coords[:, axis].min(), geo.node_coords[:, axis].max()
198
+ others = [i for i in range(3) if i != axis]
199
+ if ref_xz is None:
200
+ ref = {others[0]: geo.suggested_gate[others[0]], others[1]: geo.suggested_gate[others[1]]}
201
+ else:
202
+ ref = {others[0]: ref_xz[0], others[1]: ref_xz[1]}
203
+ targets = []
204
+ for fr in fractions:
205
+ t = [0.0, 0.0, 0.0]
206
+ t[axis] = lo + fr * (hi - lo)
207
+ t[others[0]] = ref[others[0]]
208
+ t[others[1]] = ref[others[1]]
209
+ targets.append(t)
210
+ return snap(np.array(targets), geo.node_coords)
@@ -0,0 +1,168 @@
1
+ """Read an STL into the vertex and face arrays the meshing pipeline expects.
2
+
3
+ This replaces a three-line idiom that appeared in every template::
4
+
5
+ surf = pv.read(str(STL)).triangulate()
6
+ pts = np.asarray(surf.points, np.float64)
7
+ faces = surf.faces.reshape(-1, 4)[:, 1:].astype(np.int32)
8
+
9
+ PyVista pulls in VTK, which is roughly 600 MB installed. Nothing here draws
10
+ anything, so the templates were paying a graphics stack to parse a header
11
+ followed by a flat list of triangles.
12
+
13
+ **The output is byte-identical to what PyVista returned**, which is a hard
14
+ requirement rather than a nicety: a gate is stored as an index into the vertex
15
+ array, so a reader that returns the same points in a different order silently
16
+ moves every gate in the kit. `tests/test_stl_reader.py` pins this against
17
+ values recorded from PyVista's own output.
18
+
19
+ Three things make that identity work, and all three are easy to get wrong:
20
+
21
+ * **Duplicate corners are folded.** An STL repeats a shared corner once per
22
+ triangle, and VTK merges them on exact float equality, numbering points in
23
+ the order they are first met while scanning triangles.
24
+ * **Degenerate triangles are dropped** after folding — VTK discards a triangle
25
+ whose corners are not three distinct points, so a reader that keeps them
26
+ returns a longer face array.
27
+ * **A binary file may open with the word "solid"**, so the text-versus-binary
28
+ decision is made on the file's length against its triangle count, never on
29
+ that prefix.
30
+
31
+ `trimesh` is used for the parsing alone — the file-format detection and its
32
+ overflow guard — and costs 3 MB with numpy as its only dependency. The folding
33
+ and the degenerate-face rule are done here, because trimesh's own versions of
34
+ both differ from what the kit assumed; :func:`read_stl` says how.
35
+
36
+ The derived quantities below are plain numpy over the arrays, so they need no
37
+ reader at all and can be tested without one.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+ import trimesh
46
+
47
+ __all__ = [
48
+ "body_count",
49
+ "open_edge_count",
50
+ "read_stl",
51
+ "surface_area_mm2",
52
+ "volume_mm3",
53
+ ]
54
+
55
+
56
+ def read_stl(path: str | Path) -> tuple[np.ndarray, np.ndarray]:
57
+ """Read a binary or ASCII STL.
58
+
59
+ trimesh parses the file and nothing else. Its own cleanup is switched off with
60
+ ``process=False``, because it does not match what the templates read before:
61
+
62
+ * it merges corners that fall in the same **tolerance bucket** (``1e-8``)
63
+ rather than corners that are exactly equal, so two distinct points closer
64
+ than that become one and every later index shifts. Coordinates smaller than
65
+ about 0.08 are where this bites, because float32 spacing is finer than the
66
+ tolerance there — which is to say, anywhere a part crosses the origin.
67
+ * it keeps a face whose corners collapse to fewer than three distinct points
68
+ once folding has happened.
69
+
70
+ Folding on exact equality in first-encounter order, then dropping collapsed
71
+ faces, is what the graphics stack did and what the rest of the kit assumes.
72
+
73
+ Args:
74
+ path: The ``.stl`` file to read.
75
+
76
+ Returns:
77
+ ``(vertices, faces)`` — an ``(N, 3)`` float64 array of point positions in
78
+ the file's own units, and an ``(M, 3)`` int32 array of indices into it.
79
+ """
80
+ soup = trimesh.load_mesh(str(path), file_type="stl", process=False)
81
+ corners = np.asarray(soup.vertices, dtype=np.float32)[np.asarray(soup.faces, dtype=np.int64)]
82
+ return _fold(corners.reshape(-1, 3))
83
+
84
+
85
+ def _fold(corners: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
86
+ """Fold a flat corner list into unique points plus the faces indexing them.
87
+
88
+ Uniqueness is decided on the float32 bit patterns, so it is exact equality and
89
+ carries no tolerance. Points are numbered in the order they are first met.
90
+ """
91
+ view = np.ascontiguousarray(corners).view([("x", "<f4"), ("y", "<f4"), ("z", "<f4")]).ravel()
92
+ _, first_seen, inverse = np.unique(view, return_index=True, return_inverse=True)
93
+
94
+ order = np.argsort(first_seen)
95
+ renumber = np.empty(len(order), dtype=np.int64)
96
+ renumber[order] = np.arange(len(order))
97
+
98
+ vertices = corners[first_seen[order]].astype(np.float64)
99
+ faces = renumber[inverse].reshape(-1, 3)
100
+ distinct = (faces[:, 0] != faces[:, 1]) & (faces[:, 0] != faces[:, 2]) & (faces[:, 1] != faces[:, 2])
101
+ return vertices, faces[distinct].astype(np.int32)
102
+
103
+
104
+ def volume_mm3(vertices: np.ndarray, faces: np.ndarray) -> float:
105
+ """Enclosed volume, by the divergence theorem over the triangles.
106
+
107
+ Absolute, matching what the templates read before: a mesh whose normals all
108
+ point inward would otherwise return a negative number where every caller
109
+ expects a positive one. Meaningless on a surface that is not closed — check
110
+ :func:`open_edge_count` first if that is in doubt.
111
+ """
112
+ a, b, c = _corners(vertices, faces)
113
+ return float(abs(np.einsum("ij,ij->i", a, np.cross(b, c)).sum() / 6.0))
114
+
115
+
116
+ def surface_area_mm2(vertices: np.ndarray, faces: np.ndarray) -> float:
117
+ """Total area of the triangles."""
118
+ a, b, c = _corners(vertices, faces)
119
+ return float(np.linalg.norm(np.cross(b - a, c - a), axis=1).sum() / 2.0)
120
+
121
+
122
+ def open_edge_count(vertices: np.ndarray, faces: np.ndarray) -> int:
123
+ """Edges that leave the surface open, so ``0`` means watertight.
124
+
125
+ Counts both boundary edges, used by one triangle, and non-manifold edges,
126
+ used by three or more. Both are defects and the graphics stack counted both;
127
+ counting only the first under-reports a mesh where three faces meet along one
128
+ edge, which is a shape that reads as closed and is not.
129
+ """
130
+ _, counts = np.unique(_edges(faces), axis=0, return_counts=True)
131
+ return int(((counts == 1) | (counts >= 3)).sum())
132
+
133
+
134
+ def body_count(vertices: np.ndarray, faces: np.ndarray) -> int:
135
+ """How many separate connected bodies the mesh describes.
136
+
137
+ Reported because a part file holding one body has been described as holding
138
+ several, and the claim travelled a long way before anyone checked it.
139
+
140
+ Union-find rather than a graph library: the obvious call needs SciPy, and
141
+ pulling in SciPy for this would cost more than the graphics stack this module
142
+ exists to remove.
143
+ """
144
+ parent = np.arange(len(vertices))
145
+
146
+ def find(node: int) -> int:
147
+ while parent[node] != node:
148
+ parent[node] = parent[parent[node]]
149
+ node = parent[node]
150
+ return int(node)
151
+
152
+ for left, right in _edges(faces):
153
+ root_left, root_right = find(left), find(right)
154
+ if root_left != root_right:
155
+ parent[root_left] = root_right
156
+ return len({find(node) for node in range(len(vertices))})
157
+
158
+
159
+ def _corners(vertices: np.ndarray, faces: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
160
+ """The three corner positions of every triangle, as three parallel arrays."""
161
+ indices = np.asarray(faces, dtype=np.int64)
162
+ return vertices[indices[:, 0]], vertices[indices[:, 1]], vertices[indices[:, 2]]
163
+
164
+
165
+ def _edges(faces: np.ndarray) -> np.ndarray:
166
+ """Every triangle edge as a sorted index pair, so the two directions agree."""
167
+ indices = np.asarray(faces, dtype=np.int64)
168
+ return np.sort(np.vstack([indices[:, [0, 1]], indices[:, [1, 2]], indices[:, [2, 0]]]), axis=1)
@@ -0,0 +1,30 @@
1
+ """Tiny toolkit for working with Simcon AI-Solver prediction files.
2
+
3
+ from cadmould_results import PredictionResult, PRESSURE, TEMPERATURE
4
+
5
+ See ``cadmould_results.loader`` for the data-model documentation.
6
+ """
7
+
8
+ from .loader import (
9
+ ALL_FIELDS,
10
+ DEGREE_OF_FILLING,
11
+ LAYERED_FIELDS,
12
+ NODE_FIELDS,
13
+ PRESSURE,
14
+ SHEAR_LOG,
15
+ TEMPERATURE,
16
+ PredictionResult,
17
+ ProcessInputs,
18
+ )
19
+
20
+ __all__ = [
21
+ "PredictionResult",
22
+ "ProcessInputs",
23
+ "ALL_FIELDS",
24
+ "NODE_FIELDS",
25
+ "LAYERED_FIELDS",
26
+ "DEGREE_OF_FILLING",
27
+ "PRESSURE",
28
+ "TEMPERATURE",
29
+ "SHEAR_LOG",
30
+ ]