forked from 1b-agent/opencadstudio-upstream
The ribbon offered four styles and dispatched each one's id as a command. Two of those ids were not visual-style commands: "Shaded" carried SOLID, which draws a 2D filled polygon, so choosing it started a draw command; "Hidden" carried HIDDEN, which matched nothing at all, so it did nothing. The intent had been for the button to fire its tool's event, but the dropdown never reads one -- it dispatches the id. Behind that sat two generations of the same feature. The render-mode picker offered the seven styles a viewport can actually be drawn in; everything else -- the ribbon, VSCURRENT, SHADEMODE, VISUALSTYLES and a handful of bare verbs -- went through a binary wireframe-or-shaded flag that could only approximate them, reporting "Hidden (shown shaded)" and "X-Ray (shown as wireframe)" when asked for something it had no way to draw. Four descriptions of one choice, each drifting on its own. There is one list now. Each style names itself once -- mode, label, icon, and the command that applies it -- and the ribbon, the picker, the VISUALSTYLES verb and the interactive prompt all read from it, down to the line that lists the choices, so what is offered cannot disagree with what works. Keywords are the render modes' own names, since there is one set of styles left to name. The binary path is gone rather than kept alongside: its message, its module event, the tool definitions that produced it, the ribbon's special-cased highlight arms, the bare style verbs and the older keyword spellings. X-Ray goes with it -- no render mode draws one, and it was already coming out as a plain wireframe. Three files that had been left holding a single icon constant each fold into the list. Note: ModuleEvent::SetWireframe leaves the plugin API with it. Closes #621 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
573 lines
21 KiB
Rust
573 lines
21 KiB
Rust
use super::*;
|
|
use super::super::document::{DynComponent, DynFieldEntry};
|
|
use super::super::Message;
|
|
use iced::widget::{button, canvas, column, container, mouse_area, row, text, tooltip};
|
|
use iced::{Background, Border, Color, Element, Length, Point, Rectangle, 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()
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct RenderModePreview {
|
|
mode: acadrust::entities::ViewportRenderMode,
|
|
}
|
|
|
|
impl canvas::Program<Message> for RenderModePreview {
|
|
type State = ();
|
|
|
|
fn draw(
|
|
&self,
|
|
_state: &(),
|
|
renderer: &iced::Renderer,
|
|
theme: &Theme,
|
|
bounds: Rectangle,
|
|
_cursor: iced::mouse::Cursor,
|
|
) -> Vec<canvas::Geometry> {
|
|
|
|
use acadrust::entities::ViewportRenderMode as M;
|
|
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
|
let palette = theme.palette();
|
|
let ink = palette.background.base.text.scale_alpha(0.86);
|
|
let faint = ink.scale_alpha(0.34);
|
|
let accent = palette.primary.base.color;
|
|
let face = palette.primary.weak.color.scale_alpha(0.72);
|
|
let face_alt = palette.primary.strong.color.scale_alpha(0.64);
|
|
let edge = if self.mode == M::Wireframe3D { accent } else { ink };
|
|
let shaded = matches!(
|
|
self.mode,
|
|
M::FlatShaded
|
|
| M::GouraudShaded
|
|
| M::FlatShadedWithEdges
|
|
| M::GouraudShadedWithEdges
|
|
);
|
|
let flat = matches!(self.mode, M::FlatShaded | M::FlatShadedWithEdges);
|
|
let smooth = matches!(self.mode, M::GouraudShaded | M::GouraudShadedWithEdges);
|
|
let with_edges = matches!(
|
|
self.mode,
|
|
M::Wireframe2D
|
|
| M::Wireframe3D
|
|
| M::HiddenLine
|
|
| M::FlatShadedWithEdges
|
|
| M::GouraudShadedWithEdges
|
|
);
|
|
|
|
// A small cube makes hidden/back edges and the shaded-edge variants
|
|
// immediately distinguishable without depending on the open drawing.
|
|
let a = Point::new(25.0, 35.0);
|
|
let b = Point::new(87.0, 35.0);
|
|
let c = Point::new(87.0, 94.0);
|
|
let d = Point::new(25.0, 94.0);
|
|
let off = iced::Vector::new(22.0, -17.0);
|
|
let e = a + off;
|
|
let f = b + off;
|
|
let g = c + off;
|
|
let h = d + off;
|
|
let quad = |points: [Point; 4]| {
|
|
canvas::Path::new(|path| {
|
|
path.move_to(points[0]);
|
|
path.line_to(points[1]);
|
|
path.line_to(points[2]);
|
|
path.line_to(points[3]);
|
|
path.close();
|
|
})
|
|
};
|
|
if shaded || self.mode == M::HiddenLine {
|
|
frame.fill(&quad([a, b, f, e]), face_alt);
|
|
frame.fill(&quad([b, c, g, f]), face.scale_alpha(0.82));
|
|
frame.fill(&quad([a, d, c, b]), face);
|
|
}
|
|
let stroke_line = |frame: &mut canvas::Frame, from: Point, to: Point, color: Color| {
|
|
frame.stroke(
|
|
&canvas::Path::line(from, to),
|
|
canvas::Stroke::default().with_color(color).with_width(1.2),
|
|
);
|
|
};
|
|
if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) {
|
|
for (from, to) in [(e, f), (f, g), (g, h), (h, e)] {
|
|
stroke_line(&mut frame, from, to, faint);
|
|
}
|
|
}
|
|
if with_edges {
|
|
for (from, to) in [
|
|
(a, b), (b, c), (c, d), (d, a),
|
|
(a, e), (b, f), (c, g), (d, h),
|
|
(e, f), (f, g),
|
|
] {
|
|
stroke_line(&mut frame, from, to, edge);
|
|
}
|
|
}
|
|
|
|
// Curved sample: faceted tones for Flat, concentric highlight for
|
|
// Gouraud, and latitude/longitude lines for the wireframe modes.
|
|
let center = Point::new((bounds.width - 55.0).max(145.0), 59.0);
|
|
let radius = 32.0;
|
|
if flat {
|
|
for i in 0..12 {
|
|
let a0 = i as f32 * std::f32::consts::TAU / 12.0;
|
|
let a1 = (i + 1) as f32 * std::f32::consts::TAU / 12.0;
|
|
let wedge = canvas::Path::new(|path| {
|
|
path.move_to(center);
|
|
path.line_to(Point::new(center.x + radius * a0.cos(), center.y + radius * a0.sin()));
|
|
path.line_to(Point::new(center.x + radius * a1.cos(), center.y + radius * a1.sin()));
|
|
path.close();
|
|
});
|
|
frame.fill(&wedge, if i % 3 == 0 { face_alt } else { face });
|
|
}
|
|
} else if smooth {
|
|
frame.fill(&canvas::Path::circle(center, radius), face_alt);
|
|
frame.fill(
|
|
&canvas::Path::circle(Point::new(center.x - 7.0, center.y - 8.0), radius * 0.72),
|
|
face,
|
|
);
|
|
frame.fill(
|
|
&canvas::Path::circle(Point::new(center.x - 12.0, center.y - 13.0), radius * 0.34),
|
|
palette.primary.weak.text.scale_alpha(0.38),
|
|
);
|
|
}
|
|
if with_edges || self.mode == M::HiddenLine {
|
|
frame.stroke(
|
|
&canvas::Path::circle(center, radius),
|
|
canvas::Stroke::default().with_color(edge).with_width(1.2),
|
|
);
|
|
}
|
|
if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) {
|
|
for scale in [0.38_f32, 0.72] {
|
|
let y = radius * scale;
|
|
let half = (radius * radius - y * y).sqrt();
|
|
stroke_line(
|
|
&mut frame,
|
|
Point::new(center.x - half, center.y - y),
|
|
Point::new(center.x + half, center.y - y),
|
|
faint,
|
|
);
|
|
stroke_line(
|
|
&mut frame,
|
|
Point::new(center.x - half, center.y + y),
|
|
Point::new(center.x + half, center.y + y),
|
|
faint,
|
|
);
|
|
}
|
|
stroke_line(
|
|
&mut frame,
|
|
Point::new(center.x, center.y - radius),
|
|
Point::new(center.x, center.y + radius),
|
|
faint,
|
|
);
|
|
}
|
|
|
|
// Bottom samples show the intentional Wireframe 2D/3D distinction:
|
|
// the legacy planar solid loses only its interior in the 3D style,
|
|
// while a hatch-like pattern remains visible in both.
|
|
let solid = quad([
|
|
Point::new(18.0, 118.0),
|
|
Point::new(74.0, 112.0),
|
|
Point::new(69.0, 140.0),
|
|
Point::new(23.0, 143.0),
|
|
]);
|
|
if self.mode != M::Wireframe3D {
|
|
frame.fill(&solid, accent.scale_alpha(0.48));
|
|
}
|
|
frame.stroke(
|
|
&solid,
|
|
canvas::Stroke::default().with_color(edge).with_width(1.0),
|
|
);
|
|
let hatch_box = canvas::Path::rectangle(Point::new(105.0, 112.0), iced::Size::new(82.0, 31.0));
|
|
frame.stroke(
|
|
&hatch_box,
|
|
canvas::Stroke::default().with_color(ink).with_width(1.0),
|
|
);
|
|
for x in (94..188).step_by(9) {
|
|
let x = x as f32;
|
|
stroke_line(
|
|
&mut frame,
|
|
Point::new(x.max(105.0), 143.0),
|
|
Point::new((x + 24.0).min(187.0), 112.0),
|
|
faint,
|
|
);
|
|
}
|
|
|
|
vec![frame.into_geometry()]
|
|
}
|
|
}
|
|
|
|
pub(super) fn viewport_controls<'a>(
|
|
render_mode: acadrust::entities::ViewportRenderMode,
|
|
show_grid: bool,
|
|
snap_on: bool,
|
|
include_split: bool,
|
|
tile_count: usize,
|
|
render_mode_menu_open: bool,
|
|
render_mode_preview: Option<acadrust::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(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 mut choices = column![].spacing(2).width(Length::Fixed(174.0));
|
|
for choice in render_modes {
|
|
let highlighted = choice.0 == preview_mode;
|
|
let selected = choice.0 == render_mode;
|
|
let option = container(text(choice.to_string()).size(11).width(Length::Fill))
|
|
.padding([6, 8])
|
|
.width(Length::Fill)
|
|
.style(move |theme: &Theme| {
|
|
let palette = theme.palette();
|
|
let active = highlighted;
|
|
container::Style {
|
|
background: active.then_some(Background::Color(
|
|
if selected { palette.primary.weak.color } else { palette.background.strong.color },
|
|
)),
|
|
text_color: if selected {
|
|
Some(palette.primary.weak.text)
|
|
} else if active {
|
|
Some(palette.background.strong.text)
|
|
} else {
|
|
Some(palette.background.base.text)
|
|
},
|
|
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 preview = container(
|
|
column![
|
|
container(
|
|
text(RenderModeChoice(preview_mode).to_string())
|
|
.size(12)
|
|
.wrapping(iced::advanced::text::Wrapping::None),
|
|
)
|
|
.width(Length::Fixed(215.0))
|
|
.height(Length::Fixed(16.0))
|
|
.align_y(iced::alignment::Vertical::Center),
|
|
canvas(RenderModePreview { mode: preview_mode })
|
|
.width(Length::Fixed(215.0))
|
|
.height(Length::Fixed(154.0)),
|
|
]
|
|
.spacing(6),
|
|
)
|
|
.padding(8)
|
|
.style(|theme: &Theme| {
|
|
let palette = theme.palette();
|
|
container::Style {
|
|
background: Some(Background::Color(palette.background.base.color)),
|
|
border: Border {
|
|
color: palette.background.neutral.color,
|
|
width: 1.0,
|
|
radius: 3.0.into(),
|
|
},
|
|
text_color: Some(palette.background.base.text),
|
|
..Default::default()
|
|
}
|
|
});
|
|
let popup = container(
|
|
row![choices, preview]
|
|
.spacing(6)
|
|
.align_y(iced::alignment::Vertical::Top),
|
|
)
|
|
.padding(6)
|
|
.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,
|
|
)
|
|
.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 => format!("{:.4}", if wh { dx.abs() } else { dx }),
|
|
DynComponent::Y if relative => format!("{:.4}", if wh { dy.abs() } else { dy }),
|
|
DynComponent::Z if relative => "0.0000".to_string(),
|
|
DynComponent::X => format!("{:.4}", p.x),
|
|
DynComponent::Y => format!("{:.4}", p.y),
|
|
DynComponent::Z => format!("{:.4}", p.z),
|
|
// Scaled by the role so a diameter box reads twice the radius.
|
|
DynComponent::Distance => {
|
|
format!("{:.4}", (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 => {
|
|
format!("{:.1}", crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32))
|
|
}
|
|
// Typed-only scalar — no geometric value to track when empty.
|
|
DynComponent::Scalar => String::new(),
|
|
}
|
|
}
|