Merge main into Pallet-placement

Resolved conflicts:
- app/update/mod.rs: keep the dock's dock_open_at_default for the XREF
  manager; take main's stable xref row identity (row_index(&id)).
- app/view/mod.rs: keep the edge layout from app::dock; drop main's copy
  of the old edge-stack code (main only reformatted it there).
- ui/window/layers.rs: keep main's new visible_indices next to the
  branch's view_panel.
Files the branch only reformatted take main's version. Adds the Lao
(lo-LA) translations for the new dock texts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
Claude committed 2026-10-11 18:44:00 +00:00
commit 2fca3fd18d
136 files changed
+12408 -949

No files matched your search

+1
View File
@@ -11,3 +11,4 @@ pub(super) mod layuniso;
pub(super) mod make_current;
pub mod match_layer;
pub(super) mod panel;
pub mod quick_layer;
+288
View File
@@ -0,0 +1,288 @@
use crate::command::{CadCommand, CmdResult, CommandSuggestion, InputKind};
use codec::Handle;
use glam::DVec3;
/// Fast searchable layer picker.
///
/// If objects were selected when QLAYER started, the chosen layer is applied
/// to those objects. With no selection, the chosen layer becomes current.
#[derive(Clone, Debug)]
pub struct QuickLayerEntry {
name: String,
color: Option<codec::types::Color>,
visible: bool,
frozen: bool,
locked: bool,
}
impl QuickLayerEntry {
pub fn new(
name: String,
color: codec::types::Color,
visible: bool,
frozen: bool,
locked: bool,
) -> Self {
Self {
name,
color: Some(color),
visible,
frozen,
locked,
}
}
fn plain(name: String) -> Self {
Self {
name,
color: None,
visible: true,
frozen: false,
locked: false,
}
}
}
pub struct QuickLayerCommand {
layers: Vec<QuickLayerEntry>,
selected: Vec<Handle>,
}
impl QuickLayerCommand {
pub fn new(layers: Vec<String>, selected: Vec<Handle>) -> Self {
let entries = layers.into_iter().map(QuickLayerEntry::plain).collect();
Self::new_with_entries(entries, selected)
}
pub fn new_with_entries(mut layers: Vec<QuickLayerEntry>, selected: Vec<Handle>) -> Self {
layers.sort_by_key(|layer| layer.name.to_lowercase());
layers.dedup_by(|a, b| a.name.eq_ignore_ascii_case(&b.name));
Self { layers, selected }
}
fn fuzzy_score(name: &str, input: &str) -> Option<(u8, usize, usize, String)> {
let name_lower = name.to_lowercase();
let query = input.trim().to_lowercase();
if query.is_empty() {
return None;
}
// Exact match.
if name_lower == query {
return Some((0, 0, name_lower.len(), name_lower));
}
// Begins with typed text.
if name_lower.starts_with(&query) {
return Some((1, 0, name_lower.len(), name_lower));
}
// A word inside the layer begins with typed text.
for (index, word) in name_lower
.split(|c: char| matches!(c, ' ' | '-' | '_' | '.'))
.enumerate()
{
if word.starts_with(&query) {
return Some((2, index, name_lower.len(), name_lower));
}
}
// Normal substring.
if let Some(position) = name_lower.find(&query) {
return Some((3, position, name_lower.len(), name_lower));
}
// Approximate fallback: ordered subsequence.
// Example: "cte" can still find "Corte".
let chars: Vec<char> = name_lower.chars().collect();
let mut cursor = 0usize;
let mut gaps = 0usize;
for wanted in query.chars() {
let Some(relative) = chars[cursor..]
.iter()
.position(|candidate| *candidate == wanted)
else {
return None;
};
gaps += relative;
cursor += relative + 1;
}
Some((4, gaps, name_lower.len(), name_lower))
}
fn matches(&self, input: &str) -> Vec<String> {
let mut matches: Vec<_> = self
.layers
.iter()
.filter_map(|layer| {
Self::fuzzy_score(&layer.name, input).map(|score| (score, layer.name.clone()))
})
.collect();
matches.sort_by(|a, b| a.0.cmp(&b.0));
matches.into_iter().map(|(_, name)| name).take(8).collect()
}
fn encode_layer_name(name: &str) -> String {
let mut out = String::with_capacity(name.len() * 2);
for byte in name.as_bytes() {
use std::fmt::Write as _;
let _ = write!(&mut out, "{byte:02X}");
}
out
}
pub(crate) fn decode_layer_name(encoded: &str) -> Option<String> {
if encoded.len() % 2 != 0 {
return None;
}
let mut bytes = Vec::with_capacity(encoded.len() / 2);
let chars: Vec<char> = encoded.chars().collect();
for i in (0..chars.len()).step_by(2) {
let hi = chars[i].to_digit(16)?;
let lo = chars[i + 1].to_digit(16)?;
bytes.push(((hi << 4) | lo) as u8);
}
String::from_utf8(bytes).ok()
}
fn canonical_layer(&self, input: &str) -> Option<String> {
self.layers
.iter()
.find(|layer| layer.name.eq_ignore_ascii_case(input.trim()))
.map(|layer| layer.name.clone())
}
}
impl CadCommand for QuickLayerCommand {
fn name(&self) -> &'static str {
"QLAYER"
}
fn prompt(&self) -> String {
if self.selected.is_empty() {
crate::t!("QLAYER Search current layer:").into_owned()
} else {
crate::tf!(
"QLAYER Search destination layer for {} object(s):",
self.selected.len()
)
.into_owned()
}
}
fn input_kind(&self) -> InputKind {
// Layer names may legitimately contain spaces. Treat the search as one
// complete text value so "Vista 1" is not split into "Vista" + "1".
InputKind::FreeText
}
fn text_suggestions(&self, input: &str) -> Vec<CommandSuggestion> {
self.matches(input)
.into_iter()
.filter_map(|name| {
self.layers
.iter()
.find(|layer| layer.name == name)
.map(|layer| CommandSuggestion {
value: layer.name.clone(),
color: layer.color.clone(),
visible: Some(layer.visible),
frozen: Some(layer.frozen),
locked: Some(layer.locked),
})
})
.collect()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let Some(layer) = self.canonical_layer(text) else {
return Some(CmdResult::ReportError(format!(
"QLAYER: no layer named \"{}\".",
text.trim()
)));
};
Some(CmdResult::Relaunch(
format!("QLAYER_APPLY_HEX {}", Self::encode_layer_name(&layer)),
std::mem::take(&mut self.selected),
))
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::NeedPoint
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["QLAYER"] });
#[cfg(test)]
mod tests {
use super::*;
fn command() -> QuickLayerCommand {
QuickLayerCommand::new(
vec![
"Corte".into(),
"Corte 2".into(),
"Curvas de nivel".into(),
"E-Proyecciones".into(),
"Propiedad".into(),
"Proyección".into(),
],
Vec::new(),
)
}
#[test]
fn exact_match_ranks_first() {
let matches = command().matches("corte");
assert_eq!(matches.first().map(String::as_str), Some("Corte"));
}
#[test]
fn prefix_search_finds_layer() {
let matches = command().matches("pro");
assert!(matches.iter().any(|name| name == "Proyección"));
assert!(matches.iter().any(|name| name == "Propiedad"));
}
#[test]
fn substring_search_finds_layer() {
let matches = command().matches("nivel");
assert_eq!(matches.first().map(String::as_str), Some("Curvas de nivel"));
}
#[test]
fn approximate_search_finds_layer() {
let matches = command().matches("cte");
assert!(matches.iter().any(|name| name == "Corte"));
}
#[test]
fn layer_name_roundtrip_preserves_spaces() {
let original = "Vista 1";
let encoded = QuickLayerCommand::encode_layer_name(original);
let decoded = QuickLayerCommand::decode_layer_name(&encoded);
assert_eq!(decoded.as_deref(), Some(original));
}
}
+145
View File
@@ -734,6 +734,9 @@ enum FilletEntity {
handle: Handle,
seg_idx: usize,
},
/// A circle, ellipse or spline: filleted through the kernel's general
/// construction, and trimmed only when it has ends to trim.
Curve(EntityType),
}
impl FilletEntity {
@@ -741,6 +744,9 @@ impl FilletEntity {
match e {
EntityType::Line(l) => Some(Self::Line(l.clone())),
EntityType::Arc(a) => Some(Self::Arc(a.clone())),
EntityType::Circle(_) | EntityType::Ellipse(_) | EntityType::Spline(_) => {
crate::entities::curve::entity_curve_xy(e).map(|_| Self::Curve(e.clone()))
}
_ => None,
}
}
@@ -759,6 +765,7 @@ impl FilletEntity {
Self::Line(l) => EntityType::Line(l.clone()),
Self::Arc(a) => EntityType::Arc(a.clone()),
Self::LwPoly { poly, .. } => EntityType::LwPolyline(poly.clone()),
Self::Curve(entity) => entity.clone(),
}
}
@@ -767,6 +774,22 @@ impl FilletEntity {
Self::Line(l) => l.start.z,
Self::Arc(a) => a.center.z,
Self::LwPoly { poly, .. } => poly.elevation,
Self::Curve(EntityType::Circle(c)) => c.center.z,
Self::Curve(EntityType::Ellipse(e)) => e.center.z,
Self::Curve(EntityType::Spline(s)) => s.control_points.first().map_or(0.0, |p| p.z),
Self::Curve(_) => 0.0,
}
}
/// The shape as a kernel curve in plan.
fn kernel_curve(&self) -> Option<KernelCurve> {
match self {
Self::Line(l) => Some(line_curve(l)),
Self::Arc(a) => Some(arc_curve(a)),
Self::LwPoly { poly, seg_idx, .. } => {
Some(line_curve(&lwpoly_seg_as_line(poly, *seg_idx)))
}
Self::Curve(entity) => crate::entities::curve::entity_curve_xy(entity),
}
}
}
@@ -790,6 +813,9 @@ fn compute_fillet_entities(
if picks_arc_segment(e1) || picks_arc_segment(e2) {
return None;
}
if matches!(e1, FilletEntity::Curve(_)) || matches!(e2, FilletEntity::Curve(_)) {
return fillet_curves(e1, click1, e2, click2, radius, z);
}
match (e1, e2) {
// ── Line × Line ───────────────────────────────────────────────────
@@ -973,6 +999,8 @@ fn compute_fillet_entities(
None
}
}
// Answered by `fillet_curves` above.
(FilletEntity::Curve(_), _) | (_, FilletEntity::Curve(_)) => None,
}
}
@@ -1232,6 +1260,123 @@ fn fillet_arc_arc(
None
}
/// FILLET where one side is a circle, ellipse or spline. The kernel finds the
/// tangent arcs; each side is then cut back to its tangent point, keeping the
/// part that was picked. A closed curve has no end to cut back and is left
/// whole.
fn fillet_curves(
e1: &FilletEntity,
click1: [f64; 2],
e2: &FilletEntity,
click2: [f64; 2],
radius: f64,
z: f64,
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
if radius < 1e-9 {
return None;
}
let (a, b) = (e1.kernel_curve()?, e2.kernel_curve()?);
let common = match e1 {
FilletEntity::Line(l) => l.common.clone(),
FilletEntity::Arc(a) => a.common.clone(),
FilletEntity::LwPoly { poly, .. } => poly.common.clone(),
FilletEntity::Curve(entity) => entity.common().clone(),
};
for fillet in ranked_fillets(&a, click1, &b, click2, radius) {
let (Some(first), Some(second)) = (
trim_to_tangent(e1, &a, fillet.tangent1, click1),
trim_to_tangent(e2, &b, fillet.tangent2, click2),
) else {
continue;
};
return Some((
first,
second,
Some(EntityType::Arc(fillet_entity(&fillet, radius, z, &common))),
));
}
None
}
/// One side of a fillet cut back to where the arc touches it, the picked part
/// kept. `None` when the touch is off a bounded curve's drawn part — only a
/// line may be extended to meet the arc.
fn trim_to_tangent(
entity: &FilletEntity,
curve: &KernelCurve,
touch: [f64; 2],
click: [f64; 2],
) -> Option<EntityType> {
match entity {
FilletEntity::Line(l) => trim_line_to_point(l, touch, click).map(EntityType::Line),
FilletEntity::LwPoly { poly, seg_idx, .. } => {
let line = lwpoly_seg_as_line(poly, *seg_idx);
let trimmed = trim_line_to_point(&line, touch, click)?;
Some(EntityType::LwPolyline(rebuild_poly_from_trimmed_line(
poly, *seg_idx, &line, &trimmed,
)))
}
FilletEntity::Arc(arc) => {
let (centre, _, start, end, _) = arc_geom(arc);
let at = arc_angle_at(centre, touch);
let clamped = clamp_angle_to_arc(at, start, end);
if (norm_angle(at) - norm_angle(clamped)).abs() > 0.01 {
return None;
}
let picked = clamp_angle_to_arc(arc_angle_at(centre, click), start, end);
Some(EntityType::Arc(
if (clamped - start).rem_euclid(TAU) <= (picked - start).rem_euclid(TAU) {
trim_arc(arc, clamped, end)
} else {
trim_arc(arc, start, clamped)
},
))
}
FilletEntity::Curve(source) => {
let at = curve.parameter_at(touch);
let on = curve.point_at(at);
let scale = 1e-6 * (1.0 + touch[0].abs().max(touch[1].abs()));
if (on[0] - touch[0]).hypot(on[1] - touch[1]) > scale.max(1e-6 * curve.length()) {
return None;
}
if curve.is_closed() {
return Some(source.clone());
}
let keep_end = curve.parameter_at(click) >= at;
match source {
// Its plan image runs the entity's own parameter only when
// it faces up; a mirrored one is left alone.
EntityType::Ellipse(ellipse) if ellipse.normal.z > 0.0 => {
let mut trimmed = ellipse.clone();
trimmed.common.handle = Handle::NULL;
let sweep = {
let raw = ellipse.end_parameter - ellipse.start_parameter;
if raw <= 0.0 {
raw + TAU
} else {
raw
}
};
let cut = ellipse.start_parameter + at * sweep;
if keep_end {
trimmed.start_parameter = cut;
} else {
trimmed.end_parameter = cut;
}
Some(EntityType::Ellipse(trimmed))
}
EntityType::Spline(spline) => {
let (low, high) = super::spline_ops::spline_range(spline)?;
let (head, tail) =
super::spline_ops::spline_cut(spline, low + at * (high - low))?;
Some(EntityType::Spline(if keep_end { tail } else { head }))
}
_ => None,
}
}
}
}
/// Trim a line endpoint nearest to the intersection point, keeping the click side.
fn trim_line_to_point(line: &LineEnt, isect: [f64; 2], click: [f64; 2]) -> Option<LineEnt> {
let mut l = line.clone();
+71 -97
View File
@@ -15,7 +15,6 @@
// offset at once on the cursor's side; a negative one, on the other side.
use crate::entities::curve::{entity_curve, lwpolyline_world_xy};
use crate::modules::draw::modify::spline_ops::spline_sample_xy;
use codec::entities::LwVertex;
use codec::entities::{
Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline,
@@ -26,7 +25,6 @@ use codec::{EntityType, Handle};
// from the kernel; only the entity conversion stays here.
use crate::t;
use glam::{DVec3, Vec3};
use kernel::geom2d::nurbs::clamped_uniform_knots;
use kernel::geom2d::{offset_polyline, Polyline as KernelPolyline, PolylineVertex as KernelVertex};
use crate::command::{CadCommand, CmdResult};
@@ -202,111 +200,87 @@ fn offset_lwpolyline(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Option<EntityT
offset_lwpolylines(p, dist, side_pt).into_iter().next()
}
// ── Ellipse offset ─────────────────────────────────────────────────────────
// ── Ellipse and spline offset ──────────────────────────────────────────────
//
// A true offset of an ellipse is a Lamé curve, not an ellipse. As an
// acceptable CAD approximation we scale both semi-axes uniformly and keep
// the same orientation, center and parameter range. The sign of the offset
// is determined by whether side_pt is inside or outside the ellipse.
// Neither has a parallel of its own kind — an ellipse's is not an ellipse —
// so both come back as a spline fitted through true offset points, within a
// millionth of the curve's size, by the kernel. The work happens in the
// curve's own plane, so a tilted or extruded one offsets in place.
fn offset_ellipse(e: &EllipseEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt();
if a < 1e-9 {
return None;
}
let b = a * e.minor_axis_ratio;
let nx = e.major_axis.x / a;
let ny = e.major_axis.y / a;
// Project side_pt onto ellipse local frame and test inside/outside.
let rx = side_pt.x as f64 - e.center.x;
let ry = side_pt.y as f64 - e.center.y;
let xl = rx * nx + ry * ny;
let yl = -rx * ny + ry * nx;
let inside = (xl / a).powi(2) + (yl / b).powi(2) < 1.0;
let sign = if inside { -1.0 } else { 1.0 };
let new_a = a + sign * dist;
let new_b = b + sign * dist;
if new_a <= 1e-9 || new_b <= 1e-9 {
return None;
}
let mut new_e = e.clone();
new_e.common.handle = Handle::NULL;
// Scale the major_axis vector proportionally.
let scale = new_a / a;
new_e.major_axis.x *= scale;
new_e.major_axis.y *= scale;
new_e.major_axis.z *= scale;
new_e.minor_axis_ratio = new_b / new_a;
Some(EntityType::Ellipse(new_e))
let source = EntityType::Ellipse(e.clone());
let mut template = SplineEnt::new();
template.common = e.common.clone();
offset_curved(&source, template, dist, side_pt)
}
// ── Spline offset ──────────────────────────────────────────────────────────
//
// Strategy: sample the spline into N points, offset each sample point by
// `dist` along the local perpendicular (based on the finite-difference
// tangent), then fit a new spline through the offset points.
fn offset_spline(spl: &SplineEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let (ts_knot, pts) = spline_sample_xy(spl, 64);
let n = pts.len();
if n < 2 {
offset_curved(&EntityType::Spline(spl.clone()), spl.clone(), dist, side_pt)
}
fn offset_curved(
source: &EntityType,
mut template: SplineEnt,
dist: f64,
side_pt: Vec3,
) -> Option<EntityType> {
let planar = entity_curve(source)?;
let side = planar
.plane
.project([side_pt.x as f64, side_pt.y as f64, side_pt.z as f64])?;
// Left of the curve's direction of travel is the kernel's positive side.
let curve = &planar.curve;
let at = curve.parameter_at(side);
let step = 1e-6;
let (before, after) = if curve.is_closed() {
(
curve.point_at((at - step).rem_euclid(1.0)),
curve.point_at((at + step).rem_euclid(1.0)),
)
} else {
(
curve.point_at((at - step).max(0.0)),
curve.point_at((at + step).min(1.0)),
)
};
let foot = curve.point_at(at);
let along = [after[0] - before[0], after[1] - before[1]];
let toward = [side[0] - foot[0], side[1] - foot[1]];
let left = along[0] * toward[1] - along[1] * toward[0] > 0.0;
let signed = if left { dist.abs() } else { -dist.abs() };
let tolerance = (curve.length() * 1e-6).max(1e-9);
let kernel::geom2d::Curve::Nurbs(parallel) =
kernel::geom2d::offset_curve(curve, signed, tolerance)?
else {
return None;
}
// Determine offset sign from the first non-degenerate tangent.
let sign: f64 = (0..n - 1).find_map(|i| {
let dx = pts[i + 1][0] - pts[i][0];
let dy = pts[i + 1][1] - pts[i][1];
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 {
return None;
}
let vx = side_pt.x as f64 - pts[i][0];
let vy = side_pt.y as f64 - pts[i][1];
let cross = dx * vy - dy * vx;
Some(if cross >= 0.0 { 1.0 } else { -1.0 })
})?;
// Offset each sample point along the local normal.
let offset_pts: Vec<codec::types::Vector3> = pts
};
template.common.handle = Handle::NULL;
template.degree = parallel.degree() as i32;
template.knots = parallel.knots().to_vec();
template.control_points = parallel
.control_points()
.iter()
.enumerate()
.map(|(i, p)| {
// Tangent via central / forward / backward difference.
let (dx, dy) = if i == 0 {
let d = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]];
(d[0], d[1])
} else if i == n - 1 {
let d = [pts[n - 1][0] - pts[n - 2][0], pts[n - 1][1] - pts[n - 2][1]];
(d[0], d[1])
} else {
(
(pts[i + 1][0] - pts[i - 1][0]) * 0.5,
(pts[i + 1][1] - pts[i - 1][1]) * 0.5,
)
};
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
let nx = -dy / len; // left perpendicular
let ny = dx / len;
let z = spl.control_points.first().map(|v| v.z).unwrap_or(0.0);
codec::types::Vector3::new(p[0] + sign * nx * dist, p[1] + sign * ny * dist, z)
.map(|uv| {
let [x, y, z] = planar.plane.point_at(*uv);
codec::types::Vector3::new(x, y, z)
})
.collect();
let _ = ts_knot;
// Build a new spline from the offset control points (treat sample pts as fit pts → ctrl pts).
let degree = spl.degree.max(1) as usize;
let new_ctrl: Vec<codec::types::Vector3> = offset_pts;
let n_ctrl = new_ctrl.len();
let mut new_spl = spl.clone();
new_spl.common.handle = Handle::NULL;
new_spl.control_points = new_ctrl;
new_spl.knots = clamped_uniform_knots(degree, n_ctrl);
new_spl.fit_points.clear();
new_spl.weights.clear();
Some(EntityType::Spline(new_spl))
template.weights = if parallel.is_rational() {
parallel.weights().to_vec()
} else {
Vec::new()
};
template.flags.rational = parallel.is_rational();
template.flags.periodic = false;
template.flags.closed = curve.is_closed();
if let Some([x, y, z]) = planar.plane.normal() {
template.normal = codec::types::Vector3::new(x, y, z);
}
// A parallel is no longer the curve through the source's fit points.
template.fit_points.clear();
template.begin_tangent = codec::types::Vector3::new(0.0, 0.0, 0.0);
template.end_tangent = codec::types::Vector3::new(0.0, 0.0, 0.0);
Some(EntityType::Spline(template))
}
// ── Dispatch ───────────────────────────────────────────────────────────────