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/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