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

24
Cargo.lock generated
View file

@ -16,7 +16,7 @@ dependencies = [
name = "OpenCADStudio" name = "OpenCADStudio"
version = "0.9.4" version = "0.9.4"
dependencies = [ dependencies = [
"acadifc", "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=fe98e42)",
"ashpd", "ashpd",
"bincode", "bincode",
"bytemuck", "bytemuck",
@ -96,6 +96,20 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "acadifc"
version = "0.5.0"
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=fe98e42#fe98e425578c54facd021d8df74d86052b2f848b"
dependencies = [
"acadrust",
"base64 0.22.1",
"cadkernel",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "acadrust" name = "acadrust"
version = "0.4.1" version = "0.4.1"
@ -935,7 +949,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cadkernel" name = "cadkernel"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git#5499242f1160018e066f78d4e462c9532e665a96" source = "git+https://github.com/HakanSeven12/cadkernel.git#c9e104b99e8901c4804e7cece67f038f249a33f5"
dependencies = [ dependencies = [
"cavalier_contours", "cavalier_contours",
] ]
@ -3462,7 +3476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
dependencies = [ dependencies = [
"bytecount", "bytecount",
"memchr 2.8.3", "memchr 1.0.2",
"nom 8.0.0", "nom 8.0.0",
] ]
@ -3939,7 +3953,7 @@ dependencies = [
name = "ocs_plugin_api" name = "ocs_plugin_api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"acadifc", "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396)",
"bincode", "bincode",
"getrandom 0.2.17", "getrandom 0.2.17",
"interprocess", "interprocess",
@ -3954,7 +3968,7 @@ dependencies = [
name = "ocs_web_worker" name = "ocs_web_worker"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"acadifc", "acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396)",
"bincode", "bincode",
"console_error_panic_hook", "console_error_panic_hook",
"getrandom 0.3.4", "getrandom 0.3.4",

View file

@ -38,7 +38,7 @@ env_logger = "0.11"
# The CAD stack is reached through acadifc, which re-exports the codec and # The CAD stack is reached through acadifc, which re-exports the codec and
# the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…` # the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…`
# paths keep resolving. # paths keep resolving.
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", features = ["serde", "offset"] } acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "fe98e42", features = ["serde", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1" flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -339,10 +339,8 @@ pub(super) fn polar_constrain(
)) ))
} }
/// Polar constraint that only engages when the cursor is within `tol_px` /// Return the polar-constrained point while its ray is engaged.
/// screen pixels of the nearest polar ray; otherwise the cursor is left free pub(super) fn polar_constrain_if_near(
/// so POLAR behaves as if off when pointing away from every angle (issue #70).
pub(super) fn polar_constrain_near(
pt: glam::DVec3, pt: glam::DVec3,
base: glam::DVec3, base: glam::DVec3,
step_deg: f32, step_deg: f32,
@ -351,7 +349,7 @@ pub(super) fn polar_constrain_near(
bounds: iced::Rectangle, bounds: iced::Rectangle,
tol_px: f32, tol_px: f32,
xf: &UcsXform, xf: &UcsXform,
) -> glam::DVec3 { ) -> Option<glam::DVec3> {
let snapped = polar_constrain(pt, base, step_deg, xf); let snapped = polar_constrain(pt, base, step_deg, xf);
let to_screen = |w: glam::DVec3| { let to_screen = |w: glam::DVec3| {
let ndc = view_rot.project_point3((w - eye).as_vec3()); 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 (cx, cy) = to_screen(pt);
let (sx, sy) = to_screen(snapped); let (sx, sy) = to_screen(snapped);
if ((cx - sx).powi(2) + (cy - sy).powi(2)).sqrt() <= tol_px { (((cx - sx).powi(2) + (cy - sy).powi(2)).sqrt() <= tol_px).then_some(snapped)
snapped
} else {
pt
} }
/// 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 /// 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 /// 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. /// the ray from the tracking point (issue #69). `None` when not aligned.
otrack_active: Option<(glam::DVec3, glam::DVec3)>, otrack_active: Option<(glam::DVec3, glam::DVec3)>,
/// Kind of the currently active tracking reference, kept separately from /// Active OTRACK ray kind, separate from typed-distance geometry.
/// `otrack_active` so typed-distance behavior remains unchanged.
otrack_kind: Option<crate::snap::TrackingKind>, otrack_kind: Option<crate::snap::TrackingKind>,
/// Whether Tangent snap was enabled before a tangent-pick command started. /// Whether Tangent snap was enabled before a tangent-pick command started.
pre_cmd_tangent: Option<bool>, pre_cmd_tangent: Option<bool>,

View file

@ -1384,7 +1384,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
&& destination_is_current && destination_is_current
{ {
self.command_line.push_error_once( 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.restore_failed_save_continuation(continuation, i);
self.active_tab = i; self.active_tab = i;

View file

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

View file

@ -4,8 +4,9 @@
use super::util::*; use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE}; use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{ use crate::app::helpers::{
axis_lock_apply, axis_lock_capture, drafting_axes, drafting_constrain, parse_coord, polar_constrain_near, axis_lock_apply, axis_lock_capture, drafting_axes, drafting_constrain, parse_coord,
ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, 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::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent; use crate::modules::ModuleEvent;
@ -117,6 +118,45 @@ fn over_ucs_icon(p: Point, h: &crate::ui::overlay::UcsIconHit) -> bool {
} }
impl OpenCADStudio { 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 /// Write PDSIZE from the dialog buffer with the current relative/absolute
/// sign. A relative size is stored negative; absolute positive. Switching to /// sign. A relative size is stored negative; absolute positive. Switching to
/// absolute with an empty/zero size seeds a positive value from the current /// 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 // snapping must drop its foot from this point, including when a
// hot-grip set is moved by the same drag vector. // 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());
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 (go, gr) = self.drafting_grid_basis(i);
let base = grip.origin_world; let base = grip.origin_world;
let ucs_xf = self.tabs[i].ucs_xform(); let construction_ray =
self.active_construction_ray(i, raw, base, view_rot, eye, bounds);
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;
}
}
// `raw` is already model space (viewport camera or paper→model), // `raw` is already model space (viewport camera or paper→model),
// and the wires are model space, so the snap result is model. // and the wires are model space, so the snap result is model.
let snap_hit = self.snapper.snap( let snap_hit = self.snapper.snap(
@ -1099,7 +1089,7 @@ impl OpenCADStudio {
bounds, bounds,
go, go,
gr, gr,
construction_cursor, construction_ray,
); );
self.tabs[i].snap_result = snap_hit; self.tabs[i].snap_result = snap_hit;
@ -1519,60 +1509,18 @@ impl OpenCADStudio {
// the downcast point here. // the downcast point here.
self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
// Build the temporary construction direction before OSNAP. let construction_ray = if is_window_corner {
// Intersection can then test the active rubber-band against
// existing geometry (#704).
let construction_cursor = if is_window_corner {
None None
} else { } else {
self.last_point.and_then(|base| { self.last_point.and_then(|base| {
let ucs_xf = self.tabs[i].ucs_xform(); self.active_construction_ray(
i,
let mut pt = if self.ortho_mode {
Some(drafting_constrain(
snap_cursor, snap_cursor,
base, 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, view_rot,
eye, eye,
bounds, 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)
}) })
}; };
@ -1585,7 +1533,7 @@ impl OpenCADStudio {
bounds, bounds,
go, go,
gr, gr,
construction_cursor, construction_ray,
) )
}; };
@ -2907,57 +2855,18 @@ impl OpenCADStudio {
let (go, gr) = self.drafting_grid_basis(i); let (go, gr) = self.drafting_grid_basis(i);
self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); 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 None
} else { } else {
self.last_point.and_then(|base| { self.last_point.and_then(|base| {
let ucs_xf = self.tabs[i].ucs_xform(); self.active_construction_ray(
i,
let mut pt = if self.ortho_mode {
Some(drafting_constrain(
snap_cursor, snap_cursor,
base, 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, view_rot,
eye, eye,
bounds, 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)
}) })
}; };
@ -2970,7 +2879,7 @@ impl OpenCADStudio {
bounds, bounds,
go, go,
gr, gr,
construction_cursor, construction_ray,
) )
}; };
// Snap runs in model space; the result is already model. // 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, crate::ui::icons::SPLIT_V,
false, false,
Message::SplitModelViewport(false), Message::SplitModelViewport(false),
crate::tr!("viewport-split-vertical"), crate::tr!("viewport", "split-vertical"),
"VPORTS 2V", "VPORTS 2V",
)) ))
.push(sep()) .push(sep())
@ -457,7 +457,7 @@ pub(super) fn viewport_controls<'a>(
crate::ui::icons::SPLIT_H, crate::ui::icons::SPLIT_H,
false, false,
Message::SplitModelViewport(true), Message::SplitModelViewport(true),
crate::tr!("viewport-split-horizontal"), crate::tr!("viewport", "split-horizontal"),
"VPORTS 2H", "VPORTS 2H",
)); ));
// Drag handle + close: only meaningful with more than one model tile. // 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) .interaction(iced::mouse::Interaction::Grab)
.on_press(Message::PaneMoveStart); .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 bar = bar
.push(sep()) .push(sep())
.push(drag) .push(drag)
@ -486,7 +486,7 @@ pub(super) fn viewport_controls<'a>(
.push(danger_btn( .push(danger_btn(
crate::ui::icons::CLOSE, crate::ui::icons::CLOSE,
Message::CloseModelViewport, Message::CloseModelViewport,
crate::tr!("viewport-close"), crate::tr!("viewport", "close"),
"VPORTS SINGLE", "VPORTS SINGLE",
)); ));
} }

View file

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

View file

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

View file

@ -710,6 +710,21 @@ mod tests {
assert!(curve.is_closed()); 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] #[test]
fn entities_that_are_not_curves_say_so() { fn entities_that_are_not_curves_say_so() {
assert!(entity_curve(&EntityType::Point(Default::default())).is_none()); assert!(entity_curve(&EntityType::Point(Default::default())).is_none());

View file

@ -88,19 +88,19 @@ impl Language {
pub fn label(self) -> String { pub fn label(self) -> String {
match self { match self {
Language::System => crate::tr!("language-system"), Language::System => crate::tr!("language", "system"),
Language::EnUs => crate::tr!("language-english"), Language::EnUs => crate::tr!("language", "english"),
Language::TrTr => crate::tr!("language-turkish"), Language::TrTr => crate::tr!("language", "turkish"),
Language::NlNl => crate::tr!("language-dutch"), Language::NlNl => crate::tr!("language", "dutch"),
Language::FrFr => crate::tr!("language-french"), Language::FrFr => crate::tr!("language", "french"),
Language::DeDe => crate::tr!("language-german"), Language::DeDe => crate::tr!("language", "german"),
Language::HiIn => crate::tr!("language-hindi"), Language::HiIn => crate::tr!("language", "hindi"),
Language::RuRu => crate::tr!("language-russian"), Language::RuRu => crate::tr!("language", "russian"),
Language::ZhCn => crate::tr!("language-chinese-simplified"), Language::ZhCn => crate::tr!("language", "chinese-simplified"),
Language::EsEs => crate::tr!("language-spanish"), Language::EsEs => crate::tr!("language", "spanish"),
Language::PtBr => crate::tr!("language-portuguese"), Language::PtBr => crate::tr!("language", "portuguese"),
Language::ArSa => crate::tr!("language-arabic"), Language::ArSa => crate::tr!("language", "arabic"),
Language::JaJp => crate::tr!("language-japanese"), Language::JaJp => crate::tr!("language", "japanese"),
} }
} }
} }
@ -200,8 +200,8 @@ pub fn active_language_tag() -> String {
/// translatable data. /// translatable data.
pub fn translate(source: impl AsRef<str>) -> Cow<'static, str> { pub fn translate(source: impl AsRef<str>) -> Cow<'static, str> {
let source = source.as_ref(); let source = source.as_ref();
locale_catalog::message_id(source) locale_catalog::message_attribute(source)
.map(|message_id| Cow::Owned(loader().get(message_id))) .map(|(message_id, attribute_id)| Cow::Owned(loader().get_attr(message_id, attribute_id)))
.unwrap_or_else(|| Cow::Owned(source.to_string())) .unwrap_or_else(|| Cow::Owned(source.to_string()))
} }
@ -213,8 +213,8 @@ pub fn translate_args(
args: &[(&str, String)], args: &[(&str, String)],
) -> Cow<'static, str> { ) -> Cow<'static, str> {
let source = source.as_ref(); let source = source.as_ref();
let mut translated = locale_catalog::message_id(source) let mut translated = locale_catalog::message_attribute(source)
.map(|message_id| loader().get(message_id)) .map(|(message_id, attribute_id)| loader().get_attr(message_id, attribute_id))
.unwrap_or_else(|| source.to_string()); .unwrap_or_else(|| source.to_string());
for (name, value) in args { for (name, value) in args {
translated = translated.replace(&format!("__ocs_arg_{name}__"), value); 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 /// sentence. File names, handles, counts, and command values therefore remain
/// data instead of being sent through translation. /// data instead of being sent through translation.
pub fn translate_format(template: &str, rendered: String) -> Cow<'static, str> { 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); return Cow::Owned(rendered);
}; };
let Some(values) = format_values(template, &rendered) else { let Some(values) = format_values(template, &rendered) else {
return Cow::Owned(rendered); 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() { for (index, value) in values.iter().enumerate() {
translated = translated.replace(&format!("__ocs_fmt_{index}__"), value); 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. /// until they provide their own localization bundle.
pub fn ribbon_module_title(id: &str, fallback: &str) -> String { pub fn ribbon_module_title(id: &str, fallback: &str) -> String {
match id { match id {
"draw" => crate::tr!("ribbon-tab-draw"), "draw" => crate::tr!("ribbon-tab", "draw"),
"annotate" => crate::tr!("ribbon-tab-annotate"), "annotate" => crate::tr!("ribbon-tab", "annotate"),
"insert" => crate::tr!("ribbon-tab-insert"), "insert" => crate::tr!("ribbon-tab", "insert"),
"model" => crate::tr!("ribbon-tab-model"), "model" => crate::tr!("ribbon-tab", "model"),
"layout" => crate::tr!("ribbon-tab-layout"), "layout" => crate::tr!("ribbon-tab", "layout"),
"manage" => crate::tr!("ribbon-tab-manage"), "manage" => crate::tr!("ribbon-tab", "manage"),
"view" => crate::tr!("ribbon-tab-view"), "view" => crate::tr!("ribbon-tab", "view"),
_ => fallback.to_string(), _ => fallback.to_string(),
} }
} }
#[macro_export] #[macro_export]
macro_rules! tr { 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 $(,)?) => { ($message_id:literal $(,)?) => {
i18n_embed_fl::fl!($crate::i18n::loader(), $message_id) 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 { fn result_message(measurement: AreaMeasurement) -> String {
match measurement.perimeter { match measurement.perimeter {
Some(perimeter) => crate::tr!( Some(perimeter) => crate::tr!(
"area-result", "area", "result",
area = format!("{:.4}", measurement.area), area = format!("{:.4}", measurement.area),
perimeter = format!("{perimeter:.4}"), 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 { fn running_result_message(&self, measurement: AreaMeasurement) -> String {
match measurement.perimeter { match measurement.perimeter {
Some(perimeter) => crate::tr!( Some(perimeter) => crate::tr!(
"area-running-result", "area", "running-result",
area = format!("{:.4}", measurement.area), area = format!("{:.4}", measurement.area),
perimeter = format!("{perimeter:.4}"), perimeter = format!("{perimeter:.4}"),
total_area = format!("{:.4}", self.total_area), total_area = format!("{:.4}", self.total_area),
total_perimeter = format!("{:.4}", self.total_perimeter), total_perimeter = format!("{:.4}", self.total_perimeter),
), ),
None => crate::tr!( None => crate::tr!(
"area-running-result-area-only", "area", "running-result-area-only",
area = format!("{:.4}", measurement.area), area = format!("{:.4}", measurement.area),
total_area = format!("{:.4}", self.total_area), total_area = format!("{:.4}", self.total_area),
), ),
@ -224,30 +224,30 @@ impl CadCommand for AreaCommand {
fn prompt(&self) -> String { fn prompt(&self) -> String {
if self.objects_gathering { if self.objects_gathering {
return crate::tr!( return crate::tr!(
"area-prompt-objects", "area", "prompt-objects",
count = self.selected_entities.len() count = self.selected_entities.len()
); );
} }
if !self.points.is_empty() { 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 { match self.mode {
AreaMode::Single => crate::tr!("area-prompt-first"), AreaMode::Single => crate::tr!("area", "prompt-first"),
AreaMode::Add => crate::tr!("area-prompt-add"), AreaMode::Add => crate::tr!("area", "prompt-add"),
AreaMode::Subtract => crate::tr!("area-prompt-subtract"), AreaMode::Subtract => crate::tr!("area", "prompt-subtract"),
} }
} }
fn options(&self) -> Vec<CmdOption> { fn options(&self) -> Vec<CmdOption> {
if self.objects_gathering { 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() { if !self.points.is_empty() {
return Vec::new(); return Vec::new();
} }
let objects = || Self::option(crate::tr!("area-option-objects"), "OBJECTS"); let objects = || Self::option(crate::tr!("area", "option-objects"), "OBJECTS");
let add = || Self::option(crate::tr!("area-option-add"), "ADD"); let add = || Self::option(crate::tr!("area", "option-add"), "ADD");
let subtract = || Self::option(crate::tr!("area-option-subtract"), "SUBTRACT"); let subtract = || Self::option(crate::tr!("area", "option-subtract"), "SUBTRACT");
match self.mode { match self.mode {
AreaMode::Single => vec![objects(), add(), subtract()], AreaMode::Single => vec![objects(), add(), subtract()],
AreaMode::Add => vec![objects(), subtract()], AreaMode::Add => vec![objects(), subtract()],
@ -323,7 +323,7 @@ impl CadCommand for AreaCommand {
let Some((measurement, handles)) = self.selection_measurement() else { let Some((measurement, handles)) = self.selection_measurement() else {
self.selected_entities.clear(); self.selected_entities.clear();
return CmdResult::ReportMeasurementAndDeselect(crate::tr!( return CmdResult::ReportMeasurementAndDeselect(crate::tr!(
"area-objects-not-measurable" "area", "objects-not-measurable"
)); ));
}; };
if self.mode != AreaMode::Single { if self.mode != AreaMode::Single {

View file

@ -61,24 +61,24 @@ impl CadCommand for CopyCommand {
fn prompt(&self) -> String { fn prompt(&self) -> String {
if self.awaiting_count { if self.awaiting_count {
return crate::tr!("command-copy-array-count"); return crate::tr!("command-copy", "array-count");
} }
match &self.step { match &self.step {
Step::Base => crate::tr!( Step::Base => crate::tr!(
"command-copy-base", "command-copy", "base",
count = (self.handles.len() as i64), count = (self.handles.len() as i64),
), ),
Step::Placing(base) => { Step::Placing(base) => {
if let Some(n) = self.array_count { if let Some(n) = self.array_count {
crate::tr!( crate::tr!(
"command-copy-array-target", "command-copy", "array-target",
count = (n as i64), count = (n as i64),
x = format!("{:.3}", base.x), x = format!("{:.3}", base.x),
y = format!("{:.3}", base.y), y = format!("{:.3}", base.y),
) )
} else { } else {
crate::tr!( crate::tr!(
"command-copy-target", "command-copy", "target",
count = (self.count as i64), count = (self.count as i64),
x = format!("{:.3}", base.x), x = format!("{:.3}", base.x),
y = format!("{:.3}", base.y), 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 /// Re-exported unchanged: these already speak in plain `f64`, so there is no
/// call-shape difference for this module to absorb. /// 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. /// Preview geometry keeps the density the commands have always used.
const SEGMENTS_PER_RADIAN: f64 = geom2d::DEFAULT_SEGMENTS_PER_RADIAN; 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) 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]> { pub fn line_points(start: [f64; 3], end: [f64; 3]) -> Vec<[f32; 3]> {
narrow(vec![start, end]) narrow(vec![start, end])
} }

View file

@ -54,11 +54,11 @@ impl CadCommand for MoveCommand {
fn prompt(&self) -> String { fn prompt(&self) -> String {
match &self.step { match &self.step {
Step::Base => crate::tr!( Step::Base => crate::tr!(
"command-move-base", "command-move", "base",
count = (self.handles.len() as i64), count = (self.handles.len() as i64),
), ),
Step::Target(base) => crate::tr!( Step::Target(base) => crate::tr!(
"command-move-target", "command-move", "target",
x = format!("{:.3}", base.x), x = format!("{:.3}", base.x),
y = format!("{:.3}", base.y), 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. // its call shapes to the loose scalars and f32 render vertices used here.
use super::geom; use super::geom;
use super::geom::{ use super::geom::{
angle_within_arc as in_arc, arc_parameter as arc_t, arc_points as arc_pts, arc_parameter as arc_t, arc_points as arc_pts, ellipse_points as ellipse_pts,
ellipse_points as ellipse_pts, lerp as lerp2, lerp as lerp2, normalize_angle as norm,
line_circle as lc, line_ellipse as le, line_line as ll, normalize_angle as norm,
}; };
use crate::modules::draw::fence::{crossing_box_preview, FencePick}; use crate::modules::draw::fence::{crossing_box_preview, FencePick};
@ -131,14 +130,9 @@ enum Geo {
t1: f64, // end parameter (may be > 2π if wrapped) t1: f64, // end parameter (may be > 2π if wrapped)
}, },
/// A NURBS boundary, carried exactly. /// 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 { Spline {
handle: Handle, handle: Handle,
curve: Box<NurbsCurve>, curve: Box<NurbsCurve>,
segs: Vec<([f64; 2], [f64; 2])>,
}, },
} }
@ -250,17 +244,10 @@ fn geo_from_entity(h: Handle, e: &EntityType) -> Option<Geo> {
t1, t1,
}) })
} }
Curve::Nurbs(curve) => { Curve::Nurbs(curve) => Some(Geo::Spline {
// 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, handle: h,
curve: Box::new(curve), curve: Box::new(curve),
segs: sampled.windows(2).map(|w| (w[0], w[1])).collect(), }),
})
}
// A polyline reaches here already taken apart into its segments. // A polyline reaches here already taken apart into its segments.
Curve::Polyline(_) => None, 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. /// Extend the first or last segment of an LwPolyline to the nearest boundary.
/// Click point (DXF XY) determines which end to extend. /// 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( fn extend_lwpoly(
poly: &LwPolyline, poly: &LwPolyline,
click_x: f64, click_x: f64,
@ -1030,124 +1058,7 @@ fn extend_lwpoly(
return None; return None;
} }
// t_click on the segment: 0 = ax/ay, 1 = bx/by. We're extending beyond t=1. let best_t = extension_hit([ax, ay], [bx, by], poly.common.handle, geos, true)?;
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 new_x = ax + best_t * dx; let new_x = ax + best_t * dx;
let new_y = ay + best_t * dy; 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 bx = orig.end.x;
let by = orig.end.y; let by = orig.end.y;
let (dx, dy) = (bx - ax, by - ay); let (dx, dy) = (bx - ax, by - ay);
let target = orig.common.handle;
let extend_end = t_click >= 0.5; 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(); let mut line = orig.clone();
line.common.handle = Handle::NULL; line.common.handle = Handle::NULL;
let new_x = ax + best_t * dx; let new_x = ax + best_t * dx;

View file

@ -1,5 +1,5 @@
use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/recap.svg")); pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/recap.svg"));
pub fn tool() -> ToolDef { 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. // 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 boundary is a closed polygon in world XY coordinates.
// The GPU fragment shader performs point-in-polygon and hatch-line tests so // The GPU fragment shader performs point-in-polygon and hatch-line tests so
// no line geometry is ever tessellated on the CPU. // 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; 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. /// One line family from a PAT-format hatch pattern.
/// ///
/// Format mirrors the standard PAT line format: /// Format mirrors the standard PAT line format:
@ -269,6 +281,13 @@ impl HatchModel {
if edges.is_empty() { if edges.is_empty() {
return Vec::new(); 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. // ── AABB of the boundary in world coords.
let mut min_x = f64::INFINITY; let mut min_x = f64::INFINITY;
@ -363,39 +382,28 @@ impl HatchModel {
let lx = origin[0] + kf * step_x; let lx = origin[0] + kf * step_x;
let ly = origin[1] + kf * step_y; let ly = origin[1] + kf * step_y;
// Intersect line P(t) = L + t·(cos_a, sin_a) against each // The stretches of this pattern line that fall inside the
// boundary edge; collect t-values where the edge actually // boundary. The direction is a unit vector, so the kernel's
// crosses (s ∈ [0,1]). // parameter is distance along the line — the same `t` the
let mut ts: Vec<f64> = Vec::with_capacity(8); // dash walk below steps in.
for &(a, b) in &edges { //
let ex = b[0] - a[0]; // Each span is tested for containment rather than taken from
let ey = b[1] - a[1]; // alternating crossings. A line that grazes a corner crosses
let det = ex * sin_a - ey * cos_a; // = sin_a·ex − cos_a·ey // twice in one place, and an alternating walk inverts from
if det.abs() < 1e-9 { // there on: the fill lands in the holes and the solid comes
continue; // out empty.
} let spans = inside_spans(
let rx = a[0] - lx; &boundary,
let ry = a[1] - ly; &KernelCurve::XLine(KernelXLine {
let t = (ex * ry - ey * rx) / det; base: [lx, ly],
let s = (cos_a * ry - sin_a * rx) / det; direction: [cos_a, sin_a],
if s >= 0.0 && s <= 1.0 { }),
ts.push(t); clip_tolerance,
} );
}
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;
}
// Even-odd: emit segments between consecutive pairs. for span in spans {
for pair in ts.chunks_exact(2) { let t0 = span[0];
let t0 = pair[0]; let t1 = span[1];
let t1 = pair[1];
if t1 - t0 < 1e-6 { if t1 - t0 < 1e-6 {
continue; continue;
} }

View file

@ -883,7 +883,7 @@ impl Snapper {
// rotated or isometric grid the user sees. // rotated or isometric grid the user sees.
grid_origin: Vec3, grid_origin: Vec3,
grid_axes: (Vec3, Vec3, Vec3), grid_axes: (Vec3, Vec3, Vec3),
construction_cursor: Option<glam::DVec3>, construction_ray: Option<(glam::DVec3, glam::DVec3)>,
) -> Option<SnapResult> { ) -> Option<SnapResult> {
// Object-snap selection is priority-then-distance, NOT nearest-wins. // Object-snap selection is priority-then-distance, NOT nearest-wins.
// "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry // "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry
@ -1232,42 +1232,45 @@ impl Snapper {
} }
} }
// ── Intersection with active construction segment (#704) ────────── let mut try_ray_intersections = |origin: glam::DVec3, through: glam::DVec3| {
// if (through - origin).length_squared() <= 1e-18 {
// The existing Intersection pass below only compares document return;
// 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;
} }
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);
}
}
}
} else {
for wire in wires.iter().filter(|wire| wire_in_range(wire)) {
for index in 0..wire.points.len().saturating_sub(1) { for index in 0..wire.points.len().saturating_sub(1) {
let b0 = wp_f64(wire, index); if let Some(point) = ray_segment_intersect_3d(
let b1 = wp_f64(wire, index + 1); origin,
through,
wp_f64(wire, index),
wp_f64(wire, index + 1),
) {
if (point - origin).length_squared() > 1e-18 {
try_pt(point, SnapType::Intersection);
}
}
}
}
}
};
if let Some(pt) = ray_segment_intersect_3d(from, to, b0, b1) { if self.is_on(SnapType::Intersection) {
if (pt - from).length_squared() > 1e-18 { if let Some((origin, through)) = construction_ray {
try_pt(pt, SnapType::Intersection); try_ray_intersections(origin, through);
} }
} }
}
} // Check engaged perpendicular tracking rays against nearby geometry.
}
}
}
// ── 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.
if self.is_on(SnapType::Intersection) if self.is_on(SnapType::Intersection)
&& self.otrack_enabled && self.otrack_enabled
&& self.is_on(SnapType::Perpendicular) && self.is_on(SnapType::Perpendicular)
@ -1278,50 +1281,23 @@ impl Snapper {
}; };
for &dir in perp_dirs { for &dir in perp_dirs {
// Project the cursor onto this perpendicular tracking line.
let rel = cursor_world - origin; let rel = cursor_world - origin;
let t = rel.x * dir.x + rel.y * dir.y; let t = rel.x * dir.x + rel.y * dir.y;
let aligned = glam::DVec3::new( let aligned = glam::DVec3::new(
origin.x + dir.x * t, origin.x + dir.x * t,
origin.y + dir.y * t, origin.y + dir.y * t,
origin.z, 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); let aligned_screen = world_to_screen(aligned, view_rot, eye, bounds);
if dist2(aligned_screen, cursor_screen) > radius2 { if dist2(aligned_screen, cursor_screen) > radius2 {
continue; 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 dir_to_cursor = if t >= 0.0 { dir } else { -dir };
let through = origin + dir_to_cursor; try_ray_intersections(origin, 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);
}
}
}
}
} }
} }
} }
drop(try_ray_intersections);
// ── Intersection — segment-segment intersections (pairwise, gated) ── // ── Intersection — segment-segment intersections (pairwise, gated) ──
if self.is_on(SnapType::Intersection) 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. /// True 3D intersection of a forward ray and a finite 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.
fn ray_segment_intersect_3d( fn ray_segment_intersect_3d(
ray_origin: glam::DVec3, ray_origin: glam::DVec3,
ray_through: glam::DVec3, ray_through: glam::DVec3,
@ -2147,8 +2117,6 @@ fn ray_segment_intersect_3d(
let t = (ex * d2y - ey * d2x) / cross; let t = (ex * d2y - ey * d2x) / cross;
let s = (ex * d1y - ey * d1x) / 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 { if t < 0.0 || s < 0.0 || s > 1.0 {
return None; return None;
} }

View file

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

View file

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

View file

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

View file

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