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,365 @@
1
+ """Window screenshot capture via WinAPI (`ctypes` only, no pywin32 dependency).
2
+
3
+ `capture_window` is a free function taking the already-connected `app` explicitly
4
+ (no adapter `self` state), same facade split as the other KsAPI capability modules.
5
+
6
+ Три грабли живого КОМПАС, из-за которых наивный `PrintWindow` отдаёт мусор
7
+ (все три проверены живьём, см. `docs/recipes/ksapi-link.md`):
8
+
9
+ - **DPI.** Python-процесс не DPI-aware, поэтому `GetWindowRect` отдаёт ЛОГИЧЕСКИЕ
10
+ координаты (напр. 822x877), а КОМПАС рисует себя в ФИЗИЧЕСКИХ пикселях
11
+ (1440x1536 при масштабе 175%) — в битмап влезает только левый верхний угол.
12
+ Лечится `SetThreadDpiAwarenessContext` на время захвата: контекст потоко-локальный
13
+ и обратимый, процесс целиком не трогаем.
14
+ - **Свёрнутое окно.** У свёрнутого окна прямоугольник — sentinel 158x26, снимок
15
+ бесполезен. Разворачиваем на время съёмки и возвращаем как было.
16
+ - **GPU-рендер сцены.** `PrintWindow` ПО ДОЧЕРНЕМУ окну вьюпорта (`D3View`) даёт
17
+ чёрный кадр — у child-окна нет своей DWM-поверхности. Сцена попадает в снимок
18
+ только через ГЛАВНОЕ окно с `PW_RENDERFULLCONTENT`, поэтому кадр — окно целиком.
19
+
20
+ Формат — JPEG (`SCREENSHOT_IMAGE_FORMAT`): BMP не принимает ни одна vision-модель
21
+ (у pydantic-ai в `_image_format_lookup` только png/jpeg/gif/webp), а PNG на скриншоте
22
+ окна весит втрое больше при том же читаемом дереве.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import ctypes
29
+ import time
30
+ from collections.abc import Generator
31
+ from ctypes import wintypes
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from PIL import Image
37
+
38
+ from kompas_kernel.features.document_session.models import (
39
+ SCREENSHOT_IMAGE_FORMAT,
40
+ ScreenshotResult,
41
+ )
42
+ from kompas_kernel.kompas.errors import KompasError
43
+ from kompas_kernel.kompas.objects import constants_class
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
+ from kompas_kernel.utils.filesystem import atomic_write_file
48
+
49
+ logger = get_logger(__name__)
50
+
51
+ # Длинная сторона снимка, уходящего в модель. Окно КОМПАС на 4K-мониторе — это ~2880px
52
+ # и мегабайты токенов на ровном месте; 1400 держит дерево построения читаемым и
53
+ # совпадает с типичным рабочим разрешением окна на 175% масштабе.
54
+ CAPTURE_MAX_SIDE_PX = 1400
55
+
56
+ # Формат кодирования — из общего контракта (`SCREENSHOT_IMAGE_FORMAT`), качество живёт
57
+ # здесь: q=90 на живом окне 1312x1400 дал 257 КБ против 907 КБ у PNG, при этом имена
58
+ # элементов в дереве построения читаются без потерь (проверено глазами и моделью).
59
+ _PILLOW_FORMAT = "JPEG"
60
+ _JPEG_QUALITY = 90
61
+
62
+ # Паузы: КОМПАС исполняет ExecuteKompasCommand асинхронно (см. `_fit_view_to_document`),
63
+ # а восстановленное окно рисуется не мгновенно. Снимать раньше — поймать полукадр.
64
+ _VIEW_SETTLE_SECONDS = 0.5
65
+ _WINDOW_RESTORE_SECONDS = 1.2
66
+
67
+ # Меньше — уже не окно, а sentinel свёрнутого/схлопнутого состояния: снимать нечего.
68
+ _MIN_USABLE_WINDOW_SIDE_PX = 200
69
+
70
+ _PW_RENDERFULLCONTENT = 2
71
+ _BI_RGB = 0
72
+ _DIB_RGB_COLORS = 0
73
+ _BMP_INFO_HEADER_SIZE = 40
74
+ _BITS_PER_PIXEL = 32
75
+ _BYTES_PER_PIXEL = _BITS_PER_PIXEL // 8
76
+
77
+ # `DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2` — псевдо-хэндл -4 (windef.h).
78
+ _DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = ctypes.c_void_p(-4)
79
+
80
+ _SW_MINIMIZE = 6
81
+ _SW_RESTORE = 9
82
+ # `showCmd` свёрнутого окна: SW_SHOWMINIMIZED / SW_SHOWMINNOACTIVE.
83
+ _MINIMIZED_SHOW_COMMANDS = frozenset({2, 7})
84
+
85
+
86
+ class _RECT(ctypes.Structure):
87
+ _fields_ = [
88
+ ("left", wintypes.LONG),
89
+ ("top", wintypes.LONG),
90
+ ("right", wintypes.LONG),
91
+ ("bottom", wintypes.LONG),
92
+ ]
93
+
94
+
95
+ class _BITMAPINFOHEADER(ctypes.Structure):
96
+ _fields_ = [
97
+ ("biSize", wintypes.DWORD),
98
+ ("biWidth", wintypes.LONG),
99
+ ("biHeight", wintypes.LONG),
100
+ ("biPlanes", wintypes.WORD),
101
+ ("biBitCount", wintypes.WORD),
102
+ ("biCompression", wintypes.DWORD),
103
+ ("biSizeImage", wintypes.DWORD),
104
+ ("biXPelsPerMeter", wintypes.LONG),
105
+ ("biYPelsPerMeter", wintypes.LONG),
106
+ ("biClrUsed", wintypes.DWORD),
107
+ ("biClrImportant", wintypes.DWORD),
108
+ ]
109
+
110
+
111
+ class _BITMAPINFO(ctypes.Structure):
112
+ _fields_ = [
113
+ ("bmiHeader", _BITMAPINFOHEADER),
114
+ ("bmiColors", wintypes.DWORD * 1),
115
+ ]
116
+
117
+
118
+ class _WINDOWPLACEMENT(ctypes.Structure):
119
+ _fields_ = [
120
+ ("length", wintypes.UINT),
121
+ ("flags", wintypes.UINT),
122
+ ("showCmd", wintypes.UINT),
123
+ ("ptMinPosition", wintypes.POINT),
124
+ ("ptMaxPosition", wintypes.POINT),
125
+ ("rcNormalPosition", _RECT),
126
+ ]
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class _WinApi:
131
+ user32: Any
132
+ gdi32: Any
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class _WindowSize:
137
+ width: int
138
+ height: int
139
+
140
+
141
+ @dataclass
142
+ class _CaptureResources:
143
+ api: _WinApi
144
+ hwnd: int
145
+ window_dc: int = 0
146
+ memory_dc: int = 0
147
+ bitmap: int = 0
148
+ old_object: int = 0
149
+
150
+ @classmethod
151
+ def create(cls, *, api: _WinApi, hwnd: int, size: _WindowSize) -> _CaptureResources:
152
+ resources = cls(api=api, hwnd=hwnd)
153
+ try:
154
+ resources.window_dc = int(api.user32.GetWindowDC(wintypes.HWND(hwnd)))
155
+ if not resources.window_dc:
156
+ raise _winapi_error("GetWindowDC")
157
+ resources.memory_dc = int(api.gdi32.CreateCompatibleDC(resources.window_dc))
158
+ if not resources.memory_dc:
159
+ raise _winapi_error("CreateCompatibleDC")
160
+ resources.bitmap = int(api.gdi32.CreateCompatibleBitmap(resources.window_dc, size.width, size.height))
161
+ if not resources.bitmap:
162
+ raise _winapi_error("CreateCompatibleBitmap")
163
+ resources.old_object = int(api.gdi32.SelectObject(resources.memory_dc, resources.bitmap))
164
+ if not resources.old_object:
165
+ raise _winapi_error("SelectObject")
166
+ return resources
167
+ except Exception:
168
+ resources.close()
169
+ raise
170
+
171
+ def close(self) -> None:
172
+ if self.old_object:
173
+ self.api.gdi32.SelectObject(self.memory_dc, self.old_object)
174
+ self.old_object = 0
175
+ if self.bitmap:
176
+ self.api.gdi32.DeleteObject(self.bitmap)
177
+ self.bitmap = 0
178
+ if self.memory_dc:
179
+ self.api.gdi32.DeleteDC(self.memory_dc)
180
+ self.memory_dc = 0
181
+ if self.window_dc:
182
+ self.api.user32.ReleaseDC(wintypes.HWND(self.hwnd), self.window_dc)
183
+ self.window_dc = 0
184
+
185
+
186
+ def _winapi_error(operation: str) -> KompasError:
187
+ code = ctypes.get_last_error()
188
+ return KompasError(f"screenshot: {operation} failed (WinError {code})")
189
+
190
+
191
+ def _load_winapi() -> _WinApi:
192
+ return _WinApi(
193
+ user32=ctypes.WinDLL("user32", use_last_error=True),
194
+ gdi32=ctypes.WinDLL("gdi32", use_last_error=True),
195
+ )
196
+
197
+
198
+ @contextlib.contextmanager
199
+ def _thread_dpi_awareness(api: _WinApi) -> Generator[None, None, None]:
200
+ """Сделать ТЕКУЩИЙ ПОТОК per-monitor-v2 DPI-aware на время захвата.
201
+
202
+ Без этого `GetWindowRect` врёт в логических координатах и снимок обрезается до
203
+ `1/scale` по каждой оси (при 175% — до 57%). Контекст потоко-локальный, поэтому
204
+ остальной процесс (и другие KsAPI-вызовы) поведение не меняют.
205
+ """
206
+ set_context = getattr(api.user32, "SetThreadDpiAwarenessContext", None)
207
+ if set_context is None:
208
+ raise KompasError("screenshot: SetThreadDpiAwarenessContext unavailable (needs Windows 10 1607+)")
209
+ set_context.restype = ctypes.c_void_p
210
+ set_context.argtypes = [ctypes.c_void_p]
211
+ previous = set_context(_DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
212
+ if not previous:
213
+ raise _winapi_error("SetThreadDpiAwarenessContext")
214
+ try:
215
+ yield
216
+ finally:
217
+ set_context(ctypes.c_void_p(previous))
218
+
219
+
220
+ def _show_command(api: _WinApi, hwnd: int) -> int:
221
+ placement = _WINDOWPLACEMENT()
222
+ placement.length = ctypes.sizeof(_WINDOWPLACEMENT)
223
+ if not api.user32.GetWindowPlacement(wintypes.HWND(hwnd), ctypes.byref(placement)):
224
+ raise _winapi_error("GetWindowPlacement")
225
+ return int(placement.showCmd)
226
+
227
+
228
+ @contextlib.contextmanager
229
+ def _window_unminimized(api: _WinApi, hwnd: int) -> Generator[None, None, None]:
230
+ """Развернуть свёрнутое окно КОМПАС на время съёмки и вернуть его как было.
231
+
232
+ Разворачивается ровно свёрнутое окно: размер и позицию нормального окна не трогаем —
233
+ пользователь мог поставить его так намеренно. Это смена состояния ОКНА, не документа.
234
+ """
235
+ if _show_command(api, hwnd) not in _MINIMIZED_SHOW_COMMANDS:
236
+ yield
237
+ return
238
+ logger.info(event_name(LogDomain.BRIDGE, "ksapi_screenshot_window_restore", LogEventType.STARTED), hwnd=hwnd)
239
+ api.user32.ShowWindow(wintypes.HWND(hwnd), _SW_RESTORE)
240
+ time.sleep(_WINDOW_RESTORE_SECONDS)
241
+ try:
242
+ yield
243
+ finally:
244
+ api.user32.ShowWindow(wintypes.HWND(hwnd), _SW_MINIMIZE)
245
+
246
+
247
+ def _fit_view_to_document(app: Any, constants: Any) -> bool:
248
+ """Вписать документ в окно (`ksCMZoomEntireDocument`) — иначе в кадр попадает кусок детали.
249
+
250
+ `ExecuteKompasCommand` требует `asyncExecution=True`: синхронный вызов с worker-потока
251
+ не отрабатывает (то же ограничение, что у `ksCMZoomSelected` в `adapter.show_component`).
252
+ Команда уходит в очередь UI-потока КОМПАС, поэтому дальше — пауза на перерисовку.
253
+ """
254
+ zoomed = bool(app.ExecuteKompasCommand(int(constants.ksCMZoomEntireDocument), True))
255
+ time.sleep(_VIEW_SETTLE_SECONDS)
256
+ return zoomed
257
+
258
+
259
+ def _window_size(api: _WinApi, hwnd: int) -> _WindowSize:
260
+ rect = _RECT()
261
+ if not api.user32.GetWindowRect(wintypes.HWND(hwnd), ctypes.byref(rect)):
262
+ raise _winapi_error("GetWindowRect")
263
+ width = int(rect.right - rect.left)
264
+ height = int(rect.bottom - rect.top)
265
+ if width <= 0 or height <= 0:
266
+ raise KompasError("screenshot: KOMPAS window has zero size")
267
+ if width < _MIN_USABLE_WINDOW_SIDE_PX or height < _MIN_USABLE_WINDOW_SIDE_PX:
268
+ raise KompasError(
269
+ f"screenshot: KOMPAS window is too small to capture ({width}x{height}px) — "
270
+ "разверните окно КОМПАС и повторите"
271
+ )
272
+ return _WindowSize(width=width, height=height)
273
+
274
+
275
+ def _render_window(api: _WinApi, *, hwnd: int, memory_dc: int) -> None:
276
+ if not api.user32.PrintWindow(wintypes.HWND(hwnd), memory_dc, _PW_RENDERFULLCONTENT):
277
+ raise _winapi_error("PrintWindow")
278
+
279
+
280
+ def _bitmap_info(size: _WindowSize, image_size: int) -> _BITMAPINFO:
281
+ bitmap_info = _BITMAPINFO()
282
+ bitmap_info.bmiHeader.biSize = _BMP_INFO_HEADER_SIZE
283
+ bitmap_info.bmiHeader.biWidth = size.width
284
+ bitmap_info.bmiHeader.biHeight = -size.height
285
+ bitmap_info.bmiHeader.biPlanes = 1
286
+ bitmap_info.bmiHeader.biBitCount = _BITS_PER_PIXEL
287
+ bitmap_info.bmiHeader.biCompression = _BI_RGB
288
+ bitmap_info.bmiHeader.biSizeImage = image_size
289
+ return bitmap_info
290
+
291
+
292
+ def _read_bitmap_pixels(api: _WinApi, *, memory_dc: int, bitmap: int, size: _WindowSize) -> bytes:
293
+ image_size = size.width * size.height * _BYTES_PER_PIXEL
294
+ pixels = ctypes.create_string_buffer(image_size)
295
+ bitmap_info = _bitmap_info(size, image_size)
296
+ scan_lines = api.gdi32.GetDIBits(
297
+ memory_dc,
298
+ bitmap,
299
+ 0,
300
+ size.height,
301
+ pixels,
302
+ ctypes.byref(bitmap_info),
303
+ _DIB_RGB_COLORS,
304
+ )
305
+ if int(scan_lines) != size.height:
306
+ raise _winapi_error("GetDIBits")
307
+ return pixels.raw
308
+
309
+
310
+ def _grab_window_pixels(api: _WinApi, *, hwnd: int, size: _WindowSize) -> bytes:
311
+ resources = _CaptureResources.create(api=api, hwnd=hwnd, size=size)
312
+ try:
313
+ _render_window(api, hwnd=hwnd, memory_dc=resources.memory_dc)
314
+ return _read_bitmap_pixels(api, memory_dc=resources.memory_dc, bitmap=resources.bitmap, size=size)
315
+ finally:
316
+ resources.close()
317
+
318
+
319
+ def to_downscaled_rgb(pixels_bgra: bytes, *, size: _WindowSize, max_side: int) -> Image.Image:
320
+ """Сырые BGRA-пиксели окна → RGB-картинка, вписанная в квадрат `max_side` (только уменьшение).
321
+
322
+ Альфу отбрасываем намеренно: `PrintWindow` заполняет её нулями, и снимок с такой
323
+ альфой уезжает в модель полностью прозрачным (виден как пустой кадр).
324
+ """
325
+ # Отрицательный `biHeight` в `_bitmap_info` уже дал top-down порядок строк — переворот не нужен.
326
+ image = Image.frombuffer("RGBA", (size.width, size.height), pixels_bgra, "raw", "BGRA", 0, 1).convert("RGB")
327
+ longest_side = max(image.width, image.height)
328
+ if longest_side <= max_side:
329
+ return image
330
+ scale = max_side / longest_side
331
+ return image.resize((round(image.width * scale), round(image.height * scale)), Image.Resampling.LANCZOS)
332
+
333
+
334
+ def write_image_atomic(image: Image.Image, out_path: Path) -> None:
335
+ """Снимок на диск через temp+rename (GPC5): читатель никогда не видит полуфайл."""
336
+
337
+ def write_jpeg(temporary: Path) -> None:
338
+ with temporary.open("wb") as stream:
339
+ image.save(stream, format=_PILLOW_FORMAT, quality=_JPEG_QUALITY, optimize=True)
340
+
341
+ atomic_write_file(out_path, write_jpeg, create_parents=True)
342
+
343
+
344
+ def capture_window(app: Any, modules: KsApiModules, out_path: Path) -> ScreenshotResult:
345
+ """Снять окно КОМПАС целиком: вписать вид, снять DPI-aware, уменьшить до `CAPTURE_MAX_SIDE_PX`."""
346
+ hwnd = int(app.GetMainWindowHandle())
347
+ if hwnd == 0:
348
+ raise KompasError("screenshot: KOMPAS has no main window handle")
349
+
350
+ api = _load_winapi()
351
+ constants = constants_class(modules)
352
+ with _thread_dpi_awareness(api), _window_unminimized(api, hwnd):
353
+ zoomed = _fit_view_to_document(app, constants)
354
+ size = _window_size(api, hwnd)
355
+ pixels = _grab_window_pixels(api, hwnd=hwnd, size=size)
356
+
357
+ image = to_downscaled_rgb(pixels, size=size, max_side=CAPTURE_MAX_SIDE_PX)
358
+ write_image_atomic(image, out_path)
359
+ logger.info(
360
+ event_name(LogDomain.BRIDGE, "ksapi_screenshot", LogEventType.COMPLETED),
361
+ captured_px=f"{size.width}x{size.height}",
362
+ saved_px=f"{image.width}x{image.height}",
363
+ zoomed=zoomed,
364
+ )
365
+ return ScreenshotResult(path=out_path, image_format=SCREENSHOT_IMAGE_FORMAT, width=image.width, height=image.height)
@@ -0,0 +1,181 @@
1
+ """Checkpoint/restore: копия файла документа как снимок и откат документа к ней.
2
+
3
+ Free functions taking explicit `modules`/`app` dependencies (no adapter `self`
4
+ state), same facade split as the other KsAPI capability modules.
5
+
6
+ Два факта KsAPI, на которых всё держится (проверено по `ksapi.py`):
7
+
8
+ - **Безымянный документ сохраняется сам.** `IKompasDocument.SaveAs(fileName)` даёт
9
+ свежесозданной детали (`IDocuments.Add`) путь — до этого `GetFullPath()` пуст, и
10
+ снимать копию было не с чего. Путь берём НЕ «где-нибудь во временном каталоге», а в
11
+ рабочей области сессии агента (`untitled_document_dir`): там же лежат todo, спул и
12
+ сами чекпоинты, пользователь видит файл рядом с остальным.
13
+ - **Откат = закрыть → перезаписать → открыть.** `IDocuments.GetItemByFilePath` находит
14
+ открытый документ по пути, `Close(constants.kdDoNotSaveChanges)` закрывает его БЕЗ
15
+ сохранения (иначе КОМПАС держит файл и перезапись упадёт), после подмены файла
16
+ `IDocuments.Open(path, True, False)` открывает его заново и `SetActive()` возвращает
17
+ фокус. Прежняя версия просто открывала файл снимка соседним документом — исходный
18
+ документ оставался изменённым, а «откат» был фикцией.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import shutil
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from kompas_kernel.features.document_session.models import Checkpoint
30
+ from kompas_kernel.kompas.errors import KompasError
31
+ from kompas_kernel.kompas.objects import as_document_3d, constants_class, doc_path
32
+ from kompas_kernel.kompas.runtime import KsApiModules
33
+ from kompas_kernel.utils.filesystem import atomic_write_file
34
+
35
+ # Расширение файла для безымянного 3D-документа, сохраняемого в рабочую область.
36
+ # Деталь и сборка различаются (`.m3d`/`.a3d`), поэтому выбирается по типу документа
37
+ # сессии, а не хардкодится одним значением (GPC1 — константа в одном месте).
38
+ _PART_SUFFIX = ".m3d"
39
+ _ASSEMBLY_SUFFIX = ".a3d"
40
+
41
+ # Имя файла для безымянного документа, у которого и `GetName()` пуст.
42
+ _FALLBACK_DOCUMENT_STEM = "untitled"
43
+
44
+ # Всё, кроме букв/цифр/пробела/`-`/`_`/`.`, вырезается из имени документа КОМПАС перед
45
+ # использованием в имени файла: `GetName()` возвращает произвольный пользовательский
46
+ # текст («Деталь без имени 1»), а он попадает в filesystem path.
47
+ _UNSAFE_NAME_CHARS = re.compile(r"[^\w\-. ]", flags=re.UNICODE)
48
+
49
+ # Потолок длины имени файла (без расширения) — длинное имя документа не должно
50
+ # упереться в лимит пути Windows.
51
+ _MAX_STEM_LENGTH = 60
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class SavedSnapshot:
56
+ """Итог `save_snapshot` для сервиса: куда лёг снимок и что стало с документом.
57
+
58
+ `document_path` — путь документа ПОСЛЕ возможного `SaveAs` (для безымянного он
59
+ только что появился); `saved_untitled_document` — честный признак, что файл
60
+ документа создан нами в рабочей области, а не был у пользователя изначально.
61
+ """
62
+
63
+ checkpoint_path: Path
64
+ document_path: str
65
+ saved_untitled_document: bool
66
+
67
+
68
+ def _document_suffix(modules: KsApiModules, doc: Any) -> str:
69
+ """`.m3d` для детали, `.a3d` для сборки — по типу документа сессии, не по имени."""
70
+ constants = constants_class(modules)
71
+ return _ASSEMBLY_SUFFIX if int(doc.GetDocumentType()) == int(constants.ksDocumentAssembly) else _PART_SUFFIX
72
+
73
+
74
+ def _safe_stem(raw_name: Any) -> str:
75
+ """Имя документа КОМПАС → безопасная основа имени файла (непустая, ограниченной длины)."""
76
+ text = str(raw_name).strip() if raw_name is not None else ""
77
+ cleaned = _UNSAFE_NAME_CHARS.sub("", text).strip().strip(".")
78
+ return cleaned[:_MAX_STEM_LENGTH] if cleaned else _FALLBACK_DOCUMENT_STEM
79
+
80
+
81
+ def _unique_path(directory: Path, stem: str, suffix: str) -> Path:
82
+ """Свободный путь `directory/stem[-N]suffix` — не затираем ранее сохранённый документ."""
83
+ candidate = directory / f"{stem}{suffix}"
84
+ index = 2
85
+ while candidate.exists():
86
+ candidate = directory / f"{stem}-{index}{suffix}"
87
+ index += 1
88
+ return candidate
89
+
90
+
91
+ def _save_untitled_document(modules: KsApiModules, doc: Any, untitled_document_dir: Path) -> str:
92
+ """Дать безымянному документу путь в рабочей области сессии через `SaveAs`.
93
+
94
+ Прежнее поведение — `KompasError` «save it once» — делало чекпоинт недоступным ровно
95
+ в самом частом сценарии («построй деталь с нуля»): документ создан агентом и имени
96
+ у него нет. Каталог для таких документов приходит СНАРУЖИ (рабочая область сессии),
97
+ этот модуль сам ничего про рабочую область не знает.
98
+ """
99
+ untitled_document_dir.mkdir(parents=True, exist_ok=True)
100
+ target = _unique_path(untitled_document_dir, _safe_stem(doc.GetName()), _document_suffix(modules, doc))
101
+ if not doc.SaveAs(str(target)):
102
+ raise KompasError(f"checkpoint: SaveAs безымянного документа вернул false: {target}")
103
+ saved_path = doc_path(doc)
104
+ if saved_path is None:
105
+ raise KompasError(f"checkpoint: после SaveAs у документа всё ещё нет пути: {target}")
106
+ return saved_path
107
+
108
+
109
+ def _copy_atomically(source: Path, target: Path) -> None:
110
+ """Скопировать файл через общий атомарный file-write примитив (GPC5)."""
111
+
112
+ def copy_file(temporary: Path) -> None:
113
+ shutil.copy2(source, temporary)
114
+
115
+ atomic_write_file(target, copy_file, create_parents=True)
116
+
117
+
118
+ def save_snapshot(
119
+ modules: KsApiModules,
120
+ app: Any,
121
+ *,
122
+ checkpoint_dir: Path,
123
+ checkpoint_id: str,
124
+ untitled_document_dir: Path,
125
+ ) -> SavedSnapshot:
126
+ """Сохранить активный 3D-документ и положить его копию в `checkpoint_dir`.
127
+
128
+ Безымянный документ сперва получает путь в `untitled_document_dir` (см.
129
+ `_save_untitled_document`) — только «нет активного 3D-документа» остаётся отказом.
130
+ """
131
+ doc = as_document_3d(modules, app.GetActiveDocument())
132
+ if doc is None:
133
+ raise KompasError("checkpoint: нет активного 3D-документа — открой или создай деталь/сборку")
134
+ source = doc_path(doc)
135
+ saved_untitled_document = source is None
136
+ if source is None:
137
+ source = _save_untitled_document(modules, doc, untitled_document_dir)
138
+ elif not doc.Save():
139
+ raise KompasError(f"checkpoint: Save() активного документа вернул false: {source}")
140
+ target = checkpoint_dir / f"{checkpoint_id}{Path(source).suffix or _PART_SUFFIX}"
141
+ _copy_atomically(Path(source), target)
142
+ return SavedSnapshot(
143
+ checkpoint_path=target,
144
+ document_path=source,
145
+ saved_untitled_document=saved_untitled_document,
146
+ )
147
+
148
+
149
+ def _close_if_open(modules: KsApiModules, docs: Any, document_path: str) -> None:
150
+ """Закрыть документ БЕЗ сохранения, если он сейчас открыт в КОМПАС.
151
+
152
+ Пока файл открыт, КОМПАС держит его — перезапись копией снимка либо упадёт, либо
153
+ останется невидимой (в памяти по-прежнему изменённая модель). Документ может быть и
154
+ не открыт (пользователь закрыл его сам) — это не ошибка, откат просто подменит файл.
155
+ """
156
+ open_doc = docs.GetItemByFilePath(document_path)
157
+ if open_doc is None:
158
+ return
159
+ constants = constants_class(modules)
160
+ if not open_doc.Close(int(constants.kdDoNotSaveChanges)):
161
+ raise KompasError(f"restore: КОМПАС не закрыл документ перед откатом: {document_path}")
162
+
163
+
164
+ def restore_snapshot(modules: KsApiModules, app: Any, checkpoint: Checkpoint) -> None:
165
+ """Откатить документ к снимку: закрыть без сохранения → подменить файл → открыть.
166
+
167
+ Именно ИСХОДНЫЙ документ (`checkpoint.document_path`) возвращается к состоянию
168
+ снимка и становится активным; файл снимка при этом остаётся нетронутым и годен для
169
+ повторного отката.
170
+ """
171
+ if not checkpoint.path.exists():
172
+ raise KompasError(f"restore: файл чекпойнта не найден: {checkpoint.path}")
173
+ docs = app.GetDocuments()
174
+ if docs is None:
175
+ raise KompasError("restore: КОМПАС не отдал коллекцию Documents")
176
+ _close_if_open(modules, docs, checkpoint.document_path)
177
+ _copy_atomically(checkpoint.path, Path(checkpoint.document_path))
178
+ reopened = docs.Open(checkpoint.document_path, True, False)
179
+ if reopened is None:
180
+ raise KompasError(f"restore: КОМПАС не смог открыть откаченный документ: {checkpoint.document_path}")
181
+ reopened.SetActive()