From f6d227d8cb8eeb44c0f6b8a8906f446e5ebb0376 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:34:32 -0400 Subject: [PATCH] UI: datum planes and axes in the viewport, datums and partitions in the browser Datum planes draw as a hatched square around the model, axes as a line through it, each with its name, in the desktop viewport and the headless render. The browser's Geometry node lists the datums and the partitions with the datum each cuts by. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/view.rs | 2 + crates/render/src/glyphs.rs | 76 ++++++++++++++++++++++++++++++++++- crates/ui-egui/src/browser.rs | 23 ++++++++++- crates/ui-egui/src/lib.rs | 5 ++- crates/ui-egui/src/scene.rs | 5 +++ crates/ui-egui/src/theme.rs | 5 +++ 6 files changed, 112 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 50a2442..823b51d 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -72,6 +72,7 @@ impl Palette { interaction: Rgb(0x9a, 0x30, 0xc0), thermal: Rgb(0xe8, 0x4c, 0x00), label: Rgb(0x26, 0x2a, 0x32), + datum: Rgb(0x1e, 0x8c, 0x6e), }, }; @@ -156,6 +157,7 @@ pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) { let mut bounds = base.bounds; let mut scene = model_scene(&base, p); bounds.union(glyphs.draw(|label| base.node_position(label), GlyphShow::ALL, &p.glyphs, &mut scene)); + bounds.union(fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &p.glyphs, &mut scene)); return (scene, bounds, shown); }; shown.step = Some(view.step.clone()); diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs index 1992ddf..c4d9dbe 100644 --- a/crates/render/src/glyphs.rs +++ b/crates/render/src/glyphs.rs @@ -72,6 +72,8 @@ pub struct GlyphColors { pub thermal: Rgb, /// Reference point names. pub label: Rgb, + /// Datum planes and axes. + pub datum: Rgb, } impl GlyphColors { @@ -89,6 +91,7 @@ impl GlyphColors { interaction: mix(self.interaction), thermal: mix(self.thermal), label: mix(self.label), + datum: mix(self.datum), } } @@ -647,6 +650,49 @@ impl Glyphs { } } +/// How far a datum plane's square reaches from the model's centre, and a datum axis's line, +/// as a fraction of the bounding box diagonal. +const DATUM_REACH: f64 = 0.6; +/// Hatch lines across a datum plane's square. +const DATUM_HATCH: usize = 4; + +/// Draw the model's datum planes (an outlined, hatched square around the model, `bounds`) and +/// axes (a line through the model), each with its name; returns the box around them. +pub fn datums(model: &Model, bounds: &Aabb3, colors: &GlyphColors, scene: &mut Scene) -> Aabb3 { + let mut d = Draw { scene, bounds: Aabb3::EMPTY }; + let (centre, diagonal) = if bounds.is_empty() { (Vec3::ZERO, 1.0) } else { (bounds.center(), bounds.diagonal().max(1e-9)) }; + let reach = DATUM_REACH * diagonal; + for (name, datum) in model.datums() { + match *datum { + fieldcraft_model::Datum::Plane { point, normal } => { + let (p, n) = (Vec3::from(point), Vec3::from(normal)); + // The model's centre moved onto the plane. + let c = centre - n * (centre - p).dot(n); + let (u, v) = perpendiculars(n); + let corner = |a: f64, b: f64| c + u * (a * reach) + v * (b * reach); + let ring = [corner(-1.0, -1.0), corner(1.0, -1.0), corner(1.0, 1.0), corner(-1.0, 1.0)]; + for (k, a) in ring.iter().enumerate() { + let b = ring.get((k + 1) % 4).copied().unwrap_or(*a); + d.line(*a, b, colors.datum, OUTLINE_WIDTH); + } + for k in 1..DATUM_HATCH { + let t = -1.0 + 2.0 * k as f64 / DATUM_HATCH as f64; + d.line(corner(t, -1.0), corner(t, 1.0), colors.datum, COUPLING_WIDTH); + d.line(corner(-1.0, t), corner(1.0, t), colors.datum, COUPLING_WIDTH); + } + d.scene.labels.push(SceneLabel { p: corner(1.0, 1.0), text: name.clone(), color: colors.datum }); + } + fieldcraft_model::Datum::Axis { point, direction } => { + let (p, a) = (Vec3::from(point), Vec3::from(direction)); + let c = p + a * (centre - p).dot(a); + d.line(c - a * reach, c + a * reach, colors.datum, SHAFT_WIDTH); + d.scene.labels.push(SceneLabel { p: c + a * reach, text: name.clone(), color: colors.datum }); + } + } + } + d.bounds +} + /// Symbol primitives into a scene. struct Draw<'s> { scene: &'s mut Scene, @@ -745,8 +791,14 @@ mod tests { use super::*; - const COLORS: GlyphColors = - GlyphColors { constraint: Rgb(0, 0, 255), load: Rgb(255, 0, 0), interaction: Rgb(0, 255, 0), thermal: Rgb(255, 128, 0), label: Rgb(0, 0, 0) }; + const COLORS: GlyphColors = GlyphColors { + constraint: Rgb(0, 0, 255), + load: Rgb(255, 0, 0), + interaction: Rgb(0, 255, 0), + thermal: Rgb(255, 128, 0), + label: Rgb(0, 0, 0), + datum: Rgb(0, 128, 128), + }; /// A row of `n` unit hex8 cells along x. fn bar(n: u32) -> Model { @@ -948,4 +1000,24 @@ mod tests { assert!(thin::(&[], 1.0, 10).is_empty()); assert!(thin(&[(1u32, Vec3::new(f64::INFINITY, 0.0, 0.0))], 1.0, 10).is_empty()); } + + #[test] + fn datums_draw_around_the_model_with_their_names() { + let mut m = Model::default(); + m.add_datum("Mid", fieldcraft_model::Datum::Plane { point: [5.0, 0.0, 0.0], normal: [1.0, 0.0, 0.0] }).unwrap(); + m.add_datum("Ax", fieldcraft_model::Datum::Axis { point: [0.0; 3], direction: [0.0, 0.0, 1.0] }).unwrap(); + let mut bounds = Aabb3::EMPTY; + bounds.add(Vec3::ZERO); + bounds.add(Vec3::new(10.0, 10.0, 10.0)); + let mut scene = Scene::default(); + let drawn = datums(&m, &bounds, &COLORS, &mut scene); + // A square with its hatch (4 + 2 × 3 lines) and the axis line. + assert_eq!(scene.lines.len(), 11); + let mut names: Vec<&str> = scene.labels.iter().map(|l| l.text.as_str()).collect(); + names.sort_unstable(); + assert_eq!(names, ["Ax", "Mid"]); + // The plane's lines lie on x = 5, centred on the model. + assert!(scene.lines.iter().skip(1).all(|l| (l.a.x - 5.0).abs() < 1e-12 && (l.b.x - 5.0).abs() < 1e-12)); + assert!(drawn.diagonal() > bounds.diagonal()); + } } diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index b680339..1383a5e 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -1,4 +1,5 @@ -//! The browser (left): the model as a tree. Geometry and Mesh with eye toggles, then the model's +//! The browser (left): the model as a tree. Geometry (the design's bodies, datums and +//! partitions) and Mesh with eye toggles, then the model's //! definitions (sets, materials, sections, steps, loads and boundary conditions, interactions) //! with counts (Loads & BCs and Interactions with eye toggles for their viewport symbols), and //! the solved steps' results. Clicking a definition selects it (its properties show on the @@ -388,6 +389,26 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec) { &Row { depth: 2, icon: Some("geometry.import.mesh"), label: s, ..Default::default() }, ); } + // Datum planes and axes, then the partitions in the order they cut. + let datums: Vec<(String, &'static str)> = + app.session.model.datums().iter().map(|(n, d)| (n.clone(), if d.plane().is_some() { "plane" } else { "axis" })).collect(); + for (i, (n, kind)) in datums.iter().enumerate() { + draw_row( + app, + ui, + egui::Id::new(("fc_row_datum", i)), + &Row { depth: 2, icon: Some("geometry.datum"), label: n, note: Some((*kind).into()), ..Default::default() }, + ); + } + let partitions: Vec<(String, String)> = app.session.model.partitions().iter().map(|p| (p.name.clone(), p.datum.clone())).collect(); + for (i, (n, datum)) in partitions.iter().enumerate() { + draw_row( + app, + ui, + egui::Id::new(("fc_row_partition", i)), + &Row { depth: 2, icon: Some("geometry.partition"), label: n, note: Some(format!("by {datum}")), ..Default::default() }, + ); + } if design.is_none() && surfaces.is_empty() { draw_row( app, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a410347..6ae3dd9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -750,7 +750,10 @@ impl ModelApp { }; // Symbols follow the drawn nodes: the deformed shape of a results plot. let drawn = self.plot.1.as_ref().map_or(&self.display.1, |p| &p.display); - let symbols = scene::symbols(&mut s, &self.glyphs, |n| drawn.node_position(n), show, self.plot.1.is_some(), &t); + let mut symbols = scene::symbols(&mut s, &self.glyphs, |n| drawn.node_position(n), show, self.plot.1.is_some(), &t); + if show.geometry && self.plot.1.is_none() { + symbols.union(scene::datums(&mut s, &self.session.model, &self.display.1.bounds, &t)); + } self.scene = (Some(key), s, symbols); } let mut bounds = self.display.1.bounds; diff --git a/crates/ui-egui/src/scene.rs b/crates/ui-egui/src/scene.rs index d749944..20056d8 100644 --- a/crates/ui-egui/src/scene.rs +++ b/crates/ui-egui/src/scene.rs @@ -69,6 +69,11 @@ pub fn symbols(s: &mut Scene, glyphs: &Glyphs, pos: impl Fn(u32) -> Option glyphs.draw(pos, GlyphShow { loads: show.loads, interactions: show.interactions }, &colors, s) } +/// Draw the model's datum planes and axes around `bounds` into `s`; returns the box around them. +pub fn datums(s: &mut Scene, model: &fieldcraft_model::Model, bounds: &Aabb3, t: &Tokens) -> Aabb3 { + fieldcraft_render::glyphs::datums(model, bounds, &t.glyph_colors(), s) +} + /// The scene for `display` drawn in `style`. pub fn build(display: &DisplayMesh, style: Style, show: Show, t: &Tokens) -> Scene { let mut s = Scene { background: Some((rgb(t.viewport_top), rgb(t.viewport_bottom))), ..Default::default() }; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ca6df42..2e288cf 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -82,6 +82,8 @@ pub struct Tokens { pub glyph_interaction: Color32, /// Temperatures, heat fluxes, convection and heat sources. pub glyph_thermal: Color32, + /// Datum planes and axes. + pub glyph_datum: Color32, // ---- selection ---- /// What is selected in the viewport, and what a click would pick under the pointer. pub pick_selected: Color32, @@ -159,6 +161,7 @@ impl Tokens { glyph_load: Color32::from_rgb(0xd0, 0x24, 0x24), glyph_interaction: Color32::from_rgb(0x9a, 0x30, 0xc0), glyph_thermal: Color32::from_rgb(0xe8, 0x4c, 0x00), + glyph_datum: Color32::from_rgb(0x1e, 0x8c, 0x6e), pick_selected: Color32::from_rgb(0xd6, 0x00, 0x7f), pick_hover: Color32::from_rgb(0xf0, 0x7c, 0xc0), } @@ -221,6 +224,7 @@ impl Tokens { t.glyph_load = Color32::from_rgb(0xff, 0x5a, 0x4c); t.glyph_interaction = Color32::from_rgb(0xd2, 0x82, 0xff); t.glyph_thermal = Color32::from_rgb(0xff, 0x8c, 0x00); + t.glyph_datum = Color32::from_rgb(0x5a, 0xd8, 0xaa); t.pick_selected = Color32::from_rgb(0xff, 0x3f, 0xb4); t.pick_hover = Color32::from_rgb(0xff, 0xa6, 0xdc); t @@ -239,6 +243,7 @@ impl Tokens { interaction: rgb(self.glyph_interaction), thermal: rgb(self.glyph_thermal), label: rgb(self.overlay_text), + datum: rgb(self.glyph_datum), } }