import struct import numpy as np from PIL import Image, ImageDraw import os import zipfile import xml.etree.ElementTree as ET def parse_stl_file(file_path: str): """Parse an STL file (binary or ASCII) and return mesh data + metadata.""" with open(file_path, 'rb') as f: header = f.read(80) is_binary = False if not header.startswith(b'solid'): is_binary = True else: with open(file_path, 'rb') as f: f.read(80) tri_count_bytes = f.read(4) if len(tri_count_bytes) == 4: tri_count = struct.unpack('= 4: v = [float(parts[1]), float(parts[2]), float(parts[3])] vertices.append(v) vertices = np.array(vertices, dtype=np.float32) tri_count = len(vertices) // 3 return _compute_metadata(vertices, tri_count) def _compute_metadata(vertices: np.ndarray, tri_count: int): if len(vertices) == 0: return { 'vertices': vertices, 'faces': 0, 'width': 0.0, 'height': 0.0, 'depth': 0.0, } min_v = vertices.min(axis=0) max_v = vertices.max(axis=0) dims = max_v - min_v return { 'vertices': vertices, 'faces': tri_count, 'width': float(dims[0]), 'height': float(dims[1]), 'depth': float(dims[2]), } def parse_3mf_file(file_path: str): """Parse a 3MF file (zip with XML) and return mesh data + metadata.""" vertices = [] tri_count = 0 try: with zipfile.ZipFile(file_path, 'r') as zf: model_path = None for name in zf.namelist(): if name.endswith('3dmodel.model') or name.endswith('.model'): model_path = name break if not model_path: raise ValueError("No 3D model found in 3MF archive") with zf.open(model_path) as mf: tree = ET.parse(mf) root = tree.getroot() # Find namespace ns = {'m': root.tag.split('}')[0].strip('{') if '}' in root.tag else ''} if ns['m']: ns = {'m': ns['m']} mesh = root.find('.//m:mesh', ns) else: mesh = root.find('.//mesh') if mesh is None: raise ValueError("No mesh found in 3MF model") verts_elem = mesh.find('m:vertices', ns) if ns else mesh.find('vertices') if verts_elem is not None: tag = 'm:vertex' if ns else 'vertex' for v in verts_elem.findall(tag, ns) if ns else verts_elem.findall(tag): x = float(v.get('x', 0)) y = float(v.get('y', 0)) z = float(v.get('z', 0)) vertices.append([x, y, z]) tris_elem = mesh.find('m:triangles', ns) if ns else mesh.find('triangles') if tris_elem is not None: tag = 'm:triangle' if ns else 'triangle' tri_verts = [] for t in tris_elem.findall(tag, ns) if ns else tris_elem.findall(tag): v1 = int(t.get('v1', 0)) v2 = int(t.get('v2', 0)) v3 = int(t.get('v3', 0)) tri_verts.extend([vertices[v1], vertices[v2], vertices[v3]]) vertices = tri_verts tri_count = len(tris_verts) // 3 else: vertices = [] except Exception as e: raise ValueError(f"Failed to parse 3MF: {str(e)}") vertices = np.array(vertices, dtype=np.float32) return _compute_metadata(vertices, tri_count) def parse_model_file(file_path: str): """Auto-detect format and parse.""" lower = file_path.lower() if lower.endswith('.stl'): return parse_stl_file(file_path) elif lower.endswith('.3mf'): return parse_3mf_file(file_path) else: raise ValueError("Unsupported file format. Only STL and 3MF are supported.") def generate_thumbnail(vertices: np.ndarray, output_path: str, size: int = 256): """Generate a simple orthographic thumbnail from vertices.""" if len(vertices) == 0: img = Image.new('RGB', (size, size), color=(30, 30, 30)) img.save(output_path) return min_v = vertices.min(axis=0) max_v = vertices.max(axis=0) dims = max_v - min_v scale = max(dims[0], dims[1]) if scale == 0: scale = 1.0 margin = 20 img_size = size - 2 * margin img = Image.new('RGB', (size, size), color=(30, 30, 30)) draw = ImageDraw.Draw(img) for i in range(0, len(vertices), 3): tri = vertices[i:i+3] pts = [] for v in tri: x = margin + int(((v[0] - min_v[0]) / scale) * img_size) y = margin + int(((1.0 - (v[1] - min_v[1]) / scale)) * img_size) pts.append((x, y)) if len(pts) == 3: z_avg = sum(v[2] for v in tri) / 3.0 z_norm = (z_avg - min_v[2]) / (dims[2] if dims[2] > 0 else 1) brightness = int(80 + z_norm * 120) color = (brightness, brightness, int(brightness * 1.1)) draw.polygon(pts, fill=color, outline=(50, 50, 60)) fmt = 'WEBP' if output_path.lower().endswith('.webp') else 'PNG' img.save(output_path, fmt) def generate_generic_thumbnail(output_path: str, size: int = 256, label: str = "3D"): """Generate a generic thumbnail for unsupported previews.""" img = Image.new('RGB', (size, size), color=(30, 30, 30)) draw = ImageDraw.Draw(img) draw.rectangle([40, 40, size-40, size-40], outline=(6, 182, 212), width=3) try: from PIL import ImageFont font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36) except: font = ImageFont.load_default() bbox = draw.textbbox((0,0), label, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] draw.text(((size - text_w) // 2, (size - text_h) // 2), label, fill=(6, 182, 212), font=font) img.save(output_path, 'PNG')