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