diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 6dc77317..51cdb88d 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -33,6 +33,12 @@ on: pull_request: branches: [main] types: [opened, synchronize, reopened, labeled] + # Docs-only PRs do not touch anything Blender runs. A PR that also changes + # code is not ignored: paths-ignore skips only when every file matches. + paths-ignore: + - "**.md" + - "docs/**" + - "assets/**" permissions: contents: read @@ -87,11 +93,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Harness protocol unit tests - run: | - set -euo pipefail - python3 tests/smoke/test_harness.py -v - - name: Init smoke status log run: | set -euo pipefail @@ -109,7 +110,8 @@ jobs: xvfb libgl1 libegl1 libxrender1 libxxf86vm1 libxfixes3 libxi6 \ libxkbcommon0 libsm6 libice6 - - name: Resolve and download Blender ${{ matrix.series }} + - name: Resolve Blender ${{ matrix.series }} + id: resolve run: | set -euo pipefail series="${{ matrix.series }}" @@ -123,14 +125,46 @@ jobs: echo "::error::Could not resolve a linux-x64 build for Blender ${series} at $base" exit 1 fi - url="${base}${file}" - echo "Downloading $url" + version="${file#blender-}" + version="${version%-linux-x64.tar.xz}" + echo "base=$base" >> "$GITHUB_OUTPUT" + echo "file=$file" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Resolved Blender $version" + + - name: Restore Blender ${{ steps.resolve.outputs.version }} from cache + id: bl-cache + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ runner.temp }}/bl + key: blender-${{ steps.resolve.outputs.version }}-linux-x64 + + - name: Download Blender and verify its published sha256 + if: steps.bl-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + base="${{ steps.resolve.outputs.base }}" + file="${{ steps.resolve.outputs.file }}" + version="${{ steps.resolve.outputs.version }}" mkdir -p "$RUNNER_TEMP/bl" - curl -fSL --retry 3 -o "$RUNNER_TEMP/bl.tar.xz" "$url" + echo "Downloading ${base}${file}" + curl -fSL --retry 3 -o "$RUNNER_TEMP/bl.tar.xz" "${base}${file}" + curl -fsSL --retry 3 -o "$RUNNER_TEMP/bl.sha256" "${base}blender-${version}.sha256" + expected=$(grep -E " ${file}$" "$RUNNER_TEMP/bl.sha256" | awk '{print $1}') + if [ -z "$expected" ]; then + echo "::error::no published sha256 for ${file}" + exit 1 + fi + echo "${expected} $RUNNER_TEMP/bl.tar.xz" | sha256sum -c - \ + || { echo "::error::sha256 mismatch for ${file}; refusing to extract"; exit 1; } tar -xf "$RUNNER_TEMP/bl.tar.xz" -C "$RUNNER_TEMP/bl" + + - name: Locate Blender binary + run: | + set -euo pipefail bl=$(find "$RUNNER_TEMP/bl" -maxdepth 2 -type f -name blender | head -1) if [ -z "$bl" ]; then - echo "::error::blender binary not found after extraction" + echo "::error::blender binary not found" exit 1 fi echo "BLENDER=$bl" >> "$GITHUB_ENV" @@ -158,7 +192,7 @@ jobs: run: | set -euo pipefail xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \ - --python tests/smoke/tmpl_gltf.py -- \ + --python templates/headless-batch-script-template/script.py -- \ --output "$RUNNER_TEMP/out/out.glb" --apply-modifier SUBSURF test -s "$RUNNER_TEMP/out/out.glb" || { echo "::error::glTF output missing/empty"; exit 1; } head -c4 "$RUNNER_TEMP/out/out.glb" | grep -q "glTF" || { echo "::error::not a glTF binary"; exit 1; } @@ -167,12 +201,17 @@ jobs: run: | set +e xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/empty.blend" \ - --python tests/smoke/tmpl_gltf.py -- --output "$RUNNER_TEMP/out/none.glb" + --python templates/headless-batch-script-template/script.py -- --output "$RUNNER_TEMP/out/none.glb" code=$? set -e [ "$code" -eq 2 ] || { echo "::error::expected exit 2 for no-mesh input, got $code"; exit 1; } echo "no-mesh exit code = $code (correct)" + - name: Extension template passes Blender's own manifest validator + run: | + set -euo pipefail + "$BLENDER" --command extension validate templates/extension-addon-template + - name: Build pipeline template fixture GLB run: | set -euo pipefail diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e53a1142..b3a9384b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -36,82 +36,10 @@ jobs: fi echo "VERSION valid: $version" - - name: Validate skill frontmatter + - name: Validate skill and rule frontmatter (parsed YAML, first block only) run: | - echo "Checking skill YAML frontmatter..." - errors=0 - for skill in skills/*/SKILL.md; do - if [ ! -f "$skill" ]; then continue; fi - if ! head -1 "$skill" | grep -q "^---$"; then - echo "ERROR: $skill missing YAML frontmatter opening ---" - errors=$((errors + 1)) - continue - fi - frontmatter=$(sed -n '/^---$/,/^---$/p' "$skill" | sed '1d;$d') - if ! echo "$frontmatter" | grep -q "^name:"; then - echo "ERROR: $skill missing 'name' in frontmatter" - errors=$((errors + 1)) - fi - if ! echo "$frontmatter" | grep -q "^description:"; then - echo "ERROR: $skill missing 'description' in frontmatter" - errors=$((errors + 1)) - fi - if ! echo "$frontmatter" | grep -q "^standards-version:"; then - echo "ERROR: $skill missing 'standards-version' in frontmatter" - errors=$((errors + 1)) - fi - done - if [ "$errors" -gt 0 ]; then - echo "$errors frontmatter error(s) found." - exit 1 - fi - echo "All skill frontmatter valid." - - - name: Check skill name matches directory - run: | - echo "Checking skill names match directory names..." - errors=0 - for skill in skills/*/SKILL.md; do - if [ ! -f "$skill" ]; then continue; fi - dir_name=$(echo "$skill" | sed 's|skills/\(.*\)/SKILL.md|\1|') - fm_name=$(sed -n '/^---$/,/^---$/p' "$skill" | sed '1d;$d' | grep "^name:" | sed 's/^name: *//') - if [ "$dir_name" != "$fm_name" ]; then - echo "ERROR: $skill has name '$fm_name' but directory is '$dir_name'" - errors=$((errors + 1)) - fi - done - if [ "$errors" -gt 0 ]; then - echo "$errors name mismatch(es) found." - exit 1 - fi - echo "All skill names match their directories." - - - name: Validate rule frontmatter - run: | - echo "Checking rule YAML frontmatter..." - errors=0 - for rule in rules/*.mdc; do - if [ ! -f "$rule" ]; then continue; fi - if ! head -1 "$rule" | grep -q "^---$"; then - echo "ERROR: $rule missing YAML frontmatter opening ---" - errors=$((errors + 1)) - continue - fi - frontmatter=$(sed -n '/^---$/,/^---$/p' "$rule" | sed '1d;$d') - if ! echo "$frontmatter" | grep -q "^description:"; then - echo "ERROR: $rule missing 'description' in frontmatter" - errors=$((errors + 1)) - fi - if ! echo "$frontmatter" | grep -q "^standards-version:"; then - echo "ERROR: $rule missing 'standards-version' in frontmatter" - errors=$((errors + 1)) - fi - done - if [ "$errors" -gt 0 ]; then - echo "$errors frontmatter error(s) found." - exit 1 - fi - echo "All rule frontmatter valid." + pip install PyYAML==6.0.2 + python3 tests/check_frontmatter.py - name: Validate snippet Python syntax run: | @@ -133,6 +61,9 @@ jobs: - name: Check import-scale and unevaluated-export anti-patterns run: python3 tests/check_import_export_rules.py + - name: Check the smoke catalog covers every example and showcase piece + run: python3 tests/check_smoke_catalog.py + - name: Check product exit codes are in README tables run: python3 tests/check_exit_code_readme.py diff --git a/tests/check_frontmatter.py b/tests/check_frontmatter.py new file mode 100644 index 00000000..90670483 --- /dev/null +++ b/tests/check_frontmatter.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Validate skill and rule frontmatter by parsing the leading YAML block. + +Only the first ``---`` block is read (a Markdown horizontal rule later in the +body cannot satisfy a missing key). Skills need name (matching the directory), +description and standards-version. Rules need description, standards-version, a +boolean alwaysApply, and non-empty globs given as a string or a list of strings. + + python tests/check_frontmatter.py [--root PATH] +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import yaml + + +def front(path: Path) -> tuple[dict | None, str]: + text = path.read_text(encoding="utf-8").replace("\r\n", "\n") + if not text.startswith("---\n"): + return None, "missing opening ---" + parts = text.split("---\n", 2) + if len(parts) < 3: + return None, "frontmatter is not closed by ---" + try: + data = yaml.safe_load(parts[1]) + except yaml.YAMLError as exc: + return None, f"invalid YAML: {exc}" + if not isinstance(data, dict): + return None, "frontmatter is not a mapping" + return data, "" + + +def need_str(data: dict, key: str, label: str, errors: list[str]) -> None: + if not isinstance(data.get(key), str) or not data[key].strip(): + errors.append(f"{label}: '{key}' must be a non-empty string") + + +def check(root: Path) -> list[str]: + errors: list[str] = [] + for path in sorted((root / "skills").glob("*/SKILL.md")): + label = path.relative_to(root).as_posix() + data, why = front(path) + if data is None: + errors.append(f"{label}: {why}") + continue + for key in ("name", "description", "standards-version"): + need_str(data, key, label, errors) + if data.get("name") != path.parent.name: + errors.append(f"{label}: name {data.get('name')!r} != directory {path.parent.name!r}") + for path in sorted((root / "rules").glob("*.mdc")): + label = path.relative_to(root).as_posix() + data, why = front(path) + if data is None: + errors.append(f"{label}: {why}") + continue + for key in ("description", "standards-version"): + need_str(data, key, label, errors) + if not isinstance(data.get("alwaysApply"), bool): + errors.append(f"{label}: 'alwaysApply' must be a boolean (got {data.get('alwaysApply')!r})") + globs = data.get("globs") + ok = (isinstance(globs, str) and globs.strip()) or ( + isinstance(globs, list) and globs and all(isinstance(g, str) and g.strip() for g in globs) + ) + if not ok: + errors.append(f"{label}: 'globs' must be a non-empty string or list of strings") + return errors + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--root", default=str(Path(__file__).resolve().parent.parent)) + errors = check(Path(ap.parse_args().root)) + for e in errors: + print(f"::error::{e}", file=sys.stderr) + if errors: + return 1 + print("skill and rule frontmatter valid") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/check_smoke_catalog.py b/tests/check_smoke_catalog.py new file mode 100644 index 00000000..0623e87f --- /dev/null +++ b/tests/check_smoke_catalog.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Fail when examples/showcase pieces and tests/smoke/catalog.json disagree. + +Every examples// and showcase// directory must be the target of at +least one catalog row (the row's ``script`` path names the directory), and every +row that points into examples/ or showcase/ must name a directory that exists. +An empty catalog fails. Rows for harness code (tests/smoke/...) are allowed. + + python tests/check_smoke_catalog.py [--root PATH] + +No Blender needed; runs in Validate. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Directories that intentionally have no smoke row. Keep empty unless justified. +EXCLUDED: set[str] = set() + + +def check(root: Path) -> list[str]: + catalog = json.loads((root / "tests/smoke/catalog.json").read_text(encoding="utf-8")) + if not catalog: + return ["tests/smoke/catalog.json is empty"] + + covered: set[str] = set() + errors: list[str] = [] + for row in catalog: + parts = Path(row["script"]).parts + if len(parts) >= 2 and parts[0] in ("examples", "showcase"): + key = f"{parts[0]}/{parts[1]}" + covered.add(key) + if not (root / key).is_dir(): + errors.append(f"catalog row {row['name']!r} points at missing directory {key}") + + for top in ("examples", "showcase"): + for d in sorted((root / top).iterdir()): + if not d.is_dir() or d.name == "__pycache__": + continue + key = f"{top}/{d.name}" + if key not in covered and key not in EXCLUDED: + errors.append(f"{key} has no row in tests/smoke/catalog.json (it would never run)") + return errors + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--root", default=str(Path(__file__).resolve().parent.parent)) + root = Path(ap.parse_args().root) + errors = check(root) + for e in errors: + print(f"::error::{e}", file=sys.stderr) + if errors: + return 1 + print("smoke catalog covers every example and showcase directory") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index f0a909b3..670065f3 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -79,6 +79,7 @@ {"name": "lattice-deform", "script": "examples/lattice-deform/lattice_deform.py"}, {"name": "solidify-even-thickness", "script": "examples/solidify-even-thickness/solidify_even_thickness.py"}, {"name": "boolean-exact-volume", "script": "examples/boolean-exact-volume/boolean_exact_volume.py"}, + {"name": "extension-template-lifecycle", "script": "tests/smoke/check_extension_template.py"}, {"name": "shipping-crate", "script": "showcase/shipping-crate/shipping_crate.py"}, {"name": "stone-well", "script": "showcase/stone-well/stone_well.py"}, {"name": "wooden-barrel", "script": "showcase/wooden-barrel/wooden_barrel.py"}, diff --git a/tests/smoke/check_extension_template.py b/tests/smoke/check_extension_template.py new file mode 100644 index 00000000..ebeb1775 --- /dev/null +++ b/tests/smoke/check_extension_template.py @@ -0,0 +1,71 @@ +"""Load the shipped extension template into Blender and prove its lifecycle. + +Run: blender --background --python tests/smoke/check_extension_template.py + +Imports templates/extension-addon-template/__init__.py as a package, calls +register(), asserts the operator, panel and Scene pointer exist and that the +operator works, then calls unregister() and asserts all three are gone. Exits +non-zero naming the first failed assertion. +""" +import importlib.util +import os +import sys + +import bpy + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +PKG_DIR = os.path.join(ROOT, "templates", "extension-addon-template") + + +def fail(msg): + print(f"FAIL: {msg}", file=sys.stderr) + sys.exit(1) + + +def operator_present(): + # bpy.ops attribute lookup is lazy and can outlive unregister_class, so + # ask the type registry instead. + return bpy.types.Operator.bl_rna_get_subclass_py("EXAMPLE_OT_nudge_active") is not None + + +def panel_present(): + return bpy.types.Panel.bl_rna_get_subclass_py("VIEW3D_PT_example_addon") is not None + + +bpy.ops.wm.read_factory_settings(use_empty=True) + +spec = importlib.util.spec_from_file_location( + "example_addon", os.path.join(PKG_DIR, "__init__.py"), + submodule_search_locations=[PKG_DIR], +) +mod = importlib.util.module_from_spec(spec) +sys.modules["example_addon"] = mod +spec.loader.exec_module(mod) + +mod.register() +if not operator_present(): + fail("operator example.nudge_active missing after register()") +if not panel_present(): + fail("panel VIEW3D_PT_example_addon missing after register()") +if not hasattr(bpy.types.Scene, "example_addon"): + fail("Scene.example_addon pointer missing after register()") + +bpy.ops.mesh.primitive_cube_add() +cube = bpy.context.active_object +settings = bpy.context.scene.example_addon +if abs(settings.factor - 1.0) > 1e-9 or settings.enabled is not True: + fail(f"PropertyGroup defaults wrong: factor={settings.factor} enabled={settings.enabled}") +z0 = cube.location.z +bpy.ops.example.nudge_active(factor=2.5) +if abs(cube.location.z - (z0 + 2.5)) > 1e-6: + fail(f"operator moved z by {cube.location.z - z0}, expected 2.5") + +mod.unregister() +if operator_present(): + fail("operator example.nudge_active still present after unregister()") +if panel_present(): + fail("panel still present after unregister()") +if hasattr(bpy.types.Scene, "example_addon"): + fail("Scene.example_addon pointer still bound after unregister()") + +print("extension template register/unregister lifecycle OK") diff --git a/tests/smoke/run_catalog.py b/tests/smoke/run_catalog.py index ef7ee46c..88c0f053 100644 --- a/tests/smoke/run_catalog.py +++ b/tests/smoke/run_catalog.py @@ -1,6 +1,8 @@ """Run every shipped example through run_example.classify via run_example.py. -Stops on the first FAIL (same fail-fast as the previous per-step YAML). +Runs every row and exits non-zero at the end listing all failures, so one PR +shows every broken example in one CI round-trip. ``--fail-fast`` restores +stop-at-first-failure. An empty catalog is a failure, not a pass. """ from __future__ import annotations @@ -21,14 +23,19 @@ def main(argv=None): p.add_argument("--status", default=os.environ.get("BDT_SMOKE_STATUS")) p.add_argument("--out", required=True, help="scratch dir for --output renders") p.add_argument("--xvfb", action="store_true") + p.add_argument("--fail-fast", action="store_true", help="stop at the first failure") args = p.parse_args(argv) with open(args.catalog, encoding="utf-8") as fh: catalog = json.load(fh) + if not catalog: + print("ERROR: catalog is empty; nothing would be smoke-tested", file=sys.stderr) + return 1 os.makedirs(args.out, exist_ok=True) runner = os.path.join(HERE, "run_example.py") n = 0 + failures = [] for item in catalog: n += 1 name = item["name"] @@ -67,15 +74,25 @@ def main(argv=None): code = subprocess.call(cmd) print("::endgroup::", flush=True) if code != 0: - print(f"catalog abort at {name} (exit {code})", file=sys.stderr) - return code + print(f"catalog FAIL {name} (exit {code})", file=sys.stderr) + failures.append(f"{name} (exit {code})") + if args.fail_fast: + break + continue expect = item.get("expect_file") if expect: expect = expect.replace("$OUT", args.out) if not os.path.isfile(expect) or os.path.getsize(expect) == 0: print(f"ERROR: expected output missing {expect}", file=sys.stderr) - return 1 - print(f"catalog finished {n} entries", flush=True) + failures.append(f"{name} (missing {expect})") + if args.fail_fast: + break + print(f"catalog finished {n} of {len(catalog)} entries", flush=True) + if failures: + print(f"{len(failures)} catalog failure(s):", file=sys.stderr) + for f in failures: + print(f" - {f}", file=sys.stderr) + return 1 return 0 diff --git a/tests/smoke/run_smoke.py b/tests/smoke/run_smoke.py index 25152abc..c38d1112 100644 --- a/tests/smoke/run_smoke.py +++ b/tests/smoke/run_smoke.py @@ -6,6 +6,7 @@ "no exception". Exits non-zero on the FIRST failed assertion, naming the example. Self-contained: example code is copied here, NOT imported from skills/ or snippets/, so the test catches drift in the shipped content rather than masking it. +The shipped templates are NOT copied: blender-smoke.yml runs them in place. """ import bpy, sys, os, tempfile diff --git a/tests/smoke/test_harness.py b/tests/smoke/test_harness.py index 2cafbb23..b943c1a7 100644 --- a/tests/smoke/test_harness.py +++ b/tests/smoke/test_harness.py @@ -171,5 +171,56 @@ def test_exit_pre_row_has_sidecar_and_floor(self): self.assertEqual(row["sidecar_contains"], "exit_pre-ok") +class RunCatalog(unittest.TestCase): + """run_catalog runs every row and reports all failures; empty is red.""" + + def _run(self, rows, codes, extra=()): + import contextlib + import io + from unittest import mock + + import run_catalog + + seen = [] + + def fake_call(cmd): + name = cmd[cmd.index("--name") + 1] + seen.append(name) + return codes.get(name, 0) + + with tempfile.TemporaryDirectory() as td: + cat = os.path.join(td, "catalog.json") + with open(cat, "w", encoding="utf-8") as fh: + json.dump(rows, fh) + err = io.StringIO() + with mock.patch.object(run_catalog.subprocess, "call", fake_call), contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): + code = run_catalog.main( + ["--blender", "x", "--series", "5.2", "--out", td, "--catalog", cat, *extra] + ) + return code, seen, err.getvalue() + + def test_two_broken_examples_are_both_reported(self): + rows = [{"name": n, "script": f"{n}.py"} for n in ("a", "b", "c", "d")] + code, seen, err = self._run(rows, {"b": 1, "d": 3}) + self.assertEqual(code, 1) + self.assertEqual(seen, ["a", "b", "c", "d"]) + self.assertIn("b (exit 1)", err) + self.assertIn("d (exit 3)", err) + + def test_fail_fast_is_opt_in(self): + rows = [{"name": n, "script": f"{n}.py"} for n in ("a", "b", "c")] + code, seen, _ = self._run(rows, {"a": 1}, extra=("--fail-fast",)) + self.assertEqual((code, seen), (1, ["a"])) + + def test_all_pass_is_green(self): + rows = [{"name": "a", "script": "a.py"}] + self.assertEqual(self._run(rows, {})[0], 0) + + def test_empty_catalog_is_red(self): + code, seen, err = self._run([], {}) + self.assertEqual((code, seen), (1, [])) + self.assertIn("catalog is empty", err) + + if __name__ == "__main__": unittest.main() diff --git a/tests/smoke/tmpl_gltf.py b/tests/smoke/tmpl_gltf.py deleted file mode 100644 index 55cfd164..00000000 --- a/tests/smoke/tmpl_gltf.py +++ /dev/null @@ -1,112 +0,0 @@ -# Headless batch script template. -# -# Run with: -# blender --background --python script.py -- \ -# --output /path/to/out.glb \ -# --apply-modifier SUBSURF -# -# Everything after the `--` token is forwarded to this script as sys.argv. -# Anything before `--` is consumed by Blender itself. -# -# This template demonstrates the safe headless batch pattern: -# - bpy.data.* for direct manipulation (no UI required) -# - bpy.context.temp_override(...) only when an operator is genuinely needed -# - explicit exit codes so a CI pipeline can detect failures -# -# References: -# docs.blender.org/manual/en/latest/advanced/command_line/arguments.html -# docs.blender.org/api/current/bpy.context.html (temp_override) - -import argparse -import sys - -import bpy - - -def parse_args(argv): - """Parse args after the `--` separator that Blender passes through.""" - if "--" in argv: - script_args = argv[argv.index("--") + 1:] - else: - script_args = [] - - parser = argparse.ArgumentParser( - description="Apply a modifier to every mesh and export to glTF.", - ) - parser.add_argument( - "--output", - required=True, - help="Path to the output .glb file.", - ) - parser.add_argument( - "--apply-modifier", - choices=["SUBSURF", "BEVEL", "MIRROR", "TRIANGULATE"], - default=None, - help="If set, add and apply this modifier to every mesh before export.", - ) - parser.add_argument( - "--subsurf-levels", - type=int, - default=2, - help="Subdivision levels when --apply-modifier=SUBSURF.", - ) - return parser.parse_args(script_args) - - -def add_and_apply_modifier(obj, modifier_type, subsurf_levels=2): - """Add a modifier to obj and apply it. - - Modifier application is one of the few cases where bpy.ops is the - canonical path; bpy.data does not expose an apply method. We use - temp_override to set the active object cleanly, instead of the - deprecated context-dict-passing form. - """ - modifier = obj.modifiers.new(name=modifier_type, type=modifier_type) - if modifier_type == "SUBSURF": - modifier.levels = subsurf_levels - modifier.render_levels = subsurf_levels - - with bpy.context.temp_override(object=obj, active_object=obj): - bpy.ops.object.modifier_apply(modifier=modifier.name) - - -def main(): - args = parse_args(sys.argv) - - mesh_objects = [obj for obj in bpy.data.objects if obj.type == "MESH"] - if not mesh_objects: - print("ERROR: no mesh objects in the input .blend", file=sys.stderr) - return 2 - - print(f"Found {len(mesh_objects)} mesh object(s): {[o.name for o in mesh_objects]}") - - if args.apply_modifier: - for obj in mesh_objects: - try: - add_and_apply_modifier(obj, args.apply_modifier, args.subsurf_levels) - print(f"Applied {args.apply_modifier} to {obj.name}") - except RuntimeError as exc: - print( - f"ERROR: failed to apply {args.apply_modifier} to {obj.name}: {exc}", - file=sys.stderr, - ) - return 3 - - try: - bpy.ops.export_scene.gltf( - filepath=args.output, - export_format="GLB", - use_selection=False, - export_apply=True, - ) - except RuntimeError as exc: - print(f"ERROR: glTF export failed: {exc}", file=sys.stderr) - return 4 - - print(f"Wrote {args.output}") - return 0 - - -if __name__ == "__main__": - exit_code = main() - sys.exit(exit_code)