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 +0 -0
- cadpilot/assembly_state.py +192 -0
- cadpilot/freecad_client.py +319 -0
- cadpilot/guidance.py +192 -0
- cadpilot/operations/__init__.py +73 -0
- cadpilot/operations/assembly.py +219 -0
- cadpilot/operations/core.py +1102 -0
- cadpilot/pattern_store.py +127 -0
- cadpilot/prompt_text.py +131 -0
- cadpilot/py.typed +0 -0
- cadpilot/responses.py +25 -0
- cadpilot/server.py +948 -0
- cadpilot/server_state.py +26 -0
- cadpilot/session_state.py +253 -0
- cadpilot/tool_docs.py +266 -0
- cadpilot-0.4.0.dist-info/METADATA +173 -0
- cadpilot-0.4.0.dist-info/RECORD +20 -0
- cadpilot-0.4.0.dist-info/WHEEL +4 -0
- cadpilot-0.4.0.dist-info/entry_points.txt +2 -0
- cadpilot-0.4.0.dist-info/licenses/LICENSE +22 -0
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
|