feat(web): complete PBR shader, asset library and offscreen render chain
- generate a desktop-Blender PBR asset library and seed catalog v2 into OPFS - add React asset browser, material preview sphere and Web PBR render panel - extend shader graph/compiler to the finite 14-node set and native properties - add image colour-space editing, scene-camera still rendering and asset repair - add focused E2E/unit coverage and implementation status notes Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
121
tools/web/generate-pbr-library-manifest.mjs
Normal file
121
tools/web/generate-pbr-library-manifest.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compose the catalog-v2 manifest for the generated PBR asset library.
|
||||
*
|
||||
* Inputs (produced by generate-pbr-library.py + export-asset-catalog-v1.py):
|
||||
* <dir>/desktop-canonical.json
|
||||
* <dir>/preview-manifest.json
|
||||
* <dir>/pbr-library.blend
|
||||
*
|
||||
* Output:
|
||||
* <dir>/manifest.json
|
||||
*
|
||||
* Usage: node tools/web/generate-pbr-library-manifest.mjs [--dir <path>]
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const REPO = path.resolve(import.meta.dirname, "../..");
|
||||
const dirIndex = process.argv.indexOf("--dir");
|
||||
const DIR = dirIndex > 0 && process.argv[dirIndex + 1]
|
||||
? path.resolve(process.argv[dirIndex + 1])
|
||||
: path.join(REPO, "web/app/public/assets/pbr-library");
|
||||
|
||||
const LIBRARY_ID = "library:pbr-essentials-v1";
|
||||
const LIBRARY_NAME = "PBR Essentials";
|
||||
const LIBRARY_SOURCE = "libraries/pbr-library.blend";
|
||||
|
||||
const readJson = (name) => JSON.parse(fs.readFileSync(path.join(DIR, name), "utf8"));
|
||||
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
||||
|
||||
async function loadProtocol() {
|
||||
const sourcePath = path.join(REPO, "web/protocol/asset-catalog-v2.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const temporary = path.join(DIR, ".asset-catalog-v2.generated.mjs");
|
||||
fs.writeFileSync(temporary, transpiled.outputText);
|
||||
try {
|
||||
return await import(`${pathToFileURL(temporary).href}?v=${Date.now()}`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const desktop = readJson("desktop-canonical.json");
|
||||
const previewManifest = readJson("preview-manifest.json");
|
||||
const libraryBytes = fs.readFileSync(path.join(DIR, "pbr-library.blend"));
|
||||
const sourceSha256 = sha256(libraryBytes);
|
||||
|
||||
const previews = new Map();
|
||||
for (const entry of previewManifest.previews) {
|
||||
const key = `${entry.idType}:${entry.name}`;
|
||||
if (previews.has(key)) throw new Error(`duplicate preview ${key}`);
|
||||
if (!fs.existsSync(path.join(DIR, entry.file))) throw new Error(`missing preview file ${entry.file}`);
|
||||
const bytes = fs.readFileSync(path.join(DIR, entry.file));
|
||||
if (sha256(bytes) !== entry.sha256) throw new Error(`preview hash mismatch ${entry.file}`);
|
||||
previews.set(key, {
|
||||
sha256: entry.sha256,
|
||||
mimeType: "image/png",
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
byteLength: entry.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
const protocol = await loadProtocol();
|
||||
const base = await protocol.createAssetCatalogManifestV2FromDesktop(desktop, 1);
|
||||
|
||||
const assets = [];
|
||||
for (const asset of base.assets) {
|
||||
const key = `${asset.idType}:${asset.name}`;
|
||||
const preview = previews.get(key);
|
||||
if (!preview) throw new Error(`asset ${key} has no preview`);
|
||||
const assetLibraryIdentifier = LIBRARY_ID;
|
||||
assets.push({
|
||||
...asset,
|
||||
assetLibraryIdentifier,
|
||||
assetId: await protocol.createAssetCatalogV2StableId({
|
||||
assetLibraryIdentifier,
|
||||
relativeAssetIdentifier: asset.relativeAssetIdentifier,
|
||||
}),
|
||||
sourcePath: LIBRARY_SOURCE,
|
||||
sourceSha256,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
catalogs: base.catalogs,
|
||||
assets,
|
||||
libraries: [{
|
||||
libraryId: LIBRARY_ID,
|
||||
name: LIBRARY_NAME,
|
||||
sourcePath: LIBRARY_SOURCE,
|
||||
sourceSha256,
|
||||
dependencyIds: [],
|
||||
readOnly: true,
|
||||
}],
|
||||
};
|
||||
|
||||
const manifest = await protocol.parseAssetCatalogManifestV2(candidate);
|
||||
fs.writeFileSync(path.join(DIR, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
const totalBytes = assets.reduce((sum, asset) => sum + (asset.preview?.byteLength ?? 0), 0);
|
||||
console.log(JSON.stringify({
|
||||
manifest: "manifest.json",
|
||||
catalogs: manifest.catalogs.length,
|
||||
assets: manifest.assets.length,
|
||||
libraries: manifest.libraries.length,
|
||||
previewBytes: totalBytes,
|
||||
sourceSha256,
|
||||
}, null, 2));
|
||||
462
tools/web/generate-pbr-library.py
Normal file
462
tools/web/generate-pbr-library.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""Generate the Web Blender PBR asset library with desktop Blender.
|
||||
|
||||
Usage:
|
||||
build_blender_native/bin/blender --background --factory-startup \
|
||||
--python tools/web/generate-pbr-library.py -- OUTPUT_DIRECTORY
|
||||
|
||||
Outputs (inside OUTPUT_DIRECTORY):
|
||||
pbr-library.blend marked Object/Material assets + packed textures
|
||||
blender_assets.cats.txt catalog definition
|
||||
previews/<sha256>.png 256x256 EEVEE previews (material spheres + models)
|
||||
preview-manifest.json preview files keyed by Blender identity
|
||||
build-report.json generator/executable/output hashes
|
||||
LICENSE.txt
|
||||
|
||||
The .blend is saved BEFORE preview-only objects are created, so the library
|
||||
contains only the marked assets.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
CATALOGS = (
|
||||
("a1111111-1111-4111-8111-111111111111", "Models/Characters", "Characters"),
|
||||
("a2222222-2222-4222-8222-222222222222", "Models/Primitives", "Primitives"),
|
||||
("a3333333-3333-4333-8333-333333333333", "Materials/Metal", "Metal"),
|
||||
("a4444444-4444-4444-8444-444444444444", "Materials/Plastic", "Plastic"),
|
||||
("a5555555-5555-4555-8555-555555555555", "Materials/Wood", "Wood"),
|
||||
("a6666666-6666-4666-8666-666666666666", "Materials/Glass", "Glass"),
|
||||
("a7777777-7777-4777-8777-777777777777", "Materials/Emission", "Emission"),
|
||||
)
|
||||
CATALOG_BY_PATH = {path: catalog_id for catalog_id, path, _name in CATALOGS}
|
||||
|
||||
PREVIEW_SIZE = 256
|
||||
PREVIEW_SAMPLES = 16
|
||||
|
||||
ID_OBJECT = 16975 # GS("OB")
|
||||
ID_MATERIAL = 16717 # GS("MA")
|
||||
ID_IMAGE = 19785 # GS("IM")
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def set_input(node, names, value):
|
||||
for name in names:
|
||||
socket = node.inputs.get(name)
|
||||
if socket is not None:
|
||||
socket.default_value = value
|
||||
return socket
|
||||
return None
|
||||
|
||||
|
||||
def new_material(name: str) -> tuple[bpy.types.Material, bpy.types.ShaderNodeBsdfPrincipled]:
|
||||
material = bpy.data.materials.new(name)
|
||||
material.use_nodes = True
|
||||
principled = material.node_tree.nodes.get("Principled BSDF")
|
||||
if principled is None:
|
||||
raise RuntimeError(f"Principled BSDF missing for {name}")
|
||||
return material, principled
|
||||
|
||||
|
||||
def add_wood_texture(material, principled):
|
||||
"""Pack a deterministic 256px wood grain and wire UV Map -> Mapping -> Image."""
|
||||
size = 256
|
||||
pixels = [0.0] * (size * size * 4)
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
warp = 0.18 * math.sin(x * 0.035) + 0.10 * math.sin(y * 0.011)
|
||||
bands = 0.5 + 0.5 * math.sin((x * 0.14) + warp * 6.0)
|
||||
grain = 0.5 + 0.5 * math.sin(y * 0.45 + bands * 3.0)
|
||||
value = 0.16 + 0.30 * bands + 0.10 * grain
|
||||
index = (y * size + x) * 4
|
||||
pixels[index] = value * 1.00
|
||||
pixels[index + 1] = value * 0.52
|
||||
pixels[index + 2] = value * 0.22
|
||||
pixels[index + 3] = 1.0
|
||||
image = bpy.data.images.new("PBR Wood Grain", width=size, height=size, alpha=False)
|
||||
image.pixels = pixels
|
||||
image.pack()
|
||||
image.name = "PBR Wood Grain"
|
||||
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
uv = nodes.new("ShaderNodeUVMap")
|
||||
uv.name = "PBR Wood UV"
|
||||
uv.uv_map = "UVMap"
|
||||
mapping = nodes.new("ShaderNodeMapping")
|
||||
mapping.name = "PBR Wood Mapping"
|
||||
mapping.inputs["Scale"].default_value = (2.0, 4.0, 1.0)
|
||||
texture = nodes.new("ShaderNodeTexImage")
|
||||
texture.name = "PBR Wood Image"
|
||||
texture.image = image
|
||||
texture.interpolation = "Linear"
|
||||
links.new(uv.outputs["UV"], mapping.inputs["Vector"])
|
||||
links.new(mapping.outputs["Vector"], texture.inputs["Vector"])
|
||||
links.new(texture.outputs["Color"], principled.inputs["Base Color"])
|
||||
set_input(principled, ["Roughness"], 0.52)
|
||||
set_input(principled, ["Metallic"], 0.0)
|
||||
return image
|
||||
|
||||
|
||||
def build_materials():
|
||||
materials = []
|
||||
|
||||
metal_brushed, p = new_material("PBR Metal Brushed")
|
||||
set_input(p, ["Base Color"], (0.55, 0.57, 0.60, 1.0))
|
||||
set_input(p, ["Metallic"], 1.0)
|
||||
set_input(p, ["Roughness"], 0.30)
|
||||
materials.append((metal_brushed, CATALOG_BY_PATH["Materials/Metal"], "Brushed sheet metal", ("metal", "brushed")))
|
||||
|
||||
metal_polished, p = new_material("PBR Metal Polished")
|
||||
set_input(p, ["Base Color"], (0.78, 0.80, 0.83, 1.0))
|
||||
set_input(p, ["Metallic"], 1.0)
|
||||
set_input(p, ["Roughness"], 0.06)
|
||||
materials.append((metal_polished, CATALOG_BY_PATH["Materials/Metal"], "Mirror-polished metal", ("metal", "polished")))
|
||||
|
||||
plastic_red, p = new_material("PBR Plastic Red")
|
||||
set_input(p, ["Base Color"], (0.62, 0.02, 0.02, 1.0))
|
||||
set_input(p, ["Metallic"], 0.0)
|
||||
set_input(p, ["Roughness"], 0.34)
|
||||
set_input(p, ["Coat Weight"], 0.35)
|
||||
set_input(p, ["Coat Roughness"], 0.08)
|
||||
materials.append((plastic_red, CATALOG_BY_PATH["Materials/Plastic"], "Glossy red plastic", ("plastic", "red")))
|
||||
|
||||
plastic_white, p = new_material("PBR Plastic White")
|
||||
set_input(p, ["Base Color"], (0.82, 0.82, 0.80, 1.0))
|
||||
set_input(p, ["Metallic"], 0.0)
|
||||
set_input(p, ["Roughness"], 0.45)
|
||||
materials.append((plastic_white, CATALOG_BY_PATH["Materials/Plastic"], "Matte white plastic", ("plastic", "white")))
|
||||
|
||||
wood, p = new_material("PBR Wood")
|
||||
add_wood_texture(wood, p)
|
||||
materials.append((wood, CATALOG_BY_PATH["Materials/Wood"], "Varnished wood with packed grain", ("wood", "texture")))
|
||||
|
||||
glass, p = new_material("PBR Glass")
|
||||
set_input(p, ["Base Color"], (0.92, 0.96, 1.0, 1.0))
|
||||
set_input(p, ["Metallic"], 0.0)
|
||||
set_input(p, ["Roughness"], 0.03)
|
||||
set_input(p, ["IOR"], 1.45)
|
||||
set_input(p, ["Transmission Weight", "Transmission"], 1.0)
|
||||
materials.append((glass, CATALOG_BY_PATH["Materials/Glass"], "Clear glass with transmission", ("glass", "transmission")))
|
||||
|
||||
emission, p = new_material("PBR Emission Cyan")
|
||||
set_input(p, ["Base Color"], (0.0, 0.0, 0.0, 1.0))
|
||||
set_input(p, ["Roughness"], 0.4)
|
||||
set_input(p, ["Emission Color", "Emission"], (0.0, 0.85, 1.0, 1.0))
|
||||
set_input(p, ["Emission Strength"], 5.0)
|
||||
materials.append((emission, CATALOG_BY_PATH["Materials/Emission"], "Cyan emissive surface", ("emission", "cyan")))
|
||||
|
||||
rubber, p = new_material("PBR Rubber Black")
|
||||
set_input(p, ["Base Color"], (0.015, 0.015, 0.017, 1.0))
|
||||
set_input(p, ["Metallic"], 0.0)
|
||||
set_input(p, ["Roughness"], 0.86)
|
||||
set_input(p, ["Specular IOR Level"], 0.25)
|
||||
materials.append((rubber, CATALOG_BY_PATH["Materials/Plastic"], "Matte black rubber", ("rubber", "black")))
|
||||
|
||||
return materials
|
||||
|
||||
|
||||
def set_metadata(asset, *, catalog_id, description, tags, id_code, extra_properties=None):
|
||||
metadata = asset.asset_data
|
||||
metadata.catalog_id = catalog_id
|
||||
metadata.author = "Web Blender PBR Library"
|
||||
metadata.description = description
|
||||
metadata.copyright = "CC0-1.0 generated by Web Blender"
|
||||
metadata.license = "CC0-1.0"
|
||||
for tag in tags:
|
||||
metadata.tags.new(tag, skip_if_exists=False)
|
||||
metadata.active_tag = 0
|
||||
metadata.use_preferred_import_method = True
|
||||
metadata.preferred_import_method = "APPEND"
|
||||
metadata["web.idCode"] = id_code
|
||||
metadata["web.datablockName"] = asset.name
|
||||
for name, value in (extra_properties or {}).items():
|
||||
metadata[name] = value
|
||||
|
||||
|
||||
def ensure_uv(obj):
|
||||
if obj.type != "MESH":
|
||||
return
|
||||
if not obj.data.uv_layers:
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bpy.ops.mesh.select_all(action="SELECT")
|
||||
bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=0.02)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
|
||||
def build_model(kind, name, material, catalog_path):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
if kind == "monkey":
|
||||
bpy.ops.mesh.primitive_monkey_add(size=2.0, location=(0.0, 0.0, 0.0))
|
||||
bpy.ops.object.shade_smooth()
|
||||
elif kind == "cube":
|
||||
bpy.ops.mesh.primitive_cube_add(size=2.0, location=(0.0, 0.0, 0.0))
|
||||
elif kind == "sphere":
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=64, ring_count=32, radius=1.0, location=(0.0, 0.0, 0.0))
|
||||
bpy.ops.object.shade_smooth()
|
||||
elif kind == "torus":
|
||||
bpy.ops.mesh.primitive_torus_add(major_radius=1.0, minor_radius=0.34, major_segments=64, minor_segments=24, location=(0.0, 0.0, 0.0))
|
||||
bpy.ops.object.shade_smooth()
|
||||
elif kind == "cylinder":
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=64, radius=0.75, depth=2.0, location=(0.0, 0.0, 0.0))
|
||||
bpy.ops.object.shade_smooth()
|
||||
elif kind == "plane":
|
||||
bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0.0, 0.0, 0.0))
|
||||
else:
|
||||
raise RuntimeError(f"unknown model kind {kind}")
|
||||
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.data.name = f"{name} Mesh"
|
||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
||||
ensure_uv(obj)
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(material)
|
||||
obj.asset_mark()
|
||||
set_metadata(
|
||||
obj,
|
||||
catalog_id=CATALOG_BY_PATH[catalog_path],
|
||||
description=f"{name} PBR model",
|
||||
tags=("model", kind),
|
||||
id_code=ID_OBJECT,
|
||||
extra_properties={"web.meshName": obj.data.name, "web.materialName": material.name},
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def write_catalog_definition(path: pathlib.Path):
|
||||
lines = [
|
||||
"# This is an Asset Catalog Definition file for Blender.",
|
||||
"# Generated by tools/web/generate-pbr-library.py",
|
||||
"",
|
||||
"VERSION 1",
|
||||
"",
|
||||
]
|
||||
lines.extend(f"{catalog_id}:{catalog_path}:{simple_name}" for catalog_id, catalog_path, simple_name in CATALOGS)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def configure_preview_scene(scene):
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x = PREVIEW_SIZE
|
||||
scene.render.resolution_y = PREVIEW_SIZE
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.image_settings.color_mode = "RGBA"
|
||||
scene.render.image_settings.color_depth = "8"
|
||||
scene.render.film_transparent = False
|
||||
scene.render.dither_intensity = 0
|
||||
scene.eevee.taa_render_samples = PREVIEW_SAMPLES
|
||||
scene.view_settings.look = "None"
|
||||
scene.view_settings.exposure = 0
|
||||
scene.view_settings.gamma = 1
|
||||
|
||||
world = bpy.data.worlds.new("PBR Preview World")
|
||||
world.use_nodes = True
|
||||
background = world.node_tree.nodes.get("Background")
|
||||
background.inputs["Color"].default_value = (0.045, 0.05, 0.06, 1.0)
|
||||
background.inputs["Strength"].default_value = 0.8
|
||||
scene.world = world
|
||||
|
||||
bpy.ops.object.light_add(type="AREA", location=(3.2, -3.2, 4.0))
|
||||
key = bpy.context.object
|
||||
key.name = "PBR Preview Key"
|
||||
key.data.energy = 600
|
||||
key.data.size = 4.0
|
||||
key.rotation_euler = (-Vector((3.2, -3.2, 4.0))).to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
bpy.ops.object.light_add(type="AREA", location=(-4.0, -2.0, 2.0))
|
||||
fill = bpy.context.object
|
||||
fill.name = "PBR Preview Fill"
|
||||
fill.data.energy = 260
|
||||
fill.data.size = 5.0
|
||||
fill.rotation_euler = (-Vector((-4.0, -2.0, 2.0))).to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
bpy.ops.object.light_add(type="AREA", location=(0.0, 4.0, 3.0))
|
||||
rim = bpy.context.object
|
||||
rim.name = "PBR Preview Rim"
|
||||
rim.data.energy = 380
|
||||
rim.data.size = 3.0
|
||||
rim.rotation_euler = (-Vector((0.0, 4.0, 3.0))).to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
bpy.ops.object.camera_add()
|
||||
camera = bpy.context.object
|
||||
camera.name = "PBR Preview Camera"
|
||||
camera.data.type = "PERSP"
|
||||
camera.data.lens = 50
|
||||
camera.data.sensor_width = 36
|
||||
camera.data.clip_start = 0.01
|
||||
camera.data.clip_end = 1000
|
||||
scene.camera = camera
|
||||
return camera
|
||||
|
||||
|
||||
def frame_object(scene, camera, obj, margin=1.35):
|
||||
bpy.context.view_layer.update()
|
||||
corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
center = sum(corners, Vector((0.0, 0.0, 0.0))) / len(corners)
|
||||
radius = max((corner - center).length for corner in corners)
|
||||
radius = max(radius, 0.001)
|
||||
half_angle = camera.data.angle / 2.0
|
||||
distance = radius / math.tan(half_angle) * margin
|
||||
direction = Vector((1.0, -1.0, 0.72)).normalized()
|
||||
camera.location = center + direction * distance
|
||||
camera.rotation_euler = (center - camera.location).to_track_quat("-Z", "Y").to_euler()
|
||||
scene.view_layers[0].update()
|
||||
|
||||
|
||||
def render_preview(scene, output_path: pathlib.Path):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
scene.render.filepath = str(output_path)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def store_preview(path: pathlib.Path, preview_dir: pathlib.Path):
|
||||
data = path.read_bytes()
|
||||
digest = sha256_bytes(data)
|
||||
final = preview_dir / f"{digest}.png"
|
||||
if path != final:
|
||||
path.replace(final)
|
||||
return {
|
||||
"file": f"previews/{final.name}",
|
||||
"sha256": digest,
|
||||
"byteLength": len(data),
|
||||
"width": PREVIEW_SIZE,
|
||||
"height": PREVIEW_SIZE,
|
||||
}
|
||||
|
||||
|
||||
def main(output_directory):
|
||||
output = pathlib.Path(output_directory).resolve()
|
||||
preview_dir = output / "previews"
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
for stale in preview_dir.glob("*.png"):
|
||||
stale.unlink()
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
|
||||
materials = build_materials()
|
||||
for material, catalog_id, description, tags in materials:
|
||||
material.asset_mark()
|
||||
set_metadata(
|
||||
material,
|
||||
catalog_id=catalog_id,
|
||||
description=description,
|
||||
tags=tags,
|
||||
id_code=ID_MATERIAL,
|
||||
extra_properties={"web.materialName": material.name},
|
||||
)
|
||||
|
||||
material_by_name = {material.name: material for material, *_rest in materials}
|
||||
models = [
|
||||
build_model("monkey", "PBR Suzanne", material_by_name["PBR Metal Brushed"], "Models/Characters"),
|
||||
build_model("cube", "PBR Cube", material_by_name["PBR Plastic Red"], "Models/Primitives"),
|
||||
build_model("sphere", "PBR UV Sphere", material_by_name["PBR Wood"], "Models/Primitives"),
|
||||
build_model("torus", "PBR Torus", material_by_name["PBR Metal Polished"], "Models/Primitives"),
|
||||
build_model("cylinder", "PBR Cylinder", material_by_name["PBR Glass"], "Models/Primitives"),
|
||||
build_model("plane", "PBR Plane", material_by_name["PBR Rubber Black"], "Models/Primitives"),
|
||||
]
|
||||
|
||||
catalog_path = output / "blender_assets.cats.txt"
|
||||
library_path = output / "pbr-library.blend"
|
||||
write_catalog_definition(catalog_path)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(library_path), compress=True)
|
||||
|
||||
camera = configure_preview_scene(scene)
|
||||
collection = scene.collection
|
||||
preview_entries = []
|
||||
|
||||
for model in models:
|
||||
model.hide_render = True
|
||||
|
||||
for material, catalog_id, description, tags in materials:
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=96, ring_count=64, radius=1.0, location=(0.0, 0.0, 0.0))
|
||||
sphere = bpy.context.object
|
||||
sphere.name = f"Preview {material.name}"
|
||||
bpy.ops.object.shade_smooth()
|
||||
sphere.data.materials.append(material)
|
||||
frame_object(scene, camera, sphere)
|
||||
tmp = preview_dir / "tmp.png"
|
||||
render_preview(scene, tmp)
|
||||
entry = store_preview(tmp, preview_dir)
|
||||
bpy.data.objects.remove(sphere, do_unlink=True)
|
||||
entry.update({"idType": "MATERIAL", "name": material.name})
|
||||
preview_entries.append(entry)
|
||||
|
||||
for model in models:
|
||||
model.hide_render = False
|
||||
frame_object(scene, camera, model)
|
||||
tmp = preview_dir / "tmp.png"
|
||||
render_preview(scene, tmp)
|
||||
entry = store_preview(tmp, preview_dir)
|
||||
model.hide_render = True
|
||||
entry.update({"idType": "OBJECT", "name": model.name})
|
||||
preview_entries.append(entry)
|
||||
|
||||
preview_path = output / "preview-manifest.json"
|
||||
preview_path.write_text(json.dumps({"schemaVersion": 1, "previews": preview_entries}, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
script_path = pathlib.Path(__file__).resolve()
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"generator": "tools/web/generate-pbr-library.py",
|
||||
"scriptSha256": sha256_file(script_path),
|
||||
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
|
||||
"blenderExecutable": bpy.app.binary_path,
|
||||
"library": {
|
||||
"path": "libraries/pbr-library.blend",
|
||||
"sha256": sha256_file(library_path),
|
||||
"byteLength": library_path.stat().st_size,
|
||||
},
|
||||
"catalogDefinition": {
|
||||
"path": "blender_assets.cats.txt",
|
||||
"sha256": sha256_file(catalog_path),
|
||||
"byteLength": catalog_path.stat().st_size,
|
||||
},
|
||||
"previewManifest": {
|
||||
"path": "preview-manifest.json",
|
||||
"sha256": sha256_file(preview_path),
|
||||
"byteLength": preview_path.stat().st_size,
|
||||
},
|
||||
"counts": {"models": len(models), "materials": len(materials), "previews": len(preview_entries)},
|
||||
"colorManagement": {"viewTransform": scene.view_settings.view_transform, "look": scene.view_settings.look},
|
||||
"render": {"engine": scene.render.engine, "resolution": PREVIEW_SIZE, "samples": PREVIEW_SAMPLES},
|
||||
}
|
||||
(output / "build-report.json").write_text(json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n")
|
||||
(output / "LICENSE.txt").write_text(
|
||||
"PBR asset library generated by tools/web/generate-pbr-library.py.\n"
|
||||
"Generated assets are released under CC0-1.0.\n"
|
||||
"Blender itself is GPL-2.0-or-later; this library only contains generated data.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
print(json.dumps({"library": report["library"]["sha256"], "models": len(models), "materials": len(materials), "previews": len(preview_entries)}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-pbr-library.py -- OUTPUT_DIRECTORY")
|
||||
main(arguments[0])
|
||||
1203
tools/web/generate-test-case-catalog.mjs
Normal file
1203
tools/web/generate-test-case-catalog.mjs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user