OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / gltf_packer.gd

162 lines · gd ref:
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)