Skip to main content

brep_render/
face_transform_help.rs

1//! Turning a Transform Face refusal into a sentence about the user's model.
2//!
3//! Move Face and Rotate Face refuse BY DESIGN — a motion that carries the
4//! selection off the body it belongs to has no answer, and the kernel says so
5//! rather than building something unsound. What it says, though, is written for
6//! the lane that raised it:
7//!
8//! ```text
9//! TF1: move_faces: the translation inverts the solid (signed volume changed sign) — refusing
10//! ```
11//!
12//! `move_faces` is a function name, and "signed volume changed sign" is the
13//! kernel's own criterion, not a statement about the part on screen. Worse, the
14//! message never names the face the user picked and is dragging: the rotation
15//! refusal names `Box_NZ` and `Box_PY`, which are the NEIGHBOURS that could not
16//! follow, so the two faces it does name are both the wrong answer to "which
17//! one did I break?".
18//!
19//! This module re-says it. It reads the kernel's text, recognises WHICH refusal
20//! it is from the few stable phrases each one is built on, and rebuilds the
21//! sentence around the face the user actually selected — which the app knows
22//! from `params.faces` and the kernel message does not carry.
23//!
24//! One thing the sentences here must get right, because it is what the user is
25//! looking at: a REFUSED Transform Face contributes NOTHING. The feature drops
26//! out of the build entirely rather than falling back to its last good motion,
27//! so the face springs back to where it was before the feature ran — measured
28//! 2026-09-21, a block lifted to z = 17.9188 by a good drag snapped back to
29//! z = 14 the moment a later drag refused. A hint that said the part was "still
30//! exactly as it was" would be describing a different app.
31//!
32//! **The kernel text is never thrown away.** [`FaceTransformRefusal::kernel`]
33//! keeps it verbatim, and an unrecognised message becomes the reason unchanged:
34//! when the kernel lane rewords a refusal under us, this degrades to exactly
35//! the message the app showed before this module existed, rather than losing
36//! it. That is the whole reason the match is on phrases rather than on a
37//! prefix — nothing here can make a refusal less legible than it was.
38
39/// A Transform Face refusal, said three ways: what happened, why, and what to
40/// do — plus the kernel's own words, kept.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct FaceTransformRefusal {
43    /// Which motion refused: `"Move Face"` or `"Rotate Face"`, named for the
44    /// half of the feature the user was actually driving. Transform Face is
45    /// both, and a user who dragged a rotation ring should not be told
46    /// something about a translation.
47    pub motion: &'static str,
48    /// The refusal in one sentence, naming the selected face.
49    pub reason: String,
50    /// What to do instead. Always something the user can do from where they
51    /// are — the model is at its last good answer, so every hint is a smaller
52    /// value away rather than a restart.
53    pub hint: String,
54    /// The kernel's message, verbatim and unabridged.
55    pub kernel: String,
56}
57
58impl FaceTransformRefusal {
59    /// The reason and the hint as one line, for a chip.
60    pub fn one_line(&self) -> String {
61        format!("{} — {}", self.reason, self.hint)
62    }
63}
64
65/// How the selection reads inside a sentence: the one face's name, or a count.
66fn subject(faces: &[String]) -> String {
67    match faces {
68        [] => "the selected face".to_string(),
69        [one] => format!("`{one}`"),
70        many => format!("the {} selected faces", many.len()),
71    }
72}
73
74/// Every \`backticked\` name in `message` that is not already in `faces` — the
75/// NEIGHBOURS a refusal names, which are the faces that could not follow the
76/// motion rather than the ones the user picked.
77fn neighbours(message: &str, faces: &[String]) -> Vec<String> {
78    let mut out: Vec<String> = Vec::new();
79    // `split('`')` alternates outside/inside starting outside, so the odd
80    // indices are the quoted spans.
81    for (index, span) in message.split('`').enumerate() {
82        if index % 2 == 1
83            && !span.is_empty()
84            && !faces.iter().any(|f| f == span)
85            && !out.iter().any(|n| n == span)
86        {
87            out.push(span.to_string());
88        }
89    }
90    out
91}
92
93/// Render `names` as `` `a` and `b` `` / `` `a`, `b` and `c` ``.
94fn and_list(names: &[String]) -> String {
95    match names {
96        [] => String::new(),
97        [one] => format!("`{one}`"),
98        [rest @ .., last] => format!(
99            "{} and `{last}`",
100            rest.iter()
101                .map(|n| format!("`{n}`"))
102                .collect::<Vec<_>>()
103                .join(", ")
104        ),
105    }
106}
107
108/// The face names in a Transform Face's `faces` param, as the kernel resolves
109/// them — re-exported here so the APP can name the selection in a refusal
110/// without taking a dependency on the kernel of its own.
111pub fn selected_face_names(faces: Option<&serde_json::Value>) -> Vec<String> {
112    brep_kernel::reference_names(faces)
113}
114
115/// What the run report says about a feature's refusal beyond its text: WHY,
116/// read off the kernel refusal's class and slug
117/// ([`brep_kernel::face_transform_reason`]), and whether the motion that
118/// refused was a TURN (`featureRefusals[id].step`, which the feature records).
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
120pub struct RefusalFacts {
121    pub reason: Option<brep_kernel::FaceTransformReason>,
122    pub turning: bool,
123}
124
125impl RefusalFacts {
126    /// The facts for feature `id` in a run report (`__brepReport` /
127    /// `history_report`): its `featureRefusals[id]` entry. A feature whose
128    /// refusal is text only — or a report from before the key existed — has no
129    /// entry, so no reason and no turn: the help says the kernel's words.
130    pub fn from_report(report: &serde_json::Value, id: &str) -> Self {
131        let Some(entry) = report.get("featureRefusals").and_then(|map| map.get(id)) else {
132            return Self::default();
133        };
134        let turning = entry.get("step").and_then(serde_json::Value::as_str) == Some("rotation");
135        let mut refusal = entry.clone();
136        if let serde_json::Value::Object(object) = &mut refusal {
137            object.remove("step");
138        }
139        let reason = serde_json::from_value::<brep_kernel::KernelRefusal>(refusal)
140            .ok()
141            .and_then(|refusal| brep_kernel::face_transform_reason(&refusal));
142        Self { reason, turning }
143    }
144}
145
146/// Explain a Transform Face refusal.
147///
148/// `message` is the kernel's text for the feature WITHOUT the `"<id>: "` prefix
149/// (what `feature_error_message` already returns); `facts` are the refusal's
150/// class-read reason and the refused motion ([`RefusalFacts::from_report`]) —
151/// the two DECISIONS, neither read from the text; `faces` is the feature's
152/// `params.faces`, which is where the selected face's name comes from — the
153/// kernel message does not carry it. The text is used only to say the kernel's
154/// own words when the reason is not one this help explains, and to name the
155/// neighbours the kernel named.
156pub fn explain_refusal(message: &str, facts: RefusalFacts, faces: &[String]) -> FaceTransformRefusal {
157    use brep_kernel::FaceTransformReason as Reason;
158    let kernel = message.to_string();
159    let subject = subject(faces);
160
161    // WHICH motion refused: the one the feature recorded on its result (a
162    // rotate-then-translate records the step that refused, so the user is told
163    // about the ring they turned or the arrow they pulled).
164    let motion = if facts.turning { "Rotate Face" } else { "Move Face" };
165    let turning = motion == "Rotate Face";
166    let motion_noun = if turning { "turn" } else { "move" };
167
168    // The neighbours the kernel named, said as what they are.
169    let blocked = neighbours(message, faces);
170    let by_neighbour = if blocked.is_empty() {
171        String::new()
172    } else {
173        format!(" {} could not follow it", and_list(&blocked))
174    };
175
176    // The refusals, by the reason the kernel's class gives.
177    let (reason, hint) = if facts.reason == Some(Reason::InsideOut) {
178        (
179            format!(
180                "That {motion_noun} carries {subject} clean through the far side of the body, \
181                 leaving it inside out."
182            ),
183            "Ease it back until the model follows again — while it refuses the feature builds \
184             nothing at all, so the face springs back to where it started."
185                .to_string(),
186        )
187    } else if facts.reason == Some(Reason::PastNeighbour) {
188        (
189            format!(
190                "That {motion_noun} carries {subject} past a face it has to meet, so{} \
191                 without turning a corner of the body inside out.",
192                if by_neighbour.is_empty() {
193                    " its neighbour could not follow it".to_string()
194                } else {
195                    by_neighbour
196                }
197            ),
198            if turning {
199                "Ease the angle back, or move the Pivot — the turn happens about it, so a pivot \
200                 nearer the blocked corner asks less of it."
201                    .to_string()
202            } else {
203                "Ease the distance back until the model follows again, or select the blocked \
204                 neighbours too so they move with it."
205                    .to_string()
206            },
207        )
208    } else if facts.reason == Some(Reason::TurnParallel) {
209        (
210            format!(
211                "That turn lays {subject} parallel to a face it has to meet, and two parallel \
212                 faces never make a corner."
213            ),
214            "Stop short of the angle that flattens it — while it refuses the feature builds \
215             nothing at all, so the face springs back unturned."
216                .to_string(),
217        )
218    } else if facts.reason == Some(Reason::RigidScale) {
219        (
220            format!("A face transform carries {subject} rigidly, so it cannot scale."),
221            "Clear the scale back to 1, 1, 1 and move or turn the face instead.".to_string(),
222        )
223    } else if facts.reason == Some(Reason::NoPivot) {
224        (
225            format!("{subject} has no geometry to turn about."),
226            "Pick the face again, or type a Pivot to turn about.".to_string(),
227        )
228    } else {
229        // UNRECOGNISED — a refusal whose class this help has no explanation for
230        // (or a text-only one): the kernel's own words, unchanged, which is what
231        // the app showed before this help existed.
232        (
233            kernel.clone(),
234            "Ease the value back until the model follows again — while it refuses the feature \
235             builds nothing at all, so the face springs back to where it started."
236                .to_string(),
237        )
238    };
239
240    FaceTransformRefusal {
241        motion,
242        reason,
243        hint,
244        kernel,
245    }
246}
247