Initial commit
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghFlipbookImport
|
||||
## Builds a PLAYING flipbook from a sprite-sheet (NxM grid) and adds it to the
|
||||
## open scene. Uses Sprite3D (hframes/vframes) + an AnimationPlayer that keyframes
|
||||
## the `frame` property — the official, non-deprecated path that animates BOTH in
|
||||
## the editor viewport and at runtime (AnimatedTexture is deprecated/broken).
|
||||
## Handles .exr HDR sheets (load_exr_from_buffer) as well as png/jpg/webp.
|
||||
|
||||
static func from_sheet(sheet_path: String, h_frames: int, v_frames: int, fps := 30.0) -> Sprite3D:
|
||||
# SNIFF the real format from magic bytes rather than trusting the extension — CDN sheets
|
||||
# are sometimes served as webp/jpg but named ".png", and Godot's extension-based img.load()
|
||||
# then fails with a misleading "PNG corrupt" (png_driver_common). Sniffing decodes them.
|
||||
var img := _decode_sheet(FileAccess.get_file_as_bytes(sheet_path), sheet_path.get_extension().to_lower())
|
||||
if img == null:
|
||||
push_error("[CGHEVEN] Could not load flipbook sheet: " + sheet_path)
|
||||
return null
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
|
||||
var cols: int = max(1, h_frames)
|
||||
var rows: int = max(1, v_frames)
|
||||
var total: int = cols * rows
|
||||
|
||||
# Sprite3D slices the sheet natively via hframes/vframes; `frame` indexes the grid.
|
||||
var spr := Sprite3D.new()
|
||||
spr.name = "CghFlipbook"
|
||||
spr.texture = tex
|
||||
spr.hframes = cols
|
||||
spr.vframes = rows
|
||||
spr.frame = 0
|
||||
spr.billboard = BaseMaterial3D.BILLBOARD_ENABLED # face the camera (VFX)
|
||||
spr.alpha_cut = SpriteBase3D.ALPHA_CUT_DISABLED # default = alpha blend
|
||||
|
||||
var edited := EditorInterface.get_edited_scene_root()
|
||||
if edited == null:
|
||||
push_warning("[CGHEVEN] No open scene — flipbook node created but not added. Open a scene and re-import.")
|
||||
return spr
|
||||
|
||||
# A Sprite3D only lives/renders in a 3D world. If the open scene is a UI
|
||||
# (Control) or 2D (Node2D) scene, nesting it inside that tree makes it
|
||||
# invisible. So guarantee a 3D host: use the root if it is Node3D, else drop
|
||||
# the flipbook under a dedicated Node3D wrapper (reused across imports).
|
||||
var parent_3d: Node = _ensure_3d_parent(edited)
|
||||
parent_3d.add_child(spr)
|
||||
spr.owner = edited
|
||||
|
||||
# Single-frame sheet (no NxM in the filename) — nothing to animate.
|
||||
if total <= 1:
|
||||
return spr
|
||||
|
||||
# Build a discrete, looping animation that steps `frame` 0..total-1.
|
||||
var anim := Animation.new()
|
||||
var track := anim.add_track(Animation.TYPE_VALUE)
|
||||
anim.track_set_path(track, NodePath(".:frame"))
|
||||
anim.value_track_set_update_mode(track, Animation.UPDATE_DISCRETE)
|
||||
anim.length = float(total) / fps
|
||||
anim.loop_mode = Animation.LOOP_LINEAR
|
||||
for i in total:
|
||||
anim.track_insert_key(track, float(i) / fps, i)
|
||||
|
||||
var lib := AnimationLibrary.new()
|
||||
lib.add_animation("play", anim)
|
||||
|
||||
var ap := AnimationPlayer.new()
|
||||
ap.name = "FlipbookPlayer"
|
||||
spr.add_child(ap)
|
||||
ap.owner = edited
|
||||
ap.add_animation_library("", lib)
|
||||
ap.autoplay = "play" # plays automatically when the scene runs
|
||||
ap.play("play") # and starts immediately in the editor viewport
|
||||
return spr
|
||||
|
||||
|
||||
## Decode a sprite-sheet from raw bytes, sniffing the real format from magic bytes (the file
|
||||
## extension often lies). PNG=89 50 4E 47, JPEG=FF D8, WEBP=RIFF....WEBP, EXR=76 2F 31 01.
|
||||
## Returns null on an unrecognised/corrupt body.
|
||||
static func _decode_sheet(body: PackedByteArray, ext: String) -> Image:
|
||||
if body.size() < 12:
|
||||
return null
|
||||
var img := Image.new()
|
||||
var ok := ERR_INVALID_DATA
|
||||
if body[0] == 0x89 and body[1] == 0x50 and body[2] == 0x4E and body[3] == 0x47:
|
||||
ok = img.load_png_from_buffer(body)
|
||||
elif body[0] == 0xFF and body[1] == 0xD8:
|
||||
ok = img.load_jpg_from_buffer(body)
|
||||
elif body[0] == 0x52 and body[1] == 0x49 and body[2] == 0x46 and body[3] == 0x46 \
|
||||
and body[8] == 0x57 and body[9] == 0x45 and body[10] == 0x42 and body[11] == 0x50:
|
||||
ok = img.load_webp_from_buffer(body)
|
||||
elif body[0] == 0x76 and body[1] == 0x2F and body[2] == 0x31 and body[3] == 0x01:
|
||||
ok = img.load_exr_from_buffer(body)
|
||||
elif ext == "exr":
|
||||
ok = img.load_exr_from_buffer(body) # magic missing but named .exr — try anyway
|
||||
else:
|
||||
return null
|
||||
return img if ok == OK else null
|
||||
|
||||
## Returns a 3D-capable parent for the flipbook. If the edited scene root is a
|
||||
## Node3D we use it directly; otherwise (Control / Node2D / plain Node scene) we
|
||||
## create — or reuse — a "CghevenFlipbooks" Node3D so the sprite always sits in a
|
||||
## real 3D context and renders in the 3D viewport instead of vanishing into UI.
|
||||
static func _ensure_3d_parent(edited: Node) -> Node:
|
||||
if edited is Node3D:
|
||||
return edited
|
||||
var existing := edited.get_node_or_null(NodePath("CghevenFlipbooks"))
|
||||
if existing != null and existing is Node3D:
|
||||
return existing
|
||||
var host := Node3D.new()
|
||||
host.name = "CghevenFlipbooks"
|
||||
edited.add_child(host)
|
||||
host.owner = edited
|
||||
push_warning("[CGHEVEN] Open scene is not 3D — flipbook placed under a new "
|
||||
+ "'CghevenFlipbooks' Node3D. Switch to the 3D viewport (or open a 3D "
|
||||
+ "scene) to see it play.")
|
||||
return host
|
||||
@@ -0,0 +1 @@
|
||||
uid://d06uk4ol66bf2
|
||||
@@ -0,0 +1,26 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghGltfImport
|
||||
## Imports a downloaded .glb/.gltf into the currently edited scene.
|
||||
|
||||
static func import_into_scene(glb_path: String) -> Node:
|
||||
var doc := GLTFDocument.new()
|
||||
var state := GLTFState.new()
|
||||
var err := doc.append_from_file(glb_path, state)
|
||||
if err != OK:
|
||||
push_error("[CGHEVEN] glTF parse failed: %d" % err)
|
||||
return null
|
||||
var scene_root := doc.generate_scene(state)
|
||||
if scene_root == null:
|
||||
push_error("[CGHEVEN] glTF generate_scene returned null")
|
||||
return null
|
||||
var edited := EditorInterface.get_edited_scene_root()
|
||||
if edited == null:
|
||||
push_warning("[CGHEVEN] No open scene to import into.")
|
||||
return scene_root
|
||||
edited.add_child(scene_root)
|
||||
# own the node so it saves with the scene
|
||||
scene_root.owner = edited
|
||||
for c in scene_root.find_children("*", "", true, false):
|
||||
c.owner = edited
|
||||
return scene_root
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2th51vfweq7f
|
||||
@@ -0,0 +1,48 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghHdriImport
|
||||
## Sets the scene's world environment from an equirectangular HDRI.
|
||||
## Builds WorldEnvironment → Environment(BG_SKY) → Sky → PanoramaSkyMaterial,
|
||||
## with ambient + reflections taken from the sky (official Godot sky workflow).
|
||||
## Reuses an existing WorldEnvironment if the scene already has one (Godot allows
|
||||
## only one active per scene tree). HDRIs are served as .exr (load_exr_from_buffer).
|
||||
|
||||
static func apply_as_world(exr_path: String) -> WorldEnvironment:
|
||||
var img := Image.new()
|
||||
var ext := exr_path.get_extension().to_lower()
|
||||
var ok: int = img.load_exr_from_buffer(FileAccess.get_file_as_bytes(exr_path)) if ext == "exr" \
|
||||
else img.load(exr_path)
|
||||
if ok != OK:
|
||||
push_error("[CGHEVEN] Could not load HDRI: " + exr_path)
|
||||
return null
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
|
||||
var sky_mat := PanoramaSkyMaterial.new()
|
||||
sky_mat.panorama = tex
|
||||
var sky := Sky.new()
|
||||
sky.sky_material = sky_mat
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_SKY
|
||||
env.sky = sky
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||
|
||||
var edited := EditorInterface.get_edited_scene_root()
|
||||
if edited == null:
|
||||
push_warning("[CGHEVEN] No open scene — WorldEnvironment created but not added.")
|
||||
var orphan := WorldEnvironment.new()
|
||||
orphan.environment = env
|
||||
return orphan
|
||||
|
||||
# Reuse an existing WorldEnvironment (only one may be active per scene tree).
|
||||
var existing := edited.find_children("*", "WorldEnvironment", true, false)
|
||||
var we: WorldEnvironment
|
||||
if existing.is_empty():
|
||||
we = WorldEnvironment.new()
|
||||
we.name = "WorldEnvironment"
|
||||
edited.add_child(we)
|
||||
we.owner = edited
|
||||
else:
|
||||
we = existing[0]
|
||||
we.environment = env
|
||||
return we
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6ro7gtcoafbj
|
||||
@@ -0,0 +1,42 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghMaterialImport
|
||||
## Builds a StandardMaterial3D from a PBR texture set and assigns it to the
|
||||
## selected MeshInstance3D. NOTE: Blender procedural node-graphs do NOT import;
|
||||
## only the baked-PBR subset is reproduced here.
|
||||
|
||||
static func build_pbr(tex_paths: Dictionary) -> StandardMaterial3D:
|
||||
# tex_paths keys: albedo, normal, roughness, metallic, emission, ao
|
||||
var m := StandardMaterial3D.new()
|
||||
if tex_paths.has("albedo"):
|
||||
m.albedo_texture = _load(tex_paths["albedo"])
|
||||
if tex_paths.has("normal"):
|
||||
m.normal_enabled = true
|
||||
m.normal_texture = _load(tex_paths["normal"])
|
||||
if tex_paths.has("roughness"):
|
||||
m.roughness_texture = _load(tex_paths["roughness"])
|
||||
if tex_paths.has("metallic"):
|
||||
m.metallic = 1.0
|
||||
m.metallic_texture = _load(tex_paths["metallic"])
|
||||
if tex_paths.has("ao"):
|
||||
m.ao_enabled = true
|
||||
m.ao_texture = _load(tex_paths["ao"])
|
||||
if tex_paths.has("emission"):
|
||||
m.emission_enabled = true
|
||||
m.emission_texture = _load(tex_paths["emission"])
|
||||
return m
|
||||
|
||||
static func _load(path: String) -> Texture2D:
|
||||
var img := Image.new()
|
||||
if img.load(path) != OK:
|
||||
return null
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
static func assign_to_selection(mat: Material) -> bool:
|
||||
var sel := EditorInterface.get_selection().get_selected_nodes()
|
||||
for n in sel:
|
||||
if n is MeshInstance3D:
|
||||
n.material_override = mat
|
||||
return true
|
||||
push_warning("[CGHEVEN] Select a MeshInstance3D to apply the material.")
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://binrjwgiv4o85
|
||||
@@ -0,0 +1,231 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghImportRouter
|
||||
## Routes a downloaded file to the right importer. CRITICAL: it routes by the
|
||||
## asset's CATEGORY first, not just the extension — an `.exr` can be EITHER an
|
||||
## HDRI panorama (hdri category) OR a flipbook sprite-sheet (flipbooks category),
|
||||
## so extension alone is ambiguous and used to send flipbook .exr sheets to the
|
||||
## HDRI importer (sky), which is why flipbooks "imported but never played".
|
||||
##
|
||||
## After a successful live-instance into the open scene, the source file is ALSO
|
||||
## copied into res://CGHEVEN/<category>/ so it shows up in the FileSystem dock and
|
||||
## the user can find / reuse it (Godot only imports files that live under res://).
|
||||
|
||||
const RES_ROOT := "res://CGHEVEN"
|
||||
|
||||
# Model formats Godot's EDITOR can import (into a PackedScene, or a Mesh for .obj) — far
|
||||
# more than the runtime GLTFDocument path, which only handles glTF. Order = most reliable
|
||||
# first (used to pick the best model inside a zip, like the Blender addon's priority).
|
||||
const EDITOR_MODEL_PRIORITY := ["glb", "gltf", "fbx", "dae", "obj"]
|
||||
|
||||
static func import_file(path: String, category_slug: String) -> String:
|
||||
var ext := path.get_extension().to_lower()
|
||||
|
||||
# unpack archives first, then re-route on the inner importable file
|
||||
if ext == "zip":
|
||||
var inner := _extract_zip(path)
|
||||
if inner == "":
|
||||
return "Extracted zip, but found no importable file inside."
|
||||
return import_file(inner, category_slug)
|
||||
|
||||
var cat := category_slug.to_lower()
|
||||
var msg := ""
|
||||
# Category decides intent. Match the TOKEN, not exact equality, because the slug can be a
|
||||
# SUBCATEGORY ("muzzle-flashes-flipbooks", "space-hdr", …) — matching "flipbooks" exactly
|
||||
# used to send subcategory flipbook .exr sheets to the HDRI importer (wrong). Fall back to
|
||||
# extension when the category is unknown.
|
||||
if cat.contains("flipbook"):
|
||||
msg = _do_flipbook(path)
|
||||
elif cat.contains("hdr"):
|
||||
msg = _do_hdri(path)
|
||||
elif cat == "3d-models" or cat.contains("model"):
|
||||
msg = _do_3d(path)
|
||||
elif ext in CghConfig.EXT_3D:
|
||||
msg = _do_3d(path)
|
||||
elif ext in CghConfig.EXT_HDRI:
|
||||
msg = _do_hdri(path)
|
||||
elif ext in CghConfig.EXT_IMAGE:
|
||||
msg = _do_flipbook(path)
|
||||
else:
|
||||
# Not auto-importable — still drop it in the project so the user has it.
|
||||
var rp0 := copy_into_project(path, cat)
|
||||
return "Saved to %s (no auto-import for .%s)." % [rp0, ext] if rp0 != "" else \
|
||||
"Downloaded to %s (no auto-import for .%s)." % [path, ext]
|
||||
|
||||
# Copy a reusable source into the project (FileSystem dock) and tell the user.
|
||||
var rp := copy_into_project(path, cat)
|
||||
if rp != "":
|
||||
msg += " Saved in %s/" % rp.get_base_dir()
|
||||
return msg
|
||||
|
||||
# ---------------- per-type importers ----------------
|
||||
static func _do_flipbook(path: String) -> String:
|
||||
var hv := _flipbook_grid(path)
|
||||
var spr := CghFlipbookImport.from_sheet(path, hv.x, hv.y)
|
||||
if spr == null:
|
||||
if path.get_extension().to_lower() == "exr":
|
||||
return "Flipbook import failed — this .exr uses a compression Godot can't read. Pick the PNG version from the ▾ menu."
|
||||
return "Flipbook import failed."
|
||||
if hv == Vector2i(1, 1):
|
||||
return "Flipbook added (single frame — grid NxM not in filename, can't auto-play)."
|
||||
return "Flipbook added & playing (%dx%d grid)." % [hv.x, hv.y]
|
||||
|
||||
static func _do_hdri(path: String) -> String:
|
||||
var we := CghHdriImport.apply_as_world(path)
|
||||
return "HDRI applied as the scene's world/sky." if we else \
|
||||
"HDRI import failed — this .exr uses a compression Godot can't read (e.g. DWAA/PXR24). Try another HDRI."
|
||||
|
||||
static func _do_3d(path: String) -> String:
|
||||
var ext := path.get_extension().to_lower()
|
||||
var n: Node = _import_fbx(path) if ext == "fbx" else CghGltfImport.import_into_scene(path)
|
||||
return "3D model added to the scene." if n else "3D import failed (open a scene first?)."
|
||||
|
||||
static func _import_fbx(path: String) -> Node:
|
||||
# ufbx (4.3+) — FBXDocument mirrors GLTFDocument
|
||||
var doc := FBXDocument.new()
|
||||
var state := FBXState.new()
|
||||
if doc.append_from_file(path, state) != OK:
|
||||
return null
|
||||
var root := doc.generate_scene(state)
|
||||
var edited := EditorInterface.get_edited_scene_root()
|
||||
if edited and root:
|
||||
edited.add_child(root)
|
||||
root.owner = edited
|
||||
for c in root.find_children("*", "", true, false):
|
||||
c.owner = edited
|
||||
return root
|
||||
|
||||
# ---------------- project copy (FileSystem dock) ----------------
|
||||
# Copy the downloaded source into res://CGHEVEN/<category>/<filename> so Godot
|
||||
# imports it and it appears in the FileSystem dock (only files under res:// are
|
||||
# importable/usable). Returns the res:// path, or "" on failure.
|
||||
static func copy_into_project(path: String, category_slug: String) -> String:
|
||||
var bytes := FileAccess.get_file_as_bytes(path)
|
||||
if bytes.is_empty():
|
||||
return ""
|
||||
var sub := category_slug if category_slug != "" else "assets"
|
||||
var dir := RES_ROOT.path_join(sub)
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
var dest := dir.path_join(CghConfig.safe_download_name(path.get_file()))
|
||||
var f := FileAccess.open(dest, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return ""
|
||||
f.store_buffer(bytes)
|
||||
f.close()
|
||||
# Make the editor pick up + import the new file so it shows in the dock.
|
||||
if Engine.is_editor_hint():
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs and not efs.is_scanning():
|
||||
efs.scan()
|
||||
return dest
|
||||
|
||||
## Extract EVERY file from a zip into a sibling temp dir and return the best
|
||||
## editor-importable MODEL inside (glb/gltf > fbx > dae > obj), or "" if none. Extracts
|
||||
## the whole tree (not just the model) so .bin/.mtl/textures land next to it and survive
|
||||
## the import. Mirrors the Blender addon's _pick_best_model_in_tree.
|
||||
static func extract_model_from_zip(zip_path: String) -> String:
|
||||
var reader := ZIPReader.new()
|
||||
if reader.open(zip_path) != OK:
|
||||
return ""
|
||||
var out_dir := zip_path.get_basename() + "_unzip"
|
||||
DirAccess.make_dir_recursive_absolute(out_dir)
|
||||
var best := ""
|
||||
var best_rank := 999
|
||||
for entry in reader.get_files():
|
||||
if entry.ends_with("/"):
|
||||
continue
|
||||
var rel := entry.replace("\\", "/")
|
||||
var out := out_dir.path_join(rel)
|
||||
DirAccess.make_dir_recursive_absolute(out.get_base_dir())
|
||||
var f := FileAccess.open(out, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_buffer(reader.read_file(entry))
|
||||
f.close()
|
||||
var rank: int = EDITOR_MODEL_PRIORITY.find(rel.get_extension().to_lower())
|
||||
if rank != -1 and rank < best_rank:
|
||||
best_rank = rank
|
||||
best = out
|
||||
reader.close()
|
||||
return best
|
||||
|
||||
## Copy a model file AND its sibling files (textures / .bin / .mtl in the SAME folder)
|
||||
## into res://CGHEVEN/<category>/<modelname>/ so the editor import keeps materials. Use
|
||||
## this for models extracted from a zip (isolated temp dir); for a bare downloaded file
|
||||
## use copy_into_project (single file). Returns the model's res:// path, or "" on failure.
|
||||
static func copy_model_into_project(model_path: String, category_slug: String) -> String:
|
||||
var src_dir := model_path.get_base_dir()
|
||||
var sub := category_slug if category_slug != "" else "assets"
|
||||
var stem := CghConfig.safe_download_name(model_path.get_file()).get_basename()
|
||||
var dest_dir := RES_ROOT.path_join(sub).path_join(stem)
|
||||
DirAccess.make_dir_recursive_absolute(dest_dir)
|
||||
var model_dest := ""
|
||||
var da := DirAccess.open(src_dir)
|
||||
if da:
|
||||
da.list_dir_begin()
|
||||
var fn := da.get_next()
|
||||
while fn != "":
|
||||
if not da.current_is_dir():
|
||||
var bytes := FileAccess.get_file_as_bytes(src_dir.path_join(fn))
|
||||
var d := dest_dir.path_join(CghConfig.safe_download_name(fn))
|
||||
var wf := FileAccess.open(d, FileAccess.WRITE)
|
||||
if wf:
|
||||
wf.store_buffer(bytes)
|
||||
wf.close()
|
||||
if fn == model_path.get_file():
|
||||
model_dest = d
|
||||
fn = da.get_next()
|
||||
da.list_dir_end()
|
||||
if model_dest == "":
|
||||
# fallback: copy just the model file (distinct var names — GDScript locals are
|
||||
# function-scoped, so re-declaring bytes/wf from the loop above would error).
|
||||
var fbytes := FileAccess.get_file_as_bytes(model_path)
|
||||
if fbytes.is_empty():
|
||||
return ""
|
||||
model_dest = dest_dir.path_join(CghConfig.safe_download_name(model_path.get_file()))
|
||||
var fwf := FileAccess.open(model_dest, FileAccess.WRITE)
|
||||
if fwf == null:
|
||||
return ""
|
||||
fwf.store_buffer(fbytes)
|
||||
fwf.close()
|
||||
if Engine.is_editor_hint():
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs and not efs.is_scanning():
|
||||
efs.scan()
|
||||
return model_dest
|
||||
|
||||
static func _extract_zip(zip_path: String) -> String:
|
||||
var reader := ZIPReader.new()
|
||||
if reader.open(zip_path) != OK:
|
||||
return ""
|
||||
var ok := CghConfig.EXT_3D + CghConfig.EXT_HDRI + CghConfig.EXT_IMAGE
|
||||
var out_dir := zip_path.get_basename() + "_unzip"
|
||||
DirAccess.make_dir_recursive_absolute(out_dir)
|
||||
var best := ""
|
||||
var best_rank := 99
|
||||
for entry in reader.get_files():
|
||||
if entry.ends_with("/"):
|
||||
continue
|
||||
var e_ext := entry.get_extension().to_lower()
|
||||
if not (e_ext in ok):
|
||||
continue
|
||||
var out := out_dir + "/" + entry.get_file()
|
||||
var f := FileAccess.open(out, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_buffer(reader.read_file(entry))
|
||||
f.close()
|
||||
# prefer 3D > hdri > image when several importable files exist
|
||||
var rank := 0 if e_ext in CghConfig.EXT_3D else (1 if e_ext in CghConfig.EXT_HDRI else 2)
|
||||
if rank < best_rank:
|
||||
best_rank = rank
|
||||
best = out
|
||||
reader.close()
|
||||
return best
|
||||
|
||||
static func _flipbook_grid(path: String) -> Vector2i:
|
||||
# look for an NxM token in the filename, e.g. fire_8x8.png / Portal_04_4K_8X8.exr
|
||||
var rx := RegEx.new()
|
||||
rx.compile("(\\d+)\\s*[xX]\\s*(\\d+)")
|
||||
var m := rx.search(path.get_file())
|
||||
if m:
|
||||
return Vector2i(int(m.get_string(1)), int(m.get_string(2)))
|
||||
return Vector2i(1, 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyrdsmgv6xlmi
|
||||
Reference in New Issue
Block a user