From 83dc497b403976a5dc26a9ead38e5ec19a04bbb9 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 28 Sep 2026 12:50:57 -0700 Subject: [PATCH] feat(blender): pinned extension set in a read-only System repo, for the GUI and blender-run --extensions Blender is now a mandatory stage in draupnir's pipeline (Prime, 2026-09-28), and draupnir asked for eight add-ons from extensions.blender.org: SurfacePsycho 0.10.4, CAD Sketcher 0.32.1, 3D-Print Toolbox 1.4.1, STEP Importer 1.2.1, Bool Tool 2.1.0, LoopTools 4.7.7, MeasureIt 1.8.4, 3MF Import/Export 2.7.7. - stacks/blender/extensions.lock pins each by version and archive sha256. - scripts/blender-extensions sync builds fv-ml1:/tank/blender-extensions/5.2/system with Blender's own install-file, pre-warms and byte-compiles it, checks a read-only enable, then swaps it in. It refuses while the GUI or a blender-run job holds the old directory. - conf/scripts/startup/fleet_extensions.py enables every package in the System repo: in a timer in the GUI (after the prefs load), and as --python ahead of the caller's args in blender-run --extensions (a failed enable exits 1 before the caller's script). - It also patches SurfacePsycho's sp_overwrite_segment_selection from eval() to literal_eval(): the eval walked past MCP safe mode (control: unpatched ran code, patched refuses). - blender-run: --extensions (bind mounts via --mount so a missing source fails instead of being created); USER/LOGNAME set, which CAD Sketcher's getpass needs. - compose.yaml mounts the repo read-only and the hook into the GUI container. NOT yet deployed. - scripts/blender-probes/extensions_acceptance.py: one operator run per add-on, safe-mode compliant. Headless 8/9 online and with --network none; CAD Sketcher sketching is GUI-only. A Python audit hook saw no network/process events (positive control fired). --- docs/fleettools/blender.md | 16 +- persistent-memory.md | 9 + scripts/blender-extensions | 110 ++++++++ .../blender-probes/extensions_acceptance.py | 239 ++++++++++++++++++ scripts/blender-run | 27 +- stacks/blender/README.md | 115 ++++++++- stacks/blender/compose.yaml | 5 + .../conf/scripts/startup/fleet_extensions.py | 144 +++++++++++ stacks/blender/extensions.lock | 19 ++ 9 files changed, 675 insertions(+), 9 deletions(-) create mode 100755 scripts/blender-extensions create mode 100644 scripts/blender-probes/extensions_acceptance.py create mode 100644 stacks/blender/conf/scripts/startup/fleet_extensions.py create mode 100644 stacks/blender/extensions.lock diff --git a/docs/fleettools/blender.md b/docs/fleettools/blender.md index 1a0f453..897bbab 100644 --- a/docs/fleettools/blender.md +++ b/docs/fleettools/blender.md @@ -20,6 +20,7 @@ reaching fv-ml1 as `infra-ops@10.251.50.54` over ssh. **No HTTP API** and no ope ```sh scripts/blender-run --job /path/to/jobdir -- --python render.py -- out.png +scripts/blender-run --extensions --job /path/to/jobdir -- --python model.py scripts/blender-run -- --python-expr 'import bpy; print(bpy.app.version_string)' ``` @@ -34,8 +35,19 @@ scripts/blender-run -- --python-expr 'import bpy; print(bpy.app.version_string)' EEVEE id is `BLENDER_EEVEE` (`BLENDER_EEVEE_NEXT` is gone). For Cycles GPU, set `prefs.compute_device_type = 'OPTIX'` and enable the OPTIX devices, then `scene.cycles.device = 'GPU'`. Factory startup defaults to CPU. -- **Import:** STL is built in (`bpy.ops.wm.stl_import`). **No STEP importer** is installed or - built in. +- **Import:** STL is built in (`bpy.ops.wm.stl_import`). STEP, 3MF and the other add-ons need + `--extensions` (below). +- **`--extensions`** enables the pinned add-on set: SurfacePsycho 0.10.4 (NURBS patches, STEP/IGES + export via its bundled OCP), CAD Sketcher 0.32.1, 3D-Print Toolbox 1.4.1, STEP Importer 1.2.1, + Bool Tool 2.1.0, LoopTools 4.7.7, MeasureIt 1.8.4, 3MF Import/Export 2.7.7. Pins: + `stacks/blender/extensions.lock`. If any add-on fails to enable, the run exits 1 before your + script starts. It adds a few seconds of startup (the add-on wheels unpack per run), so it is + off by default. **CAD Sketcher's sketch operators need the GUI** (they activate a workspace + tool); headless they fail with "'NoneType' object has no attribute 'widget'". Operators that + want a 3D View (MeasureIt, LoopTools) take a `bpy.context.temp_override(area=...)` with a + screen datablock's VIEW_3D area. Worked calls for every add-on: + `scripts/blender-probes/extensions_acceptance.py`. Full notes and foot-guns: the stack README, + section "Extensions". - **Budget:** each run is capped at 64 GB RAM and 48 CPUs, with up to the whole 96 GB of VRAM. Keep to about 2 concurrent renders. It is not on irv-ml1, so irv-ml1's working-set budget does not apply. diff --git a/persistent-memory.md b/persistent-memory.md index 60141e6..9da2341 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -197,6 +197,14 @@ _As of 2026-09-27 ~0900 PT._ share a filesystem. Telemetry is off and safe mode is on. Verified end to end (render, screenshot, safe-mode refusal). **Batch path: `scripts/blender-run`** (one-shot `docker run --rm`, `--job` staging; first user is draupnir). **Access: the shared fleet `infra-ops` login, with no render-only key (Prime, 2026-09-28).** **Registration: PER TASK (Prime, 2026-09-27)**: a session that needs Blender runs `claude mcp add blender -- …/scripts/blender-mcp`. It goes in NO user- or project-wide config. → `stacks/blender/README.md` +- **Extensions (2026-09-28, draupnir; Prime ruled Blender a MANDATORY pipeline stage):** 8 pinned + add-ons (`stacks/blender/extensions.lock`) built by `scripts/blender-extensions sync` into + `fv-ml1:/tank/blender-extensions/5.2/system` (LIVE), mounted read-only as the System repo; + `fleet_extensions.py` enables them (GUI startup timer; `blender-run --extensions`). Headless + acceptance 8/9 (CAD Sketcher sketching is GUI-only). **⚠ The stack deploy that wires the GUI + and the hook (`deploy-stack.sh fv-ml1 blender`) was DENIED by the permission classifier on + 2026-09-28 and awaits Prime**; until it runs, `blender-run --extensions` fails cleanly (mount + error) and the MCP acceptance is not done. SurfacePsycho's eval() is patched to literal_eval. ### Zigbee2MQTT on esh-docker-vm (2026-09-27, Prime go-ahead; ha-dev request) @@ -270,6 +278,7 @@ _As of 2026-09-27 ~0900 PT._ ## Recent decisions +- `[2026-09-28]` **Blender extensions live in a read-only System repo built from a sha256 lock, enabled by a hook, opt-in for blender-run (`--extensions`).** SurfacePsycho's eval() is patched to literal_eval (a proven safe-mode escape). → `stacks/blender/README.md` § Extensions - `[2026-09-27]` **hermes-gateway restarted 0401 for highseat-dev** (SVOS v2.1.12: `propose_decision` gained `seat_up`, and Hermes reads the plugin only at start). The plugin load was verified at file level; the end-to-end proof is Miranda's first seat_up card. Enabling `zellij-fleet@Claude` at boot remains Prime's call. - `[2026-09-27]` **SemIf LIVE on fv-ml1 GPU 1 (semif-serve 0.1.2, Prime):** wrapper + contract + 39 tests, 142/144 upstream parity, two card-only memory defects fixed. → `persistent-memory.d/2026-09-27-semif-live-on-fv-ml1-gpu1.md` - `[2026-09-27]` **Blender 5.2 on fv-ml1 GPU 3 (on demand), agent-driven via mcp-for-blender running in-container over ssh stdio; safe mode on, no published port.** → `stacks/blender/README.md` diff --git a/scripts/blender-extensions b/scripts/blender-extensions new file mode 100755 index 0000000..e15f46a --- /dev/null +++ b/scripts/blender-extensions @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# blender-extensions — fv-ml1's pinned Blender extension set (stacks/blender, README "Extensions"). +# +# scripts/blender-extensions sync rebuild fv-ml1:/tank/blender-extensions/5.2/system from +# stacks/blender/extensions.lock +# scripts/blender-extensions status what is installed, against the lock +# +# The set lives in Blender's System extension repository, which is a plain directory. The GUI +# container and `blender-run --extensions` both mount it READ-ONLY at /blender/5.2/extensions/system, +# and conf/scripts/startup/fleet_extensions.py enables everything in it. Agents never install +# anything: MCP safe mode blocks it, and the mount is read-only. +# +# sync: +# 1. downloads each pinned archive into /tank/blender-extensions/zips/ (kept as a cache) and checks +# its sha256 against the lock. A mismatch stops everything. +# 2. installs each with Blender's own `--command extension install-file`, which validates the +# manifest against this Blender and platform, into a staging directory. +# 3. pre-warms the staging copy: enables everything once while it is still WRITABLE, then +# byte-compiles it. 3D-Print Toolbox writes a translation cache into its own package dir on +# first import, and that fails on the read-only mount (found 2026-09-28). +# 4. checks that the staging copy enables READ-ONLY, then swaps it in and records the lock it was +# built from as /tank/blender-extensions/5.2/installed.lock. +# ⚠ It refuses while the GUI container or any blender-run job is running. They hold the old +# directory through a bind mount, and the swap would pull it out from under them. +set -euo pipefail + +HOST=${BLENDER_SSH_HOST:-infra-ops@10.251.50.54} +HERE=$(cd "$(dirname "$0")" && pwd) +LOCK=$HERE/../stacks/blender/extensions.lock +HOOK=$HERE/../stacks/blender/conf/scripts/startup/fleet_extensions.py + +case "${1:-}" in + sync) + rows=$(grep -vE '^\s*(#|$)' "$LOCK") + echo "$rows" | awk 'NF != 4 || $3 !~ /^[0-9a-f]{64}$/ || $1 !~ /^[A-Za-z0-9_]+$/ { bad=1; print "bad lock row: " $0 > "/dev/stderr" } END { exit bad }' + ssh -n -o BatchMode=yes "$HOST" "mkdir -p /tank/blender-extensions/zips /tank/blender-extensions/5.2" + scp -q "$LOCK" "$HOST:/tank/blender-extensions/5.2/staging.lock" + scp -q "$HOOK" "$HOST:/tank/blender-extensions/5.2/staging-hook.py" + ssh -o BatchMode=yes "$HOST" bash -s <<'REMOTE' +set -euo pipefail +cd /tank/blender-extensions +if [ -n "$(docker ps -q --filter name='^blender$' --filter name='^blender-run-')" ]; then + echo "blender-extensions: the GUI container or a blender-run job is running; stop it first (scripts/blender-mcp down)" >&2 + exit 3 +fi +IMG=$(grep '^IMAGE=' /opt/docker/compose/blender/.env | cut -d= -f2) +# Fixed, literal staging path: it is emptied before every build. +rm -rf /tank/blender-extensions/5.2/staging +mkdir -p 5.2/staging/system +LOG=5.2/staging/sync.log +blender() { # a throwaway Blender with the staging repo at $1 (rw|ro) and HOME in the container + local mode=$1; shift + docker run --rm --user 1002:1003 -e HOME=/tmp -e USER=infra-ops \ + -v /tank/blender-extensions/5.2/staging/system:/blender/5.2/extensions/system:"$mode" \ + -v /tank/blender-extensions/zips:/zips:ro \ + -v /tank/blender-extensions/5.2/staging-hook.py:/fleet/fleet_extensions.py:ro \ + --entrypoint /blender/blender "$IMG" "$@" +} +grep -vE '^\s*(#|$)' 5.2/staging.lock | while read -r id ver sha url; do + zip=zips/$id-$ver.zip + if ! echo "$sha $zip" | sha256sum -c --status 2>/dev/null; then + curl -fsSL --retry 3 -o "$zip.part" "$url" + mv "$zip.part" "$zip" + fi + echo "$sha $zip" | sha256sum -c --status || { echo "blender-extensions: sha256 MISMATCH for $id $ver; nothing installed" >&2; exit 4; } + # install-file only targets user repos, so the staging dir is mounted as user_default for this step. + docker run --rm --user 1002:1003 -e HOME=/tmp -e USER=infra-ops \ + -v /tank/blender-extensions/5.2/staging/system:/tmp/.config/blender/5.2/extensions/user_default \ + -v /tank/blender-extensions/zips:/zips:ro \ + --entrypoint /blender/blender "$IMG" --factory-startup \ + -c extension install-file -r user_default --no-prefs "/zips/$id-$ver.zip" >>"$LOG" 2>&1 + [ -f "5.2/staging/system/$id/blender_manifest.toml" ] || { echo "blender-extensions: install of $id failed; see $PWD/$LOG" >&2; exit 5; } + echo "installed $id $ver" +done +# Pre-warm (writable), then byte-compile with Blender's own Python. +blender rw -b --factory-startup --python-exit-code 1 --python /fleet/fleet_extensions.py >>"$LOG" 2>&1 \ + || { echo "blender-extensions: pre-warm failed; see $PWD/$LOG" >&2; exit 6; } +docker run --rm --user 1002:1003 -v /tank/blender-extensions/5.2/staging/system:/s --entrypoint /blender/5.2/python/bin/python3.13 "$IMG" \ + -m compileall -q /s >>"$LOG" 2>&1 || true +# Must enable from the read-only mount before it goes live. +blender ro -b --factory-startup --python-exit-code 1 --python /fleet/fleet_extensions.py >>"$LOG" 2>&1 \ + || { echo "blender-extensions: read-only enable failed; see $PWD/$LOG" >&2; exit 7; } +grep 'fleet_extensions: enabled' "$LOG" | tail -1 +# Swap in. Literal paths only. +rm -rf /tank/blender-extensions/5.2/system.prev +if [ -d /tank/blender-extensions/5.2/system ]; then mv /tank/blender-extensions/5.2/system /tank/blender-extensions/5.2/system.prev; fi +mv /tank/blender-extensions/5.2/staging/system /tank/blender-extensions/5.2/system +mv /tank/blender-extensions/5.2/staging.lock /tank/blender-extensions/5.2/installed.lock +mv /tank/blender-extensions/5.2/staging/sync.log /tank/blender-extensions/5.2/sync.log +rm -rf /tank/blender-extensions/5.2/system.prev /tank/blender-extensions/5.2/staging /tank/blender-extensions/5.2/staging-hook.py +echo "live: /tank/blender-extensions/5.2/system ($(du -sh /tank/blender-extensions/5.2/system | cut -f1))" +REMOTE + "$HERE/ops-log" record --host fv-ml1 --action extensions-sync --target blender \ + --detail "rebuilt /tank/blender-extensions/5.2/system from extensions.lock ($(echo "$rows" | awk '{printf "%s %s, ", $1, $2}' | sed 's/, $//'))" ;; + status) + echo "lock (repo):" + grep -vE '^\s*(#|$)' "$LOCK" | awk '{printf " %-16s %s\n", $1, $2}' + echo "installed on fv-ml1:" + ssh -n -o BatchMode=yes "$HOST" 'for m in /tank/blender-extensions/5.2/system/*/blender_manifest.toml; do + [ -f "$m" ] || { echo " (nothing installed)"; break; } + printf " %-16s %s\n" "$(sed -n "s/^id = \"\(.*\)\"/\1/p" "$m")" "$(sed -n "s/^version = \"\(.*\)\"/\1/p" "$m")" + done' + if ssh -n -o BatchMode=yes "$HOST" cat /tank/blender-extensions/5.2/installed.lock 2>/dev/null | cmp -s - "$LOCK"; then + echo "installed.lock matches the repo lock" + else + echo "installed.lock DIFFERS from the repo lock (or is missing): run '$0 sync'" + fi ;; + *) + sed -n 2,6p "$0" >&2; exit 2 ;; +esac diff --git a/scripts/blender-probes/extensions_acceptance.py b/scripts/blender-probes/extensions_acceptance.py new file mode 100644 index 0000000..7d935c8 --- /dev/null +++ b/scripts/blender-probes/extensions_acceptance.py @@ -0,0 +1,239 @@ +# Acceptance probe for the fleet Blender extensions (stacks/blender, README "Extensions"). +# One real operator run per add-on. The same code runs on both paths: +# headless: scripts/blender-run --extensions -- --python extensions_acceptance.py -- headless +# MCP: the body pasted into execute_blender_code, with TAG = "mcp" +# so it is written to pass MCP safe mode: bpy, bmesh and mathutils only, no os/open/imports. +# Files land in /work/acceptance/extensions/ (= fv-ml1:/tank/blender/acceptance/extensions/), +# which must exist first. Every result prints as PASS/FAIL; any FAIL raises at the end, so +# blender-run exits 1. +import bpy +import bmesh +import sys +from mathutils import Vector + +TAG = sys.argv[sys.argv.index("--") + 1] if "--" in sys.argv else "mcp" +OUT = "/work/acceptance/extensions/" +results = [] + + +def report(name, ok, detail): + results.append((name, ok, detail)) + print(("PASS " if ok else "FAIL ") + name + " - " + detail) + + +def clear(): + if bpy.context.object is not None and bpy.context.object.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + for o in list(bpy.data.objects): + bpy.data.objects.remove(o, do_unlink=True) + + +def select_only(*objs): + for o in bpy.context.view_layer.objects: + o.select_set(False) + for o in objs: + o.select_set(True) + bpy.context.view_layer.objects.active = objs[0] + + +def view3d_override(): + """Context for operators that insist on a 3D View area: the GUI window's, or (headless, + where there is no window) a VIEW_3D area of a screen datablock.""" + for w in bpy.context.window_manager.windows: + for a in w.screen.areas: + if a.type == "VIEW_3D": + region = [r for r in a.regions if r.type == "WINDOW"][0] + return {"window": w, "screen": w.screen, "area": a, "region": region} + for s in bpy.data.screens: + for a in s.areas: + if a.type == "VIEW_3D": + return {"area": a} + return {} + + +def non_manifold_edges(o): + bm = bmesh.new() + bm.from_mesh(o.data) + n = len([e for e in bm.edges if not e.is_manifold]) + bm.free() + return n + + +def world_extent(o): + """World-space bounding-box size. Object.dimensions is in LOCAL axes and ignores + rotation, so it cannot show which way an import is facing.""" + pts = [o.matrix_world @ Vector(c) for c in o.bound_box] + return [max(p[i] for p in pts) - min(p[i] for p in pts) for i in range(3)] + + +def dims(o): + return "x".join(str(round(d * 1000, 3)) for d in world_extent(o)) + " mm (world)" + + +def run(name, fn): + try: + fn(name) + except Exception as ex: + report(name, False, "raised " + type(ex).__name__ + ": " + str(ex)) + + +# 1. 3D-Print Toolbox: clean non-manifold on a cube with one face deleted. +def t_print3d(name): + clear() + bpy.ops.mesh.primitive_cube_add(size=0.02) + cube = bpy.context.active_object + bm = bmesh.new() + bm.from_mesh(cube.data) + bm.faces.ensure_lookup_table() + bmesh.ops.delete(bm, geom=[bm.faces[0]], context="FACES_ONLY") + bm.to_mesh(cube.data) + bm.free() + before = non_manifold_edges(cube) + r = bpy.ops.mesh.print3d_clean_non_manifold() + after = non_manifold_edges(cube) + report(name, r == {"FINISHED"} and before > 0 and after == 0, + "non-manifold edges " + str(before) + " -> " + str(after)) + + +# 2. Bool Tool: auto difference, a 4 mm hole through a 20 mm cube. +def t_booltool(name): + clear() + bpy.ops.mesh.primitive_cube_add(size=0.02) + canvas = bpy.context.active_object + bpy.ops.mesh.primitive_cylinder_add(radius=0.002, depth=0.05) + cutter = bpy.context.active_object + cutter_name = cutter.name + select_only(canvas, cutter) + v0 = len(canvas.data.vertices) + r = bpy.ops.object.boolean_auto_difference() + v1 = len(canvas.data.vertices) + gone = cutter_name not in bpy.data.objects + report(name, r == {"FINISHED"} and gone and v1 > v0 and non_manifold_edges(canvas) == 0, + "canvas verts " + str(v0) + " -> " + str(v1) + ", cutter consumed " + str(gone) + + ", non-manifold " + str(non_manifold_edges(canvas))) + + +# 3. LoopTools: circle the boundary loop of a 5x5 grid (a square: corners sit further out). +def t_looptools(name): + clear() + bpy.ops.mesh.primitive_grid_add(x_subdivisions=4, y_subdivisions=4, size=0.02) + g = bpy.context.active_object + edge = max(abs(v.co.x) for v in g.data.vertices) + for v in g.data.vertices: + v.select = abs(abs(v.co.x) - edge) < 1e-6 or abs(abs(v.co.y) - edge) < 1e-6 + ring = [v.index for v in g.data.vertices if v.select] + + def spread(): + radii = [g.data.vertices[i].co.to_2d().length for i in ring] + return max(radii) - min(radii) + + before = spread() + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_mode(type="VERT") + with bpy.context.temp_override(**view3d_override()): + r = bpy.ops.mesh.looptools_circle() + bpy.ops.object.mode_set(mode="OBJECT") + after = spread() + report(name, r == {"FINISHED"} and before > 1e-4 and after < 1e-6, + str(len(ring)) + " boundary verts, radius spread " + str(round(before * 1000, 4)) + + " -> " + str(round(after * 1000, 6)) + " mm") + + +# 4. MeasureIt: a dimension segment between two vertices. +def t_measureit(name): + clear() + bpy.ops.mesh.primitive_cube_add(size=0.02) + c = bpy.context.active_object + for elems in (c.data.polygons, c.data.edges, c.data.vertices): + for e in elems: + e.select = False + for v in c.data.vertices: + v.select = v.index in (0, 1) + bpy.ops.object.mode_set(mode="EDIT") + with bpy.context.temp_override(**view3d_override()): + r = bpy.ops.measureit.addsegment() + bpy.ops.object.mode_set(mode="OBJECT") + n = c.MeasureGenerator[0].measureit_num if len(c.MeasureGenerator) else 0 + report(name, r == {"FINISHED"} and n == 1, "segments " + str(n)) + + +# 5. SurfacePsycho: one Bezier patch scaled to 20 x 20 mm, exported to STEP through its bundled OCP. +def t_surfacepsycho(name): + clear() + r1 = bpy.ops.object.sp_add_bezier_patch() + patch = bpy.context.active_object + patch.scale = (0.01, 0.01, 0.01) # the default patch is 2 x 2 m + bpy.context.view_layer.update() + select_only(patch) + path = OUT + "sp-patch-" + TAG + ".step" + r2 = bpy.ops.wm.sp_step_export(filepath=path, use_selection=True) + report(name, r1 == {"FINISHED"} and r2 == {"FINISHED"}, + "patch " + dims(patch) + " -> " + path + " (read back by the STEP import below)") + + +# 6. STEP Importer (Clonephaze, OCCT via cascadio): read the SurfacePsycho STEP back, and check it +# lands the same way up (world space) and the same size. +def t_step_import(name): + clear() + path = OUT + "sp-patch-" + TAG + ".step" + r = bpy.ops.import_scene.step(filepath=path) + meshes = [o for o in bpy.data.objects if o.type == "MESH"] + faces = sum(len(o.data.polygons) for o in meshes) + d = world_extent(meshes[0]) if meshes else (0, 0, 0) + same = abs(d[0] - 0.02) < 1e-5 and abs(d[1] - 0.02) < 1e-5 and d[2] < 1e-6 + report(name, r == {"FINISHED"} and len(meshes) == 1 and faces > 0 and same, + str(len(meshes)) + " mesh object(s), " + str(faces) + " faces" + + ("; " + dims(meshes[0]) if meshes else "") + ", expect 20x20x0") + # Control for the orientation check itself: a deliberately wrong source axis must stand + # the patch on its edge, and the same check must say so. + clear() + bpy.ops.import_scene.step(filepath=path, up_axis="Z") + wrong = [o for o in bpy.data.objects if o.type == "MESH"] + w = world_extent(wrong[0]) if wrong else (0, 0, 0) + caught = not (abs(w[0] - 0.02) < 1e-5 and abs(w[1] - 0.02) < 1e-5 and w[2] < 1e-6) + report(name + " (control: wrong up-axis is caught)", caught, + "up_axis=Z gives " + (dims(wrong[0]) if wrong else "nothing")) + + +# 7. 3MF Import/Export: a 20 mm cube out and back; the size must survive (3MF carries units). +def t_threemf(name): + clear() + bpy.ops.mesh.primitive_cube_add(size=0.02) + select_only(bpy.context.active_object) + path = OUT + "cube-" + TAG + ".3mf" + r1 = bpy.ops.export_mesh.threemf(filepath=path, use_selection=True) + clear() + r2 = bpy.ops.import_mesh.threemf(filepath=path) + meshes = [o for o in bpy.data.objects if o.type == "MESH"] + ok_size = len(meshes) == 1 and all(abs(d - 0.02) < 1e-6 for d in world_extent(meshes[0])) + report(name, r1 == {"FINISHED"} and r2 == {"FINISHED"} and ok_size, + path + ", re-imported " + (dims(meshes[0]) if meshes else "nothing")) + + +# 8. CAD Sketcher: a sketch on the XY origin plane, then a full solve through slvs. +def t_cad_sketcher(name): + clear() + with bpy.context.temp_override(**view3d_override()): + r1 = bpy.ops.view3d.slvs_add_sketch_on_plane(plane="XY") + r2 = bpy.ops.view3d.slvs_solve(all=True) + sketches = [o for o in bpy.data.objects if o.type == "CURVES"] + report(name, r1 == {"FINISHED"} and r2 == {"FINISHED"} and len(sketches) == 1, + "sketch objects " + str(len(sketches))) + + +for n, f in ( + ("print3d_toolbox clean_non_manifold", t_print3d), + ("bool_tool boolean_auto_difference", t_booltool), + ("looptools circle", t_looptools), + ("measureit addsegment", t_measureit), + ("surfacepsycho bezier patch + STEP export", t_surfacepsycho), + ("step_importer import_scene.step", t_step_import), + ("ThreeMF_io export + import", t_threemf), + ("CAD_Sketcher sketch + solve", t_cad_sketcher), +): + run(n, f) + +failed = [r[0] for r in results if not r[1]] +print("SUMMARY " + TAG + ": " + str(len(results) - len(failed)) + "/" + str(len(results)) + " passed") +if failed: + raise RuntimeError("failed: " + ", ".join(failed)) diff --git a/scripts/blender-run b/scripts/blender-run index 30846e9..6c22cf9 100755 --- a/scripts/blender-run +++ b/scripts/blender-run @@ -2,9 +2,9 @@ # blender-run — one-shot HEADLESS Blender on fv-ml1 GPU 3, for scripted/CLI callers (draupnir etc.). # The agent-driven, interactive path is scripts/blender-mcp; this is the batch path. # -# scripts/blender-run [--job DIR] -- +# scripts/blender-run [--job DIR] [--extensions] -- # scripts/blender-run --job /mnt/smithy/draupnir/j42 -- --python render.py -- --out out.png -# scripts/blender-run -- --python-expr 'import bpy; print(bpy.app.version_string)' +# scripts/blender-run --extensions -- --python-expr 'import bpy; print(bpy.app.version_string)' # # Each call is its own `docker run --rm` of the stacks/blender image (Blender 5.2.2 LTS, Python # 3.13): no desktop, no MCP socket, gone when Blender exits. So it never collides with the on-demand @@ -16,6 +16,12 @@ # output path against the working directory: "cannot save 'out.png'". Python file I/O and # importers do use the cwd. # +# --extensions: also enable the pinned add-on set (stacks/blender/extensions.lock: SurfacePsycho, +# CAD Sketcher, 3D-Print Toolbox, STEP Importer, Bool Tool, LoopTools, MeasureIt, 3MF). It mounts +# /tank/blender-extensions/5.2/system read-only as Blender's System repository and runs +# fleet_extensions.py ahead of your arguments. If any add-on fails to enable, the run exits 1 +# before your script starts. Off by default, so a plain render stays factory-clean. +# # Files: fv-ml1 does NOT mount /mnt/smithy. With --job DIR, DIR is mirrored to # fv-ml1:/tank/blender/jobs/-/, Blender runs WITH THAT AS ITS # WORKING DIRECTORY, and new or changed files are copied back into DIR afterwards (nothing is ever @@ -32,11 +38,18 @@ set -euo pipefail HOST=${BLENDER_SSH_HOST:-infra-ops@10.251.50.54} ENV_FILE=/opt/docker/compose/blender/.env JOB="" +EXT="" while [ $# -gt 0 ]; do case "$1" in --job) JOB=${2:?--job needs a directory}; shift 2 ;; + --extensions) + # --mount, not -v: a missing source is an error, where -v would silently create it as an + # empty root-owned DIRECTORY on fv-ml1 (and a directory where the hook file belongs). + EXT="--mount type=bind,src=/tank/blender-extensions/5.2/system,dst=/blender/5.2/extensions/system,readonly \ + --mount type=bind,src=/opt/docker/conf/blender/scripts/startup/fleet_extensions.py,dst=/fleet/fleet_extensions.py,readonly" + shift ;; --) shift; break ;; - -h|--help) sed -n 2,23p "$0"; exit 0 ;; + -h|--help) sed -n 2,35p "$0"; exit 0 ;; *) echo "blender-run: unknown option $1 (blender args go after --)" >&2; exit 2 ;; esac done @@ -59,13 +72,15 @@ fi # Arguments travel as one shell-quoted string: ssh flattens argv into a remote command line. ARGS=$(printf '%q ' "$@") +PRE="" +[ -n "$EXT" ] && PRE="--python /fleet/fleet_extensions.py" set +e ssh -n -o BatchMode=yes "$HOST" "IMG=\$(grep '^IMAGE=' $ENV_FILE | cut -d= -f2) && \ exec docker run --rm --name blender-run-\$\$ --runtime nvidia \ -e NVIDIA_VISIBLE_DEVICES=3 -e NVIDIA_DRIVER_CAPABILITIES=all \ - --user 1002:1003 -e HOME=/tmp --memory 64g --cpus 48 \ - -v /tank/blender:/work -w $WORKDIR --entrypoint /blender/blender \"\$IMG\" \ - -b --factory-startup --python-exit-code 1 $ARGS" + --user 1002:1003 -e HOME=/tmp -e USER=infra-ops -e LOGNAME=infra-ops --memory 64g --cpus 48 \ + -v /tank/blender:/work $EXT -w $WORKDIR --entrypoint /blender/blender \"\$IMG\" \ + -b --factory-startup --python-exit-code 1 $PRE $ARGS" RC=$? set -e if [ -n "$JOB" ]; then diff --git a/stacks/blender/README.md b/stacks/blender/README.md index 2439867..e7a0652 100644 --- a/stacks/blender/README.md +++ b/stacks/blender/README.md @@ -29,7 +29,8 @@ stays down.** A one-shot `docker run --rm` of this image with Blender as the entrypoint. It needs no desktop and does not collide with the GUI container's up/down. `--job DIR` stages a local dir to `/tank/blender/jobs//` and copies results back. Engines tested headless on 2026-09-28: -Cycles GPU and CPU, EEVEE (EGL), Workbench. STL import is built in; there is **no STEP importer**. +Cycles GPU and CPU, EEVEE (EGL), Workbench. STL import is built in. **`--extensions`** also +enables the pinned add-on set (STEP import and export among them; see "Extensions" below). Foot-guns and budget are in `docs/fleettools/blender.md`. First consumer: draupnir. ## Headless rendering inside the running GUI container @@ -112,3 +113,115 @@ An MCP client on nh3-dev → `scripts/blender-mcp` → the in-container server in a timer instead. - A pre-flight `ssh` without `-n` swallowed the MCP client's `initialize`, and the session hung at init. + +## Extensions: the pinned add-on set (2026-09-28) + +**Why:** Prime's ruling of 2026-09-28 makes Blender a mandatory stage in draupnir's pipeline +(requirements → functional shape in build123d → industrial design in Blender → print). draupnir +asked for these add-ons (thread `01M3MQEGGR0N981645WNZ9DMF3`). All come from extensions.blender.org, +all are GPL, and all are pinned by version and archive sha256 in [`extensions.lock`](extensions.lock). + +| Add-on | Version | For | Wheels | +|---|---|---|---| +| SurfacePsycho | 0.10.4 | NURBS/Bezier patch surfacing; STEP/IGES export (the route back to build123d). Alpha. | cadquery-ocp-novtk 7.9.3.1 (cp313) | +| CAD Sketcher | 0.32.1 | Constraint-based precise profiles | slvs 3.2 (cp313) | +| 3D-Print Toolbox | 1.4.1 | Mesh cleanup (clean non-manifold) and checks before a mesh leaves Blender | none | +| STEP Importer (Clonephaze) | 1.2.1 | STEP in, from vendor parts and functional shapes | cascadio 0.0.18rc8 (abi3) | +| Bool Tool | 2.1.0 | Hard-surface booleans | none | +| LoopTools | 4.7.7 | Mesh helpers | none | +| MeasureIt | 1.8.4 | Dimensions drawn in the viewport (they show in screenshots) | none | +| 3MF Import/Export (Clonephaze) | 2.7.7 | 3MF, which carries units (STL does not) | none | + +Deliberately skipped (draupnir): ND, HardOps, BoxCutter (modal only, an agent cannot drive them), +Quad Remesher (paid; the built-in QuadriFlow covers it), QRemeshify (not in the 5.2 catalogue). + +### How it is wired + +``` +stacks/blender/extensions.lock ──scripts/blender-extensions sync──▶ fv-ml1:/tank/blender-extensions/5.2/system + │ mounted READ-ONLY as Blender's + │ System repo (/blender/5.2/extensions/system) + ┌───────────────────────────────────────────┴──────────────────────────┐ + GUI container (compose.yaml) blender-run --extensions + conf/scripts/startup/fleet_extensions.py enables all the same file runs as --python ahead of the + of it in a timer after the prefs load caller's args; any failure exits 1 first +``` + +- **Nobody installs from inside Blender.** MCP safe mode blocks `bpy.ops.extensions.*`, + `addon_enable` and `register_class`, and the repository is mounted read-only, so an agent can + neither add an add-on nor alter one. Everything in the System repo is enabled; that directory + holds exactly the lock. +- **Wheels** (OCP, slvs, cascadio) are unpacked by Blender into the USER extensions dir, about 60 MB. + In the GUI that is `/config` (= `/opt/docker/data/blender`, restic). In blender-run it is the + container's `/tmp`, rebuilt every run, so concurrent runs share nothing. +- **`scripts/blender-extensions sync`** downloads each archive (cached in + `/tank/blender-extensions/zips/`), checks its sha256, installs it with Blender's own + `--command extension install-file` into a staging dir, pre-warms and byte-compiles it, checks + it enables read-only, and only then swaps it in. It **refuses while the GUI container or a + blender-run job is running**, because they hold the old directory through the bind mount. + `scripts/blender-extensions status` compares what is installed with the lock. +- **Bumping a version:** edit the lock row, re-run the audit below on the new archive, stop the + GUI (`scripts/blender-mcp down`), `scripts/blender-extensions sync`, run the acceptance probe. + +### Fleet-local fixes (in `fleet_extensions.py`, both paths) + +- **SurfacePsycho `view3d.sp_overwrite_segment_selection` runs `eval()` on its string property.** + That walks straight past MCP safe mode: an agent's `bpy.ops` call passes the AST check, and the + string inside it is never parsed. Nothing in the add-on calls that operator, so the hook swaps + `eval` for `ast.literal_eval`, which keeps its documented use (a literal set of segment ids) and + refuses code. **Control (2026-09-28):** unpatched, the payload `[__import__('os').getpid()]` ran + and returned `[1]`; patched, it raised `ValueError: malformed node`; the literal `{3, 5}` worked + both ways. The hook logs a warning if the upstream code changes. +- **3MF's "please rate us" popup** (after five exports) is switched off. + +### Phone-home audit (2026-09-28) + +- **Manifests:** none of the eight declares the `network` permission (five declare `files` only). +- **Source:** no add-on imports `socket`, `urllib.request`, `requests`, `http`, `subprocess` or + `webbrowser` (3MF uses `urllib.parse` for path joining only). `wm.url_open` appears only behind + buttons a person clicks (CAD Sketcher's help links, 3MF's rating dialog), and safe mode blocks + `wm.url_open` in agent code anyway. The GUI hook logs `bpy.app.online_access`. +- **Runtime:** the full acceptance probe ran with `docker run --network none` under a Python + audit hook watching `socket.*`, `urllib.Request`, `http.client.*`, `subprocess.Popen`, + `os.system`/`exec`/`posix_spawn` and `webbrowser.open`. **Zero events**, and every operator + passed offline. **Positive control:** one deliberate `socket.getaddrinfo` in the same setup + showed up as an event. **Sensitivity floor:** the hook sees Python-level calls only. The native + wheels (OCCT, SolveSpace) could open a socket without Python seeing it, but nothing needed the + network to work, and none of them is a networking library. + +### Foot-guns (all measured 2026-09-28) + +- **`blender --addons x,y` is not the same as enabling.** It leaves the add-on out of + `preferences.addons`, and Bool Tool and LoopTools read their own prefs in `register()`, so + they failed with a KeyError. The hook enables them the way the Preferences button does. +- **3D-Print Toolbox writes a cache into its own package dir** on first import. On the read-only + mount that fails, which is why `sync` pre-warms the staging copy while it is still writable. +- **CAD Sketcher calls `getpass.getuser()`**, which fails for a uid with no passwd entry. + blender-run sets `USER`; the GUI's `abc` user exists. +- **CAD Sketcher's sketch operators need the GUI.** Creating a sketch activates a workspace tool, + and in `blender -b` there is none: `'NoneType' object has no attribute 'widget'`. The add-on + loads headless and its solver (`slvs`) imports; sketch authoring is a GUI/MCP job. +- **`Object.dimensions` is in local axes** and ignores rotation, so it cannot show which way an + import faces. The probe uses world-space bounds. +- **STEP Importer orientation:** a SurfacePsycho STEP (Z-up, mm) comes back the right way up with + the importer's default `up_axis="Y"`. `up_axis="Z"` stands it on its edge (this is the probe's + control for its own orientation check). +- **SurfacePsycho's default patch is 2 x 2 m** (Blender units), and STEP export scales ×1000 into + millimetres by default. Scale the object to part size first. +- **MeasureIt and LoopTools want a 3D View area.** In the GUI, override with the window's + VIEW_3D area; headless, a screen datablock's VIEW_3D area works (`view3d_override()` in the probe). + +### Acceptance + +The probe is [`scripts/blender-probes/extensions_acceptance.py`](../../scripts/blender-probes/extensions_acceptance.py): +one real operator run per add-on, written to pass MCP safe mode so the same code runs on both +paths. Files land in `fv-ml1:/tank/blender/acceptance/extensions/`. + +**Headless, 2026-09-28:** 8 of 9 checks PASS, both online and with `--network none`: print3d +clean non-manifold (4 → 0 non-manifold edges), Bool Tool auto difference, LoopTools circle +(radius spread 4.14 mm → 0), MeasureIt segment, SurfacePsycho patch → STEP +(`sp-patch-headless.step`, 20 × 20 mm), STEP Importer read-back (20 × 20 × 0 mm world, plus the +wrong-axis control), 3MF out and back (20 mm cube kept its size). The one FAIL is CAD Sketcher, +for the GUI-only reason above. These runs used the same docker invocation blender-run makes. + +**MCP (GUI) path:** pending the stack deploy that mounts the repo and hook into the GUI container. diff --git a/stacks/blender/compose.yaml b/stacks/blender/compose.yaml index 3946447..55cd423 100644 --- a/stacks/blender/compose.yaml +++ b/stacks/blender/compose.yaml @@ -43,6 +43,11 @@ services: # startup hook that enables it and keeps its socket serving. Read-only; update via the repo. - /opt/docker/conf/blender/scripts/addons/blender_mcp.py:/config/.config/blender/5.2/scripts/addons/blender_mcp.py:ro - /opt/docker/conf/blender/scripts/startup/fleet_mcp.py:/config/.config/blender/5.2/scripts/startup/fleet_mcp.py:ro + # Pinned extensions (stacks/blender/extensions.lock, built by scripts/blender-extensions sync) + # as Blender's System repository, READ-ONLY: agents cannot install or alter add-ons. The + # startup hook enables every package in it once the prefs have loaded. README "Extensions". + - /tank/blender-extensions/5.2/system:/blender/5.2/extensions/system:ro + - /opt/docker/conf/blender/scripts/startup/fleet_extensions.py:/config/.config/blender/5.2/scripts/startup/fleet_extensions.py:ro ports: - "${HTTPS_PORT:-3001}:3001" # ⚠ NO port for the MCP add-on socket (it runs arbitrary Python, no auth). It listens on the diff --git a/stacks/blender/conf/scripts/startup/fleet_extensions.py b/stacks/blender/conf/scripts/startup/fleet_extensions.py new file mode 100644 index 0000000..f03f863 --- /dev/null +++ b/stacks/blender/conf/scripts/startup/fleet_extensions.py @@ -0,0 +1,144 @@ +"""Fleet extensions: enable the pinned add-ons in Blender's System extension repository, in the GUI +container and in `blender-run --extensions`. Part of eshpfi-management stacks/blender; see its +README, section "Extensions". + +The add-ons (versions and sha256 pinned in conf/extensions.lock) are installed by +scripts/blender-extensions into fv-ml1:/tank/blender-extensions/5.2/system. Both the GUI container +and blender-run mount that directory READ-ONLY as the System repository +(/blender/5.2/extensions/system). Nobody installs anything from inside Blender: MCP safe mode blocks +bpy.ops.extensions.*, addon_enable and register_class, and the repository is read-only anyway. +Every package in that directory is enabled, because the directory holds exactly the pinned set. + +This file runs in one of two ways: +- GUI: from the user scripts/startup dir, at every launch. The add-ons are enabled in a timer, + after the user preferences have loaded, because an earlier enable is undone by the prefs load + (same trap as fleet_mcp.py). Progress goes to /config/.local/state/fleet_extensions.log. +- Headless: blender-run --extensions passes this file as `--python` ahead of the caller's own + arguments. If any add-on fails to enable it raises, and with --python-exit-code the run exits 1 + before the caller's script starts, so a job never runs silently without an add-on it needs. + +Enabling follows the Preferences "enable" button: refresh the extension wheels with the pending +modules listed, then addon_utils.enable(default_set=True). `blender --addons` is NOT equivalent: +it leaves the add-on out of preferences.addons, and Bool Tool and LoopTools read their own prefs +in register() and fail with a KeyError (found 2026-09-28). +""" +import ast +import inspect +import os +import sys +import time +import traceback + +import addon_utils +import bpy + +REPO = "system" +LOG = "/config/.local/state/fleet_extensions.log" + + +def log(msg): + if bpy.app.background: + print(f"fleet_extensions: {msg}", file=sys.stderr, flush=True) + return + os.makedirs(os.path.dirname(LOG), exist_ok=True) + with open(LOG, "a") as f: + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") + + +def repo_dir(): + for repo in bpy.context.preferences.extensions.repos: + if repo.module == REPO: + return repo.directory + raise RuntimeError(f"no '{REPO}' extension repository in the preferences") + + +def packages(): + """Package ids in the System repository (one directory with a manifest per package).""" + d = repo_dir() + if not os.path.isdir(d): + return [] + return sorted(n for n in os.listdir(d) if os.path.isfile(os.path.join(d, n, "blender_manifest.toml"))) + + +def enable_all(): + """Enable every System-repository package. Returns (modules, failed, errors).""" + modules = [f"bl_ext.{REPO}.{p}" for p in packages()] + if not modules: + raise RuntimeError(f"no extensions in {repo_dir()}: is /tank/blender-extensions mounted?") + errors = [] + addon_utils.extensions_refresh( + ensure_wheels=True, + addon_modules_pending=modules, + handle_error=lambda ex: errors.append(f"wheels: {ex}"), + ) + for m in modules: + if not addon_utils.check(m)[1]: + addon_utils.enable(m, default_set=True, handle_error=lambda ex, m=m: errors.append(f"{m}: {ex!r}")) + failed = [m for m in modules if not addon_utils.check(m)[1]] + harden() + return modules, failed, errors + + +def harden(): + """Fleet-local fixes applied on top of the pinned add-ons. Each one names what it guards.""" + # SurfacePsycho 0.10.4: view3d.sp_overwrite_segment_selection runs eval() on its string + # property. That walks straight past MCP safe mode: an agent's bpy.ops call passes the AST + # check, and the string inside it is never parsed. Nothing in the add-on calls this operator + # (audited 2026-09-28), so literal_eval keeps its documented use (a literal set/list of + # segment ids) and refuses code. + try: + from bl_ext.system.surfacepsycho.tools import overlay_segment_selection as oss + except ImportError: + oss = None + cls = getattr(oss, "SP_OT_overwrite_segment_selection", None) + if cls is not None and not getattr(cls.execute, "fleet_hardened", False): + if " eval(self.select_string)" in inspect.getsource(cls.execute): + def execute(self, context): + oss.SELECTED_SEGMENTS.clear() + for s in ast.literal_eval(self.select_string): + oss.SELECTED_SEGMENTS.append(s) + return {"FINISHED"} + execute.fleet_hardened = True + cls.execute = execute + log("hardened: surfacepsycho sp_overwrite_segment_selection eval -> literal_eval") + else: + log("WARNING: surfacepsycho sp_overwrite_segment_selection changed upstream; re-audit it") + + # 3MF Import/Export 2.7.7 opens a "please rate us" popup after five exports. Off: agents + # export far more than five files and a popup is noise in the viewport screenshots. + entry = bpy.context.preferences.addons.get(f"bl_ext.{REPO}.ThreeMF_io") + if entry is not None and getattr(entry.preferences, "rating_prompt_after", -1) != -1: + entry.preferences.rating_prompt_after = -1 + + +def _gui_timer(): + """GUI: runs once, after the user prefs have loaded.""" + try: + modules, failed, errors = enable_all() + for e in errors: + log(f"error: {e}") + log(f"enabled {len(modules) - len(failed)}/{len(modules)}" + (f"; FAILED: {', '.join(failed)}" if failed else "")) + log(f"online access: {bpy.app.online_access}") + except Exception: + log("enable_all failed:\n" + traceback.format_exc()) + return None + + +def register(): + if bpy.app.background: + return + bpy.app.timers.register(_gui_timer, first_interval=2.0, persistent=True) + + +def unregister(): + pass + + +if __name__ == "__main__": + # Headless (blender-run --extensions): fail the run if anything did not enable. + _modules, _failed, _errors = enable_all() + for _e in _errors: + log(f"error: {_e}") + if _failed: + raise RuntimeError(f"extensions failed to enable: {', '.join(_failed)}") + log(f"enabled {len(_modules)}: {', '.join(m.rsplit('.', 1)[1] for m in _modules)}") diff --git a/stacks/blender/extensions.lock b/stacks/blender/extensions.lock new file mode 100644 index 0000000..fe1a1b2 --- /dev/null +++ b/stacks/blender/extensions.lock @@ -0,0 +1,19 @@ +# Blender extensions baked into fv-ml1's Blender, for the GUI (MCP) and for blender-run --extensions. +# See stacks/blender/README.md, section "Extensions". Built by scripts/blender-extensions sync. +# +# Pinned 2026-09-28 from extensions.blender.org for Blender 5.2 / linux-x64 (draupnir's request, +# Prime's ruling that Blender is a mandatory pipeline stage). All GPL. None declares the +# "network" permission; the code audit and the controls are in the README. +# +# Bumping a row = re-audit that add-on (README "Extensions" -> Audit), then run +# `scripts/blender-extensions sync` with the GUI container down. +# +# id version sha256 of the archive archive url +surfacepsycho 0.10.4 ea889cbbced58b069767b28186946b3f4caf3890e2ca3139d1163b1aa867db69 https://extensions.blender.org/download/sha256:ea889cbbced58b069767b28186946b3f4caf3890e2ca3139d1163b1aa867db69/add-on-surfacepsycho-v0.10.4-linux-x64.zip +CAD_Sketcher 0.32.1 2188c91753b8178cc3dc49384d69ddd18264aa468b66341db565700adbb398c6 https://extensions.blender.org/download/sha256:2188c91753b8178cc3dc49384d69ddd18264aa468b66341db565700adbb398c6/add-on-cad-sketcher-v0.32.1-linux-x64.zip +print3d_toolbox 1.4.1 cf5952c84d802a8df67368fd866e8fc01424bd1d71ab5db39d187dfbcfc6f2fd https://extensions.blender.org/download/sha256:cf5952c84d802a8df67368fd866e8fc01424bd1d71ab5db39d187dfbcfc6f2fd/add-on-print3d-toolbox-v1.4.1.zip +step_importer 1.2.1 9db6fa4a7f11c9cd1e43c061a4cdb3b1d4a376847991d60c94e80c9fb075c2ef https://extensions.blender.org/download/sha256:9db6fa4a7f11c9cd1e43c061a4cdb3b1d4a376847991d60c94e80c9fb075c2ef/add-on-step-importer-v1.2.1-linux-x64.zip +bool_tool 2.1.0 d3a282db25925d115dd1a7638aa28116f2f9198423b0afb6179e8127d59c06e1 https://extensions.blender.org/download/sha256:d3a282db25925d115dd1a7638aa28116f2f9198423b0afb6179e8127d59c06e1/add-on-bool-tool-v2.1.0.zip +looptools 4.7.7 ff1ca3b3fff73094379da8b1fa2c1acbc9d88d26b7dfc73bb9de5941a6b50108 https://extensions.blender.org/download/sha256:ff1ca3b3fff73094379da8b1fa2c1acbc9d88d26b7dfc73bb9de5941a6b50108/add-on-looptools-v4.7.7.zip +measureit 1.8.4 85b1836d97e5c2f0311afdf45cf9fd3cefa71bade074963864ce40ee15c26042 https://extensions.blender.org/download/sha256:85b1836d97e5c2f0311afdf45cf9fd3cefa71bade074963864ce40ee15c26042/add-on-measureit-v1.8.4.zip +ThreeMF_io 2.7.7 17c85812a331f8a7244629e3ee10a1aa0c7f7dc2a9eb9d60919677bba6e82d1d https://extensions.blender.org/download/sha256:17c85812a331f8a7244629e3ee10a1aa0c7f7dc2a9eb9d60919677bba6e82d1d/add-on-threemf-io-v2.7.7.zip