#!/usr/bin/env python3 """ bundle.py -- pack DPL3 models (.B2Z) + textures (.SVT) into a single, self-contained WebGL viewer HTML. No server, no dependencies, no external assets: geometry is inlined as JSON and textures as base64 RGBA. Open the output in any browser. python viewer/bundle.py # builds viewer/dpl3-viewer.html from SHOWCASE The viewer proves the whole recovered art pipeline end-to-end: B2Z geometry + UVs rendered on modern hardware, textured with decoded SVT bitmaps. """ import base64 import glob import json import math import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) sys.path.insert(0, os.path.join(ROOT, "parser")) import b2z import scn import spl import bsl import gamemap import svt # the read-only source archive (models/textures are resolved by name from here) ARCHIVE = r"s:/OneDrive/Tesla III/DaveMcCoy/sda4" # ---- what to put in the viewer --------------------------------------------- # Each model: file + per-material {color | texture}. Material is matched by the # f_material name carried in the B2Z; "*" is the fallback for any other material. SAMPLES = os.path.join(ROOT, "samples") SHOWCASE = [ { "name": "Raptor terrain flyover (fly-through)", "scene": "DPL3RLS/SCENES/RAPTOR.SCN", "spline": "DPL3RLS/EXAMPLES/CAMERA.SPL", "up": "y", }, { "name": "Red Planet canal (assembled scene, 47 objects)", "scene": "DPL3RLS/SCENES/CANAL.SCN", "up": "y", }, { "name": "Aquarium reef (underwater scene)", "scene": "DPL3/EXAMPLES/SHARKS.SCN", "up": "y", }, { "name": "Star Trek — the Tesla (TREK.SCN)", "scene": "STDAVE/TREK.SCN", "up": "y", }, { "name": "Red Planet canyon (textured terrain)", "file": "CANYONS1.B2Z", "up": "z", "materials": { "redrock_mtl": {"texture": "ROCKNOIZ.SVT", "tint": [1.0, 0.55, 0.4]}, "water1_mtl": {"color": [0.15, 0.28, 0.5]}, "*": {"texture": "ROCKNOIZ.SVT", "tint": [1.0, 0.6, 0.45]}, }, }, { "name": "Test box (multi-material, per-part colour)", "file": "TESTBOX.B2Z", "up": "y", "materials": {"*": {"from_material": True}}, }, { "name": "Sphere (smooth-shaded)", "file": "BALL.B2Z", "up": "y", "materials": {"*": {"color": [0.72, 0.74, 0.8]}}, }, ] def compute_normals(positions, indices): n = [0.0] * len(positions) for t in range(0, len(indices), 3): i, j, k = indices[t] * 3, indices[t + 1] * 3, indices[t + 2] * 3 ax, ay, az = positions[i], positions[i + 1], positions[i + 2] bx, by, bz = positions[j], positions[j + 1], positions[j + 2] cx, cy, cz = positions[k], positions[k + 1], positions[k + 2] ux, uy, uz = bx - ax, by - ay, bz - az vx, vy, vz = cx - ax, cy - ay, cz - az nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx for base in (i, j, k): n[base] += nx; n[base + 1] += ny; n[base + 2] += nz for o in range(0, len(n), 3): L = math.sqrt(n[o]**2 + n[o+1]**2 + n[o+2]**2) or 1.0 n[o] /= L; n[o+1] /= L; n[o+2] /= L return n _TEX_INFO = {} def load_texture_rgba(path): """Decode a texture ref to (edge, rgba, is_luminance). Refs are .SVT paths or ("bsl", path, slice) bit-slice picks. Full-colour painted skins (vehicle textures) are shown as-is. Greyscale 'noise' maps, bit-slices, and broken intensity maps (a dead colour channel) are rebuilt as luminance and flagged -- those get coloured by the material's diffuse.""" if path in _TEX_INFO: return _TEX_INFO[path] if isinstance(path, tuple) and path[0] == "bsl": b = bsl.load(path[1]) rgba = bsl.slice_plane(b, path[2]) res = (b["edge"], rgba, True) _TEX_INFO[path] = res return res data, edge = svt.load(path) rgba = bytearray(svt.to_rgba(data, "xbgr")) chan_max = [max(rgba[c::4]) for c in range(3)] is_lum = False if min(chan_max) < 32: # a dead channel -> intensity map for i in range(0, len(rgba), 4): L = max(rgba[i], rgba[i+1], rgba[i+2]) rgba[i] = rgba[i+1] = rgba[i+2] = L is_lum = True else: # inherently greyscale (low saturation)? sat = n = 0 for i in range(0, len(rgba), 4 * 17): sat += max(rgba[i], rgba[i+1], rgba[i+2]) - min(rgba[i], rgba[i+1], rgba[i+2]) n += 1 if n and sat / n < 22: is_lum = True res = (edge, bytes(rgba), is_lum) _TEX_INFO[path] = res return res def build_texture(svt_path, cache): if svt_path in cache: return cache[svt_path] edge, rgba, _is_lum = load_texture_rgba(svt_path) idx = len(cache) cache[svt_path] = idx build_texture.records.append({ "w": edge, "h": edge, "rgba": base64.b64encode(rgba).decode("ascii"), }) return idx # ---- scene assembly -------------------------------------------------------- _INDEX = None _MODEL_CACHE = {} _MATS_CACHE = {} FLIP_V = False # The original renderer indexed texture rows directly: v=0 = SVT # row 0 (image top). Verified by A/B renders: mech pod vents vs # missile tubes, the MADCOL "08" insignia, and the shark's # back/belly shading are all correct UNFLIPPED and inverted when # flipped. GL's texImage2D also puts the first uploaded row at # t=0, so v passes through unchanged. def load_model_cached(path): if path not in _MODEL_CACHE: _MODEL_CACHE[path] = b2z.load(path) return _MODEL_CACHE[path] def load_mats_cached(geo_path): if geo_path not in _MATS_CACHE: v = v2z_for(geo_path) _MATS_CACHE[geo_path] = parse_v2z_materials(v) if v else {} return _MATS_CACHE[geo_path] def archive_index(): """basename(lower, no ext) -> path indexes over the read-only archive: geo : geometry (.B2Z/.BIZ first, then game-side .BGF) tex : .SVT texture maps bsl : .BSL bit-slice texture packs v2z : .V2Z text model sources (materials) mtl : material libraries (.VMF text preferred over .BMF binary)""" global _INDEX if _INDEX is None: geo, tex, v2z, bsl, mtl = {}, {}, {}, {}, {} for ext in ("B2Z", "BIZ", "BGF"): for f in glob.glob(ARCHIVE + "/**/*." + ext, recursive=True): geo.setdefault(os.path.basename(f)[:-4].lower(), f) for f in glob.glob(ARCHIVE + "/**/*.SVT", recursive=True): tex.setdefault(os.path.basename(f)[:-4].lower(), f) for f in glob.glob(ARCHIVE + "/**/*.BSL", recursive=True): bsl.setdefault(os.path.basename(f)[:-4].lower(), f) for f in glob.glob(ARCHIVE + "/**/*.V2Z", recursive=True): v2z.setdefault(os.path.basename(f)[:-4].lower(), f) for f in glob.glob(ARCHIVE + "/**/*.VMF", recursive=True): mtl.setdefault(os.path.basename(f)[:-4].lower(), f) for f in glob.glob(ARCHIVE + "/**/*.BMF", recursive=True): mtl.setdefault(os.path.basename(f)[:-4].lower(), f) mod = {} for f in glob.glob(ARCHIVE + "/**/*.MOD", recursive=True): mod.setdefault(os.path.basename(f)[:-4].lower(), f) _INDEX = {"geo": geo, "tex": tex, "v2z": v2z, "bsl": bsl, "mtl": mtl, "mod": mod} return _INDEX def v2z_for(geo_path): """The material text source matching a geometry file -- SAME directory/basename first (geometry and materials must come from one source; different asset dirs define the same material name with different colours), else any same-name V2Z. Game-side .BGF models pair with a .VGF text twin the same way.""" if not geo_path: return None base = os.path.splitext(geo_path)[0] for ext in (".V2Z", ".v2z", ".VGF", ".vgf"): if os.path.exists(base + ext): return base + ext return archive_index()["v2z"].get(os.path.basename(base).lower()) _NAME_RE = re.compile(r'NAME\s*=\s*"?([\w.]+)"?', re.I) _SPECIAL_RE = re.compile(r'SPECIAL\s*=\s*"([^"]*)"', re.I) _SCROLL_RE = re.compile(r"SCROLL\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)") _MAP_RE = re.compile(r"MAP\s*\{\"?(\w+)\"?\}") _DIF_RE = re.compile(r"DIFFUSE\s*\{([^}]*)\}") _MTLTEX_RE = re.compile(r"TEXTURE\s*\{\"?([\w.]+)\"?\}") _SLICE_RE = re.compile(r"BITSLICE\s*\{(\d+)\}") def _blocks(text, kw): """yield (header, body) for `KW(header) {body}` blocks, brace-matched. Two source dialects exist: modeller exports (NAME=x, `// End of ...` marker comments) and the compact game libraries (NAME="x", no markers, SPECIAL= hooks in the header). Matching braces handles both; `KW {...}` references (no parens) inside other bodies are not block starts and are skipped.""" pos = 0 while True: j = text.find(kw + "(", pos) if j < 0: return if j and (text[j - 1].isalnum() or text[j - 1] == "_"): pos = j + len(kw) continue h_end = text.find(")", j) if h_end < 0: return b = text.find("{", h_end) nxt = text.find(kw + "(", h_end) if b < 0 or (0 <= nxt < b): # headerless block (no body braces) yield text[j + len(kw) + 1:h_end], "" pos = h_end + 1 continue depth, e = 0, b for e in range(b, len(text)): c = text[e] if c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: break yield text[j + len(kw) + 1:h_end], text[b + 1:e] pos = e + 1 def _parse_scroll(special): """texture SPECIAL hook ' SCROLL u0 v0 du dv' -> [u0,v0,du,dv] or None.""" if not special: return None m = _SCROLL_RE.search(special) return [float(m.group(i)) for i in range(1, 5)] if m else None def parse_v2z_materials(path): """Authoritative materials from a .V2Z/.VGF/.VMF text source: name -> (diffuse[3], map_name_or_None, bitslice, scroll_or_None, texname). scroll is the texture entity's SPECIAL " SCROLL u0 v0 du dv" hook (UV drift rates -- rivers, smoke, laser beams); texname is the TEXTURE entity name a scene-side SCROLL override addresses.""" try: t = open(path, encoding="latin-1").read() except OSError: return {} texmap = {} for header, body in _blocks(t, "TEXTURE"): nm = _NAME_RE.search(header) if not nm: continue mm = _MAP_RE.search(body) sl = _SLICE_RE.search(body) sp = _SPECIAL_RE.search(header) texmap[nm.group(1).lower()] = (mm.group(1).lower() if mm else None, int(sl.group(1)) if sl else 0, _parse_scroll(sp.group(1) if sp else None)) out = {} for header, body in _blocks(t, "MATERIAL"): nm = _NAME_RE.search(header) if not nm: continue dif = _DIF_RE.search(body) diffuse = [float(x) for x in dif.group(1).split(",")] if dif else [0.7, 0.7, 0.7] tx = _MTLTEX_RE.search(body) texname = tx.group(1).lower() if tx else None tmap, sl, scroll = texmap.get(texname, (None, 0, None)) if texname else (None, 0, None) out[nm.group(1).lower()] = (diffuse, tmap, sl, scroll, texname) return out _LIB_CACHE = {} def load_material_library(libname): """Load a named material library (`stships` in `stships:eblu4_mtl`). Text .VMF preferred; binary .BMF parsed with the b2z reader (which now captures the 0x018 BITSLICE tag).""" key = libname.lower() if key in _LIB_CACHE: return _LIB_CACHE[key] path = archive_index()["mtl"].get(key) mats = {} if path and path.lower().endswith((".vmf", ".vgf", ".v2z")): mats = parse_v2z_materials(path) elif path: try: lib = b2z.load(path) for name, mt in lib.materials.items(): tx = lib.textures.get(mt.texture) if mt.texture else None mats[name.lower()] = (list(mt.diffuse), tx.mapfile.lower() if tx and tx.mapfile else None, tx.bitslice if tx else 0, _parse_scroll(tx.special) if tx else None, mt.texture.lower() if mt.texture else None) except Exception: mats = {} _LIB_CACHE[key] = mats return mats # fallback surface colours when a material has no texture (keyed by name substring) COLOR_HINTS = [ ("water", [0.15, 0.28, 0.5]), ("redrock", [0.55, 0.32, 0.24]), ("rednoi", [0.58, 0.3, 0.24]), ("rock", [0.5, 0.42, 0.38]), ("techy", [0.5, 0.52, 0.57]), ("grey", [0.55, 0.55, 0.58]), ("metal", [0.55, 0.57, 0.6]), ("sky", [0.4, 0.6, 0.8]), ] # when a material has no exact .SVT, fall back to a representative texture # for common terrain surfaces (the authoritative map lives in material libraries # we haven't decoded; this is a documented plausibility fallback -- see SCN spec). # keyword -> (texture stem, tint). Grey noise textures are tinted by the material # colour, as the original renderer did (e.g. red rock = grey rock-noise * red diffuse). TEXTURE_HINTS = [ ("redrock", ("rocknoiz", [1.0, 0.50, 0.38])), ("rednoi", ("rocknoiz", [1.0, 0.50, 0.38])), ("cliff", ("rocknoiz", [1.0, 0.50, 0.38])), ("rock", ("rocknoiz", [0.95, 0.62, 0.52])), ("techy", ("generic", [0.78, 0.80, 0.85])), ] def surface_for(matname, has_uv, idx, tint=(1.0, 1.0, 1.0)): """Resolve a material name to (texture_path_or_None, color). First the exact stem==texture convention (e.g. 'vtvcab_mtl' -> VTVCAB.SVT), then a keyword texture fallback (tinted), then a flat colour.""" stem = (matname or "").lower() if stem.endswith("_mtl"): stem = stem[:-4] if has_uv and stem in idx["tex"]: return idx["tex"][stem], list(tint) if has_uv: for key, (tname, ttint) in TEXTURE_HINTS: if key in stem and tname in idx["tex"]: return idx["tex"][tname], list(ttint) for key, col in COLOR_HINTS: if key in stem: return None, col return None, [0.62, 0.6, 0.6] def bake_instances(instances, idx, scrolls=None): """Bake a list of (model, materials, matrix, model_name) into merged, textured surfaces in world space. Shared by whole scenes and single-model previews. `scrolls` is the scene's SCROLL override map: texture entity id ("lib:name" or plain name) -> [u0,v0,du,dv]; a texture's own SPECIAL scroll applies even without an override. Returns (groups, tex_records, bounds, tris).""" surfaces = {} # (texpath, colour) -> dict(pos,normal,uv,idx,color,texture) tex_ids = {} tex_records = [] def resolve(matname, has_uv, mats, model_name=None): """-> (texture_ref|None, colour, from_material, scroll|None, texid|None). A texture_ref is an .SVT path or ("bsl", path, slice) for bit-slice packs; texid is the addressable texture entity ("lib:name" / plain name). Namespaced names (`stships:eblu4_mtl`, the game-side convention) resolve through the named material library (.VMF/.BMF) and use its declared map directly. Plain names use the model's own materials with candidates: per-part skin .SVT, the declared map, then the model-name skin.""" mname = (matname or "").lower() if ":" in mname: lib, sub = mname.split(":", 1) info = load_material_library(lib).get(sub) if info is not None: diffuse, tmap, sl, scroll, texname = info tref = None if has_uv and tmap: if tmap in idx["bsl"]: tref = ("bsl", idx["bsl"][tmap], sl) elif tmap in idx["tex"]: tref = idx["tex"][tmap] return tref, list(diffuse), True, scroll, \ (lib + ":" + texname) if texname else None return None, [0.62, 0.6, 0.6], True, None, None info = mats.get(mname) stem = mname[:-4] if mname.endswith("_mtl") else mname tmap, sl, scroll, texname = (info[1], info[2], info[3], info[4]) if info \ else (None, 0, None, None) tref = None if has_uv: for cand in (stem, tmap, model_name): if cand and cand in idx["tex"]: tref = idx["tex"][cand] break if tref is None and tmap and tmap in idx["bsl"]: tref = ("bsl", idx["bsl"][tmap], sl) if info is not None: return tref, list(info[0]), True, scroll, texname if tref is not None: # generic material, but a model skin exists return tref, [1.0, 1.0, 1.0], True, None, None tp, color = surface_for(matname, has_uv, idx) return tp, color, False, None, None def get_surface(matname, has_uv, mats, model_name=None, anim=None, akey=None): tpath, color, from_mat, scroll, texid = resolve(matname, has_uv, mats, model_name) if anim is None and tpath is not None: rates = (scrolls or {}).get(texid, scroll) if rates and (rates[2] or rates[3]): anim = {"type": "scroll", "rate": [rates[2], rates[3]], "phase": [rates[0], rates[1]]} akey = ("scroll", texid, rates[2], rates[3]) tid = -1 if tpath is not None: edge, rgba, is_lum = load_texture_rgba(tpath) if from_mat and not is_lum: color = [1.0, 1.0, 1.0] # full-colour skin: show texture as-is if tpath not in tex_ids: tex_ids[tpath] = len(tex_records) tex_records.append({"w": edge, "h": edge, "rgba": base64.b64encode(rgba).decode("ascii")}) tid = tex_ids[tpath] # key on texture AND colour so materials sharing a texture but differing # in tint stay separate; animated parts get their own surfaces (akey) so # the viewer can move them independently of the static batch. key = (tpath, tuple(round(c, 4) for c in color), akey) if key not in surfaces: surfaces[key] = {"positions": [], "normals": [], "uvs": [], "indices": [], "color": color, "texture": tid} if anim: surfaces[key]["anim"] = anim return surfaces[key] for inst in instances: model, mats, M, mname = inst[0], inst[1], inst[2], inst[3] anim = inst[4] if len(inst) > 4 else None if model is None: continue akey = id(anim) if anim else None for g in model.geogroups: uv_present = any(geom.verts and geom.verts[0].uv for geom in g.geoms) surf = get_surface(g.f_material, uv_present, mats, mname, anim, akey) for geom in g.geoms: gbase = len(surf["positions"]) // 3 for v in geom.verts: wx, wy, wz = scn.xform_point(M, v.pos[0], v.pos[1], v.pos[2]) surf["positions"] += [wx, wy, wz] if v.normal: nx, ny, nz = scn.xform_dir(M, *v.normal) else: nx, ny, nz = 0.0, 1.0, 0.0 surf["normals"] += [nx, ny, nz] if v.uv: surf["uvs"] += [v.uv[0], (1.0 - v.uv[1]) if FLIP_V else v.uv[1]] else: surf["uvs"] += [0.0, 0.0] for (a, b, c) in b2z.triangles(geom): surf["indices"] += [gbase + a, gbase + b, gbase + c] groups = [] for s in surfaces.values(): if not s["indices"]: continue g = { "positions": [round(x, 3) for x in s["positions"]], "normals": [round(x, 4) for x in s["normals"]], "uvs": [round(x, 4) for x in s["uvs"]], "indices": s["indices"], "color": s["color"], "texture": s["texture"], } if s.get("anim"): g["anim"] = s["anim"] groups.append(g) xs = [g["positions"][i] for g in groups for i in range(0, len(g["positions"]), 3)] ys = [g["positions"][i+1] for g in groups for i in range(0, len(g["positions"]), 3)] zs = [g["positions"][i+2] for g in groups for i in range(0, len(g["positions"]), 3)] bounds = {"min": [min(xs), min(ys), min(zs)], "max": [max(xs), max(ys), max(zs)]} if xs \ else {"min": [-1, -1, -1], "max": [1, 1, 1]} tris = sum(len(g["indices"]) for g in groups) // 3 return groups, tex_records, bounds, tris def build_model_archive(geo_path, name): """Build one standalone model (identity transform) with authoritative materials.""" idx = archive_index() model = load_model_cached(geo_path) mats = load_mats_cached(geo_path) groups, tex_records, bounds, tris = bake_instances( [(model, mats, scn.ident(), name.lower())], idx) return {"name": name.upper(), "up": "y", "groups": groups, "textures": tex_records, "bounds": bounds, "tris": tris} def _tree_file(map_path, subdir, base, exts): """Look for /..//. -- assets from the SAME game tree as the map (BTDAVE maps use BTDAVE models, not BTLIVE's).""" root = os.path.dirname(os.path.dirname(map_path)) for d in (os.path.join(root, subdir), os.path.join(root, subdir, "EDITBACK")): for ext in exts: p = os.path.join(d, base + ext) if os.path.exists(p): return p return None def _dropzone_marker(x, y, z): """A small red pyramid marking a pod dropzone.""" s, h = 1.5, 4.0 pos = [x, y + h, z, x - s, y, z - s, x + s, y, z - s, x + s, y, z + s, x - s, y, z + s] idx = [0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 3, 2, 1, 4, 3] return pos, idx def build_map(cfg): """Assemble a game .MAP (arena / track) into a renderable scene.""" map_path = os.path.join(ARCHIVE, cfg["map"]) idx = archive_index() def mod_resolver(modfile): """-> [(bgf_name, local_anim_or_None), ...] for every video object in the MOD. Door leaves get a data-driven slide from the .SUB (MotionExtent / TravelTime / DeadTime); laser-drill objects ('laz*') get a glow pulse.""" base = os.path.splitext(os.path.basename(modfile or ""))[0].lower() if not base: return [] p = _tree_file(map_path, "MODELS", base, (".MOD",)) or idx["mod"].get(base) if not p: return [] names, gclass, subfile = gamemap.parse_mod_objects(p) doors = {} if subfile: sp = os.path.join(os.path.dirname(p), os.path.basename(subfile)) if os.path.exists(sp): doors = gamemap.parse_sub(sp) out = [] for n in names: anim = None if n in doors: d = doors[n] anim = {"type": "slide", "extent": d["extent"], "travel": d["travel"], "dead": d["dead"]} elif n.startswith("laz"): anim = {"type": "pulse"} # laser drill: engine-driven; approximated out.append((n, anim)) return out placements, dropzones = gamemap.expand(map_path, mod_resolver) instances, missing = [], set() for (bgf, anim), M in placements: p = _tree_file(map_path, "VIDEO", bgf, (".BGF",)) or idx["geo"].get(bgf) if not p: missing.add(bgf) continue wanim = None if anim and anim.get("type") == "slide": ex, ey, ez = scn.xform_dir(M, *anim["extent"]) # local -> world axis wanim = {"type": "slide", "extent": [round(ex, 3), round(ey, 3), round(ez, 3)], "travel": anim["travel"], "dead": anim["dead"]} elif anim: wanim = dict(anim) instances.append((load_model_cached(p), load_mats_cached(p), M, bgf, wanim)) groups, tex_records, bounds, tris = bake_instances(instances, idx) if dropzones: pos, nrm, uv, ind = [], [], [], [] for d in dropzones: mp, mi = _dropzone_marker(d[0], d[1], d[2]) base = len(pos) // 3 pos += mp nrm += [0.0, 1.0, 0.0] * (len(mp) // 3) uv += [0.0, 0.0] * (len(mp) // 3) ind += [base + i for i in mi] groups.append({"positions": [round(v, 3) for v in pos], "normals": nrm, "uvs": uv, "indices": ind, "color": [0.9, 0.1, 0.1], "texture": -1}) out = {"name": cfg["name"], "up": "y", "groups": groups, "textures": tex_records, "bounds": bounds, "tris": tris, "background": [0.05, 0.05, 0.08]} print(" map %-22s %d placements (%d missing: %s), %d dropzones, %d surfaces" % (os.path.basename(cfg["map"]), len(placements), len(missing), sorted(missing)[:4], len(dropzones), len(groups))) return out def build_scene(cfg): sc = scn.load(os.path.join(ARCHIVE, cfg["scene"])) idx = archive_index() cache, mat_cache = {}, {} for inst in sc.instances: n = inst.name.lower() if n not in cache: path = idx["geo"].get(n) cache[n] = load_model_cached(path) if path else None mat_cache[n] = load_mats_cached(path) if path else {} instances = [(cache.get(i.name.lower()), mat_cache.get(i.name.lower(), {}), i.matrix, i.name.lower()) for i in sc.instances] placed = sum(1 for m, _, _, _ in instances if m is not None) groups, tex_records, bounds, tris = bake_instances(instances, idx, sc.scrolls) out = { "name": cfg["name"], "up": cfg.get("up", "y"), "groups": groups, "textures": tex_records, "bounds": bounds, "tris": tris, "background": list(sc.background), } if sc.fog: z0, z1, r, g, b = sc.fog out["fog"] = [r, g, b, z0, z1] if cfg.get("spline"): frames = spl.sample_flythrough(os.path.join(ARCHIVE, cfg["spline"])) out["flythrough"] = [{"eye": [round(x, 2) for x in f["eye"]], "center": [round(x, 2) for x in f["center"]], "up": [round(x, 3) for x in f["up"]]} for f in frames] print(" scene %-22s %d/%d objects placed, %d surfaces, %d textures%s" % (os.path.basename(cfg["scene"]), placed, len(sc.instances), len(groups), len(tex_records), ", %d camera frames" % len(out["flythrough"]) if "flythrough" in out else "")) return out def build_model(cfg): if "scene" in cfg: return build_scene(cfg) model = b2z.load(os.path.join(SAMPLES, cfg["file"])) idx = archive_index() v2z = idx["v2z"].get(os.path.splitext(cfg["file"])[0].lower()) mats = parse_v2z_materials(v2z) if v2z else {} tex_cache = {} build_texture.records = [] groups = [] for g in model.geogroups: # merge every geometry in the geogroup into one indexed mesh pos, uv, ind = [], [], [] have_uv = False for geom in g.geoms: base = len(pos) // 3 for v in geom.verts: pos += [v.pos[0], v.pos[1], v.pos[2]] if v.uv: have_uv = True uv += [v.uv[0], 1.0 - v.uv[1]] # flip V for GL origin else: uv += [0.0, 0.0] for (a, b, c) in b2z.triangles(geom): ind += [base + a, base + b, base + c] if not ind: continue # resolve material -> colour / texture. Prefer the model's own authoritative # material (.V2Z: diffuse x texture); fall back to the showcase cfg. color = [0.7, 0.7, 0.72] tex = -1 info = mats.get((g.f_material or "").lower()) if info is not None: diffuse, tmap = info[0], info[1] color = list(diffuse) if have_uv and tmap and tmap in idx["tex"]: tex = build_texture(idx["tex"][tmap], tex_cache) # modulated by diffuse else: spec = cfg.get("materials", {}).get(g.f_material) or cfg.get("materials", {}).get("*", {}) tint = spec.get("tint", [1.0, 1.0, 1.0]) if spec.get("from_material") and g.f_material in model.materials: d = model.materials[g.f_material].diffuse color = [d[0], d[1], d[2]] elif "color" in spec: color = spec["color"] if "texture" in spec and have_uv: tex = build_texture(os.path.join(SAMPLES, spec["texture"]), tex_cache) color = tint groups.append({ "positions": [round(x, 4) for x in pos], "normals": [round(x, 4) for x in compute_normals(pos, ind)], "uvs": [round(x, 4) for x in uv], "indices": ind, "color": color, "texture": tex, }) # bounds xs = [g["positions"][i] for g in groups for i in range(0, len(g["positions"]), 3)] ys = [g["positions"][i+1] for g in groups for i in range(0, len(g["positions"]), 3)] zs = [g["positions"][i+2] for g in groups for i in range(0, len(g["positions"]), 3)] bounds = {"min": [min(xs), min(ys), min(zs)], "max": [max(xs), max(ys), max(zs)]} tris = sum(len(g["indices"]) for g in groups) // 3 return { "name": cfg["name"], "up": cfg["up"], "groups": groups, "textures": build_texture.records, "bounds": bounds, "tris": tris, } def main(): scene = {"models": [build_model(c) for c in SHOWCASE]} payload = json.dumps(scene, separators=(",", ":")) html = TEMPLATE.replace("/*__SCENE__*/null", payload) out = os.path.join(HERE, "dpl3-viewer.html") with open(out, "w", encoding="utf-8") as fp: fp.write(html) kb = len(html) / 1024 print("wrote %s (%.0f KB, %d models)" % (out, kb, len(scene["models"]))) for m in scene["models"]: print(" - %-42s %5d tris, %d textures" % (m["name"], m["tris"], len(m["textures"]))) # ---- the viewer (content-only: no // so it doubles as an # Artifact source; browsers render it fine opened directly too) ---------- TEMPLATE = r"""DPL3 Model Viewer
DPL3 · recovered art

Model Viewer

Virtual World Entertainment, c.1994 — on modern hardware
triangles
geo​groups
textures
drag to orbit · scroll to zoom
""" if __name__ == "__main__": main()