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.
- stepscribe/__init__.py +28 -0
- stepscribe/analysis.py +247 -0
- stepscribe/api.py +252 -0
- stepscribe/assembly/__init__.py +99 -0
- stepscribe/assembly/bom.py +28 -0
- stepscribe/assembly/contacts.py +380 -0
- stepscribe/assembly/interference.py +48 -0
- stepscribe/assembly/joints.py +229 -0
- stepscribe/assembly/relations.py +288 -0
- stepscribe/assembly/spatial.py +170 -0
- stepscribe/assembly/tree.py +176 -0
- stepscribe/batch.py +233 -0
- stepscribe/cli.py +713 -0
- stepscribe/config.py +93 -0
- stepscribe/describe/__init__.py +2 -0
- stepscribe/describe/assembly_describer.py +152 -0
- stepscribe/describe/budget.py +100 -0
- stepscribe/describe/part_describer.py +237 -0
- stepscribe/describe/phrases.py +45 -0
- stepscribe/describe/reconstruct.py +394 -0
- stepscribe/exporter.py +496 -0
- stepscribe/exporter_text.py +457 -0
- stepscribe/features/__init__.py +2 -0
- stepscribe/features/bosses.py +99 -0
- stepscribe/features/cutout_geometry.py +376 -0
- stepscribe/features/cutouts.py +777 -0
- stepscribe/features/fillets_chamfers.py +184 -0
- stepscribe/features/hole_standards.py +135 -0
- stepscribe/features/holes.py +542 -0
- stepscribe/features/patterns.py +227 -0
- stepscribe/features/shape_class.py +429 -0
- stepscribe/features/slots_pockets.py +232 -0
- stepscribe/geometry/__init__.py +2 -0
- stepscribe/geometry/occ_utils.py +211 -0
- stepscribe/geometry/part_geom.py +57 -0
- stepscribe/geometry/pointdist.py +113 -0
- stepscribe/geometry/properties.py +106 -0
- stepscribe/geometry/raycast.py +60 -0
- stepscribe/geometry/section_report.py +120 -0
- stepscribe/geometry/sections.py +42 -0
- stepscribe/geometry/surfaces.py +80 -0
- stepscribe/geometry/thickness.py +69 -0
- stepscribe/geometry/topology.py +102 -0
- stepscribe/geometry/validity.py +18 -0
- stepscribe/headless.py +34 -0
- stepscribe/io/__init__.py +2 -0
- stepscribe/io/discovery.py +22 -0
- stepscribe/io/report_writer.py +43 -0
- stepscribe/io/step_reader.py +262 -0
- stepscribe/jobs.py +156 -0
- stepscribe/knowledge/bearings.yaml +45 -0
- stepscribe/knowledge/belts.yaml +11 -0
- stepscribe/knowledge/boards.yaml +17 -0
- stepscribe/knowledge/extrusions.yaml +28 -0
- stepscribe/knowledge/gears.yaml +14 -0
- stepscribe/knowledge/lead_screws.yaml +10 -0
- stepscribe/knowledge/motors.yaml +31 -0
- stepscribe/knowledge/process_rules.yaml +101 -0
- stepscribe/knowledge/roles.yaml +151 -0
- stepscribe/knowledge/screws_iso_metric.yaml +27 -0
- stepscribe/knowledge/screws_unified.yaml +15 -0
- stepscribe/knowledge/shafts.yaml +16 -0
- stepscribe/knowledge/weak_spot_rules.yaml +57 -0
- stepscribe/llm/__init__.py +2 -0
- stepscribe/llm/adapter.py +122 -0
- stepscribe/llm/evaluate.py +121 -0
- stepscribe/mcp/__init__.py +2 -0
- stepscribe/mcp/server.py +153 -0
- stepscribe/mcp/tools.py +558 -0
- stepscribe/models/__init__.py +2 -0
- stepscribe/models/schema.py +655 -0
- stepscribe/pack/__init__.py +2 -0
- stepscribe/pack/design_context.py +133 -0
- stepscribe/pack/templates/00_READ_ME_FIRST.md.j2 +19 -0
- stepscribe/pack/templates/01_overview.md.j2 +52 -0
- stepscribe/pack/templates/02_assembly.md.j2 +65 -0
- stepscribe/pack/templates/04_design_context.md.j2 +28 -0
- stepscribe/pack/templates/context_header.md.j2 +14 -0
- stepscribe/pack/templates/part.md.j2 +118 -0
- stepscribe/pack/writer.py +280 -0
- stepscribe/procutil.py +20 -0
- stepscribe/progress.py +154 -0
- stepscribe/render/__init__.py +466 -0
- stepscribe/render/dimensions.py +151 -0
- stepscribe/render/features.py +411 -0
- stepscribe/render/kinematics_view.py +108 -0
- stepscribe/render/overlay.py +183 -0
- stepscribe/render/raster.py +221 -0
- stepscribe/render/scene.py +162 -0
- stepscribe/render/tessellate.py +87 -0
- stepscribe/semantics/__init__.py +56 -0
- stepscribe/semantics/bearings.py +93 -0
- stepscribe/semantics/extrusions.py +91 -0
- stepscribe/semantics/hardware.py +239 -0
- stepscribe/semantics/matcher.py +43 -0
- stepscribe/semantics/mounts.py +178 -0
- stepscribe/semantics/shafts.py +135 -0
- stepscribe/ui/__init__.py +2 -0
- stepscribe/ui/index.html +460 -0
- stepscribe/ui/interview_view.py +99 -0
- stepscribe/ui/server.py +395 -0
- stepscribe/ui/worker.py +304 -0
- stepscribe/understanding/__init__.py +318 -0
- stepscribe/understanding/aag.py +333 -0
- stepscribe/understanding/answers.py +250 -0
- stepscribe/understanding/describe.py +238 -0
- stepscribe/understanding/evaluate.py +168 -0
- stepscribe/understanding/function.py +781 -0
- stepscribe/understanding/interview.py +126 -0
- stepscribe/understanding/kinematics.py +1199 -0
- stepscribe/understanding/load_path.py +218 -0
- stepscribe/understanding/mechanisms.py +617 -0
- stepscribe/understanding/ml_roles.py +48 -0
- stepscribe/understanding/motion_check.py +181 -0
- stepscribe/understanding/process.py +434 -0
- stepscribe/understanding/questions.py +357 -0
- stepscribe/understanding/session.py +288 -0
- stepscribe/understanding/stability.py +251 -0
- stepscribe/understanding/structural.py +589 -0
- stepscribe/understanding/symmetry.py +215 -0
- stepscribe/understanding/weak_spots.py +648 -0
- stepscribe_cad-0.1.0.dist-info/METADATA +208 -0
- stepscribe_cad-0.1.0.dist-info/RECORD +126 -0
- stepscribe_cad-0.1.0.dist-info/WHEEL +4 -0
- stepscribe_cad-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|