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.
- kompas_kernel/__init__.py +3 -0
- kompas_kernel/features/__init__.py +9 -0
- kompas_kernel/features/assembly_navigator/__init__.py +8 -0
- kompas_kernel/features/assembly_navigator/commands.py +37 -0
- kompas_kernel/features/assembly_navigator/ksapi/__init__.py +5 -0
- kompas_kernel/features/assembly_navigator/ksapi/mapping.py +122 -0
- kompas_kernel/features/assembly_navigator/ksapi/presentation.py +107 -0
- kompas_kernel/features/assembly_navigator/ksapi/raw.py +74 -0
- kompas_kernel/features/assembly_navigator/ksapi/reader.py +287 -0
- kompas_kernel/features/assembly_navigator/ksapi/resolver.py +35 -0
- kompas_kernel/features/assembly_navigator/models.py +218 -0
- kompas_kernel/features/assembly_navigator/queries.py +42 -0
- kompas_kernel/features/assembly_navigator/service.py +31 -0
- kompas_kernel/features/assembly_navigator/summary.py +90 -0
- kompas_kernel/features/document_session/__init__.py +5 -0
- kompas_kernel/features/document_session/checkpointed.py +165 -0
- kompas_kernel/features/document_session/commands.py +94 -0
- kompas_kernel/features/document_session/digest.py +126 -0
- kompas_kernel/features/document_session/ksapi/__init__.py +5 -0
- kompas_kernel/features/document_session/ksapi/context_reader.py +174 -0
- kompas_kernel/features/document_session/ksapi/screenshot.py +365 -0
- kompas_kernel/features/document_session/ksapi/snapshot.py +181 -0
- kompas_kernel/features/document_session/models.py +311 -0
- kompas_kernel/features/document_session/queries.py +62 -0
- kompas_kernel/features/document_session/registry.py +101 -0
- kompas_kernel/features/document_session/service.py +128 -0
- kompas_kernel/features/geometry_3d/__init__.py +9 -0
- kompas_kernel/features/geometry_3d/axis_math.py +50 -0
- kompas_kernel/features/geometry_3d/commands.py +68 -0
- kompas_kernel/features/geometry_3d/hex_detect.py +113 -0
- kompas_kernel/features/geometry_3d/ksapi/__init__.py +5 -0
- kompas_kernel/features/geometry_3d/ksapi/component_geometry.py +559 -0
- kompas_kernel/features/geometry_3d/ksapi/measurements.py +368 -0
- kompas_kernel/features/geometry_3d/ksapi/presentation.py +204 -0
- kompas_kernel/features/geometry_3d/models.py +216 -0
- kompas_kernel/features/geometry_3d/queries.py +96 -0
- kompas_kernel/features/geometry_3d/service.py +69 -0
- kompas_kernel/features/geometry_3d/wrench_zone.py +161 -0
- kompas_kernel/features/ksapi_automation/__init__.py +9 -0
- kompas_kernel/features/ksapi_automation/gate.py +282 -0
- kompas_kernel/features/ksapi_automation/models.py +75 -0
- kompas_kernel/features/ksapi_automation/namespace.py +12 -0
- kompas_kernel/features/ksapi_automation/run_python.py +133 -0
- kompas_kernel/features/ksapi_automation/runner.py +126 -0
- kompas_kernel/features/ksapi_automation/service.py +85 -0
- kompas_kernel/features/part_authoring/__init__.py +5 -0
- kompas_kernel/features/part_authoring/build_geometry.py +122 -0
- kompas_kernel/features/part_authoring/models.py +99 -0
- kompas_kernel/features/wrench_clearance/__init__.py +7 -0
- kompas_kernel/features/wrench_clearance/models.py +87 -0
- kompas_kernel/features/wrench_clearance/service.py +154 -0
- kompas_kernel/kompas/__init__.py +14 -0
- kompas_kernel/kompas/errors.py +11 -0
- kompas_kernel/kompas/models.py +68 -0
- kompas_kernel/kompas/objects.py +93 -0
- kompas_kernel/kompas/platform.py +40 -0
- kompas_kernel/kompas/runtime.py +186 -0
- kompas_kernel/kompas/session.py +195 -0
- kompas_kernel/kompas/units.py +48 -0
- kompas_kernel/ksapi_static/__init__.py +8 -0
- kompas_kernel/ksapi_static/facts.py +132 -0
- kompas_kernel/ksapi_static/inventory.py +185 -0
- kompas_kernel/ksapi_static/recipes.py +114 -0
- kompas_kernel/ksapi_static/wrapper_grep.py +83 -0
- kompas_kernel/observability/__init__.py +1 -0
- kompas_kernel/observability/logger.py +24 -0
- kompas_kernel/observability/setup.py +115 -0
- kompas_kernel/observability/taxonomy.py +27 -0
- kompas_kernel/py.typed +1 -0
- kompas_kernel/standards/__init__.py +1 -0
- kompas_kernel/standards/wrench_clearance.py +234 -0
- kompas_kernel/utils/__init__.py +1 -0
- kompas_kernel/utils/filesystem.py +82 -0
- kompas_kernel/utils/validation.py +21 -0
- kompas_kernel-0.0.1.dist-info/METADATA +56 -0
- kompas_kernel-0.0.1.dist-info/RECORD +77 -0
- 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)
|