cadpilot 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cadpilot/__init__.py ADDED
File without changes
@@ -0,0 +1,192 @@
1
+ """Assembly sessions: component registry + joint sequence + precomputed-undo rollback.
2
+
3
+ 独立于建模会话(session_state.py)的装配状态机。每个装配步骤在记录时就
4
+ 预计算好 undo 负载(删哪些关节/裁剪、恢复哪些 Link 位姿、Link 重指向谁),
5
+ rollback 时聚合为单个 rollback_step RPC spec 发给 addon 原子执行。
6
+
7
+ Storage layout: ``<data_dir>/assembly/<session_id>.json``,data_dir 与建模会话
8
+ 共用 ``$CADPILOT_HOME``(默认 ``~/.cadpilot``)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import threading
16
+ import uuid
17
+ from dataclasses import asdict, dataclass, field
18
+ from typing import Any
19
+
20
+ from .session_state import _now, data_dir
21
+
22
+ logger = logging.getLogger("CADPilot")
23
+
24
+ _lock = threading.Lock()
25
+ _current: AssemblySession | None = None
26
+
27
+
28
+ def assembly_dir():
29
+ path = data_dir() / "assembly"
30
+ path.mkdir(parents=True, exist_ok=True)
31
+ return path
32
+
33
+
34
+ @dataclass
35
+ class AssemblyStep:
36
+ """一个装配步骤;undo 是预计算的 rollback_step spec 字段。"""
37
+
38
+ step_number: int
39
+ operation: str # start / add_component / mate / unmate
40
+ description: str
41
+ spec_echo: dict[str, Any] = field(default_factory=dict)
42
+ undo: dict[str, Any] = field(default_factory=dict)
43
+ timestamp: str = field(default_factory=_now)
44
+
45
+
46
+ @dataclass
47
+ class AssemblySession:
48
+ session_id: str
49
+ name: str
50
+ doc_name: str
51
+ assembly_name: str
52
+ ground_part: str
53
+ status: str = "active" # active | completed
54
+ components: dict[str, Any] = field(default_factory=dict) # part -> {"link", "added_step"}
55
+ joints: list[dict[str, Any]] = field(default_factory=list)
56
+ steps: list[AssemblyStep] = field(default_factory=list)
57
+ created_at: str = field(default_factory=_now)
58
+ updated_at: str = field(default_factory=_now)
59
+
60
+
61
+ def save(session: AssemblySession) -> None:
62
+ """原子写入(tmp + replace):写盘失败不会损坏已保存的会话文件。"""
63
+ session.updated_at = _now()
64
+ path = assembly_dir() / f"{session.session_id}.json"
65
+ payload = asdict(session)
66
+ tmp = path.with_suffix(".tmp")
67
+ try:
68
+ with open(tmp, "w", encoding="utf-8") as f:
69
+ json.dump(payload, f, ensure_ascii=False, indent=1)
70
+ tmp.replace(path)
71
+ finally:
72
+ tmp.unlink(missing_ok=True)
73
+
74
+
75
+ def load(session_id: str) -> AssemblySession | None:
76
+ """加载会话;文件缺失/损坏/结构不符时返回 None(与 session_state 一致)。"""
77
+ path = assembly_dir() / f"{session_id}.json"
78
+ if not path.exists():
79
+ return None
80
+ try:
81
+ with open(path, encoding="utf-8") as f:
82
+ d = json.load(f)
83
+ d["steps"] = [s if isinstance(s, AssemblyStep) else AssemblyStep(**s) for s in d["steps"]]
84
+ return AssemblySession(**d)
85
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
86
+ logger.warning("cannot load assembly session %s: %s", session_id, exc)
87
+ return None
88
+
89
+
90
+ def list_sessions() -> list[dict[str, Any]]:
91
+ out = []
92
+ for fn in assembly_dir().glob("*.json"):
93
+ s = load(fn.stem)
94
+ if s is None:
95
+ logger.warning("skipping corrupt assembly session %s", fn)
96
+ continue
97
+ out.append(
98
+ {
99
+ "session_id": s.session_id,
100
+ "name": s.name,
101
+ "doc_name": s.doc_name,
102
+ "status": s.status,
103
+ "steps": len(s.steps),
104
+ "updated_at": s.updated_at,
105
+ }
106
+ )
107
+ out.sort(key=lambda s: s["updated_at"], reverse=True)
108
+ return out
109
+
110
+
111
+ def start_session(doc_name: str, ground: str, name: str = "") -> AssemblySession:
112
+ sid = uuid.uuid4().hex[:12]
113
+ session = AssemblySession(
114
+ session_id=sid,
115
+ name=name or f"assembly-{sid}",
116
+ doc_name=doc_name,
117
+ assembly_name="MCP_Assembly",
118
+ ground_part=ground,
119
+ )
120
+ save(session)
121
+ set_current(session)
122
+ return session
123
+
124
+
125
+ def current_session() -> AssemblySession | None:
126
+ with _lock:
127
+ return _current
128
+
129
+
130
+ def set_current(session: AssemblySession | None) -> None:
131
+ global _current
132
+ with _lock:
133
+ _current = session
134
+
135
+
136
+ def resume_session(session_id: str) -> AssemblySession | None:
137
+ session = load(session_id)
138
+ if session is None:
139
+ return None
140
+ set_current(session)
141
+ return session
142
+
143
+
144
+ def record_step(
145
+ session: AssemblySession, operation: str, description: str, spec_echo: dict, undo: dict
146
+ ) -> AssemblyStep:
147
+ step = AssemblyStep(
148
+ step_number=len(session.steps) + 1,
149
+ operation=operation,
150
+ description=description,
151
+ spec_echo=spec_echo,
152
+ undo=undo,
153
+ )
154
+ session.steps.append(step)
155
+ save(session)
156
+ return step
157
+
158
+
159
+ def plan_rollback(session: AssemblySession, to_step: int) -> dict[str, Any]:
160
+ """聚合 to_step 之后所有步骤的 undo(逆序)为单个 rollback_step spec。
161
+
162
+ links_restore 记录的是每步**之前**的 Link 位姿快照;逆序遍历时
163
+ setdefault 保留最靠后步骤的快照,即最接近 to_step 时刻的状态。
164
+ """
165
+ joints: list[str] = []
166
+ cuts: list[str] = []
167
+ restore: dict[str, Any] = {}
168
+ repoint: dict[str, Any] = {}
169
+ remove_links: list[str] = []
170
+ for step in reversed([s for s in session.steps if s.step_number > to_step]):
171
+ undo = step.undo
172
+ joints += list(undo.get("joints_to_delete", []))
173
+ cuts += list(undo.get("cuts_to_delete", []))
174
+ for link, plc in undo.get("links_restore", {}).items():
175
+ restore.setdefault(link, plc)
176
+ repoint.update(undo.get("links_repoint", {}))
177
+ remove_links += list(undo.get("remove_links", []))
178
+ return {
179
+ "operation": "rollback_step",
180
+ "joints_to_delete": joints,
181
+ "cuts_to_delete": cuts,
182
+ "links_restore": restore,
183
+ "links_repoint": repoint,
184
+ "remove_links": remove_links,
185
+ }
186
+
187
+
188
+ def truncate_after_rollback(session: AssemblySession, to_step: int) -> None:
189
+ session.steps = [s for s in session.steps if s.step_number <= to_step]
190
+ session.joints = [j for j in session.joints if j["step"] <= to_step]
191
+ session.components = {p: c for p, c in session.components.items() if c["added_step"] <= to_step}
192
+ save(session)
@@ -0,0 +1,319 @@
1
+ import http.client
2
+ import logging
3
+ import xmlrpc.client
4
+ from typing import Any
5
+
6
+ logger = logging.getLogger("CADPilot")
7
+
8
+ # Errors that mean the TCP connection is dead (FreeCAD restarted, addon
9
+ # restarted, socket reset). Retrying once on a fresh proxy is safe.
10
+ # socket.timeout is deliberately excluded: a timeout may mean FreeCAD is
11
+ # still executing the request, and retrying would double-execute it.
12
+ _RECOVERABLE_ERRORS = (ConnectionError, http.client.HTTPException)
13
+
14
+
15
+ class _TimeoutTransport(xmlrpc.client.Transport):
16
+ """XML-RPC transport with a configurable socket timeout.
17
+
18
+ The default Transport has no timeout, so a frozen FreeCAD GUI thread
19
+ causes the MCP client to hang indefinitely (observed: 4+ minute waits).
20
+ """
21
+
22
+ def __init__(self, timeout: float = 30, **kwargs):
23
+ super().__init__(**kwargs)
24
+ self._timeout = timeout
25
+
26
+ def make_connection(self, host):
27
+ conn = super().make_connection(host)
28
+ conn.timeout = self._timeout
29
+ return conn
30
+
31
+
32
+ class FreeCADConnection:
33
+ def __init__(self, host: str = "localhost", port: int = 9875, timeout: float = 150):
34
+ self._uri = f"http://{host}:{port}"
35
+ self._timeout = timeout
36
+ self.server = self._make_proxy(timeout)
37
+
38
+ def _make_proxy(self, timeout: float) -> xmlrpc.client.ServerProxy:
39
+ return xmlrpc.client.ServerProxy(
40
+ self._uri,
41
+ allow_none=True,
42
+ transport=_TimeoutTransport(timeout=timeout),
43
+ )
44
+
45
+ def _invoke(self, method: str, *args):
46
+ """Call an RPC method, rebuilding the proxy and retrying once if the
47
+ connection died (e.g. FreeCAD or the addon was restarted)."""
48
+ try:
49
+ return getattr(self.server, method)(*args)
50
+ except _RECOVERABLE_ERRORS as e:
51
+ logger.warning(
52
+ f"RPC connection lost during '{method}' ({e}); reconnecting and retrying once"
53
+ )
54
+ self.server = self._make_proxy(self._timeout)
55
+ return getattr(self.server, method)(*args)
56
+
57
+ def _invoke_with_screenshot(self, method: str, *args, screenshot: dict[str, Any] | None):
58
+ """Call a mutation RPC with an inline screenshot request.
59
+
60
+ Falls back to the legacy two-call path (op + get_active_screenshot)
61
+ when the addon predates the screenshot parameter, so a new MCP server
62
+ keeps working against an old addon install.
63
+ """
64
+ if screenshot is None:
65
+ return self._invoke(method, *args)
66
+ try:
67
+ return self._invoke(method, *args, screenshot)
68
+ except xmlrpc.client.Fault as e:
69
+ if "TypeError" not in str(e):
70
+ raise
71
+ logger.info(
72
+ f"Addon does not support inline screenshots for '{method}'; using legacy path"
73
+ )
74
+ res = self._invoke(method, *args)
75
+ if isinstance(res, dict) and res.get("success"):
76
+ shot = self.get_active_screenshot(
77
+ screenshot.get("view_name", "Isometric"),
78
+ screenshot.get("width"),
79
+ screenshot.get("height"),
80
+ screenshot.get("focus_object"),
81
+ )
82
+ if shot:
83
+ res["screenshot"] = shot
84
+ return res
85
+
86
+ def disconnect(self) -> None:
87
+ # Transport.close() clears cached HTTP connections if one was opened.
88
+ transport = getattr(self.server, "_ServerProxy__transport", None)
89
+ close = getattr(transport, "close", None)
90
+ if callable(close):
91
+ close()
92
+
93
+ def ping(self) -> bool:
94
+ return self._invoke("ping")
95
+
96
+ def create_document(
97
+ self, name: str, screenshot: dict[str, Any] | None = None
98
+ ) -> dict[str, Any]:
99
+ return self._invoke_with_screenshot("create_document", name, screenshot=screenshot)
100
+
101
+ def create_object(
102
+ self,
103
+ doc_name: str,
104
+ obj_data: dict[str, Any],
105
+ screenshot: dict[str, Any] | None = None,
106
+ ) -> dict[str, Any]:
107
+ return self._invoke_with_screenshot(
108
+ "create_object", doc_name, obj_data, screenshot=screenshot
109
+ )
110
+
111
+ def create_feature(
112
+ self,
113
+ doc_name: str,
114
+ spec: dict[str, Any],
115
+ screenshot: dict[str, Any] | None = None,
116
+ ) -> dict[str, Any]:
117
+ return self._invoke_with_screenshot("create_feature", doc_name, spec, screenshot=screenshot)
118
+
119
+ def assembly_op(self, doc_name: str, spec: dict[str, Any]) -> dict[str, Any]:
120
+ """Assembly-session RPC (addon joint_ops.assembly_op). No screenshots."""
121
+ return self._invoke("assembly_op", doc_name, spec)
122
+
123
+ def edit_object(
124
+ self,
125
+ doc_name: str,
126
+ obj_name: str,
127
+ obj_data: dict[str, Any],
128
+ screenshot: dict[str, Any] | None = None,
129
+ ) -> dict[str, Any]:
130
+ return self._invoke_with_screenshot(
131
+ "edit_object", doc_name, obj_name, obj_data, screenshot=screenshot
132
+ )
133
+
134
+ def delete_object(
135
+ self,
136
+ doc_name: str,
137
+ obj_name: str,
138
+ screenshot: dict[str, Any] | None = None,
139
+ ) -> dict[str, Any]:
140
+ return self._invoke_with_screenshot(
141
+ "delete_object", doc_name, obj_name, screenshot=screenshot
142
+ )
143
+
144
+ def execute_code(self, code: str, screenshot: dict[str, Any] | None = None) -> dict[str, Any]:
145
+ return self._invoke_with_screenshot("execute_code", code, screenshot=screenshot)
146
+
147
+ def execute_code_async(self, code: str) -> dict[str, Any]:
148
+ return self._invoke("execute_code_async", code)
149
+
150
+ def get_task_result(self, task_id: str) -> dict[str, Any]:
151
+ return self._invoke("get_task_result", task_id)
152
+
153
+ def execute_operations(
154
+ self,
155
+ doc_name: str,
156
+ ops: list[dict[str, Any]],
157
+ stop_on_error: bool = False,
158
+ screenshot: dict[str, Any] | None = None,
159
+ ) -> dict[str, Any]:
160
+ return self._invoke_with_screenshot(
161
+ "execute_operations", doc_name, ops, stop_on_error, screenshot=screenshot
162
+ )
163
+
164
+ def undo_transactions(self, doc_name: str, n: int = 1) -> dict[str, Any]:
165
+ return self._invoke("undo_transactions", doc_name, n)
166
+
167
+ def redo_transactions(self, doc_name: str, n: int = 1) -> dict[str, Any]:
168
+ return self._invoke("redo_transactions", doc_name, n)
169
+
170
+ def save_document(self, doc_name: str, path: str | None = None) -> dict[str, Any]:
171
+ return self._invoke("save_document", doc_name, path)
172
+
173
+ def inspect_freecad(
174
+ self,
175
+ doc_name: str | None = None,
176
+ obj_name: str | None = None,
177
+ dotted_name: str | None = None,
178
+ ) -> dict[str, Any]:
179
+ return self._invoke("inspect_freecad", doc_name, obj_name, dotted_name)
180
+
181
+ def measure_geometry(self, doc_name: str, obj_name: str) -> dict[str, Any]:
182
+ return self._invoke("measure_geometry", doc_name, obj_name)
183
+
184
+ def get_topology(
185
+ self,
186
+ doc_name: str,
187
+ obj_name: str,
188
+ element: str = "faces",
189
+ limit: int = 50,
190
+ offset: int = 0,
191
+ ) -> dict[str, Any]:
192
+ return self._invoke("get_topology", doc_name, obj_name, element, limit, offset)
193
+
194
+ def check_interference(self, doc_name: str, obj_a: str, obj_b: str) -> dict[str, Any]:
195
+ return self._invoke("check_interference", doc_name, obj_a, obj_b)
196
+
197
+ def get_positioning_info(
198
+ self,
199
+ doc_name: str,
200
+ obj_name: str,
201
+ element: str,
202
+ element_index: int,
203
+ ) -> dict[str, Any]:
204
+ return self._invoke("get_positioning_info", doc_name, obj_name, element, element_index)
205
+
206
+ def align_shapes(
207
+ self,
208
+ doc_name: str,
209
+ obj_name: str,
210
+ element: str,
211
+ element_index: int,
212
+ target_obj_name: str,
213
+ target_element: str,
214
+ target_element_index: int,
215
+ mode: str = "touch",
216
+ offset: float = 0.0,
217
+ ) -> dict[str, Any]:
218
+ return self._invoke(
219
+ "align_shapes",
220
+ doc_name,
221
+ obj_name,
222
+ element,
223
+ element_index,
224
+ target_obj_name,
225
+ target_element,
226
+ target_element_index,
227
+ mode,
228
+ offset,
229
+ )
230
+
231
+ def get_anchors(self, doc_name: str, obj_name: str) -> dict[str, Any]:
232
+ return self._invoke("get_anchors", doc_name, obj_name)
233
+
234
+ def set_anchors(
235
+ self,
236
+ doc_name: str,
237
+ obj_name: str,
238
+ anchors: dict[str, Any],
239
+ replace: bool = False,
240
+ coord_frame: str = "local",
241
+ screenshot: dict[str, Any] | None = None,
242
+ ) -> dict[str, Any]:
243
+ return self._invoke_with_screenshot(
244
+ "set_anchors",
245
+ doc_name,
246
+ obj_name,
247
+ anchors,
248
+ replace,
249
+ coord_frame,
250
+ screenshot=screenshot,
251
+ )
252
+
253
+ def assemble(
254
+ self,
255
+ doc_name: str,
256
+ mates: list[dict[str, Any]],
257
+ tolerance: float = 0.1,
258
+ stop_on_error: bool = True,
259
+ screenshot: dict[str, Any] | None = None,
260
+ ) -> dict[str, Any]:
261
+ return self._invoke_with_screenshot(
262
+ "assemble", doc_name, mates, tolerance, stop_on_error, screenshot=screenshot
263
+ )
264
+
265
+ def verify_assembly(
266
+ self,
267
+ doc_name: str,
268
+ checks: list[dict[str, Any]] | None = None,
269
+ float_threshold: float = 1.0,
270
+ interference_min_volume: float = 1.0,
271
+ ) -> dict[str, Any]:
272
+ return self._invoke(
273
+ "verify_assembly", doc_name, checks, float_threshold, interference_min_volume
274
+ )
275
+
276
+ def get_active_screenshot(
277
+ self,
278
+ view_name: str = "Isometric",
279
+ width: int | None = None,
280
+ height: int | None = None,
281
+ focus_object: str | None = None,
282
+ ) -> str | None:
283
+ try:
284
+ return self._invoke("get_active_screenshot", view_name, width, height, focus_object)
285
+ except Exception as e:
286
+ logger.error(f"Error getting screenshot: {e}")
287
+ return None
288
+
289
+ def get_objects(self, doc_name: str) -> list[dict[str, Any]]:
290
+ res = self._invoke("get_objects", doc_name)
291
+ # New addon returns {"success": ..., "objects": [...]};
292
+ # old addon returns the list directly. A failure (e.g. document not
293
+ # found) must surface as an error, not as a misleading empty list.
294
+ if isinstance(res, dict):
295
+ if res.get("success") is False:
296
+ raise RuntimeError(res.get("error", "get_objects failed"))
297
+ if "objects" in res:
298
+ return res["objects"]
299
+ return []
300
+ return res if isinstance(res, list) else []
301
+
302
+ def get_object(self, doc_name: str, obj_name: str) -> dict[str, Any]:
303
+ res = self._invoke("get_object", doc_name, obj_name)
304
+ # New addon returns {"success": ..., "object": {...}};
305
+ # old addon returns the dict directly.
306
+ if isinstance(res, dict):
307
+ if res.get("success") is False:
308
+ raise RuntimeError(res.get("error", "get_object failed"))
309
+ if "object" in res:
310
+ return res["object"] or {}
311
+ return res if isinstance(res, dict) else {}
312
+
313
+ def list_documents(self) -> list[str]:
314
+ res = self._invoke("list_documents")
315
+ # New addon returns {"success": True, "documents": [...]};
316
+ # old addon returns the list directly.
317
+ if isinstance(res, dict) and "documents" in res:
318
+ return res["documents"]
319
+ return res if isinstance(res, list) else []
cadpilot/guidance.py ADDED
@@ -0,0 +1,192 @@
1
+ """Lightweight guidance for modeling sessions (CAD-trimmed from nsforge).
2
+
3
+ Only heuristics that are genuinely useful while modeling: what to do next
4
+ based on the document state, and risks that make rollback unreliable.
5
+ Deliberately omitted from the nsforge original: goal/progress tracking,
6
+ derivation patterns, and step replay verification.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .session_state import ModelingSession
12
+
13
+ # Ops that count as "sketch mode" activity for the primitive_without_sketch
14
+ # heuristic (designing parts from constrained sketches, not raw primitives).
15
+ _SKETCH_OPS = {
16
+ "sketch",
17
+ "pad",
18
+ "pocket",
19
+ "revolution",
20
+ "groove",
21
+ "hull",
22
+ "datum_plane",
23
+ "variables",
24
+ }
25
+
26
+
27
+ def suggest_next_steps(
28
+ session: ModelingSession,
29
+ object_names: list[str],
30
+ ) -> list[dict[str, str]]:
31
+ suggestions: list[dict[str, str]] = []
32
+
33
+ if not object_names:
34
+ suggestions.append(
35
+ {
36
+ "tool": "cad",
37
+ "operation": "create_object",
38
+ "reason": "The document is empty — create the first solid "
39
+ "(e.g. Part::Box / Part::Cylinder)",
40
+ }
41
+ )
42
+ return suggestions
43
+
44
+ non_atomic = [s.step_number for s in session.steps if not s.atomic]
45
+ if non_atomic:
46
+ suggestions.append(
47
+ {
48
+ "tool": "cad",
49
+ "operation": "batch",
50
+ "reason": f"Step(s) {non_atomic} used execute_code and cannot be rolled back "
51
+ "reliably; prefer cad() mutations for undoable steps",
52
+ }
53
+ )
54
+
55
+ # Multiple parts and no assembly activity yet → assembly mode.
56
+ if len(object_names) >= 2:
57
+ from . import assembly_state as _astate # lazy: read-only registry probe
58
+
59
+ has_assembly = (
60
+ any(s.operation == "assemble" for s in session.steps)
61
+ or _astate.current_session() is not None
62
+ )
63
+ if not has_assembly:
64
+ suggestions.append(
65
+ {
66
+ "tool": "assembly_session",
67
+ "operation": "start",
68
+ "reason": "Multiple parts in the document — assembly mode mates them "
69
+ "with persistent joints (start → add_component → mate → solve)",
70
+ }
71
+ )
72
+
73
+ if session.step_count >= 10 and session.status == "active":
74
+ suggestions.append(
75
+ {
76
+ "tool": "session_complete",
77
+ "operation": "",
78
+ "reason": f"{session.step_count} steps recorded — consider completing the "
79
+ "session to save the workflow into the pattern store",
80
+ }
81
+ )
82
+
83
+ suggestions.append(
84
+ {
85
+ "tool": "get_view",
86
+ "operation": "",
87
+ "reason": "Visually check the current model state if unsure",
88
+ }
89
+ )
90
+ return suggestions[:3]
91
+
92
+
93
+ def detect_risks(
94
+ session: ModelingSession,
95
+ doc_open: bool,
96
+ object_names: list[str] | None,
97
+ ) -> list[dict[str, str]]:
98
+ risks: list[dict[str, str]] = []
99
+
100
+ if not doc_open:
101
+ risks.append(
102
+ {
103
+ "level": "warning",
104
+ "type": "document_closed",
105
+ "message": f"Session document '{session.doc_name}' is not open in FreeCAD "
106
+ "(closed externally?). Rollback and mutations will fail.",
107
+ }
108
+ )
109
+ return risks
110
+
111
+ non_atomic = [s.step_number for s in session.steps if not s.atomic]
112
+ if non_atomic:
113
+ risks.append(
114
+ {
115
+ "level": "warning",
116
+ "type": "non_atomic_steps",
117
+ "message": f"Step(s) {non_atomic} were recorded via execute_code without a "
118
+ "transaction; session_rollback past them may undo the wrong change.",
119
+ }
120
+ )
121
+
122
+ # Fingerprint drift: the document's objects no longer match what the last
123
+ # recorded step saw — someone/something edited the document outside cad().
124
+ if object_names is not None and session.steps:
125
+ expected = session.steps[-1].objects_after
126
+ if expected and sorted(object_names) != expected:
127
+ risks.append(
128
+ {
129
+ "level": "warning",
130
+ "type": "state_drift",
131
+ "message": "Document objects differ from the last recorded step — "
132
+ "the model was edited outside cad() (GUI?). Rollback may "
133
+ "undo those external edits too.",
134
+ }
135
+ )
136
+
137
+ # Connectivity: the last committed mutation's auto-audit found parts not
138
+ # touching the main assembly (recorded in the step's result_summary).
139
+ if session.steps and "Connectivity:" in session.steps[-1].result_summary:
140
+ detail = (
141
+ session.steps[-1].result_summary.split("Connectivity:", 1)[1].split("\n", 1)[0].strip()
142
+ )
143
+ risks.append(
144
+ {
145
+ "level": "warning",
146
+ "type": "disconnected_islands",
147
+ "message": f"Last mutation left disconnected islands: {detail} — "
148
+ "fix gaps so parts touch (≤0.5mm) or intersect.",
149
+ }
150
+ )
151
+
152
+ # Sketch-mode steer: several raw Part:: primitives and no sketch activity.
153
+ primitive = [
154
+ s
155
+ for s in session.steps
156
+ if s.operation == "create_object" and s.params_summary.startswith("Part::")
157
+ ]
158
+ if len(primitive) >= 2 and not any(s.operation in _SKETCH_OPS for s in session.steps):
159
+ risks.append(
160
+ {
161
+ "level": "info",
162
+ "type": "primitive_without_sketch",
163
+ "message": f"{len(primitive)} raw Part:: primitives created and no sketch "
164
+ "used — sketch mode (constrained sketch → pad/revolution/hull) "
165
+ "gives parametric, editable parts.",
166
+ }
167
+ )
168
+
169
+ # Blind placement: steps that set an absolute Placement (marker added by
170
+ # cad_operation in the step's params_summary).
171
+ placed = [s.step_number for s in session.steps if "+Placement" in s.params_summary]
172
+ if placed:
173
+ risks.append(
174
+ {
175
+ "level": "info",
176
+ "type": "absolute_placement",
177
+ "message": f"Step(s) {placed} set an absolute Placement — blind coordinates "
178
+ "cause floating parts. Prefer move/align_shapes/anchors "
179
+ "(free mode) or assembly_session mates.",
180
+ }
181
+ )
182
+
183
+ if session.redo_buffer:
184
+ risks.append(
185
+ {
186
+ "level": "info",
187
+ "type": "redo_available",
188
+ "message": f"{len(session.redo_buffer)} undone step(s) can be restored with "
189
+ "session_redo — any new cad() step discards them.",
190
+ }
191
+ )
192
+ return risks