forked from 1b-agent/opencadstudio-upstream
The codec and kernel repositories and crates are renamed opencadcodec and opencadkernel. Inside OCS they are depended on under short keys, so code reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node graph engine). File-format and protocol spellings stay as they were: the ACIS product id `acadrust`, the ACADRUST_* switches and the plugin version keys `acadrust_version` / `acadrust_source`. A button left of MCP on the command line opens a translucent node graph over the viewport, with a palette of object, input, math, logic, list, text and geometry nodes added by click or drag. An object node owns drawing entities and its rows are the Properties panel's own rows for them: the single-object page is extracted from refresh_properties as entity_property_sections, values use the control protocol's property_json, and writes go through set_property_value with a property target override, so the panel, MCP and the graph share one read/write path. A list reaching an object row makes one entity per element. Each graph action is one undo step; the canvas pans and zooms. Co-Authored-By: Claude Opus 5.5 <[email protected]>
476 lines
18 KiB
Rust
476 lines
18 KiB
Rust
use super::super::document::{DynComponent, DynFieldEntry};
|
|
use super::super::Message;
|
|
use super::*;
|
|
use iced::widget::{button, column, container, mouse_area, row, text, tooltip};
|
|
use iced::{Background, Border, Element, Length, Theme};
|
|
use std::time::Duration;
|
|
|
|
fn viewport_tooltip<'a>(
|
|
control: impl Into<Element<'a, Message>>,
|
|
title: String,
|
|
command: &'static str,
|
|
) -> Element<'a, Message> {
|
|
let text = format!("{title}\n{} {command}", crate::t!("Command:"));
|
|
tooltip(
|
|
control,
|
|
crate::ui::ribbon::tooltip_content(text),
|
|
tooltip::Position::Bottom,
|
|
)
|
|
.gap(6.0)
|
|
.delay(Duration::from_millis(400))
|
|
.style(crate::ui::ribbon::tooltip_style)
|
|
.into()
|
|
}
|
|
|
|
pub(super) fn viewport_controls<'a>(
|
|
render_mode: codec::entities::ViewportRenderMode,
|
|
show_grid: bool,
|
|
snap_on: bool,
|
|
include_split: bool,
|
|
tile_count: usize,
|
|
render_mode_menu_open: bool,
|
|
render_mode_preview: Option<codec::entities::ViewportRenderMode>,
|
|
) -> Element<'a, Message> {
|
|
let render_modes: Vec<RenderModeChoice> = crate::modules::view::visual_style::VISUAL_STYLES
|
|
.iter()
|
|
.map(|style| RenderModeChoice(style.mode))
|
|
.collect();
|
|
let danger_btn =
|
|
move |bytes: &'static [u8], msg: Message, title: String, command: &'static str| {
|
|
let button = button(crate::ui::icons::themed_danger(bytes, 15.0))
|
|
.on_press(msg)
|
|
.padding([4, 6])
|
|
.style(move |theme: &Theme, status| iced::widget::button::Style {
|
|
background: matches!(
|
|
status,
|
|
iced::widget::button::Status::Hovered
|
|
| iced::widget::button::Status::Pressed
|
|
)
|
|
.then_some(Background::Color(theme.palette().danger.weak.color)),
|
|
border: Border {
|
|
radius: 3.0.into(),
|
|
..Default::default()
|
|
},
|
|
text_color: theme.palette().danger.base.color,
|
|
..Default::default()
|
|
});
|
|
viewport_tooltip(button, title, command)
|
|
};
|
|
|
|
// Borderless icon button; an `active` toggle gets an accent tint + fill.
|
|
let icon_btn = move |bytes: &'static [u8],
|
|
active: bool,
|
|
msg: Message,
|
|
title: String,
|
|
command: &'static str| {
|
|
let icon = if active {
|
|
crate::ui::icons::themed_primary_weak_text(bytes, 15.0)
|
|
} else {
|
|
crate::ui::icons::themed(bytes, 15.0)
|
|
};
|
|
let button =
|
|
button(icon)
|
|
.on_press(msg)
|
|
.padding([4, 6])
|
|
.style(move |theme: &Theme, status| {
|
|
let palette = theme.palette();
|
|
let pair = match (active, status) {
|
|
(_, iced::widget::button::Status::Hovered) => {
|
|
Some(palette.background.strong)
|
|
}
|
|
(true, _) => Some(palette.primary.weak),
|
|
(false, _) => None,
|
|
};
|
|
iced::widget::button::Style {
|
|
background: pair.map(|p| Background::Color(p.color)),
|
|
border: Border {
|
|
radius: 3.0.into(),
|
|
..Default::default()
|
|
},
|
|
text_color: pair.map(|p| p.text).unwrap_or(palette.background.base.text),
|
|
..Default::default()
|
|
}
|
|
});
|
|
viewport_tooltip(button, title, command)
|
|
};
|
|
|
|
// The standard picker cannot show a live sample beside its rows. This
|
|
// flyout keeps the current style unchanged while hovering; a click commits.
|
|
let picker_head = button(
|
|
row![
|
|
text(RenderModeChoice(render_mode).to_string()).size(11),
|
|
crate::ui::icons::themed_arrow_toggle(render_mode_menu_open, 9.0),
|
|
]
|
|
.spacing(6)
|
|
.align_y(iced::Center),
|
|
)
|
|
.on_press(Message::ToggleRenderModeMenu(render_mode))
|
|
.padding([4, 6])
|
|
.style(move |theme: &Theme, status| {
|
|
let palette = theme.palette();
|
|
let active = render_mode_menu_open
|
|
|| matches!(status, button::Status::Hovered | button::Status::Pressed);
|
|
button::Style {
|
|
background: active.then_some(Background::Color(palette.background.strong.color)),
|
|
text_color: if active {
|
|
palette.background.strong.text
|
|
} else {
|
|
palette.background.base.text
|
|
},
|
|
border: Border {
|
|
radius: 3.0.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}
|
|
});
|
|
let picker_head = viewport_tooltip(
|
|
picker_head,
|
|
crate::t!("Visual Style").into_owned(),
|
|
"VISUALSTYLES",
|
|
);
|
|
|
|
let preview_mode = render_mode_preview.unwrap_or(render_mode);
|
|
let mode_names: Vec<String> = render_modes.iter().map(|c| c.to_string()).collect();
|
|
let dropdown_w = crate::ui::style::common::dropdown_popup_width(
|
|
mode_names.iter().map(|s| s.as_str()),
|
|
11.0,
|
|
46.0,
|
|
180.0,
|
|
);
|
|
|
|
let mut choices = column![].spacing(2).width(Length::Fill);
|
|
for choice in render_modes {
|
|
let highlighted = choice.0 == preview_mode;
|
|
let selected = choice.0 == render_mode;
|
|
let checkmark = crate::ui::icons::themed_check_cell(selected);
|
|
let label = text(choice.to_string())
|
|
.size(11)
|
|
.wrapping(iced::advanced::text::Wrapping::None);
|
|
let row_content = row![checkmark, label]
|
|
.spacing(6)
|
|
.align_y(iced::alignment::Vertical::Center);
|
|
let option = container(row_content)
|
|
.padding([5, 8])
|
|
.width(Length::Fill)
|
|
.style(move |theme: &Theme| {
|
|
let palette = theme.palette();
|
|
let bg = if highlighted {
|
|
Some(Background::Color(palette.background.strong.color))
|
|
} else {
|
|
None
|
|
};
|
|
let text_color = if highlighted || selected {
|
|
palette.background.strong.text
|
|
} else {
|
|
palette.background.base.text
|
|
};
|
|
container::Style {
|
|
background: bg,
|
|
text_color: Some(text_color),
|
|
border: Border {
|
|
radius: 3.0.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}
|
|
});
|
|
choices = choices.push(
|
|
mouse_area(option)
|
|
.interaction(iced::mouse::Interaction::Pointer)
|
|
.on_enter(Message::PreviewRenderMode(choice.0))
|
|
.on_press(Message::SetRenderMode(choice.0)),
|
|
);
|
|
}
|
|
let popup = container(choices)
|
|
.padding(4)
|
|
.width(Length::Fixed(dropdown_w))
|
|
.style(|theme: &Theme| {
|
|
let palette = theme.palette();
|
|
container::Style {
|
|
background: Some(Background::Color(palette.background.weak.color)),
|
|
border: Border {
|
|
color: palette.background.neutral.color,
|
|
width: 1.0,
|
|
radius: 4.0.into(),
|
|
},
|
|
text_color: Some(palette.background.weak.text),
|
|
..Default::default()
|
|
}
|
|
});
|
|
let picker: Element<'a, Message> =
|
|
iced_aw::DropDown::new(picker_head, popup, render_mode_menu_open)
|
|
.width(Length::Fixed(dropdown_w))
|
|
.alignment(iced_aw::drop_down::Alignment::Bottom)
|
|
.offset(3.0)
|
|
.on_dismiss(Message::DismissRenderModeMenu)
|
|
.into();
|
|
|
|
// Thin vertical divider between control groups.
|
|
let sep = || {
|
|
container(iced::widget::Space::new().width(1.0).height(16.0)).style(|theme: &Theme| {
|
|
iced::widget::container::Style {
|
|
background: Some(Background::Color(
|
|
theme.palette().background.neutral.color.scale_alpha(0.7),
|
|
)),
|
|
..Default::default()
|
|
}
|
|
})
|
|
};
|
|
|
|
let mut bar = row![].spacing(3).align_y(iced::alignment::Vertical::Center);
|
|
bar = bar
|
|
.push(icon_btn(
|
|
crate::ui::icons::GRID,
|
|
show_grid,
|
|
Message::ToggleGrid,
|
|
crate::t!("Toggle Grid").into_owned(),
|
|
"GRID",
|
|
))
|
|
.push(sep())
|
|
.push(icon_btn(
|
|
crate::ui::icons::SNAP,
|
|
snap_on,
|
|
Message::ToggleGridSnap,
|
|
crate::t!("Toggle Grid Snap").into_owned(),
|
|
"SNAP",
|
|
))
|
|
.push(sep())
|
|
.push(picker);
|
|
if include_split {
|
|
bar = bar
|
|
.push(sep())
|
|
.push(icon_btn(
|
|
crate::ui::icons::SPLIT_V,
|
|
false,
|
|
Message::SplitModelViewport(false),
|
|
crate::tr!("viewport", "split-vertical"),
|
|
"VPORTS 2V",
|
|
))
|
|
.push(sep())
|
|
.push(icon_btn(
|
|
crate::ui::icons::SPLIT_H,
|
|
false,
|
|
Message::SplitModelViewport(true),
|
|
crate::tr!("viewport", "split-horizontal"),
|
|
"VPORTS 2H",
|
|
));
|
|
// Drag handle + close: only meaningful with more than one model tile.
|
|
// The handle is a `mouse_area` (not a button) so it fires on press-DOWN,
|
|
// letting the drag continue onto the target pane to swap them (a button
|
|
// would only fire on release). Placed just left of Close.
|
|
if tile_count > 1 {
|
|
let drag = mouse_area(
|
|
container(crate::ui::icons::themed_success(
|
|
crate::ui::icons::MOVE,
|
|
15.0,
|
|
))
|
|
.padding([4, 6])
|
|
.style(|_: &Theme| iced::widget::container::Style {
|
|
border: Border {
|
|
radius: 3.0.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.interaction(iced::mouse::Interaction::Grab)
|
|
.on_press(Message::PaneMoveStart);
|
|
let drag = viewport_tooltip(drag, crate::tr!("viewport", "move"), "VPORTS");
|
|
bar = bar.push(sep()).push(drag).push(sep()).push(danger_btn(
|
|
crate::ui::icons::CLOSE,
|
|
Message::CloseModelViewport,
|
|
crate::tr!("viewport", "close"),
|
|
"VPORTS SINGLE",
|
|
));
|
|
}
|
|
}
|
|
|
|
container(bar)
|
|
.padding(2)
|
|
.style(|theme: &Theme| {
|
|
let palette = theme.palette();
|
|
iced::widget::container::Style {
|
|
background: Some(Background::Color(
|
|
palette.background.weak.color.scale_alpha(0.92),
|
|
)),
|
|
border: Border {
|
|
color: palette.background.neutral.color,
|
|
width: 1.0,
|
|
radius: 4.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.into()
|
|
}
|
|
|
|
// ── Dynamic-input field formatting ─────────────────────────────────────────
|
|
|
|
/// Short prefix shown before a dynamic-input box's value.
|
|
/// The string shown inside a dynamic-input box: the typed buffer when the
|
|
/// field is locked, otherwise the live value derived from the cursor
|
|
/// world position (and the base point for polar quantities).
|
|
pub(super) fn dyn_component_value(
|
|
f: &DynFieldEntry,
|
|
w: glam::DVec3,
|
|
base: Option<glam::DVec3>,
|
|
xf: &super::super::helpers::UcsXform,
|
|
comma_cartesian: bool,
|
|
absolute: bool,
|
|
) -> String {
|
|
if let Some(b) = &f.buffer {
|
|
return b.clone();
|
|
}
|
|
let b = base.unwrap_or(glam::DVec3::ZERO);
|
|
let p = xf.to_ucs(w);
|
|
// Relative deltas and the polar angle read in the active UCS plane. The
|
|
// delta is offset-invariant, so only the axis rotation matters (identity
|
|
// xf reproduces the world-frame deltas).
|
|
let d = xf.vec_to_ucs(w - b);
|
|
let dx = d.x as f64;
|
|
let dy = d.y as f64;
|
|
// When a base point exists (DYN-on after the first pick) the cartesian
|
|
// fields show relative deltas — matching the typed-value convention
|
|
// in `dyn_resolve_point` so the live preview and the committed
|
|
// coordinate use the same frame. See #35.
|
|
let relative = base.is_some() && !absolute;
|
|
// Width / Height read as unsigned magnitudes (the sign is taken from the
|
|
// cursor side on commit), matching the rectangle's two-edge entry. But once
|
|
// the user separates the values with `,` the entry is a cartesian
|
|
// coordinate pair, so the fields read as signed X/Y deltas to match the
|
|
// committed point (#269).
|
|
let wh = matches!(
|
|
f.role,
|
|
crate::command::DynRole::Width | crate::command::DynRole::Height
|
|
) && relative
|
|
&& !comma_cartesian;
|
|
match f.component {
|
|
DynComponent::X if relative => crate::entities::common::format_length(if wh { dx.abs() } else { dx }),
|
|
DynComponent::Y if relative => crate::entities::common::format_length(if wh { dy.abs() } else { dy }),
|
|
DynComponent::Z if relative => crate::entities::common::format_length(0.0),
|
|
DynComponent::X => crate::entities::common::format_length(p.x),
|
|
DynComponent::Y => crate::entities::common::format_length(p.y),
|
|
DynComponent::Z => crate::entities::common::format_length(p.z),
|
|
// Scaled by the role so a diameter box reads twice the radius.
|
|
DynComponent::Distance => {
|
|
crate::entities::common::format_length(
|
|
(dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64,
|
|
)
|
|
}
|
|
// Shared rule: unsigned magnitude of the short angle, so CW (below the
|
|
// reference axis) reads positive (e.g. 30°, not -30°/330°). The
|
|
// committed value stays signed (see dyn_resolve_point).
|
|
DynComponent::Angle => {
|
|
crate::entities::common::format_angle(
|
|
(crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32) as f64).to_radians(),
|
|
)
|
|
}
|
|
// Typed-only scalar — no geometric value to track when empty.
|
|
DynComponent::Scalar => String::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codec::entities::ViewportRenderMode as M;
|
|
|
|
#[test]
|
|
fn test_viewport_controls_construction() {
|
|
for style in crate::modules::view::visual_style::VISUAL_STYLES {
|
|
let _el = viewport_controls(style.mode, true, true, true, 1, false, None);
|
|
let _el_open = viewport_controls(
|
|
style.mode,
|
|
false,
|
|
false,
|
|
false,
|
|
2,
|
|
true,
|
|
Some(M::Wireframe3D),
|
|
);
|
|
}
|
|
}
|
|
|
|
use crate::ui::style::common::wcag_contrast;
|
|
|
|
#[test]
|
|
fn test_visual_style_dropdown_contrast_across_all_themes() {
|
|
for theme in iced::Theme::ALL {
|
|
let p = theme.palette();
|
|
let bg = p.background.weak.color;
|
|
|
|
// 1. Text contrast (WCAG standard AA for normal text is >= 4.5:1, large/ui >= 3:1):
|
|
let strong_text = p.background.strong.text;
|
|
let text_contrast = wcag_contrast(strong_text, bg);
|
|
assert!(
|
|
text_contrast >= 4.5,
|
|
"Theme {:?} selected text contrast {:.2} on background is too low",
|
|
theme,
|
|
text_contrast
|
|
);
|
|
|
|
let base_text = p.background.base.text;
|
|
let base_contrast = wcag_contrast(base_text, bg);
|
|
assert!(
|
|
base_contrast >= 4.5,
|
|
"Theme {:?} unselected text contrast {:.2} on background is too low",
|
|
theme,
|
|
base_contrast
|
|
);
|
|
|
|
// 2. Checkmark contrast:
|
|
let pri = p.primary.base.color;
|
|
let pri_contrast = wcag_contrast(pri, bg);
|
|
let tick_color = if pri_contrast >= 2.0 {
|
|
pri
|
|
} else {
|
|
p.background.base.text
|
|
};
|
|
let final_tick_contrast = wcag_contrast(tick_color, bg);
|
|
assert!(
|
|
final_tick_contrast >= 2.0,
|
|
"Theme {:?} tick contrast {:.2} on background is too low",
|
|
theme,
|
|
final_tick_contrast
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_visual_style_dropdown_width_accommodates_all_items() {
|
|
let max_label_len = crate::modules::view::visual_style::VISUAL_STYLES
|
|
.iter()
|
|
.map(|c| c.label.chars().count())
|
|
.max()
|
|
.unwrap_or(0);
|
|
let dropdown_w = crate::ui::style::common::dropdown_popup_width(
|
|
crate::modules::view::visual_style::VISUAL_STYLES
|
|
.iter()
|
|
.map(|c| c.label),
|
|
11.0,
|
|
46.0,
|
|
180.0,
|
|
);
|
|
|
|
// Longest default label is "Gouraud Shaded + Edges" (22 chars)
|
|
assert_eq!(max_label_len, 22);
|
|
// Computed width should easily fit the 22-char label + checkmark + padding
|
|
assert!(dropdown_w >= 210.0);
|
|
|
|
// CJK labels count wide characters double
|
|
let cjk_labels = ["二维线框", "着色+边"];
|
|
let cjk_w = crate::ui::style::common::dropdown_popup_width(
|
|
cjk_labels.iter().copied(),
|
|
11.0,
|
|
46.0,
|
|
180.0,
|
|
);
|
|
assert_eq!(cjk_w, 180.0);
|
|
|
|
for style in crate::modules::view::visual_style::VISUAL_STYLES {
|
|
assert!(!style.label.contains('\n'), "Label should be a single line");
|
|
}
|
|
}
|
|
}
|