From dde5ac07cfcdd9b1d9c614cbbda8c34cfc0e8a12 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 19:53:55 +0300 Subject: [PATCH 01/18] refactor(trim): find boundary crossings with cadkernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `line_seg_ts`, `arc_seg_ts`, `ellipse_seg_ts` and `spline_seg_ts` each walked every boundary kind and solved that pair by hand — four nested matches over the same seven cases. They are now four descriptions of the target as a `Curve` handed to one `cut_params`, which asks the kernel. Their signatures are unchanged, so the twenty call sites are untouched. Three things follow from it. Boundary splines and target splines stop being sampled. A cut on a spline now lands where the boundary actually crosses rather than where a fixed sampling said it did, because the kernel subdivides and converges to a tolerance instead. Rays and infinite lines as boundaries are handled by the kernel's extent rather than by a case here — a ray rejecting what lies behind its origin is the kernel's job now. `extend_spline` shoots a real ray. It used to build a line a million units long and cut against that, which both invents a limit and, at survey coordinates, is comparable to the coordinates themselves. The ray's direction is a unit vector, so what comes back is the distance to the boundary. That also fixes the hit position, which multiplied by the million-unit extent a second time and landed the extended end far past where it was supposed to. `cut_params` clamps to 0..=1 only for a bounded target; a ray has to keep the parameter it comes back with, since the distance is the answer. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/modules/draw/modify/geom.rs | 5 - src/modules/draw/modify/trim.rs | 765 +++++++------------------------- 3 files changed, 171 insertions(+), 601 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbc4f1b5a..e0e800513 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,7 +945,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#e76334b7697a3c39292c8a6494d9b837bba78a6e" +source = "git+https://github.com/HakanSeven12/cadkernel.git#5294a45d19508c22726c01b91215d3ca3fd4c815" dependencies = [ "cavalier_contours", ] diff --git a/src/modules/draw/modify/geom.rs b/src/modules/draw/modify/geom.rs index ca8eacb87..64569cc05 100644 --- a/src/modules/draw/modify/geom.rs +++ b/src/modules/draw/modify/geom.rs @@ -51,11 +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) } -/// Angles **on the first circle** where two circles meet. -pub fn circle_circle_angles(cx1: f64, cy1: f64, r1: f64, cx2: f64, cy2: f64, r2: f64) -> Vec { - geom2d::circle_circle_angles([cx1, cy1], r1, [cx2, cy2], r2) -} - /// `(s_on_line, t_on_ellipse)` pairs where a line meets an ellipse. /// /// `nx, ny` is the unit major axis. diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index d43214ae6..4b976296b 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -15,7 +15,7 @@ use std::f64::consts::TAU; use super::geom; use super::geom::{ angle_within_arc as in_arc, arc_parameter as arc_t, arc_points as arc_pts, - circle_circle_angles as cc_angles, ellipse_points as ellipse_pts, lerp as lerp2, + ellipse_points as ellipse_pts, lerp as lerp2, line_circle as lc, line_ellipse as le, line_line as ll, normalize_angle as norm, }; @@ -29,6 +29,13 @@ use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::DVec3; use acadrust::kernel::geom2d::nurbs::clamped_uniform_knots; +use acadrust::kernel::geom2d::{ + intersect as kernel_intersect, Arc as KernelArc, Circle as KernelCircle, Curve, + Extent as KernelExtent, + Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc, Line as KernelLine, + Polyline as KernelPolyline, PolylineVertex as KernelVertex, Ray as KernelRay, + Tolerance as KernelTolerance, XLine as KernelXLine, +}; use crate::command::{CadCommand, CmdResult}; use crate::modules::draw::modify::spline_ops::{ @@ -250,134 +257,126 @@ fn geo_from_entity(h: Handle, e: &EntityType) -> Option { // ── Intersection helpers ────────────────────────────────────────────────── -/// Sorted, deduped t-params ∈ [0,1] where LINE segment (ax,ay)→(bx,by) intersects boundaries. -fn line_seg_ts(ax: f64, ay: f64, bx: f64, by: f64, target: Handle, geos: &[Geo]) -> Vec { - let (dx, dy) = (bx - ax, by - ay); - let mut ts = vec![]; - 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) && (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - Geo::Arc { - handle, - cx, - cy, - r, - a0, - a1, - } => { - if *handle == target { - continue; - } - for t in lc(ax, ay, dx, dy, *cx, *cy, *r) { - if !(-1e-9..=1.0 + 1e-9).contains(&t) { - continue; - } - let ix = ax + t * dx; - let iy = ay + t * dy; - if in_arc((iy - cy).atan2(ix - cx), *a0, *a1) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - Geo::Circle { handle, cx, cy, r } => { - if *handle == target { - continue; - } - for t in lc(ax, ay, dx, dy, *cx, *cy, *r) { - if (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - 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) { - // Ray: u >= 0 (semi-infinite) - if u >= -1e-9 && (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - 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) { - // XLine: any u accepted - if (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - Geo::Ellipse { - handle, - cx, - cy, - a, - b, - nx, - ny, - t0, - t1, - } => { - if *handle == target { - continue; - } - for (s, t_ell) in le(ax, ay, dx, dy, *cx, *cy, *a, *b, *nx, *ny) { - if !(-1e-9..=1.0 + 1e-9).contains(&s) { - continue; - } - if in_arc(t_ell, *t0, *t1) { - ts.push(s.clamp(0.0, 1.0)); - } - } - } - 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) && (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - } - } +// ── Boundary crossings ──────────────────────────────────────────────────── +// +// Every cut a trim or extend makes comes from one question: where does the +// entity being edited meet the boundaries picked as cutting edges. The kernel +// answers it for any pair of curves, so these four just describe the target as +// a `Curve` and hand it over. + +/// The entity a boundary came from, so a curve is never cut by itself. +fn geo_handle(geo: &Geo) -> Handle { + match geo { + Geo::Line { handle, .. } + | Geo::Arc { handle, .. } + | Geo::Circle { handle, .. } + | Geo::Ray { handle, .. } + | Geo::InfLine { handle, .. } + | Geo::Ellipse { handle, .. } + | Geo::Spline { handle, .. } => *handle, } - ts.sort_by(|a, b| a.partial_cmp(b).unwrap()); +} + +/// A boundary as a kernel curve. +fn geo_to_curve(geo: &Geo) -> Option { + Some(match geo { + Geo::Line { p1, p2, .. } => Curve::Line(KernelLine { + start: *p1, + end: *p2, + }), + Geo::Arc { cx, cy, r, a0, a1, .. } => Curve::Arc(KernelArc { + centre: [*cx, *cy], + radius: *r, + start_angle: *a0, + end_angle: *a1, + }), + Geo::Circle { cx, cy, r, .. } => Curve::Circle(KernelCircle { + centre: [*cx, *cy], + radius: *r, + }), + Geo::Ray { bx, by, dx, dy, .. } => Curve::Ray(KernelRay { + origin: [*bx, *by], + direction: [*dx, *dy], + }), + Geo::InfLine { bx, by, dx, dy, .. } => Curve::XLine(KernelXLine { + base: [*bx, *by], + direction: [*dx, *dy], + }), + Geo::Ellipse { + cx, cy, a, b, nx, ny, t0, t1, .. + } => Curve::Ellipse(KernelEllipseArc { + ellipse: KernelEllipse { + centre: [*cx, *cy], + major_radius: *a, + minor_radius: *b, + major_axis: [*nx, *ny], + }, + start_parameter: *t0, + end_parameter: *t1, + }), + // Boundary splines arrive already sampled into segments, so they stay + // a chain of straight pieces here. + Geo::Spline { segs, .. } => { + let mut vertices: Vec = segs + .iter() + .map(|(from, _)| KernelVertex::straight(*from)) + .collect(); + let last = segs.last()?.1; + vertices.push(KernelVertex::straight(last)); + if vertices.len() < 2 { + return None; + } + Curve::Polyline(KernelPolyline { + vertices, + closed: false, + }) + } + }) +} + +/// Where `target` is cut by every boundary except its own entity, as +/// parameters along it in `0..=1`. +fn cut_params(target: &Curve, handle: Handle, geos: &[Geo]) -> Vec { + let tolerance = KernelTolerance::new(CUT_TOLERANCE); + // A bounded target's parameters are pinned to its own span, as every + // caller here expects. An unbounded one — a ray shot out to find the + // nearest boundary — has to keep whatever it comes back with, since the + // whole point is how far away the crossing is. + let bounded = matches!(target.extent(), KernelExtent::Bounded); + let mut ts: Vec = geos + .iter() + .filter(|geo| geo_handle(geo) != handle) + .filter_map(geo_to_curve) + .flat_map(|boundary| { + kernel_intersect(target, &boundary, tolerance) + .into_iter() + .map(|hit| if bounded { hit.t_a.clamp(0.0, 1.0) } else { hit.t_a }) + .collect::>() + }) + .collect(); + ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); ts } -/// Sorted, deduped t-params ∈ [0,1] where ARC (cx,cy,r,a0→a1) intersects boundaries. +/// How close two points have to be to count as a crossing. +/// +/// Loose enough that a boundary drawn to meet an entity still registers when +/// its endpoint is a rounding step away, which is the usual case in a drawing +/// that was snapped together rather than computed. +const CUT_TOLERANCE: f64 = 1e-7; + +fn line_seg_ts(ax: f64, ay: f64, bx: f64, by: f64, target: Handle, geos: &[Geo]) -> Vec { + cut_params( + &Curve::Line(KernelLine { + start: [ax, ay], + end: [bx, by], + }), + target, + geos, + ) +} + fn arc_seg_ts( cx: f64, cy: f64, @@ -387,213 +386,19 @@ fn arc_seg_ts( target: Handle, geos: &[Geo], ) -> Vec { - let mut ts = vec![]; - for geo in geos { - let angles: Vec = match geo { - Geo::Line { handle, p1, p2 } => { - if *handle == target { - continue; - } - let (ldx, ldy) = (p2[0] - p1[0], p2[1] - p1[1]); - lc(p1[0], p1[1], ldx, ldy, cx, cy, r) - .into_iter() - .filter(|&u| (-1e-9..=1.0 + 1e-9).contains(&u)) - .map(|u| (p1[1] + u * ldy - cy).atan2(p1[0] + u * ldx - cx)) - .collect() - } - Geo::Arc { - handle, - cx: cx2, - cy: cy2, - r: r2, - a0: a02, - a1: a12, - } => { - if *handle == target { - continue; - } - cc_angles(cx, cy, r, *cx2, *cy2, *r2) - .into_iter() - .filter(|&a| { - // `a` lies on the TARGET circle — re-express the - // intersection point as an angle on the BOUNDARY - // circle before testing that arc's span (#370). - let px = cx + r * a.cos(); - let py = cy + r * a.sin(); - in_arc((py - cy2).atan2(px - cx2), *a02, *a12) - }) - .collect() - } - Geo::Circle { - handle, - cx: cx2, - cy: cy2, - r: r2, - } => { - if *handle == target { - continue; - } - cc_angles(cx, cy, r, *cx2, *cy2, *r2) - } - Geo::Ray { - handle, - bx: rbx, - by: rby, - dx: rdx, - dy: rdy, - } => { - if *handle == target { - continue; - } - // Intersect arc circle with the Ray direction - lc(*rbx, *rby, *rdx, *rdy, cx, cy, r) - .into_iter() - .filter(|&u| u >= -1e-9) // Ray: u >= 0 - .map(|u| (rby + u * rdy - cy).atan2(rbx + u * rdx - cx)) - .collect() - } - Geo::InfLine { - handle, - bx: ibx, - by: iby, - dx: idx, - dy: idy, - } => { - if *handle == target { - continue; - } - // XLine: any u accepted - lc(*ibx, *iby, *idx, *idy, cx, cy, r) - .into_iter() - .map(|u| (iby + u * idy - cy).atan2(ibx + u * idx - cx)) - .collect() - } - Geo::Ellipse { - handle, - cx: ecx, - cy: ecy, - a: ea, - b: eb, - nx, - ny, - t0: et0, - t1: et1, - } => { - if *handle == target { - continue; - } - // Sample the arc and find where it crosses the ellipse boundary. - ellipse_boundary_angles_for_arc( - cx, cy, r, a0, a1, *ecx, *ecy, *ea, *eb, *nx, *ny, *et0, *et1, - ) - } - Geo::Spline { handle, segs } => { - if *handle == target { - continue; - } - // Intersect arc circle with each spline segment. - let mut hit_angles = vec![]; - for (p1, p2) in segs { - let ldx = p2[0] - p1[0]; - let ldy = p2[1] - p1[1]; - for u in lc(p1[0], p1[1], ldx, ldy, cx, cy, r) { - if !(-1e-9..=1.0 + 1e-9).contains(&u) { - continue; - } - let ix = p1[0] + u * ldx; - let iy = p1[1] + u * ldy; - hit_angles.push((iy - cy).atan2(ix - cx)); - } - } - hit_angles - } - }; - for a in angles { - if in_arc(a, a0, a1) { - ts.push(arc_t(a, a0, a1)); - } - } - } - ts.sort_by(|a, b| a.partial_cmp(b).unwrap()); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - ts + cut_params( + &Curve::Arc(KernelArc { + centre: [cx, cy], + radius: r, + start_angle: a0, + end_angle: a1, + }), + target, + geos, + ) } -/// Find angles on a circular arc where it crosses an ellipse-arc boundary. -/// Uses 64-sample sign-change detection + bisection. -fn ellipse_boundary_angles_for_arc( - cx: f64, - cy: f64, - r: f64, - a0: f64, - a1: f64, - ecx: f64, - ecy: f64, - ea: f64, - eb: f64, - nx: f64, - ny: f64, - et0: f64, - et1: f64, -) -> Vec { - // f(α) = (x_local/ea)² + (y_local/eb)² – 1 where (x_local, y_local) is the arc - // point projected onto ellipse local axes. - let f = |alpha: f64| { - let px = cx + r * alpha.cos() - ecx; - let py = cy + r * alpha.sin() - ecy; - let xl = px * nx + py * ny; - let yl = -px * ny + py * nx; - (xl / ea).powi(2) + (yl / eb).powi(2) - 1.0 - }; - let span = { - let s = norm(a1) - norm(a0); - if s <= 0.0 { - s + TAU - } else { - s - } - }; - let n = 128usize; - let mut hits = vec![]; - let mut prev = f(norm(a0)); - for i in 1..=n { - let alpha = norm(a0) + span * (i as f64 / n as f64); - let cur = f(alpha); - if prev * cur <= 0.0 { - // Bisect - let alpha_lo = norm(a0) + span * ((i - 1) as f64 / n as f64); - let alpha_hi = alpha; - let mut lo = alpha_lo; - let mut hi = alpha_hi; - let mut flo = prev; - for _ in 0..32 { - let mid = (lo + hi) * 0.5; - let fm = f(mid); - if flo * fm <= 0.0 { - hi = mid; - } else { - lo = mid; - flo = fm; - } - } - let alpha_hit = (lo + hi) * 0.5; - // Check that the intersection point is on the ellipse ARC (not outside t0..t1) - let px = cx + r * alpha_hit.cos() - ecx; - let py = cy + r * alpha_hit.sin() - ecy; - let xl = px * nx + py * ny; - let yl = -px * ny + py * nx; - let t_ell = yl.atan2(xl); - if in_arc(t_ell, et0, et1) { - hits.push(alpha_hit); - } - } - prev = cur; - } - hits -} - -/// Sorted t-params ∈ [0,1] where an ELLIPSE arc intersects boundary geometries. -/// t is the normalised eccentric-anomaly parameter along [t0, t1]. +#[allow(clippy::too_many_arguments)] fn ellipse_seg_ts( cx: f64, cy: f64, @@ -606,235 +411,30 @@ fn ellipse_seg_ts( target: Handle, geos: &[Geo], ) -> Vec { - let span = t1 - t0; // always positive (build_geos ensures t1 > t0) - let ellipse_pt = |t: f64| -> [f64; 2] { - [ - cx + a * t.cos() * nx - b * t.sin() * ny, - cy + a * t.cos() * ny + b * t.sin() * nx, - ] - }; - // f_boundary(t) > 0 means "outside this boundary segment" - let mut ts = vec![]; + cut_params( + &Curve::Ellipse(KernelEllipseArc { + ellipse: KernelEllipse { + centre: [cx, cy], + major_radius: a, + minor_radius: b, + major_axis: [nx, ny], + }, + start_parameter: t0, + end_parameter: t1, + }), + target, + geos, + ) +} - for geo in geos { - match geo { - Geo::Line { handle, p1, p2 } => { - if *handle == target { - continue; - } - // Find t values where ellipse crosses the infinite line p1→p2, - // then filter to the finite segment [p1,p2]. - let ldx = p2[0] - p1[0]; - let ldy = p2[1] - p1[1]; - for (s, t_ell) in le(p1[0], p1[1], ldx, ldy, cx, cy, a, b, nx, ny) { - if !(-1e-9..=1.0 + 1e-9).contains(&s) { - continue; - } - if in_arc(t_ell, t0, t1) { - let t_norm = arc_t(t_ell, t0, t0 + span); - ts.push(t_norm); - } - } - } - Geo::Arc { - handle, - cx: acx, - cy: acy, - r, - a0: aa0, - a1: aa1, - } => { - if *handle == target { - continue; - } - // 64-sample sign-change on (dist_to_arc_circle - r) - let n = 64usize; - let mut prev_sign = { - let [px, py] = ellipse_pt(t0); - (px - acx).hypot(py - acy) - r - }; - for i in 1..=n { - let t_ell = t0 + span * (i as f64 / n as f64); - let [px, py] = ellipse_pt(t_ell); - let cur_sign = (px - acx).hypot(py - acy) - r; - if prev_sign * cur_sign <= 0.0 { - let t_lo = t0 + span * ((i - 1) as f64 / n as f64); - let t_hi = t_ell; - let mut lo = t_lo; - let mut hi = t_hi; - let mut flo = prev_sign; - for _ in 0..32 { - let mid = (lo + hi) * 0.5; - let [px2, py2] = ellipse_pt(mid); - let fm = (px2 - acx).hypot(py2 - acy) - r; - if flo * fm <= 0.0 { - hi = mid; - } else { - lo = mid; - flo = fm; - } - } - let t_hit = (lo + hi) * 0.5; - let [phx, phy] = ellipse_pt(t_hit); - let ang = (phy - acy).atan2(phx - acx); - if in_arc(ang, *aa0, *aa1) { - ts.push(arc_t(t_hit, t0, t0 + span)); - } - } - prev_sign = cur_sign; - } - } - Geo::Circle { - handle, - cx: acx, - cy: acy, - r, - } => { - if *handle == target { - continue; - } - let n = 64usize; - let mut prev_sign = { - let [px, py] = ellipse_pt(t0); - (px - acx).hypot(py - acy) - r - }; - for i in 1..=n { - let t_ell = t0 + span * (i as f64 / n as f64); - let [px, py] = ellipse_pt(t_ell); - let cur_sign = (px - acx).hypot(py - acy) - r; - if prev_sign * cur_sign <= 0.0 { - let t_lo = t0 + span * ((i - 1) as f64 / n as f64); - let t_hi = t_ell; - let mut lo = t_lo; - let mut hi = t_hi; - let mut flo = prev_sign; - for _ in 0..32 { - let mid = (lo + hi) * 0.5; - let [px2, py2] = ellipse_pt(mid); - let fm = (px2 - acx).hypot(py2 - acy) - r; - if flo * fm <= 0.0 { - hi = mid; - } else { - lo = mid; - flo = fm; - } - } - ts.push(arc_t((lo + hi) * 0.5, t0, t0 + span)); - } - prev_sign = cur_sign; - } - } - Geo::Ray { - handle, - bx: rbx, - by: rby, - dx: rdx, - dy: rdy, - } => { - if *handle == target { - continue; - } - for (s, t_ell) in le(*rbx, *rby, *rdx, *rdy, cx, cy, a, b, nx, ny) { - if s >= -1e-9 && in_arc(t_ell, t0, t1) { - ts.push(arc_t(t_ell, t0, t0 + span)); - } - } - } - Geo::InfLine { - handle, - bx: ibx, - by: iby, - dx: idx, - dy: idy, - } => { - if *handle == target { - continue; - } - for (_s, t_ell) in le(*ibx, *iby, *idx, *idy, cx, cy, a, b, nx, ny) { - if in_arc(t_ell, t0, t1) { - ts.push(arc_t(t_ell, t0, t0 + span)); - } - } - } - Geo::Ellipse { handle, .. } => { - if *handle == target { - continue; - } - // Ellipse-ellipse: numerical 64-sample - if let Geo::Ellipse { - cx: ecx2, - cy: ecy2, - a: ea2, - b: eb2, - nx: nx2, - ny: ny2, - t0: et02, - t1: et12, - .. - } = geo - { - let n = 64usize; - let f = |t: f64| -> f64 { - let [px, py] = ellipse_pt(t); - let xl = (px - ecx2) * nx2 + (py - ecy2) * ny2; - let yl = -(px - ecx2) * ny2 + (py - ecy2) * nx2; - (xl / ea2).powi(2) + (yl / eb2).powi(2) - 1.0 - }; - let mut prev_f = f(t0); - for i in 1..=n { - let t_ell = t0 + span * (i as f64 / n as f64); - let cur_f = f(t_ell); - if prev_f * cur_f <= 0.0 { - let t_lo = t0 + span * ((i - 1) as f64 / n as f64); - let mut lo = t_lo; - let mut hi = t_ell; - let mut flo = prev_f; - for _ in 0..32 { - let mid = (lo + hi) * 0.5; - let fm = f(mid); - if flo * fm <= 0.0 { - hi = mid; - } else { - lo = mid; - flo = fm; - } - } - let t_hit = (lo + hi) * 0.5; - let [phx, phy] = ellipse_pt(t_hit); - let xl = (phx - ecx2) * nx2 + (phy - ecy2) * ny2; - let yl = -(phx - ecx2) * ny2 + (phy - ecy2) * nx2; - let t_ell2 = yl.atan2(xl); - if in_arc(t_ell2, *et02, *et12) { - ts.push(arc_t(t_hit, t0, t0 + span)); - } - } - prev_f = cur_f; - } - } - } - Geo::Spline { handle, segs } => { - if *handle == target { - continue; - } - // Ellipse × Spline: sign-change detection on each spline segment - for (p1, p2) in segs { - let ldx = p2[0] - p1[0]; - let ldy = p2[1] - p1[1]; - for (s, t_ell) in le(p1[0], p1[1], ldx, ldy, cx, cy, a, b, nx, ny) { - if !(-1e-9..=1.0 + 1e-9).contains(&s) { - continue; - } - if in_arc(t_ell, t0, t1) { - ts.push(arc_t(t_ell, t0, t0 + span)); - } - } - } - } - } - } - ts.sort_by(|a, b| a.partial_cmp(b).unwrap()); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - ts +/// Cuts on a spline, solved against the true curve rather than a sampling of +/// it. The kernel subdivides and converges, so a cut lands where the boundary +/// actually crosses instead of where a fixed number of samples said it did. +fn spline_seg_ts(spl: &SplineEnt, target: Handle, geos: &[Geo]) -> Vec { + let Some(curve) = spline_to_nurbs(spl) else { + return vec![]; + }; + cut_params(&Curve::Nurbs(curve), target, geos) } /// Trim an Ellipse entity. Returns the surviving ellipse-arc segments. @@ -940,36 +540,6 @@ fn extend_ellipse(orig: &EllipseEnt, t_click: f64, geos: &[Geo]) -> Option Vec { - let Some((t0, t1)) = spline_range(spl) else { - return vec![]; - }; - let range = t1 - t0; - if range < 1e-12 { - return vec![]; - } - - let (ts_spl, pts) = spline_sample_xy(spl, 64); - let mut out = vec![]; - for i in 0..pts.len().saturating_sub(1) { - let ax = pts[i][0]; - let ay = pts[i][1]; - let bx = pts[i + 1][0]; - let by = pts[i + 1][1]; - let seg_ts = line_seg_ts(ax, ay, bx, by, target, geos); - for u in seg_ts { - // u is a t-param on this polyline segment; map to spline knot range, then normalise. - let t_spline = ts_spl[i] + u * (ts_spl[i + 1] - ts_spl[i]); - out.push(t_to_rel(t_spline, t0, t1)); - } - } - out.sort_by(|a, b| a.partial_cmp(b).unwrap()); - out.dedup_by(|a, b| (*a - *b).abs() < 1e-4); - out -} - /// Trim a Spline entity. Returns surviving spline pieces (one or two). fn trim_spline(spl: &SplineEnt, ts: &[f64], t_click: f64) -> Vec { let Some((t0, t1)) = spline_range(spl) else { @@ -1031,15 +601,20 @@ fn extend_spline(spl: &SplineEnt, t_click: f64, geos: &[Geo]) -> Option 1e-6) + .reduce(f64::min)?; - let best_t = seg_ts.into_iter().filter(|&t| t > 1e-6).reduce(f64::min)?; - - let hit_x = ep[0] + best_t * (ray_end_x - ep[0]) * TRIM_EXTENT; - let hit_y = ep[1] + best_t * (ray_end_y - ep[1]) * TRIM_EXTENT; + let hit_x = ep[0] + best_t * dx; + let hit_y = ep[1] + best_t * dy; // Add a new control point at the hit location by appending/prepending. let z = spl.control_points.first().map(|v| v.z).unwrap_or(0.0); From 3f103d2f8b50373b1e58ca35a48b44ad2cad2014 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 20:04:08 +0300 Subject: [PATCH 02/18] refactor(trim): give rays and infinite lines their real parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trimming a Ray or an XLine mapped it onto a virtual 0..=1 across a million-unit stand-in for infinity, and read the surviving ends back with a threshold — 0.9999 of that stand-in meant "still infinite". Both now use the curve's own parameter, `t = 0` at the base and advancing by one direction vector per unit, with no upper limit. An end that reaches infinity says so, and `cut_intervals` bounds the target by its extent instead of always by 0..=1. The stand-in was not merely inelegant. A million units is the same order as the coordinates themselves in a survey drawing, so a boundary beyond it was silently missed, and the threshold meant a cut very near the far end was rounded to "unbounded" and kept the wrong shape. Implied and extended boundary edges become infinite lines rather than segments stretched by the same constant. That is what they always meant — the boundary cuts wherever its line reaches — and the kernel solves it exactly. The hover preview shared the virtual mapping and would have been left feeding the old parameters into the new interpretation, so it is converted with the rest. TRIM_EXTENT survives only in `fence_sample_xy`, where a finite polyline is genuinely what the fence test needs. Co-Authored-By: Claude Opus 5 --- src/modules/draw/modify/trim.rs | 182 +++++++++++++++----------------- 1 file changed, 88 insertions(+), 94 deletions(-) diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index 4b976296b..e8a763008 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -75,7 +75,6 @@ pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[ /// Virtual extent used to represent infinite ends of Ray / XLine. const TRIM_EXTENT: f64 = 1_000_000.0; /// If a trim interval endpoint is beyond this threshold it is treated as "infinite". -const INF_T: f64 = 0.9999; #[derive(Clone)] enum Geo { @@ -359,6 +358,23 @@ fn cut_params(target: &Curve, handle: Handle, geos: &[Geo]) -> Vec { ts } +/// A Ray entity as the kernel curve it is, so its parameter is the ray's own +/// rather than a fraction of some invented length. +fn ray_curve(r: &RayEnt) -> Curve { + Curve::Ray(KernelRay { + origin: [r.base_point.x, r.base_point.y], + direction: [r.direction.x, r.direction.y], + }) +} + +/// An XLine entity as the kernel curve it is. +fn xline_curve(x: &XLineEnt) -> Curve { + Curve::XLine(KernelXLine { + base: [x.base_point.x, x.base_point.y], + direction: [x.direction.x, x.direction.y], + }) +} + /// How close two points have to be to count as a crossing. /// /// Loose enough that a boundary drawn to meet an entity still registers when @@ -641,9 +657,24 @@ fn extend_spline(spl: &SplineEnt, t_click: f64, geos: &[Geo]) -> Option Vec<(f64, f64)> { - let mut bounds = vec![0.0f64]; + cut_intervals(ts, t_click, KernelExtent::Bounded) +} + +/// The stretches a target keeps after the one holding the click is removed. +/// +/// The outer bounds come from how far the target's parameter runs, so a ray +/// keeps an interval that reaches infinity rather than one that stops at an +/// invented far end. Those infinities are what tell the caller a surviving +/// piece is still unbounded. +fn cut_intervals(ts: &[f64], t_click: f64, extent: KernelExtent) -> Vec<(f64, f64)> { + let (first, last) = match extent { + KernelExtent::Bounded => (0.0, 1.0), + KernelExtent::Forward => (0.0, f64::INFINITY), + KernelExtent::Infinite => (f64::NEG_INFINITY, f64::INFINITY), + }; + let mut bounds = vec![first]; bounds.extend_from_slice(ts); - bounds.push(1.0); + bounds.push(last); bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6); let remove = bounds @@ -1314,8 +1345,10 @@ fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option } /// Trim a Ray entity. -/// Virtual t ∈ [0,1]: t=0 → base_point, t=1 → base + TRIM_EXTENT * dir. -/// Surviving pieces become Lines (finite) or Rays (still semi-infinite). +/// +/// The parameter is the ray's own: `t = 0` at the base and advancing by one +/// `direction` per unit, unbounded above. A surviving piece that reaches +/// infinity stays a Ray; one with two ends becomes a Line. fn trim_ray(orig: &RayEnt, ts: &[f64], t_click: f64) -> Vec { let bx = orig.base_point.x; let by = orig.base_point.y; @@ -1323,24 +1356,18 @@ fn trim_ray(orig: &RayEnt, ts: &[f64], t_click: f64) -> Vec { let dx = orig.direction.x; let dy = orig.direction.y; let dz = orig.direction.z; - let pt = |t: f64| { - [ - bx + t * dx * TRIM_EXTENT, - by + t * dy * TRIM_EXTENT, - bz + t * dz * TRIM_EXTENT, - ] - }; + let pt = |t: f64| [bx + t * dx, by + t * dy, bz + t * dz]; - trim_intervals(ts, t_click) + cut_intervals(ts, t_click, KernelExtent::Forward) .into_iter() .filter_map(|(ta, tb)| { let pa = pt(ta); let pb = pt(tb); - if (pb[0] - pa[0]).hypot(pb[1] - pa[1]) < 1e-6 { + if tb.is_finite() && (pb[0] - pa[0]).hypot(pb[1] - pa[1]) < 1e-6 { return None; } - if tb > INF_T { + if tb.is_infinite() { // Still extends to infinity → remains a Ray with new base let r = RayEnt::new(Vector3::new(pa[0], pa[1], pa[2]), Vector3::new(dx, dy, dz)); let mut r = r; @@ -1363,8 +1390,11 @@ fn trim_ray(orig: &RayEnt, ts: &[f64], t_click: f64) -> Vec { } /// Trim an XLine entity. -/// Virtual t ∈ [0,1]: t=0 → base - dir*TRIM_EXTENT, t=0.5 → base, t=1 → base + dir*TRIM_EXTENT. -/// Surviving pieces become Lines (finite), Rays (one infinite end), or the original XLine (both ends). +/// +/// The parameter is the line's own: `t = 0` at the base point, advancing by +/// one `direction` per unit and running both ways without limit. A surviving +/// piece keeps whichever ends reach infinity — both, and it is still an XLine; +/// one, and it is a Ray; neither, and it is a Line. fn trim_xline(orig: &XLineEnt, ts: &[f64], t_click: f64) -> Vec { let bx = orig.base_point.x; let by = orig.base_point.y; @@ -1372,23 +1402,15 @@ fn trim_xline(orig: &XLineEnt, ts: &[f64], t_click: f64) -> Vec { let dx = orig.direction.x; let dy = orig.direction.y; let dz = orig.direction.z; - // Point at virtual t: scale factor s = 2t - 1 ∈ [-1, +1] - let pt = |t: f64| { - let s = 2.0 * t - 1.0; - [ - bx + s * dx * TRIM_EXTENT, - by + s * dy * TRIM_EXTENT, - bz + s * dz * TRIM_EXTENT, - ] - }; + let pt = |t: f64| [bx + t * dx, by + t * dy, bz + t * dz]; - trim_intervals(ts, t_click) + cut_intervals(ts, t_click, KernelExtent::Infinite) .into_iter() .filter_map(|(ta, tb)| { let pa = pt(ta); let pb = pt(tb); - let ext_neg = ta < 1.0 - INF_T; // extends toward -infinity - let ext_pos = tb > INF_T; // extends toward +infinity + let ext_neg = ta.is_infinite(); + let ext_pos = tb.is_infinite(); match (ext_neg, ext_pos) { (true, true) => { @@ -1770,43 +1792,21 @@ fn pick_trim_at( Some(survivors) } Some(EntityType::Ray(r)) => { - // Virtual segment: base → base + dir * TRIM_EXTENT (t ∈ [0,1]) - let bx = r.base_point.x; - let by = r.base_point.y; - let ex = bx + r.direction.x * TRIM_EXTENT; - let ey = by + r.direction.y * TRIM_EXTENT; - let ts = line_seg_ts(bx, by, ex, ey, handle, geos); + let curve = ray_curve(r); + let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return None; } - let dx = r.direction.x * TRIM_EXTENT; - let dy = r.direction.y * TRIM_EXTENT; - let len2 = dx * dx + dy * dy; - let t_click = if len2 > 1e-12 { - ((px - bx) * dx + (py - by) * dy) / len2 - } else { - 0.5 - }; + let t_click = curve.parameter_at([px, py]); Some(trim_ray(r, &ts, t_click)) } Some(EntityType::XLine(x)) => { - // Virtual segment: base - dir*TRIM_EXTENT → base + dir*TRIM_EXTENT - let bx = x.base_point.x - x.direction.x * TRIM_EXTENT; - let by = x.base_point.y - x.direction.y * TRIM_EXTENT; - let ex = x.base_point.x + x.direction.x * TRIM_EXTENT; - let ey = x.base_point.y + x.direction.y * TRIM_EXTENT; - let ts = line_seg_ts(bx, by, ex, ey, handle, geos); + let curve = xline_curve(x); + let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return None; } - let dx = ex - bx; - let dy = ey - by; - let len2 = dx * dx + dy * dy; - let t_click = if len2 > 1e-12 { - ((px - bx) * dx + (py - by) * dy) / len2 - } else { - 0.5 - }; + let t_click = curve.parameter_at([px, py]); Some(trim_xline(x, &ts, t_click)) } Some(EntityType::Ellipse(e)) => { @@ -1929,13 +1929,21 @@ fn pick_extend_at( fn imply_edge_geos(geos: &mut [Geo]) { for g in geos.iter_mut() { match g { - Geo::Line { p1, p2, .. } => { + Geo::Line { handle, p1, p2 } => { let (dx, dy) = (p2[0] - p1[0], p2[1] - p1[1]); - let len = dx.hypot(dy); - if len > 1e-9 { - let (ux, uy) = (dx / len, dy / len); - *p1 = [p1[0] - ux * TRIM_EXTENT, p1[1] - uy * TRIM_EXTENT]; - *p2 = [p2[0] + ux * TRIM_EXTENT, p2[1] + uy * TRIM_EXTENT]; + if dx.hypot(dy) > 1e-9 { + // An implied edge cuts wherever the boundary's line would + // reach, which is what an infinite line is. Stretching the + // segment to a stand-in length instead both invents a + // limit and, at survey coordinates, is no larger than the + // coordinates themselves. + *g = Geo::InfLine { + handle: *handle, + bx: p1[0], + by: p1[1], + dx, + dy, + }; } } Geo::Arc { a0, a1, .. } => { @@ -2876,20 +2884,12 @@ impl CadCommand for TrimCommand { Some(EntityType::Ray(r)) => { let bx = r.base_point.x; let by = r.base_point.y; - let ex = bx + r.direction.x * TRIM_EXTENT; - let ey = by + r.direction.y * TRIM_EXTENT; - let ts = line_seg_ts(bx, by, ex, ey, handle, geos); + let curve = ray_curve(r); + let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return vec![]; } - let dx = r.direction.x * TRIM_EXTENT; - let dy = r.direction.y * TRIM_EXTENT; - let len2 = dx * dx + dy * dy; - let t_click = if len2 > 1e-12 { - ((pt.x as f64 - bx) * dx + (pt.y as f64 - by) * dy) / len2 - } else { - 0.5 - }; + let t_click = curve.parameter_at([pt.x as f64, pt.y as f64]); let survivors = trim_ray(r, &ts, t_click); // Show a finite preview section (20 units) for the original ray let far = [ @@ -2914,22 +2914,12 @@ impl CadCommand for TrimCommand { Some(EntityType::XLine(x)) => { let bx = x.base_point.x; let by = x.base_point.y; - let ex_start = bx - x.direction.x * TRIM_EXTENT; - let ey_start = by - x.direction.y * TRIM_EXTENT; - let ex_end = bx + x.direction.x * TRIM_EXTENT; - let ey_end = by + x.direction.y * TRIM_EXTENT; - let ts = line_seg_ts(ex_start, ey_start, ex_end, ey_end, handle, geos); + let curve = xline_curve(x); + let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return vec![]; } - let dx = ex_end - ex_start; - let dy = ey_end - ey_start; - let len2 = dx * dx + dy * dy; - let t_click = if len2 > 1e-12 { - ((pt.x as f64 - ex_start) * dx + (pt.y as f64 - ey_start) * dy) / len2 - } else { - 0.5 - }; + let t_click = curve.parameter_at([pt.x as f64, pt.y as f64]); let survivors = trim_xline(x, &ts, t_click); let neg = [ (bx - x.direction.x * 20.0) as f32, @@ -3727,14 +3717,18 @@ mod tests { /// drawing (EXTRIM treats a line boundary as infinite). fn extend_line_geos(geos: &mut [Geo]) { for g in geos.iter_mut() { - if let Geo::Line { p1, p2, .. } = g { + if let Geo::Line { handle, p1, p2 } = g { let (dx, dy) = (p2[0] - p1[0], p2[1] - p1[1]); - let len = dx.hypot(dy); - if len > 1e-9 { - let (ux, uy) = (dx / len, dy / len); - let mid = [(p1[0] + p2[0]) * 0.5, (p1[1] + p2[1]) * 0.5]; - *p1 = [mid[0] - ux * TRIM_EXTENT, mid[1] - uy * TRIM_EXTENT]; - *p2 = [mid[0] + ux * TRIM_EXTENT, mid[1] + uy * TRIM_EXTENT]; + if dx.hypot(dy) > 1e-9 { + // Same as `imply_edge_geos`: the boundary reaches as far as its + // line does, so say so rather than picking a length. + *g = Geo::InfLine { + handle: *handle, + bx: p1[0], + by: p1[1], + dx, + dy, + }; } } } From adb8ced8b5404cb6197215fb5addcfd97d7b65cd Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 20:37:53 +0300 Subject: [PATCH 03/18] feat(view): complete ZOOM options Add command-line navigation modes and persistent wheel controls.\n\nRefs #412 --- assets/ocad.pgp | 1 + src/app/command_driver.rs | 2 + src/app/commands/dim.rs | 83 +++++++++++++++--- src/app/commands/mod.rs | 3 + src/app/commands/styleprops.rs | 41 ++++++++- src/app/document.rs | 4 + src/app/mod.rs | 6 ++ src/app/settings.rs | 4 + src/app/update/command.rs | 6 ++ src/app/update/dialog.rs | 1 + src/app/update/file.rs | 4 + src/app/update/mod.rs | 3 + src/app/update/viewport.rs | 63 ++++++++++++- src/app/view/mod.rs | 2 +- src/command.rs | 5 +- src/scene/camera_ops.rs | 156 ++++++++++++++++++++++++++++++++- src/scene/mod.rs | 19 ++++ 17 files changed, 380 insertions(+), 23 deletions(-) diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 2a2dd8479..c2be1de94 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -120,6 +120,7 @@ QS, *QSELECT FI, *FILTER ; ── View / display ──────────────────────────────────────────────────────── +Z, *ZOOM P, *PAN U, *UNDO PR, *PROPERTIES diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index fcfaaf8ca..206ff8f09 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -100,6 +100,7 @@ impl OpenCADStudio { self.ucs_icon_hover = false; self.tabs[i].pan_mode = false; self.tabs[i].orbit_mode = false; + self.tabs[i].zoom_dynamic_mode = false; let _ = self.on_viewport_exit(); } @@ -1939,6 +1940,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].scene.remember_current_view(); self.tabs[i] .scene .zoom_to_window(p1.as_vec3(), p2.as_vec3()); diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index e8665b41f..1556d988d 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -805,40 +805,94 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } - "ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZEA" => { + "ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZOOM EA" | "ZEA" => { + self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.fit_all_model_viewports(); self.command_line.push_output(crate::t!("Zoom Extents — All Viewports").as_ref()); } - "ZOOM EXTENTS" | "ZOOMEXTENTS" | "ZE" => { + "ZOOM EXTENTS" | "ZOOM E" | "ZOOMEXTENTS" | "ZE" => { + self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.fit_all(); self.command_line.push_output(crate::t!("Zoom Extents").as_ref()); } - "ZOOM IN" | "ZI" => { + "ZOOM IN" | "ZOOM I" | "ZI" => { + self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.zoom_camera(1.0 / 1.5); self.command_line.push_output(crate::t!("Zoom In").as_ref()); } "ZOOM OUT" | "ZO" => { + self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.zoom_camera(1.5); self.command_line.push_output(crate::t!("Zoom Out").as_ref()); } // ZOOM ALL — fit the configured drawing limits. "ZOOM ALL" | "ZOOM A" | "ZA" => { + self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.fit_all_with_limits(); self.command_line.push_output(crate::t!("Zoom All").as_ref()); } + "ZOOM PREVIOUS" | "ZOOM P" | "ZP" => { + if self.tabs[i].scene.restore_previous_view() { + self.command_line.push_output(crate::t!("Zoom Previous").as_ref()); + } else { + self.command_line + .push_error(crate::t!("ZOOM: no previous view.").as_ref()); + } + } + + "ZOOM OBJECT" | "ZOOM O" | "ZOBJ" => { + let handles: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(handle, _)| handle) + .collect(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let command = SelectObjectsCommand::new("ZOOM OBJECT"); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else { + self.tabs[i].scene.remember_current_view(); + if self.tabs[i].scene.zoom_to_entities(&handles) { + self.command_line.push_output(crate::t!("Zoom Object").as_ref()); + } else { + self.command_line.push_error( + crate::t!("ZOOM: selected objects have no visible bounds.").as_ref(), + ); + } + } + } + + "ZOOM DYNAMIC" | "ZOOM D" | "ZD" => { + self.tabs[i].zoom_dynamic_mode = true; + self.clear_navigation_hover(i); + self.command_line.push_output( + crate::t!( + "ZOOM Dynamic: drag horizontally to pan and vertically to zoom. Press Esc to exit." + ) + .as_ref(), + ); + } + // ZOOM SCALE — set zoom factor (e.g. "ZOOM SCALE 2" or "ZS 0.5") - cmd if cmd.starts_with("ZOOM SCALE ") || cmd.starts_with("ZS ") => { + cmd if cmd.starts_with("ZOOM SCALE ") + || cmd.starts_with("ZOOM S ") + || cmd.starts_with("ZS ") => + { let rest = cmd - .split_once(' ') - .and_then(|(_, r)| r.split_once(' ').map(|(_, v)| v).or(Some(r))) + .strip_prefix("ZOOM SCALE ") + .or_else(|| cmd.strip_prefix("ZOOM S ")) + .or_else(|| cmd.strip_prefix("ZS ")) .unwrap_or("1"); if let Ok(factor) = rest.trim().parse::() { if factor > 0.0 { + self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.zoom_camera(1.0 / factor); self.command_line .push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref()); @@ -866,15 +920,18 @@ impl OpenCADStudio { use crate::command::KeywordCommand; let c = KeywordCommand::new( "ZOOM", - "ZOOM [Window / Extents / Extents All / All / In / Out / Scale]:", + "ZOOM [Window / Extents / Previous / Object / All / Dynamic / Extents All / In / Out / Scale]:", vec![ - ("Window", "WINDOW", None), - ("Extents", "EXTENTS", None), - ("Extents All", "EXTENTS ALL", None), - ("All", "ALL", None), - ("In", "IN", None), + ("Window", "W", None), + ("Extents", "E", None), + ("Previous", "P", None), + ("Object", "O", None), + ("All", "A", None), + ("Dynamic", "D", None), + ("Extents All", "EA", None), + ("In", "I", None), ("Out", "OUT", None), - ("Scale", "SCALE", Some("ZOOM scale factor (e.g. 2 or 0.5):")), + ("Scale", "S", Some("ZOOM scale factor (e.g. 2 or 0.5):")), ], ); self.command_line.push_info(&c.prompt()); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 094cea430..8d3e76b12 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -98,6 +98,7 @@ impl OpenCADStudio { // command arms below re-enable the selected one). self.tabs[i].pan_mode = false; self.tabs[i].orbit_mode = false; + self.tabs[i].zoom_dynamic_mode = false; // Reset the last committed point so the first click of the new command // is not constrained by ortho/polar relative to a previous command's endpoint. self.last_point = None; @@ -445,6 +446,8 @@ inventory::submit!(crate::command::CommandRegistration { "FITSPLINE", // System variables (typeable directly). "MIRRTEXT", + "ZOOMWHEEL", + "ZOOMFACTOR", "ATTREQ", "ATTDIA", "DIMASSOC", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 721c1e227..6bc5bfff6 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -866,6 +866,8 @@ impl OpenCADStudio { cmd if matches!( cmd.split_whitespace().next().unwrap_or(""), "MIRRTEXT" + | "ZOOMWHEEL" + | "ZOOMFACTOR" | "TEXTFILL" | "ATTREQ" | "ATTDIA" @@ -936,7 +938,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { // Parse a boolean given as 0/1 or ON/OFF. @@ -1028,6 +1030,37 @@ impl OpenCADStudio { .ok_or_else(|| "SETVAR: 0 or 1 required.".into()), None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)), }, + "ZOOMWHEEL" => match &value { + Some(v) => match parse_bool(v) { + Some(reversed) => { + self.zoom_wheel_reversed = reversed; + Ok(( + format!("ZOOMWHEEL = {}", reversed as i32), + true, + )) + } + None => Err("SETVAR: 0 or 1 required.".into()), + }, + None => Ok(( + format!( + "ZOOMWHEEL = {}", + self.zoom_wheel_reversed as i32 + ), + false, + )), + }, + "ZOOMFACTOR" => match &value { + Some(v) => match v.parse::() { + Ok(factor) if (3..=100).contains(&factor) => { + self.zoom_factor = factor; + Ok((format!("ZOOMFACTOR = {factor}"), true)) + } + _ => Err("SETVAR: integer from 3 to 100 required.".into()), + }, + None => { + Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false)) + } + }, // Global (not stored in the drawing): fill vs. hollow // TrueType text. The active tab re-tessellates below. "TEXTFILL" => match &value { @@ -1579,7 +1612,11 @@ impl OpenCADStudio { match outcome { Ok((msg, changed)) => { if changed { - self.tabs[i].dirty = true; + if matches!(name.as_str(), "ZOOMWHEEL" | "ZOOMFACTOR") { + self.persist_settings_if_changed(); + } else { + self.tabs[i].dirty = true; + } self.command_line.push_output(&msg); } else { // Queried with no value (`changed == false`): diff --git a/src/app/document.rs b/src/app/document.rs index abc2ea656..702d20296 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -212,6 +212,9 @@ pub(super) struct DocumentTab { /// Interactive 3-D orbit mode. While active, a left-button drag follows /// the same camera-orbit path as Shift + middle-button drag. pub(super) orbit_mode: bool, + /// Interactive ZOOM Dynamic mode. A left drag pans horizontally and zooms + /// vertically until Escape or another command ends the mode. + pub(super) zoom_dynamic_mode: bool, /// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(super) plugin_state: HashMap<&'static str, Box>, @@ -476,6 +479,7 @@ impl DocumentTab { is_start: false, pan_mode: false, orbit_mode: false, + zoom_dynamic_mode: false, plugin_state: HashMap::new(), suspended_cmd: None, } diff --git a/src/app/mod.rs b/src/app/mod.rs index f7ae64bf6..b9d3f17a6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -444,6 +444,10 @@ pub(super) struct OpenCADStudio { polar_mode: bool, /// Polar tracking angle increment in degrees (15 / 30 / 45 / 90). polar_increment_deg: f32, + /// Reverse the mouse-wheel zoom direction when true (ZOOMWHEEL = 1). + zoom_wheel_reversed: bool, + /// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR). + zoom_factor: i32, /// Show grid lines in the viewport (F7). show_grid: bool, /// Dynamic input overlay (F12): show coordinate tooltip near cursor. @@ -3030,6 +3034,8 @@ impl OpenCADStudio { ortho_mode: false, polar_mode: false, polar_increment_deg: 45.0, + zoom_wheel_reversed: false, + zoom_factor: 60, show_grid: false, dyn_input: true, texteditmode: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index 13906d3d4..727eaae1d 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -97,6 +97,8 @@ pub struct UserSettings { pub dyn_input: bool, pub polar: bool, pub polar_increment_deg: f32, + pub zoom_wheel_reversed: bool, + pub zoom_factor: i32, pub otrack: bool, // Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing — // stored in the document header, not here (they used to be persisted app- @@ -166,6 +168,8 @@ impl Default for UserSettings { dyn_input: true, polar: false, polar_increment_deg: 45.0, + zoom_wheel_reversed: false, + zoom_factor: 60, otrack: false, default_assoc_prompted: false, disabled_plugins: Vec::new(), diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 390d48a06..f854b612f 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -660,16 +660,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // drag. Orbit exits silently; PAN keeps its existing message. if self.tabs[self.active_tab].pan_mode || self.tabs[self.active_tab].orbit_mode + || self.tabs[self.active_tab].zoom_dynamic_mode { let i = self.active_tab; let was_pan = self.tabs[i].pan_mode; self.tabs[i].pan_mode = false; self.tabs[i].orbit_mode = false; + self.tabs[i].zoom_dynamic_mode = false; { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = false; sel.middle_last_pos = None; sel.orbit_pivot = None; + sel.box_anchor = None; + sel.box_anchor_world = None; + sel.box_current = None; + sel.box_crossing_locked = false; } if was_pan { self.command_line.push_output(crate::t!("PAN ended.").as_ref()); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 4e82f7f58..a3119277d 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -143,6 +143,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven && self.active_modal.is_none() && !self.tabs[i].pan_mode && !self.tabs[i].orbit_mode + && !self.tabs[i].zoom_dynamic_mode { self.ribbon.deactivate_tool(); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 30eb93c0f..5d7ed8591 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -265,6 +265,8 @@ impl OpenCADStudio { dyn_input: self.dyn_input, polar: self.polar_mode, polar_increment_deg: self.polar_increment_deg, + zoom_wheel_reversed: self.zoom_wheel_reversed, + zoom_factor: self.zoom_factor, otrack: self.snapper.otrack_enabled, default_assoc_prompted: self.default_assoc_prompted, disabled_plugins: { @@ -299,6 +301,8 @@ impl OpenCADStudio { self.dyn_input = s.dyn_input; self.polar_mode = s.polar; self.polar_increment_deg = s.polar_increment_deg; + self.zoom_wheel_reversed = s.zoom_wheel_reversed; + self.zoom_factor = s.zoom_factor.clamp(3, 100); // Ortho + running OSNAP are per-drawing (adopted from the header on // open / tab switch), not app-global, so they are not applied here. self.snapper.otrack_enabled = s.otrack; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 969866370..9fed4ee80 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2831,6 +2831,7 @@ impl OpenCADStudio { Message::ViewCubeHome => { let i = self.active_tab; self.clear_navigation_hover(i); + self.tabs[i].scene.remember_current_view(); let r_ucs = self.tabs[i].scene.viewcube_ucs_mat(); if self.tabs[i].scene.active_viewport.is_some() { self.tabs[i] @@ -2847,6 +2848,7 @@ impl OpenCADStudio { Message::ViewCubeRoll(cw) => { let i = self.active_tab; self.clear_navigation_hover(i); + self.tabs[i].scene.remember_current_view(); let ang = if cw { std::f32::consts::FRAC_PI_2 } else { @@ -2873,6 +2875,7 @@ impl OpenCADStudio { }; let i = self.active_tab; self.clear_navigation_hover(i); + self.tabs[i].scene.remember_current_view(); if self.tabs[i].scene.active_viewport.is_some() { self.tabs[i] .scene diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index a603859c7..14bd53b7c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -739,6 +739,39 @@ impl OpenCADStudio { if mid_down { if let Some(last) = mid_last { let (dx, dy) = (p.x - last.x, p.y - last.y); + if self.tabs[i].zoom_dynamic_mode { + let bounds = self.tabs[i] + .scene + .active_model_tile_bounds(vp_size.0, vp_size.1); + drop(sel); + let zoom_delta = -dy * 0.03; + if self.tabs[i].scene.active_viewport.is_some() { + self.tabs[i].scene.pan_active_viewport(dx, 0.0, bounds); + self.tabs[i] + .scene + .zoom_active_viewport(zoom_delta, None); + } else { + let local = iced::Point { + x: p.x - bounds.x, + y: p.y - bounds.y, + }; + let local_bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: bounds.width, + height: bounds.height, + }; + let mut camera = self.tabs[i].scene.camera.borrow_mut(); + camera.pan_screen(dx, 0.0, bounds.height); + camera.zoom_about_point(local, local_bounds, zoom_delta); + } + self.tabs[i].scene.camera_generation += 1; + self.tabs[i] + .scene + .record_nav_perf(crate::scene::NavPerfOp::Zoom, move_started); + self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); + return Task::none(); + } // Shift+MMB drag orbits the model view instead of panning // — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB // scheme (#229). Floating viewports and paper keep the @@ -2288,11 +2321,21 @@ impl OpenCADStudio { // Interactive navigation tools reuse the middle-button movement path, // so no selection/pick logic runs while the left button drives them. - if self.tabs[i].orbit_mode || self.tabs[i].pan_mode { + if self.tabs[i].orbit_mode + || self.tabs[i].pan_mode + || self.tabs[i].zoom_dynamic_mode + { self.clear_navigation_hover(i); + self.tabs[i].scene.remember_current_view(); let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = true; sel.middle_last_pos = Some(p); + if self.tabs[i].zoom_dynamic_mode { + sel.box_anchor = Some(p); + sel.box_current = Some(p); + sel.box_crossing = false; + sel.box_crossing_locked = true; + } return Task::none(); } @@ -2460,11 +2503,18 @@ impl OpenCADStudio { // Navigation mode: end this drag but keep the tool armed for the next // left drag (exit is Esc / another command). Mirror of the press. - if self.tabs[i].orbit_mode || self.tabs[i].pan_mode { + if self.tabs[i].orbit_mode + || self.tabs[i].pan_mode + || self.tabs[i].zoom_dynamic_mode + { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = false; sel.middle_last_pos = None; sel.orbit_pivot = None; + sel.box_anchor = None; + sel.box_anchor_world = None; + sel.box_current = None; + sel.box_crossing_locked = false; drop(sel); self.arm_hover_after_navigation(i); return Task::none(); @@ -4007,6 +4057,7 @@ impl OpenCADStudio { let i = self.active_tab; self.clear_navigation_hover(i); self.ribbon.close_dropdown(); + self.tabs[i].scene.remember_current_view(); let now = Instant::now(); let is_double = { let sel = self.tabs[i].scene.selection.borrow(); @@ -4035,11 +4086,16 @@ impl OpenCADStudio { pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task { let nav_started = Instant::now(); - let s = match delta { + let mut s = match delta { mouse::ScrollDelta::Lines { y, .. } => y, mouse::ScrollDelta::Pixels { y, .. } => y * 0.01, }; + s *= self.zoom_factor as f32 / 60.0; + if self.zoom_wheel_reversed { + s = -s; + } let i = self.active_tab; + self.tabs[i].scene.remember_current_view(); self.clear_navigation_hover(i); let cursor = self.tabs[i].scene.selection.borrow().last_move_pos; let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; @@ -4204,6 +4260,7 @@ impl OpenCADStudio { fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task { let i = self.active_tab; self.clear_navigation_hover(i); + self.tabs[i].scene.remember_current_view(); let mut region = region; // "Already there → flip to opposite" check: compare the // current gaze direction with the region's target gaze. diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 363cfb635..c0ec9d441 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -680,7 +680,7 @@ impl OpenCADStudio { dividers, pane_move_rect, pane_drop_rect, - tab.pan_mode || tab.orbit_mode, + tab.pan_mode || tab.orbit_mode || tab.zoom_dynamic_mode, self.ribbon.open_dropdown.is_some(), hover_locked, crosshair_background(tab, is_paper), diff --git a/src/command.rs b/src/command.rs index 0c9233f4e..dca1fc0b3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -566,8 +566,9 @@ impl CadCommand for KeywordCommand { // `None` would hand the same text to the command a second time. None => { let up = t.to_uppercase(); - let Some((_, keyword, value_prompt)) = - self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up)) + let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| { + k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) + }) else { // Unknown verb — keep prompting rather than dispatch garbage. return Some(CmdResult::NeedPoint); diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 50c7a344d..dce2bbac6 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -3,7 +3,10 @@ // hit-testing lives in `scene::pick::hit_test`.) use super::*; -fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option { +fn rendered_wire_bounds( + wires: &[WireModel], + fallback_z: f64, +) -> Option<(glam::DVec3, glam::DVec3)> { let mut min = glam::DVec3::splat(f64::INFINITY); let mut max = glam::DVec3::splat(f64::NEG_INFINITY); let mut include = |point: glam::DVec3| { @@ -55,12 +58,16 @@ fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option Option { + rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5) +} + fn block_entity_transform( document: &CadDocument, block_name: &str, @@ -189,6 +196,105 @@ impl Scene { self.camera_generation += 1; } + /// Save the active view immediately before a navigation operation. The + /// paper-space camera and an entered floating viewport use different state, + /// so the snapshot records whichever one currently owns navigation. + pub fn remember_current_view(&mut self) { + let layout = self.current_layout.clone(); + if let Some(handle) = self.active_viewport { + if let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) { + self.previous_view = Some(ViewSnapshot::Floating { + layout, + handle, + viewport: Box::new(viewport.clone()), + }); + return; + } + } + self.previous_view = Some(ViewSnapshot::Main { + layout, + tile: self.active_model_tile.get(), + camera: self.camera.borrow().clone(), + }); + } + + /// Swap the active view with the most recently remembered one. Keeping the + /// displaced current view makes repeated ZOOM Previous calls toggle cleanly + /// between the last two views. + pub fn restore_previous_view(&mut self) -> bool { + let Some(snapshot) = self.previous_view.take() else { + return false; + }; + match snapshot { + ViewSnapshot::Main { + layout, + tile, + camera, + } if self.active_viewport.is_none() + && self.current_layout == layout + && (layout != "Model" || self.active_model_tile.get() == tile) => + { + let current = ViewSnapshot::Main { + layout, + tile, + camera: self.camera.borrow().clone(), + }; + *self.camera.borrow_mut() = camera; + self.previous_view = Some(current); + self.camera_generation += 1; + true + } + ViewSnapshot::Floating { + layout, + handle, + viewport, + } if self.current_layout == layout && self.active_viewport == Some(handle) => { + let Some(EntityType::Viewport(current)) = self.document.get_entity(handle) else { + self.previous_view = Some(ViewSnapshot::Floating { + layout, + handle, + viewport, + }); + return false; + }; + if current.status.locked { + self.previous_view = Some(ViewSnapshot::Floating { + layout, + handle, + viewport, + }); + return false; + } + let current = Box::new(current.clone()); + if let Some(EntityType::Viewport(destination)) = + self.document.get_entity_mut(handle) + { + destination.view_center = viewport.view_center; + destination.view_direction = viewport.view_direction; + destination.view_target = viewport.view_target; + destination.lens_length = viewport.lens_length; + destination.front_clip_z = viewport.front_clip_z; + destination.back_clip_z = viewport.back_clip_z; + destination.view_height = viewport.view_height; + destination.twist_angle = viewport.twist_angle; + destination.custom_scale = viewport.custom_scale; + destination.status.perspective = viewport.status.perspective; + } + self.previous_view = Some(ViewSnapshot::Floating { + layout, + handle, + viewport: current, + }); + self.camera_generation += 1; + true + } + snapshot => { + self.previous_view = Some(snapshot); + false + } + } + } + /// Refresh model bounds without moving the live camera. Reusing `fit_all` /// keeps projection framing on the same visibility and outlier filters as /// Zoom Extents instead of accepting every finite cached AABB. @@ -270,6 +376,52 @@ impl Scene { self.camera_generation += 1; } + /// Fit the active view to the rendered bounds of the supplied entities. + /// Returns false when the selection has no finite visible geometry. + pub fn zoom_to_entities(&mut self, handles: &[Handle]) -> bool { + let fallback_z = self.active_camera_target().z; + let wires = self.wire_models_for(handles); + let mut bounds = rendered_wire_bounds(&wires, fallback_z); + + for handle in handles { + let Some(mesh) = self.meshes.get(handle) else { + continue; + }; + let [x0, y0, x1, y1] = mesh.world_aabb; + let [z0, z1] = mesh.z_aabb; + let mesh_min = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64); + let mesh_max = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64); + if !mesh_min.is_finite() || !mesh_max.is_finite() { + continue; + } + bounds = Some(match bounds { + Some((min, max)) => (min.min(mesh_min), max.max(mesh_max)), + None => (mesh_min, mesh_max), + }); + } + + let Some((mut min, mut max)) = bounds else { + return false; + }; + if min == max { + min -= glam::DVec3::splat(1.0); + max += glam::DVec3::splat(1.0); + } + let min = min.as_vec3(); + let max = max.as_vec3(); + if !min.is_finite() || !max.is_finite() { + return false; + } + if self.active_viewport.is_some() { + return self.fit_active_viewport_to_bounds(min, max); + } + let aspect = self.active_camera_aspect(); + self.camera.borrow_mut().fit_to_bounds(min, max, aspect); + self.projection_bounds_epoch.set(self.geometry_epoch); + self.camera_generation += 1; + true + } + /// Centre the active camera on one rendered entity without changing zoom /// distance, orientation, or viewport scale. pub fn center_camera_on_entity(&mut self, handle: Handle) -> bool { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 52acf6d89..67cfa4154 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1123,6 +1123,20 @@ pub(crate) struct ModelTile { pub(crate) snap_on: bool, } +#[derive(Clone)] +enum ViewSnapshot { + Main { + layout: String, + tile: usize, + camera: Camera, + }, + Floating { + layout: String, + handle: Handle, + viewport: Box, + }, +} + /// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible /// divider width. The renderer derives tile rects through this same spacing so /// the drawn viewports line up exactly with the pane_grid layout. @@ -1488,6 +1502,10 @@ struct SceneLight { pub struct Scene { pub camera: Rc>, + /// View saved immediately before the latest navigation operation. ZOOM + /// Previous swaps with this snapshot, allowing the user to toggle between + /// the two most recent views without touching drawing history. + previous_view: Option, /// Model-space tiled viewport layout. One full-window tile by default; /// the split buttons / VPORTS subdivide the active tile. pub(crate) model_tiles: RefCell>, @@ -1932,6 +1950,7 @@ impl Scene { pub fn new() -> Self { Self { camera: Rc::new(RefCell::new(Camera::default())), + previous_view: None, model_tiles: RefCell::new(vec![ModelTile { rect: iced::Rectangle { x: 0.0, From 188ac80b2ef362d349a877145726b7c4eb14ca64 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 21:15:24 +0300 Subject: [PATCH 04/18] chore: take the stack without nalgebra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codec declared nalgebra and never used it — its Vector2 and Vector3 are plain structs of its own. It was pulling eleven crates into every build here for nothing. Also picks up cadkernel's Vec2, which replaces the vector arithmetic the kernel had been writing out by hand. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 93 ++------------------------------ Cargo.toml | 2 +- crates/ocs_plugin_api/Cargo.toml | 2 +- crates/ocs_web_worker/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e0e800513..ce4147119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,7 +85,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadifc" version = "0.5.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=0a61cf9#0a61cf98b6736e070e02be3bc7a1818ff7522095" +source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396#c65d396abc3defcf96b1d71a3f812c8ad993e77c" dependencies = [ "acadrust", "base64 0.22.1", @@ -99,7 +99,7 @@ dependencies = [ [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=f226110#f226110a5b272838f9eeca4e3330ace827817db4" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=d645c7f#d645c7f526c199c02fcab7272d6b6f2d1c6b91ae" dependencies = [ "ahash 0.8.12", "anyhow", @@ -110,7 +110,6 @@ dependencies = [ "indexmap", "itoa", "memmap2", - "nalgebra", "nom 7.1.3", "once_cell", "rayon", @@ -353,15 +352,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - [[package]] name = "arbitrary" version = "1.4.2" @@ -945,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#5294a45d19508c22726c01b91215d3ca3fd4c815" +source = "git+https://github.com/HakanSeven12/cadkernel.git#dee251f190a82f66d548e10d36f112815d9a901f" dependencies = [ "cavalier_contours", ] @@ -1031,7 +1021,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" dependencies = [ - "approx 0.4.0", + "approx", "num-traits", "serde", ] @@ -3215,16 +3205,6 @@ dependencies = [ "num-complex", ] -[[package]] -name = "matrixmultiply" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" -dependencies = [ - "autocfg", - "rawpointer", -] - [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3402,33 +3382,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "nalgebra" -version = "0.32.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" -dependencies = [ - "approx 0.5.1", - "matrixmultiply", - "nalgebra-macros", - "num-complex", - "num-rational", - "num-traits", - "simba", - "typenum", -] - -[[package]] -name = "nalgebra-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "ndk" version = "0.9.0" @@ -4720,12 +4673,6 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - [[package]] name = "rayon" version = "1.12.0" @@ -5173,15 +5120,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - [[package]] name = "same-file" version = "1.0.6" @@ -5367,19 +5305,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simba" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" -dependencies = [ - "approx 0.5.1", - "num-complex", - "num-traits", - "paste", - "wide", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -6837,16 +6762,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index f6372fba8..93dc999b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ env_logger = "0.11" # The CAD stack is reached through acadifc, which re-exports the codec and # the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…` # paths keep resolving. -acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", features = ["serde", "offset"] } +acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", features = ["serde", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index bcb564cfd..e20d48c29 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -9,7 +9,7 @@ license = "GPL-3.0-only" # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", optional = true, features = ["serde"] } +acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index a693f1ca3..03dda7e9f 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", features = ["serde"] } +acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", features = ["serde"] } bincode = "1.3" console_error_panic_hook = "0.1" getrandom = { version = "0.3", features = ["wasm_js"] } From 1738f9a79b568d27bebac22d5efdcae25f37bc56 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 21:32:44 +0300 Subject: [PATCH 05/18] feat(cursor): add crosshair options Add persistent cursor sizing, pick aperture, color, pointer mode, isometric drafting planes, and SNAPANG-aligned grid behavior.\n\nRefs #413 --- src/app/commands/display.rs | 37 ++++++++ src/app/commands/mod.rs | 6 ++ src/app/commands/styleprops.rs | 66 +++++++++++++- src/app/config.rs | 4 +- src/app/helpers.rs | 78 +++++++++++++--- src/app/mod.rs | 47 +++++++++- src/app/settings.rs | 77 ++++++++++++++++ src/app/shortcuts.rs | 1 + src/app/update/command.rs | 2 +- src/app/update/dialog.rs | 2 +- src/app/update/dynamic.rs | 2 +- src/app/update/file.rs | 27 +++++- src/app/update/mod.rs | 86 ++++++++++++++++- src/app/update/style.rs | 2 +- src/app/update/viewport.rs | 98 +++++++++++++++----- src/app/view/mod.rs | 32 ++++++- src/app/view/modal.rs | 26 ++++++ src/scene/pick/hit_test.rs | 37 ++++---- src/snap.rs | 36 +++++--- src/ui/overlay.rs | 110 +++++++++++++--------- src/ui/popup/snap_popup.rs | 53 ++++++++++- src/ui/statusbar/mod.rs | 8 +- src/ui/window/drafting_settings.rs | 135 +++++++++++++++++++++++++++ src/ui/window/mod.rs | 1 + src/ui/window/options.rs | 142 ++++++++++++++++++++++++++++- 25 files changed, 989 insertions(+), 126 deletions(-) create mode 100644 src/ui/window/drafting_settings.rs diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 9303281a4..f6f23ef93 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -224,6 +224,43 @@ impl OpenCADStudio { "SNAP" => { return Some(Task::done(Message::ToggleGridSnap)); } + // ISOPLANE — cycle the isometric drafting axis pair (F5). + "ISOPLANE" => { + return Some(Task::done(Message::CycleIsoPlane)); + } + cmd if cmd.starts_with("ISOPLANE ") => { + let plane = match cmd.trim_start_matches("ISOPLANE").trim() { + "LEFT" | "L" => Some(crate::app::settings::IsoPlane::Left), + "TOP" | "T" => Some(crate::app::settings::IsoPlane::Top), + "RIGHT" | "R" => Some(crate::app::settings::IsoPlane::Right), + _ => None, + }; + if let Some(plane) = plane { + return Some(Task::done(Message::SetIsoPlane(plane))); + } + self.command_line + .push_error(crate::t!("ISOPLANE: expected Left, Top, or Right.").as_ref()); + } + // ISODRAFT — enable or disable isometric drafting. + "ISODRAFT" => { + return Some(Task::done(Message::ToggleIsometricDrafting)); + } + cmd if cmd.starts_with("ISODRAFT ") => { + let requested = match cmd.trim_start_matches("ISODRAFT").trim() { + "1" | "ON" => Some(true), + "0" | "OFF" => Some(false), + _ => None, + }; + match requested { + Some(value) if value != self.isometric_drafting => { + return Some(Task::done(Message::ToggleIsometricDrafting)); + } + Some(_) => {} + None => self + .command_line + .push_error(crate::t!("ISODRAFT: expected On or Off.").as_ref()), + } + } // POLAR — toggle polar tracking. "POLAR" => { return Some(Task::done(Message::TogglePolar)); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 8d3e76b12..6843a5cda 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -301,6 +301,8 @@ inventory::submit!(crate::command::CommandRegistration { "CUI", "DSETTINGS", "GRID", + "ISODRAFT", + "ISOPLANE", "OSNAP", "POLAR", "QUICKPROPERTIES", @@ -448,6 +450,10 @@ inventory::submit!(crate::command::CommandRegistration { "MIRRTEXT", "ZOOMWHEEL", "ZOOMFACTOR", + "CURSORSIZE", + "PICKBOX", + "CURSORTYPE", + "SNAPANG", "ATTREQ", "ATTDIA", "DIMASSOC", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 6bc5bfff6..e18432c25 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -868,6 +868,10 @@ impl OpenCADStudio { "MIRRTEXT" | "ZOOMWHEEL" | "ZOOMFACTOR" + | "CURSORSIZE" + | "PICKBOX" + | "CURSORTYPE" + | "SNAPANG" | "TEXTFILL" | "ATTREQ" | "ATTDIA" @@ -938,7 +942,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { // Parse a boolean given as 0/1 or ON/OFF. @@ -1061,6 +1065,56 @@ impl OpenCADStudio { Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false)) } }, + "CURSORSIZE" => match &value { + Some(v) => match v.parse::() { + Ok(size) if (1..=100).contains(&size) => { + self.cursor_size = size; + Ok((format!("CURSORSIZE = {size}"), true)) + } + _ => Err("SETVAR: integer from 1 to 100 required.".into()), + }, + None => Ok((format!("CURSORSIZE = {}", self.cursor_size), false)), + }, + "PICKBOX" => match &value { + Some(v) => match v.parse::() { + Ok(size) if (0..=50).contains(&size) => { + self.pick_box = size; + Ok((format!("PICKBOX = {size}"), true)) + } + _ => Err("SETVAR: integer from 0 to 50 required.".into()), + }, + None => Ok((format!("PICKBOX = {}", self.pick_box), false)), + }, + "CURSORTYPE" => match &value { + Some(v) => match v.as_str() { + "0" => { + self.cursor_type = crate::app::settings::CursorType::Crosshair; + Ok(("CURSORTYPE = 0".to_string(), true)) + } + "1" => { + self.cursor_type = crate::app::settings::CursorType::Pointer; + Ok(("CURSORTYPE = 1".to_string(), true)) + } + _ => Err("SETVAR: 0 or 1 required.".into()), + }, + None => Ok(( + format!( + "CURSORTYPE = {}", + i32::from(self.cursor_type == crate::app::settings::CursorType::Pointer) + ), + false, + )), + }, + "SNAPANG" => match &value { + Some(v) => match v.parse::() { + Ok(angle) if angle.is_finite() => { + self.snap_angle_deg = angle.rem_euclid(360.0); + Ok((format!("SNAPANG = {}", self.snap_angle_deg), true)) + } + _ => Err("SETVAR: finite numeric value required.".into()), + }, + None => Ok((format!("SNAPANG = {}", self.snap_angle_deg), false)), + }, // Global (not stored in the drawing): fill vs. hollow // TrueType text. The active tab re-tessellates below. "TEXTFILL" => match &value { @@ -1612,7 +1666,15 @@ impl OpenCADStudio { match outcome { Ok((msg, changed)) => { if changed { - if matches!(name.as_str(), "ZOOMWHEEL" | "ZOOMFACTOR") { + if matches!( + name.as_str(), + "ZOOMWHEEL" + | "ZOOMFACTOR" + | "CURSORSIZE" + | "PICKBOX" + | "CURSORTYPE" + | "SNAPANG" + ) { self.persist_settings_if_changed(); } else { self.tabs[i].dirty = true; diff --git a/src/app/config.rs b/src/app/config.rs index 7e5791800..2e4d5e796 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -209,11 +209,11 @@ fn rgb_to_color(rgb: [u8; 3]) -> iced::Color { iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]) } -fn rgb_to_hex(rgb: [u8; 3]) -> String { +pub(crate) fn rgb_to_hex(rgb: [u8; 3]) -> String { format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) } -fn parse_hex(value: &str) -> Option<[u8; 3]> { +pub(crate) fn parse_hex(value: &str) -> Option<[u8; 3]> { let value = value.trim().strip_prefix('#').unwrap_or(value.trim()); if value.len() != 6 { return None; diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 0c010b5f9..821d25262 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -260,19 +260,56 @@ pub(super) fn ucs_rotated_z(origin: glam::DVec3, angle_z: f32) -> Ucs { // ── Drawing constraint helpers ───────────────────────────────────────────── -/// Constrain `pt` to the nearest 90° direction from `base`, in the active UCS -/// plane — ortho follows the user's coordinate system, not world axes. `xf` is -/// identity for plain WCS, so the world-XY behaviour is unchanged there. -pub(super) fn ortho_constrain(pt: glam::DVec3, base: glam::DVec3, xf: &UcsXform) -> glam::DVec3 { +/// The two live drafting directions in degrees inside the active UCS plane. +pub(super) fn drafting_angles( + isometric: bool, + iso_plane: super::settings::IsoPlane, + snap_angle_deg: f32, +) -> [f64; 2] { + let base = if isometric { + iso_plane.angles() + } else { + [0.0, 90.0] + }; + base.map(|angle| angle + snap_angle_deg as f64) +} + +/// Convert the live drafting directions into world-space axes. +pub(super) fn drafting_axes( + x: glam::DVec3, + y: glam::DVec3, + z: glam::DVec3, + isometric: bool, + iso_plane: super::settings::IsoPlane, + snap_angle_deg: f32, +) -> (glam::DVec3, glam::DVec3, glam::DVec3) { + let [a, b] = drafting_angles(isometric, iso_plane, snap_angle_deg); + let direction = |degrees: f64| { + let radians = degrees.to_radians(); + (x * radians.cos() + y * radians.sin()).normalize_or(x) + }; + (direction(a), direction(b), z.normalize_or(x.cross(y))) +} + +/// Constrain `pt` to the nearest live drafting direction from `base`. +pub(super) fn drafting_constrain( + pt: glam::DVec3, + base: glam::DVec3, + xf: &UcsXform, + isometric: bool, + iso_plane: super::settings::IsoPlane, + snap_angle_deg: f32, +) -> glam::DVec3 { let p = xf.to_ucs(pt); let b = xf.to_ucs(base); - let dx = (p.x - b.x).abs(); - let dy = (p.y - b.y).abs(); - let c = if dx >= dy { - glam::DVec3::new(p.x, b.y, p.z) - } else { - glam::DVec3::new(b.x, p.y, p.z) - }; + let delta = glam::DVec2::new(p.x - b.x, p.y - b.y); + let [a, c] = drafting_angles(isometric, iso_plane, snap_angle_deg).map(|degrees| { + let radians = degrees.to_radians(); + glam::DVec2::new(radians.cos(), radians.sin()) + }); + let direction = if delta.dot(a).abs() >= delta.dot(c).abs() { a } else { c }; + let projected = direction * delta.dot(direction); + let c = glam::DVec3::new(b.x + projected.x, b.y + projected.y, p.z); xf.to_wcs(c) } @@ -341,6 +378,9 @@ pub(super) fn axis_lock_capture( polar: bool, step_deg: f32, xf: &UcsXform, + isometric: bool, + iso_plane: super::settings::IsoPlane, + snap_angle_deg: f32, ) -> Option { let p = xf.to_ucs(cursor); let b = xf.to_ucs(base); @@ -349,8 +389,20 @@ pub(super) fn axis_lock_capture( if dx.hypot(dy) < 1e-9 { return None; } - let step = (if polar { step_deg as f64 } else { 90.0 }).to_radians(); - let ang = (dy.atan2(dx) / step).round() * step; + let ang = if polar { + let base = (snap_angle_deg as f64).to_radians(); + let step = (step_deg as f64).to_radians(); + ((dy.atan2(dx) - base) / step).round() * step + base + } else { + let delta = glam::DVec2::new(dx, dy); + let [a, b] = drafting_angles(isometric, iso_plane, snap_angle_deg).map(|degrees| { + let radians = degrees.to_radians(); + glam::DVec2::new(radians.cos(), radians.sin()) + }); + let direction = if delta.dot(a).abs() >= delta.dot(b).abs() { a } else { b }; + let direction = if delta.dot(direction) < 0.0 { -direction } else { direction }; + direction.y.atan2(direction.x) + }; let dir_ucs = glam::DVec3::new(ang.cos(), ang.sin(), 0.0); let dir = xf.to_wcs(b + dir_ucs) - xf.to_wcs(b); (dir.length_squared() > 1e-12).then(|| dir.normalize()) diff --git a/src/app/mod.rs b/src/app/mod.rs index b9d3f17a6..bec19b348 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,7 +18,7 @@ mod mtext_editor; pub mod plugin_host; mod properties; mod recent; -mod settings; +pub(crate) mod settings; mod shortcuts; mod style_ops; mod text_inline; @@ -448,10 +448,27 @@ pub(super) struct OpenCADStudio { zoom_wheel_reversed: bool, /// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR). zoom_factor: i32, + /// Crosshair reach as a viewport percentage (CURSORSIZE, 1..=100). + cursor_size: i32, + /// Selection-box half-size and click aperture in pixels (PICKBOX, 0..=50). + pick_box: i32, + /// Drawing viewport cursor style (CURSORTYPE). + cursor_type: settings::CursorType, + /// Explicit crosshair colour; `None` retains automatic contrast. + crosshair_color: Option<[u8; 3]>, + /// Editable Options buffer for the crosshair colour. + crosshair_color_input: String, + /// Isometric drafting state and active axis pair. + isometric_drafting: bool, + iso_plane: settings::IsoPlane, + /// Drafting-grid/crosshair rotation in degrees (SNAPANG). + snap_angle_deg: f32, /// Show grid lines in the viewport (F7). show_grid: bool, /// Dynamic input overlay (F12): show coordinate tooltip near cursor. dyn_input: bool, + /// Currently visible page in the application Options dialog. + options_tab: crate::ui::window::options::OptionsTab, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, /// When true (default), saving over an existing file first writes a `.bak` @@ -1502,6 +1519,7 @@ pub enum ModalKind { LayerStateManager, LayerTranslator, DrawingUnits, + DraftingSettings, LayerStateEditor, Plot, PrintAll, @@ -1768,6 +1786,16 @@ pub enum Message { SaveDialogPathPicked(Option), /// Open the application-wide Options dialog. OptionsOpen, + /// Switch the visible page in Options. + OptionsTabChanged(crate::ui::window::options::OptionsTab), + /// Set CURSORSIZE from the Display-page slider. + CursorSizeChanged(i32), + /// Set PICKBOX from the Selection-page slider. + PickBoxChanged(i32), + /// Set CURSORTYPE from Options. + CursorTypeChanged(settings::CursorType), + /// Edit the optional crosshair RGB value; blank restores automatic contrast. + CrosshairColorChanged(String), /// Set the default type/version used when first saving a new drawing. DefaultSaveFormatChanged(String), /// Select one of Iced's built-in themes or the editable Custom theme. @@ -2094,6 +2122,14 @@ pub enum Message { ToggleSnapEnabled, /// Toggle grid-snap on/off — F9 / SNAP status-bar button. ToggleGridSnap, + /// Enable or disable isometric drafting. + ToggleIsometricDrafting, + /// Select one isometric drafting axis pair. + SetIsoPlane(settings::IsoPlane), + /// Advance Left → Top → Right, enabling isometric drafting if necessary. + CycleIsoPlane, + /// Reset SNAPANG and return the active drafting coordinate system to World. + ResetDraftingRotation, /// Toggle the ViewCube 3D gizmo visibility (NAVVCUBE). ToggleViewCube, /// Toggle the Properties panel visibility (PROPERTIES). @@ -3036,8 +3072,17 @@ impl OpenCADStudio { polar_increment_deg: 45.0, zoom_wheel_reversed: false, zoom_factor: 60, + cursor_size: 5, + pick_box: 3, + cursor_type: settings::CursorType::Crosshair, + crosshair_color: None, + crosshair_color_input: String::new(), + isometric_drafting: false, + iso_plane: settings::IsoPlane::Left, + snap_angle_deg: 0.0, show_grid: false, dyn_input: true, + options_tab: crate::ui::window::options::OptionsTab::General, texteditmode: false, backup_on_save: true, file_assoc_enabled: true, diff --git a/src/app/settings.rs b/src/app/settings.rs index 727eaae1d..25cf6f8bb 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -11,6 +11,63 @@ use crate::snap::SnapType; use serde::{Deserialize, Serialize}; +/// Cursor shown over the drawing viewport. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CursorType { + #[default] + Crosshair, + Pointer, +} + +impl CursorType { + pub const ALL: [Self; 2] = [Self::Crosshair, Self::Pointer]; + + pub fn label(self) -> &'static str { + match self { + Self::Crosshair => "Crosshair", + Self::Pointer => "Desktop pointer", + } + } +} + +/// Active pair of axes while isometric drafting is enabled. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum IsoPlane { + #[default] + Left, + Top, + Right, +} + +impl IsoPlane { + pub const ALL: [Self; 3] = [Self::Left, Self::Top, Self::Right]; + + pub fn next(self) -> Self { + match self { + Self::Left => Self::Top, + Self::Top => Self::Right, + Self::Right => Self::Left, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Left => "Left", + Self::Top => "Top", + Self::Right => "Right", + } + } + + /// The two drafting directions, in degrees in the active UCS plane. + pub fn angles(self) -> [f64; 2] { + match self { + Self::Left => [90.0, 150.0], + Self::Top => [30.0, 150.0], + Self::Right => [30.0, 90.0], + } + } +} + /// Canonical order of the user-toggleable object-snap modes. Drives the /// deterministic order when decoding the `$OSMODE` bitmask (see /// [`snaps_from_osmode`]). @@ -99,6 +156,19 @@ pub struct UserSettings { pub polar_increment_deg: f32, pub zoom_wheel_reversed: bool, pub zoom_factor: i32, + /// CURSORSIZE: crosshair arm reach as a percentage of the viewport. + pub cursor_size: i32, + /// PICKBOX: visible selection-box half-size and click aperture in pixels. + pub pick_box: i32, + /// CURSORTYPE: crosshair or the platform pointer over the drawing. + pub cursor_type: CursorType, + /// Explicit crosshair RGB. `None` keeps automatic background contrast. + pub crosshair_color: Option<[u8; 3]>, + /// Isometric drafting changes the grid and crosshair to the active axis pair. + pub isometric_drafting: bool, + pub iso_plane: IsoPlane, + /// SNAPANG in degrees, applied in the active UCS plane. + pub snap_angle_deg: f32, pub otrack: bool, // Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing — // stored in the document header, not here (they used to be persisted app- @@ -170,6 +240,13 @@ impl Default for UserSettings { polar_increment_deg: 45.0, zoom_wheel_reversed: false, zoom_factor: 60, + cursor_size: 5, + pick_box: 3, + cursor_type: CursorType::Crosshair, + crosshair_color: None, + isometric_drafting: false, + iso_plane: IsoPlane::Left, + snap_angle_deg: 0.0, otrack: false, default_assoc_prompted: false, disabled_plugins: Vec::new(), diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index a5c9e8861..6c888fa40 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -17,6 +17,7 @@ pub(super) fn default_bindings() -> BTreeMap { ("F1".to_string(), "HELP"), ("F2".to_string(), "COMMANDHISTORY"), ("F3".to_string(), "TOGGLEOSNAP"), + ("F5".to_string(), "ISOPLANE"), ("F7".to_string(), "GRID"), ("F8".to_string(), "ORTHO"), ("F9".to_string(), "SNAP"), diff --git a/src/app/update/command.rs b/src/app/update/command.rs index f854b612f..f735c6d82 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index a3119277d..c72f0f7d8 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -5,7 +5,7 @@ use super::util::*; use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 1d88daabd..72a44921b 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 5d7ed8591..fa58d2fcf 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; @@ -267,6 +267,13 @@ impl OpenCADStudio { polar_increment_deg: self.polar_increment_deg, zoom_wheel_reversed: self.zoom_wheel_reversed, zoom_factor: self.zoom_factor, + cursor_size: self.cursor_size, + pick_box: self.pick_box, + cursor_type: self.cursor_type, + crosshair_color: self.crosshair_color, + isometric_drafting: self.isometric_drafting, + iso_plane: self.iso_plane, + snap_angle_deg: self.snap_angle_deg, otrack: self.snapper.otrack_enabled, default_assoc_prompted: self.default_assoc_prompted, disabled_plugins: { @@ -303,6 +310,21 @@ impl OpenCADStudio { self.polar_increment_deg = s.polar_increment_deg; self.zoom_wheel_reversed = s.zoom_wheel_reversed; self.zoom_factor = s.zoom_factor.clamp(3, 100); + self.cursor_size = s.cursor_size.clamp(1, 100); + self.pick_box = s.pick_box.clamp(0, 50); + self.cursor_type = s.cursor_type; + self.crosshair_color = s.crosshair_color; + self.crosshair_color_input = s + .crosshair_color + .map(crate::app::config::rgb_to_hex) + .unwrap_or_default(); + self.isometric_drafting = s.isometric_drafting; + self.iso_plane = s.iso_plane; + self.snap_angle_deg = if s.snap_angle_deg.is_finite() { + s.snap_angle_deg.rem_euclid(360.0) + } else { + 0.0 + }; // Ortho + running OSNAP are per-drawing (adopted from the header on // open / tab switch), not app-global, so they are not applied here. self.snapper.otrack_enabled = s.otrack; @@ -588,6 +610,9 @@ impl OpenCADStudio { self.ribbon.set_collapse_mode(cfg.ribbon.collapse); self.plot_dialog = cfg.plot; self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect(); + self.shortcut_bindings + .entry("F5".to_string()) + .or_insert_with(|| "ISOPLANE".to_string()); } /// Write the config only when it changed since the last write, so a toggle diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9fed4ee80..62e382f2e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -149,6 +149,9 @@ impl OpenCADStudio { if self.active_modal == Some(ScaleManager) { self.scale_stage_discard(); } + if self.active_modal == Some(DraftingSettings) { + self.snap_popup_open = false; + } #[cfg(not(target_arch = "wasm32"))] if self.active_modal == Some(FileInUse) { self.pending_save_failure = None; @@ -3014,6 +3017,43 @@ impl OpenCADStudio { self.sync_vport_display(self.active_tab); Task::none() } + Message::ToggleIsometricDrafting => { + self.isometric_drafting = !self.isometric_drafting; + self.persist_settings_if_changed(); + Task::none() + } + Message::SetIsoPlane(plane) => { + self.isometric_drafting = true; + self.iso_plane = plane; + self.persist_settings_if_changed(); + Task::none() + } + Message::CycleIsoPlane => { + if self.isometric_drafting { + self.iso_plane = self.iso_plane.next(); + } else { + self.isometric_drafting = true; + } + self.command_line.push_output(crate::tf!( + "Isometric plane: {}.", + self.iso_plane.label() + ).as_ref()); + self.persist_settings_if_changed(); + Task::none() + } + Message::ResetDraftingRotation => { + self.snap_angle_deg = 0.0; + let i = self.active_tab; + if self.tabs[i].active_ucs.is_some() { + self.tabs[i].active_ucs = None; + self.commit_active_ucs_change(i, "UCS"); + self.tabs[i].scene.camera_generation += 1; + } + self.command_line + .push_output(crate::t!("Drafting rotation reset to World at 0°.").as_ref()); + self.persist_settings_if_changed(); + Task::none() + } Message::ToggleGrid => { self.show_grid ^= true; self.sync_vport_display(self.active_tab); @@ -3654,11 +3694,20 @@ impl OpenCADStudio { Task::none() } Message::ToggleSnapPopup => { - self.snap_popup_open ^= true; + if self.active_modal == Some(super::ModalKind::DraftingSettings) { + self.close_active_modal(); + self.snap_popup_open = false; + } else { + self.active_modal = Some(super::ModalKind::DraftingSettings); + self.snap_popup_open = true; + } Task::none() } Message::CloseSnapPopup => { self.snap_popup_open = false; + if self.active_modal == Some(super::ModalKind::DraftingSettings) { + self.close_active_modal(); + } Task::none() } Message::SnapSelectAll => { @@ -5149,6 +5198,41 @@ impl OpenCADStudio { Task::none() } + Message::OptionsTabChanged(tab) => { + self.options_tab = tab; + Task::none() + } + + Message::CursorSizeChanged(value) => { + self.cursor_size = value.clamp(1, 100); + self.persist_settings_if_changed(); + Task::none() + } + + Message::PickBoxChanged(value) => { + self.pick_box = value.clamp(0, 50); + self.persist_settings_if_changed(); + Task::none() + } + + Message::CursorTypeChanged(value) => { + self.cursor_type = value; + self.persist_settings_if_changed(); + Task::none() + } + + Message::CrosshairColorChanged(value) => { + self.crosshair_color_input = value.clone(); + if value.trim().is_empty() { + self.crosshair_color = None; + self.persist_settings_if_changed(); + } else if let Some(rgb) = crate::app::config::parse_hex(&value) { + self.crosshair_color = Some(rgb); + self.persist_settings_if_changed(); + } + Task::none() + } + Message::DefaultSaveFormatChanged(format) => { self.default_save_format = crate::io::canonical_save_format(&format).to_string(); diff --git a/src/app/update/style.rs b/src/app/update/style.rs index bff4cd52d..0274b140e 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 14bd53b7c..c567cc622 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - axis_lock_apply, axis_lock_capture, ortho_constrain, parse_coord, polar_constrain_near, + 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, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; @@ -171,6 +171,26 @@ impl OpenCADStudio { .set_active_tile_grid_snap(grid_on, snap_on); } + /// Grid origin plus the rotated/isometric axes used by both drawing and snap. + pub(in crate::app) fn drafting_grid_basis( + &self, + i: usize, + ) -> (glam::Vec3, (glam::Vec3, glam::Vec3, glam::Vec3)) { + let (origin, rotation) = self.tabs[i].ucs_grid_basis(); + let x = rotation.transform_vector3(glam::Vec3::X).as_dvec3(); + let y = rotation.transform_vector3(glam::Vec3::Y).as_dvec3(); + let z = rotation.transform_vector3(glam::Vec3::Z).as_dvec3(); + let (x, y, z) = drafting_axes( + x, + y, + z, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); + (origin, (x.as_vec3(), y.as_vec3(), z.as_vec3())) + } + /// Adopt the active viewport's grid *display* into the live toggle. Called /// on load and whenever the active tab or viewport changes, so the grid /// drawing follows the active viewport. @@ -1014,7 +1034,7 @@ 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()); - let (go, gr) = self.tabs[i].ucs_grid_basis(); + let (go, gr) = self.drafting_grid_basis(i); // `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 = @@ -1044,7 +1064,7 @@ impl OpenCADStudio { None }; - let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes(); + let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i); self.snapper.otrack_snap( raw, @@ -1054,8 +1074,8 @@ impl OpenCADStudio { polar_step, Some(grip.origin_world), self.ortho_mode, - ucs_x, - ucs_y, + ucs_x.as_dvec3(), + ucs_y.as_dvec3(), ) } else { None @@ -1086,6 +1106,9 @@ impl OpenCADStudio { self.polar_mode, self.polar_increment_deg, &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, ); } } else { @@ -1099,7 +1122,14 @@ impl OpenCADStudio { let base = grip.origin_world; let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { - snapped = ortho_constrain(snapped, base, &ucs_xf); + snapped = drafting_constrain( + snapped, + base, + &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); } else if self.polar_mode { snapped = polar_constrain_near( snapped, @@ -1423,7 +1453,7 @@ impl OpenCADStudio { bounds, ) } else { - let (go, gr) = self.tabs[i].ucs_grid_basis(); + let (go, gr) = self.drafting_grid_basis(i); // The snapper is a screen-space (f32) engine; the f64 // base only matters for typed-input precision, so hand it // the downcast point here. @@ -1472,7 +1502,7 @@ impl OpenCADStudio { } else { None }; - let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes(); + let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i); self.snapper.otrack_snap( cursor_world, view_rot, @@ -1481,8 +1511,8 @@ impl OpenCADStudio { step, self.last_point, self.ortho_mode && !is_window_corner, - ucs_x, - ucs_y, + ucs_x.as_dvec3(), + ucs_y.as_dvec3(), ) } else { None @@ -1545,6 +1575,9 @@ impl OpenCADStudio { self.polar_mode, self.polar_increment_deg, &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, ); } } else if !self.shift_down { @@ -1568,7 +1601,14 @@ impl OpenCADStudio { if let Some(base) = self.last_point { let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { - pt = ortho_constrain(pt, base, &ucs_xf); + pt = drafting_constrain( + pt, + base, + &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); } else if self.polar_mode { pt = polar_constrain_near( pt, @@ -1738,6 +1778,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -2077,7 +2118,7 @@ impl OpenCADStudio { self.snapper.grid_spacing = grid_spacing; // No rubber-band origin (perp/extension feet don't apply to a free drag). self.snapper.from_point = None; - let (go, gr) = self.tabs[i].ucs_grid_basis(); + let (go, gr) = self.drafting_grid_basis(i); let snap_hit = self .snapper .snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr); @@ -2740,7 +2781,7 @@ impl OpenCADStudio { bounds, ) } else { - let (go, gr) = self.tabs[i].ucs_grid_basis(); + let (go, gr) = self.drafting_grid_basis(i); self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); self.snapper.snap( snap_cursor, @@ -2779,7 +2820,7 @@ impl OpenCADStudio { } else { None }; - let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes(); + let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i); self.snapper.otrack_snap( raw, view_rot, @@ -2788,8 +2829,8 @@ impl OpenCADStudio { step, self.last_point, self.ortho_mode && !is_window_corner, - ucs_x, - ucs_y, + ucs_x.as_dvec3(), + ucs_y.as_dvec3(), ) } else { None @@ -2808,7 +2849,14 @@ impl OpenCADStudio { if let Some(base) = self.last_point { let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { - pt = ortho_constrain(pt, base, &ucs_xf); + pt = drafting_constrain( + pt, + base, + &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); } else if self.polar_mode { pt = polar_constrain_near( pt, @@ -2893,7 +2941,7 @@ impl OpenCADStudio { view_rot2, eye2, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); let include_fills = self.tabs[i] .active_cmd @@ -2914,6 +2962,7 @@ impl OpenCADStudio { eye2, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3442,7 +3491,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); let candidate_handles = self.tabs[i] .scene @@ -3462,6 +3511,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .into_iter() .filter_map(|s| Scene::handle_from_wire_name(s)) @@ -3483,6 +3533,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3824,7 +3875,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); let candidate_handles = self.tabs[i] .scene @@ -3839,6 +3890,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3943,7 +3995,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); scene::pick::hit_test::click_hit( p, @@ -3952,6 +4004,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .and_then(|h| { @@ -4357,7 +4410,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); let candidate_ms = candidate_started.elapsed().as_secs_f64() * 1000.0; let candidate_count = hover_candidates.len(); @@ -4377,6 +4430,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(Scene::handle_from_wire_name); let wire_ms = wire_started.elapsed().as_secs_f64() * 1000.0; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index c0ec9d441..d104c0027 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -335,6 +335,14 @@ impl OpenCADStudio { let (vw, vh) = tab.scene.selection.borrow().vp_size; let model_basis = { let (o, ux, uy, uz) = tab.ucs_xform().axes(); + let (ux, uy, uz) = super::helpers::drafting_axes( + ux, + uy, + uz, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); (o, (ux.as_vec3(), uy.as_vec3(), uz.as_vec3())) }; let grid: Vec = tab @@ -342,7 +350,7 @@ impl OpenCADStudio { .grid_views(vw, vh) .into_iter() .map(|(bounds, cam, handle)| { - let (origin, axes): (glam::DVec3, _) = if is_paper { + let (origin, mut axes): (glam::DVec3, _) = if is_paper { match tab.ucs_from_viewport(handle) { Some(u) => { let (o, ux, uy, uz) = @@ -357,6 +365,17 @@ impl OpenCADStudio { } else { model_basis }; + if is_paper { + let (ux, uy, uz) = super::helpers::drafting_axes( + axes.0.as_dvec3(), + axes.1.as_dvec3(), + axes.2.as_dvec3(), + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); + axes = (ux.as_vec3(), uy.as_vec3(), uz.as_vec3()); + } crate::ui::overlay::GridParams { view_rot: cam.view_proj_rte(bounds), eye: cam.eye(), @@ -684,6 +703,15 @@ impl OpenCADStudio { self.ribbon.open_dropdown.is_some(), hover_locked, crosshair_background(tab, is_paper), + crate::ui::overlay::CrosshairOptions { + size_percent: self.cursor_size, + pick_box: self.pick_box, + cursor_type: self.cursor_type, + color: self.crosshair_color, + isometric: self.isometric_drafting, + iso_plane: self.iso_plane, + snap_angle_deg: self.snap_angle_deg, + }, ) }; @@ -1693,6 +1721,8 @@ impl OpenCADStudio { self.polar_increment_deg, self.dyn_input, self.snapper.otrack_enabled, + self.isometric_drafting, + self.iso_plane, layout_names.clone(), block_tabs, layout_names.into_iter().skip(1).collect(), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 2b1c1ffb9..409c23ebc 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -21,6 +21,7 @@ impl OpenCADStudio { 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::PrintAll) => t!("Print All").into_owned(), @@ -96,6 +97,31 @@ impl OpenCADStudio { &self.ui_theme, &self.theme_color_inputs, self.language, + self.options_tab, + self.cursor_size, + self.pick_box, + self.cursor_type, + self.crosshair_color, + &self.crosshair_color_input, + flow, + ) + }, + ), + super::super::ModalKind::DraftingSettings => sized_flow( + ex, + 520, + 560, + |flow| { + crate::ui::window::drafting_settings::view_window( + &self.snapper, + self.show_grid, + self.snapper.grid_snap(), + self.ortho_mode, + self.polar_mode, + self.snapper.otrack_enabled, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, flow, ) }, diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index b7f5329e3..464247abc 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -15,13 +15,10 @@ use crate::scene::model::mesh_model::MeshModel; use crate::scene::model::wire_model::WireModel; use crate::scene::pick::interaction_index::WireSource; -/// Pixel radius used for single-click wire detection. -pub const CLICK_THRESHOLD_PX: f32 = 8.0; - /// Pick radius for one wire, in screen pixels. /// /// A wire renders as a band `line_weight_px` wide, so testing every wire at the -/// bare [`CLICK_THRESHOLD_PX`] would leave the outer part of a heavy line +/// configured base radius would leave the outer part of a heavy line /// unselectable — the cursor would sit on solid ink and miss. Widening to the /// rendered half-width keeps "looks like I'm on it" and "picks it" the same /// thing at any zoom: both quantities are screen-space, so the relation holds @@ -35,13 +32,13 @@ pub const CLICK_THRESHOLD_PX: f32 = 8.0; /// renders 7.97 px half-width), so this only bites for out-of-range weights — /// and it keeps the two sides from silently drifting apart if the display boost /// in `view::render::lineweight_to_px` ever changes. -pub fn pick_tolerance_px(wire: &WireModel, lw_display: bool) -> f32 { +pub fn pick_tolerance_px(wire: &WireModel, lw_display: bool, base_radius_px: f32) -> f32 { let half_width = if lw_display { wire.line_weight_px * 0.5 } else { 0.5 }; - CLICK_THRESHOLD_PX.max(half_width) + base_radius_px.max(1.0).max(half_width) } /// Is `aabb` — a wire's world-space XY box — further than `tol` pixels from @@ -199,6 +196,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( eye: glam::DVec3, bounds: Rectangle, lw_display: bool, + base_radius_px: f32, ) -> Option<&'a str> { // A click outside the pane rectangle (e.g. on the paper around a floating // viewport) must not reach geometry scissored out of the viewport. @@ -243,7 +241,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( bounds, ); let d = dist_point_to_segment(cursor, p0, p1); - if d < pick_tolerance_px(wire, lw_display) && d < best_dist { + if d < pick_tolerance_px(wire, lw_display, base_radius_px) && d < best_dist { best_dist = d; best = Some(&wire.name); } @@ -251,7 +249,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } else { // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. for wire in wires.iter() { - let tol = pick_tolerance_px(wire, lw_display); + let tol = pick_tolerance_px(wire, lw_display, base_radius_px); // Cheap AABB pre-reject (flat view only; never for the unbounded // sentinel used by previews / greeked text). if z_flat @@ -446,6 +444,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( eye: glam::DVec3, bounds: Rectangle, lw_display: bool, + base_radius_px: f32, ) -> Vec<&'a str> { if cursor.x < 0.0 || cursor.x > bounds.width || cursor.y < 0.0 || cursor.y > bounds.height { return Vec::new(); @@ -474,7 +473,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( bounds, ); let d = dist_point_to_segment(cursor, p0, p1); - if d < pick_tolerance_px(wire, lw_display) { + if d < pick_tolerance_px(wire, lw_display, base_radius_px) { best_by_wire .entry(segment.wire) .and_modify(|best| *best = best.min(d)) @@ -488,7 +487,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( })); } else { for wire in wires.iter() { - let tol = pick_tolerance_px(wire, lw_display); + let tol = pick_tolerance_px(wire, lw_display, base_radius_px); let mut prev: Option = None; let mut best_for_wire = tol; let mut hit = false; @@ -543,7 +542,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( ) .is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); matched.insert(triangle.wire); } } @@ -562,7 +561,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( ) .is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); } } } @@ -597,7 +596,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( ) .is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); matched.insert(triangle.wire); } } @@ -616,7 +615,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( ) .is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); } } } @@ -641,7 +640,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( continue; }; if text_quad_hit_area(cursor, vertices, view_rot, eye, bounds).is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); matched.insert(glyph.wire); } } @@ -651,7 +650,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( continue; } if text_quad_hit_area(cursor, &wire.text_verts, view_rot, eye, bounds).is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); } } } @@ -2038,16 +2037,16 @@ mod aabb_reject_tests { let eye = glam::DVec3::ZERO; assert_eq!( - click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true), + click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true, 8.0), Some("5") ); assert_eq!( - click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true), + click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true, 8.0), None ); // The far wire must be rejected without hiding the near one. assert_eq!( - click_hit(cursor, &[far, near], vp, eye, bounds, true), + click_hit(cursor, &[far, near], vp, eye, bounds, true, 8.0), Some("5") ); } diff --git a/src/snap.rs b/src/snap.rs index 6766219ed..add3ee424 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -11,7 +11,7 @@ use iced::{Point, Rectangle}; use crate::command::TangentObject; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; -use crate::ui::overlay::CROSSHAIR_ARM; +const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; // ── Snap type ───────────────────────────────────────────────────────────── @@ -174,7 +174,7 @@ impl Default for Snapper { enabled, grid_snap_on: false, grid_spacing: 1.0, - osnap_radius_px: CROSSHAIR_ARM * 0.25, + osnap_radius_px: DEFAULT_OSNAP_RADIUS_PX, otrack_enabled: false, tracking_points: Vec::new(), tracking_dirs: Vec::new(), @@ -830,7 +830,7 @@ impl Snapper { eye, bounds, Vec3::ZERO, - Mat4::IDENTITY, + (Vec3::X, Vec3::Y, Vec3::Z), ) } @@ -843,10 +843,10 @@ impl Snapper { view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, - // Grid origin (render/wire space) and UCS→world rotation, so grid snap - // lands on the UCS grid the user sees. `(ZERO, IDENTITY)` = world grid. + // Grid origin and live drafting axes, so grid snap lands on the same + // rotated or isometric grid the user sees. grid_origin: Vec3, - grid_rot: Mat4, + grid_axes: (Vec3, Vec3, Vec3), ) -> Option { // Object-snap selection is priority-then-distance, NOT nearest-wins. // "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry @@ -881,13 +881,27 @@ impl Snapper { let s = self.grid_spacing as f64; if s.abs() > 1e-9 { // Round in the UCS grid frame, then map back to world. - let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3(); - let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3(); - let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3(); + let (ax, ay, az) = grid_axes; + let ax = ax.normalize_or(Vec3::X).as_dvec3(); + let ay = ay.normalize_or(Vec3::Y).as_dvec3(); + let az = az.normalize_or(Vec3::Z).as_dvec3(); let origin = grid_origin.as_dvec3(); let rel = cursor_world - origin; - let ux = (rel.dot(ax) / s).round() * s; - let uy = (rel.dot(ay) / s).round() * s; + // The isometric pairs are oblique, so dot products alone are + // not coordinates. Invert their 2×2 Gram matrix before rounding. + let aa = ax.dot(ax); + let ab = ax.dot(ay); + let bb = ay.dot(ay); + let det = aa * bb - ab * ab; + let (ux, uy) = if det.abs() > 1e-9 { + let ra = rel.dot(ax); + let rb = rel.dot(ay); + ((ra * bb - rb * ab) / det, (rb * aa - ra * ab) / det) + } else { + (rel.dot(ax), rel.dot(ay)) + }; + let ux = (ux / s).round() * s; + let uy = (uy / s).round() * s; let uz = (rel.dot(az) / s).round() * s; let gp = origin + ax * ux + ay * uy + az * uz; let screen = world_to_screen(gp, view_rot, eye, bounds); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 5995b322d..a92324536 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -6,15 +6,23 @@ use iced::widget::canvas; use iced::{Color, Element, Length, Point, Size, Theme}; use crate::app::Message; +use crate::app::settings::{CursorType, IsoPlane}; use crate::scene::model::object::GripShape; use crate::scene::SelectionState; -/// Half-size of the crosshair center square in screen pixels (square = SQ*2 × SQ*2). -pub const CROSSHAIR_SQ: f32 = 7.5; -/// Arm length of the crosshair from center — used as the snap aperture radius. -pub const CROSSHAIR_ARM: f32 = 60.0; use crate::snap::SnapType; +#[derive(Clone, Copy)] +pub struct CrosshairOptions { + pub size_percent: i32, + pub pick_box: i32, + pub cursor_type: CursorType, + pub color: Option<[u8; 3]>, + pub isometric: bool, + pub iso_plane: IsoPlane, + pub snap_angle_deg: f32, +} + // ── Grip marker data ────────────────────────────────────────────────────── /// Describes one grip to be drawn in the viewport overlay. @@ -223,6 +231,7 @@ pub fn selection_overlay<'a>( suppressed: bool, hover_locked: bool, crosshair_bg: [f32; 4], + crosshair: CrosshairOptions, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -243,6 +252,7 @@ pub fn selection_overlay<'a>( suppressed, hover_locked, crosshair_bg, + crosshair, }) .width(Length::Fill) .height(Length::Fill) @@ -297,6 +307,7 @@ struct SelectionCanvas { /// Background of the active drawing space. Crosshair contrast follows this /// rather than the UI theme, which may be light over a dark model viewport. crosshair_bg: [f32; 4], + crosshair: CrosshairOptions, } fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) { @@ -432,7 +443,7 @@ impl canvas::Program for SelectionCanvas { // cursor entirely. `Interaction::None` would let the stack fall // through to a sibling — `Hidden` is the explicit "no cursor" // signal that actually suppresses the OS arrow. - if cursor.is_over(bounds) { + if cursor.is_over(bounds) && self.crosshair.cursor_type == CursorType::Crosshair { mouse::Interaction::Hidden } else { mouse::Interaction::default() @@ -924,11 +935,28 @@ impl canvas::Program for SelectionCanvas { // top of it would double up the visual feedback. let over_divider = self.divider_under(cursor, bounds); // PAN mode replaces the crosshair with a hand cursor. - if !over_viewcube && !over_divider && !self.pan_mode && !self.suppressed { + if !over_viewcube + && !over_divider + && !self.pan_mode + && !self.suppressed + && self.crosshair.cursor_type == CursorType::Crosshair + { if let Some(cp) = self.selection.last_move_pos { - let [r, g, b, a] = crate::scene::view::render::adapt_to_bg( - [1.0, 1.0, 1.0, 0.90], - self.crosshair_bg, + let [r, g, b, a] = self.crosshair.color.map_or_else( + || { + crate::scene::view::render::adapt_to_bg( + [1.0, 1.0, 1.0, 0.90], + self.crosshair_bg, + ) + }, + |[r, g, b]| { + [ + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + 0.90, + ] + }, ); let color = Color { r, g, b, a }; let stroke = canvas::Stroke { @@ -936,38 +964,38 @@ impl canvas::Program for SelectionCanvas { style: canvas::Style::Solid(color), ..Default::default() }; - let sq = CROSSHAIR_SQ; // square half-size → 15×15 - let arm = CROSSHAIR_ARM; // crosshair arm length from center - - // Horizontal arms (start at square edge, end at arm length) - let h_left = canvas::Path::new(|b| { - b.move_to(Point::new(cp.x - sq, cp.y)); - b.line_to(Point::new(cp.x - arm, cp.y)); - }); - let h_right = canvas::Path::new(|b| { - b.move_to(Point::new(cp.x + sq, cp.y)); - b.line_to(Point::new(cp.x + arm, cp.y)); - }); - // Vertical arms - let v_top = canvas::Path::new(|b| { - b.move_to(Point::new(cp.x, cp.y - sq)); - b.line_to(Point::new(cp.x, cp.y - arm)); - }); - let v_bot = canvas::Path::new(|b| { - b.move_to(Point::new(cp.x, cp.y + sq)); - b.line_to(Point::new(cp.x, cp.y + arm)); - }); - // Center square - let square = canvas::Path::rectangle( - Point::new(cp.x - sq, cp.y - sq), - Size::new(sq * 2.0, sq * 2.0), - ); - - frame.stroke(&h_left, stroke.clone()); - frame.stroke(&h_right, stroke.clone()); - frame.stroke(&v_top, stroke.clone()); - frame.stroke(&v_bot, stroke.clone()); - frame.stroke(&square, stroke); + let sq = self.crosshair.pick_box.clamp(0, 50) as f32; + let arm = bounds.width.hypot(bounds.height) + * self.crosshair.size_percent.clamp(1, 100) as f32 + / 100.0; + let base_angles: [f64; 2] = if self.crosshair.isometric { + self.crosshair.iso_plane.angles() + } else { + [0.0, 90.0] + }; + for angle in base_angles { + let rad = (angle + self.crosshair.snap_angle_deg as f64).to_radians(); + let dir = Point::new(rad.cos() as f32, -rad.sin() as f32); + let gap = if sq > 0.0 { + sq / dir.x.abs().max(dir.y.abs()).max(1e-6) + } else { + 0.0 + }; + let arms = canvas::Path::new(|path| { + path.move_to(Point::new(cp.x + dir.x * gap, cp.y + dir.y * gap)); + path.line_to(Point::new(cp.x + dir.x * arm, cp.y + dir.y * arm)); + path.move_to(Point::new(cp.x - dir.x * gap, cp.y - dir.y * gap)); + path.line_to(Point::new(cp.x - dir.x * arm, cp.y - dir.y * arm)); + }); + frame.stroke(&arms, stroke.clone()); + } + if sq > 0.0 { + let square = canvas::Path::rectangle( + Point::new(cp.x - sq, cp.y - sq), + Size::new(sq * 2.0, sq * 2.0), + ); + frame.stroke(&square, stroke); + } // Locked-layer badge: a small padlock beside the crosshair when // the hovered object sits on a locked layer (issue: locked diff --git a/src/ui/popup/snap_popup.rs b/src/ui/popup/snap_popup.rs index bc6b674c6..c5a36c852 100644 --- a/src/ui/popup/snap_popup.rs +++ b/src/ui/popup/snap_popup.rs @@ -1,14 +1,19 @@ //! OpenCADStudio-style OSNAP status menu. -use iced::widget::{button, container, row, text}; +use iced::widget::{button, checkbox, column, container, row, text}; use iced::{Background, Element, Fill, Length, Theme}; use crate::app::Message; +use crate::app::settings::IsoPlane; use crate::snap::{SnapType, Snapper, ALL_SNAP_MODES}; use crate::ui::statusbar::status_menu::Entry; use crate::t; -pub fn menu_entries<'a>(snapper: &'a Snapper) -> Vec> { +pub fn menu_entries<'a>( + snapper: &'a Snapper, + isometric: bool, + iso_plane: IsoPlane, +) -> Vec> { let all_on = snapper.all_on(); let none_on = snapper.none_on(); @@ -30,7 +35,49 @@ pub fn menu_entries<'a>(snapper: &'a Snapper) -> Vec> { .width(Fill) .padding([0, 4]); - let mut entries = vec![Entry::stay(header), Entry::stay(divider)]; + let mut iso_planes = row![].spacing(3); + for plane in IsoPlane::ALL { + iso_planes = iso_planes.push( + button(text(t!(plane.label())).size(10)) + .on_press(Message::SetIsoPlane(plane)) + .style(if isometric && iso_plane == plane { + button::primary + } else { + button::secondary + }) + .padding([3, 8]), + ); + } + let drafting = column![ + row![ + checkbox(isometric) + .on_toggle(|_| Message::ToggleIsometricDrafting) + .size(14), + text(t!("Isometric drafting")).size(11), + ] + .spacing(6) + .align_y(iced::Center), + iso_planes, + text(t!("F5 cycles the active plane.")).size(10), + ] + .spacing(5) + .padding([5, 8]); + let drafting_divider = container(iced::widget::Space::new().height(1)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.weak.color, + )), + ..Default::default() + }) + .width(Fill) + .padding([0, 4]); + + let mut entries = vec![ + Entry::stay(header), + Entry::stay(divider), + Entry::stay(drafting), + Entry::stay(drafting_divider), + ]; for &(snap_type, _glyph, label) in ALL_SNAP_MODES { entries.push(Entry::stay(snap_row( snap_type, diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index 6a3a9630b..7736d3028 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -71,6 +71,8 @@ impl StatusBar { polar_increment_deg: f32, dyn_input: bool, otrack: bool, + isometric_drafting: bool, + iso_plane: crate::app::settings::IsoPlane, layouts: Vec, block_tabs: Vec, reorderable_layouts: Vec, @@ -297,7 +299,11 @@ impl StatusBar { osnap_active, snapper.snap_enabled, tooltip_hidden, - crate::ui::popup::snap_popup::menu_entries(snapper), + crate::ui::popup::snap_popup::menu_entries( + snapper, + isometric_drafting, + iso_plane, + ), ) .into(), ); diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs new file mode 100644 index 000000000..714225ec9 --- /dev/null +++ b/src/ui/window/drafting_settings.rs @@ -0,0 +1,135 @@ +use crate::app::settings::IsoPlane; +use crate::app::Message; +use crate::snap::{Snapper, ALL_SNAP_MODES}; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space}; +use iced::{Element, Fill}; + +pub fn view_window<'a>( + snapper: &'a Snapper, + grid: bool, + grid_snap: bool, + ortho: bool, + polar: bool, + otrack: bool, + isometric: bool, + iso_plane: IsoPlane, + snap_angle_deg: f32, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let toggle = |value: bool, label: std::borrow::Cow<'a, str>, message: Message| { + row![ + checkbox(value).on_toggle(move |_| message.clone()).size(15), + text(label).size(12), + ] + .spacing(7) + .align_y(iced::Center) + }; + + let drafting_modes = column![ + text(crate::t!("Drafting modes")).size(15), + Space::new().height(8), + toggle(grid, crate::t!("Grid display"), Message::ToggleGrid), + toggle(grid_snap, crate::t!("Grid snap"), Message::ToggleGridSnap), + toggle(ortho, crate::t!("Ortho"), Message::ToggleOrtho), + toggle(polar, crate::t!("Polar tracking"), Message::TogglePolar), + toggle(otrack, crate::t!("Object snap tracking"), Message::ToggleOTrack), + ] + .spacing(6); + + let mut planes = row![].spacing(5); + for plane in IsoPlane::ALL { + planes = planes.push( + button(text(crate::t!(plane.label())).size(11)) + .on_press(Message::SetIsoPlane(plane)) + .style(if isometric && iso_plane == plane { + button::primary + } else { + button::secondary + }) + .padding([5, 12]), + ); + } + let isometric_controls = column![ + text(crate::t!("Isometric drafting")).size(15), + Space::new().height(8), + toggle( + isometric, + crate::t!("Enable isometric drafting"), + Message::ToggleIsometricDrafting, + ), + planes, + text(crate::t!("F5 cycles Left, Top, and Right.")).size(11), + Space::new().height(6), + row![ + text(crate::t!("Rotation: %{angle}°", angle = snap_angle_deg)).size(11), + button(text(crate::t!("Reset rotation")).size(10)) + .on_press(Message::ResetDraftingRotation) + .style(button::secondary) + .padding([4, 10]), + ] + .spacing(10) + .align_y(iced::Center), + ] + .spacing(7); + + let mut snap_modes = column![ + text(crate::t!("Object snap modes")).size(15), + Space::new().height(8), + toggle( + snapper.snap_enabled, + crate::t!("Enable object snap"), + Message::ToggleSnapEnabled, + ), + row![ + button(text(crate::t!("Select All")).size(10)) + .on_press(Message::SnapSelectAll) + .style(button::secondary) + .padding([4, 10]), + button(text(crate::t!("Clear All")).size(10)) + .on_press(Message::SnapClearAll) + .style(button::secondary) + .padding([4, 10]), + ] + .spacing(6), + ] + .spacing(5); + for &(snap_type, _, label) in ALL_SNAP_MODES { + snap_modes = snap_modes.push( + row![ + checkbox(snapper.is_on(snap_type)) + .on_toggle(move |_| Message::ToggleSnap(snap_type)) + .size(14), + text(crate::t!(label)).size(11), + ] + .spacing(7) + .align_y(iced::Center), + ); + } + + let close = button(text(crate::tr!("action-close")).size(12)) + .on_press(Message::CloseModal) + .padding([6, 18]) + .style(button::secondary); + let content = column![ + drafting_modes, + Space::new().height(20), + isometric_controls, + Space::new().height(20), + snap_modes, + ] + .width(sizing.width); + let body = column![ + scrollable(content).spacing(8).height(sizing.height), + Space::new().height(12), + row![Space::new().width(Fill), close], + ] + .width(sizing.width) + .height(sizing.height); + + container(body) + .style(container::rounded_box) + .padding([16, 18]) + .width(sizing.width) + .height(sizing.height) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 4e8cd139d..e1ae0b229 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -3,6 +3,7 @@ pub mod block_palette; pub mod layout_manager; pub mod layer_state_manager; pub mod drawing_units; +pub mod drafting_settings; pub mod layer_translator; pub mod plot; pub mod print_all; diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index cb27fd43c..02eabff93 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -1,11 +1,20 @@ use crate::app::config::UiThemeConfig; +use crate::app::settings::CursorType; use crate::app::Message; use iced::widget::{ - button, column, container, row, scrollable, text, text_input, Space, + button, column, container, row, scrollable, slider, text, text_input, Space, }; -use iced::{Background, Border, Element, Theme}; +use iced::{Background, Border, Element, Fill, Theme}; use std::fmt; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OptionsTab { + #[default] + General, + Display, + Selection, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct Labelled { value: T, @@ -24,6 +33,12 @@ pub fn view_window<'a>( ui_theme: &'a UiThemeConfig, theme_color_inputs: &'a [String; 6], language: crate::i18n::Language, + active_tab: OptionsTab, + cursor_size: i32, + pick_box: i32, + cursor_type: CursorType, + crosshair_color: Option<[u8; 3]>, + crosshair_color_input: &'a str, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let selected_format = crate::io::SAVE_FORMAT_OPTIONS @@ -62,6 +77,18 @@ pub fn view_window<'a>( .find(|choice| choice.value == language) .cloned(); + let cursor_options = CursorType::ALL + .into_iter() + .map(|value: CursorType| Labelled { + label: crate::t!(value.label()).into_owned(), + value, + }) + .collect::>(); + let selected_cursor = cursor_options + .iter() + .find(|choice| choice.value == cursor_type) + .cloned(); + let palette = ui_theme.palette.to_iced(); let colors = [ (crate::tr!("options-color-background"), palette.background), @@ -104,7 +131,7 @@ pub fn view_window<'a>( .padding([6, 18]) .style(button::secondary); - let content = column![ + let general = column![ text(crate::tr!("options-language-section")).size(15), Space::new().height(10), row![ @@ -154,7 +181,29 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), - Space::new().height(22), + ] + .spacing(0) + .width(sizing.width); + + let crosshair_rgb = crosshair_color.unwrap_or([255, 255, 255]); + let crosshair_swatch = container(Space::new()) + .width(28) + .height(22) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(iced::Color::from_rgb8( + crosshair_rgb[0], + crosshair_rgb[1], + crosshair_rgb[2], + ))), + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + + let display = column![ text(crate::tr!("options-theme-section")).size(15), Space::new().height(10), row![ @@ -175,11 +224,96 @@ pub fn view_window<'a>( .width(sizing.width), Space::new().height(12), color_controls, + Space::new().height(24), + text(crate::t!("Crosshair")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Crosshair size")).size(12).width(140), + slider(1..=100, cursor_size.clamp(1, 100), Message::CursorSizeChanged) + .step(1) + .width(Fill), + text(format!("{}%", cursor_size.clamp(1, 100))) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Cursor type")).size(12).width(140), + iced::widget::pick_list( + selected_cursor, + cursor_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::CursorTypeChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Crosshair color")).size(12).width(140), + crosshair_swatch, + text_input(crate::t!("#RRGGBB or blank").as_ref(), crosshair_color_input) + .on_input(Message::CrosshairColorChanged) + .width(150), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!("Leave the color blank to keep automatic viewport contrast.")) + .size(11) + .width(sizing.width), ] .spacing(0) .width(sizing.width); + let selection = column![ + text(crate::t!("Selection")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Pick box size")).size(12).width(140), + slider(0..=50, pick_box.clamp(0, 50), Message::PickBoxChanged) + .step(1) + .width(Fill), + text(pick_box.clamp(0, 50).to_string()).size(11).width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(8), + text(crate::t!( + "Controls both the visible selection box and the click aperture." + )) + .size(11) + .width(sizing.width), + ] + .spacing(0) + .width(sizing.width); + + let content: Element<'a, Message> = match active_tab { + OptionsTab::General => general.into(), + OptionsTab::Display => display.into(), + OptionsTab::Selection => selection.into(), + }; + + let tab_button = |label, tab| { + let selected = active_tab == tab; + button(text(label).size(12)) + .on_press(Message::OptionsTabChanged(tab)) + .padding([6, 14]) + .style(if selected { button::primary } else { button::secondary }) + }; + let tabs = row![ + tab_button(crate::t!("General"), OptionsTab::General), + tab_button(crate::t!("Display"), OptionsTab::Display), + tab_button(crate::t!("Selection"), OptionsTab::Selection), + ] + .spacing(6); + let body = column![ + tabs, + Space::new().height(12), // Keep the scrollbar in its own lane instead of floating over the // controls at the trailing edge of the Options content. scrollable(content).spacing(8).height(sizing.height), From 8847724127fe87523d3a1be0ee01197ec170473d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 21:53:26 +0300 Subject: [PATCH 06/18] fix(cursor): restore default geometry Keep CURSORSIZE 5 and PICKBOX 3 mapped to the pre-option 60 px arms, 15 px box, and 8 px selection aperture.\n\nRefs #413 --- src/app/mod.rs | 4 +-- src/app/settings.rs | 4 +-- src/app/update/viewport.rs | 24 +++++++++--------- src/ui/overlay.rs | 51 +++++++++++++++++++++++++++++++++++--- 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index bec19b348..e582987e1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -448,9 +448,9 @@ pub(super) struct OpenCADStudio { zoom_wheel_reversed: bool, /// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR). zoom_factor: i32, - /// Crosshair reach as a viewport percentage (CURSORSIZE, 1..=100). + /// Crosshair size setting (CURSORSIZE, 1..=100). cursor_size: i32, - /// Selection-box half-size and click aperture in pixels (PICKBOX, 0..=50). + /// Selection-box size setting (PICKBOX, 0..=50). pick_box: i32, /// Drawing viewport cursor style (CURSORTYPE). cursor_type: settings::CursorType, diff --git a/src/app/settings.rs b/src/app/settings.rs index 25cf6f8bb..4e4ad329c 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -156,9 +156,9 @@ pub struct UserSettings { pub polar_increment_deg: f32, pub zoom_wheel_reversed: bool, pub zoom_factor: i32, - /// CURSORSIZE: crosshair arm reach as a percentage of the viewport. + /// CURSORSIZE: normalized crosshair reach; 5 retains the original 60 px arms. pub cursor_size: i32, - /// PICKBOX: visible selection-box half-size and click aperture in pixels. + /// PICKBOX: normalized visible-box and click-aperture size. pub pick_box: i32, /// CURSORTYPE: crosshair or the platform pointer over the drawing. pub cursor_type: CursorType, diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c567cc622..37219dd69 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1778,7 +1778,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, - self.pick_box.max(1) as f32, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -2941,7 +2941,7 @@ impl OpenCADStudio { view_rot2, eye2, bounds, - self.pick_box.max(1) as f32 * 2.0, + crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0, ); let include_fills = self.tabs[i] .active_cmd @@ -2962,7 +2962,7 @@ impl OpenCADStudio { eye2, bounds, self.tabs[i].scene.document.header.lineweight_display, - self.pick_box.max(1) as f32, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3491,7 +3491,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - self.pick_box.max(1) as f32 * 2.0, + crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0, ); let candidate_handles = self.tabs[i] .scene @@ -3511,7 +3511,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, - self.pick_box.max(1) as f32, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) .into_iter() .filter_map(|s| Scene::handle_from_wire_name(s)) @@ -3533,7 +3533,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, - self.pick_box.max(1) as f32, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3875,7 +3875,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - self.pick_box.max(1) as f32 * 2.0, + crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0, ); let candidate_handles = self.tabs[i] .scene @@ -3890,7 +3890,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, - self.pick_box.max(1) as f32, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3995,7 +3995,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - self.pick_box.max(1) as f32 * 2.0, + crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0, ); scene::pick::hit_test::click_hit( p, @@ -4004,7 +4004,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, - self.pick_box.max(1) as f32, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) .and_then(|s| Scene::handle_from_wire_name(s)) .and_then(|h| { @@ -4410,7 +4410,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - self.pick_box.max(1) as f32 * 2.0, + crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0, ); let candidate_ms = candidate_started.elapsed().as_secs_f64() * 1000.0; let candidate_count = hover_candidates.len(); @@ -4430,7 +4430,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, - self.pick_box.max(1) as f32, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) .and_then(Scene::handle_from_wire_name); let wire_ms = wire_started.elapsed().as_secs_f64() * 1000.0; diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index a92324536..c0eef63c1 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -12,6 +12,51 @@ use crate::scene::SelectionState; use crate::snap::SnapType; +/// Original crosshair geometry retained at the default setting values. +pub const CROSSHAIR_SQ: f32 = 7.5; +pub const CROSSHAIR_ARM: f32 = 60.0; +const DEFAULT_CURSOR_SIZE: i32 = 5; +const DEFAULT_PICK_BOX: i32 = 3; +const DEFAULT_PICK_APERTURE: f32 = 8.0; + +/// Convert CURSORSIZE to a screen-space arm length while keeping the original +/// 60 px cursor at the default value and the full-viewport result at 100. +pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 { + let value = value.clamp(1, 100); + if value <= DEFAULT_CURSOR_SIZE { + return CROSSHAIR_ARM * value as f32 / DEFAULT_CURSOR_SIZE as f32; + } + + let full = bounds.width.hypot(bounds.height).max(CROSSHAIR_ARM); + let scale = (value - DEFAULT_CURSOR_SIZE) as f32 / (100 - DEFAULT_CURSOR_SIZE) as f32; + CROSSHAIR_ARM + (full - CROSSHAIR_ARM) * scale +} + +/// Convert PICKBOX to the visible half-size while retaining the original +/// 15 x 15 px center box at the default value. +pub(crate) fn pick_box_half_px(value: i32) -> f32 { + let value = value.clamp(0, 50); + if value <= DEFAULT_PICK_BOX { + return CROSSHAIR_SQ * value as f32 / DEFAULT_PICK_BOX as f32; + } + + let scale = (value - DEFAULT_PICK_BOX) as f32 / (50 - DEFAULT_PICK_BOX) as f32; + CROSSHAIR_SQ + (50.0 - CROSSHAIR_SQ) * scale +} + +/// Convert PICKBOX to the real click/hover aperture. A zero setting hides the +/// box but retains the one-pixel minimum needed for direct hits. +pub(crate) fn pick_box_aperture_px(value: i32) -> f32 { + let value = value.clamp(0, 50); + let aperture = if value <= DEFAULT_PICK_BOX { + DEFAULT_PICK_APERTURE * value as f32 / DEFAULT_PICK_BOX as f32 + } else { + let scale = (value - DEFAULT_PICK_BOX) as f32 / (50 - DEFAULT_PICK_BOX) as f32; + DEFAULT_PICK_APERTURE + (50.0 - DEFAULT_PICK_APERTURE) * scale + }; + aperture.max(1.0) +} + #[derive(Clone, Copy)] pub struct CrosshairOptions { pub size_percent: i32, @@ -964,10 +1009,8 @@ impl canvas::Program for SelectionCanvas { style: canvas::Style::Solid(color), ..Default::default() }; - let sq = self.crosshair.pick_box.clamp(0, 50) as f32; - let arm = bounds.width.hypot(bounds.height) - * self.crosshair.size_percent.clamp(1, 100) as f32 - / 100.0; + let sq = pick_box_half_px(self.crosshair.pick_box); + let arm = crosshair_arm_px(bounds, self.crosshair.size_percent); let base_angles: [f64; 2] = if self.crosshair.isometric { self.crosshair.iso_plane.angles() } else { From 57661c5754e5b7a350659db759b2bfa1b8521a71 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 00:43:35 +0300 Subject: [PATCH 07/18] feat(entities): give an entity its geometry as a curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same shape was being read out of an entity's fields in three places and the three had drifted. An arc's tessellation cut it at 20 segments per radian, TRIM's sampling at 16, and its snap points came from a fourth expression again; a spline was sampled at a fixed 96 points however long it was; TRIM read an arc's centre as world coordinates when the entity stores it in its OCS, so an arc carrying the (0, 0, −1) normal a MIRROR leaves behind acted as a boundary on the wrong side of the drawing. entity_curve returns a PlanarCurve: the shape, plus the plane its coordinates are read in. The plane comes from the entity's OCS — its extrusion normal through the DXF arbitrary-axis algorithm — not from the UCS. A UCS is the working plane a user drew in and an entity does not remember it: draw a circle in a UCS turned 30° about Z and the stored normal is still +Z, which is why the header keeps the UCS separately. The algorithm itself stays in transform.rs, where the format is understood and where #142's degenerate case was fixed once. Entities that store world coordinates and may not be planar at all — LINE, SPLINE — are checked rather than assumed. A spline whose points wander in space returns None instead of being flattened onto XY, which would move it. entity_curve_xy folds the plane away for the editing commands, which work in plan view and need every boundary in one frame before two of them can be intersected. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/entities/curve.rs | 710 ++++++++++++++++++++++++++ src/entities/mod.rs | 1 + src/modules/draw/modify/spline_ops.rs | 41 +- 4 files changed, 745 insertions(+), 9 deletions(-) create mode 100644 src/entities/curve.rs diff --git a/Cargo.lock b/Cargo.lock index ce4147119..aaaa6aff5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#dee251f190a82f66d548e10d36f112815d9a901f" +source = "git+https://github.com/HakanSeven12/cadkernel.git#39083307452ba14b3968bdb0738e2665d3d18e62" dependencies = [ "cavalier_contours", ] diff --git a/src/entities/curve.rs b/src/entities/curve.rs new file mode 100644 index 000000000..12182c71c --- /dev/null +++ b/src/entities/curve.rs @@ -0,0 +1,710 @@ +//! An entity's geometry as a kernel curve. +//! +//! One converter, so that what is drawn, what is snapped to and what is +//! trimmed all read the same definition. Before this each of those grew its +//! own per-type match, and the three drifted: an arc's tessellation, its snap +//! points and its trim parameters were computed by different code from the +//! same fields. +//! +//! # The plane +//! +//! A [`Curve`] is two-dimensional; a drawing's curve lives on a plane in +//! space. The two together are a [`PlanarCurve`], and the plane comes from +//! the entity's **OCS** — its extrusion normal, run through the DXF +//! arbitrary-axis algorithm in [`ocs_axes`]. Not from the UCS: the UCS is the +//! working plane a user drew in, and an entity does not remember it. Draw a +//! circle in a UCS turned 30° about Z and the stored normal is still +Z, so +//! the frame this builds is the world one — which is exactly right, because +//! that is where the circle's stored coordinates are read. +//! +//! Entities that store WCS coordinates rather than OCS ones (LINE, ELLIPSE, +//! SPLINE, RAY, XLINE) still get a plane, derived from the geometry itself. +//! Where they turn out not to be planar at all, the answer is `None` rather +//! than a silent projection onto XY, which would move the geometry. +//! +//! # What is deliberately absent +//! +//! Text, hatch, dimensions, meshes, solids, images and blocks are not +//! curves. They keep their own paths and this returns `None` for them, which +//! is what lets a caller ask about any entity without checking first. + +use acadrust::entities::{ + Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, LwPolyline as LwPolylineEnt, + Polyline2D, Spline as SplineEnt, +}; +use acadrust::kernel::geom2d::{ + characteristic_points, Arc, Circle, Curve, Ellipse, EllipseArc, Line, Polyline, PolylineVertex, + Ray, SnapKind, Transform, XLine, +}; +use acadrust::kernel::space::{PlanarCurve, Plane, Vec3}; +use acadrust::types::Vector3; +use acadrust::EntityType; + +use crate::modules::draw::modify::spline_ops::spline_to_nurbs_on; +use crate::scene::model::wire_model::SnapHint; +use crate::scene::view::transform::ocs_axes; + +/// The codec's vector as the array the kernel speaks in. The two have no +/// conversion between them by design — neither crate knows the other — so the +/// bridge is here, once. +fn xyz(v: Vector3) -> [f64; 3] { + [v.x, v.y, v.z] +} + +/// How far a point may sit off a candidate plane and still be taken to lie on +/// it. +/// +/// Relative to the geometry's own size rather than absolute: a spline drawn +/// at survey coordinates carries several orders more rounding in its stored +/// values than one drawn near the origin, and a fixed floor would reject the +/// first or accept anything for the second. +const PLANARITY_TOLERANCE: f64 = 1e-9; + +/// The entity's geometry as a curve on the plane it lives on. +/// +/// `None` for entities that are not curves, and for the ones that are but +/// whose points do not lie on a plane — a 3D polyline, a spline through +/// points in space. +pub fn entity_curve(entity: &EntityType) -> Option { + match entity { + EntityType::Line(line) => straight_curve(line.start, line.end, Straight::Segment), + EntityType::Ray(ray) => { + straight_curve(ray.base_point, ray.base_point + ray.direction, Straight::Ray) + } + EntityType::XLine(line) => straight_curve( + line.base_point, + line.base_point + line.direction, + Straight::Infinite, + ), + EntityType::Circle(circle) => Some(circle_curve(circle)), + EntityType::Arc(arc) => Some(arc_curve(arc)), + EntityType::Ellipse(ellipse) => ellipse_curve(ellipse), + EntityType::LwPolyline(polyline) => lwpolyline_curve(polyline), + EntityType::Polyline2D(polyline) => polyline2d_curve(polyline), + EntityType::Spline(spline) => spline_curve(spline), + _ => None, + } +} + +/// The plane an OCS-stored entity's coordinates are read in. +/// +/// `elevation` is the entity's third stored coordinate — the distance along +/// the normal its plane sits at — so the plane's origin is where a stored +/// `(0, 0)` actually lands. +/// +/// The axes come from [`ocs_axes`] rather than being derived here. That +/// algorithm is a DXF storage convention with its own history of getting the +/// degenerate cases wrong, and having it in one place is what keeps the +/// second copy from drifting. +pub fn ocs_plane(normal: Vector3, elevation: f64) -> Plane { + let normal = normalized(normal); + if is_default_normal(normal) { + // The overwhelming majority. Named rather than left to the general + // path so the axes come out bit-exact, which is what lets consumers + // take the world-XY shortcut. + return Plane::from_axes([0.0, 0.0, elevation], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + } + let (x_axis, y_axis) = ocs_axes((normal.x, normal.y, normal.z)); + Plane::from_axes( + (Vec3::from(xyz(normal)) * elevation).to_array(), + [x_axis.0, x_axis.1, x_axis.2], + [y_axis.0, y_axis.1, y_axis.2], + ) +} + +/// Which of the three straight kinds a pair of points describes. +enum Straight { + Segment, + Ray, + Infinite, +} + +/// A LINE, RAY or XLINE, which store world coordinates and may run anywhere. +/// +/// A straight curve lies in infinitely many planes, so any of them gives the +/// same points back. The one picked is the world XY plane when the geometry +/// is level, because that is the frame everything else in a drawing shares +/// and a caller that wants to intersect two curves needs them in one frame. +/// Otherwise it is the upright plane through the line, which is the only +/// choice that does not depend on an arbitrary rotation. +fn straight_curve(from: Vector3, to: Vector3, kind: Straight) -> Option { + let along = Vec3::from(xyz(to)) - Vec3::from(xyz(from)); + if along.length_squared() <= 0.0 { + return None; + } + let plane = if (to.z - from.z).abs() <= PLANARITY_TOLERANCE * scale_of(&[from, to]) { + Plane::from_axes([0.0, 0.0, from.z], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + } else { + // The upright plane through the line: its normal is horizontal, and + // perpendicular to the line. A line that is itself vertical has no + // such normal, and any plane containing it will do — XZ is taken. + let normal = along.cross(Vec3::Z).normalize().unwrap_or(Vec3::Y); + Plane::orthonormal(xyz(from), along.to_array(), normal.to_array())? + }; + let start = plane.project(xyz(from))?; + let end = plane.project(xyz(to))?; + let direction = [end[0] - start[0], end[1] - start[1]]; + Some(PlanarCurve::new( + plane, + match kind { + Straight::Segment => Curve::Line(Line { start, end }), + Straight::Ray => Curve::Ray(Ray { + origin: start, + direction, + }), + Straight::Infinite => Curve::XLine(XLine { + base: start, + direction, + }), + }, + )) +} + +pub fn circle_curve(circle: &CircleEnt) -> PlanarCurve { + PlanarCurve::new( + ocs_plane(circle.normal, circle.center.z), + Curve::Circle(Circle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, + }), + ) +} + +pub fn arc_curve(arc: &ArcEnt) -> PlanarCurve { + PlanarCurve::new( + ocs_plane(arc.normal, arc.center.z), + Curve::Arc(Arc { + centre: [arc.center.x, arc.center.y], + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + }), + ) +} + +/// An ELLIPSE, whose centre and major axis are stored in world coordinates +/// even though the shape is planar. +/// +/// The plane is still the entity's OCS one, so an ellipse and a circle with +/// the same normal end up in the same frame and can be intersected without +/// re-expressing either. Getting there means projecting the two stored +/// vectors rather than reading them off, which is where the major axis picks +/// up its direction *and* its length. +pub fn ellipse_curve(ellipse: &EllipseEnt) -> Option { + let normal = normalized(ellipse.normal); + let elevation = Vec3::from(xyz(ellipse.center)).dot(Vec3::from(xyz(normal))); + let plane = ocs_plane(normal, elevation); + let centre = plane.project(xyz(ellipse.center))?; + let major = plane.project_vector(xyz(ellipse.major_axis))?; + let major_radius = (major[0] * major[0] + major[1] * major[1]).sqrt(); + if major_radius <= 0.0 { + return None; + } + Some(PlanarCurve::new( + plane, + Curve::Ellipse(EllipseArc { + ellipse: Ellipse { + centre, + major_radius, + minor_radius: major_radius * ellipse.minor_axis_ratio, + major_axis: [major[0] / major_radius, major[1] / major_radius], + }, + start_parameter: ellipse.start_parameter, + end_parameter: ellipse.end_parameter, + }), + )) +} + +pub fn lwpolyline_curve(polyline: &LwPolylineEnt) -> Option { + let vertices: Vec = polyline + .vertices + .iter() + .map(|v| PolylineVertex { + position: [v.location.x, v.location.y], + bulge: v.bulge, + }) + .collect(); + (vertices.len() >= 2).then(|| { + PlanarCurve::new( + ocs_plane(polyline.normal, polyline.elevation), + Curve::Polyline(Polyline { + vertices, + closed: polyline.is_closed, + }), + ) + }) +} + +/// A heavy 2D POLYLINE. Its vertices carry a Z, but the entity's own +/// `elevation` is what defines the plane; the two agree in a well-formed +/// drawing and the entity field is the one the writer round-trips. +pub fn polyline2d_curve(polyline: &Polyline2D) -> Option { + let vertices: Vec = polyline + .vertices + .iter() + .map(|v| PolylineVertex { + position: [v.location.x, v.location.y], + bulge: v.bulge, + }) + .collect(); + (vertices.len() >= 2).then(|| { + PlanarCurve::new( + ocs_plane(polyline.normal, polyline.elevation), + Curve::Polyline(Polyline { + vertices, + closed: polyline.flags.is_closed(), + }), + ) + }) +} + +/// A SPLINE, whose control and fit points are world coordinates that need not +/// lie on any plane. +/// +/// The `planar` flag is not trusted on its own — it is routinely wrong in +/// files written by other software — so the points are checked against the +/// plane the normal describes. A spline that genuinely wanders in space gets +/// `None`, which is honest: flattening it to XY would move it. +pub fn spline_curve(spline: &SplineEnt) -> Option { + let points: Vec = spline + .control_points + .iter() + .chain(spline.fit_points.iter()) + .copied() + .collect(); + let first = points.first()?; + let normal = normalized(spline.normal); + let elevation = Vec3::from(xyz(*first)).dot(Vec3::from(xyz(normal))); + let plane = ocs_plane(normal, elevation); + + let tolerance = PLANARITY_TOLERANCE * scale_of(&points); + if !points.iter().all(|p| plane.contains(xyz(*p), tolerance)) { + return None; + } + Some(PlanarCurve::new( + plane, + Curve::Nurbs(spline_to_nurbs_on(spline, &plane)?), + )) +} + +/// The entity's curve in world XY coordinates. +/// +/// The editing commands — TRIM, EXTEND, FILLET, OFFSET — work in plan view, +/// where every boundary has to be expressed in one shared frame before two of +/// them can be intersected. [`entity_curve`] gives each entity its own plane, +/// which is right for describing the geometry and wrong for comparing two +/// pieces of it; this folds the plane away. +/// +/// `None` when the entity is not a planar curve, and when its plane stands +/// edge-on to the view: a circle seen exactly from the side is a line +/// segment, and there is no honest curve to trim against. +/// +/// This is also where an extruded entity stops being read wrong. Reading an +/// arc's `center.x/.y` straight out of the entity treats OCS coordinates as +/// world ones, so an arc carrying the `(0, 0, −1)` normal a MIRROR leaves +/// behind acted as a boundary on the wrong side of the drawing. +pub fn entity_curve_xy(entity: &EntityType) -> Option { + let planar = entity_curve(entity)?; + let plane = planar.plane; + if plane.is_xy_aligned() { + // Already world-aligned, so only the elevation has to be dropped and + // any origin offset carried over. + let [x, y, _] = plane.origin; + return if x == 0.0 && y == 0.0 { + Some(planar.curve) + } else { + planar.curve.transformed(&Transform::translation([x, y])) + }; + } + // A plane whose normal has no Z component is edge-on: it projects to a + // line, and a curve on it has no plan-view shape. + let normal = plane.normal()?; + if normal[2].abs() <= 1e-12 { + return None; + } + // Otherwise the drop to XY is affine — each plane axis contributes its own + // X and Y — so the curve can follow it exactly rather than being sampled. + planar.curve.transformed(&Transform { + x_axis: [plane.x_axis[0], plane.x_axis[1]].into(), + y_axis: [plane.y_axis[0], plane.y_axis[1]].into(), + origin: [plane.origin[0], plane.origin[1]].into(), + }) +} + +/// The snap candidates an entity's curve offers, in the two channels the +/// wire model carries them in. +#[derive(Debug, Default, Clone)] +pub struct CurveSnap { + /// Centres, quadrants and midpoints, in world coordinates. + pub snap_pts: Vec<(glam::DVec3, SnapHint)>, + /// The ends of the curve and of each of a chain's segments. + pub key_vertices: Vec<[f64; 3]>, +} + +/// Every point the entity's own geometry offers to snap to. +/// +/// The point of routing this through [`entity_curve`] rather than reading the +/// fields per type: an arc's quadrants, an ellipse's axis ends and a spline's +/// midpoint are all the same question asked of different shapes, and asking +/// it once means the answer is exact everywhere. Previously an arc offered no +/// quadrants at all and a spline's "endpoints" were its control points, which +/// for a control-point spline are not on the curve. +/// +/// `None` for anything that is not a planar curve, which keeps the callers +/// that have their own snap sources — text, blocks, dimensions — untouched. +pub fn curve_snap(entity: &EntityType) -> Option { + Some(snap_from(&entity_curve(entity)?)) +} + +/// [`curve_snap`] for a caller that already has the curve. +/// +/// The per-type wire builders take a concrete entity rather than an +/// [`EntityType`], and wrapping one back up would mean cloning it on the +/// render path. +pub fn snap_from(curve: &PlanarCurve) -> CurveSnap { + // A chain of straight segments is what `key_vertices` means: the snap + // engine joins consecutive entries and offers the midpoint of each. Only + // a line or a polyline can promise that, so those two put their ends + // there and let their midpoints be derived; everything else names its own + // ends and middle explicitly. + // + // The same distinction is why a polyline's arc-segment centres are not + // emitted: a wire carrying a centre is treated as round elsewhere, which + // a polyline with one bulge in it is not. + let chain = matches!(curve.curve, Curve::Line(_) | Curve::Polyline(_)); + let mut out = CurveSnap::default(); + let mut push = |world: [f64; 3], hint: SnapHint| { + out.snap_pts.push((glam::DVec3::from_array(world), hint)); + }; + for point in characteristic_points(&curve.curve) { + let world = curve.plane.point_at(point.point); + match point.kind { + SnapKind::Endpoint if chain => out.key_vertices.push(world), + SnapKind::Endpoint => push(world, SnapHint::Endpoint), + SnapKind::Midpoint if !chain => push(world, SnapHint::Midpoint), + SnapKind::Centre if !chain => push(world, SnapHint::Center), + SnapKind::Quadrant => push(world, SnapHint::Quadrant), + _ => {} + } + } + out +} + +/// The magnitude the geometry's own coordinates sit at, for scaling a +/// relative tolerance. Floored at one so geometry near the origin does not +/// end up with a tolerance of nothing. +fn scale_of(points: &[Vector3]) -> f64 { + points + .iter() + .map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs())) + .fold(1.0, f64::max) +} + +fn is_default_normal(normal: Vector3) -> bool { + normal.x == 0.0 && normal.y == 0.0 && normal.z == 1.0 +} + +/// A unit normal, falling back to +Z for the degenerate vector some files +/// store. Zero would make every axis collapse and put the whole entity at +/// one point. +fn normalized(normal: Vector3) -> Vector3 { + match Vec3::from(xyz(normal)).normalize() { + Some(unit) => Vector3::new(unit.x, unit.y, unit.z), + None => Vector3::new(0.0, 0.0, 1.0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::lwpolyline::LwVertex; + use acadrust::entities::{Line as LineEnt, Ray as RayEnt, XLine as XLineEnt}; + use acadrust::types::Vector2; + use std::f64::consts::{FRAC_PI_2, PI, TAU}; + + fn v3(x: f64, y: f64, z: f64) -> Vector3 { + Vector3::new(x, y, z) + } + + /// Every point the curve reports, against a direct evaluation of the + /// entity's own fields. The property that matters: the converter must not + /// move anything. + fn assert_on(curve: &PlanarCurve, expected: impl Fn(f64) -> [f64; 3]) { + for i in 0..=8 { + let t = i as f64 / 8.0; + let got = curve.point_at(t); + let want = expected(t); + for axis in 0..3 { + assert!( + (got[axis] - want[axis]).abs() < 1e-9, + "t={t}: {got:?} vs {want:?}" + ); + } + } + } + + #[test] + fn a_flat_circle_lands_on_the_world_xy_plane() { + let mut circle = CircleEnt::default(); + circle.center = v3(10.0, 20.0, 4.0); + circle.radius = 3.0; + circle.normal = v3(0.0, 0.0, 1.0); + let curve = entity_curve(&EntityType::Circle(circle)).unwrap(); + assert!(curve.plane.is_xy_aligned()); + assert_on(&curve, |t| { + let a = t * TAU; + [10.0 + 3.0 * a.cos(), 20.0 + 3.0 * a.sin(), 4.0] + }); + } + + #[test] + fn an_extruded_arc_uses_the_arbitrary_axis_frame() { + // Normal along −Z: the frame flips, and a converter that ignored it + // would put the arc on the wrong side of the drawing. + let mut arc = ArcEnt::default(); + arc.center = v3(5.0, 0.0, 2.0); + arc.radius = 1.0; + arc.start_angle = 0.0; + arc.end_angle = FRAC_PI_2; + arc.normal = v3(0.0, 0.0, -1.0); + let curve = entity_curve(&EntityType::Arc(arc)).unwrap(); + assert!(!curve.plane.is_xy_aligned()); + assert_on(&curve, |t| { + let a = t * FRAC_PI_2; + let wcs = crate::scene::view::transform::ocs_point_to_wcs( + (5.0 + a.cos(), a.sin(), 2.0), + (0.0, 0.0, -1.0), + ); + [wcs.0, wcs.1, wcs.2] + }); + } + + #[test] + fn a_level_line_shares_the_world_frame() { + let mut line = LineEnt::default(); + line.start = v3(1.0, 2.0, 7.0); + line.end = v3(4.0, 6.0, 7.0); + let curve = entity_curve(&EntityType::Line(line)).unwrap(); + assert!(curve.plane.is_xy_aligned(), "{:?}", curve.plane); + assert_on(&curve, |t| [1.0 + 3.0 * t, 2.0 + 4.0 * t, 7.0]); + } + + #[test] + fn a_sloping_line_still_reports_its_own_points() { + // No XY frame contains it, so the plane is the upright one through + // the line. The points are what must not change. + let mut line = LineEnt::default(); + line.start = v3(0.0, 0.0, 0.0); + line.end = v3(3.0, 4.0, 12.0); + let curve = entity_curve(&EntityType::Line(line)).unwrap(); + assert!(!curve.plane.is_xy_aligned()); + assert_on(&curve, |t| [3.0 * t, 4.0 * t, 12.0 * t]); + } + + #[test] + fn a_vertical_line_is_not_a_degenerate_case() { + // `along × Z` is zero here, which is where an unguarded normal would + // collapse the frame and send every point to the origin. + let mut line = LineEnt::default(); + line.start = v3(2.0, 3.0, 0.0); + line.end = v3(2.0, 3.0, 10.0); + let curve = entity_curve(&EntityType::Line(line)).unwrap(); + assert_on(&curve, |t| [2.0, 3.0, 10.0 * t]); + } + + #[test] + fn a_zero_length_line_has_no_curve() { + let mut line = LineEnt::default(); + line.start = v3(1.0, 1.0, 1.0); + line.end = v3(1.0, 1.0, 1.0); + assert!(entity_curve(&EntityType::Line(line)).is_none()); + } + + #[test] + fn an_ellipse_keeps_its_axis_length_and_direction() { + let mut ellipse = EllipseEnt::default(); + ellipse.center = v3(10.0, 5.0, 0.0); + ellipse.major_axis = v3(0.0, 4.0, 0.0); // up, length 4 + ellipse.minor_axis_ratio = 0.5; + ellipse.start_parameter = 0.0; + ellipse.end_parameter = TAU; + ellipse.normal = v3(0.0, 0.0, 1.0); + let curve = entity_curve(&EntityType::Ellipse(ellipse)).unwrap(); + let Curve::Ellipse(arc) = &curve.curve else { + panic!("expected an ellipse"); + }; + assert!((arc.ellipse.major_radius - 4.0).abs() < 1e-12); + assert!((arc.ellipse.minor_radius - 2.0).abs() < 1e-12); + assert_on(&curve, |t| { + let a = t * TAU; + // Major axis points along +Y, so the roles of the two axes swap. + [10.0 - 2.0 * a.sin(), 5.0 + 4.0 * a.cos(), 0.0] + }); + } + + #[test] + fn an_ellipse_on_an_extruded_plane_stays_on_it() { + let mut ellipse = EllipseEnt::default(); + ellipse.center = v3(0.0, 0.0, 3.0); + ellipse.major_axis = v3(2.0, 0.0, 0.0); + ellipse.minor_axis_ratio = 0.5; + ellipse.start_parameter = 0.0; + ellipse.end_parameter = PI; + ellipse.normal = v3(0.0, 1.0, 0.0); + // The centre is off that plane, so the entity is inconsistent and the + // projection is what decides. What must hold is that the result is a + // planar curve whose points all sit on the plane it reports. + let curve = entity_curve(&EntityType::Ellipse(ellipse)).unwrap(); + for point in curve.tessellate(20.0) { + assert!(curve.plane.contains(point, 1e-9), "{point:?}"); + } + } + + #[test] + fn a_polyline_carries_its_bulges_and_closure() { + let mut polyline = LwPolylineEnt::default(); + polyline.elevation = 2.0; + polyline.is_closed = true; + polyline.normal = v3(0.0, 0.0, 1.0); + polyline.vertices = vec![ + LwVertex::with_bulge(Vector2::new(0.0, 0.0), 1.0), + LwVertex::from_coords(10.0, 0.0), + ]; + let curve = entity_curve(&EntityType::LwPolyline(polyline)).unwrap(); + assert!(curve.is_closed()); + // A bulge of 1 is a half circle. Left to right it dips below. + let points = curve.tessellate(20.0); + assert!(points.iter().all(|p| (p[2] - 2.0).abs() < 1e-12)); + assert!( + points.iter().any(|p| p[1] < -4.9), + "the semicircle should reach y = −5" + ); + } + + #[test] + fn a_two_point_minimum_is_enforced() { + let mut polyline = LwPolylineEnt::default(); + polyline.vertices = vec![LwVertex::from_coords(0.0, 0.0)]; + assert!(entity_curve(&EntityType::LwPolyline(polyline)).is_none()); + } + + #[test] + fn a_flat_spline_converts_and_a_spatial_one_does_not() { + let mut spline = SplineEnt::default(); + spline.degree = 3; + spline.fit_points = vec![ + v3(0.0, 0.0, 5.0), + v3(1.0, 2.0, 5.0), + v3(3.0, 1.0, 5.0), + v3(5.0, 4.0, 5.0), + ]; + spline.normal = v3(0.0, 0.0, 1.0); + let curve = entity_curve(&EntityType::Spline(spline.clone())).unwrap(); + assert!(curve.plane.is_xy_aligned()); + for point in curve.tessellate(20.0) { + assert!((point[2] - 5.0).abs() < 1e-9, "{point:?}"); + } + + // One point lifted out of the plane. The `planar` flag still says + // nothing; the geometry is what is checked. + spline.fit_points[2].z = 9.0; + assert!(entity_curve(&EntityType::Spline(spline)).is_none()); + } + + fn hints(snap: &CurveSnap, want: SnapHint) -> Vec { + snap.snap_pts + .iter() + .filter(|(_, hint)| std::mem::discriminant(hint) == std::mem::discriminant(&want)) + .map(|&(p, _)| p) + .collect() + } + + #[test] + fn an_arc_offers_the_quadrants_its_sweep_covers() { + let mut arc = ArcEnt::default(); + arc.center = v3(0.0, 0.0, 0.0); + arc.radius = 2.0; + arc.start_angle = 0.0; + arc.end_angle = PI; // the upper half + arc.normal = v3(0.0, 0.0, 1.0); + let snap = curve_snap(&EntityType::Arc(arc)).unwrap(); + let quadrants = hints(&snap, SnapHint::Quadrant); + // 0° and 90° and 180° are on it; 270° is not. + assert_eq!(quadrants.len(), 3, "{quadrants:?}"); + assert!(quadrants.iter().all(|q| q.y >= -1e-9)); + + assert_eq!(hints(&snap, SnapHint::Center), vec![glam::DVec3::ZERO]); + assert_eq!(hints(&snap, SnapHint::Midpoint).len(), 1); + // Ends go through the hint channel, not `key_vertices`: a pair there + // would also offer the chord's midpoint, which is not on the arc. + assert_eq!(hints(&snap, SnapHint::Endpoint).len(), 2); + assert!(snap.key_vertices.is_empty()); + } + + #[test] + fn a_polyline_puts_its_vertices_in_the_chain_channel() { + let mut polyline = LwPolylineEnt::default(); + polyline.normal = v3(0.0, 0.0, 1.0); + polyline.vertices = vec![ + LwVertex::from_coords(0.0, 0.0), + LwVertex::from_coords(10.0, 0.0), + LwVertex::from_coords(10.0, 5.0), + ]; + let snap = curve_snap(&EntityType::LwPolyline(polyline)).unwrap(); + assert_eq!(snap.key_vertices.len(), 3); + // Midpoints are derived from those by the snap engine, so emitting + // them here as well would offer every one of them twice. + assert!(snap.snap_pts.is_empty(), "{:?}", snap.snap_pts); + } + + #[test] + fn a_closed_curve_has_no_ends_to_offer() { + let mut circle = CircleEnt::default(); + circle.radius = 1.0; + circle.normal = v3(0.0, 0.0, 1.0); + let snap = curve_snap(&EntityType::Circle(circle)).unwrap(); + assert!(snap.key_vertices.is_empty()); + assert!(hints(&snap, SnapHint::Endpoint).is_empty()); + assert!(hints(&snap, SnapHint::Midpoint).is_empty()); + assert_eq!(hints(&snap, SnapHint::Quadrant).len(), 4); + } + + #[test] + fn entities_that_are_not_curves_say_so() { + assert!(entity_curve(&EntityType::Point(Default::default())).is_none()); + assert!(entity_curve(&EntityType::Text(Default::default())).is_none()); + assert!(entity_curve(&EntityType::Hatch(Default::default())).is_none()); + assert!(entity_curve(&EntityType::MText(Default::default())).is_none()); + // A 3D polyline is a curve but not a planar one, so it goes the same + // way rather than being flattened. + assert!(entity_curve(&EntityType::Polyline3D(Default::default())).is_none()); + } + + #[test] + fn a_zero_normal_falls_back_rather_than_collapsing() { + let mut circle = CircleEnt::default(); + circle.center = v3(1.0, 2.0, 0.0); + circle.radius = 1.0; + circle.normal = v3(0.0, 0.0, 0.0); + let curve = entity_curve(&EntityType::Circle(circle)).unwrap(); + assert!(curve.plane.is_xy_aligned()); + assert_eq!(curve.point_at(0.0), [2.0, 2.0, 0.0]); + } + + #[test] + fn a_ray_keeps_its_direction_and_a_construction_line_its_extent() { + let mut ray = RayEnt::default(); + ray.base_point = v3(1.0, 1.0, 0.0); + ray.direction = v3(2.0, 0.0, 0.0); + let curve = entity_curve(&EntityType::Ray(ray)).unwrap(); + assert_eq!(curve.extent(), acadrust::kernel::geom2d::Extent::Forward); + assert_eq!(curve.point_at(1.0), [3.0, 1.0, 0.0]); + + let mut line = XLineEnt::default(); + line.base_point = v3(0.0, 0.0, 0.0); + line.direction = v3(0.0, 3.0, 0.0); + let curve = entity_curve(&EntityType::XLine(line)).unwrap(); + assert_eq!(curve.extent(), acadrust::kernel::geom2d::Extent::Infinite); + assert_eq!(curve.point_at(-1.0), [0.0, -3.0, 0.0]); + } +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 69e81b14e..8d1d02182 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -2,6 +2,7 @@ pub mod arc; pub mod attribute; pub mod circle; pub mod common; +pub mod curve; pub mod dim_override; pub mod dimension; pub mod ellipse; diff --git a/src/modules/draw/modify/spline_ops.rs b/src/modules/draw/modify/spline_ops.rs index e13ef55e9..662f5a24a 100644 --- a/src/modules/draw/modify/spline_ops.rs +++ b/src/modules/draw/modify/spline_ops.rs @@ -11,6 +11,7 @@ use acadrust::entities::Spline; use acadrust::kernel::geom2d::{NurbsCurve, Parameterization}; +use acadrust::kernel::space::Plane; use acadrust::types::Vector3; use acadrust::Handle; @@ -38,13 +39,36 @@ fn elevation(spl: &Spline) -> f64 { /// any other instead of failing every command that touches it. /// /// `None` only when neither form has enough points to describe a curve. +/// +/// Reads the stored points as XY directly, which is right for the splines the +/// editing commands work on — they run in the world frame. A spline on an +/// extruded plane needs [`spline_to_nurbs_on`] instead. pub fn spline_to_nurbs(spl: &Spline) -> Option { + spline_to_nurbs_with(spl, |p| [p.x, p.y], |v| [v.x, v.y]) +} + +/// [`spline_to_nurbs`] with the points expressed in `plane`'s coordinates. +/// +/// The two differ only for a spline whose extrusion normal is not +Z. Where +/// it is, the projection reduces to reading X and Y, so this is the same +/// conversion with the frame made explicit. +/// +/// `None` additionally when the plane is degenerate, since there is then no +/// coordinate to express the points in. +pub fn spline_to_nurbs_on(spl: &Spline, plane: &Plane) -> Option { + let point = |p: &Vector3| plane.project([p.x, p.y, p.z]).unwrap_or([p.x, p.y]); + let vector = |v: &Vector3| plane.project_vector([v.x, v.y, v.z]).unwrap_or([v.x, v.y]); + plane.normal()?; + spline_to_nurbs_with(spl, point, vector) +} + +fn spline_to_nurbs_with( + spl: &Spline, + point: impl Fn(&Vector3) -> [f64; 2], + vector: impl Fn(&Vector3) -> [f64; 2], +) -> Option { let degree = (spl.degree.max(1)) as usize; - let control_points: Vec<[f64; 2]> = spl - .control_points - .iter() - .map(|p| [p.x, p.y]) - .collect(); + let control_points: Vec<[f64; 2]> = spl.control_points.iter().map(&point).collect(); let weights = (!spl.weights.is_empty()).then(|| spl.weights.clone()); if let Some(curve) = @@ -54,9 +78,10 @@ pub fn spline_to_nurbs(spl: &Spline) -> Option { } // No usable control polygon, so this is a fit-point spline. - let fit: Vec<[f64; 2]> = spl.fit_points.iter().map(|p| [p.x, p.y]).collect(); - let tangent = |v: &acadrust::types::Vector3| { - (v.x * v.x + v.y * v.y > 1e-18).then_some([v.x, v.y]) + let fit: Vec<[f64; 2]> = spl.fit_points.iter().map(&point).collect(); + let tangent = |v: &Vector3| { + let flat = vector(v); + (flat[0] * flat[0] + flat[1] * flat[1] > 1e-18).then_some(flat) }; NurbsCurve::interpolate( &fit, From b36e85e46e107c4ba2c7d19bad37c3764d58d871 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 00:43:44 +0300 Subject: [PATCH 08/18] refactor(snap): take an entity's snap points from its curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each entity built its own, and what they offered had drifted apart from what the geometry actually has: - An arc offered no quadrants at all. It now offers the ones its sweep covers, and no more. - A spline offered its control points as endpoints. For a control-point spline those are not on the curve, so the cursor landed in empty space beside the geometry. It now offers its two real ends and its midpoint, with the fit points — which are on the curve — still on offer. - An elliptical arc had no midpoint and no ends. A new SnapHint::Endpoint carries an end that cannot go in key_vertices. Consecutive entries there are taken to be joined by a straight segment and their midpoint offered, which is right for a line or a polyline and wrong for anything curved: an arc's two ends would also produce a snap out on the chord. Splines lose their key_vertices for the same reason — the chord between two fit points of a curvy spline does not run along it. A polyline's midpoints and arc-segment centres stay out of snap_pts deliberately. The engine already derives a chain's midpoints from its vertices, and a wire carrying a centre is treated as round elsewhere, which a polyline with one bulge in it is not. Co-Authored-By: Claude Opus 5 --- src/entities/arc.rs | 15 ++++++------- src/entities/circle.rs | 17 +++++--------- src/entities/ellipse.rs | 34 ++++++++-------------------- src/entities/spline.rs | 42 ++++++++++++++++++++++++++++++----- src/scene/model/wire_model.rs | 8 +++++++ src/snap.rs | 1 + 6 files changed, 66 insertions(+), 51 deletions(-) diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 2e08abcef..b254f8c14 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -10,7 +10,7 @@ use crate::entities::common::{ use crate::entities::traits::TruckConvertible; use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; -use crate::scene::model::wire_model::{SnapHint, TangentGeom}; +use crate::scene::model::wire_model::TangentGeom; const TAU: f64 = std::f64::consts::TAU; @@ -42,11 +42,10 @@ fn to_truck(arc: &Arc) -> TruckEntity { ) }; - let cv = glam::DVec3::new(cwx, cwy, cwz); - // Arc-length centre — one well-defined midpoint snap. Circles and - // ellipses (closed curves) deliberately don't emit this; see #34. - let mid_pt_3 = arc_pt(mid_a); - let mv = glam::DVec3::new(mid_pt_3.x, mid_pt_3.y, mid_pt_3.z); + // Centre, ends, arc-length midpoint and the quadrants the sweep actually + // covers, all from the entity's own curve. Circles and ellipses (closed + // curves) deliberately emit no midpoint; see #34. + let snap = crate::entities::curve::snap_from(&crate::entities::curve::arc_curve(arc)); let tangent = TangentGeom::Circle { center: [cwx as f32, cwy as f32, cwz as f32], radius: r as f32, @@ -81,7 +80,7 @@ fn to_truck(arc: &Arc) -> TruckEntity { return TruckEntity { pick_tris: extrusion_wall_tris(&base, [t * nx, t * ny, t * nz]), object: TruckObject::Lines(pts), - snap_pts: vec![(cv, SnapHint::Center), (mv, SnapHint::Midpoint)], + snap_pts: snap.snap_pts.clone(), tangent_geoms: vec![tangent], key_vertices: vec![], fill_tris: vec![], @@ -97,7 +96,7 @@ fn to_truck(arc: &Arc) -> TruckEntity { TruckEntity { pick_tris: Vec::new(), object: TruckObject::Curve(edge), - snap_pts: vec![(cv, SnapHint::Center), (mv, SnapHint::Midpoint)], + snap_pts: snap.snap_pts.clone(), tangent_geoms: vec![tangent], key_vertices: vec![], fill_tris: vec![], diff --git a/src/entities/circle.rs b/src/entities/circle.rs index 3c1a93f70..983aa473b 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -8,7 +8,7 @@ use crate::entities::common::{ use crate::entities::traits::TruckConvertible; use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; -use crate::scene::model::wire_model::{SnapHint, TangentGeom}; +use crate::scene::model::wire_model::TangentGeom; fn to_truck(circle: &Circle) -> TruckEntity { let cx = circle.center.x; @@ -20,18 +20,11 @@ fn to_truck(circle: &Circle) -> TruckEntity { let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); let (cwx, cwy, cwz) = crate::scene::view::transform::ocs_point_to_wcs((cx, cy, cz), normal); - let cv = glam::DVec3::new(cwx, cwy, cwz); let rf = r as f32; - let q = |d: (f64, f64, f64)| { - glam::DVec3::new(cwx + r * d.0, cwy + r * d.1, cwz + r * d.2) - }; - let snap_pts = vec![ - (cv, SnapHint::Center), - (q(ax), SnapHint::Quadrant), - (q(ay), SnapHint::Quadrant), - (q((-ax.0, -ax.1, -ax.2)), SnapHint::Quadrant), - (q((-ay.0, -ay.1, -ay.2)), SnapHint::Quadrant), - ]; + // Centre and quadrants come from the entity's own curve, so the same + // definition answers here, in the tessellation and in a trim. + let snap = crate::entities::curve::snap_from(&crate::entities::curve::circle_curve(circle)); + let snap_pts = snap.snap_pts; let tangent = TangentGeom::Circle { center: [cwx as f32, cwy as f32, cwz as f32], radius: rf, diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index cde496da1..3552433ba 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -9,7 +9,6 @@ use crate::entities::common::{ use crate::entities::traits::TruckConvertible; use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; -use crate::scene::model::wire_model::SnapHint; const TAU: f64 = std::f64::consts::TAU; @@ -43,8 +42,12 @@ fn to_truck(ell: &Ellipse) -> TruckEntity { // Minor axis direction: WCS_normal × u (both unit vectors, always perpendicular). let wcs_normal = glam::Vec3::new(nx as f32, ny as f32, nz as f32); let v_axis = wcs_normal.cross(u); - let center_v3 = glam::DVec3::new(cwx, cwy, cwz); let is_closed = (t1 - t0 - TAU).abs() < 1e-6; + // Centre, axis ends and — for an open arc — its own two ends and middle, + // all read off the entity's curve rather than rebuilt from these locals. + let snap = crate::entities::curve::ellipse_curve(ell) + .map(|curve| crate::entities::curve::snap_from(&curve)) + .unwrap_or_default(); if is_closed { let n = 16usize; @@ -81,21 +84,7 @@ fn to_truck(ell: &Ellipse) -> TruckEntity { let edge_upper = Edge::new(&v_pos, &v_neg, Curve::BSplineCurve(spl_u)); let edge_lower = Edge::new(&v_neg, &v_pos, Curve::BSplineCurve(spl_l)); let wire: Wire = [edge_upper, edge_lower].into_iter().collect(); - // Quadrant points at ±major and ±minor axis endpoints in WCS. - let q = |lx: f64, lz: f64| { - glam::DVec3::new( - cwx + lx * u.x as f64 + lz * v_axis.x as f64, - cwy + lx * u.y as f64 + lz * v_axis.y as f64, - cwz + lx * u.z as f64 + lz * v_axis.z as f64, - ) - }; - let snap_pts = vec![ - (center_v3, SnapHint::Center), - (q(r_major, 0.0), SnapHint::Quadrant), - (q(-r_major, 0.0), SnapHint::Quadrant), - (q(0.0, r_minor), SnapHint::Quadrant), - (q(0.0, -r_minor), SnapHint::Quadrant), - ]; + let snap_pts = snap.snap_pts.clone(); TruckEntity { pick_tris: Vec::new(), object: TruckObject::Contour(wire), @@ -123,18 +112,13 @@ fn to_truck(ell: &Ellipse) -> TruckEntity { let v_start = builder::vertex(*ctrl_pts.first().unwrap()); let v_end = builder::vertex(*ctrl_pts.last().unwrap()); let edge = Edge::new(&v_start, &v_end, Curve::BSplineCurve(bspline)); - let pt_start = ctrl_pts.first().unwrap(); - let pt_end = ctrl_pts.last().unwrap(); - let key_vertices: Vec<[f64; 3]> = vec![ - [pt_start.x, pt_start.y, pt_start.z], - [pt_end.x, pt_end.y, pt_end.z], - ]; + TruckEntity { pick_tris: Vec::new(), object: TruckObject::Curve(edge), - snap_pts: vec![(center_v3, SnapHint::Center)], + snap_pts: snap.snap_pts.clone(), tangent_geoms: vec![], - key_vertices, + key_vertices: vec![], fill_tris: vec![], } } diff --git a/src/entities/spline.rs b/src/entities/spline.rs index e644bc91f..a814e3b8f 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -87,12 +87,42 @@ fn to_truck(spl: &Spline) -> TruckEntity { ) }; - let snap_source = if !spl.fit_points.is_empty() { - &spl.fit_points - } else { - &spl.control_points + // Snap sources from the spline's own curve. A spline is not a chain of + // straight segments, so nothing goes in `key_vertices`: consecutive + // entries there are joined and their midpoints offered, and the chord + // between two fit points of a curvy spline does not run along it. + // + // Fit points are on the curve and stay on offer, as ends in their own + // right. Control points are not on the curve and are dropped — snapping + // to one put the cursor in empty space beside the geometry. + let curve_snap = crate::entities::curve::spline_curve(spl) + .map(|curve| crate::entities::curve::snap_from(&curve)); + let (snap_pts, key_vertices) = match curve_snap { + Some(snap) => { + let mut points = snap.snap_pts; + points.extend(spl.fit_points.iter().map(|p| { + ( + glam::DVec3::new(p.x, p.y, p.z), + crate::scene::model::wire_model::SnapHint::Endpoint, + ) + })); + (points, Vec::new()) + } + // A spline through points in space is not a planar curve, so the + // kernel has nothing to say about it. Its stored points remain the + // only thing to offer. + None => { + let source = if spl.fit_points.is_empty() { + &spl.control_points + } else { + &spl.fit_points + }; + ( + Vec::new(), + source.iter().map(|p| [p.x, p.y, p.z]).collect::>(), + ) + } }; - let key_vertices: Vec<[f64; 3]> = snap_source.iter().map(|p| [p.x, p.y, p.z]).collect(); let is_closed = spl.flags.closed || spl.flags.periodic; let gap = { @@ -120,7 +150,7 @@ fn to_truck(spl: &Spline) -> TruckEntity { TruckEntity { pick_tris: Vec::new(), object, - snap_pts: vec![], + snap_pts, tangent_geoms: vec![], key_vertices, fill_tris: vec![], diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 50e9467c0..3ee671e94 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -14,6 +14,14 @@ pub enum SnapHint { /// arc-length centre, a spline's `t = 0.5`). Lines / polylines do /// not use this — their midpoints are derived from `key_vertices`. Midpoint, + /// An end of a curve that is not a chain of straight segments — an arc, + /// an elliptical arc, a spline. + /// + /// Such a curve cannot put its ends in `key_vertices`: consecutive + /// entries there are taken to be joined by a straight segment, so an + /// arc's two ends would also produce a midpoint snap out on the chord, + /// which is not on the geometry. + Endpoint, } /// Geometric primitive used by the tangent-snap engine. diff --git a/src/snap.rs b/src/snap.rs index add3ee424..ffdee88b0 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1026,6 +1026,7 @@ impl Snapper { SnapHint::Quadrant => SnapType::Quadrant, SnapHint::Insertion => SnapType::Insertion, SnapHint::Midpoint => SnapType::Midpoint, + SnapHint::Endpoint => SnapType::Endpoint, }; if self.is_on(snap_type) { try_pt(world, snap_type); From c2cc6fd7c468eea5264a0bfdffaefb54221d05d8 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 00:43:53 +0300 Subject: [PATCH 09/18] refactor(trim): read boundaries and samples through the entity curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three per-type matches went; what is left is only the shape of the boundary record, which the edit options need to mutate — imply_edge_geos turns a segment into an infinite line and opens an arc into a full circle, neither of which a bare curve says. Two things change with it. A boundary spline is carried as a NURBS rather than as 64 chords, so a trim cuts where the spline actually runs; the sampled copy stays only for the two hand-rolled ray walks in the polyline-extend paths. And an extruded arc or circle is read in its own OCS instead of having its stored coordinates taken for world ones, so it acts as a boundary where it is drawn. sample_entity_xy is now one tessellation at the density the renderer uses, so a fence pass and a preview cut follow the drawn geometry rather than a coarser approximation of it. It declines to guess a length for a ray or an infinite line; fence_sample_xy, which has an answer, still supplies one. Co-Authored-By: Claude Opus 5 --- src/modules/draw/modify/trim.rs | 295 ++++++++++++++------------------ 1 file changed, 132 insertions(+), 163 deletions(-) diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index e8a763008..ccf16c37f 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -33,14 +33,14 @@ use acadrust::kernel::geom2d::{ intersect as kernel_intersect, Arc as KernelArc, Circle as KernelCircle, Curve, Extent as KernelExtent, Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc, Line as KernelLine, - Polyline as KernelPolyline, PolylineVertex as KernelVertex, Ray as KernelRay, - Tolerance as KernelTolerance, XLine as KernelXLine, + NurbsCurve, Ray as KernelRay, Tolerance as KernelTolerance, XLine as KernelXLine, }; +use crate::entities::curve::entity_curve_xy; + use crate::command::{CadCommand, CmdResult}; use crate::modules::draw::modify::spline_ops::{ - spline_cut, spline_nearest_t, spline_pts_wire, spline_range, spline_sample_xy, - spline_to_nurbs, t_to_rel, + spline_cut, spline_nearest_t, spline_pts_wire, spline_range, spline_to_nurbs, t_to_rel, }; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; @@ -74,6 +74,11 @@ pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[ /// Virtual extent used to represent infinite ends of Ray / XLine. const TRIM_EXTENT: f64 = 1_000_000.0; + +/// Sampling density for the plan-view point lists the fence and preview +/// passes walk. The renderer's own figure, so a preview cut lands where the +/// drawn geometry is rather than a chord away from it. +const SAMPLE_SEGMENTS_PER_RADIAN: f64 = acadrust::kernel::geom2d::DEFAULT_SEGMENTS_PER_RADIAN; /// If a trim interval endpoint is beyond this threshold it is treated as "infinite". #[derive(Clone)] @@ -125,9 +130,14 @@ enum Geo { t0: f64, // start parameter t1: f64, // end parameter (may be > 2π if wrapped) }, - /// Spline represented as sampled polyline segments (DXF XY). + /// 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, segs: Vec<([f64; 2], [f64; 2])>, }, } @@ -179,78 +189,80 @@ fn build_geos(entities: &[EntityType]) -> Vec { /// Ellipse / Spline) into a `Geo`, tagged with `h`. Returns `None` for types /// that do not act as trim boundaries. fn geo_from_entity(h: Handle, e: &EntityType) -> Option { - match e { - EntityType::Line(l) => Some(Geo::Line { - handle: h, - p1: [l.start.x, l.start.y], - p2: [l.end.x, l.end.y], - }), - EntityType::Arc(a) => Some(Geo::Arc { - handle: h, - cx: a.center.x, - cy: a.center.y, - r: a.radius, - a0: a.start_angle, - a1: a.end_angle, - }), - EntityType::Circle(c) => Some(Geo::Circle { - handle: h, - cx: c.center.x, - cy: c.center.y, - r: c.radius, - }), - EntityType::Ray(r) => Some(Geo::Ray { - handle: h, - bx: r.base_point.x, - by: r.base_point.y, - dx: r.direction.x, - dy: r.direction.y, - }), - EntityType::XLine(x) => Some(Geo::InfLine { - handle: h, - bx: x.base_point.x, - by: x.base_point.y, - dx: x.direction.x, - dy: x.direction.y, - }), - EntityType::Ellipse(e) => { - let mx = e.major_axis.x; - let my = e.major_axis.y; - let a = (mx * mx + my * my).sqrt(); - if a < 1e-9 { - return None; - } - let (nx, ny) = (mx / a, my / a); - let b = a * e.minor_axis_ratio; - let t0 = e.start_parameter; - let mut t1 = e.end_parameter; - if t1 <= t0 { - t1 += TAU; - } - Some(Geo::Ellipse { - handle: h, - cx: e.center.x, - cy: e.center.y, - a, - b, - nx, - ny, - t0, - t1, - }) - } - EntityType::Spline(s) => { - let (_, pts) = spline_sample_xy(s, 64); - if pts.len() < 2 { - return None; - } - let segs = pts - .windows(2) - .map(|w| ([w[0][0], w[0][1]], [w[1][0], w[1][1]])) - .collect(); - Some(Geo::Spline { handle: h, segs }) - } - _ => None, + // The geometry itself comes from the one converter, in plan-view + // coordinates. What is left here is only the shape of the boundary + // record, which the edit options need to mutate — `imply_edge_geos` turns + // a segment into an infinite line and opens an arc into a full circle, + // neither of which a bare curve says. + match entity_curve_xy(e)? { + Curve::Line(line) => Some(Geo::Line { + handle: h, + p1: line.start, + p2: line.end, + }), + Curve::Arc(arc) => Some(Geo::Arc { + handle: h, + cx: arc.centre[0], + cy: arc.centre[1], + r: arc.radius, + a0: arc.start_angle, + a1: arc.end_angle, + }), + Curve::Circle(circle) => Some(Geo::Circle { + handle: h, + cx: circle.centre[0], + cy: circle.centre[1], + r: circle.radius, + }), + Curve::Ray(ray) => Some(Geo::Ray { + handle: h, + bx: ray.origin[0], + by: ray.origin[1], + dx: ray.direction[0], + dy: ray.direction[1], + }), + Curve::XLine(line) => Some(Geo::InfLine { + handle: h, + bx: line.base[0], + by: line.base[1], + dx: line.direction[0], + dy: line.direction[1], + }), + Curve::Ellipse(arc) => { + let ellipse = arc.ellipse; + if ellipse.major_radius < 1e-9 { + return None; + } + let t0 = arc.start_parameter; + let mut t1 = arc.end_parameter; + if t1 <= t0 { + t1 += TAU; + } + Some(Geo::Ellipse { + handle: h, + cx: ellipse.centre[0], + cy: ellipse.centre[1], + a: ellipse.major_radius, + b: ellipse.minor_radius, + nx: ellipse.major_axis[0], + ny: ellipse.major_axis[1], + t0, + 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(), + }) + } + // A polyline reaches here already taken apart into its segments. + Curve::Polyline(_) => None, } } @@ -313,23 +325,11 @@ fn geo_to_curve(geo: &Geo) -> Option { start_parameter: *t0, end_parameter: *t1, }), - // Boundary splines arrive already sampled into segments, so they stay - // a chain of straight pieces here. - Geo::Spline { segs, .. } => { - let mut vertices: Vec = segs - .iter() - .map(|(from, _)| KernelVertex::straight(*from)) - .collect(); - let last = segs.last()?.1; - vertices.push(KernelVertex::straight(last)); - if vertices.len() < 2 { - return None; - } - Curve::Polyline(KernelPolyline { - vertices, - closed: false, - }) - } + // Exactly, not through its sampling. The kernel intersects a NURBS by + // subdivision against a bounding box, so a boundary spline cuts where + // it actually runs rather than where a 64-chord approximation of it + // does. + Geo::Spline { curve, .. } => Curve::Nurbs((**curve).clone()), }) } @@ -1128,7 +1128,7 @@ fn extend_lwpoly( } } } - Geo::Spline { handle, segs } => { + Geo::Spline { handle, segs, .. } => { if *handle == target { continue; } @@ -1306,7 +1306,7 @@ fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option } } } - Geo::Spline { handle, segs } => { + Geo::Spline { handle, segs, .. } => { if *handle == target { continue; } @@ -1959,15 +1959,8 @@ fn imply_edge_geos(geos: &mut [Geo]) { /// supports (adds Circle / Ray / XLine over `sample_entity_xy`). fn fence_sample_xy(e: &EntityType) -> Vec<[f64; 2]> { match e { - EntityType::Circle(c) => { - let steps = 64usize; - (0..=steps) - .map(|i| { - let a = TAU * (i as f64 / steps as f64); - [c.center.x + c.radius * a.cos(), c.center.y + c.radius * a.sin()] - }) - .collect() - } + // The two unbounded kinds, which `sample_entity_xy` declines to + // guess a length for. Here there is one: as far as a trim reaches. EntityType::Ray(r) => vec![ [r.base_point.x, r.base_point.y], [ @@ -3846,69 +3839,45 @@ fn extrim_circle(orig: &CircleEnt, ts: &[f64], side: &dyn Fn([f64; 2]) -> bool) } /// Sample any entity to a dense XY polyline for the sampled trim path. +/// Plan-view sampling of any entity, at the density the editing previews +/// have always used. +/// +/// One tessellation rather than a case per type. The per-type version this +/// replaced had drifted from the drawing's own: it sampled an arc at 16 +/// segments per radian against the renderer's 20, sampled a spline at a +/// fixed 96 points however long it was, and read an arc's centre as world +/// coordinates when the entity stores it in its OCS. +/// +/// Unbounded curves come back empty — a ray has no finite point list, and +/// how far to run it is the caller's decision. [`fence_sample_xy`] makes it. fn sample_entity_xy(e: &EntityType) -> Vec<[f64; 2]> { - match e { - EntityType::Line(l) => vec![[l.start.x, l.start.y], [l.end.x, l.end.y]], - EntityType::Arc(a) => { - let span = { - let s = norm(a.end_angle) - norm(a.start_angle); - if s <= 0.0 { - s + TAU - } else { - s - } - }; - let steps = (span.abs() * 16.0).ceil().max(4.0) as usize; - (0..=steps) - .map(|i| { - let ang = norm(a.start_angle) + span * (i as f64 / steps as f64); - [ - a.center.x + a.radius * ang.cos(), - a.center.y + a.radius * ang.sin(), - ] - }) - .collect() - } + // A polyline is sampled through its exploded segments so that a caller + // walking the result sees the same seams the rest of TRIM does. + if matches!( + e, EntityType::LwPolyline(_) - | EntityType::Polyline(_) - | EntityType::Polyline2D(_) - | EntityType::Polyline3D(_) => { - let mut pts: Vec<[f64; 2]> = Vec::new(); - for seg in crate::modules::draw::modify::explode::explode_polyline_segments(e) { - let sp = sample_entity_xy(&seg); - if pts.last() == sp.first() { - pts.extend_from_slice(&sp[1..]); - } else { - pts.extend(sp); - } + | EntityType::Polyline(_) + | EntityType::Polyline2D(_) + | EntityType::Polyline3D(_) + ) { + let mut pts: Vec<[f64; 2]> = Vec::new(); + for seg in crate::modules::draw::modify::explode::explode_polyline_segments(e) { + let sp = sample_entity_xy(&seg); + if pts.last() == sp.first() { + pts.extend_from_slice(&sp[1..]); + } else { + pts.extend(sp); } - pts } - EntityType::Ellipse(el) => { - let mx = el.major_axis.x; - let my = el.major_axis.y; - let a = (mx * mx + my * my).sqrt(); - if a < 1e-9 { - return vec![]; - } - let (nx, ny) = (mx / a, my / a); - let b = a * el.minor_axis_ratio; - let t0 = el.start_parameter; - let mut t1 = el.end_parameter; - if t1 <= t0 { - t1 += TAU; - } - ellipse_pts(el.center.x, el.center.y, a, b, nx, ny, t0, t1, el.center.z) - .into_iter() - .map(|p| [p[0] as f64, p[1] as f64]) - .collect() - } - EntityType::Spline(s) => { - let (_, pts) = spline_sample_xy(s, 96); - pts.into_iter().map(|p| [p[0], p[1]]).collect() - } - _ => vec![], + return pts; } + let Some(curve) = entity_curve_xy(e) else { + return vec![]; + }; + if curve.extent() != KernelExtent::Bounded { + return vec![]; + } + curve.tessellate(SAMPLE_SEGMENTS_PER_RADIAN) } /// Dense XY sampling of any entity for the removal preview (lines are From 90abfc21a95b72fed738cf3f301fe337d8f160a8 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 00:55:29 +0300 Subject: [PATCH 10/18] refactor(render): tessellate curved entities from their own curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The point lists were built three different ways and none of them scaled with the geometry. A full ellipse was cut into sixteen segments per half and an elliptical arc into thirty-two, whatever its size, so anything large read as a polygon; a circle worked out its own segment count from the chord tolerance; an arc was handed to truck's arc-through-three- points builder, which cancels at survey coordinates. All three now sample the entity's curve at the render pass's chord tolerance, so how round something looks follows how big it is and how far in the view is zoomed. The ellipse also stops casting its local coordinates to f32 on the way in — the vertex path splits f64 into a double-single pair precisely so that precision survives to the shader, and throwing it away first defeated that. What does not change is the kind of object each entity hands over. EXTRUDE, REVOLVE and SWEEP read their profile and path out of Contour or Curve and have no arm for a bare point list, so an arc or an ellipse given as Lines would quietly stop being usable as either. The arc keeps its circle_arc edge for that reason; only the ellipse's control points change, and they keep the same two-half-edge wire. The ellipse now decides it is closed from its own endpoints rather than from its stored parameters, so a file that writes an end a hair short of a full turn still produces a wire a plane can attach to. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/entities/arc.rs | 13 +-- src/entities/circle.rs | 32 ++----- src/entities/curve.rs | 16 ++++ src/entities/ellipse.rs | 191 ++++++++++++++++++---------------------- 5 files changed, 116 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aaaa6aff5..718fbb6c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#39083307452ba14b3968bdb0738e2665d3d18e62" +source = "git+https://github.com/HakanSeven12/cadkernel.git#9406868b1bedd57d121b11fc75658bf9eaa2c764" dependencies = [ "cavalier_contours", ] diff --git a/src/entities/arc.rs b/src/entities/arc.rs index b254f8c14..f55aa8e46 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -26,9 +26,6 @@ fn to_truck(arc: &Arc) -> TruckEntity { // Compute OCS basis vectors for this entity's normal. let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); - let ccw_end = if ea >= sa { ea } else { ea + TAU }; - let mid_a = sa + (ccw_end - sa) * 0.5; - // Arc centre in WCS. let (cwx, cwy, cwz) = crate::scene::view::transform::ocs_point_to_wcs((cx, cy, cz), normal); @@ -45,7 +42,8 @@ fn to_truck(arc: &Arc) -> TruckEntity { // Centre, ends, arc-length midpoint and the quadrants the sweep actually // covers, all from the entity's own curve. Circles and ellipses (closed // curves) deliberately emit no midpoint; see #34. - let snap = crate::entities::curve::snap_from(&crate::entities::curve::arc_curve(arc)); + let curve = crate::entities::curve::arc_curve(arc); + let snap = crate::entities::curve::snap_from(&curve); let tangent = TangentGeom::Circle { center: [cwx as f32, cwy as f32, cwz as f32], radius: r as f32, @@ -87,9 +85,14 @@ fn to_truck(arc: &Arc) -> TruckEntity { }; } + // Kept as a truck edge rather than a point list: EXTRUDE, REVOLVE and + // SWEEP take their profile and path from `Curve` / `Contour` and have no + // arm for `Lines`, so handing them one would quietly stop an arc being + // usable as either. let p_start = arc_pt(sa); let p_end = arc_pt(ea); - let p_mid = arc_pt(mid_a); + let ccw_end = if ea >= sa { ea } else { ea + TAU }; + let p_mid = arc_pt(sa + (ccw_end - sa) * 0.5); let v_start = builder::vertex(p_start); let v_end = builder::vertex(p_end); let edge = builder::circle_arc(&v_start, &v_end, p_mid); diff --git a/src/entities/circle.rs b/src/entities/circle.rs index 983aa473b..cfc65bcb1 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -23,8 +23,8 @@ fn to_truck(circle: &Circle) -> TruckEntity { let rf = r as f32; // Centre and quadrants come from the entity's own curve, so the same // definition answers here, in the tessellation and in a trim. - let snap = crate::entities::curve::snap_from(&crate::entities::curve::circle_curve(circle)); - let snap_pts = snap.snap_pts; + let curve = crate::entities::curve::circle_curve(circle); + let snap_pts = crate::entities::curve::snap_from(&curve).snap_pts; let tangent = TangentGeom::Circle { center: [cwx as f32, cwy as f32, cwz as f32], radius: rf, @@ -74,34 +74,16 @@ fn to_truck(circle: &Circle) -> TruckEntity { }; } - // Tessellate directly as a cos/sin polyline rather than a truck + // Sampled from the entity's own curve rather than as a truck // `circle_arc` (arc-through-three-points). At large WCS coordinates // (e.g. −1.2M UTM) the three-point fit cancels catastrophically — the // circle comes back with a ~3% radius wobble and uneven segment lengths, // which then throws off a dashed linetype's dash spacing. Direct - // evaluation only adds a small ±r term to the centre, so it stays precise; - // the `Lines` path RTE-splits the absolute-f64 points into the + // evaluation only adds a small ±r term to the centre, so it stays + // precise; the `Lines` path RTE-splits the absolute-f64 points into the // double-single the shader reconstructs. - let tol = crate::scene::convert::truck_tess::current_curve_tol(); - // Chord-height tolerance → segment count: sag = r·(1 − cos(π/N)). - let n = if r > tol { - (std::f64::consts::PI / (1.0 - tol / r).clamp(-1.0, 1.0).acos()) - .ceil() - .clamp(16.0, 4096.0) as usize - } else { - 16 - }; - let tau = std::f64::consts::TAU; - let mut pts: Vec<[f64; 3]> = Vec::with_capacity(n + 1); - for i in 0..=n { - let a = i as f64 * tau / n as f64; - let (s, c) = a.sin_cos(); - pts.push([ - cwx + r * (c * ax.0 + s * ay.0), - cwy + r * (c * ax.1 + s * ay.1), - cwz + r * (c * ax.2 + s * ay.2), - ]); - } + let pts = crate::entities::curve::curve_points(&curve); + TruckEntity { pick_tris: Vec::new(), object: TruckObject::Lines(pts), diff --git a/src/entities/curve.rs b/src/entities/curve.rs index 12182c71c..5c0102223 100644 --- a/src/entities/curve.rs +++ b/src/entities/curve.rs @@ -331,6 +331,22 @@ pub fn entity_curve_xy(entity: &EntityType) -> Option { }) } +/// World-space wire points for a curve, cut to the render pass's current +/// chord tolerance. +/// +/// The density comes from how far a chord may sag from the curve, not from a +/// segment count: an arc a metre across and one a kilometre across need +/// different numbers of points to look equally round, and the tolerance the +/// render pass carries is already zoom-adaptive. +/// +/// Everything stays `f64` to the end. The narrowing belongs at the GPU +/// boundary, where the residual is kept as the low half of a double-single +/// pair; casting local coordinates on the way in throws away precision the +/// shader was built to reconstruct. +pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> { + curve.tessellate_within(crate::scene::convert::truck_tess::current_curve_tol()) +} + /// The snap candidates an entity's curve offers, in the two channels the /// wire model carries them in. #[derive(Debug, Default, Clone)] diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index 3552433ba..8363d0da8 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -1,7 +1,9 @@ use acadrust::entities::Ellipse; -use crate::t; use truck_modeling::{builder, BSplineCurve, Curve, Edge, KnotVec, Point3, Wire}; +use crate::entities::curve::CurveSnap; +use crate::t; + use crate::command::EntityTransform; use crate::entities::common::{ center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip, @@ -10,118 +12,93 @@ use crate::entities::traits::TruckConvertible; use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; -const TAU: f64 = std::f64::consts::TAU; - fn to_truck(ell: &Ellipse) -> TruckEntity { - let normal = (ell.normal.x, ell.normal.y, ell.normal.z); - let (nx, ny, nz) = normal; - // ELLIPSE is one of the few WCS entities in DXF: `center` (code 10) and // `major_axis` (code 11) are world coordinates already — unlike ARC / - // CIRCLE, whose centers are OCS. (This used to run both through the - // arbitrary-axis OCS, which misplaced any ellipse whose normal isn't - // Z-up — e.g. the (0,0,-1) result of a mirrored-block explode.) - let (cwx, cwy, cwz) = (ell.center.x, ell.center.y, ell.center.z); - let wcs_maj = glam::Vec3::new( - ell.major_axis.x as f32, - ell.major_axis.y as f32, - ell.major_axis.z as f32, - ); - let r_major = wcs_maj.length() as f64; - let r_minor = r_major * ell.minor_axis_ratio; - let t0 = ell.start_parameter; - let mut t1 = ell.end_parameter; - if t1 <= t0 { - t1 += TAU; - } - let u = if r_major > 1e-9 { - wcs_maj / wcs_maj.length() - } else { - glam::Vec3::X - }; - // Minor axis direction: WCS_normal × u (both unit vectors, always perpendicular). - let wcs_normal = glam::Vec3::new(nx as f32, ny as f32, nz as f32); - let v_axis = wcs_normal.cross(u); - let is_closed = (t1 - t0 - TAU).abs() < 1e-6; - // Centre, axis ends and — for an open arc — its own two ends and middle, - // all read off the entity's curve rather than rebuilt from these locals. - let snap = crate::entities::curve::ellipse_curve(ell) - .map(|curve| crate::entities::curve::snap_from(&curve)) + // CIRCLE, whose centers are OCS. The converter knows that; this used to + // run both through the arbitrary-axis OCS, which misplaced any ellipse + // whose normal isn't Z-up — e.g. the (0,0,-1) result of a mirrored-block + // explode. + let curve = crate::entities::curve::ellipse_curve(ell); + let snap = curve + .as_ref() + .map(crate::entities::curve::snap_from) .unwrap_or_default(); - if is_closed { - let n = 16usize; - let pts_upper: Vec = (0..=n) - .map(|i| { - let t = (i as f64 / n as f64) * std::f64::consts::PI; - let lx = (r_major * t.cos()) as f32; - let lz = (r_minor * t.sin()) as f32; - Point3::new( - cwx + (lx * u.x + lz * v_axis.x) as f64, - cwy + (lx * u.y + lz * v_axis.y) as f64, - cwz + (lx * u.z + lz * v_axis.z) as f64, - ) - }) - .collect(); - let pts_lower: Vec = (0..=n) - .map(|i| { - let t = std::f64::consts::PI + (i as f64 / n as f64) * std::f64::consts::PI; - let lx = (r_major * t.cos()) as f32; - let lz = (r_minor * t.sin()) as f32; - Point3::new( - cwx + (lx * u.x + lz * v_axis.x) as f64, - cwy + (lx * u.y + lz * v_axis.y) as f64, - cwz + (lx * u.z + lz * v_axis.z) as f64, - ) - }) - .collect(); - let v_pos = builder::vertex(*pts_upper.first().unwrap()); - let v_neg = builder::vertex(*pts_upper.last().unwrap()); - let kv_u = KnotVec::uniform_knot(1, n); - let kv_l = KnotVec::uniform_knot(1, n); - let spl_u = BSplineCurve::new(kv_u, pts_upper); - let spl_l = BSplineCurve::new(kv_l, pts_lower); - let edge_upper = Edge::new(&v_pos, &v_neg, Curve::BSplineCurve(spl_u)); - let edge_lower = Edge::new(&v_neg, &v_pos, Curve::BSplineCurve(spl_l)); - let wire: Wire = [edge_upper, edge_lower].into_iter().collect(); - let snap_pts = snap.snap_pts.clone(); - TruckEntity { - pick_tris: Vec::new(), - object: TruckObject::Contour(wire), - snap_pts, - tangent_geoms: vec![], - key_vertices: vec![], - fill_tris: vec![], - } - } else { - let n = 32usize; - let ctrl_pts: Vec = (0..=n) - .map(|i| { - let t = t0 + (t1 - t0) * (i as f64 / n as f64); - let lx = (r_major * t.cos()) as f32; - let lz = (r_minor * t.sin()) as f32; - Point3::new( - cwx + (lx * u.x + lz * v_axis.x) as f64, - cwy + (lx * u.y + lz * v_axis.y) as f64, - cwz + (lx * u.z + lz * v_axis.z) as f64, - ) - }) - .collect(); - let kv = KnotVec::uniform_knot(1, n); - let bspline = BSplineCurve::new(kv, ctrl_pts.clone()); - let v_start = builder::vertex(*ctrl_pts.first().unwrap()); - let v_end = builder::vertex(*ctrl_pts.last().unwrap()); - let edge = Edge::new(&v_start, &v_end, Curve::BSplineCurve(bspline)); - - TruckEntity { - pick_tris: Vec::new(), - object: TruckObject::Curve(edge), - snap_pts: snap.snap_pts.clone(), - tangent_geoms: vec![], - key_vertices: vec![], - fill_tris: vec![], - } + // The points come from the entity's own curve, cut to the render pass's + // chord tolerance. The two fixed counts this replaced — sixteen per half + // for a full ellipse, thirty-two for an arc — faceted visibly on + // anything large, and the local coordinates were built in `f32`, which + // threw away precision the double-single vertex path exists to keep. + // + // What does not change is the shape of the object. EXTRUDE, REVOLVE and + // SWEEP read their profile out of `Contour` / `Curve` and have no arm for + // a bare point list, so an ellipse handed over as `Lines` would stop + // being usable as either. + let empty = |snap: CurveSnap| TruckEntity { + pick_tris: Vec::new(), + object: TruckObject::Lines(Vec::new()), + snap_pts: snap.snap_pts, + tangent_geoms: vec![], + key_vertices: vec![], + fill_tris: vec![], + }; + let Some(planar) = curve else { + return empty(snap); + }; + let points: Vec = crate::entities::curve::curve_points(&planar) + .into_iter() + .map(|p| Point3::new(p[0], p[1], p[2])) + .collect(); + if points.len() < 2 { + return empty(snap); } + + // Closure decided on the points rather than on the stored parameters. A + // file that writes an end parameter a hair short of a full turn still + // draws a closed ellipse, and an open edge whose two vertices sit on top + // of each other is not something a plane can be attached to. + let ends_meet = { + let (first, last) = (points[0], *points.last().expect("checked non-empty")); + let span = first.x.abs().max(first.y.abs()).max(first.z.abs()).max(1.0); + let gap = first - last; + (gap.x * gap.x + gap.y * gap.y + gap.z * gap.z).sqrt() <= 1e-9 * span + }; + let object = if planar.is_closed() || ends_meet { + // Two half-edges rather than one closed edge: a single edge whose + // two vertices coincide is not a wire truck will attach a plane to. + let split = points.len() / 2; + let upper = points[..=split].to_vec(); + let lower = points[split..].to_vec(); + let v_pos = builder::vertex(points[0]); + let v_neg = builder::vertex(points[split]); + let edge_upper = Edge::new(&v_pos, &v_neg, polyline_curve(upper)); + let edge_lower = Edge::new(&v_neg, &v_pos, polyline_curve(lower)); + TruckObject::Contour([edge_upper, edge_lower].into_iter().collect::()) + } else { + let v_start = builder::vertex(points[0]); + let v_end = builder::vertex(*points.last().expect("checked non-empty")); + TruckObject::Curve(Edge::new(&v_start, &v_end, polyline_curve(points))) + }; + + TruckEntity { + pick_tris: Vec::new(), + object, + snap_pts: snap.snap_pts, + tangent_geoms: vec![], + key_vertices: vec![], + fill_tris: vec![], + } +} + +/// A truck curve that passes through every one of `points` in order. +/// +/// Degree one, so the control polygon *is* the curve — the same thing the +/// hand-built sampling produced, without restating how a B-spline is put +/// together at each site. +fn polyline_curve(points: Vec) -> Curve { + let spans = points.len().saturating_sub(1); + Curve::BSplineCurve(BSplineCurve::new(KnotVec::uniform_knot(1, spans), points)) } fn grips(ell: &Ellipse) -> Vec { From ece440bcabba5318adef942b60dedc15c82155e2 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 01:08:45 +0300 Subject: [PATCH 11/18] refactor(curve): draw a fit spline and an offset preview from the curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places were left describing geometry for themselves. A fit-point SPLINE was drawn from a C² cubic solved in spline.rs while TRIM and snap cut a separate interpolation from the kernel. Near enough to look right, far enough apart that a trim landed beside the line it was aimed at. It now draws the curve those commands work on, and falls back to the local solve only where the kernel declines — a fit spline through points in space is not a planar curve, so there is nothing for it to say. Closed fit splines were worse than that: the kernel's interpolation is a clamped solve with no wrap in it, so the curve TRIM used never returned to its start and the seam cut nothing. Repeating the first fit point closes it. The seam is C¹ where the rest of the curve is C², which is the honest limit of a clamped solve — the alternative was a curve with a gap in it. OFFSET's preview sampling had drifted the same way TRIM's had: an arc and a circle read `center.x/.y` as world coordinates when the entity stores them in its OCS, so an extruded one previewed on the wrong side; every curved kind was cut at a fixed twenty segments per radian whatever its size; and an ellipse rebuilt its axis frame from the major axis' X and Y alone, dropping the Z the entity stores. A hundred lines of that become one tessellation. A RAY now previews as far as an XLINE does rather than as a stub the length of its direction vector. Co-Authored-By: Claude Opus 5 --- src/entities/curve.rs | 25 +++ src/entities/spline.rs | 49 ++++-- src/modules/draw/modify/offset.rs | 217 ++++++-------------------- src/modules/draw/modify/spline_ops.rs | 15 +- 4 files changed, 122 insertions(+), 184 deletions(-) diff --git a/src/entities/curve.rs b/src/entities/curve.rs index 5c0102223..7ce9d8490 100644 --- a/src/entities/curve.rs +++ b/src/entities/curve.rs @@ -685,6 +685,31 @@ mod tests { assert_eq!(hints(&snap, SnapHint::Quadrant).len(), 4); } + #[test] + fn a_closed_fit_point_spline_comes_back_closed() { + // The interpolation behind a fit-point spline is a clamped solve and + // does not model a wrap, so without closing the point list the curve + // ended somewhere else entirely — and a TRIM against it cut nothing + // along the seam. + let mut spline = SplineEnt::default(); + spline.degree = 3; + spline.flags.closed = true; + spline.fit_points = vec![ + v3(0.0, 0.0, 0.0), + v3(10.0, 0.0, 0.0), + v3(10.0, 10.0, 0.0), + v3(0.0, 10.0, 0.0), + ]; + spline.normal = v3(0.0, 0.0, 1.0); + let curve = entity_curve(&EntityType::Spline(spline)).unwrap(); + let (start, end) = (curve.point_at(0.0), curve.point_at(1.0)); + assert!( + (start[0] - end[0]).abs() < 1e-9 && (start[1] - end[1]).abs() < 1e-9, + "{start:?} vs {end:?}" + ); + assert!(curve.is_closed()); + } + #[test] fn entities_that_are_not_curves_say_so() { assert!(entity_curve(&EntityType::Point(Default::default())).is_none()); diff --git a/src/entities/spline.rs b/src/entities/spline.rs index a814e3b8f..a72824162 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -19,23 +19,48 @@ fn to_truck(spl: &Spline) -> TruckEntity { // smooth Catmull-Rom curve through them and hand it back as a dense // polyline so it still draws. if spl.fit_points.len() >= 2 { - let closed = spl.flags.closed || spl.flags.periodic; - // A fit spline is a C² cubic through its fit points honouring the - // stored end tangents. Catmull-Rom ignores those tangents (its ends - // use local slopes), so an open curve peels away from the true shape - // at the ends; interpolate properly instead. Closed splines wrap, - // which the clamped solve doesn't model, so they keep Catmull-Rom. - let pts = if closed { - catmull_rom_polyline(&spl.fit_points, true) + // Drawn from the same interpolation TRIM and snap use, so that + // what is on screen is the curve those commands cut. The two had + // parted: this drew a C² cubic solved here (or a Catmull-Rom for a + // closed spline), while the kernel interpolated its own — near + // enough to look right and far enough apart that a trim landed + // beside the line it was aimed at. + let planar = crate::entities::curve::spline_curve(spl); + let pts = match &planar { + Some(curve) => crate::entities::curve::curve_points(curve), + // A fit spline through points in space is not a planar curve, + // so the kernel has nothing to say about it and the solve + // here remains the only description of its shape. + None if spl.flags.closed || spl.flags.periodic => { + catmull_rom_polyline(&spl.fit_points, true) + } + None => fit_spline_polyline(spl), + }; + let snap = planar + .as_ref() + .map(crate::entities::curve::snap_from) + .unwrap_or_default(); + // Fit points are on the curve, so they stay on offer as ends in + // their own right — but never through `key_vertices`, whose + // consecutive entries the snap engine joins and offers the middle + // of. The chord between two fit points of a curvy spline does not + // run along it. + let mut snap_pts = snap.snap_pts; + snap_pts.extend(spl.fit_points.iter().map(|p| { + ( + glam::DVec3::new(p.x, p.y, p.z), + crate::scene::model::wire_model::SnapHint::Endpoint, + ) + })); + let key_vertices = if planar.is_some() { + Vec::new() } else { - fit_spline_polyline(spl) + spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect() }; - let key_vertices: Vec<[f64; 3]> = - spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect(); return TruckEntity { pick_tris: Vec::new(), object: TruckObject::Lines(pts), - snap_pts: vec![], + snap_pts, tangent_geoms: vec![], key_vertices, fill_tris: vec![], diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index 3438fd9c2..232eb6565 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -10,9 +10,8 @@ // 3. Pick a point on the side to offset toward, or choose Multiple // to keep offsetting the newly created result at the same distance -use std::f64::consts::TAU; - -use crate::modules::draw::modify::spline_ops::{spline_pts_wire, spline_sample_xy}; +use crate::entities::curve::entity_curve; +use crate::modules::draw::modify::spline_ops::spline_sample_xy; use acadrust::entities::LwVertex; use acadrust::entities::{ Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, @@ -23,8 +22,7 @@ use acadrust::{EntityType, Handle}; // from the kernel; only the entity conversion stays here. use acadrust::kernel::geom2d::nurbs::clamped_uniform_knots; use acadrust::kernel::geom2d::{ - normalize_angle as norm_rad, offset_polyline, Polyline as KernelPolyline, - PolylineVertex as KernelVertex, + offset_polyline, Polyline as KernelPolyline, PolylineVertex as KernelVertex, }; use glam::{DVec3, Vec3}; use crate::t; @@ -362,178 +360,55 @@ fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 { // ── Wire preview points ───────────────────────────────────────────────────── +/// Preview density, matching the figure the drawn wires use. +const PREVIEW_SEGMENTS_PER_RADIAN: f64 = acadrust::kernel::geom2d::DEFAULT_SEGMENTS_PER_RADIAN; + +/// Preview points for an entity, from its own curve. +/// +/// One tessellation rather than a case per type. What that case-per-type +/// version had accumulated: an arc and a circle read their `center.x/.y` as +/// world coordinates when the entity stores them in its OCS, so an extruded +/// one previewed on the wrong side of the drawing; every curved kind was cut +/// at a fixed twenty segments per radian whatever its size; and an ellipse +/// rebuilt its own axis frame from the major axis' X and Y alone, dropping +/// the Z the entity actually stores. +/// +/// The narrowing to `f32` stays here rather than in the kernel: a preview +/// wire leaves `points_low` empty and accepts the loss, while the resident +/// render path splits the `f64` into a high/low pair instead. Only a caller +/// knows which of the two it is. fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> { - match e { - EntityType::Line(l) => vec![ - [l.start.x as f32, l.start.y as f32, l.start.z as f32], - [l.end.x as f32, l.end.y as f32, l.end.z as f32], - ], - EntityType::Circle(c) => { - let steps = 64usize; - (0..=steps) - .map(|i| { - let a = TAU * i as f64 / steps as f64; - [ - (c.center.x + c.radius * a.cos()) as f32, - (c.center.y + c.radius * a.sin()) as f32, - c.center.z as f32, - ] - }) - .collect() - } - EntityType::Arc(a) => { - let a0 = norm_rad(a.start_angle); - let a1 = norm_rad(a.end_angle); - let span = { - let s = a1 - a0; - if s <= 0.0 { - s + TAU - } else { - s - } - }; - let steps = ((span.abs() * 20.0).ceil() as usize).max(4); - (0..=steps) - .map(|i| { - let ang = a0 + span * (i as f64 / steps as f64); - [ - (a.center.x + a.radius * ang.cos()) as f32, - (a.center.y + a.radius * ang.sin()) as f32, - a.center.z as f32, - ] - }) - .collect() - } - EntityType::LwPolyline(p) => lwpolyline_pts(p), - EntityType::Ellipse(e) => { - let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); - if a < 1e-9 { - return vec![]; - } - let b = a * e.minor_axis_ratio; - let nx = e.major_axis.x / a; - let ny = e.major_axis.y / a; - let t0 = e.start_parameter; - let mut t1 = e.end_parameter; - if t1 <= t0 { - t1 += TAU; - } - let span = t1 - t0; - let steps = ((span.abs() * 20.0).ceil() as usize).max(4); - (0..=steps) - .map(|i| { - let t = t0 + span * (i as f64 / steps as f64); - let lx = a * t.cos(); - let ly = b * t.sin(); - [ - (e.center.x + lx * nx - ly * ny) as f32, - (e.center.y + lx * ny + ly * nx) as f32, - e.center.z as f32, - ] - }) - .collect() - } - EntityType::Spline(s) => spline_pts_wire(s), - EntityType::XLine(x) => { - // Infinite in both directions — represent it as a very long segment - // for hit-testing and the offset preview. Long enough to read as - // infinite at any working zoom; the committed XLine renders true. - const HL: f64 = 1.0e6; - let (bx, by, bz) = (x.base_point.x, x.base_point.y, x.base_point.z); - let (dx, dy, dz) = (x.direction.x, x.direction.y, x.direction.z); - vec![ - [(bx - dx * HL) as f32, (by - dy * HL) as f32, (bz - dz * HL) as f32], - [(bx + dx * HL) as f32, (by + dy * HL) as f32, (bz + dz * HL) as f32], + // The two kinds with no finite point list of their own, so how far to run + // them is a decision rather than a measurement. Long enough to read as + // infinite at any working zoom; the committed entity renders true. A ray + // runs one way only. + const HL: f64 = 1.0e6; + let unbounded = match e { + EntityType::XLine(x) => Some((x.base_point, x.direction, -HL)), + EntityType::Ray(r) => Some((r.base_point, r.direction, 0.0)), + _ => None, + }; + if let Some((base, direction, back)) = unbounded { + let at = |k: f64| { + [ + (base.x + direction.x * k) as f32, + (base.y + direction.y * k) as f32, + (base.z + direction.z * k) as f32, ] - } - _ => vec![], + }; + return vec![at(back), at(HL)]; } -} - -/// Tessellate a LwPolyline into wire points (straight segments + arc bulges). -fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> { - let n = p.vertices.len(); - if n < 2 { + let Some(curve) = entity_curve(e) else { return vec![]; - } - let z = p.elevation as f32; - let n_segs = if p.is_closed { n } else { n - 1 }; - let mut pts: Vec<[f32; 3]> = Vec::new(); - - for i in 0..n_segs { - let v0 = &p.vertices[i]; - let v1 = &p.vertices[(i + 1) % n]; - let x0 = v0.location.x; - let y0 = v0.location.y; - let x1 = v1.location.x; - let y1 = v1.location.y; - - if pts.is_empty() { - pts.push([x0 as f32, y0 as f32, z]); - } - - if v0.bulge.abs() < 1e-10 { - pts.push([x1 as f32, y1 as f32, z]); - } else { - // Arc from bulge - let b = v0.bulge; - let chord_x = x1 - x0; - let chord_y = y1 - y0; - let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt(); - if chord_len < 1e-12 { - pts.push([x1 as f32, y1 as f32, z]); - continue; - } - - let b2 = b * b; - let r = chord_len * (1.0 + b2) / (4.0 * b.abs()); - let d = r * (1.0 - b2) / (1.0 + b2); - let mx = (x0 + x1) * 0.5; - let my = (y0 + y1) * 0.5; - let perp_x = -chord_y / chord_len; - let perp_y = chord_x / chord_len; - let sign = b.signum(); - let cx = mx + sign * d * perp_x; - let cy = my + sign * d * perp_y; - - let a0 = norm_rad((y0 - cy).atan2(x0 - cx)); - let a1 = norm_rad((y1 - cy).atan2(x1 - cx)); - let span = if b > 0.0 { - let s = a1 - a0; - if s <= 0.0 { - s + TAU - } else { - s - } - } else { - let s = a0 - a1; - if s <= 0.0 { - s + TAU - } else { - s - } - }; - let steps = ((span.abs() * 20.0).ceil() as usize).max(4); - for j in 1..=steps { - let t = j as f64 / steps as f64; - let ang = if b > 0.0 { - a0 + span * t - } else { - a0 - span * t - }; - pts.push([(cx + r * ang.cos()) as f32, (cy + r * ang.sin()) as f32, z]); - } - } - } - - if p.is_closed { - if let Some(&first) = pts.first() { - pts.push(first); - } - } - pts + }; + curve + .tessellate(PREVIEW_SEGMENTS_PER_RADIAN) + .into_iter() + .map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]) + .collect::>() } + // ── Command implementation ───────────────────────────────────────────────── enum Step { diff --git a/src/modules/draw/modify/spline_ops.rs b/src/modules/draw/modify/spline_ops.rs index 662f5a24a..184a0fccc 100644 --- a/src/modules/draw/modify/spline_ops.rs +++ b/src/modules/draw/modify/spline_ops.rs @@ -78,7 +78,20 @@ fn spline_to_nurbs_with( } // No usable control polygon, so this is a fit-point spline. - let fit: Vec<[f64; 2]> = spl.fit_points.iter().map(&point).collect(); + let mut fit: Vec<[f64; 2]> = spl.fit_points.iter().map(&point).collect(); + if spl.flags.closed || spl.flags.periodic { + // The interpolation is a clamped solve and does not model a wrap, so + // a closed spline came back as an open curve that never returned to + // its start — and a TRIM against it then cut nothing along the seam. + // Repeating the first point closes it. The seam is C¹ rather than the + // C² the rest of the curve has, which is the honest limit of a + // clamped solve; the alternative was a curve with a gap in it. + if let (Some(&first), Some(&last)) = (fit.first(), fit.last()) { + if first != last { + fit.push(first); + } + } + } let tangent = |v: &Vector3| { let flat = vector(v); (flat[0] * flat[0] + flat[1] * flat[1] > 1e-18).then_some(flat) From e6345f89f73c576ac74b1e0a3f3788fd6515d887 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 01:15:51 +0300 Subject: [PATCH 12/18] feat(i18n): expand language coverage Complete missing keys in the existing translations and add selectable Spanish, Brazilian Portuguese, Arabic, and Japanese resources.\n\nThe large compatibility catalogs for the new locales remain in progress. --- locales/ar-SA/opencadstudio.ftl | 182 ++++++++++ locales/de-DE/opencadstudio.ftl | 56 +++ locales/en-US/opencadstudio.ftl | 4 + locales/es-ES/opencadstudio.ftl | 594 ++++++++++++++++++++++++++++++++ locales/fr-FR/opencadstudio.ftl | 56 +++ locales/hi-IN/opencadstudio.ftl | 56 +++ locales/ja-JP/opencadstudio.ftl | 160 +++++++++ locales/nl-NL/opencadstudio.ftl | 56 +++ locales/pt-BR/opencadstudio.ftl | 170 +++++++++ locales/ru-RU/opencadstudio.ftl | 56 +++ locales/tr-TR/opencadstudio.ftl | 14 + locales/zh-CN/opencadstudio.ftl | 56 +++ src/i18n.rs | 28 +- 13 files changed, 1487 insertions(+), 1 deletion(-) create mode 100644 locales/ar-SA/opencadstudio.ftl create mode 100644 locales/es-ES/opencadstudio.ftl create mode 100644 locales/ja-JP/opencadstudio.ftl create mode 100644 locales/pt-BR/opencadstudio.ftl diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl new file mode 100644 index 000000000..afde60bd8 --- /dev/null +++ b/locales/ar-SA/opencadstudio.ftl @@ -0,0 +1,182 @@ +language-system = لغة النظام +language-english = English +language-turkish = Türkçe +language-dutch = Nederlands +language-french = Français +language-german = Deutsch +language-hindi = हिन्दी +language-russian = Русский +language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 + +action-close = إغلاق +action-options = الخيارات +action-plugins = الإضافات + +ribbon-tab-draw = رسم +ribbon-tab-annotate = تعليقات توضيحية +ribbon-tab-insert = إدراج +ribbon-tab-model = نموذج +ribbon-tab-layout = تخطيط +ribbon-tab-manage = إدارة +ribbon-tab-view = عرض + +options-language-section = اللغة +options-language-label = لغة الواجهة: +options-open-save-section = الفتح والحفظ +options-default-save-format-label = تنسيق الحفظ الافتراضي: +options-default-save-format-help = يُستخدم عند الحفظ الأول لرسم جديد. تحتفظ الرسومات الموجودة بنوع الملف وإصداره. +options-theme-section = السمة +options-theme-label = سمة التطبيق: +options-theme-help = يؤدي تغيير لون أساسي إلى الانتقال إلى السمة المخصصة. تُنشئ السمة جميع درجات المكونات من هذه الألوان الستة. +options-color-background = الخلفية +options-color-text = النص +options-color-primary = أساسي +options-color-success = نجاح +options-color-warning = تحذير +options-color-danger = خطر + +command-line-ready = ‏Open CAD Studio جاهز. +command-line-hint = اكتب أمرًا أو استخدم الشريط. افتح OBJ من علامة التبويب إدراج. +command-line-label = الأمر: +command-line-literal-spaces = المسافات الحرفية: يبقى مفتاح المسافة في السطر بدلًا من تنفيذ الأمر. يظل مفعّلًا حتى إيقافه. + +area-option-objects = الكائنات +area-option-back = رجوع +area-option-add = إضافة مساحة +area-option-subtract = طرح مساحة +area-prompt-first = AREA حدد نقطة الزاوية الأولى أو [الكائنات/إضافة مساحة/طرح مساحة] <الكائنات>: +area-prompt-add = AREA إضافة حدد نقطة الزاوية الأولى أو [الكائنات/طرح مساحة] <الكائنات>: +area-prompt-subtract = AREA طرح حدد نقطة الزاوية الأولى أو [الكائنات/إضافة مساحة] <الكائنات>: +area-prompt-next = AREA حدد النقطة التالية (تم اختيار { $count }، Enter للحساب): +area-prompt-objects = AREA حدد الكائنات (تم تحديد { $count }، Enter للحساب): +area-objects-not-measurable = AREA: لا يحتوي أي من الكائنات المحددة على مساحة قابلة للقياس. +area-result = المساحة = { $area }، المحيط = { $perimeter } +area-result-area-only = المساحة = { $area } +area-running-result = + المساحة = { $area }، المحيط = { $perimeter } + المساحة الإجمالية = { $total_area }، المحيط الإجمالي = { $total_perimeter } +area-running-result-area-only = + المساحة = { $area } + المساحة الإجمالية = { $total_area } + +start-new-drawing = رسم جديد +start-open-file = فتح ملف… +start-donate = تبرع +start-send-feedback = إرسال ملاحظات +start-sponsors = الرعاة +start-tutorials = الدروس التعليمية +start-loading-videos = جارٍ تحميل مقاطع الفيديو… +start-videos-online = تُحمّل مقاطع الفيديو من الإنترنت. +start-open-playlist = فتح قائمة التشغيل على YouTube +start-discussions = المناقشات +start-pinned = المثبتة +start-loading-discussions = جارٍ تحميل المناقشات… +start-discussions-online = تُحمّل المناقشات من GitHub. +start-open-discussions = فتح المناقشات على GitHub +start-supporters = الداعمون +start-support-on-patreon = الدعم على Patreon +start-recent-files = الملفات الأخيرة +start-videos = مقاطع الفيديو +start-welcome = مرحبًا +start-recent-documents = المستندات الأخيرة +start-no-recent-files = ستظهر الملفات التي تفتحها هنا. +start-browser-storage = تخزين المتصفح +start-keep-recent-files = الاحتفاظ بالملفات الأخيرة + +modal-about = حول +modal-keyboard-shortcuts = اختصارات لوحة المفاتيح +modal-command-aliases = الأسماء البديلة للأوامر +modal-find-replace = بحث واستبدال +modal-plugin-manager = مدير الإضافات +modal-update-available = يتوفر تحديث +modal-layer-manager = مدير الطبقات +modal-layer-state-manager = مدير حالات الطبقات +modal-edit-layer-state = تحرير حالة الطبقة +modal-plot = طباعة +modal-layout-manager = مدير التخطيطات +modal-scale-manager = مدير المقاييس +modal-annotation-object-scale = مقياس كائن التعليقات التوضيحية +modal-plot-style-editor = محرر أنماط الطباعة +modal-text-style-manager = مدير أنماط النص +modal-multiline-style-manager = مدير أنماط الخطوط المتعددة +modal-table-style-manager = مدير أنماط الجداول +modal-multileader-style-manager = مدير أنماط القادة المتعددين +modal-dimension-style-manager = مدير أنماط الأبعاد +modal-default-application = التطبيق الافتراضي +modal-save-warning = تحذير الحفظ +modal-unable-save = تعذر حفظ الرسم +modal-drawing-changed = تغير الرسم على القرص +modal-delete-layer = حذف الطبقة +modal-unsaved-changes = تغييرات غير محفوظة +modal-unsaved-save-prompt = هل تريد حفظ التغييرات؟ +clipboard-no-supported-content = لا تحتوي الحافظة على نص أو كائنات رسم مدعومة. +clipboard-unavailable = حافظة النظام غير متاحة. +clipboard-occupied = حافظة النظام قيد الاستخدام حاليًا. حاول مرة أخرى. +clipboard-conversion-failed = تعذرت قراءة نص الحافظة. +viewport-split-vertical = تقسيم منفذ العرض رأسيًا +viewport-split-horizontal = تقسيم منفذ العرض أفقيًا +viewport-move = نقل منفذ العرض +viewport-close = إغلاق منفذ العرض +modal-point-style = نمط النقطة +modal-attribute-editor = محرر السمات +modal-save-drawing-as = حفظ الرسم باسم +modal-recovery-report = تقرير استرداد الرسم +modal-recovery-prompt = استرداد الرسم + +recovery-opened-with-repairs = فُتح الرسم مع تقرير استرداد +recovery-open-failed = تعذر فتح الرسم +recovery-repaired-description = اكتُشفت مشكلات أثناء فتح الرسم. راجع التقرير؛ وإذا أُصلحت بيانات الرسم، فاحفظه كملف جديد. +recovery-failed-description = تعذر على الاسترداد إنتاج بيانات رسم قابلة للاستخدام. راجع التقرير وجرّب نسخة احتياطية أو نسخة حفظ تلقائي. +recovery-entities-checked = الكيانات التي تم فحصها +recovery-issues-found = المشكلات الموجودة +recovery-entities-removed = الكيانات التي تمت إزالتها +recovery-references-checked = المراجع التي تم فحصها +recovery-referenced-entities-removed = الكيانات المرجعية التي تمت إزالتها +recovery-references-unavailable = المراجع غير المتاحة +recovery-log-path = سجل الاسترداد +recovery-log-write-failed = تعذرت كتابة سجل الاسترداد +recovery-log-download-ready = تقرير الاسترداد جاهز للتنزيل. +recovery-save-copy = حفظ النسخة التي تم إصلاحها +recovery-show-log = إظهار سجل الاسترداد +recovery-save-new-file-required = يجب حفظ الرسم الذي تم إصلاحه في ملف جديد. +recovery-prompt-heading = فشل الفتح العادي +recovery-prompt-description = لم يتغير الرسم. يمكن لوضع الاسترداد محاولة إنقاذ البيانات القابلة للاستخدام، لكن قد يتم تخطي السجلات التالفة. هل تريد المتابعة؟ +recovery-attempt = محاولة الاسترداد +recovery-decline = إلغاء + +command-move-base = + MOVE حدد نقطة الأساس [{ $count -> + [zero] لا توجد كائنات + [one] كائن واحد + [two] كائنان + [few] { $count } كائنات + [many] { $count } كائنًا + *[other] { $count } كائن + }]: +command-move-target = MOVE حدد الوجهة [الأساس { $x },{ $y }]: +command-copy-array-count = COPY أدخل عدد عناصر المصفوفة: +command-copy-base = + COPY حدد نقطة الأساس [{ $count -> + [zero] لا توجد كائنات + [one] كائن واحد + [two] كائنان + [few] { $count } كائنات + [many] { $count } كائنًا + *[other] { $count } كائن + }]: +command-copy-array-target = COPY حدد النقطة الثانية للمصفوفة ذات { $count } عنصر [الأساس { $x },{ $y }]: +command-copy-target = + COPY حدد الوجهة [{ $count -> + [zero] لا توجد نسخ حتى الآن + [one] نسخة واحدة حتى الآن + [two] نسختان حتى الآن + [few] { $count } نسخ حتى الآن + [many] { $count } نسخة حتى الآن + *[other] { $count } نسخة حتى الآن + } | مصفوفة | Enter=إنهاء | الأساس { $x },{ $y }]: + +# كتالوج التطبيق الكامل diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 793460551..fdf6e4ae4 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -7,6 +7,10 @@ language-german = Deutsch language-hindi = हिन्दी language-russian = Русский language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 action-close = Schließen action-options = Optionen @@ -3384,3 +3388,55 @@ style-manager-center-of-text = Textzentrum style-manager-center-of-text-overline = Textzentrum mit Oberstrich style-manager-block-extents = Blockgrenzen style-manager-base-point = Basispunkt + +area-option-objects = Objekte +area-option-back = Zurück +area-option-add = Fläche addieren +area-option-subtract = Fläche subtrahieren +area-prompt-first = AREA Ersten Eckpunkt angeben oder [Objekte/Fläche addieren/Fläche subtrahieren] : +area-prompt-add = AREA ADDIEREN Ersten Eckpunkt angeben oder [Objekte/Fläche subtrahieren] : +area-prompt-subtract = AREA SUBTRAHIEREN Ersten Eckpunkt angeben oder [Objekte/Fläche addieren] : +area-prompt-next = AREA Nächsten Punkt angeben ({ $count } gewählt, Eingabetaste zum Berechnen): +area-prompt-objects = AREA Objekte auswählen ({ $count } ausgewählt, Eingabetaste zum Berechnen): +area-objects-not-measurable = AREA: Keines der ausgewählten Objekte hat eine messbare Fläche. +area-result = Fläche = { $area }, Umfang = { $perimeter } +area-result-area-only = Fläche = { $area } +area-running-result = + Fläche = { $area }, Umfang = { $perimeter } + Gesamtfläche = { $total_area }, Gesamtumfang = { $total_perimeter } +area-running-result-area-only = + Fläche = { $area } + Gesamtfläche = { $total_area } + +modal-recovery-report = Zeichnungswiederherstellungsbericht +modal-recovery-prompt = Zeichnungswiederherstellung +recovery-opened-with-repairs = Zeichnung mit einem Wiederherstellungsbericht geöffnet +recovery-open-failed = Zeichnung konnte nicht geöffnet werden +recovery-repaired-description = Beim Öffnen der Zeichnung wurden Probleme erkannt. Prüfen Sie den Bericht; wenn Zeichnungsdaten repariert wurden, speichern Sie die Zeichnung als neue Datei. +recovery-failed-description = Die Wiederherstellung konnte keine verwendbaren Zeichnungsdaten erzeugen. Prüfen Sie den Bericht und versuchen Sie eine Sicherungskopie oder automatisch gespeicherte Kopie. +recovery-entities-checked = Geprüfte Objekte +recovery-issues-found = Gefundene Probleme +recovery-entities-removed = Entfernte Objekte +recovery-references-checked = Geprüfte Referenzen +recovery-referenced-entities-removed = Entfernte referenzierte Objekte +recovery-references-unavailable = Nicht verfügbare Referenzen +recovery-log-path = Wiederherstellungsprotokoll +recovery-log-write-failed = Wiederherstellungsprotokoll konnte nicht geschrieben werden +recovery-log-download-ready = Der Wiederherstellungsbericht kann heruntergeladen werden. +recovery-save-copy = Reparierte Kopie speichern +recovery-show-log = Wiederherstellungsprotokoll anzeigen +recovery-save-new-file-required = Eine reparierte Zeichnung muss als neue Datei gespeichert werden. +recovery-prompt-heading = Normales Öffnen ist fehlgeschlagen +recovery-prompt-description = Die Zeichnung wurde nicht verändert. Der Wiederherstellungsmodus kann versuchen, verwendbare Daten zu retten; beschädigte Datensätze können dabei übersprungen werden. Möchten Sie fortfahren? +recovery-attempt = Wiederherstellung versuchen +recovery-decline = Abbrechen + +catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Textobjekte auswählen und dann die Eingabetaste drücken: +catalog-tcount-starting-number-start-7471e47711 = TCOUNT Startnummer <__ocs_fmt_0__>: +catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Schrittweite <__ocs_fmt_0__>: +catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Platzierung [Überschreiben/Präfix/Suffix] <Überschreiben>: +catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: Die Platzierung muss Überschreiben, Präfix oder Suffix sein. +catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: __ocs_fmt_0__ Textobjekt(e) ab __ocs_fmt_1__ in Schritten von __ocs_fmt_2__ nummeriert (__ocs_fmt_3__). +catalog-prefix-90eceb016c = Präfix +catalog-suffix-885db975a3 = Suffix +catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = Das Öffnen einer Zeichnung über einen Pfad ist im Browser nicht verfügbar. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 8763f703a..503a9d482 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -7,6 +7,10 @@ language-german = Deutsch language-hindi = हिन्दी language-russian = Русский language-chinese-simplified = Simplified Chinese +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 action-close = Close action-options = Options diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl new file mode 100644 index 000000000..8a4cadae3 --- /dev/null +++ b/locales/es-ES/opencadstudio.ftl @@ -0,0 +1,594 @@ +language-system = Idioma del sistema +language-english = English +language-turkish = Türkçe +language-dutch = Nederlands +language-french = Français +language-german = Deutsch +language-hindi = हिन्दी +language-russian = Русский +language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 + +action-close = Cerrar +action-options = Opciones +action-plugins = Complementos + +ribbon-tab-draw = Dibujo +ribbon-tab-annotate = Anotar +ribbon-tab-insert = Insertar +ribbon-tab-model = Modelo +ribbon-tab-layout = Presentación +ribbon-tab-manage = Administrar +ribbon-tab-view = Vista + +options-language-section = Idioma +options-language-label = Idioma de la interfaz: +options-open-save-section = Abrir y guardar +options-default-save-format-label = Formato de guardado predeterminado: +options-default-save-format-help = Se utiliza al guardar por primera vez un dibujo nuevo. Los dibujos existentes conservan su tipo y versión de archivo. +options-theme-section = Tema +options-theme-label = Tema de la aplicación: +options-theme-help = Al cambiar un color base se activa el tema Personalizado. El tema genera todos los tonos de los componentes a partir de estos seis colores. +options-color-background = Fondo +options-color-text = Texto +options-color-primary = Principal +options-color-success = Correcto +options-color-warning = Advertencia +options-color-danger = Peligro + +command-line-ready = Open CAD Studio está listo. +command-line-hint = Escriba un comando o utilice la cinta. Abra archivos OBJ desde la pestaña Insertar. +command-line-label = Comando: +command-line-literal-spaces = Espacios literales: el espacio permanece en la línea en lugar de ejecutar el comando. Sigue activo hasta que se desactive. + +area-option-objects = Objetos +area-option-back = Atrás +area-option-add = Añadir área +area-option-subtract = Restar área +area-prompt-first = AREA Especifique el primer punto de esquina o [Objetos/Añadir área/Restar área] : +area-prompt-add = AREA AÑADIR Especifique el primer punto de esquina o [Objetos/Restar área] : +area-prompt-subtract = AREA RESTAR Especifique el primer punto de esquina o [Objetos/Añadir área] : +area-prompt-next = AREA Especifique el punto siguiente ({ $count } seleccionados, Enter para calcular): +area-prompt-objects = AREA Seleccione objetos ({ $count } seleccionados, Enter para calcular): +area-objects-not-measurable = AREA: ninguno de los objetos seleccionados tiene un área medible. +area-result = Área = { $area }, Perímetro = { $perimeter } +area-result-area-only = Área = { $area } +area-running-result = + Área = { $area }, Perímetro = { $perimeter } + Área total = { $total_area }, Perímetro total = { $total_perimeter } +area-running-result-area-only = + Área = { $area } + Área total = { $total_area } + +start-new-drawing = Dibujo nuevo +start-open-file = Abrir archivo… +start-donate = Donar +start-send-feedback = Enviar comentarios +start-sponsors = Patrocinadores +start-tutorials = Tutoriales +start-loading-videos = Cargando vídeos… +start-videos-online = Los vídeos se cargan desde Internet. +start-open-playlist = Abrir lista de reproducción en YouTube +start-discussions = Debates +start-pinned = Fijados +start-loading-discussions = Cargando debates… +start-discussions-online = Los debates se cargan desde GitHub. +start-open-discussions = Abrir debates en GitHub +start-supporters = Colaboradores +start-support-on-patreon = Apoyar en Patreon +start-recent-files = Archivos recientes +start-videos = Vídeos +start-welcome = Bienvenido +start-recent-documents = Documentos recientes +start-no-recent-files = Los archivos que abra aparecerán aquí. +start-browser-storage = Almacenamiento del navegador +start-keep-recent-files = Conservar archivos recientes + +modal-about = Acerca de +modal-keyboard-shortcuts = Métodos abreviados de teclado +modal-command-aliases = Alias de comandos +modal-find-replace = Buscar y reemplazar +modal-plugin-manager = Administrador de complementos +modal-update-available = Actualización disponible +modal-layer-manager = Administrador de capas +modal-layer-state-manager = Administrador de estados de capa +modal-edit-layer-state = Editar estado de capa +modal-plot = Trazar +modal-layout-manager = Administrador de presentaciones +modal-scale-manager = Administrador de escalas +modal-annotation-object-scale = Escala del objeto anotativo +modal-plot-style-editor = Editor de estilos de trazado +modal-text-style-manager = Administrador de estilos de texto +modal-multiline-style-manager = Administrador de estilos de líneas múltiples +modal-table-style-manager = Administrador de estilos de tabla +modal-multileader-style-manager = Administrador de estilos de directriz múltiple +modal-dimension-style-manager = Administrador de estilos de cota +modal-default-application = Aplicación predeterminada +modal-save-warning = Advertencia de guardado +modal-unable-save = No se puede guardar el dibujo +modal-drawing-changed = El dibujo cambió en el disco +modal-delete-layer = Eliminar capa +modal-unsaved-changes = Cambios sin guardar +modal-unsaved-save-prompt = ¿Desea guardar los cambios? +clipboard-no-supported-content = El portapapeles no contiene texto ni objetos de dibujo compatibles. +clipboard-unavailable = El portapapeles del sistema no está disponible. +clipboard-occupied = El portapapeles del sistema está en uso. Inténtelo de nuevo. +clipboard-conversion-failed = No se pudo leer el texto del portapapeles. +viewport-split-vertical = Dividir ventana gráfica verticalmente +viewport-split-horizontal = Dividir ventana gráfica horizontalmente +viewport-move = Mover ventana gráfica +viewport-close = Cerrar ventana gráfica +modal-point-style = Estilo de punto +modal-attribute-editor = Editor de atributos +modal-save-drawing-as = Guardar dibujo como +modal-recovery-report = Informe de recuperación del dibujo +modal-recovery-prompt = Recuperación del dibujo + +recovery-opened-with-repairs = Dibujo abierto con un informe de recuperación +recovery-open-failed = No se pudo abrir el dibujo +recovery-repaired-description = Se detectaron problemas al abrir el dibujo. Revise el informe; si se repararon datos del dibujo, guárdelo como un archivo nuevo. +recovery-failed-description = La recuperación no pudo producir datos de dibujo utilizables. Revise el informe e intente usar una copia de seguridad o de guardado automático. +recovery-entities-checked = Entidades comprobadas +recovery-issues-found = Problemas encontrados +recovery-entities-removed = Entidades eliminadas +recovery-references-checked = Referencias comprobadas +recovery-referenced-entities-removed = Entidades referenciadas eliminadas +recovery-references-unavailable = Referencias no disponibles +recovery-log-path = Registro de recuperación +recovery-log-write-failed = No se pudo escribir el registro de recuperación +recovery-log-download-ready = El informe de recuperación está listo para descargarse. +recovery-save-copy = Guardar copia reparada +recovery-show-log = Mostrar registro de recuperación +recovery-save-new-file-required = Un dibujo reparado debe guardarse en un archivo nuevo. +recovery-prompt-heading = Falló la apertura normal +recovery-prompt-description = El dibujo no se modificó. El modo de recuperación puede intentar rescatar los datos utilizables, pero puede omitir los registros dañados. ¿Desea continuar? +recovery-attempt = Intentar recuperación +recovery-decline = Cancelar + +command-move-base = + MOVE Especifique el punto base [{ $count -> + [one] { $count } objeto + *[other] { $count } objetos + }]: +command-move-target = MOVE Especifique el destino [base { $x },{ $y }]: +command-copy-array-count = COPY Introduzca el número de elementos de la matriz: +command-copy-base = + COPY Especifique el punto base [{ $count -> + [one] { $count } objeto + *[other] { $count } objetos + }]: +command-copy-array-target = COPY Especifique el segundo punto de la matriz de { $count } elementos [base { $x },{ $y }]: +command-copy-target = + COPY Especifique el destino [{ $count -> + [one] { $count } copia hasta ahora + *[other] { $count } copias hasta ahora + } | Matriz | Enter=terminar | base { $x },{ $y }]: + +# Catálogo completo de la aplicación +catalog-message-4883dcba20 = __ocs_fmt_0__ +catalog-none-use-shortcuts-set-key-command-c80dfd9247 = (ninguno — use: SHORTCUTS SET ) +catalog-alignment-9ebcd20bed = Alineación: +catalog-background-fill-enabled-0c1c0ba4e5 = Relleno de fondo activado +catalog-blocks-4bad59225e = Bloques: __ocs_fmt_0__ +catalog-borders-type-weight-color-spacing-hidden-124f1c3e83 = Bordes (tipo / grosor / color / espaciado / oculto) +catalog-code-page-0ee6cb7096 = Página de códigos: __ocs_fmt_0__ +catalog-color-cur-color-c5551cdd28 = Color: __ocs_arg_cur_color__ +catalog-created-julian-6-73067f26b5 = Creado (juliano): __ocs_fmt_0__ +catalog-data-type-81d1887ccc = Tipo de datos: +catalog-fill-color-5ff3cfda77 = Color de relleno: +catalog-fingerprint-guid-45c5d0e61f = GUID de huella: __ocs_fmt_0__ +catalog-format-string-8cbc3b8117 = Cadena de formato: +catalog-handle-x-c4f8c4f968 = Identificador __ocs_fmt_0__: «__ocs_fmt_1__» +catalog-hyperlink-base-09068ed00b = Base de hipervínculo: __ocs_fmt_0__ +catalog-insert-x-no-attributes-b9883820e5 = Inserción __ocs_fmt_0__: sin atributos. +catalog-last-saved-by-a49aa0e84e = Guardado por última vez por: __ocs_fmt_0__ +catalog-layers-f59e27d3f2 = Capas: __ocs_fmt_0__ +catalog-linetypes-8b1852ca9d = Tipos de línea: __ocs_fmt_0__ +catalog-lineweight-cur-lw-ca24227ace = Grosor de línea: __ocs_arg_cur_lw__ +catalog-measurement-31bd3b8028 = Medición: __ocs_fmt_0__ (__ocs_fmt_1__) +catalog-menu-name-f6d04ff4a4 = Nombre del menú: __ocs_fmt_0__ +catalog-off-color-ltype-a19d109b27 = Desact. Color Tipo de línea +catalog-project-name-937c9166bc = Nombre del proyecto: __ocs_fmt_0__ +catalog-proxy-graphics-ace9a5cbe8 = Gráficos proxy: __ocs_fmt_0__ +catalog-required-versions-018x-19f89c2732 = Versiones necesarias: __ocs_fmt_0__ +catalog-screening-cur-scr-b002df78e2 = Tramado: __ocs_arg_cur_scr__ +catalog-stylesheet-7e0e1382f1 = Hoja de estilos: __ocs_fmt_0__ +catalog-text-color-c9b1538f38 = Color de texto: +catalog-text-height-16502af79f = Altura de texto: +catalog-text-style-fafe521a25 = Estilo de texto: +catalog-text-styles-46532e43fd = Estilos de texto: __ocs_fmt_0__ +catalog-total-edit-time-4-d8ae993d59 = Tiempo total de edición: __ocs_fmt_0__ +catalog-tree-depth-8ff2a32dbe = Profundidad del árbol: __ocs_fmt_0__ +catalog-unit-type-419ce6a5da = Tipo de unidad: +catalog-updated-julian-6-d935762c42 = Actualizado (juliano): __ocs_fmt_0__ +catalog-user-elapsed-4-43a1242325 = Tiempo transcurrido del usuario: __ocs_fmt_0__ +catalog-user-timer-70edaf24f6 = Temporizador del usuario: __ocs_fmt_0__ +catalog-user-vars-int-6bc5221565 = Variables de usuario (enteras): __ocs_fmt_0__ __ocs_fmt_1__ __ocs_fmt_2__ __ocs_fmt_3__ __ocs_fmt_4__ +catalog-user-vars-real-6-6-6-6-6-ad2697f888 = Variables de usuario (reales): __ocs_fmt_0__ __ocs_fmt_1__ __ocs_fmt_2__ __ocs_fmt_3__ __ocs_fmt_4__ +catalog-vp-id-w-1-h-1-1-1-scale-scale-4-state-4bf2fa0893 = VG n.º __ocs_fmt_0__: __ocs_fmt_1__×__ocs_fmt_2__ @ (__ocs_fmt_3__,__ocs_fmt_4__) escala=__ocs_fmt_5__ [__ocs_fmt_6__] +catalog-version-guid-aac810d967 = GUID de versión: __ocs_fmt_0__ +catalog-tag-val-009348b9fb = [__ocs_fmt_0__] = __ocs_fmt_1__ +catalog-parse-ms-purge-ms-caches-ms-xref-ms-total-ms-9d07a1d8fb = análisis __ocs_fmt_0__ ms · limpieza __ocs_fmt_1__ ms · cachés __ocs_fmt_2__ ms · ref. ext. __ocs_fmt_3__ ms · total __ocs_fmt_4__ ms +catalog-x-value-s-adb79ea510 = __ocs_fmt_0__ [__ocs_fmt_1__]: __ocs_fmt_2__ valor(es) +catalog-x-no-xdata-cd844bf6b1 = __ocs_fmt_0__: sin datos extendidos. +catalog-k-n-610d3c5a70 = __ocs_fmt_0__: __ocs_fmt_1__ +catalog-no-closed-boundary-found-7a55b25ecb = ⚠ No se encontró ningún contorno cerrado. +catalog-count-objects-83518eccc2 = (__ocs_arg_count__ objetos) +catalog-edge-extend-90a0cb9b4f = [Borde: Alargar] +catalog-file-name-could-not-be-saved-b35d7df5dd = No se pudo guardar «__ocs_arg_file_name__». +catalog-file-name-was-changed-by-another-application-278da6e091 = Otra aplicación modificó «__ocs_arg_file_name__». +catalog-name-already-in-use-1ce1d7a45c = El nombre «__ocs_fmt_0__» ya está en uso +catalog-rrggbb-or-blank-a67a2b684c = #RRGGBB o vacío +catalog-cmd-select-objects-count-selected-enter-to-app-095ab66a21 = __ocs_arg_cmd__ Seleccione objetos (__ocs_arg_count__ seleccionados, Enter para aplicar): +catalog-cmd-select-objects-6b61510181 = __ocs_arg_cmd__ Seleccione objetos: +catalog-count-objects-selected-5f4cee3b4c = __ocs_arg_count__ objetos seleccionados +catalog-count-selected-layers-558031ed47 = __ocs_arg_count__ capas seleccionadas +catalog-count-steps-available-0f89e5e1fa = __ocs_arg_count__ pasos disponibles +catalog-label-history-defb4ca993 = Historial de __ocs_arg_label__ +catalog-name-select-objects-then-press-enter-fa8657f9a8 = __ocs_arg_name__ Seleccione objetos y pulse Enter: +catalog-name-which-register-1-5-fa400449b8 = __ocs_arg_name__ ¿Qué registro? [1-5]: +catalog-name-slot-new-value-7a91d6723d = __ocs_arg_name____ocs_arg_slot__ Nuevo valor: +catalog-n-specify-center-point-2a5fdd544a = __ocs_arg_n__ Especifique el centro: +catalog-n-specify-first-corner-aaab274163 = __ocs_arg_n__ Especifique la primera esquina: +catalog-n-specify-height-enter-for-default-ede62d5a53 = __ocs_arg_n__ Especifique la altura : +catalog-n-specify-opposite-corner-e55167705b = __ocs_arg_n__ Especifique la esquina opuesta: +catalog-n-specify-radius-9f59c68028 = __ocs_arg_n__ Especifique el radio: +catalog-n-specify-tube-radius-4b3328cd10 = __ocs_arg_n__ Especifique el radio del tubo: +catalog-n-vp-ffc3c37041 = __ocs_arg_n__ VG +catalog-n-layers-3d7760d9c7 = __ocs_arg_n__ capas +catalog-n-saved-layers-accdcb3129 = __ocs_arg_n__ capas guardadas +catalog-prefix-textedit-enter-text-edit-mode-single-mu-332f576fed = __ocs_arg_prefix__TEXTEDIT Indique el modo de edición de texto [Único/Múltiple] <__ocs_arg_mode__>: +catalog-prefix-texteditmode-enter-new-value-for-texted-7155f96153 = __ocs_arg_prefix__TEXTEDITMODE Introduzca un valor nuevo para TEXTEDITMODE <__ocs_arg_v__>: +catalog-scope-visual-style-3981127921 = Estilo visual de __ocs_arg_scope__ +catalog-subject-still-has-count-obj-deleting-will-also-55dbfda19a = + __ocs_arg_subject__ aún __ocs_arg_has__ __ocs_arg_count__ __ocs_arg_obj__. + Al eliminarlo también se quitarán __ocs_arg_those__ del dibujo. ¿Desea continuar? +catalog-default-c8283858bc = (predeterminado) +catalog-no-page-setups-edfbc7b5af = (sin configuraciones de página) +catalog-no-table-loaded-ef95c1e199 = (sin tabla cargada) +catalog-none-3c6b183d34 = (ninguno) +catalog-cancel-81e7a407ec = *Cancelar* +catalog-add-alias-66714e6b33 = + Añadir alias +catalog-inverted-feee82a852 = , invertido +catalog-0-variable-87388fa3ed = 0 = variable +catalog-1st-arrowhead-dimblk1-12777c1288 = 1.ª punta de flecha (DIMBLK1) +catalog-2-point-064de0a7be = 2 puntos +catalog-2d-solid-6483f99ec4 = Sólido 2D +catalog-2d-wireframe-3ac363806a = Estructura alámbrica 2D +catalog-2nd-arrowhead-dimblk2-27ede794e5 = 2.ª punta de flecha (DIMBLK2) +catalog-3-point-93e3a91b12 = 3 puntos +catalog-3d-face-1ddb360ce9 = Cara 3D +catalog-3d-orbit-3f95e247d2 = Órbita 3D +catalog-3d-orbit-drag-with-right-mouse-button-36852405c8 = Órbita 3D: arrastre con el botón derecho del ratón. +catalog-3d-polyline-1607624e1a = Polilínea 3D +catalog-3d-solid-101aeeb575 = Sólido 3D +catalog-3d-visualization-8cd08058f1 = Visualización 3D +catalog-3d-wireframe-5538ca1b90 = Estructura alámbrica 3D +catalog-3dalign-aligned-the-solid-to-the-destination-p-ef4378355c = 3DALIGN: sólido alineado con los puntos de destino. +catalog-3dalign-each-point-triple-must-be-non-coincide-1d59168ea6 = 3DALIGN: cada terna de puntos debe contener puntos distintos y no colineales. +catalog-3dalign-select-exactly-one-solid-created-this-903183ff09 = 3DALIGN: seleccione exactamente un sólido creado en esta sesión. +catalog-3dface-23c5eb1ee2 = Cara 3D +catalog-3dmirror-mirror-plane-x-y-z-110de6e59d = 3DMIRROR Plano de simetría [X / Y / Z]: +catalog-3dmirror-added-a-mirror-across-the-plane-f74aa71107 = 3DMIRROR: se añadió una copia simétrica respecto al plano __ocs_fmt_0__. +catalog-3dmirror-could-not-determine-the-solid-s-exten-f3a69be090 = 3DMIRROR: no se pudo determinar la extensión del sólido. +catalog-3dmirror-select-exactly-one-solid-created-this-a1e696f9f9 = 3DMIRROR: seleccione exactamente un sólido creado en esta sesión. +catalog-3dpoly-specify-next-point-close-undo-428121e01c = 3DPOLY Especifique el punto siguiente [Cerrar/Deshacer]: +catalog-3dpoly-specify-next-point-undo-4701b64d13 = 3DPOLY Especifique el punto siguiente [Deshacer]: +catalog-3dpoly-specify-start-point-of-polyline-37cbc00122 = 3DPOLY Especifique el punto inicial de la polilínea: +catalog-3drotate-angle-in-degrees-6e999b0cde = 3DROTATE Ángulo en grados: +catalog-3drotate-rotation-axis-x-y-z-9b471b7d4d = 3DROTATE Eje de rotación [X / Y / Z]: +catalog-3drotate-could-not-determine-the-solid-s-exten-0809d4b118 = 3DROTATE: no se pudo determinar la extensión del sólido. +catalog-3drotate-rotated-angle-deg-about-the-axis-0beb8a4058 = 3DROTATE: girado __ocs_fmt_0__° alrededor del eje __ocs_fmt_1__. +catalog-3drotate-select-exactly-one-solid-created-this-89c49aa84a = 3DROTATE: seleccione exactamente un sólido creado en esta sesión. +catalog-none-9dcf3a8263 = +catalog-previous-40bf65fa86 = +catalog-v-0869c38eb9 = = __ocs_fmt_0__ +catalog-a-newer-open-cad-studio-version-is-published-o-72916d24bd = Hay una versión más reciente de Open CAD Studio publicada en GitHub. +catalog-ac-version-1469527046 = Versión AC +catalog-aci-color-index-83dbaf2a98 = Índice de color ACI +catalog-aci-c9f2c0d974 = ACI: +catalog-addselected-creating-a-new-kind-is-not-support-b2f0e7607a = ADDSELECTED: no se admite la creación de un nuevo __ocs_fmt_0__. +catalog-addselected-selected-object-not-found-f4e4fa943b = ADDSELECTED: no se encontró el objeto seleccionado. +catalog-adjust-brightness-contrast-fade-5f7ee97676 = ADJUST [Brillo/Contraste/Desvanecimiento]: +catalog-adjust-brightness-0-100-620b56755f = ADJUST Brillo 0-100: +catalog-adjust-contrast-0-100-6f0f0b5601 = ADJUST Contraste 0-100: +catalog-adjust-fade-0-100-e29fd6bfe5 = ADJUST Desvanecimiento 0-100: +catalog-adjust-select-raster-image-s-first-cda45c767d = ADJUST: seleccione primero una o más imágenes ráster. +catalog-adjust-value-must-be-0-100-8f52a3a68c = ADJUST: el valor debe estar entre 0 y 100. +catalog-adjust-action-v-on-changed-image-s-f55a178ba4 = ADJUST: __ocs_fmt_0__ = __ocs_fmt_1__ en __ocs_fmt_2__ imagen(es). +catalog-align-scale-objects-based-on-alignment-points-e5e0f5e590 = ALIGN ¿Escalar los objetos según los puntos de alineación? [S/N]: +catalog-align-select-objects-count-selected-enter-when-ce0fbaf924 = ALIGN Seleccione objetos (__ocs_arg_count__ seleccionados, Enter al terminar): +catalog-align-specify-1st-destination-point-bd64ad3d0a = ALIGN Especifique el primer punto de destino: +catalog-align-specify-1st-source-point-a0123ef59a = ALIGN Especifique el primer punto de origen: +catalog-align-specify-2nd-destination-point-ec83e4ac3f = ALIGN Especifique el segundo punto de destino: +catalog-align-specify-2nd-source-point-enter-translate-b299106722 = ALIGN Especifique el segundo punto de origen (Enter = solo desplazar): +catalog-align-applied-4b237394ff = ALIGN: aplicado. +catalog-annoallvisible-new-value-0-1-b40aa2f762 = ANNOALLVISIBLE Valor nuevo [0/1]: +catalog-annoallvisible-enter-0-or-1-28860bef7c = ANNOALLVISIBLE: introduzca 0 o 1. +catalog-annoautoscale-new-value-4-4-bd2aa5942c = ANNOAUTOSCALE Valor nuevo [-4..4]: +catalog-annoscale-new-annotation-scale-e-g-1-50-2-1-or-2a9523cf29 = ANNOSCALE Nueva escala anotativa (p. ej., 1:50, 2:1 o un factor): +catalog-annoupdate-select-annotation-objects-first-3c7b6cc302 = ANNOUPDATE: seleccione primero objetos anotativos. +catalog-annoupdate-updated-updated-object-s-8426986d33 = ANNOUPDATE: __ocs_fmt_0__ objeto(s) actualizado(s). +catalog-api-incompatible-7aee4ab3d5 = API incompatible +catalog-arc-specify-center-8ca5921878 = ARC Especifique el centro: +catalog-arc-specify-end-point-c-cx-cy-r-r-e86ec11dd4 = ARC Especifique el punto final [c=(__ocs_arg_cx__,__ocs_arg_cy__) r=__ocs_arg_r__]: +catalog-arc-specify-start-point-153faf380c = ARC Especifique el punto inicial: +catalog-arc-3p-specify-end-point-3bc9227d83 = ARC 3P Especifique el punto final: +catalog-arc-3p-specify-second-point-on-arc-0d9ca492f6 = ARC 3P Especifique el segundo punto del arco: +catalog-arc-3p-specify-start-point-3d72ee7604 = ARC 3P Especifique el punto inicial: +catalog-arc-csa-click-end-direction-or-type-arc-span-i-9517e465a6 = ARC CSA Pulse la dirección final o escriba el ángulo del arco en grados [inicio=__ocs_arg_sa__]: +catalog-arc-csa-specify-center-69db023196 = ARC CSA Especifique el centro: +catalog-arc-csa-specify-start-point-d8128b5bcc = ARC CSA Especifique el punto inicial: +catalog-arc-csl-click-chord-end-or-type-chord-length-r-874fb30c98 = ARC CSL Pulse el extremo de la cuerda o escriba su longitud [r=__ocs_arg_r__]: +catalog-arc-csl-specify-center-a94dd7b827 = ARC CSL Especifique el centro: +catalog-arc-csl-specify-start-point-8386c5fca2 = ARC CSL Especifique el punto inicial: +catalog-arc-continue-no-previous-line-or-arc-to-contin-07fb818cce = ARC Continuar No hay una línea ni un arco anterior que continuar. +catalog-arc-continue-specify-end-point-ctrl-flip-direc-49c2e70b61 = ARC Continuar Especifique el punto final [Ctrl = invertir dirección]: +catalog-arc-sca-click-end-direction-or-type-arc-span-i-cf8ad6b04a = ARC SCA Pulse la dirección final o escriba el ángulo del arco en grados [inicio=__ocs_arg_sa__]: +catalog-arc-sca-specify-center-f13ddbc182 = ARC SCA Especifique el centro: +catalog-arc-sca-specify-start-point-336bca2af1 = ARC SCA Especifique el punto inicial: +catalog-arc-sce-specify-center-bc1b3642cf = ARC SCE Especifique el centro: +catalog-arc-sce-specify-end-point-r-r-022367ed3a = ARC SCE Especifique el punto final [r=__ocs_arg_r__]: +catalog-arc-sce-specify-start-point-d41f00e961 = ARC SCE Especifique el punto inicial: +catalog-arc-scl-click-chord-end-or-type-chord-length-r-01164c14cf = ARC SCL Pulse el extremo de la cuerda o escriba su longitud [r=__ocs_arg_r__]: +catalog-arc-scl-specify-center-0301cbb398 = ARC SCL Especifique el centro: +catalog-arc-scl-specify-start-point-c515072807 = ARC SCL Especifique el punto inicial: +catalog-arc-sea-specify-angle-move-cursor-perpendicula-61017230dd = ARC SEA Especifique el ángulo (mueva el cursor perpendicularmente a la cuerda): +catalog-arc-sea-specify-end-point-77bd4966b9 = ARC SEA Especifique el punto final: +catalog-arc-sea-specify-start-point-e898a4049b = ARC SEA Especifique el punto inicial: +catalog-arc-sed-specify-end-point-b7be2060ba = ARC SED Especifique el punto final: +catalog-arc-sed-specify-start-point-185d74d9e9 = ARC SED Especifique el punto inicial: +catalog-arc-sed-specify-tangent-direction-at-start-aacef7082c = ARC SED Especifique la dirección tangente en el inicio: +catalog-arc-ser-click-radius-point-or-type-radius-valu-3f10a80b2f = ARC SER Pulse el punto del radio o escriba su valor: +catalog-arc-ser-specify-end-point-ce51f1c5c1 = ARC SER Especifique el punto final: +catalog-arc-ser-specify-start-point-437c9f141f = ARC SER Especifique el punto inicial: +catalog-archive-save-the-drawing-first-it-has-no-file-0654e9fa77 = ARCHIVE: guarde primero el dibujo (todavía no tiene una ruta de archivo). +catalog-arctext-text-to-place-along-the-arc-d391455094 = ARCTEXT Texto que se colocará a lo largo del arco: +catalog-arctext-no-printable-text-04a1a7b60e = ARCTEXT: no hay texto imprimible. +catalog-arctext-placed-n-character-s-along-the-arc-9645e47dae = ARCTEXT: se colocaron __ocs_fmt_0__ carácter(es) a lo largo del arco. +catalog-arctext-select-an-arc-first-5c14001453 = ARCTEXT: seleccione primero un arco. +catalog-area-specify-first-corner-point-enter-to-cance-047cdde937 = AREA Especifique el primer punto de esquina (Enter para cancelar): +catalog-area-specify-next-point-n-picked-enter-to-calc-4cc5e3a547 = AREA Especifique el punto siguiente (__ocs_arg_n__ seleccionados, Enter para calcular): +catalog-array3d-column-spacing-rows-cols-levels-row-rs-58a57e15b7 = ARRAY3D Espaciado entre columnas [__ocs_arg_rows__×__ocs_arg_cols__×__ocs_arg_levels__, fila=__ocs_arg_rs__]: +catalog-array3d-enter-column-count-rows-rows-04c74ee0a8 = ARRAY3D Introduzca el número de columnas [__ocs_arg_rows__ filas]: +catalog-array3d-enter-level-count-rows-cols-94d772b78f = ARRAY3D Introduzca el número de niveles [__ocs_arg_rows__×__ocs_arg_cols__]: +catalog-array3d-enter-row-count-4df8b03777 = ARRAY3D Introduzca el número de filas: +catalog-array3d-level-spacing-rows-cols-levels-r-rs-c-739a9348a6 = ARRAY3D Espaciado entre niveles [__ocs_arg_rows__×__ocs_arg_cols__×__ocs_arg_levels__, f=__ocs_arg_rs__ c=__ocs_arg_cs__]: +catalog-array3d-row-spacing-rows-cols-levels-dfaeacd019 = ARRAY3D Espaciado entre filas [__ocs_arg_rows__×__ocs_arg_cols__×__ocs_arg_levels__]: +catalog-arraypath-enter-item-count-3c594b1e1a = ARRAYPATH Introduzca el número de elementos <__ocs_fmt_0__>: +catalog-arraypath-select-path-entity-objects-b957016cff = ARRAYPATH Seleccione la entidad de trayectoria [__ocs_fmt_0__ objetos]: +catalog-arraypolar-enter-item-count-db5a1cb4bc = ARRAYPOLAR Introduzca el número de elementos <__ocs_fmt_0__>: +catalog-arraypolar-enter-total-angle-in-degrees-0-coun-0a521b3d22 = ARRAYPOLAR Introduzca el ángulo total en grados <__ocs_fmt_0__> [__ocs_fmt_1__ elementos]: +catalog-arraypolar-specify-center-point-objects-01c9546098 = ARRAYPOLAR Especifique el centro [__ocs_fmt_0__ objetos]: +catalog-arrayrect-column-spacing-0-rows-cols-row-row-s-60eb63ad7c = ARRAYRECT Espaciado entre columnas <__ocs_fmt_0__> [__ocs_fmt_1__×__ocs_fmt_2__, fila=__ocs_fmt_3__]: +catalog-arrayrect-enter-column-count-rows-rows-44ec061783 = ARRAYRECT Introduzca el número de columnas <__ocs_fmt_0__> [__ocs_fmt_1__ filas]: +catalog-arrayrect-enter-row-count-65fa47e79f = ARRAYRECT Introduzca el número de filas <__ocs_fmt_0__>: +catalog-arrayrect-row-spacing-0-rows-cols-7a96cc7063 = ARRAYRECT Espaciado entre filas <__ocs_fmt_0__> [__ocs_fmt_1__×__ocs_fmt_2__]: +catalog-attdef-enter-attribute-tag-no-spaces-832e173615 = ATTDEF Introduzca la etiqueta del atributo (sin espacios): +catalog-attdef-enter-default-value-for-tag-enter-blank-6f682a8f81 = ATTDEF Introduzca el valor predeterminado de «__ocs_arg_tag__» (Enter=vacío): +catalog-attdef-enter-prompt-for-tag-enter-use-tag-62ee2f91c8 = ATTDEF Introduzca el mensaje de «__ocs_arg_tag__» (Enter=usar etiqueta): +catalog-attdef-specify-insertion-point-for-tag-c14b412fd6 = ATTDEF Especifique el punto de inserción de «__ocs_arg_tag__»: +catalog-attdisp-attribute-display-on-off-normal-5f1ba3187f = ATTDISP Visualización de atributos [Activada/Desactivada/Normal]: +catalog-attdisp-sub-count-attribute-definition-s-updat-64934ed370 = ATTDISP __ocs_fmt_0__: se actualizaron __ocs_fmt_1__ definiciones de atributo. +catalog-attedit-select-block-with-attributes-0d9f102dc5 = ATTEDIT Seleccione un bloque con atributos: +catalog-attedit-no-insert-entities-in-selection-2efd334f89 = ATTEDIT: no hay entidades de inserción en la selección. +catalog-attedit-select-an-insert-entity-first-7306428ee8 = ATTEDIT: seleccione primero una entidad de inserción. +catalog-attedit-tag-tag-up-not-found-in-selection-f6179123ff = ATTEDIT: no se encontró la etiqueta «__ocs_fmt_0__» en la selección. +catalog-attedit-updated-changed-attribute-s-tag-up-new-e42fc240a2 = ATTEDIT: se actualizaron __ocs_fmt_0__ atributos [__ocs_fmt_1__] = __ocs_fmt_2__. +catalog-attsync-block-name-to-sync-213951b511 = ATTSYNC Nombre del bloque que se sincronizará: +catalog-attsync-no-block-named-arg-1693bb3d0a = ATTSYNC: no existe ningún bloque llamado «__ocs_fmt_0__». +catalog-attsync-synchronised-synced-insert-s-of-block-9536b3279c = ATTSYNC: se sincronizaron __ocs_fmt_0__ inserciones de «__ocs_fmt_1__» con __ocs_fmt_2__ definiciones de atributo. +catalog-audit-no-issues-found-1bffbfca7c = AUDIT: no se encontraron problemas. +catalog-audit-reference-s-to-undefined-block-s-9f8580578c = AUDIT: referencias a bloques no definidos: __ocs_fmt_0__ +catalog-audit-reference-s-to-undefined-layer-s-4ca4272890 = AUDIT: referencias a capas no definidas: __ocs_fmt_0__ +catalog-audit-report-only-no-automatic-repair-performe-665ff2fda6 = AUDIT: solo informe; no se realizó ninguna reparación automática. +catalog-audit-scanned-total-object-s-22e39a658c = AUDIT: se examinaron __ocs_fmt_0__ objetos. +catalog-about-6b21fb791a = Acerca de +catalog-above-6370c271df = Arriba +catalog-above-text-77d508757f = Encima del texto +catalog-accept-bb54db510a = Aceptar +catalog-action-97c89a4d66 = Acción +catalog-active-a733b809d2 = Activo +catalog-active-ucs-named-c213f515c3 = SCP activo: __ocs_fmt_0__ | Con nombre: __ocs_fmt_1__ +catalog-active-ucs-no-named-ucss-defined-fede450e16 = SCP activo: __ocs_fmt_0__ | No hay SCP con nombre definidos. +catalog-active-viewport-cb256416bf = Ventana gráfica activa +catalog-active-dim-style-set-to-name-arg-223a582b1b = Estilo de cota activo definido como «__ocs_fmt_0__» +catalog-add-61cc55aa04 = Añadir +catalog-add-leader-89cc8c25ab = Añadir directriz +catalog-add-scale-0b67ea5641 = Añadir escala +catalog-add-selected-drawing-a-new-kind-on-layer-layer-3b3b470d3e = Añadir seleccionado: dibujando un nuevo __ocs_fmt_0__ en la capa «__ocs_fmt_1__». +catalog-add-from-github-eeb16a4e4e = Añadir desde GitHub +catalog-add-leader-cee76facb8 = Añadir directriz +catalog-add-repository-1b73cdafd0 = Añadir repositorio +catalog-added-annotation-scale-arg-40be971a98 = Se añadió la escala anotativa __ocs_fmt_0__. +catalog-added-from-github-e1efdbf07b = Añadido desde GitHub +catalog-adjust-ab166bd5e8 = Ajustar +catalog-adjust-colors-for-background-6b76eaf659 = Ajustar colores al fondo +catalog-advanced-4d06472695 = Avanzado +catalog-alias-04259816ac = Alias +catalog-align-leaders-b9f937bb33 = Alinear directrices +catalog-align-space-08a021e08e = Espacio de alineación: +catalog-aligned-6ad112f324 = Alineado +catalog-alignment-7f8c5176c3 = Alineación +catalog-all-6a72085653 = Todo +catalog-all-layers-thawed-24edb0d02c = Se han reutilizado todas las capas. +catalog-all-layers-turned-on-87e4339b14 = Se han activado todas las capas. +catalog-alt-enabled-a14ad88d38 = Alternativas activadas +catalog-alt-format-46994a6359 = Formato alternativo +catalog-alt-precision-f3371cdf77 = Precisión alternativa +catalog-alt-prefix-suffix-5dd9b50190 = Prefijo/sufijo alternativo +catalog-alt-roundoff-7381768fb5 = Redondeo alternativo +catalog-alt-scale-factor-953b5847c3 = Factor de escala alternativo +catalog-alt-suppress-leading-zeros-c1ef8064a6 = Suprimir ceros iniciales alternativos +catalog-alt-suppress-trailing-zeros-984f3ed380 = Suprimir ceros finales alternativos +catalog-alternate-9ed2535f09 = Alternativo +catalog-alternate-units-2991d968e8 = Unidades alternativas +catalog-ambient-c10e8f39fc = Ambiente +catalog-angle-1fd70473fb = Ángulo +catalog-angle-angle-6c7de6261f = Ángulo = __ocs_arg_angle__° +catalog-angle-0-0000-degenerate-rays-deda28c583 = Ángulo = 0,0000° (rayos degenerados) +catalog-angle-format-196af98163 = Formato de ángulo +catalog-angle-precision-dc1c64d6e2 = Precisión angular +catalog-angles-ff8084f15b = Ángulos +catalog-angular-8e09000e5c = Angular +catalog-angular-decimals-dimadec-52810900b4 = Decimales angulares (DIMADEC) +catalog-angular-unit-dimaunit-fd4f07da8d = Unidad angular (DIMAUNIT) +catalog-angular-zero-supp-dimazin-2a8d799e53 = Supresión de ceros angulares (DIMAZIN) +catalog-annotate-4f5f32d8fd = Anotar +catalog-annotation-de3b78b641 = Anotación +catalog-annotation-viewport-scale-click-to-change-1d574da062 = + Escala de anotación/ventana gráfica + Pulse para cambiar +catalog-annotation-object-scale-a6c5c4ebdc = Escala del objeto anotativo +catalog-annotation-scale-b49ce65069 = Escala anotativa +catalog-annotation-scaling-c4b9cb83e6 = Ajuste de escala anotativa +catalog-annotation-scale-0400c101c7 = Escala anotativa +catalog-annotation-scale-arg-129d46a1ed = Escala anotativa: __ocs_fmt_0__ +catalog-annotation-scales-bb77e7eeb2 = Escalas anotativas: __ocs_fmt_0__ +catalog-annotative-142ac0bf83 = Anotativo +catalog-annotative-scale-bc6ed32c2b = Escala anotativa +catalog-apparent-intersection-ac8c738027 = Intersección aparente +catalog-append-to-current-selection-faba277dc7 = Añadir a la selección actual +catalog-application-b291beb879 = Aplicación +catalog-apply-cfea419c3b = Aplicar +catalog-apply-cell-11779665e5 = Aplicar celda +catalog-apply-margins-dcae9eafd9 = Aplicar márgenes +catalog-apply-to-aci-6e60658cfb = Aplicar a ACI +catalog-arc-853a983f6b = Arco +catalog-arc-end-deg-eddeebcf5c = Fin del arco (grados) +catalog-arc-start-deg-013eef7448 = Inicio del arco (grados) +catalog-arc-x-1384a11219 = X del arco +catalog-arc-y-aba1c57898 = Y del arco +catalog-arc-z-6189310706 = Z del arco +catalog-arc-length-a3852745d9 = Longitud de arco +catalog-arc-length-symbol-dimarcsym-52f9e2ba9d = Símbolo de longitud de arco (DIMARCSYM) +catalog-arc-aligned-text-c3e108e803 = Texto alineado con arco +catalog-arch-f96ddf323d = Arquitectónico +catalog-architectural-33680ccdb3 = Arquitectónico +catalog-area-2745debaa6 = Área +catalog-area-area-perimeter-perimeter-319368a17d = Área = __ocs_arg_area__, Perímetro = __ocs_arg_perimeter__ +catalog-array-insert-cf61c7bf2a = Inserción de matriz +catalog-arrow-3a4758c3cc = Flecha +catalog-arrow-size-593a16dcc8 = Tamaño de flecha +catalog-arrow-size-dimasz-e79def674f = Tamaño de flecha (DIMASZ) +catalog-arrowhead-e6870bf25a = Punta de flecha +catalog-arrowhead-dimblk-ca3d3ffe0e = Punta de flecha (DIMBLK) +catalog-arrowhead-block-f59d3e2221 = Bloque de punta de flecha: +catalog-arrowhead-size-17232209e6 = Tamaño de punta de flecha +catalog-arrowhead-size-7e5cc19172 = Tamaño de punta de flecha: +catalog-arrows-0cf604cb00 = Flechas +catalog-arrows-out-first-34c08696c5 = Flechas fuera primero +catalog-as-displayed-86cb59e670 = Como se muestra +catalog-associative-b7d696bef2 = Asociativo +catalog-at-first-ext-93aa189569 = En la primera extensión +catalog-at-least-2233bfd2f5 = Al menos +catalog-at-second-ext-6ecae855c6 = En la segunda extensión +catalog-attach-1afff0157c = Enlazar +catalog-attach-xref-a010ad7bff = Enlazar referencia externa +catalog-attachment-direction-833e9716b0 = Dirección de enlace: +catalog-attachment-type-4ed4f27cde = Tipo de enlace +catalog-attenuation-56a66dbaef = Atenuación +catalog-attribute-a086d94288 = Atributo +catalog-attribute-definition-baaf399856 = Definición de atributo +catalog-attribute-editor-b41475bb05 = Editor de atributos +catalog-attributedefinition-117ad6ffdf = Definición de atributo +catalog-attributes-a6652617f2 = Atributos +catalog-audit-fa1703dd78 = Auditoría +catalog-auto-c614ba7c45 = Automático +catalog-automatically-add-scales-da9d358823 = Añadir escalas automáticamente +catalog-autosaved-1-drawing-2605935129 = Se guardó automáticamente 1 dibujo +catalog-available-plugins-8d019a58db = Complementos disponibles +catalog-axis-160bbfbc4d = Eje +catalog-axis-point-b50068c6a4 = Punto del eje +catalog-axis-vector-9fa9b9ea2f = Vector del eje +catalog-axis-end-888da7e434 = Eje, extremo +catalog-background-colour-default-black-darkgray-gray-8bc2289b5a = BACKGROUND Color [Predeterminado/Negro/GrisOscuro/Gris/GrisClaro/Blanco] o R G B (0–255): +catalog-base-specify-base-point-bf41e42257 = BASE Especifique el punto base: +catalog-bedit-select-block-reference-to-edit-4ebf3273b9 = BEDIT Seleccione la referencia de bloque que desea editar: +catalog-bedit-block-edit-discarded-6aa417f180 = BEDIT: se descartó la edición del bloque «__ocs_fmt_0__». +catalog-bedit-block-saved-all-references-updated-cd95d9a6be = BEDIT: se guardó el bloque «__ocs_fmt_0__». Se actualizaron todas las referencias. +catalog-bedit-editing-block-use-save-block-or-discard-9adbb41f27 = BEDIT: editando el bloque «__ocs_fmt_0__». Use Guardar bloque o Descartar para terminar. +catalog-bedit-block-not-found-8bc01ffc67 = BEDIT: no se encontró el bloque «__ocs_fmt_0__». +catalog-bedit-block-tab-name-is-not-open-25058e33e0 = BEDIT: la pestaña de bloque «__ocs_fmt_0__» no está abierta. +catalog-bedit-cannot-edit-an-external-reference-xref-5ec4cd50e5 = BEDIT: no se puede editar una referencia externa. +catalog-bedit-finish-the-active-refedit-refclose-first-fe3a5d34be = BEDIT: finalice primero la sesión REFEDIT activa con REFCLOSE. +catalog-bedit-selected-object-is-not-a-block-reference-506eb728f1 = BEDIT: el objeto seleccionado no es una referencia de bloque. +catalog-bedit-discard-invalid-block-editor-state-229f649aa8 = BEDIT_DISCARD: estado no válido del editor de bloques. +catalog-bedit-discard-no-block-editor-is-open-663f2ee328 = BEDIT_DISCARD: no hay ningún editor de bloques abierto. +catalog-bedit-save-invalid-block-editor-state-72f68d1608 = BEDIT_SAVE: estado no válido del editor de bloques. +catalog-bedit-save-no-block-editor-is-open-59bd83df7a = BEDIT_SAVE: no hay ningún editor de bloques abierto. +catalog-blend-enter-continuity-tangent-curvature-c-3059df3a8c = BLEND Indique la continuidad [Tangente/Curvatura] <__ocs_arg_c__>: +catalog-blend-select-first-open-curve-continuity-c-1df991256c = BLEND Seleccione la primera curva abierta [Continuidad=__ocs_arg_c__]: +catalog-blend-select-second-open-curve-continuity-c-07eac40b1f = BLEND Seleccione la segunda curva abierta [Continuidad=__ocs_arg_c__]: +catalog-block-enter-block-name-count-objects-selected-5d1e2ecc8d = BLOCK Introduzca el nombre del bloque [__ocs_arg_count__ objetos seleccionados]: +catalog-block-specify-base-point-for-name-count-object-c13399f982 = BLOCK Especifique el punto base de «__ocs_arg_name__» [__ocs_arg_count__ objetos]: +catalog-boundary-pick-internal-point-miss-ce5a48a278 = BOUNDARY Designe un punto interior:__ocs_arg_miss__ +catalog-break-select-object-3c60ed92a5 = BREAK Seleccione un objeto: +catalog-break-specify-first-break-point-5414ba5c05 = BREAK Especifique el primer punto de ruptura: +catalog-break-specify-second-break-point-6612862348 = BREAK Especifique el segundo punto de ruptura: +catalog-break-entity-type-not-supported-4f09c29ce8 = BREAK: tipo de entidad no compatible. +catalog-break-fragment-s-5033646313 = BREAK: __ocs_fmt_0__ fragmento(s). +catalog-breakatpoint-select-object-137ea03c41 = BREAKATPOINT Seleccione un objeto: +catalog-breakatpoint-specify-break-point-e352b5e6d2 = BREAKATPOINT Especifique el punto de ruptura: +catalog-back-b52b36b726 = Atrás +catalog-back-line-3298a89ba0 = Línea posterior +catalog-background-64dd60fe1a = Fondo +catalog-background-color-1a0371e373 = Color de fondo +catalog-background-mask-0ce6ad9115 = Máscara de fondo +catalog-background-reset-to-default-a637948b7a = Se restableció el fondo predeterminado. +catalog-background-transparency-7776075b37 = Transparencia del fondo +catalog-background-rgb-5e99de3743 = Fondo: rgb(__ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__) +catalog-backward-1f5bafe101 = Hacia atrás +catalog-backwards-95e848a0c7 = Hacia atrás +catalog-base-x-4353dc555d = X base +catalog-base-y-87538b4355 = Y base +catalog-base-z-5f5848e0c7 = Z base +catalog-base-point-set-for-space-004db7ea9c = Punto base (__ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__) definido para __ocs_fmt_3__. +catalog-base-point-x-7f45d2aae6 = X del punto base +catalog-base-point-y-5a29cad8fa = Y del punto base +catalog-base-point-z-2089628ed2 = Z del punto base +catalog-base-radius-3f03a89862 = Radio de la base +catalog-basepoint-034aee94d4 = Punto base +catalog-baseline-e6ab7982fa = Línea base +catalog-before-text-068662e1bd = Antes del texto +catalog-below-5ba07a31df = Debajo +catalog-best-fit-5f03a109b6 = Mejor ajuste +catalog-big-font-6ad88f2edb = Tipo de letra grande +catalog-big-font-7da1245560 = Tipo de letra grande: +catalog-blend-creases-94092eb7ec = Suavizar pliegues +catalog-block-82dd2cdf36 = Bloque +catalog-block-name-has-no-entities-24562fef57 = El bloque «__ocs_arg_name__» no tiene entidades. +catalog-block-name-not-found-85e9566b91 = No se encontró el bloque «__ocs_arg_name__». +catalog-block-name-produced-no-exportable-entities-f1f1e71ce4 = El bloque «__ocs_arg_name__» no produjo entidades exportables. +catalog-block-name-created-fe57377fd3 = Se creó el bloque «__ocs_fmt_0__». +catalog-block-orig-new-name-a628362e5d = Bloque «__ocs_fmt_0__» → «__ocs_fmt_1__» +catalog-block-content-6e0fc82b79 = Contenido del bloque +catalog-block-end-fc67a15be8 = Fin de bloque +catalog-block-reference-8e31379f57 = Referencia de bloque +catalog-block-color-04cda52b98 = Color del bloque +catalog-block-color-aci-b23bdc8b28 = Color del bloque (ACI): +catalog-block-connection-67ebca2316 = Conexión del bloque +catalog-block-connection-d3ab2b5450 = Conexión del bloque: +catalog-block-contents-3b8943f1fc = Contenido del bloque +catalog-block-rotation-d0e8ca3899 = Rotación del bloque +catalog-block-rotation-dd01908d3e = Rotación del bloque: +catalog-block-scale-4cb072f35b = Escala del bloque +catalog-block-scale-x-4f2937ce1d = Escala X del bloque: +catalog-block-scale-y-7f59f7f3bb = Escala Y del bloque: +catalog-block-scale-z-02ea455fb3 = Escala Z del bloque: +catalog-block-6d3566e517 = Bloque: +catalog-block-block-15d7d0ffb6 = Bloque: __ocs_arg_block__ +catalog-blockend-e828a456c7 = Fin de bloque +catalog-blockextents-86672f15e0 = Extensión del bloque +catalog-blocks-54c45c033f = Bloques +catalog-blocks-insert-with-insert-name-ab85ea5100 = Bloques (__ocs_fmt_0__) — inserte con INSERT : __ocs_fmt_1__ +catalog-blocks-6186728619 = Bloques (__ocs_fmt_0__): __ocs_fmt_1__ +catalog-blue-7d44bc449c = Azul +catalog-body-718a7e8a58 = Cuerpo +catalog-bold-19e07430ee = Negrita +catalog-book-f69f233005 = Libro +catalog-boolean-failed-the-solids-may-not-overlap-8a3181c2e7 = Falló la operación booleana; es posible que los sólidos no se solapen. +catalog-boolean-select-exactly-two-solids-created-this-976fe80f2e = Operación booleana: seleccione exactamente dos sólidos creados en esta sesión. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index d676f9796..d4e2eb826 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -7,6 +7,10 @@ language-german = Deutsch language-hindi = हिन्दी language-russian = Русский language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 action-close = Fermer action-options = Options @@ -3385,3 +3389,55 @@ style-manager-center-of-text = Centre du texte style-manager-center-of-text-overline = Centre du texte avec surlignement style-manager-block-extents = Limites du bloc style-manager-base-point = Point de base + +area-option-objects = Objets +area-option-back = Retour +area-option-add = Ajouter une aire +area-option-subtract = Soustraire une aire +area-prompt-first = AREA Indiquez le premier sommet ou [Objets/Ajouter une aire/Soustraire une aire] : +area-prompt-add = AREA AJOUTER Indiquez le premier sommet ou [Objets/Soustraire une aire] : +area-prompt-subtract = AREA SOUSTRAIRE Indiquez le premier sommet ou [Objets/Ajouter une aire] : +area-prompt-next = AREA Indiquez le point suivant ({ $count } sélectionné(s), Entrée pour calculer) : +area-prompt-objects = AREA Sélectionnez les objets ({ $count } sélectionné(s), Entrée pour calculer) : +area-objects-not-measurable = AREA : aucun des objets sélectionnés ne possède d'aire mesurable. +area-result = Aire = { $area }, Périmètre = { $perimeter } +area-result-area-only = Aire = { $area } +area-running-result = + Aire = { $area }, Périmètre = { $perimeter } + Aire totale = { $total_area }, Périmètre total = { $total_perimeter } +area-running-result-area-only = + Aire = { $area } + Aire totale = { $total_area } + +modal-recovery-report = Rapport de récupération du dessin +modal-recovery-prompt = Récupération du dessin +recovery-opened-with-repairs = Dessin ouvert avec un rapport de récupération +recovery-open-failed = Impossible d'ouvrir le dessin +recovery-repaired-description = Des problèmes ont été détectés pendant l'ouverture du dessin. Consultez le rapport ; si des données ont été réparées, enregistrez le dessin dans un nouveau fichier. +recovery-failed-description = La récupération n'a pas produit de données de dessin utilisables. Consultez le rapport et essayez une sauvegarde ou une copie d'enregistrement automatique. +recovery-entities-checked = Entités vérifiées +recovery-issues-found = Problèmes détectés +recovery-entities-removed = Entités supprimées +recovery-references-checked = Références vérifiées +recovery-referenced-entities-removed = Entités référencées supprimées +recovery-references-unavailable = Références indisponibles +recovery-log-path = Journal de récupération +recovery-log-write-failed = Impossible d'écrire le journal de récupération +recovery-log-download-ready = Le rapport de récupération est prêt à être téléchargé. +recovery-save-copy = Enregistrer la copie réparée +recovery-show-log = Afficher le journal de récupération +recovery-save-new-file-required = Un dessin réparé doit être enregistré dans un nouveau fichier. +recovery-prompt-heading = L'ouverture normale a échoué +recovery-prompt-description = Le dessin n'a pas été modifié. Le mode de récupération peut tenter de sauver les données utilisables, mais les enregistrements endommagés peuvent être ignorés. Voulez-vous continuer ? +recovery-attempt = Tenter la récupération +recovery-decline = Annuler + +catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Sélectionnez les objets texte, puis appuyez sur Entrée : +catalog-tcount-starting-number-start-7471e47711 = TCOUNT Numéro de départ <__ocs_fmt_0__> : +catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Incrément <__ocs_fmt_0__> : +catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Position [Remplacer/Préfixe/Suffixe] : +catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT : la position doit être Remplacer, Préfixe ou Suffixe. +catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT : __ocs_fmt_0__ objet(s) texte numéroté(s) à partir de __ocs_fmt_1__ par pas de __ocs_fmt_2__ (__ocs_fmt_3__). +catalog-prefix-90eceb016c = Préfixe +catalog-suffix-885db975a3 = Suffixe +catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = L'ouverture d'un dessin par son chemin n'est pas disponible dans le navigateur. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index fc039232b..33b34dc2a 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -7,6 +7,10 @@ language-german = Deutsch language-hindi = हिन्दी language-russian = Русский language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 action-close = बंद करें action-options = विकल्प @@ -3374,3 +3378,55 @@ style-manager-center-of-text = टेक्स्ट का केंद्र style-manager-center-of-text-overline = ऊपरी रेखा सहित टेक्स्ट का केंद्र style-manager-block-extents = ब्लॉक परिमिति style-manager-base-point = आधार बिंदु + +area-option-objects = वस्तुएँ +area-option-back = वापस +area-option-add = क्षेत्रफल जोड़ें +area-option-subtract = क्षेत्रफल घटाएँ +area-prompt-first = AREA पहला कोना बिंदु निर्दिष्ट करें या [वस्तुएँ/क्षेत्रफल जोड़ें/क्षेत्रफल घटाएँ] <वस्तुएँ>: +area-prompt-add = AREA जोड़ें पहला कोना बिंदु निर्दिष्ट करें या [वस्तुएँ/क्षेत्रफल घटाएँ] <वस्तुएँ>: +area-prompt-subtract = AREA घटाएँ पहला कोना बिंदु निर्दिष्ट करें या [वस्तुएँ/क्षेत्रफल जोड़ें] <वस्तुएँ>: +area-prompt-next = AREA अगला बिंदु निर्दिष्ट करें ({ $count } चुने गए, गणना के लिए Enter): +area-prompt-objects = AREA वस्तुएँ चुनें ({ $count } चयनित, गणना के लिए Enter): +area-objects-not-measurable = AREA: चुनी गई किसी भी वस्तु का मापने योग्य क्षेत्रफल नहीं है। +area-result = क्षेत्रफल = { $area }, परिमाप = { $perimeter } +area-result-area-only = क्षेत्रफल = { $area } +area-running-result = + क्षेत्रफल = { $area }, परिमाप = { $perimeter } + कुल क्षेत्रफल = { $total_area }, कुल परिमाप = { $total_perimeter } +area-running-result-area-only = + क्षेत्रफल = { $area } + कुल क्षेत्रफल = { $total_area } + +modal-recovery-report = ड्रॉइंग पुनर्प्राप्ति रिपोर्ट +modal-recovery-prompt = ड्रॉइंग पुनर्प्राप्ति +recovery-opened-with-repairs = ड्रॉइंग पुनर्प्राप्ति रिपोर्ट के साथ खोली गई +recovery-open-failed = ड्रॉइंग खोली नहीं जा सकी +recovery-repaired-description = ड्रॉइंग खोलते समय समस्याएँ मिलीं। रिपोर्ट की समीक्षा करें; यदि ड्रॉइंग डेटा सुधारा गया है, तो उसे नई फ़ाइल के रूप में सहेजें। +recovery-failed-description = पुनर्प्राप्ति उपयोग योग्य ड्रॉइंग डेटा नहीं बना सकी। रिपोर्ट की समीक्षा करें और बैकअप या स्वतः-सहेजी गई प्रति आज़माएँ। +recovery-entities-checked = जाँची गई एंटिटियाँ +recovery-issues-found = मिली समस्याएँ +recovery-entities-removed = हटाई गई एंटिटियाँ +recovery-references-checked = जाँचे गए संदर्भ +recovery-referenced-entities-removed = हटाई गई संदर्भित एंटिटियाँ +recovery-references-unavailable = अनुपलब्ध संदर्भ +recovery-log-path = पुनर्प्राप्ति लॉग +recovery-log-write-failed = पुनर्प्राप्ति लॉग लिखा नहीं जा सका +recovery-log-download-ready = पुनर्प्राप्ति रिपोर्ट डाउनलोड के लिए तैयार है। +recovery-save-copy = सुधारी गई प्रति सहेजें +recovery-show-log = पुनर्प्राप्ति लॉग दिखाएँ +recovery-save-new-file-required = सुधारी गई ड्रॉइंग को नई फ़ाइल में सहेजना आवश्यक है। +recovery-prompt-heading = सामान्य तरीके से खोलना विफल रहा +recovery-prompt-description = ड्रॉइंग बदली नहीं गई है। पुनर्प्राप्ति मोड उपयोग योग्य डेटा बचाने का प्रयास कर सकता है, लेकिन क्षतिग्रस्त रिकॉर्ड छोड़े जा सकते हैं। क्या आप जारी रखना चाहते हैं? +recovery-attempt = पुनर्प्राप्ति आज़माएँ +recovery-decline = रद्द करें + +catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT टेक्स्ट वस्तुएँ चुनें, फिर Enter दबाएँ: +catalog-tcount-starting-number-start-7471e47711 = TCOUNT आरंभिक संख्या <__ocs_fmt_0__>: +catalog-tcount-increment-increment-79f9ab6108 = TCOUNT वृद्धि <__ocs_fmt_0__>: +catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT स्थान [अधिलेखित/उपसर्ग/प्रत्यय] <अधिलेखित>: +catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: स्थान अधिलेखित, उपसर्ग या प्रत्यय होना चाहिए। +catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: __ocs_fmt_0__ टेक्स्ट वस्तु(ओं) को __ocs_fmt_1__ से आरंभ करके __ocs_fmt_2__ की वृद्धि से क्रमांकित किया गया (__ocs_fmt_3__)। +catalog-prefix-90eceb016c = उपसर्ग +catalog-suffix-885db975a3 = प्रत्यय +catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = ब्राउज़र में पथ द्वारा ड्रॉइंग खोलना उपलब्ध नहीं है। diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl new file mode 100644 index 000000000..221ec8bfa --- /dev/null +++ b/locales/ja-JP/opencadstudio.ftl @@ -0,0 +1,160 @@ +language-system = システムの言語 +language-english = English +language-turkish = Türkçe +language-dutch = Nederlands +language-french = Français +language-german = Deutsch +language-hindi = हिन्दी +language-russian = Русский +language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 + +action-close = 閉じる +action-options = オプション +action-plugins = プラグイン + +ribbon-tab-draw = 作図 +ribbon-tab-annotate = 注釈 +ribbon-tab-insert = 挿入 +ribbon-tab-model = モデル +ribbon-tab-layout = レイアウト +ribbon-tab-manage = 管理 +ribbon-tab-view = 表示 + +options-language-section = 言語 +options-language-label = インターフェイスの言語: +options-open-save-section = 開く/保存 +options-default-save-format-label = 既定の保存形式: +options-default-save-format-help = 新しい図面を初めて保存するときに使用します。既存の図面はファイルの種類とバージョンを維持します。 +options-theme-section = テーマ +options-theme-label = アプリケーションのテーマ: +options-theme-help = 基本色を変更するとカスタムテーマに切り替わります。すべての部品の色調は、この6色から生成されます。 +options-color-background = 背景 +options-color-text = テキスト +options-color-primary = プライマリ +options-color-success = 成功 +options-color-warning = 警告 +options-color-danger = 危険 + +command-line-ready = Open CAD Studio の準備ができました。 +command-line-hint = コマンドを入力するか、リボンを使用してください。OBJ は[挿入]タブから開けます。 +command-line-label = コマンド: +command-line-literal-spaces = リテラルスペース: Space キーでコマンドを実行せず、行内に空白を入力します。オフにするまで有効です。 + +area-option-objects = オブジェクト +area-option-back = 戻る +area-option-add = 面積を加算 +area-option-subtract = 面積を減算 +area-prompt-first = AREA 最初のコーナー点を指定 または [オブジェクト/面積を加算/面積を減算] <オブジェクト>: +area-prompt-add = AREA 加算 最初のコーナー点を指定 または [オブジェクト/面積を減算] <オブジェクト>: +area-prompt-subtract = AREA 減算 最初のコーナー点を指定 または [オブジェクト/面積を加算] <オブジェクト>: +area-prompt-next = AREA 次の点を指定 ({ $count } 点選択済み、Enter で計算): +area-prompt-objects = AREA オブジェクトを選択 ({ $count } 個選択済み、Enter で計算): +area-objects-not-measurable = AREA: 選択したオブジェクトに計測可能な面積がありません。 +area-result = 面積 = { $area }、周長 = { $perimeter } +area-result-area-only = 面積 = { $area } +area-running-result = + 面積 = { $area }、周長 = { $perimeter } + 合計面積 = { $total_area }、合計周長 = { $total_perimeter } +area-running-result-area-only = + 面積 = { $area } + 合計面積 = { $total_area } + +start-new-drawing = 新しい図面 +start-open-file = ファイルを開く… +start-donate = 寄付 +start-send-feedback = フィードバックを送信 +start-sponsors = スポンサー +start-tutorials = チュートリアル +start-loading-videos = 動画を読み込んでいます… +start-videos-online = 動画はインターネットから読み込まれます。 +start-open-playlist = YouTube で再生リストを開く +start-discussions = ディスカッション +start-pinned = 固定済み +start-loading-discussions = ディスカッションを読み込んでいます… +start-discussions-online = ディスカッションは GitHub から読み込まれます。 +start-open-discussions = GitHub でディスカッションを開く +start-supporters = サポーター +start-support-on-patreon = Patreon で支援 +start-recent-files = 最近使ったファイル +start-videos = 動画 +start-welcome = ようこそ +start-recent-documents = 最近使ったドキュメント +start-no-recent-files = 開いたファイルがここに表示されます。 +start-browser-storage = ブラウザーストレージ +start-keep-recent-files = 最近使ったファイルを保持 + +modal-about = バージョン情報 +modal-keyboard-shortcuts = キーボードショートカット +modal-command-aliases = コマンドエイリアス +modal-find-replace = 検索と置換 +modal-plugin-manager = プラグインマネージャー +modal-update-available = 更新があります +modal-layer-manager = レイヤーマネージャー +modal-layer-state-manager = レイヤー状態マネージャー +modal-edit-layer-state = レイヤー状態を編集 +modal-plot = 印刷 +modal-layout-manager = レイアウトマネージャー +modal-scale-manager = 尺度マネージャー +modal-annotation-object-scale = 異尺度対応オブジェクトの尺度 +modal-plot-style-editor = 印刷スタイルエディター +modal-text-style-manager = 文字スタイルマネージャー +modal-multiline-style-manager = マルチラインスタイルマネージャー +modal-table-style-manager = 表スタイルマネージャー +modal-multileader-style-manager = マルチ引出線スタイルマネージャー +modal-dimension-style-manager = 寸法スタイルマネージャー +modal-default-application = 既定のアプリケーション +modal-save-warning = 保存の警告 +modal-unable-save = 図面を保存できません +modal-drawing-changed = ディスク上の図面が変更されました +modal-delete-layer = レイヤーを削除 +modal-unsaved-changes = 保存されていない変更 +modal-unsaved-save-prompt = 変更を保存しますか? +clipboard-no-supported-content = クリップボードに対応するテキストまたは図面オブジェクトがありません。 +clipboard-unavailable = システムのクリップボードを使用できません。 +clipboard-occupied = システムのクリップボードは現在使用中です。もう一度お試しください。 +clipboard-conversion-failed = クリップボードのテキストを読み取れませんでした。 +viewport-split-vertical = ビューポートを縦に分割 +viewport-split-horizontal = ビューポートを横に分割 +viewport-move = ビューポートを移動 +viewport-close = ビューポートを閉じる +modal-point-style = 点スタイル +modal-attribute-editor = 属性エディター +modal-save-drawing-as = 図面に名前を付けて保存 +modal-recovery-report = 図面修復レポート +modal-recovery-prompt = 図面の修復 + +recovery-opened-with-repairs = 修復レポートとともに図面を開きました +recovery-open-failed = 図面を開けませんでした +recovery-repaired-description = 図面を開くときに問題が検出されました。レポートを確認し、図面データが修復された場合は新しいファイルとして保存してください。 +recovery-failed-description = 修復処理で使用可能な図面データを生成できませんでした。レポートを確認し、バックアップまたは自動保存コピーをお試しください。 +recovery-entities-checked = 確認したエンティティ +recovery-issues-found = 検出した問題 +recovery-entities-removed = 削除したエンティティ +recovery-references-checked = 確認した参照 +recovery-referenced-entities-removed = 削除した参照エンティティ +recovery-references-unavailable = 使用できない参照 +recovery-log-path = 修復ログ +recovery-log-write-failed = 修復ログを書き込めませんでした +recovery-log-download-ready = 修復レポートをダウンロードできます。 +recovery-save-copy = 修復済みコピーを保存 +recovery-show-log = 修復ログを表示 +recovery-save-new-file-required = 修復した図面は新しいファイルに保存する必要があります。 +recovery-prompt-heading = 通常の方法で開けませんでした +recovery-prompt-description = 図面は変更されていません。修復モードで使用可能なデータを救出できますが、破損したレコードは省略される場合があります。続行しますか? +recovery-attempt = 修復を試行 +recovery-decline = キャンセル + +command-move-base = + MOVE 基点を指定 [{ $count } 個のオブジェクト]: +command-move-target = MOVE 移動先を指定 [基点 { $x },{ $y }]: +command-copy-array-count = COPY 配列複写する項目数を入力: +command-copy-base = + COPY 基点を指定 [{ $count } 個のオブジェクト]: +command-copy-array-target = COPY { $count } 項目の配列複写の2点目を指定 [基点 { $x },{ $y }]: +command-copy-target = COPY 移動先を指定 [これまでに { $count } 個複写 | 配列 | Enter=完了 | 基点 { $x },{ $y }]: + +# 完全なアプリケーションカタログ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index d3bd24189..8f1ac879b 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -7,6 +7,10 @@ language-german = Deutsch language-hindi = हिन्दी language-russian = Русский language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 action-close = Sluiten action-options = Opties @@ -3386,3 +3390,55 @@ style-manager-center-of-text = Tekstmiddelpunt style-manager-center-of-text-overline = Tekstmiddelpunt met bovenstreep style-manager-block-extents = Blokgrenzen style-manager-base-point = Basispunt + +area-option-objects = Objecten +area-option-back = Terug +area-option-add = Oppervlakte optellen +area-option-subtract = Oppervlakte aftrekken +area-prompt-first = AREA Geef het eerste hoekpunt op of [Objecten/Oppervlakte optellen/Oppervlakte aftrekken] : +area-prompt-add = AREA OPTELLEN Geef het eerste hoekpunt op of [Objecten/Oppervlakte aftrekken] : +area-prompt-subtract = AREA AFTREKKEN Geef het eerste hoekpunt op of [Objecten/Oppervlakte optellen] : +area-prompt-next = AREA Geef het volgende punt op ({ $count } gekozen, Enter om te berekenen): +area-prompt-objects = AREA Selecteer objecten ({ $count } geselecteerd, Enter om te berekenen): +area-objects-not-measurable = AREA: geen van de geselecteerde objecten heeft een meetbare oppervlakte. +area-result = Oppervlakte = { $area }, Omtrek = { $perimeter } +area-result-area-only = Oppervlakte = { $area } +area-running-result = + Oppervlakte = { $area }, Omtrek = { $perimeter } + Totale oppervlakte = { $total_area }, Totale omtrek = { $total_perimeter } +area-running-result-area-only = + Oppervlakte = { $area } + Totale oppervlakte = { $total_area } + +modal-recovery-report = Tekeningherstelrapport +modal-recovery-prompt = Tekeningherstel +recovery-opened-with-repairs = Tekening geopend met een herstelrapport +recovery-open-failed = Tekening kon niet worden geopend +recovery-repaired-description = Tijdens het openen van de tekening zijn problemen gevonden. Controleer het rapport; sla de tekening als een nieuw bestand op als gegevens zijn hersteld. +recovery-failed-description = Het herstel kon geen bruikbare tekeninggegevens opleveren. Controleer het rapport en probeer een reservekopie of automatisch opgeslagen kopie. +recovery-entities-checked = Gecontroleerde objecten +recovery-issues-found = Gevonden problemen +recovery-entities-removed = Verwijderde objecten +recovery-references-checked = Gecontroleerde verwijzingen +recovery-referenced-entities-removed = Verwijderde objecten waarnaar werd verwezen +recovery-references-unavailable = Niet-beschikbare verwijzingen +recovery-log-path = Herstellogboek +recovery-log-write-failed = Het herstellogboek kon niet worden geschreven +recovery-log-download-ready = Het herstelrapport kan worden gedownload. +recovery-save-copy = Herstelde kopie opslaan +recovery-show-log = Herstellogboek tonen +recovery-save-new-file-required = Een herstelde tekening moet als een nieuw bestand worden opgeslagen. +recovery-prompt-heading = Normaal openen is mislukt +recovery-prompt-description = De tekening is niet gewijzigd. De herstelmodus kan proberen bruikbare gegevens te redden, maar beschadigde records kunnen worden overgeslagen. Wilt u doorgaan? +recovery-attempt = Herstel proberen +recovery-decline = Annuleren + +catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Selecteer tekstobjecten en druk daarna op Enter: +catalog-tcount-starting-number-start-7471e47711 = TCOUNT Beginnummer <__ocs_fmt_0__>: +catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Stapgrootte <__ocs_fmt_0__>: +catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Plaatsing [Overschrijven/Voorvoegsel/Achtervoegsel] : +catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: plaatsing moet Overschrijven, Voorvoegsel of Achtervoegsel zijn. +catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: __ocs_fmt_0__ tekstobject(en) genummerd vanaf __ocs_fmt_1__ met stappen van __ocs_fmt_2__ (__ocs_fmt_3__). +catalog-prefix-90eceb016c = Voorvoegsel +catalog-suffix-885db975a3 = Achtervoegsel +catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = Een tekening openen via een pad is niet beschikbaar in de browser. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl new file mode 100644 index 000000000..84ed69b9b --- /dev/null +++ b/locales/pt-BR/opencadstudio.ftl @@ -0,0 +1,170 @@ +language-system = Idioma do sistema +language-english = English +language-turkish = Türkçe +language-dutch = Nederlands +language-french = Français +language-german = Deutsch +language-hindi = हिन्दी +language-russian = Русский +language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 + +action-close = Fechar +action-options = Opções +action-plugins = Plugins + +ribbon-tab-draw = Desenhar +ribbon-tab-annotate = Anotar +ribbon-tab-insert = Inserir +ribbon-tab-model = Modelo +ribbon-tab-layout = Layout +ribbon-tab-manage = Gerenciar +ribbon-tab-view = Exibir + +options-language-section = Idioma +options-language-label = Idioma da interface: +options-open-save-section = Abrir e salvar +options-default-save-format-label = Formato padrão ao salvar: +options-default-save-format-help = Usado ao salvar um desenho novo pela primeira vez. Os desenhos existentes mantêm o tipo e a versão do arquivo. +options-theme-section = Tema +options-theme-label = Tema do aplicativo: +options-theme-help = Alterar uma cor base muda para o tema Personalizado. O tema gera todos os tons dos componentes a partir dessas seis cores. +options-color-background = Fundo +options-color-text = Texto +options-color-primary = Principal +options-color-success = Sucesso +options-color-warning = Aviso +options-color-danger = Perigo + +command-line-ready = Open CAD Studio pronto. +command-line-hint = Digite um comando ou use a faixa de opções. Abra OBJ pela guia Inserir. +command-line-label = Comando: +command-line-literal-spaces = Espaços literais: o espaço permanece na linha em vez de executar o comando. Continua ativo até ser desativado. + +area-option-objects = Objetos +area-option-back = Voltar +area-option-add = Adicionar área +area-option-subtract = Subtrair área +area-prompt-first = AREA Especifique o primeiro canto ou [Objetos/Adicionar área/Subtrair área] : +area-prompt-add = AREA ADICIONAR Especifique o primeiro canto ou [Objetos/Subtrair área] : +area-prompt-subtract = AREA SUBTRAIR Especifique o primeiro canto ou [Objetos/Adicionar área] : +area-prompt-next = AREA Especifique o próximo ponto ({ $count } selecionados, Enter para calcular): +area-prompt-objects = AREA Selecione objetos ({ $count } selecionados, Enter para calcular): +area-objects-not-measurable = AREA: nenhum dos objetos selecionados possui área mensurável. +area-result = Área = { $area }, Perímetro = { $perimeter } +area-result-area-only = Área = { $area } +area-running-result = + Área = { $area }, Perímetro = { $perimeter } + Área total = { $total_area }, Perímetro total = { $total_perimeter } +area-running-result-area-only = + Área = { $area } + Área total = { $total_area } + +start-new-drawing = Novo desenho +start-open-file = Abrir arquivo… +start-donate = Doar +start-send-feedback = Enviar feedback +start-sponsors = Patrocinadores +start-tutorials = Tutoriais +start-loading-videos = Carregando vídeos… +start-videos-online = Os vídeos são carregados da internet. +start-open-playlist = Abrir playlist no YouTube +start-discussions = Discussões +start-pinned = Fixados +start-loading-discussions = Carregando discussões… +start-discussions-online = As discussões são carregadas do GitHub. +start-open-discussions = Abrir Discussões no GitHub +start-supporters = Apoiadores +start-support-on-patreon = Apoiar no Patreon +start-recent-files = Arquivos recentes +start-videos = Vídeos +start-welcome = Bem-vindo +start-recent-documents = Documentos recentes +start-no-recent-files = Os arquivos que você abrir aparecerão aqui. +start-browser-storage = Armazenamento do navegador +start-keep-recent-files = Manter arquivos recentes + +modal-about = Sobre +modal-keyboard-shortcuts = Atalhos de teclado +modal-command-aliases = Aliases de comando +modal-find-replace = Localizar e substituir +modal-plugin-manager = Gerenciador de plugins +modal-update-available = Atualização disponível +modal-layer-manager = Gerenciador de camadas +modal-layer-state-manager = Gerenciador de estados de camada +modal-edit-layer-state = Editar estado da camada +modal-plot = Plotar +modal-layout-manager = Gerenciador de layouts +modal-scale-manager = Gerenciador de escalas +modal-annotation-object-scale = Escala do objeto anotativo +modal-plot-style-editor = Editor de estilos de plotagem +modal-text-style-manager = Gerenciador de estilos de texto +modal-multiline-style-manager = Gerenciador de estilos de multilinha +modal-table-style-manager = Gerenciador de estilos de tabela +modal-multileader-style-manager = Gerenciador de estilos de multichamada +modal-dimension-style-manager = Gerenciador de estilos de cota +modal-default-application = Aplicativo padrão +modal-save-warning = Aviso ao salvar +modal-unable-save = Não foi possível salvar o desenho +modal-drawing-changed = Desenho alterado no disco +modal-delete-layer = Excluir camada +modal-unsaved-changes = Alterações não salvas +modal-unsaved-save-prompt = Deseja salvar as alterações? +clipboard-no-supported-content = A área de transferência não contém texto nem objetos de desenho compatíveis. +clipboard-unavailable = A área de transferência do sistema não está disponível. +clipboard-occupied = A área de transferência do sistema está em uso. Tente novamente. +clipboard-conversion-failed = Não foi possível ler o texto da área de transferência. +viewport-split-vertical = Dividir viewport verticalmente +viewport-split-horizontal = Dividir viewport horizontalmente +viewport-move = Mover viewport +viewport-close = Fechar viewport +modal-point-style = Estilo de ponto +modal-attribute-editor = Editor de atributos +modal-save-drawing-as = Salvar desenho como +modal-recovery-report = Relatório de recuperação do desenho +modal-recovery-prompt = Recuperação do desenho + +recovery-opened-with-repairs = Desenho aberto com um relatório de recuperação +recovery-open-failed = Não foi possível abrir o desenho +recovery-repaired-description = Foram detectados problemas ao abrir o desenho. Revise o relatório; se os dados foram reparados, salve o desenho como um novo arquivo. +recovery-failed-description = A recuperação não conseguiu produzir dados de desenho utilizáveis. Revise o relatório e tente um backup ou uma cópia de salvamento automático. +recovery-entities-checked = Entidades verificadas +recovery-issues-found = Problemas encontrados +recovery-entities-removed = Entidades removidas +recovery-references-checked = Referências verificadas +recovery-referenced-entities-removed = Entidades referenciadas removidas +recovery-references-unavailable = Referências indisponíveis +recovery-log-path = Log de recuperação +recovery-log-write-failed = Não foi possível gravar o log de recuperação +recovery-log-download-ready = O relatório de recuperação está pronto para download. +recovery-save-copy = Salvar cópia reparada +recovery-show-log = Mostrar log de recuperação +recovery-save-new-file-required = Um desenho reparado deve ser salvo em um novo arquivo. +recovery-prompt-heading = Falha na abertura normal +recovery-prompt-description = O desenho não foi alterado. O modo de recuperação pode tentar resgatar dados utilizáveis, mas registros danificados podem ser ignorados. Deseja continuar? +recovery-attempt = Tentar recuperar +recovery-decline = Cancelar + +command-move-base = + MOVE Especifique o ponto base [{ $count -> + [one] { $count } objeto + *[other] { $count } objetos + }]: +command-move-target = MOVE Especifique o destino [base { $x },{ $y }]: +command-copy-array-count = COPY Informe a quantidade de itens da matriz: +command-copy-base = + COPY Especifique o ponto base [{ $count -> + [one] { $count } objeto + *[other] { $count } objetos + }]: +command-copy-array-target = COPY Especifique o segundo ponto da matriz de { $count } itens [base { $x },{ $y }]: +command-copy-target = + COPY Especifique o destino [{ $count -> + [one] { $count } cópia até agora + *[other] { $count } cópias até agora + } | Matriz | Enter=concluir | base { $x },{ $y }]: + +# Catálogo completo do aplicativo diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 5de474276..6da58df8f 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -7,6 +7,10 @@ language-german = Deutsch language-hindi = हिन्दी language-russian = Русский language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 action-close = Замкнуть action-options = Параметры @@ -3389,3 +3393,55 @@ style-manager-center-of-text = Центр текста style-manager-center-of-text-overline = Центр текста с надчёркиванием style-manager-block-extents = Габариты блока style-manager-base-point = Базовая точка + +area-option-objects = Объекты +area-option-back = Назад +area-option-add = Добавить площадь +area-option-subtract = Вычесть площадь +area-prompt-first = AREA Укажите первую угловую точку или [Объекты/Добавить площадь/Вычесть площадь] <Объекты>: +area-prompt-add = AREA ДОБАВИТЬ Укажите первую угловую точку или [Объекты/Вычесть площадь] <Объекты>: +area-prompt-subtract = AREA ВЫЧЕСТЬ Укажите первую угловую точку или [Объекты/Добавить площадь] <Объекты>: +area-prompt-next = AREA Укажите следующую точку (выбрано: { $count }, Enter для вычисления): +area-prompt-objects = AREA Выберите объекты (выбрано: { $count }, Enter для вычисления): +area-objects-not-measurable = AREA: ни один из выбранных объектов не имеет измеряемой площади. +area-result = Площадь = { $area }, Периметр = { $perimeter } +area-result-area-only = Площадь = { $area } +area-running-result = + Площадь = { $area }, Периметр = { $perimeter } + Общая площадь = { $total_area }, Общий периметр = { $total_perimeter } +area-running-result-area-only = + Площадь = { $area } + Общая площадь = { $total_area } + +modal-recovery-report = Отчёт о восстановлении чертежа +modal-recovery-prompt = Восстановление чертежа +recovery-opened-with-repairs = Чертёж открыт с отчётом о восстановлении +recovery-open-failed = Не удалось открыть чертёж +recovery-repaired-description = При открытии чертежа обнаружены проблемы. Просмотрите отчёт; если данные чертежа были исправлены, сохраните его в новый файл. +recovery-failed-description = Восстановить пригодные данные чертежа не удалось. Просмотрите отчёт и попробуйте резервную или автоматически сохранённую копию. +recovery-entities-checked = Проверено объектов +recovery-issues-found = Обнаружено проблем +recovery-entities-removed = Удалено объектов +recovery-references-checked = Проверено ссылок +recovery-referenced-entities-removed = Удалено ссылочных объектов +recovery-references-unavailable = Недоступные ссылки +recovery-log-path = Журнал восстановления +recovery-log-write-failed = Не удалось записать журнал восстановления +recovery-log-download-ready = Отчёт о восстановлении готов к загрузке. +recovery-save-copy = Сохранить исправленную копию +recovery-show-log = Показать журнал восстановления +recovery-save-new-file-required = Исправленный чертёж необходимо сохранить в новый файл. +recovery-prompt-heading = Обычное открытие не удалось +recovery-prompt-description = Чертёж не был изменён. Режим восстановления может попытаться извлечь пригодные данные, однако повреждённые записи могут быть пропущены. Продолжить? +recovery-attempt = Попробовать восстановить +recovery-decline = Отмена + +catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Выберите текстовые объекты и нажмите Enter: +catalog-tcount-starting-number-start-7471e47711 = TCOUNT Начальный номер <__ocs_fmt_0__>: +catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Шаг <__ocs_fmt_0__>: +catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Размещение [Заменить/Префикс/Суффикс] <Заменить>: +catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: размещение должно быть «Заменить», «Префикс» или «Суффикс». +catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: пронумеровано текстовых объектов: __ocs_fmt_0__; начало: __ocs_fmt_1__; шаг: __ocs_fmt_2__ (__ocs_fmt_3__). +catalog-prefix-90eceb016c = Префикс +catalog-suffix-885db975a3 = Суффикс +catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = Открытие чертежа по пути недоступно в браузере. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2c37fa600..76fba87ec 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -7,6 +7,10 @@ language-german = Deutsch language-hindi = हिन्दी language-russian = Русский language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 action-close = Kapat action-options = Seçenekler @@ -3351,3 +3355,13 @@ style-manager-center-of-text = Metnin merkezi style-manager-center-of-text-overline = Üst çizgili metin merkezi style-manager-block-extents = Blok sınırları style-manager-base-point = Taban noktası + +catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Metin nesnelerini seçip Enter'a basın: +catalog-tcount-starting-number-start-7471e47711 = TCOUNT Başlangıç numarası <__ocs_fmt_0__>: +catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Artış miktarı <__ocs_fmt_0__>: +catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Yerleştirme [Üzerine yaz/Önek/Sonek] <Üzerine yaz>: +catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: Yerleştirme Üzerine yaz, Önek veya Sonek olmalıdır. +catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: __ocs_fmt_0__ metin nesnesi, __ocs_fmt_1__ değerinden başlayıp __ocs_fmt_2__ artışla numaralandırıldı (__ocs_fmt_3__). +catalog-prefix-90eceb016c = Önek +catalog-suffix-885db975a3 = Sonek +catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = Tarayıcıda dosya yoluyla çizim açılamaz. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 92be54201..3320b8c94 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -7,6 +7,10 @@ language-german = Deutsch language-hindi = हिन्दी language-russian = Русский language-chinese-simplified = 简体中文 +language-spanish = Español +language-portuguese = Português (Brasil) +language-arabic = العربية +language-japanese = 日本語 action-close = 关闭 action-options = 选项 @@ -3371,3 +3375,55 @@ style-manager-center-of-text = 文字中心 style-manager-center-of-text-overline = 带上划线的文字中心 style-manager-block-extents = 块范围 style-manager-base-point = 基点 + +area-option-objects = 对象 +area-option-back = 返回 +area-option-add = 添加面积 +area-option-subtract = 减去面积 +area-prompt-first = AREA 指定第一个角点或 [对象/添加面积/减去面积] <对象>: +area-prompt-add = AREA 添加 指定第一个角点或 [对象/减去面积] <对象>: +area-prompt-subtract = AREA 减去 指定第一个角点或 [对象/添加面积] <对象>: +area-prompt-next = AREA 指定下一点(已选 { $count } 个,按 Enter 计算): +area-prompt-objects = AREA 选择对象(已选 { $count } 个,按 Enter 计算): +area-objects-not-measurable = AREA:所选对象均无可测量面积。 +area-result = 面积 = { $area },周长 = { $perimeter } +area-result-area-only = 面积 = { $area } +area-running-result = + 面积 = { $area },周长 = { $perimeter } + 总面积 = { $total_area },总周长 = { $total_perimeter } +area-running-result-area-only = + 面积 = { $area } + 总面积 = { $total_area } + +modal-recovery-report = 图形修复报告 +modal-recovery-prompt = 图形修复 +recovery-opened-with-repairs = 图形已打开,并生成修复报告 +recovery-open-failed = 无法打开图形 +recovery-repaired-description = 打开图形时检测到问题。请查看报告;如果图形数据已修复,请将其另存为新文件。 +recovery-failed-description = 修复未能生成可用的图形数据。请查看报告并尝试备份或自动保存的副本。 +recovery-entities-checked = 已检查实体 +recovery-issues-found = 发现的问题 +recovery-entities-removed = 已删除实体 +recovery-references-checked = 已检查参照 +recovery-referenced-entities-removed = 已删除被参照实体 +recovery-references-unavailable = 不可用参照 +recovery-log-path = 修复日志 +recovery-log-write-failed = 无法写入修复日志 +recovery-log-download-ready = 修复报告已可下载。 +recovery-save-copy = 保存已修复副本 +recovery-show-log = 显示修复日志 +recovery-save-new-file-required = 修复后的图形必须保存到新文件。 +recovery-prompt-heading = 常规打开失败 +recovery-prompt-description = 图形未被修改。修复模式可以尝试挽救可用数据,但可能跳过损坏的记录。是否继续? +recovery-attempt = 尝试修复 +recovery-decline = 取消 + +catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT 选择文字对象,然后按 Enter: +catalog-tcount-starting-number-start-7471e47711 = TCOUNT 起始编号 <__ocs_fmt_0__>: +catalog-tcount-increment-increment-79f9ab6108 = TCOUNT 增量 <__ocs_fmt_0__>: +catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT 放置方式 [覆盖/前缀/后缀] <覆盖>: +catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT:放置方式必须为覆盖、前缀或后缀。 +catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT:已将 __ocs_fmt_0__ 个文字对象从 __ocs_fmt_1__ 开始按 __ocs_fmt_2__ 的增量编号(__ocs_fmt_3__)。 +catalog-prefix-90eceb016c = 前缀 +catalog-suffix-885db975a3 = 后缀 +catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = 浏览器不支持通过路径打开图形。 diff --git a/src/i18n.rs b/src/i18n.rs index a501ba97b..4281c5146 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -41,10 +41,18 @@ pub enum Language { RuRu, #[serde(rename = "zh-CN")] ZhCn, + #[serde(rename = "es-ES")] + EsEs, + #[serde(rename = "pt-BR")] + PtBr, + #[serde(rename = "ar-SA")] + ArSa, + #[serde(rename = "ja-JP")] + JaJp, } impl Language { - pub const ALL: [Language; 9] = [ + pub const ALL: [Language; 13] = [ Language::System, Language::EnUs, Language::TrTr, @@ -54,6 +62,10 @@ impl Language { Language::HiIn, Language::RuRu, Language::ZhCn, + Language::EsEs, + Language::PtBr, + Language::ArSa, + Language::JaJp, ]; fn requested(self) -> Vec { @@ -67,6 +79,10 @@ impl Language { Language::HiIn => vec!["hi-IN".parse().expect("valid locale")], Language::RuRu => vec!["ru-RU".parse().expect("valid locale")], Language::ZhCn => vec!["zh-CN".parse().expect("valid locale")], + Language::EsEs => vec!["es-ES".parse().expect("valid locale")], + Language::PtBr => vec!["pt-BR".parse().expect("valid locale")], + Language::ArSa => vec!["ar-SA".parse().expect("valid locale")], + Language::JaJp => vec!["ja-JP".parse().expect("valid locale")], } } @@ -81,6 +97,10 @@ impl Language { 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"), } } } @@ -131,6 +151,12 @@ fn load_language( .and_then(|document| document.document_element()) { let _ = root.set_attribute("lang", &language.to_string()); + let direction = if language.to_string().starts_with("ar") { + "rtl" + } else { + "ltr" + }; + let _ = root.set_attribute("dir", direction); } } Ok(()) From d32f39221caf4b2f008f68f7d01df51f2771e734 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 01:24:29 +0300 Subject: [PATCH 13/18] fix(measure): space points along a curve by distance, not by parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DIVIDE and MEASURE did nothing at all on an ELLIPSE or a SPLINE. Their length function answered `0.0` for both — the `_ => 0.0` arm — and the command reported the entity type as unsupported. On a polyline they measured the chords and ignored the bulges, so points landed short of where they belonged; on an arc or a circle they read the centre as world coordinates when the entity stores it in its OCS. All three now walk the entity's own curve, which the kernel measures properly: closed forms where they exist, and the integral of the speed where they do not. A path ARRAY had the same confusion in a different place. It sampled the path at even *parameters*, which is the same as even distances on a line and a circle and nothing like it on an ellipse or a spline — copies bunched up wherever the parameter ran slow. It also now closes properly: on a closed path the last copy no longer lands on the first. LENGTHEN measured an ellipse with a hundred and twenty-eight chords and then bisected on that, and a spline with forty rounds of thirty-two chords apiece. A chord sum reads short, so "current length" was already below the true one before any delta was applied. Both now ask the kernel, which also makes the spline case one evaluation instead of about thirteen hundred. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/modules/draw/inquiry/divide.rs | 147 +++------------------ src/modules/draw/modify/array.rs | 173 ++++-------------------- src/modules/draw/modify/lengthen.rs | 197 ++++++++++------------------ 4 files changed, 116 insertions(+), 403 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 718fbb6c6..663716dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#9406868b1bedd57d121b11fc75658bf9eaa2c764" +source = "git+https://github.com/HakanSeven12/cadkernel.git#92ef66a4e124881252d10eef066c65bd129ebddc" dependencies = [ "cavalier_contours", ] diff --git a/src/modules/draw/inquiry/divide.rs b/src/modules/draw/inquiry/divide.rs index 5c9fabe90..6ce799c80 100644 --- a/src/modules/draw/inquiry/divide.rs +++ b/src/modules/draw/inquiry/divide.rs @@ -1,12 +1,12 @@ // DIVIDE command — place Point entities at N equal intervals along an entity. // MEASURE command — place Point entities at fixed-distance intervals along an entity. -use std::f64::consts::PI; - use acadrust::entities::Point as PointEnt; +use acadrust::kernel::space::PlanarCurve; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::DVec3; +use crate::entities::curve::entity_curve; use crate::t; use crate::command::{CadCommand, CmdResult}; @@ -164,16 +164,12 @@ pub fn divide_entity(entity: &EntityType, n: usize) -> Vec { if n < 2 { return vec![]; } - let total = entity_length(entity); - if total < 1e-10 { + let Some((curve, total)) = measurable(entity) else { return vec![]; - } + }; let step = total / n as f64; (1..n) - .filter_map(|k| { - let t = step * k as f64; - point_at_distance(entity, t).map(make_point) - }) + .map(|k| make_point(curve.point_at_distance(step * k as f64))) .collect() } @@ -182,135 +178,30 @@ pub fn measure_entity(entity: &EntityType, segment_length: f64) -> Vec EntityType { +fn make_point(pos: [f64; 3]) -> EntityType { let mut p = PointEnt::new(); - p.location = pos; + p.location = Vector3::new(pos[0], pos[1], pos[2]); EntityType::Point(p) } -fn entity_length(entity: &EntityType) -> f64 { - match entity { - EntityType::Line(l) => { - let dx = l.end.x - l.start.x; - let dy = l.end.y - l.start.y; - let dz = l.end.z - l.start.z; - (dx * dx + dy * dy + dz * dz).sqrt() - } - EntityType::Arc(a) => { - let span = arc_span_rad(a.start_angle, a.end_angle); - a.radius * span - } - EntityType::Circle(c) => 2.0 * PI * c.radius, - EntityType::LwPolyline(p) => { - let n = p.vertices.len(); - if n < 2 { - return 0.0; - } - let segs = if p.is_closed { n } else { n - 1 }; - (0..segs) - .map(|i| { - let v0 = &p.vertices[i]; - let v1 = &p.vertices[(i + 1) % n]; - let dx = v1.location.x - v0.location.x; - let dy = v1.location.y - v0.location.y; - (dx * dx + dy * dy).sqrt() - }) - .sum() - } - _ => 0.0, - } -} - -fn point_at_distance(entity: &EntityType, d: f64) -> Option { - match entity { - EntityType::Line(l) => { - let total = entity_length(entity); - if total < 1e-10 { - return None; - } - let t = (d / total).clamp(0.0, 1.0); - Some(Vector3::new( - l.start.x + t * (l.end.x - l.start.x), - l.start.y + t * (l.end.y - l.start.y), - l.start.z + t * (l.end.z - l.start.z), - )) - } - EntityType::Arc(a) => { - let span = arc_span_rad(a.start_angle, a.end_angle); - let t = d / a.radius; // arc_length = r * theta - if t > span { - return None; - } - let angle = a.start_angle + t; - Some(Vector3::new( - a.center.x + a.radius * angle.cos(), - a.center.y + a.radius * angle.sin(), - a.center.z, - )) - } - EntityType::Circle(c) => { - let circumference = 2.0 * PI * c.radius; - if circumference < 1e-10 { - return None; - } - let angle = 2.0 * PI * (d / circumference); - Some(Vector3::new( - c.center.x + c.radius * angle.cos(), - c.center.y + c.radius * angle.sin(), - c.center.z, - )) - } - EntityType::LwPolyline(p) => { - let n = p.vertices.len(); - if n < 2 { - return None; - } - let segs = if p.is_closed { n } else { n - 1 }; - let mut acc = 0.0f64; - for i in 0..segs { - let v0 = &p.vertices[i]; - let v1 = &p.vertices[(i + 1) % n]; - let dx = v1.location.x - v0.location.x; - let dy = v1.location.y - v0.location.y; - let seg_len = (dx * dx + dy * dy).sqrt(); - if acc + seg_len >= d - 1e-10 { - let t = (d - acc) / seg_len.max(1e-10); - return Some(Vector3::new( - v0.location.x + t * dx, - v0.location.y + t * dy, - p.elevation, - )); - } - acc += seg_len; - } - None - } - _ => None, - } -} - -fn arc_span_rad(start: f64, end: f64) -> f64 { - let span = (end - start).rem_euclid(2.0 * PI); - if span < 1e-6 { - 2.0 * PI - } else { - span - } +/// The entity's curve and its length, or `None` for anything that cannot be +/// walked along — a hatch, a block, an unbounded ray. +fn measurable(entity: &EntityType) -> Option<(PlanarCurve, f64)> { + let curve = entity_curve(entity)?; + let total = curve.curve.length(); + (total.is_finite() && total > 1e-10).then_some((curve, total)) } diff --git a/src/modules/draw/modify/array.rs b/src/modules/draw/modify/array.rs index 4960a2d12..d0d7a83b6 100644 --- a/src/modules/draw/modify/array.rs +++ b/src/modules/draw/modify/array.rs @@ -422,102 +422,13 @@ impl CadCommand for ArrayPolarCommand { // → Returns BatchCopy with Translate transforms derived from path samples. use acadrust::EntityType; +use crate::entities::curve::entity_curve; use std::f64::consts::PI as FPI; use std::f64::consts::TAU as FTAU; // ── Path geometry helpers ────────────────────────────────────────────────── -/// Tessellate an LwPolyline into dense WCS points, matching the renderer: -/// bulge arcs use the canonical `BulgeArc` (correct centre side + signed -/// sweep) and every point is mapped OCS→WCS via the polyline's normal. The -/// previous ad-hoc bulge math placed the arc centre on the wrong side of the -/// chord and ignored the extrusion, so arc-segment paths came out mirrored. -fn lw_dense_pts(p: &acadrust::entities::LwPolyline) -> Vec { - use crate::entities::common::BulgeArc; - use crate::scene::view::transform::ocs_point_to_wcs; - let verts = &p.vertices; - if verts.is_empty() { - return vec![]; - } - let normal = (p.normal.x, p.normal.y, p.normal.z); - let elev = p.elevation; - let to_wcs = |x: f64, y: f64| -> DVec3 { - let (wx, wy, wz) = ocs_point_to_wcs((x, y, elev), normal); - DVec3::new(wx, wy, wz) - }; - - let n = verts.len(); - let segs = if p.is_closed { n } else { n.saturating_sub(1) }; - let mut out: Vec = vec![]; - - for i in 0..segs { - let v0 = &verts[i]; - let v1 = &verts[(i + 1) % n]; - let p0 = [v0.location.x, v0.location.y]; - let p1 = [v1.location.x, v1.location.y]; - - if out.is_empty() { - out.push(to_wcs(p0[0], p0[1])); - } - - let bulge = v0.bulge; - match (bulge.abs() >= 1e-9) - .then(|| BulgeArc::from_bulge(p0, p1, bulge)) - .flatten() - { - Some(arc) => { - let steps = ((arc.radius * arc.sweep.abs() / 2.0).ceil() as usize).clamp(4, 64); - for j in 1..=steps { - let s = arc.sample(j as f64 / steps as f64); - out.push(to_wcs(s[0], s[1])); - } - } - None => out.push(to_wcs(p1[0], p1[1])), - } - } - out -} - -/// Walk `pts` (ordered) and return `count` points at equal arc-length spacing. -fn subsample_equidistant(pts: &[DVec3], count: usize) -> Vec { - if count == 0 { - return vec![]; - } - if pts.len() < 2 { - return vec![pts.first().copied().unwrap_or(DVec3::ZERO); count]; - } - - let mut cum = vec![0.0f64; pts.len()]; - for i in 1..pts.len() { - cum[i] = cum[i - 1] + pts[i].distance(pts[i - 1]); - } - let total = *cum.last().unwrap(); - if total < 1e-9 { - return vec![pts[0]; count]; - } - - let mut out = Vec::with_capacity(count); - let mut seg = 0usize; - for i in 0..count { - let target = if count > 1 { - total * i as f64 / (count - 1) as f64 - } else { - 0.0 - }; - while seg + 2 < pts.len() && cum[seg + 1] < target - 1e-9 { - seg += 1; - } - let seg_len = cum[seg + 1] - cum[seg]; - let t = if seg_len > 1e-9 { - (target - cum[seg]) / seg_len - } else { - 0.0 - }; - out.push(pts[seg].lerp(pts[seg + 1], t.clamp(0.0, 1.0))); - } - out -} // ── State machine ────────────────────────────────────────────────────────── @@ -570,66 +481,38 @@ impl ArrayPathCommand { } /// Sample `count` evenly-spaced points along `entity`. + /// `count` points spaced evenly **by distance** along the path. + /// + /// By distance, not by parameter. The two agree on a line, a circle and a + /// circular arc, which is how a per-type version got away with confusing + /// them; on an ellipse or a spline they do not, and copies laid out at + /// even parameters bunch up wherever the parameter runs slow. The path's + /// own OCS is honoured too, so an arc carrying a mirrored normal is + /// walked along the side it is drawn on. fn sample_path(entity: &EntityType, count: usize) -> Vec { if count == 0 { return vec![]; } - match entity { - EntityType::Line(l) => { - let p0 = DVec3::new(l.start.x, l.start.y, 0.0); - let p1 = DVec3::new(l.end.x, l.end.y, 0.0); - let d = (count - 1).max(1) as f64; - (0..count).map(|i| p0.lerp(p1, i as f64 / d)).collect() - } - EntityType::Arc(a) => { - // Sample in WCS via the arc's OCS basis so flipped-normal arcs - // (normal = -Z) are traced on the same side the renderer draws, - // matching `entities::arc::to_truck`. Sampling the raw centre + - // angle ignores the extrusion and lands on the mirrored arc. - use crate::scene::view::transform::{ocs_axes, ocs_point_to_wcs}; - let normal = (a.normal.x, a.normal.y, a.normal.z); - let (ax, ay) = ocs_axes(normal); - let (cx, cy, cz) = - ocs_point_to_wcs((a.center.x, a.center.y, a.center.z), normal); - let r = a.radius; - let (sa, ea) = (a.start_angle, a.end_angle); - let end = if ea >= sa { ea } else { ea + std::f64::consts::TAU }; - let span = end - sa; - let d = (count - 1).max(1) as f64; - (0..count) - .map(|i| { - let ang = sa + span * (i as f64 / d); - let (c, s) = (ang.cos(), ang.sin()); - DVec3::new( - cx + r * c * ax.0 + r * s * ay.0, - cy + r * c * ax.1 + r * s * ay.1, - cz + r * c * ax.2 + r * s * ay.2, - ) - }) - .collect() - } - EntityType::Circle(c) => { - use crate::scene::view::transform::{ocs_axes, ocs_point_to_wcs}; - let normal = (c.normal.x, c.normal.y, c.normal.z); - let (ax, ay) = ocs_axes(normal); - let (cx, cy, cz) = - ocs_point_to_wcs((c.center.x, c.center.y, c.center.z), normal); - let r = c.radius; - (0..count) - .map(|i| { - let ang = i as f64 / count as f64 * std::f64::consts::TAU; - let (cs, sn) = (ang.cos(), ang.sin()); - DVec3::new( - cx + r * cs * ax.0 + r * sn * ay.0, - cy + r * cs * ax.1 + r * sn * ay.1, - cz + r * cs * ax.2 + r * sn * ay.2, - ) - }) - .collect() - } - EntityType::LwPolyline(p) => subsample_equidistant(&lw_dense_pts(p), count), - _ => vec![DVec3::ZERO; count], + let Some(curve) = entity_curve(entity) else { + return vec![DVec3::ZERO; count]; + }; + let total = curve.length(); + if !total.is_finite() || total <= 0.0 { + return vec![DVec3::ZERO; count]; } + // A closed path has no far end to stop short of, so the last copy + // must not land on top of the first. + let steps = if curve.is_closed() { + count as f64 + } else { + (count - 1).max(1) as f64 + }; + (0..count) + .map(|i| { + let p = curve.point_at_distance(total * (i as f64 / steps)); + DVec3::new(p[0], p[1], p[2]) + }) + .collect() } /// Continuous path-tangent angle (XY) at each sample, via central diff --git a/src/modules/draw/modify/lengthen.rs b/src/modules/draw/modify/lengthen.rs index 3044a2dd2..a8ece8057 100644 --- a/src/modules/draw/modify/lengthen.rs +++ b/src/modules/draw/modify/lengthen.rs @@ -11,6 +11,9 @@ use crate::modules::draw::modify::spline_ops::{spline_cut, spline_to_nurbs}; use acadrust::entities::{ Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt, }; +use acadrust::kernel::geom2d::{ + Curve, Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc, +}; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::{DVec3, Vec3}; @@ -18,6 +21,8 @@ use crate::t; use crate::command::{CadCommand, CmdResult}; +const TAU: f64 = std::f64::consts::TAU; + pub struct LengthenCommand { state: LenState, } @@ -263,71 +268,64 @@ fn lengthen_ellipse(ell: &EllipseEnt, pick_pt: Vec3, mode: &LenMode) -> Option f64 { - let n = 128usize; - let mut len = 0.0; - for i in 0..n { - let ti = t0 + span * (i as f64 / n as f64); - let tip = t0 + span * ((i + 1) as f64 / n as f64); - let xi = a * ti.cos() * nx - b * ti.sin() * ny + ell.center.x; - let yi = a * ti.cos() * ny + b * ti.sin() * nx + ell.center.y; - let xip = a * tip.cos() * nx - b * tip.sin() * ny + ell.center.x; - let yip = a * tip.cos() * ny + b * tip.sin() * nx + ell.center.y; - len += (xip - xi).hypot(yip - yi); - } - len + // Measured by the kernel rather than by a hundred and twenty-eight + // chords: the chord sum reads short, so LENGTHEN's idea of "current" was + // already below the true length before a delta was applied to it. + let shape = KernelEllipse { + centre: [ell.center.x, ell.center.y], + major_radius: a, + minor_radius: b, + major_axis: [nx, ny], }; - - let current_len = arc_len_approx(span); + let arc = |from: f64, to: f64| { + Curve::Ellipse(KernelEllipseArc { + ellipse: shape, + start_parameter: from, + end_parameter: to, + }) + }; + let current_len = arc(t0, t1).length(); if current_len < 1e-10 { return None; } - let new_len = apply_mode(current_len, mode)?; if new_len < 1e-10 { return None; } - // Find the new span via bisection so that arc_len_approx(new_span) ≈ new_len. - let max_span = std::f64::consts::TAU; - let mut lo = 0.0f64; - let mut hi = max_span; - for _ in 0..40 { - let mid = (lo + hi) * 0.5; - if arc_len_approx(mid) < new_len { - lo = mid; - } else { - hi = mid; - } - } - let new_span = (lo + hi) * 0.5; - - // Determine which end is closer to pick_pt (use DXF XY plane). - let p_x = pick_pt.x as f64; - let p_y = pick_pt.y as f64; - let pt_start_x = ell.center.x + a * t0.cos() * nx - b * t0.sin() * ny; - let pt_start_y = ell.center.y + a * t0.cos() * ny + b * t0.sin() * nx; - let pt_end_x = ell.center.x + a * t1.cos() * nx - b * t1.sin() * ny; - let pt_end_y = ell.center.y + a * t1.cos() * ny + b * t1.sin() * nx; - let dist_start = (p_x - pt_start_x).hypot(p_y - pt_start_y); - let dist_end = (p_x - pt_end_x).hypot(p_y - pt_end_y); + // Which end is closer to the pick, in the DXF XY plane. + let point_at = |t: f64| { + ( + ell.center.x + a * t.cos() * nx - b * t.sin() * ny, + ell.center.y + a * t.cos() * ny + b * t.sin() * nx, + ) + }; + let (p_x, p_y) = (pick_pt.x as f64, pick_pt.y as f64); + let (sx, sy) = point_at(t0); + let (ex, ey) = point_at(t1); + let extend_end = (p_x - ex).hypot(p_y - ey) <= (p_x - sx).hypot(p_y - sy); let mut result = ell.clone(); result.common.handle = Handle::NULL; - - if dist_end <= dist_start { - result.end_parameter = t0 + new_span; + if extend_end { + // Walk `new_len` forward from the fixed start. A whole turn is the + // most there is to walk, and the kernel clamps to it. + let whole = arc(t0, t0 + TAU); + result.end_parameter = t0 + whole.parameter_at_distance(new_len) * TAU; } else { - result.start_parameter = t1 - new_span; + // The same measured backwards from the fixed end: the last `new_len` + // of a whole turn ending at t1. + let whole = arc(t1 - TAU, t1); + let from_start = whole.length() - new_len; + result.start_parameter = t1 - TAU + whole.parameter_at_distance(from_start) * TAU; } Some(EntityType::Ellipse(result)) } + fn apply_mode(current: f64, mode: &LenMode) -> Option { match mode { LenMode::Delta(d) => Some(current + d), @@ -337,9 +335,9 @@ fn apply_mode(current: f64, mode: &LenMode) -> Option { } fn arc_span_rad(start: f64, end: f64) -> f64 { - let span = (end - start).rem_euclid(std::f64::consts::TAU); + let span = (end - start).rem_euclid(TAU); if span < 1e-6 { - std::f64::consts::TAU + TAU } else { span } @@ -410,103 +408,44 @@ fn lengthen_lwpoly(poly: &LwPolyline, pick_pt: Vec3, mode: &LenMode) -> Option Option { - let curve = spline_to_nurbs(spl)?; - let (t0, t1) = curve.domain(); + let nurbs = spline_to_nurbs(spl)?; + let (t0, t1) = nurbs.domain(); if (t1 - t0).abs() < 1e-12 { return None; } - - // Approximate arc length via 64-point numerical integration. - let arc_len = { - let n = 64usize; - let mut len = 0.0f64; - for i in 0..n { - let ta = t0 + (t1 - t0) * (i as f64 / n as f64); - let tb = t0 + (t1 - t0) * ((i + 1) as f64 / n as f64); - let pa = curve.point_at_knot(ta); - let pb = curve.point_at_knot(tb); - len += (pb[0] - pa[0]).hypot(pb[1] - pa[1]); - } - len - }; + let curve = Curve::Nurbs(nurbs.clone()); + let arc_len = curve.length(); if arc_len < 1e-10 { return None; } - let new_len = apply_mode(arc_len, mode)?; - if new_len < 1e-10 { + if new_len < 1e-10 || new_len >= arc_len { + // A spline is shortened by splitting it, so there is nothing to keep + // if the new length is the whole of it or more. Extending would mean + // continuing the curve past its own control polygon, which is a + // different operation from cutting one. return None; } - // Determine which end (start or end) is closer to pick_pt. - let p_start = curve.point_at_knot(t0); - let p_end = curve.point_at_knot(t1); - let dist_start = (p_start[0] - pick_pt.x as f64).hypot(p_start[1] - pick_pt.y as f64); - let dist_end = (p_end[0] - pick_pt.x as f64).hypot(p_end[1] - pick_pt.y as f64); - let extend_end = dist_end <= dist_start; + let p_start = nurbs.point_at_knot(t0); + let p_end = nurbs.point_at_knot(t1); + let (px, py) = (pick_pt.x as f64, pick_pt.y as f64); + let extend_end = (p_end[0] - px).hypot(p_end[1] - py) + <= (p_start[0] - px).hypot(p_start[1] - py); - // Find the parameter `t_new` such that the arc length from the fixed end to t_new = new_len. - // Use bisection on cumulative arc length. - let fixed_t = if extend_end { t0 } else { t1 }; - let delta_ratio = new_len / arc_len; - - // Find t_new via bisection: cumulative_len(fixed_t..t_new) = new_len. - let cum_len = |t_end_param: f64| -> f64 { - let (lo, hi) = if extend_end { - (t0, t_end_param) - } else { - (t_end_param, t1) - }; - if hi <= lo { - return 0.0; - } - let n = 32usize; - let mut len = 0.0f64; - for i in 0..n { - let ta = lo + (hi - lo) * (i as f64 / n as f64); - let tb = lo + (hi - lo) * ((i + 1) as f64 / n as f64); - let pa = curve.point_at_knot(ta); - let pb = curve.point_at_knot(tb); - len += (pb[0] - pa[0]).hypot(pb[1] - pa[1]); - } - len - }; - - let (mut lo_t, mut hi_t) = if extend_end { - (t0, t0 + delta_ratio * (t1 - t0) * 2.0) + // Where to cut, by distance along the curve rather than by a bisection + // over repeated chord sums. Keeping the head means cutting `new_len` from + // the start; keeping the tail means cutting what is left over. + let along = if extend_end { + new_len } else { - (t1 - delta_ratio * (t1 - t0) * 2.0, t1) + arc_len - new_len }; - // Clamp to valid range with some buffer for extension. - let buf = (t1 - t0) * 0.5; - lo_t = lo_t.max(t0 - buf); - hi_t = hi_t.min(t1 + buf); - - for _ in 0..40 { - let mid = (lo_t + hi_t) * 0.5; - if cum_len(mid) < new_len { - if extend_end { - hi_t = mid; - } else { - lo_t = mid; - } - } else { - if extend_end { - lo_t = mid; - } else { - hi_t = mid; - } - } - } - let t_new = (lo_t + hi_t) * 0.5; - let _ = fixed_t; - - // Split the spline at t_new and keep the side the pick asked for. - let cut = t_new.clamp(t0 + 1e-10, t1 - 1e-10); + let at = curve.parameter_at_distance(along); + let cut = (t0 + at * (t1 - t0)).clamp(t0 + 1e-10, t1 - 1e-10); let (left, right) = spline_cut(spl, cut)?; Some(EntityType::Spline(if extend_end { left } else { right })) } - // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["LENGTHEN"] }); // LengthenCommand From f9a0d73dc681a85bb3e6035f482ba1c7069cf982 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 01:30:32 +0300 Subject: [PATCH 14/18] refactor(hatch): take the boundary arrangement from the kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 624 lines become 70. What went is the planar arrangement itself — splitting segments at their crossings, welding coincident endpoints and tracing the bounded faces of the resulting graph — which is not a drawing-specific problem. It is the same one a B-rep boolean solves in a face's parameter space once the intersection curves are projected into it, so solving it in the kernel means the boolean does not arrive with a second copy. What stays is reading the wires, which is where the drawing's own conventions are: NaNs separating the runs inside one WireModel, and the double-single coordinate pair that has to be recombined before anything is measured. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/scene/boundary.rs | 618 +++--------------------------------------- 2 files changed, 33 insertions(+), 587 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 663716dbb..6abac6fa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#92ef66a4e124881252d10eef066c65bd129ebddc" +source = "git+https://github.com/HakanSeven12/cadkernel.git#441e3c6d8a55f3a8e799ef3cbe1e0b4301d66f14" dependencies = [ "cavalier_contours", ] diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 054ca4f38..6f2eee63d 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -1,92 +1,14 @@ use super::*; -use std::collections::{HashMap, HashSet}; +use acadrust::kernel::geom2d::{bounded_faces, Line}; -const NODE_EPS: f64 = 1.0e-6; -const ORIENTATION_EPS: f64 = 1.0e-12; -const AREA_EPS: f64 = 1.0e-10; - -#[derive(Clone, Copy, Debug)] -struct P2 { - x: f64, - y: f64, -} - -impl P2 { - fn new(x: f64, y: f64) -> Self { - Self { x, y } - } - - fn lerp(self, other: Self, t: f64) -> Self { - Self { - x: self.x + (other.x - self.x) * t, - y: self.y + (other.y - self.y) * t, - } - } - - fn distance2(self, other: Self) -> f64 { - let dx = self.x - other.x; - let dy = self.y - other.y; - dx * dx + dy * dy - } -} - -#[derive(Clone, Copy, Debug)] -struct Segment { - a: P2, - b: P2, -} - -impl Segment { - fn new(a: P2, b: P2) -> Self { - Self { a, b } - } -} - -#[derive(Clone, Copy, Debug)] -struct SegmentAabb { - min_x: f64, - max_x: f64, - min_y: f64, - max_y: f64, -} - -impl SegmentAabb { - fn new(segment: Segment) -> Self { - Self { - min_x: segment.a.x.min(segment.b.x), - max_x: segment.a.x.max(segment.b.x), - min_y: segment.a.y.min(segment.b.y), - max_y: segment.a.y.max(segment.b.y), - } - } - - fn min(self, axis: SweepAxis) -> f64 { - match axis { - SweepAxis::X => self.min_x, - SweepAxis::Y => self.min_y, - } - } - - fn max(self, axis: SweepAxis) -> f64 { - match axis { - SweepAxis::X => self.max_x, - SweepAxis::Y => self.max_y, - } - } -} - -#[derive(Clone, Copy)] -enum SweepAxis { - X, - Y, -} - -enum SegmentIntersection { - None, - Point { a: f64, b: f64 }, - Overlap { a: [f64; 2], b: [f64; 2] }, -} +/// How far apart two points may be and still be taken for the same one. +/// +/// The boundary search runs on already-tessellated wire geometry, so the +/// input is a chord approximation of the drawn curves to begin with; this +/// only has to be coarse enough to close the gaps that leaves and fine +/// enough not to weld genuinely separate corners together. +const WELD_TOLERANCE: f64 = 1.0e-6; impl Scene { /// Build closed planar regions from the visible wire geometry. @@ -98,12 +20,17 @@ impl Scene { /// Curved entities participate through their already-tessellated WireModel /// geometry, so arcs, circles, ellipses and splines can take part in the /// boundary search without modifying the source entities. + /// + /// The arrangement itself is the kernel's: splitting at crossings, welding + /// coincident ends and tracing the bounded faces is the same problem a + /// B-rep boolean solves in a face's parameter space, and it is solved + /// once. What stays here is reading the wires — which is where the + /// drawing's own conventions live. pub fn hatch_boundary_outlines(&self) -> Vec> { - let wires = self.entity_wires(); - let mut segments = Vec::::new(); + let mut segments = Vec::::new(); - for wire in wires.iter() { - let mut previous: Option = None; + for wire in self.entity_wires().iter() { + let mut previous: Option<[f64; 2]> = None; for (index, high) in wire.points.iter().copied().enumerate() { // NaNs delimit independent segments inside some WireModels, @@ -113,16 +40,24 @@ impl Scene { continue; } + // The wire carries its coordinates as a double-single pair, so + // both halves are needed to recover the f64 the tessellation + // produced. Reading only the high half would put every vertex + // of a survey-coordinate drawing on a grid coarser than the + // weld tolerance. let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); - - let current = P2::new( + let current = [ high[0] as f64 + low[0] as f64, high[1] as f64 + low[1] as f64, - ); + ]; - if let Some(prev) = previous { - if prev.distance2(current) > NODE_EPS * NODE_EPS { - segments.push(Segment::new(prev, current)); + if let Some(start) = previous { + let (dx, dy) = (current[0] - start[0], current[1] - start[1]); + if dx.hypot(dy) > WELD_TOLERANCE { + segments.push(Line { + start, + end: current, + }); } } @@ -130,495 +65,6 @@ impl Scene { } } - build_planar_outlines(&segments) - .into_iter() - .map(|ring| ring.into_iter().map(|p| [p.x, p.y]).collect()) - .collect() - } -} - -/// Build all bounded faces produced by a collection of planar segments. -/// -/// Every intersection splits the participating segments virtually. The -/// resulting pieces are assembled into an undirected planar graph and its -/// bounded faces are traced using a half-edge walk. -fn build_planar_outlines(segments: &[Segment]) -> Vec> { - if segments.is_empty() { - return Vec::new(); - } - - // Each original segment starts with its two endpoints as split positions. - let mut cuts: Vec> = vec![vec![0.0, 1.0]; segments.len()]; - - // Sweep the less-congested coordinate axis, then run the exact AABB and - // segment tests only for active candidates. Dense intersections remain - // output-sensitive, while spatially separated geometry avoids all-pairs - // work. - let aabbs: Vec = segments.iter().copied().map(SegmentAabb::new).collect(); - let axis = choose_sweep_axis(&aabbs); - let mut order: Vec = (0..segments.len()).collect(); - order.sort_by(|&a, &b| aabbs[a].min(axis).total_cmp(&aabbs[b].min(axis))); - let mut active = Vec::::new(); - - for i in order { - let current_min = aabbs[i].min(axis); - active.retain(|&j| aabbs[j].max(axis) + NODE_EPS >= current_min); - - for &j in &active { - if !segment_aabbs_overlap(aabbs[i], aabbs[j]) { - continue; - } - - match segment_intersection(segments[i], segments[j]) { - SegmentIntersection::None => {} - SegmentIntersection::Point { a, b } => { - cuts[i].push(a.clamp(0.0, 1.0)); - cuts[j].push(b.clamp(0.0, 1.0)); - } - SegmentIntersection::Overlap { a, b } => { - cuts[i].push(a[0].clamp(0.0, 1.0)); - cuts[i].push(a[1].clamp(0.0, 1.0)); - cuts[j].push(b[0].clamp(0.0, 1.0)); - cuts[j].push(b[1].clamp(0.0, 1.0)); - } - } - } - - active.push(i); - } - - // Split every segment at all of its intersection parameters. - let mut pieces = Vec::::new(); - - for (segment, params) in segments.iter().copied().zip(cuts.iter_mut()) { - let segment_len = segment.a.distance2(segment.b).sqrt(); - let param_merge_eps = (NODE_EPS / segment_len).min(1.0); - params.sort_by(|a, b| a.total_cmp(b)); - params.dedup_by(|a, b| (*a - *b).abs() <= param_merge_eps); - - for pair in params.windows(2) { - let t0 = pair[0]; - let t1 = pair[1]; - - if (t1 - t0) * segment_len <= NODE_EPS { - continue; - } - - let a = segment.a.lerp(segment.b, t0); - let b = segment.a.lerp(segment.b, t1); - - if a.distance2(b) > NODE_EPS * NODE_EPS { - pieces.push(Segment::new(a, b)); - } - } - } - - // Convert split segment endpoints to graph nodes. - let mut nodes = Vec::::new(); - let mut node_map = HashMap::<(i64, i64), Vec>::new(); - let mut edges = HashSet::<(usize, usize)>::new(); - - for piece in pieces { - let a = node_for_point(piece.a, &mut nodes, &mut node_map); - let b = node_for_point(piece.b, &mut nodes, &mut node_map); - - if a == b { - continue; - } - - let edge = if a < b { (a, b) } else { (b, a) }; - edges.insert(edge); - } - - if edges.is_empty() { - return Vec::new(); - } - - // Build adjacency lists. - let mut adjacency = vec![Vec::::new(); nodes.len()]; - - for &(a, b) in &edges { - adjacency[a].push(b); - adjacency[b].push(a); - } - - // A planar half-edge walk needs the neighbors around every vertex sorted - // by polar angle. - for (vertex, neighbors) in adjacency.iter_mut().enumerate() { - let origin = nodes[vertex]; - - neighbors.sort_by(|&a, &b| { - let aa = (nodes[a].y - origin.y).atan2(nodes[a].x - origin.x); - let ab = (nodes[b].y - origin.y).atan2(nodes[b].x - origin.x); - aa.total_cmp(&ab) - }); - - neighbors.dedup(); - } - - let mut visited = HashSet::<(usize, usize)>::new(); - let mut faces = Vec::>::new(); - - // Each undirected edge represents two directed half-edges. Walking each - // unused half-edge while always taking the clockwise neighbor at the next - // vertex traces one face. - for u in 0..nodes.len() { - for &v in &adjacency[u] { - if visited.contains(&(u, v)) { - continue; - } - - let start = (u, v); - let mut current = start; - let mut ring = Vec::::new(); - let mut closed = false; - - // A valid planar face cannot require more directed edges than exist - // in the complete graph. This also protects against malformed input. - let max_steps = edges.len() * 2 + 1; - - for _ in 0..max_steps { - if visited.contains(¤t) { - break; - } - - visited.insert(current); - - let (from, to) = current; - ring.push(nodes[from]); - - let neighbors = &adjacency[to]; - if neighbors.is_empty() { - break; - } - - let Some(incoming_index) = neighbors.iter().position(|&neighbor| neighbor == from) - else { - break; - }; - - // Neighbors are sorted counter-clockwise. Taking the previous - // one keeps the bounded face on the left side of the half-edge. - let next_index = if incoming_index == 0 { - neighbors.len() - 1 - } else { - incoming_index - 1 - }; - - let next = neighbors[next_index]; - current = (to, next); - - if current == start { - closed = true; - break; - } - } - - if !closed || ring.len() < 3 { - continue; - } - - let area = signed_area(&ring); - - // The reverse traversal produces the unbounded exterior face. - // With the walk rule above, bounded faces are counter-clockwise. - if area > AREA_EPS { - faces.push(ring); - } - } - } - - faces -} - -fn node_for_point( - p: P2, - nodes: &mut Vec, - map: &mut HashMap<(i64, i64), Vec>, -) -> usize { - let key = node_key(p); - - for dx in -1_i64..=1 { - for dy in -1_i64..=1 { - let neighbor = (key.0.saturating_add(dx), key.1.saturating_add(dy)); - if let Some(indices) = map.get(&neighbor) { - if let Some(&index) = indices - .iter() - .find(|&&index| nodes[index].distance2(p) <= NODE_EPS * NODE_EPS) - { - return index; - } - } - } - } - - let index = nodes.len(); - nodes.push(p); - map.entry(key).or_default().push(index); - index -} - -fn node_key(p: P2) -> (i64, i64) { - ( - (p.x / NODE_EPS).floor() as i64, - (p.y / NODE_EPS).floor() as i64, - ) -} - -fn choose_sweep_axis(aabbs: &[SegmentAabb]) -> SweepAxis { - if normalized_interval_span(aabbs, SweepAxis::X) - <= normalized_interval_span(aabbs, SweepAxis::Y) - { - SweepAxis::X - } else { - SweepAxis::Y - } -} - -fn normalized_interval_span(aabbs: &[SegmentAabb], axis: SweepAxis) -> f64 { - let min = aabbs - .iter() - .map(|aabb| aabb.min(axis)) - .fold(f64::INFINITY, f64::min); - let max = aabbs - .iter() - .map(|aabb| aabb.max(axis)) - .fold(f64::NEG_INFINITY, f64::max); - let extent = max - min; - - if !extent.is_finite() || extent <= NODE_EPS { - return f64::INFINITY; - } - - let total: f64 = aabbs - .iter() - .map(|aabb| aabb.max(axis) - aabb.min(axis) + NODE_EPS) - .sum(); - total / extent -} - -fn segment_aabbs_overlap(a: SegmentAabb, b: SegmentAabb) -> bool { - a.max_x + NODE_EPS >= b.min_x - && b.max_x + NODE_EPS >= a.min_x - && a.max_y + NODE_EPS >= b.min_y - && b.max_y + NODE_EPS >= a.min_y -} - -/// Intersection parameters of two finite XY segments. -fn segment_intersection(a: Segment, b: Segment) -> SegmentIntersection { - let rx = a.b.x - a.a.x; - let ry = a.b.y - a.a.y; - - let sx = b.b.x - b.a.x; - let sy = b.b.y - b.a.y; - - let r2 = rx * rx + ry * ry; - let s2 = sx * sx + sy * sy; - if r2 <= f64::EPSILON || s2 <= f64::EPSILON { - return SegmentIntersection::None; - } - let r_len = r2.sqrt(); - let s_len = s2.sqrt(); - let cross = rx * sy - ry * sx; - let qpx = b.a.x - a.a.x; - let qpy = b.a.y - a.a.y; - - if cross.abs() <= ORIENTATION_EPS * r_len * s_len { - if (qpx * ry - qpy * rx).abs() > NODE_EPS * r_len { - return SegmentIntersection::None; - } - - let b0_on_a = (qpx * rx + qpy * ry) / r2; - let b1_on_a = b0_on_a + (sx * rx + sy * ry) / r2; - let lo = b0_on_a.min(b1_on_a).max(0.0); - let hi = b0_on_a.max(b1_on_a).min(1.0); - let a_param_eps = NODE_EPS / r_len; - - if hi < lo - a_param_eps { - return SegmentIntersection::None; - } - - let lo = lo.clamp(0.0, 1.0); - let hi = hi.clamp(0.0, 1.0); - let lo_point = a.a.lerp(a.b, lo); - let hi_point = a.a.lerp(a.b, hi); - let b_lo = ((lo_point.x - b.a.x) * sx + (lo_point.y - b.a.y) * sy) / s2; - let b_hi = ((hi_point.x - b.a.x) * sx + (hi_point.y - b.a.y) * sy) / s2; - - if (hi - lo) * r_len <= NODE_EPS { - return SegmentIntersection::Point { - a: (lo + hi) * 0.5, - b: (b_lo + b_hi) * 0.5, - }; - } - - return SegmentIntersection::Overlap { - a: [lo, hi], - b: [b_lo, b_hi], - }; - } - - let t = (qpx * sy - qpy * sx) / cross; - let u = (qpx * ry - qpy * rx) / cross; - let t_eps = NODE_EPS / r_len; - let u_eps = NODE_EPS / s_len; - - if t >= -t_eps && t <= 1.0 + t_eps && u >= -u_eps && u <= 1.0 + u_eps { - SegmentIntersection::Point { a: t, b: u } - } else { - SegmentIntersection::None - } -} - -fn signed_area(poly: &[P2]) -> f64 { - if poly.len() < 3 { - return 0.0; - } - - let origin = poly[0]; - let mut area = 0.0; - - for i in 1..poly.len() - 1 { - let a = poly[i]; - let b = poly[i + 1]; - area += (a.x - origin.x) * (b.y - origin.y) - - (b.x - origin.x) * (a.y - origin.y); - } - - area * 0.5 -} - -#[cfg(test)] -mod tests { - use super::*; - - fn s(ax: f64, ay: f64, bx: f64, by: f64) -> Segment { - Segment::new(P2::new(ax, ay), P2::new(bx, by)) - } - - #[test] - fn four_unjoined_lines_form_one_face() { - let segments = vec![ - s(0.0, 0.0, 10.0, 0.0), - s(10.0, 0.0, 10.0, 10.0), - s(10.0, 10.0, 0.0, 10.0), - s(0.0, 10.0, 0.0, 0.0), - ]; - - let faces = build_planar_outlines(&segments); - - assert_eq!(faces.len(), 1); - assert!((signed_area(&faces[0]) - 100.0).abs() < 1.0e-6); - } - - #[test] - fn lines_may_extend_past_the_boundary() { - let segments = vec![ - s(-5.0, 0.0, 15.0, 0.0), - s(-5.0, 10.0, 15.0, 10.0), - s(0.0, -5.0, 0.0, 15.0), - s(10.0, -5.0, 10.0, 15.0), - ]; - - let faces = build_planar_outlines(&segments); - - assert_eq!(faces.len(), 1); - assert!((signed_area(&faces[0]) - 100.0).abs() < 1.0e-6); - } - - #[test] - fn open_geometry_does_not_create_a_face() { - let segments = vec![ - s(0.0, 0.0, 10.0, 0.0), - s(10.0, 0.0, 10.0, 10.0), - s(10.0, 10.0, 0.0, 10.0), - ]; - - let faces = build_planar_outlines(&segments); - - assert!(faces.is_empty()); - } - - #[test] - fn collinear_overlap_closes_rectangle() { - let segments = vec![ - s(0.0, 0.0, 7.0, 0.0), - s(3.0, 0.0, 10.0, 0.0), - s(10.0, 0.0, 10.0, 10.0), - s(10.0, 10.0, 0.0, 10.0), - s(0.0, 10.0, 0.0, 0.0), - ]; - - let faces = build_planar_outlines(&segments); - - assert_eq!(faces.len(), 1); - assert!((signed_area(&faces[0]) - 100.0).abs() < 1.0e-6); - } - - #[test] - fn node_merge_crosses_bucket_boundary() { - let mut nodes = Vec::new(); - let mut map = HashMap::new(); - let a = node_for_point(P2::new(NODE_EPS * 0.99, 0.0), &mut nodes, &mut map); - let b = node_for_point(P2::new(NODE_EPS * 1.01, 0.0), &mut nodes, &mut map); - - assert_eq!(a, b); - } - - #[test] - fn node_merge_rejects_distant_diagonal_points() { - let mut nodes = Vec::new(); - let mut map = HashMap::new(); - let a = node_for_point( - P2::new(NODE_EPS * 0.51, NODE_EPS * 0.51), - &mut nodes, - &mut map, - ); - let b = node_for_point( - P2::new(NODE_EPS * 1.49, NODE_EPS * 1.49), - &mut nodes, - &mut map, - ); - - assert_ne!(a, b); - } - - #[test] - fn signed_area_is_stable_at_large_coordinates() { - let base = 1.0e12; - let poly = vec![ - P2::new(base, base), - P2::new(base + 3.0, base), - P2::new(base + 3.0, base + 4.0), - P2::new(base, base + 4.0), - ]; - - assert!((signed_area(&poly) - 12.0).abs() < 1.0e-9); - } - - #[test] - fn long_segments_keep_world_scale_cuts() { - let segments = vec![ - s(0.0, 0.0, 1.0e12, 0.0), - s(0.0, 1.0, 1.0e12, 1.0), - s(1.0, -1.0, 1.0, 2.0), - s(2.0, -1.0, 2.0, 2.0), - ]; - - let faces = build_planar_outlines(&segments); - - assert_eq!(faces.len(), 1); - assert!((signed_area(&faces[0]) - 1.0).abs() < 1.0e-9); - } - - #[test] - fn long_near_collinear_segments_overlap_within_node_tolerance() { - let a = s(0.0, 0.0, 1.0e9, 1.0e-3); - let b = s(5.0e8, 5.0e-4 + 5.0e-7, 1.5e9, 1.5e-3 + 5.0e-7); - - assert!(matches!( - segment_intersection(a, b), - SegmentIntersection::Overlap { .. } - )); + bounded_faces(&segments, WELD_TOLERANCE) } } From 9babf355b1fe72e1068937d1583b3db3c7f534d9 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 01:37:11 +0300 Subject: [PATCH 15/18] refactor(fillet): find the tangent arc in the kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FILLET enumerated its own candidates twice, once for a line against an arc and once for two arcs, each with its own quadratic, its own four sign combinations and its own conventions about which root meant what. Both are the same construction: a circle of the given radius tangent to a curve has its centre on one of that curve's offsets, so the fillet centre is where an offset of one crosses an offset of the other. That now comes from the kernel, which does it for any pair of straight and circular curves and agrees with the corner formula where both apply. What stays here is the part that genuinely belongs to the command — which candidate the two picks meant, whether the touch lands on the drawn part of an arc rather than elsewhere on its circle, and which side of each entity to keep. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/modules/draw/modify/fillet.rs | 315 ++++++++++++------------------ src/scene/boundary.rs | 4 +- 3 files changed, 132 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6abac6fa3..72eb2896f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#441e3c6d8a55f3a8e799ef3cbe1e0b4301d66f14" +source = "git+https://github.com/HakanSeven12/cadkernel.git#77d8ee9d3ee7f8bfafecbafb0ee3097c4b413ccd" dependencies = [ "cavalier_contours", ] diff --git a/src/modules/draw/modify/fillet.rs b/src/modules/draw/modify/fillet.rs index 67509ea41..0d1466c41 100644 --- a/src/modules/draw/modify/fillet.rs +++ b/src/modules/draw/modify/fillet.rs @@ -15,8 +15,11 @@ use acadrust::entities::{Arc as ArcEnt, Line as LineEnt, LwPolyline}; use super::geom; use super::geom::{arc_points as arc_pts, line_line as ll, normalize_angle as norm_angle}; use acadrust::kernel::geom2d::{ - circle_circle_points as circle_circle_pts, fillet_between_rays, line_circle, + circle_circle_points as circle_circle_pts, fillet_between_rays, fillets_between, line_circle, + Arc as KernelArc, Curve as KernelCurve, Fillet as KernelFillet, + Line as KernelLine, Tolerance, }; +use acadrust::entities::EntityCommon; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::DVec3; @@ -24,6 +27,11 @@ use crate::t; const TAU: f64 = std::f64::consts::TAU; +/// How close two positions must be to count as one while a fillet is being +/// placed. Loose enough to survive the rounding in a drawing's stored +/// coordinates, tight enough not to merge two distinct candidate centres. +const FILLET_TOLERANCE: f64 = 1.0e-9; + use crate::command::{CadCommand, CmdResult}; use crate::modules::draw::defaults; use crate::modules::IconKind; @@ -677,6 +685,58 @@ fn rebuild_poly_from_trimmed_line( } /// Fillet a Line and an Arc. +/// Fillets of `radius` tangent to both curves, nearest pick first. +/// +/// The candidates come from the kernel, which finds them by crossing the two +/// curves' offsets — the construction that works whether or not the curves +/// meet. What is left here is the choice between them, which needs the two +/// pick points and so cannot be made anywhere else. +fn ranked_fillets( + a: &KernelCurve, + click_a: [f64; 2], + b: &KernelCurve, + click_b: [f64; 2], + radius: f64, +) -> Vec { + let reach = |from: [f64; 2], to: [f64; 2]| (to[0] - from[0]).hypot(to[1] - from[1]); + let mut found = fillets_between(a, b, radius, Tolerance::new(FILLET_TOLERANCE)); + found.sort_by(|first, second| { + let one = reach(click_a, first.tangent1) + reach(click_b, first.tangent2); + let other = reach(click_a, second.tangent1) + reach(click_b, second.tangent2); + one.total_cmp(&other) + }); + found +} + +/// The entity's shape as a kernel curve. +fn line_curve(l: &LineEnt) -> KernelCurve { + KernelCurve::Line(KernelLine { + start: [l.start.x, l.start.y], + end: [l.end.x, l.end.y], + }) +} + +fn arc_curve(a: &ArcEnt) -> KernelCurve { + KernelCurve::Arc(KernelArc { + centre: [a.center.x, a.center.y], + radius: a.radius, + start_angle: a.start_angle, + end_angle: a.end_angle, + }) +} + +/// An arc entity built from a kernel fillet. +fn fillet_entity(fillet: &KernelFillet, radius: f64, z: f64, common: &EntityCommon) -> ArcEnt { + let mut arc = ArcEnt::new(); + arc.common = common.clone(); + arc.common.handle = Handle::NULL; + arc.center = Vector3::new(fillet.centre[0], fillet.centre[1], z); + arc.radius = radius; + arc.start_angle = fillet.start_angle; + arc.end_angle = fillet.end_angle; + arc +} + fn fillet_line_arc( line: &LineEnt, click_line: [f64; 2], @@ -723,116 +783,47 @@ fn fillet_line_arc( return Some((EntityType::Line(new_line), EntityType::Arc(new_arc), None)); } - // For r>0: find the fillet arc center — tangent to both the line and the circle. - // The fillet circle center lies at distance (radius) from the line - // and at distance |ar ± radius| from the arc center. - // Sign: outside=ar+radius (external), inside=ar-radius (internal). - let perp_x = -u[1]; - let perp_y = u[0]; - - let mut best: Option<(EntityType, EntityType, EntityType)> = None; - let mut best_dist = f64::MAX; - - for sign_perp in &[-1.0_f64, 1.0_f64] { - for sign_circle in &[-1.0_f64, 1.0_f64] { - // Candidate fillet center offset from line by ±radius in perp direction. - // Find point on offset line closest to arc center at distance |ar + sign*radius|. - let off_dist = ar + sign_circle * radius; - if off_dist < 1e-9 { - continue; - } - - // The fillet center is at distance `off_dist` from the arc center - // and also at distance `radius` from the line (perpendicular). - // Parametrize: fc = p1 + t*u + sign_perp*radius*perp - // |fc - ac|^2 = off_dist^2 - // (p1[0] + t*u[0] + sign_perp*radius*perp_x - ac[0])^2 + ...= off_dist^2 - let qx = p1[0] + sign_perp * radius * perp_x - ac[0]; - let qy = p1[1] + sign_perp * radius * perp_y - ac[1]; - // (qx + t*u[0])^2 + (qy + t*u[1])^2 = off_dist^2 - let qa = u[0] * u[0] + u[1] * u[1]; // = 1.0 - let qb = 2.0 * (qx * u[0] + qy * u[1]); - let qc = qx * qx + qy * qy - off_dist * off_dist; - let disc = qb * qb - 4.0 * qa * qc; - if disc < 0.0 { - continue; - } - let sq = disc.sqrt(); - for &sign_t in &[-1.0_f64, 1.0_f64] { - let t_fc = (-qb + sign_t * sq) / (2.0 * qa); - let fc = [ - p1[0] + t_fc * u[0] + sign_perp * radius * perp_x, - p1[1] + t_fc * u[1] + sign_perp * radius * perp_y, - ]; - - // Tangent point on the line - let tp_line = [p1[0] + t_fc * u[0], p1[1] + t_fc * u[1]]; - // Tangent point on the arc circle - let fd = [(ac[0] - fc[0]), (ac[1] - fc[1])]; - let fdl = (fd[0] * fd[0] + fd[1] * fd[1]).sqrt().max(1e-12); - let tp_arc = [ac[0] + fd[0] / fdl * ar, ac[1] + fd[1] / fdl * ar]; - - // The tangent point on the arc must be within the arc's angular range - let tp_arc_angle = arc_angle_at(ac, tp_arc); - let tp_arc_clamped = clamp_angle_to_arc(tp_arc_angle, a_start, a_end); - if (norm_angle(tp_arc_angle) - norm_angle(tp_arc_clamped)).abs() > 0.01 { - continue; - } - - // The tangent point on the line must be on the correct side of the click - // (prefer the intersection closest to the click) - let dist_to_click_line = - (tp_line[0] - click_line[0]).hypot(tp_line[1] - click_line[1]); - let dist_to_click_arc = (tp_arc[0] - click_arc[0]).hypot(tp_arc[1] - click_arc[1]); - let dist_total = dist_to_click_line + dist_to_click_arc; - if dist_total >= best_dist { - continue; - } - - // Build trimmed line - let Some(new_line) = trim_line_to_point(line, tp_line, click_line) else { - continue; - }; - - // Build trimmed arc - let arc_click_angle = arc_angle_at(ac, click_arc); - let arc_click_rel = (arc_click_angle - a_start).rem_euclid(TAU); - let tp_arc_rel = (tp_arc_clamped - a_start).rem_euclid(TAU); - let new_arc = if tp_arc_rel <= arc_click_rel { - trim_arc(arc, tp_arc_clamped, a_end) - } else { - trim_arc(arc, a_start, tp_arc_clamped) - }; - - // Build fillet arc angles - let fa_line = arc_angle_at(fc, tp_line); - let fa_arc = arc_angle_at(fc, tp_arc); - let cross = (tp_line[0] - fc[0]) * (tp_arc[1] - fc[1]) - - (tp_line[1] - fc[1]) * (tp_arc[0] - fc[0]); - let (fstart, fend) = if cross >= 0.0 { - (fa_line, fa_arc) - } else { - (fa_arc, fa_line) - }; - let mut fillet_arc = ArcEnt::new(); - fillet_arc.common = line.common.clone(); - fillet_arc.common.handle = Handle::NULL; - fillet_arc.center = Vector3::new(fc[0], fc[1], z); - fillet_arc.radius = radius; - fillet_arc.start_angle = norm_angle(fstart); - fillet_arc.end_angle = norm_angle(fend); - - best_dist = dist_total; - best = Some(( - EntityType::Line(new_line), - EntityType::Arc(new_arc), - EntityType::Arc(fillet_arc), - )); - } + // The candidates come from the kernel: a circle of `radius` tangent to + // both has its centre where an offset of the line crosses an offset of + // the arc's circle. What was here enumerated the same four combinations + // by hand, with its own quadratic and its own sign conventions. + for fillet in ranked_fillets( + &line_curve(line), + click_line, + &arc_curve(arc), + click_arc, + radius, + ) { + // The touch on the arc has to be on the drawn part of it, not on the + // rest of the circle it belongs to. + let tp_arc_angle = arc_angle_at(ac, fillet.tangent2); + let tp_arc_clamped = clamp_angle_to_arc(tp_arc_angle, a_start, a_end); + if (norm_angle(tp_arc_angle) - norm_angle(tp_arc_clamped)).abs() > 0.01 { + continue; } + let Some(new_line) = trim_line_to_point(line, fillet.tangent1, click_line) else { + continue; + }; + // Keep the side of the arc the click is on. + let arc_click_rel = (arc_angle_at(ac, click_arc) - a_start).rem_euclid(TAU); + let tp_arc_rel = (tp_arc_clamped - a_start).rem_euclid(TAU); + let new_arc = if tp_arc_rel <= arc_click_rel { + trim_arc(arc, tp_arc_clamped, a_end) + } else { + trim_arc(arc, a_start, tp_arc_clamped) + }; + return Some(( + EntityType::Line(new_line), + EntityType::Arc(new_arc), + Some(EntityType::Arc(fillet_entity( + &fillet, + radius, + z, + &line.common, + ))), + )); } - - best.map(|(l, a, f)| (l, a, Some(f))) + None } /// Fillet two arcs. @@ -884,87 +875,39 @@ fn fillet_arc_arc( return Some((EntityType::Arc(new_a1), EntityType::Arc(new_a2), None)); } - // For r>0: find a circle of `radius` tangent to both arc circles. - // Center lies at |r1 ± radius| from c1 and |r2 ± radius| from c2. - let mut best: Option<(EntityType, EntityType, EntityType)> = None; - let mut best_dist = f64::MAX; - - for sign1 in &[-1.0_f64, 1.0_f64] { - let d1 = r1 + sign1 * radius; - if d1 < 1e-9 { + // Same construction as the line-and-arc case, from the same place: the + // centre is where an offset of one circle crosses an offset of the other. + for fillet in ranked_fillets(&arc_curve(a1), click1, &arc_curve(a2), click2, radius) { + // Both touches have to be on the drawn parts of their arcs. + let tp1_angle = arc_angle_at(c1, fillet.tangent1); + let tp2_angle = arc_angle_at(c2, fillet.tangent2); + let tc1 = clamp_angle_to_arc(tp1_angle, s1, e1); + let tc2 = clamp_angle_to_arc(tp2_angle, s2, e2); + if (norm_angle(tp1_angle) - norm_angle(tc1)).abs() > 0.01 + || (norm_angle(tp2_angle) - norm_angle(tc2)).abs() > 0.01 + { continue; } - for sign2 in &[-1.0_f64, 1.0_f64] { - let d2 = r2 + sign2 * radius; - if d2 < 1e-9 { - continue; - } - for fc in circle_circle_pts(c1, d1, c2, d2) { - // Tangent points on each arc - let fd1 = [(c1[0] - fc[0]), (c1[1] - fc[1])]; - let fdl1 = (fd1[0] * fd1[0] + fd1[1] * fd1[1]).sqrt().max(1e-12); - let tp1 = [c1[0] + fd1[0] / fdl1 * r1, c1[1] + fd1[1] / fdl1 * r1]; - let fd2 = [(c2[0] - fc[0]), (c2[1] - fc[1])]; - let fdl2 = (fd2[0] * fd2[0] + fd2[1] * fd2[1]).sqrt().max(1e-12); - let tp2 = [c2[0] + fd2[0] / fdl2 * r2, c2[1] + fd2[1] / fdl2 * r2]; - - // Tangent points must lie within respective arc ranges - let tp1a = arc_angle_at(c1, tp1); - let tp2a = arc_angle_at(c2, tp2); - let tc1 = clamp_angle_to_arc(tp1a, s1, e1); - let tc2 = clamp_angle_to_arc(tp2a, s2, e2); - if (norm_angle(tp1a) - norm_angle(tc1)).abs() > 0.01 { - continue; - } - if (norm_angle(tp2a) - norm_angle(tc2)).abs() > 0.01 { - continue; - } - - let dist_total = (tp1[0] - click1[0]).hypot(tp1[1] - click1[1]) - + (tp2[0] - click2[0]).hypot(tp2[1] - click2[1]); - if dist_total >= best_dist { - continue; - } - - let ca1 = clamp_angle_to_arc(arc_angle_at(c1, click1), s1, e1); - let ca2 = clamp_angle_to_arc(arc_angle_at(c2, click2), s2, e2); - - let new_a1 = if (tc1 - s1).rem_euclid(TAU) <= (ca1 - s1).rem_euclid(TAU) { - trim_arc(a1, tc1, e1) - } else { - trim_arc(a1, s1, tc1) - }; - let new_a2 = if (tc2 - s2).rem_euclid(TAU) <= (ca2 - s2).rem_euclid(TAU) { - trim_arc(a2, tc2, e2) - } else { - trim_arc(a2, s2, tc2) - }; - - let fa1 = arc_angle_at(fc, tp1); - let fa2 = arc_angle_at(fc, tp2); - let cross = - (tp1[0] - fc[0]) * (tp2[1] - fc[1]) - (tp1[1] - fc[1]) * (tp2[0] - fc[0]); - let (fstart, fend) = if cross >= 0.0 { (fa1, fa2) } else { (fa2, fa1) }; - let mut fillet_arc = ArcEnt::new(); - fillet_arc.common = a1.common.clone(); - fillet_arc.common.handle = Handle::NULL; - fillet_arc.center = Vector3::new(fc[0], fc[1], z); - fillet_arc.radius = radius; - fillet_arc.start_angle = norm_angle(fstart); - fillet_arc.end_angle = norm_angle(fend); - - best_dist = dist_total; - best = Some(( - EntityType::Arc(new_a1), - EntityType::Arc(new_a2), - EntityType::Arc(fillet_arc), - )); - } - } + let ca1 = clamp_angle_to_arc(arc_angle_at(c1, click1), s1, e1); + let ca2 = clamp_angle_to_arc(arc_angle_at(c2, click2), s2, e2); + let new_a1 = if (tc1 - s1).rem_euclid(TAU) <= (ca1 - s1).rem_euclid(TAU) { + trim_arc(a1, tc1, e1) + } else { + trim_arc(a1, s1, tc1) + }; + let new_a2 = if (tc2 - s2).rem_euclid(TAU) <= (ca2 - s2).rem_euclid(TAU) { + trim_arc(a2, tc2, e2) + } else { + trim_arc(a2, s2, tc2) + }; + return Some(( + EntityType::Arc(new_a1), + EntityType::Arc(new_a2), + Some(EntityType::Arc(fillet_entity(&fillet, radius, z, &a1.common))), + )); } - - best.map(|(a1, a2, f)| (a1, a2, Some(f))) + None } /// Trim a line endpoint nearest to the intersection point, keeping the click side. diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 6f2eee63d..ddf4f0e1d 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -1,6 +1,6 @@ use super::*; -use acadrust::kernel::geom2d::{bounded_faces, Line}; +use acadrust::kernel::geom2d::{bounded_faces, Line, Tolerance}; /// How far apart two points may be and still be taken for the same one. /// @@ -65,6 +65,6 @@ impl Scene { } } - bounded_faces(&segments, WELD_TOLERANCE) + bounded_faces(&segments, Tolerance::new(WELD_TOLERANCE)) } } From 77a9167e3474eb650fb1be9ac6dc5a001f0a88f8 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 01:44:04 +0300 Subject: [PATCH 16/18] fix(area): measure the curve, not a polygon through some of its points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hatch's area was not the area of anything. Its boundary was flattened into a ring by pushing an arc's *centre* into it, and a spline's control points — neither of which is on the boundary — and the shoelace formula was run over the result. Boundary edges now go through the kernel, which measures each for what it encloses: an arc contributes its sector, a spline is integrated. AREA had three measurements between them. The bulged-polyline one carried its own arc corrections; the planar entities it did not cover fell through to mass properties. Both are now one call on the entity's curve, which gets the bulges, the ellipse and the spline right in the same place. What is left is the genuinely spatial — a 3D polyline, a spline whose points wander off any plane — measured as rings of points by Newell's method rather than by projecting them flat. Regions were doing the same Newell sum by hand. They share it now. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/entities/hatch.rs | 155 +++++++++++++++++++++++-------- src/entities/solid3d.rs | 34 +++---- src/modules/draw/inquiry/area.rs | 118 +++++++---------------- 4 files changed, 168 insertions(+), 141 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72eb2896f..565f6a2f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#77d8ee9d3ee7f8bfafecbafb0ee3097c4b413ccd" +source = "git+https://github.com/HakanSeven12/cadkernel.git#e4c9fe626a60fd1500e8f2264e5a712e939c4027" dependencies = [ "cavalier_contours", ] diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index 2e9223ef7..022fc3f9c 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -1,4 +1,9 @@ use acadrust::entities::{BoundaryEdge, Hatch}; +use acadrust::kernel::geom2d::{ + Arc as KernelArc, Curve as KernelCurve, Ellipse as KernelEllipse, + EllipseArc as KernelEllipseArc, Line as KernelLine, NurbsCurve as KernelNurbs, + Parameterization, Polyline as KernelPolyline, PolylineVertex as KernelVertex, +}; use glam::Vec3; use crate::t; @@ -9,54 +14,128 @@ use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Pr use crate::scene::convert::tess_util::{arc_segments, arc_signed_span, wire_chord_tol, FallbackGeometry}; use crate::scene::model::wire_model::SnapHint; -/// Signed area of every boundary path, summed via the shoelace formula over -/// the OCS boundary vertices (bulge/curved edges approximated by their end -/// points). Returns the absolute total area. +/// The area the hatch's boundary paths enclose. +/// +/// Summed edge by edge through the kernel, which measures what each edge +/// actually encloses rather than what a polygon through some of its points +/// would. The version this replaced pushed an arc's *centre* into the ring +/// and a spline's control points — neither of which is on the boundary — so +/// the number it produced was not the area of anything. +/// +/// Outer paths and their holes both contribute; the sign of a loop says +/// which it is, so the magnitude of the sum is the region's own area. fn boundary_area(h: &Hatch) -> f64 { let mut area = 0.0; for path in &h.paths { - let mut ring: Vec<[f64; 2]> = Vec::new(); + let mut path_area = 0.0; + let mut ends: Vec<[f64; 2]> = Vec::new(); for edge in &path.edges { - match edge { - BoundaryEdge::Polyline(poly) => { - for v in &poly.vertices { - ring.push([v.x, v.y]); - } - } - BoundaryEdge::Line(l) => { - ring.push([l.start.x, l.start.y]); - ring.push([l.end.x, l.end.y]); - } - BoundaryEdge::CircularArc(a) => ring.push([a.center.x, a.center.y]), - BoundaryEdge::EllipticArc(e) => ring.push([e.center.x, e.center.y]), - BoundaryEdge::Spline(s) => { - let src = if !s.fit_points.is_empty() { - s.fit_points.iter().map(|p| [p.x, p.y]).collect::>() - } else { - s.control_points - .iter() - .map(|p| [p.x, p.y]) - .collect::>() - }; - ring.extend(src); - } - } + let Some(curve) = edge_curve(edge) else { + continue; + }; + path_area += curve.enclosed_area(); + ends.push(curve.point_at(0.0)); + ends.push(curve.point_at(1.0)); } - let n = ring.len(); - if n < 3 { - continue; + // Edges are stored as separate pieces, so the chain has to be closed + // by the chord from the last end back to the first — the same closing + // an open polyline gets. + if let (Some(first), Some(last)) = (ends.first(), ends.last()) { + path_area += 0.5 * (last[0] * first[1] - first[0] * last[1]); } - let mut acc = 0.0; - for i in 0..n { - let a = ring[i]; - let b = ring[(i + 1) % n]; - acc += a[0] * b[1] - b[0] * a[1]; - } - area += acc.abs() * 0.5; + area += path_area.abs(); } area } +/// A hatch boundary edge as a kernel curve, in the hatch's own OCS. +fn edge_curve(edge: &BoundaryEdge) -> Option { + Some(match edge { + BoundaryEdge::Line(l) => KernelCurve::Line(KernelLine { + start: [l.start.x, l.start.y], + end: [l.end.x, l.end.y], + }), + BoundaryEdge::CircularArc(a) => { + // A clockwise edge is the same arc walked the other way, so the + // stored angles swap rather than the sweep going negative. + let (start, end) = if a.counter_clockwise { + (a.start_angle, a.end_angle) + } else { + (a.end_angle, a.start_angle) + }; + KernelCurve::Arc(KernelArc { + centre: [a.center.x, a.center.y], + radius: a.radius, + start_angle: start, + end_angle: end, + }) + } + BoundaryEdge::EllipticArc(e) => { + let major = (e.major_axis_endpoint.x, e.major_axis_endpoint.y); + let radius = major.0.hypot(major.1); + if radius < 1e-12 { + return None; + } + let (start, end) = if e.counter_clockwise { + (e.start_angle, e.end_angle) + } else { + (e.end_angle, e.start_angle) + }; + KernelCurve::Ellipse(KernelEllipseArc { + ellipse: KernelEllipse { + centre: [e.center.x, e.center.y], + major_radius: radius, + minor_radius: radius * e.minor_axis_ratio, + major_axis: [major.0 / radius, major.1 / radius], + }, + start_parameter: start, + end_parameter: end, + }) + } + BoundaryEdge::Polyline(poly) => { + let vertices: Vec = poly + .vertices + .iter() + .map(|v| KernelVertex { + position: [v.x, v.y], + // A boundary polyline stores its bulge in the vertex's + // third component. + bulge: v.z, + }) + .collect(); + if vertices.len() < 2 { + return None; + } + KernelCurve::Polyline(KernelPolyline { + vertices, + closed: poly.is_closed, + }) + } + BoundaryEdge::Spline(s) => { + let control: Vec<[f64; 2]> = s.control_points.iter().map(|p| [p.x, p.y]).collect(); + // A boundary spline's control points carry their weight in the + // third component; for a polynomial one it is unset and the + // kernel's own default of all-ones applies. + let weights = s + .rational + .then(|| s.control_points.iter().map(|p| p.z).collect::>()); + let curve = KernelNurbs::new( + s.degree.max(1) as usize, + control, + s.knots.clone(), + weights, + ) + .or_else(|| { + // A fit-point boundary spline, interpolated the same way a + // SPLINE entity's is. + let fit: Vec<[f64; 2]> = s.fit_points.iter().map(|p| [p.x, p.y]).collect(); + KernelNurbs::interpolate(&fit, None, None, Parameterization::Chord) + })?; + KernelCurve::Nurbs(curve) + } + }) +} + /// Mean of every boundary edge point (OCS) — the centroid used to place the /// pattern-origin grip on the hatch body. `None` when there are no boundary /// points. diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index 4bb6c9216..e5abbafec 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -7,6 +7,7 @@ // the MeshModel vertices to match. use acadrust::entities::{Body, Region, Solid3D, Surface}; +use acadrust::kernel::space::polygon; use crate::t; use crate::command::EntityTransform; use crate::entities::common::{center_grip, edit_prop as edit, parse_f64, ro_prop as ro}; @@ -203,30 +204,25 @@ fn position_section(prefix: &str, p: &acadrust::types::Vector3) -> PropSection { /// holes subtract) and halves its magnitude — exact for a single planar /// loop, approximate for multi-loop or curved regions. Returns zeros when /// there is nothing to measure. +/// The area and perimeter of a region's boundary wires. +/// +/// Measured in space rather than in projection: a region need not lie in a +/// coordinate plane, and flattening it to XY first would report its shadow. fn region_area_perimeter(wires: &[acadrust::entities::Wire]) -> (f64, f64) { + let mut area_vector = [0.0f64; 3]; let mut perimeter = 0.0; - let (mut nx, mut ny, mut nz) = (0.0, 0.0, 0.0); for wire in wires { - let pts = &wire.points; - if pts.len() < 2 { - continue; - } - for seg in pts.windows(2) { - let dx = seg[1].x - seg[0].x; - let dy = seg[1].y - seg[0].y; - let dz = seg[1].z - seg[0].z; - perimeter += (dx * dx + dy * dy + dz * dz).sqrt(); - } - let n = pts.len(); - for i in 0..n { - let a = &pts[i]; - let b = &pts[(i + 1) % n]; - nx += (a.y - b.y) * (a.z + b.z); - ny += (a.z - b.z) * (a.x + b.x); - nz += (a.x - b.x) * (a.y + b.y); + let ring: Vec<[f64; 3]> = wire.points.iter().map(|p| [p.x, p.y, p.z]).collect(); + // Wires arrive as open chains that the region closes between them, so + // the length is the chain's and the area vectors are summed before + // being measured — one wire's contribution is not an area on its own. + perimeter += polygon::chain_length(&ring); + let piece = polygon::area_vector(&ring); + for axis in 0..3 { + area_vector[axis] += piece[axis]; } } - let area = 0.5 * (nx * nx + ny * ny + nz * nz).sqrt(); + let area = (area_vector[0].powi(2) + area_vector[1].powi(2) + area_vector[2].powi(2)).sqrt(); (area, perimeter) } diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 2c961c096..78ee1dc0a 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -1,9 +1,11 @@ +use acadrust::kernel::space::polygon; use acadrust::{EntityType, Handle}; use glam::DVec3; use crate::command::{ AreaPreviewRegion, AreaPreviewSource, CadCommand, CmdOption, CmdResult, SelectionEntity, }; +use crate::entities::curve::entity_curve; use crate::entities::traits::EntityTypeOps; use crate::scene::model::wire_model::WireModel; @@ -47,100 +49,50 @@ impl AreaCommand { CmdOption { label, keyword: keyword.to_string() } } + /// Area and perimeter of a ring of picked points. + /// + /// Measured in space rather than in projection — a picked sequence need + /// not lie in a coordinate plane, and flattening it to XY first would + /// report its shadow. fn point_measurement(points: &[DVec3], close_perimeter: bool) -> AreaMeasurement { if points.len() < 2 { return AreaMeasurement { area: 0.0, perimeter: Some(0.0) }; } - - let origin = points[0]; - let mut area_vector = DVec3::ZERO; - for index in 0..points.len() { - let a = points[index] - origin; - let b = points[(index + 1) % points.len()] - origin; - area_vector += a.cross(b); + let ring: Vec<[f64; 3]> = points.iter().map(|p| [p.x, p.y, p.z]).collect(); + let perimeter = if close_perimeter { + polygon::perimeter(&ring) + } else { + polygon::chain_length(&ring) + }; + AreaMeasurement { + area: polygon::area(&ring), + perimeter: Some(perimeter), } - - let mut perimeter = points - .windows(2) - .map(|pair| (pair[1] - pair[0]).length()) - .sum::(); - if close_perimeter { - perimeter += (points[0] - points[points.len() - 1]).length(); - } - - AreaMeasurement { area: area_vector.length() * 0.5, perimeter: Some(perimeter) } } - fn bulged_polyline_measurement( - points: &[(f64, f64)], - bulges: &[f64], - closed: bool, - ) -> AreaMeasurement { - let count = points.len(); - if count < 2 { - return AreaMeasurement { area: 0.0, perimeter: Some(0.0) }; - } - - let segment_count = if closed { count } else { count - 1 }; - let origin = points[0]; - let mut signed_area = 0.0; - let mut perimeter = 0.0; - for index in 0..segment_count { - let a = points[index]; - let b = points[(index + 1) % count]; - let local_a = (a.0 - origin.0, a.1 - origin.1); - let local_b = (b.0 - origin.0, b.1 - origin.1); - signed_area += 0.5 * (local_a.0 * local_b.1 - local_b.0 * local_a.1); - - let chord = (b.0 - a.0).hypot(b.1 - a.1); - let bulge = bulges.get(index).copied().unwrap_or(0.0); - if bulge.abs() < 1e-12 || chord <= 1e-12 { - perimeter += chord; - continue; - } - - let angle = 4.0 * bulge.atan(); - let sine = (angle * 0.5).sin().abs(); - if sine <= 1e-12 { - perimeter += chord; - continue; - } - let radius = chord / (2.0 * sine); - signed_area += 0.5 * radius * radius * (angle - angle.sin()); - perimeter += radius * angle.abs(); - } - - AreaMeasurement { area: signed_area.abs(), perimeter: Some(perimeter) } + /// Area and perimeter of an entity whose geometry is a planar curve. + /// + /// Both exact: the kernel measures the curve rather than a polygon + /// through some of its points, so a bulged polyline counts each arc's own + /// bite and length, and a spline is integrated rather than sampled. + fn curve_measurement(entity: &EntityType) -> Option { + let curve = entity_curve(entity)?; + let perimeter = curve.length(); + Some(AreaMeasurement { + area: curve.curve.enclosed_area().abs(), + perimeter: perimeter.is_finite().then_some(perimeter), + }) } fn entity_measurement(entity: &EntityType) -> Option { + // Anything the converter recognises is measured from its own curve. + if let Some(measurement) = Self::curve_measurement(entity) { + return Some(measurement); + } match entity { - EntityType::LwPolyline(polyline) => { - let points = polyline - .vertices - .iter() - .map(|vertex| (vertex.location.x, vertex.location.y)) - .collect::>(); - let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::>(); - Some(Self::bulged_polyline_measurement( - &points, - &bulges, - polyline.is_closed, - )) - } - EntityType::Polyline2D(polyline) => { - let points = polyline - .vertices - .iter() - .map(|vertex| (vertex.location.x, vertex.location.y)) - .collect::>(); - let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::>(); - Some(Self::bulged_polyline_measurement( - &points, - &bulges, - polyline.is_closed(), - )) - } + // What is left is the genuinely spatial: a 3D polyline, and a + // spline whose points wander off any plane. Both are measured as + // rings of points in space. EntityType::Polyline(polyline) => { let points = polyline .vertices From 0677dd146c12480e00790124545d7a2502660863 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 01:47:04 +0300 Subject: [PATCH 17/18] =?UTF-8?q?refactor(blend):=20evaluate=20the=20B?= =?UTF-8?q?=C3=A9zier=20with=20de=20Casteljau=20from=20the=20kernel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLEND summed Bernstein terms using a binomial-coefficient table with entries for degrees three and five — the two it builds — and a fallback of one for everything else. Correct today, and quietly wrong the moment a third continuity mode is added. De Casteljau needs no coefficients at all, works at any degree, and is steadier: the Bernstein terms are large coefficients times small powers and they cancel, while repeated interpolation only ever averages neighbours. Curvature through three points moves with it, since both are the 3D arithmetic a blend needs and neither has a planar counterpart to delegate to. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/modules/draw/modify/blend.rs | 59 +++++++++++--------------------- 2 files changed, 21 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 565f6a2f8..d59ee5617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#e4c9fe626a60fd1500e8f2264e5a712e939c4027" +source = "git+https://github.com/HakanSeven12/cadkernel.git#69a8bf2b747c8b0a4abe6a571859c4c3a4f610d2" dependencies = [ "cavalier_contours", ] diff --git a/src/modules/draw/modify/blend.rs b/src/modules/draw/modify/blend.rs index 5636e32ed..faadd4a3c 100644 --- a/src/modules/draw/modify/blend.rs +++ b/src/modules/draw/modify/blend.rs @@ -6,6 +6,7 @@ //! geometric curvature (G2). use acadrust::entities::{EntityCommon, Spline}; +use acadrust::kernel::space::curve as space_curve; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::DVec3; @@ -628,49 +629,29 @@ fn curve_frame( }) } +/// The curvature vector at `point` of the circle through the three points. fn circumcircle_curvature(point: DVec3, next: DVec3, third: DVec3) -> DVec3 { - let u = next - point; - let v = third - point; - let cross = u.cross(v); - let denominator = 2.0 * cross.length_squared(); - if denominator <= EPS { - return DVec3::ZERO; - } - let center_offset = - (u.length_squared() * v.cross(cross) + v.length_squared() * cross.cross(u)) / denominator; - let radius_squared = center_offset.length_squared(); - if radius_squared <= EPS { - DVec3::ZERO - } else { - center_offset / radius_squared - } + let curvature = space_curve::curvature_through( + [point.x, point.y, point.z], + [next.x, next.y, next.z], + [third.x, third.y, third.z], + ); + DVec3::new(curvature[0], curvature[1], curvature[2]) } +/// A Bézier control polygon sampled into a polyline. +/// +/// De Casteljau, from the kernel. What was here summed Bernstein terms with +/// binomial coefficients out of a lookup table that had entries for degrees +/// three and five and returned one for everything else — correct only for +/// the two the blend happens to build today, and quietly wrong the moment a +/// third was added. fn sample_bezier(control_points: &[Vector3], segments: usize) -> Vec<[f64; 3]> { - let degree = control_points.len().saturating_sub(1); - (0..=segments) - .map(|step| { - let t = step as f64 / segments as f64; - let mut point = DVec3::ZERO; - for (index, control) in control_points.iter().enumerate() { - let coefficient = binomial(degree, index) as f64 - * t.powi(index as i32) - * (1.0 - t).powi((degree - index) as i32); - point += coefficient * to_dvec(*control); - } - [point.x, point.y, point.z] - }) - .collect() -} - -fn binomial(n: usize, k: usize) -> usize { - match (n, k) { - (3, 0 | 3) | (5, 0 | 5) => 1, - (3, 1 | 2) => 3, - (5, 1 | 4) => 5, - (5, 2 | 3) => 10, - _ => 1, - } + let control: Vec<[f64; 3]> = control_points + .iter() + .map(|p| [p.x, p.y, p.z]) + .collect(); + space_curve::bezier_points(&control, segments) } fn ocs_to_dvec(x: f64, y: f64, z: f64, normal: DVec3) -> DVec3 { From f1bc5f2caea056a3f1844e4dfc6ea484a61d8bcb Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 9 Aug 2026 01:52:12 +0300 Subject: [PATCH 18/18] fix(spline): reverse a spline without reshaping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPLINEDIT's REVERSE reversed the control points and then regenerated a clamped *uniform* knot vector. That is only harmless for a spline whose knots were evenly spaced to begin with; for any other — which is most of them once a modeller has been near one — the curve came back a different shape, bulging where it had been taut. It now mirrors the knots within the domain, which is exact, and swaps the end tangents to match. PEDIT's Spline option evaluated a uniform cubic B-spline from four blending polynomials written out by hand, clamped the open case by repeating end control points, and then pinned the two endpoints afterwards to undo the drift that leaves. A clamped knot vector says all of that directly, and the closed case gets a genuinely periodic curve rather than one whose seam is a special case. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/modules/draw/modify/pedit.rs | 94 +++++++++++++--------------- src/modules/draw/modify/splinedit.rs | 41 ++++++++++-- 3 files changed, 81 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d59ee5617..bdc515374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#69a8bf2b747c8b0a4abe6a571859c4c3a4f610d2" +source = "git+https://github.com/HakanSeven12/cadkernel.git#5499242f1160018e066f78d4e462c9532e665a96" dependencies = [ "cavalier_contours", ] diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index dec9c4b73..b5c2703a5 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -12,6 +12,8 @@ // straightens every segment. use acadrust::entities::LwVertex; +use acadrust::kernel::geom2d::nurbs::clamped_uniform_knots; +use acadrust::kernel::geom2d::NurbsCurve; use acadrust::types::Vector2; use acadrust::{EntityType, Handle}; use glam::{DVec2, DVec3}; @@ -535,69 +537,61 @@ fn fit_curve(p: &mut acadrust::LwPolyline) -> bool { /// PEDIT Spline: replace the shape with a sampled uniform cubic B-spline of /// the vertex frame (8 samples per span; a closed frame wraps around). +/// Replace the polyline's vertices with a sampling of the cubic B-spline its +/// vertices control. +/// +/// Evaluated by the kernel rather than from a hand-written basis. The four +/// blending polynomials that were here are the uniform cubic ones spelled +/// out, with the open case clamped by repeating end points and then the two +/// ends pinned back afterwards to undo the drift that leaves — all of which +/// a clamped knot vector expresses directly and exactly. fn spline_smooth(p: &mut acadrust::LwPolyline) -> bool { - let n = p.vertices.len(); - if n < 3 { + const DEGREE: usize = 3; + const PER_SPAN: usize = 8; + + let control: Vec<[f64; 2]> = p.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); + if control.len() < 3 { return false; } - let pts: Vec = p.vertices.iter().map(vert_xy).collect(); - const S: usize = 8; - let bspline = |p0: DVec2, p1: DVec2, p2: DVec2, p3: DVec2, t: f64| -> DVec2 { - let t2 = t * t; - let t3 = t2 * t; - (p0 * (-t3 + 3.0 * t2 - 3.0 * t + 1.0) - + p1 * (3.0 * t3 - 6.0 * t2 + 4.0) - + p2 * (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) - + p3 * t3) - / 6.0 - }; - let mut out: Vec = Vec::new(); - if p.is_closed { - for i in 0..n { - let p0 = pts[(i + n - 1) % n]; - let p1 = pts[i]; - let p2 = pts[(i + 1) % n]; - let p3 = pts[(i + 2) % n]; - for s in 0..S { - out.push(bspline(p0, p1, p2, p3, s as f64 / S as f64)); - } - } + let curve = if p.is_closed { + // A periodic curve is written by wrapping the first `degree` control + // points onto the end, over a uniform knot vector — which is what + // makes the seam as smooth as everywhere else. + let mut wrapped = control.clone(); + wrapped.extend(control.iter().take(DEGREE).copied()); + let count = wrapped.len(); + let knots: Vec = (0..count + DEGREE + 1).map(|i| i as f64).collect(); + NurbsCurve::new(DEGREE, wrapped, knots, None) } else { - // Clamp the ends by repeating the end control points so the curve - // starts and finishes on them. - let at = |i: isize| -> DVec2 { pts[i.clamp(0, n as isize - 1) as usize] }; - for i in -1..(n as isize - 2) { - let (p0, p1, p2, p3) = (at(i), at(i + 1), at(i + 2), at(i + 3)); - let last_span = i == n as isize - 3; - let steps = if last_span { S + 1 } else { S }; - for s in 0..steps { - out.push(bspline(p0, p1, p2, p3, s as f64 / S as f64)); - } - } - // Pin the exact endpoints. - if let (Some(v), Some(q)) = (out.first_mut(), pts.first()) { - *v = *q; - } - if let (Some(v), Some(q)) = (out.last_mut(), pts.last()) { - *v = *q; - } - } - if out.len() < 2 { + // Clamped: the curve starts and finishes on its outer control points + // without them needing to be repeated. + let knots = clamped_uniform_knots(DEGREE, control.len()); + NurbsCurve::new(DEGREE, control.clone(), knots, None) + }; + let Some(curve) = curve else { + return false; + }; + + let steps = PER_SPAN * control.len(); + let sampled: Vec<[f64; 2]> = (0..=steps) + .map(|step| curve.point_at(step as f64 / steps as f64)) + .collect(); + if sampled.len() < 2 { return false; } - let w = p.constant_width; - p.vertices = out + + let width = p.constant_width; + p.vertices = sampled .into_iter() .map(|q| { - let mut v = LwVertex::new(Vector2::new(q.x, q.y)); - v.start_width = w; - v.end_width = w; + let mut v = LwVertex::new(Vector2::new(q[0], q[1])); + v.start_width = width; + v.end_width = width; v }) .collect(); true } - // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["PEDIT"] }); // PeditCommand diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 75f1173ce..6a5852f73 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -9,9 +9,12 @@ // // Control-point dragging is already supported via the grip editing system. +use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; +use crate::modules::draw::modify::spline_ops::spline_to_nurbs; + use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -190,12 +193,40 @@ pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle } } "__SPLINEDIT_REVERSE__" => { - spline.control_points.reverse(); spline.fit_points.reverse(); - spline.knots = acadrust::entities::Spline::generate_clamped_knots( - spline.degree as usize, - spline.control_points.len(), - ); + let begin = spline.begin_tangent; + spline.begin_tangent = spline.end_tangent; + spline.end_tangent = begin; + // The knots mirror within the domain rather than being + // regenerated. Rebuilding a clamped *uniform* vector here changed + // the shape of any spline whose knots were unevenly spaced — + // which is most of them once a modeller has been near one. + match spline_to_nurbs(spline).map(|curve| curve.reversed()) { + Some(reversed) => { + let elevation = spline + .control_points + .first() + .or_else(|| spline.fit_points.first()) + .map(|p| p.z) + .unwrap_or(0.0); + spline.degree = reversed.degree() as i32; + spline.knots = reversed.knots().to_vec(); + spline.control_points = reversed + .control_points() + .iter() + .map(|p| Vector3::new(p[0], p[1], elevation)) + .collect(); + spline.weights = if reversed.is_rational() { + reversed.weights().to_vec() + } else { + Vec::new() + }; + } + // Nothing the kernel can read — a spline with too few points + // to describe a curve. Reversing its stored points is still + // the honest answer. + None => spline.control_points.reverse(), + } } _ => {} }