gdharness 1.3.8 → 1.3.9

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 (35) hide show
  1. package/build/cli.js +137 -66
  2. package/build/godot/addons/gdharness_editor/file_reads.gd +13 -0
  3. package/build/godot/addons/gdharness_editor/reading.gd +61 -5
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +41 -35
  5. package/build/godot/addons/gdharness_editor/serialisation.gd +8 -7
  6. package/build/godot/addons/gdharness_editor/tool_executor.gd +12 -2
  7. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +244 -0
  8. package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +105 -157
  10. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +25 -1
  11. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -1
  12. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +72 -3
  14. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +7 -1
  15. package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
  16. package/build/godot/addons/gdharness_runtime/reading.gd +61 -5
  17. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +3 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_input.gd +27 -31
  20. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +11 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +2 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +26 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_values.gd +105 -6
  24. package/build/godot/addons/gdharness_runtime/serialisation.gd +8 -7
  25. package/build/godot/operations/editor_tools.gd +8 -0
  26. package/build/godot/operations/file_reads.gd +13 -0
  27. package/build/godot/operations/gdscript_analysis.gd +14 -0
  28. package/build/godot/operations/gdscript_authoring.gd +1 -1
  29. package/build/godot/operations/godot_operations.gd +2 -0
  30. package/build/godot/operations/input_actions.gd +22 -7
  31. package/build/godot/operations/project_config.gd +31 -0
  32. package/build/godot/operations/reading.gd +61 -5
  33. package/build/godot/operations/serialisation.gd +8 -7
  34. package/build/keeper.js +43 -13
  35. package/package.json +1 -1
@@ -11,9 +11,6 @@ const GdhRead = preload("../reading.gd")
11
11
  const GdhSceneFile = preload("../scene_file.gd")
12
12
  const GdhSerialisation = preload("../serialisation.gd")
13
13
 
14
- ## How far right of the rightmost state a new one goes in the editor's graph, wider than a state's
15
- ## box there.
16
- const STATE_SPACING: float = 200.0
17
14
  const LOOP_MODES: Dictionary = {
18
15
  "none": Animation.LOOP_NONE, "linear": Animation.LOOP_LINEAR, "pingpong": Animation.LOOP_PINGPONG
19
16
  }
@@ -335,6 +332,10 @@ func add_animation_track(args: Dictionary) -> Dictionary:
335
332
  var track: Dictionary = given_track if given_track is Dictionary else {}
336
333
  if track.is_empty():
337
334
  return {"ok": false, "error": "Missing track"}
335
+ # The schema leaves keyframes out of what track requires, since remove_track finds a track without
336
+ # them.
337
+ if not track.has("keyframes"):
338
+ return {"ok": false, "error": "Missing track.keyframes"}
338
339
  var keyed: Array = _keyframes(track)
339
340
  if not keyed[0]:
340
341
  return {"ok": false, "error": keyed[1]}
@@ -439,7 +440,15 @@ func _add_track(
439
440
  method_found = target.has_method(method_name)
440
441
  kind = Animation.TYPE_METHOD
441
442
  else:
442
- return [-1, "Unsupported track.type: " + track_type, null, false]
443
+ return [
444
+ -1,
445
+ (
446
+ "add_track makes property and method tracks, and track.type is %s; remove_track takes any."
447
+ % track_type
448
+ ),
449
+ null,
450
+ false
451
+ ]
443
452
 
444
453
  var track_idx: int = anim.find_track(path, kind)
445
454
  var existing: bool = track_idx >= 0
@@ -472,6 +481,98 @@ func _add_track(
472
481
  return [track_idx, "", method_found, existing]
473
482
 
474
483
 
484
+ ## Takes out the track [code]track[/code] names by its type, nodePath and property as read answers
485
+ ## them, so a track read back can be passed as it stands; its keyframes are not looked at. Every
486
+ ## track of that shape goes, since the editor can make two for one path and both play.
487
+ func remove_animation_track(args: Dictionary) -> Dictionary:
488
+ var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
489
+ var player_node_path: String = str(args.get("playerNodePath", "."))
490
+ var animation_name: String = str(args.get("animationName", ""))
491
+ var given_track: Variant = args.get("track", {})
492
+ if scene_path.strip_edges() == "res://":
493
+ return {"ok": false, "error": "Missing scenePath"}
494
+ if animation_name.strip_edges().is_empty():
495
+ return {"ok": false, "error": "Missing animationName"}
496
+ var track: Dictionary = given_track if given_track is Dictionary else {}
497
+ if track.is_empty():
498
+ return {"ok": false, "error": "Missing track"}
499
+ var track_type: String = str(track.get("type", ""))
500
+ if not track_type in GdhAnimationReads.TRACK_TYPE_NAMES.values():
501
+ return {"ok": false, "error": "Unsupported track.type: " + track_type}
502
+ var node_path: String = str(track.get("nodePath", ""))
503
+ var property: String = str(track.get("property", ""))
504
+ var shape: Array = [track_type, node_path, property]
505
+
506
+ var found: Array = _animation_at(scene_path, player_node_path, animation_name)
507
+ if found[1] == null:
508
+ return found[2]
509
+ var scene: GdhSceneFile = found[0]
510
+ var player: AnimationPlayer = found[1]
511
+ var anim: Animation = player.get_animation(StringName(animation_name))
512
+ var homes: Array = _homes(
513
+ scene, [[_library_of(player, animation_name), "animation library"], [anim, "animation"]]
514
+ )
515
+ if not homes[0]:
516
+ return scene.refuse(str(homes[1]))
517
+ var matching: Array[int] = []
518
+ var held: PackedStringArray = []
519
+ for index: int in anim.get_track_count():
520
+ var of: Array = _track_shape(anim, index)
521
+ if of == shape:
522
+ matching.append(index)
523
+ var _listed: bool = held.append("%s (%s)" % [str(anim.track_get_path(index)), of[0]])
524
+ if matching.is_empty():
525
+ var said: String = (
526
+ "%s has no %s track on %s"
527
+ % [
528
+ animation_name,
529
+ track_type,
530
+ node_path if property.is_empty() else "%s:%s" % [node_path, property]
531
+ ]
532
+ )
533
+ if held.is_empty():
534
+ return scene.refuse("%s; it has no tracks." % said)
535
+ return scene.refuse("%s; its tracks are %s." % [said, ", ".join(held)])
536
+ var keys: int = 0
537
+ matching.reverse()
538
+ for index: int in matching:
539
+ keys += anim.track_get_key_count(index)
540
+ anim.remove_track(index)
541
+
542
+ var at: String = scene.path_of(player)
543
+ var left: int = anim.get_track_count()
544
+ var check: Callable = func(saved: Node) -> String:
545
+ var copy: AnimationPlayer = saved.get_node_or_null(at) as AnimationPlayer
546
+ if copy == null or not copy.has_animation(StringName(animation_name)):
547
+ return "%s would not be in the scene when it loads." % animation_name
548
+ if copy.get_animation(StringName(animation_name)).get_track_count() != left:
549
+ return "the track would still be in %s when the scene loads." % animation_name
550
+ return ""
551
+ var removed: Dictionary = {"type": track_type, "nodePath": node_path}
552
+ if not property.is_empty():
553
+ removed["property"] = property
554
+ var answer: Dictionary = {
555
+ "ok": true,
556
+ "animationName": animation_name,
557
+ "removed": removed,
558
+ "tracksRemoved": matching.size(),
559
+ "keysRemoved": keys,
560
+ "tracks": left,
561
+ }
562
+ var files: Dictionary = homes[1]
563
+ return _finish(scene, check, files, answer)
564
+
565
+
566
+ ## Track [param index] of [param anim] as [type, nodePath, property], named as read names them.
567
+ static func _track_shape(anim: Animation, index: int) -> Array:
568
+ var path: NodePath = anim.track_get_path(index)
569
+ return [
570
+ GdhAnimationReads.TRACK_TYPE_NAMES.get(anim.track_get_type(index), str(anim.track_get_type(index))),
571
+ str(path.get_concatenated_names()),
572
+ str(path.get_concatenated_subnames()),
573
+ ]
574
+
575
+
475
576
  ## The keys of [param anim], called [param animation_name], that lie past its end, where they never
476
577
  ## play, as a sentence naming each by its track's path and time, or "" when none does. The engine
477
578
  ## keeps such a key, as the editor does, so a longer length plays it again.
@@ -826,156 +927,3 @@ static func _collect_playing(
826
927
  for step: String in children:
827
928
  var child: AnimationNode = children[step]
828
929
  _collect_playing(child, step if at.is_empty() else at + "/" + step, tree_path, animation_name, found)
829
-
830
-
831
- # =============================================================================
832
- # add_animation_state
833
- # =============================================================================
834
- func add_animation_state(args: Dictionary) -> Dictionary:
835
- var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
836
- var anim_tree_path: String = str(args.get("animTreePath", ""))
837
- var state_name: String = str(args.get("stateName", ""))
838
- var animation_name: String = str(args.get("animationName", ""))
839
- var state_machine_path: String = str(args.get("stateMachinePath", ""))
840
-
841
- if scene_path.strip_edges() == "res://":
842
- return {"ok": false, "error": "Missing scenePath"}
843
- for required: Array in [
844
- [anim_tree_path, "animTreePath"], [state_name, "stateName"], [animation_name, "animationName"]
845
- ]:
846
- if str(required[0]).is_empty():
847
- return {"ok": false, "error": "Missing " + str(required[1])}
848
-
849
- var opened: Array = _open_at(scene_path, anim_tree_path, "AnimationTree")
850
- var scene: GdhSceneFile = opened[0]
851
- if opened[1] == null:
852
- return opened[2]
853
- var anim_tree: AnimationTree = opened[1]
854
- var machine: Array = _machine(scene, anim_tree, state_machine_path)
855
- if machine[0] == null:
856
- return scene.refuse(str(machine[2]))
857
- var sm: AnimationNodeStateMachine = machine[0]
858
- # add_node fails with nothing but a line in the editor's log on a name in use, and Start and End
859
- # are in use in every state machine.
860
- if sm.has_node(StringName(state_name)):
861
- return scene.refuse("The state machine already has a state named %s." % state_name)
862
- var player: AnimationPlayer = anim_tree.get_node_or_null(anim_tree.anim_player) as AnimationPlayer
863
- var found: bool = player != null and player.has_animation(StringName(animation_name))
864
-
865
- var anim_node: AnimationNodeAnimation = AnimationNodeAnimation.new()
866
- anim_node.animation = StringName(animation_name)
867
- # Beside the rightmost state: add_node puts a state at the origin, where the editor's graph
868
- # draws it over whatever is already there, Start included.
869
- var rightmost: Vector2 = Vector2.ZERO
870
- for state: StringName in sm.get_node_list():
871
- var held: Vector2 = sm.get_node_position(state)
872
- if held.x > rightmost.x:
873
- rightmost = held
874
- var position: Vector2 = rightmost + Vector2(STATE_SPACING, 0)
875
- sm.add_node(StringName(state_name), anim_node, position)
876
- if not sm.has_node(StringName(state_name)):
877
- return scene.refuse("The state machine did not take the state %s." % state_name)
878
-
879
- var answer: Dictionary = {
880
- "ok": true,
881
- "stateName": state_name,
882
- "animationName": animation_name,
883
- # False when the tree has no player to look the animation up in, or its player has none of
884
- # that name.
885
- "animationFound": found,
886
- "position": GdhSerialisation.new().serialize_value(position),
887
- }
888
- # Written all the same, as the editor writes one: a state machine is often laid out before its
889
- # animations are made, and refusing it stopped a tree being built in that order.
890
- if player != null and not found:
891
- answer["animationMissing"] = _missing_animation(scene, player, animation_name)
892
- var files: Dictionary = machine[1]
893
- return _finish(scene, Callable(), files, answer)
894
-
895
-
896
- ## Why the state playing [param animation_name] plays nothing yet. The player is named by its path
897
- ## from the scene root, as playerNodePath takes it; anim_player is relative to the tree ("../Anim"),
898
- ## which no argument takes.
899
- static func _missing_animation(
900
- scene: GdhSceneFile, player: AnimationPlayer, animation_name: String
901
- ) -> String:
902
- var said: String = (
903
- "The AnimationPlayer at %s has no animation %s yet, so the state plays nothing until it has one."
904
- % [str(scene.root.get_path_to(player)), animation_name]
905
- )
906
- var close: PackedStringArray = GdhRead.nearest(animation_name, player.get_animation_list())
907
- if close.is_empty():
908
- return said
909
- return (
910
- said + " The nearest %s %s." % ["it has is" if close.size() == 1 else "it has are", ", ".join(close)]
911
- )
912
-
913
-
914
- # =============================================================================
915
- # connect_animation_states
916
- # =============================================================================
917
- func connect_animation_states(args: Dictionary) -> Dictionary:
918
- var scene_path: String = GdhSceneFile.res_path("", str(args.get("scenePath", "")))
919
- var anim_tree_path: String = str(args.get("animTreePath", ""))
920
- var from_state: String = str(args.get("fromState", ""))
921
- var to_state: String = str(args.get("toState", ""))
922
- var transition_type: String = str(args.get("transitionType", "immediate"))
923
- var state_machine_path: String = str(args.get("stateMachinePath", ""))
924
- var advance_condition: String = str(args.get("advanceCondition", ""))
925
-
926
- if scene_path.strip_edges() == "res://":
927
- return {"ok": false, "error": "Missing scenePath"}
928
- if anim_tree_path.is_empty():
929
- return {"ok": false, "error": "Missing animTreePath"}
930
- if from_state.is_empty() or to_state.is_empty():
931
- return {"ok": false, "error": "Missing fromState or toState"}
932
- var modes: Dictionary = {
933
- "sync": AnimationNodeStateMachineTransition.SWITCH_MODE_SYNC,
934
- "at_end": AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END,
935
- "immediate": AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE,
936
- }
937
- if not modes.has(transition_type):
938
- return {"ok": false, "error": "Unsupported transitionType: " + transition_type}
939
- var transition: AnimationNodeStateMachineTransition = AnimationNodeStateMachineTransition.new()
940
- transition.switch_mode = (
941
- GdhRead.as_int(modes[transition_type]) as AnimationNodeStateMachineTransition.SwitchMode
942
- )
943
- if not advance_condition.is_empty():
944
- transition.advance_condition = StringName(advance_condition)
945
-
946
- var opened: Array = _open_at(scene_path, anim_tree_path, "AnimationTree")
947
- var scene: GdhSceneFile = opened[0]
948
- if opened[1] == null:
949
- return opened[2]
950
- var anim_tree: AnimationTree = opened[1]
951
- var machine: Array = _machine(scene, anim_tree, state_machine_path)
952
- if machine[0] == null:
953
- return scene.refuse(str(machine[2]))
954
- var sm: AnimationNodeStateMachine = machine[0]
955
- var problem: String = _transition_problem(sm, from_state, to_state)
956
- if not problem.is_empty():
957
- return scene.refuse(problem)
958
-
959
- sm.add_transition(StringName(from_state), StringName(to_state), transition)
960
- if not sm.has_transition(StringName(from_state), StringName(to_state)):
961
- return scene.refuse("The state machine did not take the transition.")
962
- var files: Dictionary = machine[1]
963
- return _finish(scene, Callable(), files, {"ok": true, "from": from_state, "to": to_state})
964
-
965
-
966
- ## Why [param sm] cannot take a transition from [param from_state] to [param to_state], or "".
967
- ## Each is a condition add_transition fails on with only a line in the editor's log, and the
968
- ## transition was answered as made.
969
- func _transition_problem(sm: AnimationNodeStateMachine, from_state: String, to_state: String) -> String:
970
- for state: String in [from_state, to_state]:
971
- if not sm.has_node(StringName(state)):
972
- return "The state machine has no state named %s." % state
973
- if from_state == to_state:
974
- return "A transition needs two different states."
975
- if from_state == "End":
976
- return "No transition can leave End."
977
- if to_state == "Start":
978
- return "No transition can arrive at Start."
979
- if sm.has_transition(StringName(from_state), StringName(to_state)):
980
- return "There is already a transition from %s to %s." % [from_state, to_state]
981
- return ""
@@ -9,6 +9,7 @@ const GdhPropertyValues = preload("../property_values.gd")
9
9
  const GdhRead = preload("../reading.gd")
10
10
  const GdhSame = preload("../same_values.gd")
11
11
  const GdhSceneFile = preload("../scene_file.gd")
12
+ const GdhSerialisation = preload("../serialisation.gd")
12
13
 
13
14
  # The minimal shader of a type: its declaration and the one function the engine calls for it.
14
15
  const SHADER_EMPTY: String = """shader_type %s;
@@ -25,6 +26,7 @@ const CELLS_SHOWN: int = 1000
25
26
 
26
27
  var _editor_plugin: EditorPlugin = null
27
28
  var _properties: GdhPropertyValues = GdhPropertyValues.new()
29
+ var _values: GdhSerialisation = GdhSerialisation.new()
28
30
 
29
31
 
30
32
  func set_editor_plugin(plugin: EditorPlugin) -> void:
@@ -257,6 +259,8 @@ func create_resource(args: Dictionary) -> Dictionary:
257
259
  var failed: String = _save_checked(resource, res_path, properties.keys(), null)
258
260
  if not failed.is_empty():
259
261
  return {"ok": false, "error": failed}
262
+ if not properties.is_empty():
263
+ answer["properties"] = _held(resource, properties.keys())
260
264
  return answer
261
265
 
262
266
 
@@ -307,9 +311,20 @@ func modify_resource(args: Dictionary) -> Dictionary:
307
311
  var failed: String = _save_checked(resource, res_path, properties.keys(), previous)
308
312
  if not failed.is_empty():
309
313
  return {"ok": false, "error": failed}
314
+ answer["properties"] = _held(resource, properties.keys())
310
315
  return answer
311
316
 
312
317
 
318
+ ## What [param resource] holds at each property or path in [param asked], once the save has been
319
+ ## checked against it: a write saying only that it was written left the caller a second call to
320
+ ## learn what the engine kept.
321
+ func _held(resource: Resource, asked: Array) -> Dictionary:
322
+ var held: Dictionary = {}
323
+ for key: Variant in asked:
324
+ held[str(key)] = _values.serialize_value(_properties.read_at(resource, str(key)))
325
+ return held
326
+
327
+
313
328
  ## What a resource file holds, read as a headless engine reads it with no editor connected.
314
329
  func read_resource(args: Dictionary) -> Dictionary:
315
330
  var res_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
@@ -649,7 +664,16 @@ func _tile_node(scene: GdhSceneFile, node_path: String, layer: int) -> Array:
649
664
  return [null, "%s has %d layers, and no layer %d" % [node_path, layered.get_layers_count(), layer]]
650
665
  var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
651
666
  if tile_set == null:
652
- return [null, "%s has no TileSet, so no cell can name a source" % node_path]
667
+ return [
668
+ null,
669
+ (
670
+ (
671
+ '%s has no TileSet, so no cell can name a source. scene_node set with properties {"tile_set": '
672
+ + '"res://..."} on %s gives it one, a TileSet file such as resource_edit create_tileset makes.'
673
+ )
674
+ % [node_path, node_path]
675
+ )
676
+ ]
653
677
  return [node, ""]
654
678
 
655
679
 
@@ -118,13 +118,16 @@ static func inherit(scene_path: String, base_path: String, root_name: String, sc
118
118
  "%s did not load back as a scene inheriting %s, so it was removed." % [scene_path, base_path]
119
119
  }
120
120
  GdhSceneFile.refresh(scene_path)
121
- return {
121
+ var answer: Dictionary = {
122
122
  "ok": true,
123
123
  "scenePath": scene_path,
124
124
  "inherits": base_path,
125
125
  "rootNodeType": root_type,
126
126
  "rootNodeName": name
127
127
  }
128
+ if script != null:
129
+ answer["script"] = script.resource_path
130
+ return answer
128
131
 
129
132
 
130
133
  ## Makes the children of [param node], an instance in [param scene], editable or not, answering why
@@ -171,6 +171,16 @@ static func place(node: Node, asked: Variant) -> void:
171
171
  node.get_parent().move_child(node, GdhRead.as_int(asked))
172
172
 
173
173
 
174
+ ## Whether [param basis] has axes that are not at right angles, which a node kept in place under a
175
+ ## parent scaled unevenly across its turn ends up with. A Node2D says so through skew; a Node3D has
176
+ ## no property for it, and its position, rotation and scale read as if the axes were square.
177
+ static func sheared(basis: Basis) -> bool:
178
+ var x: Vector3 = basis.x.normalized()
179
+ var y: Vector3 = basis.y.normalized()
180
+ var z: Vector3 = basis.z.normalized()
181
+ return absf(x.dot(y)) > 0.0001 or absf(x.dot(z)) > 0.0001 or absf(y.dot(z)) > 0.0001
182
+
183
+
174
184
  ## The properties that hold where [param node] is drawn, which a reparent keeping it in place
175
185
  ## changes: a Control keeps only its position, as Node.reparent keeps it.
176
186
  static func placing(node: Node) -> Array[String]:
@@ -204,6 +204,8 @@ func _create_of_a_class(args: Dictionary) -> Dictionary:
204
204
  var answer: Dictionary = {
205
205
  "ok": true, "scenePath": scene_path, "rootNodeType": asked_type, "rootNodeName": root_node_name
206
206
  }
207
+ if not script_path.is_empty():
208
+ answer["script"] = script_path
207
209
  if not had_folder:
208
210
  answer["madeFolder"] = scene_path.get_base_dir()
209
211
  return answer
@@ -299,6 +301,11 @@ func add_node(args: Dictionary) -> Dictionary:
299
301
  "nodePath": new_path,
300
302
  "index": new_node.get_index(),
301
303
  }
304
+ if not properties.is_empty():
305
+ var held: Dictionary = {}
306
+ for key: Variant in properties:
307
+ held[str(key)] = _readable(new_node, _properties.read_at(new_node, str(key)))
308
+ answer["properties"] = held
302
309
  _properties.note_write(answer, new_node)
303
310
  if not adding.is_empty():
304
311
  answer.merge(GdhGroups.read(scene, new_node))
@@ -503,6 +510,7 @@ func reparent_node(args: Dictionary) -> Dictionary:
503
510
  var drawn_at: Dictionary = {}
504
511
  for property: String in GdhPlacing.placing(node):
505
512
  drawn_at[property] = _properties.read_at(node, property)
513
+ var held_at: Transform3D = (node as Node3D).transform if node is Node3D else Transform3D.IDENTITY
506
514
  # Unowned while it moves: a node taken out of the tree keeps its owner, and adding it under the new
507
515
  # parent warned that the owner was not above it. Node.reparent warns the same way.
508
516
  node.owner = null
@@ -517,6 +525,20 @@ func reparent_node(args: Dictionary) -> Dictionary:
517
525
  var now: Variant = _properties.read_at(node, property)
518
526
  if not GdhSame.same(now, drawn_at[property]):
519
527
  also_changed[property] = {"was": _readable(node, drawn_at[property]), "now": _readable(node, now)}
528
+ var sheared: String = ""
529
+ if node is Node3D and GdhPlacing.sheared((node as Node3D).basis):
530
+ var spatial: Node3D = node
531
+ also_changed["transform"] = {
532
+ "was": _readable(node, held_at), "now": _readable(node, spatial.transform)
533
+ }
534
+ sheared = (
535
+ (
536
+ "%s keeps where it is drawn under %s, which is scaled unevenly across the way it is turned,"
537
+ + " so its basis is sheared: no position, rotation and scale describe it, and transform is"
538
+ + " what it holds. keepGlobalTransform false keeps its own transform instead."
539
+ )
540
+ % [str(node.name), new_parent_path]
541
+ )
520
542
 
521
543
  var new_path: String = scene.path_of(node)
522
544
  var index: int = node.get_index()
@@ -546,6 +568,8 @@ func reparent_node(args: Dictionary) -> Dictionary:
546
568
  }
547
569
  if not also_changed.is_empty():
548
570
  answer["alsoChanged"] = also_changed
571
+ if not sheared.is_empty():
572
+ answer["sheared"] = sheared
549
573
  if new_path != old_path:
550
574
  answer.merge(GdhKin.follow(scene_path, old_path, new_path, carried))
551
575
  return answer
@@ -571,9 +595,8 @@ func set_node_properties(args: Dictionary) -> Dictionary:
571
595
  var node: Node = GdhSceneFile.node_at(scene.root, node_path)
572
596
  if not node:
573
597
  return scene.refuse(scene.not_found("Node not found: " + node_path, node_path))
574
- var foreign: String = scene.foreign(node)
575
- if not foreign.is_empty():
576
- return scene.refuse(foreign)
598
+ # A rename is refused for the scene that makes the node before anything else, since editable
599
+ # children would let this scene keep values on it and still never rename it.
577
600
  if properties.has("name") and node != scene.root:
578
601
  var fixed: String = scene.fixed(node)
579
602
  if not fixed.is_empty():
@@ -581,6 +604,9 @@ func set_node_properties(args: Dictionary) -> Dictionary:
581
604
  var unusable: String = scene.unusable_name(node.get_parent(), str(properties["name"]), node)
582
605
  if not unusable.is_empty():
583
606
  return scene.refuse(unusable)
607
+ var foreign: String = scene.foreign(node)
608
+ if not foreign.is_empty():
609
+ return scene.refuse(foreign)
584
610
 
585
611
  var dropped: Dictionary = {}
586
612
  if editable != null:
@@ -607,6 +633,12 @@ func set_node_properties(args: Dictionary) -> Dictionary:
607
633
  _properties.note_write(answer, node)
608
634
  if not adding.is_empty() or not removing.is_empty():
609
635
  answer["groups"] = GdhGroups.read(scene, node).get("groups", [])
636
+ var named: Array[String] = []
637
+ for key: Variant in properties:
638
+ var property: String = str(key).get_slice(":", 0)
639
+ if property != "name" and property in node and not named.has(property):
640
+ named.append(property)
641
+ var source: String = scene.values_from(node, old_path)
610
642
  var carried: GdhPaths = GdhPaths.new()
611
643
  var _rewritten: int = carried.carry(scene, {old_path: new_path} if new_path != old_path else {})
612
644
  var written: Dictionary = scene.write(
@@ -630,11 +662,48 @@ func set_node_properties(args: Dictionary) -> Dictionary:
630
662
  if not dropped.is_empty():
631
663
  answer["droppedValues"] = dropped["values"]
632
664
  answer["droppedNodes"] = dropped["nodes"]
665
+ _note_left_out(answer, scene.written_pack, new_path, named, source)
633
666
  if new_path != old_path:
634
667
  answer.merge(GdhKin.follow(scene_path, old_path, new_path, carried))
635
668
  return answer
636
669
 
637
670
 
671
+ ## Notes under notStored each of [param named] the file saved from [param pack] does not set on the
672
+ ## node at [param at]: a set gave it the value the node has without it, so the packer leaves it out,
673
+ ## as the editor does, and it follows [param source], or its default, from then on.
674
+ static func _note_left_out(
675
+ answer: Dictionary, pack: PackedScene, at: String, named: Array[String], source: String
676
+ ) -> void:
677
+ var stored: Dictionary = {}
678
+ var state: SceneState = pack.get_state()
679
+ for index: int in state.get_node_count():
680
+ if str(state.get_node_path(index)).trim_prefix("./") == at:
681
+ for property: int in state.get_node_property_count(index):
682
+ stored[str(state.get_node_property_name(index, property))] = true
683
+ var left: Array[String] = []
684
+ for asked: String in named:
685
+ if not stored.has(asked):
686
+ left.append(asked)
687
+ if left.is_empty():
688
+ return
689
+ answer["notStored"] = left
690
+ var names: String = ", ".join(left)
691
+ var verb: String = "holds" if left.size() == 1 else "hold"
692
+ var pronoun: String = "it" if left.size() == 1 else "them"
693
+ if source.is_empty():
694
+ answer["notStoredNote"] = (
695
+ "%s now %s the default, so the file does not store %s." % [names, verb, pronoun]
696
+ )
697
+ else:
698
+ answer["notStoredNote"] = (
699
+ (
700
+ "%s now %s what %s gives this node, so the file does not store %s, and a change there reaches "
701
+ + "this node."
702
+ )
703
+ % [names, verb, source, pronoun]
704
+ )
705
+
706
+
638
707
  func get_node_properties(args: Dictionary) -> Dictionary:
639
708
  var project_path: String = str(args.get("projectPath", ""))
640
709
  var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
@@ -265,8 +265,14 @@ func disconnect_signal(args: Dictionary) -> Dictionary:
265
265
  # A connection that was never there saved the scene unchanged and answered as removed, so a
266
266
  # typo in either name read as success.
267
267
  if _connection_flags(scene.root, from, signal_name, to, method_name) < 0:
268
+ # The root's path is ".", which before ".method" read as "..method".
269
+ var said_from: String = str(source.name) if source == scene.root else from
270
+ var said_to: String = str(target.name) if target == scene.root else to
268
271
  return scene.refuse(
269
- "%s has no connection from %s.%s to %s.%s." % [scene_path, from, signal_name, to, method_name]
272
+ (
273
+ "%s has no connection from %s.%s to %s.%s."
274
+ % [scene_path, said_from, signal_name, said_to, method_name]
275
+ )
270
276
  )
271
277
  source.disconnect(signal_name, Callable(target, method_name))
272
278
 
@@ -16,6 +16,10 @@ var refused: String = ""
16
16
  var raised: Array[String] = []
17
17
  ## The warnings reported while it ran, a line each.
18
18
  var warned: Array[String] = []
19
+ ## Where a script error first ended each function it ended, as "res://path:line" by function name.
20
+ ## A function the VM ends at a script error gives back its return type's default, 0, "" or null,
21
+ ## and its caller goes on with that as if it had been returned.
22
+ var ended_at: Dictionary = {}
19
23
 
20
24
 
21
25
  func _log_message(_message: String, _error: bool) -> void:
@@ -26,8 +30,8 @@ func _log_message(_message: String, _error: bool) -> void:
26
30
  # gdlint:ignore = function-arguments-number
27
31
  func _log_error(
28
32
  function: String,
29
- _file: String,
30
- _line: int,
33
+ file: String,
34
+ line: int,
31
35
  code: String,
32
36
  rationale: String,
33
37
  _editor_notify: bool,
@@ -45,3 +49,5 @@ func _log_error(
45
49
  warned.append(text)
46
50
  else:
47
51
  raised.append(text)
52
+ if error_type == ERROR_TYPE_SCRIPT and not ended_at.has(function):
53
+ ended_at[function] = "%s:%d" % [file, line]
@@ -24,6 +24,23 @@ extends RefCounted
24
24
 
25
25
  ## How many of a node's children a refusal for a path missing below it names.
26
26
  const NAMED_CHILDREN: int = 12
27
+ ## The types holding their floats as the engine's real numbers, 32-bit unless it was built for 64.
28
+ const SHAPES_OF_REALS: Array[int] = [
29
+ TYPE_VECTOR2,
30
+ TYPE_VECTOR3,
31
+ TYPE_VECTOR4,
32
+ TYPE_RECT2,
33
+ TYPE_PLANE,
34
+ TYPE_QUATERNION,
35
+ TYPE_AABB,
36
+ TYPE_BASIS,
37
+ TYPE_TRANSFORM2D,
38
+ TYPE_TRANSFORM3D,
39
+ TYPE_PROJECTION,
40
+ TYPE_PACKED_VECTOR2_ARRAY,
41
+ TYPE_PACKED_VECTOR3_ARRAY,
42
+ TYPE_PACKED_VECTOR4_ARRAY,
43
+ ]
27
44
 
28
45
 
29
46
  static func as_int(value: Variant, fallback: int = 0) -> int:
@@ -190,18 +207,26 @@ static func offered(said: String, word: String, names: Array) -> String:
190
207
 
191
208
 
192
209
  ## [param said], the refusal for [param property], which [param target] does not declare, with the
193
- ## names it declares nearest it when any are, as a misspelt one is usually one of them. Unlike
194
- ## [method offered], nothing is listed when none is near, since an object declares a hundred.
210
+ ## names it declares nearest it when any are, as a misspelt one is usually one of them, or else the
211
+ ## variables its script declares. The engine's own are not listed when none is near, since an object
212
+ ## has a hundred, while a script's are the few a caller most likely meant.
195
213
  static func near_property(said: String, target: Object, property: String) -> String:
196
214
  var headings: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
197
215
  var names: Array[String] = []
216
+ var scripted: Array[String] = []
198
217
  for declared: Dictionary in target.get_property_list():
199
- if not as_int(declared.get("usage", 0)) & headings:
218
+ var usage: int = as_int(declared.get("usage", 0))
219
+ if not usage & headings:
200
220
  names.append(str(declared.get("name", "")))
221
+ if usage & PROPERTY_USAGE_SCRIPT_VARIABLE and not usage & headings:
222
+ scripted.append(str(declared.get("name", "")))
201
223
  var close: PackedStringArray = nearest(property, PackedStringArray(names))
202
- if close.is_empty():
224
+ if not close.is_empty():
225
+ return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
226
+ if scripted.is_empty():
203
227
  return said
204
- return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
228
+ scripted.sort()
229
+ return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(scripted))]
205
230
 
206
231
 
207
232
  ## The signals [param object] has that a caller can connect, its own class's first: the engine
@@ -219,3 +244,34 @@ static func signals_of(object: Object) -> Array:
219
244
  ## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
220
245
  static func with_article(word: String) -> String:
221
246
  return ("an " if word.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + word
247
+
248
+
249
+ ## [param value], which the engine holds as a 32-bit float, as the shortest decimal that is still
250
+ ## that float. JSON carries a double, and the double a 32-bit 0.2 widens to is 0.200000002980232:
251
+ ## the same number, but not the one written or the one the file shows, so a caller comparing what it
252
+ ## set against what it read saw them differ. The shorter one, sent back, stores the same 32 bits. A
253
+ ## plain float is left alone by the serialiser, since nothing says whether the engine holds it in
254
+ ## 32 bits or 64.
255
+ static func single(value: float) -> float:
256
+ if not is_finite(value) or value == 0.0:
257
+ return value
258
+ var held: float = PackedFloat32Array([value])[0]
259
+ var magnitude: int = floori(log(absf(held)) / log(10.0))
260
+ for digits: int in range(1, 10):
261
+ var shift: int = digits - 1 - magnitude
262
+ var tried: float = (
263
+ roundf(held * pow(10.0, shift)) / pow(10.0, shift)
264
+ if shift >= 0
265
+ else roundf(held / pow(10.0, -shift)) * pow(10.0, -shift)
266
+ )
267
+ if PackedFloat32Array([tried])[0] == held:
268
+ return tried
269
+ return held
270
+
271
+
272
+ ## Whether a value of [param type] holds its floats in 32 bits: a colour always, and the vectors and
273
+ ## the shapes built of them unless the engine was built for 64.
274
+ static func holds_singles(type: int) -> bool:
275
+ if type in [TYPE_COLOR, TYPE_PACKED_COLOR_ARRAY, TYPE_PACKED_FLOAT32_ARRAY]:
276
+ return true
277
+ return not OS.has_feature("double") and type in SHAPES_OF_REALS