Files
cad-Integration/CADDesigner-Code-main/test/test_model_view_tools.py
T
2026-07-22 13:48:46 +08:00

340 lines
11 KiB
Python

import os
import sys
from types import SimpleNamespace
import numpy as np
import pytest
from PIL import Image
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
import tools.model_view_tools as model_view_tools_module
import tools.reference_image as reference_image_module
from tools.model_view_tools import get_visual_feedback
@pytest.mark.asyncio
async def test_get_visual_feedback_uses_latest_uploaded_image_for_both_llm_steps(
monkeypatch, tmp_path
):
image_path = tmp_path / "query_image_001.png"
image_path.write_bytes(b"fake-png-bytes")
render_path = tmp_path / "render.png"
render_path.write_bytes(b"fake-render")
class FakeContext:
def retrieve_full_messages(self):
return [
SimpleNamespace(
role="user",
content=[
{"type": "text", "text": "Inspect this part"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"local_path": str(image_path),
},
},
],
)
]
monkeypatch.setattr(
reference_image_module,
"get_current_context",
lambda: FakeContext(),
)
monkeypatch.setattr(
model_view_tools_module, "_require_simplecad_renderer", lambda: None
)
monkeypatch.setattr(
model_view_tools_module, "print_tool_output", lambda *args, **kwargs: None
)
class FakeSketchPad:
async def set_item(self, key, value, ttl=None, summary=None, tags=None):
return key
monkeypatch.setattr(
model_view_tools_module,
"get_current_sketch_pad",
lambda: FakeSketchPad(),
)
captured = {}
async def fake_question_generator(user_query, code, query_image_path):
captured["question_image_path"] = (
str(query_image_path.path) if query_image_path else None
)
return "Checklist"
async def fake_visual_feedback_generator(
questions, multi_view_results, query_image_path
):
captured["visual_image_path"] = (
str(query_image_path.path) if query_image_path else None
)
captured["multi_view_results"] = str(multi_view_results.path)
return "Looks correct\nPASS"
monkeypatch.setattr(
model_view_tools_module,
"question_generator",
fake_question_generator,
)
monkeypatch.setattr(
model_view_tools_module,
"visual_feedback_generator",
fake_visual_feedback_generator,
)
from SimpleLLMFunc.type import ImgPath
monkeypatch.setattr(
model_view_tools_module,
"render_multi_view_model",
lambda model_path, output_path: ImgPath(render_path, detail="high"),
)
result = await get_visual_feedback(
user_query="Inspect this part",
code="result = None",
model_path="./part/model.stl",
)
expected_image_path = str(image_path.resolve())
assert captured["question_image_path"] == expected_image_path
assert captured["visual_image_path"] == expected_image_path
assert captured["multi_view_results"] == str(render_path.resolve())
assert "Model path: ./part/model.stl" in result
def test_camera_relative_light_rig_tracks_camera_direction() -> None:
for view_dir in (
np.array([1.0, 0.0, 0.0]),
np.array([-1.0, 0.0, 0.0]),
np.array([0.0, 0.0, 1.0]),
np.array([1.0, 1.0, -1.0]),
):
normalized_view = view_dir / np.linalg.norm(view_dir)
light_dirs, light_weights, ambient = (
model_view_tools_module._camera_relative_light_rig(normalized_view)
)
assert ambient > 0.0
assert len(light_dirs) == len(light_weights) >= 3
assert np.dot(light_dirs[0], normalized_view) > 0.85
assert all(
abs(np.linalg.norm(light_dir) - 1.0) < 1e-6 for light_dir in light_dirs
)
def test_camera_relative_shading_keeps_front_faces_bright_across_views() -> None:
base_color = np.array([0.72, 0.76, 0.81], dtype=float)
front_face_brightness: list[float] = []
for view_dir in (
np.array([1.0, 0.0, 0.0]),
np.array([-1.0, 0.0, 0.0]),
np.array([0.0, 1.0, 0.0]),
np.array([1.0, 1.0, -1.0]),
):
normalized_view = view_dir / np.linalg.norm(view_dir)
normals = np.stack([normalized_view, -normalized_view])
shaded = model_view_tools_module._shade_normals_camera_relative(
normals,
base_color,
normalized_view,
)
front_face_brightness.append(float(np.mean(shaded[0, :3])))
assert float(np.mean(shaded[0, :3])) > float(np.mean(shaded[1, :3]))
assert min(front_face_brightness) > 0.55
assert max(front_face_brightness) - min(front_face_brightness) < 0.12
def test_surface_shading_normals_keep_planar_triangles_consistent() -> None:
class FakeVector:
def __init__(self, x: float, y: float, z: float) -> None:
self.x = x
self.y = y
self.z = z
class FakeCadFace:
def geomType(self) -> str:
return "PLANE"
def normalAt(self, location=None):
return FakeVector(0.0, 0.0, 1.0)
class FakeFace:
cq_face = FakeCadFace()
tri_pts = np.array(
[
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
[[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
],
dtype=float,
)
normals = model_view_tools_module._compute_surface_shading_normals(
FakeFace(), tri_pts
)
assert normals.shape == (2, 3)
assert np.allclose(normals[0], [0.0, 0.0, 1.0])
assert np.allclose(normals[1], [0.0, 0.0, 1.0])
def test_direct_cad_renderer_is_used_for_brep_like_formats() -> None:
assert model_view_tools_module._should_use_direct_cad_renderer("part.step") is True
assert model_view_tools_module._should_use_direct_cad_renderer("part.stp") is True
assert model_view_tools_module._should_use_direct_cad_renderer("part.brep") is True
assert model_view_tools_module._should_use_direct_cad_renderer("part.bin") is True
assert model_view_tools_module._should_use_direct_cad_renderer("part.stl") is False
def test_prefer_cad_native_model_path_uses_step_over_stl(tmp_path) -> None:
stl_path = tmp_path / "model.stl"
step_path = tmp_path / "model.step"
stl_path.write_text("solid", encoding="utf-8")
step_path.write_text("step", encoding="utf-8")
selected = model_view_tools_module._prefer_cad_native_model_path(str(stl_path))
assert selected == str(step_path.resolve())
def test_prefer_cad_native_model_path_keeps_stl_without_step(tmp_path) -> None:
stl_path = tmp_path / "model.stl"
stl_path.write_text("solid", encoding="utf-8")
selected = model_view_tools_module._prefer_cad_native_model_path(str(stl_path))
assert selected == str(stl_path.resolve())
def test_load_renderable_shapes_flattens_step_compounds(monkeypatch) -> None:
class FakeCadShape:
def __init__(self, shape_type: str, solids=None) -> None:
self._shape_type = shape_type
self._solids = list(solids or [])
def ShapeType(self) -> str:
return self._shape_type
def Solids(self):
return list(self._solids)
class FakeWrappedSolid:
def __init__(self, obj) -> None:
self.obj = obj
class FakeWorkplane:
def __init__(self, values) -> None:
self._values = values
def vals(self):
return list(self._values)
solid_a = FakeCadShape("Solid")
solid_b = FakeCadShape("Solid")
compound = FakeCadShape("Compound", solids=[solid_a, solid_b])
fake_cq = SimpleNamespace(
importers=SimpleNamespace(
importShape=lambda import_type, model_path: FakeWorkplane([compound])
)
)
monkeypatch.setattr(model_view_tools_module, "cq", fake_cq)
monkeypatch.setattr(model_view_tools_module, "ScadSolid", FakeWrappedSolid)
monkeypatch.setattr(
model_view_tools_module, "_require_simplecad_renderer", lambda: None
)
result = model_view_tools_module._load_renderable_shapes("part.step")
assert [wrapped.obj for wrapped in result] == [solid_a, solid_b]
def test_feature_edge_mask_detects_normal_and_depth_discontinuities() -> None:
mask = np.ones((8, 8), dtype=bool)
depth = np.zeros((8, 8), dtype=float)
normals = np.zeros((8, 8, 3), dtype=float)
normals[:, :4] = np.array([0.0, 0.0, 1.0])
normals[:, 4:] = np.array([1.0, 0.0, 0.0])
edge_mask = model_view_tools_module._compute_feature_edge_mask(
mask,
depth,
normals,
depth_jump_threshold=10.0,
normal_cos_threshold=0.95,
)
assert edge_mask[:, 3:5].any()
depth[:, 4:] = 4.0
normals[:, :] = np.array([0.0, 0.0, 1.0])
edge_mask = model_view_tools_module._compute_feature_edge_mask(
mask,
depth,
normals,
depth_jump_threshold=1.0,
normal_cos_threshold=0.95,
)
assert edge_mask[:, 3:5].any()
def test_direct_rasterizer_supersamples_and_preserves_target_size() -> None:
triangles = [
np.array([[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0]], dtype=float),
np.array([[0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [0.0, 1.0, 1.0]], dtype=float),
]
normals = [
np.array([0.0, 0.0, 1.0], dtype=float),
np.array([0.0, 0.0, 1.0], dtype=float),
]
image = model_view_tools_module._rasterize_projected_triangles(
triangles,
normals,
image_size=(48, 48),
zoom=4.0,
background_rgb=np.array([255, 255, 255], dtype=np.uint8),
fill_rgb=np.array([180, 190, 200], dtype=np.uint8),
outline_rgb=np.array([0, 0, 0], dtype=np.uint8),
)
pixels = np.asarray(image)
unique_colors = np.unique(pixels.reshape(-1, 3), axis=0)
assert image.size == (48, 48)
assert len(unique_colors) > 3
def test_axis_triad_overlay_draws_small_corner_marker() -> None:
image = Image.new("RGB", (320, 320), "white")
annotated = model_view_tools_module._add_axis_triad_overlay(
image,
np.array([1.0, 1.0, 1.0]) / np.sqrt(3.0),
)
original = np.asarray(image)
updated = np.asarray(annotated)
diff = np.abs(updated.astype(int) - original.astype(int)).sum(axis=2)
changed_pixels = np.argwhere(diff > 0)
assert changed_pixels.size > 0
assert int(changed_pixels[:, 0].max()) > 220
assert int(changed_pixels[:, 1].max()) < 120