mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
G1: Fix intermediate overflow in adjoining width adjustments
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Codex
parent
f2f1945973
commit
98511401a0
@@ -257,7 +257,22 @@ fn place(points: &mut Vec<(f64, f64, f64)>, mut pt: (f64, f64, f64), from_below:
|
||||
/// points at other positions either side change in proportion (by the same amount from zero).
|
||||
fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f64), new: (f64, f64, f64)) {
|
||||
let t = points[i].0;
|
||||
let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o }.max(0.0);
|
||||
let scale = |v: f64, o: f64, n: f64| {
|
||||
let width = if o > 1e-9 {
|
||||
let product = v * n;
|
||||
if product.is_finite() {
|
||||
// Keep the existing rounding, including small products that n/o would lose.
|
||||
product / o
|
||||
} else {
|
||||
// With finite nonnegative factors, an overflowing product has both factors
|
||||
// above one. Divide the larger first so this fallback cannot underflow.
|
||||
(v.max(n) / o) * v.min(n)
|
||||
}
|
||||
} else {
|
||||
v + n - o
|
||||
};
|
||||
width.max(0.0)
|
||||
};
|
||||
let before = (0..i).rev().find(|j| (points[*j].0 - t).abs() > SAME_T);
|
||||
let after = (i + 1..points.len()).find(|j| (points[*j].0 - t).abs() > SAME_T);
|
||||
for j in before.into_iter().chain(after) {
|
||||
@@ -293,8 +308,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = weighted_stroke(d, id)?;
|
||||
let before = st.profile.clone();
|
||||
let half = st.width / 2.0;
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
let pt = (t, left / half, right / half);
|
||||
if !half.is_finite() || half <= 0.0 || !pt.1.is_finite() || !pt.2.is_finite() {
|
||||
return Err(bad(C, "width factors must be finite"));
|
||||
}
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
pos = match point_index(p, &prof, C)? {
|
||||
Some(i) => {
|
||||
let old = prof.points[i];
|
||||
@@ -320,6 +338,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
i
|
||||
}
|
||||
};
|
||||
// Validate on edit_counted's copy: a failed adjoining adjustment must not change
|
||||
// the document, its dirty state or either side of its history.
|
||||
if prof.points.iter().any(|p| !p.1.is_finite() || !p.2.is_finite()) {
|
||||
return Err(bad(C, "width factors must be finite"));
|
||||
}
|
||||
st.profile = Some(prof);
|
||||
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
|
||||
})?;
|
||||
|
||||
@@ -230,3 +230,115 @@ fn new_width_point_adjust_adjoining_uses_sampled_widths() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_point_finite_extreme_adjoining_roundtrips_with_history() {
|
||||
// Ordinary scaling, finite factors whose product overflows, and a representable
|
||||
// normalization with a subnormal half weight. Exercise both adjoining callers.
|
||||
for (weight, factor) in [(10.0, 1.0), (1e-200, 1e200), (f64::MIN_POSITIVE, 1.0)] {
|
||||
for indexed in [false, true] {
|
||||
let (mut s, id) = line_session();
|
||||
s.execute("stroke.set", &json!({"ids": [id.0], "weight": weight})).unwrap();
|
||||
let mut profile = vec![(0.0, factor, factor), (1.0, factor, factor)];
|
||||
if indexed {
|
||||
profile.insert(1, (0.5, factor, factor));
|
||||
}
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": profile})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let index = indexed.then_some(1);
|
||||
let side = weight * factor;
|
||||
set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": side, "right": side / 4.0, "adjustAdjoining": true}));
|
||||
let pts = points(&s, id);
|
||||
assert_eq!(pts.len(), 3);
|
||||
for (i, &(t, left, right)) in pts.iter().enumerate() {
|
||||
assert_eq!(t, i as f64 / 2.0);
|
||||
assert!(left.is_finite() && right.is_finite(), "adjoining factors must stay finite: {pts:?}");
|
||||
assert!((left / factor - 2.0).abs() < 1e-14 && (right / factor - 0.5).abs() < 1e-14, "{pts:?}");
|
||||
}
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection, selection);
|
||||
assert_eq!((st.history.undo.len(), st.history.redo.len(), st.is_dirty()), (undo + 1, 0, true));
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Width Point");
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&after, false)).unwrap();
|
||||
assert_eq!(back, *after, "finite width edits must round-trip through the native format");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc, before);
|
||||
assert_eq!(st.selection, selection);
|
||||
assert_eq!((st.history.undo.len(), st.history.redo.len(), st.is_dirty()), (undo, 1, false));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc, after);
|
||||
assert_eq!(st.selection, selection);
|
||||
assert_eq!((st.history.undo.len(), st.history.redo.len(), st.is_dirty()), (undo + 1, 0, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_point_finite_scaling_preserves_small_results() {
|
||||
// Blindly changing v*n/o to v*(n/o) underflows n/o to zero in this case.
|
||||
for indexed in [false, true] {
|
||||
let (mut s, id) = line_session();
|
||||
s.execute("stroke.set", &json!({"ids": [id.0], "weight": 2})).unwrap();
|
||||
let mut profile = vec![(0.0, 1e200, 1e200), (1.0, 1e200, 1e200)];
|
||||
if indexed {
|
||||
profile.insert(1, (0.5, 1e200, 1e200));
|
||||
}
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": profile})).unwrap();
|
||||
set(&mut s, json!({"id": id.0, "index": indexed.then_some(1), "t": 0.5, "left": 1e-200, "right": 1e-200, "adjustAdjoining": true}));
|
||||
for (_, left, right) in points(&s, id) {
|
||||
assert!(left > 0.0 && right > 0.0, "representable small widths must not become zero");
|
||||
assert!((left / 1e-200 - 1.0).abs() < 1e-14 && (right / 1e-200 - 1.0).abs() < 1e-14);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_point_nonrepresentable_widths_reject_atomically() {
|
||||
let history = |s: &Session| {
|
||||
let h = &s.doc().unwrap().history;
|
||||
let entries = |es: &[HistoryEntry]| es.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone(), e.bytes)).collect::<Vec<_>>();
|
||||
(entries(&h.undo), entries(&h.redo), h.limit, h.byte_budget)
|
||||
};
|
||||
for (case, weight, ends, middle, left) in [
|
||||
("normalized overflow", 1e-310, 1.0, 1.0, 1.0),
|
||||
("half weight rounds to zero", f64::from_bits(1), 1.0, 1.0, 0.0),
|
||||
("adjoining proportional overflow", 2.0, 1e308, 1.0, 4.0),
|
||||
("adjoining additive overflow", 1e-308, 1e308, 0.0, 0.5),
|
||||
] {
|
||||
for indexed in [false, true] {
|
||||
let (mut s, id) = line_session();
|
||||
s.execute("stroke.set", &json!({"ids": [id.0], "weight": weight})).unwrap();
|
||||
// The first neighbour can change before the second overflows; neither may commit.
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 1, 1], [0.5, middle, 1], [1, ends, 1]]})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let redo_doc = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let before_history = history(&s);
|
||||
assert_eq!(s.doc().unwrap().history.redo.len(), 1);
|
||||
let result = s.execute(
|
||||
"stroke.widthPoint.set",
|
||||
&json!({"id": id.0, "index": indexed.then_some(1), "t": 0.5, "left": left, "right": 0, "adjustAdjoining": true}),
|
||||
);
|
||||
assert!(result.is_err(), "{case}, indexed={indexed}: accepted nonrepresentable width factors");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{case}");
|
||||
assert_eq!(s.doc().unwrap().selection, selection, "{case}");
|
||||
assert_eq!(s.doc().unwrap().revision, revision, "{case}");
|
||||
assert_eq!(history(&s), before_history, "{case}");
|
||||
assert!(!s.doc().unwrap().is_dirty(), "{case}");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, redo_doc, "{case}: pre-existing redo remains usable");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user