perf(glyphs): memoize constraint-glyph placements behind constraints_epoch

Placements (entity lookups, intersections, String labels, projections)
recomputed per view() and per mouse-move hit-test, with overlay re-clones
and 2-3x offset recomputes. New constraints_epoch bumped on every set
mutation/visibility/undo/DWG-load (plus new set_constraint_enabled API);
single-slot Arc<[GlyphEntry]> memo on an 11-field key (hover/selection
excluded); overlay de-cloned/de-duped to the single geometry source;
hit paths reuse the cache.

Tests:
- cached_glyph_placements_hit_returns_same_arc: same-key Arc identity
- cached_glyph_placements_invalidated_by_metadata_edit: edit + selection change yield new Arc
- glyph_hit_test_entries_matches_precomputed_boxes: shared hit-test helper

Bench (cargo bench glyph, 60 constraints): 8.18us -> 0.05-0.15us (~55x).
A/B reference deleted.
This commit is contained in:
Karim Jerbi committed 2026-09-21 22:52:47 +01:00
1 parent 2d49204c50
commit 4432f633e7
12 files changed
+767 -239

No files matched your search

+2 -1
View File
@@ -19,7 +19,7 @@ cargo bench --bench performance_benchmarks -- --quick
--- ---
## Benchmark Suite Catalog (29 Metrics) ## Benchmark Suite Catalog (30 Metrics)
### 1. Scene & Entity Management ### 1. Scene & Entity Management
- `scene_entity_ingestion_10k`: Ingestion throughput for 10,000 mixed 2D entities (Lines, Circles, Arcs, Polylines). - `scene_entity_ingestion_10k`: Ingestion throughput for 10,000 mixed 2D entities (Lines, Circles, Arcs, Polylines).
@@ -55,6 +55,7 @@ cargo bench --bench performance_benchmarks -- --quick
- `ui_statusbar_derived_data`: Status-bar cached derived-data hit (layout names + scale picker `Arc` clones). - `ui_statusbar_derived_data`: Status-bar cached derived-data hit (layout names + scale picker `Arc` clones).
- `ui_grip_budget`: Budgeted per-frame selection-grip projection (dense-polyline grips capped to `MAX_SELECTED_GRIPS`). - `ui_grip_budget`: Budgeted per-frame selection-grip projection (dense-polyline grips capped to `MAX_SELECTED_GRIPS`).
- `ui_grip_budget_build`: Selection-change `apply_grip_budget` cost over the full grip set (sort + FxHashSet + filter). - `ui_grip_budget_build`: Selection-change `apply_grip_budget` cost over the full grip set (sort + FxHashSet + filter).
- `ui_constraint_glyphs`: Constraint-glyph cache hit (`cached_glyph_placements` key build + `Arc` clone over dozens of glyphs, no recompute).
### 6. State Management & Draworder ### 6. State Management & Draworder
- `selection_state_deep_clone`: Deep cloning of `SelectionState` (with 64 polygon points). - `selection_state_deep_clone`: Deep cloning of `SelectionState` (with 64 polygon points).
+94
View File
@@ -1618,6 +1618,99 @@ fn bench_ui_grip_budget(runner: &mut BenchmarkRunner) {
); );
} }
// ── 12e. UI Constraint-Glyph Cache Hit (memoised placements) ────────────────
// Covers the parametric constraint-glyph memo (`Scene::cached_glyph_placements`,
// src/scene/parametric_constraints.rs): dozens of constraints whose per-frame
// view + hit-test + dwell + click share one key, so the hot path is a cache
// hit (key build + Arc clone), not a placements recompute (entity lookups,
// intersections, String labels, projections).
//
// NOTE: the bench calls the real `cached_glyph_placements` with the same key
// inputs the production consumers (app/view, viewport hit paths, overlay) and
// the `cached_glyph_placements_*` unit-test fixture use
// (`ParametricScope::ModelSpace`, full-canvas vp, values on, display 3,
// bar 4095), so the measured hit is exactly what the frame shares.
fn bench_ui_constraint_glyphs(runner: &mut BenchmarkRunner) {
if !runner.should_run("ui_constraint_glyphs") {
return;
}
// Fixture: dozens of horizontal constraints over a grid of short lines
// around the origin — mirrors the `cached_glyph_placements_*` unit-test
// fixture (Scene::new + add_entity + constraint add + note applied).
let n_constraints = if runner.quick_mode { 24 } else { 60 };
let mut scene = Scene::new();
let cols = 8;
for i in 0..n_constraints {
let x = ((i % cols) as f64) * 30.0 - 100.0;
let y = ((i / cols) as f64) * 30.0 - 100.0;
let line = scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(x, y, 0.0),
Vector3::new(x + 20.0, y, 0.0),
)));
let id = scene
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
.add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(line)],
None,
);
scene.note_parametric_constraint_applied(ParametricScope::ModelSpace, id, 3);
}
scene.selection.borrow_mut().vp_size = (1920.0, 1080.0);
let vp = (1920.0_f32, 1080.0_f32);
// Prime the cache so every timed call below is a hit (same key, same Arc).
let primed =
scene.cached_glyph_placements(ParametricScope::ModelSpace, vp, true, 3, 4095);
assert!(
!primed.is_empty(),
"glyph fixture must yield placements"
);
let n_glyphs = primed.len();
// Warm-up for allocator settling.
for _ in 0..10 {
let hit =
scene.cached_glyph_placements(ParametricScope::ModelSpace, vp, true, 3, 4095);
black_box(hit);
}
let n = if runner.quick_mode { 200 } else { 1_000 };
let runs = 5;
let mut samples = Vec::with_capacity(runs);
for _ in 0..runs {
let t0 = Instant::now();
for _ in 0..n {
let hit = scene.cached_glyph_placements(
black_box(ParametricScope::ModelSpace),
black_box(vp),
black_box(true),
black_box(3),
black_box(4095),
);
black_box(hit);
}
let per_us = (t0.elapsed().as_micros() as f64) / (n as f64);
samples.push(per_us);
}
let median_us = samples[samples.len() / 2];
runner.record(
"ui_constraint_glyphs",
&format!(
"Constraint-glyph cache hit over {} glyphs (key build + Arc clone, no recompute)",
n_glyphs
),
"µs",
samples,
Some(((n_glyphs as f64) / (median_us / 1_000_000.0), "glyphs/s")),
Some(0.5), // Target threshold < 0.5 µs (measured ~0.06 µs quick / ~0.15 µs full, ~3x headroom)
);
}
// ── 13. Wide & Tapered Arc + Donut Tessellation ───────────────────────────── // ── 13. Wide & Tapered Arc + Donut Tessellation ─────────────────────────────
fn bench_wide_and_tapered_arc_tessellation(runner: &mut BenchmarkRunner) { fn bench_wide_and_tapered_arc_tessellation(runner: &mut BenchmarkRunner) {
@@ -2023,6 +2116,7 @@ fn main() {
bench_ui_plotstyle_layer_usage(&mut runner); bench_ui_plotstyle_layer_usage(&mut runner);
bench_ui_statusbar_derived_data(&mut runner); bench_ui_statusbar_derived_data(&mut runner);
bench_ui_grip_budget(&mut runner); bench_ui_grip_budget(&mut runner);
bench_ui_constraint_glyphs(&mut runner);
bench_wide_and_tapered_arc_tessellation(&mut runner); bench_wide_and_tapered_arc_tessellation(&mut runner);
bench_zoom_extents_calculation(&mut runner); bench_zoom_extents_calculation(&mut runner);
bench_batch_entity_mutation(&mut runner); bench_batch_entity_mutation(&mut runner);
+12
View File
@@ -1409,6 +1409,7 @@ impl OpenCADStudio {
dimension.into_iter().collect(), dimension.into_iter().collect(),
parameter.into_iter().collect(), parameter.into_iter().collect(),
); );
self.tabs[i].scene.bump_constraints_epoch();
if self.tabs[i].scene.selected_constraint == Some(id) { if self.tabs[i].scene.selected_constraint == Some(id) {
self.tabs[i].scene.selected_constraint = None; self.tabs[i].scene.selected_constraint = None;
} }
@@ -1720,6 +1721,9 @@ impl OpenCADStudio {
self.tabs[i] self.tabs[i]
.scene .scene
.record_undo_parametric_constraints_before(scope, before); .record_undo_parametric_constraints_before(scope, before);
// A parameter rename rewrites the `Named` driving references
// pointing at it — the glyph labels change with them.
let mut renamed = false;
for constraint in for constraint in
&mut self.tabs[i].scene.parametric_constraints[set_index].constraints &mut self.tabs[i].scene.parametric_constraints[set_index].constraints
{ {
@@ -1728,9 +1732,13 @@ impl OpenCADStudio {
{ {
if name == &current.name { if name == &current.name {
*name = typed.clone(); *name = typed.clone();
renamed = true;
} }
} }
} }
if renamed {
self.tabs[i].scene.bump_constraints_epoch();
}
} }
} }
self.resolve_named_parameter_edit(i, &resolve_name); self.resolve_named_parameter_edit(i, &resolve_name);
@@ -1866,6 +1874,10 @@ impl OpenCADStudio {
if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) {
constraint.enabled = !reference; constraint.enabled = !reference;
} }
// Toggling `enabled` changes the glyph placement set (and the
// dynamic pills), so the memoised placements must miss next
// frame — same guarantee `set_constraint_enabled` gives.
self.tabs[i].scene.bump_constraints_epoch();
self.tabs[i].scene.refresh_dynamic_dimension_texts(); self.tabs[i].scene.refresh_dynamic_dimension_texts();
self.tabs[i].scene.sync_native_parametric_graph(); self.tabs[i].scene.sync_native_parametric_graph();
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
+1
View File
@@ -1159,6 +1159,7 @@ impl OpenCADStudio {
set.remove(*id); set.remove(*id);
} }
self.purge_dimensional_extras(i, dimensions, parameters); self.purge_dimensional_extras(i, dimensions, parameters);
self.tabs[i].scene.bump_constraints_epoch();
let changes: Vec<_> = handles let changes: Vec<_> = handles
.iter() .iter()
.copied() .copied()
+3
View File
@@ -978,6 +978,9 @@ impl OpenCADStudio {
} }
let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo);
let scene = &mut self.tabs[i].scene; let scene = &mut self.tabs[i].scene;
if !d.parametric_constraints.is_empty() {
scene.bump_constraints_epoch();
}
for entry in &d.parametric_constraints { for entry in &d.parametric_constraints {
let value = if undo { &entry.before } else { &entry.after }; let value = if undo { &entry.before } else { &entry.after };
*scene.parametric_constraint_set_mut(entry.scope) = value.clone(); *scene.parametric_constraint_set_mut(entry.scope) = value.clone();
+18 -30
View File
@@ -863,39 +863,26 @@ bg={bg_ms:.1}ms n={view_count}"
.map(|constraint| constraint.kind.glyph_symbol().to_string()) .map(|constraint| constraint.kind.glyph_symbol().to_string())
}) })
}; };
let constraint_glyphs: Vec<( let constraint_glyphs: std::sync::Arc<
iced::Point, [crate::scene::parametric_constraints::GlyphEntry],
[f32; 2], > = if is_paper {
String, std::sync::Arc::from([])
bool,
bool,
Vec<iced::Point>,
)> = if is_paper {
Vec::new()
} else { } else {
let scope = tab.current_parametric_scope(); let scope = tab.current_parametric_scope();
tab.scene tab.scene.cached_glyph_placements(
.constraint_glyph_placements_screen( scope,
scope, sel_ref.vp_size,
sel_ref.vp_size, self.show_constraint_values,
self.show_constraint_values, self.constraint_bar_display,
self.constraint_bar_display, self.constraint_bar_mode,
self.constraint_bar_mode, )
)
.into_iter()
.map(|(id, point, direction, label, is_conflicting, hover_points)| {
let selected = tab.scene.selected_constraint == Some(id);
(
point,
direction,
label,
is_conflicting,
selected,
hover_points,
)
})
.collect()
}; };
// Selection stays OUTSIDE the cache (applied post-hoc): a selection
// change must not invalidate the placement memo.
let constraint_glyph_selected: std::sync::Arc<[bool]> = constraint_glyphs
.iter()
.map(|entry| tab.scene.selected_constraint == Some(entry.id))
.collect();
crate::ui::overlay::selection_overlay( crate::ui::overlay::selection_overlay(
std::sync::Arc::clone(&tab.scene.selection), std::sync::Arc::clone(&tab.scene.selection),
snap_info, snap_info,
@@ -939,6 +926,7 @@ bg={bg_ms:.1}ms n={view_count}"
grip_hover: self.model_space.grip_hover, grip_hover: self.model_space.grip_hover,
}, },
constraint_glyphs, constraint_glyphs,
constraint_glyph_selected,
self.constraint_glyph_tooltip self.constraint_glyph_tooltip
.map(|kind| crate::t!(kind.label()).into_owned()), .map(|kind| crate::t!(kind.label()).into_owned()),
constraint_cursor_badge, constraint_cursor_badge,
+4
View File
@@ -2841,6 +2841,10 @@ impl Scene {
self.parametric_constraints.push(set); self.parametric_constraints.push(set);
} }
} }
// Whole-set replace (file open / reload): covers the DWG-import
// `enabled` / `axis_direction` / `distance_direction` / `angle_sector`
// writes, which happen on the local set during decode.
self.bump_constraints_epoch();
} }
/// Makes the standard associative graph match the live command model. /// Makes the standard associative graph match the live command model.
+1
View File
@@ -2710,6 +2710,7 @@ impl Scene {
// and named parameters into the fresh one. // and named parameters into the fresh one.
self.parametric_constraints.clear(); self.parametric_constraints.clear();
self.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.named_parameters = crate::scene::named_parameters::ParameterTable::new();
self.bump_constraints_epoch();
self.bump_geometry(); self.bump_geometry();
self.bump_layout_epoch(); self.bump_layout_epoch();
self.bump_scale_epoch(); self.bump_scale_epoch();
+47
View File
@@ -2321,6 +2321,25 @@ pub struct Scene {
parametric_constraints::ParametricScope, parametric_constraints::ParametricScope,
parametric_constraints::ConstraintId, parametric_constraints::ConstraintId,
)>, )>,
/// Bumped on every parametric-constraint-set mutation (add / remove /
/// metadata edit / visibility override / whole-set restore). The
/// constraint-glyph memoization keys on this so a stale cache can never
/// outlive the edit that invalidated it — a missed bump is a
/// correctness bug (stale glyphs), not just a perf miss.
constraints_epoch: u64,
/// Memoised constraint-glyph placements for the latest [`GlyphKey`](parametric_constraints::GlyphKey)
/// (Task 3; overlay/hit consumers adopt it in Task 4). Single-slot memo:
/// within one frame view + hit-test + dwell + click share the key (hits),
/// camera motion misses once per frame by design, and edits just miss on
/// the stale entry — no leak possible. `RefCell` + `Arc` shape mirrors
/// `layout_names_cache`; a missed constraint bump serves stale glyphs, so
/// every set mutation must bump `constraints_epoch`.
glyph_cache: RefCell<
Option<(
parametric_constraints::GlyphKey,
Arc<[parametric_constraints::GlyphEntry]>,
)>,
>,
/// Document-wide named-parameter and expression table decoded from /// Document-wide named-parameter and expression table decoded from
/// standard associative variables. /// standard associative variables.
pub(crate) named_parameters: named_parameters::ParameterTable, pub(crate) named_parameters: named_parameters::ParameterTable,
@@ -2656,6 +2675,8 @@ impl Scene {
dynamic_dimension_camera_gen: None, dynamic_dimension_camera_gen: None,
hidden_parametric_constraints: HashSet::default(), hidden_parametric_constraints: HashSet::default(),
shown_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(),
constraints_epoch: 0,
glyph_cache: RefCell::new(None),
named_parameters: named_parameters::ParameterTable::new(), named_parameters: named_parameters::ParameterTable::new(),
has_associative_centers: std::cell::Cell::new(None), has_associative_centers: std::cell::Cell::new(None),
block_defn_cache: RefCell::new(HashMap::default()), block_defn_cache: RefCell::new(HashMap::default()),
@@ -5703,6 +5724,9 @@ impl Scene {
.collect(); .collect();
self.object_isolation.hidden.extend(hide); self.object_isolation.hidden.extend(hide);
self.object_isolation.keep = Some(keep); self.object_isolation.keep = Some(keep);
// Isolation hides entities the memoised glyph placements read
// (`entity_temporarily_hidden`), so invalidate them too.
self.bump_constraints_epoch();
if !changes.is_empty() { if !changes.is_empty() {
self.bump_entities(&changes); self.bump_entities(&changes);
} }
@@ -5726,6 +5750,9 @@ impl Scene {
self.selected.clear(); self.selected.clear();
self.selected_order.clear(); self.selected_order.clear();
self.bump_selection_set(); self.bump_selection_set();
// Newly hidden entities change `entity_temporarily_hidden`, which the
// memoised glyph placements read — invalidate them too.
self.bump_constraints_epoch();
self.bump_entities(&changes); self.bump_entities(&changes);
} }
@@ -5742,6 +5769,12 @@ impl Scene {
.map(|handle| (handle, ChangeKind::Modified)) .map(|handle| (handle, ChangeKind::Modified))
.collect(); .collect();
self.object_isolation = ObjectIsolationState::default(); self.object_isolation = ObjectIsolationState::default();
// Restoring isolated entities changes `entity_temporarily_hidden`,
// which the memoised glyph placements read — invalidate them too.
// (`reset_transient_visibility` needs no bump: it only runs on
// doc-replace, where `load_parametric_constraints_from_document`
// already bumps the epoch for the whole-set replace.)
self.bump_constraints_epoch();
if !changes.is_empty() { if !changes.is_empty() {
self.bump_entities(&changes); self.bump_entities(&changes);
} }
@@ -6027,6 +6060,20 @@ impl Scene {
self.scale_epoch += 1; self.scale_epoch += 1;
} }
/// Current parametric-constraint-set epoch (cache key input for the
/// memoised `cached_glyph_placements` accessor).
#[allow(dead_code)]
pub(crate) fn constraints_epoch(&self) -> u64 {
self.constraints_epoch
}
/// Invalidate constraint-glyph consumers. Call on EVERY mutation of the
/// constraint set or its visibility overrides — a missed bump serves
/// stale glyphs.
pub(crate) fn bump_constraints_epoch(&mut self) {
self.constraints_epoch += 1;
}
pub fn cached_scale_picker_list(&self) -> std::sync::Arc<Vec<(String, f32, f64)>> { pub fn cached_scale_picker_list(&self) -> std::sync::Arc<Vec<(String, f32, f64)>> {
let factor = self.annotation_scale_unit_factor(); let factor = self.annotation_scale_unit_factor();
let imperial = self.prefers_imperial_scales(); let imperial = self.prefers_imperial_scales();
+484 -15
View File
@@ -2,6 +2,8 @@
use super::named_parameters::DrivingValue; use super::named_parameters::DrivingValue;
use acadrust::types::{Handle, Vector3}; use acadrust::types::{Handle, Vector3};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
/// One endpoint a constraint attaches to: an entity plus which sub-element /// One endpoint a constraint attaches to: an entity plus which sub-element
/// of it. /// of it.
@@ -1579,6 +1581,170 @@ pub(crate) fn constraint_hover_points(
points points
} }
/// Memoised key for `Scene::cached_glyph_placements`.
/// `sel_sig` hashes the hide sets the dynamic pills read on EVERY display
/// mode (`preview_hidden`, `command_preview_hidden`,
/// `hidden_dynamic_dimensions` — `constraint_glyph_placements_screen` filters
/// `entity_temporarily_hidden` unconditionally) PLUS, only when
/// `display_mode & 2 != 0`, the selection-gated sets (`selected`,
/// `hidden_parametric_constraints`, `shown_parametric_constraints`, and again
/// `preview_hidden` as a selection source). Hover and selection-highlight
/// state deliberately stay OUT — they only affect the highlight, never the
/// placement set. Isolation contents stay OUT — the isolation mutators bump
/// `constraints_epoch` instead, so no key input is needed for them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GlyphKey {
pub scope: ParametricScope,
pub c_epoch: u64,
pub g_epoch: u64,
pub cam_gen: u64,
pub active_vp: Option<Handle>,
pub layout_is_model: bool,
pub vp_bits: (u32, u32),
pub show_values: bool,
pub disp: i16,
pub bar: i16,
pub sel_sig: u64,
pub annotation_scale: u32,
}
/// One cached glyph placement: the `constraint_glyph_placements_screen`
/// tuple plus its precomputed overlay layout (`size`, `tangent_dx`,
/// `top_left`), so overlay consumers never re-clone labels or recompute
/// offsets. `is_selected` and the tooltip stay OUT — applied post-hoc.
#[derive(Debug, Clone)]
pub struct GlyphEntry {
pub id: ConstraintId,
pub anchor: iced::Point,
pub outward: [f32; 2],
pub label: Arc<str>,
pub conflicting: bool,
pub hover: Arc<[iced::Point]>,
pub size: iced::Size,
pub tangent_dx: f32,
pub top_left: iced::Point,
}
// Single source of truth for constraint-glyph layout (Task 4): the overlay
// side was deleted and imports these instead, so the drawn pills, the
// hit-test, and the cached `top_left`/`tangent_dx` can never drift apart.
pub(crate) const GLYPH_SIZE: f32 = 14.0;
const GLYPH_PAD_X: f32 = 7.0;
const GLYPH_PAD_Y: f32 = 4.0;
const GLYPH_GAP: f32 = 6.0;
const GLYPH_ROW_GAP: f32 = 4.0;
const GLYPH_COINCIDENT_SIZE: f32 = 9.0;
pub(crate) fn glyph_is_compact(label: &str) -> bool {
matches!(label, "≡" | "∈")
}
pub(crate) fn glyph_is_fixed(label: &str) -> bool {
label == "F" || label == FIXED_POINT_GLYPH
}
pub(crate) fn glyph_is_vertical(label: &str) -> bool {
label == "│" || label == VERTICAL_POINTS_GLYPH
}
pub(crate) fn constraint_glyph_size(label: &str) -> iced::Size {
if glyph_is_compact(label) {
return iced::Size::new(GLYPH_COINCIDENT_SIZE, GLYPH_COINCIDENT_SIZE);
}
if label == "G²"
|| glyph_is_fixed(label)
|| glyph_is_vertical(label)
|| label == DYNAMIC_DIMENSION_GLYPH
{
let side = GLYPH_SIZE + GLYPH_PAD_Y * 2.0;
return iced::Size::new(side, side);
}
let w = label.chars().count() as f32 * GLYPH_SIZE * 0.62 + GLYPH_PAD_X * 2.0;
let h = GLYPH_SIZE + GLYPH_PAD_Y * 2.0;
iced::Size::new(w, h)
}
pub(crate) fn constraint_glyph_box(
anchor: iced::Point,
outward: [f32; 2],
label: &str,
tangent_offset: f32,
) -> (iced::Point, iced::Size) {
let size = constraint_glyph_size(label);
let gap = if glyph_is_compact(label) {
1.0
} else {
GLYPH_GAP
};
let distance =
outward[0].abs() * size.width * 0.5 + outward[1].abs() * size.height * 0.5 + gap;
let tangent = [-outward[1], outward[0]];
(
iced::Point::new(
anchor.x + outward[0] * distance + tangent[0] * tangent_offset - size.width * 0.5,
anchor.y + outward[1] * distance + tangent[1] * tangent_offset - size.height * 0.5,
),
size,
)
}
pub(crate) fn constraint_glyph_offsets(glyphs: &[(iced::Point, [f32; 2], &str)]) -> Vec<f32> {
let mut groups: rustc_hash::FxHashMap<[u32; 4], Vec<usize>> =
rustc_hash::FxHashMap::default();
for (index, (anchor, outward, _)) in glyphs.iter().enumerate() {
groups
.entry([
anchor.x.to_bits(),
anchor.y.to_bits(),
outward[0].to_bits(),
outward[1].to_bits(),
])
.or_default()
.push(index);
}
let mut offsets = vec![0.0; glyphs.len()];
for indices in groups.values().filter(|indices| indices.len() > 1) {
let half_extents: Vec<f32> = indices
.iter()
.map(|index| {
let (_, outward, label) = &glyphs[*index];
let size = constraint_glyph_size(label);
let tangent = [-outward[1], outward[0]];
tangent[0].abs() * size.width * 0.5 + tangent[1].abs() * size.height * 0.5
})
.collect();
let total =
half_extents.iter().sum::<f32>() * 2.0 + GLYPH_ROW_GAP * (indices.len() - 1) as f32;
let mut cursor = -total * 0.5;
for (index, half_extent) in indices.iter().zip(half_extents) {
offsets[*index] = cursor + half_extent;
cursor += half_extent * 2.0 + GLYPH_ROW_GAP;
}
}
offsets
}
/// Hit-tests screen point `p` against the precomputed `top_left`/`size` boxes
/// in `entries` — the same pills `draw` renders, including the tangential
/// fan-out baked into `tangent_dx` at cache time. Returns the index of the
/// topmost (last-drawn) match. Shared by `Scene::constraint_glyph_hit` and
/// the overlay draw/hover paths so the clickable area can never drift from
/// what's drawn and no caller recomputes offsets.
pub(crate) fn glyph_hit_test_entries(entries: &[GlyphEntry], p: iced::Point) -> Option<usize> {
entries
.iter()
.enumerate()
.rev()
.find_map(|(index, entry)| {
let within = p.x >= entry.top_left.x
&& p.x <= entry.top_left.x + entry.size.width
&& p.y >= entry.top_left.y
&& p.y <= entry.top_left.y + entry.size.height;
within.then_some(index)
})
}
impl super::Scene { impl super::Scene {
/// Expand through enabled constraints. Whole-object translations follow /// Expand through enabled constraints. Whole-object translations follow
/// point connections; grip previews include curve relations as well. /// point connections; grip previews include curve relations as well.
@@ -1772,6 +1938,10 @@ impl super::Scene {
} else { } else {
self.shown_parametric_constraints.remove(&(scope, id)); self.shown_parametric_constraints.remove(&(scope, id));
} }
// Every production `add` flows through here, so this bump covers all
// constraint creations (add / add_axis_constraint / AUTOCONSTRAIN /
// DCCONVERT / Coincident / Equal / Fixed / dimensional handlers).
self.bump_constraints_epoch();
} }
pub fn set_parametric_constraint_visibility( pub fn set_parametric_constraint_visibility(
@@ -1806,6 +1976,9 @@ impl super::Scene {
} }
} }
self.refresh_hidden_dynamic_dimensions(); self.refresh_hidden_dynamic_dimensions();
if !ids.is_empty() {
self.bump_constraints_epoch();
}
ids.len() ids.len()
} }
@@ -1959,9 +2132,48 @@ impl super::Scene {
.map(|handle| (handle, super::ChangeKind::Modified)) .map(|handle| (handle, super::ChangeKind::Modified))
.collect(); .collect();
self.hidden_dynamic_dimensions = desired; self.hidden_dynamic_dimensions = desired;
// The memoised glyph placements read this set on every display mode
// (dynamic pills filter `entity_temporarily_hidden` unconditionally),
// so a change here must invalidate them — this covers all four
// callers, including the DYNCONSTRAINTDISPLAY toggle that has no
// call-site bump of its own.
self.bump_constraints_epoch();
self.bump_entities(&changes); self.bump_entities(&changes);
} }
/// Enables or disables one constraint without losing it — the production
/// path for toggling `ParametricConstraint::enabled` (a re-solve skips a
/// disabled constraint; glyph placements filter it out). Returns whether
/// a constraint with `id` exists in `scope`. Bumps the constraints epoch
/// exactly when the value actually changes so future callers cannot
/// introduce a stale-glyph path by writing the field directly.
pub fn set_constraint_enabled(
&mut self,
scope: ParametricScope,
id: ConstraintId,
enabled: bool,
) -> bool {
let mut found = false;
let mut changed = false;
if let Some(set) = self
.parametric_constraints
.iter_mut()
.find(|set| set.scope == scope)
{
if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) {
found = true;
if constraint.enabled != enabled {
constraint.enabled = enabled;
changed = true;
}
}
}
if changed {
self.bump_constraints_epoch();
}
found
}
/// Infers relations already present in the selected geometry. /// Infers relations already present in the selected geometry.
pub fn inferred_parametric_constraints( pub fn inferred_parametric_constraints(
&self, &self,
@@ -2298,10 +2510,11 @@ impl super::Scene {
/// `vp_size` is the full canvas size (as `SelectionState::vp_size` /// `vp_size` is the full canvas size (as `SelectionState::vp_size`
/// reports it), matching what `viewport_edit_frame`/ /// reports it), matching what `viewport_edit_frame`/
/// `active_model_tile_bounds` expect. Mirrors the projection /// `active_model_tile_bounds` expect. Mirrors the projection
/// `crate::app::view` builds its own render list with, and is reused by /// `crate::app::view` builds its own render list with. The memoised
/// [`constraint_glyph_hit`](Self::constraint_glyph_hit) — both feed the /// [`cached_glyph_placements`](Self::cached_glyph_placements) maps these
/// same `(anchor, outward, label)` triples into /// same `(anchor, outward, label)` triples through
/// `crate::ui::overlay::constraint_glyph_box`/`constraint_glyph_offsets`, /// [`constraint_glyph_box`](crate::scene::parametric_constraints::constraint_glyph_box)/
/// [`constraint_glyph_offsets`](crate::scene::parametric_constraints::constraint_glyph_offsets),
/// so hit-testing can never drift from what's actually drawn. /// so hit-testing can never drift from what's actually drawn.
pub fn constraint_glyph_placements_screen( pub fn constraint_glyph_placements_screen(
&self, &self,
@@ -2503,10 +2716,9 @@ impl super::Scene {
} }
/// Hit-tests screen point `p` (same coordinate space as `p_full` in the /// Hit-tests screen point `p` (same coordinate space as `p_full` in the
/// viewport click handler) against the glyph pills from /// viewport click handler) against the memoised [`cached_glyph_placements`](Self::cached_glyph_placements)
/// [`constraint_glyph_placements_screen`](Self::constraint_glyph_placements_screen), /// entries, so a click only registers where the pill is actually drawn —
/// via `crate::ui::overlay::constraint_glyph_hit_test`'s shared layout /// with no placement recompute on the mouse-move/click hot path.
/// math, so a click only registers where the pill is actually drawn.
pub fn constraint_glyph_hit( pub fn constraint_glyph_hit(
&self, &self,
scope: ParametricScope, scope: ParametricScope,
@@ -2516,6 +2728,111 @@ impl super::Scene {
bar_mode: i16, bar_mode: i16,
p: iced::Point, p: iced::Point,
) -> Option<ConstraintId> { ) -> Option<ConstraintId> {
let entries = self.cached_glyph_placements(scope, vp_size, show_values, display_mode, bar_mode);
let index = glyph_hit_test_entries(&entries, p)?;
Some(entries[index].id)
}
/// Order-independent hash of the visibility sets that feed
/// [`constraint_glyph_placements_screen`](Self::constraint_glyph_placements_screen).
/// The dynamic pills filter `entity_temporarily_hidden` on EVERY display
/// mode, so `preview_hidden`, `command_preview_hidden` and
/// `hidden_dynamic_dimensions` are hashed UNCONDITIONALLY; `selected` and
/// the hidden/shown override sets only affect the `should_display` filter
/// when `display_mode & 2 != 0`, so they stay conditional (and the key
/// stays shared across selection changes in the other modes).
pub(crate) fn glyph_selection_signature(&self, display_mode: i16) -> u64 {
fn item_hash(item: &impl Hash) -> u64 {
let mut hasher = rustc_hash::FxHasher::default();
item.hash(&mut hasher);
hasher.finish()
}
// Order-independent single pass: per-item hashes combine
// commutatively (wrapping add), so iteration order never matters;
// one FxHasher over (combined, count) folds in the total length, no
// alloc/sort on the hot hit path.
let mut combined: u64 = 0;
let mut count: u64 = 0;
// Always-read hide sets: the dynamic-dimension pills consult these
// whatever the display mode, so a stale signature here would serve a
// pill for a just-hidden dimension (or hide a just-shown one).
for item in self.preview_hidden.iter() {
combined = combined.wrapping_add(item_hash(item));
count += 1;
}
for item in self.command_preview_hidden.iter() {
combined = combined.wrapping_add(item_hash(item));
count += 1;
}
for item in self.hidden_dynamic_dimensions.iter() {
combined = combined.wrapping_add(item_hash(item));
count += 1;
}
if display_mode & 2 != 0 {
for item in self.selected.iter() {
combined = combined.wrapping_add(item_hash(item));
count += 1;
}
for item in self.hidden_parametric_constraints.iter() {
combined = combined.wrapping_add(item_hash(item));
count += 1;
}
for item in self.shown_parametric_constraints.iter() {
combined = combined.wrapping_add(item_hash(item));
count += 1;
}
}
let mut hasher = rustc_hash::FxHasher::default();
combined.hash(&mut hasher);
count.hash(&mut hasher);
hasher.finish()
}
/// Memoised [`constraint_glyph_placements_screen`](Self::constraint_glyph_placements_screen),
/// keyed by [`GlyphKey`]: the miss path calls the existing placements fn
/// VERBATIM (no algorithm change), maps each tuple to a [`GlyphEntry`]
/// with precomputed layout, and stores the `Arc<[GlyphEntry]>` in the
/// Scene-owned single-slot memo. NaN filtering is the placements fn's own and is
/// cached as-is. Camera/layout/selection state is read from `self`, so
/// the `(scope, vp_size, show_values, display_mode, bar_mode)` signature
/// stays identical to the stub's.
///
/// Single-slot rationale: within one frame, view + hit-test + dwell +
/// click all share the key (hits); across camera motion each frame misses
/// once by design; across edits the stale single entry just misses — no
/// leak possible (an unbounded map would orphan an entry per camera frame
/// and per edit, since `cam_gen` is in the key).
pub fn cached_glyph_placements(
&self,
scope: ParametricScope,
vp_size: (f32, f32),
show_values: bool,
display_mode: i16,
bar_mode: i16,
) -> std::sync::Arc<[GlyphEntry]> {
let key = GlyphKey {
scope,
c_epoch: self.constraints_epoch,
g_epoch: self.geometry_epoch,
cam_gen: self.camera_generation,
active_vp: self.active_viewport,
layout_is_model: self.current_layout == "Model",
vp_bits: (vp_size.0.to_bits(), vp_size.1.to_bits()),
show_values,
disp: display_mode,
bar: bar_mode,
sel_sig: self.glyph_selection_signature(display_mode),
// The dynamic pills anchor through `dynamic_dimension_lock_anchor`
// with this scale — a CANNOSCALE change moves them, so it is keyed
// (bitwise: NaN never occurs here, and distinct bit patterns must
// miss rather than alias).
annotation_scale: self.annotation_scale.to_bits(),
};
if let Some((cached_key, cached_arc)) = self.glyph_cache.borrow().as_ref() {
if *cached_key == key {
return std::sync::Arc::clone(cached_arc);
}
}
let placements = self.constraint_glyph_placements_screen( let placements = self.constraint_glyph_placements_screen(
scope, scope,
vp_size, vp_size,
@@ -2523,14 +2840,36 @@ impl super::Scene {
display_mode, display_mode,
bar_mode, bar_mode,
); );
let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements let offsets = constraint_glyph_offsets(
.iter() &placements
.map(|(_, point, direction, label, is_conflicting, _)| { .iter()
(*point, *direction, label.clone(), *is_conflicting) .map(|(_, point, direction, label, _, _)| (*point, *direction, label.as_str()))
}) .collect::<Vec<_>>(),
);
let entries: Vec<GlyphEntry> = placements
.into_iter()
.zip(offsets)
.map(
|((id, anchor, outward, label, conflicting, hover), tangent_dx)| {
let size = constraint_glyph_size(&label);
let (top_left, _) = constraint_glyph_box(anchor, outward, &label, tangent_dx);
GlyphEntry {
id,
anchor,
outward,
label: Arc::from(label),
conflicting,
hover: Arc::from(hover),
size,
tangent_dx,
top_left,
}
},
)
.collect(); .collect();
let index = crate::ui::overlay::constraint_glyph_hit_test(&glyphs, p)?; let arc: Arc<[GlyphEntry]> = Arc::from(entries);
Some(placements[index].0) *self.glyph_cache.borrow_mut() = Some((key, Arc::clone(&arc)));
arc
} }
/// Handle remapping lives in each command that duplicates /// Handle remapping lives in each command that duplicates
@@ -2598,6 +2937,7 @@ impl super::Scene {
} }
touched.sort(); touched.sort();
touched.dedup(); touched.dedup();
self.bump_constraints_epoch();
let changes: Vec<(Handle, super::ChangeKind)> = touched let changes: Vec<(Handle, super::ChangeKind)> = touched
.into_iter() .into_iter()
.map(|h| (h, super::ChangeKind::Modified)) .map(|h| (h, super::ChangeKind::Modified))
@@ -2897,6 +3237,135 @@ mod tests {
); );
} }
#[test]
fn glyph_hit_test_entries_matches_precomputed_boxes() {
let mut scene = super::super::Scene::new();
let scope = ParametricScope::ModelSpace;
let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
Vector3::new(-1.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
),
));
scene.selection.borrow_mut().vp_size = (800.0, 600.0);
let id = scene.parametric_constraint_set_mut(scope).add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(line)],
None,
);
scene.note_parametric_constraint_applied(scope, id, 3);
let vp = (800.0_f32, 600.0_f32);
let entries = scene.cached_glyph_placements(scope, vp, true, 3, 4095);
assert!(!entries.is_empty(), "fixture must yield glyphs");
// A click at the centre of the precomputed box must hit that entry,
// using the cached layout only (no offset recompute).
let centre = iced::Point::new(
entries[0].top_left.x + entries[0].size.width * 0.5,
entries[0].top_left.y + entries[0].size.height * 0.5,
);
assert_eq!(super::glyph_hit_test_entries(&entries, centre), Some(0));
// Far away from every box must miss.
assert_eq!(
super::glyph_hit_test_entries(&entries, iced::Point::new(-5000.0, -5000.0)),
None
);
// `constraint_glyph_hit` must agree with the cached entries (it reuses
// the cache rather than recomputing placements).
assert_eq!(
scene.constraint_glyph_hit(scope, vp, true, 3, 4095, centre),
Some(entries[0].id)
);
}
#[test]
fn cached_glyph_placements_hit_returns_same_arc() {
let mut scene = super::super::Scene::new();
let scope = ParametricScope::ModelSpace;
let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
Vector3::new(-1.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
),
));
scene.selection.borrow_mut().vp_size = (800.0, 600.0);
let id = scene.parametric_constraint_set_mut(scope).add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(line)],
None,
);
scene.note_parametric_constraint_applied(scope, id, 3);
let vp = (800.0_f32, 600.0_f32);
let first = scene.cached_glyph_placements(scope, vp, true, 3, 4095);
let second = scene.cached_glyph_placements(scope, vp, true, 3, 4095);
assert!(
!first.is_empty(),
"fixture must yield at least one glyph placement"
);
assert!(
std::sync::Arc::ptr_eq(&first, &second),
"identical key inputs must return the same cached Arc"
);
}
#[test]
fn cached_glyph_placements_invalidated_by_metadata_edit() {
let mut scene = super::super::Scene::new();
let scope = ParametricScope::ModelSpace;
let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
Vector3::new(-1.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
),
));
scene.selection.borrow_mut().vp_size = (800.0, 600.0);
let id = scene.parametric_constraint_set_mut(scope).add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(line)],
None,
);
scene.note_parametric_constraint_applied(scope, id, 3);
let vp = (800.0_f32, 600.0_f32);
let before = scene.cached_glyph_placements(scope, vp, true, 3, 4095);
assert!(
!before.is_empty(),
"fixture must yield at least one glyph placement"
);
// NOTE: no production set_enabled/toggle_enabled method exists (grep:
// `enabled` is only written in `add()`, DWG import, and tests), so the
// metadata edit goes through the REAL production Scene-level toggle
// used by draw/update paths — hiding the geometric constraint — which
// Task 2 must wire to an epoch bump. Not a direct field write.
scene.set_parametric_constraint_visibility(scope, None, false, false);
let after = scene.cached_glyph_placements(scope, vp, true, 3, 4095);
assert!(
!std::sync::Arc::ptr_eq(&before, &after),
"metadata edit must invalidate the cache (new Arc)"
);
assert!(
after.is_empty(),
"output must reflect the edit: hidden constraint yields no glyphs"
);
// A selection change must also miss when `display_mode & 2 != 0`
// (sel_sig is part of the key): restore, warm, then reselect.
scene.set_parametric_constraint_visibility(scope, None, false, true);
let restored = scene.cached_glyph_placements(scope, vp, true, 3, 4095);
assert!(
!restored.is_empty(),
"restored constraint must yield glyphs again"
);
let reselected_warm = scene.cached_glyph_placements(scope, vp, true, 3, 4095);
assert!(
std::sync::Arc::ptr_eq(&restored, &reselected_warm),
"identical key inputs must return the same cached Arc"
);
scene.selected.insert(line);
let reselected = scene.cached_glyph_placements(scope, vp, true, 3, 4095);
assert!(
!std::sync::Arc::ptr_eq(&reselected_warm, &reselected),
"selection change must invalidate the cache (new Arc)"
);
}
#[test] #[test]
fn scope_owner_handle_resolves_block_directly() { fn scope_owner_handle_resolves_block_directly() {
let block_handle = h(42); let block_handle = h(42);
+12 -2
View File
@@ -3772,6 +3772,9 @@ impl Scene {
let before = self.parametric_constraints[i].clone(); let before = self.parametric_constraints[i].clone();
self.record_undo_parametric_constraints_before(scope, before); self.record_undo_parametric_constraints_before(scope, before);
self.parametric_constraints[i].remove_all_touching(*handle); self.parametric_constraints[i].remove_all_touching(*handle);
// Erasing an entity deletes its constraints with it — the
// glyph set changed.
self.bump_constraints_epoch();
} }
} }
} }
@@ -3824,10 +3827,17 @@ impl Scene {
}; };
if initial_fixed_refs.is_empty() { if initial_fixed_refs.is_empty() {
self.parametric_constraints[i].dof = Some(dof); self.parametric_constraints[i].dof = Some(dof);
self.parametric_constraints[i].conflicts = conflicts; if self.parametric_constraints[i].conflicts != conflicts {
self.parametric_constraints[i].conflicts = conflicts;
// Conflict badges render on the glyphs themselves.
self.bump_constraints_epoch();
}
} else { } else {
self.parametric_constraints[i].dof = None; self.parametric_constraints[i].dof = None;
self.parametric_constraints[i].conflicts.clear(); if !self.parametric_constraints[i].conflicts.is_empty() {
self.parametric_constraints[i].conflicts.clear();
self.bump_constraints_epoch();
}
} }
// A dimensional constraint set to zero means the collapse. // A dimensional constraint set to zero means the collapse.
let zero_collapse: HashSet<Handle> = { let zero_collapse: HashSet<Handle> = {
+89 -191
View File
@@ -1,7 +1,6 @@
//! Viewport overlay widgets. //! Viewport overlay widgets.
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap;
use glam::{Mat4, Vec3}; use glam::{Mat4, Vec3};
use iced::mouse; use iced::mouse;
@@ -22,51 +21,18 @@ pub const CROSSHAIR_ARM: f32 = 60.0;
const DEFAULT_CURSOR_SIZE: i32 = 5; const DEFAULT_CURSOR_SIZE: i32 = 5;
const DEFAULT_PICK_BOX: i32 = 3; const DEFAULT_PICK_BOX: i32 = 3;
const DEFAULT_PICK_APERTURE: f32 = 8.0; const DEFAULT_PICK_APERTURE: f32 = 8.0;
const CONSTRAINT_GLYPH_SIZE: f32 = 14.0;
const CONSTRAINT_GLYPH_PAD_X: f32 = 7.0;
const CONSTRAINT_GLYPH_PAD_Y: f32 = 4.0;
const CONSTRAINT_GLYPH_GAP: f32 = 6.0;
const CONSTRAINT_GLYPH_ROW_GAP: f32 = 4.0;
const CONSTRAINT_HOVER_MARKER_RADIUS: f32 = 7.0; const CONSTRAINT_HOVER_MARKER_RADIUS: f32 = 7.0;
const COINCIDENT_GLYPH_SIZE: f32 = 9.0;
fn is_compact_coincident_glyph(label: &str) -> bool { use crate::scene::parametric_constraints::{
matches!(label, "≡" | "∈") glyph_hit_test_entries, glyph_is_compact, glyph_is_fixed, glyph_is_vertical,
} GlyphEntry, GLYPH_SIZE,
};
/// Fixed's two padlock labels (`parametric_constraints::fixed_glyph_label`).
fn is_fixed_glyph(label: &str) -> bool {
label == "F" || label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH
}
/// The lock mark a dynamic dimension carries (`DYNAMIC_DIMENSION_GLYPH`). /// The lock mark a dynamic dimension carries (`DYNAMIC_DIMENSION_GLYPH`).
fn is_dynamic_dimension_glyph(label: &str) -> bool { fn is_dynamic_dimension_glyph(label: &str) -> bool {
label == crate::scene::parametric_constraints::DYNAMIC_DIMENSION_GLYPH label == crate::scene::parametric_constraints::DYNAMIC_DIMENSION_GLYPH
} }
/// Vertical's two axis-mark labels (`parametric_constraints::vertical_glyph_label`).
fn is_vertical_glyph(label: &str) -> bool {
label == "│" || label == crate::scene::parametric_constraints::VERTICAL_POINTS_GLYPH
}
fn constraint_glyph_size(label: &str) -> Size {
if is_compact_coincident_glyph(label) {
return Size::new(COINCIDENT_GLYPH_SIZE, COINCIDENT_GLYPH_SIZE);
}
if label == "G²"
|| is_fixed_glyph(label)
|| is_vertical_glyph(label)
|| is_dynamic_dimension_glyph(label)
{
let side = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0;
return Size::new(side, side);
}
let w = label.chars().count() as f32 * CONSTRAINT_GLYPH_SIZE * 0.62
+ CONSTRAINT_GLYPH_PAD_X * 2.0;
let h = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0;
Size::new(w, h)
}
fn draw_smooth_constraint_glyph(frame: &mut canvas::Frame, center: Point, color: Color) { fn draw_smooth_constraint_glyph(frame: &mut canvas::Frame, center: Point, color: Color) {
let curve = canvas::Path::new(|builder| { let curve = canvas::Path::new(|builder| {
for step in 0..=16 { for step in 0..=16 {
@@ -209,96 +175,6 @@ fn draw_vertical_constraint_glyph(
} }
} }
fn constraint_glyph_box(
anchor: Point,
outward: [f32; 2],
label: &str,
tangent_offset: f32,
) -> (Point, Size) {
let size = constraint_glyph_size(label);
let gap = if is_compact_coincident_glyph(label) {
1.0
} else {
CONSTRAINT_GLYPH_GAP
};
let distance = outward[0].abs() * size.width * 0.5
+ outward[1].abs() * size.height * 0.5
+ gap;
let tangent = [-outward[1], outward[0]];
(
Point::new(
anchor.x + outward[0] * distance + tangent[0] * tangent_offset
- size.width * 0.5,
anchor.y + outward[1] * distance + tangent[1] * tangent_offset
- size.height * 0.5,
),
size,
)
}
fn constraint_glyph_offsets(glyphs: &[(Point, [f32; 2], String, bool)]) -> Vec<f32> {
let mut groups: HashMap<[u32; 4], Vec<usize>> = HashMap::new();
for (index, (anchor, outward, _, _)) in glyphs.iter().enumerate() {
groups
.entry([
anchor.x.to_bits(),
anchor.y.to_bits(),
outward[0].to_bits(),
outward[1].to_bits(),
])
.or_default()
.push(index);
}
let mut offsets = vec![0.0; glyphs.len()];
for indices in groups.values().filter(|indices| indices.len() > 1) {
let half_extents: Vec<f32> = indices
.iter()
.map(|index| {
let (_, outward, label, _) = &glyphs[*index];
let size = constraint_glyph_size(label);
let tangent = [-outward[1], outward[0]];
tangent[0].abs() * size.width * 0.5
+ tangent[1].abs() * size.height * 0.5
})
.collect();
let total = half_extents.iter().sum::<f32>() * 2.0
+ CONSTRAINT_GLYPH_ROW_GAP * (indices.len() - 1) as f32;
let mut cursor = -total * 0.5;
for (index, half_extent) in indices.iter().zip(half_extents) {
offsets[*index] = cursor + half_extent;
cursor += half_extent * 2.0 + CONSTRAINT_GLYPH_ROW_GAP;
}
}
offsets
}
/// Hit-tests screen point `p` against the same glyph-pill layout `draw`
/// renders — reusing `constraint_glyph_offsets`/`constraint_glyph_box` so
/// the clickable area can never drift from what's actually drawn, including
/// the tangential fan-out applied when several glyphs share one anchor.
/// Returns the index into `glyphs` of the topmost (last-drawn) match.
/// `Scene::constraint_glyph_hit` maps the index back to a constraint id.
pub(crate) fn constraint_glyph_hit_test(
glyphs: &[(Point, [f32; 2], String, bool)],
p: Point,
) -> Option<usize> {
let offsets = constraint_glyph_offsets(glyphs);
glyphs
.iter()
.zip(offsets)
.enumerate()
.rev()
.find_map(|(index, ((anchor, outward, label, _), tangent_offset))| {
let (top_left, size) = constraint_glyph_box(*anchor, *outward, label, tangent_offset);
let within = p.x >= top_left.x
&& p.x <= top_left.x + size.width
&& p.y >= top_left.y
&& p.y <= top_left.y + size.height;
within.then_some(index)
})
}
/// Convert CURSORSIZE to a screen-space arm length while keeping the original /// 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. /// 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 { pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 {
@@ -978,7 +854,11 @@ pub fn selection_overlay<'a>(
crosshair_bg: [f32; 4], crosshair_bg: [f32; 4],
crosshair: CrosshairOptions, crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions, selection_visual: SelectionVisualOptions,
constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec<Point>)>, constraint_glyphs: Arc<[GlyphEntry]>,
// Per-glyph selection flags, parallel to `constraint_glyphs`, applied
// post-hoc by the view layer — deliberately outside the memoised entries
// so selection changes never invalidate the placement cache.
constraint_glyph_selected: Arc<[bool]>,
constraint_glyph_tooltip: Option<String>, constraint_glyph_tooltip: Option<String>,
constraint_cursor_badge: Option<String>, constraint_cursor_badge: Option<String>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@@ -1005,6 +885,7 @@ pub fn selection_overlay<'a>(
crosshair, crosshair,
selection_visual, selection_visual,
constraint_glyphs, constraint_glyphs,
constraint_glyph_selected,
constraint_glyph_tooltip, constraint_glyph_tooltip,
constraint_cursor_badge, constraint_cursor_badge,
}) })
@@ -1066,10 +947,14 @@ struct SelectionCanvas {
crosshair_bg: [f32; 4], crosshair_bg: [f32; 4],
crosshair: CrosshairOptions, crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions, selection_visual: SelectionVisualOptions,
/// Constraint glyph anchor, outward screen direction, label, conflict /// Memoised constraint-glyph entries (precomputed anchor/outward/label +
/// state, whether the pill itself is the current click-to-select target, /// layout) shared with the hit-test and tooltip paths — draw reads
/// and the points to mark while the pill is hovered. /// `top_left`/`size` directly and never recomputes offsets or clones
constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec<Point>)>, /// labels.
constraint_glyphs: Arc<[GlyphEntry]>,
/// Per-glyph selection flags, parallel to `constraint_glyphs`, applied
/// post-hoc by the view layer (outside the cache).
constraint_glyph_selected: Arc<[bool]>,
/// Localized kind name made visible after the app-level hover dwell. /// Localized kind name made visible after the app-level hover dwell.
constraint_glyph_tooltip: Option<String>, constraint_glyph_tooltip: Option<String>,
/// Symbol shown beside the cursor while it targets an entity that already /// Symbol shown beside the cursor while it targets an entity that already
@@ -1257,14 +1142,7 @@ impl canvas::Program<Message> for SelectionCanvas {
} }
} }
if let Some(pos) = cursor.position_in(bounds) { if let Some(pos) = cursor.position_in(bounds) {
let glyphs: Vec<_> = self if glyph_hit_test_entries(&self.constraint_glyphs, pos).is_some() {
.constraint_glyphs
.iter()
.map(|(anchor, outward, label, conflict, _, _)| {
(*anchor, *outward, label.clone(), *conflict)
})
.collect();
if constraint_glyph_hit_test(&glyphs, pos).is_some() {
return mouse::Interaction::Pointer; return mouse::Interaction::Pointer;
} }
} }
@@ -2066,22 +1944,13 @@ impl canvas::Program<Message> for SelectionCanvas {
frame.stroke(&b1, stroke.clone()); frame.stroke(&b1, stroke.clone());
frame.stroke(&b2, stroke); frame.stroke(&b2, stroke);
} }
// Hit testing shares this layout math with the scene projection. // Draw + hover + tooltip all read the memoised entries' precomputed
// `top_left`/`size` (one shared `hovered` lookup below) — no offset
// recompute, no label clones on this path.
if !self.constraint_glyphs.is_empty() { if !self.constraint_glyphs.is_empty() {
// `constraint_glyph_offsets` only needs the anchor/outward/label/
// conflict quadruple it was written against; project away the
// trailing display fields rather than widen its signature.
let glyphs_for_offsets: Vec<(Point, [f32; 2], String, bool)> = self
.constraint_glyphs
.iter()
.map(|(anchor, outward, label, is_conflicting, _, _)| {
(*anchor, *outward, label.clone(), *is_conflicting)
})
.collect();
let offsets = constraint_glyph_offsets(&glyphs_for_offsets);
let hovered = cursor let hovered = cursor
.position_in(bounds) .position_in(bounds)
.and_then(|point| constraint_glyph_hit_test(&glyphs_for_offsets, point)); .and_then(|point| glyph_hit_test_entries(&self.constraint_glyphs, point));
let normal_bg = Color::from_rgb8(103, 109, 118); let normal_bg = Color::from_rgb8(103, 109, 118);
let normal_fg = Color::WHITE; let normal_fg = Color::WHITE;
// A redundant or conflicting constraint gets the danger palette // A redundant or conflicting constraint gets the danger palette
@@ -2092,22 +1961,24 @@ impl canvas::Program<Message> for SelectionCanvas {
let conflict_fg = theme.palette().danger.base.text; let conflict_fg = theme.palette().danger.base.text;
let selected_ring = theme.palette().primary.strong.color; let selected_ring = theme.palette().primary.strong.color;
let coincident_bg = Color::from_rgb8(35, 145, 230); let coincident_bg = Color::from_rgb8(35, 145, 230);
for ((anchor, outward, label, is_conflicting, is_selected, _), tangent_offset) in for (entry, is_selected) in self
self.constraint_glyphs.iter().zip(offsets.iter().copied()) .constraint_glyphs
.iter()
.zip(self.constraint_glyph_selected.iter())
{ {
if !anchor.x.is_finite() || !anchor.y.is_finite() { let label: &str = &entry.label;
if !entry.anchor.x.is_finite() || !entry.anchor.y.is_finite() {
continue; continue;
} }
let compact_coincident = is_compact_coincident_glyph(label); let compact_coincident = glyph_is_compact(label);
let (bg, fg) = if *is_conflicting { let (bg, fg) = if entry.conflicting {
(conflict_bg, conflict_fg) (conflict_bg, conflict_fg)
} else if compact_coincident { } else if compact_coincident {
(coincident_bg, normal_fg) (coincident_bg, normal_fg)
} else { } else {
(normal_bg, normal_fg) (normal_bg, normal_fg)
}; };
let (top_left, size) = let (top_left, size) = (entry.top_left, entry.size);
constraint_glyph_box(*anchor, *outward, label, tangent_offset);
let pill = canvas::Path::rounded_rectangle( let pill = canvas::Path::rounded_rectangle(
top_left, top_left,
size, size,
@@ -2141,7 +2012,7 @@ impl canvas::Program<Message> for SelectionCanvas {
draw_concentric_constraint_glyph(&mut frame, glyph_center, fg); draw_concentric_constraint_glyph(&mut frame, glyph_center, fg);
} else if is_dynamic_dimension_glyph(label) { } else if is_dynamic_dimension_glyph(label) {
draw_fixed_constraint_glyph(&mut frame, glyph_center, fg, bg, false); draw_fixed_constraint_glyph(&mut frame, glyph_center, fg, bg, false);
} else if is_fixed_glyph(label) { } else if glyph_is_fixed(label) {
let point = let point =
label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH; label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH;
// The object-mode lock reads red, the point-mode one white. // The object-mode lock reads red, the point-mode one white.
@@ -2151,7 +2022,7 @@ impl canvas::Program<Message> for SelectionCanvas {
Color::from_rgb8(214, 76, 76) Color::from_rgb8(214, 76, 76)
}; };
draw_fixed_constraint_glyph(&mut frame, glyph_center, lock, bg, point); draw_fixed_constraint_glyph(&mut frame, glyph_center, lock, bg, point);
} else if is_vertical_glyph(label) { } else if glyph_is_vertical(label) {
draw_vertical_constraint_glyph( draw_vertical_constraint_glyph(
&mut frame, &mut frame,
glyph_center, glyph_center,
@@ -2166,10 +2037,10 @@ impl canvas::Program<Message> for SelectionCanvas {
fg fg
}; };
frame.fill_text(canvas::Text { frame.fill_text(canvas::Text {
content: label.clone(), content: label.to_string(),
position: glyph_center, position: glyph_center,
color, color,
size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), size: iced::Pixels(GLYPH_SIZE),
align_x: iced::alignment::Horizontal::Center.into(), align_x: iced::alignment::Horizontal::Center.into(),
align_y: iced::alignment::Vertical::Center, align_y: iced::alignment::Vertical::Center,
shaping: iced::advanced::text::Shaping::Advanced, shaping: iced::advanced::text::Shaping::Advanced,
@@ -2181,7 +2052,7 @@ impl canvas::Program<Message> for SelectionCanvas {
if let Some(index) = hovered { if let Some(index) = hovered {
let red = Color::from_rgb(1.0, 0.0, 0.0); let red = Color::from_rgb(1.0, 0.0, 0.0);
let stroke = canvas::Stroke::default().with_color(red).with_width(1.5); let stroke = canvas::Stroke::default().with_color(red).with_width(1.5);
for point in &self.constraint_glyphs[index].5 { for point in self.constraint_glyphs[index].hover.iter() {
let first = canvas::Path::line( let first = canvas::Path::line(
Point::new( Point::new(
point.x - CONSTRAINT_HOVER_MARKER_RADIUS, point.x - CONSTRAINT_HOVER_MARKER_RADIUS,
@@ -2206,11 +2077,9 @@ impl canvas::Program<Message> for SelectionCanvas {
frame.stroke(&second, stroke.clone()); frame.stroke(&second, stroke.clone());
} }
if let Some(label) = &self.constraint_glyph_tooltip { if let Some(label) = &self.constraint_glyph_tooltip {
let (glyph_top_left, glyph_size) = constraint_glyph_box( let (glyph_top_left, glyph_size) = (
self.constraint_glyphs[index].0, self.constraint_glyphs[index].top_left,
self.constraint_glyphs[index].1, self.constraint_glyphs[index].size,
&self.constraint_glyphs[index].2,
offsets[index],
); );
let width = (label.chars().count() as f32 * 7.0 + 14.0).max(54.0); let width = (label.chars().count() as f32 * 7.0 + 14.0).max(54.0);
let height = 24.0; let height = 24.0;
@@ -2229,8 +2098,8 @@ impl canvas::Program<Message> for SelectionCanvas {
.with_color(theme.palette().background.strong.text) .with_color(theme.palette().background.strong.text)
.with_width(1.0), .with_width(1.0),
); );
frame.fill_text(canvas::Text { frame.fill_text(canvas::Text {
content: label.clone(), content: label.to_string(),
position: Point::new(left + width * 0.5, top + height * 0.5), position: Point::new(left + width * 0.5, top + height * 0.5),
color: theme.palette().background.strong.text, color: theme.palette().background.strong.text,
size: iced::Pixels(12.0), size: iced::Pixels(12.0),
@@ -3912,41 +3781,70 @@ mod clip_tests {
#[cfg(test)] #[cfg(test)]
mod constraint_glyph_tests { mod constraint_glyph_tests {
use super::*; use super::*;
use crate::scene::parametric_constraints::{
constraint_glyph_box, constraint_glyph_offsets,
};
use std::sync::Arc;
/// Build cache-shaped entries the same way
/// `Scene::cached_glyph_placements` does: one offsets pass, then size +
/// box per glyph. Keeps this test on the single source of truth instead
/// of a local layout copy.
fn entries_for(glyphs: &[(Point, [f32; 2], &str)]) -> Arc<[GlyphEntry]> {
let offsets = constraint_glyph_offsets(glyphs);
Arc::from(
glyphs
.iter()
.zip(offsets)
.enumerate()
.map(|(i, ((anchor, outward, label), tangent_dx))| {
let size =
crate::scene::parametric_constraints::constraint_glyph_size(label);
let (top_left, _) =
constraint_glyph_box(*anchor, *outward, label, tangent_dx);
GlyphEntry {
id: i as u32,
anchor: *anchor,
outward: *outward,
label: Arc::from(*label),
conflicting: false,
hover: Arc::from([]),
size,
tangent_dx,
top_left,
}
})
.collect::<Vec<_>>(),
)
}
#[test] #[test]
fn enlarged_glyph_box_stays_clear_of_its_geometry_anchor() { fn enlarged_glyph_box_stays_clear_of_its_geometry_anchor() {
let anchor = Point::new(100.0, 80.0); let anchor = Point::new(100.0, 80.0);
let (right, size) = constraint_glyph_box(anchor, [1.0, 0.0], "⊥", 0.0); let (right, size) = constraint_glyph_box(anchor, [1.0, 0.0], "⊥", 0.0);
assert_eq!(size.height, 22.0); assert_eq!(size.height, 22.0);
assert!((right.x - anchor.x - CONSTRAINT_GLYPH_GAP).abs() < 1e-6); assert!((right.x - anchor.x - 6.0).abs() < 1e-6);
let (above, size) = constraint_glyph_box(anchor, [0.0, -1.0], "↔ 25.00", 0.0); let (above, size) = constraint_glyph_box(anchor, [0.0, -1.0], "↔ 25.00", 0.0);
assert!((anchor.y - (above.y + size.height) - CONSTRAINT_GLYPH_GAP).abs() < 1e-6); assert!((anchor.y - (above.y + size.height) - 6.0).abs() < 1e-6);
} }
#[test] #[test]
fn coincident_glyphs_are_arranged_side_by_side() { fn coincident_glyphs_are_arranged_side_by_side() {
let anchor = Point::new(100.0, 80.0); let anchor = Point::new(100.0, 80.0);
let glyphs = vec![ let entries = entries_for(&[
(anchor, [0.0, -1.0], "—".to_string(), false), (anchor, [0.0, -1.0], "—"),
(anchor, [0.0, -1.0], "∥".to_string(), false), (anchor, [0.0, -1.0], "∥"),
]; ]);
let offsets = constraint_glyph_offsets(&glyphs); let (left, left_size) = (entries[0].top_left, entries[0].size);
let (left, left_size) = let (right, right_size) = (entries[1].top_left, entries[1].size);
constraint_glyph_box(anchor, glyphs[0].1, &glyphs[0].2, offsets[0]);
let (right, right_size) =
constraint_glyph_box(anchor, glyphs[1].1, &glyphs[1].2, offsets[1]);
assert!((left.y - right.y).abs() < 1e-6); assert!((left.y - right.y).abs() < 1e-6);
assert!( assert!((right.x - (left.x + left_size.width) - 4.0).abs() < 1e-4);
(right.x - (left.x + left_size.width) - CONSTRAINT_GLYPH_ROW_GAP).abs() < 1e-4 assert!((anchor.y - (left.y + right_size.height) - 6.0).abs() < 1e-6);
);
assert!(
(anchor.y - (left.y + right_size.height) - CONSTRAINT_GLYPH_GAP).abs() < 1e-6
);
let click = Point::new(left.x + left_size.width * 0.5, left.y + left_size.height * 0.5); let click = Point::new(left.x + left_size.width * 0.5, left.y + left_size.height * 0.5);
assert_eq!(constraint_glyph_hit_test(&glyphs, click), Some(0)); assert_eq!(glyph_hit_test_entries(&entries, click), Some(0));
} }
} }