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            // Programmatic payloads a feature's form never shows but its
156            // builder reads: ACOMP's occurrence attributes, and IMPORT3D's
157            // `stepText` (the STEP document an import bakes in — undeclared
158            // so no form renders it, kernel `import3d::schema`).
159            if params.get(key).is_none()
160                && !(super::components::is_acomp_feature_type(ty)
161                    && (key == "occurrenceAttributes" || key == super::bom::OCCURRENCE_ATTRIBUTES))
162                && !(ty == "IMPORT3D" && key == "stepText")
163            {
164                return Err(format!("features[{i}].params.{key}: unknown field"));
165            }
166        }
167        merge(&mut params, &patch);
168        let id = item["id"]
169            .as_str()
170            .or_else(|| params["id"].as_str())
171            .map(str::to_owned)
172            .unwrap_or_else(|| loop {
173                let id = history.next_feature_id(schema["shortName"].as_str().unwrap_or(ty));
174                if !ids.contains(&id) {
175                    break id;
176                }
177            });
178        if id.is_empty() || !ids.insert(id.clone()) {
179            return Err(format!("features[{i}].id: empty or duplicate id `{id}`"));
180        }
181        if matches!(ty, "ACOMP" | "ASSEMBLY COMPONENT") && !brep_kernel::is_component_reference(&id)
182        {
183            return Err(format!(
184                "features[{i}].id: component ids must be ACOMP<digits>"
185            ));
186        }
187        params["id"] = json!(id);
188        if super::components::is_acomp_feature_type(ty) {
189            if !object.contains_key("isFixed") {
190                params.as_object_mut().unwrap().remove("isFixed");
191            }
192            // Occurrence attributes live in the ACOMP's `bom` record — the
193            // one the BOM reads. A params-level `occurrenceAttributes` (the
194            // key this path once wrote) is folded into it.
195            let record = super::bom::OCCURRENCE_ATTRIBUTES;
196            if let Some(legacy) = params.as_object_mut().and_then(|p| p.remove("occurrenceAttributes")) {
197                merge(&mut params[record], &legacy);
198            }
199            if let Some(attributes) = item.get("attributes") {
200                if !attributes.is_object() {
201                    return Err(format!("features[{i}].attributes must be an object"));
202                }
203                merge(&mut params[record], attributes);
204            }
205            if let Some(key) = params[record].as_object().and_then(|object| {
206                object.keys().find(|key| super::part_mass::PART_ONLY_KEYS.contains(&key.as_str())).cloned()
207            }) {
208                return Err(format!(
209                    "features[{i}].attributes.{key}: {key} is an attribute of the PART, not of one occurrence — put it in the part's partAttributes (or use `material`)"
210                ));
211            }
212            if let Some(name) = item.get("display_name") {
213                params[record]["Name"] = name.clone();
214            }
215            // `material` is a PART attribute: `author_batch` writes it onto
216            // the placed part's own document, never onto this occurrence.
217        }
218        if let Some(alias) = item.get("alias") {
219            let alias = alias
220                .as_str()
221                .filter(|s| !s.is_empty() && !s.contains(['@', '/', ':']))
222                .ok_or_else(|| format!("features[{i}].alias: invalid alias"))?;
223            if aliases.insert(alias.into(), id.clone()).is_some() {
224                return Err(format!("features[{i}].alias: duplicate alias {alias}"));
225            }
226        }
227        brep_kernel::feature_pipeline::validate_literal_feature_params(schema["type"].as_str().unwrap_or_default(), &params)?;
228        features.push(json!({"type":schema["type"], "inputParams":params, "persistentData":item.get("persistent_data").or_else(|| item.get("persistentData")).cloned().unwrap_or(json!({}))}));
229    }
230    Ok(features)
231}
232
233/// Stable topological order; dependencies include explicit `depends_on` aliases
234/// and alias references anywhere in parameters or persistent data.
235fn plan(
236    items: &[Value],
237    features: &[Value],
238    aliases: &BTreeMap<String, String>,
239) -> Result<(Vec<usize>, Vec<Vec<usize>>), String> {
240    let positions: BTreeMap<&str, usize> = features
241        .iter()
242        .enumerate()
243        .map(|(i, f)| (f["inputParams"]["id"].as_str().unwrap(), i))
244        .collect();
245    let mut deps = vec![Vec::new(); features.len()];
246    for (i, feature) in features.iter().enumerate() {
247        let mut refs = Vec::new();
248        strings(&feature["inputParams"], &mut refs);
249        strings(&feature["persistentData"], &mut refs);
250        if let Some(explicit) = items[i].get("depends_on") {
251            let a = explicit
252                .as_array()
253                .ok_or_else(|| format!("features[{i}].depends_on must be an array"))?;
254            for r in a {
255                refs.push(format!(
256                    "@{}",
257                    r.as_str().ok_or("depends_on entries must be aliases")?
258                ));
259            }
260        }
261        for r in refs {
262            validate_reference(&r, aliases).map_err(|error| format!("features[{i}]: {error}"))?;
263            if let Some((a, _)) = alias_ref(&r) {
264                let id = &aliases[a];
265                if let Some(&p) = positions.get(id.as_str()) {
266                    deps[i].push(p);
267                }
268            }
269        }
270        deps[i].sort_unstable();
271        deps[i].dedup();
272    }
273    let mut order = Vec::new();
274    while order.len() < features.len() {
275        let Some(i) = (0..features.len())
276            .find(|i| !order.contains(i) && deps[*i].iter().all(|d| order.contains(d)))
277        else {
278            return Err(format!(
279                "dependency cycle among feature positions {:?}",
280                (0..features.len())
281                    .filter(|i| !order.contains(i))
282                    .collect::<Vec<_>>()
283            ));
284        };
285        order.push(i);
286    }
287    Ok((order, deps))
288}
289
290/// What [`EngineState::stage_feature_begin`] keeps until the run lands.
291#[derive(Debug, Clone)]
292pub(crate) struct StagedFeature {
293    id: String,
294    before: History,
295    metadata: crate::metadata::MetadataStore,
296    library: brep_kernel::PartsLibraryMap,
297    staged: History,
298    /// `History::edit_serial` right after the staged feature was appended.
299    edit_serial: u64,
300}
301
302fn failed(report: &Value) -> bool {
303    report.get("error").is_some()
304        || [
305            "featureErrors",
306            "displayErrors",
307            "unresolved",
308            "featureFulfilment",
309        ]
310        .iter()
311        .any(|key| match &report[*key] {
312            Value::Object(o) => !o.is_empty(),
313            Value::Array(a) => !a.is_empty(),
314            _ => false,
315        })
316}
317
318fn refusal_category(refusal: &Value) -> &'static str {
319    match refusal["class"].as_str() {
320        Some("degenerate_arrangement" | "non_integral_genus" | "invalid_result_topology") => {
321            "invalid_topology"
322        }
323        Some("tangent_node_singularity" | "conservative_empty_overlap") => "contact_related",
324        Some("non_convergence") => "numerical_nonconvergence",
325        Some("invalid_input") => "invalid_input",
326        Some("unsupported_geometry") => "unsupported_geometry",
327        _ => "geometry_execution_failure",
328    }
329}
330
331impl EngineState {
332    /// Full diagnostics from the last batch, including a rolled-back attempt.
333    pub fn geometry_diagnostics(&self, id: &str) -> Option<Value> {
334        self.geometry_diagnostics.get(id).cloned()
335    }
336
337    /// Wait on the installed native runner, preserving its resident geometry
338    /// and delta baseline. A transaction is one app command, so other commands
339    /// cannot observe its intermediate recipes. Kernel operations are not
340    /// preemptible inside a feature.
341    /// Stage ONE feature without waiting for it (an agent's `cad.*` write,
342    /// contract §5.1/§6): snapshot the recipe, metadata and parts library,
343    /// append the seeded, validated feature and hand its run to the runner,
344    /// then return. [`Self::stage_feature_poll`] finishes it on the frame the
345    /// run lands — committing it as one undo checkpoint, or restoring the
346    /// snapshot if it failed — so no frame waits for geometry. One stage at a
347    /// time per document; `item` is a batch item (`{type, params, …}`).
348    pub fn stage_feature_begin(&mut self, item: &Value) -> Result<String, String> {
349        if self.staged_feature.is_some() {
350            return Err("document_busy: a staged feature is still evaluating".into());
351        }
352        if self.run_pending() || self.queries_pending() || self.mesh_imports_pending() || self.topology_pending() {
353            return Err("wait for pending engine work before staging a feature".into());
354        }
355        if let Some(reason) = self.history.locked() {
356            return Err(format!("document is locked: {reason}"));
357        }
358        let before = self.history.clone();
359        let metadata = self.metadata.clone();
360        let library = brep_kernel::parts_library_map();
361        let mut staged = before.clone();
362        let mut aliases = BTreeMap::new();
363        if item.get("material").is_some() {
364            return Err("material is a PART attribute: set it with bom_set_part_attribute, or in a feature_add_many part's document".into());
365        }
366        let features = prepare(&self.feature_catalogue(), std::slice::from_ref(item), &mut staged, &mut aliases)?;
367        let feature = features.into_iter().next().ok_or("nothing to stage")?;
368        let id = feature["inputParams"]["id"].as_str().ok_or("the staged feature has no id")?.to_string();
369        let mut validation: Value = serde_json::from_str(&before.request_json()).map_err(|e| e.to_string())?;
370        validation["features"].as_array_mut().ok_or("document features missing")?.push(feature.clone());
371        serde_json::from_value::<HistoryRequest>(validation).map_err(|e| format!("stage validation: {e}"))?;
372        self.add_features(&[feature]);
373        // The user may keep editing while the run evaluates (the frame is
374        // not held): the serial after OUR write tells the finish whether
375        // anything else wrote the recipe meanwhile.
376        let edit_serial = self.history.edit_serial();
377        // Until it lands, the user's own edits to this document are refused
378        // (and said), not interleaved with the agent's.
379        self.history.set_busy(Some("an agent's change to this document is still evaluating".into()));
380        self.staged_feature = Some(StagedFeature { id: id.clone(), before, metadata, library, staged, edit_serial });
381        Ok(id)
382    }
383
384    /// Whether a feature is staged and its run has not been finished yet.
385    pub fn stage_feature_pending(&self) -> bool {
386        self.staged_feature.is_some()
387    }
388
389    /// Finish a staged feature once its run has landed: `None` while it is
390    /// still evaluating, else its row `{id, committed, status,
391    /// generatedSolids, error}`.
392    pub fn stage_feature_poll(&mut self) -> Option<Result<Value, String>> {
393        if self.staged_feature.is_none() || self.run_pending() {
394            return None;
395        }
396        let st = self.staged_feature.take()?;
397        self.history.set_busy(None);
398        // Say any refused user edit now: a restore below replaces the history
399        // that counted it.
400        if self.history.busy_refused() > self.busy_refused_seen {
401            self.push_notice("Not applied: an agent's change to this document was still evaluating. Try again.");
402        }
403        let report: Value = serde_json::from_str(&self.history_report_json()).unwrap_or(Value::Null);
404        let id = st.id.clone();
405        let names: Vec<String> = serde_json::from_value(report["featureOutputs"][&id].clone()).unwrap_or_default();
406        let errors: Vec<String> = report["featureErrors"]
407            .as_array()
408            .into_iter()
409            .flatten()
410            .filter_map(Value::as_str)
411            .filter(|e| e.starts_with(&format!("{id}:")))
412            .map(|e| e.chars().take(240).collect())
413            .collect();
414        let evaluated = report["featureTimings"].get(&id).is_some();
415        let failed = !errors.is_empty() || report["featureFulfilment"].get(&id).is_some() || report.get("error").is_some() || !evaluated;
416        // Did a user edit the recipe while it evaluated?
417        let interleaved = self.history.edit_serial() != st.edit_serial;
418        if failed && interleaved {
419            // Restoring the snapshot would throw the user's edit away: remove
420            // only the staged feature, and keep everything else as it is now.
421            if let Some(index) = self.history.index_of(&id) {
422                self.history.remove_feature(index);
423            }
424            self.rerun_history();
425            let why = if errors.is_empty() { "the feature did not evaluate".to_string() } else { errors.join("; ") };
426            self.busy_refused_seen = self.history.busy_refused();
427            return Some(Ok(json!({ "id": id, "committed": false, "status": "failed", "generatedSolids": [], "error": why, "rolledBack": true, "interleavedEdit": true })));
428        }
429        if failed {
430            // Restore the snapshot exactly; the display re-runs on its own.
431            self.history = st.before;
432            self.metadata = st.metadata;
433            brep_kernel::install_parts_library(&Default::default());
434            brep_kernel::install_parts_library(&st.library);
435            self.rerun_history();
436            let why = if errors.is_empty() { "the feature did not evaluate".to_string() } else { errors.join("; ") };
437            self.busy_refused_seen = self.history.busy_refused();
438            return Some(Ok(json!({ "id": id, "committed": false, "status": "failed", "generatedSolids": [], "error": why, "rolledBack": true })));
439        }
440        if interleaved {
441            // The user's own edits sit on the undo stack beside ours: leave the
442            // stack as it is rather than fold them into the agent's step.
443            self.history.retain_feature_allocations(&st.staged);
444            self.busy_refused_seen = self.history.busy_refused();
445            return Some(Ok(json!({ "id": id, "committed": true, "status": "evaluated", "generatedSolids": names, "rolledBack": false, "interleavedEdit": true })));
446        }
447        // One undo checkpoint for the staged write, as a batch commits.
448        let final_document = self.history.request_json();
449        self.history = st.before;
450        if let Err(e) = self.history.adopt_document_checkpointed(&final_document) {
451            return Some(Err(e));
452        }
453        self.history.retain_feature_allocations(&st.staged);
454        self.history.set_rollback(self.history.len().saturating_sub(1));
455        self.busy_refused_seen = self.history.busy_refused();
456        Some(Ok(json!({ "id": id, "committed": true, "status": "evaluated", "generatedSolids": names, "rolledBack": false })))
457    }
458
459    fn finish_batch_run(&mut self) {
460        while self.run_pending() {
461            self.pump();
462            #[cfg(not(target_arch = "wasm32"))]
463            if self.run_pending() {
464                std::thread::sleep(std::time::Duration::from_millis(1));
465            }
466        }
467    }
468
469    /// Full-document rollback includes recipe, library, metadata, counters and
470    /// undo/redo. Runtime caches and the generation counter are deliberately
471    /// invalidated, not rewound. No file, network or document-tab side effects.
472    pub fn author_batch(&mut self, args: &Value) -> Result<Value, String> {
473        if cfg!(target_arch = "wasm32") {
474            return Err("structured native authoring requires a native MCP host".into());
475        }
476        if self.run_pending()
477            || self.queries_pending()
478            || self.mesh_imports_pending()
479            || self.topology_pending()
480        {
481            return Err("wait for pending engine work before authoring a batch".into());
482        }
483        if let Some(reason) = self.history.locked() {
484            return Err(format!("document is locked: {reason}"));
485        }
486        let items = args["features"]
487            .as_array()
488            .ok_or("features must be an array")?;
489        let rollback = args["rollback_on_error"].as_bool().unwrap_or(false);
490        let before = self.history.clone();
491        let metadata = self.metadata.clone();
492        let library = brep_kernel::parts_library_map();
493        let mut staged = before.clone();
494        let mut document: Value =
495            serde_json::from_str(&staged.request_json()).map_err(|e| e.to_string())?;
496        let mut aliases = BTreeMap::new();
497        let mut part_aliases = BTreeMap::new();
498        let mut part_work = Vec::new();
499        let mut constraint_ids = Vec::new();
500        if let Some(parts) = args.get("parts") {
501            for (i, part) in parts
502                .as_array()
503                .ok_or("parts must be an array")?
504                .iter()
505                .enumerate()
506            {
507                let alias = part["alias"]
508                    .as_str()
509                    .filter(|s| !s.is_empty() && !s.contains(['@', '/', ':']))
510                    .ok_or_else(|| format!("parts[{i}].alias is required"))?;
511                if aliases.insert(alias.into(), alias.into()).is_some()
512                    || document["partsLibrary"].get(alias).is_some()
513                {
514                    return Err(format!("parts[{i}]: duplicate part {alias}"));
515                }
516                let mut doc = part["document"].clone();
517                let native = doc["features"]
518                    .as_array()
519                    .ok_or_else(|| format!("parts[{i}].document.features must be an array"))?
520                    .clone();
521                let mut allocation_base = doc.clone();
522                allocation_base["features"] = json!([]);
523                let mut part_history = History::from_request_json(&allocation_base.to_string())?;
524                let mut local_aliases = BTreeMap::new();
525                let mut scope = EngineState::new();
526                scope.inherit_plugins(self);
527                scope.history = part_history.clone();
528                let mut prepared = prepare(&scope.feature_catalogue(), &native, &mut part_history, &mut local_aliases)
529                    .map_err(|e| format!("parts[{i}].document.{e}"))?;
530                let _ = plan(&native, &prepared, &local_aliases)
531                    .map_err(|e| format!("parts[{i}].document.{e}"))?;
532                for (index, f) in prepared.iter_mut().enumerate() {
533                    for key in ["alias", "depends_on"] {
534                        if let Some(value) = native[index].get(key) {
535                            f[key] = value.clone();
536                        }
537                    }
538                }
539                part_aliases.insert(alias.to_owned(), local_aliases);
540                // Keep client order for result positions; the isolated batch
541                // below executes its own dependency plan.
542                doc["features"] = json!(prepared);
543                doc["featureCounter"] = serde_json::from_str::<Value>(&part_history.request_json())
544                    .unwrap_or_default()["featureCounter"]
545                    .clone();
546                part_work.push((alias.to_owned(), doc.clone()));
547                document["partsLibrary"][alias] = json!({"sourceKey":alias,"sourceSignature":"batch", "document":doc,"snapshot":""});
548            }
549        }
550        let mut features = prepare(&self.feature_catalogue(), items, &mut staged, &mut aliases)?;
551        // `material` on a component names the PART's material: it goes onto
552        // the part's own `partAttributes`. Only a part this batch defines can
553        // take it here (an existing library part is edited through
554        // `bom_set_part_attribute`, which also writes the kernel store), and
555        // two components of one part must agree.
556        let mut materials: BTreeMap<String, Value> = BTreeMap::new();
557        for (i, item) in items.iter().enumerate() {
558            let Some(material) = item.get("material") else { continue };
559            let part = features[i]["inputParams"]["partName"].as_str().unwrap_or_default();
560            let part = part.strip_prefix('@').unwrap_or(part).to_string();
561            if !part_work.iter().any(|(alias, _)| *alias == part) {
562                return Err(format!(
563                    "features[{i}].material: Material is an attribute of the PART '{part}', not of this occurrence — set it with bom_set_part_attribute (only a part defined in this batch's `parts` takes it here)"
564                ));
565            }
566            if let Some(previous) = materials.insert(part.clone(), material.clone()) {
567                if previous != *material {
568                    return Err(format!(
569                        "features[{i}].material: part '{part}' is given two materials ({previous} and {material}); a material belongs to the part, so its components must agree"
570                    ));
571                }
572            }
573        }
574        for (part, material) in materials {
575            for (alias, doc) in part_work.iter_mut() {
576                if *alias == part {
577                    doc[super::bom::PART_ATTRIBUTES]["Material"] = material.clone();
578                    document["partsLibrary"][alias.as_str()]["document"] = doc.clone();
579                }
580            }
581        }
582        let (order, deps) = plan(items, &features, &aliases)?;
583        document["featureCounter"] = serde_json::from_str::<Value>(&staged.request_json())
584            .unwrap_or_default()["featureCounter"]
585            .clone();
586        if let Some(expressions) = args.get("expressions") {
587            if !expressions.is_string() {
588                return Err("expressions must be a script string".into());
589            }
590            document["expressions"] = expressions.clone();
591        }
592        // Constraints use the native catalogue and descriptor shape.
593        if let Some(constraints) = args.get("constraints") {
594            let catalogue = brep_kernel::constraint_schema_catalogue();
595            let mut counter = document["assembly"]["idCounter"].as_u64().unwrap_or(0);
596            for (i, constraint) in constraints
597                .as_array()
598                .ok_or("constraints must be an array")?
599                .iter()
600                .enumerate()
601            {
602                let ty = constraint["type"]
603                    .as_str()
604                    .ok_or("constraint type is required")?;
605                if ty == "fixed" {
606                    return Err(format!("constraints[{i}].type: {}", brep_kernel::FIXED_IS_NOT_A_CONSTRAINT));
607                }
608                let entry = catalogue
609                    .as_array()
610                    .and_then(|a| a.iter().find(|e| e["type"].as_str() == Some(ty)))
611                    .ok_or_else(|| format!("constraints[{i}].type: unknown constraint {ty}"))?;
612                counter += 1;
613                let id = format!("{}{}", entry["shortName"].as_str().unwrap_or("C"), counter);
614                let mut params = json!({});
615                if let Some(schema) = entry["inputParamsSchema"].as_object() {
616                    for (k, v) in schema {
617                        params[k] = v["default_value"].clone();
618                    }
619                }
620                merge(
621                    &mut params,
622                    constraint
623                        .get("params")
624                        .or_else(|| constraint.get("inputParams"))
625                        .unwrap_or(&json!({})),
626                );
627                for key in params
628                    .as_object()
629                    .ok_or_else(|| format!("constraints[{i}].params must be an object"))?
630                    .keys()
631                {
632                    if entry["inputParamsSchema"].get(key).is_none() {
633                        return Err(format!("constraints[{i}].params.{key}: unknown field"));
634                    }
635                }
636                constraint_ids.push(id.clone());
637                params["id"] = json!(id);
638                validate_references(&params, &aliases, &format!("constraints[{i}].params"))?;
639                if let Some(a) = constraint["alias"].as_str() {
640                    if aliases.insert(a.into(), id).is_some() {
641                        return Err(format!("constraints[{i}].alias is duplicate"));
642                    }
643                }
644                if !document["assembly"]["constraints"].is_array() {
645                    document["assembly"]["constraints"] = json!([]);
646                }
647                let mut persistent = constraint
648                    .get("persistent_data")
649                    .or_else(|| constraint.get("persistentData"))
650                    .cloned()
651                    .unwrap_or(json!({}));
652                validate_references(
653                    &persistent,
654                    &aliases,
655                    &format!("constraints[{i}].persistentData"),
656                )?;
657                let explicit = constraint
658                    .get("params")
659                    .or_else(|| constraint.get("inputParams"));
660                // Interactive creation adopts a measurement on its first solve.
661                // A batch's explicitly supplied target already expresses intent.
662                for (field, flag) in [
663                    ("distance", "initializedDistance"),
664                    ("angle", "initializedAngle"),
665                ] {
666                    if explicit.is_some_and(|p| p.get(field).is_some()) {
667                        persistent
668                            .as_object_mut()
669                            .ok_or_else(|| {
670                                format!("constraints[{i}].persistent_data must be an object")
671                            })?
672                            .entry(flag)
673                            .or_insert(json!(true));
674                    }
675                }
676                document["assembly"]["constraints"].as_array_mut().unwrap().push(json!({"type":ty,"inputParams":params,"persistentData":persistent,"enabled":constraint.get("enabled").cloned().unwrap_or(json!(true))}));
677            }
678            document["assembly"]["idCounter"] = json!(counter);
679        }
680        if let Some(patch) = args.get("metadata") {
681            let map = patch.as_object().ok_or("metadata must be an object")?;
682            for key in map.keys() {
683                if let Some((a, _)) = alias_ref(key) {
684                    if !aliases.contains_key(a) {
685                        return Err(format!("metadata.{key}: missing alias @{a}"));
686                    }
687                }
688            }
689        }
690        // Parse the entire proposed request before touching any live state.
691        let mut validation = document.clone();
692        validation["features"]
693            .as_array_mut()
694            .ok_or("document features missing")?
695            .extend(features.clone());
696        serde_json::from_value::<HistoryRequest>(validation)
697            .map_err(|e| format!("batch validation: {e}"))?;
698        self.geometry_diagnostics.clear();
699        let run = (|| -> Result<Value, String> {
700            // Compile each native definition in isolation first. This resolves
701            // generated-output aliases inside part histories too, without
702            // turning the part into an imported/baked solid.
703            let mut part_results = Vec::new();
704            for (position, (alias, source)) in part_work.iter().enumerate() {
705                let mut base = source.clone();
706                let part_features = base["features"].take();
707                base["features"] = json!([]);
708                base.as_object_mut().unwrap().remove("metadata");
709                let mut part = EngineState::new();
710                part.inherit_plugins(self);
711                part.set_history_json(&base.to_string())?;
712                let mut part_args = json!({"features":part_features,"rollback_on_error":true});
713                if let Some(metadata) = source.get("metadata") {
714                    part_args["metadata"] = metadata.clone();
715                }
716                let mut evaluated = part.author_batch(&part_args)?;
717                for (id, details) in &part.geometry_diagnostics {
718                    self.geometry_diagnostics
719                        .insert(format!("part:{alias}:{id}"), details.clone());
720                }
721                for item in evaluated["items"].as_array_mut().into_iter().flatten() {
722                    if let Some(id) = item["diagnostic"]["detailId"].as_str().map(str::to_owned) {
723                        item["diagnostic"]["detailId"] = json!(format!("part:{alias}:{id}"));
724                    }
725                }
726                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"]}));
727                if evaluated["success"] != true {
728                    for (i, (remaining, _)) in part_work.iter().enumerate().skip(position + 1) {
729                        part_results.push(json!({"position":i,"alias":remaining,"id":remaining,"status":"not_evaluated","committed":false}));
730                    }
731                    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();
732                    return Ok(
733                        json!({"success":false,"requiresRestore":true,"items":pending,"partResults":part_results,"constraintResults":[],"aliases":aliases,"partAliases":part_aliases,"diagnosticsTool":"geometry_diagnostics"}),
734                    );
735                }
736                document["partsLibrary"][alias]["document"] =
737                    serde_json::from_str::<Value>(&part.history_request_json())
738                        .map_err(|e| e.to_string())?;
739            }
740            // Delay constraints until every instance exists.
741            let constraints = document.get("assembly").cloned();
742            document.as_object_mut().unwrap().remove("assembly");
743            self.edit_document_json(&document.to_string())?;
744            self.finish_batch_run();
745            let prepared_library =
746                serde_json::from_value(document.get("partsLibrary").cloned().unwrap_or(json!({})))
747                    .map_err(|e| format!("partsLibrary: {e}"))?;
748            let mut outputs: BTreeMap<String, Vec<String>> = BTreeMap::new();
749            let mut results = vec![Value::Null; items.len()];
750            let mut failed_items = BTreeSet::new();
751            for i in order {
752                let id = features[i]["inputParams"]["id"]
753                    .as_str()
754                    .unwrap()
755                    .to_string();
756                let blocked: Vec<usize> = deps[i]
757                    .iter()
758                    .filter(|d| failed_items.contains(*d))
759                    .copied()
760                    .collect();
761                let mut result = json!({"position":i,"alias":items[i]["alias"],"id":id,"committed":false,"dependencies":deps[i]});
762                if !blocked.is_empty() {
763                    failed_items.insert(i);
764                    result["status"] = json!("not_evaluated");
765                    result["dependencyFailures"] = json!(blocked);
766                    results[i] = result;
767                    continue;
768                }
769                if let Err(error) = resolve(&mut features[i], &aliases, &outputs) {
770                    failed_items.insert(i);
771                    result["status"] = json!("failed");
772                    result["error"] = json!({"category":"reference_resolution","message":error,"path":format!("features[{i}]")});
773                    results[i] = result;
774                    continue;
775                }
776                brep_kernel::install_parts_library(&prepared_library);
777                self.history.set_parts_library(
778                    serde_json::to_value(brep_kernel::parts_library_map())
779                        .map_err(|e| e.to_string())?,
780                );
781                self.add_features(&[features[i].clone()]);
782                self.finish_batch_run();
783                let report: Value =
784                    serde_json::from_str(&self.history_report_json()).map_err(|e| e.to_string())?;
785                let names: Vec<String> =
786                    serde_json::from_value(report["featureOutputs"][&id].clone())
787                        .unwrap_or_default();
788                outputs.insert(id.clone(), names.clone());
789                let errors: Vec<Value> = report["featureErrors"]
790                    .as_array()
791                    .into_iter()
792                    .flatten()
793                    .filter(|e| e.as_str().is_some_and(|e| e.starts_with(&format!("{id}:"))))
794                    .cloned()
795                    .collect();
796                let has_error = !errors.is_empty()
797                    || report["featureFulfilment"].get(&id).is_some()
798                    || report.get("error").is_some();
799                if has_error {
800                    failed_items.insert(i);
801                }
802                result["status"] = json!(if has_error {
803                    "failed"
804                } else if report["featureTimings"].get(&id).is_some() {
805                    "evaluated"
806                } else {
807                    "not_evaluated"
808                });
809                if result["status"] == "not_evaluated" {
810                    failed_items.insert(i);
811                }
812                result["generatedSolids"] = json!(names);
813                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]}));
814                result["error"] = json!(errors
815                    .iter()
816                    .filter_map(Value::as_str)
817                    .map(|s| s.chars().take(240).collect::<String>())
818                    .collect::<Vec<_>>());
819                let mut references = Vec::new();
820                for name in &names {
821                    if let Some(solid) = self.scene.solid(name) {
822                        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<_>>()}));
823                    }
824                }
825                outputs.insert(
826                    format!("{id}/face"),
827                    references
828                        .iter()
829                        .flat_map(|r| r["faces"].as_array().into_iter().flatten())
830                        .filter_map(Value::as_str)
831                        .map(str::to_owned)
832                        .collect(),
833                );
834                outputs.insert(
835                    format!("{id}/edge"),
836                    references
837                        .iter()
838                        .flat_map(|r| r["edges"].as_array().into_iter().flatten())
839                        .filter_map(Value::as_str)
840                        .map(str::to_owned)
841                        .collect(),
842                );
843                result["generatedReferences"] = json!(references);
844                let schema =
845                    self.feature_schema(features[i]["type"].as_str().unwrap_or(""))
846                        .unwrap_or_default();
847                let operands: serde_json::Map<String, Value> = features[i]["inputParams"]
848                    .as_object()
849                    .into_iter()
850                    .flat_map(|o| o.iter())
851                    .filter(|(k, _)| {
852                        matches!(
853                            schema["inputParamsSchema"][*k]["type"].as_str(),
854                            Some("reference_selection" | "boolean_operation")
855                        ) || k.as_str() == "partName"
856                    })
857                    .map(|(k, v)| (k.clone(), v.clone()))
858                    .collect();
859                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});
860                result["committed"] = json!(true);
861                results[i] = result;
862            }
863            let mut constraint_errors = BTreeMap::new();
864            if let Some(mut constraints) = constraints {
865                if let Some(entries) = constraints["constraints"].as_array_mut() {
866                    entries.retain_mut(|entry| {
867                        let id = entry["inputParams"]["id"].as_str().unwrap_or("").to_owned();
868                        if let Err(error) = resolve(entry, &aliases, &outputs) {
869                            constraint_errors.insert(id, error);
870                            false
871                        } else {
872                            true
873                        }
874                    });
875                }
876                let mut final_doc: Value = serde_json::from_str(&self.history.request_json())
877                    .map_err(|e| e.to_string())?;
878                final_doc["assembly"] = constraints;
879                self.edit_document_json(&final_doc.to_string())?;
880                self.finish_batch_run();
881            }
882            if let Some(patch) = args.get("metadata").and_then(Value::as_object) {
883                let mut merged = self.metadata.to_json();
884                for (key, record) in patch {
885                    let mut name = json!(key);
886                    resolve(&mut name, &aliases, &outputs)?;
887                    merge(&mut merged[name.as_str().unwrap()], record);
888                }
889                self.metadata.load_json(Some(&merged));
890                self.sync_colors_from_metadata();
891            }
892            let final_report: Value =
893                serde_json::from_str(&self.history_report_json()).map_err(|e| e.to_string())?;
894            let statuses = self.assembly_statuses_value();
895            let constraint_failure = statuses.as_array().is_some_and(|a| {
896                a.iter()
897                    .any(|s| s["enabled"] != false && s["satisfied"] == false)
898            });
899            let success = failed_items.is_empty()
900                && !failed(&final_report)
901                && !constraint_failure
902                && constraint_errors.is_empty();
903            let constraint_results: Vec<Value> = constraint_ids.iter().enumerate().map(|(i,id)| {
904                let row = statuses.as_array().and_then(|a| a.iter().find(|r| r["id"].as_str() == Some(id)));
905                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" };
906                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![] }})
907            }).collect();
908            // Keep explicitly supplied definitions, including ones not yet
909            // instantiated. Normal library GC still applies on later edits.
910            let mut retained_library = brep_kernel::parts_library_map();
911            for (alias, _) in &part_work {
912                if let Some(entry) = prepared_library.get(alias) {
913                    retained_library
914                        .entry(alias.clone())
915                        .or_insert_with(|| entry.clone());
916                }
917            }
918            brep_kernel::install_parts_library(&retained_library);
919            self.history.set_parts_library(
920                serde_json::to_value(&retained_library).map_err(|e| e.to_string())?,
921            );
922            for result in &mut part_results {
923                result["committed"] = json!(true);
924            }
925            self.geometry_diagnostics
926                .insert("__report__".into(), final_report);
927            Ok(
928                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()}),
929            )
930        })();
931        let restore = run
932            .as_ref()
933            .map(|r| r["requiresRestore"] == true || (rollback && r["success"] != true))
934            .unwrap_or(true);
935        if restore {
936            self.history = before.clone();
937            self.metadata = metadata.clone();
938            brep_kernel::install_parts_library(&Default::default());
939            brep_kernel::install_parts_library(&library);
940            self.rerun_history();
941            self.finish_batch_run();
942            // The restoration rebuild may heal/GC library entries or fold a
943            // solver pose. Preserve the exact pre-transaction authored state.
944            self.history = before;
945            self.metadata = metadata;
946            brep_kernel::install_parts_library(&Default::default());
947            brep_kernel::install_parts_library(&library);
948        } else {
949            // Collapse the staged rebuilds into a single user undo checkpoint.
950            let final_document = self.history.request_json();
951            self.history = before;
952            self.history.adopt_document_checkpointed(&final_document)?;
953            self.history.retain_feature_allocations(&staged);
954            self.history
955                .set_rollback(self.history.len().saturating_sub(1));
956        }
957        let mut result = run?;
958        result.as_object_mut().unwrap().remove("requiresRestore");
959        result["rolledBack"] = json!(restore);
960        result["transaction"] = json!({"validationAtomic":true,"geometryAtomic":rollback,"idCountersRolledBack":restore,"runtimeGenerationRolledBack":false});
961        if restore {
962            fn uncommit(value: &mut Value) {
963                match value {
964                    Value::Object(object) => {
965                        if object.contains_key("committed") {
966                            object.insert("committed".into(), json!(false));
967                        }
968                        for value in object.values_mut() {
969                            uncommit(value);
970                        }
971                    }
972                    Value::Array(array) => {
973                        for value in array {
974                            uncommit(value);
975                        }
976                    }
977                    _ => (),
978                }
979            }
980            uncommit(&mut result);
981        }
982        Ok(result)
983    }
984}
985
986/// Transform strings are expressions, including strings nested in vec3s.
987pub(super) fn contains_expression(value: &Value) -> bool {
988    match value {
989        Value::String(_) => true,
990        Value::Array(a) => a.iter().any(contains_expression),
991        Value::Object(o) => o.values().any(contains_expression),
992        _ => false,
993    }
994}
995
996
997
998
999