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