mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 14:46:03 +00:00
Merge main into fix/plugin-grants-delete-test-portable
This commit is contained in:
+144
-25
@@ -534,8 +534,8 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
}
|
||||
|
||||
/// One part of [`solid_parts`]: a fill or stroke, its shape's opacity, the region it paints and its
|
||||
/// fill rule, and its shape's scopes.
|
||||
type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]);
|
||||
/// fill rule, its shape's scopes and the index of its shape.
|
||||
type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope], usize);
|
||||
|
||||
/// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes,
|
||||
/// a group's members from the back up, each with its shape's opacity, the region it paints and its
|
||||
@@ -544,18 +544,128 @@ type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]);
|
||||
fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
|
||||
let parts: Vec<Part> = shapes
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
.enumerate()
|
||||
.flat_map(|(member, s)| {
|
||||
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| {
|
||||
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(move |(_, i)| {
|
||||
let (area, rule) = region(&s.path, s.rule, i);
|
||||
(i, s.opacity, area, rule, s.scopes.as_slice())
|
||||
(i, s.opacity, area, rule, s.scopes.as_slice(), member)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let bounds = parts.iter().filter_map(|(_, _, area, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
|
||||
let bounds = parts.iter().filter_map(|(_, _, area, _, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
|
||||
(parts, bounds)
|
||||
}
|
||||
|
||||
/// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's
|
||||
/// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of
|
||||
/// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its
|
||||
/// strokes that covers the fill's whole edge.
|
||||
struct Placed {
|
||||
depth: f64,
|
||||
side: bool,
|
||||
away: bool,
|
||||
bounds: Rect,
|
||||
member: usize,
|
||||
paints: Paints,
|
||||
}
|
||||
|
||||
/// The part of its shape a face's solid is built from ([`Placed`]).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Paints {
|
||||
Fill,
|
||||
/// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without
|
||||
/// dashes, a width profile or a brush, painted with a solid color or a gradient without
|
||||
/// transparent stops, on a shape whose subpaths are all closed.
|
||||
Stroke,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Paints {
|
||||
/// What `item` of a shape is, the shape's subpaths all closed when `closed`.
|
||||
fn of(item: &AppearanceItem, closed: bool) -> Self {
|
||||
match item {
|
||||
AppearanceItem::Fill(_) => Paints::Fill,
|
||||
AppearanceItem::Stroke(s)
|
||||
if closed
|
||||
&& s.width > 0.0
|
||||
&& s.width.is_finite()
|
||||
&& s.dash.is_none()
|
||||
&& s.profile.is_none()
|
||||
&& s.brush.is_none()
|
||||
&& match &s.paint {
|
||||
Paint::Solid { .. } => true,
|
||||
Paint::Gradient(_) => crate::lighting::opaque_paint(&s.paint).1.is_none(),
|
||||
_ => false,
|
||||
} =>
|
||||
{
|
||||
Paints::Stroke
|
||||
}
|
||||
AppearanceItem::Stroke(_) => Paints::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Placed {
|
||||
/// The face of `f`, of shape `member`'s part `paints`.
|
||||
fn of(f: &Facet, member: usize, paints: Paints) -> Self {
|
||||
let mut points = f.contours.iter().flatten();
|
||||
let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q)));
|
||||
Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints }
|
||||
}
|
||||
}
|
||||
|
||||
/// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep
|
||||
/// their depths.
|
||||
const MAX_OVERLAP_CHECKS: usize = 4_000_000;
|
||||
|
||||
/// The order faces `faces` are drawn in, back to front, as indices into `faces`: by depth (faces
|
||||
/// at the same depth in the order given), except that with `sealed` (solids with caps) a side face
|
||||
/// of a shape's fill turned toward the viewer is drawn before each face of the same shape's
|
||||
/// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own.
|
||||
/// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under
|
||||
/// any other face.
|
||||
fn draw_order(faces: &[Placed], sealed: bool) -> Vec<usize> {
|
||||
// Each face's depth to sort by, and whether it was moved before a stroke face at that depth.
|
||||
let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect();
|
||||
if sealed {
|
||||
// The stroke faces turned toward the viewer of each shape, nearest last.
|
||||
let mut strokes: Vec<Vec<&Placed>> = vec![];
|
||||
for f in faces.iter().filter(|f| f.paints == Paints::Stroke && !f.away) {
|
||||
if strokes.len() <= f.member {
|
||||
strokes.resize_with(f.member + 1, Vec::new);
|
||||
}
|
||||
strokes[f.member].push(f);
|
||||
}
|
||||
for list in &mut strokes {
|
||||
list.sort_by(|a, b| a.depth.total_cmp(&b.depth));
|
||||
}
|
||||
let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
||||
let mut checks = 0usize;
|
||||
for (f, key) in faces.iter().zip(&mut keys) {
|
||||
if f.paints != Paints::Fill || !f.side || f.away {
|
||||
continue;
|
||||
}
|
||||
let Some(list) = strokes.get(f.member).filter(|l| !l.is_empty()) else { continue };
|
||||
for s in list.iter().take_while(|s| s.depth <= f.depth) {
|
||||
if checks >= MAX_OVERLAP_CHECKS {
|
||||
break;
|
||||
}
|
||||
checks += 1;
|
||||
if overlap(&s.bounds, &f.bounds) {
|
||||
*key = (s.depth, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut order: Vec<usize> = (0..faces.len()).collect();
|
||||
// A stable sort: faces at the same depth keep the order of the appearance, and a fill's side
|
||||
// face moved to a stroke face's depth goes before it.
|
||||
order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1)));
|
||||
order
|
||||
}
|
||||
|
||||
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
|
||||
/// its 3D effect, about the bounds of all of them.
|
||||
pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
|
||||
@@ -577,7 +687,9 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
|
||||
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
|
||||
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
|
||||
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
||||
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
|
||||
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with
|
||||
/// caps, a shape's fill walls before the faces of its covering strokes that overlap them:
|
||||
/// [`draw_order`]);
|
||||
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
|
||||
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
|
||||
/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it
|
||||
@@ -628,17 +740,17 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<
|
||||
let inks: Vec<shadow::Ink> = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect();
|
||||
// Inflate's eye is placed for every shape together, as the solids are.
|
||||
let added: Vec<Result<std::ops::Range<usize>, String>> = if id == INFLATE {
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
|
||||
let casts: Vec<bool> = inks.iter().map(|i| i.opacity > 0.0).collect();
|
||||
caster.inflate(&shapes, &casts, bounds, inflate_options(p), view)
|
||||
} else {
|
||||
let mut added = vec![];
|
||||
for ((_, _, area, rule, _), ink) in parts.iter().zip(&inks) {
|
||||
for ((_, _, area, rule, _, _), ink) in parts.iter().zip(&inks) {
|
||||
added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) });
|
||||
}
|
||||
added
|
||||
};
|
||||
for (((item, _, _, _, scopes), ink), faces) in parts.iter().zip(inks).zip(added) {
|
||||
for (((item, _, _, _, scopes, _), ink), faces) in parts.iter().zip(inks).zip(added) {
|
||||
if ink.opacity <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
@@ -688,17 +800,17 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
|
||||
let (parts, bounds) = solid_parts(shapes, b);
|
||||
let built = if fx.id == INFLATE {
|
||||
// One eye for every puff, as far away as the highest needs.
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
||||
} else {
|
||||
parts.iter().map(|(_, _, area, rule, _)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
parts.iter().map(|(_, _, area, rule, _, _)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
};
|
||||
let mut solids = vec![];
|
||||
for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) {
|
||||
for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built) {
|
||||
let item: &AppearanceItem = item;
|
||||
match f {
|
||||
Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None,
|
||||
Ok(f) => solids.push((item, *opacity, *scopes, f)),
|
||||
Ok(f) => solids.push((item, *opacity, *scopes, f, *member)),
|
||||
// A stroke's outline past the limits is left out; the fill's solid still draws.
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path
|
||||
@@ -710,14 +822,16 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
|
||||
if solids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let opaque = solids.iter().all(|(item, member, _, _)| {
|
||||
let opaque = solids.iter().all(|(item, member, _, _, _)| {
|
||||
let (_, opacity, blend) = paint_of(item, *member);
|
||||
opacity >= 1.0 && blend == BlendMode::Normal
|
||||
});
|
||||
let surfaces: Vec<(Vec<f64>, Node, &[Scope])> = solids
|
||||
let surfaces: Vec<(Vec<Placed>, Node, &[Scope])> = solids
|
||||
.into_iter()
|
||||
.map(|(item, member, scopes, f)| {
|
||||
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
|
||||
.map(|(item, member, scopes, f, shape)| {
|
||||
let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed));
|
||||
let paints = Paints::of(item, closed);
|
||||
let depths: Vec<Placed> = f.iter().map(|f| Placed::of(f, shape, paints)).collect();
|
||||
let (paint, opacity, blend) = paint_of(item, member);
|
||||
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
|
||||
(depths, art, scopes)
|
||||
@@ -735,15 +849,20 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
|
||||
if !opaque || !plain {
|
||||
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
|
||||
}
|
||||
let mut faces = vec![];
|
||||
let (mut placed, mut faces) = (vec![], vec![]);
|
||||
for (depths, s, _) in surfaces {
|
||||
if let NodeKind::Group { children, .. } = s.kind {
|
||||
faces.extend(depths.into_iter().zip(children));
|
||||
placed.extend(depths);
|
||||
faces.extend(children.into_iter().map(Some));
|
||||
}
|
||||
}
|
||||
// A stable sort: faces at the same depth keep the order of the appearance.
|
||||
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))])
|
||||
let sealed = match fx.id.as_str() {
|
||||
INFLATE => inflate_options(&p).cap,
|
||||
CLASSIC_EXTRUDE => classic_extrude_options(&p).cap,
|
||||
_ => extrude_options(&p).cap,
|
||||
};
|
||||
let order = draw_order(&placed, sealed);
|
||||
Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
@@ -896,11 +1015,11 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
|
||||
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
|
||||
// solid and its mesh are built as the loop reaches them.
|
||||
let mut inflated = (fx.id == INFLATE).then(|| {
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
|
||||
});
|
||||
let extrude = extrude_options(&p);
|
||||
for (item, member, area, rule, _) in &parts {
|
||||
for (item, member, area, rule, _, _) in &parts {
|
||||
let item: &AppearanceItem = item;
|
||||
let mesh = match inflated.as_mut() {
|
||||
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
|
||||
|
||||
@@ -180,6 +180,137 @@ fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A C, open to the right: two concave corners where its walls meet the walls of the notch.
|
||||
fn letter_c() -> BezPath {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((200.0, 100.0));
|
||||
b.line_to((200.0, 130.0));
|
||||
b.line_to((130.0, 130.0));
|
||||
b.line_to((130.0, 170.0));
|
||||
b.line_to((200.0, 170.0));
|
||||
b.line_to((200.0, 200.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
b
|
||||
}
|
||||
|
||||
/// The pairs (blue, red) of `walls`, by index, where the blue face is drawn first and the two
|
||||
/// faces' bounds overlap.
|
||||
fn red_after_blue(walls: &[&Node]) -> Vec<(usize, usize)> {
|
||||
let bounds: Vec<Rect> = walls.iter().map(|f| f.path_data().and_then(PathData::bounds).unwrap()).collect();
|
||||
let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
||||
let mut out = vec![];
|
||||
for (i, a) in walls.iter().enumerate() {
|
||||
for (j, b) in walls.iter().enumerate().skip(i + 1) {
|
||||
if blue(a) && !blue(b) && overlap(&bounds[i], &bounds[j]) {
|
||||
out.push((i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An opaque stroke's solid holds the fill's edge, so the fill's walls are drawn before every
|
||||
/// face of the stroke they overlap, and the stroke's walls cover them at the concave corners of a
|
||||
/// turned C, in Extrude & Bevel and Extrude & Bevel (Classic). The front caps stay last, the
|
||||
/// fill's below the stroke's.
|
||||
#[test]
|
||||
fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() {
|
||||
let views = [json!({}), json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10})];
|
||||
for id in [EXTRUDE, CLASSIC_EXTRUDE] {
|
||||
for view in &views {
|
||||
let mut params = view.clone();
|
||||
params["depth"] = json!(60);
|
||||
let mut n = stroked(&letter_c());
|
||||
n.appearance.effects.push(fx(id, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let [walls @ .., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{id} {view}: the caps") };
|
||||
assert!(!blue(fill_cap) && blue(stroke_cap), "{id} {view}: the caps last");
|
||||
assert!(walls.iter().any(|f| !blue(f)) && walls.iter().any(|f| blue(f)), "{id} {view}: walls of both");
|
||||
let wrong = red_after_blue(walls);
|
||||
assert!(wrong.is_empty(), "{id} {view}: fill walls after the stroke faces over them: {wrong:?} of {}", walls.len());
|
||||
}
|
||||
}
|
||||
// Strokes aligned inside or outside keep the fill's walls, in that order. A dashed stroke
|
||||
// leaves the fill's walls in the gaps between its dashes, and they keep their depths.
|
||||
use vectorcraft_doc::StrokeAlign;
|
||||
for (dash, align) in [(true, StrokeAlign::Center), (false, StrokeAlign::Inside), (false, StrokeAlign::Outside)] {
|
||||
let mut n = stroked(&letter_c());
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.dash = dash.then(|| vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false });
|
||||
s.align = align;
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, views[1].clone()));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let [walls @ .., _, _] = faces.as_slice() else { panic!("{align:?}: the caps") };
|
||||
assert!(walls.iter().any(|f| !blue(f)), "{align:?}: the fill's walls");
|
||||
if !dash {
|
||||
assert!(red_after_blue(walls).is_empty(), "{align:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a
|
||||
/// face of the same shape's stroke drawn before it: the wall then goes just before that face. A
|
||||
/// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an
|
||||
/// effect without caps keep the order of the depths.
|
||||
#[test]
|
||||
fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() {
|
||||
let at = |depth: f64, x: f64, member: usize, paints: Paints, side: bool, away: bool| Placed {
|
||||
depth,
|
||||
side,
|
||||
away,
|
||||
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
|
||||
member,
|
||||
paints,
|
||||
};
|
||||
// 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke
|
||||
// wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a
|
||||
// fill wall at the stroke wall's depth.
|
||||
let faces = [
|
||||
at(5.0, 0.0, 0, Paints::Fill, true, false),
|
||||
at(3.0, 5.0, 0, Paints::Stroke, true, false),
|
||||
at(2.0, 5.0, 1, Paints::Stroke, true, false),
|
||||
at(1.0, 50.0, 0, Paints::Stroke, true, false),
|
||||
at(0.0, 0.0, 0, Paints::Stroke, true, true),
|
||||
at(4.0, 0.0, 0, Paints::Fill, false, false),
|
||||
at(6.0, 0.0, 0, Paints::Stroke, false, false),
|
||||
at(3.0, 0.0, 0, Paints::Fill, true, false),
|
||||
];
|
||||
assert_eq!(draw_order(&faces, true), [4, 3, 2, 0, 7, 1, 5, 6]);
|
||||
assert_eq!(draw_order(&faces, false), [4, 3, 2, 1, 7, 5, 0, 6]);
|
||||
// A shape with no stroke, and a stroke without a width, move nothing.
|
||||
// Only a stroke whose solid hides the fill's whole edge counts: an opaque, plain stroke with a
|
||||
// width on a closed shape.
|
||||
let stroke = |f: &dyn Fn(&mut StrokeLayer)| {
|
||||
let mut s = StrokeLayer::new(Paint::solid(BLUE), 6.0);
|
||||
f(&mut s);
|
||||
AppearanceItem::Stroke(s)
|
||||
};
|
||||
assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke);
|
||||
assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape");
|
||||
assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width");
|
||||
let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false };
|
||||
assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes");
|
||||
assert!(Paints::of(&stroke(&|s| s.brush = Some("brush".into())), true) == Paints::Other);
|
||||
assert!(Paints::of(&stroke(&|s| s.paint = Paint::None), true) == Paints::Other);
|
||||
assert!(Paints::of(&stroke(&|s| s.paint = Paint::Pattern { pattern: "p".into(), xf: Affine::IDENTITY }), true) == Paints::Other, "a pattern");
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let gradient = |opacity: f32| {
|
||||
let mut end = GradientStop::new(1.0, RED);
|
||||
end.opacity = opacity;
|
||||
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end]))))
|
||||
};
|
||||
assert!(Paints::of(&stroke(&|s| s.paint = gradient(1.0)), true) == Paints::Stroke, "an opaque gradient");
|
||||
assert!(Paints::of(&stroke(&|s| s.paint = gradient(0.5)), true) == Paints::Other, "a gradient with a transparent stop");
|
||||
let other = |p: Paints| if p == Paints::Stroke { Paints::Other } else { p };
|
||||
let unstroked: Vec<Placed> = faces.iter().map(|f| at(f.depth, f.bounds.x0, f.member, other(f.paints), f.side, f.away)).collect();
|
||||
assert_eq!(draw_order(&unstroked, true), draw_order(&faces, false));
|
||||
}
|
||||
|
||||
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
|
||||
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
|
||||
#[test]
|
||||
|
||||
@@ -673,10 +673,14 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// the step before. As any new edit it ends the steps to redo, so Redo never swaps the document
|
||||
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
|
||||
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
|
||||
/// document.
|
||||
/// document. A preview that Real-time Drawing off left unapplied is applied first, as the tool's own
|
||||
/// commit applies it; when it fails, the interaction is canceled.
|
||||
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
let typing = typing_in_progress(s);
|
||||
s.commit_interaction()?;
|
||||
match s.unapplied_interaction_preview().filter(|_| !s.previewing) {
|
||||
Some((cmd, p)) if s.preview(&cmd, &p).is_err() => s.cancel_interaction()?,
|
||||
_ => s.commit_interaction()?,
|
||||
}
|
||||
if typing {
|
||||
s.set_tool_option("endTyping", &Value::Bool(true));
|
||||
}
|
||||
|
||||
@@ -3813,3 +3813,60 @@ fn a_filter_that_calls_commands_is_given_the_edit_in_progress() {
|
||||
assert_eq!(x, json!(17.0), "undoing the filter brings back the moved rectangle");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// Installs a filter `id` that calls commands and hands back what it is given, with `commands` (a
|
||||
/// JSON list of command specs) in its manifest.
|
||||
fn install_echo_calling(s: &mut Session, id: &str, commands: &str) {
|
||||
let grant = json!(["doc.read", "doc.write", "commands"]);
|
||||
let manifest = format!(
|
||||
r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{grant},"commandAllowlist":["select.all"],"commands":{commands}}}"#
|
||||
);
|
||||
let wasm = wat::parse_str(tpl::module(&manifest, tpl::ECHO, "", 1)).unwrap();
|
||||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true}));
|
||||
}
|
||||
|
||||
/// With Real-time Drawing off, a 50 × 50 rectangle at (10, 10) whose move by 7 pt is still being
|
||||
/// dragged: the tool has stored the move, which the document does not hold yet.
|
||||
fn deferred_move(s: &mut Session) -> NodeId {
|
||||
let r = NodeId(run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap());
|
||||
run(s, "prefs.set", json!({"key": "realTimeDrawing", "value": false}));
|
||||
let move_by = vectorcraft_tools::Action::Preview("object.move".into(), json!({"dx": 7, "dy": 0}));
|
||||
s.apply_actions(vec![vectorcraft_tools::Action::Begin("Move".into()), move_by]).unwrap();
|
||||
assert_eq!(node(s, r).geometric_bounds().unwrap().x0, 10.0, "the move is not applied while it is dragged");
|
||||
r
|
||||
}
|
||||
|
||||
/// The rectangle a run named `label` during [`deferred_move`] left: moved, with the interaction
|
||||
/// ended and the move an undo step of its own before the run's.
|
||||
fn assert_moved_once(s: &Session, r: NodeId, label: &str) {
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!(node(s, r).geometric_bounds().unwrap().x0, 17.0, "the filter was given the moved rectangle");
|
||||
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
|
||||
assert_eq!(labels, [label, "Move"]);
|
||||
}
|
||||
|
||||
/// With Real-time Drawing off, `plugin.run` of a filter that calls commands during a drag applies
|
||||
/// the dragged move first and gives the filter the moved rectangle, which it hands back as it was.
|
||||
#[test]
|
||||
fn a_filter_that_calls_commands_is_given_a_deferred_move() {
|
||||
let id = "test.engine.callsdeferred";
|
||||
let mut s = session();
|
||||
install_echo_calling(&mut s, id, "[]");
|
||||
let r = deferred_move(&mut s);
|
||||
run(&mut s, "plugin.run", json!({"id": id}));
|
||||
assert_moved_once(&s, r, &format!("Test {id}"));
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A plug-in command that calls commands, run with Real-time Drawing off during a drag, is given
|
||||
/// the moved rectangle as `plugin.run` is.
|
||||
#[test]
|
||||
fn a_plugin_command_that_calls_commands_is_given_a_deferred_move() {
|
||||
let id = "test.engine.callsdeferredcmd";
|
||||
let mut s = session();
|
||||
install_echo_calling(&mut s, id, r#"[{"id":"same","label":"Same"}]"#);
|
||||
let r = deferred_move(&mut s);
|
||||
run(&mut s, &format!("plugin.{id}.same"), json!({}));
|
||||
assert_moved_once(&s, r, "Same");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
@@ -2047,6 +2047,42 @@ fn real_time_drawing_turned_on_mid_drag_still_commits_deferred_preview() {
|
||||
assert_eq!(paths(&s), 1, "the deferred rectangle is committed");
|
||||
}
|
||||
|
||||
/// With Real-time Drawing off, Undo during a drag keeps the dragged move as its own step and takes
|
||||
/// back only that: the rectangle drawn before stays, and Redo brings the move back.
|
||||
#[test]
|
||||
fn undo_during_a_deferred_drag_takes_back_only_the_drag() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap());
|
||||
set_pref(&mut s, "realTimeDrawing", json!(false));
|
||||
let x = |s: &Session| s.doc().unwrap().doc.node(r).and_then(vectorcraft_doc::Node::geometric_bounds).map(|b| b.x0);
|
||||
let move_by = vectorcraft_tools::Action::Preview("object.move".into(), json!({"dx": 7, "dy": 0}));
|
||||
s.apply_actions(vec![vectorcraft_tools::Action::Begin("Move".into()), move_by]).unwrap();
|
||||
assert_eq!(x(&s), Some(10.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!(x(&s), Some(10.0), "the rectangle stays");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(x(&s), Some(17.0), "Redo brings the move back");
|
||||
}
|
||||
|
||||
/// Undo during a drag whose stored preview fails cancels the drag, as its release would, and then
|
||||
/// takes back the step before it.
|
||||
#[test]
|
||||
fn undo_during_a_deferred_drag_that_fails_cancels_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
set_pref(&mut s, "realTimeDrawing", json!(false));
|
||||
let bad = vectorcraft_tools::Action::Preview("no.such.command".into(), json!({}));
|
||||
s.apply_actions(vec![vectorcraft_tools::Action::Begin("Bad".into()), bad]).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.in_interaction());
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::<usize>(), 0, "the rectangle is undone");
|
||||
assert!(d.history.undo.iter().all(|e| e.label != "Bad"));
|
||||
}
|
||||
|
||||
/// Repeated overlay redraws with Real-time Drawing off reuse the cached deferred feedback.
|
||||
#[test]
|
||||
fn deferred_preview_overlays_are_cached_across_redraws() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The E4X built-ins (ECMA-357 sections 13.1 to 13.5): `XML`, `XMLList`, `Namespace`, `QName`,
|
||||
//! `isXMLName` and `setDefaultXMLNamespace`. A realm has them only with [`crate::Options::e4x`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::builtins::{arg, constructor, data, method, native};
|
||||
@@ -662,19 +663,25 @@ fn has_simple_content(interp: &mut Interp, this: Value, _: &[Value], _: bool) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The namespaces in scope at `x`, nearest declarations first, one for each prefix.
|
||||
/// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit
|
||||
/// of fuel for each node it climbs through and for each declaration it reads.
|
||||
fn scope_of(interp: &mut Interp, x: Option<ObjId>) -> JsResult<Vec<XmlNs>> {
|
||||
let mut out: Vec<XmlNs> = Vec::new();
|
||||
let mut seen: HashSet<Option<Key>> = HashSet::new();
|
||||
let mut cur = x;
|
||||
while let Some(y) = cur {
|
||||
let node = interp.xml_node(y)?.clone();
|
||||
for ns in &node.ns {
|
||||
interp.use_fuel()?;
|
||||
let (list, parent) = {
|
||||
let node = interp.xml_node(y)?;
|
||||
(node.ns.clone(), node.parent)
|
||||
};
|
||||
for ns in list {
|
||||
interp.use_fuel()?;
|
||||
if !out.iter().any(|n| n.prefix == ns.prefix) {
|
||||
out.push(ns.clone());
|
||||
if seen.insert(ns.prefix.clone()) {
|
||||
out.push(ns);
|
||||
}
|
||||
}
|
||||
cur = node.parent;
|
||||
cur = parent;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -702,9 +709,8 @@ fn namespace_declarations(interp: &mut Interp, this: Value, _: &[Value], _: bool
|
||||
return namespaces_array(interp, Vec::new());
|
||||
}
|
||||
let parent = interp.xml_node(x)?.parent;
|
||||
let ancestors = scope_of(interp, parent)?;
|
||||
let declared: Vec<XmlNs> =
|
||||
interp.xml_node(x)?.ns.iter().filter(|ns| !ancestors.iter().any(|a| a.prefix == ns.prefix && a.uri == ns.uri)).cloned().collect();
|
||||
let ancestors: HashMap<Option<Key>, Key> = scope_of(interp, parent)?.into_iter().map(|a| (a.prefix, a.uri)).collect();
|
||||
let declared: Vec<XmlNs> = interp.xml_node(x)?.ns.iter().filter(|ns| ancestors.get(&ns.prefix) != Some(&ns.uri)).cloned().collect();
|
||||
namespaces_array(interp, declared)
|
||||
}
|
||||
|
||||
|
||||
@@ -740,6 +740,16 @@ fn insert_child_before_and_after_take_fuel_for_each_child_they_scan() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_lookups_take_fuel_for_each_ancestor_they_climb() {
|
||||
let shallow = "var x = <a><a/></a>; var leaf = x.children()[0];";
|
||||
let deep = "var x = <a/>; var leaf = x; for (var i = 0; i < 150; i++) { var c = <a/>; leaf.appendChild(c); leaf = c; }";
|
||||
for action in ["leaf.inScopeNamespaces()", "leaf.namespaceDeclarations()", "leaf.namespace()", "leaf.namespace('p')"] {
|
||||
let (s, d) = (fuel_of(shallow, action), fuel_of(deep, action));
|
||||
assert!(d >= s + 149, "{action}: {s} one level down, {d} 150 levels down");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_cannot_be_caught_by_the_script() {
|
||||
assert_eq!(kind_of(TIGHT, "try { for (;;) (<a/>).toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel));
|
||||
|
||||
@@ -257,6 +257,12 @@ pub struct Facet {
|
||||
/// The facet's outline with the profile's curves kept as cubics, for [`Facet::path`]. The
|
||||
/// contours stay the polygon that visibility and depth use.
|
||||
pub outline: Option<PathData>,
|
||||
/// The facet is a piece of the solid's sides (Extrude & Bevel's walls and bevels, Inflate's
|
||||
/// sides) rather than of its ends (the caps, a plane, Inflate's front and back).
|
||||
pub side: bool,
|
||||
/// The facet is turned away from the viewer: a closed solid keeps it (seen edge-on, or with
|
||||
/// Draw Hidden Faces) and draws it behind the facets turned toward the viewer.
|
||||
pub away: bool,
|
||||
/// The contours in view space, before projection, for tests that measure depths.
|
||||
#[cfg(test)]
|
||||
view: Vec<Vec<V3>>,
|
||||
|
||||
@@ -148,6 +148,8 @@ impl Builder<'_> {
|
||||
normal: [n.0, n.1, n.2],
|
||||
rgb_gradient: None,
|
||||
outline: None,
|
||||
side: false,
|
||||
away: behind,
|
||||
#[cfg(test)]
|
||||
view,
|
||||
});
|
||||
@@ -227,6 +229,11 @@ impl Builder<'_> {
|
||||
}
|
||||
let before = self.facets.len();
|
||||
self.face(vec![quad], normal, order)?;
|
||||
if self.facets.len() > before
|
||||
&& let Some(f) = self.facets.last_mut()
|
||||
{
|
||||
f.side = true;
|
||||
}
|
||||
if let Some(w) = curves.as_mut().filter(|_| pieces == 1)
|
||||
&& let Some(span) = w.spans.get(i).copied().flatten()
|
||||
{
|
||||
|
||||
@@ -98,6 +98,33 @@ fn hidden_faces_draw_every_face_of_the_solid_behind_the_visible_ones() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each facet says whether it is a piece of the solid's sides and whether it is turned away from
|
||||
/// the viewer: a box's walls and bevels are sides and its two caps are not; Inflate's sides are
|
||||
/// sides and its front is not.
|
||||
#[test]
|
||||
fn facets_tell_the_sides_from_the_ends_and_the_faces_turned_away() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
|
||||
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
|
||||
for o in [Extrude { hidden: true, ..Extrude::default() }, Extrude { hidden: true, bevel: Some(bevel), ..Extrude::default() }] {
|
||||
let all = extrude(&p, FillRule::EvenOdd, b, o, v).unwrap();
|
||||
let ends: Vec<&Facet> = all.iter().filter(|f| !f.side).collect();
|
||||
let [a, c] = ends[..] else { panic!("two caps among {} facets", all.len()) };
|
||||
let along = a.normal[0] * c.normal[0] + a.normal[1] * c.normal[1] + a.normal[2] * c.normal[2];
|
||||
assert!(close(along, -1.0, 1e-9), "the caps face opposite ways: {along}");
|
||||
assert!(all.len() > 6 || o.bevel.is_none(), "the bevel adds sides");
|
||||
// The faces turned away are drawn first, the others after them.
|
||||
let away = all.iter().take_while(|f| f.away).count();
|
||||
assert!(away > 0 && all[away..].iter().all(|f| !f.away), "{away} of {}", all.len());
|
||||
}
|
||||
let shown = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap();
|
||||
assert!(shown.iter().all(|f| !f.away));
|
||||
let puff = inflate(&p, FillRule::EvenOdd, b, Inflate { depth: 20.0, ..Inflate::default() }, v).unwrap();
|
||||
assert!(puff.iter().any(|f| f.side) && puff.iter().any(|f| !f.side));
|
||||
let flat = inflate(&p, FillRule::EvenOdd, b, Inflate::default(), v).unwrap();
|
||||
assert!(!flat.is_empty() && flat.iter().all(|f| !f.side));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
|
||||
@@ -1100,6 +1100,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged.
|
||||
- Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect.
|
||||
- Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)).
|
||||
- With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`.
|
||||
- Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715).
|
||||
- Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)).
|
||||
- Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`).
|
||||
@@ -1682,6 +1683,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged.
|
||||
- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged.
|
||||
- **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged.
|
||||
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
|
||||
- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
|
||||
- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged.
|
||||
|
||||
+6
-1
@@ -100,7 +100,12 @@ reply on the reference app's bug forum says that strokes "will be treated as out
|
||||
The rest is our reading:
|
||||
|
||||
- When the fill and every stroke are opaque with Normal blending, the faces of all the solids are drawn in one
|
||||
back-to-front order, and the caps turned toward the viewer are drawn last, the lower item's first. Otherwise
|
||||
back-to-front order, and the caps turned toward the viewer are drawn last, the lower item's first. With caps on,
|
||||
each side face of the fill's solid that is turned toward the viewer is drawn before every face of the same
|
||||
shape's strokes, turned toward the viewer, whose bounds overlap it, so the stroke's walls cover the fill's walls
|
||||
at concave corners. This applies to strokes on closed paths without dashes, a width profile or a brush, painted
|
||||
with a solid color or a gradient without transparent stops; with other strokes, and without caps, the faces keep
|
||||
their depth order. Otherwise
|
||||
each solid is drawn on its own with its opacity and blending, the lower item's first. With a light that is not
|
||||
white, each solid is also drawn on its own when one of them is a puffed, shaded Inflate surface in a solid color
|
||||
(drawn as one composite) or is painted with a gradient that has transparent stops (drawn with an opacity mask).
|
||||
|
||||
Reference in New Issue
Block a user