extends RefCounted
class_name GltfPacker
## .gltf -> .glb, AT IMPORT, on the importer's own machine.
##
## A .gltf is JSON that points at its data by `uri`: a .bin beside it, texture files,
## or base64 `data:` URIs. The room must never receive that shape — GlbCheck refuses
## every uri, because GLTFDocument resolves them against the LOCAL disk of whoever
## loads the file. So a .gltf is packed into one self-contained .glb here, and that
## .glb is what gets checked, cached and shared. Peers only ever see a .glb.
##
## Reading the side files is safe HERE because they are the importer's own files,
## next to a file they chose — but only inside that file's folder: an absolute path,
## a `..`, or anything that resolves outside the folder is refused, so a .gltf from
## the internet cannot make the importer pack ~/.ssh into a model.
##
## The repack is a faithful JSON edit, not a parse-and-re-export: buffers are
## concatenated into the one BIN chunk (bufferViews re-pointed), images become
## bufferViews, every `uri` is removed. Materials, extensions and animations are left
## exactly as authored — GLTFDocument's own re-export would re-encode and drop things.
## {ok, bytes: PackedByteArray (a .glb), reason}
static func pack_file(path: String) -> Dictionary:
var out := {"ok": false, "bytes": PackedByteArray(), "reason": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
out["reason"] = "cannot open %s" % path.get_file()
return out
if f.get_length() > GlbCheck.MAX_BYTES:
out["reason"] = "file too large"
return out
var parsed: Variant = JSON.parse_string(f.get_as_text())
f.close()
if not (parsed is Dictionary):
out["reason"] = "not a .gltf (JSON does not parse)"
return out
return pack(parsed as Dictionary, path.get_base_dir())
## Pack parsed .gltf JSON whose relative uris resolve against `base_dir`.
static func pack(src: Dictionary, base_dir: String) -> Dictionary:
var out := {"ok": false, "bytes": PackedByteArray(), "reason": ""}
var j: Dictionary = src.duplicate(true)
var budget := [GlbCheck.MAX_BYTES] # running total of bytes pulled in
var bin := PackedByteArray()
# ---- buffers -> one BIN; remember where each one landed ----
var starts: Array = []
var buffers: Array = j.get("buffers", [])
for i in buffers.size():
var b: Dictionary = buffers[i]
var data := PackedByteArray()
if b.has("uri"):
var r := _read_uri(String(b["uri"]), base_dir, budget)
if r.has("error"):
out["reason"] = "buffer %d: %s" % [i, r["error"]]
return out
data = r["bytes"]
elif i != 0:
out["reason"] = "buffer %d has no data" % i
return out
var want := int(b.get("byteLength", data.size()))
if data.size() < want:
out["reason"] = "buffer %d is shorter than its byteLength" % i
return out
_pad(bin)
starts.append(bin.size())
bin.append_array(data.slice(0, want))
for v: Variant in j.get("bufferViews", []):
var bv: Dictionary = v
var bi := int(bv.get("buffer", 0))
if bi < 0 or bi >= starts.size():
out["reason"] = "a bufferView names a missing buffer"
return out
bv["buffer"] = 0
bv["byteOffset"] = int(starts[bi]) + int(bv.get("byteOffset", 0))
# ---- images with a uri -> a bufferView in the BIN ----
if not j.has("bufferViews"):
j["bufferViews"] = []
var images: Array = j.get("images", [])
for i in images.size():
var img: Dictionary = images[i]
if not img.has("uri"):
continue
var r := _read_uri(String(img["uri"]), base_dir, budget)
if r.has("error"):
out["reason"] = "texture %d: %s" % [i, r["error"]]
return out
var mime := String(img.get("mimeType", r.get("mime", "")))
if mime == "":
mime = _mime_of(String(img["uri"]))
if mime == "":
out["reason"] = "texture %d: unknown image type" % i
return out
_pad(bin)
(j["bufferViews"] as Array).append({"buffer": 0, "byteOffset": bin.size(),
"byteLength": (r["bytes"] as PackedByteArray).size()})
bin.append_array(r["bytes"])
img.erase("uri")
img["bufferView"] = (j["bufferViews"] as Array).size() - 1
img["mimeType"] = mime
_pad(bin)
j["buffers"] = [{"byteLength": bin.size()}] if bin.size() > 0 else []
out["bytes"] = GlbCheck.pack(j, bin)
out["ok"] = true
return out
## A uri's bytes: base64 `data:`, or a file INSIDE base_dir. {bytes, mime?} or {error}.
static func _read_uri(uri: String, base_dir: String, budget: Array) -> Dictionary:
if uri.begins_with("data:"):
var comma := uri.find(",")
var head := uri.substr(5, comma - 5) if comma > 0 else ""
if comma < 0 or not head.ends_with(";base64"):
return {"error": "only base64 data: URIs are supported"}
var bytes := Marshalls.base64_to_raw(uri.substr(comma + 1))
if bytes.size() > int(budget[0]):
return {"error": "file too large"}
budget[0] = int(budget[0]) - bytes.size()
return {"bytes": bytes, "mime": head.trim_suffix(";base64")}
var rel := uri.uri_decode()
if rel.contains("://") or rel.begins_with("/") or rel.begins_with("\\") or (rel.length() > 1 and rel[1] == ":"):
return {"error": "absolute path or URL (%s) not allowed" % uri}
var parts := rel.replace("\\", "/").split("/")
if parts.has(".."):
return {"error": "'..' in %s not allowed" % uri}
var full := base_dir.path_join(rel)
# NO SYMLINKS on the way down: a link inside the folder can point anywhere. Checked
# per path step with DirAccess.is_link (portable — the Windows build has no realpath).
var walk := base_dir
for p in parts:
if p == "" or p == ".":
continue
var d := DirAccess.open(walk)
if d == null or d.is_link(p):
return {"error": "%s goes through a link or a missing folder" % uri}
walk = walk.path_join(p)
var f := FileAccess.open(full, FileAccess.READ)
if f == null:
return {"error": "missing file %s" % rel}
if f.get_length() > int(budget[0]):
return {"error": "file too large"}
budget[0] = int(budget[0]) - int(f.get_length())
var data := f.get_buffer(f.get_length())
f.close()
return {"bytes": data}
static func _mime_of(uri: String) -> String:
match uri.get_extension().to_lower():
"png": return "image/png"
"jpg", "jpeg": return "image/jpeg"
"webp": return "image/webp"
return ""
static func _pad(b: PackedByteArray) -> void:
while b.size() % 4 != 0:
b.append(0)