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,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()
|