stepscribe-cad 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 (126) hide show
  1. stepscribe/__init__.py +28 -0
  2. stepscribe/analysis.py +247 -0
  3. stepscribe/api.py +252 -0
  4. stepscribe/assembly/__init__.py +99 -0
  5. stepscribe/assembly/bom.py +28 -0
  6. stepscribe/assembly/contacts.py +380 -0
  7. stepscribe/assembly/interference.py +48 -0
  8. stepscribe/assembly/joints.py +229 -0
  9. stepscribe/assembly/relations.py +288 -0
  10. stepscribe/assembly/spatial.py +170 -0
  11. stepscribe/assembly/tree.py +176 -0
  12. stepscribe/batch.py +233 -0
  13. stepscribe/cli.py +713 -0
  14. stepscribe/config.py +93 -0
  15. stepscribe/describe/__init__.py +2 -0
  16. stepscribe/describe/assembly_describer.py +152 -0
  17. stepscribe/describe/budget.py +100 -0
  18. stepscribe/describe/part_describer.py +237 -0
  19. stepscribe/describe/phrases.py +45 -0
  20. stepscribe/describe/reconstruct.py +394 -0
  21. stepscribe/exporter.py +496 -0
  22. stepscribe/exporter_text.py +457 -0
  23. stepscribe/features/__init__.py +2 -0
  24. stepscribe/features/bosses.py +99 -0
  25. stepscribe/features/cutout_geometry.py +376 -0
  26. stepscribe/features/cutouts.py +777 -0
  27. stepscribe/features/fillets_chamfers.py +184 -0
  28. stepscribe/features/hole_standards.py +135 -0
  29. stepscribe/features/holes.py +542 -0
  30. stepscribe/features/patterns.py +227 -0
  31. stepscribe/features/shape_class.py +429 -0
  32. stepscribe/features/slots_pockets.py +232 -0
  33. stepscribe/geometry/__init__.py +2 -0
  34. stepscribe/geometry/occ_utils.py +211 -0
  35. stepscribe/geometry/part_geom.py +57 -0
  36. stepscribe/geometry/pointdist.py +113 -0
  37. stepscribe/geometry/properties.py +106 -0
  38. stepscribe/geometry/raycast.py +60 -0
  39. stepscribe/geometry/section_report.py +120 -0
  40. stepscribe/geometry/sections.py +42 -0
  41. stepscribe/geometry/surfaces.py +80 -0
  42. stepscribe/geometry/thickness.py +69 -0
  43. stepscribe/geometry/topology.py +102 -0
  44. stepscribe/geometry/validity.py +18 -0
  45. stepscribe/headless.py +34 -0
  46. stepscribe/io/__init__.py +2 -0
  47. stepscribe/io/discovery.py +22 -0
  48. stepscribe/io/report_writer.py +43 -0
  49. stepscribe/io/step_reader.py +262 -0
  50. stepscribe/jobs.py +156 -0
  51. stepscribe/knowledge/bearings.yaml +45 -0
  52. stepscribe/knowledge/belts.yaml +11 -0
  53. stepscribe/knowledge/boards.yaml +17 -0
  54. stepscribe/knowledge/extrusions.yaml +28 -0
  55. stepscribe/knowledge/gears.yaml +14 -0
  56. stepscribe/knowledge/lead_screws.yaml +10 -0
  57. stepscribe/knowledge/motors.yaml +31 -0
  58. stepscribe/knowledge/process_rules.yaml +101 -0
  59. stepscribe/knowledge/roles.yaml +151 -0
  60. stepscribe/knowledge/screws_iso_metric.yaml +27 -0
  61. stepscribe/knowledge/screws_unified.yaml +15 -0
  62. stepscribe/knowledge/shafts.yaml +16 -0
  63. stepscribe/knowledge/weak_spot_rules.yaml +57 -0
  64. stepscribe/llm/__init__.py +2 -0
  65. stepscribe/llm/adapter.py +122 -0
  66. stepscribe/llm/evaluate.py +121 -0
  67. stepscribe/mcp/__init__.py +2 -0
  68. stepscribe/mcp/server.py +153 -0
  69. stepscribe/mcp/tools.py +558 -0
  70. stepscribe/models/__init__.py +2 -0
  71. stepscribe/models/schema.py +655 -0
  72. stepscribe/pack/__init__.py +2 -0
  73. stepscribe/pack/design_context.py +133 -0
  74. stepscribe/pack/templates/00_READ_ME_FIRST.md.j2 +19 -0
  75. stepscribe/pack/templates/01_overview.md.j2 +52 -0
  76. stepscribe/pack/templates/02_assembly.md.j2 +65 -0
  77. stepscribe/pack/templates/04_design_context.md.j2 +28 -0
  78. stepscribe/pack/templates/context_header.md.j2 +14 -0
  79. stepscribe/pack/templates/part.md.j2 +118 -0
  80. stepscribe/pack/writer.py +280 -0
  81. stepscribe/procutil.py +20 -0
  82. stepscribe/progress.py +154 -0
  83. stepscribe/render/__init__.py +466 -0
  84. stepscribe/render/dimensions.py +151 -0
  85. stepscribe/render/features.py +411 -0
  86. stepscribe/render/kinematics_view.py +108 -0
  87. stepscribe/render/overlay.py +183 -0
  88. stepscribe/render/raster.py +221 -0
  89. stepscribe/render/scene.py +162 -0
  90. stepscribe/render/tessellate.py +87 -0
  91. stepscribe/semantics/__init__.py +56 -0
  92. stepscribe/semantics/bearings.py +93 -0
  93. stepscribe/semantics/extrusions.py +91 -0
  94. stepscribe/semantics/hardware.py +239 -0
  95. stepscribe/semantics/matcher.py +43 -0
  96. stepscribe/semantics/mounts.py +178 -0
  97. stepscribe/semantics/shafts.py +135 -0
  98. stepscribe/ui/__init__.py +2 -0
  99. stepscribe/ui/index.html +460 -0
  100. stepscribe/ui/interview_view.py +99 -0
  101. stepscribe/ui/server.py +395 -0
  102. stepscribe/ui/worker.py +304 -0
  103. stepscribe/understanding/__init__.py +318 -0
  104. stepscribe/understanding/aag.py +333 -0
  105. stepscribe/understanding/answers.py +250 -0
  106. stepscribe/understanding/describe.py +238 -0
  107. stepscribe/understanding/evaluate.py +168 -0
  108. stepscribe/understanding/function.py +781 -0
  109. stepscribe/understanding/interview.py +126 -0
  110. stepscribe/understanding/kinematics.py +1199 -0
  111. stepscribe/understanding/load_path.py +218 -0
  112. stepscribe/understanding/mechanisms.py +617 -0
  113. stepscribe/understanding/ml_roles.py +48 -0
  114. stepscribe/understanding/motion_check.py +181 -0
  115. stepscribe/understanding/process.py +434 -0
  116. stepscribe/understanding/questions.py +357 -0
  117. stepscribe/understanding/session.py +288 -0
  118. stepscribe/understanding/stability.py +251 -0
  119. stepscribe/understanding/structural.py +589 -0
  120. stepscribe/understanding/symmetry.py +215 -0
  121. stepscribe/understanding/weak_spots.py +648 -0
  122. stepscribe_cad-0.1.0.dist-info/METADATA +208 -0
  123. stepscribe_cad-0.1.0.dist-info/RECORD +126 -0
  124. stepscribe_cad-0.1.0.dist-info/WHEEL +4 -0
  125. stepscribe_cad-0.1.0.dist-info/entry_points.txt +2 -0
  126. stepscribe_cad-0.1.0.dist-info/licenses/LICENSE +661 -0
stepscribe/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 idris sadiq
3
+ """stepscribe: turn STEP files into an LLM Context Pack."""
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__", "analyze", "build_context_pack", "export"]
8
+
9
+
10
+ def analyze(*args, **kwargs): # type: ignore[no-untyped-def]
11
+ """Analyze a STEP file or folder; see :func:`stepscribe.api.analyze`."""
12
+ from stepscribe.api import analyze as _analyze
13
+
14
+ return _analyze(*args, **kwargs)
15
+
16
+
17
+ def build_context_pack(*args, **kwargs): # type: ignore[no-untyped-def]
18
+ """Build a context pack; see :func:`stepscribe.api.build_context_pack`."""
19
+ from stepscribe.api import build_context_pack as _b
20
+
21
+ return _b(*args, **kwargs)
22
+
23
+
24
+ def export(*args, **kwargs): # type: ignore[no-untyped-def]
25
+ """Write the portable export; see :func:`stepscribe.exporter.export`."""
26
+ from stepscribe.exporter import export as _export
27
+
28
+ return _export(*args, **kwargs)
stepscribe/analysis.py ADDED
@@ -0,0 +1,247 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 idris sadiq
3
+ """Per-part analysis: ties readers, geometry and feature detectors into ``Part`` models."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, TypeVar
11
+
12
+ import numpy as np
13
+ from OCP.TopAbs import TopAbs_SOLID
14
+ from OCP.TopoDS import TopoDS_Shape
15
+
16
+ from stepscribe.features.bosses import detect_bosses
17
+ from stepscribe.features.cutouts import detect_cutouts
18
+ from stepscribe.features.fillets_chamfers import detect_chamfers, detect_fillets
19
+ from stepscribe.features.holes import detect_holes
20
+ from stepscribe.features.patterns import detect_patterns
21
+ from stepscribe.features.shape_class import classify_shape
22
+ from stepscribe.features.slots_pockets import detect_pockets, detect_slots
23
+ from stepscribe.geometry.occ_utils import unique_subshapes
24
+ from stepscribe.geometry.part_geom import PartGeom
25
+ from stepscribe.geometry.properties import aabb, guess_density, mass_properties, obb
26
+ from stepscribe.geometry.thickness import sample_wall_thickness
27
+ from stepscribe.geometry.topology import topology_summary
28
+ from stepscribe.geometry.validity import is_valid_solid
29
+ from stepscribe.models.schema import (
30
+ Boss,
31
+ Chamfer,
32
+ Cutout,
33
+ CutoutPattern,
34
+ Fillet,
35
+ Hole,
36
+ HolePattern,
37
+ Part,
38
+ Pocket,
39
+ SemanticTag,
40
+ ShapeClass,
41
+ Slot,
42
+ )
43
+
44
+ T = TypeVar("T")
45
+
46
+
47
+ @dataclass
48
+ class AnalyzeOptions:
49
+ """User-facing options shared by the API, CLI and MCP server."""
50
+
51
+ density: float | None = None
52
+ material: str | None = None
53
+ up: str = "+Z"
54
+ front: str = "-Y"
55
+ no_timestamp: bool = False
56
+ check_interference: bool = False
57
+ skip_wall_thickness: bool = False
58
+ understanding: bool = True
59
+ check_motion: bool = False
60
+ motion_budget_s: int = 120
61
+ debug_graphs: bool = False
62
+ precision: int = 4
63
+ extra: dict[str, Any] = field(default_factory=dict)
64
+
65
+
66
+ def split_bodies(name: str, shape: TopoDS_Shape) -> list[tuple[str, TopoDS_Shape]]:
67
+ """One part per solid; several solids become ``<name>_body<n>`` (stable order)."""
68
+ solids = unique_subshapes(shape, TopAbs_SOLID)
69
+ if len(solids) <= 1:
70
+ return [(name, solids[0] if solids else shape)]
71
+ keyed = []
72
+ for s in solids:
73
+ bb = aabb(s)
74
+ keyed.append(
75
+ ((round(bb.min.x, 3), round(bb.min.y, 3), round(bb.min.z, 3), round(bb.size.x, 3)), s)
76
+ )
77
+ keyed.sort(key=lambda kv: kv[0])
78
+ return [(f"{name}_body{i}", s) for i, (_k, s) in enumerate(keyed, 1)]
79
+
80
+
81
+ def _guarded(label: str, default: T, warnings: list[str], fn: Callable[[], T]) -> T:
82
+ """Rule 11: per-entity errors become warnings, never tracebacks."""
83
+ try:
84
+ return fn()
85
+ except Exception as exc: # noqa: BLE001
86
+ warnings.append(f"{label} failed: {type(exc).__name__}: {exc}")
87
+ return default
88
+
89
+
90
+ def content_hash(part: Part) -> str:
91
+ """Hash of rounded volume, area, OBB sizes, face-type counts and sorted hole diameters."""
92
+ parts = [
93
+ f"{part.mass.volume_mm3:.2f}",
94
+ f"{part.mass.surface_area_mm2:.2f}",
95
+ *(f"{s:.2f}" for s in part.obb.size_sorted),
96
+ *(f"{k}={v}" for k, v in sorted(part.topology.face_types.items())),
97
+ *(f"{d:.3f}" for d in sorted(h.diameter_mm for h in part.holes)),
98
+ ]
99
+ return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16] # noqa: S324
100
+
101
+
102
+ @dataclass
103
+ class AnalyzedPart:
104
+ """A ``Part`` plus the live geometry that produced it (needed for assembly analysis)."""
105
+
106
+ part: Part
107
+ geom: PartGeom
108
+
109
+
110
+ def analyze_geom(
111
+ geom: PartGeom,
112
+ source_file: str,
113
+ opts: AnalyzeOptions,
114
+ color: list[float] | None = None,
115
+ semantics: Callable[[PartGeom, Part], list[SemanticTag]] | None = None,
116
+ ) -> Part:
117
+ """Run every analysis step on one solid. Failures become warnings on the part."""
118
+ shape = geom.shape
119
+ w: list[str] = []
120
+ solid = is_valid_solid(shape)
121
+ if not solid:
122
+ w.append("shape is not a valid closed solid; results may be unreliable")
123
+ material = opts.extra.get("part_materials", {}).get(geom.name) or opts.material
124
+ density = opts.density
125
+ if density is None and material:
126
+ guess = guess_density(material)
127
+ if guess is not None:
128
+ density = guess[0]
129
+ else:
130
+ w.append(f"material '{material}' is not a known keyword; mass left unknown")
131
+ mass = mass_properties(shape, geom.name, density, material)
132
+ topo = topology_summary(shape, geom.table)
133
+ bb, ob = aabb(shape), obb(shape)
134
+ holes: list[Hole] = _guarded("hole detection", [], w, lambda: detect_holes(geom))
135
+ patterns: list[HolePattern] = _guarded(
136
+ "pattern detection", [], w, lambda: detect_patterns(holes)
137
+ )
138
+ no_slots: tuple[list[Slot], set[str]] = ([], set())
139
+ slots_used = _guarded("slot detection", no_slots, w, lambda: detect_slots(geom))
140
+ slots, slot_faces = slots_used
141
+ fillets: list[Fillet] = _guarded(
142
+ "fillet detection", [], w, lambda: detect_fillets(geom, slot_faces)
143
+ )
144
+ chamfers: list[Chamfer] = _guarded("chamfer detection", [], w, lambda: detect_chamfers(geom))
145
+ bosses: list[Boss] = _guarded("boss detection", [], w, lambda: detect_bosses(geom, holes))
146
+ pockets: list[Pocket] = _guarded("pocket detection", [], w, lambda: detect_pockets(geom, holes))
147
+ wall = None
148
+ if not opts.skip_wall_thickness:
149
+ wall = _guarded("wall thickness", None, w, lambda: sample_wall_thickness(geom))
150
+ shape_class: ShapeClass = _guarded(
151
+ "shape classification",
152
+ ShapeClass(confidence=0.0, evidence="classification failed", label="other"),
153
+ w,
154
+ lambda: classify_shape(geom, ob, mass.volume_mm3, mass.surface_area_mm2, holes, wall),
155
+ )
156
+ part = Part(
157
+ id="",
158
+ name=geom.name,
159
+ source_file=source_file,
160
+ content_hash="",
161
+ is_valid_solid=solid,
162
+ bbox=bb,
163
+ obb=ob,
164
+ mass=mass,
165
+ topology=topo,
166
+ shape_class=shape_class,
167
+ holes=holes,
168
+ hole_patterns=patterns,
169
+ fillets=fillets,
170
+ chamfers=chamfers,
171
+ bosses=bosses,
172
+ slots=slots,
173
+ pockets=pockets,
174
+ color_rgb=color,
175
+ min_wall_thickness_mm=wall[0] if wall else None,
176
+ warnings=[*geom.warnings, *w],
177
+ )
178
+ no_cutouts: tuple[list[Cutout], list[CutoutPattern]] = ([], [])
179
+ part.cutouts, part.cutout_patterns = _guarded(
180
+ "cutout detection", no_cutouts, part.warnings, lambda: detect_cutouts(geom, part)
181
+ )
182
+ if semantics is not None:
183
+ part.semantic_tags = _guarded(
184
+ "semantic matching", [], part.warnings, lambda: semantics(geom, part)
185
+ )
186
+ if opts.understanding:
187
+ from stepscribe.understanding import part_understanding
188
+
189
+ override = opts.extra.get("part_processes", {}).get(geom.name) or opts.extra.get(
190
+ "default_process"
191
+ )
192
+ part.understanding = _guarded(
193
+ "understanding", None, part.warnings, lambda: part_understanding(geom, part, override)
194
+ )
195
+ part.content_hash = content_hash(part)
196
+ return part
197
+
198
+
199
+ def analyze_brep_file(
200
+ brep_path: str,
201
+ name: str,
202
+ source_file: str,
203
+ opts: AnalyzeOptions,
204
+ color: list[float] | None,
205
+ ) -> tuple[Part, float]:
206
+ """Process-pool entry point: load a solid saved as BREP and analyse it (returns Part, seconds)."""
207
+ import time
208
+
209
+ from OCP.BRep import BRep_Builder
210
+ from OCP.BRepTools import BRepTools
211
+
212
+ from stepscribe.semantics import run_semantics
213
+
214
+ started = time.time()
215
+ shape = TopoDS_Shape()
216
+ if not BRepTools.Read_s(shape, brep_path, BRep_Builder()):
217
+ raise RuntimeError(f"could not reload part '{name}' for parallel analysis")
218
+ part = analyze_geom(PartGeom(name, shape), source_file, opts, color, run_semantics)
219
+ return part, time.time() - started
220
+
221
+
222
+ def worker_count(n_parts: int) -> int:
223
+ """Processes to use for part analysis: env STEPSCRIBE_WORKERS, else CPUs - 1 (min 1)."""
224
+ import os
225
+
226
+ env = os.environ.get("STEPSCRIBE_WORKERS")
227
+ if env and env.isdigit():
228
+ return max(1, min(int(env), n_parts))
229
+ return max(1, min((os.cpu_count() or 2) - 1, n_parts, 8))
230
+
231
+
232
+ def assign_part_ids(parts: list[Part]) -> None:
233
+ """PRT001... sorted by (name, content hash): Rule 6.4."""
234
+ ordered = sorted(parts, key=lambda p: (p.name, p.content_hash))
235
+ for i, p in enumerate(ordered, 1):
236
+ p.id = f"PRT{i:03d}"
237
+
238
+
239
+ __all__ = [
240
+ "AnalyzeOptions",
241
+ "AnalyzedPart",
242
+ "analyze_brep_file",
243
+ "analyze_geom",
244
+ "assign_part_ids",
245
+ "np",
246
+ "split_bodies",
247
+ ]
stepscribe/api.py ADDED
@@ -0,0 +1,252 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 idris sadiq
3
+ """Public Python API: analyse STEP files and build context packs."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from OCP.TopAbs import TopAbs_FACE
13
+ from OCP.TopoDS import TopoDS_Shape
14
+
15
+ from stepscribe import __version__, config
16
+ from stepscribe.analysis import (
17
+ AnalyzedPart,
18
+ AnalyzeOptions,
19
+ analyze_brep_file,
20
+ assign_part_ids,
21
+ split_bodies,
22
+ worker_count,
23
+ )
24
+ from stepscribe.geometry.occ_utils import unique_subshapes
25
+ from stepscribe.geometry.part_geom import PartGeom
26
+ from stepscribe.io.step_reader import StepModel, StepReadError, read_step
27
+ from stepscribe.models.schema import SCHEMA_VERSION, Meta, Part, Report
28
+ from stepscribe.progress import Tracker
29
+
30
+ PARALLEL_MIN_PARTS = 3
31
+ PARALLEL_MIN_FACES = 300 # below this, starting processes costs more than it saves
32
+
33
+
34
+ @dataclass
35
+ class Analysis:
36
+ """A report together with the live geometry behind it."""
37
+
38
+ report: Report
39
+ model: StepModel | None
40
+ parts: list[AnalyzedPart] = field(default_factory=list)
41
+ proto_to_parts: dict[str, list[str]] = field(default_factory=dict) # proto key -> part ids
42
+ understanding_pipeline: Any = None # cached stages, reused when answers change
43
+
44
+
45
+ def _tolerances() -> dict[str, float]:
46
+ return {
47
+ k: float(v)
48
+ for k, v in sorted(vars(config).items())
49
+ if k.isupper() and isinstance(v, (int, float)) and not isinstance(v, bool)
50
+ }
51
+
52
+
53
+ def _meta(files: list[str], unit: str, opts: AnalyzeOptions) -> Meta:
54
+ return Meta(
55
+ schema_version=SCHEMA_VERSION,
56
+ tool_version=__version__,
57
+ generated_at=None if opts.no_timestamp else datetime.now(UTC).isoformat(timespec="seconds"),
58
+ source_files=files,
59
+ original_length_unit=unit,
60
+ tolerances=_tolerances(),
61
+ options={
62
+ "density": opts.density,
63
+ "material": opts.material,
64
+ "up": opts.up,
65
+ "front": opts.front,
66
+ "check_interference": opts.check_interference,
67
+ "understanding": opts.understanding,
68
+ "check_motion": opts.check_motion,
69
+ "debug_graphs": opts.debug_graphs,
70
+ },
71
+ )
72
+
73
+
74
+ def _analyse_bodies(
75
+ bodies: list[tuple[str, str, TopoDS_Shape, list[float] | None]],
76
+ faces: dict[int, int],
77
+ source_file: str,
78
+ opts: AnalyzeOptions,
79
+ tracker: Tracker,
80
+ errors: list[dict[str, object]],
81
+ ) -> list[tuple[str, PartGeom, Part]]:
82
+ """Analyse every body, on a process pool for large assemblies.
83
+
84
+ Serial and parallel runs both analyse each part from a BREP copy, so the report is the same
85
+ whatever the worker count (a reload shifts floats by ~1e-13, which can flip ties such as the
86
+ axes of a square box, so mixing live and reloaded shapes would not be reproducible).
87
+ """
88
+ import multiprocessing as mp
89
+ import tempfile
90
+ from concurrent.futures import ProcessPoolExecutor, as_completed
91
+ from concurrent.futures.process import BrokenProcessPool
92
+
93
+ from OCP.BRepTools import BRepTools
94
+
95
+ workers = worker_count(len(bodies))
96
+ if len(bodies) < PARALLEL_MIN_PARTS or sum(faces.values()) < PARALLEL_MIN_FACES:
97
+ workers = 1
98
+ results: dict[int, Part] = {}
99
+
100
+ def failed(i: int, exc: Exception) -> None:
101
+ name = bodies[i][1]
102
+ errors.append({"file": source_file, "part": name, "error": f"{type(exc).__name__}: {exc}"})
103
+ tracker.part_done(name, faces[id(bodies[i][2])], 0.0)
104
+
105
+ with tempfile.TemporaryDirectory(prefix="stepscribe_") as tmp:
106
+ paths: list[str] = []
107
+ for i, (_k, name, shape, _c) in enumerate(bodies):
108
+ f = str(Path(tmp) / f"b{i}.brep")
109
+ if not BRepTools.Write_s(shape, f):
110
+ raise RuntimeError(f"could not serialise part '{name}'")
111
+ paths.append(f)
112
+
113
+ def args(i: int) -> tuple[str, str, str, AnalyzeOptions, list[float] | None]:
114
+ return paths[i], bodies[i][1], source_file, opts, bodies[i][3]
115
+
116
+ if workers == 1:
117
+ for i in range(len(bodies)):
118
+ try:
119
+ results[i], secs = analyze_brep_file(*args(i))
120
+ except Exception as exc: # noqa: BLE001
121
+ failed(i, exc)
122
+ continue
123
+ tracker.part_done(bodies[i][1], faces[id(bodies[i][2])], secs)
124
+ else:
125
+ order = sorted(range(len(bodies)), key=lambda i: -faces[id(bodies[i][2])]) # big first
126
+ tracker.stage("parts", f"analysing {len(bodies)} parts on {workers} processes")
127
+ retry: list[int] = []
128
+ try:
129
+ with ProcessPoolExecutor(workers, mp_context=mp.get_context("spawn")) as pool:
130
+ futures = {pool.submit(analyze_brep_file, *args(i)): i for i in order}
131
+ for fut in as_completed(futures):
132
+ i = futures[fut]
133
+ try:
134
+ part, secs = fut.result()
135
+ except BrokenProcessPool:
136
+ retry.append(i) # e.g. the caller is a script without a main guard
137
+ continue
138
+ except Exception as exc: # noqa: BLE001
139
+ failed(i, exc)
140
+ continue
141
+ results[i] = part
142
+ # wall-clock share, so the ETA (faces per second) reflects the parallel speed
143
+ tracker.part_done(bodies[i][1], faces[id(bodies[i][2])], secs / workers)
144
+ except (BrokenProcessPool, RuntimeError, OSError):
145
+ retry = [i for i in order if i not in results]
146
+ if retry:
147
+ tracker.stage(
148
+ "parts",
149
+ f"process pool unavailable: analysing {len(retry)} part(s) in this process",
150
+ )
151
+ for i in sorted(retry):
152
+ if i in results:
153
+ continue
154
+ try:
155
+ results[i], secs = analyze_brep_file(*args(i))
156
+ except Exception as exc: # noqa: BLE001
157
+ failed(i, exc)
158
+ continue
159
+ tracker.part_done(bodies[i][1], faces[id(bodies[i][2])], secs)
160
+ return [
161
+ (bodies[i][0], PartGeom(bodies[i][1], bodies[i][2]), results[i]) for i in sorted(results)
162
+ ]
163
+
164
+
165
+ def analyze_full(
166
+ path: str | Path, options: AnalyzeOptions | None = None, tracker: Tracker | None = None
167
+ ) -> Analysis:
168
+ """Analyse one STEP file; unreadable files yield a report with an ``errors`` entry."""
169
+ opts = options or AnalyzeOptions()
170
+ p = Path(path)
171
+ try:
172
+ model = read_step(p)
173
+ except StepReadError as exc:
174
+ report = Report(
175
+ meta=_meta([p.name], "mm", opts), parts=[], errors=[{"file": p.name, "error": str(exc)}]
176
+ )
177
+ return Analysis(report, None)
178
+
179
+ tracker = tracker or Tracker()
180
+ bodies = [
181
+ (key, name, shape, model.protos[key].color_rgb)
182
+ for key in sorted(model.protos)
183
+ for name, shape in split_bodies(model.protos[key].name, model.protos[key].shape)
184
+ ]
185
+ faces = {id(shape): len(unique_subshapes(shape, TopAbs_FACE)) for _k, _n, shape, _c in bodies}
186
+ tracker.plan_parts(
187
+ len(bodies),
188
+ sum(faces.values()),
189
+ model.is_assembly or len(model.instances) > 1 or len(bodies) > 1,
190
+ )
191
+ parts: list[AnalyzedPart] = []
192
+ proto_parts: dict[str, list[AnalyzedPart]] = {}
193
+ warnings = list(model.warnings)
194
+ errors: list[dict[str, object]] = []
195
+ done_parts = _analyse_bodies(bodies, faces, p.name, opts, tracker, errors)
196
+ for key, geom, part in done_parts:
197
+ ap = AnalyzedPart(part, geom)
198
+ parts.append(ap)
199
+ proto_parts.setdefault(key, []).append(ap)
200
+ assign_part_ids([ap.part for ap in parts])
201
+ parts.sort(key=lambda ap: ap.part.id)
202
+ report = Report(
203
+ meta=_meta([p.name], model.original_unit, opts),
204
+ parts=[ap.part for ap in parts],
205
+ warnings=warnings,
206
+ errors=errors,
207
+ )
208
+ analysis = Analysis(
209
+ report, model, parts, {k: [ap.part.id for ap in v] for k, v in proto_parts.items()}
210
+ )
211
+ from stepscribe.assembly import build_assembly
212
+
213
+ tracker.stage("assembly", "building assembly relationships")
214
+ report.assembly = build_assembly(analysis, opts, tracker)
215
+ if opts.understanding:
216
+ from stepscribe.understanding import build_understanding
217
+
218
+ try:
219
+ report.understanding = build_understanding(analysis, opts, tracker)
220
+ except Exception as exc: # noqa: BLE001 - the measured report must survive
221
+ report.warnings.append(f"understanding layer failed: {type(exc).__name__}: {exc}")
222
+ return analysis
223
+
224
+
225
+ def analyze(path: str | Path, options: AnalyzeOptions | None = None) -> Report:
226
+ """Analyse a STEP file and return its :class:`Report`."""
227
+ return analyze_full(path, options).report
228
+
229
+
230
+ def build_context_pack(
231
+ path: str | Path,
232
+ out_dir: str | Path,
233
+ options: AnalyzeOptions | None = None,
234
+ budget_tokens: int = config.DEFAULT_BUDGET_TOKENS,
235
+ context_file: str | Path | None = None,
236
+ images: bool = False,
237
+ tracker: Tracker | None = None,
238
+ ) -> Path:
239
+ """Analyse *path* and write ``<name>_context_pack/`` under *out_dir*; returns its folder."""
240
+ from stepscribe.pack.design_context import apply_context, load_context
241
+ from stepscribe.pack.writer import write_pack
242
+
243
+ opts = options or AnalyzeOptions()
244
+ if context_file:
245
+ ctx = load_context(context_file)
246
+ apply_context(opts, ctx)
247
+ tracker = tracker or Tracker()
248
+ tracker.images_wanted = images
249
+ analysis = analyze_full(path, opts, tracker)
250
+ folder = write_pack(analysis, Path(out_dir), budget_tokens, context_file, images, tracker)
251
+ tracker.finish("pack written")
252
+ return folder
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 idris sadiq
3
+ """Assembly analysis: tree, BOM, contacts, joints, relations, spatial facts."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ import numpy as np
10
+
11
+ from stepscribe.assembly.bom import build_bom
12
+ from stepscribe.assembly.contacts import detect_contacts
13
+ from stepscribe.assembly.interference import find_interferences
14
+ from stepscribe.assembly.joints import detect_joints, shopping_list
15
+ from stepscribe.assembly.relations import build_relations
16
+ from stepscribe.assembly.spatial import spatial_facts
17
+ from stepscribe.assembly.tree import (
18
+ InstanceData,
19
+ build_subassemblies,
20
+ to_schema_instance,
21
+ union_bounds,
22
+ )
23
+ from stepscribe.geometry.properties import vec3
24
+ from stepscribe.models.schema import AssemblyInfo
25
+
26
+ if TYPE_CHECKING:
27
+ from stepscribe.analysis import AnalyzeOptions
28
+ from stepscribe.api import Analysis
29
+ from stepscribe.progress import Tracker
30
+
31
+
32
+ def build_instances(analysis: Analysis) -> list[InstanceData]:
33
+ """One :class:`InstanceData` per placed body, with stable IDs INS001..."""
34
+ assert analysis.model is not None
35
+ by_part = {ap.part.id: ap for ap in analysis.parts}
36
+ rows: list[tuple[str, str, InstanceData]] = []
37
+ for rec in analysis.model.instances:
38
+ part_ids = analysis.proto_to_parts.get(rec.proto_key, [])
39
+ for pid in part_ids:
40
+ ap = by_part[pid]
41
+ path = rec.path if len(part_ids) == 1 else f"{rec.path}/{ap.part.name}"
42
+ rows.append(
43
+ (
44
+ path,
45
+ pid,
46
+ InstanceData("", ap, rec.matrix, path, rec.parent_path, rec.subassembly),
47
+ )
48
+ )
49
+ rows.sort(key=lambda r: (r[0], r[1]))
50
+ out = []
51
+ for n, (_p, _pid, inst) in enumerate(rows, 1):
52
+ inst.id = f"INS{n:03d}"
53
+ out.append(inst)
54
+ return out
55
+
56
+
57
+ def build_assembly(
58
+ analysis: Analysis, opts: AnalyzeOptions, tracker: Tracker | None = None
59
+ ) -> AssemblyInfo | None:
60
+ """Assembly info for files with more than one placed body; None for a single part."""
61
+ if analysis.model is None or not analysis.parts:
62
+ return None
63
+ instances = build_instances(analysis)
64
+ if len(instances) < 2:
65
+ return None
66
+ gbox = union_bounds(instances)
67
+ contacts = detect_contacts(instances, tracker)
68
+ joints = detect_joints(instances)
69
+ relations = build_relations(instances, contacts, joints, opts.up)
70
+ masses = [i.ap.part.mass.mass_g for i in instances]
71
+ known = all(m is not None for m in masses)
72
+ total = float(sum(m for m in masses if m is not None)) if known else None
73
+ com = None
74
+ if known and total:
75
+ weights = np.array([m or 0.0 for m in masses])
76
+ cents = np.array([i.to_global_point(_centroid(i)) for i in instances])
77
+ com = vec3((weights[:, None] * cents).sum(axis=0) / total)
78
+ return AssemblyInfo(
79
+ root_name=analysis.model.root_name,
80
+ up_axis=opts.up,
81
+ front_axis=opts.front,
82
+ instances=[to_schema_instance(i, None) for i in instances],
83
+ subassemblies=build_subassemblies(instances),
84
+ bom=build_bom(instances),
85
+ global_bbox=gbox,
86
+ total_mass_g=total,
87
+ center_of_mass=com,
88
+ contacts=contacts,
89
+ fastener_joints=joints,
90
+ relations=relations,
91
+ spatial_facts=spatial_facts(instances, contacts, gbox, opts.up, opts.front),
92
+ interferences=find_interferences(instances) if opts.check_interference else [],
93
+ fastener_shopping_list=shopping_list(joints),
94
+ )
95
+
96
+
97
+ def _centroid(inst: InstanceData) -> np.ndarray:
98
+ c = inst.ap.part.mass.centroid
99
+ return np.array([c.x, c.y, c.z])
@@ -0,0 +1,28 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 idris sadiq
3
+ """Bill of materials: instances grouped by part ID and content hash."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from stepscribe.assembly.tree import InstanceData
8
+ from stepscribe.models.schema import BOMLine
9
+
10
+
11
+ def build_bom(instances: list[InstanceData]) -> list[BOMLine]:
12
+ """One line per unique part (grouped by ``part_id`` + ``content_hash``), sorted by part ID."""
13
+ groups: dict[tuple[str, str], list[InstanceData]] = {}
14
+ for inst in instances:
15
+ groups.setdefault((inst.part_id, inst.ap.part.content_hash), []).append(inst)
16
+ lines: list[BOMLine] = []
17
+ for (pid, _hash), members in sorted(groups.items()):
18
+ part = members[0].ap.part
19
+ lines.append(
20
+ BOMLine(
21
+ part_id=pid,
22
+ name=part.name,
23
+ quantity=len(members),
24
+ mass_each_g=part.mass.mass_g,
25
+ category="purchased_hardware" if part.likely_purchased_hardware else "fabricated",
26
+ )
27
+ )
28
+ return lines