draftwright 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.
- draftwright/__init__.py +39 -0
- draftwright/make_drawing.py +3030 -0
- draftwright/pmi.py +362 -0
- draftwright-0.1.0.dist-info/METADATA +824 -0
- draftwright-0.1.0.dist-info/RECORD +8 -0
- draftwright-0.1.0.dist-info/WHEEL +4 -0
- draftwright-0.1.0.dist-info/entry_points.txt +2 -0
- draftwright-0.1.0.dist-info/licenses/LICENSE +661 -0
draftwright/pmi.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""PMI (Product Manufacturing Information) extractor for AP242 STEP files.
|
|
2
|
+
|
|
3
|
+
Reads semantic PMI from an ISO 10303-242 STEP file via a second
|
|
4
|
+
``STEPCAFControl_Reader`` pass with ``SetGDTMode(True)``. Returns a list of
|
|
5
|
+
:class:`PmiRecord` objects that ``_annotate_pmi`` in ``make_drawing.py`` turns
|
|
6
|
+
into drawing annotations.
|
|
7
|
+
|
|
8
|
+
build123d's ``import_step`` already uses ``STEPCAFControl_Reader`` + an XCAF
|
|
9
|
+
document (for names/colours/layers) but never enables GDT mode and discards
|
|
10
|
+
the document, so the PMI is inaccessible after that call. This module runs
|
|
11
|
+
a *separate*, read-only pass against the same file to recover the semantic PMI
|
|
12
|
+
without touching the solid geometry at all.
|
|
13
|
+
|
|
14
|
+
Key OCP gotcha: the ``label.FindAttribute(GetID_s(), attr)`` out-param pattern
|
|
15
|
+
returns True but leaves ``attr.Label()`` null so ``GetObject()`` throws. The
|
|
16
|
+
working pattern is ``XCAFDoc_Dimension.Set_s(label).GetObject()`` (same for
|
|
17
|
+
GeomTolerance, Datum).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# OCP capability guard
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from OCP.Bnd import Bnd_Box
|
|
34
|
+
from OCP.BRepBndLib import BRepBndLib
|
|
35
|
+
from OCP.IFSelect import IFSelect_RetDone
|
|
36
|
+
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
|
37
|
+
from OCP.TCollection import TCollection_ExtendedString
|
|
38
|
+
from OCP.TDF import TDF_LabelSequence
|
|
39
|
+
from OCP.TDocStd import TDocStd_Document
|
|
40
|
+
from OCP.XCAFDoc import (
|
|
41
|
+
XCAFDoc_Dimension,
|
|
42
|
+
XCAFDoc_DimTolTool,
|
|
43
|
+
XCAFDoc_DocumentTool,
|
|
44
|
+
XCAFDoc_GeomTolerance,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_PMI_AVAILABLE = hasattr(STEPCAFControl_Reader, "SetGDTMode")
|
|
48
|
+
except ImportError:
|
|
49
|
+
_PMI_AVAILABLE = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Type-code tables
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
# int → human tag for XCAFDimTolObjects_DimensionType enum
|
|
57
|
+
_DIM_TYPE: dict[int, str] = {
|
|
58
|
+
0: "location", # Location_None
|
|
59
|
+
1: "curved_dist", # Location_CurvedDistance
|
|
60
|
+
2: "linear", # Location_LinearDistance (outer-to-outer, generic)
|
|
61
|
+
3: "linear", # FromCenterToOuter
|
|
62
|
+
4: "linear", # FromCenterToInner
|
|
63
|
+
5: "linear", # FromOuterToCenter
|
|
64
|
+
6: "linear", # FromOuterToOuter
|
|
65
|
+
7: "linear", # FromOuterToInner
|
|
66
|
+
8: "linear", # FromInnerToCenter
|
|
67
|
+
9: "linear", # FromInnerToOuter
|
|
68
|
+
10: "linear", # FromInnerToInner
|
|
69
|
+
11: "angular", # Location_Angular (incl. curved centre-to-centre)
|
|
70
|
+
12: "oriented", # Location_Oriented
|
|
71
|
+
14: "curve_length", # Size_CurveLength
|
|
72
|
+
15: "diameter", # Size_Diameter ← add ø prefix
|
|
73
|
+
16: "diameter", # Size_SphericalDiameter
|
|
74
|
+
17: "radius", # Size_Radius ← add R prefix
|
|
75
|
+
18: "radius", # Size_SphericalRadius
|
|
76
|
+
27: "thickness", # Size_Thickness
|
|
77
|
+
28: "angular", # Size_Angular
|
|
78
|
+
30: "label", # CommonLabel ← no numeric value, skip
|
|
79
|
+
31: "presentation", # DimensionPresentation ← graphical only, skip
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Types whose GetValue() is a meaningful length/angle (skip label/presentation)
|
|
83
|
+
_SKIP_TYPES = {30, 31}
|
|
84
|
+
|
|
85
|
+
# prefix character for the label
|
|
86
|
+
_DIM_PREFIX: dict[str, str] = {
|
|
87
|
+
"diameter": "ø",
|
|
88
|
+
"radius": "R",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# int → short tag for XCAFDimTolObjects_GeomToleranceType
|
|
92
|
+
_GTOL_TYPE: dict[int, str] = {
|
|
93
|
+
1: "straightness",
|
|
94
|
+
2: "flatness",
|
|
95
|
+
3: "circularity",
|
|
96
|
+
4: "cylindricity",
|
|
97
|
+
5: "profile_line",
|
|
98
|
+
6: "profile_surface",
|
|
99
|
+
7: "perpendicularity",
|
|
100
|
+
8: "angularity",
|
|
101
|
+
9: "parallelism",
|
|
102
|
+
10: "position",
|
|
103
|
+
11: "concentricity",
|
|
104
|
+
12: "symmetry",
|
|
105
|
+
13: "circular_runout",
|
|
106
|
+
14: "total_runout",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Data classes
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class PmiRecord:
|
|
117
|
+
"""One semantic PMI annotation from an AP242 STEP file.
|
|
118
|
+
|
|
119
|
+
Attributes:
|
|
120
|
+
kind: Human-readable category (``"linear"``, ``"diameter"``,
|
|
121
|
+
``"angular"``, ``"gtol"``, ``"datum"``).
|
|
122
|
+
type_code: Raw OCCT enum integer.
|
|
123
|
+
value: Nominal value in mm (or degrees for angular).
|
|
124
|
+
upper_tol: Upper tolerance in mm, or ``None``.
|
|
125
|
+
lower_tol: Lower tolerance in mm, or ``None``.
|
|
126
|
+
ref_pts: Bounding-box centroids of referenced geometry in global
|
|
127
|
+
STEP space (same coordinate frame as the imported solid).
|
|
128
|
+
ref_bbox: Combined axis-aligned bbox of ALL referenced shapes:
|
|
129
|
+
``(xmin, ymin, zmin, xmax, ymax, zmax)``. Used by
|
|
130
|
+
``_annotate_pmi`` for witness-point placement — the outer
|
|
131
|
+
edges of the referenced geometry give the correct measurement
|
|
132
|
+
span rather than the shorter centroid-to-centroid distance.
|
|
133
|
+
dominant_axis: ``'X'``, ``'Y'``, ``'Z'``, or ``'?'`` — the direction
|
|
134
|
+
in which the dimension primarily spans (based on the outer
|
|
135
|
+
bbox extent, not the centroid difference).
|
|
136
|
+
label: Ready-to-use annotation label (e.g. ``"ø35"``, ``"60"``).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
kind: str
|
|
140
|
+
type_code: int
|
|
141
|
+
value: float
|
|
142
|
+
upper_tol: float | None = None
|
|
143
|
+
lower_tol: float | None = None
|
|
144
|
+
ref_pts: list[tuple[float, float, float]] = field(default_factory=list)
|
|
145
|
+
ref_bbox: tuple[float, float, float, float, float, float] | None = None
|
|
146
|
+
dominant_axis: str = "?"
|
|
147
|
+
label: str = ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Helpers
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _shape_bbox(shape) -> tuple[float, float, float, float, float, float]:
|
|
156
|
+
"""Return ``(xmin, ymin, zmin, xmax, ymax, zmax)`` of *shape* in global space."""
|
|
157
|
+
bb = Bnd_Box()
|
|
158
|
+
BRepBndLib.Add_s(shape, bb)
|
|
159
|
+
return bb.Get()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _bbox_centroid(bbox: tuple) -> tuple[float, float, float]:
|
|
163
|
+
xmin, ymin, zmin, xmax, ymax, zmax = bbox
|
|
164
|
+
return ((xmin + xmax) / 2, (ymin + ymax) / 2, (zmin + zmax) / 2)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _merge_bboxes(
|
|
168
|
+
boxes: list[tuple[float, float, float, float, float, float]],
|
|
169
|
+
) -> tuple[float, float, float, float, float, float]:
|
|
170
|
+
"""Return the combined axis-aligned bbox of *boxes*."""
|
|
171
|
+
xs = [b[0] for b in boxes] + [b[3] for b in boxes]
|
|
172
|
+
ys = [b[1] for b in boxes] + [b[4] for b in boxes]
|
|
173
|
+
zs = [b[2] for b in boxes] + [b[5] for b in boxes]
|
|
174
|
+
return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _dominant_from_bbox(bbox: tuple[float, float, float, float, float, float]) -> str:
|
|
178
|
+
"""Return ``'X'``/``'Y'``/``'Z'`` for the axis with the largest bbox extent."""
|
|
179
|
+
xmin, ymin, zmin, xmax, ymax, zmax = bbox
|
|
180
|
+
spans = [("X", abs(xmax - xmin)), ("Y", abs(ymax - ymin)), ("Z", abs(zmax - zmin))]
|
|
181
|
+
dom = max(spans, key=lambda t: t[1])
|
|
182
|
+
return dom[0] if dom[1] > 1e-6 else "?"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _make_label(kind: str, value: float, upper_tol: float | None, lower_tol: float | None) -> str:
|
|
186
|
+
"""Format the annotation label with optional tolerance suffix."""
|
|
187
|
+
from draftwright.make_drawing import _fmt # local import to avoid circularity
|
|
188
|
+
|
|
189
|
+
prefix = _DIM_PREFIX.get(kind, "")
|
|
190
|
+
base = f"{prefix}{_fmt(value)}"
|
|
191
|
+
# OCCT returns tolerances as positive magnitudes regardless of sign
|
|
192
|
+
# convention. upper_tol is always the + deviation; lower_tol is always
|
|
193
|
+
# the - deviation stored as a positive magnitude. We add explicit signs
|
|
194
|
+
# so the label is unambiguous on the drawing.
|
|
195
|
+
if upper_tol is not None and lower_tol is not None:
|
|
196
|
+
if abs(abs(upper_tol) - abs(lower_tol)) < 1e-4:
|
|
197
|
+
base += f" ±{_fmt(abs(upper_tol))}"
|
|
198
|
+
else:
|
|
199
|
+
base += f" +{_fmt(abs(upper_tol))}/-{_fmt(abs(lower_tol))}"
|
|
200
|
+
elif upper_tol is not None:
|
|
201
|
+
base += f" +{_fmt(abs(upper_tol))}"
|
|
202
|
+
elif lower_tol is not None:
|
|
203
|
+
base += f" -{_fmt(abs(lower_tol))}"
|
|
204
|
+
return base
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Public API
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def extract_pmi(step_file: str | Path) -> list[PmiRecord]:
|
|
213
|
+
"""Extract semantic PMI from an AP242 STEP file.
|
|
214
|
+
|
|
215
|
+
Returns an empty list (with a log message) when:
|
|
216
|
+
|
|
217
|
+
- the file contains no GDT data;
|
|
218
|
+
- OCP's GDT support is unavailable (``_PMI_AVAILABLE`` is False);
|
|
219
|
+
- the file uses AP203/AP214 which carry no semantic PMI.
|
|
220
|
+
|
|
221
|
+
Does **not** modify the solid geometry — purely a read-only second pass.
|
|
222
|
+
"""
|
|
223
|
+
if not _PMI_AVAILABLE:
|
|
224
|
+
_log.debug("PMI extraction unavailable (OCP SetGDTMode not found)")
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
path = str(step_file)
|
|
228
|
+
doc = TDocStd_Document(TCollection_ExtendedString("XCAF"))
|
|
229
|
+
reader = STEPCAFControl_Reader()
|
|
230
|
+
reader.SetGDTMode(True)
|
|
231
|
+
reader.SetNameMode(True)
|
|
232
|
+
status = reader.ReadFile(path)
|
|
233
|
+
if status != IFSelect_RetDone:
|
|
234
|
+
_log.warning(
|
|
235
|
+
"PMI extraction: ReadFile failed for %s (status=%s)", Path(step_file).name, status
|
|
236
|
+
)
|
|
237
|
+
return []
|
|
238
|
+
reader.Transfer(doc)
|
|
239
|
+
|
|
240
|
+
main = doc.Main()
|
|
241
|
+
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(main)
|
|
242
|
+
dt = XCAFDoc_DocumentTool.DimTolTool_s(main)
|
|
243
|
+
|
|
244
|
+
records: list[PmiRecord] = []
|
|
245
|
+
|
|
246
|
+
# ---- Dimensions --------------------------------------------------------
|
|
247
|
+
dims = TDF_LabelSequence()
|
|
248
|
+
dt.GetDimensionLabels(dims)
|
|
249
|
+
n_dims_ok = 0
|
|
250
|
+
|
|
251
|
+
for i in range(1, dims.Length() + 1):
|
|
252
|
+
lab = dims.Value(i)
|
|
253
|
+
try:
|
|
254
|
+
obj = XCAFDoc_Dimension.Set_s(lab).GetObject()
|
|
255
|
+
tc = int(obj.GetType())
|
|
256
|
+
if tc in _SKIP_TYPES:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# Nominal value: scalar first, array fallback
|
|
260
|
+
val: float = 0.0
|
|
261
|
+
try:
|
|
262
|
+
val = float(obj.GetValue())
|
|
263
|
+
except Exception:
|
|
264
|
+
try:
|
|
265
|
+
arr = obj.GetValues()
|
|
266
|
+
if arr is not None:
|
|
267
|
+
val = float(arr.Value(arr.Lower()))
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
# Tolerances
|
|
272
|
+
upper_tol: float | None = None
|
|
273
|
+
lower_tol: float | None = None
|
|
274
|
+
try:
|
|
275
|
+
u = float(obj.GetUpperTolValue())
|
|
276
|
+
if abs(u) > 1e-9:
|
|
277
|
+
upper_tol = u
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
try:
|
|
281
|
+
lo = float(obj.GetLowerTolValue())
|
|
282
|
+
if abs(lo) > 1e-9:
|
|
283
|
+
lower_tol = lo
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
# Referenced geometry → bboxes and centroids
|
|
288
|
+
f_seq = TDF_LabelSequence()
|
|
289
|
+
s_seq = TDF_LabelSequence()
|
|
290
|
+
XCAFDoc_DimTolTool.GetRefShapeLabel_s(lab, f_seq, s_seq)
|
|
291
|
+
pts: list[tuple[float, float, float]] = []
|
|
292
|
+
raw_bboxes: list[tuple[float, float, float, float, float, float]] = []
|
|
293
|
+
for seq in (f_seq, s_seq):
|
|
294
|
+
for k in range(1, seq.Length() + 1):
|
|
295
|
+
shp = shape_tool.GetShape_s(seq.Value(k))
|
|
296
|
+
if shp is not None and not shp.IsNull():
|
|
297
|
+
try:
|
|
298
|
+
bb6 = _shape_bbox(shp)
|
|
299
|
+
raw_bboxes.append(bb6)
|
|
300
|
+
pts.append(_bbox_centroid(bb6))
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
# Combined bbox of all referenced shapes, used for witness placement.
|
|
305
|
+
# The outer edges of the referenced geometry give the correct measurement
|
|
306
|
+
# span (e.g., the two far sides of a ø35 bore) rather than the much
|
|
307
|
+
# shorter centroid-to-centroid distance.
|
|
308
|
+
ref_bbox = _merge_bboxes(raw_bboxes) if raw_bboxes else None
|
|
309
|
+
dom = _dominant_from_bbox(ref_bbox) if ref_bbox else "?"
|
|
310
|
+
|
|
311
|
+
kind = _DIM_TYPE.get(tc, f"type{tc}")
|
|
312
|
+
lbl = _make_label(kind, val, upper_tol, lower_tol)
|
|
313
|
+
records.append(
|
|
314
|
+
PmiRecord(
|
|
315
|
+
kind=kind,
|
|
316
|
+
type_code=tc,
|
|
317
|
+
value=val,
|
|
318
|
+
upper_tol=upper_tol,
|
|
319
|
+
lower_tol=lower_tol,
|
|
320
|
+
ref_pts=pts,
|
|
321
|
+
ref_bbox=ref_bbox,
|
|
322
|
+
dominant_axis=dom,
|
|
323
|
+
label=lbl,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
n_dims_ok += 1
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
_log.debug("PMI dim[%d] skipped: %s", i, exc)
|
|
329
|
+
|
|
330
|
+
# ---- Geometric tolerances ----------------------------------------------
|
|
331
|
+
gts = TDF_LabelSequence()
|
|
332
|
+
dt.GetGeomToleranceLabels(gts)
|
|
333
|
+
n_gtol_ok = 0
|
|
334
|
+
|
|
335
|
+
for i in range(1, gts.Length() + 1):
|
|
336
|
+
lab = gts.Value(i)
|
|
337
|
+
try:
|
|
338
|
+
obj = XCAFDoc_GeomTolerance.Set_s(lab).GetObject()
|
|
339
|
+
tc = int(obj.GetType())
|
|
340
|
+
val = float(obj.GetValue())
|
|
341
|
+
kind = _GTOL_TYPE.get(tc, f"gtol{tc}")
|
|
342
|
+
records.append(
|
|
343
|
+
PmiRecord(
|
|
344
|
+
kind=kind,
|
|
345
|
+
type_code=tc,
|
|
346
|
+
value=val,
|
|
347
|
+
label=f"{kind} {val:.3g}" if val else kind,
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
n_gtol_ok += 1
|
|
351
|
+
except Exception as exc:
|
|
352
|
+
_log.debug("PMI gtol[%d] skipped: %s", i, exc)
|
|
353
|
+
|
|
354
|
+
_log.info(
|
|
355
|
+
"PMI extracted from %s: %d/%d dims, %d/%d gtols",
|
|
356
|
+
Path(step_file).name,
|
|
357
|
+
n_dims_ok,
|
|
358
|
+
dims.Length(),
|
|
359
|
+
n_gtol_ok,
|
|
360
|
+
gts.Length(),
|
|
361
|
+
)
|
|
362
|
+
return records
|