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(®ion.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 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;