kompas-kernel 0.0.1__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 (77) hide show
  1. kompas_kernel/__init__.py +3 -0
  2. kompas_kernel/features/__init__.py +9 -0
  3. kompas_kernel/features/assembly_navigator/__init__.py +8 -0
  4. kompas_kernel/features/assembly_navigator/commands.py +37 -0
  5. kompas_kernel/features/assembly_navigator/ksapi/__init__.py +5 -0
  6. kompas_kernel/features/assembly_navigator/ksapi/mapping.py +122 -0
  7. kompas_kernel/features/assembly_navigator/ksapi/presentation.py +107 -0
  8. kompas_kernel/features/assembly_navigator/ksapi/raw.py +74 -0
  9. kompas_kernel/features/assembly_navigator/ksapi/reader.py +287 -0
  10. kompas_kernel/features/assembly_navigator/ksapi/resolver.py +35 -0
  11. kompas_kernel/features/assembly_navigator/models.py +218 -0
  12. kompas_kernel/features/assembly_navigator/queries.py +42 -0
  13. kompas_kernel/features/assembly_navigator/service.py +31 -0
  14. kompas_kernel/features/assembly_navigator/summary.py +90 -0
  15. kompas_kernel/features/document_session/__init__.py +5 -0
  16. kompas_kernel/features/document_session/checkpointed.py +165 -0
  17. kompas_kernel/features/document_session/commands.py +94 -0
  18. kompas_kernel/features/document_session/digest.py +126 -0
  19. kompas_kernel/features/document_session/ksapi/__init__.py +5 -0
  20. kompas_kernel/features/document_session/ksapi/context_reader.py +174 -0
  21. kompas_kernel/features/document_session/ksapi/screenshot.py +365 -0
  22. kompas_kernel/features/document_session/ksapi/snapshot.py +181 -0
  23. kompas_kernel/features/document_session/models.py +311 -0
  24. kompas_kernel/features/document_session/queries.py +62 -0
  25. kompas_kernel/features/document_session/registry.py +101 -0
  26. kompas_kernel/features/document_session/service.py +128 -0
  27. kompas_kernel/features/geometry_3d/__init__.py +9 -0
  28. kompas_kernel/features/geometry_3d/axis_math.py +50 -0
  29. kompas_kernel/features/geometry_3d/commands.py +68 -0
  30. kompas_kernel/features/geometry_3d/hex_detect.py +113 -0
  31. kompas_kernel/features/geometry_3d/ksapi/__init__.py +5 -0
  32. kompas_kernel/features/geometry_3d/ksapi/component_geometry.py +559 -0
  33. kompas_kernel/features/geometry_3d/ksapi/measurements.py +368 -0
  34. kompas_kernel/features/geometry_3d/ksapi/presentation.py +204 -0
  35. kompas_kernel/features/geometry_3d/models.py +216 -0
  36. kompas_kernel/features/geometry_3d/queries.py +96 -0
  37. kompas_kernel/features/geometry_3d/service.py +69 -0
  38. kompas_kernel/features/geometry_3d/wrench_zone.py +161 -0
  39. kompas_kernel/features/ksapi_automation/__init__.py +9 -0
  40. kompas_kernel/features/ksapi_automation/gate.py +282 -0
  41. kompas_kernel/features/ksapi_automation/models.py +75 -0
  42. kompas_kernel/features/ksapi_automation/namespace.py +12 -0
  43. kompas_kernel/features/ksapi_automation/run_python.py +133 -0
  44. kompas_kernel/features/ksapi_automation/runner.py +126 -0
  45. kompas_kernel/features/ksapi_automation/service.py +85 -0
  46. kompas_kernel/features/part_authoring/__init__.py +5 -0
  47. kompas_kernel/features/part_authoring/build_geometry.py +122 -0
  48. kompas_kernel/features/part_authoring/models.py +99 -0
  49. kompas_kernel/features/wrench_clearance/__init__.py +7 -0
  50. kompas_kernel/features/wrench_clearance/models.py +87 -0
  51. kompas_kernel/features/wrench_clearance/service.py +154 -0
  52. kompas_kernel/kompas/__init__.py +14 -0
  53. kompas_kernel/kompas/errors.py +11 -0
  54. kompas_kernel/kompas/models.py +68 -0
  55. kompas_kernel/kompas/objects.py +93 -0
  56. kompas_kernel/kompas/platform.py +40 -0
  57. kompas_kernel/kompas/runtime.py +186 -0
  58. kompas_kernel/kompas/session.py +195 -0
  59. kompas_kernel/kompas/units.py +48 -0
  60. kompas_kernel/ksapi_static/__init__.py +8 -0
  61. kompas_kernel/ksapi_static/facts.py +132 -0
  62. kompas_kernel/ksapi_static/inventory.py +185 -0
  63. kompas_kernel/ksapi_static/recipes.py +114 -0
  64. kompas_kernel/ksapi_static/wrapper_grep.py +83 -0
  65. kompas_kernel/observability/__init__.py +1 -0
  66. kompas_kernel/observability/logger.py +24 -0
  67. kompas_kernel/observability/setup.py +115 -0
  68. kompas_kernel/observability/taxonomy.py +27 -0
  69. kompas_kernel/py.typed +1 -0
  70. kompas_kernel/standards/__init__.py +1 -0
  71. kompas_kernel/standards/wrench_clearance.py +234 -0
  72. kompas_kernel/utils/__init__.py +1 -0
  73. kompas_kernel/utils/filesystem.py +82 -0
  74. kompas_kernel/utils/validation.py +21 -0
  75. kompas_kernel-0.0.1.dist-info/METADATA +56 -0
  76. kompas_kernel-0.0.1.dist-info/RECORD +77 -0
  77. kompas_kernel-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,559 @@
1
+ """Component resolution + exact geometry primitives: axis, mesh, planar/cylindrical B-rep facts.
2
+
3
+ Free functions taking explicit `modules`/`app` dependencies (no adapter `self` state),
4
+ matching the same facade-vs-implementation split as `features/assembly_navigator/ksapi/
5
+ reader.py`. `KompasSession` owns `modules`/`app`/the worker; this module never reaches
6
+ back into session state.
7
+
8
+ `resolve_component`/`component_by_identifier` are the ONE place the "identifier ->
9
+ `IPart`" convention lives for this slice's KsAPI modules (`measurements.py` and
10
+ `presentation.py` both import them from here, hence the public — no leading underscore
11
+ — names, same precedent as `kompas.objects`) — replacing what used to be six near-
12
+ identical `doc3d`/`top`/`components`/`_component_by_identifier` preambles duplicated
13
+ across `measure_min_distance`/`find_neighbors`/`component_axis`/`component_mesh`/
14
+ `component_planar_faces`/`component_cylindrical_faces`/`show_distance_dimension`
15
+ (E16.04a dedup step, before the split from legacy `bridge/kompas_ksapi/geometry.py`).
16
+
17
+ `_global_corners`/`_collect_face_facts` are the second dedup: `component_planar_faces`
18
+ and `component_cylindrical_faces` used to duplicate the SAME six-branch face-enumeration
19
+ loop (type predicate → tolerance gate → corner transform → append-or-skip) with only the
20
+ per-surface-type math (`IsPlanar`/`_face_plane_local` vs `IsCylinder`/`_cylinder_axis_local`/
21
+ `GetRadius`) actually differing. `_collect_face_facts` owns the shared loop/corner-transform
22
+ skeleton; each caller supplies only its own `compute(face) -> radial_mm | None` closure.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import math
29
+ from collections.abc import Callable
30
+ from typing import Any
31
+
32
+ from kompas_kernel.features.geometry_3d.models import (
33
+ AxisLine,
34
+ ComponentMesh,
35
+ CylindricalFaceFact,
36
+ CylindricalFaces,
37
+ FaceMesh,
38
+ PlanarFaceFact,
39
+ PlanarFaces,
40
+ )
41
+ from kompas_kernel.kompas.errors import KompasError
42
+ from kompas_kernel.kompas.models import Point3D
43
+ from kompas_kernel.kompas.objects import as_document_3d, constants3d_class, parse_index
44
+ from kompas_kernel.kompas.runtime import KsApiModules
45
+ from kompas_kernel.observability.logger import get_logger
46
+ from kompas_kernel.observability.taxonomy import LogDomain, LogEventType, event_name
47
+
48
+ logger = get_logger(__name__)
49
+
50
+ _KSAPI_BOOL_XYZ_VALUE_COUNT = 4
51
+ _POINT3D_COORDINATE_COUNT = 3
52
+ # |dot(face plane normal, query axis direction)| below this ⇒ plane parallel to the axis
53
+ # (a side wall, not a torec/скос) — exact geometry, so the tolerance only absorbs float noise.
54
+ _PLANE_PARALLEL_TO_AXIS_TOLERANCE = 1e-3
55
+ # |abs(dot(cylinder axis direction, query axis direction)) - 1| below this ⇒ the two axes
56
+ # are parallel (within float noise) — a cylindrical wall whose own axis runs alongside
57
+ # the query axis, not a bore/fillet crossing it at an angle.
58
+ _CYLINDER_AXIS_PARALLEL_TOLERANCE = 1e-3
59
+
60
+
61
+ def component_by_identifier(operation: str, components: list[Any], identifier: str) -> Any:
62
+ index = parse_index(identifier)
63
+ if index is None or not (0 <= index < len(components)):
64
+ raise KompasError(f"{operation}: component identifier {identifier!r} is invalid")
65
+ return components[index]
66
+
67
+
68
+ def resolve_assembly(modules: KsApiModules, app: Any, operation: str) -> tuple[Any, Any, list[Any]]:
69
+ """`(doc3d, top, components)` of the active 3D document's assembly, or raise `KompasError`.
70
+
71
+ `doc3d` is returned alongside `top`/`components` (not just discarded internally)
72
+ because `presentation.py::show_distance_dimension` needs it later for
73
+ `GetSelectionManager()` — recomputing the same cheap `IKompasDocument3D` cast a
74
+ second time would work too (it is a pure interface cast, no KsAPI side effect), but
75
+ returning it here avoids a second call and a second (dead, since the first already
76
+ proved it succeeds) not-3D guard at the call site.
77
+ """
78
+ doc3d = as_document_3d(modules, app.GetActiveDocument())
79
+ if doc3d is None:
80
+ raise KompasError(f"{operation}: активный документ не 3D")
81
+ constants3d = constants3d_class(modules)
82
+ top = doc3d.GetTopPart()
83
+ components: list[Any] = list(top.GetPartsArray(int(constants3d.ksAllParts)))
84
+ return doc3d, top, components
85
+
86
+
87
+ def resolve_component(
88
+ modules: KsApiModules, app: Any, operation: str, identifier: str
89
+ ) -> tuple[Any, Any, list[Any], Any]:
90
+ """`(doc3d, top, components, component)` for `identifier`, or raise `KompasError`.
91
+
92
+ Shared preamble for every geometry query/command that resolves exactly one component
93
+ identifier before doing its own work — a caller resolving a SECOND identifier from the
94
+ same assembly reuses the returned `components` list with `component_by_identifier`
95
+ directly (`measure_min_distance`/`show_distance_dimension` in the sibling modules),
96
+ rather than calling this twice and re-walking `GetPartsArray`.
97
+ """
98
+ doc3d, top, components = resolve_assembly(modules, app, operation)
99
+ component = component_by_identifier(operation, components, identifier)
100
+ return doc3d, top, components, component
101
+
102
+
103
+ def bool_xyz_point(raw: Any) -> Point3D | None:
104
+ """Parse a KsAPI `(ok, x, y, z)` out-param tuple into `Point3D`, or `None` if not ok.
105
+
106
+ Shared shape across several unrelated KsAPI calls that all follow the same
107
+ convention (`IPlacement3D.GetOrigin`/`GetVector`, `IMeasurement3D.GetMinPoint1/2`)
108
+ — one parser, not one per call site (GPC8).
109
+ """
110
+ values = tuple(raw) if raw is not None else ()
111
+ if len(values) < _KSAPI_BOOL_XYZ_VALUE_COUNT or not bool(values[0]):
112
+ return None
113
+ return Point3D(x_mm=float(values[1]), y_mm=float(values[2]), z_mm=float(values[3]))
114
+
115
+
116
+ def _points_from_flat(values: list[float]) -> list[Point3D] | None:
117
+ """Group a flat `[x0, y0, z0, x1, y1, z1, ...]` list into `Point3D` triples."""
118
+ if len(values) < _POINT3D_COORDINATE_COUNT or len(values) % _POINT3D_COORDINATE_COUNT != 0:
119
+ return None
120
+ return [
121
+ Point3D(x_mm=values[i], y_mm=values[i + 1], z_mm=values[i + 2])
122
+ for i in range(0, len(values), _POINT3D_COORDINATE_COUNT)
123
+ ]
124
+
125
+
126
+ def _face_tessellation_points(face: Any, top: Any, component: Any) -> list[Point3D] | None:
127
+ """Tessellation points of one face, transformed into the assembly's global CS (mm).
128
+
129
+ `IFace.GetTessellation().GetFacetPoints()` + `IPart.TransformPoints()` call
130
+ sequence; feeds `component_mesh` and `nearest_face`. A face without tessellation
131
+ (or any KsAPI failure along the way) is a graceful `None`, not an exception —
132
+ callers skip it.
133
+ """
134
+ tessellation = face.GetTessellation()
135
+ if not tessellation:
136
+ return None
137
+ with contextlib.suppress(Exception):
138
+ ok, raw_points, _indices, _normals = tessellation.GetFacetPoints([], [], [])
139
+ if bool(ok):
140
+ transformed_ok, transformed_points = top.TransformPoints([float(point) for point in raw_points], component)
141
+ if bool(transformed_ok):
142
+ return _points_from_flat([float(point) for point in transformed_points])
143
+ return None
144
+
145
+
146
+ def _component_faces(modules: KsApiModules, component: Any) -> list[Any]:
147
+ constants3d = constants3d_class(modules)
148
+ container = component.GetModelContainer()
149
+ if container is None:
150
+ return []
151
+ raw_faces = container.GetObjects(int(constants3d.o3d_face))
152
+ if raw_faces is None:
153
+ return []
154
+ faces: list[Any] = []
155
+ for raw_face in raw_faces:
156
+ face = modules.ksapi.IFace(raw_face)
157
+ if face:
158
+ faces.append(face)
159
+ return faces
160
+
161
+
162
+ def _subtract(a: Point3D, b: Point3D) -> Point3D:
163
+ return Point3D(x_mm=a.x_mm - b.x_mm, y_mm=a.y_mm - b.y_mm, z_mm=a.z_mm - b.z_mm)
164
+
165
+
166
+ def _dot(a: Point3D, b: Point3D) -> float:
167
+ return a.x_mm * b.x_mm + a.y_mm * b.y_mm + a.z_mm * b.z_mm
168
+
169
+
170
+ def _normalize(v: Point3D) -> Point3D | None:
171
+ length = math.sqrt(v.x_mm**2 + v.y_mm**2 + v.z_mm**2)
172
+ if length == 0:
173
+ return None
174
+ return Point3D(x_mm=v.x_mm / length, y_mm=v.y_mm / length, z_mm=v.z_mm / length)
175
+
176
+
177
+ def _axis_in_local_frame(top: Any, component: Any, axis: AxisLine) -> tuple[Point3D, Point3D] | None:
178
+ """Transform a global `AxisLine` into `component`'s own local body frame.
179
+
180
+ `component.TransformPoint(x, y, z, top)` — called on the TARGET frame (`component`),
181
+ with `top` as the frame the given `(x, y, z)` are expressed in (AGENTS.md §«Точная
182
+ трансформация локальная → глобальная СК» — same method as the corner transform
183
+ elsewhere in this module, used here in the opposite direction: global→local of an
184
+ arbitrary component, not local→global). Direction cannot be point-transformed
185
+ directly (translation would corrupt it) — recovered by transforming a SECOND point
186
+ along the axis and subtracting, which cancels the translation and leaves the rotated
187
+ direction (AGENTS.md строки 176-178).
188
+ """
189
+ local_origin = bool_xyz_point(component.TransformPoint(axis.origin.x_mm, axis.origin.y_mm, axis.origin.z_mm, top))
190
+ far_global = Point3D(
191
+ x_mm=axis.origin.x_mm + axis.direction.x_mm,
192
+ y_mm=axis.origin.y_mm + axis.direction.y_mm,
193
+ z_mm=axis.origin.z_mm + axis.direction.z_mm,
194
+ )
195
+ local_far = bool_xyz_point(component.TransformPoint(far_global.x_mm, far_global.y_mm, far_global.z_mm, top))
196
+ if local_origin is None or local_far is None:
197
+ return None
198
+ unit_direction = _normalize(_subtract(local_far, local_origin))
199
+ if unit_direction is None:
200
+ return None
201
+ return local_origin, unit_direction
202
+
203
+
204
+ def _cylinder_axis_local(modules: KsApiModules, face: Any) -> tuple[Point3D, Point3D] | None:
205
+ """Exact (origin, unit direction) of a cylindrical face's surface axis, component's OWN local body frame.
206
+
207
+ `IFace.GetMathSurface().GetPlacement()` → `GetOrigin()`/`GetVector(o3d_axisOZ)` — same
208
+ origin+direction shape as `IPlacement3D` elsewhere in this module. Confirmed live
209
+ (spike, 2026-07-08, see `docs/recipes/ksapi-link.md`) to be in the face's OWN LOCAL
210
+ body frame for a component's face — exactly like `GetPoint`/`GetNormal`
211
+ (AGENTS.md §«KsAPI: точный B-rep»), NOT already the assembly's global CS. An earlier
212
+ spike's opposite conclusion tested only a non-rotated component, where local and
213
+ global numerically coincide — it could not actually distinguish the two hypotheses
214
+ (see that spike's erratum). Caller must already have confirmed `face.IsCylinder()`.
215
+ """
216
+ surface = face.GetMathSurface()
217
+ if not surface:
218
+ return None
219
+ placement = surface.GetPlacement()
220
+ if not placement:
221
+ return None
222
+ origin = bool_xyz_point(placement.GetOrigin(0.0, 0.0, 0.0))
223
+ direction = bool_xyz_point(placement.GetVector(int(constants3d_class(modules).o3d_axisOZ), 0.0, 0.0, 0.0))
224
+ if origin is None or direction is None:
225
+ return None
226
+ unit_direction = _normalize(direction)
227
+ if unit_direction is None:
228
+ return None
229
+ return origin, unit_direction
230
+
231
+
232
+ def _face_plane_local(face: Any) -> tuple[Point3D, Point3D] | None:
233
+ """Exact (normal, point-on-plane) of a planar face, sampled once at its own param midpoint.
234
+
235
+ `IFace.GetNormal`/`GetPoint` evaluate the underlying analytic surface directly — no
236
+ tessellation. Param midpoint (`GetParamUMin/UMax`+`GetParamVMin/VMax`, averaged) sits
237
+ inside any simple 4-edge trimmed face (a hex flat's straight-sided rectangle), so this
238
+ never samples outside the face's own boundary. Both points/vectors are in the
239
+ component's OWN LOCAL body frame (see AGENTS.md §«KsAPI: точный B-rep вместо
240
+ меша/тесселляции» — `IFace` methods are NOT in the assembly's global CS). Caller must
241
+ already have confirmed `face.IsPlanar()` — for a planar face the normal is constant
242
+ over (u, v), so one sample fully determines the plane; for any other surface type this
243
+ would just be the local tangent, not useful here.
244
+ """
245
+ u_mid = (face.GetParamUMin() + face.GetParamUMax()) / 2.0
246
+ v_mid = (face.GetParamVMin() + face.GetParamVMax()) / 2.0
247
+ normal = bool_xyz_point(face.GetNormal(u_mid, v_mid, 0.0, 0.0, 0.0))
248
+ point = bool_xyz_point(face.GetPoint(u_mid, v_mid, 0.0, 0.0, 0.0))
249
+ if normal is None or point is None:
250
+ return None
251
+ length = math.sqrt(normal.x_mm**2 + normal.y_mm**2 + normal.z_mm**2)
252
+ if length == 0:
253
+ return None
254
+ unit_normal = Point3D(x_mm=normal.x_mm / length, y_mm=normal.y_mm / length, z_mm=normal.z_mm / length)
255
+ return unit_normal, point
256
+
257
+
258
+ def _face_param_box_corners_local(face: Any) -> list[Point3D] | None:
259
+ """Four corner points of a planar face's own parameter box, exact surface points, local frame.
260
+
261
+ Approximates the trimmed boundary by its parameter-range corners — exact for a
262
+ simple 4-edge rectangular flat (a hex side wall), not a general trim (a face with a
263
+ curved or non-rectangular loop would need `GetLoops()`/edge vertices instead — out of
264
+ scope here, see `component_planar_faces` docstring).
265
+
266
+ Two known strangenesses, carried over verbatim from the legacy module (E16.04a,
267
+ behavior-preserving split — not bugs of THIS function, do not "fix" them here):
268
+ corners come back in `(u_min,v_min), (u_min,v_max), (u_max,v_min), (u_max,v_max)`
269
+ order, which is NOT a contour walk (sequential connection self-intersects into a
270
+ "bowtie") — safe today because both callers (`eyes/hex_detect.py`, `eyes/wrench_zone.py`)
271
+ only take min/max of projections and `corners[0]`, order does not matter to them.
272
+ """
273
+ u_min, u_max = face.GetParamUMin(), face.GetParamUMax()
274
+ v_min, v_max = face.GetParamVMin(), face.GetParamVMax()
275
+ corners: list[Point3D] = []
276
+ for u, v in ((u_min, v_min), (u_min, v_max), (u_max, v_min), (u_max, v_max)):
277
+ point = bool_xyz_point(face.GetPoint(u, v, 0.0, 0.0, 0.0))
278
+ if point is None:
279
+ return None
280
+ corners.append(point)
281
+ return corners
282
+
283
+
284
+ def _global_corners(face: Any, top: Any, component: Any) -> list[Point3D] | None:
285
+ """A face's own parameter-box corners (local frame), transformed to the assembly's global CS.
286
+
287
+ Shared by `component_planar_faces`/`component_cylindrical_faces` (E16.04a dedup):
288
+ `IPart.TransformPoint` is called on `top`, with `component` as the frame the raw
289
+ local corners are expressed in — the OPPOSITE direction of `_axis_in_local_frame`
290
+ above (local component -> global assembly, not global -> local). `None` as soon as
291
+ ANY corner fails to transform (matches the legacy per-caller loop's `break` +
292
+ length-mismatch check: a partially-transformed face was never used, only fully
293
+ transformed-or-skipped).
294
+ """
295
+ local_corners = _face_param_box_corners_local(face)
296
+ if local_corners is None:
297
+ return None
298
+ global_corners: list[Point3D] = []
299
+ for local_point in local_corners:
300
+ transformed = bool_xyz_point(
301
+ top.TransformPoint(local_point.x_mm, local_point.y_mm, local_point.z_mm, component)
302
+ )
303
+ if transformed is None:
304
+ return None
305
+ global_corners.append(transformed)
306
+ return global_corners
307
+
308
+
309
+ def _collect_face_facts(
310
+ faces: list[Any],
311
+ compute: Callable[[Any], float | None],
312
+ *,
313
+ top: Any,
314
+ component: Any,
315
+ ) -> list[tuple[int, float, list[Point3D]]]:
316
+ """Shared face-enumeration skeleton: `(face_index, radial_mm, global_corners)` triples.
317
+
318
+ `compute(face)` is the ONLY part that differs between `component_planar_faces`
319
+ (`IsPlanar`/`_face_plane_local`) and `component_cylindrical_faces` (`IsCylinder`/
320
+ `_cylinder_axis_local`/`GetRadius`) — it returns this face's `radial_mm` if the face
321
+ passes the caller's type/tolerance gate, or `None` to skip it (non-matching surface
322
+ type, degenerate sample, torec/скос not parallel to the axis). Corner computation and
323
+ the transform-to-global-CS are identical for both surface kinds and live here once.
324
+ """
325
+ results: list[tuple[int, float, list[Point3D]]] = []
326
+ for face_index, face in enumerate(faces):
327
+ radial_mm = compute(face)
328
+ if radial_mm is None:
329
+ continue
330
+ corners = _global_corners(face, top, component)
331
+ if corners is None:
332
+ continue
333
+ results.append((face_index, radial_mm, corners))
334
+ return results
335
+
336
+
337
+ def nearest_face(modules: KsApiModules, component: Any, top: Any, target: Point3D) -> Any | None:
338
+ """Resolve the `IFace` of `component` whose tessellation has the vertex nearest `target`.
339
+
340
+ The fast `measure_min_distance` mechanism (`IMeasurement3D`, part<->part) does not
341
+ return the winning face pair, but the permanent `IDistanceAngleMeasurement3D` feature
342
+ only accepts `IFace<->IFace`. This resolves a face by nearest tessellated vertex to
343
+ the fast measurement's own min-distance point — same `component_mesh` mechanic
344
+ (`_component_faces` + `_face_tessellation_points`), reused here instead of duplicated
345
+ (GPC8). Chordal tessellation error can occasionally pick a neighboring face over the
346
+ mathematically exact one — acceptable for a highlight, not for a measured fact (see
347
+ `ShowDistanceResult` docstring on `shown_value_mm`).
348
+ """
349
+ best_face: Any | None = None
350
+ best_distance_sq: float | None = None
351
+ for face in _component_faces(modules, component):
352
+ points = _face_tessellation_points(face, top, component)
353
+ if points is None:
354
+ continue
355
+ for point in points:
356
+ dx = point.x_mm - target.x_mm
357
+ dy = point.y_mm - target.y_mm
358
+ dz = point.z_mm - target.z_mm
359
+ distance_sq = dx * dx + dy * dy + dz * dz
360
+ if best_distance_sq is None or distance_sq < best_distance_sq:
361
+ best_distance_sq = distance_sq
362
+ best_face = face
363
+ return best_face
364
+
365
+
366
+ def component_axis(modules: KsApiModules, app: Any, identifier: str) -> AxisLine:
367
+ """Component axis: origin point + direction, global document CS, mm.
368
+
369
+ Spike 01: `IPlacement3D.GetVector(o3d_axisOZ)` is the direct, recommended way
370
+ to get the local-Z axis direction of a component's placement (not a manual
371
+ `GetMatrix3D()` parse, which risks a row-major/column-major mixup on rotated
372
+ components). For standard library fasteners, `GetOrigin()` lands on the
373
+ reference plane under the head/nut.
374
+ """
375
+ _doc3d, _top, _components, component = resolve_component(modules, app, "component_axis", identifier)
376
+
377
+ placement = component.GetPlacement()
378
+ if not placement:
379
+ raise KompasError(f"component_axis: component {identifier!r} has no placement")
380
+ origin = bool_xyz_point(placement.GetOrigin(0.0, 0.0, 0.0))
381
+ if origin is None:
382
+ raise KompasError(f"component_axis: GetOrigin() failed for component {identifier!r}")
383
+ direction = bool_xyz_point(placement.GetVector(int(constants3d_class(modules).o3d_axisOZ), 0.0, 0.0, 0.0))
384
+ if direction is None:
385
+ raise KompasError(f"component_axis: GetVector(o3d_axisOZ) failed for component {identifier!r}")
386
+
387
+ logger.info(
388
+ event_name(LogDomain.BRIDGE, "ksapi_component_axis", LogEventType.COMPLETED),
389
+ identifier=identifier,
390
+ )
391
+ return AxisLine(origin=origin, direction=direction)
392
+
393
+
394
+ def component_mesh(modules: KsApiModules, app: Any, identifier: str) -> ComponentMesh:
395
+ """Tessellation points of every face of a component, global document CS, mm.
396
+
397
+ Reuses the `GetTessellation().GetFacetPoints()` + `IPart.TransformPoints()`
398
+ mechanic that also feeds `nearest_face`. Faces without tessellation are skipped,
399
+ not errors.
400
+ """
401
+ _doc3d, top, _components, component = resolve_component(modules, app, "component_mesh", identifier)
402
+
403
+ faces = _component_faces(modules, component)
404
+ face_meshes: list[FaceMesh] = []
405
+ for face_index, face in enumerate(faces):
406
+ points = _face_tessellation_points(face, top, component)
407
+ if points is None:
408
+ continue
409
+ face_meshes.append(FaceMesh(face_index=face_index, points=points))
410
+
411
+ logger.info(
412
+ event_name(LogDomain.BRIDGE, "ksapi_component_mesh", LogEventType.COMPLETED),
413
+ identifier=identifier,
414
+ face_count=len(faces),
415
+ faces_with_tessellation=len(face_meshes),
416
+ )
417
+ return ComponentMesh(faces=face_meshes)
418
+
419
+
420
+ def component_planar_faces(modules: KsApiModules, app: Any, identifier: str, axis: AxisLine) -> PlanarFaces:
421
+ """Exact planar side-wall faces of a component parallel to `axis`, global document CS, mm.
422
+
423
+ For each face: `IFace.IsPlanar()` gates out every non-planar surface (cylinder,
424
+ cone, fillet, freeform — see AGENTS.md §«KsAPI: точный B-rep вместо
425
+ меша/тесселляции», `GetSurface3DType()` proved unreliable, `Is*()` predicates
426
+ didn't). For planar faces, `_face_plane_local` samples the underlying analytic
427
+ plane once (normal + a point on it) at the face's own parameter midpoint — exact,
428
+ not a tessellation fit. A face is kept only if its plane is parallel to `axis`
429
+ (`|dot(normal, unit axis direction)|` below tolerance) — a torec/скос face has no
430
+ single well-defined radial distance and is not a side wall.
431
+
432
+ `axis` need not be `identifier`'s own axis: calling this with a FASTENER's axis
433
+ while `identifier` is one of its NEIGHBORS finds the neighbor's walls parallel to
434
+ the fastener, which is what a wrench-clearance radial fact needs (`eyes.wrench_zone`).
435
+ Calling it with `component_axis(identifier)` (the component's own axis) reproduces
436
+ the original hex-detection use (`eyes.hex_detect`) — that case is not a special
437
+ branch, just this same code with a particular `axis` value: `_axis_in_local_frame`
438
+ transforms `axis.origin`/`origin+direction` into `identifier`'s local frame via
439
+ `component.TransformPoint(x, y, z, top)`, and for a component's OWN axis that
440
+ transform trivially yields local `(0,0,0)`/`(0,0,1)` — the previous hardcoded
441
+ assumption, now a special case rather than baked in.
442
+
443
+ Coordinate frame: `IFace` methods operate in the component's OWN LOCAL body frame,
444
+ not the assembly's global CS (unlike `component_axis`/`component_mesh`, which are
445
+ already transformed) — see AGENTS.md §«KsAPI: точный B-rep». `radial_mm` is
446
+ computed in this local frame using the axis ALSO expressed there
447
+ (`_axis_in_local_frame`) — a plane-to-line distance, frame-invariant in magnitude,
448
+ and (because the plane is parallel to the axis) independent of which point on the
449
+ axis is used: for axis direction `d` with `dot(normal, d) = 0`, every point `O` on
450
+ the axis gives the same `dot(point_on_plane - O, normal)` as `O` varies by `t*d`
451
+ (that extra term is `t * dot(d, normal) = 0`). `corners` (the face's parameter-box
452
+ corners, `_face_param_box_corners_local`) ARE transformed to the assembly's global
453
+ CS via `_global_corners` so the caller can project them onto the global `axis`
454
+ without knowing about this function's local-frame internals.
455
+ """
456
+ _doc3d, top, _components, component = resolve_component(modules, app, "component_planar_faces", identifier)
457
+
458
+ local_axis = _axis_in_local_frame(top, component, axis)
459
+ if local_axis is None:
460
+ raise KompasError(f"component_planar_faces: could not transform axis into local frame of {identifier!r}")
461
+ local_origin, unit_local_direction = local_axis
462
+
463
+ def _planar_radial_mm(face: Any) -> float | None:
464
+ if not bool(face.IsPlanar()):
465
+ return None
466
+ plane = _face_plane_local(face)
467
+ if plane is None:
468
+ return None
469
+ normal, point_on_plane = plane
470
+ if abs(_dot(normal, unit_local_direction)) > _PLANE_PARALLEL_TO_AXIS_TOLERANCE:
471
+ return None # торец/скос — не боковая стенка призмы
472
+ return abs(_dot(_subtract(point_on_plane, local_origin), normal))
473
+
474
+ faces = _component_faces(modules, component)
475
+ facts = [
476
+ PlanarFaceFact(face_index=face_index, radial_mm=radial_mm, corners=corners)
477
+ for face_index, radial_mm, corners in _collect_face_facts(
478
+ faces, _planar_radial_mm, top=top, component=component
479
+ )
480
+ ]
481
+
482
+ logger.info(
483
+ event_name(LogDomain.BRIDGE, "ksapi_component_planar_faces", LogEventType.COMPLETED),
484
+ identifier=identifier,
485
+ face_count=len(faces),
486
+ planar_side_faces=len(facts),
487
+ )
488
+ return PlanarFaces(faces=facts)
489
+
490
+
491
+ def component_cylindrical_faces(modules: KsApiModules, app: Any, identifier: str, axis: AxisLine) -> CylindricalFaces:
492
+ """Exact cylindrical faces of a component whose axis is parallel to `axis`, global document CS, mm.
493
+
494
+ Sibling of `component_planar_faces` for the ROUND pocket-corner case of ГОСТ
495
+ 13682-80 Черт. 5 (R — the radius a wrench needs to turn in). For each face:
496
+ `IFace.IsCylinder()` gates non-cylindrical surfaces; `GetRadius()` gives the exact
497
+ radius (no fitting — call only after `IsCylinder()` confirmed true, it has no
498
+ success flag); `_cylinder_axis_local` gives the cylinder's own axis via
499
+ `GetMathSurface().GetPlacement()`, in the SAME local body frame as `_face_plane_local`
500
+ (confirmed live — see that function's docstring). A face is kept only if its own
501
+ axis is parallel to `axis` (within `_CYLINDER_AXIS_PARALLEL_TOLERANCE`) — a bore or
502
+ fillet crossing `axis` at an angle has no single radial distance from it.
503
+
504
+ `radial_mm = abs(axis_to_axis_mm - radius_mm)`: `axis_to_axis_mm` is the radial
505
+ component (`eyes.wrench_zone`-style axis projection, computed inline here — this
506
+ module does not import `eyes`) of the cylinder axis's origin relative to `axis`, in
507
+ the shared local frame; since the two axes are parallel, this is the same for any
508
+ point picked on the cylinder axis. `abs(d - R)` is the distance from a point (the
509
+ projection of `axis` onto the cross-section plane) to a circle of radius `R` —
510
+ correct whether `axis` runs inside or outside the cylinder, no sign/side reasoning
511
+ needed. Confirmed live (spike, 2026-07-08): matched two independently known
512
+ reference distances on the same demo assembly exactly (6.0 mm and 16.0 mm).
513
+
514
+ `corners` — same `_global_corners` transform to global CS as `component_planar_faces`,
515
+ reused verbatim.
516
+ """
517
+ _doc3d, top, _components, component = resolve_component(modules, app, "component_cylindrical_faces", identifier)
518
+
519
+ local_axis = _axis_in_local_frame(top, component, axis)
520
+ if local_axis is None:
521
+ raise KompasError(f"component_cylindrical_faces: could not transform axis into local frame of {identifier!r}")
522
+ local_origin, unit_local_direction = local_axis
523
+
524
+ def _cylindrical_radial_mm(face: Any) -> float | None:
525
+ if not bool(face.IsCylinder()):
526
+ return None
527
+ cylinder_axis = _cylinder_axis_local(modules, face)
528
+ if cylinder_axis is None:
529
+ return None
530
+ cylinder_origin, unit_cylinder_direction = cylinder_axis
531
+ alignment = abs(_dot(unit_cylinder_direction, unit_local_direction))
532
+ if abs(alignment - 1.0) > _CYLINDER_AXIS_PARALLEL_TOLERANCE:
533
+ return None # ось цилиндра не параллельна запрошенной оси — вне охвата этого fast-path
534
+ radius_mm = float(face.GetRadius())
535
+ delta = _subtract(cylinder_origin, local_origin)
536
+ axial_mm = _dot(delta, unit_local_direction)
537
+ radial_vector = Point3D(
538
+ x_mm=delta.x_mm - axial_mm * unit_local_direction.x_mm,
539
+ y_mm=delta.y_mm - axial_mm * unit_local_direction.y_mm,
540
+ z_mm=delta.z_mm - axial_mm * unit_local_direction.z_mm,
541
+ )
542
+ axis_to_axis_mm = math.sqrt(radial_vector.x_mm**2 + radial_vector.y_mm**2 + radial_vector.z_mm**2)
543
+ return abs(axis_to_axis_mm - radius_mm)
544
+
545
+ faces = _component_faces(modules, component)
546
+ facts = [
547
+ CylindricalFaceFact(face_index=face_index, radial_mm=radial_mm, corners=corners)
548
+ for face_index, radial_mm, corners in _collect_face_facts(
549
+ faces, _cylindrical_radial_mm, top=top, component=component
550
+ )
551
+ ]
552
+
553
+ logger.info(
554
+ event_name(LogDomain.BRIDGE, "ksapi_component_cylindrical_faces", LogEventType.COMPLETED),
555
+ identifier=identifier,
556
+ face_count=len(faces),
557
+ cylindrical_faces=len(facts),
558
+ )
559
+ return CylindricalFaces(faces=facts)