nodecanvas-cli 0.1.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,627 @@
1
+ """Creating graph assets and moving subtrees between graphs (``new``, ``extract``, ``inline``, ``copy``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import difflib
7
+ import re
8
+ import sys
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Tuple
12
+
13
+ from . import fsjson
14
+ from .container import ObjectRef, UnityFile
15
+ from .model import BT, DT, FSM, Graph, walk
16
+ from .schema import generic_args, simple_name
17
+ from .views import render_graph
18
+ from .yamlscalar import encode
19
+
20
+ GRAPH_TYPES = {"bt": BT, "fsm": FSM, "dt": DT}
21
+ DERIVED = {
22
+ "bt": {"repeat": True, "$type": BT + "+DerivedSerializationData"},
23
+ "fsm": None,
24
+ "dt": {"actorParameters": [], "$type": DT + "+DerivedSerializationData"},
25
+ }
26
+ NESTED_KEYS = ("_subTree", "_nestedBT", "_nestedFSM", "_nestedDLG", "_subGraph")
27
+ WRITE_FIELDS = {
28
+ "saveAs",
29
+ "returnValue",
30
+ "current",
31
+ "storeIndex",
32
+ "boolVariable",
33
+ "floatVariable",
34
+ "intVariable",
35
+ "saveIndexAs",
36
+ "saveDistanceAs",
37
+ "saveObjectAs",
38
+ "savePositionAs",
39
+ "saveTo",
40
+ }
41
+ META = (
42
+ "fileFormatVersion: 2\nguid: {guid}\nNativeFormatImporter:\n externalObjects: {{}}\n"
43
+ " mainObjectFileID: 11400000\n userData: \n assetBundleName: \n assetBundleVariant: \n"
44
+ )
45
+
46
+
47
+ class AssetError(Exception):
48
+ pass
49
+
50
+
51
+ # ---------------------------------------------------------------------- new asset
52
+
53
+
54
+ def _template_lines(ws, kind: str) -> Tuple[List[str], str]:
55
+ """Header/footer of an existing asset of the same kind (exact Unity layout), or a built-in one."""
56
+ for s in ws.slots:
57
+ if s.bound or not s.path.endswith(".asset"):
58
+ continue
59
+ try:
60
+ g = ws.load(s)
61
+ except Exception:
62
+ continue
63
+ if g.kind == kind:
64
+ return list(ws.file(s.path).lines), s.path
65
+ decl = ws.schema.find(GRAPH_TYPES[kind])
66
+ script_guid = ws.project.path_to_guid(decl.file) if decl is not None else None
67
+ if not script_guid:
68
+ raise AssetError(f"cannot find the {GRAPH_TYPES[kind]} script in the project to reference from a new asset")
69
+ lines = [
70
+ "%YAML 1.1",
71
+ "%TAG !u! tag:unity3d.com,2011:",
72
+ "--- !u!114 &11400000",
73
+ "MonoBehaviour:",
74
+ " m_ObjectHideFlags: 0",
75
+ " m_CorrespondingSourceObject: {fileID: 0}",
76
+ " m_PrefabInstance: {fileID: 0}",
77
+ " m_PrefabAsset: {fileID: 0}",
78
+ " m_GameObject: {fileID: 0}",
79
+ " m_Enabled: 1",
80
+ " m_EditorHideFlags: 0",
81
+ f" m_Script: {{fileID: 11500000, guid: {script_guid}, type: 3}}",
82
+ " m_Name: X",
83
+ f" m_EditorClassIdentifier: NodeCanvas::{GRAPH_TYPES[kind]}",
84
+ " _serializedGraph: '{}'",
85
+ " _objectReferences: []",
86
+ " _graphSource:",
87
+ " _version: 3.33",
88
+ " _category: ",
89
+ " _comments: ",
90
+ " _translation: {x: 0, y: 0}",
91
+ " _zoomFactor: 1",
92
+ " _haltSerialization: 0",
93
+ " _externalSerializationFile: {fileID: 0}",
94
+ "",
95
+ ]
96
+ return lines, ""
97
+
98
+
99
+ def make_asset(ws, rel: str, kind: str, data: dict, refs: List[ObjectRef]) -> UnityFile:
100
+ """Build (in memory) a new graph asset file at project-relative ``rel``."""
101
+ lines, _ = _template_lines(ws, kind)
102
+ out = []
103
+ i = 0
104
+ name = Path(rel).stem
105
+ while i < len(lines):
106
+ line = lines[i]
107
+ st = line.lstrip(" ")
108
+ if st.startswith("m_Name:"):
109
+ out.append(" m_Name: " + name)
110
+ elif st.startswith("_serializedGraph:"):
111
+ out += encode("_serializedGraph", 2, fsjson.dumps(data))
112
+ i += 1
113
+ while i < len(lines) and not lines[i].lstrip(" ").startswith("_objectReferences:"):
114
+ i += 1
115
+ continue
116
+ elif st.startswith("_objectReferences:"):
117
+ out.append(" _objectReferences:" if refs else " _objectReferences: []")
118
+ out += [" - " + r.dump() for r in refs]
119
+ i += 1
120
+ while i < len(lines) and lines[i].startswith(" - "):
121
+ i += 1
122
+ continue
123
+ elif st.startswith("_translation:"):
124
+ out.append(" _translation: {x: 0, y: 0}")
125
+ elif st.startswith("_zoomFactor:"):
126
+ out.append(" _zoomFactor: 1")
127
+ elif st.startswith(("_category:", "_comments:")):
128
+ out.append(" " + st.split(":")[0] + ": ")
129
+ else:
130
+ out.append(line)
131
+ i += 1
132
+ data_bytes = "\n".join(out).encode("utf-8")
133
+ return UnityFile(ws.project.abs(rel), rel, data_bytes)
134
+
135
+
136
+ def empty_graph(kind: str) -> dict:
137
+ return {
138
+ "type": GRAPH_TYPES[kind],
139
+ "nodes": [],
140
+ "connections": [],
141
+ "canvasGroups": [],
142
+ "localBlackboard": {"_variables": {}},
143
+ "derivedData": copy.deepcopy(DERIVED[kind]),
144
+ }
145
+
146
+
147
+ def _check_new_path(ws, rel: str, allow_outside: bool = False) -> None:
148
+ if not rel.endswith(".asset"):
149
+ raise AssetError("the new graph path must end with .asset")
150
+ if rel.startswith("/") or rel.startswith(".."):
151
+ raise AssetError("the new graph must be inside the Unity project")
152
+ if not (rel.startswith("Assets/") or rel.startswith("Packages/")):
153
+ if not allow_outside:
154
+ raise AssetError("the new graph must be inside Assets/ or Packages/ (or pass --allow-outside)")
155
+ print(f"note: {rel} is outside Assets/ and Packages/; Unity will not import it", file=sys.stderr)
156
+ if ws.project.abs(rel).exists():
157
+ raise AssetError(f"{rel} already exists")
158
+
159
+
160
+ def _rel(ws, path: str) -> str:
161
+ p = Path(path)
162
+ return ws.project.rel(p if p.is_absolute() else Path.cwd() / p)
163
+
164
+
165
+ # ---------------------------------------------------------------------- copying subtrees
166
+
167
+
168
+ def subtree_nodes(g: Graph, root: dict) -> List[dict]:
169
+ order, seen = [], set()
170
+
171
+ def visit(n):
172
+ if id(n) in seen:
173
+ return
174
+ seen.add(id(n))
175
+ order.append(n)
176
+ for k in g.children(n):
177
+ visit(k)
178
+
179
+ visit(root)
180
+ inside = {id(n) for n in order}
181
+ for n in order[1:]:
182
+ outside = [p for p in g.parents(n) if id(p) not in inside]
183
+ if outside:
184
+ raise AssetError(
185
+ f"#{g.nid(n)} also has a parent outside the subtree (#{g.nid(outside[0])}); "
186
+ "move or remove that link first"
187
+ )
188
+ return order
189
+
190
+
191
+ def used_variables(g: Graph, nodes: List[dict]) -> Dict[str, str]:
192
+ """``variable id -> "r" | "w" | "rw"`` for variables referenced by ``nodes``."""
193
+ out: Dict[str, str] = {}
194
+ for n in nodes:
195
+ for path, d in walk(n):
196
+ vid = d.get("_targetVariableID")
197
+ if not vid or g.var_by_id(vid) is None:
198
+ continue
199
+ key = path[-1] if path else ""
200
+ owner_type = _owner_type(n, path)
201
+ write = key in WRITE_FIELDS or (key == "valueA" and simple_name(owner_type).startswith("Set"))
202
+ if isinstance(key, int) and len(path) >= 2 and path[-2] == "_variablesMap":
203
+ write = bool(d.get("_canRead"))
204
+ mode = out.get(vid, "")
205
+ out[vid] = "".join(sorted(set(mode + ("w" if write else "r"))))
206
+ return out
207
+
208
+
209
+ def _owner_type(node: dict, path: Tuple) -> str:
210
+ cur, t = node, node.get("$type", "")
211
+ for p in path[:-1]:
212
+ cur = cur[p]
213
+ if isinstance(cur, dict) and "$type" in cur:
214
+ t = cur["$type"]
215
+ return t
216
+
217
+
218
+ def clone(
219
+ src: Graph, dst: Graph, nodes: List[dict], var_map: Dict[str, dict], keep_uid: bool = True
220
+ ) -> Tuple[List[dict], List[dict]]:
221
+ """Deep-copy ``nodes`` (with their internal connections) for ``dst``: variable ids and object refs remapped.
222
+
223
+ Returned connections use indices into the returned node list.
224
+ """
225
+ index = {id(n): i for i, n in enumerate(nodes)}
226
+ copies = [copy.deepcopy(n) for n in nodes]
227
+ for c in copies:
228
+ c.pop("$id", None)
229
+ if not keep_uid:
230
+ c.pop("_UID", None)
231
+ for _, d in walk(c):
232
+ vid = d.get("_targetVariableID")
233
+ if vid and vid in var_map:
234
+ d["_targetVariableID"] = var_map[vid]["_id"]
235
+ if "_name" in d:
236
+ d["_name"] = var_map[vid]["_name"]
237
+ for k in NESTED_KEYS:
238
+ v = c.get(k)
239
+ if isinstance(v, dict) and isinstance(v.get("_value"), int):
240
+ ref = src.ref(v["_value"])
241
+ v["_value"] = dst.ref_index(ref) if ref is not None and not ref.is_null else 0
242
+ conns = []
243
+ for n in nodes:
244
+ for c in src.out.get(src.nid(n), []):
245
+ t = src.by_id[c["_targetNode"]["$ref"]]
246
+ if id(t) in index:
247
+ cc = copy.deepcopy(c)
248
+ cc.pop("$id", None)
249
+ cc["_sourceNode"] = {"$ref": str(index[id(n)])}
250
+ cc["_targetNode"] = {"$ref": str(index[id(t)])}
251
+ conns.append(cc)
252
+ return copies, conns
253
+
254
+
255
+ def graft(ed, copies: List[dict], conns: List[dict], parent: Optional[dict], index: Optional[int]) -> dict:
256
+ """Append cloned nodes to the editor's graph and hang their root under ``parent``."""
257
+ g = ed.g
258
+ base = len(g.nodes)
259
+ g.nodes.extend(copies)
260
+ for c in conns:
261
+ c["_sourceNode"]["$ref"] = str(base + int(c["_sourceNode"]["$ref"]))
262
+ c["_targetNode"]["$ref"] = str(base + int(c["_targetNode"]["$ref"]))
263
+ g.connections.append(c)
264
+ g.reindex()
265
+ root = copies[0]
266
+ if parent is not None:
267
+ ed._link(parent, root, index)
268
+ return root
269
+
270
+
271
+ # ---------------------------------------------------------------------- commands
272
+
273
+
274
+ def _print_views(ws, before: Dict[str, List[str]], graphs: List[Graph]) -> None:
275
+ for g in graphs:
276
+ addr = g.loaded.slot.address
277
+ after = [re.sub(r"#\d+", "#", line) for line in render_graph(g, ws.renderer(g), with_header=True)[1:]]
278
+ print(f"== {addr}")
279
+ if addr in before:
280
+ diff = list(difflib.unified_diff(before[addr], after, "before", "after", n=1, lineterm=""))
281
+ print("\n".join(diff[2:]) if diff else "(no visible change)")
282
+ else:
283
+ print("\n".join(after))
284
+
285
+
286
+ def _view(ws, g: Graph) -> List[str]:
287
+ return [re.sub(r"#\d+", "#", line) for line in render_graph(g, ws.renderer(g), with_header=True)[1:]]
288
+
289
+
290
+ def _new_graph(ws, rel: str, kind: str) -> Tuple[UnityFile, Graph]:
291
+ f = make_asset(ws, rel, kind, empty_graph(kind), [])
292
+ slot = f.slots()[0]
293
+ return f, Graph(f.load(slot))
294
+
295
+
296
+ def _finish(ws, a, created: List[Tuple[UnityFile, Graph, str]], changed: List[Graph]) -> int:
297
+ from .lint import ERROR, lint_graph
298
+
299
+ errors = [i for g in [c[1] for c in created] + changed for i in lint_graph(ws, g) if i.severity == ERROR]
300
+ for i in errors:
301
+ print("lint: " + i.line())
302
+ if errors and not getattr(a, "force", False):
303
+ print("not saved: new lint errors (use --force)", file=sys.stderr)
304
+ return 1
305
+ if a.dry_run:
306
+ print("dry run: nothing written")
307
+ return 0
308
+ for f, g, guid in created:
309
+ f.store(g.loaded)
310
+ f.abspath.parent.mkdir(parents=True, exist_ok=True)
311
+ f.dirty = True
312
+ f.save()
313
+ Path(str(f.abspath) + ".meta").write_text(META.format(guid=guid), encoding="utf-8")
314
+ where = f.relpath if f.relpath.startswith(("Assets/", "Packages/")) else str(f.abspath)
315
+ print(f"created {where} (guid {guid})")
316
+ for g in changed:
317
+ ws.save(g)
318
+ print(f"saved {g.loaded.slot.address}")
319
+ ws.project._containers = None
320
+ ws._slots = None
321
+ return 0
322
+
323
+
324
+ def cmd_new(ws, a) -> int:
325
+ from .build import Builder
326
+
327
+ rel = _rel(ws, a.path)
328
+ _check_new_path(ws, rel, a.allow_outside)
329
+ f, g = _new_graph(ws, rel, a.kind)
330
+ b = Builder(ws, g)
331
+ for spec in a.var or []:
332
+ m = re.match(r"^\s*([^:=]+?)\s*:\s*([^=:]+?)\s*(?:=\s*([^:]*?))?\s*(:\s*pub(?:lic)?)?\s*$", spec)
333
+ if not m:
334
+ raise AssetError(f"--var {spec!r}: use Name:type[=value][:pub]")
335
+ value = None
336
+ if m.group(3):
337
+ import json as _json
338
+
339
+ try:
340
+ value = _json.loads(m.group(3))
341
+ except ValueError:
342
+ value = m.group(3)
343
+ g.blackboard["_variables"][m.group(1)] = b.new_variable(m.group(1), m.group(2), value, bool(m.group(4)))
344
+ if a.source:
345
+ if ":" not in a.source:
346
+ raise AssetError("--from takes <graph>:<node>")
347
+ src_name, node_ref = a.source.rsplit(":", 1)
348
+ src = ws.graph(src_name)
349
+ _, created = copy_subtree(ws, src, node_ref, g, "root", None, True, public_vars=not a.private_vars)
350
+ if created:
351
+ print("created variables: " + ", ".join("$" + n for n in created))
352
+ _print_views(ws, {}, [g])
353
+ return _finish(ws, a, [(f, g, uuid.uuid4().hex)], [])
354
+
355
+
356
+ def cmd_extract(ws, a) -> int:
357
+ from .layout import Layouter
358
+ from .ops import apply_ops, set_position
359
+
360
+ g = ws.graph(a.graph, "bt")
361
+ root = g.node(a.node)
362
+ rel = _rel(ws, a.path)
363
+ _check_new_path(ws, rel, a.allow_outside)
364
+ nodes = subtree_nodes(g, root)
365
+ usage = used_variables(g, nodes)
366
+ before = {g.loaded.slot.address: _view(ws, g)}
367
+
368
+ f, sub = _new_graph(ws, rel, "bt")
369
+ sub.data["derivedData"] = copy.deepcopy(g.data.get("derivedData")) or sub.data["derivedData"]
370
+ var_map: Dict[str, dict] = {}
371
+ for name, v in g.variables.items():
372
+ if v.get("_id") in usage:
373
+ nv = {k: copy.deepcopy(x) for k, x in v.items() if k not in ("_isPublic", "$type")}
374
+ nv["_isPublic"] = True
375
+ nv["$type"] = v["$type"]
376
+ sub.blackboard["_variables"][name] = nv
377
+ var_map[v["_id"]] = nv
378
+ copies, conns = clone(g, sub, nodes, var_map)
379
+ sub.data["nodes"] = copies
380
+ sub.data["connections"] = conns
381
+ sub.reindex()
382
+ rx, ry = Layouter.pos(root) or (0.0, 0.0)
383
+ for c in copies:
384
+ p = Layouter.pos(c) or (0.0, 0.0)
385
+ set_position(c, p[0] - rx, p[1] - ry)
386
+ sub.renumber()
387
+
388
+ guid = uuid.uuid4().hex
389
+ ws.register_new(f, sub, guid)
390
+ ref_index = g.ref_index(ObjectRef(11400000, guid, 2))
391
+ mapping = []
392
+ order = [v.get("_id") for v in g.variables.values()]
393
+ for vid, mode in sorted(usage.items(), key=lambda kv: order.index(kv[0]) if kv[0] in order else len(order)):
394
+ v = g.var_by_id(vid)
395
+ vt = generic_args(v.get("$type", "")) or [None]
396
+ entry = {"_targetSubGraphVariableID": vid}
397
+ if "w" in mode:
398
+ entry["_canRead"] = True
399
+ if "r" in mode or "w" not in mode:
400
+ entry["_canWrite"] = True
401
+ entry["_type"] = vt[0].split(",")[0].strip() if vt[0] and "[[" not in vt[0] else vt[0]
402
+ entry["_name"] = v["_name"]
403
+ entry["_targetVariableID"] = vid
404
+ mapping.append(entry)
405
+ spec = {"$type": "SubTree", "_subTree": {"raw": {"_value": ref_index}}, "_variablesMap": {"raw": mapping}}
406
+ if a.name:
407
+ spec["_name"] = a.name
408
+ ops = [{"op": "remove", "node": g.nid(k), "recursive": True} for k in g.children(root)]
409
+ ops.append({"op": "replace", "node": g.nid(root), "with": spec})
410
+ apply_ops(ws, g, ops)
411
+ _print_views(ws, before, [sub, g])
412
+ return _finish(ws, a, [(f, sub, guid)], [g])
413
+
414
+
415
+ def cmd_inline(ws, a) -> int:
416
+ from .ops import Editor, OpError
417
+
418
+ g = ws.graph(a.graph, "bt")
419
+ node = g.node(a.node)
420
+ if simple_name(node.get("$type", "")) != "SubTree":
421
+ raise AssetError(f"#{g.nid(node)} is not a SubTree")
422
+ ref = g.ref((node.get("_subTree") or {}).get("_value"))
423
+ sub = ws.ref_graph(ref) if ref is not None else None
424
+ if sub is None or not sub.nodes:
425
+ raise AssetError("the SubTree has no (or an empty) graph assigned")
426
+ before = {g.loaded.slot.address: _view(ws, g)}
427
+ var_map: Dict[str, dict] = {}
428
+ literal = []
429
+ for m in node.get("_variablesMap") or []:
430
+ sid = m.get("_targetSubGraphVariableID")
431
+ if m.get("_targetVariableID") and g.var_by_id(m["_targetVariableID"]) is not None:
432
+ var_map[sid] = g.var_by_id(m["_targetVariableID"])
433
+ elif "_value" in m:
434
+ literal.append(next((v.get("_name") for v in sub.variables.values() if v.get("_id") == sid), sid))
435
+ if literal:
436
+ raise AssetError(
437
+ f"mapped to constants: {', '.join(literal)}; give them parent variables first (map op), then inline"
438
+ )
439
+ created_vars = []
440
+ for name, v in sub.variables.items():
441
+ if v.get("_id") in var_map or "VariableSeperator" in v.get("$type", ""):
442
+ continue
443
+ new_name = name
444
+ while g.var_by_name(new_name) is not None:
445
+ new_name = f"{new_name} ({sub.name})"
446
+ nv = {k: copy.deepcopy(x) for k, x in v.items() if k not in ("_isPublic",)}
447
+ nv["_name"] = new_name
448
+ nv["_id"] = v["_id"] if g.var_by_id(v["_id"]) is None else str(uuid.uuid4())
449
+ g.blackboard["_variables"][new_name] = nv
450
+ var_map[v["_id"]] = nv
451
+ created_vars.append(new_name)
452
+ nodes = subtree_nodes(sub, sub.prime)
453
+ copies, conns = clone(sub, g, nodes, var_map)
454
+ ed = Editor(ws, g)
455
+ parent = (g.parents(node) or [None])[0]
456
+ old_pos = ed.lay.pos(node)
457
+ index = g.children(parent).index(node) if parent is not None else None
458
+ root = graft(ed, copies, conns, None, None)
459
+ sub_root_pos = ed.lay.pos(root) or (0.0, 0.0)
460
+ if old_pos is not None:
461
+ ed.lay.translate([root], old_pos[0] - sub_root_pos[0], old_pos[1] - sub_root_pos[1])
462
+ if parent is not None:
463
+ _, _, item = ed._unlink(parent, node)
464
+ ed._link(parent, root, index, item=item, has_item=item is not None)
465
+ ed._remove_node(node)
466
+ if parent is None:
467
+ ri = g.nodes.index(root)
468
+ g.nodes.insert(0, g.nodes.pop(ri))
469
+ ed._reorder_refs_after_move(ri)
470
+ ed.check_roots.append(root)
471
+ try:
472
+ ed.validate_structure()
473
+ except OpError as e:
474
+ raise AssetError(str(e)) from e
475
+ ed.layout()
476
+ ed.b.normalize(g.nodes)
477
+ g.renumber()
478
+ _print_views(ws, before, [g])
479
+ if created_vars:
480
+ print("created variables: " + ", ".join("$" + n for n in created_vars))
481
+ return _finish(ws, a, [], [g])
482
+
483
+
484
+ def copy_subtree(
485
+ ws,
486
+ src: Graph,
487
+ node_ref: str,
488
+ dst: Graph,
489
+ parent_ref: str,
490
+ index: Optional[int],
491
+ create_vars: bool,
492
+ public_vars: bool = False,
493
+ ):
494
+ """Copy ``src``'s subtree at ``node_ref`` under ``parent_ref`` in ``dst`` (``root``: as the root of an
495
+ empty graph). Returns ``(editor, created variable names)``."""
496
+ from .ops import Editor, OpError
497
+
498
+ root = src.node(node_ref)
499
+ nodes = subtree_nodes(src, root)
500
+ usage = used_variables(src, nodes)
501
+ var_map: Dict[str, dict] = {}
502
+ missing = []
503
+ for vid in usage:
504
+ v = src.var_by_id(vid)
505
+ same = dst.var_by_id(vid) if dst is src else None
506
+ match = same or dst.var_by_name(v["_name"])
507
+ if match is not None and match.get("$type") == v.get("$type"):
508
+ var_map[vid] = match
509
+ elif match is not None:
510
+ raise AssetError(f"${v['_name']} exists in {dst.name} with another type")
511
+ else:
512
+ missing.append(v)
513
+ if missing and not create_vars:
514
+ raise AssetError(
515
+ "missing in the destination: "
516
+ + ", ".join(f"${v['_name']}" for v in missing)
517
+ + " (pass --create-vars to add them)"
518
+ )
519
+ as_root = str(parent_ref).lower() == "root"
520
+ if as_root and dst.nodes and dst.kind == "bt":
521
+ raise AssetError(f"{dst.name} already has a root; give a parent node to attach the copy to")
522
+ for v in missing:
523
+ nv = copy.deepcopy(v)
524
+ nv.pop("_isPublic", None)
525
+ if public_vars:
526
+ nv["_isPublic"] = True
527
+ nv["$type"] = nv.pop("$type")
528
+ if dst.var_by_id(nv["_id"]) is not None:
529
+ nv["_id"] = str(uuid.uuid4())
530
+ dst.blackboard["_variables"][nv["_name"]] = nv
531
+ var_map[v["_id"]] = nv
532
+ copies, conns = clone(src, dst, nodes, var_map, keep_uid=False)
533
+ ed = Editor(ws, dst)
534
+ was_empty = not dst.nodes
535
+ parent = None if as_root else (dst.node(parent_ref) if dst is not src else ed.node(parent_ref))
536
+ newroot = graft(ed, copies, conns, parent, index)
537
+ if parent is None:
538
+ if was_empty:
539
+ p = ed.lay.pos(newroot) or (0.0, 0.0)
540
+ ed.lay.translate([newroot], -p[0], -p[1])
541
+ ed.check_roots.append(newroot)
542
+ else:
543
+ ed.place_roots.append(newroot)
544
+ try:
545
+ ed.validate_structure()
546
+ except OpError as e:
547
+ raise AssetError(str(e)) from e
548
+ ed.layout()
549
+ ed.b.normalize(dst.nodes)
550
+ dst.renumber()
551
+ return ed, [v["_name"] for v in missing]
552
+
553
+
554
+ def cmd_copy(ws, a) -> int:
555
+ src = ws.graph(a.src)
556
+ dst = src if a.dst in (a.src, ".") else ws.graph(a.dst)
557
+ before = {dst.loaded.slot.address: _view(ws, dst)}
558
+ ed, created = copy_subtree(ws, src, a.node, dst, a.parent, a.index, a.create_vars, a.public_vars)
559
+ _print_views(ws, before, [dst])
560
+ summary = ed.layout_summary()
561
+ if summary:
562
+ print(summary)
563
+ if created:
564
+ print("created variables: " + ", ".join("$" + n for n in created))
565
+ return _finish(ws, a, [], [dst])
566
+
567
+
568
+ def _guard(fn):
569
+ def run(ws, a):
570
+ try:
571
+ return fn(ws, a)
572
+ except AssetError as e:
573
+ print(f"error: {e}", file=sys.stderr)
574
+ return 1
575
+
576
+ return run
577
+
578
+
579
+ def register(sub) -> None:
580
+ s = sub.add_parser("new", help="create a new graph asset (with .meta)")
581
+ s.add_argument("path", help="Assets/.../Name.asset")
582
+ s.add_argument("--kind", choices=["bt", "fsm", "dt"], required=True)
583
+ s.add_argument("--var", action="append", help='blackboard variable "Name:type[=value][:pub]", repeatable')
584
+ s.add_argument(
585
+ "--from",
586
+ dest="source",
587
+ help="start with a copy of <graph>:<node>; its variables are created public, "
588
+ "like extract does, so the graph is ready to be a SubTree",
589
+ )
590
+ s.add_argument("--private-vars", action="store_true", help="with --from: create the variables private")
591
+ s.add_argument("--dry-run", action="store_true")
592
+ s.add_argument("--force", action="store_true")
593
+ s.add_argument(
594
+ "--allow-outside", action="store_true", help="allow a path outside Assets/ and Packages/ (for experiments)"
595
+ )
596
+ s.set_defaults(func=_guard(cmd_new))
597
+
598
+ s = sub.add_parser("extract", help="move a BT subtree into a new SubTree asset and reference it in place")
599
+ s.add_argument("graph")
600
+ s.add_argument("node", help="root of the subtree")
601
+ s.add_argument("path", help="Assets/.../NewSubTree.asset")
602
+ s.add_argument("--name", help="node name for the SubTree")
603
+ s.add_argument("--dry-run", action="store_true")
604
+ s.add_argument("--force", action="store_true")
605
+ s.add_argument(
606
+ "--allow-outside", action="store_true", help="allow a path outside Assets/ and Packages/ (for experiments)"
607
+ )
608
+ s.set_defaults(func=_guard(cmd_extract))
609
+
610
+ s = sub.add_parser("inline", help="replace a SubTree node with a copy of its graph's nodes")
611
+ s.add_argument("graph")
612
+ s.add_argument("node", help="the SubTree node")
613
+ s.add_argument("--dry-run", action="store_true")
614
+ s.add_argument("--force", action="store_true")
615
+ s.set_defaults(func=_guard(cmd_inline))
616
+
617
+ s = sub.add_parser("copy", help="copy a subtree into another (or the same) graph")
618
+ s.add_argument("src")
619
+ s.add_argument("node")
620
+ s.add_argument("dst", help="destination graph (or . for the same graph)")
621
+ s.add_argument("parent", help="node in the destination to attach the copy to, or 'root' for an empty graph")
622
+ s.add_argument("--index", type=int)
623
+ s.add_argument("--create-vars", action="store_true", help="create missing variables in the destination")
624
+ s.add_argument("--public-vars", action="store_true", help="make variables created by --create-vars public")
625
+ s.add_argument("--dry-run", action="store_true")
626
+ s.add_argument("--force", action="store_true")
627
+ s.set_defaults(func=_guard(cmd_copy))