M4: Delete closes a region's loop through the segment that joins its ends (#1951)

A Pen bridge between two degree-2 points leaves the region alone, and Delete on the point between them then rotated the loop open with the bridge in no loop, so even an unrounded fill ran straight across a curved bridge. reopen_loops now pushes the one segment in no loop that joins an open chain's ends onto the loop's end (winding kept, loop by loop), as connect does. This also covers removing segments and the eraser.

closing_segment is public, derives its own start point, walks strictly (a loop out of walking order is left alone) and gives up when two segments qualify. The render fix stays as the fallback for files already saved in that state.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Fable 5.1 reviewed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor BrownandClaude Sonnet 5.5 committed 2026-11-11 02:16:29 -05:00
1 parent 8e6c342f13
commit a75d8bf04f
5 files changed
+104 -19

No files matched your search

+13 -2
View File
@@ -908,12 +908,23 @@ fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
/// the stroke's outline, cut by an arc at the chain's ends as at every other point.
#[test]
fn a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment() {
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![3, 1, 2, 0]);
}
/// The same network with the joining segment listed first, and with the loop walked the other way.
#[test]
fn an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards() {
open_loop_follows_the_segment_joining_its_ends([(3, 4), (0, 3), (1, 2), (2, 0), (4, 1)], vec![4, 2, 3, 1]);
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![0, 2, 1, 3]);
}
fn open_loop_follows_the_segment_joining_its_ends(segments: [(usize, usize); 5], open_loop: Vec<usize>) {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let corners = [Point::new(0.0, 0.0), Point::new(525.0, 525.0), Point::new(0.0, 525.0), Point::new(333.0, 0.0), Point::new(525.0, 183.0)];
let mut net = VectorNetwork::from_points(&corners, false);
net.segments = [(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)].iter().map(|&(a, b)| Segment::line(a, b)).collect();
net.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, paint_space: None });
net.segments = segments.iter().map(|&(a, b)| Segment::line(a, b)).collect();
net.regions.push(Region { loops: vec![open_loop], winding: WindingRule::NonZero, fills: None, paint_space: None });
for v in &mut net.vertices {
v.radius = 125.0;
}
+33 -15
View File
@@ -472,15 +472,13 @@ impl VectorNetwork {
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
// rounded corners isn't quadratic.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
(Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b,
(Some(first), _) => first.a,
_ => 0,
};
if lp.is_empty() {
continue;
}
let start_v = self.loop_start(lp).unwrap_or(0);
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()) + 1);
let mut cur = start_v;
let closing = self.closing_segment(lp, start_v);
let closing = self.closing_segment(lp);
for &x in lp.iter().chain(closing.iter()) {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
@@ -498,23 +496,43 @@ impl VectorNetwork {
Some(out)
}
/// The segment that joins the two ends of an open loop `lp` (walked from `start_v`) when it is
/// in no loop of its own: a corner cut off by a segment drawn before the corner point was
/// deleted leaves the loop open there, and the stroke follows that segment. Rounding walks it
/// as the loop's last segment, so the points at the loop's ends are cut by arcs as the stroke's
/// are (#1951).
fn closing_segment(&self, lp: &[usize], start_v: usize) -> Option<usize> {
/// The point loop `lp` is walked from: the end of its first segment that its second does not
/// share.
fn loop_start(&self, lp: &[usize]) -> Option<usize> {
let first = self.segments.get(*lp.first()?)?;
match lp.get(1).and_then(|&i| self.segments.get(i)) {
Some(second) if first.a == second.a || first.a == second.b => Some(first.b),
_ => Some(first.a),
}
}
/// The segment that joins the two ends of an open loop `lp` when it is in no loop of its own
/// (and the only such segment): a corner cut off by a segment drawn before the corner point was
/// deleted leaves the loop open there, and the stroke follows that segment. Only a loop that
/// walks in order as one continuous chain has one. Rounding walks it as the loop's last
/// segment, so the points at the loop's ends are cut by arcs as the stroke's are, and deleting
/// points or segments puts it into the loop (#1951).
pub fn closing_segment(&self, lp: &[usize]) -> Option<usize> {
let start_v = self.loop_start(lp)?;
let mut cur = start_v;
for &x in lp {
let s = self.segments.get(x)?;
cur = if s.a == cur { s.b } else { s.a };
cur = if s.a == cur {
s.b
} else if s.b == cur {
s.a
} else {
return None;
};
}
if cur == start_v {
return None;
}
let in_loop = |i: usize| self.regions.iter().flat_map(|r| r.loops.iter().flatten()).any(|&x| x == i);
let ends = |s: &Segment| (s.a == cur && s.b == start_v) || (s.b == cur && s.a == start_v);
self.segments.iter().position(ends).filter(|&i| !in_loop(i))
let mut joining = self.segments.iter().enumerate().filter(|(i, s)| ends(s) && !in_loop(*i)).map(|(i, _)| i);
let first = joining.next()?;
joining.next().is_none().then_some(first)
}
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
@@ -468,6 +468,22 @@ fn reopen_loops(net: &mut VectorNetwork) {
}
}
}
// An open chain whose two ends a segment in no loop already joins (a bridge drawn between two
// points before the point between them was deleted) closes through that segment, at the end so
// the loop's winding is kept, as `connect` closes it (#1951). One loop at a time, so two loops
// can't both take one segment.
let places: Vec<(usize, usize)> = net.regions.iter().enumerate().flat_map(|(ri, r)| (0..r.loops.len()).map(move |li| (ri, li))).collect();
for (ri, li) in places {
let Some(lp) = net.regions.get(ri).and_then(|r| r.loops.get(li)) else { continue };
if lp.len() < 2 {
continue;
}
if let Some(closing) = net.closing_segment(lp)
&& let Some(lp) = net.regions.get_mut(ri).and_then(|r| r.loops.get_mut(li))
{
lp.push(closing);
}
}
}
/// Cut a segment at `t` into two open ends at the same spot. Returns the two end vertices.
@@ -1107,6 +1123,24 @@ mod tests {
assert!(d.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
}
/// A curved bridge between two points of a ring, then the point between them deleted (Delete,
/// no healing): the region's loop closes through the bridge, so the fill follows its curve and
/// is as long as the stroke (#1951).
#[test]
fn deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::ParamCurveArclen;
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap();
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "the ring kept");
delete_vertices(&mut n, &[2], false);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5), "the loop holds the bridge: {:?}", n.regions);
let length = |p: &BezPath| p.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let (fill, stroke) = (length(&n.fill_path()), length(&n.stroke_path()));
assert!((fill - stroke).abs() < 1e-6, "fill {fill} and stroke {stroke} follow the same outline");
}
#[test]
fn lasso_polygon() {
let pts = [Point::new(5.0, 5.0), Point::new(50.0, 50.0), Point::new(15.0, 5.0)];
@@ -659,6 +659,27 @@ fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
fill_traces_stroke(&s, id, "drawn with the pen");
}
/// The reporter's sequence (#1951): a bridge drawn between the two new points while the corner is
/// still there (both have two segments, so the region keeps its ring), then the corner deleted with
/// Delete. The region's loop must close through the bridge, so the rounded fill traces the stroke.
#[test]
fn deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
let n = net(&s, id);
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
let n = net(&s, id);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "bridged, the ring kept: {:?}", n.regions);
let corner = vertex_at(&n, 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.edit.delete", json!({}));
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "bridged, then the corner deleted");
}
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
/// fill that keeps off every sharp point and traces the same outline as the stroke.
+3 -2
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap, also when the loop was left open at a segment drawn before the corner was deleted (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap, also when the loop was left open at a segment drawn before the corner was deleted, which Delete now closes through that segment (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite andLine truncated
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -246,7 +246,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede
closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and
length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and
merge (joining the two ends of a path broken open by a deleted point closes it again, so its
fill follows the new segment, and a rounded loop left open where a segment joins its ends is cut by arcs at them too, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder
fill follows the new segment, and a rounded loop left open where a segment joins its ends is cut by arcs at them too, and Delete closes such a loop through that segment, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder
(⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept),
variable width strokes (width profile presets with previews in the stroke settings, the Variable
width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete;
@@ -656,6 +656,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). |
| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). |
| 2026-11-11 | patch | M4, #1951 (reporter's file, v0.17.0): a corner cut off by a segment drawn first, the corner point then deleted with Delete, leaves the region's loop as the open chain of the other segments, the cutting segment (which joins the chain's two ends) in no loop; rounded, the loop's first point was never cut by an arc (rounding only adds arcs at points the loop arrives at) and the closing edge ran straight, so the fill and the selection outline missed corners the stroke rounded. `VectorNetwork::rounded_smoothed` now walks the segment joining an open loop's ends, when no loop holds it, as the loop's last segment (`closing_segment`). Evidence: `doc::tests::a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment` (fails on main: fill 1810.85 vs stroke 1818.58); #1991's tests unchanged. |
| 2026-11-11 | patch | M4, #1951: Delete (and removing segments, and the eraser) closes a region's loop through the segment that joins its ends: a bridge drawn between two points of a ring leaves the region alone (`connect` rightly keeps the ring), and deleting the point between them rotated the loop open, the bridge in no loop, so even an unrounded fill ran straight across a curved bridge. `reopen_loops` now pushes the one segment in no loop that joins an open chain's ends onto the loop's end, keeping its winding, as #1991's connect does; `closing_segment` (now public) walks strictly, ignores segments already in loops and gives up when two qualify. The render fix stays as the fallback for files saved in that state. Evidence: `vectoredit::tests::deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge` (fails before: the loop kept 4 of 5 segments), `ops::tests::deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop`, `doc::tests::an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards`. |
| 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. |
| 2026-11-02 | minor | M3, #1885: glass bends towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly, and a thin part (a capsule, a letter's stroke) bends across itself, checked side by side on a capsule 44 units tall. Rounded rectangles work it out in closed form. A shape too big to keep whole at the zoom bends along its edge normals instead, so no render works out a core over more pixels than a kept shape holds. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`, `glass_cut_tests::a_cut_through_the_pane_matches_the_whole_render`. Grade unchanged. |
| 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it, 0.6 as much on the opposite side, with a faint gradient across the curve. `splay` turns the bend from along the edge's normal towards the way away from the layer's centre and no longer widens the light, as in the reference tool. Checked side by side on synthetic files: a splay of 1 bends the backdrop towards the centre in both. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Grade unchanged. |