gdharness 1.3.66 → 1.3.68

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.
Files changed (25) hide show
  1. package/README.md +1 -1
  2. package/build/cli.js +19209 -17814
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +18 -5
  4. package/build/godot/addons/gdharness_editor/headless_check.gd +52 -0
  5. package/build/godot/addons/gdharness_editor/picture_saves.gd +48 -0
  6. package/build/godot/addons/gdharness_editor/picture_saves.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/plugin.gd +84 -3
  8. package/build/godot/addons/gdharness_editor/scene_file.gd +133 -12
  9. package/build/godot/addons/gdharness_editor/tool_executor.gd +16 -1
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +3 -1
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +9 -13
  12. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +87 -13
  13. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +20 -4
  14. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +51 -17
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +473 -8
  16. package/build/godot/addons/gdharness_editor/tools/view_tools.gd +121 -0
  17. package/build/godot/addons/gdharness_editor/tools/view_tools.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/picture_saves.gd +48 -0
  19. package/build/godot/addons/gdharness_runtime/picture_saves.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -43
  21. package/build/godot/operations/editor_tools.gd +1 -0
  22. package/build/godot/operations/gdscript_analysis.gd +17 -0
  23. package/build/godot/operations/picture_saves.gd +48 -0
  24. package/build/keeper.js +116 -114
  25. package/package.json +1 -1
@@ -15,6 +15,7 @@ const GdhPropertyValues = preload("../property_values.gd")
15
15
  const GdhSame = preload("../same_values.gd")
16
16
  const GdhSceneFile = preload("../scene_file.gd")
17
17
  const GdhSceneSharing = preload("scene_sharing.gd")
18
+ const GdhScriptPaths = preload("script_paths.gd")
18
19
  const GdhSerialisation = preload("../serialisation.gd")
19
20
  const GdhTilesetTools = preload("tileset_tools.gd")
20
21
 
@@ -126,10 +127,11 @@ func _inherited(args: Dictionary) -> Dictionary:
126
127
  )
127
128
 
128
129
 
129
- ## The res:// path a create in [param args] writes, given .tscn when it has no extension, or "".
130
- func _new_scene_path(args: Dictionary) -> String:
130
+ ## The res:// path [param args] give under [param argument] for a new scene, given .tscn when it
131
+ ## has no extension, or "".
132
+ func _new_scene_path(args: Dictionary, argument: String = "scenePath") -> String:
131
133
  var scene_path: String = GdhSceneFile.res_path(
132
- str(args.get("projectPath", "")), str(args.get("scenePath", ""))
134
+ str(args.get("projectPath", "")), str(args.get(argument, ""))
133
135
  )
134
136
  if scene_path == "res://":
135
137
  return ""
@@ -301,18 +303,26 @@ func add_node(args: Dictionary) -> Dictionary:
301
303
  if not unplaceable.is_empty():
302
304
  return scene.refuse(unplaceable)
303
305
 
304
- # In the tree before its properties, so a property holding a node can find it by path.
306
+ # In the tree before its properties, so a property holding a node can find it by path. The
307
+ # script goes on once the node is in place, as an add and then a set of the script would put
308
+ # it. What the parent gives the node, such as a Container's layout mode, is then what the node
309
+ # held before its script, and only what the script's _init changed is put back.
305
310
  var new_node: Node = GdhPlacing.made(packed, node_type)
306
- if class_script != null:
307
- scene.asked(new_node, ["script"])
308
- new_node.set_script(class_script)
309
311
  new_node.name = node_name
310
312
  parent.add_child(new_node)
311
313
  GdhPlacing.place(new_node, place)
312
314
  GdhPlacing.own(new_node, root)
315
+ if class_script != null:
316
+ scene.asked(new_node, ["script"])
317
+ new_node.set_script(class_script)
318
+ # The script's _init can name the node, and the call's name is the one kept.
319
+ new_node.name = node_name
313
320
  var unsettled: String = scene.settle()
314
321
  if not unsettled.is_empty():
315
322
  return scene.refuse("Nothing was written: " + unsettled)
323
+ if GdhPlacing.cornered(new_node, packed):
324
+ scene.asked(new_node, ["layout_mode"])
325
+ new_node.set("layout_mode", GdhPlacing.ANCHORS_MODE)
316
326
  scene.asked(new_node, properties.keys())
317
327
  var unwritten: String = _properties.write_all(new_node, properties)
318
328
  scene.asked(new_node, _properties.moved_by_engine.keys())
@@ -482,7 +492,10 @@ func duplicate_node(args: Dictionary) -> Dictionary:
482
492
  duplicated_node.name = new_name
483
493
  target_parent.add_child(duplicated_node)
484
494
  GdhPlacing.place(duplicated_node, place)
485
- GdhPlacing.own(duplicated_node, root)
495
+ var made: Dictionary = {}
496
+ for copy: Variant in copies.values():
497
+ made[copy] = true
498
+ GdhPlacing.own(duplicated_node, root, made)
486
499
  GdhPlacing.reconnect(copies, source)
487
500
  scene.copied(copies)
488
501
 
@@ -655,6 +668,449 @@ func reparent_node(args: Dictionary) -> Dictionary:
655
668
  return answer
656
669
 
657
670
 
671
+ ## Saves the branch at nodePath as a scene of its own at newScenePath and puts an instance of that
672
+ ## scene where the branch was, as the editor's Save Branch as Scene does. The new file holds the
673
+ ## branch's nodes, values, resources, groups and the connections among them, with its root's
674
+ ## position reset as the editor resets it. The instance keeps the branch's name, place, position,
675
+ ## rotation and scale. The rest of the scene keeps what it named in the branch: the connections to
676
+ ## its nodes, the properties holding them and the %unique paths, which no longer reach a node
677
+ ## unique only in the new scene.
678
+ func save_branch(args: Dictionary) -> Dictionary:
679
+ var project_path: String = str(args.get("projectPath", ""))
680
+ var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
681
+ var node_path: String = str(args.get("nodePath", ""))
682
+ var branch_path: String = _new_scene_path(args, "newScenePath")
683
+ if node_path.is_empty() or node_path == ".":
684
+ return {
685
+ "ok": false,
686
+ "error":
687
+ "The root is the whole scene, which scene_create save_as copies to a new file.",
688
+ }
689
+ if branch_path.is_empty():
690
+ return { "ok": false, "error": "Missing newScenePath" }
691
+ if branch_path == scene_path:
692
+ return {
693
+ "ok": false, "error": "newScenePath is %s, the scene the branch is in." % scene_path,
694
+ }
695
+ if FileAccess.file_exists(branch_path):
696
+ return {
697
+ "ok": false,
698
+ "error": "%s already exists, and save_branch writes a new scene only." % branch_path,
699
+ }
700
+
701
+ var opened: Array = _open(scene_path, true)
702
+ var scene: GdhSceneFile = opened[0]
703
+ var refused: Dictionary = opened[1]
704
+ if not refused.is_empty():
705
+ return refused
706
+ var root: Node = scene.root
707
+ var top: Node = GdhSceneFile.node_at(root, node_path)
708
+ if top == null:
709
+ return scene.refuse(scene.not_found("Node not found: " + node_path, node_path))
710
+ var fixed: String = scene.fixed(top)
711
+ if not fixed.is_empty():
712
+ return scene.refuse(fixed)
713
+ if not top.scene_file_path.is_empty():
714
+ return scene.refuse(
715
+ "%s is an instance of %s, its scene already." % [node_path, top.scene_file_path]
716
+ )
717
+
718
+ var at: String = scene.path_of(top)
719
+ var top_name: StringName = top.name
720
+ var parent: Node = top.get_parent()
721
+ var index: int = top.get_index()
722
+ var unique: bool = top.unique_name_in_owner
723
+ var placed_at: Dictionary = {}
724
+ if top is Node2D or top is Node3D:
725
+ for property: String in ["position", "rotation", "scale"]:
726
+ placed_at[property] = top.get(property)
727
+ var outside: Array[Node] = [root]
728
+ var inside: Array[Node] = [top]
729
+ for node: Node in root.find_children("*", "", true, false):
730
+ if node.owner == root and node != top:
731
+ if top.is_ancestor_of(node):
732
+ inside.append(node)
733
+ else:
734
+ outside.append(node)
735
+
736
+ var answer: Dictionary = { "ok": true, "nodePath": at, "newScenePath": branch_path }
737
+ var enclosed: Dictionary = {}
738
+ for node: Node in inside:
739
+ if node != top and node.unique_name_in_owner:
740
+ enclosed[str(node.name)] = scene.path_of(node)
741
+ var held: Array[Array] = _holding_the_branch(outside, top)
742
+ var crossing: Array[Array] = _crossing(outside, inside, top)
743
+ _named_outside(scene, inside, top, answer)
744
+ var scripts_out: Array[Dictionary] = _scripts_naming_outside(inside, top, branch_path)
745
+
746
+ var split: Array = scene.split_off(top, branch_path)
747
+ var branch: GdhSceneFile = split[0]
748
+ var crossing_unmade: Array[Dictionary] = split[1]
749
+ var editable: Array[Node] = []
750
+ for node: Node in inside:
751
+ if node != top and not node.scene_file_path.is_empty() and root.is_editable_instance(node):
752
+ editable.append(node)
753
+ var below: Array[String] = []
754
+ for node: Node in inside:
755
+ below.append(str(top.get_path_to(node)))
756
+ branch.asked(top, ["unique_name_in_owner"] + placed_at.keys())
757
+ parent.remove_child(top)
758
+ top.unique_name_in_owner = false
759
+ top.owner = null
760
+ for node: Node in inside:
761
+ if node != top:
762
+ node.owner = top
763
+ for node: Node in editable:
764
+ top.set_editable_instance(node, true)
765
+ if top is Node2D:
766
+ (top as Node2D).position = Vector2.ZERO
767
+ elif top is Node3D:
768
+ (top as Node3D).position = Vector3.ZERO
769
+ var written: Dictionary = branch.write(
770
+ func(saved: Node) -> String:
771
+ for one: String in below:
772
+ if saved.get_node_or_null(one) == null:
773
+ return "%s would not be in %s when it loads." % [one, branch_path]
774
+ return ""
775
+ )
776
+ if not written.is_empty():
777
+ scene.close()
778
+ return written
779
+
780
+ var packed: PackedScene = ResourceLoader.load(branch_path, "PackedScene") as PackedScene
781
+ var instance: Node = (
782
+ packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) if packed != null else null
783
+ )
784
+ if instance == null:
785
+ return _unwritten(scene, branch_path, "%s did not instance once written." % branch_path)
786
+ instance.name = top_name
787
+ parent.add_child(instance)
788
+ parent.move_child(instance, index)
789
+ instance.owner = root
790
+ # Before the scene is next packed, which reads the path to every node a property holds.
791
+ for one: Array in held:
792
+ var holder: Node = one[0]
793
+ var holding: String = one[1]
794
+ scene.asked(holder, [holding])
795
+ holder.set(holding, _unmarked(one[2], instance))
796
+ var unsettled: String = scene.settle()
797
+ if not unsettled.is_empty():
798
+ return _unwritten(scene, branch_path, unsettled)
799
+ scene.asked(instance, ["unique_name_in_owner"] + placed_at.keys())
800
+ instance.unique_name_in_owner = unique
801
+ for property: String in placed_at:
802
+ instance.set(property, placed_at[property])
803
+ scene.rejoin_unmade(crossing_unmade, instance)
804
+ var lost: Array[Dictionary] = _rejoined(scene, crossing, instance)
805
+ if not lost.is_empty():
806
+ answer["connectionsLost"] = lost
807
+ var carried: GdhPaths = GdhPaths.new()
808
+ var _rewritten: int = carried.carry_enclosed(scene, enclosed)
809
+
810
+ written = scene.write(
811
+ func(saved: Node) -> String:
812
+ var copy: Node = saved.get_node_or_null(at)
813
+ if copy == null or copy.scene_file_path != branch_path:
814
+ return "%s would not load as an instance of %s." % [at, branch_path]
815
+ if copy.get_index() != index:
816
+ return "%s would load at index %d, not %d." % [at, copy.get_index(), index]
817
+ return ""
818
+ )
819
+ if not written.is_empty():
820
+ carried.put_back(scene_path)
821
+ return _unwritten(null, branch_path, str(written.get("error", "")))
822
+ answer["index"] = index
823
+ answer.merge(carried.answer())
824
+ answer.merge(GdhKin.enclose(scene_path, at))
825
+ if not scripts_out.is_empty():
826
+ var naming: Array = answer.get_or_add("scriptsNaming", [])
827
+ naming.append_array(scripts_out)
828
+ return answer
829
+
830
+
831
+ ## Removes the scene [method save_branch] wrote at [param branch_path] and answers [param why] as
832
+ ## its refusal. [param scene] is closed when it is still open.
833
+ static func _unwritten(scene: GdhSceneFile, branch_path: String, why: String) -> Dictionary:
834
+ if scene != null:
835
+ scene.close()
836
+ var _removed: Error = DirAccess.remove_absolute(branch_path)
837
+ if Engine.is_editor_hint():
838
+ GdhSceneFile.scan_later()
839
+ return { "ok": false, "error": why }
840
+
841
+
842
+ ## Each property of the nodes [param outside] the branch at [param top] that holds a node of it, as
843
+ ## [holder, property, the value with each such node marked by its path from [param top]], for
844
+ ## [method _unmarked] to give the instance's node at that path.
845
+ static func _holding_the_branch(outside: Array[Node], top: Node) -> Array[Array]:
846
+ var held: Array[Array] = []
847
+ for node: Node in outside:
848
+ for property: Dictionary in node.get_property_list():
849
+ if not GdhRead.as_int(property.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
850
+ continue
851
+ var called: String = str(property["name"])
852
+ var marked: Array = _marked(node.get(called), top)
853
+ if marked[0]:
854
+ held.append([node, called, marked[1]])
855
+ return held
856
+
857
+
858
+ ## [param value] with each node of the branch at [param top] in it as a [GdhBranchMark], as
859
+ ## [whether it held one, the value].
860
+ static func _marked(value: Variant, top: Node) -> Array:
861
+ if typeof(value) == TYPE_OBJECT and is_instance_valid(value) and value is Node:
862
+ var node: Node = value
863
+ if node == top or top.is_ancestor_of(node):
864
+ return [true, GdhBranchMark.new(top.get_path_to(node))]
865
+ elif value is Array:
866
+ var items: Array = value
867
+ var changed: bool = false
868
+ var copy: Array = items.duplicate()
869
+ for at: int in items.size():
870
+ var one: Array = _marked(items[at], top)
871
+ if one[0]:
872
+ copy[at] = one[1]
873
+ changed = true
874
+ return [changed, copy if changed else value]
875
+ elif value is Dictionary:
876
+ var pairs: Dictionary = value
877
+ var changed: bool = false
878
+ var copy: Dictionary = pairs.duplicate()
879
+ for key: Variant in pairs:
880
+ var one: Array = _marked(pairs[key], top)
881
+ if one[0]:
882
+ copy[key] = one[1]
883
+ changed = true
884
+ return [changed, copy if changed else value]
885
+ return [false, value]
886
+
887
+
888
+ ## [param value] with each [GdhBranchMark] in it as the node at its path below [param instance].
889
+ static func _unmarked(value: Variant, instance: Node) -> Variant:
890
+ if value is GdhBranchMark:
891
+ var mark: GdhBranchMark = value
892
+ return instance.get_node_or_null(mark.below)
893
+ if value is Array:
894
+ var items: Array = value
895
+ return items.map(func(item: Variant) -> Variant: return _unmarked(item, instance))
896
+ if value is Dictionary:
897
+ var pairs: Dictionary = value
898
+ var copy: Dictionary = pairs.duplicate()
899
+ for key: Variant in pairs:
900
+ copy[key] = _unmarked(pairs[key], instance)
901
+ return copy
902
+ return value
903
+
904
+
905
+ ## Each node path and node the nodes [param inside] the branch at [param top] hold that reaches a
906
+ ## node outside it, answered under namesOutside as {nodePath, property, path, names} with nodePath
907
+ ## the holder's path in the new scene and names the node's path here. A path is kept, and names
908
+ ## that node only where the new scene is instanced as here. A node is cleared, with cleared: true,
909
+ ## since the new scene's file can only save it as a path within itself.
910
+ func _named_outside(
911
+ scene: GdhSceneFile, inside: Array[Node], top: Node, answer: Dictionary
912
+ ) -> void:
913
+ var named: Array[Dictionary] = []
914
+ for node: Node in inside:
915
+ for property: Dictionary in node.get_property_list():
916
+ if not GdhRead.as_int(property.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
917
+ continue
918
+ var called: String = str(property["name"])
919
+ if GdhHeadlessCheck.at_default(node, called):
920
+ continue
921
+ var record: Dictionary = {
922
+ "nodePath": str(top.get_path_to(node)), "property": called,
923
+ }
924
+ var kept: Array = _outside_of(node.get(called), node, top, scene, record, named)
925
+ if kept[0]:
926
+ scene.asked(node, [called])
927
+ node.set(called, kept[1])
928
+ if not named.is_empty():
929
+ answer["namesOutside"] = named
930
+
931
+
932
+ ## [param value], held by [param holder] in the branch at [param top], with each node outside the
933
+ ## branch in it cleared, each such node and each path reaching one recorded from [param record] in
934
+ ## [param named], as [whether a node was cleared, the value].
935
+ static func _outside_of(
936
+ value: Variant,
937
+ holder: Node,
938
+ top: Node,
939
+ scene: GdhSceneFile,
940
+ record: Dictionary,
941
+ named: Array[Dictionary]
942
+ ) -> Array:
943
+ if value is NodePath:
944
+ var path: NodePath = value
945
+ var target: Node = (
946
+ holder.get_node_or_null(NodePath(path.get_concatenated_names()))
947
+ if path.get_name_count() > 0
948
+ else null
949
+ )
950
+ if target != null and target != top and not top.is_ancestor_of(target):
951
+ var entry: Dictionary = record.duplicate()
952
+ entry["path"] = str(path)
953
+ entry["names"] = scene.path_of(target)
954
+ named.append(entry)
955
+ return [false, value]
956
+ if typeof(value) == TYPE_OBJECT and is_instance_valid(value) and value is Node:
957
+ var node: Node = value
958
+ if node == top or top.is_ancestor_of(node):
959
+ return [false, value]
960
+ var entry: Dictionary = record.duplicate()
961
+ entry["names"] = scene.path_of(node)
962
+ entry["cleared"] = true
963
+ named.append(entry)
964
+ return [true, null]
965
+ if value is Array:
966
+ var items: Array = value
967
+ var changed: bool = false
968
+ var copy: Array = items.duplicate()
969
+ for at: int in items.size():
970
+ var one: Array = _outside_of(items[at], holder, top, scene, record, named)
971
+ if one[0]:
972
+ copy[at] = one[1]
973
+ changed = true
974
+ return [changed, copy if changed else value]
975
+ if value is Dictionary:
976
+ var pairs: Dictionary = value
977
+ var changed: bool = false
978
+ var copy: Dictionary = pairs.duplicate()
979
+ for key: Variant in pairs:
980
+ var one: Array = _outside_of(pairs[key], holder, top, scene, record, named)
981
+ if one[0]:
982
+ copy[key] = one[1]
983
+ changed = true
984
+ return [changed, copy if changed else value]
985
+ return [false, value]
986
+
987
+
988
+ ## The persistent connections between a node [param outside] the branch at [param top] and one
989
+ ## [param inside] it, each taken off its source and answered as [source, signal, callable, flags,
990
+ ## the source's path from [param top] or "", the target's path from [param top] or ""]. The source
991
+ ## is given only when it is outside, since a node of the branch is freed with it.
992
+ static func _crossing(outside: Array[Node], inside: Array[Node], top: Node) -> Array[Array]:
993
+ var crossing: Array[Array] = []
994
+ for from_inside: bool in [false, true]:
995
+ var sources: Array[Node] = inside if from_inside else outside
996
+ for source: Node in sources:
997
+ for info: Dictionary in source.get_signal_list():
998
+ var signal_name: String = str(info.get("name", ""))
999
+ for conn: Dictionary in source.get_signal_connection_list(signal_name):
1000
+ var flags: int = GdhRead.as_int(conn.get("flags", 0))
1001
+ var callable: Callable = conn.get("callable", Callable())
1002
+ var target: Node = callable.get_object() as Node
1003
+ if not flags & Object.CONNECT_PERSIST or target == null:
1004
+ continue
1005
+ if (target == top or top.is_ancestor_of(target)) == from_inside:
1006
+ continue
1007
+ crossing.append(
1008
+ [
1009
+ null if from_inside else source,
1010
+ signal_name,
1011
+ callable,
1012
+ flags,
1013
+ str(top.get_path_to(source)) if from_inside else "",
1014
+ "" if from_inside else str(top.get_path_to(target)),
1015
+ ]
1016
+ )
1017
+ source.disconnect(signal_name, callable)
1018
+ return crossing
1019
+
1020
+
1021
+ ## Makes each of [param crossing], from [method _crossing], again between the node outside and the
1022
+ ## node at its path below [param instance]. Answers each that the scene's file would not hold, as
1023
+ ## {sourceNodePath, signalName, targetNodePath, methodName}: one from a node inside an instance is
1024
+ ## saved only once the instance's children are editable.
1025
+ func _rejoined(scene: GdhSceneFile, crossing: Array[Array], instance: Node) -> Array[Dictionary]:
1026
+ var made: Array[Array] = []
1027
+ for one: Array in crossing:
1028
+ var callable: Callable = one[2]
1029
+ var source: Node = one[0]
1030
+ if not str(one[4]).is_empty():
1031
+ source = instance.get_node_or_null(NodePath(str(one[4])))
1032
+ # The node of the branch a callable aims at has been freed with the branch.
1033
+ var target: Object = (
1034
+ instance.get_node_or_null(NodePath(str(one[5])))
1035
+ if not str(one[5]).is_empty()
1036
+ else callable.get_object()
1037
+ )
1038
+ if source == null or target == null:
1039
+ continue
1040
+ var aimed: Callable = GdhPlacing.aimed(callable, target)
1041
+ if source.connect(str(one[1]), aimed, GdhRead.as_int(one[3])) == OK:
1042
+ made.append([source, str(one[1]), aimed, target])
1043
+ var lost: Array[Dictionary] = []
1044
+ if made.is_empty():
1045
+ return lost
1046
+ var probe: PackedScene = PackedScene.new()
1047
+ if probe.pack(scene.root) != OK:
1048
+ return lost
1049
+ var state: SceneState = probe.get_state()
1050
+ var saved: Dictionary = {}
1051
+ for at: int in state.get_connection_count():
1052
+ var key: String = "%s|%s|%s|%s" % [
1053
+ str(state.get_connection_source(at)).trim_prefix("./"),
1054
+ state.get_connection_signal(at),
1055
+ str(state.get_connection_target(at)).trim_prefix("./"),
1056
+ state.get_connection_method(at),
1057
+ ]
1058
+ saved[key] = true
1059
+ for one: Array in made:
1060
+ var source: Node = one[0]
1061
+ var aimed: Callable = one[2]
1062
+ var target: Node = one[3]
1063
+ var held: Dictionary = {
1064
+ "sourceNodePath": scene.path_of(source),
1065
+ "signalName": one[1],
1066
+ "targetNodePath": scene.path_of(target),
1067
+ "methodName": str(aimed.get_method()),
1068
+ }
1069
+ var key: String = "%s|%s|%s|%s" % held.values()
1070
+ if not saved.has(key):
1071
+ source.disconnect(str(one[1]), aimed)
1072
+ lost.append(held)
1073
+ return lost
1074
+
1075
+
1076
+ ## Each line of a script on a node [param inside] the branch at [param top] that names a node
1077
+ ## outside it through a %unique node, which is not unique in the new scene at [param branch_path],
1078
+ ## as scriptsNaming gives it with no to: {scenePath, nodePath, script, line, from}, nodePath the
1079
+ ## node's path in the new scene.
1080
+ static func _scripts_naming_outside(
1081
+ inside: Array[Node], top: Node, branch_path: String
1082
+ ) -> Array[Dictionary]:
1083
+ var found: Array[Dictionary] = []
1084
+ var seen: Dictionary = {}
1085
+ for node: Node in inside:
1086
+ var script: Script = node.get_script()
1087
+ while script is GDScript:
1088
+ var file: String = script.resource_path
1089
+ var source: String = (script as GDScript).source_code
1090
+ if source.is_empty() and FileAccess.file_exists(file):
1091
+ source = FileAccess.get_file_as_string(file)
1092
+ for named: Dictionary in GdhScriptPaths.named(source):
1093
+ var path: String = named["path"]
1094
+ if not path.begins_with("%"):
1095
+ continue
1096
+ var through: Node = node.get_node_or_null(NodePath(path.get_slice("/", 0)))
1097
+ if through == null or through == top or top.is_ancestor_of(through):
1098
+ continue
1099
+ var record: Dictionary = {
1100
+ "scenePath": branch_path,
1101
+ "nodePath": str(top.get_path_to(node)),
1102
+ "script": file,
1103
+ "line": named["line"],
1104
+ "from": path,
1105
+ }
1106
+ var key: String = "%s:%d:%s:%s" % [file, named["line"], path, record["nodePath"]]
1107
+ if not seen.has(key):
1108
+ seen[key] = true
1109
+ found.append(record)
1110
+ script = script.get_base_script()
1111
+ return found
1112
+
1113
+
658
1114
  func set_node_properties(args: Dictionary) -> Dictionary:
659
1115
  var project_path: String = str(args.get("projectPath", ""))
660
1116
  var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
@@ -1059,3 +1515,12 @@ func _save_open_scene(scene_path: String) -> Dictionary:
1059
1515
  "from": "editor",
1060
1516
  "unsavedInEditor": GdhSceneFile.unsaved(scene_path),
1061
1517
  }
1518
+
1519
+
1520
+ ## A node of a branch being saved as its own scene, held by a property outside it, by its path from
1521
+ ## the branch's top, until the instance replacing the branch gives the node at that path.
1522
+ class GdhBranchMark:
1523
+ var below: NodePath
1524
+
1525
+ func _init(at: NodePath) -> void:
1526
+ below = at
@@ -0,0 +1,121 @@
1
+ @tool
2
+ extends Node
3
+ ## Pictures of a scene as the editor draws it, with what its @tool scripts draw only there.
4
+ ##
5
+ ## A game never runs a script's editor branch, so runtime_capture cannot show it, and a headless
6
+ ## engine draws nothing at all. The editor's own 2D and 3D views are the one place it is drawn.
7
+
8
+ const GdhRead = preload("../reading.gd")
9
+ const GdhSceneFile = preload("../scene_file.gd")
10
+ const GdhPictures = preload("../picture_saves.gd")
11
+ const HEADLESS_DISPLAY: String = "headless"
12
+ ## Frames let pass once the scene is shown: the first lays the screen out, and the next run the
13
+ ## redraws its @tool scripts queued as they entered the tree.
14
+ const SETTLING_FRAMES: int = 3
15
+
16
+ ## Whether a capture is under way, during which the editor turns other calls away, since one that
17
+ ## switched scenes between the open and the read would be what was captured.
18
+ var capturing: bool = false
19
+ var _editor_plugin: EditorPlugin = null
20
+ ## The main screen the editor shows, as it last said, or "" before it has said.
21
+ var _screen: String = ""
22
+
23
+
24
+ func set_editor_plugin(plugin: EditorPlugin) -> void:
25
+ _editor_plugin = plugin
26
+ if not plugin.main_screen_changed.is_connected(_on_main_screen_changed):
27
+ var joined: int = plugin.main_screen_changed.connect(_on_main_screen_changed)
28
+ if joined != OK:
29
+ push_warning("gdharness: following the editor's main screen: %s" % error_string(joined))
30
+
31
+
32
+ func _on_main_screen_changed(screen_name: String) -> void:
33
+ _screen = screen_name
34
+
35
+
36
+ ## The scene at scenePath as the editor's 2D or 3D view draws it, saved at output_path, which the
37
+ ## server names. The editor is left showing the scene and the screen it showed before.
38
+ func capture_editor_view(args: Dictionary) -> Dictionary:
39
+ var output_path: String = str(args.get("output_path", ""))
40
+ if output_path.is_empty():
41
+ return { "ok": false, "error": "output_path required" }
42
+ if DisplayServer.get_name() == HEADLESS_DISPLAY:
43
+ return {
44
+ "ok": false,
45
+ "error":
46
+ (
47
+ "This editor runs headless, so it draws nothing to capture. Use a visible "
48
+ + "editor, or a hidden one on Windows."
49
+ ),
50
+ }
51
+ var project_path: String = str(args.get("projectPath", ""))
52
+ var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
53
+ if not ResourceLoader.exists(scene_path, "PackedScene"):
54
+ return { "ok": false, "error": "Scene not found: " + scene_path }
55
+ var view: String = str(args.get("view", ""))
56
+ var shown: Node = EditorInterface.get_edited_scene_root()
57
+ var previous: String = shown.scene_file_path if shown != null else ""
58
+ var was_open: bool = EditorInterface.get_open_scenes().has(scene_path)
59
+ var screen_before: String = _screen
60
+
61
+ capturing = true
62
+ EditorInterface.open_scene_from_path(scene_path)
63
+ var root: Node = EditorInterface.get_edited_scene_root()
64
+ if root == null or root.scene_file_path != scene_path:
65
+ capturing = false
66
+ return { "ok": false, "error": "The editor did not open %s." % scene_path }
67
+ if view.is_empty():
68
+ view = "3d" if root is Node3D else "2d"
69
+ EditorInterface.set_main_screen_editor("3D" if view == "3d" else "2D")
70
+ for frame: int in SETTLING_FRAMES:
71
+ await get_tree().process_frame
72
+ var viewport: SubViewport = (
73
+ EditorInterface.get_editor_viewport_3d(0)
74
+ if view == "3d"
75
+ else EditorInterface.get_editor_viewport_2d()
76
+ )
77
+ var image: Image = viewport.get_texture().get_image()
78
+ var unsaved: bool = GdhSceneFile.unsaved(scene_path)
79
+
80
+ if not was_open:
81
+ var closing: Error = EditorInterface.close_scene()
82
+ if closing != OK:
83
+ push_warning(
84
+ "gdharness: closing %s after its capture: %s" % [scene_path, error_string(closing)]
85
+ )
86
+ if not previous.is_empty() and previous != scene_path:
87
+ EditorInterface.open_scene_from_path(previous)
88
+ if not screen_before.is_empty() and screen_before != ("3D" if view == "3d" else "2D"):
89
+ EditorInterface.set_main_screen_editor(screen_before)
90
+ capturing = false
91
+
92
+ if image == null or image.is_empty():
93
+ return { "ok": false, "error": "The editor's %s view held no picture." % view.to_upper() }
94
+ var drawn: Vector2i = image.get_size()
95
+ var target: Vector2i = GdhPictures.scaled_to(
96
+ drawn, GdhRead.as_int(args.get("width", 0)), GdhRead.as_int(args.get("height", 0))
97
+ )
98
+ if target != drawn:
99
+ image.resize(target.x, target.y)
100
+ # The server makes the folder for each capture and takes it away when it stops waiting.
101
+ if not DirAccess.dir_exists_absolute(output_path.get_base_dir()):
102
+ return {
103
+ "ok": false,
104
+ "error":
105
+ (
106
+ "The folder to save the capture in, %s, is gone because the server that asked "
107
+ + "for it stopped waiting."
108
+ )
109
+ % output_path.get_base_dir(),
110
+ }
111
+ var answer: Dictionary = GdhPictures.saved(image, output_path)
112
+ if answer.get("type", "") == "error":
113
+ return { "ok": false, "error": str(answer.get("message", "")) }
114
+ answer["ok"] = true
115
+ answer["scenePath"] = scene_path
116
+ answer["view"] = view
117
+ if target != drawn:
118
+ answer["drawnAt"] = { "width": drawn.x, "height": drawn.y }
119
+ if unsaved:
120
+ answer["unsavedInEditor"] = true
121
+ return answer
@@ -0,0 +1 @@
1
+ uid://bbvurjno0b6f8