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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user