Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/cpp/include/scad/ops/features/diagnostics.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <functional>
Expand All @@ -27,6 +27,8 @@ struct BlendDiagnosis {
std::vector<std::string> possible_causes;
std::vector<ErrorMeasurement> measurements;
std::vector<std::string> repair;
std::vector<Edge> 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.
Expand Down
18 changes: 17 additions & 1 deletion src/cpp/src/ops/features/blend_diagnosis.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,13 @@ std::optional<BlendDiagnosis> 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;
Expand All @@ -103,6 +107,8 @@ std::optional<BlendDiagnosis> 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;
}

Expand All @@ -126,6 +132,8 @@ std::optional<BlendDiagnosis> 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;
Expand All @@ -151,6 +159,8 @@ std::optional<BlendDiagnosis> 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;
}
}
Expand Down Expand Up @@ -185,6 +195,8 @@ std::optional<BlendDiagnosis> 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;
}
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -262,6 +276,8 @@ std::optional<BlendDiagnosis> 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;
Expand Down
24 changes: 23 additions & 1 deletion src/cpp/src/ops/features/blends.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@

#include <algorithm>
#include <cmath>
#include <cstdio>
#include <functional>

#include "internal.hpp"
#include "scad/ops/features/diagnostics.hpp"
#include "scad/topo/diagnostic_image.hpp"
#include "scad/semantic/tracking.hpp"

namespace scad {
Expand Down Expand Up @@ -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<ErrorEvidence> 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<std::int64_t>(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<ErrorMeasurement>{},
diagnosis ? diagnosis->repair : std::vector<std::string>{});
diagnosis ? diagnosis->repair : std::vector<std::string>{}, {},
std::move(evidence));
}

// The shared flow of fillet and chamfer once the size and the edges are known.
Expand Down
11 changes: 9 additions & 2 deletions src/cpp/src/render/raster.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
105 changes: 104 additions & 1 deletion src/simplecadapi/_cpp/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 --------------------------------------------------------


Expand Down Expand Up @@ -379,3 +481,4 @@ def install() -> None:
from . import _native

_native._set_diagnostic_image_provider(provide)
_native.ql._set_evidence_provider(_ql_evidence)
Loading
Loading