1287 lines
44 KiB
Python
1287 lines
44 KiB
Python
from typing import Any, Literal, Optional, Sequence, cast
|
|
import os
|
|
import numpy as np
|
|
import math
|
|
import tempfile
|
|
|
|
try:
|
|
import cadquery as cq
|
|
from OCP.StlAPI import StlAPI_Reader
|
|
from OCP.TopoDS import TopoDS_Shape
|
|
from simplecadapi.core import Solid as ScadSolid
|
|
except Exception:
|
|
cq = None
|
|
StlAPI_Reader = None
|
|
TopoDS_Shape = None
|
|
ScadSolid = None
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from SimpleLLMFunc import tool, llm_function
|
|
from SimpleLLMFunc.type import ImgPath
|
|
from config.config import get_config
|
|
|
|
from context.conversation_manager import get_current_sketch_pad
|
|
from .common import print_tool_output
|
|
from .reference_image import resolve_reference_image_path
|
|
|
|
|
|
config = get_config()
|
|
|
|
SINGLE_VIEW_IMAGE_SIZE = (320, 320)
|
|
VIEW_LABEL_FONT_SIZE = 20
|
|
AXIS_TRIAD_MARGIN = 30
|
|
AXIS_TRIAD_LENGTH = 36
|
|
SILHOUETTE_LINE_WIDTH = 1.5
|
|
DIRECT_RENDER_SUPERSAMPLE = 3
|
|
|
|
|
|
def _require_simplecad_renderer() -> None:
|
|
if cq is None or StlAPI_Reader is None or TopoDS_Shape is None or ScadSolid is None:
|
|
raise RuntimeError(
|
|
"SimpleCADAPI screenshot dependencies are not available. Install cadquery/simplecadapi before using get_visual_feedback."
|
|
)
|
|
|
|
|
|
def _load_renderable_shapes(model_path: str) -> list[Any]:
|
|
_require_simplecad_renderer()
|
|
|
|
cq_module = cast(Any, cq)
|
|
stl_reader_cls = cast(Any, StlAPI_Reader)
|
|
topods_shape_cls = cast(Any, TopoDS_Shape)
|
|
solid_cls = cast(Any, ScadSolid)
|
|
|
|
ext = os.path.splitext(model_path)[1].lower()
|
|
import_type_map: dict[str, Literal["STEP", "BREP", "BIN"]] = {
|
|
".step": "STEP",
|
|
".stp": "STEP",
|
|
".brep": "BREP",
|
|
".brp": "BREP",
|
|
".bin": "BIN",
|
|
}
|
|
|
|
if ext == ".stl":
|
|
shape = topods_shape_cls()
|
|
reader = stl_reader_cls()
|
|
if not reader.Read(shape, model_path):
|
|
raise ValueError(f"Failed to read STL file: {model_path}")
|
|
return [solid_cls(cq_module.Shape.cast(shape))]
|
|
|
|
import_type = import_type_map.get(ext)
|
|
if import_type is None:
|
|
supported = ", ".join([".stl", ".step", ".stp", ".brep", ".brp", ".bin"])
|
|
raise ValueError(
|
|
f"Unsupported model format for SimpleCADAPI screenshots: {ext or '<none>'}. Supported formats: {supported}"
|
|
)
|
|
|
|
workplane = cq_module.importers.importShape(import_type, model_path)
|
|
objects = _expand_imported_cadquery_solids(workplane.vals())
|
|
if not objects:
|
|
raise ValueError(f"No renderable solids were loaded from: {model_path}")
|
|
return [solid_cls(obj) for obj in objects]
|
|
|
|
|
|
def _expand_imported_cadquery_solids(objects: Sequence[Any]) -> list[Any]:
|
|
expanded: list[Any] = []
|
|
|
|
for obj in objects:
|
|
shape_type_getter = getattr(obj, "ShapeType", None)
|
|
shape_type = shape_type_getter() if callable(shape_type_getter) else None
|
|
|
|
if shape_type in {"Compound", "CompSolid"}:
|
|
solids_getter = getattr(obj, "Solids", None)
|
|
nested_solids = list(solids_getter()) if callable(solids_getter) else []
|
|
if nested_solids:
|
|
expanded.extend(_expand_imported_cadquery_solids(nested_solids))
|
|
continue
|
|
|
|
expanded.append(obj)
|
|
|
|
return expanded
|
|
|
|
|
|
def _direction_to_view(direction: tuple[float, float, float]) -> tuple[float, float]:
|
|
dir_array = np.asarray(direction, dtype=float)
|
|
norm = np.linalg.norm(dir_array)
|
|
if norm == 0:
|
|
raise ValueError("Camera direction cannot be the zero vector")
|
|
|
|
x, y, z = dir_array / norm
|
|
elev = math.degrees(math.asin(float(np.clip(z, -1.0, 1.0))))
|
|
azim = math.degrees(math.atan2(float(y), float(x)))
|
|
return elev, azim
|
|
|
|
|
|
def _view_direction_from_angles(elev: float, azim: float) -> np.ndarray:
|
|
elev_rad = math.radians(elev)
|
|
azim_rad = math.radians(azim)
|
|
return np.array(
|
|
[
|
|
math.cos(elev_rad) * math.cos(azim_rad),
|
|
math.cos(elev_rad) * math.sin(azim_rad),
|
|
math.sin(elev_rad),
|
|
],
|
|
dtype=float,
|
|
)
|
|
|
|
|
|
def _camera_basis(view_dir: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
forward = np.asarray(view_dir, dtype=float)
|
|
norm = np.linalg.norm(forward)
|
|
if norm == 0:
|
|
raise ValueError("Camera direction cannot be the zero vector")
|
|
forward = forward / norm
|
|
|
|
up_guess = np.array([0.0, 0.0, 1.0], dtype=float)
|
|
if abs(float(np.dot(forward, up_guess))) > 0.95:
|
|
up_guess = np.array([0.0, 1.0, 0.0], dtype=float)
|
|
|
|
right = np.cross(up_guess, forward)
|
|
right_norm = np.linalg.norm(right)
|
|
if right_norm == 0:
|
|
up_guess = np.array([1.0, 0.0, 0.0], dtype=float)
|
|
right = np.cross(up_guess, forward)
|
|
right_norm = np.linalg.norm(right)
|
|
if right_norm == 0:
|
|
raise ValueError("Failed to derive a camera basis")
|
|
right = right / right_norm
|
|
up = np.cross(forward, right)
|
|
up = up / np.linalg.norm(up)
|
|
return forward, right, up
|
|
|
|
|
|
def _camera_relative_light_rig(
|
|
view_dir: np.ndarray,
|
|
) -> tuple[list[np.ndarray], list[float], float]:
|
|
forward, right, up = _camera_basis(view_dir)
|
|
raw_dirs = [
|
|
1.45 * forward + 0.26 * up + 0.18 * right,
|
|
1.10 * forward - 0.24 * right + 0.10 * up,
|
|
0.82 * forward - 0.22 * up - 0.28 * right,
|
|
-0.08 * forward + 0.75 * up + 0.06 * right,
|
|
]
|
|
light_dirs = [vec / np.linalg.norm(vec) for vec in raw_dirs]
|
|
light_weights = [0.95, 0.42, 0.24, 0.16]
|
|
ambient = 0.24
|
|
return light_dirs, light_weights, ambient
|
|
|
|
|
|
def _shade_normals_camera_relative(
|
|
normals: np.ndarray,
|
|
color: np.ndarray,
|
|
view_dir: np.ndarray,
|
|
) -> np.ndarray:
|
|
light_dirs, light_weights, ambient = _camera_relative_light_rig(view_dir)
|
|
intensity = np.full((normals.shape[0],), ambient, dtype=float)
|
|
for weight, light_dir in zip(light_weights, light_dirs):
|
|
intensity += weight * np.maximum(0.0, normals @ light_dir)
|
|
|
|
camera_fill = np.maximum(0.0, normals @ (view_dir / np.linalg.norm(view_dir)))
|
|
intensity += 0.22 * np.sqrt(camera_fill)
|
|
intensity = np.clip(intensity, 0.18, 1.08)
|
|
tone = np.clip(np.power(intensity / 1.08, 0.95), 0.0, 1.0)
|
|
|
|
shaded = color[None, :] * (0.58 + 0.42 * tone[:, None])
|
|
shaded += (1.0 - shaded) * (0.08 * tone[:, None])
|
|
shaded = np.clip(shaded, 0.0, 1.0)
|
|
alpha = np.ones((shaded.shape[0], 1))
|
|
return np.hstack([shaded, alpha])
|
|
|
|
|
|
def _compute_surface_shading_normals(face: Any, tri_pts: np.ndarray) -> np.ndarray:
|
|
try:
|
|
face_kind = str(face.cq_face.geomType()).upper()
|
|
except Exception:
|
|
face_kind = ""
|
|
|
|
shading_normals: list[np.ndarray] = []
|
|
for triangle in tri_pts:
|
|
if face_kind == "PLANE":
|
|
ref_normal = face.cq_face.normalAt()
|
|
else:
|
|
centroid = np.mean(triangle, axis=0)
|
|
ref_normal = face.cq_face.normalAt(
|
|
tuple(float(value) for value in centroid)
|
|
)
|
|
|
|
ref_vector = np.array([ref_normal.x, ref_normal.y, ref_normal.z], dtype=float)
|
|
ref_norm = np.linalg.norm(ref_vector)
|
|
if ref_norm == 0:
|
|
raise ValueError("Encountered a zero surface normal while shading")
|
|
shading_normals.append(ref_vector / ref_norm)
|
|
|
|
return np.vstack(shading_normals)
|
|
|
|
|
|
def _resolve_render_view(
|
|
view_spec: tuple[float, float] | str,
|
|
bbox_min: np.ndarray,
|
|
bbox_max: np.ndarray,
|
|
) -> tuple[float, float]:
|
|
if isinstance(view_spec, str):
|
|
token = view_spec.strip().lower()
|
|
spans = bbox_max - bbox_min
|
|
if token == "auto":
|
|
azim = 35.0 if spans[0] >= spans[1] else 125.0
|
|
elev = 22.0 if spans[2] <= max(spans[0], spans[1]) else 35.0
|
|
return elev, azim
|
|
if token in {"iso", "isometric"}:
|
|
return 25.0, 35.0
|
|
if token == "top":
|
|
return 90.0, 0.0
|
|
if token == "bottom":
|
|
return -90.0, 0.0
|
|
if token == "front":
|
|
return 0.0, -90.0
|
|
if token == "back":
|
|
return 0.0, 90.0
|
|
if token == "left":
|
|
return 0.0, 180.0
|
|
if token == "right":
|
|
return 0.0, 0.0
|
|
if token == "front_right":
|
|
return 20.0, -45.0
|
|
if token == "front_left":
|
|
return 20.0, 135.0
|
|
if token == "rear_right":
|
|
return 20.0, 45.0
|
|
if token == "rear_left":
|
|
return 20.0, -135.0
|
|
raise ValueError(f"Unsupported view preset: {view_spec}")
|
|
|
|
if isinstance(view_spec, (list, tuple)) and len(view_spec) == 2:
|
|
return float(view_spec[0]), float(view_spec[1])
|
|
|
|
raise ValueError("view must be a (elev, azim) pair or preset name")
|
|
|
|
|
|
def _normalize_render_shape_input(shapes: Any) -> list[Any]:
|
|
solid_cls = cast(Any, ScadSolid)
|
|
if isinstance(shapes, solid_cls):
|
|
return [shapes]
|
|
|
|
if isinstance(shapes, Sequence) and not isinstance(shapes, (str, bytes)):
|
|
solids = [shape for shape in shapes if isinstance(shape, solid_cls)]
|
|
if len(solids) != len(shapes):
|
|
raise ValueError("Only SimpleCAD Solid objects are supported for rendering")
|
|
if solids:
|
|
return solids
|
|
|
|
raise ValueError(
|
|
"render_multi_view_model requires one or more SimpleCAD Solid objects"
|
|
)
|
|
|
|
|
|
def _should_use_direct_cad_renderer(model_path: str) -> bool:
|
|
return os.path.splitext(model_path)[1].lower() in {
|
|
".step",
|
|
".stp",
|
|
".brep",
|
|
".brp",
|
|
".bin",
|
|
}
|
|
|
|
|
|
def _prefer_cad_native_model_path(model_path: str) -> str:
|
|
stem, ext = os.path.splitext(model_path)
|
|
if ext.lower() != ".stl":
|
|
return model_path
|
|
|
|
for candidate_ext in (".step", ".stp"):
|
|
candidate = stem + candidate_ext
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
return model_path
|
|
|
|
|
|
def _project_points_camera_frame(
|
|
points: np.ndarray,
|
|
right: np.ndarray,
|
|
up: np.ndarray,
|
|
forward: np.ndarray,
|
|
) -> np.ndarray:
|
|
pts = np.asarray(points, dtype=float)
|
|
return np.column_stack(
|
|
[
|
|
pts @ right,
|
|
pts @ up,
|
|
pts @ forward,
|
|
]
|
|
)
|
|
|
|
|
|
def _sample_projected_edge_polyline(
|
|
edge: Any,
|
|
*,
|
|
deflection: float,
|
|
) -> Optional[np.ndarray]:
|
|
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
|
from OCP.GCPnts import GCPnts_QuasiUniformDeflection
|
|
|
|
curve = BRepAdaptor_Curve(edge)
|
|
sampler = GCPnts_QuasiUniformDeflection(curve, deflection)
|
|
points: list[np.ndarray] = []
|
|
|
|
if sampler.IsDone() and sampler.NbPoints() >= 2:
|
|
for index in range(1, sampler.NbPoints() + 1):
|
|
point = sampler.Value(index)
|
|
points.append(np.array([point.X(), point.Y(), point.Z()], dtype=float))
|
|
else:
|
|
for parameter in (curve.FirstParameter(), curve.LastParameter()):
|
|
point = curve.Value(parameter)
|
|
points.append(np.array([point.X(), point.Y(), point.Z()], dtype=float))
|
|
|
|
if len(points) < 2:
|
|
return None
|
|
return np.vstack(points)
|
|
|
|
|
|
def _extract_hlr_projected_edges(
|
|
shapes: Sequence[Any],
|
|
*,
|
|
right: np.ndarray,
|
|
up: np.ndarray,
|
|
forward: np.ndarray,
|
|
deflection: float,
|
|
) -> list[tuple[str, np.ndarray]]:
|
|
from OCP.HLRAlgo import HLRAlgo_Projector
|
|
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
|
|
from OCP.TopAbs import TopAbs_EDGE
|
|
from OCP.TopExp import TopExp_Explorer
|
|
from OCP.TopoDS import TopoDS
|
|
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
|
|
|
|
projector = HLRAlgo_Projector(
|
|
gp_Ax2(
|
|
gp_Pnt(0.0, 0.0, 0.0),
|
|
gp_Dir(float(forward[0]), float(forward[1]), float(forward[2])),
|
|
gp_Dir(float(right[0]), float(right[1]), float(right[2])),
|
|
)
|
|
)
|
|
algo = HLRBRep_Algo()
|
|
for shape in shapes:
|
|
algo.Add(shape)
|
|
algo.Projector(projector)
|
|
algo.Update()
|
|
algo.Hide()
|
|
extractor = HLRBRep_HLRToShape(algo)
|
|
|
|
style_methods = [
|
|
("outline", "OutLineVCompound3d"),
|
|
("outline", "OutLineVCompound"),
|
|
("visible", "VCompound"),
|
|
]
|
|
|
|
edge_sets: list[tuple[str, np.ndarray]] = []
|
|
seen: set[tuple[tuple[float, float], ...]] = set()
|
|
for style, method_name in style_methods:
|
|
compound = getattr(extractor, method_name)()
|
|
if compound.IsNull():
|
|
continue
|
|
explorer = TopExp_Explorer(compound, TopAbs_EDGE)
|
|
while explorer.More():
|
|
edge = TopoDS.Edge_s(explorer.Current())
|
|
polyline = _sample_projected_edge_polyline(
|
|
edge,
|
|
deflection=deflection,
|
|
)
|
|
explorer.Next()
|
|
if polyline is None:
|
|
continue
|
|
|
|
rounded = tuple(
|
|
(round(float(point[0]), 4), round(float(point[1]), 4))
|
|
for point in polyline
|
|
)
|
|
reverse_rounded = tuple(reversed(rounded))
|
|
key = rounded if rounded <= reverse_rounded else reverse_rounded
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
edge_sets.append((style, polyline))
|
|
|
|
return edge_sets
|
|
|
|
|
|
def _compute_feature_edge_mask(
|
|
mask: np.ndarray,
|
|
depth: np.ndarray,
|
|
normals: np.ndarray,
|
|
*,
|
|
depth_jump_threshold: float,
|
|
normal_cos_threshold: float,
|
|
) -> np.ndarray:
|
|
height, width = mask.shape
|
|
|
|
padded = np.pad(mask, 1, mode="constant", constant_values=False)
|
|
neighbors = [
|
|
padded[1 + dy : 1 + dy + height, 1 + dx : 1 + dx + width]
|
|
for dy in (-1, 0, 1)
|
|
for dx in (-1, 0, 1)
|
|
]
|
|
eroded = np.logical_and.reduce(neighbors)
|
|
silhouette = mask & ~eroded
|
|
|
|
internal = np.zeros_like(mask)
|
|
shifts = ((0, 1), (1, 0), (1, 1), (1, -1))
|
|
for dy, dx in shifts:
|
|
src_y = slice(max(0, -dy), min(height, height - dy))
|
|
src_x = slice(max(0, -dx), min(width, width - dx))
|
|
dst_y = slice(max(0, dy), min(height, height + dy))
|
|
dst_x = slice(max(0, dx), min(width, width + dx))
|
|
|
|
left_mask = mask[src_y, src_x]
|
|
right_mask = mask[dst_y, dst_x]
|
|
valid = left_mask & right_mask
|
|
if not np.any(valid):
|
|
continue
|
|
|
|
left_normals = normals[src_y, src_x]
|
|
right_normals = normals[dst_y, dst_x]
|
|
normal_dot = np.einsum("...i,...i->...", left_normals, right_normals)
|
|
normal_jump = normal_dot < normal_cos_threshold
|
|
|
|
left_depth = depth[src_y, src_x]
|
|
right_depth = depth[dst_y, dst_x]
|
|
depth_delta = np.zeros_like(left_depth)
|
|
depth_delta[valid] = np.abs(left_depth[valid] - right_depth[valid])
|
|
depth_jump = depth_delta > depth_jump_threshold
|
|
|
|
discontinuity = valid & (normal_jump | depth_jump)
|
|
if not np.any(discontinuity):
|
|
continue
|
|
internal[src_y, src_x] |= discontinuity
|
|
internal[dst_y, dst_x] |= discontinuity
|
|
|
|
return silhouette | internal
|
|
|
|
|
|
def _rasterize_projected_triangles(
|
|
projected_triangles: Sequence[np.ndarray],
|
|
triangle_normals: Sequence[np.ndarray],
|
|
*,
|
|
image_size: tuple[int, int],
|
|
zoom: float,
|
|
background_rgb: np.ndarray,
|
|
fill_rgb: np.ndarray,
|
|
outline_rgb: np.ndarray,
|
|
) -> Image.Image:
|
|
if not projected_triangles:
|
|
raise ValueError("No projected triangles were available for rasterization")
|
|
if len(projected_triangles) != len(triangle_normals):
|
|
raise ValueError("Projected triangle count must match triangle normal count")
|
|
if zoom <= 0:
|
|
raise ValueError("zoom must be greater than zero")
|
|
|
|
supersample = max(1, int(DIRECT_RENDER_SUPERSAMPLE))
|
|
width, height = image_size
|
|
raster_width = width * supersample
|
|
raster_height = height * supersample
|
|
points_xy = np.vstack([triangle[:, :2] for triangle in projected_triangles])
|
|
x_min, y_min = points_xy.min(axis=0)
|
|
x_max, y_max = points_xy.max(axis=0)
|
|
x_span = max(float(x_max - x_min), 1e-6)
|
|
y_span = max(float(y_max - y_min), 1e-6)
|
|
pad = max(x_span, y_span) * (0.08 / zoom)
|
|
x_min -= pad
|
|
x_max += pad
|
|
y_min -= pad
|
|
y_max += pad
|
|
|
|
world_w = max(float(x_max - x_min), 1e-6)
|
|
world_h = max(float(y_max - y_min), 1e-6)
|
|
scale = min((raster_width - 1) / world_w, (raster_height - 1) / world_h)
|
|
x_offset = (raster_width - scale * world_w) * 0.5 - scale * x_min
|
|
y_offset = (raster_height - scale * world_h) * 0.5 + scale * y_max
|
|
|
|
image = np.empty((raster_height, raster_width, 3), dtype=np.uint8)
|
|
image[:, :] = background_rgb.astype(np.uint8)
|
|
depth = np.full((raster_height, raster_width), -np.inf, dtype=float)
|
|
mask = np.zeros((raster_height, raster_width), dtype=bool)
|
|
normal_buffer = np.zeros((raster_height, raster_width, 3), dtype=float)
|
|
|
|
def transform_xy(points: np.ndarray) -> np.ndarray:
|
|
px = x_offset + scale * points[:, 0]
|
|
py = y_offset - scale * points[:, 1]
|
|
return np.column_stack([px, py])
|
|
|
|
def edge_fn(
|
|
a: np.ndarray, b: np.ndarray, px: np.ndarray, py: np.ndarray
|
|
) -> np.ndarray:
|
|
return (px - a[0]) * (b[1] - a[1]) - (py - a[1]) * (b[0] - a[0])
|
|
|
|
for triangle, triangle_normal in zip(projected_triangles, triangle_normals):
|
|
pts2 = transform_xy(triangle[:, :2])
|
|
z_vals = triangle[:, 2]
|
|
area = edge_fn(pts2[0], pts2[1], pts2[2, 0], pts2[2, 1])
|
|
if abs(float(area)) < 1e-8:
|
|
continue
|
|
|
|
min_px = max(int(math.floor(float(np.min(pts2[:, 0])))), 0)
|
|
max_px = min(int(math.ceil(float(np.max(pts2[:, 0])))), raster_width - 1)
|
|
min_py = max(int(math.floor(float(np.min(pts2[:, 1])))), 0)
|
|
max_py = min(int(math.ceil(float(np.max(pts2[:, 1])))), raster_height - 1)
|
|
if min_px > max_px or min_py > max_py:
|
|
continue
|
|
|
|
xs = np.arange(min_px, max_px + 1, dtype=float) + 0.5
|
|
ys = np.arange(min_py, max_py + 1, dtype=float) + 0.5
|
|
grid_x, grid_y = np.meshgrid(xs, ys)
|
|
|
|
w0 = edge_fn(pts2[1], pts2[2], grid_x, grid_y)
|
|
w1 = edge_fn(pts2[2], pts2[0], grid_x, grid_y)
|
|
w2 = edge_fn(pts2[0], pts2[1], grid_x, grid_y)
|
|
if area > 0:
|
|
inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
|
|
else:
|
|
inside = (w0 <= 0) & (w1 <= 0) & (w2 <= 0)
|
|
if not np.any(inside):
|
|
continue
|
|
|
|
b0 = w0 / area
|
|
b1 = w1 / area
|
|
b2 = w2 / area
|
|
tri_depth = b0 * z_vals[0] + b1 * z_vals[1] + b2 * z_vals[2]
|
|
|
|
depth_slice = depth[min_py : max_py + 1, min_px : max_px + 1]
|
|
update = inside & (tri_depth > depth_slice)
|
|
if not np.any(update):
|
|
continue
|
|
|
|
depth_slice[update] = tri_depth[update]
|
|
mask[min_py : max_py + 1, min_px : max_px + 1][update] = True
|
|
image[min_py : max_py + 1, min_px : max_px + 1][update] = fill_rgb
|
|
normal_slice = normal_buffer[min_py : max_py + 1, min_px : max_px + 1]
|
|
normal_slice[update] = triangle_normal
|
|
|
|
depth_jump_threshold = max(2.0 / scale, 1e-6)
|
|
normal_cos_threshold = math.cos(math.radians(20.0))
|
|
edge_mask = _compute_feature_edge_mask(
|
|
mask,
|
|
depth,
|
|
normal_buffer,
|
|
depth_jump_threshold=depth_jump_threshold,
|
|
normal_cos_threshold=normal_cos_threshold,
|
|
)
|
|
image[edge_mask] = outline_rgb
|
|
|
|
rendered = Image.fromarray(image)
|
|
if supersample > 1:
|
|
rendered = rendered.resize(image_size, Image.Resampling.LANCZOS)
|
|
return rendered
|
|
|
|
|
|
def _render_direct_cad_screenshot(
|
|
shapes: Any,
|
|
output_path: str,
|
|
*,
|
|
image_size: tuple[int, int] = SINGLE_VIEW_IMAGE_SIZE,
|
|
view: tuple[float, float] | str = "auto",
|
|
zoom: float = 4.0,
|
|
) -> str:
|
|
solids = _normalize_render_shape_input(shapes)
|
|
background_rgb = np.array([246, 247, 249], dtype=np.uint8)
|
|
base_fill_rgb = np.array([196, 204, 214], dtype=np.uint8)
|
|
outline_rgb = np.array([10, 18, 26], dtype=np.uint8)
|
|
mesh_tolerance = 0.35
|
|
mesh_angular_tolerance = 0.22
|
|
|
|
bbox_min = np.array([np.inf, np.inf, np.inf], dtype=float)
|
|
bbox_max = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
|
|
for solid in solids:
|
|
bb = solid.cq_solid.BoundingBox()
|
|
bbox_min = np.minimum(bbox_min, np.array([bb.xmin, bb.ymin, bb.zmin]))
|
|
bbox_max = np.maximum(bbox_max, np.array([bb.xmax, bb.ymax, bb.zmax]))
|
|
|
|
elev, azim = _resolve_render_view(view, bbox_min, bbox_max)
|
|
view_dir = _view_direction_from_angles(elev, azim)
|
|
forward, right, up = _camera_basis(view_dir)
|
|
|
|
projected_triangles: list[np.ndarray] = []
|
|
triangle_normals: list[np.ndarray] = []
|
|
|
|
for solid in solids:
|
|
for face in solid.get_faces():
|
|
verts, tri_indices = face.cq_face.tessellate(
|
|
mesh_tolerance, mesh_angular_tolerance
|
|
)
|
|
if not tri_indices:
|
|
continue
|
|
|
|
vertices = np.array([[v.x, v.y, v.z] for v in verts], dtype=float)
|
|
tri_pts = vertices[np.array(tri_indices, dtype=int)]
|
|
geometric_normals = np.cross(
|
|
tri_pts[:, 1] - tri_pts[:, 0], tri_pts[:, 2] - tri_pts[:, 0]
|
|
)
|
|
geometric_norms = np.linalg.norm(geometric_normals, axis=1)
|
|
geometric_normals = np.divide(
|
|
geometric_normals,
|
|
geometric_norms[:, None],
|
|
out=np.zeros_like(geometric_normals),
|
|
where=geometric_norms[:, None] != 0,
|
|
)
|
|
try:
|
|
surface_normals = _compute_surface_shading_normals(face, tri_pts)
|
|
except Exception:
|
|
surface_normals = geometric_normals
|
|
camera_normals = np.column_stack(
|
|
[
|
|
surface_normals @ right,
|
|
surface_normals @ up,
|
|
surface_normals @ forward,
|
|
]
|
|
)
|
|
camera_norms = np.linalg.norm(camera_normals, axis=1)
|
|
camera_normals = np.divide(
|
|
camera_normals,
|
|
camera_norms[:, None],
|
|
out=np.zeros_like(camera_normals),
|
|
where=camera_norms[:, None] != 0,
|
|
)
|
|
projected = _project_points_camera_frame(
|
|
tri_pts.reshape(-1, 3), right, up, forward
|
|
)
|
|
projected_triangles.extend(list(projected.reshape((-1, 3, 3))))
|
|
triangle_normals.extend(list(camera_normals))
|
|
|
|
image = _rasterize_projected_triangles(
|
|
projected_triangles,
|
|
triangle_normals,
|
|
image_size=image_size,
|
|
zoom=zoom,
|
|
background_rgb=background_rgb,
|
|
fill_rgb=base_fill_rgb,
|
|
outline_rgb=outline_rgb,
|
|
)
|
|
image.save(output_path)
|
|
return output_path
|
|
|
|
|
|
def _render_camera_lit_screenshot(
|
|
shapes: Any,
|
|
output_path: str,
|
|
*,
|
|
image_size: tuple[int, int] = SINGLE_VIEW_IMAGE_SIZE,
|
|
view: tuple[float, float] | str = "auto",
|
|
zoom: float = 4.0,
|
|
) -> str:
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
from mpl_toolkits.mplot3d.art3d import Line3DCollection, Poly3DCollection
|
|
|
|
solids = _normalize_render_shape_input(shapes)
|
|
background = "#f3f5f7"
|
|
base_color = np.array([0.72, 0.76, 0.81], dtype=float)
|
|
edge_quant = 1e-6
|
|
mesh_tolerance = 0.35
|
|
mesh_angular_tolerance = 0.22
|
|
|
|
bbox_min = np.array([np.inf, np.inf, np.inf], dtype=float)
|
|
bbox_max = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
|
|
for solid in solids:
|
|
bb = solid.cq_solid.BoundingBox()
|
|
bbox_min = np.minimum(bbox_min, np.array([bb.xmin, bb.ymin, bb.zmin]))
|
|
bbox_max = np.maximum(bbox_max, np.array([bb.xmax, bb.ymax, bb.zmax]))
|
|
|
|
elev, azim = _resolve_render_view(view, bbox_min, bbox_max)
|
|
view_dir = _view_direction_from_angles(elev, azim)
|
|
|
|
all_polys: list[list[tuple[float, float, float]]] = []
|
|
all_colors: list[tuple[float, float, float, float]] = []
|
|
triangles: list[np.ndarray] = []
|
|
tri_normals: list[np.ndarray] = []
|
|
|
|
for solid in solids:
|
|
for face in solid.get_faces():
|
|
verts, tri_indices = face.cq_face.tessellate(
|
|
mesh_tolerance, mesh_angular_tolerance
|
|
)
|
|
if not tri_indices:
|
|
continue
|
|
|
|
vertices = np.array([[v.x, v.y, v.z] for v in verts], dtype=float)
|
|
tris = np.array(tri_indices, dtype=int)
|
|
tri_pts = vertices[tris]
|
|
geometric_normals = np.cross(
|
|
tri_pts[:, 1] - tri_pts[:, 0], tri_pts[:, 2] - tri_pts[:, 0]
|
|
)
|
|
norms = np.linalg.norm(geometric_normals, axis=1)
|
|
geometric_normals = np.divide(
|
|
geometric_normals,
|
|
norms[:, None],
|
|
out=np.zeros_like(geometric_normals),
|
|
where=norms[:, None] != 0,
|
|
)
|
|
try:
|
|
shading_normals = _compute_surface_shading_normals(face, tri_pts)
|
|
except Exception:
|
|
shading_normals = geometric_normals
|
|
|
|
colors = _shade_normals_camera_relative(
|
|
shading_normals, base_color, view_dir
|
|
)
|
|
all_polys.extend(tri_pts.tolist())
|
|
all_colors.extend(colors.tolist())
|
|
triangles.extend(list(tri_pts))
|
|
tri_normals.extend(list(shading_normals))
|
|
|
|
if not all_polys:
|
|
raise ValueError("No renderable triangles were generated")
|
|
|
|
fig = plt.figure(figsize=(image_size[0] / 100, image_size[1] / 100), dpi=100)
|
|
fig.patch.set_facecolor(background)
|
|
ax = fig.add_subplot(111, projection="3d")
|
|
ax.set_facecolor(background)
|
|
ax.set_axis_off()
|
|
fig.subplots_adjust(left=0.0, right=1.0, bottom=0.0, top=1.0)
|
|
ax.set_position((0.0, 0.0, 1.0, 1.0))
|
|
|
|
collection = Poly3DCollection(all_polys, facecolors=all_colors, linewidths=0.0)
|
|
collection.set_edgecolor((0.0, 0.0, 0.0, 0.0))
|
|
collection.set_zsort("average")
|
|
ax.add_collection3d(collection)
|
|
|
|
span = float(np.max(bbox_max - bbox_min))
|
|
if span <= 0:
|
|
span = 1.0
|
|
if zoom <= 0:
|
|
raise ValueError("zoom must be greater than zero")
|
|
size = bbox_max - bbox_min
|
|
pad_ratio = 0.10
|
|
pad_min = span * 0.015
|
|
pad_vec = np.maximum(size * (pad_ratio / zoom), pad_min)
|
|
min_extent = bbox_min - pad_vec
|
|
max_extent = bbox_max + pad_vec
|
|
ax.set_xlim(min_extent[0], max_extent[0])
|
|
ax.set_ylim(min_extent[1], max_extent[1])
|
|
ax.set_zlim(min_extent[2], max_extent[2])
|
|
try:
|
|
ax.set_box_aspect(max_extent - min_extent)
|
|
except Exception:
|
|
pass
|
|
ax.view_init(elev=elev, azim=azim)
|
|
|
|
def _quantize_point(point: np.ndarray) -> tuple[float, float, float]:
|
|
snapped = np.round(point / edge_quant) * edge_quant
|
|
return float(snapped[0]), float(snapped[1]), float(snapped[2])
|
|
|
|
edge_to_tris: dict[
|
|
tuple[tuple[float, float, float], tuple[float, float, float]],
|
|
list[int],
|
|
] = {}
|
|
edge_to_seg: dict[
|
|
tuple[tuple[float, float, float], tuple[float, float, float]],
|
|
tuple[np.ndarray, np.ndarray],
|
|
] = {}
|
|
|
|
for tri_idx, tri in enumerate(triangles):
|
|
for i0, i1 in ((0, 1), (1, 2), (2, 0)):
|
|
p0 = tri[i0]
|
|
p1 = tri[i1]
|
|
q0 = _quantize_point(p0)
|
|
q1 = _quantize_point(p1)
|
|
key = (q0, q1) if q0 <= q1 else (q1, q0)
|
|
edge_to_tris.setdefault(key, []).append(tri_idx)
|
|
edge_to_seg.setdefault(key, (p0, p1))
|
|
|
|
hard_segments: list[np.ndarray] = []
|
|
silhouette_segments: list[np.ndarray] = []
|
|
angle_threshold = max(math.radians(38.0), mesh_angular_tolerance * 3.0)
|
|
|
|
for key, tri_indices in edge_to_tris.items():
|
|
seg = edge_to_seg[key]
|
|
if len(tri_indices) == 1:
|
|
silhouette_segments.append(np.array(seg, dtype=float))
|
|
continue
|
|
|
|
normals = [tri_normals[index] for index in tri_indices]
|
|
facing = [float(np.dot(normal, view_dir)) for normal in normals]
|
|
if min(facing) <= 0.0 <= max(facing):
|
|
silhouette_segments.append(np.array(seg, dtype=float))
|
|
|
|
max_angle = 0.0
|
|
for left_idx in range(len(normals)):
|
|
for right_idx in range(left_idx + 1, len(normals)):
|
|
dot = float(
|
|
np.clip(
|
|
np.dot(normals[left_idx], normals[right_idx]),
|
|
-1.0,
|
|
1.0,
|
|
)
|
|
)
|
|
max_angle = max(max_angle, math.acos(dot))
|
|
if max_angle >= angle_threshold:
|
|
hard_segments.append(np.array(seg, dtype=float))
|
|
|
|
if hard_segments:
|
|
hard_collection = Line3DCollection(
|
|
hard_segments,
|
|
colors=[(0.10, 0.13, 0.18, 0.72)],
|
|
linewidths=0.95,
|
|
)
|
|
ax.add_collection3d(hard_collection)
|
|
if silhouette_segments:
|
|
silhouette_collection = Line3DCollection(
|
|
silhouette_segments,
|
|
colors=[(0.05, 0.08, 0.12, 0.92)],
|
|
linewidths=SILHOUETTE_LINE_WIDTH,
|
|
)
|
|
ax.add_collection3d(silhouette_collection)
|
|
|
|
plt.savefig(output_path, facecolor=background)
|
|
plt.close(fig)
|
|
return output_path
|
|
|
|
|
|
def _get_label_font(size: int = VIEW_LABEL_FONT_SIZE):
|
|
try:
|
|
return ImageFont.truetype("DejaVuSans.ttf", size)
|
|
except Exception:
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _add_view_label(image: Image.Image, label: str) -> Image.Image:
|
|
font = _get_label_font()
|
|
labeled = image.convert("RGBA")
|
|
draw = ImageDraw.Draw(labeled)
|
|
|
|
x = 16
|
|
y = 16
|
|
left, top, right, bottom = draw.textbbox((x, y), label, font=font)
|
|
draw.rounded_rectangle(
|
|
(left - 10, top - 8, right + 10, bottom + 8),
|
|
fill=(0, 0, 0, 170),
|
|
radius=10,
|
|
)
|
|
draw.text((x, y), label, fill=(255, 255, 255, 255), font=font)
|
|
return labeled.convert("RGB")
|
|
|
|
|
|
def _screen_offset_for_axis(
|
|
axis: np.ndarray, view_dir: np.ndarray
|
|
) -> tuple[float, float]:
|
|
_, right, up = _camera_basis(view_dir)
|
|
return float(np.dot(axis, right)), float(np.dot(axis, up))
|
|
|
|
|
|
def _draw_arrow(
|
|
draw: ImageDraw.ImageDraw,
|
|
origin: tuple[float, float],
|
|
end: tuple[float, float],
|
|
color: tuple[int, int, int, int],
|
|
) -> None:
|
|
draw.line((origin, end), fill=color, width=3)
|
|
dx = end[0] - origin[0]
|
|
dy = end[1] - origin[1]
|
|
length = math.hypot(dx, dy)
|
|
if length < 1e-6:
|
|
return
|
|
|
|
ux = dx / length
|
|
uy = dy / length
|
|
head_len = min(9.0, max(5.0, 0.28 * length))
|
|
head_width = head_len * 0.6
|
|
left = (
|
|
end[0] - head_len * ux + head_width * uy,
|
|
end[1] - head_len * uy - head_width * ux,
|
|
)
|
|
right = (
|
|
end[0] - head_len * ux - head_width * uy,
|
|
end[1] - head_len * uy + head_width * ux,
|
|
)
|
|
draw.polygon([end, left, right], fill=color)
|
|
|
|
|
|
def _add_axis_triad_overlay(image: Image.Image, view_dir: np.ndarray) -> Image.Image:
|
|
triad = image.convert("RGBA")
|
|
draw = ImageDraw.Draw(triad)
|
|
font = _get_label_font(16)
|
|
|
|
origin = (AXIS_TRIAD_MARGIN + 18.0, image.height - AXIS_TRIAD_MARGIN - 18.0)
|
|
|
|
axis_specs = (
|
|
(np.array([1.0, 0.0, 0.0]), "X", (216, 76, 71, 255)),
|
|
(np.array([0.0, 1.0, 0.0]), "Y", (43, 166, 95, 255)),
|
|
(np.array([0.0, 0.0, 1.0]), "Z", (59, 122, 235, 255)),
|
|
)
|
|
for axis, label, color in axis_specs:
|
|
offset_x, offset_y = _screen_offset_for_axis(axis, view_dir)
|
|
end = (
|
|
origin[0] + AXIS_TRIAD_LENGTH * offset_x,
|
|
origin[1] - AXIS_TRIAD_LENGTH * offset_y,
|
|
)
|
|
_draw_arrow(draw, origin, end, color)
|
|
text_pos = (end[0] + 4.0, end[1] - 10.0)
|
|
draw.text(text_pos, label, fill=color, font=font)
|
|
|
|
draw.ellipse(
|
|
(origin[0] - 4.0, origin[1] - 4.0, origin[0] + 4.0, origin[1] + 4.0),
|
|
fill=(32, 36, 43, 255),
|
|
)
|
|
return triad.convert("RGB")
|
|
|
|
|
|
@tool(
|
|
name="get_visual_feedback",
|
|
description="Generate visual feedback for a CAD model using the user requirement, code, and multi-view render results.",
|
|
best_practices=[
|
|
"If the user uploaded a reference image, pass it in `query_image_path` when you know the exact path.",
|
|
"If `query_image_path` is omitted, the tool will automatically reuse the latest uploaded image from the active conversation when available.",
|
|
"When both STEP/STP and STL exist for the same model, prefer the STEP/STP path for visual feedback because CAD-native rendering preserves geometry better.",
|
|
"Always pass the latest requirement text and current modeling code so the feedback can compare intent, code, and geometry together.",
|
|
],
|
|
)
|
|
async def get_visual_feedback(
|
|
user_query: str,
|
|
code: str,
|
|
model_path: str,
|
|
query_image_path: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
Generate visual feedback for the specified model.
|
|
|
|
Args:
|
|
user_query: User request text, or a SketchPad reference in the form `key:xxxxxxx`.
|
|
code: Modeling code text, or a SketchPad reference in the form `key:xxxxxxx`.
|
|
model_path: Path to the model that should be inspected. Prefer `.step`/`.stp`; if an `.stl` path is provided and a sibling `.step`/`.stp` exists, the CAD-native file is used automatically.
|
|
query_image_path: Optional path to a reference image.
|
|
|
|
Returns:
|
|
str: Visual feedback and follow-up suggestions.
|
|
"""
|
|
_require_simplecad_renderer()
|
|
|
|
# Support SketchPad-backed user_query and code content.
|
|
sketch_pad = get_current_sketch_pad()
|
|
if sketch_pad is None:
|
|
return "Error: No active conversation context. Cannot access SketchPad."
|
|
|
|
real_user_query: str = ""
|
|
real_code: str = ""
|
|
|
|
# Resolve user_query.
|
|
if isinstance(user_query, str) and user_query.startswith("key:"):
|
|
sketch_key = user_query[4:]
|
|
try:
|
|
pad_content = sketch_pad.get_value(sketch_key)
|
|
if pad_content is not None:
|
|
real_user_query = str(pad_content)
|
|
else:
|
|
return f"[SketchPad Key Error] Key not found: {sketch_key}"
|
|
except Exception as e:
|
|
return f"[SketchPad Error] Failed to read key {sketch_key}: {e}"
|
|
else:
|
|
real_user_query = user_query
|
|
|
|
# Resolve code.
|
|
if isinstance(code, str) and code.startswith("key:"):
|
|
sketch_key = code[4:]
|
|
try:
|
|
pad_content = sketch_pad.get_value(sketch_key)
|
|
if pad_content is not None:
|
|
real_code = str(pad_content)
|
|
else:
|
|
return f"[SketchPad Key Error] Key not found: {sketch_key}"
|
|
except Exception as e:
|
|
return f"[SketchPad Error] Failed to read key {sketch_key}: {e}"
|
|
else:
|
|
real_code = code
|
|
|
|
if real_code == "" or real_user_query == "":
|
|
return "Error: user query or code content is empty."
|
|
|
|
requested_model_path = model_path
|
|
model_path = _prefer_cad_native_model_path(model_path)
|
|
if model_path != requested_model_path:
|
|
print_tool_output(
|
|
title="📷 Visual Feedback Tool",
|
|
content=(
|
|
"STEP/STP counterpart detected. "
|
|
f"Using CAD-native model for rendering: {model_path}"
|
|
),
|
|
)
|
|
|
|
print_tool_output(
|
|
title="📷 Visual Feedback Tool",
|
|
content=f"Rendering multi-view image for {model_path}...",
|
|
)
|
|
|
|
# Render multi-view image.
|
|
base_path, _ = os.path.splitext(model_path)
|
|
output_path = f"{base_path}_multi_view_render.png"
|
|
multi_view_results = render_multi_view_model(model_path, output_path)
|
|
|
|
print_tool_output(
|
|
title="📷 Visual Feedback Tool",
|
|
content="Multi-view render complete. Generating visual feedback...",
|
|
)
|
|
|
|
requested_query_image_path = (
|
|
query_image_path.strip()
|
|
if isinstance(query_image_path, str) and query_image_path.strip()
|
|
else None
|
|
)
|
|
resolved_query_image_path = resolve_reference_image_path(query_image_path)
|
|
|
|
if requested_query_image_path is not None and resolved_query_image_path is None:
|
|
return f"Error: Reference image not found: {requested_query_image_path}"
|
|
|
|
if resolved_query_image_path is not None:
|
|
print_tool_output(
|
|
title="📎 Reference Image",
|
|
content=f"Using reference image: {resolved_query_image_path}",
|
|
)
|
|
try:
|
|
actual_query_image_path = ImgPath(resolved_query_image_path, detail="high")
|
|
except Exception as exc:
|
|
return (
|
|
"Error: Failed to load reference image for visual feedback.\n"
|
|
f"Path: {resolved_query_image_path}\n"
|
|
f"Reason: {exc}"
|
|
)
|
|
else:
|
|
actual_query_image_path = None
|
|
|
|
questions = await question_generator(
|
|
user_query=real_user_query,
|
|
code=real_code,
|
|
query_image_path=actual_query_image_path,
|
|
)
|
|
|
|
# Generate visual feedback.
|
|
visual_feedback = await visual_feedback_generator(
|
|
multi_view_results=multi_view_results,
|
|
questions=questions,
|
|
query_image_path=actual_query_image_path,
|
|
)
|
|
|
|
verdict = visual_feedback.strip().split("\n")[-1].strip().upper()
|
|
if verdict == "FAIL":
|
|
code_feedback = await code_suggestion_generation(
|
|
feed_back=visual_feedback, code=real_code
|
|
)
|
|
|
|
feedback = (
|
|
"Visual feedback based on the user requirement and multi-view render:\n\n"
|
|
+ visual_feedback
|
|
+ "\nBased on that feedback, suggested code changes are:\n\n"
|
|
+ code_feedback
|
|
+ "\nPlease review the feedback carefully and decide what to change next."
|
|
)
|
|
else:
|
|
feedback = (
|
|
"Visual feedback based on the user requirement and multi-view render:\n\n"
|
|
+ visual_feedback
|
|
+ "\nPlease review the feedback carefully, share it with the user, and recommend the next action."
|
|
)
|
|
|
|
print_tool_output(
|
|
title="📷 Visual Feedback Tool",
|
|
content=f"Visual feedback generated:\n{feedback}",
|
|
)
|
|
|
|
# Store feedback in SketchPad.
|
|
if sketch_pad is not None:
|
|
import uuid
|
|
|
|
content_key = f"feedback_{uuid.uuid4().hex[:8]}"
|
|
tags = {"visual_feedback", "cad_feedback", "text", f"model_path:{model_path}"}
|
|
summary = f"Visual feedback for {model_path}"
|
|
|
|
try:
|
|
await sketch_pad.set_item(
|
|
key=content_key,
|
|
value=feedback.strip(),
|
|
ttl=None,
|
|
summary=summary,
|
|
tags=tags,
|
|
)
|
|
print_tool_output(
|
|
title="💾 Visual Feedback Stored In SketchPad",
|
|
content=f"Key: {content_key}\nContent length: {len(feedback)} characters",
|
|
)
|
|
return (
|
|
f"📁 Model path: {model_path}\n"
|
|
f"📄 Feedback:\n{feedback}\n"
|
|
"Visual feedback stored in SketchPad:\n\n"
|
|
f"🔑 SketchPad Key: {content_key}\n\n"
|
|
f'💡 Tip: You can reference this feedback later with key "{content_key}"'
|
|
)
|
|
except Exception as e:
|
|
return f"[SketchPad Store Error] key: {content_key}, error: {e}\nFeedback:\n{feedback}"
|
|
else:
|
|
return (
|
|
f"📁 Model path: {model_path}\n"
|
|
f"📄 Feedback:\n{feedback}\n"
|
|
"⚠️ No active conversation context. Feedback was not stored in SketchPad."
|
|
)
|
|
|
|
|
|
@llm_function(
|
|
llm_interface=config.MULTIMODALITY_INTERFACE,
|
|
)
|
|
async def question_generator(
|
|
user_query: str, code: str, query_image_path: Optional[ImgPath]
|
|
) -> str: # type: ignore[call-args]
|
|
"""
|
|
You are a professional CAD model inspector.
|
|
|
|
Based on the user requirement, the optional reference sketch/image, and the modeling code,
|
|
produce a focused checklist of geometry questions that should be verified from the render.
|
|
|
|
Keep the questions tightly aligned with the target part and the code.
|
|
Do not focus on surface roughness or rendering artifacts.
|
|
|
|
Args:
|
|
|
|
user_query: The user requirement
|
|
code: The modeling code
|
|
|
|
Returns:
|
|
str: Questions that should be checked against the rendered model
|
|
"""
|
|
return ""
|
|
|
|
|
|
@llm_function(llm_interface=config.MULTIMODALITY_INTERFACE, timeout=600)
|
|
async def visual_feedback_generator(
|
|
questions: str,
|
|
multi_view_results: ImgPath,
|
|
query_image_path: Optional[ImgPath],
|
|
) -> str: # type: ignore
|
|
"""
|
|
Review the rendered multi-view result against the required checklist and optional reference image.
|
|
|
|
The render should be treated as geometry-first evidence. When a STEP/STP source is available, it is preferred over STL because it preserves CAD geometry more faithfully.
|
|
|
|
First describe the rendered model, then:
|
|
1. inspect the geometry shown in each view,
|
|
2. when a reference image is provided, compare the rendered geometry against it,
|
|
3. answer the checklist questions directly,
|
|
4. suggest possible modifications,
|
|
5. end with a single standalone verdict line: `PASS` or `FAIL`.
|
|
|
|
You must answer the checklist questions clearly and end with an explicit verdict.
|
|
|
|
Args:
|
|
questions (str): The checklist generated from the requirement and code
|
|
multi_view_results (ImgPath): The multi-view render result
|
|
query_image_path (Optional[ImgPath]): Optional reference image uploaded by the user
|
|
|
|
Returns:
|
|
str: Visual feedback ending with `PASS` or `FAIL`
|
|
|
|
"""
|
|
|
|
return ""
|
|
|
|
|
|
@llm_function(llm_interface=config.CODE_INTERFACE, timeout=600)
|
|
async def code_suggestion_generation(feed_back: str, code: str) -> str: # type: ignore[call-args]
|
|
"""
|
|
You are a CAD modeling expert.
|
|
|
|
Based on the visual feedback and the current modeling code, produce concrete code-edit suggestions.
|
|
Identify the code regions that likely need to change, explain the direction of the change,
|
|
and provide short pseudocode or implementation hints when helpful.
|
|
|
|
Args:
|
|
feed_back: The visual feedback result
|
|
code: The modeling code
|
|
|
|
Returns:
|
|
str: Suggestions for modifying the modeling code
|
|
"""
|
|
return ""
|
|
|
|
|
|
def render_multi_view_model(
|
|
model_path: str,
|
|
output_path: str = "multi_view_render.png",
|
|
) -> ImgPath:
|
|
"""
|
|
Render a six-view composite image for a 3D model.
|
|
|
|
Args:
|
|
model_path: 3D model file path (.stl, .step, .stp, .brep, .brp, .bin)
|
|
output_path: Output image path, defaulting to "multi_view_render.png"
|
|
|
|
Returns:
|
|
ImgPath: Path to the rendered composite image
|
|
"""
|
|
|
|
try:
|
|
_require_simplecad_renderer()
|
|
|
|
print_tool_output(
|
|
"Multi-View Render Tool", f"Rendering 3D model: {model_path}", "cyan"
|
|
)
|
|
|
|
renderable_shapes = _load_renderable_shapes(model_path)
|
|
direct_cad_renderer = _should_use_direct_cad_renderer(model_path)
|
|
|
|
views = [
|
|
((-1, -1, 1), "Front Left Top"),
|
|
((1, 1, -1), "Back Right Bottom"),
|
|
((1, -1, -1), "Right Front Bottom"),
|
|
((-1, 1, 1), "Left Back Top"),
|
|
((0.01, 0.01, math.sqrt(3)), "Top"),
|
|
((math.sqrt(3), 0.01, 0.01), "Right Side"),
|
|
]
|
|
|
|
images = []
|
|
with tempfile.TemporaryDirectory(prefix="multi_view_render_") as temp_dir:
|
|
for idx, (dir_vec, label) in enumerate(views):
|
|
view_output_path = os.path.join(temp_dir, f"view_{idx}.png")
|
|
view_angles = _direction_to_view(dir_vec)
|
|
render_fn = (
|
|
_render_direct_cad_screenshot
|
|
if direct_cad_renderer
|
|
else _render_camera_lit_screenshot
|
|
)
|
|
render_fn(
|
|
renderable_shapes,
|
|
view_output_path,
|
|
image_size=SINGLE_VIEW_IMAGE_SIZE,
|
|
view=view_angles,
|
|
zoom=4.0,
|
|
)
|
|
with Image.open(view_output_path) as rendered_view:
|
|
annotated = _add_axis_triad_overlay(
|
|
rendered_view.copy(),
|
|
_view_direction_from_angles(*view_angles),
|
|
)
|
|
images.append(_add_view_label(annotated, label))
|
|
|
|
w, h = images[0].size
|
|
grid_img = Image.new("RGB", (3 * w, 2 * h), "white")
|
|
grid_img.paste(images[0], (0, 0))
|
|
grid_img.paste(images[1], (w, 0))
|
|
grid_img.paste(images[2], (2 * w, 0))
|
|
grid_img.paste(images[3], (0, h))
|
|
grid_img.paste(images[4], (w, h))
|
|
grid_img.paste(images[5], (2 * w, h))
|
|
|
|
grid_img.save(output_path)
|
|
|
|
return ImgPath(str(output_path), detail="high")
|
|
|
|
except Exception as e:
|
|
raise Exception(f"Multi-view rendering failed: {str(e)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Render a six-view image for a CAD model.")
|
|
parser.add_argument("model_path", help="Path to the model file to render")
|
|
parser.add_argument(
|
|
"--output",
|
|
default="multi_view_render.png",
|
|
help="Output image path",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
render_multi_view_model(model_path=args.model_path, output_path=args.output)
|