class_name PropFile extends RefCounted ## PropFile - Static schema + converters for .prop vector prop assets. ## ## The file stores the editor's full multi-shape stack (version "1.0") plus ## enough provenance for later sandbox placement. flatten_to_payload() converts ## the stack into the single-shape payload PropUtils.spawn_prop() already ## consumes: frontmost closed polygon -> else circle -> else first shape. const PropBlockScript := preload("res://scripts/prop_block.gd") const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd") const FILE_VERSION: String = "1.0" const FILE_EXT: String = ".prop" const DEFAULT_DIR: String = "res://props" const SUPPORTED_VERSIONS: Array[String] = ["1.0"] # index == PropBlock.MaterialPreset const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"] const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"] const DEFAULT_RADIUS: float = 24.0 const DEFAULT_CIRCLE_SEGMENTS: int = 12 static func preset_to_string(preset: int) -> String: return PRESET_STRINGS[clampi(preset, 0, PRESET_STRINGS.size() - 1)] static func preset_from_string(s: String) -> int: var idx: int = PRESET_STRINGS.find(s.to_lower()) if idx < 0: return PropBlockScript.MaterialPreset.NONE return idx static func shape_type_to_payload_type(s: String) -> int: if s.to_lower() == "circle": return PropBlockScript.ShapeType.CIRCLE return PropBlockScript.ShapeType.POLYGON ## Sanitize a shape dictionary: polygon/line points run through ## TerrainUtils.sanitize_points (grid snap, redundancy/collinear removal, ## clockwise enforcement). Circles pass through unchanged (radius-based). static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScript.DEFAULT_GRID_SIZE) -> Dictionary: var result: Dictionary = shape.duplicate(true) var shape_type: String = str(result.get("shape_type", "polygon")) if shape_type == "circle": return result var pts: PackedVector2Array = _coerce_points(result.get("points", PackedVector2Array())) var cleaned: PackedVector2Array = TerrainUtilsScript.sanitize_points(pts, grid_size) var flags: PackedInt32Array = result.get("vertex_flags", PackedInt32Array()) var kept_flags := PackedInt32Array() # Keep vertex flags aligned: sanitize only ever drops points, so walk the # cleaned list and retain a flag for each surviving vertex (match by index # order; approximate alignment is acceptable for write-only metadata). kept_flags.resize(cleaned.size()) for i: int in mini(cleaned.size(), flags.size()): kept_flags[i] = flags[i] result["points"] = cleaned result["vertex_flags"] = kept_flags return result static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary: var serialized: Array = [] for shape: Dictionary in shapes: serialized.append(_serialize_shape(shape)) var now: String = Time.get_datetime_string_from_system() return { "version": FILE_VERSION, "prop_name": prop_name, "material_preset": preset_to_string(material_preset), "shapes": serialized, "metadata": {"created_at": now, "modified_at": now}, } ## Parse a file-level dictionary (or JSON string) into the editor model. ## Returns {} on any schema error (with a push_warning), otherwise ## { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] }. static func from_file_dict(data: Variant) -> Dictionary: if data is String: var parsed: Variant = JSON.parse_string(data as String) if not parsed is Dictionary: push_warning("PropFile: file data is not a valid JSON object.") return {} data = parsed if not data is Dictionary: push_warning("PropFile: file data is not a dictionary.") return {} var dict: Dictionary = data as Dictionary var version: String = str(dict.get("version", "")) if not SUPPORTED_VERSIONS.has(version): push_warning("PropFile: unsupported file version '%s'." % version) return {} var preset: int = preset_from_string(str(dict.get("material_preset", "none"))) var shapes: Array[Dictionary] = [] var raw_shapes: Variant = dict.get("shapes", []) if raw_shapes is Array: for item: Variant in raw_shapes as Array: if item is Dictionary: shapes.append(_parse_shape(item as Dictionary, preset)) return { "prop_name": str(dict.get("prop_name", "")), "material_preset": preset, "shapes": shapes, } ## Deterministic conversion of the multi-shape stack into a single ## PropUtils.spawn_prop payload. Rule: frontmost closed polygon -> else circle ## -> else first shape. Returns {} when shapes is empty. static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary: if shapes.is_empty(): push_warning("PropFile: cannot flatten an empty shape stack.") return {} var chosen: Dictionary = {} var kind: String = "polygon" # 1. Frontmost closed polygon (scan back to front). for i: int in range(shapes.size() - 1, -1, -1): var sd: Dictionary = shapes[i] if str(sd.get("shape_type", "polygon")) == "polygon" and bool(sd.get("closed", true)): chosen = sd kind = "polygon" break # 2. Else frontmost circle. if chosen.is_empty(): for i: int in range(shapes.size() - 1, -1, -1): var sd: Dictionary = shapes[i] if str(sd.get("shape_type", "polygon")) == "circle": chosen = sd kind = "circle" break # 3. Else the first shape (even a line) as a polygon. if chosen.is_empty() and not shapes.is_empty(): chosen = shapes[0] kind = "polygon" var payload: Dictionary = {} if kind == "circle": payload = { "type": PropBlockScript.ShapeType.CIRCLE, "radius": float(chosen.get("radius", DEFAULT_RADIUS)), } else: var pts: PackedVector2Array = _coerce_points(chosen.get("points", PackedVector2Array())) payload = { "type": PropBlockScript.ShapeType.POLYGON, "points": TerrainUtilsScript.sanitize_points(pts), } payload["fill_color"] = _color_from_hex(str(chosen.get("fill_color", "")), PropBlockScript.tint_for(PropBlockScript.MaterialPreset.NONE)) payload["outline_color"] = _color_from_hex(str(chosen.get("outline_color", "")), PropBlockScript.DEFAULT_OUTLINE_COLOR) payload["outline_width"] = float(chosen.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)) return payload ## Write `data` (a to_file_dict result) as pretty-printed JSON to `path`. static func save(path: String, data: Dictionary) -> bool: var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) if file == null: push_warning("PropFile: could not open '%s' for writing." % path) return false file.store_string(JSON.stringify(data, "\t")) file.close() return true ## Atomic write mirroring StageSceneIO.write_file_safe: write a temp file, ## swap the previous file to .bak, rename the temp into place, then remove the ## backup. Used by the editor autosave so a crash mid-write cannot corrupt it. static func save_safe(path: String, data: Dictionary) -> bool: var tmp := path + ".tmp" var bak := path + ".bak" var file := FileAccess.open(tmp, FileAccess.WRITE) if file == null: push_warning("PropFile: could not write temporary file '%s'." % tmp) return false file.store_string(JSON.stringify(data, "\t")) file.flush() file.close() if FileAccess.file_exists(bak): DirAccess.remove_absolute(bak) if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK: DirAccess.remove_absolute(tmp) return false if DirAccess.rename_absolute(tmp, path) != OK: if FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path) return false if FileAccess.file_exists(bak): DirAccess.remove_absolute(bak) return true ## Read and parse a .prop file. Returns the raw dictionary on success, {} ## on read/parse failure (with a push_warning). Schema validation (version, ## shapes) happens in from_file_dict. static func load(path: String) -> Dictionary: if not FileAccess.file_exists(path): push_warning("PropFile: '%s' does not exist." % path) return {} var file: FileAccess = FileAccess.open(path, FileAccess.READ) if file == null: push_warning("PropFile: could not open '%s' for reading." % path) return {} var parsed: Variant = JSON.parse_string(file.get_as_text()) file.close() if not parsed is Dictionary: push_warning("PropFile: '%s' is not a valid JSON object." % path) return {} return parsed as Dictionary # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- static func _serialize_shape(shape: Dictionary) -> Dictionary: # Accept both internal (PackedVector2Array) and export (Array of {x,y}) # point formats; a typed assignment would silently zero an Array of dicts. var points: PackedVector2Array = _coerce_points(shape.get("points", PackedVector2Array())) var points_arr: Array = [] for pt: Vector2 in points: points_arr.append({"x": pt.x, "y": pt.y}) var flags: PackedInt32Array = shape.get("vertex_flags", PackedInt32Array()) var flags_arr: Array = [] for i: int in flags.size(): flags_arr.append(flags[i]) var result := { "shape_type": str(shape.get("shape_type", "polygon")), "points": points_arr, "fill_color": str(shape.get("fill_color", "")), "outline_color": str(shape.get("outline_color", "")), "outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)), "closed": bool(shape.get("closed", false)), "vertex_flags": flags_arr, "reshaped": bool(shape.get("reshaped", false)), } if str(shape.get("shape_type", "")) == "circle": result["radius"] = float(shape.get("radius", DEFAULT_RADIUS)) return result static func _parse_shape(shape: Dictionary, preset: int) -> Dictionary: var shape_type: String = str(shape.get("shape_type", "polygon")) if not SHAPE_STRINGS.has(shape_type): shape_type = "polygon" var pts := PackedVector2Array() var pts_variant: Variant = shape.get("points", []) if pts_variant is Array: for p: Variant in pts_variant as Array: if p is Dictionary: var d: Dictionary = p as Dictionary pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))) elif p is Vector2: pts.append(p as Vector2) elif pts_variant is PackedVector2Array: pts = pts_variant as PackedVector2Array var closed: bool if shape.has("closed"): closed = bool(shape.get("closed", false)) else: closed = shape_type != "line" var radius: float = float(shape.get("radius", DEFAULT_RADIUS)) if shape_type == "circle" and pts.is_empty(): pts = _circle_points(radius, DEFAULT_CIRCLE_SEGMENTS) var vf := PackedInt32Array() var vf_variant: Variant = shape.get("vertex_flags", []) if vf_variant is Array: var vf_arr: Array = vf_variant as Array vf.resize(vf_arr.size()) for i: int in vf_arr.size(): vf[i] = int(vf_arr[i]) elif vf_variant is PackedInt32Array: vf = vf_variant as PackedInt32Array if vf.size() != pts.size(): vf.resize(pts.size()) var tint: Color = PropBlockScript.tint_for(preset) var fill_hex: String = str(shape.get("fill_color", "")) if fill_hex.is_empty(): fill_hex = tint.to_html() var outline_hex: String = str(shape.get("outline_color", "")) if outline_hex.is_empty(): outline_hex = tint.darkened(0.55).to_html() var result := { "shape_type": shape_type, "points": pts, "radius": radius, "fill_color": fill_hex, "outline_color": outline_hex, "outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)), "closed": closed, "vertex_flags": vf, "reshaped": bool(shape.get("reshaped", false)), } return result static func _circle_points(radius: float, segments: int) -> PackedVector2Array: var pts := PackedVector2Array() var seg: int = maxi(segments, 3) for i: int in seg: var angle: float = TAU * float(i) / float(seg) pts.append(Vector2(cos(angle), sin(angle)) * radius) return pts static func _color_from_hex(hex: String, fallback: Color) -> Color: if hex.is_empty(): return fallback return Color.from_string(hex, fallback) ## Accept either internal (PackedVector2Array) or export (Array of {x,y}) ## point formats. static func _coerce_points(variant: Variant) -> PackedVector2Array: var pts := PackedVector2Array() if variant is PackedVector2Array: pts = variant as PackedVector2Array elif variant is Array: for p: Variant in variant as Array: if p is Dictionary: var d: Dictionary = p as Dictionary pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))) elif p is Vector2: pts.append(p as Vector2) return pts