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/// Explain a Transform Face refusal.
116///
117/// `message` is the kernel's text for the feature WITHOUT the `"<id>: "` prefix
118/// (what `feature_error_message` already returns); `faces` is the feature's
119/// `params.faces`, which is where the selected face's name comes from — the
120/// kernel message does not carry it.
121pub fn explain_refusal(message: &str, faces: &[String]) -> FaceTransformRefusal {
122    let kernel = message.to_string();
123    let lower = message.to_ascii_lowercase();
124    let subject = subject(faces);
125
126    // WHICH HALF of Transform Face refused. A combined motion names its step
127    // ("the ROTATION step of this rotate-then-translate motion"), and that
128    // naming is what decides it — the user turned a ring or pulled an arrow and
129    // must be told about the one they touched. Checked before the bare
130    // `rotate_faces` / `move_faces` test, because a combined message contains
131    // both words.
132    let motion = if lower.contains("rotation step") {
133        "Rotate Face"
134    } else if lower.contains("translation step") {
135        "Move Face"
136    } else if lower.contains("rotate_faces") || lower.contains("the rotation") {
137        "Rotate Face"
138    } else {
139        "Move Face"
140    };
141    let turning = motion == "Rotate Face";
142    let motion_noun = if turning { "turn" } else { "move" };
143
144    // The neighbours the kernel named, said as what they are.
145    let blocked = neighbours(message, faces);
146    let by_neighbour = if blocked.is_empty() {
147        String::new()
148    } else {
149        format!(" {} could not follow it", and_list(&blocked))
150    };
151
152    // The refusals, by the phrase each is built on. The ORDER matters: the
153    // whole-solid inversion is the more specific reading of an "inverts", so it
154    // is tested first.
155    let (reason, hint) = if lower.contains("signed volume changed sign")
156        || lower.contains("inverts the solid")
157    {
158        (
159            format!(
160                "That {motion_noun} carries {subject} clean through the far side of the body, \
161                 leaving it inside out."
162            ),
163            "Ease it back until the model follows again — while it refuses the feature builds \
164             nothing at all, so the face springs back to where it started."
165                .to_string(),
166        )
167    } else if lower.contains("inverts edge") || lower.contains("passes beyond its neighbour") {
168        (
169            format!(
170                "That {motion_noun} carries {subject} past a face it has to meet, so{} \
171                 without turning a corner of the body inside out.",
172                if by_neighbour.is_empty() {
173                    " its neighbour could not follow it".to_string()
174                } else {
175                    by_neighbour
176                }
177            ),
178            if turning {
179                "Ease the angle back, or move the Pivot — the turn happens about it, so a pivot \
180                 nearer the blocked corner asks less of it."
181                    .to_string()
182            } else {
183                "Ease the distance back until the model follows again, or select the blocked \
184                 neighbours too so they move with it."
185                    .to_string()
186            },
187        )
188    } else if lower.contains("parallel") {
189        (
190            format!(
191                "That turn lays {subject} parallel to a face it has to meet, and two parallel \
192                 faces never make a corner."
193            ),
194            "Stop short of the angle that flattens it — while it refuses the feature builds \
195             nothing at all, so the face springs back unturned."
196                .to_string(),
197        )
198    } else if lower.contains("transform.scale") {
199        (
200            format!("A face transform carries {subject} rigidly, so it cannot scale."),
201            "Clear the scale back to 1, 1, 1 and move or turn the face instead.".to_string(),
202        )
203    } else if lower.contains("centre a pivot on") || lower.contains("center a pivot on") {
204        (
205            format!("{subject} has no geometry to turn about."),
206            "Pick the face again, or type a Pivot to turn about.".to_string(),
207        )
208    } else {
209        // UNRECOGNISED — the kernel's own words, unchanged. This is the branch
210        // that makes a reworded refusal degrade to what the app showed before
211        // rather than to nothing.
212        (
213            kernel.clone(),
214            "Ease the value back until the model follows again — while it refuses the feature \
215             builds nothing at all, so the face springs back to where it started."
216                .to_string(),
217        )
218    };
219
220    FaceTransformRefusal {
221        motion,
222        reason,
223        hint,
224        kernel,
225    }
226}
227