refactor: replace STL intermediary with OCC-native STEP→GLB pipeline

- export_step_to_gltf.py: STEP→GLB via RWGltf_CafWriter + BRepBuilderAPI_Transform
  (mm→m pre-scaling, XCAFDoc_ShapeTool.GetComponents_s static method)
- Blender scripts (blender_render.py, still_render.py, turntable_render.py,
  export_gltf.py, export_blend.py): import GLB instead of STL, remove _scale_mm_to_m
- step_tasks.py: add generate_gltf_production_task, remove generate_stl_cache,
  replace _bbox_from_stl with _bbox_from_glb (trimesh), auto-queue geometry GLB
  after thumbnail render
- render_blender.py: replace _stl_from_cache_or_convert with _glb_from_step,
  remove convert_step_to_stl and export_per_part_stls
- domains/rendering/tasks.py: update render_turntable_task, export_gltf/blend tasks
  to use GLB instead of STL
- cad.py: remove STL download/generate endpoints, add generate-gltf-production
- admin.py: generate-missing-stls → generate-missing-geometry-glbs
- Frontend: replace STL cache UI with GLB generate buttons, remove stl_cached field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 16:49:18 +01:00
parent 3eba7b2d37
commit 95cfe0aa93
20 changed files with 809 additions and 1301 deletions
+24 -91
View File
@@ -57,12 +57,12 @@ else:
if len(argv) < 4:
print("Usage: blender --background --python blender_render.py -- "
"<stl_path> <output_path> <width> <height> [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]")
"<glb_path> <output_path> <width> <height> [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]")
sys.exit(1)
import json as _json
stl_path = argv[0]
glb_path = argv[0]
output_path = argv[1]
width = int(argv[2])
height = int(argv[3])
@@ -173,23 +173,7 @@ def _assign_palette_material(part_obj, index):
import re as _re
def _scale_mm_to_m(parts):
"""Scale imported STL objects from mm to Blender metres (×0.001).
STEP/STL coordinates are in mm; Blender's default unit is metres.
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
relative to any template environment designed in metric units.
"""
if not parts:
return
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.scale = (0.001, 0.001, 0.001)
p.location *= 0.001
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
print(f"[blender_render] scaled {len(parts)} parts mm→m (×0.001)")
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
def _apply_rotation(parts, rx, ry, rz):
@@ -276,85 +260,37 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non
bpy.ops.object.mode_set(mode='OBJECT')
def _import_stl(stl_file):
"""Import STL into Blender, using per-part STLs if available.
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
Checks for {stl_stem}_parts/manifest.json next to the STL file.
- Per-part mode: imports each part STL, names Blender object after STEP part name.
- Fallback: imports combined STL and splits by loose geometry.
OCC exports one mesh object per STEP part, already in metres.
Blender's native GLTF importer preserves part names.
Returns list of Blender mesh objects, centred at origin.
Returns list of Blender mesh objects, centred at world origin.
"""
stl_dir = os.path.dirname(stl_file)
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
manifest_path = os.path.join(parts_dir, "manifest.json")
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
parts = []
if os.path.isfile(manifest_path):
# ── Per-part mode ────────────────────────────────────────────────
try:
with open(manifest_path, "r") as f:
manifest = _json.loads(f.read())
part_entries = manifest.get("parts", [])
except Exception as e:
print(f"[blender_render] WARNING: failed to read manifest: {e}")
part_entries = []
if part_entries:
for entry in part_entries:
part_file = os.path.join(parts_dir, entry["file"])
part_name = entry["name"]
if not os.path.isfile(part_file):
print(f"[blender_render] WARNING: part STL missing: {part_file}")
continue
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.wm.stl_import(filepath=part_file)
imported = bpy.context.selected_objects
if imported:
obj = imported[0]
obj.name = part_name
if obj.data:
obj.data.name = part_name
parts.append(obj)
if parts:
print(f"[blender_render] imported {len(parts)} named parts from per-part STLs")
# ── Fallback: combined STL + separate by loose ───────────────────────
if not parts:
bpy.ops.wm.stl_import(filepath=stl_file)
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
if obj is None:
print(f"ERROR: No objects imported from {stl_file}")
sys.exit(1)
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
obj.location = (0.0, 0.0, 0.0)
print(f"[blender_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.separate(type='LOOSE')
bpy.ops.object.mode_set(mode='OBJECT')
parts = list(bpy.context.selected_objects)
print(f"[blender_render] fallback: separated into {len(parts)} part(s)")
return parts
# ── Centre per-part imports at origin (combined bbox) ────────────────
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
@@ -453,10 +389,8 @@ if use_template:
# Find or create target collection
target_col = _ensure_collection(target_collection)
# Import and split STL
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
# Import OCC GLB (already in metres, one object per STEP part)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
@@ -538,9 +472,8 @@ else:
# ── MODE A: Factory settings (original behavior) ─────────────────────────
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
# Import OCC GLB (already in metres, one object per STEP part)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
+7 -12
View File
@@ -1,14 +1,14 @@
"""Blender headless script: export a STEP-derived scene as a production .blend.
"""Blender headless script: export a geometry GLB as a production .blend.
Usage:
blender --background --python export_blend.py -- \\
--stl_path /path/to/file.stl \\
--glb_path /path/to/geometry.glb \\
--output_path /path/to/output.blend \\
[--asset_library_blend /path/to/library.blend] \\
[--material_map '{"SrcMat": "LibMat"}']
The script:
1. Imports the STL file (with mm→m scale).
1. Imports the geometry GLB (already in metres, Y-up).
2. Optionally applies asset library materials from a .blend.
3. Packs all external data.
4. Saves a copy as the output .blend.
@@ -28,7 +28,8 @@ def parse_args() -> argparse.Namespace:
sys.exit(1)
rest = argv[argv.index("--") + 1:]
parser = argparse.ArgumentParser()
parser.add_argument("--stl_path", required=True)
parser.add_argument("--glb_path", required=True,
help="Geometry GLB from export_step_to_gltf.py (already in metres)")
parser.add_argument("--output_path", required=True)
parser.add_argument("--asset_library_blend", default=None)
parser.add_argument("--material_map", default="{}")
@@ -44,14 +45,8 @@ def main() -> None:
# Clean scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import STL
bpy.ops.import_mesh.stl(filepath=args.stl_path)
# Scale mm → m
for obj in bpy.context.selected_objects:
obj.scale = (0.001, 0.001, 0.001)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(scale=True)
# Import geometry GLB (metres, Y-up — no rescaling needed)
bpy.ops.import_scene.gltf(filepath=args.glb_path)
# Apply asset library materials if provided
if args.asset_library_blend and material_map:
+13 -68
View File
@@ -27,78 +27,30 @@ def parse_args() -> argparse.Namespace:
sys.exit(1)
rest = argv[argv.index("--") + 1:]
parser = argparse.ArgumentParser()
parser.add_argument("--stl_path", required=True)
parser.add_argument("--glb_path", required=True,
help="Geometry GLB from export_step_to_gltf.py (already in metres)")
parser.add_argument("--output_path", required=True)
parser.add_argument("--asset_library_blend", default=None)
parser.add_argument("--material_map", default="{}")
parser.add_argument("--sharp_edges_json", default="[]",
help="JSON array of [x, y, z] midpoints (mm) to mark as sharp edges")
return parser.parse_args(rest)
def mark_sharp_edges_by_proximity(midpoints_mm: list, threshold_mm: float = 1.0) -> None:
"""Mark Blender mesh edges as sharp based on proximity to OCC-derived midpoints.
midpoints_mm: list of [x, y, z] in mm (from OCC coordinate space).
After STL import + scale-apply (mm→m), Blender vertices are in meters, so we
convert the edge midpoint back to mm before comparing.
threshold_mm: snap distance in mm (default 1.0 mm).
"""
if not midpoints_mm:
return
import bpy # type: ignore[import]
import math
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
mesh = obj.data
# Blender 4.1+ removed use_auto_smooth — use shade_smooth_by_angle instead
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
try:
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(30))
except Exception:
pass # fallback: stay flat-shaded
mw = obj.matrix_world
for edge in mesh.edges:
v1 = mw @ mesh.vertices[edge.vertices[0]].co
v2 = mw @ mesh.vertices[edge.vertices[1]].co
# Convert Blender meters → mm for comparison
mid_mm = [
(v1.x + v2.x) / 2 * 1000,
(v1.y + v2.y) / 2 * 1000,
(v1.z + v2.z) / 2 * 1000,
]
for hint in midpoints_mm:
dist_sq = sum((a - b) ** 2 for a, b in zip(mid_mm, hint))
if dist_sq < threshold_mm ** 2:
edge.use_edge_sharp = True
break
def main() -> None:
args = parse_args()
material_map: dict = json.loads(args.material_map)
sharp_edge_midpoints: list = json.loads(args.sharp_edges_json)
import bpy # type: ignore[import]
import math as _math
# Clean scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import STL (bpy.ops.wm.stl_import is the Blender 4.0+ API)
bpy.ops.wm.stl_import(filepath=args.stl_path)
# Scale mm → m
for obj in bpy.context.selected_objects:
obj.scale = (0.001, 0.001, 0.001)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(scale=True)
# Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up)
bpy.ops.import_scene.gltf(filepath=args.glb_path)
print(f"Imported geometry GLB: {args.glb_path} "
f"({len([o for o in bpy.data.objects if o.type == 'MESH'])} mesh objects)")
# Apply smooth shading with 30° angle threshold (Blender 4.1+ API)
import math as _math
for obj in bpy.data.objects:
if obj.type == "MESH":
bpy.context.view_layer.objects.active = obj
@@ -108,37 +60,30 @@ def main() -> None:
except Exception:
pass
# Mark sharp edges for better UV seams
if sharp_edge_midpoints:
mark_sharp_edges_by_proximity(sharp_edge_midpoints)
print(f"Marked sharp edges from {len(sharp_edge_midpoints)} hint points")
# Apply asset library materials if provided.
# link=False (append) is required for GLB export: the GLTF exporter can only
# traverse local (appended) Principled BSDF node trees to extract PBR values.
# Linked materials are external references whose node data is not accessible.
# link=False (append) is required: the GLTF exporter can only traverse
# local (appended) Principled BSDF node trees to extract PBR values.
if args.asset_library_blend and material_map:
import os
sys.path.insert(0, os.path.dirname(__file__))
from asset_library import apply_asset_library_materials
apply_asset_library_materials(args.asset_library_blend, material_map, link=False)
# Export GLB with full PBR material data
# Note: export_colors was removed in Blender 4.x — do not pass it.
# Export production GLB with full PBR material data
try:
bpy.ops.export_scene.gltf(
filepath=args.output_path,
export_format="GLB",
export_apply=True,
use_selection=False,
export_materials="EXPORT", # export all materials (Principled BSDF → glTF PBR)
export_image_format="AUTO", # embed textures (base color, normal, roughness maps)
export_materials="EXPORT",
export_image_format="AUTO",
)
except Exception as exc:
print(f"GLB export failed: {exc}", file=sys.stderr)
sys.exit(1)
print(f"GLB exported to {args.output_path}")
print(f"Production GLB exported to {args.output_path}")
try:
@@ -0,0 +1,232 @@
"""OCC-native STEP → GLB export script.
Reads a STEP file via OCP/XCAF (preserving part names and embedded colors),
tessellates with BRepMesh, optionally applies per-part hex colors, and writes
a binary GLB in meters (Y-up, glTF convention).
No Blender required. Uses the same OCP bindings that cadquery ships with.
Usage:
python3 export_step_to_gltf.py \
--step_path /path/to/file.stp \
--output_path /path/to/output.glb \
[--linear_deflection 0.1] \
[--angular_deflection 0.5] \
[--color_map '{"RingInner": "#4C9BE8", "RingOuter": "#E85B4C"}']
Exit 0 on success, exit 1 on failure.
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
from pathlib import Path
PALETTE_HEX = [
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--step_path", required=True)
parser.add_argument("--output_path", required=True)
parser.add_argument(
"--linear_deflection", type=float, default=0.1,
help="OCC linear deflection for tessellation (mm). Smaller = finer mesh. Default 0.1",
)
parser.add_argument(
"--angular_deflection", type=float, default=0.5,
help="OCC angular deflection (radians). Default 0.5",
)
parser.add_argument(
"--color_map", default="{}",
help='JSON dict mapping part name → hex color, e.g. \'{"Ring": "#4C9BE8"}\'',
)
return parser.parse_args()
def _hex_to_occ_color(hex_color: str):
"""Convert '#RRGGBB' → Quantity_Color (linear float)."""
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
h = hex_color.lstrip("#")
if len(h) < 6:
return Quantity_Color(0.7, 0.7, 0.7, Quantity_TOC_RGB)
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return Quantity_Color(r, g, b, Quantity_TOC_RGB)
def _apply_color_map(shape_tool, color_tool, free_labels, color_map: dict) -> None:
"""Apply hex colors from color_map to matching shapes by name (case-insensitive substring)."""
from OCP.TDF import TDF_LabelSequence
from OCP.TDataStd import TDataStd_Name
from OCP.XCAFDoc import XCAFDoc_ShapeTool
# XCAFDoc_ColorType: XCAFDoc_ColorGen=0, XCAFDoc_ColorSurf=1, XCAFDoc_ColorCurv=2
try:
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
except ImportError:
COLOR_SURF = 1 # integer fallback
def _visit(label) -> None:
name_attr = TDataStd_Name()
name = ""
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
name = name_attr.Get().ToExtString()
if name:
for part_name, hex_color in color_map.items():
if part_name.lower() in name.lower() or name.lower() in part_name.lower():
color_tool.SetColor(label, _hex_to_occ_color(hex_color), COLOR_SURF)
break
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
for i in range(1, components.Length() + 1):
_visit(components.Value(i))
for i in range(1, free_labels.Length() + 1):
_visit(free_labels.Value(i))
def _apply_palette_colors(shape_tool, color_tool, free_labels) -> None:
"""Assign palette colors to leaf shapes when no color_map is provided."""
from OCP.TDF import TDF_LabelSequence
from OCP.XCAFDoc import XCAFDoc_ShapeTool
try:
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
except ImportError:
COLOR_SURF = 1
leaves: list = []
def _collect(label) -> None:
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
if components.Length() == 0:
leaves.append(label)
else:
for i in range(1, components.Length() + 1):
_collect(components.Value(i))
for i in range(1, free_labels.Length() + 1):
_collect(free_labels.Value(i))
for idx, label in enumerate(leaves):
occ_color = _hex_to_occ_color(PALETTE_HEX[idx % len(PALETTE_HEX)])
color_tool.SetColor(label, occ_color, COLOR_SURF)
def main() -> None:
args = parse_args()
color_map: dict = json.loads(args.color_map)
from OCP.STEPCAFControl import STEPCAFControl_Reader
from OCP.TDocStd import TDocStd_Document
from OCP.XCAFApp import XCAFApp_Application
from OCP.XCAFDoc import XCAFDoc_DocumentTool
from OCP.TCollection import TCollection_ExtendedString, TCollection_AsciiString
from OCP.TDF import TDF_LabelSequence
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.IFSelect import IFSelect_RetDone
from OCP.Message import Message_ProgressRange
# --- Init XDE document ---
app = XCAFApp_Application.GetApplication_s()
doc = TDocStd_Document(TCollection_ExtendedString("MDTV-CAF"))
app.InitDocument(doc)
# --- Read STEP into XDE (preserves part names + embedded colors) ---
reader = STEPCAFControl_Reader()
reader.SetNameMode(True)
reader.SetColorMode(True)
reader.SetLayerMode(True)
status = reader.ReadFile(args.step_path)
if status != IFSelect_RetDone:
print(f"ERROR: STEPCAFControl_Reader failed (status={status})", file=sys.stderr)
sys.exit(1)
reader.Transfer(doc)
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
# --- Tessellate all free shapes ---
free_labels = TDF_LabelSequence()
shape_tool.GetFreeShapes(free_labels)
print(f"Found {free_labels.Length()} root shape(s), tessellating "
f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …")
for i in range(1, free_labels.Length() + 1):
shape = shape_tool.GetShape_s(free_labels.Value(i))
if not shape.IsNull():
BRepMesh_IncrementalMesh(
shape,
args.linear_deflection,
False, # isRelative
args.angular_deflection,
True, # isInParallel
)
# --- Apply colors ---
if color_map:
_apply_color_map(shape_tool, color_tool, free_labels, color_map)
print(f"Applied color_map ({len(color_map)} entries)")
else:
_apply_palette_colors(shape_tool, color_tool, free_labels)
print("Applied palette colors (no color_map provided)")
# --- Scale shapes mm → m before GLB export ---
# RWMesh_CoordinateSystemConverter is not wrapped in OCP Python bindings.
# Pre-scale each free shape by 0.001 (mm → m) using BRepBuilderAPI_Transform.
from OCP.gp import gp_Trsf
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
trsf = gp_Trsf()
trsf.SetScaleFactor(0.001)
for i in range(1, free_labels.Length() + 1):
label = free_labels.Value(i)
orig_shape = shape_tool.GetShape_s(label)
if not orig_shape.IsNull():
scaled = BRepBuilderAPI_Transform(orig_shape, trsf, True).Shape()
shape_tool.SetShape(label, scaled)
print("Shapes scaled mm → m")
# --- Export GLB via RWGltf_CafWriter ---
from OCP.RWGltf import RWGltf_CafWriter
writer = RWGltf_CafWriter(TCollection_AsciiString(args.output_path), True) # True = binary GLB
# Z-up → Y-up rotation is applied by RWGltf_CafWriter by default (OCC 7.6+).
# Perform export
try:
from OCP.TColStd import TColStd_IndexedDataMapOfStringString
metadata = TColStd_IndexedDataMapOfStringString()
ok = writer.Perform(doc, metadata, Message_ProgressRange())
except TypeError:
# Older API without metadata dict
ok = writer.Perform(doc, Message_ProgressRange())
out = Path(args.output_path)
if not ok or not out.exists() or out.stat().st_size == 0:
print(f"ERROR: RWGltf_CafWriter.Perform returned ok={ok}, file exists={out.exists()}",
file=sys.stderr)
sys.exit(1)
print(f"GLB exported: {out.name} ({out.stat().st_size // 1024} KB)")
try:
main()
except SystemExit:
raise
except Exception:
traceback.print_exc()
sys.exit(1)
+22 -92
View File
@@ -87,23 +87,7 @@ def _apply_smooth(part_obj, angle_deg):
import re as _re
def _scale_mm_to_m(parts):
"""Scale imported STL objects from mm to Blender metres (×0.001).
STEP/STL coordinates are in mm; Blender's default unit is metres.
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
relative to any template environment designed in metric units.
"""
if not parts:
return
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.scale = (0.001, 0.001, 0.001)
p.location *= 0.001
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
print(f"[still_render] scaled {len(parts)} parts mm→m (×0.001)")
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
def _apply_rotation(parts, rx, ry, rz):
@@ -209,85 +193,35 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non
bpy.ops.object.mode_set(mode='OBJECT')
def _import_stl(stl_file):
"""Import STL into Blender, using per-part STLs if available.
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
Checks for {stl_stem}_parts/manifest.json next to the STL file.
- Per-part mode: imports each part STL, names Blender object after STEP part name.
- Fallback: imports combined STL and splits by loose geometry.
Returns list of Blender mesh objects, centred at origin.
OCC exports one mesh object per STEP part, already in metres.
Returns list of Blender mesh objects, centred at world origin.
"""
stl_dir = os.path.dirname(stl_file)
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
manifest_path = os.path.join(parts_dir, "manifest.json")
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
parts = []
if os.path.isfile(manifest_path):
# ── Per-part mode ────────────────────────────────────────────────
try:
with open(manifest_path, "r") as f:
manifest = json.loads(f.read())
part_entries = manifest.get("parts", [])
except Exception as e:
print(f"[still_render] WARNING: failed to read manifest: {e}")
part_entries = []
if part_entries:
for entry in part_entries:
part_file = os.path.join(parts_dir, entry["file"])
part_name = entry["name"]
if not os.path.isfile(part_file):
print(f"[still_render] WARNING: part STL missing: {part_file}")
continue
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.wm.stl_import(filepath=part_file)
imported = bpy.context.selected_objects
if imported:
obj = imported[0]
obj.name = part_name
if obj.data:
obj.data.name = part_name
parts.append(obj)
if parts:
print(f"[still_render] imported {len(parts)} named parts from per-part STLs")
# ── Fallback: combined STL + separate by loose ───────────────────────
if not parts:
bpy.ops.wm.stl_import(filepath=stl_file)
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
if obj is None:
print(f"ERROR: No objects imported from {stl_file}")
sys.exit(1)
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
obj.location = (0.0, 0.0, 0.0)
print(f"[still_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.separate(type='LOOSE')
bpy.ops.object.mode_set(mode='OBJECT')
parts = list(bpy.context.selected_objects)
print(f"[still_render] fallback: separated into {len(parts)} part(s)")
return parts
# ── Centre per-part imports at origin (combined bbox) ────────────────
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
@@ -376,7 +310,7 @@ def main():
argv = sys.argv
args = argv[argv.index("--") + 1:]
stl_path = args[0]
glb_path = args[0]
output_path = args[1]
width = int(args[2])
height = int(args[3])
@@ -460,10 +394,8 @@ def main():
# Find or create target collection
target_col = _ensure_collection(target_collection)
# Import and split STL
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
# Import OCC GLB (already in metres, one object per STEP part)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides
@@ -562,9 +494,7 @@ def main():
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides
@@ -839,7 +769,7 @@ def main():
draw.rectangle([0, 0, W - 1, bar_h - 1], fill=(0, 137, 61, 255))
# Model name strip at bottom
model_name = os.path.splitext(os.path.basename(stl_path))[0]
model_name = os.path.splitext(os.path.basename(glb_path))[0]
label_h = max(20, H // 20)
img.alpha_composite(
Image.new("RGBA", (W, label_h), (30, 30, 30, 180)),
+21 -91
View File
@@ -138,23 +138,7 @@ def _set_fcurves_linear(action):
kp.interpolation = 'LINEAR'
def _scale_mm_to_m(parts):
"""Scale imported STL objects from mm to Blender metres (×0.001).
STEP/STL coordinates are in mm; Blender's default unit is metres.
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
relative to any template environment designed in metric units.
"""
if not parts:
return
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.scale = (0.001, 0.001, 0.001)
p.location *= 0.001
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
print(f"[turntable_render] scaled {len(parts)} parts mm→m (×0.001)")
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
@@ -179,85 +163,35 @@ def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
obj.data.auto_smooth_angle = threshold_rad
def _import_stl(stl_file):
"""Import STL into Blender, using per-part STLs if available.
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
Checks for {stl_stem}_parts/manifest.json next to the STL file.
- Per-part mode: imports each part STL, names Blender object after STEP part name.
- Fallback: imports combined STL and splits by loose geometry.
Returns list of Blender mesh objects, centred at origin.
OCC exports one mesh object per STEP part, already in metres.
Returns list of Blender mesh objects, centred at world origin.
"""
stl_dir = os.path.dirname(stl_file)
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
manifest_path = os.path.join(parts_dir, "manifest.json")
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
parts = []
if os.path.isfile(manifest_path):
# ── Per-part mode ────────────────────────────────────────────────
try:
with open(manifest_path, "r") as f:
manifest = json.loads(f.read())
part_entries = manifest.get("parts", [])
except Exception as e:
print(f"[turntable_render] WARNING: failed to read manifest: {e}")
part_entries = []
if part_entries:
for entry in part_entries:
part_file = os.path.join(parts_dir, entry["file"])
part_name = entry["name"]
if not os.path.isfile(part_file):
print(f"[turntable_render] WARNING: part STL missing: {part_file}")
continue
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.wm.stl_import(filepath=part_file)
imported = bpy.context.selected_objects
if imported:
obj = imported[0]
obj.name = part_name
if obj.data:
obj.data.name = part_name
parts.append(obj)
if parts:
print(f"[turntable_render] imported {len(parts)} named parts from per-part STLs")
# ── Fallback: combined STL + separate by loose ───────────────────────
if not parts:
bpy.ops.wm.stl_import(filepath=stl_file)
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
if obj is None:
print(f"ERROR: No objects imported from {stl_file}")
sys.exit(1)
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
obj.location = (0.0, 0.0, 0.0)
print(f"[turntable_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.separate(type='LOOSE')
bpy.ops.object.mode_set(mode='OBJECT')
parts = list(bpy.context.selected_objects)
print(f"[turntable_render] fallback: separated into {len(parts)} part(s)")
return parts
# ── Centre per-part imports at origin (combined bbox) ────────────────
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
@@ -347,7 +281,7 @@ def main():
# Everything after "--" is our args
args = argv[argv.index("--") + 1:]
stl_path = args[0]
glb_path = args[0]
frames_dir = args[1]
frame_count = int(args[2])
degrees = int(args[3])
@@ -427,10 +361,8 @@ def main():
# Find or create target collection
target_col = _ensure_collection(target_collection)
# Import and split STL
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
# Import OCC GLB (already in metres, one object per STEP part)
parts = _import_glb(glb_path)
# Apply render position rotation before material/camera setup
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides
@@ -508,9 +440,7 @@ def main():
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
parts = _import_stl(stl_path)
# Scale mm→m: STEP coords are mm, Blender default unit is metres
_scale_mm_to_m(parts)
parts = _import_glb(glb_path)
# Apply render position rotation before material/camera setup
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides