Skip to main content

runmat_runtime/geometry/
mod.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet, HashMap};
3use std::io::ErrorKind;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, OnceLock, RwLock};
7#[cfg(test)]
8use std::sync::{Mutex, MutexGuard};
9
10use self::capture::DEFAULT_SVG_CAPTURE_ADAPTER;
11use chrono::Utc;
12use runmat_geometry_core::{
13    AssemblyNode, DiagnosticSeverity, EntityIdRange, EntityKind, EntityRef, GeometryAsset,
14    GeometrySource, MeshKind, Region, SourceGeometry, SourceGeometryKind, TessellationProfile,
15    UnitSystem,
16};
17use runmat_geometry_io::{
18    import::GeometryImportError, import_geometry_with_context, GeometryFormat,
19    GeometryImportContext, GeometryImportOptions,
20};
21use runmat_geometry_ops::{compute_stats, find_region, GeometryStats, QueryError};
22use runmat_meshing_core::{
23    prepare_geometry_for_analysis, MeshingOptions, MeshingPrepResult, MeshingProfile,
24};
25use serde::{Deserialize, Serialize};
26
27use crate::operations::{
28    operation_error, OperationContext, OperationEnvelope, OperationErrorEnvelope,
29    OperationErrorSeverity, OperationErrorSpec, OperationErrorType,
30};
31use crate::{build_runtime_error, BuiltinResult};
32
33const GEOMETRY_INSPECT_OPERATION: &str = "geometry.inspect";
34const GEOMETRY_INSPECT_OP_VERSION: &str = "geometry.inspect/v1";
35const GEOMETRY_LOAD_OPERATION: &str = "geometry.load";
36const GEOMETRY_LOAD_OP_VERSION: &str = "geometry.load/v1";
37const GEOMETRY_COMPUTE_STATS_OPERATION: &str = "geometry.compute_stats";
38const GEOMETRY_COMPUTE_STATS_OP_VERSION: &str = "geometry.compute_stats/v1";
39const GEOMETRY_LIST_REGIONS_OPERATION: &str = "geometry.list_regions";
40const GEOMETRY_LIST_REGIONS_OP_VERSION: &str = "geometry.list_regions/v1";
41const GEOMETRY_QUERY_ENTITIES_OPERATION: &str = "geometry.query_entities";
42const GEOMETRY_QUERY_ENTITIES_OP_VERSION: &str = "geometry.query_entities/v1";
43const GEOMETRY_CAPTURE_VIEW_OPERATION: &str = "geometry.capture_view";
44const GEOMETRY_CAPTURE_VIEW_OP_VERSION: &str = "geometry.capture_view/v1";
45const GEOMETRY_PREP_FOR_ANALYSIS_OPERATION: &str = "geometry.prep_for_analysis";
46const GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION: &str = "geometry.prep_for_analysis/v1";
47const GEOMETRY_PREP_ARTIFACT_HEALTH_OPERATION: &str = "geometry.prep_artifact_health";
48const GEOMETRY_PREP_ARTIFACT_HEALTH_OP_VERSION: &str = "geometry.prep_artifact_health/v1";
49const DEFAULT_QUERY_LIMIT: usize = 2048;
50const DEFAULT_MAPPING_RANGE_PREVIEW_LIMIT: usize = 8;
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct GeometryInspectResult {
54    pub format: String,
55    pub byte_count: usize,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct GeometryRegionsResult {
60    pub regions: Vec<Region>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct GeometryEntityQuery {
65    pub region_id: Option<String>,
66    pub mesh_id: Option<String>,
67    pub entity_kind: EntityKind,
68    pub limit: Option<usize>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct GeometryEntityQueryResult {
73    pub entities: Vec<EntityRef>,
74    pub truncated: bool,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct GeometryBoundsSummary {
80    pub min: [f64; 3],
81    pub max: [f64; 3],
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct GeometryMeshSummary {
87    pub mesh_id: String,
88    pub kind: MeshKind,
89    pub vertex_count: u64,
90    pub element_count: u64,
91    pub surface_vertex_count: Option<u64>,
92    pub surface_triangle_count: Option<u64>,
93    pub bounds: Option<GeometryBoundsSummary>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct GeometryRegionMappingSummaryEntry {
99    pub region_id: String,
100    pub mesh_id: String,
101    pub entity_kind: EntityKind,
102    pub range_count: usize,
103    pub entity_count: u64,
104    pub range_preview: Vec<EntityIdRange>,
105    pub truncated: bool,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct GeometryRegionMappingSummary {
111    pub mapping_count: usize,
112    pub mapped_region_count: usize,
113    pub total_entity_count: u64,
114    pub range_preview_limit: usize,
115    pub entries: Vec<GeometryRegionMappingSummaryEntry>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum GeometryCadRegionStatus {
121    NotCad,
122    MetadataOnly,
123    GenericFaceTopology,
124    SemanticRegions,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct GeometryCadSummary {
130    pub backend: Option<String>,
131    pub source_format: Option<String>,
132    pub face_region_count: usize,
133    pub mapped_face_region_count: usize,
134    pub semantic_region_count: usize,
135    pub mapped_semantic_region_count: usize,
136    pub region_status: GeometryCadRegionStatus,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct GeometryAssetSummary {
142    pub geometry_id: String,
143    pub revision: u32,
144    pub source: GeometrySource,
145    pub source_geometry: SourceGeometry,
146    pub tessellation_profile: TessellationProfile,
147    pub units: UnitSystem,
148    pub meshes: Vec<GeometryMeshSummary>,
149    pub mapping_summary: GeometryRegionMappingSummary,
150    pub cad: GeometryCadSummary,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct GeometryCaptureViewSpec {
155    pub format: String,
156    pub width: u32,
157    pub height: u32,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct GeometryCaptureViewResult {
162    pub format: String,
163    pub width: u32,
164    pub height: u32,
165    pub payload: Vec<u8>,
166}
167
168#[cfg(feature = "plot-core")]
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum GeometryPreviewPresentation {
172    Analysis,
173    Cad,
174}
175
176#[cfg(feature = "plot-core")]
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178pub struct GeometryPreviewFigureOptions {
179    pub edge_overlay_triangle_limit: usize,
180    pub presentation: GeometryPreviewPresentation,
181    pub xray: bool,
182    pub allow_create_fea_study: bool,
183}
184
185#[cfg(feature = "plot-core")]
186impl Default for GeometryPreviewFigureOptions {
187    fn default() -> Self {
188        Self {
189            edge_overlay_triangle_limit: 250_000,
190            presentation: GeometryPreviewPresentation::Analysis,
191            xray: false,
192            allow_create_fea_study: false,
193        }
194    }
195}
196
197#[cfg(feature = "plot-core")]
198impl GeometryPreviewFigureOptions {
199    pub fn cad_preview() -> Self {
200        Self {
201            edge_overlay_triangle_limit: 250_000,
202            presentation: GeometryPreviewPresentation::Cad,
203            xray: false,
204            allow_create_fea_study: false,
205        }
206    }
207}
208
209#[cfg(feature = "plot-core")]
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211pub struct GeometryPreviewSceneOptions {
212    pub triangles_per_chunk: usize,
213    pub presentation: GeometryPreviewPresentation,
214    pub xray: bool,
215    pub allow_create_fea_study: bool,
216}
217
218#[cfg(feature = "plot-core")]
219impl Default for GeometryPreviewSceneOptions {
220    fn default() -> Self {
221        Self {
222            triangles_per_chunk: 128_000,
223            presentation: GeometryPreviewPresentation::Cad,
224            xray: false,
225            allow_create_fea_study: false,
226        }
227    }
228}
229
230#[cfg(feature = "plot-core")]
231const CAD_DEFAULT_FACE_COLOR: glam::Vec4 = glam::Vec4::new(0.66, 0.72, 0.80, 1.0);
232#[cfg(feature = "plot-core")]
233const CAD_FEATURE_EDGE_COLOR: glam::Vec4 = glam::Vec4::new(0.08, 0.10, 0.13, 1.0);
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum GeometryPrepProfile {
238    SurfaceOnly,
239    AnalysisReady,
240    AdaptiveRefine,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct GeometryPrepForAnalysisSpec {
245    pub profile: GeometryPrepProfile,
246    pub target_element_budget: usize,
247}
248
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct GeometryPrepForAnalysisResult {
251    pub prep_artifact_id: String,
252    pub prep: MeshingPrepResult,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct StoredGeometryPrepArtifact {
257    pub prep_artifact_id: String,
258    pub schema_version: String,
259    pub created_at: String,
260    pub source_geometry_id: String,
261    pub source_geometry_revision: u32,
262    pub prep: MeshingPrepResult,
263}
264
265#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266pub struct PrepArtifactMetrics {
267    pub created_count: u64,
268    pub loaded_count: u64,
269    pub pruned_count: u64,
270    pub stale_reject_count: u64,
271    pub mismatch_reject_count: u64,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct GeometryPrepArtifactHealthQuery {
276    pub include_per_geometry: bool,
277}
278
279impl Default for GeometryPrepArtifactHealthQuery {
280    fn default() -> Self {
281        Self {
282            include_per_geometry: true,
283        }
284    }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct GeometryPrepArtifactHealthEntry {
289    pub geometry_id: String,
290    pub latest_revision: u32,
291    pub artifact_count: usize,
292}
293
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295pub struct GeometryPrepArtifactHealthResult {
296    pub schema_version: String,
297    pub current_artifact_count: usize,
298    pub age_p50_seconds: Option<f64>,
299    pub age_p95_seconds: Option<f64>,
300    pub metrics: PrepArtifactMetrics,
301    pub per_geometry: Vec<GeometryPrepArtifactHealthEntry>,
302}
303
304#[derive(Debug, Clone, Default, PartialEq, Eq)]
305pub struct GeometryPrepArtifactConfig {
306    pub artifact_root: Option<PathBuf>,
307    pub max_artifacts: Option<usize>,
308    pub max_artifacts_per_geometry: Option<usize>,
309    pub max_age_seconds: Option<u64>,
310    pub require_latest_revision: Option<bool>,
311}
312
313type PrepStore = Arc<RwLock<HashMap<String, StoredGeometryPrepArtifact>>>;
314
315fn prep_store() -> &'static PrepStore {
316    static STORE: OnceLock<PrepStore> = OnceLock::new();
317    STORE.get_or_init(|| Arc::new(RwLock::new(HashMap::new())))
318}
319
320fn prep_artifact_counter() -> &'static AtomicU64 {
321    static COUNTER: OnceLock<AtomicU64> = OnceLock::new();
322    COUNTER.get_or_init(|| AtomicU64::new(1))
323}
324
325fn prep_metrics() -> &'static Arc<RwLock<PrepArtifactMetrics>> {
326    static METRICS: OnceLock<Arc<RwLock<PrepArtifactMetrics>>> = OnceLock::new();
327    METRICS.get_or_init(|| Arc::new(RwLock::new(PrepArtifactMetrics::default())))
328}
329
330fn prep_config() -> &'static RwLock<GeometryPrepArtifactConfig> {
331    static CONFIG: OnceLock<RwLock<GeometryPrepArtifactConfig>> = OnceLock::new();
332    CONFIG.get_or_init(|| RwLock::new(GeometryPrepArtifactConfig::default()))
333}
334
335fn current_prep_config() -> GeometryPrepArtifactConfig {
336    prep_config()
337        .read()
338        .map(|guard| guard.clone())
339        .unwrap_or_default()
340}
341
342pub fn configure_prep_artifacts(config: GeometryPrepArtifactConfig) -> Result<(), String> {
343    let mut guard = prep_config()
344        .write()
345        .map_err(|_| "geometry prep artifact config lock poisoned".to_string())?;
346    *guard = config;
347    Ok(())
348}
349
350fn increment_metric(f: impl FnOnce(&mut PrepArtifactMetrics)) {
351    if let Ok(mut metrics) = prep_metrics().write() {
352        f(&mut metrics);
353    }
354}
355
356fn prep_artifact_root() -> Option<PathBuf> {
357    current_prep_config().artifact_root.or_else(|| {
358        std::env::var("RUNMAT_GEOMETRY_PREP_ARTIFACT_ROOT")
359            .ok()
360            .map(PathBuf::from)
361    })
362}
363
364pub(crate) fn require_latest_prep_revision() -> bool {
365    current_prep_config()
366        .require_latest_revision
367        .unwrap_or_else(|| {
368            std::env::var("RUNMAT_GEOMETRY_PREP_REQUIRE_LATEST_REVISION")
369                .ok()
370                .map(|value| {
371                    matches!(
372                        value.to_ascii_lowercase().as_str(),
373                        "1" | "true" | "yes" | "on"
374                    )
375                })
376                .unwrap_or(true)
377        })
378}
379
380fn prep_artifact_path(root: &Path, prep_artifact_id: &str) -> PathBuf {
381    root.join("prep").join(format!("{prep_artifact_id}.json"))
382}
383
384fn prep_artifact_id_fragment(value: &str) -> String {
385    let mut fragment = String::with_capacity(value.len());
386    for byte in value.bytes() {
387        match byte {
388            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
389                fragment.push(byte as char);
390            }
391            _ => {
392                if !fragment.ends_with('_') {
393                    fragment.push('_');
394                }
395            }
396        }
397    }
398    let fragment = fragment.trim_matches('_').to_string();
399    if fragment.is_empty() {
400        "geometry".to_string()
401    } else {
402        fragment
403    }
404}
405
406fn fs_create_dir_all(path: impl Into<PathBuf>) -> std::io::Result<()> {
407    runmat_filesystem::create_dir_all(path.into())
408}
409
410fn fs_read(path: impl Into<PathBuf>) -> std::io::Result<Vec<u8>> {
411    runmat_filesystem::read(path.into())
412}
413
414fn fs_write(path: impl Into<PathBuf>, bytes: &[u8]) -> std::io::Result<()> {
415    runmat_filesystem::write(path.into(), bytes)
416}
417
418fn fs_remove_file(path: impl Into<PathBuf>) -> std::io::Result<()> {
419    match runmat_filesystem::remove_file(path.into()) {
420        Ok(()) => Ok(()),
421        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
422        Err(err) => Err(err),
423    }
424}
425
426fn fs_read_dir(path: impl Into<PathBuf>) -> std::io::Result<Vec<runmat_filesystem::DirEntry>> {
427    runmat_filesystem::read_dir(path.into())
428}
429
430fn fs_exists(path: impl Into<PathBuf>) -> std::io::Result<bool> {
431    match runmat_filesystem::metadata(path.into()) {
432        Ok(_) => Ok(true),
433        Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
434        Err(err) => Err(err),
435    }
436}
437
438#[derive(Debug, Clone, Copy)]
439struct PrepArtifactRetentionPolicy {
440    max_artifacts: usize,
441    max_artifacts_per_geometry: usize,
442    max_age_seconds: u64,
443}
444
445impl PrepArtifactRetentionPolicy {
446    fn current() -> Self {
447        let config = current_prep_config();
448        Self {
449            max_artifacts: config.max_artifacts.unwrap_or_else(|| {
450                std::env::var("RUNMAT_GEOMETRY_PREP_MAX_ARTIFACTS")
451                    .ok()
452                    .and_then(|value| value.parse::<usize>().ok())
453                    .unwrap_or(0)
454            }),
455            max_artifacts_per_geometry: config.max_artifacts_per_geometry.unwrap_or_else(|| {
456                std::env::var("RUNMAT_GEOMETRY_PREP_MAX_ARTIFACTS_PER_GEOMETRY")
457                    .ok()
458                    .and_then(|value| value.parse::<usize>().ok())
459                    .unwrap_or(0)
460            }),
461            max_age_seconds: config.max_age_seconds.unwrap_or_else(|| {
462                std::env::var("RUNMAT_GEOMETRY_PREP_MAX_AGE_SECONDS")
463                    .ok()
464                    .and_then(|value| value.parse::<u64>().ok())
465                    .unwrap_or(0)
466            }),
467        }
468    }
469}
470
471fn persist_prep_artifact(
472    geometry: &GeometryAsset,
473    prep: MeshingPrepResult,
474) -> Result<StoredGeometryPrepArtifact, String> {
475    let prep_artifact_id = format!(
476        "prep_{}_{}_{}",
477        prep_artifact_id_fragment(&geometry.geometry_id),
478        geometry.revision,
479        prep_artifact_counter().fetch_add(1, Ordering::Relaxed)
480    );
481    let artifact = StoredGeometryPrepArtifact {
482        prep_artifact_id: prep_artifact_id.clone(),
483        schema_version: "geometry_prep_artifact/v1".to_string(),
484        created_at: Utc::now().to_rfc3339(),
485        source_geometry_id: geometry.geometry_id.clone(),
486        source_geometry_revision: geometry.revision,
487        prep,
488    };
489
490    prep_store()
491        .write()
492        .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?
493        .insert(prep_artifact_id.clone(), artifact.clone());
494    increment_metric(|metrics| metrics.created_count = metrics.created_count.saturating_add(1));
495    tracing::info!(
496        target: "runmat_geometry",
497        "prep_artifact_created id={} geometry_id={} revision={}",
498        prep_artifact_id,
499        geometry.geometry_id,
500        geometry.revision
501    );
502
503    if let Some(root) = prep_artifact_root() {
504        let path = prep_artifact_path(&root, &prep_artifact_id);
505        if let Some(parent) = path.parent() {
506            fs_create_dir_all(parent)
507                .map_err(|err| format!("failed to create prep artifact directory: {err}"))?;
508        }
509        let bytes = serde_json::to_vec_pretty(&artifact)
510            .map_err(|err| format!("failed to encode prep artifact: {err}"))?;
511        fs_write(&path, &bytes).map_err(|err| format!("failed to write prep artifact: {err}"))?;
512    }
513
514    prune_prep_artifacts(PrepArtifactRetentionPolicy::current())?;
515
516    Ok(artifact)
517}
518
519pub(crate) fn load_prep_artifact(
520    prep_artifact_id: &str,
521) -> Result<Option<StoredGeometryPrepArtifact>, String> {
522    if let Some(artifact) = prep_store()
523        .read()
524        .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?
525        .get(prep_artifact_id)
526        .cloned()
527    {
528        return Ok(Some(artifact));
529    }
530
531    let Some(root) = prep_artifact_root() else {
532        return Ok(None);
533    };
534    let path = prep_artifact_path(&root, prep_artifact_id);
535    if !fs_exists(&path).map_err(|err| format!("failed to inspect prep artifact: {err}"))? {
536        return Ok(None);
537    }
538    let bytes = fs_read(&path).map_err(|err| format!("failed to read prep artifact: {err}"))?;
539    let artifact = serde_json::from_slice::<StoredGeometryPrepArtifact>(&bytes)
540        .map_err(|err| format!("failed to decode prep artifact: {err}"))?;
541    prep_store()
542        .write()
543        .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?
544        .insert(prep_artifact_id.to_string(), artifact.clone());
545    increment_metric(|metrics| metrics.loaded_count = metrics.loaded_count.saturating_add(1));
546    tracing::info!(
547        target: "runmat_geometry",
548        "prep_artifact_loaded id={} geometry_id={} revision={}",
549        prep_artifact_id,
550        artifact.source_geometry_id,
551        artifact.source_geometry_revision
552    );
553    prune_prep_artifacts(PrepArtifactRetentionPolicy::current())?;
554    Ok(Some(artifact))
555}
556
557pub(crate) fn record_prep_stale_reject() {
558    increment_metric(|metrics| {
559        metrics.stale_reject_count = metrics.stale_reject_count.saturating_add(1)
560    });
561    tracing::warn!(target: "runmat_geometry", "prep_artifact_rejected reason=stale");
562}
563
564pub(crate) fn record_prep_mismatch_reject() {
565    increment_metric(|metrics| {
566        metrics.mismatch_reject_count = metrics.mismatch_reject_count.saturating_add(1)
567    });
568    tracing::warn!(
569        target: "runmat_geometry",
570        "prep_artifact_rejected reason=mismatch"
571    );
572}
573
574pub fn geometry_prep_artifact_health_op(
575    query: GeometryPrepArtifactHealthQuery,
576    context: OperationContext,
577) -> Result<OperationEnvelope<GeometryPrepArtifactHealthResult>, OperationErrorEnvelope> {
578    let artifacts = list_prep_artifacts().map_err(|err| {
579        operation_error(
580            GEOMETRY_PREP_ARTIFACT_HEALTH_OPERATION,
581            GEOMETRY_PREP_ARTIFACT_HEALTH_OP_VERSION,
582            &context,
583            OperationErrorSpec {
584                error_code: "RM.GEOMETRY.PREP_ARTIFACT_HEALTH.STORE_FAILED",
585                error_type: OperationErrorType::Internal,
586                retryable: true,
587                severity: OperationErrorSeverity::Error,
588            },
589            format!("failed to list prep artifacts: {err}"),
590            BTreeMap::new(),
591        )
592    })?;
593
594    let now = Utc::now();
595    let mut age_seconds = Vec::new();
596    let mut per_geometry_map: HashMap<String, (u32, usize)> = HashMap::new();
597    for artifact in &artifacts {
598        if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&artifact.created_at) {
599            let age = now.signed_duration_since(created.with_timezone(&Utc));
600            age_seconds.push(age.num_seconds().max(0) as f64);
601        }
602        let entry = per_geometry_map
603            .entry(artifact.source_geometry_id.clone())
604            .or_insert((artifact.source_geometry_revision, 0));
605        if artifact.source_geometry_revision > entry.0 {
606            entry.0 = artifact.source_geometry_revision;
607        }
608        entry.1 = entry.1.saturating_add(1);
609    }
610    age_seconds.sort_by(|a, b| a.total_cmp(b));
611
612    let per_geometry = if query.include_per_geometry {
613        let mut values = per_geometry_map
614            .into_iter()
615            .map(|(geometry_id, (latest_revision, artifact_count))| {
616                GeometryPrepArtifactHealthEntry {
617                    geometry_id,
618                    latest_revision,
619                    artifact_count,
620                }
621            })
622            .collect::<Vec<_>>();
623        values.sort_by(|a, b| a.geometry_id.cmp(&b.geometry_id));
624        values
625    } else {
626        Vec::new()
627    };
628
629    let metrics = prep_metrics()
630        .read()
631        .map_err(|_| {
632            operation_error(
633                GEOMETRY_PREP_ARTIFACT_HEALTH_OPERATION,
634                GEOMETRY_PREP_ARTIFACT_HEALTH_OP_VERSION,
635                &context,
636                OperationErrorSpec {
637                    error_code: "RM.GEOMETRY.PREP_ARTIFACT_HEALTH.STORE_FAILED",
638                    error_type: OperationErrorType::Internal,
639                    retryable: true,
640                    severity: OperationErrorSeverity::Error,
641                },
642                "geometry prep metrics store lock poisoned",
643                BTreeMap::new(),
644            )
645        })?
646        .clone();
647
648    Ok(OperationEnvelope::new(
649        GEOMETRY_PREP_ARTIFACT_HEALTH_OPERATION,
650        GEOMETRY_PREP_ARTIFACT_HEALTH_OP_VERSION,
651        &context,
652        GeometryPrepArtifactHealthResult {
653            schema_version: "geometry-prep-artifact-health/v1".to_string(),
654            current_artifact_count: artifacts.len(),
655            age_p50_seconds: percentile(&age_seconds, 0.5),
656            age_p95_seconds: percentile(&age_seconds, 0.95),
657            metrics,
658            per_geometry,
659        },
660    ))
661}
662
663fn percentile(sorted: &[f64], ratio: f64) -> Option<f64> {
664    if sorted.is_empty() {
665        return None;
666    }
667    let index = ((sorted.len() - 1) as f64 * ratio.clamp(0.0, 1.0)).round() as usize;
668    sorted.get(index).copied()
669}
670
671pub(crate) fn latest_prep_revision_for_geometry(geometry_id: &str) -> Result<Option<u32>, String> {
672    let mut revisions = list_prep_artifacts()?
673        .into_iter()
674        .filter(|artifact| artifact.source_geometry_id == geometry_id)
675        .map(|artifact| artifact.source_geometry_revision)
676        .collect::<Vec<_>>();
677    revisions.sort_unstable();
678    Ok(revisions.pop())
679}
680
681fn list_prep_artifacts() -> Result<Vec<StoredGeometryPrepArtifact>, String> {
682    let mut artifacts = prep_store()
683        .read()
684        .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?
685        .values()
686        .cloned()
687        .collect::<Vec<_>>();
688    if artifacts.is_empty() {
689        if let Some(root) = prep_artifact_root() {
690            let prep_dir = root.join("prep");
691            if fs_exists(&prep_dir)
692                .map_err(|err| format!("failed to inspect prep artifacts: {err}"))?
693            {
694                for entry in fs_read_dir(&prep_dir)
695                    .map_err(|err| format!("failed to scan prep artifacts: {err}"))?
696                {
697                    let path = entry.path().to_path_buf();
698                    if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
699                        continue;
700                    }
701                    let bytes = fs_read(&path)
702                        .map_err(|err| format!("failed to read prep artifact: {err}"))?;
703                    if let Ok(artifact) =
704                        serde_json::from_slice::<StoredGeometryPrepArtifact>(&bytes)
705                    {
706                        artifacts.push(artifact);
707                    }
708                }
709            }
710        }
711    }
712    Ok(artifacts)
713}
714
715fn prune_prep_artifacts(policy: PrepArtifactRetentionPolicy) -> Result<(), String> {
716    if policy.max_artifacts == 0
717        && policy.max_artifacts_per_geometry == 0
718        && policy.max_age_seconds == 0
719    {
720        return Ok(());
721    }
722
723    let now = Utc::now();
724    let mut artifacts = list_prep_artifacts()?;
725    artifacts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
726
727    let mut remove_ids = Vec::new();
728    if policy.max_age_seconds > 0 {
729        for artifact in &artifacts {
730            if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&artifact.created_at) {
731                let age = now.signed_duration_since(created.with_timezone(&Utc));
732                if age.num_seconds().max(0) as u64 > policy.max_age_seconds {
733                    remove_ids.push(artifact.prep_artifact_id.clone());
734                }
735            }
736        }
737    }
738
739    if policy.max_artifacts_per_geometry > 0 {
740        let mut per_geometry_counts: HashMap<String, usize> = HashMap::new();
741        for artifact in &artifacts {
742            let count = per_geometry_counts
743                .entry(artifact.source_geometry_id.clone())
744                .or_default();
745            *count += 1;
746            if *count > policy.max_artifacts_per_geometry {
747                remove_ids.push(artifact.prep_artifact_id.clone());
748            }
749        }
750    }
751
752    if policy.max_artifacts > 0 {
753        for (index, artifact) in artifacts.iter().enumerate() {
754            if index >= policy.max_artifacts {
755                remove_ids.push(artifact.prep_artifact_id.clone());
756            }
757        }
758    }
759
760    remove_ids.sort();
761    remove_ids.dedup();
762    if remove_ids.is_empty() {
763        return Ok(());
764    }
765
766    {
767        let mut store = prep_store()
768            .write()
769            .map_err(|_| "geometry prep artifact store lock poisoned".to_string())?;
770        for id in &remove_ids {
771            store.remove(id);
772        }
773    }
774
775    if let Some(root) = prep_artifact_root() {
776        for id in &remove_ids {
777            let path = prep_artifact_path(&root, id);
778            let _ = fs_remove_file(path);
779        }
780    }
781
782    increment_metric(|metrics| {
783        metrics.pruned_count = metrics.pruned_count.saturating_add(remove_ids.len() as u64)
784    });
785    tracing::info!(
786        target: "runmat_geometry",
787        "prep_artifact_pruned count={}",
788        remove_ids.len()
789    );
790
791    Ok(())
792}
793
794#[doc(hidden)]
795pub fn reset_prep_artifact_store_for_tests() {
796    if let Ok(mut store) = prep_store().write() {
797        store.clear();
798    }
799    prep_artifact_counter().store(1, Ordering::Relaxed);
800    if let Ok(mut metrics) = prep_metrics().write() {
801        *metrics = PrepArtifactMetrics::default();
802    }
803    if let Ok(mut config) = prep_config().write() {
804        *config = GeometryPrepArtifactConfig::default();
805    }
806}
807
808#[cfg(test)]
809pub(crate) fn prep_artifact_test_guard() -> MutexGuard<'static, ()> {
810    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
811    LOCK.get_or_init(|| Mutex::new(()))
812        .lock()
813        .unwrap_or_else(|poisoned| poisoned.into_inner())
814}
815
816impl Default for GeometryPrepForAnalysisSpec {
817    fn default() -> Self {
818        Self {
819            profile: GeometryPrepProfile::AnalysisReady,
820            target_element_budget: 250_000,
821        }
822    }
823}
824
825pub trait GeometryViewCaptureAdapter {
826    fn adapter_name(&self) -> &'static str;
827    fn capture(
828        &self,
829        asset: &GeometryAsset,
830        view_spec: &GeometryCaptureViewSpec,
831    ) -> Result<GeometryCaptureViewResult, String>;
832}
833
834thread_local! {
835    static GEOMETRY_CAPTURE_ADAPTER: RefCell<Option<&'static dyn GeometryViewCaptureAdapter>> =
836        RefCell::new(None);
837}
838
839mod capture;
840
841pub struct ThreadGeometryCaptureAdapterGuard {
842    previous: Option<&'static dyn GeometryViewCaptureAdapter>,
843}
844
845impl ThreadGeometryCaptureAdapterGuard {
846    pub fn set(adapter: Option<&'static dyn GeometryViewCaptureAdapter>) -> Self {
847        let previous = GEOMETRY_CAPTURE_ADAPTER.with(|slot| slot.replace(adapter));
848        Self { previous }
849    }
850}
851
852impl Drop for ThreadGeometryCaptureAdapterGuard {
853    fn drop(&mut self) {
854        GEOMETRY_CAPTURE_ADAPTER.with(|slot| {
855            slot.replace(self.previous.take());
856        });
857    }
858}
859
860pub fn geometry_inspect_op(
861    path: &str,
862    bytes: &[u8],
863    context: OperationContext,
864) -> Result<OperationEnvelope<GeometryInspectResult>, OperationErrorEnvelope> {
865    let format = runmat_geometry_io::detect_geometry_format(path, bytes);
866    let data = GeometryInspectResult {
867        format: format_name(format).to_string(),
868        byte_count: bytes.len(),
869    };
870    Ok(OperationEnvelope::new(
871        GEOMETRY_INSPECT_OPERATION,
872        GEOMETRY_INSPECT_OP_VERSION,
873        &context,
874        data,
875    ))
876}
877
878pub fn geometry_inspect(path: &str, bytes: &[u8]) -> BuiltinResult<GeometryInspectResult> {
879    let envelope =
880        geometry_inspect_op(path, bytes, OperationContext::new(None, None)).map_err(|error| {
881            build_runtime_error(error.message)
882                .with_builtin(GEOMETRY_INSPECT_OPERATION)
883                .with_identifier("RunMat:GeometryInspectFailed")
884                .build()
885        })?;
886    Ok(envelope.data)
887}
888
889pub fn geometry_load_op(
890    path: &str,
891    bytes: &[u8],
892    context: OperationContext,
893) -> Result<OperationEnvelope<GeometryAsset>, OperationErrorEnvelope> {
894    geometry_load_with_options_op(path, bytes, GeometryImportOptions::default(), context)
895}
896
897pub fn geometry_load_with_options_op(
898    path: &str,
899    bytes: &[u8],
900    options: GeometryImportOptions,
901    context: OperationContext,
902) -> Result<OperationEnvelope<GeometryAsset>, OperationErrorEnvelope> {
903    let import_context = current_geometry_import_context();
904    let imported = import_geometry_with_context(path, bytes, options, &import_context)
905        .map_err(|error| map_geometry_load_error(path, error, &context))?;
906    Ok(OperationEnvelope::new(
907        GEOMETRY_LOAD_OPERATION,
908        GEOMETRY_LOAD_OP_VERSION,
909        &context,
910        imported.asset,
911    ))
912}
913
914pub fn geometry_load(path: &str, bytes: &[u8]) -> BuiltinResult<GeometryAsset> {
915    let envelope =
916        geometry_load_op(path, bytes, OperationContext::new(None, None)).map_err(|error| {
917            build_runtime_error(error.message)
918                .with_builtin(GEOMETRY_LOAD_OPERATION)
919                .with_identifier("RunMat:GeometryLoadFailed")
920                .build()
921        })?;
922    Ok(envelope.data)
923}
924
925fn current_geometry_import_context() -> GeometryImportContext {
926    crate::interrupt::current_interrupt()
927        .map(GeometryImportContext::with_cancellation)
928        .unwrap_or_default()
929}
930
931pub fn geometry_compute_stats_op(
932    asset: &GeometryAsset,
933    context: OperationContext,
934) -> Result<OperationEnvelope<GeometryStats>, OperationErrorEnvelope> {
935    Ok(OperationEnvelope::new(
936        GEOMETRY_COMPUTE_STATS_OPERATION,
937        GEOMETRY_COMPUTE_STATS_OP_VERSION,
938        &context,
939        compute_stats(asset),
940    ))
941}
942
943pub fn geometry_compute_stats(asset: &GeometryAsset) -> BuiltinResult<GeometryStats> {
944    let envelope =
945        geometry_compute_stats_op(asset, OperationContext::new(None, None)).map_err(|error| {
946            build_runtime_error(error.message)
947                .with_builtin(GEOMETRY_COMPUTE_STATS_OPERATION)
948                .with_identifier("RunMat:GeometryStatsFailed")
949                .build()
950        })?;
951    Ok(envelope.data)
952}
953
954pub fn geometry_asset_summary(asset: &GeometryAsset) -> GeometryAssetSummary {
955    geometry_asset_summary_with_options(asset, DEFAULT_MAPPING_RANGE_PREVIEW_LIMIT)
956}
957
958pub fn geometry_asset_summary_with_options(
959    asset: &GeometryAsset,
960    range_preview_limit: usize,
961) -> GeometryAssetSummary {
962    GeometryAssetSummary {
963        geometry_id: asset.geometry_id.clone(),
964        revision: asset.revision,
965        source: asset.source.clone(),
966        source_geometry: asset.source_geometry.clone(),
967        tessellation_profile: asset.tessellation_profile.clone(),
968        units: asset.units,
969        meshes: mesh_summaries(asset),
970        mapping_summary: region_mapping_summary(asset, range_preview_limit),
971        cad: cad_summary(asset),
972    }
973}
974
975fn mesh_summaries(asset: &GeometryAsset) -> Vec<GeometryMeshSummary> {
976    asset
977        .meshes
978        .iter()
979        .map(|mesh| {
980            let surface_mesh = asset
981                .surface_meshes
982                .iter()
983                .find(|surface| surface.mesh_id == mesh.mesh_id);
984            GeometryMeshSummary {
985                mesh_id: mesh.mesh_id.clone(),
986                kind: mesh.kind,
987                vertex_count: mesh.vertex_count,
988                element_count: mesh.element_count,
989                surface_vertex_count: surface_mesh.map(|surface| surface.vertices.len() as u64),
990                surface_triangle_count: surface_mesh.map(|surface| surface.triangles.len() as u64),
991                bounds: surface_mesh.and_then(|surface| bounds_for_vertices(&surface.vertices)),
992            }
993        })
994        .collect()
995}
996
997fn bounds_for_vertices(vertices: &[[f64; 3]]) -> Option<GeometryBoundsSummary> {
998    let first = vertices.first().copied()?;
999    let mut min = first;
1000    let mut max = first;
1001    for vertex in vertices.iter().skip(1) {
1002        for axis in 0..3 {
1003            min[axis] = min[axis].min(vertex[axis]);
1004            max[axis] = max[axis].max(vertex[axis]);
1005        }
1006    }
1007    Some(GeometryBoundsSummary { min, max })
1008}
1009
1010fn region_mapping_summary(
1011    asset: &GeometryAsset,
1012    range_preview_limit: usize,
1013) -> GeometryRegionMappingSummary {
1014    let mut mapped_regions = BTreeSet::new();
1015    let mut total_entity_count = 0_u64;
1016    let entries = asset
1017        .region_entity_mappings
1018        .iter()
1019        .map(|mapping| {
1020            mapped_regions.insert(mapping.region_id.clone());
1021            let entity_count = mapping.entity_count();
1022            total_entity_count = total_entity_count.saturating_add(entity_count);
1023            let range_count = mapping.ranges.len();
1024            GeometryRegionMappingSummaryEntry {
1025                region_id: mapping.region_id.clone(),
1026                mesh_id: mapping.mesh_id.clone(),
1027                entity_kind: mapping.entity_kind,
1028                range_count,
1029                entity_count,
1030                range_preview: mapping
1031                    .ranges
1032                    .iter()
1033                    .take(range_preview_limit)
1034                    .copied()
1035                    .collect(),
1036                truncated: range_count > range_preview_limit,
1037            }
1038        })
1039        .collect();
1040
1041    GeometryRegionMappingSummary {
1042        mapping_count: asset.region_entity_mappings.len(),
1043        mapped_region_count: mapped_regions.len(),
1044        total_entity_count,
1045        range_preview_limit,
1046        entries,
1047    }
1048}
1049
1050fn cad_summary(asset: &GeometryAsset) -> GeometryCadSummary {
1051    let importer_parts = asset.source.importer_version.split('/').collect::<Vec<_>>();
1052    let backend = match importer_parts.as_slice() {
1053        ["cad", backend, ..] => Some((*backend).to_string()),
1054        ["step", ..] if asset.source_geometry.kind == SourceGeometryKind::Cad => {
1055            Some("metadata".to_string())
1056        }
1057        _ => None,
1058    };
1059    let source_format = match importer_parts.as_slice() {
1060        ["cad", _, format, ..] => Some((*format).to_string()),
1061        [format, ..] if asset.source_geometry.kind == SourceGeometryKind::Cad => {
1062            Some((*format).to_string())
1063        }
1064        _ => None,
1065    };
1066    let face_region_ids = asset
1067        .regions
1068        .iter()
1069        .filter(|region| region.tag.as_deref() == Some("occt_face"))
1070        .map(|region| region.region_id.as_str())
1071        .collect::<BTreeSet<_>>();
1072    let mapped_face_region_ids = asset
1073        .region_entity_mappings
1074        .iter()
1075        .filter_map(|mapping| {
1076            (mapping.entity_kind == EntityKind::Face
1077                && face_region_ids.contains(mapping.region_id.as_str()))
1078            .then_some(mapping.region_id.as_str())
1079        })
1080        .collect::<BTreeSet<_>>();
1081    let semantic_region_ids = asset
1082        .regions
1083        .iter()
1084        .filter(|region| {
1085            region.cad_ownership.is_some()
1086                && region
1087                    .tag
1088                    .as_deref()
1089                    .is_some_and(|tag| tag.starts_with("cad_"))
1090        })
1091        .map(|region| region.region_id.as_str())
1092        .collect::<BTreeSet<_>>();
1093    let mapped_semantic_region_ids = asset
1094        .region_entity_mappings
1095        .iter()
1096        .filter_map(|mapping| {
1097            (mapping.entity_kind == EntityKind::Face
1098                && semantic_region_ids.contains(mapping.region_id.as_str()))
1099            .then_some(mapping.region_id.as_str())
1100        })
1101        .collect::<BTreeSet<_>>();
1102    let region_status = if asset.source_geometry.kind != SourceGeometryKind::Cad {
1103        GeometryCadRegionStatus::NotCad
1104    } else if mapped_face_region_ids.is_empty() {
1105        GeometryCadRegionStatus::MetadataOnly
1106    } else if !mapped_semantic_region_ids.is_empty() {
1107        GeometryCadRegionStatus::SemanticRegions
1108    } else {
1109        GeometryCadRegionStatus::GenericFaceTopology
1110    };
1111
1112    GeometryCadSummary {
1113        backend,
1114        source_format,
1115        face_region_count: face_region_ids.len(),
1116        mapped_face_region_count: mapped_face_region_ids.len(),
1117        semantic_region_count: semantic_region_ids.len(),
1118        mapped_semantic_region_count: mapped_semantic_region_ids.len(),
1119        region_status,
1120    }
1121}
1122
1123pub fn geometry_list_regions_op(
1124    asset: &GeometryAsset,
1125    context: OperationContext,
1126) -> Result<OperationEnvelope<GeometryRegionsResult>, OperationErrorEnvelope> {
1127    Ok(OperationEnvelope::new(
1128        GEOMETRY_LIST_REGIONS_OPERATION,
1129        GEOMETRY_LIST_REGIONS_OP_VERSION,
1130        &context,
1131        GeometryRegionsResult {
1132            regions: asset.regions.clone(),
1133        },
1134    ))
1135}
1136
1137pub fn geometry_list_regions(asset: &GeometryAsset) -> BuiltinResult<GeometryRegionsResult> {
1138    let envelope =
1139        geometry_list_regions_op(asset, OperationContext::new(None, None)).map_err(|error| {
1140            build_runtime_error(error.message)
1141                .with_builtin(GEOMETRY_LIST_REGIONS_OPERATION)
1142                .with_identifier("RunMat:GeometryListRegionsFailed")
1143                .build()
1144        })?;
1145    Ok(envelope.data)
1146}
1147
1148pub fn geometry_query_entities_op(
1149    asset: &GeometryAsset,
1150    query: GeometryEntityQuery,
1151    context: OperationContext,
1152) -> Result<OperationEnvelope<GeometryEntityQueryResult>, OperationErrorEnvelope> {
1153    let requested_limit = query.limit.unwrap_or(DEFAULT_QUERY_LIMIT);
1154    if requested_limit == 0 {
1155        return Err(operation_error(
1156            GEOMETRY_QUERY_ENTITIES_OPERATION,
1157            GEOMETRY_QUERY_ENTITIES_OP_VERSION,
1158            &context,
1159            OperationErrorSpec {
1160                error_code: "RM.GEOMETRY.QUERY_ENTITIES.INVALID_LIMIT",
1161                error_type: OperationErrorType::Input,
1162                retryable: false,
1163                severity: OperationErrorSeverity::Error,
1164            },
1165            "entity query limit must be greater than zero",
1166            BTreeMap::new(),
1167        ));
1168    }
1169
1170    if let Some(region_id) = query.region_id.as_ref() {
1171        find_region(asset, region_id)
1172            .map_err(|error| map_geometry_query_error(region_id, error, &context))?;
1173        return Ok(OperationEnvelope::new(
1174            GEOMETRY_QUERY_ENTITIES_OPERATION,
1175            GEOMETRY_QUERY_ENTITIES_OP_VERSION,
1176            &context,
1177            query_region_entities(asset, &query, region_id, requested_limit),
1178        ));
1179    }
1180
1181    let mut entities = Vec::new();
1182    let mut produced_total = 0usize;
1183
1184    for mesh in &asset.meshes {
1185        if query
1186            .mesh_id
1187            .as_ref()
1188            .is_some_and(|mesh_id| mesh_id != &mesh.mesh_id)
1189        {
1190            continue;
1191        }
1192
1193        let count = match query.entity_kind {
1194            EntityKind::Node => mesh.vertex_count as usize,
1195            EntityKind::Element | EntityKind::Face => mesh.element_count as usize,
1196            EntityKind::Edge => 0,
1197        };
1198
1199        produced_total += count;
1200
1201        if entities.len() >= requested_limit {
1202            continue;
1203        }
1204
1205        let remaining = requested_limit - entities.len();
1206        let emit = count.min(remaining);
1207        for entity_id in 0..emit {
1208            entities.push(EntityRef {
1209                geometry_id: asset.geometry_id.clone(),
1210                geometry_revision: asset.revision,
1211                mesh_id: mesh.mesh_id.clone(),
1212                entity_kind: query.entity_kind,
1213                entity_id: entity_id as u64,
1214            });
1215        }
1216    }
1217
1218    Ok(OperationEnvelope::new(
1219        GEOMETRY_QUERY_ENTITIES_OPERATION,
1220        GEOMETRY_QUERY_ENTITIES_OP_VERSION,
1221        &context,
1222        GeometryEntityQueryResult {
1223            entities,
1224            truncated: produced_total > requested_limit,
1225        },
1226    ))
1227}
1228
1229fn query_region_entities(
1230    asset: &GeometryAsset,
1231    query: &GeometryEntityQuery,
1232    region_id: &str,
1233    requested_limit: usize,
1234) -> GeometryEntityQueryResult {
1235    if query.entity_kind == EntityKind::Node {
1236        return query_region_nodes(asset, query, region_id, requested_limit);
1237    }
1238
1239    let mut entities = Vec::new();
1240    let mut produced_total = 0usize;
1241    for mapping in asset.region_entity_mappings.iter().filter(|mapping| {
1242        mapping.region_id == region_id
1243            && query
1244                .mesh_id
1245                .as_ref()
1246                .is_none_or(|mesh_id| mesh_id == &mapping.mesh_id)
1247            && mapping_matches_query_kind(mapping.entity_kind, query.entity_kind)
1248    }) {
1249        let mapped_total = mapping.entity_count() as usize;
1250        produced_total = produced_total.saturating_add(mapped_total);
1251        if entities.len() >= requested_limit {
1252            continue;
1253        }
1254        for range in &mapping.ranges {
1255            let Some(end) = range.end_exclusive() else {
1256                continue;
1257            };
1258            for entity_id in range.start..end {
1259                if entities.len() >= requested_limit {
1260                    break;
1261                }
1262                entities.push(EntityRef {
1263                    geometry_id: asset.geometry_id.clone(),
1264                    geometry_revision: asset.revision,
1265                    mesh_id: mapping.mesh_id.clone(),
1266                    entity_kind: query.entity_kind,
1267                    entity_id,
1268                });
1269            }
1270        }
1271    }
1272
1273    GeometryEntityQueryResult {
1274        entities,
1275        truncated: produced_total > requested_limit,
1276    }
1277}
1278
1279fn query_region_nodes(
1280    asset: &GeometryAsset,
1281    query: &GeometryEntityQuery,
1282    region_id: &str,
1283    requested_limit: usize,
1284) -> GeometryEntityQueryResult {
1285    let mut node_refs = BTreeSet::<(String, u64)>::new();
1286    let mut truncated = false;
1287
1288    for mapping in asset.region_entity_mappings.iter().filter(|mapping| {
1289        mapping.region_id == region_id
1290            && query
1291                .mesh_id
1292                .as_ref()
1293                .is_none_or(|mesh_id| mesh_id == &mapping.mesh_id)
1294            && mapping_matches_query_kind(mapping.entity_kind, EntityKind::Face)
1295    }) {
1296        let Some(surface_mesh) = asset
1297            .surface_meshes
1298            .iter()
1299            .find(|mesh| mesh.mesh_id == mapping.mesh_id)
1300        else {
1301            continue;
1302        };
1303        for range in &mapping.ranges {
1304            let Some(end) = range.end_exclusive() else {
1305                continue;
1306            };
1307            for face_id in range.start..end {
1308                let Some(triangle) = surface_mesh.triangles.get(face_id as usize) else {
1309                    continue;
1310                };
1311                for vertex_id in triangle {
1312                    node_refs.insert((mapping.mesh_id.clone(), *vertex_id as u64));
1313                    if node_refs.len() > requested_limit {
1314                        truncated = true;
1315                        break;
1316                    }
1317                }
1318                if truncated {
1319                    break;
1320                }
1321            }
1322            if truncated {
1323                break;
1324            }
1325        }
1326        if truncated {
1327            break;
1328        }
1329    }
1330
1331    let entities = node_refs
1332        .into_iter()
1333        .take(requested_limit)
1334        .map(|(mesh_id, entity_id)| EntityRef {
1335            geometry_id: asset.geometry_id.clone(),
1336            geometry_revision: asset.revision,
1337            mesh_id,
1338            entity_kind: EntityKind::Node,
1339            entity_id,
1340        })
1341        .collect();
1342
1343    GeometryEntityQueryResult {
1344        entities,
1345        truncated,
1346    }
1347}
1348
1349fn mapping_matches_query_kind(mapping_kind: EntityKind, query_kind: EntityKind) -> bool {
1350    mapping_kind == query_kind
1351        || matches!(
1352            (mapping_kind, query_kind),
1353            (EntityKind::Face, EntityKind::Element) | (EntityKind::Element, EntityKind::Face)
1354        )
1355}
1356
1357pub fn geometry_query_entities(
1358    asset: &GeometryAsset,
1359    query: GeometryEntityQuery,
1360) -> BuiltinResult<GeometryEntityQueryResult> {
1361    let envelope = geometry_query_entities_op(asset, query, OperationContext::new(None, None))
1362        .map_err(|error| {
1363            build_runtime_error(error.message)
1364                .with_builtin(GEOMETRY_QUERY_ENTITIES_OPERATION)
1365                .with_identifier("RunMat:GeometryQueryEntitiesFailed")
1366                .build()
1367        })?;
1368    Ok(envelope.data)
1369}
1370
1371pub fn geometry_capture_view_op(
1372    asset: &GeometryAsset,
1373    view_spec: GeometryCaptureViewSpec,
1374    context: OperationContext,
1375) -> Result<OperationEnvelope<GeometryCaptureViewResult>, OperationErrorEnvelope> {
1376    if view_spec.width == 0 || view_spec.height == 0 {
1377        return Err(operation_error(
1378            GEOMETRY_CAPTURE_VIEW_OPERATION,
1379            GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1380            &context,
1381            OperationErrorSpec {
1382                error_code: "RM.GEOMETRY.CAPTURE_VIEW.INVALID_SPEC",
1383                error_type: OperationErrorType::Input,
1384                retryable: false,
1385                severity: OperationErrorSeverity::Error,
1386            },
1387            "capture view dimensions must be greater than zero",
1388            BTreeMap::from([
1389                ("width".to_string(), view_spec.width.to_string()),
1390                ("height".to_string(), view_spec.height.to_string()),
1391            ]),
1392        ));
1393    }
1394
1395    let adapter = GEOMETRY_CAPTURE_ADAPTER.with(|slot| *slot.borrow());
1396    if let Some(adapter) = adapter {
1397        let capture = adapter.capture(asset, &view_spec).map_err(|message| {
1398            operation_error(
1399                GEOMETRY_CAPTURE_VIEW_OPERATION,
1400                GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1401                &context,
1402                OperationErrorSpec {
1403                    error_code: "RM.GEOMETRY.CAPTURE_VIEW.BACKEND_FAILED",
1404                    error_type: OperationErrorType::Backend,
1405                    retryable: true,
1406                    severity: OperationErrorSeverity::Error,
1407                },
1408                message,
1409                BTreeMap::from([
1410                    ("geometry_id".to_string(), asset.geometry_id.clone()),
1411                    ("adapter".to_string(), adapter.adapter_name().to_string()),
1412                ]),
1413            )
1414        })?;
1415        return Ok(OperationEnvelope::new(
1416            GEOMETRY_CAPTURE_VIEW_OPERATION,
1417            GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1418            &context,
1419            capture,
1420        ));
1421    }
1422
1423    if view_spec.format.eq_ignore_ascii_case("svg") {
1424        let capture = DEFAULT_SVG_CAPTURE_ADAPTER
1425            .capture(asset, &view_spec)
1426            .map_err(|message| {
1427                operation_error(
1428                    GEOMETRY_CAPTURE_VIEW_OPERATION,
1429                    GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1430                    &context,
1431                    OperationErrorSpec {
1432                        error_code: "RM.GEOMETRY.CAPTURE_VIEW.BACKEND_FAILED",
1433                        error_type: OperationErrorType::Backend,
1434                        retryable: true,
1435                        severity: OperationErrorSeverity::Error,
1436                    },
1437                    message,
1438                    BTreeMap::from([
1439                        ("geometry_id".to_string(), asset.geometry_id.clone()),
1440                        (
1441                            "adapter".to_string(),
1442                            DEFAULT_SVG_CAPTURE_ADAPTER.adapter_name().to_string(),
1443                        ),
1444                    ]),
1445                )
1446            })?;
1447        return Ok(OperationEnvelope::new(
1448            GEOMETRY_CAPTURE_VIEW_OPERATION,
1449            GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1450            &context,
1451            capture,
1452        ));
1453    }
1454
1455    Err(operation_error(
1456        GEOMETRY_CAPTURE_VIEW_OPERATION,
1457        GEOMETRY_CAPTURE_VIEW_OP_VERSION,
1458        &context,
1459        OperationErrorSpec {
1460            error_code: "RM.GEOMETRY.CAPTURE_VIEW.UNSUPPORTED",
1461            error_type: OperationErrorType::Backend,
1462            retryable: false,
1463            severity: OperationErrorSeverity::Error,
1464        },
1465        "geometry view capture is not wired in runtime yet",
1466        BTreeMap::from([("geometry_id".to_string(), asset.geometry_id.clone())]),
1467    ))
1468}
1469
1470pub fn geometry_capture_view(
1471    asset: &GeometryAsset,
1472    view_spec: GeometryCaptureViewSpec,
1473) -> BuiltinResult<GeometryCaptureViewResult> {
1474    let envelope = geometry_capture_view_op(asset, view_spec, OperationContext::new(None, None))
1475        .map_err(|error| {
1476            build_runtime_error(error.message)
1477                .with_builtin(GEOMETRY_CAPTURE_VIEW_OPERATION)
1478                .with_identifier("RunMat:GeometryCaptureViewFailed")
1479                .build()
1480        })?;
1481    Ok(envelope.data)
1482}
1483
1484#[cfg(feature = "plot-core")]
1485pub fn geometry_preview_scene(
1486    asset: &GeometryAsset,
1487    title: impl Into<String>,
1488    options: GeometryPreviewSceneOptions,
1489) -> Result<runmat_plot::GeometryScene, String> {
1490    if asset.surface_meshes.is_empty() {
1491        return Err("geometry asset does not contain renderable surface mesh data".to_string());
1492    }
1493
1494    let triangles_per_chunk = options.triangles_per_chunk.max(1);
1495    let cad_presentation = options.presentation == GeometryPreviewPresentation::Cad;
1496    let mut chunks = Vec::new();
1497
1498    for (mesh_index, surface_mesh) in asset.surface_meshes.iter().enumerate() {
1499        if surface_mesh.vertices.is_empty() || surface_mesh.triangles.is_empty() {
1500            continue;
1501        }
1502        let positions = surface_mesh
1503            .vertices
1504            .iter()
1505            .map(|position| {
1506                Ok([
1507                    f64_to_f32_coordinate(position[0])?,
1508                    f64_to_f32_coordinate(position[1])?,
1509                    f64_to_f32_coordinate(position[2])?,
1510                ])
1511            })
1512            .collect::<Result<Vec<_>, String>>()?;
1513
1514        let presentation = cad_presentation.then(|| {
1515            cad_mesh_presentation(
1516                asset,
1517                &surface_mesh.mesh_id,
1518                surface_mesh.triangles.len(),
1519                surface_mesh.vertices.len(),
1520            )
1521        });
1522        let base_color = if cad_presentation {
1523            CAD_DEFAULT_FACE_COLOR
1524        } else {
1525            preview_mesh_color(mesh_index)
1526        };
1527        let alpha = if options.xray { 0.34 } else { base_color.w };
1528        let mut material = runmat_plot::cad_default_material();
1529        material.albedo = glam::Vec4::new(base_color.x, base_color.y, base_color.z, alpha);
1530        if options.xray {
1531            material.alpha_mode = runmat_plot::core::AlphaMode::Blend;
1532        }
1533
1534        let mut chunk_index = 0usize;
1535        let mut chunk_start_triangle = 0usize;
1536        while chunk_start_triangle < surface_mesh.triangles.len() {
1537            let owner_node_ids = presentation
1538                .as_ref()
1539                .map(|item| {
1540                    item.owner_node_ids_for_triangle(chunk_start_triangle)
1541                        .to_vec()
1542                })
1543                .unwrap_or_default();
1544            let mut chunk_end_triangle = chunk_start_triangle + 1;
1545            while chunk_end_triangle < surface_mesh.triangles.len()
1546                && chunk_end_triangle.saturating_sub(chunk_start_triangle) < triangles_per_chunk
1547            {
1548                let next_owner_node_ids = presentation
1549                    .as_ref()
1550                    .map(|item| item.owner_node_ids_for_triangle(chunk_end_triangle))
1551                    .unwrap_or(&[]);
1552                if next_owner_node_ids != owner_node_ids.as_slice() {
1553                    break;
1554                }
1555                chunk_end_triangle += 1;
1556            }
1557            let triangles = &surface_mesh.triangles[chunk_start_triangle..chunk_end_triangle];
1558            let mut remap = HashMap::<u32, u32>::with_capacity(triangles.len() * 3);
1559            let mut local_positions = Vec::<[f32; 3]>::new();
1560            let mut local_colors = Vec::<[f32; 4]>::new();
1561            let mut indices = Vec::<u32>::with_capacity(triangles.len() * 3);
1562
1563            for triangle in triangles {
1564                for source_index in triangle {
1565                    let local_index = if let Some(local_index) = remap.get(source_index) {
1566                        *local_index
1567                    } else {
1568                        let source_index_usize = usize::try_from(*source_index).map_err(|_| {
1569                            format!(
1570                                "surface mesh '{}' has an invalid vertex index",
1571                                surface_mesh.mesh_id
1572                            )
1573                        })?;
1574                        let position = positions.get(source_index_usize).ok_or_else(|| {
1575                            format!(
1576                                "surface mesh '{}' references vertex {} outside {} vertices",
1577                                surface_mesh.mesh_id,
1578                                source_index,
1579                                positions.len()
1580                            )
1581                        })?;
1582                        let local_index = u32::try_from(local_positions.len()).map_err(|_| {
1583                            format!(
1584                                "surface mesh '{}' preview chunk exceeded u32 vertex indices",
1585                                surface_mesh.mesh_id
1586                            )
1587                        })?;
1588                        local_positions.push(*position);
1589                        let vertex_color = presentation
1590                            .as_ref()
1591                            .and_then(|item| item.vertex_colors.as_ref())
1592                            .and_then(|colors| colors.get(source_index_usize))
1593                            .copied()
1594                            .unwrap_or(base_color);
1595                        local_colors.push([vertex_color.x, vertex_color.y, vertex_color.z, alpha]);
1596                        remap.insert(*source_index, local_index);
1597                        local_index
1598                    };
1599                    indices.push(local_index);
1600                }
1601            }
1602
1603            let normals = local_vertex_normals(&local_positions, &indices);
1604            let vertices = local_positions
1605                .into_iter()
1606                .zip(local_colors)
1607                .zip(normals)
1608                .map(|((position, color), normal)| {
1609                    runmat_plot::geometry_scene_vertex(position, color, normal)
1610                })
1611                .collect::<Vec<_>>();
1612            let regions = geometry_scene_regions_for_surface_chunk(
1613                asset,
1614                &surface_mesh.mesh_id,
1615                chunk_start_triangle,
1616                triangles.len(),
1617            );
1618            let chunk = runmat_plot::GeometrySceneChunk::indexed_triangles(
1619                format!("{}:chunk_{chunk_index}", surface_mesh.mesh_id),
1620                vertices,
1621                indices,
1622                material.clone(),
1623            )
1624            .with_mesh_id(surface_mesh.mesh_id.clone())
1625            .with_label(format!(
1626                "{} chunk {}",
1627                surface_mesh.mesh_id,
1628                chunk_index + 1
1629            ))
1630            .with_regions(regions)
1631            .with_owner_node_ids(owner_node_ids);
1632            chunks.push(chunk);
1633            chunk_start_triangle = chunk_end_triangle;
1634            chunk_index += 1;
1635        }
1636    }
1637
1638    if chunks.is_empty() {
1639        return Err("geometry asset did not contain renderable surface mesh triangles".to_string());
1640    }
1641
1642    Ok(
1643        runmat_plot::GeometryScene::new(geometry_scene_id(asset), asset.revision as u64, chunks)
1644            .with_title(title),
1645    )
1646}
1647
1648#[cfg(feature = "plot-core")]
1649pub fn geometry_preview_scene_overlay(
1650    asset: &GeometryAsset,
1651    source_name: Option<String>,
1652    status: runmat_plot::GeometrySceneCompleteness,
1653    quality_label: impl Into<String>,
1654    format: Option<String>,
1655    byte_count: Option<u64>,
1656    allow_create_fea_study: bool,
1657) -> runmat_plot::GeometrySceneOverlay {
1658    let mapping_summary = region_mapping_summary(asset, DEFAULT_MAPPING_RANGE_PREVIEW_LIMIT);
1659    let source_label = Some(format!(
1660        "{} / {}",
1661        source_geometry_kind_label(asset.source_geometry.kind),
1662        asset.source.importer_version
1663    ));
1664    let warnings = asset
1665        .diagnostics
1666        .iter()
1667        .filter(|diagnostic| {
1668            matches!(
1669                diagnostic.severity,
1670                DiagnosticSeverity::Warning | DiagnosticSeverity::Error
1671            )
1672        })
1673        .take(4)
1674        .map(|diagnostic| diagnostic.message.clone())
1675        .collect();
1676
1677    runmat_plot::GeometrySceneOverlay {
1678        source_name,
1679        status,
1680        quality_label: Some(quality_label.into()),
1681        format,
1682        source_label,
1683        allow_create_fea_study,
1684        byte_count,
1685        mesh_count: asset.meshes.len(),
1686        vertex_count: asset
1687            .surface_meshes
1688            .iter()
1689            .map(|mesh| mesh.vertices.len())
1690            .sum(),
1691        triangle_count: asset
1692            .surface_meshes
1693            .iter()
1694            .map(|mesh| mesh.triangles.len())
1695            .sum(),
1696        progress_percent: None,
1697        region_count: asset.regions.len(),
1698        mapped_region_count: mapping_summary.mapped_region_count,
1699        assembly_nodes: asset
1700            .source_geometry
1701            .assembly
1702            .as_ref()
1703            .map(|node| vec![geometry_scene_assembly_node(node)])
1704            .unwrap_or_default(),
1705        regions: geometry_scene_region_summaries(asset),
1706        warnings,
1707    }
1708}
1709
1710#[cfg(feature = "plot-core")]
1711fn geometry_scene_assembly_node(node: &AssemblyNode) -> runmat_plot::GeometrySceneAssemblyNode {
1712    runmat_plot::GeometrySceneAssemblyNode {
1713        node_id: node.node_id.clone(),
1714        label: node.label.clone(),
1715        children: node
1716            .children
1717            .iter()
1718            .map(geometry_scene_assembly_node)
1719            .collect(),
1720    }
1721}
1722
1723#[cfg(feature = "plot-core")]
1724fn geometry_scene_region_summaries(
1725    asset: &GeometryAsset,
1726) -> Vec<runmat_plot::GeometrySceneRegionSummary> {
1727    let mut triangle_counts: BTreeMap<String, usize> = BTreeMap::new();
1728    for mapping in &asset.region_entity_mappings {
1729        if !matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element) {
1730            continue;
1731        }
1732        let count = mapping
1733            .ranges
1734            .iter()
1735            .filter_map(|range| {
1736                range
1737                    .end_exclusive()
1738                    .map(|end| end.saturating_sub(range.start))
1739            })
1740            .map(|count| usize::try_from(count).unwrap_or(usize::MAX))
1741            .fold(0usize, |total, count| total.saturating_add(count));
1742        triangle_counts
1743            .entry(mapping.region_id.clone())
1744            .and_modify(|total| *total = total.saturating_add(count))
1745            .or_insert(count);
1746    }
1747
1748    asset
1749        .regions
1750        .iter()
1751        .map(|region| runmat_plot::GeometrySceneRegionSummary {
1752            region_id: region.region_id.clone(),
1753            label: region.name.clone(),
1754            tag: region.tag.clone(),
1755            kind: region
1756                .cad_ownership
1757                .as_ref()
1758                .and_then(|ownership| ownership.label.as_ref())
1759                .map(|label| format!("{:?}", label.kind).to_ascii_lowercase()),
1760            triangle_count: triangle_counts
1761                .get(&region.region_id)
1762                .copied()
1763                .unwrap_or_default(),
1764        })
1765        .collect()
1766}
1767
1768#[cfg(feature = "plot-core")]
1769fn source_geometry_kind_label(kind: SourceGeometryKind) -> &'static str {
1770    match kind {
1771        SourceGeometryKind::Mesh => "mesh",
1772        SourceGeometryKind::Cad => "cad",
1773    }
1774}
1775
1776#[cfg(feature = "plot-core")]
1777pub fn geometry_preview_figure(
1778    asset: &GeometryAsset,
1779    title: impl Into<String>,
1780    options: GeometryPreviewFigureOptions,
1781) -> Result<runmat_plot::plots::Figure, String> {
1782    if asset.surface_meshes.is_empty() {
1783        return Err("geometry asset does not contain renderable surface mesh data".to_string());
1784    }
1785
1786    let cad_presentation = options.presentation == GeometryPreviewPresentation::Cad;
1787    let mut figure = if cad_presentation {
1788        runmat_plot::plots::Figure::new()
1789            .with_grid(false)
1790            .with_legend(false)
1791            .with_axis_equal(true)
1792    } else {
1793        let mut figure = runmat_plot::plots::Figure::new()
1794            .with_title(title)
1795            .with_labels("X", "Y")
1796            .with_grid(true)
1797            .with_axis_equal(true);
1798        figure.z_label = Some("Z".to_string());
1799        figure
1800    };
1801    if cad_presentation {
1802        figure.set_axes_view(0, -38.0, 24.0);
1803    }
1804
1805    for (index, surface_mesh) in asset.surface_meshes.iter().enumerate() {
1806        let vertices = surface_mesh
1807            .vertices
1808            .iter()
1809            .map(|vertex| {
1810                Ok(glam::Vec3::new(
1811                    f64_to_f32_coordinate(vertex[0])?,
1812                    f64_to_f32_coordinate(vertex[1])?,
1813                    f64_to_f32_coordinate(vertex[2])?,
1814                ))
1815            })
1816            .collect::<Result<Vec<_>, String>>()?;
1817        let mut mesh = runmat_plot::plots::MeshPlot::new(vertices, surface_mesh.triangles.clone())?;
1818        mesh.set_mesh_id(Some(surface_mesh.mesh_id.clone()));
1819        mesh.set_regions(mesh_regions_for_surface(asset, &surface_mesh.mesh_id));
1820        if !cad_presentation {
1821            mesh.set_label(Some(format!(
1822                "{}: {} triangles",
1823                surface_mesh.mesh_id,
1824                surface_mesh.triangles.len()
1825            )));
1826        }
1827
1828        if cad_presentation {
1829            let presentation = cad_mesh_presentation(
1830                asset,
1831                &surface_mesh.mesh_id,
1832                surface_mesh.triangles.len(),
1833                surface_mesh.vertices.len(),
1834            );
1835            mesh.set_face_color(CAD_DEFAULT_FACE_COLOR);
1836            mesh.set_edge_color(CAD_FEATURE_EDGE_COLOR);
1837            mesh.set_face_alpha(if options.xray { 0.34 } else { 1.0 });
1838            mesh.set_edge_alpha(if options.xray { 0.9 } else { 0.72 });
1839            if let Some(colors) = presentation.vertex_colors {
1840                mesh.set_vertex_colors(Some(colors))?;
1841            }
1842            if let Some(groups) = presentation.feature_edge_groups {
1843                mesh.set_feature_edge_groups(Some(groups))?;
1844                mesh.set_edge_mode(runmat_plot::plots::MeshEdgeMode::Feature);
1845                mesh.set_edge_width(0.85);
1846            } else if surface_mesh.triangles.len() > options.edge_overlay_triangle_limit {
1847                mesh.set_edge_mode(runmat_plot::plots::MeshEdgeMode::None);
1848                mesh.set_edge_width(0.0);
1849            } else {
1850                mesh.set_edge_mode(runmat_plot::plots::MeshEdgeMode::All);
1851                mesh.set_edge_width(0.28);
1852            }
1853        } else {
1854            let color = preview_mesh_color(index);
1855            mesh.set_face_color(color);
1856            mesh.set_edge_color(glam::Vec4::new(0.86, 0.91, 1.0, 0.82));
1857            mesh.set_face_alpha(0.92);
1858            if surface_mesh.triangles.len() > options.edge_overlay_triangle_limit {
1859                mesh.set_edge_width(0.0);
1860            } else {
1861                mesh.set_edge_width(0.35);
1862            }
1863        }
1864        figure.add_mesh_plot(mesh);
1865    }
1866
1867    Ok(figure)
1868}
1869
1870#[cfg(feature = "plot-core")]
1871#[derive(Debug, Default)]
1872struct CadMeshPresentation {
1873    feature_edge_groups: Option<Vec<u64>>,
1874    vertex_colors: Option<Vec<glam::Vec4>>,
1875    owner_paths: Vec<Vec<String>>,
1876    triangle_owner_path_indices: Option<Vec<Option<usize>>>,
1877}
1878
1879#[cfg(feature = "plot-core")]
1880impl CadMeshPresentation {
1881    fn owner_node_ids_for_triangle(&self, triangle_index: usize) -> &[String] {
1882        self.triangle_owner_path_indices
1883            .as_ref()
1884            .and_then(|indices| indices.get(triangle_index))
1885            .and_then(|index| index.and_then(|index| self.owner_paths.get(index)))
1886            .map(Vec::as_slice)
1887            .unwrap_or(&[])
1888    }
1889}
1890
1891#[cfg(feature = "plot-core")]
1892fn cad_mesh_presentation(
1893    asset: &GeometryAsset,
1894    mesh_id: &str,
1895    triangle_count: usize,
1896    vertex_count: usize,
1897) -> CadMeshPresentation {
1898    if triangle_count == 0 {
1899        return CadMeshPresentation::default();
1900    }
1901
1902    let prefer_face_mappings = asset.source_geometry.kind == SourceGeometryKind::Cad;
1903    let mut feature_edge_groups = vec![0_u64; triangle_count];
1904    let mut vertex_colors = vec![CAD_DEFAULT_FACE_COLOR; vertex_count];
1905    let mut triangle_owner_path_indices = vec![None; triangle_count];
1906    let mut owner_paths = Vec::<Vec<String>>::new();
1907    let mut owner_path_indices = BTreeMap::<Vec<String>, usize>::new();
1908    let mut group_ids_by_region = BTreeMap::<String, u64>::new();
1909    let mut assigned_groups = false;
1910    let mut assigned_colors = false;
1911    let mut assigned_owner_paths = false;
1912    let surface_triangles = asset
1913        .surface_meshes
1914        .iter()
1915        .find(|surface_mesh| surface_mesh.mesh_id == mesh_id)
1916        .map(|surface_mesh| surface_mesh.triangles.as_slice());
1917
1918    for mapping in asset.region_entity_mappings.iter().filter(|mapping| {
1919        mapping.mesh_id == mesh_id
1920            && matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element)
1921    }) {
1922        let Some(region) = asset
1923            .regions
1924            .iter()
1925            .find(|region| region.region_id == mapping.region_id)
1926        else {
1927            continue;
1928        };
1929        let face_id = region
1930            .cad_ownership
1931            .as_ref()
1932            .and_then(|ownership| ownership.face_id);
1933        if prefer_face_mappings && face_id.is_none() {
1934            continue;
1935        }
1936        let group_id = face_id
1937            .map(|face_id| face_id.saturating_add(1))
1938            .unwrap_or_else(|| {
1939                if let Some(group_id) = group_ids_by_region.get(&mapping.region_id) {
1940                    *group_id
1941                } else {
1942                    let group_id = group_ids_by_region.len() as u64 + 1;
1943                    group_ids_by_region.insert(mapping.region_id.clone(), group_id);
1944                    group_id
1945                }
1946            });
1947        let color = cad_region_color(region);
1948        let owner_node_ids = cad_region_owner_node_ids(region);
1949        let owner_path_index = if owner_node_ids.is_empty() {
1950            None
1951        } else if let Some(index) = owner_path_indices.get(&owner_node_ids) {
1952            Some(*index)
1953        } else {
1954            let index = owner_paths.len();
1955            owner_path_indices.insert(owner_node_ids.clone(), index);
1956            owner_paths.push(owner_node_ids);
1957            Some(index)
1958        };
1959        for range in &mapping.ranges {
1960            for triangle_index in bounded_range(range, triangle_count) {
1961                feature_edge_groups[triangle_index] = group_id;
1962                assigned_groups = true;
1963                if let Some(owner_path_index) = owner_path_index {
1964                    triangle_owner_path_indices[triangle_index] = Some(owner_path_index);
1965                    assigned_owner_paths = true;
1966                }
1967                if let Some(color) = color {
1968                    assigned_colors |= color_vertices_for_triangle(
1969                        surface_triangles,
1970                        triangle_index,
1971                        color,
1972                        &mut vertex_colors,
1973                    );
1974                }
1975            }
1976        }
1977    }
1978
1979    CadMeshPresentation {
1980        feature_edge_groups: assigned_groups.then_some(feature_edge_groups),
1981        vertex_colors: assigned_colors.then_some(vertex_colors),
1982        owner_paths,
1983        triangle_owner_path_indices: assigned_owner_paths.then_some(triangle_owner_path_indices),
1984    }
1985}
1986
1987#[cfg(feature = "plot-core")]
1988fn cad_region_owner_node_ids(region: &Region) -> Vec<String> {
1989    let Some(ownership) = region.cad_ownership.as_ref() else {
1990        return Vec::new();
1991    };
1992    let mut ids = Vec::new();
1993    // Render chunk ownership drives assembly-tree visibility. Face labels remain
1994    // region metadata so picking/selectors can stay face-level without forcing
1995    // one GPU draw item per face.
1996    for owner in &ownership.owner_path {
1997        push_unique_owner_node_id(&mut ids, &owner.label_entry);
1998    }
1999    ids
2000}
2001
2002#[cfg(feature = "plot-core")]
2003fn push_unique_owner_node_id(ids: &mut Vec<String>, candidate: &str) {
2004    let candidate = candidate.trim();
2005    if candidate.is_empty() || ids.iter().any(|existing| existing == candidate) {
2006        return;
2007    }
2008    ids.push(candidate.to_string());
2009}
2010
2011#[cfg(feature = "plot-core")]
2012fn bounded_range(range: &EntityIdRange, upper_bound: usize) -> std::ops::Range<usize> {
2013    let start = usize::try_from(range.start).unwrap_or(usize::MAX);
2014    let count = usize::try_from(range.count).unwrap_or(usize::MAX);
2015    let start = start.min(upper_bound);
2016    let end = start.saturating_add(count).min(upper_bound);
2017    start..end
2018}
2019
2020#[cfg(feature = "plot-core")]
2021fn color_vertices_for_triangle(
2022    triangles: Option<&[[u32; 3]]>,
2023    triangle_index: usize,
2024    color: glam::Vec4,
2025    vertex_colors: &mut [glam::Vec4],
2026) -> bool {
2027    let Some(triangle) = triangles.and_then(|triangles| triangles.get(triangle_index)) else {
2028        return false;
2029    };
2030    let mut colored = false;
2031    for vertex_id in triangle {
2032        if let Some(slot) = vertex_colors.get_mut(*vertex_id as usize) {
2033            *slot = color;
2034            colored = true;
2035        }
2036    }
2037    colored
2038}
2039
2040#[cfg(feature = "plot-core")]
2041fn cad_region_color(region: &Region) -> Option<glam::Vec4> {
2042    region
2043        .cad_ownership
2044        .as_ref()
2045        .and_then(|ownership| ownership.color.as_ref())
2046        .and_then(|color| parse_cad_hex_rgba(&color.hex_rgba))
2047        .map(cad_display_color)
2048}
2049
2050#[cfg(feature = "plot-core")]
2051fn parse_cad_hex_rgba(value: &str) -> Option<glam::Vec4> {
2052    let value = value.trim().trim_start_matches('#');
2053    if value.len() != 6 && value.len() != 8 {
2054        return None;
2055    }
2056    let r = u8::from_str_radix(&value[0..2], 16).ok()? as f32 / 255.0;
2057    let g = u8::from_str_radix(&value[2..4], 16).ok()? as f32 / 255.0;
2058    let b = u8::from_str_radix(&value[4..6], 16).ok()? as f32 / 255.0;
2059    let a = if value.len() == 8 {
2060        u8::from_str_radix(&value[6..8], 16).ok()? as f32 / 255.0
2061    } else {
2062        1.0
2063    };
2064    Some(glam::Vec4::new(r, g, b, a))
2065}
2066
2067#[cfg(feature = "plot-core")]
2068fn cad_display_color(color: glam::Vec4) -> glam::Vec4 {
2069    let rgb = glam::Vec3::new(color.x, color.y, color.z);
2070    let gray = glam::Vec3::splat((rgb.x + rgb.y + rgb.z) / 3.0);
2071    let softened = rgb
2072        .lerp(gray, 0.18)
2073        .lerp(CAD_DEFAULT_FACE_COLOR.truncate(), 0.16);
2074    glam::Vec4::new(softened.x, softened.y, softened.z, color.w.max(0.2))
2075}
2076
2077#[cfg(feature = "plot-core")]
2078fn mesh_regions_for_surface(
2079    asset: &GeometryAsset,
2080    mesh_id: &str,
2081) -> Vec<runmat_plot::plots::MeshRegion> {
2082    asset
2083        .region_entity_mappings
2084        .iter()
2085        .filter(|mapping| {
2086            mapping.mesh_id == mesh_id
2087                && matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element)
2088        })
2089        .filter_map(|mapping| {
2090            let triangle_ranges = mapping
2091                .ranges
2092                .iter()
2093                .filter_map(|range| {
2094                    let start = u32::try_from(range.start).ok()?;
2095                    let count = u32::try_from(range.count).ok()?;
2096                    if count == 0 {
2097                        None
2098                    } else {
2099                        Some(runmat_plot::plots::MeshTriangleRange::new(start, count))
2100                    }
2101                })
2102                .collect::<Vec<_>>();
2103            if triangle_ranges.is_empty() {
2104                return None;
2105            }
2106            let region = asset
2107                .regions
2108                .iter()
2109                .find(|region| region.region_id == mapping.region_id);
2110            Some(runmat_plot::plots::MeshRegion::new(
2111                mapping.region_id.clone(),
2112                region.map(|region| region.name.clone()),
2113                region.and_then(|region| region.tag.clone()),
2114                triangle_ranges,
2115            ))
2116        })
2117        .collect()
2118}
2119
2120#[cfg(feature = "plot-core")]
2121fn geometry_scene_id(asset: &GeometryAsset) -> String {
2122    format!(
2123        "{}:{}:{}",
2124        asset.geometry_id, asset.source.sha256, asset.tessellation_profile.profile_id
2125    )
2126}
2127
2128#[cfg(feature = "plot-core")]
2129fn geometry_scene_regions_for_surface_chunk(
2130    asset: &GeometryAsset,
2131    mesh_id: &str,
2132    chunk_start_triangle: usize,
2133    chunk_triangle_count: usize,
2134) -> Vec<runmat_plot::GeometrySceneRegion> {
2135    if chunk_triangle_count == 0 {
2136        return Vec::new();
2137    }
2138    let chunk_start = chunk_start_triangle as u64;
2139    let chunk_end = chunk_start.saturating_add(chunk_triangle_count as u64);
2140    asset
2141        .region_entity_mappings
2142        .iter()
2143        .filter(|mapping| {
2144            mapping.mesh_id == mesh_id
2145                && matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element)
2146        })
2147        .filter_map(|mapping| {
2148            let triangle_ranges = mapping
2149                .ranges
2150                .iter()
2151                .filter_map(|range| {
2152                    let range_end = range.end_exclusive()?;
2153                    let start = range.start.max(chunk_start);
2154                    let end = range_end.min(chunk_end);
2155                    if end <= start {
2156                        return None;
2157                    }
2158                    let local_start = u32::try_from(start - chunk_start).ok()?;
2159                    let count = u32::try_from(end - start).ok()?;
2160                    Some(runmat_plot::GeometrySceneTriangleRange::new(
2161                        local_start,
2162                        count,
2163                    ))
2164                })
2165                .collect::<Vec<_>>();
2166            if triangle_ranges.is_empty() {
2167                return None;
2168            }
2169            let region = asset
2170                .regions
2171                .iter()
2172                .find(|region| region.region_id == mapping.region_id);
2173            Some(runmat_plot::GeometrySceneRegion::new(
2174                mapping.region_id.clone(),
2175                region.map(|region| region.name.clone()),
2176                region.and_then(|region| region.tag.clone()),
2177                triangle_ranges,
2178            ))
2179        })
2180        .collect()
2181}
2182
2183#[cfg(feature = "plot-core")]
2184fn local_vertex_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
2185    let mut normals = vec![[0.0, 0.0, 0.0]; positions.len()];
2186    for triangle in indices.chunks_exact(3) {
2187        let a = triangle[0] as usize;
2188        let b = triangle[1] as usize;
2189        let c = triangle[2] as usize;
2190        if a >= positions.len() || b >= positions.len() || c >= positions.len() {
2191            continue;
2192        }
2193        let normal = face_normal(positions[a], positions[b], positions[c]);
2194        accumulate_normal(&mut normals[a], normal);
2195        accumulate_normal(&mut normals[b], normal);
2196        accumulate_normal(&mut normals[c], normal);
2197    }
2198    normals.into_iter().map(normalize_or_default).collect()
2199}
2200
2201#[cfg(feature = "plot-core")]
2202fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
2203    let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
2204    let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
2205    normalize_or_default([
2206        ab[1] * ac[2] - ab[2] * ac[1],
2207        ab[2] * ac[0] - ab[0] * ac[2],
2208        ab[0] * ac[1] - ab[1] * ac[0],
2209    ])
2210}
2211
2212#[cfg(feature = "plot-core")]
2213fn accumulate_normal(target: &mut [f32; 3], normal: [f32; 3]) {
2214    target[0] += normal[0];
2215    target[1] += normal[1];
2216    target[2] += normal[2];
2217}
2218
2219#[cfg(feature = "plot-core")]
2220fn normalize_or_default(value: [f32; 3]) -> [f32; 3] {
2221    let length_squared = value[0] * value[0] + value[1] * value[1] + value[2] * value[2];
2222    if length_squared <= f32::EPSILON || !length_squared.is_finite() {
2223        return [0.0, 0.0, 1.0];
2224    }
2225    let inv_length = length_squared.sqrt().recip();
2226    [
2227        value[0] * inv_length,
2228        value[1] * inv_length,
2229        value[2] * inv_length,
2230    ]
2231}
2232
2233#[cfg(feature = "plot-core")]
2234fn f64_to_f32_coordinate(value: f64) -> Result<f32, String> {
2235    if !value.is_finite() {
2236        return Err("geometry preview mesh contains a non-finite coordinate".to_string());
2237    }
2238    if value < f32::MIN as f64 || value > f32::MAX as f64 {
2239        return Err("geometry preview mesh coordinate exceeds f32 render range".to_string());
2240    }
2241    Ok(value as f32)
2242}
2243
2244#[cfg(feature = "plot-core")]
2245fn preview_mesh_color(index: usize) -> glam::Vec4 {
2246    const PALETTE: [[f32; 4]; 6] = [
2247        [0.18, 0.48, 0.86, 1.0],
2248        [0.13, 0.62, 0.44, 1.0],
2249        [0.84, 0.43, 0.18, 1.0],
2250        [0.57, 0.38, 0.77, 1.0],
2251        [0.73, 0.62, 0.18, 1.0],
2252        [0.20, 0.62, 0.75, 1.0],
2253    ];
2254    glam::Vec4::from_array(PALETTE[index % PALETTE.len()])
2255}
2256
2257pub fn geometry_prep_for_analysis_op(
2258    asset: &GeometryAsset,
2259    spec: GeometryPrepForAnalysisSpec,
2260    context: OperationContext,
2261) -> Result<OperationEnvelope<GeometryPrepForAnalysisResult>, OperationErrorEnvelope> {
2262    if spec.target_element_budget == 0 {
2263        return Err(operation_error(
2264            GEOMETRY_PREP_FOR_ANALYSIS_OPERATION,
2265            GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION,
2266            &context,
2267            OperationErrorSpec {
2268                error_code: "RM.GEOMETRY.PREP_FOR_ANALYSIS.INVALID_SPEC",
2269                error_type: OperationErrorType::Input,
2270                retryable: false,
2271                severity: OperationErrorSeverity::Error,
2272            },
2273            "prep-for-analysis target_element_budget must be greater than zero",
2274            BTreeMap::from([(
2275                "target_element_budget".to_string(),
2276                spec.target_element_budget.to_string(),
2277            )]),
2278        ));
2279    }
2280
2281    let profile = match spec.profile {
2282        GeometryPrepProfile::SurfaceOnly => MeshingProfile::SurfaceOnly,
2283        GeometryPrepProfile::AnalysisReady => MeshingProfile::AnalysisReady,
2284        GeometryPrepProfile::AdaptiveRefine => MeshingProfile::AdaptiveRefine,
2285    };
2286    let prepared = prepare_geometry_for_analysis(
2287        asset,
2288        MeshingOptions {
2289            profile,
2290            target_element_budget: spec.target_element_budget,
2291        },
2292    )
2293    .map_err(|error| {
2294        operation_error(
2295            GEOMETRY_PREP_FOR_ANALYSIS_OPERATION,
2296            GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION,
2297            &context,
2298            OperationErrorSpec {
2299                error_code: "RM.GEOMETRY.PREP_FOR_ANALYSIS.FAILED",
2300                error_type: OperationErrorType::Validation,
2301                retryable: false,
2302                severity: OperationErrorSeverity::Error,
2303            },
2304            format!("failed to prepare geometry for analysis: {error}"),
2305            BTreeMap::from([("geometry_id".to_string(), asset.geometry_id.clone())]),
2306        )
2307    })?;
2308
2309    let artifact = persist_prep_artifact(asset, prepared).map_err(|error| {
2310        operation_error(
2311            GEOMETRY_PREP_FOR_ANALYSIS_OPERATION,
2312            GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION,
2313            &context,
2314            OperationErrorSpec {
2315                error_code: "RM.GEOMETRY.PREP_FOR_ANALYSIS.ARTIFACT_STORE_FAILED",
2316                error_type: OperationErrorType::Internal,
2317                retryable: true,
2318                severity: OperationErrorSeverity::Error,
2319            },
2320            format!("failed to persist prep artifact: {error}"),
2321            BTreeMap::from([("geometry_id".to_string(), asset.geometry_id.clone())]),
2322        )
2323    })?;
2324
2325    Ok(OperationEnvelope::new(
2326        GEOMETRY_PREP_FOR_ANALYSIS_OPERATION,
2327        GEOMETRY_PREP_FOR_ANALYSIS_OP_VERSION,
2328        &context,
2329        GeometryPrepForAnalysisResult {
2330            prep_artifact_id: artifact.prep_artifact_id,
2331            prep: artifact.prep,
2332        },
2333    ))
2334}
2335
2336pub fn geometry_prep_for_analysis(
2337    asset: &GeometryAsset,
2338    spec: GeometryPrepForAnalysisSpec,
2339) -> BuiltinResult<GeometryPrepForAnalysisResult> {
2340    let envelope = geometry_prep_for_analysis_op(asset, spec, OperationContext::new(None, None))
2341        .map_err(|error| {
2342            build_runtime_error(error.message)
2343                .with_builtin(GEOMETRY_PREP_FOR_ANALYSIS_OPERATION)
2344                .with_identifier("RunMat:GeometryPrepForAnalysisFailed")
2345                .build()
2346        })?;
2347    Ok(envelope.data)
2348}
2349
2350fn format_name(format: GeometryFormat) -> &'static str {
2351    match format {
2352        runmat_geometry_io::GeometryFormat::Stl => "stl",
2353        runmat_geometry_io::GeometryFormat::Step => "step",
2354        runmat_geometry_io::GeometryFormat::Iges => "iges",
2355        runmat_geometry_io::GeometryFormat::Brep => "brep",
2356        runmat_geometry_io::GeometryFormat::Obj => "obj",
2357        runmat_geometry_io::GeometryFormat::Ply => "ply",
2358        runmat_geometry_io::GeometryFormat::Gltf => "gltf",
2359        runmat_geometry_io::GeometryFormat::Unknown => "unknown",
2360    }
2361}
2362
2363fn map_geometry_load_error(
2364    path: &str,
2365    error: GeometryImportError,
2366    context: &OperationContext,
2367) -> OperationErrorEnvelope {
2368    let (error_code, error_type, retryable) = match &error {
2369        GeometryImportError::UnsupportedFormat => (
2370            "RM.GEOMETRY.LOAD.FORMAT_UNSUPPORTED",
2371            OperationErrorType::Input,
2372            false,
2373        ),
2374        GeometryImportError::ParseFailed(_) => (
2375            "RM.GEOMETRY.LOAD.PARSE_FAILED",
2376            OperationErrorType::Validation,
2377            false,
2378        ),
2379        GeometryImportError::CapacityExceeded { .. } => (
2380            "RM.GEOMETRY.LOAD.CAPACITY_LIMIT_EXCEEDED",
2381            OperationErrorType::Capacity,
2382            false,
2383        ),
2384        GeometryImportError::BackendUnavailable(_) => (
2385            "RM.GEOMETRY.LOAD.BACKEND_UNAVAILABLE",
2386            OperationErrorType::Backend,
2387            false,
2388        ),
2389        GeometryImportError::Cancelled => (
2390            "RM.GEOMETRY.LOAD.CANCELLED",
2391            OperationErrorType::Cancelled,
2392            false,
2393        ),
2394    };
2395    operation_error(
2396        GEOMETRY_LOAD_OPERATION,
2397        GEOMETRY_LOAD_OP_VERSION,
2398        context,
2399        OperationErrorSpec {
2400            error_code,
2401            error_type,
2402            retryable,
2403            severity: OperationErrorSeverity::Error,
2404        },
2405        error.to_string(),
2406        BTreeMap::from([("path".to_string(), path.to_string())]),
2407    )
2408}
2409
2410fn map_geometry_query_error(
2411    region_id: &str,
2412    error: QueryError,
2413    context: &OperationContext,
2414) -> OperationErrorEnvelope {
2415    match error {
2416        QueryError::RegionNotFound => operation_error(
2417            GEOMETRY_QUERY_ENTITIES_OPERATION,
2418            GEOMETRY_QUERY_ENTITIES_OP_VERSION,
2419            context,
2420            OperationErrorSpec {
2421                error_code: "RM.GEOMETRY.QUERY_ENTITIES.REGION_NOT_FOUND",
2422                error_type: OperationErrorType::Validation,
2423                retryable: false,
2424                severity: OperationErrorSeverity::Error,
2425            },
2426            format!("region '{region_id}' does not exist"),
2427            BTreeMap::from([("region_id".to_string(), region_id.to_string())]),
2428        ),
2429    }
2430}
2431
2432#[cfg(test)]
2433mod tests;