Merge remote-tracking branch 'upstream/main' into fix/716-perpendicular-tracking

This commit is contained in:
gianlucafiore 2026-08-09 11:43:08 -03:00
commit af7613f1d8
38 changed files with 45433 additions and 32398 deletions

View file

@ -339,10 +339,8 @@ pub(super) fn polar_constrain(
))
}
/// Polar constraint that only engages when the cursor is within `tol_px`
/// screen pixels of the nearest polar ray; otherwise the cursor is left free
/// so POLAR behaves as if off when pointing away from every angle (issue #70).
pub(super) fn polar_constrain_near(
/// Return the polar-constrained point while its ray is engaged.
pub(super) fn polar_constrain_if_near(
pt: glam::DVec3,
base: glam::DVec3,
step_deg: f32,
@ -351,7 +349,7 @@ pub(super) fn polar_constrain_near(
bounds: iced::Rectangle,
tol_px: f32,
xf: &UcsXform,
) -> glam::DVec3 {
) -> Option<glam::DVec3> {
let snapped = polar_constrain(pt, base, step_deg, xf);
let to_screen = |w: glam::DVec3| {
let ndc = view_rot.project_point3((w - eye).as_vec3());
@ -362,11 +360,22 @@ pub(super) fn polar_constrain_near(
};
let (cx, cy) = to_screen(pt);
let (sx, sy) = to_screen(snapped);
if ((cx - sx).powi(2) + (cy - sy).powi(2)).sqrt() <= tol_px {
snapped
} else {
pt
}
(((cx - sx).powi(2) + (cy - sy).powi(2)).sqrt() <= tol_px).then_some(snapped)
}
/// Constrain near an engaged polar ray; otherwise keep the cursor free.
pub(super) fn polar_constrain_near(
pt: glam::DVec3,
base: glam::DVec3,
step_deg: f32,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
tol_px: f32,
xf: &UcsXform,
) -> glam::DVec3 {
polar_constrain_if_near(pt, base, step_deg, view_rot, eye, bounds, tol_px, xf)
.unwrap_or(pt)
}
/// Hard axis lock (#312): the locked ray's direction — the nearest polar

View file

@ -430,8 +430,7 @@ pub(super) struct OpenCADStudio {
/// cursor is on a tracking ray. Lets a typed distance place a point along
/// the ray from the tracking point (issue #69). `None` when not aligned.
otrack_active: Option<(glam::DVec3, glam::DVec3)>,
/// Kind of the currently active tracking reference, kept separately from
/// `otrack_active` so typed-distance behavior remains unchanged.
/// Active OTRACK ray kind, separate from typed-distance geometry.
otrack_kind: Option<crate::snap::TrackingKind>,
/// Whether Tangent snap was enabled before a tangent-pick command started.
pre_cmd_tangent: Option<bool>,

View file

@ -1384,7 +1384,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
&& destination_is_current
{
self.command_line.push_error_once(
crate::tr!("recovery-save-new-file-required").as_ref(),
crate::tr!("recovery", "save-new-file-required").as_ref(),
);
self.restore_failed_save_continuation(continuation, i);
self.active_tab = i;

View file

@ -3137,6 +3137,8 @@ impl OpenCADStudio {
self.snapper.otrack_enabled ^= true;
if !self.snapper.otrack_enabled {
self.snapper.clear_tracking();
self.otrack_active = None;
self.otrack_kind = None;
}
Task::none()
}
@ -3997,23 +3999,23 @@ impl OpenCADStudio {
}
Text::EmptyOrUnsupported => {
self.command_line.push_error(
crate::tr!("clipboard-no-supported-content").as_ref(),
crate::tr!("clipboard", "no-supported-content").as_ref(),
);
Task::none()
}
Text::Unavailable => {
self.command_line
.push_error(crate::tr!("clipboard-unavailable").as_ref());
.push_error(crate::tr!("clipboard", "unavailable").as_ref());
Task::none()
}
Text::Occupied => {
self.command_line
.push_error(crate::tr!("clipboard-occupied").as_ref());
.push_error(crate::tr!("clipboard", "occupied").as_ref());
Task::none()
}
Text::ConversionFailed => {
self.command_line
.push_error(crate::tr!("clipboard-conversion-failed").as_ref());
.push_error(crate::tr!("clipboard", "conversion-failed").as_ref());
Task::none()
}
}

View file

@ -4,8 +4,9 @@
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
axis_lock_apply, axis_lock_capture, drafting_axes, drafting_constrain, parse_coord, polar_constrain_near,
ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind,
axis_lock_apply, axis_lock_capture, drafting_axes, drafting_constrain, parse_coord,
polar_constrain_if_near, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
@ -117,6 +118,45 @@ fn over_ucs_icon(p: Point, h: &crate::ui::overlay::UcsIconHit) -> bool {
}
impl OpenCADStudio {
fn active_construction_ray(
&self,
tab: usize,
cursor: glam::DVec3,
base: glam::DVec3,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
) -> Option<(glam::DVec3, glam::DVec3)> {
let ucs = self.tabs[tab].ucs_xform();
let mut target = if self.ortho_mode {
drafting_constrain(
cursor,
base,
&ucs,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
)
} else if self.polar_mode {
polar_constrain_if_near(
cursor,
base,
self.polar_increment_deg,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
&ucs,
)?
} else {
return None;
};
if self.tabs[tab].active_ucs.is_none() {
target.z = base.z;
}
Some((base, target))
}
/// Write PDSIZE from the dialog buffer with the current relative/absolute
/// sign. A relative size is stored negative; absolute positive. Switching to
/// absolute with an empty/zero size seeds a positive value from the current
@ -1034,60 +1074,10 @@ impl OpenCADStudio {
// snapping must drop its foot from this point, including when a
// hot-grip set is moved by the same drag vector.
self.snapper.from_point = Some(grip.origin_world.as_vec3());
self.snapper.from_point = Some(grip.origin_world.as_vec3());
// Keep the updated drafting basis from main so grid/snap follows the
// current drafting axes, including isometric drafting and SNAPANG.
let (go, gr) = self.drafting_grid_basis(i);
let base = grip.origin_world;
let ucs_xf = self.tabs[i].ucs_xform();
let mut construction_cursor = if self.ortho_mode {
Some(drafting_constrain(
raw,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
))
} else if self.polar_mode {
let pt = polar_constrain_near(
raw,
base,
self.polar_increment_deg,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
);
// POLAR being enabled is not enough: the cursor must actually
// be engaged on one of the polar tracking rays.
let delta = pt - base;
let step = (self.polar_increment_deg as f64).to_radians();
let angle = delta.y.atan2(delta.x);
let polar_engaged = step > 1e-9
&& (((angle / step).round() * step - angle).abs() < 1e-3)
&& (delta.x * delta.x + delta.y * delta.y).sqrt() > 1e-9;
if polar_engaged {
Some(pt)
} else {
None
}
} else {
None
};
if let Some(pt) = construction_cursor.as_mut() {
if self.tabs[i].active_ucs.is_none() {
pt.z = base.z;
}
}
let construction_ray =
self.active_construction_ray(i, raw, base, view_rot, eye, bounds);
// `raw` is already model space (viewport camera or paper→model),
// and the wires are model space, so the snap result is model.
let snap_hit = self.snapper.snap(
@ -1099,7 +1089,7 @@ impl OpenCADStudio {
bounds,
go,
gr,
construction_cursor,
construction_ray,
);
self.tabs[i].snap_result = snap_hit;
@ -1519,60 +1509,18 @@ impl OpenCADStudio {
// the downcast point here.
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
// Build the temporary construction direction before OSNAP.
// Intersection can then test the active rubber-band against
// existing geometry (#704).
let construction_cursor = if is_window_corner {
let construction_ray = if is_window_corner {
None
} else {
self.last_point.and_then(|base| {
let ucs_xf = self.tabs[i].ucs_xform();
let mut pt = if self.ortho_mode {
Some(drafting_constrain(
snap_cursor,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
))
} else if self.polar_mode {
let pt = polar_constrain_near(
snap_cursor,
base,
self.polar_increment_deg,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
);
// POLAR being enabled is not enough: the cursor must actually
// be engaged on one of the polar tracking rays.
let delta = pt - base;
let step = (self.polar_increment_deg as f64).to_radians();
let angle = delta.y.atan2(delta.x);
let polar_engaged = step > 1e-9
&& (((angle / step).round() * step - angle).abs() < 1e-3)
&& (delta.x * delta.x + delta.y * delta.y).sqrt() > 1e-9;
if polar_engaged {
Some(pt)
} else {
None
}
} else {
None
}?;
if self.tabs[i].active_ucs.is_none() {
pt.z = base.z;
}
Some(pt)
self.active_construction_ray(
i,
snap_cursor,
base,
view_rot,
eye,
bounds,
)
})
};
@ -1585,7 +1533,7 @@ impl OpenCADStudio {
bounds,
go,
gr,
construction_cursor,
construction_ray,
)
};
@ -2907,57 +2855,18 @@ impl OpenCADStudio {
let (go, gr) = self.drafting_grid_basis(i);
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
let construction_cursor = if is_window_corner {
let construction_ray = if is_window_corner {
None
} else {
self.last_point.and_then(|base| {
let ucs_xf = self.tabs[i].ucs_xform();
let mut pt = if self.ortho_mode {
Some(drafting_constrain(
self.active_construction_ray(
i,
snap_cursor,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
))
} else if self.polar_mode {
let pt = polar_constrain_near(
snap_cursor,
base,
self.polar_increment_deg,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
);
// POLAR being enabled is not enough: the cursor must actually
// be engaged on one of the polar tracking rays.
let delta = pt - base;
let step = (self.polar_increment_deg as f64).to_radians();
let angle = delta.y.atan2(delta.x);
let polar_engaged = step > 1e-9
&& (((angle / step).round() * step - angle).abs() < 1e-3)
&& (delta.x * delta.x + delta.y * delta.y).sqrt() > 1e-9;
if polar_engaged {
Some(pt)
} else {
None
}
} else {
None
}?;
if self.tabs[i].active_ucs.is_none() {
pt.z = base.z;
}
Some(pt)
view_rot,
eye,
bounds,
)
})
};
@ -2970,7 +2879,7 @@ impl OpenCADStudio {
bounds,
go,
gr,
construction_cursor,
construction_ray,
)
};
// Snap runs in model space; the result is already model.

View file

@ -449,7 +449,7 @@ pub(super) fn viewport_controls<'a>(
crate::ui::icons::SPLIT_V,
false,
Message::SplitModelViewport(false),
crate::tr!("viewport-split-vertical"),
crate::tr!("viewport", "split-vertical"),
"VPORTS 2V",
))
.push(sep())
@ -457,7 +457,7 @@ pub(super) fn viewport_controls<'a>(
crate::ui::icons::SPLIT_H,
false,
Message::SplitModelViewport(true),
crate::tr!("viewport-split-horizontal"),
crate::tr!("viewport", "split-horizontal"),
"VPORTS 2H",
));
// Drag handle + close: only meaningful with more than one model tile.
@ -478,7 +478,7 @@ pub(super) fn viewport_controls<'a>(
)
.interaction(iced::mouse::Interaction::Grab)
.on_press(Message::PaneMoveStart);
let drag = viewport_tooltip(drag, crate::tr!("viewport-move"), "VPORTS");
let drag = viewport_tooltip(drag, crate::tr!("viewport", "move"), "VPORTS");
bar = bar
.push(sep())
.push(drag)
@ -486,7 +486,7 @@ pub(super) fn viewport_controls<'a>(
.push(danger_btn(
crate::ui::icons::CLOSE,
Message::CloseModelViewport,
crate::tr!("viewport-close"),
crate::tr!("viewport", "close"),
"VPORTS SINGLE",
));
}

View file

@ -812,10 +812,10 @@ impl OpenCADStudio {
let tracking_hint = match self.otrack_kind {
Some(crate::snap::TrackingKind::Perpendicular) => {
Some("Perpendicular".to_string())
Some(crate::tr!("common", "perpendicular"))
}
Some(crate::snap::TrackingKind::Extension) => {
Some("Extension".to_string())
Some(crate::tr!("common", "extension"))
}
_ => None,
};
@ -2636,7 +2636,7 @@ fn start_page_content<'a>(
button(
row![
crate::ui::icons::themed_danger_text(crate::ui::icons::HEART, 14.0),
text(crate::tr!("start-donate")).size(14),
text(crate::tr!("start", "donate")).size(14),
]
.spacing(5)
.align_y(iced::Center),
@ -2650,8 +2650,8 @@ fn start_page_content<'a>(
};
let primary_row = WrapFlow::new(vec![
outline_btn(crate::tr!("start-new-drawing"), Message::TabNew).into(),
outline_btn(crate::tr!("start-open-file"), Message::OpenFile).into(),
outline_btn(crate::tr!("start", "new-drawing"), Message::TabNew).into(),
outline_btn(crate::tr!("start", "open-file"), Message::OpenFile).into(),
donate_btn.into(),
])
.spacing_x(12.0)
@ -2661,16 +2661,16 @@ fn start_page_content<'a>(
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut secondary_items: Vec<Element<'a, Message>> = vec![
outline_btn(
crate::tr!("start-send-feedback"),
crate::tr!("start", "send-feedback"),
Message::RibbonToolClick {
tool_id: "REPORT".to_string(),
event: crate::modules::ModuleEvent::Command("REPORT".to_string()),
},
)
.into(),
outline_btn(crate::tr!("action-options"), Message::OptionsOpen).into(),
outline_btn(crate::tr!("action", "options"), Message::OptionsOpen).into(),
];
secondary_items.push(outline_btn(crate::tr!("action-plugins"), Message::PluginManagerOpen).into());
secondary_items.push(outline_btn(crate::tr!("action", "plugins"), Message::PluginManagerOpen).into());
// The web build is already in the browser, so only the desktop offers a
// link to the web version.
#[cfg(not(target_arch = "wasm32"))]
@ -2693,7 +2693,7 @@ fn start_page_content<'a>(
.report_natural_width(action_width_out.clone());
let sponsors = column![
text(crate::tr!("start-sponsors")).size(15),
text(crate::tr!("start", "sponsors")).size(15),
mouse_area(
container(
iced::widget::svg(iced::widget::svg::Handle::from_memory(include_bytes!(
@ -2795,7 +2795,7 @@ fn start_page_content<'a>(
// whole thumbnail remains visible when that width changes.
let thumb_h =
(panel_w - VIDEO_PANEL_PADDING * 2.0 - VIDEO_SCROLL_GUTTER) * 9.0 / 16.0;
let mut list = column![text(crate::tr!("start-tutorials")).size(15)]
let mut list = column![text(crate::tr!("start", "tutorials")).size(15)]
.spacing(10)
.width(Fill)
// Keep the scrollbar off the thumbnails.
@ -2835,14 +2835,14 @@ fn start_page_content<'a>(
}
if videos.is_empty() {
let note = if videos_loading {
crate::tr!("start-loading-videos")
crate::tr!("start", "loading-videos")
} else {
crate::tr!("start-videos-online")
crate::tr!("start", "videos-online")
};
list = list.push(text(note).size(12).style(start_muted_style));
}
let playlist_btn = mouse_area(
container(text(crate::tr!("start-open-playlist")).size(12))
container(text(crate::tr!("start", "open-playlist")).size(12))
.padding([6, 10])
.width(Fill)
.center_x(Fill)
@ -2895,7 +2895,7 @@ fn start_page_content<'a>(
// web builds read the CI-generated snapshot. Both sources mark pinned
// discussions and sort them before the rest of the list.
let discussions_panel: Element<'a, Message> = {
let mut list = column![text(crate::tr!("start-discussions")).size(15)]
let mut list = column![text(crate::tr!("start", "discussions")).size(15)]
.spacing(8)
.width(Fill);
for discussion in discussions {
@ -2908,7 +2908,7 @@ fn start_page_content<'a>(
.align_y(iced::Center);
if discussion.pinned {
meta = meta.push(
text(crate::tr!("start-pinned"))
text(crate::tr!("start", "pinned"))
.size(10)
.style(start_primary_style),
);
@ -2951,14 +2951,14 @@ fn start_page_content<'a>(
}
if discussions.is_empty() {
let note = if discussions_loading {
crate::tr!("start-loading-discussions")
crate::tr!("start", "loading-discussions")
} else {
crate::tr!("start-discussions-online")
crate::tr!("start", "discussions-online")
};
list = list.push(text(note).size(12).style(start_muted_style));
}
let open_btn = mouse_area(
container(text(crate::tr!("start-open-discussions")).size(12))
container(text(crate::tr!("start", "open-discussions")).size(12))
.padding([6, 10])
.width(Fill)
.center_x(Fill)
@ -3015,7 +3015,7 @@ fn start_page_content<'a>(
// shows, so the rail always invites support.
let supporters: Element<'a, Message> = {
let mut list = column![
text(crate::tr!("start-supporters")).size(15),
text(crate::tr!("start", "supporters")).size(15),
Space::new().height(iced::Length::Fixed(12.0)),
]
.spacing(6)
@ -3040,7 +3040,7 @@ fn start_page_content<'a>(
container(
iced::widget::row![
crate::ui::icons::themed_danger_text(crate::ui::icons::HEART, 13.0),
text(crate::tr!("start-support-on-patreon")).size(12),
text(crate::tr!("start", "support-on-patreon")).size(12),
]
.spacing(6)
.align_y(iced::Center),
@ -3158,11 +3158,11 @@ fn start_page_content<'a>(
})
};
let tab_bar = Row::with_children(vec![
tab_btn(crate::tr!("start-recent-files"), super::StartSection::Recent).into(),
tab_btn(crate::tr!("start-videos"), super::StartSection::Videos).into(),
tab_btn(crate::tr!("start-welcome"), super::StartSection::Welcome).into(),
tab_btn(crate::tr!("start-discussions"), super::StartSection::Discussions).into(),
tab_btn(crate::tr!("start-supporters"), super::StartSection::Supporters).into(),
tab_btn(crate::tr!("start", "recent-files"), super::StartSection::Recent).into(),
tab_btn(crate::tr!("start", "videos"), super::StartSection::Videos).into(),
tab_btn(crate::tr!("start", "welcome"), super::StartSection::Welcome).into(),
tab_btn(crate::tr!("start", "discussions"), super::StartSection::Discussions).into(),
tab_btn(crate::tr!("start", "supporters"), super::StartSection::Supporters).into(),
])
.spacing(6.0)
.align_y(iced::Center)
@ -3238,11 +3238,11 @@ pub(super) fn recent_files_panel<'a>(
) -> Element<'a, Message> {
// Title mirrors the Supporters rail: size 15 in the bright text colour,
// followed by a 12px gap before the content.
let title = text(crate::tr!("start-recent-documents")).size(15);
let title = text(crate::tr!("start", "recent-documents")).size(15);
let body: Element<'a, Message> = if recents.is_empty() {
container(
text(crate::tr!("start-no-recent-files"))
text(crate::tr!("start", "no-recent-files"))
.size(12)
.style(start_muted_style)
)
@ -3270,7 +3270,7 @@ pub(super) fn recent_files_panel<'a>(
// directory line.
#[cfg(target_arch = "wasm32")]
let dir = if dir.is_empty() {
crate::tr!("start-browser-storage")
crate::tr!("start", "browser-storage")
} else {
dir
};
@ -3381,7 +3381,7 @@ pub(super) fn recent_files_panel<'a>(
.padding([2, 6])
.width(iced::Length::Fixed(46.0));
let limit_row = row![
text(crate::tr!("start-keep-recent-files")).size(11).style(start_muted_style).width(Fill),
text(crate::tr!("start", "keep-recent-files")).size(11).style(start_muted_style).width(Fill),
button(crate::ui::icons::themed(crate::ui::icons::MINUS, 11.0))
.on_press(Message::SetRecentLimit(shown.saturating_sub(STEP)))
.padding([3, 6])

View file

@ -10,43 +10,43 @@ impl OpenCADStudio {
pub(super) fn modal_title(&self) -> String {
use super::super::ModalKind as K;
match self.active_modal {
Some(K::About) => crate::tr!("modal-about"),
Some(K::Shortcuts) => crate::tr!("modal-keyboard-shortcuts"),
Some(K::Aliases) => crate::tr!("modal-command-aliases"),
Some(K::Options) => crate::tr!("action-options"),
Some(K::FindReplace) => crate::tr!("modal-find-replace"),
Some(K::PluginManager) => crate::tr!("modal-plugin-manager"),
Some(K::UpdateNotice) => crate::tr!("modal-update-available"),
Some(K::Layers) => crate::tr!("modal-layer-manager"),
Some(K::LayerStateManager) => crate::tr!("modal-layer-state-manager"),
Some(K::About) => crate::tr!("modal", "about"),
Some(K::Shortcuts) => crate::tr!("modal", "keyboard-shortcuts"),
Some(K::Aliases) => crate::tr!("modal", "command-aliases"),
Some(K::Options) => crate::tr!("action", "options"),
Some(K::FindReplace) => crate::tr!("modal", "find-replace"),
Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"),
Some(K::UpdateNotice) => crate::tr!("modal", "update-available"),
Some(K::Layers) => crate::tr!("modal", "layer-manager"),
Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"),
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(),
Some(K::LayerStateEditor) => crate::tr!("modal-edit-layer-state"),
Some(K::Plot) => crate::tr!("modal-plot"),
Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"),
Some(K::Plot) => crate::tr!("modal", "plot"),
Some(K::PrintAll) => t!("Print All").into_owned(),
Some(K::LayoutManager) => crate::tr!("modal-layout-manager"),
Some(K::ScaleManager) => crate::tr!("modal-scale-manager"),
Some(K::AnnoObjectScale) => crate::tr!("modal-annotation-object-scale"),
Some(K::Plotstyle) => crate::tr!("modal-plot-style-editor"),
Some(K::TextStyle) => crate::tr!("modal-text-style-manager"),
Some(K::MlStyle) => crate::tr!("modal-multiline-style-manager"),
Some(K::TableStyle) => crate::tr!("modal-table-style-manager"),
Some(K::MLeaderStyle) => crate::tr!("modal-multileader-style-manager"),
Some(K::DimStyle) => crate::tr!("modal-dimension-style-manager"),
Some(K::AssocPrompt) => crate::tr!("modal-default-application"),
Some(K::AecDropWarning) => crate::tr!("modal-save-warning"),
Some(K::LayoutManager) => crate::tr!("modal", "layout-manager"),
Some(K::ScaleManager) => crate::tr!("modal", "scale-manager"),
Some(K::AnnoObjectScale) => crate::tr!("modal", "annotation-object-scale"),
Some(K::Plotstyle) => crate::tr!("modal", "plot-style-editor"),
Some(K::TextStyle) => crate::tr!("modal", "text-style-manager"),
Some(K::MlStyle) => crate::tr!("modal", "multiline-style-manager"),
Some(K::TableStyle) => crate::tr!("modal", "table-style-manager"),
Some(K::MLeaderStyle) => crate::tr!("modal", "multileader-style-manager"),
Some(K::DimStyle) => crate::tr!("modal", "dimension-style-manager"),
Some(K::AssocPrompt) => crate::tr!("modal", "default-application"),
Some(K::AecDropWarning) => crate::tr!("modal", "save-warning"),
#[cfg(not(target_arch = "wasm32"))]
Some(K::FileInUse) => crate::tr!("modal-unable-save"),
Some(K::FileInUse) => crate::tr!("modal", "unable-save"),
#[cfg(not(target_arch = "wasm32"))]
Some(K::ExternalChange) => crate::tr!("modal-drawing-changed"),
Some(K::LayerDeleteWarning) => crate::tr!("modal-delete-layer"),
Some(K::Unsaved) => crate::tr!("modal-unsaved-changes"),
Some(K::PointStyle) => crate::tr!("modal-point-style"),
Some(K::AttributeEditor) => crate::tr!("modal-attribute-editor"),
Some(K::SaveDialog) => crate::tr!("modal-save-drawing-as"),
Some(K::Recovery) => crate::tr!("modal-recovery-report"),
Some(K::RecoveryPrompt) => crate::tr!("modal-recovery-prompt"),
Some(K::ExternalChange) => crate::tr!("modal", "drawing-changed"),
Some(K::LayerDeleteWarning) => crate::tr!("modal", "delete-layer"),
Some(K::Unsaved) => crate::tr!("modal", "unsaved-changes"),
Some(K::PointStyle) => crate::tr!("modal", "point-style"),
Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"),
Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"),
Some(K::Recovery) => crate::tr!("modal", "recovery-report"),
Some(K::RecoveryPrompt) => crate::tr!("modal", "recovery-prompt"),
None => String::new(),
}
}
@ -1528,7 +1528,7 @@ fn unsaved_changes_dialog_window(
column![
text(name.to_owned()).size(14),
iced::widget::Space::new().height(4),
text(crate::tr!("modal-unsaved-save-prompt"))
text(crate::tr!("modal", "unsaved-save-prompt"))
.size(12)
.style(dialog_muted_text_style),
iced::widget::Space::new().height(20),

View file

@ -710,6 +710,21 @@ mod tests {
assert!(curve.is_closed());
}
/// The whole stack resolves from here: OCS reaches the B-rep layer and
/// the ACIS bridge through the same alias every other CAD type comes in
/// by. A compile-time check, so a chain that stops resolving is caught
/// where it happens rather than the next time somebody reaches for it.
#[test]
fn the_solid_layer_and_the_acis_bridge_are_reachable() {
let solid = acadrust::kernel::brep::make::cuboid([0.0; 3], [1.0; 3])
.expect("the kernel builds its own primitives");
assert!(solid.validate().is_empty());
assert_eq!(solid.euler_characteristic(), 2);
let document = acadrust::entities::acis::types::SatDocument::new();
let (bodies, loss) = acadrust::acis::lift(&document);
assert!(bodies.is_empty() && loss.is_empty(), "an empty document lifts to nothing");
}
#[test]
fn entities_that_are_not_curves_say_so() {
assert!(entity_curve(&EntityType::Point(Default::default())).is_none());

View file

@ -88,19 +88,19 @@ impl Language {
pub fn label(self) -> String {
match self {
Language::System => crate::tr!("language-system"),
Language::EnUs => crate::tr!("language-english"),
Language::TrTr => crate::tr!("language-turkish"),
Language::NlNl => crate::tr!("language-dutch"),
Language::FrFr => crate::tr!("language-french"),
Language::DeDe => crate::tr!("language-german"),
Language::HiIn => crate::tr!("language-hindi"),
Language::RuRu => crate::tr!("language-russian"),
Language::ZhCn => crate::tr!("language-chinese-simplified"),
Language::EsEs => crate::tr!("language-spanish"),
Language::PtBr => crate::tr!("language-portuguese"),
Language::ArSa => crate::tr!("language-arabic"),
Language::JaJp => crate::tr!("language-japanese"),
Language::System => crate::tr!("language", "system"),
Language::EnUs => crate::tr!("language", "english"),
Language::TrTr => crate::tr!("language", "turkish"),
Language::NlNl => crate::tr!("language", "dutch"),
Language::FrFr => crate::tr!("language", "french"),
Language::DeDe => crate::tr!("language", "german"),
Language::HiIn => crate::tr!("language", "hindi"),
Language::RuRu => crate::tr!("language", "russian"),
Language::ZhCn => crate::tr!("language", "chinese-simplified"),
Language::EsEs => crate::tr!("language", "spanish"),
Language::PtBr => crate::tr!("language", "portuguese"),
Language::ArSa => crate::tr!("language", "arabic"),
Language::JaJp => crate::tr!("language", "japanese"),
}
}
}
@ -200,8 +200,8 @@ pub fn active_language_tag() -> String {
/// translatable data.
pub fn translate(source: impl AsRef<str>) -> Cow<'static, str> {
let source = source.as_ref();
locale_catalog::message_id(source)
.map(|message_id| Cow::Owned(loader().get(message_id)))
locale_catalog::message_attribute(source)
.map(|(message_id, attribute_id)| Cow::Owned(loader().get_attr(message_id, attribute_id)))
.unwrap_or_else(|| Cow::Owned(source.to_string()))
}
@ -213,8 +213,8 @@ pub fn translate_args(
args: &[(&str, String)],
) -> Cow<'static, str> {
let source = source.as_ref();
let mut translated = locale_catalog::message_id(source)
.map(|message_id| loader().get(message_id))
let mut translated = locale_catalog::message_attribute(source)
.map(|(message_id, attribute_id)| loader().get_attr(message_id, attribute_id))
.unwrap_or_else(|| source.to_string());
for (name, value) in args {
translated = translated.replace(&format!("__ocs_arg_{name}__"), value);
@ -229,13 +229,13 @@ pub fn translate_args(
/// sentence. File names, handles, counts, and command values therefore remain
/// data instead of being sent through translation.
pub fn translate_format(template: &str, rendered: String) -> Cow<'static, str> {
let Some(message_id) = locale_catalog::message_id(template) else {
let Some((message_id, attribute_id)) = locale_catalog::message_attribute(template) else {
return Cow::Owned(rendered);
};
let Some(values) = format_values(template, &rendered) else {
return Cow::Owned(rendered);
};
let mut translated = loader().get(message_id);
let mut translated = loader().get_attr(message_id, attribute_id);
for (index, value) in values.iter().enumerate() {
translated = translated.replace(&format!("__ocs_fmt_{index}__"), value);
}
@ -297,19 +297,30 @@ fn format_values(template: &str, rendered: &str) -> Option<Vec<String>> {
/// until they provide their own localization bundle.
pub fn ribbon_module_title(id: &str, fallback: &str) -> String {
match id {
"draw" => crate::tr!("ribbon-tab-draw"),
"annotate" => crate::tr!("ribbon-tab-annotate"),
"insert" => crate::tr!("ribbon-tab-insert"),
"model" => crate::tr!("ribbon-tab-model"),
"layout" => crate::tr!("ribbon-tab-layout"),
"manage" => crate::tr!("ribbon-tab-manage"),
"view" => crate::tr!("ribbon-tab-view"),
"draw" => crate::tr!("ribbon-tab", "draw"),
"annotate" => crate::tr!("ribbon-tab", "annotate"),
"insert" => crate::tr!("ribbon-tab", "insert"),
"model" => crate::tr!("ribbon-tab", "model"),
"layout" => crate::tr!("ribbon-tab", "layout"),
"manage" => crate::tr!("ribbon-tab", "manage"),
"view" => crate::tr!("ribbon-tab", "view"),
_ => fallback.to_string(),
}
}
#[macro_export]
macro_rules! tr {
($message_id:literal, $attribute_id:literal $(,)?) => {
i18n_embed_fl::fl!($crate::i18n::loader(), $message_id, $attribute_id)
};
($message_id:literal, $attribute_id:literal, $($name:ident = $value:expr),+ $(,)?) => {
i18n_embed_fl::fl!(
$crate::i18n::loader(),
$message_id,
$attribute_id,
$($name = $value),+
)
};
($message_id:literal $(,)?) => {
i18n_embed_fl::fl!($crate::i18n::loader(), $message_id)
};

File diff suppressed because it is too large Load diff

View file

@ -165,25 +165,25 @@ impl AreaCommand {
fn result_message(measurement: AreaMeasurement) -> String {
match measurement.perimeter {
Some(perimeter) => crate::tr!(
"area-result",
"area", "result",
area = format!("{:.4}", measurement.area),
perimeter = format!("{perimeter:.4}"),
),
None => crate::tr!("area-result-area-only", area = format!("{:.4}", measurement.area)),
None => crate::tr!("area", "result-area-only", area = format!("{:.4}", measurement.area)),
}
}
fn running_result_message(&self, measurement: AreaMeasurement) -> String {
match measurement.perimeter {
Some(perimeter) => crate::tr!(
"area-running-result",
"area", "running-result",
area = format!("{:.4}", measurement.area),
perimeter = format!("{perimeter:.4}"),
total_area = format!("{:.4}", self.total_area),
total_perimeter = format!("{:.4}", self.total_perimeter),
),
None => crate::tr!(
"area-running-result-area-only",
"area", "running-result-area-only",
area = format!("{:.4}", measurement.area),
total_area = format!("{:.4}", self.total_area),
),
@ -224,30 +224,30 @@ impl CadCommand for AreaCommand {
fn prompt(&self) -> String {
if self.objects_gathering {
return crate::tr!(
"area-prompt-objects",
"area", "prompt-objects",
count = self.selected_entities.len()
);
}
if !self.points.is_empty() {
return crate::tr!("area-prompt-next", count = self.points.len());
return crate::tr!("area", "prompt-next", count = self.points.len());
}
match self.mode {
AreaMode::Single => crate::tr!("area-prompt-first"),
AreaMode::Add => crate::tr!("area-prompt-add"),
AreaMode::Subtract => crate::tr!("area-prompt-subtract"),
AreaMode::Single => crate::tr!("area", "prompt-first"),
AreaMode::Add => crate::tr!("area", "prompt-add"),
AreaMode::Subtract => crate::tr!("area", "prompt-subtract"),
}
}
fn options(&self) -> Vec<CmdOption> {
if self.objects_gathering {
return vec![Self::option(crate::tr!("area-option-back"), "BACK")];
return vec![Self::option(crate::tr!("area", "option-back"), "BACK")];
}
if !self.points.is_empty() {
return Vec::new();
}
let objects = || Self::option(crate::tr!("area-option-objects"), "OBJECTS");
let add = || Self::option(crate::tr!("area-option-add"), "ADD");
let subtract = || Self::option(crate::tr!("area-option-subtract"), "SUBTRACT");
let objects = || Self::option(crate::tr!("area", "option-objects"), "OBJECTS");
let add = || Self::option(crate::tr!("area", "option-add"), "ADD");
let subtract = || Self::option(crate::tr!("area", "option-subtract"), "SUBTRACT");
match self.mode {
AreaMode::Single => vec![objects(), add(), subtract()],
AreaMode::Add => vec![objects(), subtract()],
@ -323,7 +323,7 @@ impl CadCommand for AreaCommand {
let Some((measurement, handles)) = self.selection_measurement() else {
self.selected_entities.clear();
return CmdResult::ReportMeasurementAndDeselect(crate::tr!(
"area-objects-not-measurable"
"area", "objects-not-measurable"
));
};
if self.mode != AreaMode::Single {

View file

@ -61,24 +61,24 @@ impl CadCommand for CopyCommand {
fn prompt(&self) -> String {
if self.awaiting_count {
return crate::tr!("command-copy-array-count");
return crate::tr!("command-copy", "array-count");
}
match &self.step {
Step::Base => crate::tr!(
"command-copy-base",
"command-copy", "base",
count = (self.handles.len() as i64),
),
Step::Placing(base) => {
if let Some(n) = self.array_count {
crate::tr!(
"command-copy-array-target",
"command-copy", "array-target",
count = (n as i64),
x = format!("{:.3}", base.x),
y = format!("{:.3}", base.y),
)
} else {
crate::tr!(
"command-copy-target",
"command-copy", "target",
count = (self.count as i64),
x = format!("{:.3}", base.x),
y = format!("{:.3}", base.y),

View file

@ -15,7 +15,7 @@ use acadrust::kernel::geom2d::{self, Ellipse};
/// Re-exported unchanged: these already speak in plain `f64`, so there is no
/// call-shape difference for this module to absorb.
pub use acadrust::kernel::geom2d::{angle_within_arc, arc_parameter, lerp, normalize_angle};
pub use acadrust::kernel::geom2d::{arc_parameter, lerp, normalize_angle};
/// Preview geometry keeps the density the commands have always used.
const SEGMENTS_PER_RADIAN: f64 = geom2d::DEFAULT_SEGMENTS_PER_RADIAN;
@ -51,35 +51,6 @@ pub fn line_circle(px: f64, py: f64, dx: f64, dy: f64, cx: f64, cy: f64, r: f64)
geom2d::line_circle([px, py], [dx, dy], [cx, cy], r)
}
/// `(s_on_line, t_on_ellipse)` pairs where a line meets an ellipse.
///
/// `nx, ny` is the unit major axis.
#[allow(clippy::too_many_arguments)]
pub fn line_ellipse(
px: f64,
py: f64,
dx: f64,
dy: f64,
cx: f64,
cy: f64,
a: f64,
b: f64,
nx: f64,
ny: f64,
) -> Vec<(f64, f64)> {
geom2d::line_ellipse(
[px, py],
[dx, dy],
&Ellipse {
centre: [cx, cy],
major_radius: a,
minor_radius: b,
major_axis: [nx, ny],
},
)
}
/// The two endpoints of a straight segment, as render vertices.
pub fn line_points(start: [f64; 3], end: [f64; 3]) -> Vec<[f32; 3]> {
narrow(vec![start, end])
}

View file

@ -54,11 +54,11 @@ impl CadCommand for MoveCommand {
fn prompt(&self) -> String {
match &self.step {
Step::Base => crate::tr!(
"command-move-base",
"command-move", "base",
count = (self.handles.len() as i64),
),
Step::Target(base) => crate::tr!(
"command-move-target",
"command-move", "target",
x = format!("{:.3}", base.x),
y = format!("{:.3}", base.y),
),

View file

@ -14,9 +14,8 @@ use std::f64::consts::TAU;
// its call shapes to the loose scalars and f32 render vertices used here.
use super::geom;
use super::geom::{
angle_within_arc as in_arc, arc_parameter as arc_t, arc_points as arc_pts,
ellipse_points as ellipse_pts, lerp as lerp2,
line_circle as lc, line_ellipse as le, line_line as ll, normalize_angle as norm,
arc_parameter as arc_t, arc_points as arc_pts, ellipse_points as ellipse_pts,
lerp as lerp2, normalize_angle as norm,
};
use crate::modules::draw::fence::{crossing_box_preview, FencePick};
@ -131,14 +130,9 @@ enum Geo {
t1: f64, // end parameter (may be > 2π if wrapped)
},
/// A NURBS boundary, carried exactly.
///
/// `segs` is a sampling of the same curve, kept only for the two
/// hand-rolled ray walks in the polyline-extend paths. Every crossing
/// that decides a trim uses `curve`.
Spline {
handle: Handle,
curve: Box<NurbsCurve>,
segs: Vec<([f64; 2], [f64; 2])>,
},
}
@ -250,17 +244,10 @@ fn geo_from_entity(h: Handle, e: &EntityType) -> Option<Geo> {
t1,
})
}
Curve::Nurbs(curve) => {
// Carried exactly. The sampled polyline beside it is only for the
// two hand-rolled ray walks in the polyline-extend paths; every
// crossing that decides a trim goes through the curve itself.
let sampled = curve.tessellate(16);
(sampled.len() >= 2).then(|| Geo::Spline {
handle: h,
curve: Box::new(curve),
segs: sampled.windows(2).map(|w| (w[0], w[1])).collect(),
})
}
Curve::Nurbs(curve) => Some(Geo::Spline {
handle: h,
curve: Box::new(curve),
}),
// A polyline reaches here already taken apart into its segments.
Curve::Polyline(_) => None,
}
@ -987,6 +974,47 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
/// Extend the first or last segment of an LwPolyline to the nearest boundary.
/// Click point (DXF XY) determines which end to extend.
/// The nearest boundary crossing past one end of a segment.
///
/// `t` runs on the infinite line through `from` → `to`: zero at `from`, one
/// at `to`. Extending the far end wants the smallest `t` above one; extending
/// the near end wants the largest `t` below zero. `None` when nothing lies
/// that way.
///
/// The crossings come from `cut_params`, which is the same kernel call every
/// other cut in this file uses. What was here instead was a match over every
/// `Geo` variant with its own line-line, line-circle and line-ellipse test —
/// twice, once for a line and once for a polyline's terminal segment — and a
/// boundary spline was walked as sixty-four chords rather than intersected.
fn extension_hit(
from: [f64; 2],
to: [f64; 2],
handle: Handle,
geos: &[Geo],
beyond_end: bool,
) -> Option<f64> {
let direction = [to[0] - from[0], to[1] - from[1]];
if direction[0].hypot(direction[1]) < 1e-9 {
return None;
}
// An infinite line rather than the segment: the crossing being looked for
// is by definition off the end of the drawn part.
let ray = Curve::XLine(KernelXLine {
base: from,
direction,
});
let hits = cut_params(&ray, handle, geos);
if beyond_end {
hits.into_iter()
.filter(|t| *t > 1.0 + 1e-6)
.min_by(f64::total_cmp)
} else {
hits.into_iter()
.filter(|t| *t < -1e-6)
.max_by(f64::total_cmp)
}
}
fn extend_lwpoly(
poly: &LwPolyline,
click_x: f64,
@ -1030,124 +1058,7 @@ fn extend_lwpoly(
return None;
}
// t_click on the segment: 0 = ax/ay, 1 = bx/by. We're extending beyond t=1.
let target = poly.common.handle;
let mut best_t = f64::INFINITY;
for geo in geos {
match geo {
Geo::Line { handle, p1, p2 } => {
if *handle == target {
continue;
}
let (ex, ey) = (p2[0] - p1[0], p2[1] - p1[1]);
if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) {
if (-1e-9..=1.0 + 1e-9).contains(&u) && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
}
}
Geo::Arc {
handle,
cx,
cy,
r,
a0,
a1,
} => {
if *handle == target {
continue;
}
for t in lc(ax, ay, dx, dy, *cx, *cy, *r) {
let ix = ax + t * dx;
let iy = ay + t * dy;
if in_arc((iy - cy).atan2(ix - cx), *a0, *a1) && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
}
}
Geo::Circle { handle, cx, cy, r } => {
if *handle == target {
continue;
}
for t in lc(ax, ay, dx, dy, *cx, *cy, *r) {
if t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
}
}
Geo::Ray {
handle,
bx: rbx,
by: rby,
dx: rdx,
dy: rdy,
} => {
if *handle == target {
continue;
}
if let Some((t, u)) = ll(ax, ay, dx, dy, *rbx, *rby, *rdx, *rdy) {
if u >= -1e-9 && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
}
}
Geo::InfLine {
handle,
bx: ibx,
by: iby,
dx: idx,
dy: idy,
} => {
if *handle == target {
continue;
}
if let Some((t, _)) = ll(ax, ay, dx, dy, *ibx, *iby, *idx, *idy) {
if t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
}
}
Geo::Ellipse {
handle,
cx,
cy,
a,
b,
nx,
ny,
t0,
t1,
} => {
if *handle == target {
continue;
}
for (t, t_ell) in le(ax, ay, dx, dy, *cx, *cy, *a, *b, *nx, *ny) {
if in_arc(t_ell, *t0, *t1) && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
}
}
Geo::Spline { handle, segs, .. } => {
if *handle == target {
continue;
}
for (p1, p2) in segs {
let ex = p2[0] - p1[0];
let ey = p2[1] - p1[1];
if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) {
if (-1e-9..=1.0 + 1e-9).contains(&u) && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
}
}
}
}
}
if !best_t.is_finite() {
return None;
}
let best_t = extension_hit([ax, ay], [bx, by], poly.common.handle, geos, true)?;
let new_x = ax + best_t * dx;
let new_y = ay + best_t * dy;
@ -1173,165 +1084,9 @@ fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option<EntityType>
let bx = orig.end.x;
let by = orig.end.y;
let (dx, dy) = (bx - ax, by - ay);
let target = orig.common.handle;
let extend_end = t_click >= 0.5;
let best_t = extension_hit([ax, ay], [bx, by], orig.common.handle, geos, extend_end)?;
let mut best_t = if extend_end {
f64::INFINITY
} else {
f64::NEG_INFINITY
};
for geo in geos {
match geo {
Geo::Line { handle, p1, p2 } => {
if *handle == target {
continue;
}
let (ex, ey) = (p2[0] - p1[0], p2[1] - p1[1]);
if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) {
if !(-1e-9..=1.0 + 1e-9).contains(&u) {
continue;
}
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
Geo::Arc {
handle,
cx,
cy,
r,
a0,
a1,
} => {
if *handle == target {
continue;
}
for t in lc(ax, ay, dx, dy, *cx, *cy, *r) {
let ix = ax + t * dx;
let iy = ay + t * dy;
if !in_arc((iy - cy).atan2(ix - cx), *a0, *a1) {
continue;
}
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
Geo::Circle { handle, cx, cy, r } => {
if *handle == target {
continue;
}
for t in lc(ax, ay, dx, dy, *cx, *cy, *r) {
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
Geo::Ray {
handle,
bx: rbx,
by: rby,
dx: rdx,
dy: rdy,
} => {
if *handle == target {
continue;
}
if let Some((t, u)) = ll(ax, ay, dx, dy, *rbx, *rby, *rdx, *rdy) {
if u >= -1e-9 {
// only forward along the Ray
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
}
Geo::InfLine {
handle,
bx: ibx,
by: iby,
dx: idx,
dy: idy,
} => {
if *handle == target {
continue;
}
if let Some((t, _u)) = ll(ax, ay, dx, dy, *ibx, *iby, *idx, *idy) {
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
Geo::Ellipse {
handle,
cx: ecx,
cy: ecy,
a,
b,
nx,
ny,
t0: et0,
t1: et1,
} => {
if *handle == target {
continue;
}
for (t, t_ell) in le(ax, ay, dx, dy, *ecx, *ecy, *a, *b, *nx, *ny) {
if !in_arc(t_ell, *et0, *et1) {
continue;
}
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
Geo::Spline { handle, segs, .. } => {
if *handle == target {
continue;
}
for (p1, p2) in segs {
let ex = p2[0] - p1[0];
let ey = p2[1] - p1[1];
if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) {
if !(-1e-9..=1.0 + 1e-9).contains(&u) {
continue;
}
if extend_end && t > 1.0 + 1e-6 && t < best_t {
best_t = t;
}
if !extend_end && t < -1e-6 && t > best_t {
best_t = t;
}
}
}
}
}
}
if !best_t.is_finite() {
return None;
}
let mut line = orig.clone();
line.common.handle = Handle::NULL;
let new_x = ax + best_t * dx;

View file

@ -1,5 +1,5 @@
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/recap.svg"));
pub fn tool() -> ToolDef {
ToolDef { id: "RECAP", label: "Autodesk\nReCap", icon: ICON, event: ModuleEvent::Command("RECAP".to_string()) }
ToolDef { id: "RECAP", label: "Point\nClouds", icon: ICON, event: ModuleEvent::Command("RECAP".to_string()) }
}

View file

@ -1,12 +1,24 @@
// HatchModel — CPU-side hatch fill data; rendered entirely on the GPU.
use std::sync::Arc;
//
// The boundary is a closed polygon in world XY coordinates.
// The GPU fragment shader performs point-in-polygon and hatch-line tests so
// no line geometry is ever tessellated on the CPU.
use std::sync::Arc;
use acadrust::kernel::geom2d::{
inside_spans, Curve as KernelCurve, Line as KernelLine, Tolerance, XLine as KernelXLine,
};
pub const MAX_HATCH_BOUNDARY_VERTS: usize = 1024;
/// How near two crossings along a pattern line must be to count as one.
///
/// The boundary reaching here is already a chord approximation of the drawn
/// curves, so this only has to be finer than that sampling and coarser than
/// the rounding in it.
const CLIP_TOLERANCE_LINEAR: f64 = 1.0e-6;
/// One line family from a PAT-format hatch pattern.
///
/// Format mirrors the standard PAT line format:
@ -269,6 +281,13 @@ impl HatchModel {
if edges.is_empty() {
return Vec::new();
}
// The boundary as kernel curves, built once for every pattern line to
// be clipped against.
let clip_tolerance = Tolerance::new(CLIP_TOLERANCE_LINEAR);
let boundary: Vec<KernelCurve> = edges
.iter()
.map(|(a, b)| KernelCurve::Line(KernelLine { start: *a, end: *b }))
.collect();
// ── AABB of the boundary in world coords.
let mut min_x = f64::INFINITY;
@ -363,39 +382,28 @@ impl HatchModel {
let lx = origin[0] + kf * step_x;
let ly = origin[1] + kf * step_y;
// Intersect line P(t) = L + t·(cos_a, sin_a) against each
// boundary edge; collect t-values where the edge actually
// crosses (s ∈ [0,1]).
let mut ts: Vec<f64> = Vec::with_capacity(8);
for &(a, b) in &edges {
let ex = b[0] - a[0];
let ey = b[1] - a[1];
let det = ex * sin_a - ey * cos_a; // = sin_a·ex − cos_a·ey
if det.abs() < 1e-9 {
continue;
}
let rx = a[0] - lx;
let ry = a[1] - ly;
let t = (ex * ry - ey * rx) / det;
let s = (cos_a * ry - sin_a * rx) / det;
if s >= 0.0 && s <= 1.0 {
ts.push(t);
}
}
if ts.len() < 2 {
continue;
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
// De-duplicate near-coincident hits (line clipping a vertex).
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-5);
if ts.len() < 2 {
continue;
}
// The stretches of this pattern line that fall inside the
// boundary. The direction is a unit vector, so the kernel's
// parameter is distance along the line — the same `t` the
// dash walk below steps in.
//
// Each span is tested for containment rather than taken from
// alternating crossings. A line that grazes a corner crosses
// twice in one place, and an alternating walk inverts from
// there on: the fill lands in the holes and the solid comes
// out empty.
let spans = inside_spans(
&boundary,
&KernelCurve::XLine(KernelXLine {
base: [lx, ly],
direction: [cos_a, sin_a],
}),
clip_tolerance,
);
// Even-odd: emit segments between consecutive pairs.
for pair in ts.chunks_exact(2) {
let t0 = pair[0];
let t1 = pair[1];
for span in spans {
let t0 = span[0];
let t1 = span[1];
if t1 - t0 < 1e-6 {
continue;
}

View file

@ -883,7 +883,7 @@ impl Snapper {
// rotated or isometric grid the user sees.
grid_origin: Vec3,
grid_axes: (Vec3, Vec3, Vec3),
construction_cursor: Option<glam::DVec3>,
construction_ray: Option<(glam::DVec3, glam::DVec3)>,
) -> Option<SnapResult> {
// Object-snap selection is priority-then-distance, NOT nearest-wins.
// "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry
@ -1232,42 +1232,45 @@ impl Snapper {
}
}
// ── Intersection with active construction segment (#704) ──────────
//
// The existing Intersection pass below only compares document
// entities with each other. Also compare the rubber-band currently
// being drawn against existing geometry.
if self.is_on(SnapType::Intersection) {
if let (Some(from), Some(to)) = (self.from_point, construction_cursor) {
let from = from.as_dvec3();
if (to - from).length_squared() > 1e-18 {
// Active/deferred Intersection uses the actual nearby wires directly.
// The interaction candidate set may contain wires while its optional
// segment index is empty.
for wire in wires.iter() {
if !wire_in_range(wire) {
continue;
let mut try_ray_intersections = |origin: glam::DVec3, through: glam::DVec3| {
if (through - origin).length_squared() <= 1e-18 {
return;
}
if let Some(segments) = &local_segments {
for segment in segments {
if let Some(point) =
ray_segment_intersect_3d(origin, through, segment.a, segment.b)
{
if (point - origin).length_squared() > 1e-18 {
try_pt(point, SnapType::Intersection);
}
for index in 0..wire.points.len().saturating_sub(1) {
let b0 = wp_f64(wire, index);
let b1 = wp_f64(wire, index + 1);
if let Some(pt) = ray_segment_intersect_3d(from, to, b0, b1) {
if (pt - from).length_squared() > 1e-18 {
try_pt(pt, SnapType::Intersection);
}
}
}
} else {
for wire in wires.iter().filter(|wire| wire_in_range(wire)) {
for index in 0..wire.points.len().saturating_sub(1) {
if let Some(point) = ray_segment_intersect_3d(
origin,
through,
wp_f64(wire, index),
wp_f64(wire, index + 1),
) {
if (point - origin).length_squared() > 1e-18 {
try_pt(point, SnapType::Intersection);
}
}
}
}
}
};
if self.is_on(SnapType::Intersection) {
if let Some((origin, through)) = construction_ray {
try_ray_intersections(origin, through);
}
}
// ── Intersection with active perpendicular OTRACK ray (#704) ──────
//
// A perpendicular tracking ray is not part of the command's ordinary
// ORTHO/POLAR construction cursor. When the cursor is actually tracking
// one of those rays, allow Intersection to stop it on nearby geometry.
// Check engaged perpendicular tracking rays against nearby geometry.
if self.is_on(SnapType::Intersection)
&& self.otrack_enabled
&& self.is_on(SnapType::Perpendicular)
@ -1278,50 +1281,23 @@ impl Snapper {
};
for &dir in perp_dirs {
// Project the cursor onto this perpendicular tracking line.
let rel = cursor_world - origin;
let t = rel.x * dir.x + rel.y * dir.y;
let aligned = glam::DVec3::new(
origin.x + dir.x * t,
origin.y + dir.y * t,
origin.z,
);
// Only consider the ray when the cursor is genuinely tracking it.
// This prevents Intersection from behaving like Nearest.
let aligned_screen = world_to_screen(aligned, view_rot, eye, bounds);
if dist2(aligned_screen, cursor_screen) > radius2 {
continue;
}
// Perpendicular tracking is an infinite line, but once the cursor
// chooses a side we only want intersections forward on that side.
let dir_to_cursor = if t >= 0.0 { dir } else { -dir };
let through = origin + dir_to_cursor;
for wire in wires.iter() {
if !wire_in_range(wire) {
continue;
}
for index in 0..wire.points.len().saturating_sub(1) {
let b0 = wp_f64(wire, index);
let b1 = wp_f64(wire, index + 1);
if let Some(pt) =
ray_segment_intersect_3d(origin, through, b0, b1)
{
// Do not re-offer the tracking origin itself.
if (pt - origin).length_squared() > 1e-18 {
try_pt(pt, SnapType::Intersection);
}
}
}
}
try_ray_intersections(origin, origin + dir_to_cursor);
}
}
}
drop(try_ray_intersections);
// ── Intersection — segment-segment intersections (pairwise, gated) ──
if self.is_on(SnapType::Intersection)
@ -2118,13 +2094,7 @@ fn perp_foot(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> Option<gla
))
}
/// Intersection between a forward construction ray and an existing segment.
///
/// The ray starts at `ray_origin` and points toward `ray_through`, but extends
/// indefinitely forward. The existing geometry remains a finite segment.
///
/// Like `seg_intersect_3d`, this is a true 3D intersection: the XY projections
/// must cross and both geometries must have the same Z at the crossing.
/// True 3D intersection of a forward ray and a finite segment.
fn ray_segment_intersect_3d(
ray_origin: glam::DVec3,
ray_through: glam::DVec3,
@ -2147,8 +2117,6 @@ fn ray_segment_intersect_3d(
let t = (ex * d2y - ey * d2x) / cross;
let s = (ex * d1y - ey * d1x) / cross;
// `t >= 0`: only forward along the construction ray.
// `s` must remain inside the existing finite segment.
if t < 0.0 || s < 0.0 || s > 1.0 {
return None;
}

View file

@ -183,8 +183,8 @@ impl CommandLine {
pub fn new() -> Self {
let mut cl = Self::default();
cl.history_height = HISTORY_HEIGHT_DEFAULT;
cl.push_info(&crate::tr!("command-line-ready"));
cl.push_info(&crate::tr!("command-line-hint"));
cl.push_info(&crate::tr!("command-line", "ready"));
cl.push_info(&crate::tr!("command-line", "hint"));
cl
}
@ -522,7 +522,7 @@ impl CommandLine {
}
});
let prompt = container(
text(crate::tr!("command-line-label")).size(11).style(|theme: &Theme| iced::widget::text::Style {
text(crate::tr!("command-line", "label")).size(11).style(|theme: &Theme| iced::widget::text::Style {
color: Some(theme.palette().success.base.color),
}),
)
@ -559,7 +559,7 @@ impl CommandLine {
..Default::default()
}
});
let literal_tip = container(text(crate::tr!("command-line-literal-spaces")).size(11))
let literal_tip = container(text(crate::tr!("command-line", "literal-spaces")).size(11))
.padding([3, 6])
.style(container::bordered_box);
let literal_btn = tooltip(literal_btn, literal_tip, tooltip::Position::Top).gap(4);

View file

@ -2193,7 +2193,7 @@ struct DynInputCanvas {
boxes: Vec<DynBox>,
/// The active command's current prompt, drawn just above the boxes.
prompt: String,
/// Informational tracking/reference mode currently engaged.
/// Active tracking-reference label.
tracking_hint: Option<String>,
}
@ -2543,9 +2543,7 @@ impl DynInputCanvas {
};
Self::draw_box(frame, b, center, bounds, theme);
}
// Guided dynamic-input layouts place their editable boxes on the
// construction geometry, so keep the tracking-reference pill near
// the crosshair instead.
// Keep the tracking hint near the crosshair in guided layouts.
if let Some(text) = self.tracking_hint.as_deref() {
let hw = (text.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0;
@ -2636,12 +2634,11 @@ impl DynInputCanvas {
});
x += w + DYN_GAP;
}
// Informational tracking reference, using the same pill style as
// the command prompt. Keep it below the editable value row.
// Place the tracking hint below the value row.
if self.tracking_hint.is_some() {
let mut hy = by + DYN_BOX_H + 3.0;
// If there is no room below, place it above the prompt/value block.
// Move above when bottom space is insufficient.
if hy + DYN_BOX_H > bounds.height {
hy = (py - DYN_BOX_H - 3.0).max(0.0);
}

View file

@ -106,7 +106,7 @@ pub fn view_window<'a>(
);
}
let close = button(text(crate::tr!("action-close")).size(12))
let close = button(text(crate::tr!("action", "close")).size(12))
.on_press(Message::CloseModal)
.padding([6, 18])
.style(button::secondary);

View file

@ -91,12 +91,12 @@ pub fn view_window<'a>(
let palette = ui_theme.palette.to_iced();
let colors = [
(crate::tr!("options-color-background"), palette.background),
(crate::tr!("options-color-text"), palette.text),
(crate::tr!("options-color-primary"), palette.primary),
(crate::tr!("options-color-success"), palette.success),
(crate::tr!("options-color-warning"), palette.warning),
(crate::tr!("options-color-danger"), palette.danger),
(crate::tr!("options", "color-background"), palette.background),
(crate::tr!("options", "color-text"), palette.text),
(crate::tr!("options", "color-primary"), palette.primary),
(crate::tr!("options", "color-success"), palette.success),
(crate::tr!("options", "color-warning"), palette.warning),
(crate::tr!("options", "color-danger"), palette.danger),
];
let mut color_controls = column![].spacing(8);
@ -126,16 +126,16 @@ pub fn view_window<'a>(
);
}
let close = button(text(crate::tr!("action-close")).size(12))
let close = button(text(crate::tr!("action", "close")).size(12))
.on_press(Message::CloseModal)
.padding([6, 18])
.style(button::secondary);
let general = column![
text(crate::tr!("options-language-section")).size(15),
text(crate::tr!("options", "language-section")).size(15),
Space::new().height(10),
row![
text(crate::tr!("options-language-label")).size(12).width(150),
text(crate::tr!("options", "language-label")).size(12).width(150),
iced::widget::pick_list(
selected_language,
language_options,
@ -147,10 +147,10 @@ pub fn view_window<'a>(
.spacing(12)
.align_y(iced::Center),
Space::new().height(22),
text(crate::tr!("options-open-save-section")).size(15),
text(crate::tr!("options", "open-save-section")).size(15),
Space::new().height(10),
row![
text(crate::tr!("options-default-save-format-label")).size(12).width(150),
text(crate::tr!("options", "default-save-format-label")).size(12).width(150),
iced::widget::pick_list(
selected_format,
crate::io::SAVE_FORMAT_OPTIONS,
@ -162,7 +162,7 @@ pub fn view_window<'a>(
.spacing(12)
.align_y(iced::Center),
Space::new().height(8),
text(crate::tr!("options-default-save-format-help"))
text(crate::tr!("options", "default-save-format-help"))
.size(11)
.width(sizing.width),
Space::new().height(14),
@ -204,10 +204,10 @@ pub fn view_window<'a>(
});
let display = column![
text(crate::tr!("options-theme-section")).size(15),
text(crate::tr!("options", "theme-section")).size(15),
Space::new().height(10),
row![
text(crate::tr!("options-theme-label")).size(12).width(150),
text(crate::tr!("options", "theme-label")).size(12).width(150),
iced::widget::pick_list(
selected_theme,
theme_options,
@ -219,7 +219,7 @@ pub fn view_window<'a>(
.spacing(12)
.align_y(iced::Center),
Space::new().height(8),
text(crate::tr!("options-theme-help"))
text(crate::tr!("options", "theme-help"))
.size(11)
.width(sizing.width),
Space::new().height(12),

View file

@ -49,32 +49,32 @@ pub fn view_window<'a>(
) -> Element<'a, Message> {
let recovered = report.status == RecoveryStatus::Recovered;
let heading = if recovered {
crate::tr!("recovery-opened-with-repairs")
crate::tr!("recovery", "opened-with-repairs")
} else {
crate::tr!("recovery-open-failed")
crate::tr!("recovery", "open-failed")
};
let description = if recovered {
crate::tr!("recovery-repaired-description")
crate::tr!("recovery", "repaired-description")
} else {
crate::tr!("recovery-failed-description")
crate::tr!("recovery", "failed-description")
};
let status = report.status;
let metrics = row![
metric(
crate::tr!("recovery-entities-checked"),
crate::tr!("recovery", "entities-checked"),
report.entities_scanned.to_string(),
),
metric(
crate::tr!("recovery-issues-found"),
crate::tr!("recovery", "issues-found"),
report.issues_found().to_string(),
),
metric(
crate::tr!("recovery-entities-removed"),
crate::tr!("recovery", "entities-removed"),
report.removed_total().to_string(),
),
metric(
crate::tr!("recovery-references-checked"),
crate::tr!("recovery", "references-checked"),
report.references_checked.to_string(),
),
]
@ -86,7 +86,7 @@ pub fn view_window<'a>(
details = details.push(
text(format!(
"{}: {}",
crate::tr!("recovery-referenced-entities-removed"),
crate::tr!("recovery", "referenced-entities-removed"),
report.referenced_entities_removed
))
.size(11),
@ -96,7 +96,7 @@ pub fn view_window<'a>(
details = details.push(
text(format!(
"{}: {}",
crate::tr!("recovery-references-unavailable"),
crate::tr!("recovery", "references-unavailable"),
report
.references_missing
.saturating_add(report.references_failed)
@ -112,7 +112,7 @@ pub fn view_window<'a>(
}
if let Some(path) = &report.log_path {
details = details.push(
text(format!("{}: {}", crate::tr!("recovery-log-path"), path.display()))
text(format!("{}: {}", crate::tr!("recovery", "log-path"), path.display()))
.size(10)
.style(muted_style),
);
@ -120,14 +120,14 @@ pub fn view_window<'a>(
details = details.push(
text(format!(
"{}: {error}",
crate::tr!("recovery-log-write-failed")
crate::tr!("recovery", "log-write-failed")
))
.size(10)
.style(status_style(RecoveryStatus::Failed)),
);
} else if cfg!(target_arch = "wasm32") {
details = details.push(
text(crate::tr!("recovery-log-download-ready"))
text(crate::tr!("recovery", "log-download-ready"))
.size(10)
.style(muted_style),
);
@ -142,7 +142,7 @@ pub fn view_window<'a>(
let mut actions = row![Space::new().width(Fill)].spacing(8);
if recovered && report.save_as_required && allow_save_copy {
actions = actions.push(
button(text(crate::tr!("recovery-save-copy")).size(12))
button(text(crate::tr!("recovery", "save-copy")).size(12))
.on_press(Message::RecoverySaveAs)
.style(button::primary)
.padding([6, 14]),
@ -150,14 +150,14 @@ pub fn view_window<'a>(
}
if report.log_path.is_some() || cfg!(target_arch = "wasm32") {
actions = actions.push(
button(text(crate::tr!("recovery-show-log")).size(12))
button(text(crate::tr!("recovery", "show-log")).size(12))
.on_press(Message::RecoveryShowLog)
.style(button::secondary)
.padding([6, 14]),
);
}
actions = actions.push(
button(text(crate::tr!("action-close")).size(12))
button(text(crate::tr!("action", "close")).size(12))
.on_press(Message::RecoveryClose)
.style(button::secondary)
.padding([6, 14]),
@ -200,11 +200,11 @@ pub fn view_prompt<'a>(
};
let actions = row![
Space::new().width(content_width),
button(text(crate::tr!("recovery-decline")).size(12))
button(text(crate::tr!("recovery", "decline")).size(12))
.on_press(Message::RecoveryDecline)
.style(button::secondary)
.padding([6, 14]),
button(text(crate::tr!("recovery-attempt")).size(12))
button(text(crate::tr!("recovery", "attempt")).size(12))
.on_press(Message::RecoveryAttempt)
.style(button::primary)
.padding([6, 14]),
@ -213,9 +213,9 @@ pub fn view_prompt<'a>(
container(
column![
text(crate::tr!("recovery-prompt-heading")).size(20),
text(crate::tr!("recovery", "prompt-heading")).size(20),
text(file_name).size(13),
text(crate::tr!("recovery-prompt-description"))
text(crate::tr!("recovery", "prompt-description"))
.size(11)
.style(muted_style),
container(scrollable(text(error).size(10)).height(content_height))