diff --git a/cell.toml b/cell.toml index 3db3365..15d6c48 100644 --- a/cell.toml +++ b/cell.toml @@ -1,3 +1,6 @@ [package] name = "cell-model" version = "0.1.0" + +[dependencies] +cell-image = "/Users/john/work/cell-image" diff --git a/gltf.c b/gltf.c index 639eec8..592130b 100644 --- a/gltf.c +++ b/gltf.c @@ -282,6 +282,7 @@ JSValue js_gltf_decode(JSContext *js, JSValue this_val, int argc, JSValueConst * JS_SetPropertyStr(js, m, "alpha_mode", JS_NewString(js, alpha_mode)); JS_SetPropertyStr(js, m, "alpha_cutoff", JS_NewFloat64(js, mat->alpha_cutoff)); JS_SetPropertyStr(js, m, "double_sided", JS_NewBool(js, mat->double_sided)); + JS_SetPropertyStr(js, m, "unlit", JS_NewBool(js, mat->unlit)); JS_SetPropertyUint32(js, materials_arr, i, m); } diff --git a/gltf.cm b/gltf.cm new file mode 100644 index 0000000..0741f20 --- /dev/null +++ b/gltf.cm @@ -0,0 +1,612 @@ +var gltf = this +var native_decode = gltf.decode + +var fd = use('fd') +var utf8 = use('utf8') +var blob = use('blob') +var http = use('http') +var text = use('internal/text') + +var png = use('cell-image/png') +var jpg = use('cell-image/jpg') + +function dirname(path) { + var idx = path.lastIndexOf("/") + if (idx == -1) return "" + return path.substring(0, idx) +} + +function join_paths(base, rel) { + base = base.replace(/\/+$/, "") + rel = rel.replace(/^\/+/, "") + if (!base) return rel + if (!rel) return base + return base + "/" + rel +} + +function ends_with(path, suffix) { + path = path.toLowerCase() + suffix = suffix.toLowerCase() + return path.length >= suffix.length && path.substring(path.length - suffix.length) == suffix +} + +function spaces(count) { + var s = "" + for (var i = 0; i < count; i++) s += " " + return s +} + +function zeros_blob(bytes) { + var b = new blob(bytes * 8) + for (var i = 0; i < bytes; i++) b.write_fit(0, 8) + stone(b) + return b +} + +function u32(out, v) { + out.write_fit(v, 32) +} + +function warn(asset, msg, mode) { + if (mode == null) mode = "collect" + if (mode == "throw") throw new Error(msg) + asset.warnings.push(msg) +} + +function parse_data_uri(uri) { + var comma = uri.indexOf(",") + if (comma == -1) return null + + var meta = uri.substring(5, comma) + var data = uri.substring(comma + 1) + + var mime = "text/plain" + var is_base64 = false + + if (meta.length > 0) { + var parts = meta.split(";") + if (parts[0]) mime = parts[0] + for (var i = 1; i < parts.length; i++) { + if (parts[i].toLowerCase() == "base64") is_base64 = true + } + } + + return { mime: mime, data: data, base64: is_base64 } +} + +function load_uri_default(uri, base_dir) { + if (uri.indexOf("://") != -1) return http.fetch(uri) + if (!base_dir) throw new Error("missing base_dir for relative uri: " + uri) + return fd.slurp(join_paths(base_dir, uri)) +} + +function load_uri(uri, base_dir, uri_loader) { + if (uri == null) return null + if (uri.startsWith("data:")) { + var parsed = parse_data_uri(uri) + if (!parsed) throw new Error("invalid data uri") + if (!parsed.base64) throw new Error("data uri is not base64") + return text.base64_to_blob(parsed.data) + } + return uri_loader(uri, base_dir) +} + +function parse_glb(glb_blob) { + if (!stone.p(glb_blob)) stone(glb_blob) + + var magic = glb_blob.read_fit(0, 32) + var version = glb_blob.read_fit(32, 32) + var total_len = glb_blob.read_fit(64, 32) + + if (magic != 0x46546c67) throw new Error("invalid glb") + if (version != 2) throw new Error("unsupported glb version") + + var json_len = glb_blob.read_fit(96, 32) + var json_type = glb_blob.read_fit(128, 32) + if (json_type != 0x4e4f534a) throw new Error("invalid glb json chunk") + + var json_from = 160 + var json_to = (20 + json_len) * 8 + var json_blob = glb_blob.read_blob(json_from, json_to) + stone(json_blob) + var json_obj = json.decode(utf8.decode(json_blob)) + + var offset = 20 + json_len + if (offset + 8 > total_len) return { json: json_obj, bin: null } + + var bin_len = glb_blob.read_fit(offset * 8, 32) + var bin_type = glb_blob.read_fit((offset + 4) * 8, 32) + if (bin_type != 0x004e4942) return { json: json_obj, bin: null } + + var bin_from = (offset + 8) * 8 + var bin_to = (offset + 8 + bin_len) * 8 + var bin_blob = glb_blob.read_blob(bin_from, bin_to) + stone(bin_blob) + + return { json: json_obj, bin: bin_blob } +} + +function make_glb_from_json_and_bin(doc, bin_blob) { + var json_text = json.encode(doc, null, 0) + var json_blob = utf8.encode(json_text) + + var json_bytes = json_blob.length / 8 + var bin_bytes = bin_blob.length / 8 + + var json_pad = (4 - (json_bytes % 4)) % 4 + var bin_pad = (4 - (bin_bytes % 4)) % 4 + + var json_pad_blob = utf8.encode(spaces(json_pad)) + var bin_pad_blob = zeros_blob(bin_pad) + + var json_chunk_len = json_bytes + json_pad + var bin_chunk_len = bin_bytes + bin_pad + + var total_len = 12 + 8 + json_chunk_len + 8 + bin_chunk_len + + var out = new blob(total_len * 8) + u32(out, 0x46546c67) + u32(out, 2) + u32(out, total_len) + + u32(out, json_chunk_len) + u32(out, 0x4e4f534a) + out.write_blob(json_blob) + out.write_blob(json_pad_blob) + + u32(out, bin_chunk_len) + u32(out, 0x004e4942) + out.write_blob(bin_blob) + out.write_blob(bin_pad_blob) + + stone(out) + return out +} + +function ensure_image_slots(asset) { + if (!asset.images) asset.images = [] + for (var i = 0; i < asset.images.length; i++) { + var im = asset.images[i] + if (!im) continue + if (im.encoded == null) im.encoded = null + if (im.pixels == null) im.pixels = null + } +} + +function synthesize_glb_from_gltf_json(doc, base_dir, uri_loader) { + if (!doc.buffers || doc.buffers.length == 0) throw new Error("gltf: .gltf has no buffers") + if (doc.buffers.length != 1) throw new Error("gltf: only .gltf with exactly 1 buffer is supported") + + var uri = doc.buffers[0].uri + if (!uri) throw new Error("gltf: buffer[0] has no uri") + + var bin_blob = load_uri(uri, base_dir, uri_loader) + + var doc2 = json.decode(json.encode(doc, null, 0)) + delete doc2.buffers[0].uri + return make_glb_from_json_and_bin(doc2, bin_blob) +} + +gltf.decode = function(input, opts) { + if (opts == null) opts = {} + + var on_warning = opts.on_warning + if (on_warning == null) on_warning = "collect" + + var uri_loader = opts.uri_loader + if (uri_loader == null) uri_loader = load_uri_default + + if (typeof input == 'string') { + var path = input + if (ends_with(path, ".gltf")) { + var gltf_blob = fd.slurp(path) + var doc = json.decode(text(gltf_blob)) + var base_dir = dirname(path) + var glb = synthesize_glb_from_gltf_json(doc, base_dir, uri_loader) + var asset = native_decode(glb) + asset.warnings = [] + asset.path = path + asset.base_dir = base_dir + asset.json = doc + asset.extensions = opts.extensions || {} + ensure_image_slots(asset) + return asset + } + + var glb_blob = fd.slurp(path) + var asset = native_decode(glb_blob) + asset.warnings = [] + asset.path = path + asset.base_dir = dirname(path) + asset.extensions = opts.extensions || {} + try { + var parsed = parse_glb(glb_blob) + asset.json = parsed.json + asset.bin = parsed.bin + } catch (e) { + warn(asset, "gltf: could not parse glb json: " + e, on_warning) + asset.json = null + asset.bin = null + } + ensure_image_slots(asset) + return asset + } + + if (input instanceof blob) { + var hint = opts.path_hint + if (hint && ends_with(hint, ".gltf")) { + var doc = json.decode(text(input)) + var base_dir = dirname(hint) + var glb = synthesize_glb_from_gltf_json(doc, base_dir, uri_loader) + var asset = native_decode(glb) + asset.warnings = [] + asset.path = hint + asset.base_dir = base_dir + asset.json = doc + asset.extensions = opts.extensions || {} + ensure_image_slots(asset) + return asset + } + + var asset = native_decode(input) + asset.warnings = [] + asset.path = hint || null + asset.base_dir = hint ? dirname(hint) : null + asset.extensions = opts.extensions || {} + try { + var parsed = parse_glb(input) + asset.json = parsed.json + asset.bin = parsed.bin + } catch (e) { + asset.json = null + asset.bin = null + } + ensure_image_slots(asset) + return asset + } + + throw new Error("gltf.decode: input must be a string path or a blob") +} + +function used_texture_indices(asset) { + var used = {} + var mats = asset.materials || [] + function mark_texinfo(ti) { + if (!ti) return + if (ti.texture == null) return + used[ti.texture] = true + } + + for (var i = 0; i < mats.length; i++) { + var m = mats[i] + if (!m) continue + if (m.pbr) { + mark_texinfo(m.pbr.base_color_texture) + mark_texinfo(m.pbr.metallic_roughness_texture) + } + mark_texinfo(m.normal_texture) + mark_texinfo(m.occlusion_texture) + mark_texinfo(m.emissive_texture) + } + + return used +} + +function used_image_indices(asset) { + var used_imgs = {} + var used_tex = used_texture_indices(asset) + var textures = asset.textures || [] + + for (var k in used_tex) { + var ti = parseInt(k) + var t = textures[ti] + if (!t) continue + if (t.image == null) continue + used_imgs[t.image] = true + } + + return used_imgs +} + +function extract_buffer_view(asset, view_index) { + if (view_index == null) return null + var view = asset.views[view_index] + if (!view) return null + if (view.buffer == null) return null + + var buf = asset.buffers[view.buffer] + if (!buf || !buf.blob) return null + var b = buf.blob + if (!stone.p(b)) stone(b) + + var from = (view.byte_offset || 0) * 8 + var to = ((view.byte_offset || 0) + (view.byte_length || 0)) * 8 + var slice = b.read_blob(from, to) + stone(slice) + return slice +} + +gltf.collect_dependencies = function(asset) { + var deps = { + buffers: [], + images: [] + } + + var doc = asset.json + if (!doc) return deps + + if (doc.buffers) { + for (var i = 0; i < doc.buffers.length; i++) { + var b = doc.buffers[i] + if (b && b.uri) deps.buffers.push(b.uri) + } + } + + if (doc.images) { + for (var i = 0; i < doc.images.length; i++) { + var im = doc.images[i] + if (im && im.uri) deps.images.push(im.uri) + } + } + + return deps +} + +gltf.pull_images = function(asset, opts) { + if (opts == null) opts = {} + + var mode = opts.mode + if (mode == null) mode = "all" + + var on_missing = opts.on_missing + if (on_missing == null) on_missing = "warn" + + var uri_loader = opts.uri_loader + if (uri_loader == null) uri_loader = load_uri_default + + var dedupe = opts.dedupe + if (dedupe == null) dedupe = true + + var max_total_bytes = opts.max_total_bytes + if (max_total_bytes == null) max_total_bytes = 0 + + ensure_image_slots(asset) + + var used = null + if (mode == "used") used = used_image_indices(asset) + + var cache = {} + var total = 0 + + for (var i = 0; i < asset.images.length; i++) { + if (used && !used[i]) continue + var img = asset.images[i] + if (!img) continue + if (img.encoded) continue + + if (img.kind == "buffer_view") { + var encoded = extract_buffer_view(asset, img.view) + if (!encoded) { + if (on_missing == "throw") throw new Error("missing embedded image bytes") + if (on_missing == "warn") warn(asset, "missing embedded image bytes", "collect") + continue + } + img.encoded = encoded + total += encoded.length / 8 + continue + } + + if (img.kind == "uri") { + if (!img.uri) { + if (on_missing == "throw") throw new Error("image has null uri") + if (on_missing == "warn") warn(asset, "image has null uri", "collect") + continue + } + + var key = img.uri + if (dedupe && cache[key]) { + img.encoded = cache[key] + continue + } + + try { + var encoded = load_uri(img.uri, asset.base_dir, uri_loader) + if (!stone.p(encoded)) stone(encoded) + img.encoded = encoded + if (dedupe) cache[key] = encoded + total += encoded.length / 8 + if (max_total_bytes > 0 && total > max_total_bytes) throw new Error("max_total_bytes exceeded") + } catch (e) { + if (on_missing == "throw") throw e + if (on_missing == "warn") warn(asset, "missing image uri: " + img.uri, "collect") + } + continue + } + } + + return asset +} + +function guess_mime(img) { + if (img.mime) return img.mime + if (img.uri) { + var u = img.uri.toLowerCase() + if (ends_with(u, ".png")) return "image/png" + if (ends_with(u, ".jpg") || ends_with(u, ".jpeg")) return "image/jpeg" + } + return null +} + +function premultiply_rgba(pixels_blob) { + if (!stone.p(pixels_blob)) stone(pixels_blob) + var bytes = pixels_blob.length / 8 + var out = new blob(bytes * 8) + for (var i = 0; i < bytes; i += 4) { + var r = pixels_blob.read_fit((i + 0) * 8, 8) + var g = pixels_blob.read_fit((i + 1) * 8, 8) + var b = pixels_blob.read_fit((i + 2) * 8, 8) + var a = pixels_blob.read_fit((i + 3) * 8, 8) + var rr = Math.floor((r * a) / 255) + var gg = Math.floor((g * a) / 255) + var bb = Math.floor((b * a) / 255) + out.write_fit(rr, 8) + out.write_fit(gg, 8) + out.write_fit(bb, 8) + out.write_fit(a, 8) + } + stone(out) + return out +} + +gltf.decode_images = function(asset, opts) { + if (opts == null) opts = {} + + var mode = opts.mode + if (mode == null) mode = "used" + + var format = opts.format + if (format == null) format = "rgba8" + + var on_missing_encoded = opts.on_missing_encoded + if (on_missing_encoded == null) on_missing_encoded = "pull" + + var decoder = opts.decoder + if (decoder == null) decoder = function(mime, b) { + if (mime == "image/png") return png.decode(b) + if (mime == "image/jpeg") return jpg.decode(b) + return null + } + + var premultiply_alpha = opts.premultiply_alpha + if (premultiply_alpha == null) premultiply_alpha = false + + ensure_image_slots(asset) + + if (on_missing_encoded == "pull") { + gltf.pull_images(asset, { mode: mode }) + } + + var used = null + if (mode == "used") used = used_image_indices(asset) + + for (var i = 0; i < asset.images.length; i++) { + if (used && !used[i]) continue + var img = asset.images[i] + if (!img) continue + if (img.pixels) continue + if (!img.encoded) { + if (on_missing_encoded == "throw") throw new Error("missing encoded image data") + continue + } + + var mime = guess_mime(img) + if (!mime) { + warn(asset, "unknown image mime", "collect") + continue + } + + var decoded = decoder(mime, img.encoded) + if (!decoded) { + warn(asset, "unsupported image mime: " + mime, "collect") + continue + } + + var pixels = decoded.pixels + if (premultiply_alpha) pixels = premultiply_rgba(pixels) + + img.pixels = { + width: decoded.width, + height: decoded.height, + pixels: pixels, + format: format, + pitch: decoded.pitch + } + } + + return asset +} + +gltf.drop_images = function(asset, what) { + if (what == null) what = "all" + ensure_image_slots(asset) + + for (var i = 0; i < asset.images.length; i++) { + var img = asset.images[i] + if (!img) continue + if (what == "encoded" || what == "all") img.encoded = null + if (what == "pixels" || what == "all") img.pixels = null + } + + return asset +} + +gltf.stats = function(asset) { + var stats = { + meshes: (asset.meshes ? asset.meshes.length : 0), + nodes: (asset.nodes ? asset.nodes.length : 0), + images: (asset.images ? asset.images.length : 0), + textures: (asset.textures ? asset.textures.length : 0), + materials: (asset.materials ? asset.materials.length : 0), + animations: (asset.animations ? asset.animations.length : 0), + skins: (asset.skins ? asset.skins.length : 0), + bin_bytes: 0, + encoded_image_bytes: 0, + triangles: 0 + } + + if (asset.buffers) { + for (var i = 0; i < asset.buffers.length; i++) { + var b = asset.buffers[i] + if (b && b.blob) stats.bin_bytes += b.blob.length / 8 + } + } + + if (asset.images) { + for (var i = 0; i < asset.images.length; i++) { + var im = asset.images[i] + if (im && im.encoded) stats.encoded_image_bytes += im.encoded.length / 8 + } + } + + if (asset.meshes) { + for (var mi = 0; mi < asset.meshes.length; mi++) { + var m = asset.meshes[mi] + if (!m || !m.primitives) continue + for (var pi = 0; pi < m.primitives.length; pi++) { + var p = m.primitives[pi] + if (!p) continue + if (p.topology != "triangles") continue + if (p.indices != null) { + var acc = asset.accessors[p.indices] + if (acc) stats.triangles += Math.floor((acc.count || 0) / 3) + } else if (p.attributes && p.attributes.POSITION != null) { + var acc = asset.accessors[p.attributes.POSITION] + if (acc) stats.triangles += Math.floor((acc.count || 0) / 3) + } + } + } + } + + asset.stats = stats + return asset +} + +gltf.load = function(input, opts) { + if (opts == null) opts = {} + + var pull = opts.pull_images + if (pull == null) pull = true + + var decode = opts.decode_images + if (decode == null) decode = false + + var mode = opts.mode + if (mode == null) mode = "used" + + var asset = gltf.decode(input, opts) + if (pull) gltf.pull_images(asset, { mode: mode }) + if (decode) gltf.decode_images(asset, { mode: mode }) + return asset +} + +return gltf \ No newline at end of file