Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: blender-make-sliceable description: Prepare a mesh from this Unity project's Assets/Models/ (typically a .glb) for the runtime mesh slicer in Assets/MeshSlicer/Runtime. Converts arbitrary input (self-intersections, poke-through geometry, open shells, non-manifold edges) into a closed, manifold, outward-oriented mesh that can be safely bisected by an arbitrary plane and capped at runtime. Invoke when the user asks to make a model "sliceable" / "slicer-ready", or when the runtime slicer produces broken caps on a model. Pipeline: import .glb → Voxel Remesh → Decimate → Data Transfer (UVs) → bisect-and-cap probe → export .glb back to Unity. Requires the Blender MCP server (configured in mcp.json); runs via mcp__blender__execute_blender_code.
blender-make-sliceable
Prepare an arbitrary mesh from this Unity project for the runtime slicer. Output is a closed, manifold, outward-oriented .glb written back into Assets/Models/..., with a runtime-friendly face count and UVs approximately preserved. Verification empirically confirms the mesh can be bisected with an arbitrary plane and cleanly capped with bmesh.ops.holes_fill.
When to invoke
Use this skill when the user wants to make a model under Assets/Models/ safe for the project's runtime slicer (BurstMeshSlicer / NaiveMeshSlicer in Assets/MeshSlicer/Runtime). Typical triggers:
- "Make
<Asset>sliceable" / "Prep this for the slicer." - The runtime slicer produces visibly broken caps on a model — see
Project_Overview.md§8 ("the slicer assumes manifold input"). - A new
.glbwas added underAssets/Models/<Name>_Assets/and needs a sliceable variant.
Before running: verify the Blender MCP server is reachable with a trivial call (e.g., mcp__blender__execute_blender_code running import bpy; print(bpy.app.version)). If not connected, ask the user to start it — do not attempt workarounds.
Pipeline
- Import the source
.glbinto Blender; capture the imported object's name. - Duplicate target; hide original as UV reference and rollback target.
- Voxel Remesh — rebuild a watertight surface (drops UVs).
- Decimate (COLLAPSE) — reduce to a runtime-friendly face count while preserving topological closure.
- Data Transfer (loop UV,
POLYINTERP_NEAREST) — project UVs back from the original. - Shade smooth — set
polygon.use_smooth = Trueon every face; without this, glTF export bakes face normals per corner and Unity renders the result flat shaded. - Verify — watertight + manifold + outward normals + no degenerate faces + slice-and-cap probe + UV present.
- Export
<source>_sliceableas.glbnext to the input so Unity re-imports it.
Result object is named <source>_sliceable. Original stays hidden in the Blender scene.
Execution (via MCP)
Three discrete mcp__blender__execute_blender_code calls: load → prepare → export.
Step 1 — Resolve / load the source object
Pick exactly one of:
- Importing a Unity `.glb` (typical entry point). Substitute
GLB_PATHwith the absolute path of the asset (e.g.,…/MeshSlicer/Assets/Models/Barrel_Assets/selected.glb):
```python import bpy GLB_PATH = "<absolute path to .glb>"
before = set(bpy.data.objects.keys()) bpy.ops.import_scene.gltf(filepath=GLB_PATH) imported = [bpy.data.objects[n] for n in (set(bpy.data.objects.keys()) - before) if bpy.data.objects[n].type == 'MESH'] assert len(imported) == 1, f"Expected 1 mesh, got {[o.name for o in imported]}" print("SRC_NAME=" + imported[0].name) ```
Capture the printed SRC_NAME and use it in Step 2. If the glTF unpacks into multiple meshes, ask the user which to prepare (or join them first if that matches intent).
- Already loaded in the current Blender scene. List candidates and disambiguate:
``python import bpy meshes = [o.name for o in bpy.data.objects if o.type == 'MESH' and not o.hide_get()] active = bpy.context.view_layer.objects.active print({"meshes": meshes, "active": active.name if active else None}) ``
If there is exactly one mesh, or the active object is a mesh, use that as SRC_NAME. Otherwise ask the user. Never guess from a partial name match.
If <SRC_NAME>_sliceable already exists in bpy.data.objects, remove it before Step 2 — leftover state from a previous run breaks the duplicate step.
Step 2 — Prepare the mesh
Substitute SRC_NAME with the value resolved in Step 1. VOXEL_SIZE is auto-computed from the source bbox; only override DECIMATE_RATIO per the Tuning section.
import bpy, bmeshfrom mathutils import VectorSRC_NAME = "<value from Step 1>"DECIMATE_RATIO = 0.3 # keep 30% of remesh facessrc = bpy.data.objects[SRC_NAME]# Auto-compute voxel size from the world-space bbox (~max_dim / 100).bb = [src.matrix_world @ Vector(c) for c in src.bound_box]bb_min = Vector((min(v.x for v in bb), min(v.y for v in bb), min(v.z for v in bb)))bb_max = Vector((max(v.x for v in bb), max(v.y for v in bb), max(v.z for v in bb)))max_dim = max((bb_max - bb_min)[:])VOXEL_SIZE = max_dim / 100.0# 1) Duplicate; hide source as UV referencebpy.ops.object.select_all(action='DESELECT')src.select_set(True)bpy.context.view_layer.objects.active = srcbpy.ops.object.duplicate(linked=False)work = bpy.context.active_objectwork.name = SRC_NAME + "_sliceable"src.hide_set(True)# 2) Voxel Remeshm1 = work.modifiers.new("VoxelRemesh", 'REMESH')m1.mode = 'VOXEL'm1.voxel_size = VOXEL_SIZEm1.use_smooth_shade = Truem1.adaptivity = 0.0bpy.ops.object.modifier_apply(modifier=m1.name)# 3) Decimatem2 = work.modifiers.new("Decimate", 'DECIMATE')m2.decimate_type = 'COLLAPSE'm2.ratio = DECIMATE_RATIOm2.use_collapse_triangulate = Truebpy.ops.object.modifier_apply(modifier=m2.name)# 4) UV transfer from sourceif not work.data.uv_layers:work.data.uv_layers.new(name="UVMap")bpy.context.view_layer.objects.active = workbpy.ops.object.select_all(action='DESELECT')work.select_set(True)m3 = work.modifiers.new("UVTransfer", 'DATA_TRANSFER')m3.object = srcm3.use_loop_data = Truem3.data_types_loops = {'UV'}m3.loop_mapping = 'POLYINTERP_NEAREST'bpy.ops.object.datalayout_transfer(modifier=m3.name)bpy.ops.object.modifier_apply(modifier=m3.name)# 5) Force smooth shading on every polygon. The REMESH modifier's# `use_smooth_shade` only affects the live preview; after `modifier_apply`# (and the Decimate re-triangulation that follows), `polygon.use_smooth`# is False, so the glTF exporter writes face normals per corner and Unity# renders the result flat shaded with ~3 unshared vertices per triangle.for p in work.data.polygons:p.use_smooth = True# 6) Verifyme = work.databm = bmesh.new(); bm.from_mesh(me); bm.normal_update()non_manifold = sum(1 for e in bm.edges if not e.is_manifold)boundary = sum(1 for e in bm.edges if e.is_boundary)zero_area = sum(1 for f in bm.faces if f.calc_area() < 1e-12)loose_verts = sum(1 for v in bm.verts if not v.link_edges)# Outward-normal check via signed volume (divergence theorem on fan-triangulated faces)signed_vol = 0.0for f in bm.faces:vs = [l.vert.co for l in f.loops]v0 = vs[0]for i in range(1, len(vs) - 1):signed_vol += v0.dot(vs[i].cross(vs[i + 1])) / 6.0# Slice-and-cap probe: bisect with an oblique plane through bbox center,# drop one half, fill the boundary as a cap, confirm result is still closed.bb_center = sum((Vector(c) for c in work.bound_box), Vector()) / 8.0bm_probe = bm.copy()bmesh.ops.bisect_plane(bm_probe,geom=bm_probe.verts[:] + bm_probe.edges[:] + bm_probe.faces[:],plane_co=bb_center,plane_no=Vector((1.0, 0.5, 0.3)).normalized(),clear_inner=True, clear_outer=False,)boundary_after_cut = [e for e in bm_probe.edges if e.is_boundary]fill = bmesh.ops.holes_fill(bm_probe, edges=boundary_after_cut, sides=0)cap_faces = len(fill.get("faces", []))post_nm = sum(1 for e in bm_probe.edges if not e.is_manifold)post_bnd = sum(1 for e in bm_probe.edges if e.is_boundary)slice_clean = (post_nm == 0 and post_bnd == 0 and cap_faces > 0)bm.free(); bm_probe.free()passed = (non_manifold == 0 and boundary == 0 andzero_area == 0 and loose_verts == 0 andsigned_vol > 0 and slice_clean andbool(me.uv_layers))result = {"object": work.name,"voxel_size": round(VOXEL_SIZE, 6),"faces": len(me.polygons),"uv_layers": [l.name for l in me.uv_layers],"watertight": {"non_manifold_edges": non_manifold, "boundary_edges": boundary},"geometry_health": {"zero_area_faces": zero_area, "loose_verts": loose_verts},"normals": {"signed_volume": round(signed_vol, 6),"outward_consistent": signed_vol > 0},"slice_probe": {"cap_faces": cap_faces,"post_cut_non_manifold_edges": post_nm,"post_cut_boundary_edges": post_bnd,"clean_solid_half": slice_clean},"pass": passed,}print(result)
Pass condition: result["pass"] == True. Inspect each sub-block on failure — any single failed check means the mesh is not slicer-ready. Do not proceed to Step 3 unless pass == True (or the user explicitly accepts the failure).
Step 3 — Export back to Unity
Write the prepared object next to the source as a sibling .glb. Default naming: <input_stem>_sliceable.glb (e.g., Assets/Models/Barrel_Assets/selected_sliceable.glb). If that path already exists, confirm with the user before overwriting.
import bpyOUT_PATH = "<absolute path to <input_stem>_sliceable.glb>"WORK_NAME = "<SRC_NAME>_sliceable"bpy.ops.object.select_all(action='DESELECT')work = bpy.data.objects[WORK_NAME]work.select_set(True)bpy.context.view_layer.objects.active = workbpy.ops.export_scene.gltf(filepath=OUT_PATH,export_format='GLB',use_selection=True,export_apply=True, # bake any remaining transforms / modifiersexport_yup=True, # glTF +Y up; Unity's importer handles the rest)print("EXPORTED=" + OUT_PATH)
Unity re-imports the .glb on next editor focus. No special importer settings are required for slicing itself, but if the runtime path mutates the mesh at edit time, the importer's Read/Write Enabled flag must be on.
Tuning
- `VOXEL_SIZE` is auto-set to
max_bbox_dim / 100. To override, edit the computed value before theREMESHblock: - Corners or thin plates collapse too much → halve it (face count grows ~4× before decimate).
- Too slow / face count explodes → double it.
- Safety floor: do not go below
max_bbox_dim / 1000without confirming — Blender can hang on extremely small voxel sizes. - `DECIMATE_RATIO`: 0.3 keeps silhouette well in most cases. Lower for lighter assets, but watch the slice probe — aggressive decimation can introduce zero-area faces or break manifoldness. Raise toward 1.0 if
pass == False. Set to1.0to disable decimation if face count is not a constraint. - Recovery on verify failure: delete
<SRC>_sliceable, unhide source (hide_set(False)), adjust parameters, rerun Step 2. The source.glbon disk is never touched until Step 3.
Limitations
- glTF round-trip splits vertices at UV seams and sharp normals. After Unity imports the exported
.glb, the mesh will appear non-manifold even though it remains topologically closed in 3D space. The runtime slicer must perform a merge-by-distance / weld pass on import (tolerance ≈1e-5of model scale) before computing cap geometry. Without this, the slicer will see spurious boundaries along the seams and produce broken caps. This is an export-format limitation, not a defect of the prepared mesh. - Cap-face UVs are out of scope. The runtime slicer must generate UVs for the newly created cap polygons (typically by planar projection in the cut plane).
- UV continuity at voxel boundaries. Where Voxel Remesh boundaries cross original UV island seams, small UV discontinuities remain on the surface. If unacceptable, follow this skill with a Smart UV Unwrap + texture bake into the new UVs.
- Only material slots and UVs are preserved. Vertex colors and other loop/poly attributes are dropped unless
data_types_loops/data_types_polyson the Data Transfer modifier are extended. - Inputs already closed with sharp detail to preserve are better handled by Boolean Union — out of scope here.