mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-26 22:11:28 -07:00
Add mosaic mode: seam-free tessellation (pixel + polygon)
Implements the topological mosaic pipeline from docs/design/mosaic.md, turning
`--hierarchical cutout` into a true gapless tessellation instead of the old
re-cluster-and-retrace fake.
LabelMap (flatten Segmentation top-down)
→ boundary-graph extraction (integer-exact: corners, node rule, segment
and ring tracing on the pixel-corner lattice)
→ face assembly (left-region successor rule; winding falls out,
so each region is one nonzero-fill path)
→ fit each segment ONCE (shared by both adjacent faces, reversed
exactly → byte-identical shared boundaries)
→ compose per-region paths
Backends: PixelSegmentFitter (exact reference) and PolygonSegmentFitter
(symmetric open Douglas-Peucker collapsing staircases to the crack midline).
The spline segment fitter is still pending; mosaic + spline currently falls
back to polygon.
Compositing now owns its fitter (Stacked(CurveFitter) / Mosaic(SegmentFitter)).
Tests: single region, vertical split, T-junction, checkerboard pinch, nested
rings, border-touching, and a pixel round-trip property test over 40 random
maps (rasterize composed faces == input label map). Plus two mosaic goldens.
This commit is contained in:
@@ -7,12 +7,24 @@
|
||||
|
||||
use crate::fitter::CurveFitter;
|
||||
use crate::ir::{Segmentation, Shape, VectorDoc};
|
||||
use crate::mosaic::{compose_mosaic, MosaicOptions, SegmentFitter};
|
||||
|
||||
/// Which compositing strategy the pipeline uses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
|
||||
pub enum Compositing {
|
||||
/// Independent per-region closed outlines, stacked bottom-to-top.
|
||||
Stacked,
|
||||
Stacked(Box<dyn CurveFitter>),
|
||||
/// Seam-free gapless tessellation via a shared boundary graph.
|
||||
Mosaic(Box<dyn SegmentFitter>, MosaicOptions),
|
||||
}
|
||||
|
||||
impl Compositing {
|
||||
/// Run the selected compositor over a segmentation.
|
||||
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
|
||||
match self {
|
||||
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()),
|
||||
Compositing::Mosaic(fitter, opts) => compose_mosaic(seg, fitter.as_ref(), opts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace every layer's closed outline and stack the shapes in paint order.
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::compose::Compositing;
|
||||
use crate::error::Error;
|
||||
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
|
||||
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend};
|
||||
use crate::mosaic::{MosaicOptions, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter};
|
||||
use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
|
||||
use crate::pipeline::Pipeline;
|
||||
use crate::svg::SvgWriter;
|
||||
@@ -160,6 +161,16 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_fitter(&self) -> Box<dyn SegmentFitter> {
|
||||
match self.mode {
|
||||
FitMode::Pixel => Box::new(PixelSegmentFitter),
|
||||
FitMode::Polygon => Box::new(PolygonSegmentFitter::default()),
|
||||
// The spline segment fitter is not implemented yet; mosaic falls
|
||||
// back to the polygon (crack-midline) fitter for now.
|
||||
FitMode::Spline => Box::new(PolygonSegmentFitter::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn optimizers(&self) -> Vec<Box<dyn OptimizerPass>> {
|
||||
if self.optimize == 0 {
|
||||
return Vec::new();
|
||||
@@ -194,18 +205,15 @@ impl Config {
|
||||
/// Assemble a concrete pipeline from this configuration.
|
||||
pub fn build(&self) -> Result<Pipeline, Error> {
|
||||
let compositing = match self.hierarchical {
|
||||
Hierarchical::Stacked => Compositing::Stacked,
|
||||
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
|
||||
Hierarchical::Cutout => {
|
||||
return Err(Error::Unsupported(
|
||||
"the mosaic (cutout) compositor is not yet implemented".into(),
|
||||
))
|
||||
Compositing::Mosaic(self.segment_fitter(), MosaicOptions::default())
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Pipeline {
|
||||
frontend: self.frontend(),
|
||||
color_fitters: self.color_fitters(),
|
||||
fitter: self.fitter(),
|
||||
compositing,
|
||||
optimizers: self.optimizers(),
|
||||
writer: self.writer(),
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod error;
|
||||
pub mod fitter;
|
||||
pub mod frontend;
|
||||
pub mod ir;
|
||||
pub mod mosaic;
|
||||
pub mod optimize;
|
||||
pub mod pipeline;
|
||||
pub mod svg;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Stage 4: compose per-region SVG paths from shared fitted segments.
|
||||
//!
|
||||
//! Each region becomes one shape whose `d` concatenates its contours as
|
||||
//! subpaths (default `nonzero` fill rule handles holes and pinch points). Each
|
||||
//! oriented segment is emitted skipping its first point (identical to the
|
||||
//! previous segment's last point), so shared boundaries are byte-identical on
|
||||
//! both sides.
|
||||
|
||||
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use super::face::{assemble, Contour, Face};
|
||||
use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
|
||||
use super::graph::BoundaryGraph;
|
||||
use super::{LabelMap, MosaicOptions, Segmentation};
|
||||
|
||||
/// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose.
|
||||
pub fn compose_mosaic(
|
||||
seg: &Segmentation,
|
||||
fitter: &dyn SegmentFitter,
|
||||
_options: &MosaicOptions,
|
||||
) -> VectorDoc {
|
||||
let map = LabelMap::from_segmentation(seg);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let faces = assemble(&graph, &map);
|
||||
|
||||
// Fit every segment exactly once; both adjacent faces share the result.
|
||||
let fitted: Vec<FittedSegment> = graph
|
||||
.segments
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if s.is_ring() {
|
||||
fitter.fit_ring(s)
|
||||
} else {
|
||||
fitter.fit_open(s)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut doc = VectorDoc::new(seg.width, seg.height);
|
||||
for face in &faces {
|
||||
let path = build_path(face, &fitted, &graph);
|
||||
if !path.is_empty() {
|
||||
doc.shapes.push(Shape {
|
||||
paint: map.paints[face.region as usize],
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn build_path(face: &Face, fitted: &[FittedSegment], _graph: &BoundaryGraph) -> MultiPath {
|
||||
let mut mp = MultiPath::new();
|
||||
for contour in &face.contours {
|
||||
let mut sub = SubPath::new();
|
||||
emit_contour(contour, fitted, &mut sub);
|
||||
if !sub.is_empty() {
|
||||
sub.commands.push(PathCmd::Close);
|
||||
mp.subpaths.push(sub);
|
||||
}
|
||||
}
|
||||
mp
|
||||
}
|
||||
|
||||
fn emit_contour(contour: &Contour, fitted: &[FittedSegment], sub: &mut SubPath) {
|
||||
for (i, sref) in contour.0.iter().enumerate() {
|
||||
let geom = &fitted[sref.seg as usize].geom;
|
||||
emit_segment(geom, sref.forward, i == 0, sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one oriented segment's commands. When `first`, opens with a `MoveTo`;
|
||||
/// otherwise the leading point (shared with the previous segment) is skipped.
|
||||
fn emit_segment(geom: &FittedGeom, forward: bool, first: bool, sub: &mut SubPath) {
|
||||
match geom {
|
||||
FittedGeom::Polyline(pts) => {
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let ordered: Vec<PointF64> = if forward {
|
||||
pts.clone()
|
||||
} else {
|
||||
pts.iter().rev().copied().collect()
|
||||
};
|
||||
if first {
|
||||
sub.commands.push(PathCmd::MoveTo(ordered[0]));
|
||||
}
|
||||
for p in &ordered[1..] {
|
||||
sub.commands.push(PathCmd::LineTo(*p));
|
||||
}
|
||||
}
|
||||
FittedGeom::Beziers(curves) => {
|
||||
if curves.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Reversing a cubic is exact: [p0,p1,p2,p3] -> [p3,p2,p1,p0], and
|
||||
// the whole chain reverses in order too.
|
||||
let ordered: Vec<[PointF64; 4]> = if forward {
|
||||
curves.clone()
|
||||
} else {
|
||||
curves
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|c| [c[3], c[2], c[1], c[0]])
|
||||
.collect()
|
||||
};
|
||||
if first {
|
||||
sub.commands.push(PathCmd::MoveTo(ordered[0][0]));
|
||||
}
|
||||
for c in &ordered {
|
||||
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Stage 2: face assembly.
|
||||
//!
|
||||
//! Lift the "region kept on the left" successor rule from unit edges to whole
|
||||
//! segments. Following it around each region yields its contours; because the
|
||||
//! interior is always on the left, outer contours and hole contours come out
|
||||
//! with opposite winding automatically — no containment/nesting computation is
|
||||
//! needed, and the region can be filled with a single `nonzero` path.
|
||||
|
||||
use super::graph::{
|
||||
edge_present, left_pixel_at, reverse, straight, turn_left, turn_right, BoundaryGraph, SegRef,
|
||||
};
|
||||
use super::{LabelMap, RegionId, OUTSIDE};
|
||||
|
||||
/// A closed cycle of directed segments bounding (part of) a region.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Contour(pub Vec<SegRef>);
|
||||
|
||||
/// One region and all of its contours (outer + holes).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Face {
|
||||
pub region: RegionId,
|
||||
pub contours: Vec<Contour>,
|
||||
}
|
||||
|
||||
/// Left region of a directed segment view.
|
||||
fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId {
|
||||
let seg = &graph.segments[r.seg as usize];
|
||||
if r.forward {
|
||||
seg.left
|
||||
} else {
|
||||
seg.right
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the next unit direction leaving `corner`, keeping region `r` on the
|
||||
/// left: sharpest right turn first (this pinches checkerboard nodes and keeps
|
||||
/// contours simple).
|
||||
fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 {
|
||||
for &d in &[turn_right(d_in), straight(d_in), turn_left(d_in)] {
|
||||
if edge_present(map, x, y, d) && left_pixel_at(map, x, y, d) == r {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
unreachable!("no successor edge keeps the region on the left");
|
||||
}
|
||||
|
||||
pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
|
||||
let mut by_region: Vec<Vec<Contour>> = vec![Vec::new(); map.paints.len()];
|
||||
// usage[seg][0] = forward view used, [1] = backward view used.
|
||||
let mut used = vec![[false; 2]; graph.segments.len()];
|
||||
|
||||
for seg_id in 0..graph.segments.len() {
|
||||
if graph.segments[seg_id].is_ring() {
|
||||
continue;
|
||||
}
|
||||
for &forward in &[true, false] {
|
||||
let start = SegRef {
|
||||
seg: seg_id as u32,
|
||||
forward,
|
||||
};
|
||||
let region = left_region(graph, start);
|
||||
if region == OUTSIDE || used[seg_id][forward as usize] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut contour = Vec::new();
|
||||
let mut cur = start;
|
||||
loop {
|
||||
used[cur.seg as usize][cur.forward as usize] = true;
|
||||
contour.push(cur);
|
||||
|
||||
let seg = &graph.segments[cur.seg as usize];
|
||||
let (node_id, d_in) = if cur.forward {
|
||||
(seg.end.unwrap(), seg.last_dir)
|
||||
} else {
|
||||
(seg.start.unwrap(), reverse(seg.first_dir))
|
||||
};
|
||||
let corner = graph.nodes[node_id as usize].corner;
|
||||
let d_next = successor(map, corner.x, corner.y, d_in, region);
|
||||
cur = graph.nodes[node_id as usize].out[d_next as usize]
|
||||
.expect("successor direction must have an outgoing segment");
|
||||
|
||||
if cur == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (region as usize) < by_region.len() {
|
||||
by_region[region as usize].push(Contour(contour));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rings: the left side uses it forward, the right side reversed.
|
||||
for seg_id in 0..graph.segments.len() {
|
||||
let seg = &graph.segments[seg_id];
|
||||
if !seg.is_ring() {
|
||||
continue;
|
||||
}
|
||||
if seg.left != OUTSIDE && (seg.left as usize) < by_region.len() {
|
||||
by_region[seg.left as usize].push(Contour(vec![SegRef {
|
||||
seg: seg_id as u32,
|
||||
forward: true,
|
||||
}]));
|
||||
}
|
||||
if seg.right != OUTSIDE && (seg.right as usize) < by_region.len() {
|
||||
by_region[seg.right as usize].push(Contour(vec![SegRef {
|
||||
seg: seg_id as u32,
|
||||
forward: false,
|
||||
}]));
|
||||
}
|
||||
}
|
||||
|
||||
by_region
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| !c.is_empty())
|
||||
.map(|(region, contours)| Face {
|
||||
region: region as RegionId,
|
||||
contours,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Stage 3: fit each boundary segment once, with endpoints pinned to nodes.
|
||||
//!
|
||||
//! A segment is fitted a single time and cached; both adjacent faces reference
|
||||
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
|
||||
//! the shared geometry is bitwise identical and no seam can appear.
|
||||
|
||||
use visioncortex::{PointF64, PointI32};
|
||||
|
||||
use super::graph::Segment;
|
||||
|
||||
/// Fitted geometry for one boundary segment.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FittedGeom {
|
||||
/// Polyline (pixel / polygon backends).
|
||||
Polyline(Vec<PointF64>),
|
||||
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
|
||||
Beziers(Vec<[PointF64; 4]>),
|
||||
}
|
||||
|
||||
/// A fitted segment, cached and indexed by segment id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FittedSegment {
|
||||
pub geom: FittedGeom,
|
||||
}
|
||||
|
||||
/// Fits a single boundary segment. `fit_open` pins both endpoints (junction
|
||||
/// nodes must not move); `fit_ring` fits a closed loop with no pinned point.
|
||||
pub trait SegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment;
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment;
|
||||
}
|
||||
|
||||
fn to_f64(points: &[PointI32]) -> Vec<PointF64> {
|
||||
points
|
||||
.iter()
|
||||
.map(|p| PointF64 {
|
||||
x: p.x as f64,
|
||||
y: p.y as f64,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Identity fitter: lattice points as f64. Produces an exact tessellation and
|
||||
/// is the reference backend for tests.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PixelSegmentFitter;
|
||||
|
||||
impl SegmentFitter for PixelSegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
}
|
||||
}
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Symmetric open Douglas–Peucker. Endpoints are always kept, so junction
|
||||
/// nodes stay pinned. Plain DP (no directional staircase removal) collapses
|
||||
/// 1-px staircases to the crack midline — centered between the two regions,
|
||||
/// which is what a mosaic wants.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PolygonSegmentFitter {
|
||||
pub tolerance: f64,
|
||||
}
|
||||
|
||||
impl Default for PolygonSegmentFitter {
|
||||
fn default() -> Self {
|
||||
Self { tolerance: 0.5 }
|
||||
}
|
||||
}
|
||||
|
||||
impl SegmentFitter for PolygonSegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
||||
let pts = to_f64(&seg.points);
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(dp_open(&pts, self.tolerance)),
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||
// Closed loop: split at the vertex farthest from the start, DP each
|
||||
// half, then rejoin. points[0] == points[last].
|
||||
let pts = to_f64(&seg.points);
|
||||
if pts.len() <= 4 {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(pts),
|
||||
};
|
||||
}
|
||||
let open = &pts[..pts.len() - 1]; // drop duplicate closing point
|
||||
let far = farthest_from(open, 0);
|
||||
let first: Vec<PointF64> = open[0..=far].to_vec();
|
||||
let second: Vec<PointF64> = open[far..]
|
||||
.iter()
|
||||
.chain(std::iter::once(&open[0]))
|
||||
.copied()
|
||||
.collect();
|
||||
let mut a = dp_open(&first, self.tolerance);
|
||||
let b = dp_open(&second, self.tolerance);
|
||||
// `a` ends at `far`, `b` starts at `far` and ends back at start.
|
||||
a.pop(); // drop shared `far`
|
||||
a.extend(b); // ...b includes far..start (closing point == start)
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(a),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn farthest_from(pts: &[PointF64], anchor: usize) -> usize {
|
||||
let a = pts[anchor];
|
||||
let mut best = anchor;
|
||||
let mut best_d = -1.0;
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let dx = p.x - a.x;
|
||||
let dy = p.y - a.y;
|
||||
let d = dx * dx + dy * dy;
|
||||
if d > best_d {
|
||||
best_d = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Douglas–Peucker on an open polyline; first and last points are always kept.
|
||||
fn dp_open(pts: &[PointF64], tol: f64) -> Vec<PointF64> {
|
||||
if pts.len() <= 2 {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let mut keep = vec![false; pts.len()];
|
||||
keep[0] = true;
|
||||
keep[pts.len() - 1] = true;
|
||||
dp_recurse(pts, 0, pts.len() - 1, tol, &mut keep);
|
||||
pts.iter()
|
||||
.zip(keep)
|
||||
.filter_map(|(p, k)| if k { Some(*p) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dp_recurse(pts: &[PointF64], lo: usize, hi: usize, tol: f64, keep: &mut [bool]) {
|
||||
if hi <= lo + 1 {
|
||||
return;
|
||||
}
|
||||
let mut max_d = -1.0;
|
||||
let mut idx = lo;
|
||||
for i in (lo + 1)..hi {
|
||||
let d = perp_distance(pts[i], pts[lo], pts[hi]);
|
||||
if d > max_d {
|
||||
max_d = d;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
if max_d > tol {
|
||||
keep[idx] = true;
|
||||
dp_recurse(pts, lo, idx, tol, keep);
|
||||
dp_recurse(pts, idx, hi, tol, keep);
|
||||
}
|
||||
}
|
||||
|
||||
/// Perpendicular distance from `p` to the segment `a`–`b`.
|
||||
fn perp_distance(p: PointF64, a: PointF64, b: PointF64) -> f64 {
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let len2 = dx * dx + dy * dy;
|
||||
if len2 == 0.0 {
|
||||
return ((p.x - a.x).powi(2) + (p.y - a.y).powi(2)).sqrt();
|
||||
}
|
||||
let cross = (p.x - a.x) * dy - (p.y - a.y) * dx;
|
||||
cross.abs() / len2.sqrt()
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Stage 1: boundary-graph extraction from a [`LabelMap`].
|
||||
//!
|
||||
//! Pure integer arithmetic on the lattice of pixel corners `0..=W × 0..=H`.
|
||||
//! Pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; boundaries run
|
||||
//! along the "cracks" between differing labels.
|
||||
|
||||
use visioncortex::PointI32;
|
||||
|
||||
use super::{LabelMap, RegionId, OUTSIDE};
|
||||
|
||||
pub type NodeId = u32;
|
||||
pub type SegId = u32;
|
||||
|
||||
// Unit directions, arranged clockwise in y-down screen space so that
|
||||
// `(d + 1) % 4` is a right turn and `(d + 2) % 4` is a reversal.
|
||||
const N: u8 = 0;
|
||||
const E: u8 = 1;
|
||||
const S: u8 = 2;
|
||||
const W: u8 = 3;
|
||||
/// (dx, dy) per direction.
|
||||
const DVEC: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||
|
||||
#[inline]
|
||||
pub(super) fn turn_right(d: u8) -> u8 {
|
||||
(d + 1) % 4
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn straight(d: u8) -> u8 {
|
||||
d
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn turn_left(d: u8) -> u8 {
|
||||
(d + 3) % 4
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn reverse(d: u8) -> u8 {
|
||||
(d + 2) % 4
|
||||
}
|
||||
|
||||
/// A directed reference to a segment: either traversed forward or reversed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SegRef {
|
||||
pub seg: SegId,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
/// A junction corner (degree ≥ 3) with the segment leaving it in each unit
|
||||
/// direction (if any).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Node {
|
||||
pub corner: PointI32,
|
||||
pub out: [Option<SegRef>; 4],
|
||||
}
|
||||
|
||||
/// A maximal boundary chain between two nodes, or a nodeless ring.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Segment {
|
||||
/// Lattice polyline; `len >= 2`. For a ring, `points[0] == points[last]`.
|
||||
pub points: Vec<PointI32>,
|
||||
pub start: Option<NodeId>,
|
||||
pub end: Option<NodeId>,
|
||||
/// Region on the left when traversing forward (y-down convention).
|
||||
pub left: RegionId,
|
||||
pub right: RegionId,
|
||||
/// Direction of the first edge (leaving `start`); unused for rings.
|
||||
pub first_dir: u8,
|
||||
/// Direction of the last edge (arriving at `end`); unused for rings.
|
||||
pub last_dir: u8,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn is_ring(&self) -> bool {
|
||||
self.start.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The extracted boundary graph. Faces are assembled separately (see `face`).
|
||||
pub struct BoundaryGraph {
|
||||
pub nodes: Vec<Node>,
|
||||
pub segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
struct Extractor<'a> {
|
||||
map: &'a LabelMap,
|
||||
w: i32,
|
||||
h: i32,
|
||||
/// NodeId per lattice corner, `u32::MAX` if not a node. Size (W+1)(H+1).
|
||||
node_at: Vec<NodeId>,
|
||||
/// Visited flags for undirected unit edges.
|
||||
visited_v: Vec<bool>, // vertical edge (x in 0..=W, y in 0..H): y*(W+1)+x
|
||||
visited_h: Vec<bool>, // horizontal edge (x in 0..W, y in 0..=H): y*W + x
|
||||
nodes: Vec<Node>,
|
||||
segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
impl<'a> Extractor<'a> {
|
||||
fn new(map: &'a LabelMap) -> Self {
|
||||
let w = map.width as i32;
|
||||
let h = map.height as i32;
|
||||
let cw = (map.width + 1) as usize;
|
||||
let ch = (map.height + 1) as usize;
|
||||
Extractor {
|
||||
map,
|
||||
w,
|
||||
h,
|
||||
node_at: vec![u32::MAX; cw * ch],
|
||||
visited_v: vec![false; (map.width as usize + 1) * map.height as usize],
|
||||
visited_h: vec![false; map.width as usize * (map.height as usize + 1)],
|
||||
nodes: Vec::new(),
|
||||
segments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn corner_index(&self, x: i32, y: i32) -> usize {
|
||||
y as usize * (self.w as usize + 1) + x as usize
|
||||
}
|
||||
|
||||
/// 4-bit edge mask (N,E,S,W) present at corner `(x,y)`.
|
||||
fn edge_mask(&self, x: i32, y: i32) -> u8 {
|
||||
let nw = self.map.label(x - 1, y - 1);
|
||||
let ne = self.map.label(x, y - 1);
|
||||
let sw = self.map.label(x - 1, y);
|
||||
let se = self.map.label(x, y);
|
||||
let mut m = 0u8;
|
||||
if nw != ne {
|
||||
m |= 1 << N;
|
||||
}
|
||||
if ne != se {
|
||||
m |= 1 << E;
|
||||
}
|
||||
if sw != se {
|
||||
m |= 1 << S;
|
||||
}
|
||||
if nw != sw {
|
||||
m |= 1 << W;
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// (left, right) regions flanking the directed edge leaving `(x,y)` in `d`.
|
||||
fn side_pixels(&self, x: i32, y: i32, d: u8) -> (RegionId, RegionId) {
|
||||
let nw = self.map.label(x - 1, y - 1);
|
||||
let ne = self.map.label(x, y - 1);
|
||||
let sw = self.map.label(x - 1, y);
|
||||
let se = self.map.label(x, y);
|
||||
match d {
|
||||
N => (nw, ne),
|
||||
E => (ne, se),
|
||||
S => (se, sw),
|
||||
W => (sw, nw),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark/query an undirected unit edge leaving `(x,y)` in direction `d`.
|
||||
/// Returns the canonical (is_vertical, index).
|
||||
fn edge_slot(&self, x: i32, y: i32, d: u8) -> (bool, usize) {
|
||||
match d {
|
||||
N => (true, (y - 1) as usize * (self.w as usize + 1) + x as usize),
|
||||
S => (true, y as usize * (self.w as usize + 1) + x as usize),
|
||||
E => (false, y as usize * self.w as usize + x as usize),
|
||||
W => (false, y as usize * self.w as usize + (x - 1) as usize),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_visited(&self, x: i32, y: i32, d: u8) -> bool {
|
||||
let (v, i) = self.edge_slot(x, y, d);
|
||||
if v {
|
||||
self.visited_v[i]
|
||||
} else {
|
||||
self.visited_h[i]
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_visited(&mut self, x: i32, y: i32, d: u8) {
|
||||
let (v, i) = self.edge_slot(x, y, d);
|
||||
if v {
|
||||
self.visited_v[i] = true;
|
||||
} else {
|
||||
self.visited_h[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass A — classify corners and allocate node ids for degree ≥ 3.
|
||||
fn classify(&mut self) {
|
||||
for y in 0..=self.h {
|
||||
for x in 0..=self.w {
|
||||
let deg = self.edge_mask(x, y).count_ones();
|
||||
if deg >= 3 {
|
||||
let id = self.nodes.len() as NodeId;
|
||||
self.nodes.push(Node {
|
||||
corner: PointI32 { x, y },
|
||||
out: [None; 4],
|
||||
});
|
||||
let ci = self.corner_index(x, y);
|
||||
self.node_at[ci] = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node_id(&self, x: i32, y: i32) -> Option<NodeId> {
|
||||
let id = self.node_at[self.corner_index(x, y)];
|
||||
if id == u32::MAX {
|
||||
None
|
||||
} else {
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk from `(x0,y0)` heading `d0` until a node (or, for rings, back to
|
||||
/// the start). Returns the polyline, the final heading, and the corner
|
||||
/// walked to. Marks every traversed edge visited.
|
||||
fn walk(&mut self, x0: i32, y0: i32, d0: u8) -> (Vec<PointI32>, u8, i32, i32) {
|
||||
let mut points = vec![PointI32 { x: x0, y: y0 }];
|
||||
let (mut cx, mut cy, mut d) = (x0, y0, d0);
|
||||
loop {
|
||||
self.mark_visited(cx, cy, d);
|
||||
let (dx, dy) = DVEC[d as usize];
|
||||
let (nx, ny) = (cx + dx, cy + dy);
|
||||
points.push(PointI32 { x: nx, y: ny });
|
||||
|
||||
let mask = self.edge_mask(nx, ny);
|
||||
if mask.count_ones() >= 3 {
|
||||
return (points, d, nx, ny); // reached a node
|
||||
}
|
||||
if nx == x0 && ny == y0 {
|
||||
return (points, d, nx, ny); // closed ring
|
||||
}
|
||||
// Degree-2: continue via the unique present edge that is not the
|
||||
// reverse of how we arrived.
|
||||
let rev = reverse(d);
|
||||
let mut nd = d;
|
||||
for cand in 0..4u8 {
|
||||
if cand != rev && (mask & (1 << cand)) != 0 {
|
||||
nd = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
d = nd;
|
||||
cx = nx;
|
||||
cy = ny;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass B — trace node-to-node segments.
|
||||
fn trace_segments(&mut self) {
|
||||
let node_corners: Vec<PointI32> = self.nodes.iter().map(|n| n.corner).collect();
|
||||
for (nid, corner) in node_corners.iter().enumerate() {
|
||||
let nid = nid as NodeId;
|
||||
let (x, y) = (corner.x, corner.y);
|
||||
let mask = self.edge_mask(x, y);
|
||||
for d in 0..4u8 {
|
||||
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
|
||||
continue;
|
||||
}
|
||||
let (left, right) = self.side_pixels(x, y, d);
|
||||
let (points, last_dir, ex, ey) = self.walk(x, y, d);
|
||||
let end = self
|
||||
.node_id(ex, ey)
|
||||
.expect("segment must end at a node");
|
||||
|
||||
let seg_id = self.segments.len() as SegId;
|
||||
self.segments.push(Segment {
|
||||
points,
|
||||
start: Some(nid),
|
||||
end: Some(end),
|
||||
left,
|
||||
right,
|
||||
first_dir: d,
|
||||
last_dir,
|
||||
});
|
||||
self.nodes[nid as usize].out[d as usize] = Some(SegRef {
|
||||
seg: seg_id,
|
||||
forward: true,
|
||||
});
|
||||
// Leaving the end node backward along this segment.
|
||||
let back = reverse(last_dir);
|
||||
self.nodes[end as usize].out[back as usize] = Some(SegRef {
|
||||
seg: seg_id,
|
||||
forward: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass C — closed rings from any remaining unvisited boundary edges.
|
||||
fn trace_rings(&mut self) {
|
||||
for y in 0..=self.h {
|
||||
for x in 0..=self.w {
|
||||
let mask = self.edge_mask(x, y);
|
||||
for d in 0..4u8 {
|
||||
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
|
||||
continue;
|
||||
}
|
||||
let (left, right) = self.side_pixels(x, y, d);
|
||||
let (points, _last, _ex, _ey) = self.walk(x, y, d);
|
||||
self.segments.push(Segment {
|
||||
points,
|
||||
start: None,
|
||||
end: None,
|
||||
left,
|
||||
right,
|
||||
first_dir: d,
|
||||
last_dir: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BoundaryGraph {
|
||||
pub fn extract(map: &LabelMap) -> BoundaryGraph {
|
||||
let mut ex = Extractor::new(map);
|
||||
ex.classify();
|
||||
ex.trace_segments();
|
||||
ex.trace_rings();
|
||||
BoundaryGraph {
|
||||
nodes: ex.nodes,
|
||||
segments: ex.segments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the
|
||||
/// face-assembly successor rule against a [`LabelMap`].
|
||||
pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId {
|
||||
let nw = map.label(x - 1, y - 1);
|
||||
let ne = map.label(x, y - 1);
|
||||
let sw = map.label(x - 1, y);
|
||||
let se = map.label(x, y);
|
||||
match d {
|
||||
N => nw,
|
||||
E => ne,
|
||||
S => se,
|
||||
W => sw,
|
||||
_ => OUTSIDE,
|
||||
}
|
||||
}
|
||||
|
||||
// Direction constants and edge-present test needed by face assembly.
|
||||
pub(super) fn edge_present(map: &LabelMap, x: i32, y: i32, d: u8) -> bool {
|
||||
let nw = map.label(x - 1, y - 1);
|
||||
let ne = map.label(x, y - 1);
|
||||
let sw = map.label(x - 1, y);
|
||||
let se = map.label(x, y);
|
||||
match d {
|
||||
N => nw != ne,
|
||||
E => ne != se,
|
||||
S => sw != se,
|
||||
W => nw != sw,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Mosaic mode: a seam-free, gapless tessellation.
|
||||
//!
|
||||
//! Instead of tracing every region independently (which lets neighboring
|
||||
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
|
||||
//!
|
||||
//! ```text
|
||||
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
|
||||
//! ```
|
||||
//!
|
||||
//! Every boundary curve exists exactly once; the two adjacent regions
|
||||
//! reference the same fitted geometry, one traversed reversed. Reversal is
|
||||
//! exact, so the serialized coordinates match on both sides — no seams.
|
||||
//!
|
||||
//! Stages 1–2 (graph + faces) are pure integer arithmetic on the lattice of
|
||||
//! pixel corners. Only fitting (stage 3) is floating point.
|
||||
|
||||
mod compose;
|
||||
mod face;
|
||||
mod fit;
|
||||
mod graph;
|
||||
|
||||
pub use compose::compose_mosaic;
|
||||
pub use fit::{
|
||||
FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter,
|
||||
};
|
||||
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
|
||||
|
||||
use crate::ir::{Paint, Segmentation};
|
||||
|
||||
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
|
||||
pub type RegionId = u32;
|
||||
|
||||
/// Sentinel label for pixels outside any region.
|
||||
pub const OUTSIDE: RegionId = u32::MAX;
|
||||
|
||||
/// Options controlling mosaic fitting and output.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MosaicOptions {
|
||||
/// Sample fitted segments and fall back to the DP polyline on any that
|
||||
/// exceed the 0.5px deviation budget, restoring a hard no-crossing guarantee.
|
||||
pub strict: bool,
|
||||
/// Stroke each path in its own fill color to hide antialiasing hairlines.
|
||||
pub seam_stroke: bool,
|
||||
}
|
||||
|
||||
/// A flat partition of the canvas: one region id per pixel, plus the paint for
|
||||
/// each region. This is the sole input to the boundary-graph extractor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabelMap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
|
||||
pub labels: Vec<RegionId>,
|
||||
/// Paint per region, indexed by label.
|
||||
pub paints: Vec<Paint>,
|
||||
}
|
||||
|
||||
impl LabelMap {
|
||||
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
|
||||
/// pixel takes the paint of the topmost layer covering it. Layers are
|
||||
/// bottom-to-top, so painting them in order lets higher layers win.
|
||||
pub fn from_segmentation(seg: &Segmentation) -> Self {
|
||||
let w = seg.width as usize;
|
||||
let h = seg.height as usize;
|
||||
let mut labels = vec![OUTSIDE; w * h];
|
||||
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
|
||||
|
||||
for (i, layer) in seg.layers.iter().enumerate() {
|
||||
let mask = &layer.mask;
|
||||
for ly in 0..mask.image.height {
|
||||
for lx in 0..mask.image.width {
|
||||
if mask.image.get_pixel(lx, ly) {
|
||||
let gx = mask.offset.x + lx as i32;
|
||||
let gy = mask.offset.y + ly as i32;
|
||||
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
|
||||
labels[gy as usize * w + gx as usize] = i as RegionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LabelMap {
|
||||
width: seg.width,
|
||||
height: seg.height,
|
||||
labels,
|
||||
paints,
|
||||
}
|
||||
}
|
||||
|
||||
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
|
||||
/// Treating outside as a real label removes all image-border special cases.
|
||||
#[inline]
|
||||
pub fn label(&self, x: i32, y: i32) -> RegionId {
|
||||
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
|
||||
return OUTSIDE;
|
||||
}
|
||||
self.labels[y as usize * self.width as usize + x as usize]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::face::{assemble, Face};
|
||||
use super::graph::BoundaryGraph;
|
||||
use super::*;
|
||||
use crate::ir::Paint;
|
||||
use visioncortex::{Color, PointF64};
|
||||
|
||||
/// Build a label map from a row-major grid (for tests).
|
||||
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
|
||||
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
|
||||
let n = max.map(|m| m as usize + 1).unwrap_or(0);
|
||||
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
|
||||
LabelMap {
|
||||
width,
|
||||
height,
|
||||
labels,
|
||||
paints,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct a face's contour polygons in exact lattice coordinates.
|
||||
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
|
||||
face.contours
|
||||
.iter()
|
||||
.map(|contour| {
|
||||
let mut ring: Vec<PointF64> = Vec::new();
|
||||
for (i, sref) in contour.0.iter().enumerate() {
|
||||
let pts = &graph.segments[sref.seg as usize].points;
|
||||
let ordered: Vec<PointF64> = if sref.forward {
|
||||
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
|
||||
} else {
|
||||
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
|
||||
};
|
||||
if i == 0 {
|
||||
ring.extend(ordered);
|
||||
} else {
|
||||
ring.extend(ordered[1..].iter().copied());
|
||||
}
|
||||
}
|
||||
ring
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
|
||||
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
|
||||
}
|
||||
|
||||
/// Winding number of point `p` w.r.t. a closed ring (last == first).
|
||||
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
|
||||
let mut wn = 0;
|
||||
for w in ring.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if a.y <= p.y {
|
||||
if b.y > p.y && is_left(a, b, p) > 0.0 {
|
||||
wn += 1;
|
||||
}
|
||||
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
|
||||
wn -= 1;
|
||||
}
|
||||
}
|
||||
wn
|
||||
}
|
||||
|
||||
/// The strongest guarantee: rasterize the composed faces at pixel centers
|
||||
/// and assert the result is byte-identical to the input label map.
|
||||
fn assert_pixel_roundtrip(map: &LabelMap) {
|
||||
let graph = BoundaryGraph::extract(map);
|
||||
let faces = assemble(&graph, map);
|
||||
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
|
||||
.iter()
|
||||
.map(|f| (f.region, face_polygons(&graph, f)))
|
||||
.collect();
|
||||
|
||||
for y in 0..map.height as i32 {
|
||||
for x in 0..map.width as i32 {
|
||||
let center = PointF64 {
|
||||
x: x as f64 + 0.5,
|
||||
y: y as f64 + 0.5,
|
||||
};
|
||||
let mut hits: Vec<RegionId> = Vec::new();
|
||||
for (region, rings) in &polys {
|
||||
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
|
||||
if wn != 0 {
|
||||
hits.push(*region);
|
||||
}
|
||||
}
|
||||
let expected = map.label(x, y);
|
||||
if expected == OUTSIDE {
|
||||
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
|
||||
} else {
|
||||
assert_eq!(
|
||||
hits,
|
||||
vec![expected],
|
||||
"({x},{y}) expected region {expected}, got {hits:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_region_is_one_ring() {
|
||||
let map = grid(3, 2, vec![0; 6]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
|
||||
assert_eq!(graph.segments.len(), 1, "one border ring");
|
||||
assert!(graph.segments[0].is_ring());
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_split() {
|
||||
// 4x2, left half 0, right half 1.
|
||||
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
// Two border junctions where the split meets the top and bottom edges.
|
||||
assert_eq!(graph.nodes.len(), 2);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_junction() {
|
||||
// top row one region, bottom row split — a degree-3 interior node.
|
||||
let map = grid(2, 2, vec![0, 0, 1, 2]);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkerboard_pinch() {
|
||||
// A B / B A — the center corner is a degree-4 pinch; each region is two
|
||||
// lobes touching there. (The four boundary/border corners are degree-3
|
||||
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
|
||||
// the real check that the pinch produces exact, simple contours.
|
||||
let map = grid(2, 2, vec![0, 1, 1, 0]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let has_degree4 = graph.nodes.iter().any(|n| {
|
||||
let c = n.corner;
|
||||
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
|
||||
});
|
||||
assert!(has_degree4, "expected a degree-4 pinch node at the center");
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_rings() {
|
||||
// Concentric squares: 0 outer, 1 middle, 2 center.
|
||||
let l = |x: i32, y: i32| -> RegionId {
|
||||
let d = x.min(y).min(5 - x).min(5 - y);
|
||||
match d {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
_ => 2,
|
||||
}
|
||||
};
|
||||
let mut labels = Vec::new();
|
||||
for y in 0..6 {
|
||||
for x in 0..6 {
|
||||
labels.push(l(x, y));
|
||||
}
|
||||
}
|
||||
assert_pixel_roundtrip(&grid(6, 6, labels));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_region_border_touching() {
|
||||
// A region that does not fill the canvas; the rest is OUTSIDE.
|
||||
let mut labels = vec![OUTSIDE; 16];
|
||||
for y in 1..3 {
|
||||
for x in 1..3 {
|
||||
labels[y * 4 + x] = 0;
|
||||
}
|
||||
}
|
||||
assert_pixel_roundtrip(&grid(4, 4, labels));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_maps_roundtrip() {
|
||||
// Deterministic LCG; connectivity not required.
|
||||
let mut state: u64 = 0x1234_5678_9abc_def0;
|
||||
let mut next = || {
|
||||
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(state >> 33) as u32
|
||||
};
|
||||
for _ in 0..40 {
|
||||
let w = 2 + next() % 10;
|
||||
let h = 2 + next() % 10;
|
||||
let nlabels = 1 + next() % 5;
|
||||
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
|
||||
assert_pixel_roundtrip(&grid(w, h, labels));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@
|
||||
use visioncortex::ColorImage;
|
||||
|
||||
use crate::colorfit::ColorFitter;
|
||||
use crate::compose::{compose_stacked, Compositing};
|
||||
use crate::compose::Compositing;
|
||||
use crate::error::Error;
|
||||
use crate::fitter::CurveFitter;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::ir::VectorDoc;
|
||||
use crate::optimize::OptimizerPass;
|
||||
@@ -16,7 +15,6 @@ use crate::svg::SvgWriter;
|
||||
pub struct Pipeline {
|
||||
pub frontend: Box<dyn Frontend>,
|
||||
pub color_fitters: Vec<Box<dyn ColorFitter>>,
|
||||
pub fitter: Box<dyn CurveFitter>,
|
||||
pub compositing: Compositing,
|
||||
pub optimizers: Vec<Box<dyn OptimizerPass>>,
|
||||
pub writer: SvgWriter,
|
||||
@@ -31,9 +29,7 @@ impl Pipeline {
|
||||
fitter.fit(&mut seg);
|
||||
}
|
||||
|
||||
let mut doc = match self.compositing {
|
||||
Compositing::Stacked => compose_stacked(&seg, self.fitter.as_ref()),
|
||||
};
|
||||
let mut doc = self.compositing.compose(&seg);
|
||||
|
||||
for pass in &self.optimizers {
|
||||
pass.run(&mut doc);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode};
|
||||
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical};
|
||||
|
||||
// --- synthetic image builders ------------------------------------------------
|
||||
|
||||
@@ -177,6 +177,26 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Mosaic (seam-free tessellation): exact pixel and polygon fitters.
|
||||
(
|
||||
"disc_mosaic_pixel",
|
||||
disc(),
|
||||
Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Pixel,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"checker_mosaic_polygon",
|
||||
checker(),
|
||||
Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Polygon,
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M16,0H8V8h8V0Z" fill="#EBEBEB"/>
|
||||
<path d="M48,40H40v8h8V40Z" fill="#141414"/>
|
||||
<path d="M40,40H32v8h8V40Z" fill="#EBEBEB"/>
|
||||
<path d="M32,40H24v8h8V40Z" fill="#141414"/>
|
||||
<path d="M24,40H16v8h8V40Z" fill="#EBEBEB"/>
|
||||
<path d="M16,40H8v8h8V40Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M8,40H0v8H8V40Z"/>
|
||||
<path d="M48,32H40v8h8V32Z"/>
|
||||
</g>
|
||||
<path d="M40,32H32v8h8V32Z" fill="#141414"/>
|
||||
<path d="M32,32H24v8h8V32Z" fill="#EBEBEB"/>
|
||||
<path d="M24,32H16v8h8V32Z" fill="#141414"/>
|
||||
<path d="M16,32H8v8h8V32Z" fill="#EBEBEB"/>
|
||||
<g fill="#141414">
|
||||
<path d="M8,32H0v8H8V32Z"/>
|
||||
<path d="M48,24H40v8h8V24Z"/>
|
||||
</g>
|
||||
<path d="M40,24H32v8h8V24Z" fill="#EBEBEB"/>
|
||||
<path d="M32,24H24v8h8V24Z" fill="#141414"/>
|
||||
<path d="M24,24H16v8h8V24Z" fill="#EBEBEB"/>
|
||||
<path d="M16,24H8v8h8V24Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M8,24H0v8H8V24Z"/>
|
||||
<path d="M48,16H40v8h8V16Z"/>
|
||||
</g>
|
||||
<path d="M40,16H32v8h8V16Z" fill="#141414"/>
|
||||
<path d="M32,16H24v8h8V16Z" fill="#EBEBEB"/>
|
||||
<path d="M24,16H16v8h8V16Z" fill="#141414"/>
|
||||
<path d="M16,16H8v8h8V16Z" fill="#EBEBEB"/>
|
||||
<g fill="#141414">
|
||||
<path d="M8,16H0v8H8V16Z"/>
|
||||
<path d="M48,8H40v8h8V8Z"/>
|
||||
</g>
|
||||
<path d="M40,8H32v8h8V8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,8H24v8h8V8Z" fill="#141414"/>
|
||||
<path d="M24,8H16v8h8V8Z" fill="#EBEBEB"/>
|
||||
<path d="M16,8H8v8h8V8Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M8,8H0v8H8V8Z"/>
|
||||
<path d="M48,8V0H40V8h8Z"/>
|
||||
</g>
|
||||
<path d="M40,0H32V8h8V0Z" fill="#141414"/>
|
||||
<path d="M32,0H24V8h8V0Z" fill="#EBEBEB"/>
|
||||
<g fill="#141414">
|
||||
<path d="M24,0H16V8h8V0Z"/>
|
||||
<path d="M8,8V0H0V8H8Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0L0,48l48,0L48,0L0,0ZM24,8l1,0l0,1l5,0l0,1l2,0l0,1l2,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,2l1,0l0,2l1,0l0,5l1,0l0,1l-1,0l0,5l-1,0l0,2l-1,0l0,2l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-2,0l0,1l-2,0l0,1l-5,0l0,1l-1,0l0-1l-5,0l0-1l-2,0l0-1l-2,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-2l-1,0l0-2L9,30l0-5L8,25l0-1l1,0l0-5l1,0l0-2l1,0l0-2l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l2,0l0-1l2,0l0-1l5,0l0-1Z" fill="#F0F0F0"/>
|
||||
<path d="M24,8l0,1L19,9l0,1l-2,0l0,1l-2,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,2l-1,0l0,2L9,19l0,5L8,24l0,1l1,0l0,5l1,0l0,2l1,0l0,2l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l2,0l0,1l2,0l0,1l5,0l0,1l1,0l0-1l5,0l0-1l2,0l0-1l2,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-2l1,0l0-2l1,0l0-5l1,0l0-1l-1,0l0-5l-1,0l0-2l-1,0l0-2l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-2,0l0-1l-2,0l0-1L25,9l0-1L24,8Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 985 B |
@@ -78,11 +78,12 @@ fn optimize_levels_shrink_or_match() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_is_reported_unsupported() {
|
||||
fn mosaic_cutout_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
let config = Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
..Config::default()
|
||||
};
|
||||
let err = config.build().err().expect("cutout should be unsupported");
|
||||
assert!(err.to_string().contains("mosaic"));
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user