Files
2026-07-22 13:48:46 +08:00

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)