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

+484 -15
View File
@@ -2,6 +2,8 @@
use super::named_parameters::DrivingValue;
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
/// of it.
@@ -1579,6 +1581,170 @@ pub(crate) fn constraint_hover_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 {
/// Expand through enabled constraints. Whole-object translations follow
/// point connections; grip previews include curve relations as well.
@@ -1772,6 +1938,10 @@ impl super::Scene {
} else {
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(
@@ -1806,6 +1976,9 @@ impl super::Scene {
}
}
self.refresh_hidden_dynamic_dimensions();
if !ids.is_empty() {
self.bump_constraints_epoch();
}
ids.len()
}
@@ -1959,9 +2132,48 @@ impl super::Scene {
.map(|handle| (handle, super::ChangeKind::Modified))
.collect();
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);
}
/// 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.
pub fn inferred_parametric_constraints(
&self,
@@ -2298,10 +2510,11 @@ impl super::Scene {
/// `vp_size` is the full canvas size (as `SelectionState::vp_size`
/// reports it), matching what `viewport_edit_frame`/
/// `active_model_tile_bounds` expect. Mirrors the projection
/// `crate::app::view` builds its own render list with, and is reused by
/// [`constraint_glyph_hit`](Self::constraint_glyph_hit) — both feed the
/// same `(anchor, outward, label)` triples into
/// `crate::ui::overlay::constraint_glyph_box`/`constraint_glyph_offsets`,
/// `crate::app::view` builds its own render list with. The memoised
/// [`cached_glyph_placements`](Self::cached_glyph_placements) maps these
/// same `(anchor, outward, label)` triples through
/// [`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.
pub fn constraint_glyph_placements_screen(
&self,
@@ -2503,10 +2716,9 @@ impl super::Scene {
}
/// Hit-tests screen point `p` (same coordinate space as `p_full` in the
/// viewport click handler) against the glyph pills from
/// [`constraint_glyph_placements_screen`](Self::constraint_glyph_placements_screen),
/// via `crate::ui::overlay::constraint_glyph_hit_test`'s shared layout
/// math, so a click only registers where the pill is actually drawn.
/// viewport click handler) against the memoised [`cached_glyph_placements`](Self::cached_glyph_placements)
/// entries, so a click only registers where the pill is actually drawn —
/// with no placement recompute on the mouse-move/click hot path.
pub fn constraint_glyph_hit(
&self,
scope: ParametricScope,
@@ -2516,6 +2728,111 @@ impl super::Scene {
bar_mode: i16,
p: iced::Point,
) -> 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(
scope,
vp_size,
@@ -2523,14 +2840,36 @@ impl super::Scene {
display_mode,
bar_mode,
);
let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements
.iter()
.map(|(_, point, direction, label, is_conflicting, _)| {
(*point, *direction, label.clone(), *is_conflicting)
})
let offsets = constraint_glyph_offsets(
&placements
.iter()
.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();
let index = crate::ui::overlay::constraint_glyph_hit_test(&glyphs, p)?;
Some(placements[index].0)
let arc: Arc<[GlyphEntry]> = Arc::from(entries);
*self.glyph_cache.borrow_mut() = Some((key, Arc::clone(&arc)));
arc
}
/// Handle remapping lives in each command that duplicates
@@ -2598,6 +2937,7 @@ impl super::Scene {
}
touched.sort();
touched.dedup();
self.bump_constraints_epoch();
let changes: Vec<(Handle, super::ChangeKind)> = touched
.into_iter()
.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]
fn scope_owner_handle_resolves_block_directly() {
let block_handle = h(42);