Skip to main content

brep_render/engine_state/
batch.rs

1//! Native batch authoring. Validation works on a cloned recipe; execution uses
2//! a native runner barrier so the transaction cannot expose pending results.
3use super::*;
4use serde_json::{json, Value};
5use std::collections::{BTreeMap, BTreeSet};
6
7fn alias_ref(text: &str) -> Option<(&str, &str)> {
8    let rest = text.strip_prefix('@')?;
9    let n = rest.find(['/', ':']).unwrap_or(rest.len());
10    Some((&rest[..n], &rest[n..]))
11}
12
13/// Validate reference grammar without requiring an output to exist yet.
14fn validate_reference(text: &str, aliases: &BTreeMap<String, String>) -> Result<(), String> {
15    if let Some((alias, suffix)) = alias_ref(text) {
16        if !aliases.contains_key(alias) {
17            return Err(format!("missing alias @{alias}"));
18        }
19        if !suffix.is_empty()
20            && !suffix.starts_with(':')
21            && suffix
22                .strip_prefix('/')
23                .and_then(|s| s.split_once('/'))
24                .filter(|(kind, _)| matches!(*kind, "output" | "face" | "edge"))
25                .and_then(|(_, index)| index.parse::<usize>().ok())
26                .is_none()
27        {
28            return Err(format!("invalid reference {text}"));
29        }
30    }
31    Ok(())
32}
33
34fn validate_references(
35    value: &Value,
36    aliases: &BTreeMap<String, String>,
37    path: &str,
38) -> Result<(), String> {
39    let mut refs = Vec::new();
40    strings(value, &mut refs);
41    for reference in refs {
42        validate_reference(&reference, aliases).map_err(|error| format!("{path}: {error}"))?;
43    }
44    Ok(())
45}
46
47fn strings(value: &Value, out: &mut Vec<String>) {
48    match value {
49        Value::String(s) => out.push(s.clone()),
50        Value::Array(a) => a.iter().for_each(|v| strings(v, out)),
51        Value::Object(o) => o.values().for_each(|v| strings(v, out)),
52        _ => (),
53    }
54}
55
56fn resolve(
57    value: &mut Value,
58    aliases: &BTreeMap<String, String>,
59    outputs: &BTreeMap<String, Vec<String>>,
60) -> Result<(), String> {
61    match value {
62        Value::String(s) => {
63            if let Some((alias, suffix)) = alias_ref(s) {
64                let id = aliases
65                    .get(alias)
66                    .ok_or_else(|| format!("missing alias @{alias}"))?;
67                *s = if let Some((kind, index)) =
68                    suffix.strip_prefix('/').and_then(|s| s.split_once('/'))
69                {
70                    if !matches!(kind, "output" | "face" | "edge") {
71                        return Err(format!("invalid output kind in {s}"));
72                    }
73                    let index: usize = index
74                        .parse()
75                        .map_err(|_| format!("invalid output index in {s}"))?;
76                    let key = if kind == "output" {
77                        id.clone()
78                    } else {
79                        format!("{id}/{kind}")
80                    };
81                    outputs
82                        .get(&key)
83                        .and_then(|o| o.get(index))
84                        .cloned()
85                        .ok_or_else(|| format!("{kind} {index} of @{alias} was not generated"))?
86                } else if suffix.is_empty() || suffix.starts_with(':') {
87                    format!("{id}{suffix}")
88                } else {
89                    return Err(format!("invalid alias reference {s}"));
90                };
91            }
92        }
93        Value::Array(a) => {
94            for v in a {
95                resolve(v, aliases, outputs)?;
96            }
97        }
98        Value::Object(o) => {
99            for v in o.values_mut() {
100                resolve(v, aliases, outputs)?;
101            }
102        }
103        _ => (),
104    }
105    Ok(())
106}
107
108fn merge(base: &mut Value, patch: &Value) {
109    if let (Some(b), Some(p)) = (base.as_object_mut(), patch.as_object()) {
110        for (k, v) in p {
111            if let Some(old) = b.get_mut(k) {
112                merge(old, v);
113            } else {
114                b.insert(k.clone(), v.clone());
115            }
116        }
117    } else {
118        *base = patch.clone();
119    }
120}
121
122fn prepare(
123    catalogue: &Value,
124    items: &[Value],
125    history: &mut History,
126    aliases: &mut BTreeMap<String, String>,
127) -> Result<Vec<Value>, String> {
128    let mut ids: BTreeSet<String> = history
129        .features()
130        .iter()
131        .filter_map(|f| f["inputParams"]["id"].as_str().map(str::to_owned))
132        .collect();
133    ids.extend(aliases.values().cloned());
134    let mut features = Vec::new();
135    for (i, item) in items.iter().enumerate() {
136        let ty = item["type"]
137            .as_str()
138            .ok_or_else(|| format!("features[{i}].type is required"))?;
139        let schema = catalogue["features"].as_array().and_then(|fs| fs.iter().find(|f| f["type"] == ty || (!f["type"].as_str().unwrap_or("").contains('/') && f["shortName"] == ty))).cloned()
140            .ok_or_else(|| format!("features[{i}].type: unknown type {ty}"))?;
141        let mut params = json!({});
142        if let Some(properties) = schema["inputParamsSchema"].as_object() {
143            for (key, spec) in properties { params[key] = spec["default_value"].clone(); }
144        }
145        params["id"] = Value::Null;
146        let patch = item
147            .get("inputParams")
148            .or_else(|| item.get("params"))
149            .cloned()
150            .unwrap_or(json!({}));
151        let object = patch
152            .as_object()
153            .ok_or_else(|| format!("features[{i}].params must be an object"))?;
154        for key in object.keys() {
155            if params.get(key).is_none()
156                && !(super::components::is_acomp_feature_type(ty) && key == "occurrenceAttributes")
157            {
158                return Err(format!("features[{i}].params.{key}: unknown field"));
159            }
160        }
161        merge(&mut params, &patch);
162        let id = item["id"]
163            .as_str()
164            .or_else(|| params["id"].as_str())
165            .map(str::to_owned)
166            .unwrap_or_else(|| loop {
167                let id = history.next_feature_id(schema["shortName"].as_str().unwrap_or(ty));
168                if !ids.contains(&id) {
169                    break id;
170                }
171            });
172        if id.is_empty() || !ids.insert(id.clone()) {
173            return Err(format!("features[{i}].id: empty or duplicate id `{id}`"));
174        }
175        if matches!(ty, "ACOMP" | "ASSEMBLY COMPONENT") && !brep_kernel::is_component_reference(&id)
176        {
177            return Err(format!(
178                "features[{i}].id: component ids must be ACOMP<digits>"
179            ));
180        }
181        params["id"] = json!(id);
182        if super::components::is_acomp_feature_type(ty) {
183            if !object.contains_key("isFixed") {
184                params.as_object_mut().unwrap().remove("isFixed");
185            }
186            if let Some(attributes) = item.get("attributes") {
187                if !attributes.is_object() {
188                    return Err(format!("features[{i}].attributes must be an object"));
189                }
190                merge(&mut params["occurrenceAttributes"], attributes);
191            }
192            if let Some(name) = item.get("display_name") {
193                params["occurrenceAttributes"]["Name"] = name.clone();
194            }
195            if let Some(material) = item.get("material") {
196                params["occurrenceAttributes"]["Material"] = material.clone();
197            }
198        }
199        if let Some(alias) = item.get("alias") {
200            let alias = alias
201                .as_str()
202                .filter(|s| !s.is_empty() && !s.contains(['@', '/', ':']))
203                .ok_or_else(|| format!("features[{i}].alias: invalid alias"))?;
204            if aliases.insert(alias.into(), id.clone()).is_some() {
205                return Err(format!("features[{i}].alias: duplicate alias {alias}"));
206            }
207        }
208        features.push(json!({"type":schema["type"], "inputParams":params, "persistentData":item.get("persistent_data").or_else(|| item.get("persistentData")).cloned().unwrap_or(json!({}))}));
209    }
210    Ok(features)
211}
212
213/// Stable topological order; dependencies include explicit `depends_on` aliases
214/// and alias references anywhere in parameters or persistent data.
215fn plan(
216    items: &[Value],
217    features: &[Value],
218    aliases: &BTreeMap<String, String>,
219) -> Result<(Vec<usize>, Vec<Vec<usize>>), String> {
220    let positions: BTreeMap<&str, usize> = features
221        .iter()
222        .enumerate()
223        .map(|(i, f)| (f["inputParams"]["id"].as_str().unwrap(), i))
224        .collect();
225    let mut deps = vec![Vec::new(); features.len()];
226    for (i, feature) in features.iter().enumerate() {
227        let mut refs = Vec::new();
228        strings(&feature["inputParams"], &mut refs);
229        strings(&feature["persistentData"], &mut refs);
230        if let Some(explicit) = items[i].get("depends_on") {
231            let a = explicit
232                .as_array()
233                .ok_or_else(|| format!("features[{i}].depends_on must be an array"))?;
234            for r in a {
235                refs.push(format!(
236                    "@{}",
237                    r.as_str().ok_or("depends_on entries must be aliases")?
238                ));
239            }
240        }
241        for r in refs {
242            validate_reference(&r, aliases).map_err(|error| format!("features[{i}]: {error}"))?;
243            if let Some((a, _)) = alias_ref(&r) {
244                let id = &aliases[a];
245                if let Some(&p) = positions.get(id.as_str()) {
246                    deps[i].push(p);
247                }
248            }
249        }
250        deps[i].sort_unstable();
251        deps[i].dedup();
252    }
253    let mut order = Vec::new();
254    while order.len() < features.len() {
255        let Some(i) = (0..features.len())
256            .find(|i| !order.contains(i) && deps[*i].iter().all(|d| order.contains(d)))
257        else {
258            return Err(format!(
259                "dependency cycle among feature positions {:?}",
260                (0..features.len())
261                    .filter(|i| !order.contains(i))
262                    .collect::<Vec<_>>()
263            ));
264        };
265        order.push(i);
266    }
267    Ok((order, deps))
268}
269
270fn failed(report: &Value) -> bool {
271    report.get("error").is_some()
272        || [
273            "featureErrors",
274            "displayErrors",
275            "unresolved",
276            "featureFulfilment",
277        ]
278        .iter()
279        .any(|key| match &report[*key] {
280            Value::Object(o) => !o.is_empty(),
281            Value::Array(a) => !a.is_empty(),
282            _ => false,
283        })
284}
285
286fn refusal_category(refusal: &Value) -> &'static str {
287    match refusal["class"].as_str() {
288        Some("degenerate_arrangement" | "non_integral_genus" | "invalid_result_topology") => {
289            "invalid_topology"
290        }
291        Some("tangent_node_singularity" | "conservative_empty_overlap") => "contact_related",
292        Some("non_convergence") => "numerical_nonconvergence",
293        Some("invalid_input") => "invalid_input",
294        Some("unsupported_geometry") => "unsupported_geometry",
295        _ => "geometry_execution_failure",
296    }
297}
298
299impl EngineState {
300    /// Full diagnostics from the last batch, including a rolled-back attempt.
301    pub fn geometry_diagnostics(&self, id: &str) -> Option<Value> {
302        self.geometry_diagnostics.get(id).cloned()
303    }
304
305    /// Wait on the installed native runner, preserving its resident geometry
306    /// and delta baseline. A transaction is one app command, so other commands
307    /// cannot observe its intermediate recipes. Kernel operations are not
308    /// preemptible inside a feature.
309    fn finish_batch_run(&mut self) {
310        while self.run_pending() {
311            self.pump();
312            #[cfg(not(target_arch = "wasm32"))]
313            if self.run_pending() {
314                std::thread::sleep(std::time::Duration::from_millis(1));
315            }
316        }
317    }
318
319    /// Full-document rollback includes recipe, library, metadata, counters and
320    /// undo/redo. Runtime caches and the generation counter are deliberately
321    /// invalidated, not rewound. No file, network or document-tab side effects.
322    pub fn author_batch(&mut self, args: &Value) -> Result<Value, String> {
323        if cfg!(target_arch = "wasm32") {
324            return Err("structured native authoring requires a native MCP host".into());
325        }
326        if self.run_pending()
327            || self.queries_pending()
328            || self.mesh_imports_pending()
329            || self.topology_pending()
330        {
331            return Err("wait for pending engine work before authoring a batch".into());
332        }
333        if let Some(reason) = self.history.locked() {
334            return Err(format!("document is locked: {reason}"));
335        }
336        let items = args["features"]
337            .as_array()
338            .ok_or("features must be an array")?;
339        let rollback = args["rollback_on_error"].as_bool().unwrap_or(false);
340        let before = self.history.clone();
341        let metadata = self.metadata.clone();
342        let library = brep_kernel::parts_library_map();
343        let mut staged = before.clone();
344        let mut document: Value =
345            serde_json::from_str(&staged.request_json()).map_err(|e| e.to_string())?;
346        let mut aliases = BTreeMap::new();
347        let mut part_aliases = BTreeMap::new();
348        let mut part_work = Vec::new();
349        let mut constraint_ids = Vec::new();
350        if let Some(parts) = args.get("parts") {
351            for (i, part) in parts
352                .as_array()
353                .ok_or("parts must be an array")?
354                .iter()
355                .enumerate()
356            {
357                let alias = part["alias"]
358                    .as_str()
359                    .filter(|s| !s.is_empty() && !s.contains(['@', '/', ':']))
360                    .ok_or_else(|| format!("parts[{i}].alias is required"))?;
361                if aliases.insert(alias.into(), alias.into()).is_some()
362                    || document["partsLibrary"].get(alias).is_some()
363                {
364                    return Err(format!("parts[{i}]: duplicate part {alias}"));
365                }
366                let mut doc = part["document"].clone();
367                let native = doc["features"]
368                    .as_array()
369                    .ok_or_else(|| format!("parts[{i}].document.features must be an array"))?
370                    .clone();
371                let mut allocation_base = doc.clone();
372                allocation_base["features"] = json!([]);
373                let mut part_history = History::from_request_json(&allocation_base.to_string())?;
374                let mut local_aliases = BTreeMap::new();
375                let mut scope = EngineState::new();
376                scope.inherit_plugins(self);
377                scope.history = part_history.clone();
378                let mut prepared = prepare(&scope.feature_catalogue(), &native, &mut part_history, &mut local_aliases)
379                    .map_err(|e| format!("parts[{i}].document.{e}"))?;
380                let _ = plan(&native, &prepared, &local_aliases)
381                    .map_err(|e| format!("parts[{i}].document.{e}"))?;
382                for (index, f) in prepared.iter_mut().enumerate() {
383                    for key in ["alias", "depends_on"] {
384                        if let Some(value) = native[index].get(key) {
385                            f[key] = value.clone();
386                        }
387                    }
388                }
389                part_aliases.insert(alias.to_owned(), local_aliases);
390                // Keep client order for result positions; the isolated batch
391                // below executes its own dependency plan.
392                doc["features"] = json!(prepared);
393                doc["featureCounter"] = serde_json::from_str::<Value>(&part_history.request_json())
394                    .unwrap_or_default()["featureCounter"]
395                    .clone();
396                part_work.push((alias.to_owned(), doc.clone()));
397                document["partsLibrary"][alias] = json!({"sourceKey":alias,"sourceSignature":"batch", "document":doc,"snapshot":""});
398            }
399        }
400        let mut features = prepare(&self.feature_catalogue(), items, &mut staged, &mut aliases)?;
401        let (order, deps) = plan(items, &features, &aliases)?;
402        document["featureCounter"] = serde_json::from_str::<Value>(&staged.request_json())
403            .unwrap_or_default()["featureCounter"]
404            .clone();
405        if let Some(expressions) = args.get("expressions") {
406            if !expressions.is_string() {
407                return Err("expressions must be a script string".into());
408            }
409            document["expressions"] = expressions.clone();
410        }
411        // Constraints use the native catalogue and descriptor shape.
412        if let Some(constraints) = args.get("constraints") {
413            let catalogue = brep_kernel::constraint_schema_catalogue();
414            let mut counter = document["assembly"]["idCounter"].as_u64().unwrap_or(0);
415            for (i, constraint) in constraints
416                .as_array()
417                .ok_or("constraints must be an array")?
418                .iter()
419                .enumerate()
420            {
421                let ty = constraint["type"]
422                    .as_str()
423                    .ok_or("constraint type is required")?;
424                let entry = catalogue
425                    .as_array()
426                    .and_then(|a| a.iter().find(|e| e["type"].as_str() == Some(ty)))
427                    .ok_or_else(|| format!("constraints[{i}].type: unknown constraint {ty}"))?;
428                counter += 1;
429                let id = format!("{}{}", entry["shortName"].as_str().unwrap_or("C"), counter);
430                let mut params = json!({});
431                if let Some(schema) = entry["inputParamsSchema"].as_object() {
432                    for (k, v) in schema {
433                        params[k] = v["default_value"].clone();
434                    }
435                }
436                merge(
437                    &mut params,
438                    constraint
439                        .get("params")
440                        .or_else(|| constraint.get("inputParams"))
441                        .unwrap_or(&json!({})),
442                );
443                for key in params
444                    .as_object()
445                    .ok_or_else(|| format!("constraints[{i}].params must be an object"))?
446                    .keys()
447                {
448                    if entry["inputParamsSchema"].get(key).is_none() {
449                        return Err(format!("constraints[{i}].params.{key}: unknown field"));
450                    }
451                }
452                constraint_ids.push(id.clone());
453                params["id"] = json!(id);
454                validate_references(&params, &aliases, &format!("constraints[{i}].params"))?;
455                if let Some(a) = constraint["alias"].as_str() {
456                    if aliases.insert(a.into(), id).is_some() {
457                        return Err(format!("constraints[{i}].alias is duplicate"));
458                    }
459                }
460                if !document["assembly"]["constraints"].is_array() {
461                    document["assembly"]["constraints"] = json!([]);
462                }
463                let mut persistent = constraint
464                    .get("persistent_data")
465                    .or_else(|| constraint.get("persistentData"))
466                    .cloned()
467                    .unwrap_or(json!({}));
468                validate_references(
469                    &persistent,
470                    &aliases,
471                    &format!("constraints[{i}].persistentData"),
472                )?;
473                let explicit = constraint
474                    .get("params")
475                    .or_else(|| constraint.get("inputParams"));
476                // Interactive creation adopts a measurement on its first solve.
477                // A batch's explicitly supplied target already expresses intent.
478                for (field, flag) in [
479                    ("distance", "initializedDistance"),
480                    ("angle", "initializedAngle"),
481                ] {
482                    if explicit.is_some_and(|p| p.get(field).is_some()) {
483                        persistent
484                            .as_object_mut()
485                            .ok_or_else(|| {
486                                format!("constraints[{i}].persistent_data must be an object")
487                            })?
488                            .entry(flag)
489                            .or_insert(json!(true));
490                    }
491                }
492                document["assembly"]["constraints"].as_array_mut().unwrap().push(json!({"type":ty,"inputParams":params,"persistentData":persistent,"enabled":constraint.get("enabled").cloned().unwrap_or(json!(true))}));
493            }
494            document["assembly"]["idCounter"] = json!(counter);
495        }
496        if let Some(patch) = args.get("metadata") {
497            let map = patch.as_object().ok_or("metadata must be an object")?;
498            for key in map.keys() {
499                if let Some((a, _)) = alias_ref(key) {
500                    if !aliases.contains_key(a) {
501                        return Err(format!("metadata.{key}: missing alias @{a}"));
502                    }
503                }
504            }
505        }
506        // Parse the entire proposed request before touching any live state.
507        let mut validation = document.clone();
508        validation["features"]
509            .as_array_mut()
510            .ok_or("document features missing")?
511            .extend(features.clone());
512        serde_json::from_value::<HistoryRequest>(validation)
513            .map_err(|e| format!("batch validation: {e}"))?;
514        self.geometry_diagnostics.clear();
515        let run = (|| -> Result<Value, String> {
516            // Compile each native definition in isolation first. This resolves
517            // generated-output aliases inside part histories too, without
518            // turning the part into an imported/baked solid.
519            let mut part_results = Vec::new();
520            for (position, (alias, source)) in part_work.iter().enumerate() {
521                let mut base = source.clone();
522                let part_features = base["features"].take();
523                base["features"] = json!([]);
524                base.as_object_mut().unwrap().remove("metadata");
525                let mut part = EngineState::new();
526                part.inherit_plugins(self);
527                part.set_history_json(&base.to_string())?;
528                let mut part_args = json!({"features":part_features,"rollback_on_error":true});
529                if let Some(metadata) = source.get("metadata") {
530                    part_args["metadata"] = metadata.clone();
531                }
532                let mut evaluated = part.author_batch(&part_args)?;
533                for (id, details) in &part.geometry_diagnostics {
534                    self.geometry_diagnostics
535                        .insert(format!("part:{alias}:{id}"), details.clone());
536                }
537                for item in evaluated["items"].as_array_mut().into_iter().flatten() {
538                    if let Some(id) = item["diagnostic"]["detailId"].as_str().map(str::to_owned) {
539                        item["diagnostic"]["detailId"] = json!(format!("part:{alias}:{id}"));
540                    }
541                }
542                part_results.push(json!({"position":position,"alias":alias,"id":alias,"status":if evaluated["success"] == true {"evaluated"} else {"failed"},"committed":false,"items":evaluated["items"],"outputs":evaluated["outputs"]}));
543                if evaluated["success"] != true {
544                    for (i, (remaining, _)) in part_work.iter().enumerate().skip(position + 1) {
545                        part_results.push(json!({"position":i,"alias":remaining,"id":remaining,"status":"not_evaluated","committed":false}));
546                    }
547                    let pending: Vec<Value> = items.iter().enumerate().map(|(i,item)| json!({"position":i,"alias":item["alias"],"id":features[i]["inputParams"]["id"],"status":"not_evaluated","committed":false,"dependencyFailure":{"part":alias}})).collect();
548                    return Ok(
549                        json!({"success":false,"requiresRestore":true,"items":pending,"partResults":part_results,"constraintResults":[],"aliases":aliases,"partAliases":part_aliases,"diagnosticsTool":"geometry_diagnostics"}),
550                    );
551                }
552                document["partsLibrary"][alias]["document"] =
553                    serde_json::from_str::<Value>(&part.history_request_json())
554                        .map_err(|e| e.to_string())?;
555            }
556            // Delay constraints until every instance exists.
557            let constraints = document.get("assembly").cloned();
558            document.as_object_mut().unwrap().remove("assembly");
559            self.edit_document_json(&document.to_string())?;
560            self.finish_batch_run();
561            let prepared_library =
562                serde_json::from_value(document.get("partsLibrary").cloned().unwrap_or(json!({})))
563                    .map_err(|e| format!("partsLibrary: {e}"))?;
564            let mut outputs: BTreeMap<String, Vec<String>> = BTreeMap::new();
565            let mut results = vec![Value::Null; items.len()];
566            let mut failed_items = BTreeSet::new();
567            for i in order {
568                let id = features[i]["inputParams"]["id"]
569                    .as_str()
570                    .unwrap()
571                    .to_string();
572                let blocked: Vec<usize> = deps[i]
573                    .iter()
574                    .filter(|d| failed_items.contains(*d))
575                    .copied()
576                    .collect();
577                let mut result = json!({"position":i,"alias":items[i]["alias"],"id":id,"committed":false,"dependencies":deps[i]});
578                if !blocked.is_empty() {
579                    failed_items.insert(i);
580                    result["status"] = json!("not_evaluated");
581                    result["dependencyFailures"] = json!(blocked);
582                    results[i] = result;
583                    continue;
584                }
585                if let Err(error) = resolve(&mut features[i], &aliases, &outputs) {
586                    failed_items.insert(i);
587                    result["status"] = json!("failed");
588                    result["error"] = json!({"category":"reference_resolution","message":error,"path":format!("features[{i}]")});
589                    results[i] = result;
590                    continue;
591                }
592                brep_kernel::install_parts_library(&prepared_library);
593                self.history.set_parts_library(
594                    serde_json::to_value(brep_kernel::parts_library_map())
595                        .map_err(|e| e.to_string())?,
596                );
597                self.add_features(&[features[i].clone()]);
598                self.finish_batch_run();
599                let report: Value =
600                    serde_json::from_str(&self.history_report_json()).map_err(|e| e.to_string())?;
601                let names: Vec<String> =
602                    serde_json::from_value(report["featureOutputs"][&id].clone())
603                        .unwrap_or_default();
604                outputs.insert(id.clone(), names.clone());
605                let errors: Vec<Value> = report["featureErrors"]
606                    .as_array()
607                    .into_iter()
608                    .flatten()
609                    .filter(|e| e.as_str().is_some_and(|e| e.starts_with(&format!("{id}:"))))
610                    .cloned()
611                    .collect();
612                let has_error = !errors.is_empty()
613                    || report["featureFulfilment"].get(&id).is_some()
614                    || report.get("error").is_some();
615                if has_error {
616                    failed_items.insert(i);
617                }
618                result["status"] = json!(if has_error {
619                    "failed"
620                } else if report["featureTimings"].get(&id).is_some() {
621                    "evaluated"
622                } else {
623                    "not_evaluated"
624                });
625                if result["status"] == "not_evaluated" {
626                    failed_items.insert(i);
627                }
628                result["generatedSolids"] = json!(names);
629                self.geometry_diagnostics.insert(id.clone(), json!({"errors":errors,"refusal":report["featureRefusals"][&id],"unresolved":report["unresolved"],"fulfilment":report["featureFulfilment"][&id],"approximations":report["featureApproximations"][&id],"operation":features[i]["type"],"operands":features[i]["inputParams"],"dependencies":deps[i]}));
630                result["error"] = json!(errors
631                    .iter()
632                    .filter_map(Value::as_str)
633                    .map(|s| s.chars().take(240).collect::<String>())
634                    .collect::<Vec<_>>());
635                let mut references = Vec::new();
636                for name in &names {
637                    if let Some(solid) = self.scene.solid(name) {
638                        references.push(json!({"solid":name,"faces":solid.faces.iter().map(|f| &f.name).collect::<Vec<_>>(),"edges":solid.edges.iter().map(|e| &e.name).collect::<Vec<_>>()}));
639                    }
640                }
641                outputs.insert(
642                    format!("{id}/face"),
643                    references
644                        .iter()
645                        .flat_map(|r| r["faces"].as_array().into_iter().flatten())
646                        .filter_map(Value::as_str)
647                        .map(str::to_owned)
648                        .collect(),
649                );
650                outputs.insert(
651                    format!("{id}/edge"),
652                    references
653                        .iter()
654                        .flat_map(|r| r["edges"].as_array().into_iter().flatten())
655                        .filter_map(Value::as_str)
656                        .map(str::to_owned)
657                        .collect(),
658                );
659                result["generatedReferences"] = json!(references);
660                let schema =
661                    self.feature_schema(features[i]["type"].as_str().unwrap_or(""))
662                        .unwrap_or_default();
663                let operands: serde_json::Map<String, Value> = features[i]["inputParams"]
664                    .as_object()
665                    .into_iter()
666                    .flat_map(|o| o.iter())
667                    .filter(|(k, _)| {
668                        matches!(
669                            schema["inputParamsSchema"][*k]["type"].as_str(),
670                            Some("reference_selection" | "boolean_operation")
671                        ) || k.as_str() == "partName"
672                    })
673                    .map(|(k, v)| (k.clone(), v.clone()))
674                    .collect();
675                result["diagnostic"] = json!({"operation":features[i]["type"],"owningComponent": if super::components::is_acomp_feature_type(features[i]["type"].as_str().unwrap_or("")) { Some(&id) } else { None }, "operands":operands,"affectedTopology":report["featureRefusals"][&id]["faces"],"category":if has_error { Some(refusal_category(&report["featureRefusals"][&id])) } else { None },"kernelClass":report["featureRefusals"][&id]["class"],"detailId":id});
676                result["committed"] = json!(true);
677                results[i] = result;
678            }
679            let mut constraint_errors = BTreeMap::new();
680            if let Some(mut constraints) = constraints {
681                if let Some(entries) = constraints["constraints"].as_array_mut() {
682                    entries.retain_mut(|entry| {
683                        let id = entry["inputParams"]["id"].as_str().unwrap_or("").to_owned();
684                        if let Err(error) = resolve(entry, &aliases, &outputs) {
685                            constraint_errors.insert(id, error);
686                            false
687                        } else {
688                            true
689                        }
690                    });
691                }
692                let mut final_doc: Value = serde_json::from_str(&self.history.request_json())
693                    .map_err(|e| e.to_string())?;
694                final_doc["assembly"] = constraints;
695                self.edit_document_json(&final_doc.to_string())?;
696                self.finish_batch_run();
697            }
698            if let Some(patch) = args.get("metadata").and_then(Value::as_object) {
699                let mut merged = self.metadata.to_json();
700                for (key, record) in patch {
701                    let mut name = json!(key);
702                    resolve(&mut name, &aliases, &outputs)?;
703                    merge(&mut merged[name.as_str().unwrap()], record);
704                }
705                self.metadata.load_json(Some(&merged));
706                self.sync_colors_from_metadata();
707            }
708            let final_report: Value =
709                serde_json::from_str(&self.history_report_json()).map_err(|e| e.to_string())?;
710            let statuses = self.assembly_statuses_value();
711            let constraint_failure = statuses.as_array().is_some_and(|a| {
712                a.iter()
713                    .any(|s| s["enabled"] != false && s["satisfied"] == false)
714            });
715            let success = failed_items.is_empty()
716                && !failed(&final_report)
717                && !constraint_failure
718                && constraint_errors.is_empty();
719            let constraint_results: Vec<Value> = constraint_ids.iter().enumerate().map(|(i,id)| {
720                let row = statuses.as_array().and_then(|a| a.iter().find(|r| r["id"].as_str() == Some(id)));
721                let state = if constraint_errors.contains_key(id) || row.is_none() || row.is_some_and(|r| r["enabled"] == false) { "not_evaluated" } else if row.is_some_and(|r| r["satisfied"] == false) { "failed" } else { "evaluated" };
722                json!({"position":i,"alias":args["constraints"][i]["alias"],"id":id,"status":state,"committed":!constraint_errors.contains_key(id),"error":constraint_errors.get(id),"diagnostics":row,"dependencyFailures":if constraint_errors.contains_key(id) { failed_items.iter().copied().collect::<Vec<_>>() } else { vec![] }})
723            }).collect();
724            // Keep explicitly supplied definitions, including ones not yet
725            // instantiated. Normal library GC still applies on later edits.
726            let mut retained_library = brep_kernel::parts_library_map();
727            for (alias, _) in &part_work {
728                if let Some(entry) = prepared_library.get(alias) {
729                    retained_library
730                        .entry(alias.clone())
731                        .or_insert_with(|| entry.clone());
732                }
733            }
734            brep_kernel::install_parts_library(&retained_library);
735            self.history.set_parts_library(
736                serde_json::to_value(&retained_library).map_err(|e| e.to_string())?,
737            );
738            for result in &mut part_results {
739                result["committed"] = json!(true);
740            }
741            self.geometry_diagnostics
742                .insert("__report__".into(), final_report);
743            Ok(
744                json!({"success":success,"items":results,"partResults":part_results,"constraintResults":constraint_results,"aliases":aliases,"partAliases":part_aliases,"outputs":outputs,"diagnosticsTool":"geometry_diagnostics","constraintStatuses":statuses,"dof":self.assembly_dof_value()}),
745            )
746        })();
747        let restore = run
748            .as_ref()
749            .map(|r| r["requiresRestore"] == true || (rollback && r["success"] != true))
750            .unwrap_or(true);
751        if restore {
752            self.history = before.clone();
753            self.metadata = metadata.clone();
754            brep_kernel::install_parts_library(&Default::default());
755            brep_kernel::install_parts_library(&library);
756            self.rerun_history();
757            self.finish_batch_run();
758            // The restoration rebuild may heal/GC library entries or fold a
759            // solver pose. Preserve the exact pre-transaction authored state.
760            self.history = before;
761            self.metadata = metadata;
762            brep_kernel::install_parts_library(&Default::default());
763            brep_kernel::install_parts_library(&library);
764        } else {
765            // Collapse the staged rebuilds into a single user undo checkpoint.
766            let final_document = self.history.request_json();
767            self.history = before;
768            self.history.adopt_document_checkpointed(&final_document)?;
769            self.history.retain_feature_allocations(&staged);
770            self.history
771                .set_rollback(self.history.len().saturating_sub(1));
772        }
773        let mut result = run?;
774        result.as_object_mut().unwrap().remove("requiresRestore");
775        result["rolledBack"] = json!(restore);
776        result["transaction"] = json!({"validationAtomic":true,"geometryAtomic":rollback,"idCountersRolledBack":restore,"runtimeGenerationRolledBack":false});
777        if restore {
778            fn uncommit(value: &mut Value) {
779                match value {
780                    Value::Object(object) => {
781                        if object.contains_key("committed") {
782                            object.insert("committed".into(), json!(false));
783                        }
784                        for value in object.values_mut() {
785                            uncommit(value);
786                        }
787                    }
788                    Value::Array(array) => {
789                        for value in array {
790                            uncommit(value);
791                        }
792                    }
793                    _ => (),
794                }
795            }
796            uncommit(&mut result);
797        }
798        Ok(result)
799    }
800}
801
802/// Transform strings are expressions, including strings nested in vec3s.
803pub(super) fn contains_expression(value: &Value) -> bool {
804    match value {
805        Value::String(_) => true,
806        Value::Array(a) => a.iter().any(contains_expression),
807        Value::Object(o) => o.values().any(contains_expression),
808        _ => false,
809    }
810}
811
812
813
814
815