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