diff --git a/CHANGELOG.md b/CHANGELOG.md index d746aab5..2aa2c92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ## Unreleased +### Fixed +- A failed fillet or chamfer, and a ql cardinality error, carry their diagnostic picture again (as in 2.2.6): the + failing edge(s) as orange lines, or the matched faces orange and the near misses purple. 2.2.7b1 drew pictures for + failed booleans only. Highlighted faces are shaded with the standard material, so the palette colours read as before. + ## 2.2.7b1 — 2026-10-24 ### Added diff --git a/src/cpp/include/scad/ops/features/diagnostics.hpp b/src/cpp/include/scad/ops/features/diagnostics.hpp index cebf4745..2d966b7d 100644 --- a/src/cpp/include/scad/ops/features/diagnostics.hpp +++ b/src/cpp/include/scad/ops/features/diagnostics.hpp @@ -3,8 +3,8 @@ // operation has failed. // // Every entry is best effort: a probe that cannot decide says nothing, and a diagnosis -// never replaces the error it explains. Unlike the Python SDK none of them renders an -// evidence image (the measurements and repair lines carry the same facts as text). +// never replaces the error it explains. A diagnosis names what to draw (`evidence_edges`, +// `evidence_caption`): the operator that raises asks `diagnostic_image` for the picture. #pragma once #include @@ -27,6 +27,8 @@ struct BlendDiagnosis { std::vector possible_causes; std::vector measurements; std::vector repair; + std::vector evidence_edges; // the edges the picture marks in orange (the solid itself is drawn) + std::string evidence_caption; }; // Does the blend succeed on this one edge alone? The per-edge diagnostic rerun. diff --git a/src/cpp/src/ops/features/blend_diagnosis.cpp b/src/cpp/src/ops/features/blend_diagnosis.cpp index 21c2a6c9..92552c73 100644 --- a/src/cpp/src/ops/features/blend_diagnosis.cpp +++ b/src/cpp/src/ops/features/blend_diagnosis.cpp @@ -86,9 +86,13 @@ std::optional classify_tiny_edges(const Case& c) { const double threshold = std::max(1e-3, median * 1e-3); // sub-micron slivers against the selection's scale std::size_t tiny = 0; double shortest = 0.0; + const Edge* shortest_edge = nullptr; for (const auto& entry : lengths) { if (entry.second > threshold) continue; - shortest = tiny == 0 ? entry.second : std::min(shortest, entry.second); + if (tiny == 0 || entry.second < shortest) { + shortest = entry.second; + shortest_edge = entry.first; + } ++tiny; } if (tiny == 0 || tiny == lengths.size()) return std::nullopt; @@ -103,6 +107,8 @@ std::optional classify_tiny_edges(const Case& c) { out.measurements = {{"shortest_selected_edge", shortest, "mm"}, {"selection_median_edge", median, "mm"}}; out.repair = {"Drop the sliver edges from the selection (filter by feature intent, not by a blanket selector).", "Re-select with a QL predicate that excludes sub-micron edges before the " + c.words.blend + "."}; + out.evidence_edges = {*shortest_edge}; + out.evidence_caption = "orange line marks the shortest sliver edge; see measurements"; return out; } @@ -126,6 +132,8 @@ std::optional classify_tangent_adjacency(const Case& c) { out.repair = {"Remove that junction edge from the selection: there is no corner to blend.", "If a visual transition is required there, it already exists \xE2\x80\x94 the faces are tangent by " "construction."}; + out.evidence_edges = {edge}; + out.evidence_caption = "orange line marks the tangent junction edge; see measurements"; return out; } return std::nullopt; @@ -151,6 +159,8 @@ std::optional classify_blend_room(const Case& c) { out.repair = {"Reduce the " + c.words.size + " to below " + g4(*extent) + " mm (e.g. " + g4(*extent * 0.95) + ") and retry.", "Or select a different edge with more room on its adjacent faces."}; + out.evidence_edges = {edge}; + out.evidence_caption = "orange line marks the failing edge; see measurements for its endpoints and the available face room"; return out; } } @@ -185,6 +195,8 @@ std::optional classify_partial_smooth_chain(const Case& c) { out.repair = {"Select the entire smooth chain: every tangent-continuous segment of that curve, then blend them " "together in one call.", "Enumerate with QL and extend the selection across the shared vertices before retrying."}; + out.evidence_edges = {edge}; + out.evidence_caption = "orange line marks the partially selected chain segment"; return out; } } @@ -207,6 +219,8 @@ BlendDiagnosis tangency_critical(const Case& c, const Edge& edge, double extent) out.repair = {"Reduce the " + c.words.size + " below " + g4(extent / 2) + " mm (half the narrowest shared face) so adjacent blend surfaces leave material between them.", "Or blend the edges in two passes with different sizes."}; + out.evidence_edges = {edge}; + out.evidence_caption = "orange line marks the critical edge; see measurements"; return out; } @@ -262,6 +276,8 @@ std::optional classify_vertex_conflict(const Case& c) { out.repair = {"Blend the converging edges in separate passes: hardest edges first, then the remainder with a " "smaller size.", "Or drop the corner edge from this pass and blend it alone afterwards."}; + for (std::size_t i = 0; i < corner.incident.size() && i < 3; ++i) out.evidence_edges.push_back(*corner.incident[i]); + out.evidence_caption = "orange lines mark the edges converging at the corner vertex"; return out; } return std::nullopt; diff --git a/src/cpp/src/ops/features/blends.cpp b/src/cpp/src/ops/features/blends.cpp index 1a55bd06..7757a271 100644 --- a/src/cpp/src/ops/features/blends.cpp +++ b/src/cpp/src/ops/features/blends.cpp @@ -3,10 +3,12 @@ #include #include +#include #include #include "internal.hpp" #include "scad/ops/features/diagnostics.hpp" +#include "scad/topo/diagnostic_image.hpp" #include "scad/semantic/tracking.hpp" namespace scad { @@ -57,10 +59,30 @@ raise_blend_failure(const ErrorSpec &base, const std::exception &error, violation != nullptr ? "BlendVolumeViolation: " + std::string(violation->what()) : technical_details_of(error); + std::vector evidence; + if (diagnosis && !diagnosis->evidence_edges.empty()) { + // The solid with the implicated edges in orange; the same failure at the same size is drawn once per process + // (agent retry loops must not pay again). + DiagnosticImage image; + image.kind = "render"; + image.operation = base.operation; + image.view = "default"; + image.caption = diagnosis->evidence_caption; + char size[32]; + std::snprintf(size, sizeof size, "%.1f", *attempt.size); + image.dedup_key = diagnosis->failure_kind + "|" + size; + image.shapes.push_back(solid); + for (const Edge &edge : diagnosis->evidence_edges) image.shapes.push_back(edge); + image.payload.set("mode", Value("highlight_edges")); + image.payload.set("edge_count", Value(static_cast(diagnosis->evidence_edges.size()))); + if (auto picture = diagnostic_image(image)) + evidence.push_back(std::move(*picture)); + } raise_harness_error( spec, details, diagnosis ? diagnosis->measurements : std::vector{}, - diagnosis ? diagnosis->repair : std::vector{}); + diagnosis ? diagnosis->repair : std::vector{}, {}, + std::move(evidence)); } // The shared flow of fillet and chamfer once the size and the edges are known. diff --git a/src/cpp/src/render/raster.cpp b/src/cpp/src/render/raster.cpp index d498999c..e1e24bf5 100644 --- a/src/cpp/src/render/raster.cpp +++ b/src/cpp/src/render/raster.cpp @@ -252,14 +252,21 @@ void shade(const MeshView& m, std::uint32_t t, float w1, float w2, const Lightin Rgb base = m.style->color; float a = 1.0f; + const Material* surface_material = m.material; const std::uint32_t face = m.fid[t]; if (face < m.style->face_colors.size()) { const Rgba& fc = m.style->face_colors[face]; - if (!std::isnan(fc[3])) { base = {fc[0], fc[1], fc[2]}; a = fc[3]; } + if (!std::isnan(fc[3])) { + base = {fc[0], fc[1], fc[2]}; + a = fc[3]; + // A face with a colour of its own is a highlight: the VTK path drew those as separate surfaces with the standard + // material (ambient .24 / diffuse .72) even in a studio shot, so the palette colour stays readable. + surface_material = &lighting.standard; + } } if (alpha) *alpha = a * m.style->opacity; if (!m.style->lit) { out[0] = base[0]; out[1] = base[1]; out[2] = base[2]; return; } - const Material& mat = *m.material; + const Material& mat = *surface_material; float diffuse[3] = {0, 0, 0}, specular[3] = {0, 0, 0}; for (const Light& l : lighting.lights) { const float ndl = n[0] * l.dir[0] + n[1] * l.dir[1] + n[2] * l.dir[2]; diff --git a/src/simplecadapi/_cpp/evidence.py b/src/simplecadapi/_cpp/evidence.py index 52e1ce17..4ff94d82 100644 --- a/src/simplecadapi/_cpp/evidence.py +++ b/src/simplecadapi/_cpp/evidence.py @@ -2,7 +2,10 @@ An operation that has diagnosed a failure asks for a picture (``DiagnosticImage`` in C++); this module draws it: -* ``render`` solids, each operand in its own colour, on the software renderer (four axonometric views); +* ``render`` solids on the software renderer (four axonometric views): each boolean operand in its own + colour, or one solid with the implicated edges as orange lines (fillet, chamfer); +* ql cardinality errors the scope with the matched faces orange and up to three near misses purple (other targets: + the matched entities as orange lines), through ``_ql_evidence``; * ``wire_plan_render`` wires projected on their best-fit plane, the loose ends in orange (matplotlib); * ``sketch_render`` a sketch at the solver's positions, implicated entities orange, related ones purple (matplotlib). @@ -71,6 +74,8 @@ def provide(kind: str, operation: str, view: str, caption: str, dedup_key: str, _RENDER_DEDUP.add(key) try: if kind == "render": + if payload.get("mode") == "highlight_edges": + return _render_highlight_edges(operation, view, caption, shapes, payload) return _render_solids(operation, view, caption, shapes, payload) if kind == "wire_plan_render": return _wire_plan(operation, view, caption, shapes, payload) @@ -117,6 +122,103 @@ def _render_solids(operation: str, view: str, caption: str, shapes: Sequence[Any return _evidence("render", path, view, caption) +def _render_highlight_edges(operation: str, view: str, caption: str, shapes: Sequence[Any], payload: Mapping[str, Any]): + """One solid, the implicated edges as crisp orange lines (a failed fillet / chamfer).""" + + from .screenshot import render_screenshot + + count = int(payload.get("edge_count") or 0) + solid, edges = shapes[0], list(shapes[1:1 + count]) + path = _path(operation) + render_screenshot( + [solid], + path, + views=_DIAGNOSTIC_VIEWS, + supersample=1, + edge_width_scale=0.0035, # heavier model ink so panels read at grid size + highlight_edge_width=8.0, # highlights outweigh the ink, always + show_legend=True, + show_callouts=False, + highlight_edges=tuple(edges), + ) + return _evidence("render", path, view, caption) + + +# ---- ql: the scope with what the query matched ---------------------------------------------------- + +_MATCHED_TAG = "diagnostic.matched" +_NEAR_MISS_TAG = "diagnostic.near_miss" + + +def _ql_evidence(scope: Any, matched: Sequence[Any], target_kind: str, dedup_key: Any, + near_misses: Sequence[Any] = ()) -> List[Dict[str, Any]]: + """Render the scope with the matched entities visibly highlighted. + + Face selections render a copy of the scope whose matched faces carry ``diagnostic.matched`` (orange) and up to three + near-miss faces carry ``diagnostic.near_miss`` (purple), with legend and callouts, so the agent can see what it + selected; the copy keeps the caller's shape free of tags. Other targets render the matched entities themselves + as orange lines. One picture per failure signature; never raises. + """ + + if not enabled() or dedup_key in _RENDER_DEDUP: + return [] + _RENDER_DEDUP.add(dedup_key) + operation = f"ql_{target_kind}_resolve" + view = "default" + try: + from . import _native as _n + from .screenshot import render_screenshot + + highlight_tags: List[str] = [] + tag_labels: Dict[str, str] = {} + shapes: List[Any] = [] + if target_kind == "face": + copy = _n.clone_semantic_view(scope) if scope.__class__.__name__ == "Solid" else None + if copy is not None: + # Match by kernel identity (IsSame): wrapper copies differ at the Python level, not in the topology. + matched_wrapped = [item.wrapped for item in matched] + near_wrapped = [item.wrapped for item in near_misses] + matched_any, near_tagged = False, 0 + for face in copy._iter_faces(): + if any(face.wrapped.IsSame(w) for w in matched_wrapped): + face._apply_tag(_MATCHED_TAG, propagate=False) + matched_any = True + elif near_tagged < 3 and any(face.wrapped.IsSame(w) for w in near_wrapped): + face._apply_tag(_NEAR_MISS_TAG, propagate=False) + near_tagged += 1 + if matched_any: + highlight_tags = [_MATCHED_TAG] + ([_NEAR_MISS_TAG] if near_tagged else []) + tag_labels = {_MATCHED_TAG: f"matched {target_kind}(s)", _NEAR_MISS_TAG: "near miss"} + shapes = [copy] + highlight_edges: List[Any] = [] + if not shapes: + highlight_edges = list(matched[:8]) + if scope.__class__.__name__ == "Solid": + shapes = [scope] + if not shapes and not highlight_edges: + return [] + path = _path(operation) + render_screenshot( + shapes, + path, + views=_DIAGNOSTIC_VIEWS, + supersample=1, + edge_width_scale=0.0035, + highlight_edge_width=8.0, + highlight_tags=tuple(highlight_tags), + tag_labels=tag_labels, + highlight_edges=tuple(highlight_edges), + show_legend=bool(highlight_tags or highlight_edges), + show_callouts=bool(highlight_tags), + ) + caption = ("orange = matched, purple = near miss; see inventory for identities" if target_kind == "face" + else "orange lines = matched entities; see inventory") + return [_evidence("render", path, view, caption)] + except Exception as error: # noqa: BLE001 - a picture must never mask the error it illustrates + return [_evidence("render-unavailable", Path(), view, + f"diagnostic render unavailable: {type(error).__name__}: {error}")] + + # ---- 2D: wires on their best-fit plane -------------------------------------------------------- @@ -379,3 +481,4 @@ def install() -> None: from . import _native _native._set_diagnostic_image_provider(provide) + _native.ql._set_evidence_provider(_ql_evidence) diff --git a/test/test_error_diagnostics_blend_ql.py b/test/test_error_diagnostics_blend_ql.py new file mode 100644 index 00000000..d6a1efee --- /dev/null +++ b/test/test_error_diagnostics_blend_ql.py @@ -0,0 +1,296 @@ +"""Fillet/chamfer degeneration taxonomy and QL selection-trust diagnosis. + +One case per failure family from the accumulated degeneration catalog +(repo history + OCC ecosystem): size vs face room, tangent adjacency, +tangency-critical interaction, bore curvature, and the QL cardinality +inventory with logical-surface grouping. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +import simplecadapi as scad +from simplecadapi.operators._diagnostics import _RENDER_DEDUP + + +@pytest.fixture(autouse=True) +def _no_render(monkeypatch): + monkeypatch.setenv("SCA_NO_DIAGNOSTIC_RENDER", "1") + + +def _box() -> scad.Solid: + return scad.make_box_rsolid(10, 10, 10) + + +def _box_edges(solid): + return scad.ql.edges().resolve(solid) + + +def _payload(error) -> dict: + return error.to_dict() + + +def test_chamfer_distance_exceeds_face_room(): + box = _box() + edges = _box_edges(box) + with pytest.raises(scad.SimpleCADError) as ctx: + scad.chamfer_rsolid(box, [edges[0]], 12) + + payload = _payload(ctx.value) + assert "distance 12 mm meets or exceeds the 10 mm" in payload["what_happened"] + measurements = {m["name"]: m["value"] for m in payload["measurements"]} + assert measurements["available_face_room"] == pytest.approx(10.0) + assert any("below 10 mm" in step for step in payload["repair"]) + + +def test_fillet_radius_exceeds_face_room(): + box = _box() + edges = _box_edges(box) + with pytest.raises(scad.SimpleCADError) as ctx: + scad.fillet_rsolid(box, [edges[0]], 12) + + payload = _payload(ctx.value) + assert "radius 12 mm meets or exceeds the 10 mm" in payload["what_happened"] + measurements = {m["name"]: m["value"] for m in payload["measurements"]} + assert measurements["radius"] == pytest.approx(12.0) + + +def _slot_solid() -> scad.Solid: + arc_left = scad.make_three_point_arc_redge((0, 5, 0), (-5, 0, 0), (0, -5, 0)) + line_bottom = scad.make_segment_redge((0, -5, 0), (20, -5, 0)) + arc_right = scad.make_three_point_arc_redge((20, -5, 0), (25, 0, 0), (20, 5, 0)) + line_top = scad.make_segment_redge((20, 5, 0), (0, 5, 0)) + wire = scad.make_wire_from_edges_rwire([arc_left, line_bottom, arc_right, line_top]) + return scad.extrude_rsolid(scad.make_face_from_wire_rface(wire), (0, 0, 1), 8) + + +def test_fillet_tangent_adjacent_faces_degenerates(): + solid = _slot_solid() + junction = None + for edge in scad.ql.edges().resolve(solid): + p1 = edge.get_start_vertex().get_coordinates() + p2 = edge.get_end_vertex().get_coordinates() + mid = tuple((a + b) / 2 for a, b in zip(p1, p2)) + if abs(mid[0]) < 1e-6 and abs(mid[1] - 5) < 1e-6: + junction = edge + break + assert junction is not None + + with pytest.raises(scad.SimpleCADError) as ctx: + scad.fillet_rsolid(solid, [junction], 1.0) + + payload = _payload(ctx.value) + assert "already tangent" in payload["what_happened"] + measurements = {m["name"]: m["value"] for m in payload["measurements"]} + assert measurements["dihedral_angle"] == pytest.approx(180.0, abs=0.5) + assert any("no corner to blend" in step for step in payload["repair"]) + + +def test_fillet_tangency_critical_all_edges(): + box = _box() + edges = _box_edges(box) + with pytest.raises(scad.SimpleCADError) as ctx: + scad.fillet_rsolid(box, edges, 5) + + payload = _payload(ctx.value) + assert "meet tangentially" in payload["what_happened"] + measurements = {m["name"]: m["value"] for m in payload["measurements"]} + assert measurements["combined_blend_width"] == pytest.approx(10.0) + assert measurements["available_face_room"] == pytest.approx(10.0) + assert any("below 5 mm" in step for step in payload["repair"]) + + +def test_fillet_bore_mouth_boundary_room(): + # Tube: outer r=10 with a bore r=4 leaves a 6 mm annulus. Empirically + # OCC accepts r=5 but fails at r=6 == annulus width: the boundary case + # degenerates the top-face strip, and the room probe must say so. + outer = scad.make_cylinder_rsolid(10, 10) + bore = scad.make_cylinder_rsolid(4, 20, bottom_face_center=(0, 0, -5)) + tube = scad.cut_rsolid(outer, bore) + mouth_edges = [ + edge + for edge in scad.ql.edges().resolve(tube) + if abs(edge.get_start_vertex().get_coordinates()[2] + - edge.get_end_vertex().get_coordinates()[2]) < 1e-6 + and edge.get_start_vertex().get_coordinates()[2] > 9.0 + ] + assert mouth_edges + + with pytest.raises(scad.SimpleCADError) as ctx: + scad.fillet_rsolid(tube, mouth_edges, 6) + + payload = _payload(ctx.value) + assert "meets or exceeds" in payload["what_happened"] + measurements = {m["name"]: m["value"] for m in payload["measurements"]} + assert measurements["available_face_room"] == pytest.approx(6.0) + assert any("below 6 mm" in step for step in payload["repair"]) + + +def test_fillet_silent_garbage_output_caught_at_exit(): + # Mechanism E: r=5 on all 12 edges of a 10-cube fails loudly, but r=6 + # makes the kernel *succeed* and hand back a degenerate solid. Geometrically + # impossible; the exit check must name it instead of returning it. + box = _box() + edges = _box_edges(box) + with pytest.raises(scad.SimpleCADError) as ctx: + scad.fillet_rsolid(box, edges, 6) + + payload = _payload(ctx.value) + what = payload["what_happened"] + # The volume violation leads what_happened so the agent can tell + # "kernel silently lied" apart from "kernel refused" (r=5 path). How the + # garbage solid measures depends on the volume integrator: the fixed-order + # one reported phantom extra material (~1036 on macOS, ~1039 on Linux, more + # than the 1000 it was given); the knot-span (Gauss-Kronrod) one sees an + # empty solid (volume 0). Either way the exit check must name it. + garbage = re.search(r"result volume (?:is )?([0-9.]+)", what) + assert garbage, what + volume = float(garbage.group(1)) + if volume > 1000: + assert "exceeds the input volume 1000" in what + else: + assert volume == 0.0 + assert "empty/degenerate solid" in what + # Probe explanation follows as the suspected root cause. + assert "probes indicate" in what + assert "BlendVolumeViolation" in (payload["technical_details"] or "") + + +def test_legit_fillet_passes_exit_check(): + # No false positives: an ordinary fillet must pass the monotonic-volume + # guard untouched. + box = _box() + edges = _box_edges(box) + result = scad.fillet_rsolid(box, [edges[0]], 1) + assert len(result._iter_faces()) >= 6 + + +def test_concave_edge_fillet_may_add_material(): + # Regression: a bolt's underhead fillet rounds a concave (270°) edge, + # and the corner fill legitimately ADDS material — observed as exactly + # the quarter-circle deficit times the circumference (+1.28 mm³ on the + # d=8 bolt). Mechanism E must skip the increase check for concave + # selections, not call the kernel a liar. + bolt = scad.std.fastener.make_bolt_rsolid(diameter=8.0, length=24.0) + assert isinstance(bolt, scad.Solid) + + +def _split_band_solid() -> scad.Solid: + cylinder = scad.make_cylinder_rsolid(10, 20) + ring = scad.make_cylinder_rsolid(12, 6, bottom_face_center=(0, 0, 7)) + inner = scad.make_cylinder_rsolid(9, 10, bottom_face_center=(0, 0, 5)) + return scad.cut_rsolid(cylinder, scad.cut_rsolid(ring, inner)) + + +def test_ql_ambiguity_reports_split_surface_group(): + part = _split_band_solid() + with pytest.raises(scad.SimpleCADError) as ctx: + scad.ql.faces().where(scad.ql.surface_type("CYLINDER")).exactly(1).resolve(part) + + payload = _payload(ctx.value) + assert "expected exactly 1 face(s), got 3" in payload["what_happened"] + assert "logical surface group" in payload["what_happened"] + # The two r=10 bands are named as one logical group in the repair. + assert any( + "lie on one logical surface" in step and "F1+F6" in step + for step in payload["repair"] + ) + inventory = payload["inventory"] + hits = [entry for entry in inventory if entry["status"] == "hit"] + assert len(hits) == 3 + # Split pieces of one surface are distinguishable by their axial span. + spans = [entry["description"] for entry in hits if "r=10" in entry["description"]] + assert len(spans) == 2 + assert spans[0] != spans[1] + assert any("z=[" in text for text in spans) + # Near-misses describe what else exists (the planes). + assert any("plane" in entry["description"] for entry in inventory) + + +def test_ql_zero_hit_lists_near_miss_inventory(): + part = _split_band_solid() + with pytest.raises(scad.SimpleCADError) as ctx: + scad.ql.faces().where(scad.ql.surface_type("SPHERE")).at_least(1).resolve(part) + + payload = _payload(ctx.value) + assert "expected at least 1 face(s), got 0" in payload["what_happened"] + inventory = payload["inventory"] + assert all(entry["status"] == "near_miss" for entry in inventory) + assert any("cylinder r=10" in entry["description"] for entry in inventory) + assert any("align predicate values" in step for step in payload["repair"]) + + +def _count_palette_pixels(path: str, rgb: tuple, tolerance: int = 40) -> int: + """Sampled pixel count near a palette color — highlight visibility check.""" + + from PIL import Image + + image = Image.open(path).convert("RGB") + width, height = image.size + return sum( + 1 + for x in range(0, width, 3) + for y in range(0, height, 3) + if all( + abs(channel - target) <= tolerance + for channel, target in zip(image.getpixel((x, y)), rgb) + ) + ) + + +def test_ql_evidence_highlight_is_visible(tmp_path, monkeypatch): + monkeypatch.delenv("SCA_NO_DIAGNOSTIC_RENDER", raising=False) + monkeypatch.chdir(tmp_path) + _RENDER_DEDUP.clear() + part = _split_band_solid() + with pytest.raises(scad.SimpleCADError) as ctx: + scad.ql.faces().where(scad.ql.surface_type("CYLINDER")).exactly(1).resolve(part) + evidence = ctx.value.to_dict()["evidence"] + assert len(evidence) == 1 + rendered = Path(evidence[0]["path"]) + assert rendered.exists() + assert rendered.stat().st_size > 10_000 + assert evidence[0]["path"] in str(ctx.value) + # The matched cylinder bands must be ORANGE in the image, not just + # present in the scene: palette first color #f39c12. + orange = _count_palette_pixels(str(rendered), (243, 156, 18)) + assert orange > 400, f"highlight not visible: only {orange} sampled orange pixels" + + +def test_fillet_evidence_highlights_edge_as_line(tmp_path, monkeypatch): + """The failing edge renders as a crisp orange line, not marker geometry.""" + + monkeypatch.delenv("SCA_NO_DIAGNOSTIC_RENDER", raising=False) + monkeypatch.chdir(tmp_path) + _RENDER_DEDUP.clear() + box = _box() + with pytest.raises(scad.SimpleCADError) as ctx: + scad.fillet_rsolid(box, [_box_edges(box)[0]], 12) + evidence = ctx.value.to_dict()["evidence"] + assert len(evidence) == 1 + orange = _count_palette_pixels(evidence[0]["path"], (243, 156, 18)) + assert orange > 40, f"edge line not visible: only {orange} sampled orange pixels" + + +def _face_tags(face) -> set: + try: + return set(scad.list_tags(shape=face)) + except Exception: + return set() + + +def test_ql_highlight_does_not_pollute_user_shape(tmp_path, monkeypatch): + monkeypatch.delenv("SCA_NO_DIAGNOSTIC_RENDER", raising=False) + monkeypatch.chdir(tmp_path) + _RENDER_DEDUP.clear() + part = _split_band_solid() + tags_before = {tag for face in part._iter_faces() for tag in _face_tags(face)} + with pytest.raises(scad.SimpleCADError): + scad.ql.faces().where(scad.ql.surface_type("CYLINDER")).exactly(1).resolve(part) + tags_after = {tag for face in part._iter_faces() for tag in _face_tags(face)} + assert tags_before == tags_after + assert all("diagnostic" not in tag for tag in tags_after) diff --git a/test/test_rearchitecture_2_0_kernel_builders.py b/test/test_rearchitecture_2_0_kernel_builders.py new file mode 100644 index 00000000..f5eca768 --- /dev/null +++ b/test/test_rearchitecture_2_0_kernel_builders.py @@ -0,0 +1,62 @@ +"""TDD coverage for the OCP-native primitive solid slice.""" + +from __future__ import annotations + +import unittest +import simplecadapi as scad + + +class TestOcpPrimitiveBuilders(unittest.TestCase): + def test_kernel_ocp_builders_module_exposes_core_solid_builders(self): + from simplecadapi.kernel.ocp_builders import ( + make_box_solid, + make_cone_solid, + make_cylinder_solid, + make_sphere_solid, + ) + + box = scad.Solid(make_box_solid((0.0, 0.0, 0.0), 1.0, 2.0, 3.0)) + cylinder = scad.Solid( + make_cylinder_solid((0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 1.0, 2.0) + ) + cone = scad.Solid( + make_cone_solid((0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 1.0, 0.5, 2.0) + ) + sphere = scad.Solid(make_sphere_solid((1.0, 2.0, 3.0), 1.5)) + + self.assertAlmostEqual(box.get_volume(), 6.0, places=5) + self.assertGreater(cylinder.get_volume(), 0.0) + self.assertGreater(cone.get_volume(), 0.0) + self.assertGreater(sphere.get_volume(), 0.0) + + def test_make_box_rsolid_uses_ocp_wrapped_storage(self): + solid = scad.make_box_rsolid(1.0, 2.0, 3.0) + self.assertIsInstance(solid, scad.Solid) + self.assertTrue(hasattr(solid, "wrapped")) + self.assertFalse(hasattr(solid, "cq_solid")) + self.assertAlmostEqual(solid.get_volume(), 6.0, places=5) + + def test_make_cylinder_rsolid_uses_ocp_wrapped_storage(self): + solid = scad.make_cylinder_rsolid(1.0, 2.0) + self.assertIsInstance(solid, scad.Solid) + self.assertTrue(hasattr(solid, "wrapped")) + self.assertFalse(hasattr(solid, "cq_solid")) + self.assertGreater(solid.get_volume(), 0.0) + + def test_make_cone_rsolid_uses_ocp_wrapped_storage(self): + solid = scad.make_cone_rsolid(1.0, 2.0, top_radius=0.5) + self.assertIsInstance(solid, scad.Solid) + self.assertTrue(hasattr(solid, "wrapped")) + self.assertFalse(hasattr(solid, "cq_solid")) + self.assertGreater(solid.get_volume(), 0.0) + + def test_make_sphere_rsolid_uses_ocp_wrapped_storage(self): + solid = scad.make_sphere_rsolid(1.5, center=(1.0, 2.0, 3.0)) + self.assertIsInstance(solid, scad.Solid) + self.assertTrue(hasattr(solid, "wrapped")) + self.assertFalse(hasattr(solid, "cq_solid")) + self.assertGreater(solid.get_volume(), 0.0) + + +if __name__ == "__main__": + unittest.main()