feat(ui): paintbrush cursor while MATCHPROP is armed

MATCHPROP transfers properties from a source object onto others, so the CAD
crosshair gives way to a monochrome paintbrush whose tip sits on the pick
point - the AutoCAD feel for the same command.

The glyph borrows the crosshair's own colour (including a user-customised
one) and uses a single stroke weight plus one filled wedge, so it reads as
part of the CAD cursor family rather than clip-art. It is drawn instead of
the crosshair, never on top of it, and only while the command is armed.
This commit is contained in:
Carlos Quiroga committed 2026-10-13 20:32:25 -06:00
1 parent acb8c5ea54
commit 1b72115f50
3 files changed
+120

No files matched your search

+2
View File
@@ -20,6 +20,7 @@
* **Draggable Tabs & Groups:** Palette tabs can now be reordered by dragging; floating windows support grouped tabs. *(Contributed by [@posnofa](https://github.com/posnofa))*
* **Layer Manager Palette:** Integrated `LAYERS` into the docking system as a native pallet. *(Contributed by [@posnofa](https://github.com/posnofa))*
* **Graphic Attributes Palette:** Added toggles, controls for open boundaries, color swatches with `*VARIES*` indicator, and persistent docking layout. *(Contributed by [@MarcelP102](https://github.com/MarcelP102))*
* **MATCHPROP Paintbrush Cursor:** While `MATCHPROP` is armed the CAD crosshair gives way to a monochrome paintbrush whose tip rides the pick point, echoing the AutoCAD "match properties" cursor. The glyph borrows the crosshair's own colour — including a user-customised one — so only the shape changes, never the ink. *(Contributed by [@Ch4rlos](https://github.com/Ch4rlos))*
#### Selection & Drafting
* **Advanced Selection Filter (`FILTER`):** Added comprehensive selection filtering with normalized logical operators and attribute filters (v2.2). *(Contributed by [@turbuczn](https://github.com/turbuczn))*
@@ -110,3 +111,4 @@ Huge thanks to everyone who contributed to this cycle:
* **Kevin Griffin** ([@KevinGriffin-new](https://github.com/KevinGriffin-new)) — macOS DMG bundle Applications shortcut link (`#769`).
* **francesco fantoni** ([@hvfrancesco](https://github.com/hvfrancesco)) — Translations and UI improvements.
* **theluckystrike** ([@theluckystrike](https://github.com/theluckystrike)) — Command line rebound spacebar execution (`#1710`).
* **Carlos Quiroga** ([@Ch4rlos](https://github.com/Ch4rlos)) — `MATCHPROP` paintbrush cursor.
+5
View File
@@ -959,6 +959,11 @@ bg={bg_ms:.1}ms n={view_count}"
}),
snap_color: self.snap_marker_color,
},
// MATCHPROP paints properties onto destinations; it gets the
// paintbrush cursor instead of the crosshair.
tab.active_cmd
.as_ref()
.is_some_and(|c| c.name() == "MATCHPROP"),
crate::ui::overlay::SelectionVisualOptions {
area: self.model_space.selection_area,
opacity: self.model_space.selection_opacity,
+113
View File
@@ -71,6 +71,70 @@ fn draw_concentric_constraint_glyph(frame: &mut canvas::Frame, center: Point, co
frame.stroke(&canvas::Path::circle(center, 2.15), stroke);
}
/// Strokes a closed four-corner polygon — the outline half of the brush glyph.
fn stroke_quad(frame: &mut canvas::Frame, corners: [Point; 4], stroke: &canvas::Stroke<'_>) {
let path = canvas::Path::new(|p| {
p.move_to(corners[0]);
p.line_to(corners[1]);
p.line_to(corners[2]);
p.line_to(corners[3]);
p.close();
});
frame.stroke(&path, stroke.clone());
}
/// MATCHPROP's paintbrush cursor.
///
/// While the user is transferring properties the CAD crosshair is replaced by a
/// paintbrush whose tip sits exactly on the pick point — the AutoCAD feel. The
/// body leans up-and-right so it never covers the object being picked.
///
/// Deliberately monochrome and geometric: the crosshair's own colour, a single
/// stroke weight and one filled wedge, so the glyph reads as part of the CAD
/// cursor family rather than clip-art.
fn draw_match_brush(frame: &mut canvas::Frame, tip: Point, color: Color) {
let inv = std::f32::consts::FRAC_1_SQRT_2;
// `along` runs up-and-right (handle direction); `across` is perpendicular.
let along = (inv, -inv);
let across = (inv, inv);
let at = |a: f32, w: f32| {
Point::new(
tip.x + along.0 * a + across.0 * w,
tip.y + along.1 * a + across.1 * w,
)
};
let stroke = canvas::Stroke {
width: 1.25,
style: canvas::Style::Solid(color),
line_join: canvas::LineJoin::Miter,
..Default::default()
};
// Bristles: the only solid mass, so the working end carries the weight and
// the silhouette still reads at cursor size.
let bristles = canvas::Path::new(|p| {
p.move_to(at(0.0, 0.0));
p.line_to(at(9.0, -3.2));
p.line_to(at(12.0, -3.2));
p.line_to(at(12.0, 3.2));
p.line_to(at(9.0, 3.2));
p.close();
});
frame.fill(&bristles, color);
// Ferrule and handle stay outline-only: line work, not clip-art.
stroke_quad(
frame,
[at(12.0, -3.2), at(15.5, -3.2), at(15.5, 3.2), at(12.0, 3.2)],
&stroke,
);
stroke_quad(
frame,
[at(15.5, -2.3), at(30.0, -1.9), at(30.0, 1.9), at(15.5, 2.3)],
&stroke,
);
}
/// The reference bar's padlock: white with a small orange point marker
/// when one point is held, red when a whole curve or segment is.
fn draw_fixed_constraint_glyph(
@@ -901,6 +965,8 @@ pub fn selection_overlay<'a>(
hover_locked: bool,
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
// MATCHPROP is armed: paintbrush cursor replaces the crosshair.
show_match_brush: bool,
selection_visual: SelectionVisualOptions,
constraint_glyphs: Arc<[GlyphEntry]>,
// Per-glyph selection flags, parallel to `constraint_glyphs`, applied
@@ -931,6 +997,7 @@ pub fn selection_overlay<'a>(
hover_locked,
crosshair_bg,
crosshair,
show_match_brush,
selection_visual,
constraint_glyphs,
constraint_glyph_selected,
@@ -995,6 +1062,9 @@ struct SelectionCanvas {
/// rather than the UI theme, which may be light over a dark model viewport.
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
/// MATCHPROP is armed: the crosshair gives way to a paintbrush cursor
/// whose tip marks the pick point.
show_match_brush: bool,
selection_visual: SelectionVisualOptions,
/// Memoised constraint-glyph entries (precomputed anchor/outward/label +
/// layout) shared with the hit-test and tooltip paths — draw reads
@@ -1961,11 +2031,54 @@ impl canvas::Program<Message> for SelectionCanvas {
// arrow (see `mouse_interaction`); drawing the CAD crosshair on
// top of it would double up the visual feedback.
let over_divider = self.divider_under(cursor, bounds);
// ── MATCHPROP paintbrush cursor ───────────────────────────────
// While MATCHPROP is armed the user is transferring properties from a
// source object onto others, so the crosshair gives way to a paintbrush
// whose tip rides the pick point — the AutoCAD feel. Drawn *instead of*
// the crosshair below, never on top of it.
if !over_viewcube
&& !over_divider
&& self.nav == NavCursor::None
&& !self.suppressed
&& self.show_match_brush
{
if let Some(cp) = self
.grips
.iter()
.find(|grip| grip.is_hovered)
.map(|grip| grip.pos)
.or(self.selection.borrow().input.last_move_pos)
{
// Inherit the crosshair's own colour (including a custom
// crosshair colour) so only the shape changes, never the ink.
let [cr, cg, cb, ca] = 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],
);
draw_match_brush(
&mut frame,
cp,
Color {
r: cr,
g: cg,
b: cb,
a: ca,
},
);
}
}
// An armed navigation tool replaces the crosshair with its own cursor.
if !over_viewcube
&& !over_divider
&& self.nav == NavCursor::None
&& !self.suppressed
&& !self.show_match_brush
&& self.crosshair.cursor_type == CursorType::Crosshair
{
// Over a grip the crosshair locks onto it, so the click lands on