@tool extends "res://addons/godot_ai/handlers/command_handler.gd" const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") const MeshWorkload := preload("res://addons/godot_ai/utils/mesh_workload.gd") const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") ## Bounded 3D mesh-only baking and explicit-map path queries in 2D/3D. const _CLASSES := { "2d": { "region": "NavigationRegion2D", "mesh": "NavigationPolygon", }, "3d": { "region": "NavigationRegion3D", "mesh": "NavigationMesh", }, } ## One deferred bake may spend this long across editor frames. The Python ## handler's timeout is this plus its transport margin (a source-shape test ## keeps the two together). const _BAKE_DEFERRED_TIMEOUT_MS := 30000 const _SOURCE_MAX_NODES := 256 const _SOURCE_MAX_GRID_CELLS := 1000000 ## In-flight bakes keyed by region instance id. `bake()` reserves its region ## before the first yield so a second call in the same frame cannot start a ## second bake on a region Godot is already baking (the engine logs ## "NavigationMesh is already baking" and the two jobs split the result). ## Every terminal path releases the reservation. static var _active_bakes: Dictionary = {} var _undo_redo: EditorUndoRedoManager var _connection func _init(undo_redo: EditorUndoRedoManager, connection = null) -> void: _undo_redo = undo_redo _connection = connection # ============================================================================ # navigation_bake # ============================================================================ ## Bake a 3D region's navigation mesh and commit a scene-anchored swap ## between the retained pre-bake and baked resources. ## ## Source collection visits one bounded mesh/container per frame, without the ## general scene parser or custom parser callbacks. Only the engine bake runs ## on a background thread. Undo/redo retain the exact before/after resources. func bake(params: Dictionary) -> Dictionary: var resolved := _resolve_region(params) if resolved.has("error"): return resolved var region: Node = resolved.node var dimension: String = resolved.dimension var before: Resource = _get_region_mesh(region, dimension) if before == null: return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "%s has no %s resource" % [resolved.path, _CLASSES[dimension].mesh]) var source_error := _validate_bake_source(dimension, before) if not source_error.is_empty(): return source_error if _active_bakes.has(region.get_instance_id()) or bool(region.call("is_baking")) or NavigationServer3D.is_baking_navigation_mesh(before): return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "%s is already baking a navigation mesh" % resolved.path) var request_id: String = params.get("_request_id", "") if _connection == null or request_id.is_empty(): ## The bake is threaded and answered out-of-band, so a direct caller ## (batch_execute, unit tests) cannot wait for it without blocking the ## editor's frame budget. Refuse instead of silently baking in place. return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "navigation_bake is deferred (threaded bake, bounded deadline); call " + "navigation_manage(op='bake') directly - batch_execute cannot await it") var force_sync := bool(params.get("force_sync", true)) var prepared := _begin_bake(region, dimension) if prepared.is_empty(): return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to prepare the %s bake" % _CLASSES[dimension].mesh) var job := _bake_job( region, dimension, resolved.scene_root, prepared.before, prepared.working, _undo_redo, _connection, request_id, force_sync ) ## Reserve the region before the driver yields: the region's own ## native bake has not started during source collection, so checking only ## the engine bake state would admit a second request for the same region. _active_bakes[region.get_instance_id()] = request_id ## No step here: the dispatcher registers the deferred request only after ## this handler returns the sentinel, and `_bake_step`'s first pending ## check would otherwise see the request as expired and abandon the job. ## `_drive_bake_job` performs the first step after its registration yield. _drive_bake_job(job) return { "_deferred": true, "_deferred_timeout_ms": _BAKE_DEFERRED_TIMEOUT_MS, } # ============================================================================ # navigation_path_get # ============================================================================ ## Query a path on an explicitly selected navigation map. Read-only: it never ## guesses a region and never changes the shared async-iteration policy unless ## `force_sync` asks it to. func path_get(params: Dictionary) -> Dictionary: var dimension_result := _resolve_dimension(params) if dimension_result.has("error"): return dimension_result var dimension: String = dimension_result.dimension var from := _coerce_vector(params.get("from_point", null), dimension, "from_point") if from.has("error"): return from var to := _coerce_vector(params.get("to_point", null), dimension, "to_point") if to.has("error"): return to var optimize := bool(params.get("optimize", true)) var navigation_layers := int(params.get("navigation_layers", 1)) var force_sync := bool(params.get("force_sync", false)) var region_path := String(params.get("region_path", "")) var scene_root := EditorInterface.get_edited_scene_root() if scene_root == null: return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No edited scene open") var map_result := _resolve_query_map( scene_root, dimension, region_path, params.get("scene_file", "") ) if map_result.has("error"): return map_result var points: Array = [] if dimension == "3d": if force_sync: _force_map_sync_3d(map_result.map) for point in NavigationServer3D.map_get_path(map_result.map, from.ok, to.ok, optimize, navigation_layers): points.append(VariantSerializer.serialize(point)) else: if force_sync: _force_map_sync_2d(map_result.map) for point in NavigationServer2D.map_get_path(map_result.map, from.ok, to.ok, optimize, navigation_layers): points.append(VariantSerializer.serialize(point)) return { "data": { "dimension": dimension, "from_point": VariantSerializer.serialize(from.ok), "to_point": VariantSerializer.serialize(to.ok), "optimize": optimize, "navigation_layers": navigation_layers, "force_sync": force_sync, "region_path": map_result.region_path, "map_source": map_result.map_source, "point_count": points.size(), "points": points, } } # ============================================================================ # Helpers — deferred bake # ============================================================================ ## Snapshot the pre-bake resource and install the working duplicate the ## threaded bake writes into. Returns `{}` when there is no mesh to bake. ## Baking in place would mutate the resource the undo action restores, so a ## second undo after a redo could no longer reach the pre-bake state. static func _begin_bake(region: Node, dimension: String) -> Dictionary: var before: Resource = _get_region_mesh(region, dimension) if before == null: return {} var working: Resource = before.duplicate() if dimension == "3d": region.call("set_navigation_mesh", working) else: region.call("set_navigation_polygon", working) return {"before": before, "working": working} ## Request state retained by the static frame driver through collection/baking. static func _bake_job( region: Node, dimension: String, scene_root: Node, before: Resource, working: Resource, undo_redo: EditorUndoRedoManager, connection, request_id: String, force_sync: bool, ) -> Dictionary: return { "region": region, "region_id": region.get_instance_id(), "dimension": dimension, "scene_root": scene_root, "before": before, "working": working, "undo_redo": undo_redo, "connection": connection, "request_id": request_id, "force_sync": force_sync, "phase": "start", "started_ms": Time.get_ticks_msec(), "deadline_ms": _BAKE_DEFERRED_TIMEOUT_MS, "parse_ms": 0, "source_data": NavigationMeshSourceGeometryData3D.new(), "pending": [region], "sources": [], "meshes": [], "source_changed": false, "triangles": 0, "vertices": 0, "bounds": AABB(), "has_bounds": false, "result": {}, } ## Drop this job's reservation so the region can bake again. Every terminal ## path (commit, abort, abandonment, or a driver that never starts) calls it. static func _release_bake(job: Dictionary) -> void: for mesh in job.meshes: var callback := _source_changed.bind(job) if mesh.changed.is_connected(callback): mesh.changed.disconnect(callback) job.meshes.clear() _active_bakes.erase(int(job.region_id)) ## Advance a bake job by one editor-frame check. Returns true once the job is ## resolved (committed, aborted, or abandoned); `job.result` holds the reply, ## or stays empty when the request was abandoned and nothing may be answered. static func _bake_step(job: Dictionary) -> bool: if str(job.phase) == "done": return true ## Read the region untyped first: a typed `Node` assignment on a freed ## instance raises "Trying to assign invalid previously freed instance" ## before any validity check could run. var region_ref = job.region if not is_instance_valid(region_ref) or not (region_ref as Node).is_inside_tree(): _bake_abort(job, ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "The navigation region went away while it was baking")) return true var region: Node = region_ref var connection = job.connection if connection != null and not _deferred_request_pending(connection, str(job.request_id)): ## The dispatcher gave up on this request (timeout, client gone): ## restore the pre-bake resource and answer nothing. _bake_restore(job) _release_bake(job) job.phase = "done" return true if Time.get_ticks_msec() - int(job.started_ms) > int(job.deadline_ms): _bake_abort(job, ErrorCodes.make(ErrorCodes.DEFERRED_TIMEOUT, "navigation_bake exceeded its %d ms budget" % int(job.deadline_ms))) return true if EditorInterface.get_edited_scene_root() != job.scene_root: _bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "The edited scene changed while the navigation mesh was baking")) return true if _get_region_mesh(region, str(job.dimension)) != job.working: _bake_abort(job, ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "The region's navigation resource was replaced while the bake was in flight; the bake result was discarded")) return true if bool(job.source_changed): _bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "A source mesh changed during navigation baking; retry with stable source geometry")) return true if str(job.phase) == "start" or str(job.phase) == "collect": var source_error := _validate_bake_source(str(job.dimension), job.working) if not source_error.is_empty(): _bake_abort(job, source_error) return true job.phase = "collect" var started := Time.get_ticks_usec() var collected := _collect_source_step(job) job.parse_ms = float(job.parse_ms) + (Time.get_ticks_usec() - started) / 1000.0 if not collected.is_empty(): _bake_abort(job, collected) return true if not job.pending.is_empty(): return false if not job.source_data.has_data(): _bake_abort(job, ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "No supported mesh triangles found below the navigation region")) return true if not _sources_match(job): _bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Navigation source nodes changed while geometry was collected")) return true var final_grid_error := _grid_error(job.bounds, job.working) if not final_grid_error.is_empty(): _bake_abort(job, final_grid_error) return true NavigationServer3D.bake_from_source_geometry_data_async(job.working, job.source_data) job.phase = "baking" return false if NavigationServer3D.is_baking_navigation_mesh(job.working): return false if not _sources_match(job): _bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Navigation source nodes changed while baking")) return true _bake_commit(job) return true static func _validate_bake_source(dimension: String, mesh: Resource) -> Dictionary: if dimension != "3d": return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "navigation_bake supports bounded 3D mesh-only sources; 2D baking is not supported (2D path queries remain available)") if mesh.geometry_source_geometry_mode != NavigationMesh.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN or mesh.geometry_parsed_geometry_type == NavigationMesh.PARSED_GEOMETRY_STATIC_COLLIDERS: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "navigation_bake requires root-children mesh-instance or both source settings; collider-only and group sources are not supported") return {} static func _source_changed(job: Dictionary) -> void: job.source_changed = true static func _sources_match(job: Dictionary) -> bool: if bool(job.source_changed): return false for source in job.sources: var node = source.node if not is_instance_valid(node) or not node.is_inside_tree() or node.get_parent() != source.parent or node.get_child_count() != int(source.children): return false if node is Node3D and node.global_transform != source.transform: return false if node is MeshInstance3D and node.mesh != source.mesh: return false return true static func _grid_error(bounds: AABB, mesh: NavigationMesh) -> Dictionary: if not is_finite(mesh.cell_size) or not is_finite(mesh.cell_height) or mesh.cell_size <= 0.0 or mesh.cell_height <= 0.0: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation cell size and height must be finite and positive") var size := bounds.size var filter_size := mesh.filter_baking_aabb.size size = Vector3(maxf(size.x, filter_size.x), maxf(size.y, filter_size.y), maxf(size.z, filter_size.z)) var cells := Vector3(size.x / mesh.cell_size, size.y / mesh.cell_height, size.z / mesh.cell_size) var count := 1 for extent in [cells.x, cells.y, cells.z]: if not is_finite(extent) or extent > _SOURCE_MAX_GRID_CELLS: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source extent/cell size exceeds the bounded voxel grid; reduce the extent or increase cell size") count *= maxi(1, int(ceil(extent))) if count > _SOURCE_MAX_GRID_CELLS: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source exceeds the %d-cell voxel grid limit; reduce the extent or increase cell size" % _SOURCE_MAX_GRID_CELLS) return {} static func _collect_source_step(job: Dictionary) -> Dictionary: var node = job.pending.pop_back() if not is_instance_valid(node) or not node.is_inside_tree(): return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "A navigation source node was removed during collection") if node != job.region and (node.get_script() != null or node.get_class() not in ["Node", "Node3D", "MeshInstance3D"]): return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Unsupported navigation source %s (%s): mesh-only baking accepts unscripted Node/Node3D containers and MeshInstance3D; colliders, CSG, GridMap, obstacles and custom parser sources are not supported" % [node.name, node.get_class()]) var children: int = node.get_child_count() if job.sources.size() + job.pending.size() + children + 1 > _SOURCE_MAX_NODES: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source traversal exceeds %d nodes" % _SOURCE_MAX_NODES) var source := {"node": node, "parent": node.get_parent(), "children": children} if node is Node3D: source.transform = node.global_transform for index in children: job.pending.append(node.get_child(index)) if node is MeshInstance3D: var mesh: Mesh = node.mesh source.mesh = mesh if mesh != null: var counts := MeshWorkload.estimate(mesh) if counts.has("error"): return counts if int(counts.triangles) == 0: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source %s has no supported triangles" % node.name) job.triangles = int(job.triangles) + int(counts.triangles) job.vertices = int(job.vertices) + int(counts.vertices) if int(job.triangles) > MeshWorkload.MAX_TRIANGLES or int(job.vertices) > MeshWorkload.MAX_VERTICES: return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation sources exceed the aggregate %d-triangle/%d-vertex limit" % [MeshWorkload.MAX_TRIANGLES, MeshWorkload.MAX_VERTICES]) var root_transform: Transform3D = job.region.global_transform if not root_transform.is_finite() or is_zero_approx(root_transform.basis.determinant()): return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation region transform must be finite and invertible") var relative: Transform3D = root_transform.affine_inverse() * node.global_transform var bounds: AABB = relative * mesh.get_aabb() if not relative.is_finite() or not bounds.is_finite(): return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation mesh transform/bounds must be finite") job.bounds = job.bounds.merge(bounds) if bool(job.has_bounds) else bounds job.has_bounds = true var grid_error := _grid_error(job.bounds, job.working) if not grid_error.is_empty(): return grid_error if not job.meshes.has(mesh): mesh.changed.connect(_source_changed.bind(job)) job.meshes.append(mesh) job.source_data.add_mesh(mesh, relative) job.sources.append(source) return {} ## Push the baked resource to the server region and commit one scene-anchored ## swap action. Every do/undo target is the region node, so the action lands in ## the edited scene's history — a RefCounted handler target would select the ## global history and trip the editor's history-mismatch check. static func _bake_commit(job: Dictionary) -> void: var region: Node = job.region var dimension: String = job.dimension var working: Resource = job.working var rid: RID = region.call("get_rid") if rid.is_valid(): if dimension == "3d": NavigationServer3D.region_set_navigation_mesh(rid, working) else: NavigationServer2D.region_set_navigation_polygon(rid, working) if bool(job.force_sync): var map: RID = region.call("get_navigation_map") if dimension == "3d": _force_map_sync_3d(map) else: _force_map_sync_2d(map) var undo_redo: EditorUndoRedoManager = job.undo_redo undo_redo.create_action("MCP: Bake navigation on %s" % region.name) if dimension == "3d": undo_redo.add_do_method(region, "set_navigation_mesh", working) undo_redo.add_undo_method(region, "set_navigation_mesh", job.before) else: undo_redo.add_do_method(region, "set_navigation_polygon", working) undo_redo.add_undo_method(region, "set_navigation_polygon", job.before) ## Both retained resources stay referenced by the action: the scene holds ## only one of them at a time, the other must survive for undo/redo. undo_redo.add_do_reference(working) undo_redo.add_undo_reference(job.before) ## The region already holds `working`, so record without re-running do. undo_redo.commit_action(false) var polygon_count := 0 var vertex_count := 0 if working != null: polygon_count = int(working.call("get_polygon_count")) vertex_count = int(working.call("get_vertices").size()) job.result = { "data": { "path": McpScenePath.from_node(region, job.scene_root), "mesh_class": working.get_class() if working != null else "", "polygon_count": polygon_count, "vertex_count": vertex_count, "force_sync": bool(job.force_sync), "bake_settle": "settled", "parse_ms": float(job.parse_ms), "source_mode": "mesh_only", "source_triangles": int(job.triangles), "undoable": true, } } _release_bake(job) job.phase = "done" static func _bake_abort(job: Dictionary, error: Dictionary) -> void: _bake_restore(job) _release_bake(job) job.result = error job.phase = "done" ## Put the pre-bake resource back after an aborted bake. The region may have ## been freed meanwhile, so it is validity-checked before the typed assignment; ## and the pre-bake resource is only restored while the region still holds this ## job's working duplicate — a newer assignment must not be clobbered. static func _bake_restore(job: Dictionary) -> void: var region_ref = job.region if not is_instance_valid(region_ref): return var region: Node = region_ref if _get_region_mesh(region, str(job.dimension)) != job.working: return if str(job.dimension) == "3d": region.call("set_navigation_mesh", job.before) else: region.call("set_navigation_polygon", job.before) ## Drive a bake job one editor frame at a time and reply when it ends. ## `static` is load-bearing: the coroutine must outlive this RefCounted ## handler, which can be freed mid-await by an editor_reload_plugin. static func _drive_bake_job(job: Dictionary) -> void: var connection = job.connection if not is_instance_valid(connection): ## `_begin_bake` already replaced the region's resource with the working ## duplicate and no undo action owns it yet, so a driver that cannot run ## must put the pre-bake resource back (ownership-guarded) before ## releasing the region for another bake. _bake_restore(job) _release_bake(job) return var tree: SceneTree = connection.get_tree() if tree == null: _bake_restore(job) _release_bake(job) return var work := ScriptWork.begin("navigation_bake") ## The first yield lets the dispatcher register the deferred request before ## any result or abort can be answered. await tree.process_frame while not _bake_step(job): await tree.process_frame ScriptWork.finish(work) if is_instance_valid(connection) and not job.result.is_empty(): connection.send_deferred_response(str(job.request_id), job.result) ## Check that the connection and deferred dispatcher entry are both live. static func _deferred_request_pending(connection, request_id: String) -> bool: if not is_instance_valid(connection): return false var dispatcher = connection.dispatcher return dispatcher == null or dispatcher.has_pending_deferred_response(request_id) ## Sync a 3D map immediately. Godot 4.7's navigation server iterates maps ## asynchronously by default, and `map_force_update` is documented as ## unsupported in that mode — turn async iterations off for the forced sync, ## then restore the previous setting. Only ever called when the caller asked ## for it (`force_sync`), never silently from a read. static func _force_map_sync_3d(map: RID) -> void: if not map.is_valid(): return var was_async := NavigationServer3D.map_get_use_async_iterations(map) if was_async: NavigationServer3D.map_set_use_async_iterations(map, false) NavigationServer3D.map_force_update(map) if was_async: NavigationServer3D.map_set_use_async_iterations(map, true) static func _force_map_sync_2d(map: RID) -> void: if not map.is_valid(): return var was_async := NavigationServer2D.map_get_use_async_iterations(map) if was_async: NavigationServer2D.map_set_use_async_iterations(map, false) NavigationServer2D.map_force_update(map) if was_async: NavigationServer2D.map_set_use_async_iterations(map, true) # ============================================================================ # Helpers — resolution # ============================================================================ static func _resolve_dimension(params: Dictionary) -> Dictionary: var dimension: String = params.get("dimension", "3d") if dimension != "2d" and dimension != "3d": return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid dimension '%s'. Valid: 2d, 3d" % dimension) return {"dimension": dimension} ## Resolve `path` to a navigation region and infer its dimension from the ## class. Success shape: `{node, dimension, path, scene_root}`. func _resolve_region(params: Dictionary) -> Dictionary: var resolved := McpNodeValidator.resolve_or_error( params.get("path", ""), "path", params.get("scene_file", "") ) if resolved.has("error"): return resolved var node: Node = resolved.node var dimension := "" if node is NavigationRegion2D: dimension = "2d" elif node is NavigationRegion3D: dimension = "3d" else: return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Node at %s is %s — expected a navigation region" % [resolved.path, node.get_class()]) return { "node": node, "dimension": dimension, "path": McpScenePath.from_node(node, resolved.scene_root), "scene_root": resolved.scene_root, } ## The map a path query runs against: the explicitly named region's map, or ## the edited scene root's world map when no region is given. Never guesses ## the scene's "first" region — a scene can host several maps. static func _resolve_query_map( scene_root: Node, dimension: String, region_path: String, scene_file: String ) -> Dictionary: if not region_path.is_empty(): var resolved := McpNodeValidator.resolve_or_error(region_path, "region_path", scene_file) if resolved.has("error"): return resolved var node: Node = resolved.node var expected: String = _CLASSES[dimension].region if not _is_a_class(node, expected): return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Node at %s is %s — expected a %s for a %s query" % [resolved.path, node.get_class(), expected, dimension.to_upper()]) var region_map: RID = node.call("get_navigation_map") if not region_map.is_valid(): return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "%s has no valid navigation map" % resolved.path) return {"map": region_map, "map_source": "region", "region_path": resolved.path} var world_method := "get_world_3d" if dimension == "3d" else "get_world_2d" if scene_root.has_method(world_method): var world_map: RID = scene_root.call(world_method).navigation_map if world_map.is_valid(): return {"map": world_map, "map_source": "world", "region_path": ""} return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "No %s navigation map found in the edited scene" % dimension.to_upper()) ## Exact class or subclass check. `ClassDB.is_parent_class` is strict, so an ## exact-class node would otherwise be rejected. static func _is_a_class(node: Node, expected: String) -> bool: return node.get_class() == expected or ClassDB.is_parent_class(node.get_class(), expected) static func _get_region_mesh(region: Node, dimension: String) -> Resource: if dimension == "3d": return region.get("navigation_mesh") return region.get("navigation_polygon") # ============================================================================ # Helpers — path points # ============================================================================ ## Parse a world point for path queries from {x,y[,z]} / [x,y[,z]]. static func _coerce_vector(raw: Variant, dimension: String, param_name: String) -> Dictionary: var parsed: Variant = null if dimension == "3d": parsed = McpJsonValues.parse_vector3(raw) else: parsed = McpJsonValues.parse_vector2(raw) if parsed == null: return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "'%s' must be a %s point ({x,y%s} or [x,y%s])" % [ param_name, dimension.to_upper(), ",z" if dimension == "3d" else "", ",z" if dimension == "3d" else "" ]) return {"ok": parsed}