Skip to content

Commit

Permalink
Backport improved line clipping
Browse files Browse the repository at this point in the history
  • Loading branch information
johannes-wolf committed Aug 16, 2024
1 parent 3d79a50 commit 2706a4c
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 140 deletions.
2 changes: 1 addition & 1 deletion src/plot/line.typ
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
mark-style: (:),
samples: 50,
sample-at: (),
line: "linear",
line: "raw",
axes: ("x", "y"),
label: none,
data
Expand Down
222 changes: 83 additions & 139 deletions src/plot/util.typ
Original file line number Diff line number Diff line change
Expand Up @@ -6,165 +6,114 @@
/// - points (array): Array of vectors representing a line-strip
/// - low (vector): Lower clip-window coordinate
/// - high (vector): Upper clip-window coordinate
/// - fill (bool): Return fillable shapes
/// - generate-edge-points (bool): Generate interpolated points on clipped edges
/// -> array List of line-strips representing the paths insides the clip-window
#let clipped-paths(points, low, high, fill: false) = {
#let clipped-paths-rect(points, low, high, fill: false, generate-edge-points: false) = {
let (min-x, max-x) = (calc.min(low.at(0), high.at(0)),
calc.max(low.at(0), high.at(0)))
let (min-y, max-y) = (calc.min(low.at(1), high.at(1)),
calc.max(low.at(1), high.at(1)))

let in-rect(pt) = {
return (pt.at(0) >= min-x and pt.at(0) <= max-x and
pt.at(1) >= min-y and pt.at(1) <= max-y)
let in-rect((x, y)) = {
return (x >= min-x and x <= max-x and
y >= min-y and y <= max-y)
}

let interpolated-end(a, b) = {
if in-rect(a) and in-rect(b) {
return b
}

let (x1, y1, ..) = a
let (x2, y2, ..) = b

if x2 - x1 == 0 {
return (x2, calc.min(max-y, calc.max(y2, min-y)))
}

if y2 - y1 == 0 {
return (calc.min(max-x, calc.max(x2, min-x)), y2)
}

let m = (y2 - y1) / (x2 - x1)
let n = y2 - m * x2

let x = x2
let y = y2
let edges = (
((min-x, min-y), (min-x, max-y)),
((max-x, min-y), (max-x, max-y)),
((min-x, min-y), (max-x, min-y)),
((min-x, max-y), (max-x, max-y)),
)

y = calc.min(max-y, calc.max(y, min-y))
x = (y - n) / m

x = calc.min(max-x, calc.max(x, min-x))
y = m * x + n

return (x, y)
}

// Append path to paths and return paths
//
// If path starts or ends with a vector of another part, merge those
// paths instead appending path as a new path.
let append-path(paths, path) = {
if path.len() <= 1 {
return paths
}

let cmp(a, b) = {
return a.map(calc.round.with(digits: 8)) == b.map(calc.round.with(digits: 8))
}

let added = false
for i in range(0, paths.len()) {
let p = paths.at(i)
if cmp(p.first(), path.last()) {
paths.at(i) = path + p
added = true
} else if cmp(p.first(), path.first()) {
paths.at(i) = path.rev() + p
added = true
} else if cmp(p.last(), path.first()) {
paths.at(i) = p + path
added = true
} else if cmp(p.last(), path.last()) {
paths.at(i) = p + path.rev()
added = true
let interpolated-end(a, b) = {
let pts = ()
for (edge-a, edge-b) in edges {
let pt = cetz.intersection.line-line(a, b, edge-a, edge-b)
if pt != none {
pts.push(pt)
}
if added { break }
}

if not added {
paths.push(path)
}
return paths
return pts
}

let clamped-pt(pt) = {
return (calc.max(min-x, calc.min(pt.at(0), max-x)),
calc.max(min-y, calc.min(pt.at(1), max-y)))
}
// Find lines crossing the rect bounds
// by storing all crossings as tuples (<index>, <goes-inside>, <point-on-border>)
let crossings = ()

let paths = ()

let path = ()
let prev = points.at(0)
let was-inside = in-rect(prev)
// Push a pseudo entry for the last point, if it is insides the bounds.
let was-inside = in-rect(points.at(0))
if was-inside {
path.push(prev)
} else if fill {
path.push(clamped-pt(prev))
crossings.push((0, true, points.first()))
}

// Find crossings and compute intersection points.
for i in range(1, points.len()) {
let prev = points.at(i - 1)
let pt = points.at(i)

let is-inside = in-rect(pt)

let (x1, y1) = prev
let (x2, y2) = pt

// Ignore lines if both ends are outsides the x-window and on the
// same side.
if (x1 < min-x and x2 < min-x) or (x1 > max-x and x2 > max-x) {
if fill {
let clamped = clamped-pt(pt)
if path.last() != clamped {
path.push(clamped)
let current-inside = in-rect(points.at(i))
if current-inside != was-inside {
crossings.push((
i,
current-inside,
interpolated-end(points.at(i - 1), points.at(i)).first()))
was-inside = current-inside
} else if not current-inside {
let (px, py) = points.at(i - 1)
let (cx, cy) = points.at(i)
let (lo-x, hi-x) = (calc.min(px, cx), calc.max(px, cx))
let (lo-y, hi-y) = (calc.min(py, cy), calc.max(py, cy))

let x-differs = (lo-x < min-x and hi-x > max-x) or (lo-x < max-x and hi-x > max-x)
let y-differs = (lo-y < min-y and hi-y > max-y) or (lo-y < max-y and hi-y > max-y)
if x-differs or y-differs {
for pt in interpolated-end(points.at(i - 1), points.at(i)) {
crossings.push((i, not current-inside, pt))
current-inside = not current-inside
}
}
was-inside = false
continue
}
}

if is-inside {
if was-inside {
path.push(pt)
} else {
path.push(interpolated-end(pt, prev))
path.push(pt)
}
} else {
if was-inside {
path.push(interpolated-end(prev, pt))
} else {
let (a, b) = (interpolated-end(pt, prev),
interpolated-end(prev, pt))
if in-rect(a) and in-rect(b) {
path.push(a)
path.push(b)
} else if fill {
let clamped = clamped-pt(pt)
if path.last() != clamped {
path.push(clamped)
}
// Push a pseudo entry for the last point, if it is insides the bounds.
if in-rect(points.last()) and crossings.last().at(1) {
crossings.push((points.len() - 1, false, points.last()))
}

// Generate paths
let paths = ()
for i in range(1, crossings.len()) {
let (a-index, a-dir, a-pt) = crossings.at(i - 1)
let (b-index, b-dir, b-pt) = crossings.at(i)

if a-dir {
let path = ()

// If we must generate edge points, take the previous crossing
// as source point and interpolate between that and the current one.
if generate-edge-points and i > 2 {
let (c-index, c-dir, c-pt) = crossings.at(i - 2)

let n = a-index - c-index
if n > 1 {
path += range(0, n).map(t => {
cetz.vector.lerp(c-pt, a-pt, t / (n - 1))
})
}
}

if path.len() > 0 and not fill {
paths = append-path(paths, path)
path = ()
}
}

was-inside = is-inside
}
// Append the path insides the bounds
path.push(a-pt)
path += points.slice(a-index, b-index)
path.push(b-pt)

// Append clamped last point if filling
if fill and not in-rect(points.last()) {
path.push(clamped-pt(points.last()))
}
// Insert the last end point to connect
// to a filled area.
if fill and paths.len() > 0 {
path.insert(0, paths.last().last())
}

if path.len() > 1 {
paths = append-path(paths, path)
paths.push(path)
}
}

return paths
Expand All @@ -176,19 +125,14 @@
/// - low (vector): Lower clip-window coordinate
/// - high (vector): Upper clip-window coordinate
/// -> array List of stroke paths
#let compute-stroke-paths(points, low, high) = {
clipped-paths(points, low, high, fill: false)
}

#let compute-stroke-paths = clipped-paths-rect.with(fill: false)
/// Compute clipped fill path
///
/// - points (array): X/Y data points
/// - low (vector): Lower clip-window coordinate
/// - high (vector): Upper clip-window coordinate
/// -> array List of fill paths
#let compute-fill-paths(points, low, high) = {
clipped-paths(points, low, high, fill: true)
}
#let compute-fill-paths = clipped-paths-rect.with(fill: true)

/// Return points of a sampled catmull-rom through the
/// input points.
Expand Down
Binary file modified tests/plot/legend/ref/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/plot/ref/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 2706a4c

Please sign in to comment.