gdharness 1.2.41 → 1.2.42

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,438 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## The node paths scenes hold, carried after a node moves as the editor's own rename carries them:
5
+ ## the NodePath values on their nodes, inside arrays, dictionaries and the resources built into the
6
+ ## scene too, and the tracks of the animations their players play. After a delete they are named
7
+ ## rather than changed, since what they named is gone and dropping a track would lose its keys.
8
+ ##
9
+ ## A path is worked out from where its holder was and where it pointed before the move, so it is
10
+ ## followed in a scene loaded after the move as well, where the old path no longer resolves.
11
+
12
+ enum { UNCHANGED, REWRITTEN, DELETED }
13
+
14
+ const HeadlessCheck = preload("../headless_check.gd")
15
+ const Read = preload("../reading.gd")
16
+ const SceneFile = preload("../scene_file.gd")
17
+ ## PROPERTY_HINT_NO_NODEPATH, which marks a property the editor's rename skips. It is not bound for
18
+ ## scripts, and follows PROPERTY_HINT_ONESHOT in the engine's list.
19
+ const HINT_NO_NODEPATH: int = PROPERTY_HINT_ONESHOT + 1
20
+
21
+ ## Each path rewritten, as {scenePath, nodePath, property or animation and track, from, to}.
22
+ var retargeted: Array[Dictionary] = []
23
+ ## Each path that needed rewriting and was left, with why.
24
+ var not_retargeted: Array[Dictionary] = []
25
+ ## Each path naming a deleted node, or a node below one, which now names nothing.
26
+ var still_naming: Array[Dictionary] = []
27
+ ## The files beside a scene holding a rewritten track, each to the resource saved there.
28
+ var _homes: Dictionary = {}
29
+ ## Each track rewritten in an animation one of [member _homes] keeps, as [animation, track, the path
30
+ ## it had, its record, the file]. The editor shares such a resource with every scene using it, so a
31
+ ## rewrite that is not saved is put back.
32
+ var _outside: Array[Array] = []
33
+ var _scene: SceneFile = null
34
+ var _moves: Dictionary = {}
35
+ var _back: Dictionary = {}
36
+ var _seen: Dictionary = {}
37
+
38
+
39
+ ## Where [param given] went under [param moves], each path a node was at to where it is now or ""
40
+ ## when it was deleted: to where a path it names went, or below it.
41
+ static func moved(given: String, moves: Dictionary) -> String:
42
+ for old: String in moves:
43
+ var now: String = moves[old]
44
+ if given == old:
45
+ return now
46
+ if given.begins_with(old + "/"):
47
+ return "" if now.is_empty() else now + given.substr(old.length())
48
+ return given
49
+
50
+
51
+ ## Carries [param moves], from the root of [param scene], into the paths it holds. Called after the
52
+ ## move and before the write. Answers how many paths it rewrote.
53
+ func carry(scene: SceneFile, moves: Dictionary) -> int:
54
+ if moves.is_empty():
55
+ return 0
56
+ _scene = scene
57
+ _moves = moves
58
+ _back = {}
59
+ for old: String in moves:
60
+ if not str(moves[old]).is_empty():
61
+ _back[moves[old]] = old
62
+ var before: int = retargeted.size()
63
+ var nodes: Array[Node] = [scene.root]
64
+ while not nodes.is_empty():
65
+ var node: Node = nodes.pop_back()
66
+ var children: Array[Node] = node.get_children()
67
+ children.reverse()
68
+ nodes.append_array(children)
69
+ if not scene.foreign(node).is_empty():
70
+ continue
71
+ var now: String = scene.path_of(node)
72
+ var was: String = moved(now, _back)
73
+ # A value another file gives is in that file's terms, which its own pass carried already.
74
+ var from: String = scene.values_from(node, was)
75
+ var set_here: Dictionary = scene.set_at(was) if not from.is_empty() else {}
76
+ for property: Dictionary in node.get_property_list():
77
+ var name: String = str(property["name"])
78
+ if not _may_hold_paths(property):
79
+ continue
80
+ var entry: Dictionary = {"scenePath": scene.path, "nodePath": now, "property": name}
81
+ var elsewhere: String = "" if set_here.has(name) else from
82
+ var carried: Array = _carried(node.get(name), was, now, entry, elsewhere)
83
+ if carried[0]:
84
+ scene.asked(node, [name])
85
+ node.set(name, carried[1])
86
+ var mixer: AnimationMixer = node as AnimationMixer
87
+ if mixer != null:
88
+ _tracks(mixer, now)
89
+ return retargeted.size() - before
90
+
91
+
92
+ ## Opens [param scene_path], carries [param moves] into it, and writes it when that rewrote a path.
93
+ ## Answers "" or why it was left as it was.
94
+ func carry_into(scene_path: String, moves: Dictionary) -> String:
95
+ var scene: SceneFile = SceneFile.new()
96
+ var refused: Dictionary = scene.open(scene_path, true)
97
+ if not refused.is_empty():
98
+ return str(refused.get("error", ""))
99
+ if carry(scene, moves) == 0:
100
+ scene.close()
101
+ return ""
102
+ var written: Dictionary = scene.write()
103
+ if written.is_empty():
104
+ return ""
105
+ var why: String = str(written.get("error", ""))
106
+ put_back(scene_path, why)
107
+ return why
108
+
109
+
110
+ ## Undoes what was carried into [param scene_path] where it outlives the scene's own nodes, in the
111
+ ## animations kept beside it, for a scene that was not written. With [param why], the paths
112
+ ## rewritten there move to notRetargeted with it.
113
+ func put_back(scene_path: String, why: String = "") -> void:
114
+ var _undone: Array[Dictionary] = _put_back(
115
+ func(record: Dictionary) -> bool: return record["scenePath"] == scene_path
116
+ )
117
+ for record: Dictionary in retargeted.duplicate():
118
+ if record["scenePath"] == scene_path:
119
+ retargeted.erase(record)
120
+ if not why.is_empty():
121
+ not_retargeted.append(_left(record, why))
122
+
123
+
124
+ ## The fields an answer gives for what was carried, each only when it names any, once the files
125
+ ## beside the scenes holding rewritten tracks are saved. The tracks of a file that does not save are
126
+ ## put back and move to notRetargeted.
127
+ func answer() -> Dictionary:
128
+ for file: String in _homes:
129
+ var resource: Resource = _homes[file]
130
+ var saving: Error = SceneFile.save_keeping_uid(resource, file)
131
+ if saving == OK:
132
+ if Engine.is_editor_hint():
133
+ EditorInterface.get_resource_filesystem().update_file(file)
134
+ continue
135
+ var why: String = "Failed to save %s: %s" % [file, error_string(saving)]
136
+ for record: Dictionary in _put_back(func(_record: Dictionary) -> bool: return false, file):
137
+ retargeted.erase(record)
138
+ not_retargeted.append(_left(record, why))
139
+ _homes.clear()
140
+ _outside.clear()
141
+ var fields: Dictionary = {}
142
+ if not retargeted.is_empty():
143
+ fields["retargeted"] = retargeted
144
+ if not not_retargeted.is_empty():
145
+ fields["notRetargeted"] = not_retargeted
146
+ if not still_naming.is_empty():
147
+ fields["stillNaming"] = still_naming
148
+ return fields
149
+
150
+
151
+ ## Puts back each track rewritten outside the scene whose record [param matching] takes, or that is
152
+ ## kept in [param file], and answers their records. A file left with none is not saved.
153
+ func _put_back(matching: Callable, file: String = "") -> Array[Dictionary]:
154
+ var records: Array[Dictionary] = []
155
+ var kept: Array[Array] = []
156
+ for edit: Array in _outside:
157
+ var record: Dictionary = edit[3]
158
+ if edit[4] != file and not matching.call(record):
159
+ kept.append(edit)
160
+ continue
161
+ var animation: Animation = edit[0]
162
+ var track: int = edit[1]
163
+ var had: NodePath = edit[2]
164
+ animation.track_set_path(track, had)
165
+ records.append(record)
166
+ _outside = kept
167
+ for home: String in _homes.keys():
168
+ if not _outside.any(func(edit: Array) -> bool: return edit[4] == home):
169
+ var _dropped: bool = _homes.erase(home)
170
+ return records
171
+
172
+
173
+ ## [param record] of a path that was not rewritten after all, with why.
174
+ static func _left(record: Dictionary, why: String) -> Dictionary:
175
+ var left: Dictionary = record.duplicate()
176
+ var _had_one: bool = left.erase("to")
177
+ left["reason"] = why
178
+ return left
179
+
180
+
181
+ ## Whether [param property] can hold a node path the editor's rename would follow: one saved or
182
+ ## shown, of a type that holds paths, and not marked as holding none.
183
+ static func _may_hold_paths(property: Dictionary) -> bool:
184
+ if not Read.as_int(property.get("usage")) & (PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR):
185
+ return false
186
+ if Read.as_int(property.get("hint")) == HINT_NO_NODEPATH:
187
+ return false
188
+ return [TYPE_OBJECT, TYPE_NODE_PATH, TYPE_ARRAY, TYPE_DICTIONARY].has(Read.as_int(property.get("type")))
189
+
190
+
191
+ ## [param value], held by the node that was at [param was] and is at [param now], with each path in
192
+ ## it carried, as [whether any was, the value]. A resource built into the scene is changed in place.
193
+ ## [param entry] says where the value is held, for the record of each path. A path in a resource may
194
+ ## be relative to something other than its holder, as the editor allows; it is followed only when it
195
+ ## names a node from where its holder was, as one relative to the holder does. A value [param
196
+ ## elsewhere], a file other than the scene's, gives is named only when it names nothing since the
197
+ ## move, which its own file's pass could not see.
198
+ func _carried(value: Variant, was: String, now: String, entry: Dictionary, elsewhere: String) -> Array:
199
+ match typeof(value):
200
+ TYPE_NODE_PATH:
201
+ var path: NodePath = value
202
+ var after: Array = _after(path, was, now)
203
+ var record: Dictionary = entry.duplicate()
204
+ record["from"] = str(path)
205
+ if not elsewhere.is_empty():
206
+ if after[0] == REWRITTEN and _names_nothing(path, now):
207
+ not_retargeted.append(
208
+ _left(
209
+ record,
210
+ "The value is set in %s, and an edit here would not be saved there." % elsewhere
211
+ )
212
+ )
213
+ elif after[0] == DELETED:
214
+ still_naming.append(record)
215
+ elif after[0] == REWRITTEN:
216
+ record["to"] = after[1]
217
+ retargeted.append(record)
218
+ return [true, NodePath(str(after[1]))]
219
+ TYPE_ARRAY:
220
+ var items: Array = value
221
+ var changed: bool = false
222
+ for index: int in items.size():
223
+ var carried: Array = _carried(items[index], was, now, entry, elsewhere)
224
+ if carried[0]:
225
+ if not changed:
226
+ items = items.duplicate()
227
+ changed = true
228
+ items[index] = carried[1]
229
+ return [changed, items]
230
+ TYPE_DICTIONARY:
231
+ var pairs: Dictionary = value
232
+ var changed: bool = false
233
+ for key: Variant in pairs.keys():
234
+ var carried: Array = _carried(pairs[key], was, now, entry, elsewhere)
235
+ if carried[0]:
236
+ if not changed:
237
+ pairs = pairs.duplicate()
238
+ changed = true
239
+ pairs[key] = carried[1]
240
+ return [changed, pairs]
241
+ TYPE_OBJECT:
242
+ var held: Object = value
243
+ # The editor leaves animations to the tracks below, and materials for what it costs.
244
+ if not held is Resource or held is Animation or held is Material:
245
+ return [false, value]
246
+ var resource: Resource = held
247
+ var file: String = resource.resource_path.get_slice("::", 0)
248
+ if not elsewhere.is_empty() or (not file.is_empty() and file != _scene.path):
249
+ return [false, value]
250
+ var reached: bool = false
251
+ for property: Dictionary in resource.get_property_list():
252
+ var name: String = str(property["name"])
253
+ if not _may_hold_paths(property):
254
+ continue
255
+ var carried: Array = _carried(resource.get(name), was, now, entry, "")
256
+ if carried[0]:
257
+ resource.set(name, carried[1])
258
+ reached = true
259
+ if reached:
260
+ _scene.reached_into(resource)
261
+ return [false, value]
262
+
263
+
264
+ ## What [param path], held by the node that was at [param was] and is at [param now], becomes, as
265
+ ## [UNCHANGED, ""], [REWRITTEN, the new path] or [DELETED, ""].
266
+ func _after(path: NodePath, was: String, now: String) -> Array:
267
+ if path.is_empty() or path.is_absolute() or path.get_name_count() == 0:
268
+ return [UNCHANGED, ""]
269
+ var names: PackedStringArray = []
270
+ for index: int in path.get_name_count():
271
+ var _named: bool = names.append(str(path.get_name(index)))
272
+ var start: String = was
273
+ var unique: bool = names[0].begins_with("%")
274
+ if unique:
275
+ start = _unique_was(names[0].substr(1), now)
276
+ if start.is_empty():
277
+ return [UNCHANGED, ""]
278
+ names = names.slice(1)
279
+ var target_was: String = _walked(start, names)
280
+ if target_was.is_empty():
281
+ return [UNCHANGED, ""]
282
+ var target_now: String = moved(target_was, _moves)
283
+ if target_now == target_was and now == was:
284
+ return [UNCHANGED, ""]
285
+ if target_now.is_empty():
286
+ return [DELETED, ""]
287
+ var holder: Node = _scene.root.get_node_or_null(now)
288
+ var target: Node = _scene.root.get_node_or_null(target_now)
289
+ if holder == null or target == null:
290
+ return [UNCHANGED, ""]
291
+ if holder.get_node_or_null(NodePath(path.get_concatenated_names())) == target:
292
+ return [UNCHANGED, ""]
293
+ var to: String = str(holder.get_path_to(target, unique))
294
+ if path.get_subname_count() > 0:
295
+ to += ":" + path.get_concatenated_subnames()
296
+ return [REWRITTEN, to]
297
+
298
+
299
+ ## Whether [param path], held by the node at [param now], names no node.
300
+ func _names_nothing(path: NodePath, now: String) -> bool:
301
+ var holder: Node = _scene.root.get_node_or_null(now)
302
+ return holder != null and holder.get_node_or_null(NodePath(path.get_concatenated_names())) == null
303
+
304
+
305
+ ## Where the node a path held by the node at [param now] names as %[param name] was: one marked
306
+ ## unique in the same owner that had that name before the move, or a deleted one that had it.
307
+ func _unique_was(name: String, now: String) -> String:
308
+ var holder: Node = _scene.root.get_node_or_null(now)
309
+ if holder == null:
310
+ return ""
311
+ var scope: Node = holder
312
+ if holder.owner != null:
313
+ scope = holder.owner
314
+ var nodes: Array[Node] = [_scene.root]
315
+ while not nodes.is_empty():
316
+ var node: Node = nodes.pop_back()
317
+ nodes.append_array(node.get_children())
318
+ if node.unique_name_in_owner and node.owner == scope:
319
+ var was: String = moved(_scene.path_of(node), _back)
320
+ if was.get_file() == name:
321
+ return was
322
+ for old: String in _moves:
323
+ if str(_moves[old]).is_empty() and old.get_file() == name:
324
+ return old
325
+ return ""
326
+
327
+
328
+ ## The path from the root reached by walking [param names] from [param start], or "" when they
329
+ ## climb out of the scene.
330
+ static func _walked(start: String, names: PackedStringArray) -> String:
331
+ var parts: PackedStringArray = []
332
+ if start != ".":
333
+ parts = start.split("/")
334
+ for name: String in names:
335
+ if name == "..":
336
+ if parts.is_empty():
337
+ return ""
338
+ parts.remove_at(parts.size() - 1)
339
+ elif name != ".":
340
+ var _added: bool = parts.append(name)
341
+ return "." if parts.is_empty() else "/".join(parts)
342
+
343
+
344
+ ## Carries the moves into the tracks of the animations [param mixer], at [param now], plays, which
345
+ ## name nodes from its root node. That is read once the mixer's own root_node is carried, so it is
346
+ ## the node the tracks were written against. An AnimationTree playing an AnimationPlayer's
347
+ ## animations leaves them to the player. A track in a file nothing here writes is named only when it
348
+ ## names nothing since the move, as a value another file gives is.
349
+ func _tracks(mixer: AnimationMixer, now: String) -> void:
350
+ if mixer is AnimationTree:
351
+ var tree: AnimationTree = mixer
352
+ if tree.get_node_or_null(tree.anim_player) is AnimationPlayer:
353
+ return
354
+ var root: Node = mixer.get_node_or_null(mixer.root_node)
355
+ if root == null:
356
+ return
357
+ var root_now: String = _scene.path_of(root)
358
+ var root_was: String = moved(root_now, _back)
359
+ for library_name: StringName in mixer.get_animation_library_list():
360
+ var library: AnimationLibrary = mixer.get_animation_library(library_name)
361
+ for animation_name: StringName in library.get_animation_list():
362
+ var animation: Animation = library.get_animation(animation_name)
363
+ var home: Dictionary = _home_of(animation, library)
364
+ var why: String = home["why"]
365
+ # Carried once, from the first scene playing it. One kept in a file of its own that this call
366
+ # rewrote is held in _homes, so a later scene loading that file is given this same copy.
367
+ if _seen.has(animation):
368
+ continue
369
+ _seen[animation] = true
370
+ var named: String = (
371
+ str(animation_name)
372
+ if str(library_name).is_empty()
373
+ else "%s/%s" % [library_name, animation_name]
374
+ )
375
+ for track: int in animation.get_track_count():
376
+ if animation.track_is_imported(track):
377
+ continue
378
+ var path: NodePath = animation.track_get_path(track)
379
+ var after: Array = _after(path, root_was, root_now)
380
+ var record: Dictionary = {
381
+ "scenePath": _scene.path,
382
+ "nodePath": now,
383
+ "animation": named,
384
+ "track": track,
385
+ "from": str(path),
386
+ }
387
+ if home.get("theirs", false):
388
+ if after[0] == REWRITTEN and _names_nothing(path, root_now):
389
+ not_retargeted.append(_left(record, why))
390
+ elif after[0] == DELETED:
391
+ still_naming.append(record)
392
+ elif after[0] == REWRITTEN and not why.is_empty():
393
+ not_retargeted.append(_left(record, why))
394
+ elif after[0] == REWRITTEN:
395
+ var file: String = home["file"]
396
+ if file.is_empty():
397
+ _scene.reached_into(animation)
398
+ else:
399
+ _homes[file] = home["home"]
400
+ _outside.append([animation, track, path, record, file])
401
+ animation.track_set_path(track, NodePath(str(after[1])))
402
+ record["to"] = after[1]
403
+ retargeted.append(record)
404
+
405
+
406
+ ## Where an edit to [param animation], played from [param library], is saved, as {file, home, why,
407
+ ## theirs}: file "" with the scene, or the file of its own it or its library is kept in, with home the
408
+ ## resource saved there; why, when nothing written here would keep the edit; theirs, when that is
409
+ ## because it is kept in another scene or an imported file, whose tracks are written in its terms.
410
+ func _home_of(animation: Animation, library: AnimationLibrary) -> Dictionary:
411
+ var file: String = animation.resource_path.get_slice("::", 0)
412
+ if file.is_empty() or file == _scene.path:
413
+ return {"file": "", "why": ""}
414
+ var home: Resource = animation
415
+ if animation.resource_path.contains("::"):
416
+ home = library if library.resource_path == file else null
417
+ if home == null or not FileAccess.file_exists(file) or FileAccess.file_exists(file + ".import"):
418
+ return {
419
+ "why": "The animation is kept in %s, and an edit here would not be saved there." % file,
420
+ "theirs": true,
421
+ }
422
+ if not Engine.is_editor_hint():
423
+ var script: Script = home.get_script()
424
+ if (script != null and not script.is_tool()) or HeadlessCheck.holds_scripted_embedded(home, {}):
425
+ return {
426
+ "why":
427
+ (
428
+ (
429
+ (
430
+ "%s holds a resource with a script that is not a tool, which a headless engine runs as "
431
+ + "it loads the file. "
432
+ )
433
+ % file
434
+ )
435
+ + HeadlessCheck.REMEDY
436
+ )
437
+ }
438
+ return {"file": file, "home": home, "why": ""}
@@ -0,0 +1 @@
1
+ uid://cgc8ac024d58g