use super::*;
fn constraint_text_color(settings: &brep_render::style::RenderSettings) -> egui::Color32 {
rgb_color32(settings.sketch_colors().constraint)
}
fn conflict_text_color(settings: &brep_render::style::RenderSettings) -> egui::Color32 {
rgb_color32(settings.sketch_colors().conflict)
}
fn rgb_color32(c: u32) -> egui::Color32 {
egui::Color32::from_rgb((c >> 16) as u8, (c >> 8) as u8, c as u8)
}
fn label_font(ui: &egui::Ui, scale: f32) -> egui::FontId {
let mut font = egui::TextStyle::Monospace.resolve(ui.style());
font.size *= scale;
font
}
fn scale_label_spacing(ui: &mut egui::Ui, scale: f32) {
let spacing = ui.spacing_mut();
spacing.interact_size *= scale;
spacing.button_padding *= scale;
spacing.item_spacing *= scale;
}
fn chip_margin(x: f32, y: f32, scale: f32) -> egui::Margin {
egui::Margin::symmetric((x * scale).round() as i8, (y * scale).round() as i8)
}
fn text_edit_width(ui: &egui::Ui, text: &str, font: &egui::FontId) -> f32 {
let measured = ui.ctx().fonts_mut(|f| {
f.layout_no_wrap(text.to_owned(), font.clone(), egui::Color32::PLACEHOLDER)
.size()
.x
});
let base = egui::TextStyle::Monospace.resolve(ui.style()).size;
let k = if base > 0.0 { font.size / base } else { 1.0 };
(measured + 12.0 * k).max(24.0 * k)
}
fn label_text_edit<'t>(
ui: &egui::Ui,
buf: &'t mut String,
font: &egui::FontId,
scale: f32,
) -> egui::TextEdit<'t> {
let width = text_edit_width(ui, buf.as_str(), font);
egui::TextEdit::singleline(buf)
.desired_width(width)
.margin(chip_margin(4.0, 2.0, scale))
.font(egui::FontSelection::FontId(font.clone()))
}
fn select_all_text_edit(ctx: &egui::Context, id: egui::Id, char_count: usize) {
if let Some(mut state) = egui::TextEdit::load_state(ctx, id) {
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(char_count),
)));
egui::TextEdit::store_state(ctx, id, state);
}
}
impl Viewport {
pub(super) fn draw_dimension_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
if !state.sketch_mode() {
self.editing_dim = None;
return;
}
let labels: Vec<serde_json::Value> =
serde_json::from_str(&state.sketch_dimension_labels_json()).unwrap_or_default();
if let Some((id, _)) = self.editing_dim.as_ref() {
let key = id.to_string();
if !labels.iter().any(|l| l["id"].to_string() == key) {
self.editing_dim = None;
}
}
if labels.is_empty() {
return;
}
let worlds: Vec<[f64; 3]> = labels
.iter()
.map(|l| {
let w = &l["world"];
[
w[0].as_f64().unwrap_or(0.0),
w[1].as_f64().unwrap_or(0.0),
w[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != labels.len() {
return;
}
let fresh = self.dim_edit_fresh;
self.dim_edit_fresh = false;
let mut editing = self.editing_dim.take();
let editing_key = editing.as_ref().map(|(id, _)| id.to_string());
let dim_text_color = constraint_text_color(&state.settings);
let dim_conflict_color = conflict_text_color(&state.settings);
let label_scale = state.settings.label_scale;
let mut start_edit: Option<serde_json::Value> = None;
let mut apply: bool = false;
let mut cancel: bool = false;
let mut drag_to: Option<(serde_json::Value, f64, f64)> = None;
let mut drag_ended = false;
for (i, label) in labels.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let cid = label["id"].clone();
let cid_key = cid.to_string();
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let is_editing = editing_key.as_deref() == Some(cid_key.as_str());
let area_id = egui::Id::new(("brep-dim-label", cid_key.clone()));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let font = label_font(ui, label_scale);
egui::Frame::popup(ui.style())
.inner_margin(chip_margin(4.0, 2.0, label_scale))
.show(ui, |ui| {
scale_label_spacing(ui, label_scale);
if is_editing {
let buf = &mut editing.as_mut().expect("editing buffer").1;
let editor = label_text_edit(ui, buf, &font, label_scale);
let resp = ui.add(editor);
if fresh {
resp.request_focus();
select_all_text_edit(ui.ctx(), resp.id, buf.chars().count());
}
let enter =
ui.input(|i| i.key_pressed(egui::Key::Enter));
if resp.lost_focus() {
if enter {
apply = true;
} else if !fresh {
cancel = true;
}
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
}
} else {
let text = label["text"].as_str().unwrap_or("").to_string();
let color = if label["conflicting"].as_bool() == Some(true) {
dim_conflict_color
} else {
dim_text_color
};
let resp = ui.add(
egui::Button::new(
egui::RichText::new(text)
.font(font.clone())
.color(color),
)
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click_and_drag()),
);
if resp.double_clicked() {
start_edit = Some(cid.clone());
}
if resp.dragged() {
if let Some(p) = resp.interact_pointer_pos() {
drag_to = Some((
cid.clone(),
(p.x - rect.min.x) as f64,
(p.y - rect.min.y) as f64,
));
}
}
if resp.drag_stopped() {
drag_ended = true;
}
}
});
});
}
if apply {
if let Some((id, text)) = editing.take() {
state.sketch_set_dimension_value(&id, &text);
}
self.editing_dim = None;
} else if cancel {
self.editing_dim = None;
} else {
self.editing_dim = editing;
}
if let Some((id, lx, ly)) = drag_to {
state.sketch_dimension_drag_to(&id, lx, ly);
}
if drag_ended {
state.sketch_dimension_drag_end();
}
if let Some(id) = start_edit {
let seed: serde_json::Value =
serde_json::from_str(&state.sketch_dimension_value_json(&id)).unwrap_or_default();
let text = seed
.get("valueExpr")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| {
seed.get("value")
.and_then(|v| v.as_f64())
.map(|n| format!("{n}"))
})
.unwrap_or_default();
self.editing_dim = Some((id, text));
self.dim_edit_fresh = true;
}
if self.editing_dim.is_some() {
ctx.request_repaint();
}
}
pub(super) fn draw_feature_dimension_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
if state.gizmo_mode() != "dimension" {
self.editing_feature_dim = None;
return;
}
let feature = state.dimension_armed_feature();
if feature.is_empty() {
self.editing_feature_dim = None;
return;
}
let annotations: Vec<serde_json::Value> =
serde_json::from_str(&state.feature_dimension_annotations_json(&feature))
.unwrap_or_default();
if let Some((_, field, _)) = self.editing_feature_dim.as_ref() {
let key = field.clone();
if !annotations
.iter()
.any(|a| a["fieldKey"].as_str() == Some(key.as_str()))
{
self.editing_feature_dim = None;
}
}
if annotations.is_empty() {
return;
}
let worlds: Vec<[f64; 3]> = annotations
.iter()
.map(|a| {
let m = &a["mid"];
[
m[0].as_f64().unwrap_or(0.0),
m[1].as_f64().unwrap_or(0.0),
m[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != annotations.len() {
return;
}
let label_scale = state.settings.label_scale;
let fresh = self.feature_dim_edit_fresh;
self.feature_dim_edit_fresh = false;
let mut editing = self.editing_feature_dim.take();
let editing_key = editing.as_ref().map(|(_, field, _)| field.clone());
let mut start_edit: Option<(String, String)> = None; let mut apply = false;
let mut cancel = false;
let mut drag_to: Option<(String, f64, f64)> = None;
for (i, annotation) in annotations.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let Some(field) = annotation["fieldKey"].as_str() else {
continue;
};
let field = field.to_string();
let value = annotation["value"].as_f64().unwrap_or(0.0);
let prefix = annotation["label"].as_str().unwrap_or("").to_string();
let is_angular = annotation["kind"].as_str() == Some("angular");
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let is_editing = editing_key.as_deref() == Some(field.as_str());
let area_id = egui::Id::new(("brep-feature-dim", feature.clone(), field.clone()));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let orange = egui::Color32::from_rgb(245, 166, 35);
let dark = egui::Color32::from_rgb(20, 20, 20);
let font = label_font(ui, label_scale);
egui::Frame::new()
.fill(dark)
.stroke(egui::Stroke::new(1.0, orange))
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(chip_margin(6.0, 3.0, label_scale))
.show(ui, |ui| {
scale_label_spacing(ui, label_scale);
if is_editing {
let buf = &mut editing.as_mut().expect("editing buffer").2;
let editor = label_text_edit(ui, buf, &font, label_scale)
.text_color(orange)
.frame(egui::Frame::NONE);
let resp = ui.add(editor);
if fresh {
resp.request_focus();
select_all_text_edit(ui.ctx(), resp.id, buf.chars().count());
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
if resp.lost_focus() {
if enter {
apply = true;
} else if !fresh {
cancel = true;
}
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
}
} else {
let text = if is_angular {
format!("{prefix} {}\u{00b0}", fmt_dim_value(value))
} else {
format!("{prefix} {}", fmt_dim_value(value))
};
let resp = ui.add(
egui::Button::new(
egui::RichText::new(text)
.font(font.clone())
.color(orange),
)
.frame(false)
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click_and_drag()),
);
if resp.double_clicked() {
start_edit = Some((field.clone(), fmt_dim_value(value)));
}
if resp.dragged() {
if let Some(p) = resp.interact_pointer_pos() {
drag_to = Some((
field.clone(),
(p.x - rect.min.x) as f64,
(p.y - rect.min.y) as f64,
));
}
}
}
});
});
}
if apply {
if let Some((feat, field, text)) = editing.take() {
state.feature_dimension_set_value(&feat, &field, &text);
}
self.editing_feature_dim = None;
} else if cancel {
self.editing_feature_dim = None;
} else {
self.editing_feature_dim = editing;
}
if let Some((field, lx, ly)) = drag_to {
state.feature_dimension_drag(&feature, &field, lx, ly);
}
if let Some((field, seed)) = start_edit {
self.editing_feature_dim = Some((feature.clone(), field, seed));
self.feature_dim_edit_fresh = true;
}
if self.editing_feature_dim.is_some() {
ctx.request_repaint();
}
}
pub(super) fn draw_constraint_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
let labels: Vec<serde_json::Value> =
serde_json::from_str(&state.constraint_labels_json()).unwrap_or_default();
if labels.is_empty() {
if self.constraint_label_hovered.take().is_some() {
state.constraint_hover_end();
}
return;
}
let worlds: Vec<[f64; 3]> = labels
.iter()
.map(|l| {
let w = &l["world"];
[
w[0].as_f64().unwrap_or(0.0),
w[1].as_f64().unwrap_or(0.0),
w[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != labels.len() {
return;
}
let label_scale = state.settings.label_scale;
let mut hovered: Option<String> = None;
let mut clicked: Option<String> = None;
for (i, label) in labels.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let Some(id) = label["id"].as_str() else {
continue;
};
let text = label["text"].as_str().unwrap_or(id).to_string();
let message = label["message"].as_str().unwrap_or("").to_string();
let status = label["status"].as_str().unwrap_or("").to_string();
let rgb = &label["color"];
let color = egui::Color32::from_rgb(
(rgb[0].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[1].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[2].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
);
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let area_id = egui::Id::new(("brep-constraint-label", id));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let selected = label["selected"].as_bool().unwrap_or(false);
let dark = egui::Color32::from_rgb(20, 20, 20);
let font = label_font(ui, label_scale);
egui::Frame::new()
.fill(dark)
.stroke(egui::Stroke::new(if selected { 2.5 } else { 1.0 }, color))
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(chip_margin(6.0, 3.0, label_scale))
.show(ui, |ui| {
scale_label_spacing(ui, label_scale);
let (icon, rest) = crate::icon_text::split_caption(&text);
let rich = egui::RichText::new(rest).font(font.clone()).color(color);
let button = match icon {
Some(icon) => {
egui_extras::install_image_loaders(ui.ctx());
let height = ui.fonts_mut(|f| f.row_height(&font));
let mut art = crate::icon_text::image(icon, height);
if icon.mono {
art = art.tint(color);
}
if rest.is_empty() {
egui::Button::new(art)
} else {
egui::Button::new((art, rich))
}
}
None => egui::Button::new(rich),
};
let resp = ui.add(
button
.frame(false)
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click()),
);
let resp = if message.is_empty() {
resp.on_hover_text(format!("{id} \u{2014} {status}"))
} else {
resp.on_hover_text(format!("{id} \u{2014} {status}: {message}"))
};
if resp.hovered() {
hovered = Some(id.to_string());
}
if resp.clicked() {
clicked = Some(id.to_string());
}
});
});
}
match hovered {
Some(id) => {
state.constraint_hover(&id);
self.constraint_label_hovered = Some(id);
}
None => {
if self.constraint_label_hovered.take().is_some() {
state.constraint_hover_end();
}
}
}
if let Some(id) = clicked {
state.constraint_label_clicked(&id);
}
}
pub(super) fn draw_pmi_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
let labels: Vec<serde_json::Value> =
serde_json::from_str(&state.pmi_labels_json()).unwrap_or_default();
self.pmi_label_hits.clear();
if labels.is_empty() {
if self.pmi_label_hovered.take().is_some() {
state.pmi_hover_end();
}
if self.pmi_label_dragging.take().is_some() {
state.pmi_label_drag_end();
}
return;
}
let worlds: Vec<[f64; 3]> = labels
.iter()
.map(|l| {
let w = &l["world"];
[
w[0].as_f64().unwrap_or(0.0),
w[1].as_f64().unwrap_or(0.0),
w[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != labels.len() {
return;
}
let label_scale = state.settings.label_scale;
let mut hovered: Option<String> = None;
let mut clicked: Option<String> = None;
let mut drag_to: Option<(String, f64, f64)> = None;
let mut drag_ended = false;
let mut chip_rects: Vec<(String, egui::Rect)> = Vec::new();
for (i, label) in labels.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let Some(id) = label["id"].as_str() else {
continue;
};
let text = label["text"].as_str().unwrap_or(id).to_string();
let message = label["message"].as_str().unwrap_or("").to_string();
let status = label["status"].as_str().unwrap_or("").to_string();
let open = label["open"].as_bool().unwrap_or(false);
let text_size = label["textSizePt"].as_f64().unwrap_or(12.0);
let rgb = &label["color"];
let color = egui::Color32::from_rgb(
(rgb[0].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[1].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[2].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
);
let scale = label_scale * (text_size / 12.0) as f32;
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let area_id = egui::Id::new(("brep-pmi-label", id));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let dark = egui::Color32::from_rgb(20, 20, 20);
let font = label_font(ui, scale);
egui::Frame::new()
.fill(dark)
.stroke(egui::Stroke::new(if open { 2.5 } else { 1.0 }, color))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(chip_margin(6.0, 3.0, scale))
.show(ui, |ui| {
scale_label_spacing(ui, scale);
let (icon, rest) = crate::icon_text::split_caption(&text);
let rich = egui::RichText::new(rest).font(font.clone()).color(color);
let button = match icon {
Some(icon) => {
egui_extras::install_image_loaders(ui.ctx());
let height = ui.fonts_mut(|f| f.row_height(&font));
let mut art = crate::icon_text::image(icon, height);
if icon.mono {
art = art.tint(color);
}
if rest.is_empty() {
egui::Button::new(art)
} else {
egui::Button::new((art, rich))
}
}
None => egui::Button::new(rich),
};
let resp = ui.add(
button
.frame(false)
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click_and_drag()),
);
let resp = if message.is_empty() {
resp.on_hover_text(format!("{id} \u{2014} {status} (drag to move, click to edit)"))
} else {
resp.on_hover_text(format!("{id} \u{2014} {status}: {message}"))
};
if resp.hovered() {
hovered = Some(id.to_string());
}
if resp.clicked() {
clicked = Some(id.to_string());
}
if resp.dragged() {
if let Some(p) = resp.interact_pointer_pos() {
drag_to = Some((
id.to_string(),
(p.x - rect.min.x) as f64,
(p.y - rect.min.y) as f64,
));
}
}
if resp.drag_stopped() {
drag_ended = true;
}
chip_rects.push((id.to_string(), resp.rect));
});
});
}
self.pmi_label_hits = chip_rects;
if let Some((id, lx, ly)) = drag_to {
self.pmi_label_dragging = Some(id.clone());
state.pmi_label_drag_to(&id, lx, ly);
}
if drag_ended {
self.pmi_label_dragging = None;
state.pmi_label_drag_end();
}
match hovered {
Some(id) => {
state.pmi_hover(&id);
self.pmi_label_hovered = Some(id);
}
None => {
if self.pmi_label_hovered.take().is_some() {
state.pmi_hover_end();
}
}
}
if let Some(id) = clicked {
state.pmi_label_clicked(&id);
}
}
pub(super) fn draw_transform_axis_labels(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
state: &mut EngineState,
) {
if state.gizmo_mode() != "transform" {
return;
}
let labels: Vec<serde_json::Value> =
serde_json::from_str(&state.transform_axis_labels_json()).unwrap_or_default();
if labels.is_empty() {
return;
}
let worlds: Vec<[f64; 3]> = labels
.iter()
.map(|l| {
let w = &l["world"];
[
w[0].as_f64().unwrap_or(0.0),
w[1].as_f64().unwrap_or(0.0),
w[2].as_f64().unwrap_or(0.0),
]
})
.collect();
let screens: Vec<[f64; 4]> = serde_json::to_string(&worlds)
.ok()
.and_then(|s| state.world_to_screen_json(&s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if screens.len() != labels.len() {
return;
}
let label_scale = state.settings.label_scale;
for (i, label) in labels.iter().enumerate() {
let scr = screens[i];
if scr[3] < 0.5 {
continue;
}
let text = label["text"].as_str().unwrap_or("").to_string();
let rgb = &label["rgb"];
let color = egui::Color32::from_rgb(
(rgb[0].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[1].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
(rgb[2].as_f64().unwrap_or(1.0) * 255.0).round() as u8,
);
let pos = egui::pos2(rect.min.x + scr[0] as f32, rect.min.y + scr[1] as f32);
let area_id = egui::Id::new(("brep-transform-axis", i));
egui::Area::new(area_id)
.order(egui::Order::Middle)
.interactable(false)
.fixed_pos(pos)
.pivot(egui::Align2::CENTER_CENTER)
.show(ctx, |ui| {
let font = label_font(ui, label_scale);
ui.label(egui::RichText::new(text).font(font).strong().color(color));
});
}
}
pub(super) fn draw_gizmo_hit_areas(
&self,
ctx: &egui::Context,
rect: egui::Rect,
state: &EngineState,
) {
if !state.settings.debug_grab_handles {
return;
}
let json = match state.gizmo_mode() {
"dimension" => state.dimension_hit_areas_json(),
_ => state.transform_hit_areas_json(),
};
let areas: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap_or_default();
if areas.is_empty() {
return;
}
let stroke = egui::Stroke::new(1.0, egui::Color32::RED);
let mut painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("brep-gizmo-hit-areas"),
));
painter.set_clip_rect(rect);
let at = |v: &serde_json::Value| {
egui::pos2(
rect.min.x + v[0].as_f64().unwrap_or(0.0) as f32,
rect.min.y + v[1].as_f64().unwrap_or(0.0) as f32,
)
};
for area in &areas {
let r = area["r"].as_f64().unwrap_or(0.0) as f32;
match area["kind"].as_str() {
Some("capsule") => {
draw_capsule_outline(&painter, at(&area["a"]), at(&area["b"]), r, stroke);
}
Some("circle") => {
painter.circle_stroke(at(&area["c"]), r, stroke);
}
_ => {}
}
}
}
}
fn draw_capsule_outline(
painter: &egui::Painter,
a: egui::Pos2,
b: egui::Pos2,
px: f32,
stroke: egui::Stroke,
) {
let seg = b - a;
let len = seg.length();
if len < 1.0 {
painter.circle_stroke(a, px, stroke);
return;
}
let dir = seg / len;
let perp = egui::vec2(-dir.y, dir.x) * px; let ang0 = perp.y.atan2(perp.x);
const CAP_SEGS: usize = 8;
let pi = std::f32::consts::PI;
let mut pts: Vec<egui::Pos2> = Vec::with_capacity(4 + 2 * CAP_SEGS);
pts.push(a + perp);
pts.push(b + perp);
for k in 1..CAP_SEGS {
let t = ang0 - pi * (k as f32) / (CAP_SEGS as f32);
pts.push(b + egui::vec2(t.cos(), t.sin()) * px);
}
pts.push(b - perp);
pts.push(a - perp);
for k in 1..CAP_SEGS {
let t = (ang0 + pi) - pi * (k as f32) / (CAP_SEGS as f32);
pts.push(a + egui::vec2(t.cos(), t.sin()) * px);
}
painter.add(egui::Shape::closed_line(pts, stroke));
}
fn fmt_dim_value(value: f64) -> String {
brep_render::formatting::compact_decimal(value, 4)
}