Skip to content

Commit

Permalink
shapes: Add hobby curve implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
johannes-wolf committed Nov 7, 2023
1 parent 891aba1 commit 451ee37
Show file tree
Hide file tree
Showing 12 changed files with 270 additions and 43 deletions.
6 changes: 6 additions & 0 deletions manual.typ
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,12 @@ catmull((0,0), (1,1), (2,-1), (3,0), tension: .4, stroke: blue)
catmull((0,0), (1,1), (2,-1), (3,0), tension: .5, stroke: red)
```

#show-module-fn(draw-module, "hobby")
```example
hobby((0,0), (1,1), (2,-1), (3,0), omega: 0, stroke: blue)
hobby((0,0), (1,1), (2,-1), (3,0), omega: 1, stroke: red)
```

#show-module-fn(draw-module, "grid")
```example
grid((0,0), (3,2), help-lines: true)
Expand Down
114 changes: 114 additions & 0 deletions src/bezier.typ
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,120 @@
return ()
}

/// Returns a list of cubic bezier curves that are fitted onto
/// a sequence of points in 2D.
///
/// - points (vector): List of points
/// - omega (float): Number between 0 and 1 that controls the curl
/// of the curve (1 is most curl)
/// - close (bool): Construct a closed curve
/// -> (array) List of cubic bezier curves (start, end, c0, c1)
#let hobby-to-cubic(points, omega, close: false) = {
assert(not close, message: "Closed hobby curves are not yet implemented")
// Implementation of Hobby's algorithm translated to Typst from:
// http://www.jakelow.com/blog/hobby-curves/hobby.js
//
// ISC License
//
// Copyright 2020 Jake Low
//
// Permission to use, copy, modify, and/or distribute this software for any purpose
// with or without fee is hereby granted, provided that the above copyright notice
// and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
// FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
// OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
// THIS SOFTWARE.
if close and points.len() > 2 and points.at(0) != points.at(-1) {
points.push(points.at(0))
}
let n = points.len() - 1

let chords = range(0, n).map(i => {
vector.sub(points.at(i + 1), points.at(i))
})
let d = range(0, n).map(i => {
vector.len(chords.at(i))
})
let angle-between(v, w) = {
calc.atan2(v.at(0) * w.at(0) + v.at(1) * w.at(1),
w.at(1) * v.at(0) - w.at(0) * v.at(1))
}
let gamma = (0,) + range(1, n).map(i => {
angle-between(chords.at(i - 1), chords.at(i)) / 1rad
}) + (0,)

let A = (0,)
let B = (2 + omega,)
let C = (2 * omega + 1,)
let D = (-1 * C.at(0) * gamma.at(1),)
for i in range(1, n) {
A.push(1 / d.at(i - 1))
B.push((2 * d.at(i - 1) + 2 * d.at(i)) / (d.at(i - 1) * d.at(i)))
C.push(1 / d.at(i))
D.push((-1 * (2 * gamma.at(i) * d.at(i) + gamma.at(i + 1) * d.at(i - 1))) / (d.at(i - 1) * d.at(i)))
}
A.push(2 * omega + 1)
B.push(2 + omega)
C.push(0)
D.push(0)

let thomas(A, B, C, D) = {
let n = B.len() - 1

let Cp = (C.at(0) / B.at(0),)
let Dp = (D.at(0) / B.at(0),)
for i in range(1, n + 1) {
let denom = B.at(i) - Cp.at(i - 1) * A.at(i)
Cp.push(C.at(i) / denom)
Dp.push((D.at(i) - Dp.at(i - 1) * A.at(i)) / denom)
}

let X = range(0, n + 1).map(_ => 0)
X.at(n) = Dp.at(n)
for i in range(n - 1, -1, step: -1) {
X.at(i) = Dp.at(i) - Cp.at(i) * X.at(i + 1)
}
return X
}

let alpha = thomas(A, B, C, D)
let beta = range(0, n - 1).map(i => {
-1 * gamma.at(i + 1) - alpha.at(i + 1)
}) + (-1 * alpha.at(n),)

let rho(x, y) = {
let c = 2 / 3
return 2 / (1 + c * calc.cos(y) + (1 - c) * calc.cos(x))
}

let rot(pt, angle) = {
let ca = calc.cos(angle)
let sa = calc.sin(angle)
(pt.at(0) * ca - pt.at(1) * sa, pt.at(0) * sa + pt.at(1) * ca)
}

let c0 = ()
let c1 = ()
for i in range(0, n) {
let a = (rho(alpha.at(i), beta.at(i)) * d.at(i)) / 3
let b = (rho(beta.at(i), alpha.at(i)) * d.at(i)) / 3

c0.push(vector.add(points.at(i),
vector.scale(vector.norm(rot(chords.at(i), alpha.at(i))), a)))
c1.push(vector.sub(points.at(i + 1),
vector.scale(vector.norm(rot(chords.at(i), -1 * beta.at(i))), b)))
}

return range(0, n).map(i => {
(points.at(i), points.at(i + 1), c0.at(i), c1.at(i))
})
}

/// Find roots of a cubic polynomial with the coefficients a, b, c and d
///
/// -> array Array of roots
Expand Down
2 changes: 1 addition & 1 deletion src/draw.typ
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#import "draw/grouping.typ": intersections, group, anchor, copy-anchors, place-anchors, set-ctx, get-ctx, for-each-anchor, on-layer, place-marks
#import "draw/transformations.typ": rotate, translate, scale, set-origin, move-to, set-viewport
#import "draw/styling.typ": set-style, fill, stroke
#import "draw/shapes.typ": circle, circle-through, arc, mark, line, grid, content, rect, bezier, bezier-through, catmull, merge-path, shadow
#import "draw/shapes.typ": circle, circle-through, arc, mark, line, grid, content, rect, bezier, bezier-through, catmull, hobby, merge-path, shadow
79 changes: 71 additions & 8 deletions src/draw/shapes.typ
Original file line number Diff line number Diff line change
Expand Up @@ -828,24 +828,87 @@
}

let style = styles.resolve(ctx.style, style, root: "catmull")
let curves = bezier_.catmull-to-cubic(
pts,
style.tension,
close: close)

let (marks, pts) = if style.mark != none {
mark_.place-marks-along-catmull(ctx, pts, style, style.mark, close: close)
let (marks, curves) = if style.mark != none {
mark_.place-marks-along-beziers(ctx, curves, style, style.mark)
} else {
(none, pts)
(none, curves)
}

let drawables = (
drawable.path(
bezier_.catmull-to-cubic(
pts,
style.tension,
close: close
).map(c => path-util.cubic-segment(..c)),
curves.map(c => path-util.cubic-segment(..c)),
fill: style.fill,
stroke: style.stroke,
close: close),)
if marks != none {
drawables += marks
}

return (
ctx: ctx,
name: name,
anchors: anchors,
drawables: drawable.apply-transform(
transform,
drawables
)
)
},)
}


#let hobby(..pts-style, close: false, name: none) = {
let (pts, style) = (pts-style.pos(), pts-style.named())

assert(pts.len() >= 2, message: "Hobby curve requires at least two points. Got " + repr(pts.len()) + "instead.")

pts.map(coordinate.resolve-system)

return (ctx => {
let (ctx, ..pts) = coordinate.resolve(ctx, ..pts)

let (transform, anchors) = {
let a = (
start: pts.first(),
end: pts.last(),
)
for (i, pt) in pts.enumerate() {
a.insert("pt-" + str(i), pt)
}
anchor_.setup(
anchor => {
a.at(anchor)
},
a.keys(),
name: name,
default: "start",
transform: ctx.transform
)
}

let style = styles.resolve(ctx.style, style, root: "hobby")
let curves = bezier_.hobby-to-cubic(
pts,
style.omega,
close: close)

let (marks, curves) = if style.mark != none {
mark_.place-marks-along-beziers(ctx, curves, style, style.mark)
} else {
(none, curves)
}

let drawables = (
drawable.path(
curves.map(c => path-util.cubic-segment(..c)),
fill: style.fill,
stroke: style.stroke,
close: close),)
if marks != none {
drawables += marks
}
Expand Down
51 changes: 18 additions & 33 deletions src/mark.typ
Original file line number Diff line number Diff line change
Expand Up @@ -254,48 +254,33 @@
return (drawables, curve)
}

/// Place marks along a catmull-rom curve
/// Place marks along a list of cubic bezier curves
///
/// - ctx (context): Context
/// - pts (array): Array of curve points
/// - curves (array): Array of curves
/// - style (style): Curve style
/// - mark-style (style): Mark style
/// -> (drawables, curve) Tuple of drawables and adjusted curve points
#let place-marks-along-catmull(ctx, pts, style, mark-style, close: false) = {
let curves = bezier.catmull-to-cubic(
pts,
style.tension,
close: close)
/// -> (drawables, curves) Tuple of drawables and adjusted curves
#let place-marks-along-beziers(ctx, curves, style, mark-style) = {
if curves.len() == 1 {
let (drawables, curve) = place-marks-along-bezier(
let (marks, curve) = place-marks-along-bezier(
ctx, curves.at(0), style, mark-style)
return (drawables, (curve.at(0), curve.at(1)))
return (marks, (curve,))
} else {
// TODO: This has the limitation that only the first curve of
// the catmull-rom is used for placing marks.
let drawables = ()

let start-marks = mark-style
start-marks.end = none
if start-marks.start != none {
let (drawables-start, curve-start) = place-marks-along-bezier(
ctx, curves.at(0), style, start-marks)
pts.at(0) = curve-start.at(0)
drawables += drawables-start
}

let end-marks = mark-style
end-marks.start = none
if end-marks.end != none {
let (drawables-end, curve-end) = place-marks-along-bezier(
ctx, curves.at(-1), style, end-marks)
if not close {
pts.at(-1) = curve-end.at(1)
}
drawables += drawables-end
}

return (drawables, pts)
let start-mark-style = mark-style
start-mark-style.end = none
let (start-marks, start-curve) = place-marks-along-bezier(
ctx, curves.at(0), style, start-mark-style)

let end-mark-style = mark-style
end-mark-style.start = none
let (end-marks, end-curve) = place-marks-along-bezier(
ctx, curves.at(-1), style, end-mark-style)
curves.at(0) = start-curve
curves.at(-1) = end-curve
return (start-marks + end-marks, curves)
}
}

Expand Down
15 changes: 14 additions & 1 deletion src/styles.typ
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

// Default mark style
//
// Asuming a mark ">" is pointing directly to the right:
// Assuming a mark ">" is pointing directly to the right:
// - length Sets the length of the mark along its direction (in this case, its horizontal size)
// - width Sets the size of the mark along the normal of its direction
// - inset Sets the inner length of triangular shaped marks
Expand Down Expand Up @@ -62,6 +62,19 @@
),
shorten: "LINEAR",
),
hobby: (
omega: 0,
mark: (
.._default-mark,
// If true, the mark points in the direction of the secant from
// its base to its tip. If false, the tangent at the marks tip is used.
flex: true,
// Max. number of samples to use for calculating curve positions
// a higher number gives better results but may slow down compilation.
position-samples: 30,
),
shorten: "LINEAR",
),
arc: (
// Supported values:
// - "OPEN"
Expand Down
Binary file added tests/hobby/ref.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions tests/hobby/test.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#set page(width: auto, height: auto)
#import "/src/lib.typ": *

#box(stroke: 2pt + red, canvas({
import draw: *
hobby((0,0), (1,0))
}))

#box(stroke: 2pt + red, canvas({
import draw: *
hobby((0,-1), (1,1), (2,0), (3,1), (4,0), (5,2), omega: 0)
}))

#box(stroke: 2pt + red, canvas({
import draw: *
hobby((0,-1), (1,1), (2,0), (3,1), (4,0), (5,2), omega: .5)
}))

#box(stroke: 2pt + red, canvas({
import draw: *
hobby((0,-1), (1,1), (2,0), (3,1), (4,0), (5,2), omega: 1)
}))
Binary file modified tests/mark/auto-offset/ref.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions tests/mark/auto-offset/test.typ
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,19 @@
bezier((-1,-.5), (1,1), (0,-.5), (0,1),
mark: (start: "|", end: "o", fill: red, stroke: blue))
}))

#box(stroke: 2pt + red, canvas({
import draw: *
rect((1,-1), (2,2))
rect((-2,-1), (-1,2))
catmull((-1,-.5), (0,-.5), (0,1), (1,1),
mark: (start: ">", end: ">", fill: red, stroke: blue))
}))

#box(stroke: 2pt + red, canvas({
import draw: *
rect((1,-1), (2,2))
rect((-2,-1), (-1,2))
hobby((-1,-.5), (0,-.5), (0,1), (1,1),
mark: (start: ">", end: ">", fill: red, stroke: blue))
}))
Binary file modified tests/mark/multiple/ref.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions tests/mark/multiple/test.typ
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
mark: (start: l, end: l, fill: red, stroke: blue, flex: true))
}))

#box(stroke: 2pt + red, canvas({
import draw: *
rect((1,-2), (2,2))
rect((-2,-2), (-1,2))
hobby((-1,-.5), (0,-.5), (0,1), (1,1),
mark: (start: l, end: l, fill: red, stroke: blue, flex: true))
}))

#box(stroke: 2pt + red, canvas({
import draw: *
line((-.5,1), (2.5,1))
Expand Down

0 comments on commit 451ee37

Please sign in to comment.