Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backport improved line clipping #39

Merged
merged 1 commit into from
Aug 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Loading