mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 11:51:40 +00:00
Canvas: selection outlines and anchors are reused between frames
The canvas flattened every selected object's outline and tessellated its anchors and handles again in each frame. It now keeps them per object as one mesh, valid for the object's node, layer color and selected anchors. An idle frame draws the mesh again. A pan moves kept outlines and handles and places the anchor squares again. A frame with a new zoom, rotation or display scale draws plain shapes and keeps nothing, and the next frame at that view builds the marks. Entries of objects that leave the selection or the document are dropped.
This commit is contained in:
@@ -80,6 +80,7 @@ impl VectorcraftApp {
|
||||
print_tiling: None,
|
||||
live_paint_gaps: None,
|
||||
hidden_chars: Default::default(),
|
||||
selection_marks: Default::default(),
|
||||
selection_box: None,
|
||||
selection_bounds: None,
|
||||
content_bounds: None,
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct CanvasCache {
|
||||
/// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame
|
||||
/// that does not draw them drops them ([`canvas::show`]).
|
||||
pub hidden_chars: canvas::HiddenChars,
|
||||
/// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that
|
||||
/// does not draw them drops them ([`canvas::show`]).
|
||||
pub selection_marks: canvas::SelectionMarks,
|
||||
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
|
||||
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
|
||||
/// [`VectorcraftApp::selection_bounds`] for (document uid, revision).
|
||||
|
||||
@@ -28,6 +28,7 @@ mod outline;
|
||||
mod overlays;
|
||||
mod panes;
|
||||
mod raster;
|
||||
mod selection_marks;
|
||||
mod task_bar;
|
||||
mod tool_overlays;
|
||||
mod view;
|
||||
@@ -65,6 +66,7 @@ pub use panes::Panes;
|
||||
pub(crate) use panes::{consolidate, pane, tile, tiled_area};
|
||||
use raster::{changed_pixels, pixel_region, upload};
|
||||
pub(crate) use raster::{color_image, color_image_owned};
|
||||
pub use selection_marks::SelectionMarks;
|
||||
use task_bar::task_bar;
|
||||
pub(crate) use task_bar::{has_colors, task_bar_rect};
|
||||
pub use tool_overlays::HiddenChars;
|
||||
@@ -193,6 +195,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// that does not draw the overlay drops them, for example with the option off, with the edges
|
||||
// hidden, on the Home screen or with no document open.
|
||||
let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars);
|
||||
// The selection's kept marks. Likewise, a frame that does not draw them drops them.
|
||||
let selection_marks = std::mem::take(&mut app.canvas.selection_marks);
|
||||
// A release ends the Image Trace panel's eye-button hold, also when the panel is no longer
|
||||
// drawn, so a later press elsewhere does not show the source image.
|
||||
if app.ui.trace_hold_source.is_some() && !ui.input(|i| i.pointer.any_down()) {
|
||||
@@ -336,7 +340,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
original_path_overlay(app, &painter, &xf);
|
||||
|
||||
let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, hover_still), revolve_edit, view_info);
|
||||
let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, selection_marks, hover_still), revolve_edit, view_info);
|
||||
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
|
||||
if !revolve_edit {
|
||||
shaper_widget_overlay(app, &painter, &xf);
|
||||
@@ -894,7 +898,7 @@ fn selection_visuals(
|
||||
app: &mut VectorcraftApp,
|
||||
painter: &egui::Painter,
|
||||
xf: &Xf,
|
||||
(hidden_chars, hover_still): (HiddenChars, bool),
|
||||
(hidden_chars, mut marks, hover_still): (HiddenChars, SelectionMarks, bool),
|
||||
revolve_edit: bool,
|
||||
view_info: vectorcraft_engine::ViewInfo,
|
||||
) -> Vec<Overlay> {
|
||||
@@ -910,7 +914,8 @@ fn selection_visuals(
|
||||
if hover_still {
|
||||
hover_highlight(app, painter, xf);
|
||||
}
|
||||
selection_overlay(app, painter, xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
|
||||
selection_overlay(app, painter, xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })), &mut marks);
|
||||
app.canvas.selection_marks = marks;
|
||||
draw_inside_overlay(app, painter, xf);
|
||||
mirror_axis_overlay(app, painter, xf);
|
||||
grid_repeat_overlay(app, painter, xf);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::artboards::names_an_artboard;
|
||||
use super::input::marquee_through;
|
||||
use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors, walk_drawn};
|
||||
use super::{Color32, CornerRadius, NodeKind, Overlay, Point, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2};
|
||||
use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors};
|
||||
use super::selection_marks::{MarkOptions, SelectionMarks};
|
||||
use super::{Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2};
|
||||
|
||||
pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(h) = app.hover_doc else { return };
|
||||
@@ -54,9 +55,9 @@ pub(super) fn selected_anchor(c: Color32) -> Color32 {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct HandleLook<'a> {
|
||||
/// Points bigger (or smaller) than the default size 3, per step of Size (1–7).
|
||||
grow: f32,
|
||||
pub(super) grow: f32,
|
||||
/// `solid` (the default), `hollow` or `large`.
|
||||
style: &'a str,
|
||||
pub(super) style: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> HandleLook<'a> {
|
||||
@@ -65,17 +66,21 @@ impl<'a> HandleLook<'a> {
|
||||
}
|
||||
|
||||
/// A direction handle's end at `c`: a solid dot, a hollow one or a larger solid one.
|
||||
fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) {
|
||||
pub(super) fn shapes(self, c: Pos2, color: Color32) -> [Option<Shape>; 2] {
|
||||
let r = 2.75 + self.grow / 2.0;
|
||||
let hollow = self.style == "hollow";
|
||||
p.circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color });
|
||||
if hollow {
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
let dot = Shape::circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color });
|
||||
[Some(dot), hollow.then(|| Shape::circle_stroke(c, r, Stroke::new(1.0, color)))]
|
||||
}
|
||||
|
||||
pub(super) fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) {
|
||||
for shape in self.shapes(c, color).into_iter().flatten() {
|
||||
p.add(shape);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||||
pub(super) fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||||
let r = egui::Rect::from_center_size(c, vec2(size, size));
|
||||
if filled {
|
||||
p.rect_filled(r, 0.0, color);
|
||||
@@ -85,11 +90,23 @@ fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size:
|
||||
}
|
||||
}
|
||||
|
||||
fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||||
pub(super) fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||||
p.circle(c, size / 2.0, if filled { color } else { Color32::WHITE }, Stroke::new(1.0, color));
|
||||
}
|
||||
|
||||
pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
|
||||
/// How the current tool and preferences draw the selection's marks.
|
||||
pub(super) fn mark_options(app: &VectorcraftApp, curve_points_from_tool: bool) -> MarkOptions<'_> {
|
||||
let tool = app.session.tool_id();
|
||||
MarkOptions {
|
||||
direct: vectorcraft_tools::catalog::edits_anchors(tool),
|
||||
curvature: tool == "curvature",
|
||||
curve_points_from_tool,
|
||||
look: HandleLook::of(&app.session.prefs),
|
||||
all_handles: app.session.prefs.show_handles_multiple_anchors,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool, marks: &mut SelectionMarks) {
|
||||
// The Selection tool's bounding box (rotated with the objects after a rotation), also shown by
|
||||
// the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It
|
||||
// hides while a drag moves or resizes the selection, so only the art is seen going with the
|
||||
@@ -121,130 +138,29 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let tool = app.session.tool_id();
|
||||
let direct = vectorcraft_tools::catalog::edits_anchors(tool);
|
||||
let curvature = tool == "curvature";
|
||||
// Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding
|
||||
// box's handles a point bigger or smaller per step.
|
||||
let look = HandleLook::of(&app.session.prefs);
|
||||
let grow = look.grow;
|
||||
let options = mark_options(app, curve_points_from_tool);
|
||||
let grow = options.look.grow;
|
||||
let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 };
|
||||
// The selected anchors' handles (drawn once they are counted: Show handles when multiple
|
||||
// anchors are selected off shows them for a single one only).
|
||||
let (mut handles, mut with_handles) = (vec![], 0);
|
||||
// Too many paths to outline: the selection's bounds stand for them.
|
||||
if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) {
|
||||
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id))));
|
||||
}
|
||||
// The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has
|
||||
// too many paths to outline.
|
||||
if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) {
|
||||
let outline = if too_many_anchors([n]) {
|
||||
n.geometric_bounds_with(&symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath())
|
||||
} else {
|
||||
Some(node_outline(n, &symbols))
|
||||
};
|
||||
if let Some(bp) = outline {
|
||||
stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k))));
|
||||
}
|
||||
}
|
||||
for id in st.selection.objects.iter().filter(|_| !big) {
|
||||
let Some(n) = st.doc.node(*id) else { continue };
|
||||
let color = c32(st.doc.layer_color(*id));
|
||||
let partial = st.selection.partial(*id);
|
||||
// Path outlines (and area type / placed document / image / mesh / symbol outlines;
|
||||
// point type baselines and origin are drawn below).
|
||||
if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) {
|
||||
stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color));
|
||||
}
|
||||
// Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh
|
||||
// envelope's points.
|
||||
walk_drawn(n, &mut |c| {
|
||||
let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid);
|
||||
let grid = match &c.kind {
|
||||
NodeKind::Mesh(m) => Some(m),
|
||||
_ => envelope.as_ref(),
|
||||
};
|
||||
if let Some(grid) = grid {
|
||||
for q in &grid.points {
|
||||
anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let NodeKind::Path { path, .. } = &c.kind else { return };
|
||||
// The Curvature tool owns its points while drawing; existing paths use this overlay.
|
||||
if curvature && curve_points_from_tool {
|
||||
return;
|
||||
}
|
||||
let selected_segments = st.selection.segments.get(&c.id);
|
||||
if let Some(segments) = selected_segments {
|
||||
for &(si, seg) in segments {
|
||||
let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue };
|
||||
let curve = sp.segment(seg);
|
||||
let mut outline = vectorcraft_geom::BezPath::new();
|
||||
outline.move_to(curve.p0);
|
||||
if sp.segment_is_line(seg) {
|
||||
outline.line_to(curve.p3);
|
||||
} else {
|
||||
outline.curve_to(curve.p1, curve.p2, curve.p3);
|
||||
}
|
||||
stroke_path(p, &outline, xf, Stroke::new(2.0, color));
|
||||
}
|
||||
}
|
||||
for (si, ai, a) in path.anchors() {
|
||||
// An object selected whole has all its anchors selected, and so do the paths inside a
|
||||
// group, compound path or clip group selected whole. Direction handles show only on a
|
||||
// path that is selected itself: those are the handles that `handle_anchors` in
|
||||
// `vectorcraft-tools` lets the tools drag.
|
||||
let (sel, handles_shown) = match partial {
|
||||
Some(set) => {
|
||||
let sel = set.contains(&(si, ai));
|
||||
(sel, sel)
|
||||
}
|
||||
None => (true, direct && c.id == *id),
|
||||
};
|
||||
let sp = xf.to_screen(a.p);
|
||||
if !curvature && handles_shown {
|
||||
with_handles += 1;
|
||||
let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true));
|
||||
handles.extend(
|
||||
[(a.h_in, sides.0), (a.h_out, sides.1)]
|
||||
.into_iter()
|
||||
.filter(|(h, show)| *show && h.distance(a.p) > 1e-6)
|
||||
.map(|(h, _)| (sp, xf.to_screen(h), color)),
|
||||
);
|
||||
}
|
||||
let anchor_selected = sel && selected_segments.is_none();
|
||||
let ink = if anchor_selected && partial.is_some() { selected_anchor(color) } else { color };
|
||||
if curvature {
|
||||
curve_point(p, sp, ink, anchor_selected && partial.is_some(), grow + 6.0);
|
||||
} else {
|
||||
anchor_square(p, sp, ink, anchor_selected, anchor(partial.is_some() || direct));
|
||||
}
|
||||
}
|
||||
});
|
||||
// Centre point (Attributes panel → Show Center).
|
||||
if n.shows_center()
|
||||
&& let Some(b) = n.geometric_bounds_with(&symbols)
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
|
||||
}
|
||||
// Point type: each line's baseline, where a click selects it (`hit`), whatever its
|
||||
// alignment and rotation, and the anchor (area type shows its frame instead, which may
|
||||
// not be a rectangle, and type on a path its path).
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
|
||||
{
|
||||
for (a, b) in tx.baselines() {
|
||||
p.line_segment([xf.to_screen(tx.xf * a), xf.to_screen(tx.xf * b)], Stroke::new(1.0, color));
|
||||
}
|
||||
p.circle_filled(xf.to_screen(tx.xf * Point::ZERO), 2.5, color);
|
||||
}
|
||||
}
|
||||
if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 {
|
||||
for (sp, hp, color) in handles {
|
||||
p.line_segment([sp, hp], Stroke::new(1.0, color));
|
||||
look.draw(p, hp, color);
|
||||
}
|
||||
let key = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?)));
|
||||
let (key_bounds, key_outline) = match key {
|
||||
Some((k, n)) if too_many_anchors([n]) => (Some((k, n)), None),
|
||||
Some((k, _)) => (None, Some(k)),
|
||||
None => (None, None),
|
||||
};
|
||||
if let Some((k, n)) = key_bounds
|
||||
&& let Some(b) = n.geometric_bounds_with(&symbols)
|
||||
{
|
||||
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k))));
|
||||
}
|
||||
marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects });
|
||||
// The spine of each selected blend (or of the blend a selected key object belongs to).
|
||||
let mut spines = vec![];
|
||||
for id in &st.selection.objects {
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
//! The selected objects' outlines, anchors and handles, kept between frames as triangles.
|
||||
//!
|
||||
//! Each selected object has an [`ObjectMarks`], valid for one node (by [`Arc`] identity), layer
|
||||
//! color and set of selected anchors. The marks of all objects are joined into one mesh, in the
|
||||
//! order they are drawn. A frame with nothing changed draws that mesh again. A pan moves the
|
||||
//! triangles of outlines and handles, and places the anchor squares again, because a square's edges
|
||||
//! are rounded to whole pixels where it lands. A frame with a new zoom, rotation or display scale
|
||||
//! draws plain shapes and keeps nothing, and the next frame with the same view builds the marks.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use egui::emath::{GuiRounding as _, TSTransform};
|
||||
use egui::epaint::{ClippedShape, Primitive, RectShape, TessellationOptions, Tessellator, Vertex};
|
||||
use egui::{Mesh, Vec2};
|
||||
use vectorcraft_doc::{AnchorRef, NodeId, SegmentRef};
|
||||
|
||||
use super::outline::{KEY_OUTLINE, c32, node_outline, to_screen_path, walk_drawn};
|
||||
use super::overlays::{HandleLook, anchor_square, selected_anchor};
|
||||
use super::{BezPath, Color32, Node, NodeKind, Point, Pos2, Shape, Stroke, StrokeKind, SymbolBoxes, Xf, pos2, vec2};
|
||||
|
||||
/// How far a pan moves kept triangles before their object is built again. Screen coordinates are
|
||||
/// `f32`, which holds a coordinate under 2^16 to within 1/256 px, and a point farther out than this
|
||||
/// plus the width of a window stays off screen.
|
||||
const MAX_PAN: f64 = 16_384.0;
|
||||
|
||||
/// The most points and vertices kept. Past this the marks are dropped after the frame that built
|
||||
/// them.
|
||||
const MAX_KEPT: usize = 8_000_000;
|
||||
|
||||
/// The most anchor square meshes kept, one per color, style and size in pixels.
|
||||
const MAX_TEMPLATES: usize = 256;
|
||||
|
||||
/// The farthest a handle's dot and its stroke reach from the end of its line, at the default size.
|
||||
const HANDLE_REACH: f32 = 6.0;
|
||||
|
||||
/// Everything that decides how marks look, apart from the object and the view's position.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct MarkLook {
|
||||
zoom: f64,
|
||||
rot: f64,
|
||||
pixels_per_point: f32,
|
||||
tessellation: TessellationOptions,
|
||||
/// Disc texture coordinates scale with the size of the font texture.
|
||||
font_image_size: [usize; 2],
|
||||
direct: bool,
|
||||
curvature: bool,
|
||||
curve_points_from_tool: bool,
|
||||
grow: f32,
|
||||
handle_style: String,
|
||||
segments: BTreeMap<NodeId, BTreeSet<SegmentRef>>,
|
||||
}
|
||||
|
||||
/// The options [`SelectionMarks::paint`] draws with.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct MarkOptions<'a> {
|
||||
/// Whether the tool edits anchors.
|
||||
pub direct: bool,
|
||||
/// Whether the tool is the Curvature tool.
|
||||
pub curvature: bool,
|
||||
/// Whether the Curvature tool draws its own points.
|
||||
pub curve_points_from_tool: bool,
|
||||
/// Selection & Anchor Display › Size and Handles.
|
||||
pub look: HandleLook<'a>,
|
||||
/// Selection & Anchor Display › Show handles when multiple anchors are selected.
|
||||
pub all_handles: bool,
|
||||
}
|
||||
|
||||
/// [`Xf`] with its rotation worked out, split into a linear part and an offset.
|
||||
struct ViewMap {
|
||||
sin: f64,
|
||||
cos: f64,
|
||||
zoom: f64,
|
||||
origin: Pos2,
|
||||
center: [f64; 2],
|
||||
offset: [f64; 2],
|
||||
}
|
||||
|
||||
impl ViewMap {
|
||||
fn new(xf: &Xf) -> Self {
|
||||
let (sin, cos) = xf.rot.sin_cos();
|
||||
let origin = xf.rect.center();
|
||||
let mut map = Self { sin, cos, zoom: xf.zoom, origin, center: [0.0; 2], offset: [0.0; 2] };
|
||||
map.center = map.view(xf.center);
|
||||
map.offset = [f64::from(origin.x) - map.center[0], f64::from(origin.y) - map.center[1]];
|
||||
map
|
||||
}
|
||||
|
||||
/// `p` scaled and rotated by the view, without its position.
|
||||
fn view(&self, p: Point) -> [f64; 2] {
|
||||
let (x, y) = (p.x * self.zoom, p.y * self.zoom);
|
||||
[x * self.cos - y * self.sin, x * self.sin + y * self.cos]
|
||||
}
|
||||
|
||||
fn screen(&self, v: [f64; 2]) -> Pos2 {
|
||||
pos2(self.origin.x + (v[0] - self.center[0]) as f32, self.origin.y + (v[1] - self.center[1]) as f32)
|
||||
}
|
||||
|
||||
fn to_screen(&self, p: Point) -> Pos2 {
|
||||
self.screen(self.view(p))
|
||||
}
|
||||
}
|
||||
|
||||
/// One run of an object's marks.
|
||||
enum Mark {
|
||||
/// Marks whose triangles keep their form when moved: polylines, discs and slanted lines.
|
||||
/// `shapes` are in screen coordinates at the object's `base` and become `mesh` the first time
|
||||
/// the run is on screen. Discs need the frame's own tessellator (`discs`).
|
||||
Rigid { shapes: Vec<Shape>, mesh: Mesh, bounds: egui::Rect, points: usize, discs: bool },
|
||||
/// Direction handles of one color, from [`ViewMap::view`]: the line from each anchor and the dot
|
||||
/// at its end. They become `mesh` for the offset `at` the first time the run is on screen. With a
|
||||
/// line that is, or is nearly, level or upright (`rounded`), `mesh` moves only by whole pixels,
|
||||
/// and any other move draws the run again.
|
||||
Handles { color: Color32, lines: Vec<[[f64; 2]; 2]>, rounded: bool, mesh: Mesh, at: [f64; 2], bounds: ([f64; 2], [f64; 2]) },
|
||||
/// Anchor squares in one style, at positions from [`ViewMap::view`].
|
||||
Squares { color: Color32, filled: bool, size: f32, at: Vec<[f64; 2]> },
|
||||
/// A line that is, or is nearly, level or upright: the tessellator rounds those to whole pixels.
|
||||
Segment { a: [f64; 2], b: [f64; 2], stroke: Stroke },
|
||||
}
|
||||
|
||||
impl Mark {
|
||||
fn kept(&self) -> usize {
|
||||
match self {
|
||||
Self::Rigid { mesh, points, .. } => mesh.vertices.len().max(*points),
|
||||
Self::Handles { lines, mesh, .. } => mesh.vertices.len() + lines.len(),
|
||||
Self::Squares { at, .. } => at.len(),
|
||||
Self::Segment { .. } => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a list of [`Mark`]s, joining neighbors that can be drawn as one run.
|
||||
struct MarkList<'a> {
|
||||
map: &'a ViewMap,
|
||||
marks: Vec<Mark>,
|
||||
}
|
||||
|
||||
impl<'a> MarkList<'a> {
|
||||
fn new(map: &'a ViewMap) -> Self {
|
||||
Self { map, marks: vec![] }
|
||||
}
|
||||
|
||||
fn rigid(&mut self, shape: Shape) {
|
||||
let rect = shape.visual_bounding_rect();
|
||||
let count = match &shape {
|
||||
Shape::Path(path) => path.points.len(),
|
||||
_ => 1,
|
||||
};
|
||||
let disc = matches!(shape, Shape::Circle(_));
|
||||
if let Some(Mark::Rigid { shapes, bounds, points, discs, .. }) = self.marks.last_mut() {
|
||||
*bounds = bounds.union(rect);
|
||||
*points += count;
|
||||
*discs |= disc;
|
||||
shapes.push(shape);
|
||||
} else {
|
||||
self.marks.push(Mark::Rigid { shapes: vec![shape], mesh: Mesh::default(), bounds: rect, points: count, discs: disc });
|
||||
}
|
||||
}
|
||||
|
||||
/// A direction handle: its line from `from` and the dot at `to`.
|
||||
fn handle(&mut self, from: Point, to: Point, color: Color32) {
|
||||
let (a, b) = (self.map.view(from), self.map.view(to));
|
||||
let is_rounded = is_rounded_line(self.map.screen(a), self.map.screen(b));
|
||||
let least = [a[0].min(b[0]), a[1].min(b[1])];
|
||||
let most = [a[0].max(b[0]), a[1].max(b[1])];
|
||||
if let Some(Mark::Handles { color: c, lines, rounded, bounds, .. }) = self.marks.last_mut()
|
||||
&& *c == color
|
||||
{
|
||||
*bounds = ([bounds.0[0].min(least[0]), bounds.0[1].min(least[1])], [bounds.1[0].max(most[0]), bounds.1[1].max(most[1])]);
|
||||
*rounded |= is_rounded;
|
||||
lines.push([a, b]);
|
||||
} else {
|
||||
self.marks.push(Mark::Handles {
|
||||
color,
|
||||
lines: vec![[a, b]],
|
||||
rounded: is_rounded,
|
||||
mesh: Mesh::default(),
|
||||
at: [0.0; 2],
|
||||
bounds: (least, most),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn outline(&mut self, path: &BezPath, xf: &Xf, stroke: Stroke) {
|
||||
for line in to_screen_path(path, xf) {
|
||||
self.rigid(Shape::line(line, stroke));
|
||||
}
|
||||
}
|
||||
|
||||
fn square(&mut self, p: Point, color: Color32, filled: bool, size: f32) {
|
||||
let v = self.map.view(p);
|
||||
if let Some(Mark::Squares { color: c, filled: f, size: s, at }) = self.marks.last_mut()
|
||||
&& (*c, *f, *s) == (color, filled, size)
|
||||
{
|
||||
at.push(v);
|
||||
} else {
|
||||
self.marks.push(Mark::Squares { color, filled, size, at: vec![v] });
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&mut self, from: Point, to: Point, stroke: Stroke) {
|
||||
let (a, b) = (self.map.view(from), self.map.view(to));
|
||||
let (sa, sb) = (self.map.screen(a), self.map.screen(b));
|
||||
if is_rounded_line(sa, sb) {
|
||||
self.marks.push(Mark::Segment { a, b, stroke });
|
||||
} else {
|
||||
self.rigid(Shape::line_segment([sa, sb], stroke));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a line from `a` to `b` is, or after a move could be, exactly level or upright.
|
||||
fn is_rounded_line(a: Pos2, b: Pos2) -> bool {
|
||||
(a.x - b.x).abs() < 0.002 || (a.y - b.y).abs() < 0.002
|
||||
}
|
||||
|
||||
/// The marks of one object (or the key object's outline), and what they were built from.
|
||||
struct ObjectMarks {
|
||||
/// Holds the allocation, so that no later node can have this address. While it exists,
|
||||
/// `Arc::make_mut` moves an edited node to a new address.
|
||||
node: Weak<Node>,
|
||||
color: Color32,
|
||||
partial: Option<BTreeSet<AnchorRef>>,
|
||||
/// The document and revision, for an object that measures symbols' art.
|
||||
symbols: Option<(u64, u64)>,
|
||||
/// [`ViewMap::offset`] when the marks were built.
|
||||
base: [f64; 2],
|
||||
marks: Vec<Mark>,
|
||||
handles: Vec<Mark>,
|
||||
/// The anchors whose handles `handles` shows.
|
||||
with_handles: usize,
|
||||
seen: u64,
|
||||
}
|
||||
|
||||
impl ObjectMarks {
|
||||
fn current(&self, node: &Arc<Node>, color: Color32, partial: Option<&BTreeSet<AnchorRef>>, source: (u64, u64), map: &ViewMap) -> bool {
|
||||
Weak::as_ptr(&self.node) == Arc::as_ptr(node)
|
||||
&& self.color == color
|
||||
&& self.partial.as_ref() == partial
|
||||
&& self.symbols.is_none_or(|s| s == source)
|
||||
&& (map.offset[0] - self.base[0]).abs() <= MAX_PAN
|
||||
&& (map.offset[1] - self.base[1]).abs() <= MAX_PAN
|
||||
}
|
||||
}
|
||||
|
||||
/// The node `id` as held in the document, for its identity.
|
||||
fn node_arc(doc: &vectorcraft_doc::Document, id: NodeId) -> Option<&Arc<Node>> {
|
||||
let path = doc.index_path(id)?;
|
||||
let (first, rest) = path.split_first()?;
|
||||
let mut node = doc.layers.get(*first)?;
|
||||
for i in rest {
|
||||
node = node.children()?.get(*i)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// A square's triangles, relative to the corner it was rounded to.
|
||||
struct Template {
|
||||
color: Color32,
|
||||
filled: bool,
|
||||
size: f32,
|
||||
pixels: (i32, i32),
|
||||
mesh: Mesh,
|
||||
}
|
||||
|
||||
/// A handle's dot in one color, around the origin.
|
||||
struct Dot {
|
||||
color: Color32,
|
||||
mesh: Mesh,
|
||||
}
|
||||
|
||||
/// What one anchor square draws: the fill, then the stroke of a hollow one.
|
||||
fn square_shapes(rect: egui::Rect, color: Color32, filled: bool) -> [Option<RectShape>; 2] {
|
||||
if filled {
|
||||
[Some(RectShape::filled(rect, 0.0, color)), None]
|
||||
} else {
|
||||
[Some(RectShape::filled(rect, 0.0, Color32::WHITE)), Some(RectShape::stroke(rect, 0.0, Stroke::new(1.0, color), StrokeKind::Inside))]
|
||||
}
|
||||
}
|
||||
|
||||
/// `shapes` as the frame's tessellator draws them, with nothing culled.
|
||||
fn tessellated(ctx: &egui::Context, shapes: Vec<Shape>, pixels_per_point: f32) -> Mesh {
|
||||
let clipped = shapes.into_iter().map(|shape| ClippedShape {
|
||||
clip_rect: egui::Rect::EVERYTHING,
|
||||
shape,
|
||||
transform_after_tessellation: TSTransform::IDENTITY,
|
||||
});
|
||||
let primitives = ctx.tessellate(clipped.collect(), pixels_per_point);
|
||||
primitives
|
||||
.into_iter()
|
||||
.find_map(|c| match c.primitive {
|
||||
Primitive::Mesh(mesh) => Some(mesh),
|
||||
Primitive::Callback(_) => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Joins marks into the frame's mesh.
|
||||
struct Composer<'a> {
|
||||
ctx: &'a egui::Context,
|
||||
map: &'a ViewMap,
|
||||
clip: egui::Rect,
|
||||
pixels_per_point: f32,
|
||||
/// Squares this size or smaller are drawn as lines, which round differently: see
|
||||
/// [`Tessellator::tessellate_rect`].
|
||||
thin: f32,
|
||||
/// Draws lines and squares that rounding keeps from being reused, culled by `clip`.
|
||||
tessellator: Tessellator,
|
||||
/// Draws what is kept for later frames, with nothing culled. Like `tessellator`, it has no discs,
|
||||
/// so neither draws circles.
|
||||
keeper: Tessellator,
|
||||
/// Draws filled squares without the soft edge, when that edge falls between pixel centers: on a
|
||||
/// square rounded to whole pixels, each pixel center is then fully in or fully out.
|
||||
crisp: Option<Tessellator>,
|
||||
/// Whether the tessellator rounds rectangles to whole pixels, as [`Self::squares`] assumes.
|
||||
rounds_rects: bool,
|
||||
look: HandleLook<'a>,
|
||||
templates: &'a mut Vec<Template>,
|
||||
dots: &'a mut Vec<Dot>,
|
||||
out: Mesh,
|
||||
}
|
||||
|
||||
/// Append `mesh` to `out`, moved by `delta`.
|
||||
fn stamp(out: &mut Mesh, mesh: &Mesh, delta: Vec2) {
|
||||
let first = out.vertices.len() as u32;
|
||||
out.vertices.extend(mesh.vertices.iter().map(|v| Vertex { pos: v.pos + delta, ..*v }));
|
||||
out.indices.extend(mesh.indices.iter().map(|i| i + first));
|
||||
}
|
||||
|
||||
impl Composer<'_> {
|
||||
fn marks(&mut self, marks: &mut [Mark], base: [f64; 2]) {
|
||||
let delta = vec2((self.map.offset[0] - base[0]) as f32, (self.map.offset[1] - base[1]) as f32);
|
||||
let margin = 2.0 / self.pixels_per_point;
|
||||
for mark in marks {
|
||||
match mark {
|
||||
Mark::Rigid { shapes, mesh, bounds, discs, .. } => {
|
||||
if !bounds.translate(delta).expand(margin).intersects(self.clip) {
|
||||
continue;
|
||||
}
|
||||
if !shapes.is_empty() {
|
||||
let shapes = std::mem::take(shapes);
|
||||
if *discs {
|
||||
*mesh = tessellated(self.ctx, shapes, self.pixels_per_point);
|
||||
} else {
|
||||
for shape in shapes {
|
||||
self.keeper.tessellate_shape(shape, mesh);
|
||||
}
|
||||
}
|
||||
}
|
||||
stamp(&mut self.out, mesh, delta);
|
||||
}
|
||||
Mark::Handles { color, lines, rounded, mesh, at, bounds } => {
|
||||
let onscreen = egui::Rect::from_two_pos(self.map.screen(bounds.0), self.map.screen(bounds.1))
|
||||
.expand(HANDLE_REACH + self.look.grow.max(0.0) / 2.0);
|
||||
if !onscreen.intersects(self.clip) {
|
||||
continue;
|
||||
}
|
||||
let moved = vec2((self.map.offset[0] - at[0]) as f32, (self.map.offset[1] - at[1]) as f32);
|
||||
let whole_pixels = (moved * self.pixels_per_point).round() == moved * self.pixels_per_point;
|
||||
if *rounded && !mesh.is_empty() && !whole_pixels {
|
||||
let mut out = std::mem::take(&mut self.out);
|
||||
self.handles(*color, lines, &mut out);
|
||||
self.out = out;
|
||||
continue;
|
||||
}
|
||||
if mesh.is_empty() {
|
||||
self.handles(*color, lines, mesh);
|
||||
*at = self.map.offset;
|
||||
stamp(&mut self.out, mesh, Vec2::ZERO);
|
||||
} else {
|
||||
stamp(&mut self.out, mesh, moved);
|
||||
}
|
||||
}
|
||||
Mark::Squares { color, filled, size, at } => self.squares(*color, *filled, *size, at),
|
||||
Mark::Segment { a, b, stroke } => {
|
||||
let points = [self.map.screen(*a), self.map.screen(*b)];
|
||||
self.tessellator.tessellate_line_segment(points, *stroke, &mut self.out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw direction handles into `mesh`.
|
||||
fn handles(&mut self, color: Color32, lines: &[[[f64; 2]; 2]], mesh: &mut Mesh) {
|
||||
let index = match self.dots.iter().position(|d| d.color == color) {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
let shapes = self.look.shapes(Pos2::ZERO, color).into_iter().flatten().collect();
|
||||
let mesh = tessellated(self.ctx, shapes, self.pixels_per_point);
|
||||
if self.dots.len() >= MAX_TEMPLATES {
|
||||
self.dots.clear();
|
||||
}
|
||||
self.dots.push(Dot { color, mesh });
|
||||
self.dots.len() - 1
|
||||
}
|
||||
};
|
||||
let Some(dot) = self.dots.get(index) else { return };
|
||||
let stroke = Stroke::new(1.0, color);
|
||||
for [from, to] in lines {
|
||||
let (from, to) = (self.map.screen(*from), self.map.screen(*to));
|
||||
self.keeper.tessellate_line_segment([from, to], stroke, mesh);
|
||||
stamp(mesh, &dot.mesh, to.to_vec2());
|
||||
}
|
||||
}
|
||||
|
||||
fn squares(&mut self, color: Color32, filled: bool, size: f32, at: &[[f64; 2]]) {
|
||||
let stamps = self.rounds_rects && size > self.thin;
|
||||
let mut template = None;
|
||||
let mut last = None;
|
||||
for v in at {
|
||||
let rect = egui::Rect::from_center_size(self.map.screen(*v), vec2(size, size));
|
||||
if !rect.intersects(self.clip) {
|
||||
continue;
|
||||
}
|
||||
if !stamps {
|
||||
for shape in square_shapes(rect, color, filled).into_iter().flatten() {
|
||||
self.tessellator.tessellate_rect(&shape, &mut self.out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let rounded = rect.round_to_pixels(self.pixels_per_point);
|
||||
// A filled square on the same pixels as the last one, in the same run, adds nothing.
|
||||
if filled && last == Some(rounded) {
|
||||
continue;
|
||||
}
|
||||
last = Some(rounded);
|
||||
let pixels = ((rounded.width() * self.pixels_per_point).round() as i32, (rounded.height() * self.pixels_per_point).round() as i32);
|
||||
let index = match template {
|
||||
Some((p, index)) if p == pixels => index,
|
||||
_ => self.template(color, filled, size, pixels, rect),
|
||||
};
|
||||
template = Some((pixels, index));
|
||||
let Some(found) = self.templates.get(index) else { continue };
|
||||
stamp(&mut self.out, &found.mesh, rounded.min.to_vec2());
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the template for a square in this style, made from `rect` when there is none.
|
||||
fn template(&mut self, color: Color32, filled: bool, size: f32, pixels: (i32, i32), rect: egui::Rect) -> usize {
|
||||
let same = |t: &Template| t.color == color && t.filled == filled && t.size == size && t.pixels == pixels;
|
||||
if let Some(index) = self.templates.iter().position(same) {
|
||||
return index;
|
||||
}
|
||||
let mut mesh = Mesh::default();
|
||||
let tessellator = match &mut self.crisp {
|
||||
Some(crisp) if filled => crisp,
|
||||
_ => &mut self.keeper,
|
||||
};
|
||||
for shape in square_shapes(rect, color, filled).into_iter().flatten() {
|
||||
tessellator.tessellate_rect(&shape, &mut mesh);
|
||||
}
|
||||
let corner = rect.round_to_pixels(self.pixels_per_point).min.to_vec2();
|
||||
for v in &mut mesh.vertices {
|
||||
v.pos -= corner;
|
||||
}
|
||||
if self.templates.len() >= MAX_TEMPLATES {
|
||||
self.templates.clear();
|
||||
}
|
||||
self.templates.push(Template { color, filled, size, pixels, mesh });
|
||||
self.templates.len() - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// What the frame's mesh was joined for.
|
||||
#[derive(PartialEq)]
|
||||
struct Shown {
|
||||
offset: [f64; 2],
|
||||
clip: egui::Rect,
|
||||
all_handles: bool,
|
||||
}
|
||||
|
||||
/// The kept marks of the selection ([`CanvasCache::selection_marks`](crate::caches::CanvasCache)).
|
||||
#[derive(Default)]
|
||||
pub struct SelectionMarks {
|
||||
look: Option<MarkLook>,
|
||||
objects: HashMap<NodeId, ObjectMarks>,
|
||||
key: Option<(NodeId, ObjectMarks)>,
|
||||
templates: Vec<Template>,
|
||||
dots: Vec<Dot>,
|
||||
frame: Arc<Mesh>,
|
||||
shown: Option<Shown>,
|
||||
order: Vec<NodeId>,
|
||||
pass: u64,
|
||||
kept: usize,
|
||||
#[cfg(test)]
|
||||
builds: usize,
|
||||
#[cfg(test)]
|
||||
composes: usize,
|
||||
}
|
||||
|
||||
impl SelectionMarks {
|
||||
/// Draw the key object `key`'s outline and the marks of `objects`, in that order, reusing what
|
||||
/// is still valid. Entries of any other object are dropped.
|
||||
pub(super) fn paint(
|
||||
&mut self,
|
||||
st: &vectorcraft_engine::DocState,
|
||||
p: &egui::Painter,
|
||||
xf: &Xf,
|
||||
options: &MarkOptions,
|
||||
key: Option<NodeId>,
|
||||
objects: &[NodeId],
|
||||
) {
|
||||
let ctx = p.ctx();
|
||||
let look = MarkLook {
|
||||
zoom: xf.zoom,
|
||||
rot: xf.rot,
|
||||
pixels_per_point: ctx.pixels_per_point(),
|
||||
tessellation: ctx.tessellation_options(|o| *o),
|
||||
font_image_size: ctx.fonts(|f| f.font_image_size()),
|
||||
direct: options.direct,
|
||||
curvature: options.curvature,
|
||||
curve_points_from_tool: options.curve_points_from_tool,
|
||||
grow: options.look.grow,
|
||||
handle_style: options.look.style.to_owned(),
|
||||
segments: st.selection.segments.clone(),
|
||||
};
|
||||
let pixels_per_point = look.pixels_per_point;
|
||||
let tessellation = look.tessellation;
|
||||
if self.look.as_ref() != Some(&look) {
|
||||
self.clear();
|
||||
self.look = Some(look);
|
||||
draw_once(st, p, xf, options, key, objects);
|
||||
return;
|
||||
}
|
||||
let map = ViewMap::new(xf);
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let source = (st.uid, st.revision);
|
||||
let mut changed = self.update_key(st, key, xf, &map, &symbols, source);
|
||||
self.pass = self.pass.wrapping_add(1);
|
||||
let pass = self.pass;
|
||||
for id in objects {
|
||||
let Some(node) = node_arc(&st.doc, *id) else { continue };
|
||||
let color = c32(st.doc.layer_color(*id));
|
||||
match self.objects.get_mut(id) {
|
||||
Some(entry) if entry.current(node, color, st.selection.partial(*id), source, &map) => entry.seen = pass,
|
||||
_ => {
|
||||
let mut entry = object_marks(st, *id, node, color, source, xf, &map, options, &symbols);
|
||||
entry.seen = pass;
|
||||
self.objects.insert(*id, entry);
|
||||
changed = true;
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.builds += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.objects.retain(|_, entry| entry.seen == pass);
|
||||
let shown = Shown { offset: map.offset, clip: p.clip_rect(), all_handles: options.all_handles };
|
||||
if changed || self.shown.as_ref() != Some(&shown) || self.order != objects {
|
||||
self.order.clear();
|
||||
self.order.extend_from_slice(objects);
|
||||
self.compose(ctx, &map, shown.clip, (pixels_per_point, tessellation), options);
|
||||
self.shown = Some(shown);
|
||||
}
|
||||
if !self.frame.is_empty() {
|
||||
p.add(Shape::Mesh(self.frame.clone()));
|
||||
}
|
||||
self.trim(MAX_KEPT);
|
||||
}
|
||||
|
||||
/// Drop everything when more than `limit` points and vertices are kept.
|
||||
fn trim(&mut self, limit: usize) {
|
||||
if self.kept > limit {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the key object's outline up to date. True when it changed.
|
||||
fn update_key(
|
||||
&mut self,
|
||||
st: &vectorcraft_engine::DocState,
|
||||
key: Option<NodeId>,
|
||||
xf: &Xf,
|
||||
map: &ViewMap,
|
||||
symbols: &SymbolBoxes,
|
||||
source: (u64, u64),
|
||||
) -> bool {
|
||||
let Some((id, node)) = key.and_then(|id| Some((id, node_arc(&st.doc, id)?))) else { return self.key.take().is_some() };
|
||||
let color = c32(st.doc.layer_color(id));
|
||||
if self.key.as_ref().is_some_and(|(k, entry)| *k == id && entry.current(node, color, None, source, map)) {
|
||||
return false;
|
||||
}
|
||||
self.key = Some((id, key_marks(node, color, source, xf, map, symbols)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Join the marks in the order they are drawn: the key object's outline, each object's marks,
|
||||
/// and then all the handles.
|
||||
fn compose(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
map: &ViewMap,
|
||||
clip: egui::Rect,
|
||||
(pixels_per_point, tessellation): (f32, TessellationOptions),
|
||||
options: &MarkOptions,
|
||||
) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.composes += 1;
|
||||
}
|
||||
let Self { objects, key, templates, dots, frame, order, kept, .. } = self;
|
||||
let all_handles = options.all_handles;
|
||||
let mut out = Arc::try_unwrap(std::mem::take(frame)).unwrap_or_default();
|
||||
out.clear();
|
||||
let font_image_size = ctx.fonts(|f| f.font_image_size());
|
||||
let keeper = Tessellator::new(pixels_per_point, tessellation, font_image_size, vec![]);
|
||||
let mut tessellator = keeper.clone();
|
||||
tessellator.set_clip_rect(clip);
|
||||
let feathering = if tessellation.feathering { tessellation.feathering_size_in_pixels / pixels_per_point } else { 0.0 };
|
||||
let crisp = (tessellation.feathering && tessellation.feathering_size_in_pixels == 1.0)
|
||||
.then(|| Tessellator::new(pixels_per_point, TessellationOptions { feathering: false, ..tessellation }, font_image_size, vec![]));
|
||||
let mut composer = Composer {
|
||||
ctx,
|
||||
map,
|
||||
clip,
|
||||
pixels_per_point,
|
||||
thin: 2.0 * feathering,
|
||||
tessellator,
|
||||
keeper,
|
||||
crisp,
|
||||
rounds_rects: tessellation.round_rects_to_pixels,
|
||||
look: options.look,
|
||||
templates,
|
||||
dots,
|
||||
out,
|
||||
};
|
||||
if let Some((_, entry)) = key {
|
||||
composer.marks(&mut entry.marks, entry.base);
|
||||
}
|
||||
for id in order.iter() {
|
||||
if let Some(entry) = objects.get_mut(id) {
|
||||
composer.marks(&mut entry.marks, entry.base);
|
||||
}
|
||||
}
|
||||
let with_handles: usize = order.iter().filter_map(|id| objects.get(id)).map(|entry| entry.with_handles).sum();
|
||||
if all_handles || with_handles <= 1 {
|
||||
for id in order.iter() {
|
||||
if let Some(entry) = objects.get_mut(id) {
|
||||
composer.marks(&mut entry.handles, entry.base);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = composer.out;
|
||||
let in_entries: usize = objects
|
||||
.values()
|
||||
.chain(key.iter().map(|(_, entry)| entry))
|
||||
.flat_map(|entry| entry.marks.iter().chain(&entry.handles))
|
||||
.map(Mark::kept)
|
||||
.sum();
|
||||
*kept = in_entries + out.vertices.len();
|
||||
*frame = Arc::new(out);
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
*self = Self {
|
||||
#[cfg(test)]
|
||||
builds: self.builds,
|
||||
#[cfg(test)]
|
||||
composes: self.composes,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `n` or anything inside it is a symbol instance, whose outline is measured from art
|
||||
/// outside the node.
|
||||
fn uses_symbols(n: &Node) -> bool {
|
||||
let mut found = false;
|
||||
n.walk(&mut |c| found |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
|
||||
found
|
||||
}
|
||||
|
||||
/// Build the key object's outline.
|
||||
fn key_marks(node: &Arc<Node>, color: Color32, source: (u64, u64), xf: &Xf, map: &ViewMap, symbols: &SymbolBoxes) -> ObjectMarks {
|
||||
let mut list = MarkList::new(map);
|
||||
list.outline(&node_outline(node, symbols), xf, Stroke::new(KEY_OUTLINE, color));
|
||||
ObjectMarks {
|
||||
node: Arc::downgrade(node),
|
||||
color,
|
||||
partial: None,
|
||||
symbols: uses_symbols(node).then_some(source),
|
||||
base: map.offset,
|
||||
marks: list.marks,
|
||||
handles: vec![],
|
||||
with_handles: 0,
|
||||
seen: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the key object's outline and the marks of `objects` as plain shapes, keeping nothing.
|
||||
fn draw_once(st: &vectorcraft_engine::DocState, p: &egui::Painter, xf: &Xf, options: &MarkOptions, key: Option<NodeId>, objects: &[NodeId]) {
|
||||
let map = ViewMap::new(xf);
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let source = (st.uid, st.revision);
|
||||
if let Some((id, node)) = key.and_then(|id| Some((id, node_arc(&st.doc, id)?))) {
|
||||
add_marks(p, &map, options.look, key_marks(node, c32(st.doc.layer_color(id)), source, xf, &map, &symbols).marks);
|
||||
}
|
||||
let (mut handles, mut with_handles) = (vec![], 0);
|
||||
for id in objects {
|
||||
let Some(node) = node_arc(&st.doc, *id) else { continue };
|
||||
let entry = object_marks(st, *id, node, c32(st.doc.layer_color(*id)), source, xf, &map, options, &symbols);
|
||||
add_marks(p, &map, options.look, entry.marks);
|
||||
handles.push(entry.handles);
|
||||
with_handles += entry.with_handles;
|
||||
}
|
||||
if options.all_handles || with_handles <= 1 {
|
||||
for marks in handles {
|
||||
add_marks(p, &map, options.look, marks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `marks` to the painter one shape at a time.
|
||||
fn add_marks(p: &egui::Painter, map: &ViewMap, look: HandleLook, marks: Vec<Mark>) {
|
||||
for mark in marks {
|
||||
match mark {
|
||||
Mark::Rigid { shapes, .. } => {
|
||||
for shape in shapes {
|
||||
p.add(shape);
|
||||
}
|
||||
}
|
||||
Mark::Handles { color, lines, .. } => {
|
||||
for [from, to] in lines {
|
||||
let (from, to) = (map.screen(from), map.screen(to));
|
||||
p.line_segment([from, to], Stroke::new(1.0, color));
|
||||
look.draw(p, to, color);
|
||||
}
|
||||
}
|
||||
Mark::Squares { color, filled, size, at } => {
|
||||
for v in at {
|
||||
anchor_square(p, map.screen(v), color, filled, size);
|
||||
}
|
||||
}
|
||||
Mark::Segment { a, b, stroke } => {
|
||||
p.line_segment([map.screen(a), map.screen(b)], stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the marks of the selected object `id`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn object_marks(
|
||||
st: &vectorcraft_engine::DocState,
|
||||
id: NodeId,
|
||||
node: &Arc<Node>,
|
||||
color: Color32,
|
||||
source: (u64, u64),
|
||||
xf: &Xf,
|
||||
map: &ViewMap,
|
||||
options: &MarkOptions,
|
||||
symbols: &SymbolBoxes,
|
||||
) -> ObjectMarks {
|
||||
let MarkOptions { direct, curvature, curve_points_from_tool, look, .. } = *options;
|
||||
let n: &Node = node;
|
||||
let partial = st.selection.partial(id);
|
||||
let anchor = |direct: bool| look.grow + if direct { 5.0 } else { 4.0 };
|
||||
let (mut marks, mut handles, mut with_handles) = (MarkList::new(map), MarkList::new(map), 0);
|
||||
// Path outlines (and area type / placed document / image / mesh / symbol outlines;
|
||||
// point type baselines and origin are drawn below).
|
||||
if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) {
|
||||
marks.outline(&node_outline(n, symbols), xf, Stroke::new(1.0, color));
|
||||
}
|
||||
// Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh
|
||||
// envelope's points.
|
||||
walk_drawn(n, &mut |c| {
|
||||
let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid);
|
||||
let grid = match &c.kind {
|
||||
NodeKind::Mesh(m) => Some(m),
|
||||
_ => envelope.as_ref(),
|
||||
};
|
||||
if let Some(grid) = grid {
|
||||
for q in &grid.points {
|
||||
marks.square(q.p, color, false, anchor(direct));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let NodeKind::Path { path, .. } = &c.kind else { return };
|
||||
// The Curvature tool owns its points while drawing; existing paths use this overlay.
|
||||
if curvature && curve_points_from_tool {
|
||||
return;
|
||||
}
|
||||
let selected_segments = st.selection.segments.get(&c.id);
|
||||
if let Some(segments) = selected_segments {
|
||||
for &(si, seg) in segments {
|
||||
let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue };
|
||||
let curve = sp.segment(seg);
|
||||
let mut outline = vectorcraft_geom::BezPath::new();
|
||||
outline.move_to(curve.p0);
|
||||
if sp.segment_is_line(seg) {
|
||||
outline.line_to(curve.p3);
|
||||
} else {
|
||||
outline.curve_to(curve.p1, curve.p2, curve.p3);
|
||||
}
|
||||
marks.outline(&outline, xf, Stroke::new(2.0, color));
|
||||
}
|
||||
}
|
||||
for (si, ai, a) in path.anchors() {
|
||||
// An object selected whole has all its anchors selected, and so do the paths inside a
|
||||
// group, compound path or clip group selected whole. Direction handles show only on a
|
||||
// path that is selected itself: those are the handles that `handle_anchors` in
|
||||
// `vectorcraft-tools` lets the tools drag.
|
||||
let (sel, handles_shown) = match partial {
|
||||
Some(set) => {
|
||||
let sel = set.contains(&(si, ai));
|
||||
(sel, sel)
|
||||
}
|
||||
None => (true, direct && c.id == id),
|
||||
};
|
||||
if !curvature && handles_shown {
|
||||
with_handles += 1;
|
||||
let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true));
|
||||
for (h, _) in [(a.h_in, sides.0), (a.h_out, sides.1)].into_iter().filter(|(h, show)| *show && h.distance(a.p) > 1e-6) {
|
||||
handles.handle(a.p, h, color);
|
||||
}
|
||||
}
|
||||
let anchor_selected = sel && selected_segments.is_none();
|
||||
let ink = if anchor_selected && partial.is_some() { selected_anchor(color) } else { color };
|
||||
if curvature {
|
||||
let (fill, size) = (if anchor_selected && partial.is_some() { ink } else { Color32::WHITE }, look.grow + 6.0);
|
||||
marks.rigid(Shape::Circle(egui::epaint::CircleShape {
|
||||
center: map.to_screen(a.p),
|
||||
radius: size / 2.0,
|
||||
fill,
|
||||
stroke: Stroke::new(1.0, ink),
|
||||
}));
|
||||
} else {
|
||||
marks.square(a.p, ink, anchor_selected, anchor(partial.is_some() || direct));
|
||||
}
|
||||
}
|
||||
});
|
||||
// Centre point (Attributes panel → Show Center).
|
||||
if n.shows_center()
|
||||
&& let Some(b) = n.geometric_bounds_with(symbols)
|
||||
{
|
||||
marks.square(b.center(), color, true, anchor(false));
|
||||
}
|
||||
// Point type: each line's baseline, where a click selects it (`hit`), whatever its
|
||||
// alignment and rotation, and the anchor (area type shows its frame instead, which may
|
||||
// not be a rectangle, and type on a path its path).
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
|
||||
{
|
||||
for (a, b) in tx.baselines() {
|
||||
marks.line(tx.xf * a, tx.xf * b, Stroke::new(1.0, color));
|
||||
}
|
||||
marks.rigid(Shape::circle_filled(map.to_screen(tx.xf * Point::ZERO), 2.5, color));
|
||||
}
|
||||
ObjectMarks {
|
||||
node: Arc::downgrade(node),
|
||||
color,
|
||||
partial: partial.cloned(),
|
||||
symbols: uses_symbols(n).then_some(source),
|
||||
base: map.offset,
|
||||
marks: marks.marks,
|
||||
handles: handles.marks,
|
||||
with_handles,
|
||||
seen: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,525 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::super::overlays::{anchor_square, curve_point, mark_options};
|
||||
use super::super::{VectorcraftApp, show};
|
||||
use super::*;
|
||||
|
||||
const SIZE: (f32, f32) = (400.0, 300.0);
|
||||
|
||||
fn id_of(made: &serde_json::Value) -> NodeId {
|
||||
NodeId(made["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A curve with handles, a live rectangle, a group, point type and a symbol instance, all selected.
|
||||
fn scene() -> (VectorcraftApp, NodeId) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let curve = id_of(&app.run("path.create", json!({"d": "M40 40 C60 10 90 10 110 40 S160 70 180 40 L180 90"})).unwrap());
|
||||
app.run("shape.rectangle", json!({"x": 200.5, "y": 30.25, "width": 60, "height": 40})).unwrap();
|
||||
let a = id_of(&app.run("shape.ellipse", json!({"x": 40, "y": 120, "width": 50, "height": 30})).unwrap());
|
||||
let b = id_of(&app.run("shape.rectangle", json!({"x": 100, "y": 120, "width": 30, "height": 30})).unwrap());
|
||||
app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap();
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
app.run("text.create", json!({"x": 200, "y": 150, "text": "Mark", "size": 20})).unwrap();
|
||||
let art = id_of(&app.run("shape.rectangle", json!({"x": 280, "y": 200, "width": 20, "height": 20})).unwrap());
|
||||
app.run("symbol.new", json!({"name": "Mark", "ids": [art.0]})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
(app, curve)
|
||||
}
|
||||
|
||||
fn xf(zoom: f64, center: (f64, f64), rot_degrees: f64) -> Xf {
|
||||
Xf {
|
||||
rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(SIZE.0, SIZE.1)),
|
||||
zoom,
|
||||
center: Point::new(center.0, center.1),
|
||||
rot: rot_degrees.to_radians(),
|
||||
}
|
||||
}
|
||||
|
||||
const VIEW: (f64, f64) = (200.0, 150.0);
|
||||
|
||||
/// The marks drawn one shape at a time, as a frame draws them with nothing kept.
|
||||
fn plain(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
|
||||
let st = app.session.active().unwrap();
|
||||
let MarkOptions { direct, curvature, look, all_handles, .. } = mark_options(app, curve_points_from_tool);
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let anchor = |direct: bool| look.grow + if direct { 5.0 } else { 4.0 };
|
||||
let (mut handles, mut with_handles) = (vec![], 0);
|
||||
if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) {
|
||||
for line in to_screen_path(&node_outline(n, &symbols), xf) {
|
||||
p.add(Shape::line(line, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))));
|
||||
}
|
||||
}
|
||||
for id in &st.selection.objects {
|
||||
let Some(n) = st.doc.node(*id) else { continue };
|
||||
let color = c32(st.doc.layer_color(*id));
|
||||
let partial = st.selection.partial(*id);
|
||||
let stroke = |bp: &BezPath, width: f32| {
|
||||
for line in to_screen_path(bp, xf) {
|
||||
p.add(Shape::line(line, Stroke::new(width, color)));
|
||||
}
|
||||
};
|
||||
if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) {
|
||||
stroke(&node_outline(n, &symbols), 1.0);
|
||||
}
|
||||
walk_drawn(n, &mut |c| {
|
||||
let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid);
|
||||
let grid = match &c.kind {
|
||||
NodeKind::Mesh(m) => Some(m),
|
||||
_ => envelope.as_ref(),
|
||||
};
|
||||
if let Some(grid) = grid {
|
||||
for q in &grid.points {
|
||||
anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let NodeKind::Path { path, .. } = &c.kind else { return };
|
||||
if curvature && curve_points_from_tool {
|
||||
return;
|
||||
}
|
||||
let selected_segments = st.selection.segments.get(&c.id);
|
||||
if let Some(segments) = selected_segments {
|
||||
for &(si, seg) in segments {
|
||||
let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue };
|
||||
let curve = sp.segment(seg);
|
||||
let mut outline = vectorcraft_geom::BezPath::new();
|
||||
outline.move_to(curve.p0);
|
||||
if sp.segment_is_line(seg) {
|
||||
outline.line_to(curve.p3);
|
||||
} else {
|
||||
outline.curve_to(curve.p1, curve.p2, curve.p3);
|
||||
}
|
||||
stroke(&outline, 2.0);
|
||||
}
|
||||
}
|
||||
for (si, ai, a) in path.anchors() {
|
||||
let (sel, handles_shown) = match partial {
|
||||
Some(set) => {
|
||||
let sel = set.contains(&(si, ai));
|
||||
(sel, sel)
|
||||
}
|
||||
None => (true, direct && c.id == *id),
|
||||
};
|
||||
let sp = xf.to_screen(a.p);
|
||||
if !curvature && handles_shown {
|
||||
with_handles += 1;
|
||||
let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true));
|
||||
handles.extend(
|
||||
[(a.h_in, sides.0), (a.h_out, sides.1)]
|
||||
.into_iter()
|
||||
.filter(|(h, show)| *show && h.distance(a.p) > 1e-6)
|
||||
.map(|(h, _)| (sp, xf.to_screen(h), color)),
|
||||
);
|
||||
}
|
||||
let anchor_selected = sel && selected_segments.is_none();
|
||||
let ink = if anchor_selected && partial.is_some() { selected_anchor(color) } else { color };
|
||||
if curvature {
|
||||
curve_point(p, sp, ink, anchor_selected && partial.is_some(), look.grow + 6.0);
|
||||
} else {
|
||||
anchor_square(p, sp, ink, anchor_selected, anchor(partial.is_some() || direct));
|
||||
}
|
||||
}
|
||||
});
|
||||
if n.shows_center()
|
||||
&& let Some(b) = n.geometric_bounds_with(&symbols)
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
|
||||
}
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
|
||||
{
|
||||
for (a, b) in tx.baselines() {
|
||||
p.line_segment([xf.to_screen(tx.xf * a), xf.to_screen(tx.xf * b)], Stroke::new(1.0, color));
|
||||
}
|
||||
p.circle_filled(xf.to_screen(tx.xf * Point::ZERO), 2.5, color);
|
||||
}
|
||||
}
|
||||
if all_handles || with_handles <= 1 {
|
||||
for (sp, hp, color) in handles {
|
||||
p.line_segment([sp, hp], Stroke::new(1.0, color));
|
||||
for shape in look.shapes(hp, color).into_iter().flatten() {
|
||||
p.add(shape);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied pixels of a frame.
|
||||
struct Picture {
|
||||
size: (usize, usize),
|
||||
pixels: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// The font texture at `uv`, filtered linearly as the GPU does.
|
||||
fn sample(atlas: &egui::ColorImage, uv: (f32, f32)) -> [f32; 4] {
|
||||
let (w, h) = (atlas.width(), atlas.height());
|
||||
let (x, y) = (uv.0 * w as f32 - 0.5, uv.1 * h as f32 - 0.5);
|
||||
let (fx, fy) = (x - x.floor(), y - y.floor());
|
||||
let texel =
|
||||
|dx: f32, dy: f32| atlas[(((x.floor() + dx).max(0.0) as usize).min(w - 1), ((y.floor() + dy).max(0.0) as usize).min(h - 1))].to_array();
|
||||
let (a, b, c, d) = (texel(0.0, 0.0), texel(1.0, 0.0), texel(0.0, 1.0), texel(1.0, 1.0));
|
||||
std::array::from_fn(|i| {
|
||||
let top = f32::from(a[i]) * (1.0 - fx) + f32::from(b[i]) * fx;
|
||||
let bottom = f32::from(c[i]) * (1.0 - fx) + f32::from(d[i]) * fx;
|
||||
top * (1.0 - fy) + bottom * fy
|
||||
})
|
||||
}
|
||||
|
||||
fn rasterize(primitives: &[egui::ClippedPrimitive], pixels_per_point: f32, atlas: &egui::ColorImage) -> Picture {
|
||||
let size = ((SIZE.0 * pixels_per_point).ceil() as usize, (SIZE.1 * pixels_per_point).ceil() as usize);
|
||||
let mut pixels = vec![[0.0f32; 4]; size.0 * size.1];
|
||||
// Vertices snap to 1/256 of a pixel, as on a GPU, so that the edge tests are exact and a pixel
|
||||
// center on an edge two triangles share belongs to exactly one of them.
|
||||
const GRID: f32 = 256.0;
|
||||
let cross =
|
||||
|a: (i64, i64), b: (i64, i64), c: (i64, i64)| i128::from(b.0 - a.0) * i128::from(c.1 - a.1) - i128::from(b.1 - a.1) * i128::from(c.0 - a.0);
|
||||
for primitive in primitives {
|
||||
let Primitive::Mesh(mesh) = &primitive.primitive else { continue };
|
||||
let clip = primitive.clip_rect;
|
||||
for [i, j, k] in mesh.indices.as_chunks::<3>().0 {
|
||||
let mut v = [mesh.vertices[*i as usize], mesh.vertices[*j as usize], mesh.vertices[*k as usize]];
|
||||
let at = |v: &Vertex| ((v.pos.x * pixels_per_point * GRID).round() as i64, (v.pos.y * pixels_per_point * GRID).round() as i64);
|
||||
let area = cross(at(&v[0]), at(&v[1]), at(&v[2]));
|
||||
if area == 0 {
|
||||
continue;
|
||||
}
|
||||
if area < 0 {
|
||||
v.swap(1, 2);
|
||||
}
|
||||
let area = area.abs() as f32;
|
||||
let [a, b, c] = [at(&v[0]), at(&v[1]), at(&v[2])];
|
||||
let range = |lo: i64, hi: i64, clip_lo: f32, clip_hi: f32, n: usize| {
|
||||
let lo = (lo as f32 / GRID).max(clip_lo * pixels_per_point).floor().max(0.0) as usize;
|
||||
let hi = ((hi as f32 / GRID).min(clip_hi * pixels_per_point).ceil().max(0.0) as usize).min(n);
|
||||
lo..hi
|
||||
};
|
||||
let upper_left = |p: (i64, i64), q: (i64, i64)| q.1 < p.1 || (q.1 == p.1 && q.0 > p.0);
|
||||
for y in range(a.1.min(b.1).min(c.1), a.1.max(b.1).max(c.1), clip.min.y, clip.max.y, size.1) {
|
||||
for x in range(a.0.min(b.0).min(c.0), a.0.max(b.0).max(c.0), clip.min.x, clip.max.x, size.0) {
|
||||
let q = (x as i64 * 256 + 128, y as i64 * 256 + 128);
|
||||
let (w0, w1, w2) = (cross(b, c, q), cross(c, a, q), cross(a, b, q));
|
||||
let inside = |w: i128, p, q| w > 0 || (w == 0 && upper_left(p, q));
|
||||
if !(inside(w0, b, c) && inside(w1, c, a) && inside(w2, a, b)) {
|
||||
continue;
|
||||
}
|
||||
let (w0, w1, w2) = (w0 as f32 / area, w1 as f32 / area, w2 as f32 / area);
|
||||
let uv = (w0 * v[0].uv.x + w1 * v[1].uv.x + w2 * v[2].uv.x, w0 * v[0].uv.y + w1 * v[1].uv.y + w2 * v[2].uv.y);
|
||||
let texel = sample(atlas, uv);
|
||||
let dst = &mut pixels[y * size.0 + x];
|
||||
let channels: [[u8; 4]; 3] = [v[0].color.to_array(), v[1].color.to_array(), v[2].color.to_array()];
|
||||
let src: [f32; 4] = std::array::from_fn(|i| {
|
||||
(w0 * f32::from(channels[0][i]) + w1 * f32::from(channels[1][i]) + w2 * f32::from(channels[2][i])) * texel[i] / 255.0
|
||||
});
|
||||
let keep = 1.0 - src[3] / 255.0;
|
||||
for i in 0..4 {
|
||||
dst[i] = src[i] + dst[i] * keep;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Picture { size, pixels }
|
||||
}
|
||||
|
||||
fn picture(ctx: &egui::Context, pixels_per_point: f32, draw: impl FnOnce(&egui::Painter)) -> Picture {
|
||||
// The window is larger than the picture at each display scale, and a new scale applies to the
|
||||
// pass after it is set.
|
||||
let raw = || egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(4.0 * SIZE.0, 4.0 * SIZE.1))),
|
||||
max_texture_side: Some(8192),
|
||||
..Default::default()
|
||||
};
|
||||
ctx.set_pixels_per_point(pixels_per_point);
|
||||
ctx.run_ui(raw(), |_| {}).textures_delta.clear();
|
||||
let mut draw = Some(draw);
|
||||
let mut out = ctx.run_ui(raw(), |ui| {
|
||||
if let Some(draw) = draw.take() {
|
||||
draw(&ui.painter_at(egui::Rect::from_min_size(Pos2::ZERO, vec2(SIZE.0, SIZE.1))));
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let primitives = ctx.tessellate(std::mem::take(&mut out.shapes), out.pixels_per_point);
|
||||
rasterize(&primitives, out.pixels_per_point, &ctx.fonts(|f| f.image()))
|
||||
}
|
||||
|
||||
/// The most a color channel may differ, out of 255: kept vertices move by rounding errors far under
|
||||
/// a pixel.
|
||||
const CHANNEL: f32 = 3.0;
|
||||
|
||||
fn assert_same(a: &Picture, b: &Picture, what: &str, allowed_per_mille: usize) {
|
||||
assert_eq!(a.size, b.size, "{what}");
|
||||
let drawn = b.pixels.iter().filter(|p| p[3] > 0.0).count();
|
||||
assert!(drawn > 0, "{what}: the picture is empty");
|
||||
let differ: Vec<usize> = (0..b.pixels.len()).filter(|i| a.pixels[*i].iter().zip(&b.pixels[*i]).any(|(x, y)| (x - y).abs() > CHANNEL)).collect();
|
||||
let first: Vec<_> = differ.iter().take(4).map(|i| ((i % a.size.0, i / a.size.0), a.pixels[*i], b.pixels[*i])).collect();
|
||||
assert!(
|
||||
differ.len() * 1000 <= drawn * allowed_per_mille,
|
||||
"{what}: {} of {drawn} drawn pixels differ, first at (pixel, got, want): {first:?}",
|
||||
differ.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn paint(marks: &mut SelectionMarks, app: &VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
|
||||
let st = app.session.active().unwrap();
|
||||
let key = st.selection.key;
|
||||
marks.paint(st, p, xf, &mark_options(app, curve_points_from_tool), key, &st.selection.objects);
|
||||
}
|
||||
|
||||
/// Kept marks, marks built from nothing and plain shapes all draw the same pixels. Kept marks may
|
||||
/// differ at up to 1 pixel in 1,000: at a corner of exactly 90 degrees on a rotated view, the
|
||||
/// tessellator picks a miter or a cut corner by a rounding error of the corner's position, and
|
||||
/// moved triangles keep the corner they were built with. An anchor square left stale covers more
|
||||
/// than this limit.
|
||||
#[track_caller]
|
||||
fn assert_matches_plain(ctx: &egui::Context, marks: &mut SelectionMarks, app: &VectorcraftApp, xf: &Xf, pixels_per_point: f32, what: &str) {
|
||||
let want = picture(ctx, pixels_per_point, |p| plain(app, p, xf, false));
|
||||
for frame in ["this frame", "the next frame"] {
|
||||
let kept = picture(ctx, pixels_per_point, |p| paint(marks, app, p, xf, false));
|
||||
assert_same(&kept, &want, &format!("{what}, kept, {frame}"), 1);
|
||||
}
|
||||
let mut fresh = SelectionMarks::default();
|
||||
for frame in ["drawn once", "built from nothing"] {
|
||||
let cold = picture(ctx, pixels_per_point, |p| paint(&mut fresh, app, p, xf, false));
|
||||
assert_same(&cold, &want, &format!("{what}, {frame}"), 0);
|
||||
}
|
||||
assert!(fresh.builds > 0, "{what}: the second frame builds the marks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_draw_the_same_pixels_as_plain_shapes_at_each_view() {
|
||||
let (mut app, _) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
for tool in ["selection", "directSelection", "curvature"] {
|
||||
app.select_tool(tool);
|
||||
for pixels_per_point in [1.0, 1.25, 2.0] {
|
||||
let mut marks = SelectionMarks::default();
|
||||
for zoom in [0.5, 1.0, 2.37, 8.0] {
|
||||
for rot in [0.0, 30.0, 90.0] {
|
||||
let view = xf(zoom, VIEW, rot);
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, pixels_per_point, &format!("{tool} {pixels_per_point}x {zoom} {rot}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pan_moves_the_marks_without_building_them() {
|
||||
let (mut app, _) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
app.select_tool("directSelection");
|
||||
for (pixels_per_point, rot) in [(1.0, 0.0), (1.25, 0.0), (2.0, 30.0)] {
|
||||
let mut marks = SelectionMarks::default();
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &xf(1.5, VIEW, rot), pixels_per_point, "before the pan");
|
||||
let builds = marks.builds;
|
||||
for (dx, dy) in [(10.0, 0.0), (3.0, 7.0), (0.37, 0.61), (-31.3, 12.9), (160.0, 90.0)] {
|
||||
let view = xf(1.5, (VIEW.0 + dx, VIEW.1 + dy), rot);
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, pixels_per_point, &format!("{pixels_per_point}x pan {dx} {dy}"));
|
||||
}
|
||||
assert_eq!(marks.builds, builds, "a pan builds nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_pan_at_high_zoom_builds_the_marks_again_and_stays_exact() {
|
||||
let (app, _) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
let mut marks = SelectionMarks::default();
|
||||
// 2,000 points per document point: the art runs far past what `f32` holds to a fine fraction.
|
||||
let zoom = 2_000.0;
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &xf(zoom, (180.0, 41.0), 0.0), 1.0, "the top of the curve's straight end");
|
||||
let builds = marks.builds;
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &xf(zoom, (180.0, 89.0), 0.0), 1.0, "96,000 px along the straight end");
|
||||
assert!(marks.builds > builds, "a pan past MAX_PAN builds the marks again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_frame_draws_the_mesh_it_joined() {
|
||||
let (app, _) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
let mut marks = SelectionMarks::default();
|
||||
let view = xf(1.0, VIEW, 0.0);
|
||||
picture(&ctx, 1.0, |p| paint(&mut marks, &app, p, &view, false));
|
||||
assert_eq!((marks.builds, marks.composes), (0, 0), "the first frame at a view keeps nothing");
|
||||
picture(&ctx, 1.0, |p| paint(&mut marks, &app, p, &view, false));
|
||||
let (builds, composes) = (marks.builds, marks.composes);
|
||||
assert!(builds > 0 && composes == 1);
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "an idle frame");
|
||||
assert_eq!((marks.builds, marks.composes), (builds, composes), "an idle frame builds and joins nothing");
|
||||
}
|
||||
|
||||
/// While the zoom or rotation changes in each frame, nothing is built or kept. The picture stays the
|
||||
/// same, and the first frame that repeats a view builds the marks.
|
||||
#[test]
|
||||
fn a_zoom_keeps_nothing_until_the_view_stays() {
|
||||
let (mut app, _) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
app.select_tool("directSelection");
|
||||
let mut marks = SelectionMarks::default();
|
||||
let settled = xf(1.5, VIEW, 0.0);
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &settled, 1.0, "before the zoom");
|
||||
let builds = marks.builds;
|
||||
for (zoom, rot) in [(1.6, 0.0), (1.7, 0.0), (1.7, 15.0), (1.9, 15.0), (2.37, 40.0)] {
|
||||
let view = xf(zoom, VIEW, rot);
|
||||
let want = picture(&ctx, 1.0, |p| plain(&app, p, &view, false));
|
||||
let drawn = picture(&ctx, 1.0, |p| paint(&mut marks, &app, p, &view, false));
|
||||
assert_same(&drawn, &want, &format!("zoom {zoom}, rotation {rot}"), 0);
|
||||
assert_eq!(marks.builds, builds, "zoom {zoom}, rotation {rot} builds nothing");
|
||||
assert!(marks.objects.is_empty() && marks.frame.is_empty(), "zoom {zoom}, rotation {rot} keeps nothing");
|
||||
}
|
||||
let last = xf(2.37, VIEW, 40.0);
|
||||
let want = picture(&ctx, 1.0, |p| plain(&app, p, &last, false));
|
||||
let drawn = picture(&ctx, 1.0, |p| paint(&mut marks, &app, p, &last, false));
|
||||
assert_same(&drawn, &want, "the same view again", 1);
|
||||
assert!(marks.builds > builds && !marks.objects.is_empty(), "the same view again builds the marks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_stale_marks_after_a_change_with_the_selection_tool() {
|
||||
no_stale_marks_after_a_change("selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_stale_marks_after_a_change_with_the_direct_selection_tool() {
|
||||
no_stale_marks_after_a_change("directSelection");
|
||||
}
|
||||
|
||||
/// Each change draws what plain shapes draw, and builds no more than the change touches.
|
||||
fn no_stale_marks_after_a_change(tool: &str) {
|
||||
let (mut app, curve) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
let mut marks = SelectionMarks::default();
|
||||
let view = xf(1.5, VIEW, 30.0);
|
||||
app.select_tool(tool);
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "at the start");
|
||||
let all = app.session.active().unwrap().selection.objects.clone();
|
||||
let ids = |list: &[NodeId]| json!({"ids": list.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
|
||||
// An edit of one object builds that object, and the symbol instance that measures the art of a
|
||||
// document at a new revision.
|
||||
let builds = marks.builds;
|
||||
app.run("select.set", json!({"ids": [curve.0]})).unwrap();
|
||||
app.run("object.nudge", json!({"dx": 12, "dy": -5})).unwrap();
|
||||
app.run("select.set", ids(&all)).unwrap();
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "after an edit");
|
||||
assert_eq!(marks.builds, builds + 2, "an edit of one object leaves the others as they were");
|
||||
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.run("select.set", ids(&all)).unwrap();
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "after an undo");
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
app.run("select.set", ids(&all)).unwrap();
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "after a redo");
|
||||
|
||||
// A layer color.
|
||||
let layer = app.session.active().unwrap().doc.layers[0].id;
|
||||
app.run("layer.setProps", json!({"id": layer.0, "color": "#ff00aa"})).unwrap();
|
||||
app.run("select.set", ids(&all)).unwrap();
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "after a layer color change");
|
||||
|
||||
// A change of the selection: fewer objects, other anchors, a segment, the key object.
|
||||
app.run("select.set", ids(&all[..2])).unwrap();
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with fewer objects");
|
||||
assert_eq!(marks.objects.len(), 2, "an object that left the selection has no entry");
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.selection.anchors.insert(curve, [(0, 1)].into_iter().collect());
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with one anchor of the curve selected");
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.selection.anchors.insert(curve, [(0, 2), (0, 3)].into_iter().collect());
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with other anchors of the curve selected");
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.selection.segments.insert(curve, [(0, 2)].into_iter().collect());
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with a segment of the curve selected");
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.selection.segments.clear();
|
||||
st.selection.anchors.clear();
|
||||
st.selection.key = Some(curve);
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with a key object");
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.selection.key = Some(all[1]);
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with another key object");
|
||||
app.session.active_mut().unwrap().selection.key = None;
|
||||
|
||||
// The tool and the display preferences.
|
||||
app.select_tool(if tool == "selection" { "directSelection" } else { "selection" });
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with the other tool");
|
||||
app.select_tool(tool);
|
||||
app.session.prefs.anchor_size = 6;
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with larger anchors");
|
||||
app.session.prefs.handle_style = "hollow".into();
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with hollow handles");
|
||||
app.session.prefs.show_handles_multiple_anchors = !app.session.prefs.show_handles_multiple_anchors;
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with the handles option changed");
|
||||
app.session.prefs.anchor_size = 1;
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "with the smallest anchors, which draw as lines");
|
||||
}
|
||||
|
||||
/// An instance's outline comes from its symbol's art, which is not part of the instance's node.
|
||||
#[test]
|
||||
fn an_edit_of_a_symbol_outlines_its_instance_again() {
|
||||
let (mut app, _) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
let mut marks = SelectionMarks::default();
|
||||
let view = xf(2.0, VIEW, 0.0);
|
||||
let instance = {
|
||||
let st = app.session.active().unwrap();
|
||||
st.selection.objects.iter().copied().find(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::SymbolInstance { .. }))).unwrap()
|
||||
};
|
||||
app.run("select.set", json!({"ids": [instance.0]})).unwrap();
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "before the edit");
|
||||
let before = app.session.active().unwrap().doc.node(instance).unwrap().geometric_bounds_with(&app.session.active().unwrap().doc.symbol_boxes());
|
||||
app.run("symbol.edit", json!({"name": "Mark"})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("object.scale", json!({"sx": 3, "sy": 2})).unwrap();
|
||||
app.run("symbol.exitEditing", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [instance.0]})).unwrap();
|
||||
let after = app.session.active().unwrap().doc.node(instance).unwrap().geometric_bounds_with(&app.session.active().unwrap().doc.symbol_boxes());
|
||||
assert_ne!(before, after, "the edit changes what the instance measures");
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "after the edit");
|
||||
}
|
||||
|
||||
/// A frame of the whole canvas keeps entries for the selected objects only.
|
||||
#[test]
|
||||
fn the_canvas_drops_entries_it_does_not_draw() {
|
||||
let (mut app, curve) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut VectorcraftApp| {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app);
|
||||
frame(&mut app);
|
||||
let all = app.session.active().unwrap().selection.objects.len();
|
||||
assert_eq!(app.canvas.selection_marks.objects.len(), all);
|
||||
app.run("select.set", json!({"ids": [curve.0]})).unwrap();
|
||||
frame(&mut app);
|
||||
assert_eq!(app.canvas.selection_marks.objects.len(), 1, "objects that left the selection");
|
||||
app.run("edit.clear", json!({"ids": [curve.0]})).unwrap();
|
||||
frame(&mut app);
|
||||
assert_eq!(app.canvas.selection_marks.objects.len(), 0, "an object that left the document");
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
frame(&mut app);
|
||||
frame(&mut app);
|
||||
assert!(!app.canvas.selection_marks.objects.is_empty());
|
||||
app.run("view.edges", json!({})).unwrap();
|
||||
frame(&mut app);
|
||||
assert_eq!(app.canvas.selection_marks.objects.len(), 0, "hidden edges keep nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_the_limit_the_marks_are_dropped_and_built_again() {
|
||||
let (app, _) = scene();
|
||||
let ctx = egui::Context::default();
|
||||
let mut marks = SelectionMarks::default();
|
||||
let view = xf(1.0, VIEW, 0.0);
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "at the limit");
|
||||
assert!(marks.kept > 0);
|
||||
marks.trim(marks.kept);
|
||||
assert!(!marks.objects.is_empty(), "at the limit the marks stay");
|
||||
marks.trim(marks.kept - 1);
|
||||
assert!(marks.objects.is_empty() && marks.frame.is_empty(), "past the limit nothing stays");
|
||||
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, "after dropping them");
|
||||
}
|
||||
@@ -1332,6 +1332,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`).
|
||||
Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms.
|
||||
A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460).
|
||||
The canvas keeps the selected objects' outlines, anchors and handles from one frame to the next as one mesh. It builds an object's marks again when the object, its layer color or its selected anchors change. A pan moves the kept outlines and handles and places the anchor squares again. A frame with a new zoom or rotation draws the marks one shape at a time and keeps nothing, and the next frame at that view builds them. The pixels are the same. On Windows at 1440 × 900, in a release build on a busy machine, with 2,000 selected circles of 50 anchors each, an idle frame takes 26.6 ms instead of 84.8 ms with the Selection tool and 27.5 ms instead of 160.9 ms with Direct Selection (medians of five runs). A frame during a zoom takes about what it did: 75.8 ms instead of 72.8 ms with the Selection tool.
|
||||
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
|
||||
In the web build the status bar's render and UI times are measured with the browser's `performance.now()`, and the Layers panel stops rendering row thumbnails in a frame once they have taken 8 ms.
|
||||
On the desktop, the Layers panel renders a row's thumbnail on its thumbnail thread when the row's paths, and the paths of its opacity masks that are not disabled, hold more than 2,000 anchors in all. It already did this for the first thumbnail of a row of more than 200 objects. Until its first thumbnail is ready, the row shows boxes at the bounds of up to 41 of its paths, each in its path's solid fill or stroke color or in gray, when its own paths, without its opacity masks, hold at most 2,000 anchors in all, and an empty white box otherwise. With one path of 200,000 anchors, the first frame after the panel opens takes 2.4 ms instead of 393 ms (release build, median of three runs).
|
||||
|
||||
Reference in New Issue
Block a user