mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Share shape gallery choices and procedural previews
* Share shape gallery choices and procedural previews * Clip shape previews to their supplied rectangles * Discard texture updates explicitly in headless rendering tests * Honor fill colors across complete star and heart contours * Make heart contour collection type explicit * Use fixed-size triangle chunks in fill regression --------- Co-authored-by: Fluorine <joseph.anglada92@gmail.com> Co-authored-by: Joseph Anglada Muñiz <janglada@nd-soft.com>
This commit is contained in:
6 files changed
+452
No files matched your search
@@ -97,6 +97,8 @@ is separate; application-specific dialog behavior remains in each app.
|
||||
|
||||
## SVG catalogs
|
||||
|
||||
For common Insert Shapes choices and previews, see [the shared shape gallery](docs/shape-gallery.md).
|
||||
|
||||
```rust
|
||||
use std::sync::OnceLock;
|
||||
use craft_ui::icons::{SvgIconSet, SvgStyle};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Shared Insert Shapes choices and previews
|
||||
|
||||
`craft_ui::shapes` shares the shape/connector menu and original procedural previews contributed in
|
||||
[WordCraft #2142](https://github.com/storytold/wordcraft/pull/2142). It provides semantic preset
|
||||
identities, default ordering, connector/arrow classification, capability filtering and a menu built
|
||||
on the existing accessible `IconButton`. This is reusable menu logic as well as painting.
|
||||
|
||||
The overlap is concrete: GridCraft's [Insert ribbon](https://github.com/storytold/gridcraft/blob/b8771d51fb4da60bd48c13cf313d6d6014dc5e66/crates/ui-egui/src/ribbon.rs#L1534)
|
||||
offers rectangle, rounded rectangle, ellipse, triangle, line, arrow and text box. WordCraft also
|
||||
offers diamond, star, heart and connectors. Each host must filter by what its engine supports;
|
||||
a preview does not imply import/export or rendering support in that app.
|
||||
|
||||
```rust
|
||||
let chosen = craft_ui::shapes::menu(ui, 220.0, font, &button_style,
|
||||
|preset| supported_by_this_app(preset),
|
||||
|preset| localize(preset.source_label()),
|
||||
);
|
||||
if let Some(preset) = chosen {
|
||||
// Adapt the semantic choice to this app's insertion operation.
|
||||
}
|
||||
```
|
||||
|
||||
`ShapePreset::key()` is a stable choice key, not a universal command payload.
|
||||
The enum is non-exhaustive: capability adapters should reject unknown future presets by default.
|
||||
WordCraft maps `StraightArrowConnector` to `kind: "straightConnector", arrow: "end"`; GridCraft
|
||||
must hide it until its own engine supports attachment/routing. An ordinary `Arrow` remains a
|
||||
different semantic choice. The library does not silently substitute unsupported objects.
|
||||
|
||||
Apps own localization, theme, popup lifetime, current document, insertion coordinates, undo and
|
||||
selection. Additional actions such as New Drawing Canvas remain app-specific menu entries.
|
||||
`paint_preset` is also available for app-owned gallery layouts. The previews use original path
|
||||
geometry and no external assets. They are UI icons, not editable document geometry or a shape
|
||||
rendering engine. Star and heart contours honor `fill` across their full area, including with no
|
||||
outline; their fixed concave paths use triangle fans around verified interior centers.
|
||||
Consumer adoption and command adapters are separate work.
|
||||
@@ -18,6 +18,7 @@ pub mod layout;
|
||||
pub mod localized;
|
||||
pub mod panes;
|
||||
pub mod printing;
|
||||
pub mod shapes;
|
||||
pub mod tabs;
|
||||
#[cfg(all(feature = "screen-testing", not(target_arch = "wasm32")))]
|
||||
pub mod testing;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Shared shape-choice semantics and procedural previews, without document insertion commands.
|
||||
|
||||
use crate::buttons::{IconButton, IconButtonStyle};
|
||||
use egui::{Align2, FontId, Ui, Vec2};
|
||||
|
||||
mod paint;
|
||||
pub use paint::paint_preset;
|
||||
|
||||
/// A menu choice, not a document shape model. Connector routing/attachment and text editing stay
|
||||
/// in each app's engine. Stable keys are suitable for IDs and explicit adapters, not localized text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum ShapePreset {
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Ellipse,
|
||||
Triangle,
|
||||
Diamond,
|
||||
Line,
|
||||
Arrow,
|
||||
Star,
|
||||
Heart,
|
||||
TextBox,
|
||||
StraightConnector,
|
||||
StraightArrowConnector,
|
||||
ElbowConnector,
|
||||
ElbowArrowConnector,
|
||||
CurvedConnector,
|
||||
CurvedArrowConnector,
|
||||
}
|
||||
|
||||
/// Default presentation order. Filter by actual application capabilities before showing choices;
|
||||
/// having a preview here never means an application's importer/renderer supports the shape.
|
||||
pub const PRESETS: &[ShapePreset] = &[
|
||||
ShapePreset::Rectangle,
|
||||
ShapePreset::RoundedRectangle,
|
||||
ShapePreset::Ellipse,
|
||||
ShapePreset::Triangle,
|
||||
ShapePreset::Diamond,
|
||||
ShapePreset::Line,
|
||||
ShapePreset::Arrow,
|
||||
ShapePreset::Star,
|
||||
ShapePreset::Heart,
|
||||
ShapePreset::TextBox,
|
||||
ShapePreset::StraightConnector,
|
||||
ShapePreset::StraightArrowConnector,
|
||||
ShapePreset::ElbowConnector,
|
||||
ShapePreset::ElbowArrowConnector,
|
||||
ShapePreset::CurvedConnector,
|
||||
ShapePreset::CurvedArrowConnector,
|
||||
];
|
||||
|
||||
impl ShapePreset {
|
||||
/// Stable semantic choice key, independent of labels and any application's command protocol.
|
||||
pub const fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rectangle => "rectangle",
|
||||
Self::RoundedRectangle => "roundedRectangle",
|
||||
Self::Ellipse => "ellipse",
|
||||
Self::Triangle => "triangle",
|
||||
Self::Diamond => "diamond",
|
||||
Self::Line => "line",
|
||||
Self::Arrow => "arrow",
|
||||
Self::Star => "star",
|
||||
Self::Heart => "heart",
|
||||
Self::TextBox => "textBox",
|
||||
Self::StraightConnector => "straightConnector",
|
||||
Self::StraightArrowConnector => "straightArrowConnector",
|
||||
Self::ElbowConnector => "elbowConnector",
|
||||
Self::ElbowArrowConnector => "elbowArrowConnector",
|
||||
Self::CurvedConnector => "curvedConnector",
|
||||
Self::CurvedArrowConnector => "curvedArrowConnector",
|
||||
}
|
||||
}
|
||||
|
||||
/// English source string for the application's localization catalog. The menu always asks
|
||||
/// the caller for a label; it never installs a translation service or uses a label as identity.
|
||||
pub const fn source_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rectangle => "Rectangle",
|
||||
Self::RoundedRectangle => "Rounded Rectangle",
|
||||
Self::Ellipse => "Oval",
|
||||
Self::Triangle => "Triangle",
|
||||
Self::Diamond => "Diamond",
|
||||
Self::Line => "Line",
|
||||
Self::Arrow => "Arrow",
|
||||
Self::Star => "Star",
|
||||
Self::Heart => "Heart",
|
||||
Self::TextBox => "Text Box",
|
||||
Self::StraightConnector => "Straight Connector",
|
||||
Self::StraightArrowConnector => "Straight Arrow Connector",
|
||||
Self::ElbowConnector => "Elbow Connector",
|
||||
Self::ElbowArrowConnector => "Elbow Arrow Connector",
|
||||
Self::CurvedConnector => "Curved Connector",
|
||||
Self::CurvedArrowConnector => "Curved Arrow Connector",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_connector(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::StraightConnector
|
||||
| Self::StraightArrowConnector
|
||||
| Self::ElbowConnector
|
||||
| Self::ElbowArrowConnector
|
||||
| Self::CurvedConnector
|
||||
| Self::CurvedArrowConnector
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this choice has an arrow at its end. An ordinary arrow remains a different
|
||||
/// choice from a connector with an arrow; the application decides its attachment semantics.
|
||||
pub const fn has_end_arrow(self) -> bool {
|
||||
matches!(self, Self::Arrow | Self::StraightArrowConnector | Self::ElbowArrowConnector | Self::CurvedArrowConnector)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the default shape list, returning the chosen semantic preset without executing commands.
|
||||
///
|
||||
/// `supported` hides capabilities absent from the host. `label` supplies localized accessible
|
||||
/// text; IDs remain stable even for duplicate translations. The caller owns the popup, theme,
|
||||
/// font, optional canvas/gallery actions and command mapping. Wrap repeated menus in `push_id`.
|
||||
/// Uses the existing [`IconButton`] for keyboard/pointer/accessibility and disabled-state behavior.
|
||||
/// Rows clip long labels; widths must be finite, positive and at most 4096 points or no rows show.
|
||||
pub fn menu(
|
||||
ui: &mut Ui,
|
||||
width: f32,
|
||||
font: FontId,
|
||||
style: &IconButtonStyle,
|
||||
mut supported: impl FnMut(ShapePreset) -> bool,
|
||||
mut label: impl FnMut(ShapePreset) -> String,
|
||||
) -> Option<ShapePreset> {
|
||||
if !width.is_finite() || width <= 0.0 || width > 4096.0 {
|
||||
return None;
|
||||
}
|
||||
let font = FontId::new(if font.size.is_finite() { font.size.clamp(1.0, 128.0) } else { 12.0 }, font.family);
|
||||
let height = (font.size + 10.0).max(26.0);
|
||||
let mut chosen = None;
|
||||
let mut previous_connector = false;
|
||||
let mut have_item = false;
|
||||
for &preset in PRESETS {
|
||||
if !supported(preset) {
|
||||
continue;
|
||||
}
|
||||
if have_item && preset.is_connector() && !previous_connector {
|
||||
ui.separator();
|
||||
}
|
||||
previous_connector = preset.is_connector();
|
||||
have_item = true;
|
||||
let label = label(preset);
|
||||
ui.push_id(preset.key(), |ui| {
|
||||
let response = IconButton::new(&label, Vec2::new(width, height), style).show(ui, |ui, rect, tint| {
|
||||
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(rect));
|
||||
let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + 14.0, rect.center().y), Vec2::splat(20.0));
|
||||
paint_preset(&painter, icon, preset, egui::Stroke::new(1.4, tint), tint.gamma_multiply(0.15));
|
||||
let text_rect = egui::Rect::from_min_max(egui::pos2((rect.left() + 30.0).min(rect.right()), rect.top()), rect.max);
|
||||
painter.with_clip_rect(ui.clip_rect().intersect(text_rect)).text(
|
||||
text_rect.left_center(),
|
||||
Align2::LEFT_CENTER,
|
||||
&label,
|
||||
font.clone(),
|
||||
tint,
|
||||
);
|
||||
});
|
||||
if response.clicked() {
|
||||
chosen = Some(preset);
|
||||
}
|
||||
});
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::ShapePreset;
|
||||
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, vec2};
|
||||
|
||||
/// Paint an original procedural preview, adapted from WordCraft's Insert Shapes menu (#2142).
|
||||
/// Preview geometry is independent of document geometry: it does not insert/render editable
|
||||
/// document objects. No external icon assets, fonts or application dependencies are needed.
|
||||
/// Invalid/empty/excessive rectangles produce no paint; stroke widths are bounded to the icon.
|
||||
pub fn paint_preset(painter: &Painter, rect: Rect, preset: ShapePreset, stroke: Stroke, fill: Color32) {
|
||||
if !rect.is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 || rect.size().max_elem() > 4096.0 {
|
||||
return;
|
||||
}
|
||||
let painter = painter.with_clip_rect(painter.clip_rect().intersect(rect));
|
||||
let scale = rect.width().min(rect.height()) / 20.0;
|
||||
let origin = rect.center() - vec2(10.0, 10.0) * scale;
|
||||
let point = |x: f32, y: f32| origin + vec2(x, y) * scale;
|
||||
let width = if stroke.width.is_finite() { stroke.width.clamp(0.0, rect.width().min(rect.height()) * 0.25) } else { 0.0 };
|
||||
let stroke = Stroke::new(width, stroke.color);
|
||||
let polygon = |points: &[(f32, f32)]| {
|
||||
painter.add(Shape::convex_polygon(points.iter().map(|&(x, y)| point(x, y)).collect(), fill, stroke));
|
||||
};
|
||||
let line = |points: &[(f32, f32)]| {
|
||||
painter.add(Shape::line(points.iter().map(|&(x, y)| point(x, y)).collect(), stroke));
|
||||
};
|
||||
let head = |tip: Pos2, previous: Pos2| {
|
||||
let direction = (tip - previous).normalized();
|
||||
let side = vec2(-direction.y, direction.x);
|
||||
painter.add(Shape::line(
|
||||
vec![tip - direction * (4.0 * scale) + side * (2.5 * scale), tip, tip - direction * (4.0 * scale) - side * (2.5 * scale)],
|
||||
stroke,
|
||||
));
|
||||
};
|
||||
match preset {
|
||||
ShapePreset::Rectangle | ShapePreset::RoundedRectangle | ShapePreset::TextBox => {
|
||||
let shape = Rect::from_min_max(point(3.0, 5.0), point(17.0, 15.0));
|
||||
let radius = if preset == ShapePreset::RoundedRectangle { 3.0 * scale } else { 0.0 };
|
||||
painter.rect(shape, radius, fill, stroke, StrokeKind::Inside);
|
||||
if preset == ShapePreset::TextBox {
|
||||
for y in [8.0, 10.0, 12.0] {
|
||||
line(&[(6.0, y), (14.0, y)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ShapePreset::Ellipse => {
|
||||
painter.add(Shape::ellipse_filled(rect.center(), vec2(7.5, 5.5) * scale, fill));
|
||||
painter.add(Shape::ellipse_stroke(rect.center(), vec2(7.5, 5.5) * scale, stroke));
|
||||
}
|
||||
ShapePreset::Triangle => polygon(&[(10.0, 3.5), (17.0, 16.5), (3.0, 16.5)]),
|
||||
ShapePreset::Diamond => polygon(&[(10.0, 3.0), (17.0, 10.0), (10.0, 17.0), (3.0, 10.0)]),
|
||||
ShapePreset::Line | ShapePreset::Arrow => {
|
||||
line(&[(4.0, 16.0), (16.0, 4.0)]);
|
||||
if preset.has_end_arrow() {
|
||||
head(point(16.0, 4.0), point(4.0, 16.0));
|
||||
}
|
||||
}
|
||||
ShapePreset::Star => {
|
||||
let points: Vec<_> = (0..10)
|
||||
.map(|i| {
|
||||
let angle = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
|
||||
let radius = if i % 2 == 0 { 8.0 } else { 3.3 };
|
||||
point(10.0 + radius * angle.cos(), 10.8 + radius * angle.sin())
|
||||
})
|
||||
.collect();
|
||||
fill_contour(&painter, point(10.0, 10.8), &points, fill);
|
||||
painter.add(Shape::closed_line(points, stroke));
|
||||
}
|
||||
ShapePreset::Heart => {
|
||||
let points: Vec<_> = (0..32)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 32.0 * std::f32::consts::TAU;
|
||||
point(
|
||||
10.0 + 16.0 * t.sin().powi(3) * 0.44,
|
||||
9.6 - (13.0 * t.cos() - 5.0 * (2.0 * t).cos() - 2.0 * (3.0 * t).cos() - (4.0 * t).cos()) * 0.44,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
fill_contour(&painter, point(10.0, 10.0), &points, fill);
|
||||
painter.add(Shape::closed_line(points, stroke));
|
||||
}
|
||||
_ => {
|
||||
let points: Vec<_> = match preset {
|
||||
ShapePreset::ElbowConnector | ShapePreset::ElbowArrowConnector => {
|
||||
vec![point(4.0, 15.0), point(10.0, 15.0), point(10.0, 5.0), point(16.0, 5.0)]
|
||||
}
|
||||
ShapePreset::CurvedConnector | ShapePreset::CurvedArrowConnector => (0..=16)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 16.0;
|
||||
let m = 1.0 - t;
|
||||
let w = [m * m * m, 3.0 * m * m * t, 3.0 * m * t * t, t * t * t];
|
||||
point(w[0] * 4.0 + w[1] * 12.0 + w[2] * 8.0 + w[3] * 16.0, w[0] * 15.0 + w[1] * 15.0 + w[2] * 5.0 + w[3] * 5.0)
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![point(4.0, 15.0), point(16.0, 5.0)],
|
||||
};
|
||||
painter.add(Shape::line(points.clone(), stroke));
|
||||
if let Some(&first) = points.first() {
|
||||
painter.circle_filled(first, 1.6 * scale, stroke.color);
|
||||
}
|
||||
if let [.., previous, tip] = points.as_slice() {
|
||||
if preset.has_end_arrow() {
|
||||
head(*tip, *previous);
|
||||
} else {
|
||||
painter.circle_filled(*tip, 1.6 * scale, stroke.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The painter's existing clip handles its host; all preview geometry stays in the icon box.
|
||||
}
|
||||
|
||||
/// These two fixed contours are star-shaped around the supplied center: it lies in every
|
||||
/// edge's interior half-plane. A triangle fan therefore fills their concavities without the
|
||||
/// convex-polygon assumption. This is not a triangulator for arbitrary application paths.
|
||||
fn fill_contour(painter: &Painter, center: Pos2, points: &[Pos2], fill: Color32) {
|
||||
if points.len() < 3 || fill == Color32::TRANSPARENT {
|
||||
return;
|
||||
}
|
||||
let mut mesh = egui::Mesh::default();
|
||||
mesh.colored_vertex(center, fill);
|
||||
for &point in points {
|
||||
mesh.colored_vertex(point, fill);
|
||||
}
|
||||
for index in 0..points.len() {
|
||||
mesh.add_triangle(0, 1 + index as u32, 1 + ((index + 1) % points.len()) as u32);
|
||||
}
|
||||
painter.add(Shape::mesh(mesh));
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::*;
|
||||
use crate::buttons::ButtonVisuals;
|
||||
use egui::{Color32, CornerRadius, Stroke};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
fn style() -> IconButtonStyle {
|
||||
let normal = ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: Color32::BLACK };
|
||||
IconButtonStyle {
|
||||
normal,
|
||||
hovered: ButtonVisuals { fill: Color32::LIGHT_BLUE, ..normal },
|
||||
pressed: None,
|
||||
selected: normal,
|
||||
corner_radius: CornerRadius::same(3),
|
||||
focus_stroke: Stroke::new(1.0, Color32::BLUE),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_filtered_menu_uses_stable_identity_for_duplicate_localized_labels() {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 300.0)).build_ui_state(
|
||||
|ui, chosen: &mut Option<ShapePreset>| {
|
||||
if let Some(preset) = menu(
|
||||
ui,
|
||||
180.0,
|
||||
FontId::proportional(12.0),
|
||||
&style(),
|
||||
|p| matches!(p, ShapePreset::Rectangle | ShapePreset::Ellipse),
|
||||
|_| "Object".into(),
|
||||
) {
|
||||
*chosen = Some(preset);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
h.run_steps(2);
|
||||
let items: Vec<_> = h.get_all_by_label("Object").collect();
|
||||
assert_eq!(items.len(), 2);
|
||||
items[1].click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(*h.state(), Some(ShapePreset::Ellipse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_keys_are_unique_and_arrow_connector_semantics_remain_distinct() {
|
||||
let keys: std::collections::HashSet<_> = PRESETS.iter().map(|p| p.key()).collect();
|
||||
assert_eq!(keys.len(), PRESETS.len());
|
||||
assert!(ShapePreset::StraightArrowConnector.is_connector());
|
||||
assert!(ShapePreset::StraightArrowConnector.has_end_arrow());
|
||||
assert!(!ShapePreset::Arrow.is_connector());
|
||||
assert!(ShapePreset::Arrow.has_end_arrow());
|
||||
assert!(!ShapePreset::ElbowConnector.has_end_arrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_procedural_preview_tessellates_and_invalid_geometry_is_inert() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
for &preset in PRESETS {
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), Vec2::splat(20.0));
|
||||
paint_preset(ui.painter(), rect, preset, Stroke::new(f32::NAN, Color32::BLACK), Color32::LIGHT_BLUE);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
for primitive in ctx.tessellate(out.shapes, out.pixels_per_point) {
|
||||
assert!(primitive.clip_rect.min.x >= 10.0 && primitive.clip_rect.min.y >= 10.0);
|
||||
assert!(primitive.clip_rect.max.x <= 30.0 && primitive.clip_rect.max.y <= 30.0);
|
||||
if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive {
|
||||
assert!(mesh.is_valid());
|
||||
assert!(mesh.vertices.iter().all(|v| v.pos.is_finite()));
|
||||
}
|
||||
}
|
||||
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
for &preset in PRESETS {
|
||||
paint_preset(ui.painter(), egui::Rect::NAN, preset, Stroke::NONE, Color32::WHITE);
|
||||
}
|
||||
});
|
||||
assert!(out.shapes.is_empty() || out.shapes.iter().all(|s| matches!(s.shape, egui::Shape::Noop)));
|
||||
out.drop_without_applying_deltas();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concave_previews_fill_the_star_tips_and_heart_without_an_outline() {
|
||||
let cross = |a: egui::Pos2, b: egui::Pos2, c: egui::Pos2| {
|
||||
let ab = b - a;
|
||||
let ac = c - a;
|
||||
ab.x * ac.y - ab.y * ac.x
|
||||
};
|
||||
for (preset, sample) in [(ShapePreset::Star, egui::pos2(20.0, 14.0)), (ShapePreset::Heart, egui::pos2(20.0, 20.0))] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
paint_preset(ui.painter(), egui::Rect::from_min_size(egui::pos2(10.0, 10.0), Vec2::splat(20.0)), preset, Stroke::NONE, Color32::RED);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut covered = false;
|
||||
for primitive in ctx.tessellate(out.shapes, out.pixels_per_point) {
|
||||
if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive {
|
||||
assert!(mesh.is_valid());
|
||||
for triangle in mesh.indices.as_chunks::<3>().0 {
|
||||
let [a, b, c] = [mesh.vertices[triangle[0] as usize], mesh.vertices[triangle[1] as usize], mesh.vertices[triangle[2] as usize]];
|
||||
// Consistent winding verifies each fixed contour's center stays in its kernel.
|
||||
assert!(cross(a.pos, b.pos, c.pos) > 0.0);
|
||||
covered |= primitive.clip_rect.contains(sample)
|
||||
&& a.color == Color32::RED
|
||||
&& b.color == Color32::RED
|
||||
&& c.color == Color32::RED
|
||||
&& cross(a.pos, b.pos, sample) >= -0.0001
|
||||
&& cross(b.pos, c.pos, sample) >= -0.0001
|
||||
&& cross(c.pos, a.pos, sample) >= -0.0001;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(covered, "{preset:?} must honor its fill with no outline");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user