oxedyne/daimond/www/assets/typst/packs/preview/cetz/0.3.4.pack
283 KiB, 1 run
created by r2519314175:1065, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | DAIMOND TYPST PACK 1 |
| 2 | namespace preview |
| 3 | name cetz |
| 4 | version 0.3.4 |
| 5 | entrypoint src/lib.typ |
| 6 | file 7651 LICENSE |
| 7 | file 1953 src/aabb.typ |
| 8 | file 7180 src/anchor.typ |
| 9 | file 18107 src/bezier.typ |
| 10 | file 5393 src/canvas.typ |
| 11 | file 2712 src/complex.typ |
| 12 | file 8806 src/coordinate.typ |
| 13 | file 32 src/deps.typ |
| 14 | file 578 src/draw.typ |
| 15 | file 18646 src/draw/grouping.typ |
| 16 | file 5905 src/draw/projection.typ |
| 17 | file 52580 src/draw/shapes.typ |
| 18 | file 2059 src/draw/styling.typ |
| 19 | file 8545 src/draw/transformations.typ |
| 20 | file 871 src/draw/util.typ |
| 21 | file 6319 src/drawable.typ |
| 22 | file 9706 src/hobby.typ |
| 23 | file 3674 src/intersection.typ |
| 24 | file 488 src/lib.typ |
| 25 | file 6855 src/lib/angle.typ |
| 26 | file 157 src/lib/decorations.typ |
| 27 | file 12984 src/lib/decorations/brace.typ |
| 28 | file 15433 src/lib/decorations/path.typ |
| 29 | file 4248 src/lib/palette.typ |
| 30 | file 410 src/lib/pin.typ |
| 31 | file 6944 src/lib/tree.typ |
| 32 | file 8800 src/mark-shapes.typ |
| 33 | file 11874 src/mark.typ |
| 34 | file 9777 src/matrix.typ |
| 35 | file 15548 src/path-util.typ |
| 36 | file 2577 src/polygon.typ |
| 37 | file 2632 src/process.typ |
| 38 | file 867 src/sorting.typ |
| 39 | file 8949 src/styles.typ |
| 40 | file 13292 src/util.typ |
| 41 | file 5002 src/vector.typ |
| 42 | file 30 src/version.typ |
| 43 | file 608 typst.toml |
| 44 | |
| 45 | GNU LESSER GENERAL PUBLIC LICENSE |
| 46 | Version 3, 29 June 2007 |
| 47 | |
| 48 | Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> |
| 49 | Everyone is permitted to copy and distribute verbatim copies |
| 50 | of this license document, but changing it is not allowed. |
| 51 | |
| 52 | |
| 53 | This version of the GNU Lesser General Public License incorporates |
| 54 | the terms and conditions of version 3 of the GNU General Public |
| 55 | License, supplemented by the additional permissions listed below. |
| 56 | |
| 57 | 0. Additional Definitions. |
| 58 | |
| 59 | As used herein, "this License" refers to version 3 of the GNU Lesser |
| 60 | General Public License, and the "GNU GPL" refers to version 3 of the GNU |
| 61 | General Public License. |
| 62 | |
| 63 | "The Library" refers to a covered work governed by this License, |
| 64 | other than an Application or a Combined Work as defined below. |
| 65 | |
| 66 | An "Application" is any work that makes use of an interface provided |
| 67 | by the Library, but which is not otherwise based on the Library. |
| 68 | Defining a subclass of a class defined by the Library is deemed a mode |
| 69 | of using an interface provided by the Library. |
| 70 | |
| 71 | A "Combined Work" is a work produced by combining or linking an |
| 72 | Application with the Library. The particular version of the Library |
| 73 | with which the Combined Work was made is also called the "Linked |
| 74 | Version". |
| 75 | |
| 76 | The "Minimal Corresponding Source" for a Combined Work means the |
| 77 | Corresponding Source for the Combined Work, excluding any source code |
| 78 | for portions of the Combined Work that, considered in isolation, are |
| 79 | based on the Application, and not on the Linked Version. |
| 80 | |
| 81 | The "Corresponding Application Code" for a Combined Work means the |
| 82 | object code and/or source code for the Application, including any data |
| 83 | and utility programs needed for reproducing the Combined Work from the |
| 84 | Application, but excluding the System Libraries of the Combined Work. |
| 85 | |
| 86 | 1. Exception to Section 3 of the GNU GPL. |
| 87 | |
| 88 | You may convey a covered work under sections 3 and 4 of this License |
| 89 | without being bound by section 3 of the GNU GPL. |
| 90 | |
| 91 | 2. Conveying Modified Versions. |
| 92 | |
| 93 | If you modify a copy of the Library, and, in your modifications, a |
| 94 | facility refers to a function or data to be supplied by an Application |
| 95 | that uses the facility (other than as an argument passed when the |
| 96 | facility is invoked), then you may convey a copy of the modified |
| 97 | version: |
| 98 | |
| 99 | a) under this License, provided that you make a good faith effort to |
| 100 | ensure that, in the event an Application does not supply the |
| 101 | function or data, the facility still operates, and performs |
| 102 | whatever part of its purpose remains meaningful, or |
| 103 | |
| 104 | b) under the GNU GPL, with none of the additional permissions of |
| 105 | this License applicable to that copy. |
| 106 | |
| 107 | 3. Object Code Incorporating Material from Library Header Files. |
| 108 | |
| 109 | The object code form of an Application may incorporate material from |
| 110 | a header file that is part of the Library. You may convey such object |
| 111 | code under terms of your choice, provided that, if the incorporated |
| 112 | material is not limited to numerical parameters, data structure |
| 113 | layouts and accessors, or small macros, inline functions and templates |
| 114 | (ten or fewer lines in length), you do both of the following: |
| 115 | |
| 116 | a) Give prominent notice with each copy of the object code that the |
| 117 | Library is used in it and that the Library and its use are |
| 118 | covered by this License. |
| 119 | |
| 120 | b) Accompany the object code with a copy of the GNU GPL and this license |
| 121 | document. |
| 122 | |
| 123 | 4. Combined Works. |
| 124 | |
| 125 | You may convey a Combined Work under terms of your choice that, |
| 126 | taken together, effectively do not restrict modification of the |
| 127 | portions of the Library contained in the Combined Work and reverse |
| 128 | engineering for debugging such modifications, if you also do each of |
| 129 | the following: |
| 130 | |
| 131 | a) Give prominent notice with each copy of the Combined Work that |
| 132 | the Library is used in it and that the Library and its use are |
| 133 | covered by this License. |
| 134 | |
| 135 | b) Accompany the Combined Work with a copy of the GNU GPL and this license |
| 136 | document. |
| 137 | |
| 138 | c) For a Combined Work that displays copyright notices during |
| 139 | execution, include the copyright notice for the Library among |
| 140 | these notices, as well as a reference directing the user to the |
| 141 | copies of the GNU GPL and this license document. |
| 142 | |
| 143 | d) Do one of the following: |
| 144 | |
| 145 | 0) Convey the Minimal Corresponding Source under the terms of this |
| 146 | License, and the Corresponding Application Code in a form |
| 147 | suitable for, and under terms that permit, the user to |
| 148 | recombine or relink the Application with a modified version of |
| 149 | the Linked Version to produce a modified Combined Work, in the |
| 150 | manner specified by section 6 of the GNU GPL for conveying |
| 151 | Corresponding Source. |
| 152 | |
| 153 | 1) Use a suitable shared library mechanism for linking with the |
| 154 | Library. A suitable mechanism is one that (a) uses at run time |
| 155 | a copy of the Library already present on the user's computer |
| 156 | system, and (b) will operate properly with a modified version |
| 157 | of the Library that is interface-compatible with the Linked |
| 158 | Version. |
| 159 | |
| 160 | e) Provide Installation Information, but only if you would otherwise |
| 161 | be required to provide such information under section 6 of the |
| 162 | GNU GPL, and only to the extent that such information is |
| 163 | necessary to install and execute a modified version of the |
| 164 | Combined Work produced by recombining or relinking the |
| 165 | Application with a modified version of the Linked Version. (If |
| 166 | you use option 4d0, the Installation Information must accompany |
| 167 | the Minimal Corresponding Source and Corresponding Application |
| 168 | Code. If you use option 4d1, you must provide the Installation |
| 169 | Information in the manner specified by section 6 of the GNU GPL |
| 170 | for conveying Corresponding Source.) |
| 171 | |
| 172 | 5. Combined Libraries. |
| 173 | |
| 174 | You may place library facilities that are a work based on the |
| 175 | Library side by side in a single library together with other library |
| 176 | facilities that are not Applications and are not covered by this |
| 177 | License, and convey such a combined library under terms of your |
| 178 | choice, if you do both of the following: |
| 179 | |
| 180 | a) Accompany the combined library with a copy of the same work based |
| 181 | on the Library, uncombined with any other library facilities, |
| 182 | conveyed under the terms of this License. |
| 183 | |
| 184 | b) Give prominent notice with the combined library that part of it |
| 185 | is a work based on the Library, and explaining where to find the |
| 186 | accompanying uncombined form of the same work. |
| 187 | |
| 188 | 6. Revised Versions of the GNU Lesser General Public License. |
| 189 | |
| 190 | The Free Software Foundation may publish revised and/or new versions |
| 191 | of the GNU Lesser General Public License from time to time. Such new |
| 192 | versions will be similar in spirit to the present version, but may |
| 193 | differ in detail to address new problems or concerns. |
| 194 | |
| 195 | Each version is given a distinguishing version number. If the |
| 196 | Library as you received it specifies that a certain numbered version |
| 197 | of the GNU Lesser General Public License "or any later version" |
| 198 | applies to it, you have the option of following the terms and |
| 199 | conditions either of that published version or of any later version |
| 200 | published by the Free Software Foundation. If the Library as you |
| 201 | received it does not specify a version number of the GNU Lesser |
| 202 | General Public License, you may choose any version of the GNU Lesser |
| 203 | General Public License ever published by the Free Software Foundation. |
| 204 | |
| 205 | If the Library as you received it specifies that a proxy can decide |
| 206 | whether future versions of the GNU Lesser General Public License shall |
| 207 | apply, that proxy's public statement of acceptance of any version is |
| 208 | permanent authorization for you to choose that version for the |
| 209 | Library. |
| 210 | #import "vector.typ" |
| 211 | |
| 212 | /// Compute an axis aligned bounding box (aabb) for a list of <Type>vectors</Type>. |
| 213 | /// |
| 214 | /// - pts (array): List of <Type>vector</Type>s. |
| 215 | /// - init (aabb): Initial aabb |
| 216 | /// -> aabb |
| 217 | #let aabb(pts, init: none) = { |
| 218 | let bounds = init |
| 219 | |
| 220 | if type(pts) == array { |
| 221 | for (i, pt) in pts.enumerate() { |
| 222 | if bounds == none and i == 0 { |
| 223 | bounds = (low: pt, high: pt) |
| 224 | } else { |
| 225 | assert(type(pt) == array and pt.len() == 3, message: repr(init) + repr(pts)) |
| 226 | let (x, y, z) = pt |
| 227 | |
| 228 | let (lo-x, lo-y, lo-z) = bounds.low |
| 229 | bounds.low = (calc.min(lo-x, x), calc.min(lo-y, y), calc.min(lo-z, z)) |
| 230 | |
| 231 | let (hi-x, hi-y, hi-z) = bounds.high |
| 232 | bounds.high = (calc.max(hi-x, x), calc.max(hi-y, y), calc.max(hi-z, z)) |
| 233 | } |
| 234 | } |
| 235 | return bounds |
| 236 | } else if type(pts) == dictionary { |
| 237 | if init == none { |
| 238 | return pts |
| 239 | } else { |
| 240 | return aabb((pts.low, pts.high,), init: bounds) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | panic("Expected array of vectors or bbox dictionary, got: " + repr(pts)) |
| 245 | } |
| 246 | |
| 247 | /// Get the mid-point of an AABB as vector. |
| 248 | /// |
| 249 | /// - bounds (aabb): The AABB to get the mid-point of. |
| 250 | /// -> vector |
| 251 | #let mid(bounds) = { |
| 252 | return vector.scale(vector.add(bounds.low, bounds.high), .5) |
| 253 | } |
| 254 | |
| 255 | /// Get the size of an aabb as vector. This is a vector from the aabb's low to high. |
| 256 | /// |
| 257 | /// - bounds (aabb): The aabb to get the size of. |
| 258 | /// -> vector |
| 259 | #let size(bounds) = { |
| 260 | return vector.sub(bounds.high, bounds.low) |
| 261 | } |
| 262 | |
| 263 | /// Pad AABB with padding from dictionary with keys top, left, right and bottom. |
| 264 | /// |
| 265 | /// - bounds (aabb): The AABB to pad. |
| 266 | /// - padding (none, dictionary): Padding values |
| 267 | /// |
| 268 | /// -> aabb |
| 269 | #let padded(bounds, padding) = { |
| 270 | if padding != none { |
| 271 | bounds.low.at(0) -= padding.at("left", default: 0) |
| 272 | bounds.low.at(1) -= padding.at("top", default: 0) |
| 273 | bounds.high.at(0) += padding.at("right", default: 0) |
| 274 | bounds.high.at(1) += padding.at("bottom", default: 0) |
| 275 | } |
| 276 | return bounds |
| 277 | } |
| 278 | #import "deps.typ" |
| 279 | #import deps.oxifmt: strfmt |
| 280 | |
| 281 | #import "util.typ" |
| 282 | #import "intersection.typ" |
| 283 | #import "drawable.typ" |
| 284 | #import "path-util.typ" |
| 285 | #import "matrix.typ" |
| 286 | #import "vector.typ" |
| 287 | |
| 288 | // Compass direction to angle |
| 289 | #let named-border-anchors = ( |
| 290 | east: 0deg, |
| 291 | north-east: 45deg, |
| 292 | north: 90deg, |
| 293 | north-west: 135deg, |
| 294 | west: 180deg, |
| 295 | south-west: 225deg, |
| 296 | south: 270deg, |
| 297 | south-east: 315deg, |
| 298 | ) |
| 299 | |
| 300 | // Path anchors |
| 301 | #let named-path-anchors = ( |
| 302 | start: 0%, |
| 303 | mid: 50%, |
| 304 | end: 100%, |
| 305 | ) |
| 306 | |
| 307 | /// Calculates a border anchor at the given angle by testing for an intersection between a line and the given drawables. Returns `none` if no intersection is found for better error reporting. |
| 308 | /// |
| 309 | /// - center (vector): The position from which to start the test line. |
| 310 | /// - x-dist (number): The furthest distance the test line should go in the x direction. |
| 311 | /// - y-dist (number): The furthest distance the test line should go in the y direction. |
| 312 | /// - drawables (drawables): Drawables to test for an intersection against. Ideally should be of type path but all others are ignored. |
| 313 | /// - angle (angle): The angle to check for a border anchor at. |
| 314 | /// -> vector,none |
| 315 | #let border(center, x-dist, y-dist, drawables, angle) = { |
| 316 | x-dist += util.float-epsilon |
| 317 | y-dist += util.float-epsilon |
| 318 | |
| 319 | if type(drawables) == dictionary { |
| 320 | drawables = (drawables,) |
| 321 | } |
| 322 | |
| 323 | let test-line = ( |
| 324 | center, |
| 325 | ( |
| 326 | center.at(0) + x-dist * calc.cos(angle), |
| 327 | center.at(1) + y-dist * calc.sin(angle), |
| 328 | center.at(2), |
| 329 | ) |
| 330 | ) |
| 331 | |
| 332 | let pts = () |
| 333 | for drawable in drawables { |
| 334 | if drawable.type != "path" { |
| 335 | continue |
| 336 | } |
| 337 | pts += intersection.line-path(..test-line, drawable) |
| 338 | } |
| 339 | |
| 340 | if pts.len() == 1 { |
| 341 | return pts.first() |
| 342 | } |
| 343 | |
| 344 | |
| 345 | return if pts.len() == 1 { |
| 346 | pts.first() |
| 347 | } else if pts.len() > 1 { |
| 348 | // Find the furthest intersection point from center |
| 349 | util.sort-points-by-distance(center, pts).last() |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | |
| 354 | /// Setup an anchor calculation and handling function for an element. Unifies anchor error checking and calculation of the offset transform. |
| 355 | /// |
| 356 | /// A tuple of a transformation matrix and function will be returned. |
| 357 | /// The transform is calculated by translating the given transform by the distance between the position of `offset-anchor` and `default`. It can then be used to correctly transform an element's drawables. If either are none the calculation won't happen but the transform will still be returned. |
| 358 | /// The function can be used to get the transformed anchors of an element by passing it a string. An empty array can be passed to get the list of valid anchors. |
| 359 | /// |
| 360 | /// - callback (function, auto): The function to call to get a named anchor's position. The anchor's name will be passed and it should return a <Type>vector</Type> (`str => vector`). If no named anchors exist on the element `auto` can be given instead of a function. |
| 361 | /// - anchor-names (array): A list of valid anchor names. This list will be used to validate an anchor exists before `callback` is used. |
| 362 | /// - default (str,none): The name of the default anchor, if one exists. |
| 363 | /// - transform (matrix,none): The current transformation matrix to apply to an anchor's position before returning it. If `offset-anchor` and `default` is set, it will be first translated by the distance between them. |
| 364 | /// - name (str, none): The name of the element, this is only used in the error message in the event an anchor is invalid. |
| 365 | /// - offset-anchor (str, none): The name of an anchor to offset the transform by. |
| 366 | /// - border-anchors (bool): If true, add border anchors. |
| 367 | /// - path-anchors (bool): If true, add path anchors. |
| 368 | /// - radii (none,array): Radius tuple used for border anchor calculation. |
| 369 | /// - path (none,drawable): Path used for path and border anchor calculation. |
| 370 | /// -> array |
| 371 | #let setup( |
| 372 | callback, |
| 373 | anchor-names, |
| 374 | default: none, |
| 375 | transform: none, |
| 376 | name: none, |
| 377 | offset-anchor: none, |
| 378 | border-anchors: false, |
| 379 | path-anchors: false, |
| 380 | radii: none, |
| 381 | path: none, |
| 382 | nested-anchors: false |
| 383 | ) = { |
| 384 | // Passing no callback is valid! |
| 385 | if callback == auto { |
| 386 | callback = (anchor) => {} |
| 387 | } |
| 388 | |
| 389 | // Add enabled anchor names |
| 390 | if border-anchors { |
| 391 | assert("center" in anchor-names and radii != none and path != none, |
| 392 | message: "Border anchors need a center anchor, radii and the path set!") |
| 393 | } |
| 394 | if path-anchors { |
| 395 | assert(path != none, |
| 396 | message: "Path anchors need the path set!") |
| 397 | } |
| 398 | |
| 399 | // Anchor callback |
| 400 | let calculate-anchor(anchor, transform: none) = { |
| 401 | if anchor == () { |
| 402 | return (anchor-names + if border-anchors { named-border-anchors.keys() } + if path-anchors { named-path-anchors.keys() }).dedup() |
| 403 | } |
| 404 | |
| 405 | let out = none |
| 406 | let nested-anchors = if type(anchor) == array { |
| 407 | if not nested-anchors { |
| 408 | anchor = anchor.join(".") |
| 409 | } else { |
| 410 | if anchor.len() > 1 { |
| 411 | anchor |
| 412 | } |
| 413 | anchor = anchor.first() |
| 414 | } |
| 415 | } else if nested-anchors and type(anchor) == str { |
| 416 | anchor = anchor.split(".") |
| 417 | if anchor.len() > 1 { |
| 418 | anchor |
| 419 | } |
| 420 | anchor = anchor.first() |
| 421 | } |
| 422 | |
| 423 | |
| 424 | if type(anchor) == str { |
| 425 | if anchor in anchor-names or (anchor == "default" and default != none) { |
| 426 | if anchor == "default" { |
| 427 | anchor = default |
| 428 | } |
| 429 | |
| 430 | out = callback(if nested-anchors != none { nested-anchors } else { anchor }) |
| 431 | } else if path-anchors and anchor in named-path-anchors { |
| 432 | anchor = named-path-anchors.at(anchor) |
| 433 | } else if border-anchors and anchor in named-border-anchors { |
| 434 | anchor = named-border-anchors.at(anchor) |
| 435 | } else if util.str-is-number(anchor) { |
| 436 | anchor = util.str-to-number(if nested-anchors != none { nested-anchors.join(".") } else { anchor }) |
| 437 | } else { |
| 438 | panic( |
| 439 | strfmt( |
| 440 | "Anchor '{}' not in anchors {} for element '{}'", |
| 441 | anchor, |
| 442 | repr(anchor-names), |
| 443 | name |
| 444 | ) |
| 445 | ) |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | if out == none { |
| 450 | if type(anchor) in (ratio, float, int) { |
| 451 | assert(path-anchors, message: strfmt("Element '{}' does not support path anchors.", name)) |
| 452 | out = path-util.point-on-path(path.segments, anchor) |
| 453 | } else if type(anchor) == angle { |
| 454 | assert(border-anchors, message: strfmt("Element '{}' does not support border anchors.", name)) |
| 455 | out = border(callback("center"), ..radii, path, anchor) |
| 456 | assert(out != none, message: strfmt("Element '{}' does not have a border for anchor '{}'.", name, anchor)) |
| 457 | } else { |
| 458 | panic(strfmt("Unknown anchor '{}' for element '{}'", repr(anchor), name)) |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | return if transform != none { |
| 463 | util.apply-transform( |
| 464 | transform, |
| 465 | out |
| 466 | ) |
| 467 | } else { |
| 468 | out |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | if default != none and offset-anchor != none { |
| 473 | let offset = matrix.transform-translate( |
| 474 | ..vector.sub(calculate-anchor(default), calculate-anchor(offset-anchor)).slice(0, 3) |
| 475 | ) |
| 476 | transform = if transform != none { |
| 477 | matrix.mul-mat( |
| 478 | transform, |
| 479 | offset |
| 480 | ) |
| 481 | } else { |
| 482 | offset |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | return (if transform == none { matrix.ident(4) } else { transform }, calculate-anchor.with(transform: transform)) |
| 487 | } |
| 488 | |
| 489 | |
| 490 | // This file contains functions related to bezier curve calculation |
| 491 | // Many functions are ports from https://github.com/Pomax/bezierjs |
| 492 | #import "vector.typ" |
| 493 | |
| 494 | // Map number v from range (ds, de) to (ts, te) |
| 495 | #let _map(v, ds, de, ts, te) = { |
| 496 | let d1 = de - ds |
| 497 | let d2 = te - ts |
| 498 | let v2 = v - ds |
| 499 | let r = v2 / d1 |
| 500 | return ts + d2 * r |
| 501 | } |
| 502 | |
| 503 | /// Get the point on quadratic bezier at position `t`. |
| 504 | /// |
| 505 | /// - a (vector): Start point |
| 506 | /// - b (vector): End point |
| 507 | /// - c (vector): Control point |
| 508 | /// - t (float): Position on curve $[0, 1]$ |
| 509 | /// -> vector |
| 510 | #let quadratic-point(a, b, c, t) = { |
| 511 | // (1-t)^2 * a + 2 * (1-t) * t * c + t^2 b |
| 512 | return vector.add( |
| 513 | vector.add( |
| 514 | vector.scale(a, calc.pow(1-t, 2)), |
| 515 | vector.scale(c, 2 * (1-t) * t) |
| 516 | ), |
| 517 | vector.scale(b, calc.pow(t, 2)) |
| 518 | ) |
| 519 | } |
| 520 | |
| 521 | /// Get the derivative (dx/dt) of a quadratic bezier at position `t`. |
| 522 | /// |
| 523 | /// - a (vector): Start point |
| 524 | /// - b (vector): End point |
| 525 | /// - c (vector): Control point |
| 526 | /// - t (float): Position on curve [0, 1] |
| 527 | /// -> vector |
| 528 | #let quadratic-derivative(a, b, c, t) = { |
| 529 | // 2(-a(1-t) + bt - 2ct + c) |
| 530 | return vector.scale( |
| 531 | vector.add( |
| 532 | vector.sub( |
| 533 | vector.add( |
| 534 | vector.scale(vector.neg(a), (1 - t)), |
| 535 | vector.scale(b, t)), |
| 536 | vector.scale(c, 2 * t)), |
| 537 | c) |
| 538 | , 2) |
| 539 | } |
| 540 | |
| 541 | /// Get the point on a cubic bezier curve at position `t`. |
| 542 | /// |
| 543 | /// - a (vector): Start point |
| 544 | /// - b (vector): End point |
| 545 | /// - c1 (vector): Control point 1 |
| 546 | /// - c2 (vector): Control point 2 |
| 547 | /// - t (float): Position on curve [0, 1] |
| 548 | /// -> vector |
| 549 | #let cubic-point(a, b, c1, c2, t) = { |
| 550 | // (1-t)^3*a + 3*(1-t)^2*t*c1 + 3*(1-t)*t^2*c2 + t^3*b |
| 551 | vector.add( |
| 552 | vector.add( |
| 553 | vector.scale(a, calc.pow(1-t, 3)), |
| 554 | vector.scale(c1, 3 * calc.pow(1-t, 2) * t) |
| 555 | ), |
| 556 | vector.add( |
| 557 | vector.scale(c2, 3*(1-t)*calc.pow(t,2)), |
| 558 | vector.scale(b, calc.pow(t, 3)) |
| 559 | ) |
| 560 | ) |
| 561 | } |
| 562 | |
| 563 | /// Get the derivative (dx/dt) of a cubic bezier at position `t`. |
| 564 | /// |
| 565 | /// - a (vector): Start point |
| 566 | /// - b (vector): End point |
| 567 | /// - c1 (vector): Control point 1 |
| 568 | /// - c2 (vector): Control point 2 |
| 569 | /// - t (float): Position on curve [0, 1] |
| 570 | /// -> vector |
| 571 | #let cubic-derivative(a, b, c1, c2, t) = { |
| 572 | // -3(a(1-t)^2 + t(-2c2 - bt + 3 c2 t) + c1(-1 + 4t - 3t^2)) |
| 573 | vector.scale( |
| 574 | vector.add( |
| 575 | vector.add( |
| 576 | vector.scale(a, calc.pow((1 - t), 2)), |
| 577 | vector.scale( |
| 578 | vector.sub( |
| 579 | vector.add( |
| 580 | vector.scale(b, -1 * t), |
| 581 | vector.scale(c2, 3 * t) |
| 582 | ), |
| 583 | vector.scale(c2, 2) |
| 584 | ), |
| 585 | t |
| 586 | ) |
| 587 | ), |
| 588 | vector.scale(c1, -3 * calc.pow(t, 2) + 4 * t - 1) |
| 589 | ), |
| 590 | -3 |
| 591 | ) |
| 592 | } |
| 593 | |
| 594 | /// Get a bezier curve's ABC coordinates. Returns them as a respective <Type>array</Type> of <Type>vector</Type>s. |
| 595 | /// ``` |
| 596 | /// /A\ <-- Control point of quadratic bezier |
| 597 | /// / | \ |
| 598 | /// / | \ |
| 599 | /// /_.-B-._\ <-- Point on curve |
| 600 | /// ,' | ', |
| 601 | /// / | \ |
| 602 | /// s------C------e <-- Point on line between s and e |
| 603 | /// ``` |
| 604 | /// - s (vector): Curve start |
| 605 | /// - e (vector): Curve end |
| 606 | /// - B (vector): Point on curve |
| 607 | /// - t (float): Position on curve $[0, 1]$ |
| 608 | /// - deg (int): Bezier degree (2 or 3) |
| 609 | /// -> array |
| 610 | #let to-abc(s, e, B, t, deg: 2) = { |
| 611 | let abc-ratio(t) = { |
| 612 | if t == 0 or t == 1 { return t } |
| 613 | let bottom = calc.pow(t, deg) + calc.pow(1 - t, deg) |
| 614 | let top = bottom - 1 |
| 615 | return calc.abs(top / bottom) |
| 616 | } |
| 617 | |
| 618 | let projection-ratio(t) = { |
| 619 | if t == 0 or t == 1 { return t } |
| 620 | let top = calc.pow(1 - t, deg) |
| 621 | let bottom = calc.pow(t, deg) + top |
| 622 | return top / bottom |
| 623 | } |
| 624 | |
| 625 | let u = projection-ratio(t) |
| 626 | let um = 1 - u |
| 627 | |
| 628 | let C = vector.add( |
| 629 | vector.scale(s, u), |
| 630 | vector.scale(e, um)) |
| 631 | |
| 632 | let s = abc-ratio(t) |
| 633 | let A = vector.add( |
| 634 | B, |
| 635 | vector.scale(vector.sub(B, C), 1/s)) |
| 636 | |
| 637 | return (A, B, C) |
| 638 | } |
| 639 | |
| 640 | |
| 641 | /// Compute the control points for a quadratic bezier through 3 points. |
| 642 | /// |
| 643 | /// - s (vector): Curve start |
| 644 | /// - e (vector): Curve end |
| 645 | /// - B (vector): A point which the curve passes through |
| 646 | /// -> bezier |
| 647 | #let quadratic-through-3points(s, B, e) = { |
| 648 | let d1 = vector.dist(s, B) |
| 649 | let d2 = vector.dist(e, B) |
| 650 | let t = d1 / (d1 + d2) |
| 651 | |
| 652 | let (A, B, C) = to-abc(s, e, B, t, deg: 2) |
| 653 | |
| 654 | return (s, e, A) |
| 655 | } |
| 656 | |
| 657 | |
| 658 | /// Convert a quadratic bezier to a cubic bezier. |
| 659 | /// |
| 660 | /// - s (vector): Curve start |
| 661 | /// - e (vector): Curve end |
| 662 | /// - c (vector): Control point |
| 663 | /// -> bezier |
| 664 | #let quadratic-to-cubic(s, e, c) = { |
| 665 | let c1 = vector.add(s, vector.scale(vector.sub(c, s), 2/3)) |
| 666 | let c2 = vector.add(e, vector.scale(vector.sub(c, e), 2/3)) |
| 667 | return (s, e, c1, c2) |
| 668 | } |
| 669 | |
| 670 | /// Compute the control points for a cubic bezier through 3 points. |
| 671 | /// |
| 672 | /// - s (vector): Curve start |
| 673 | /// - e (vector): Curve end |
| 674 | /// - B (vector): A point which the curve passes through |
| 675 | /// -> bezier |
| 676 | #let cubic-through-3points(s, B, e) = { |
| 677 | if s == B or e == B { |
| 678 | return (s, e, s, e) |
| 679 | } |
| 680 | |
| 681 | let d1 = vector.dist(s, B) |
| 682 | let d2 = vector.dist(e, B) |
| 683 | let t = d1 / (d1 + d2) |
| 684 | |
| 685 | let (A, _, C) = to-abc(s, e, B, t, deg: 3) |
| 686 | |
| 687 | let angle = vector.angle2(s, e) - vector.angle2(s, B) |
| 688 | if angle == 0deg or calc.abs(angle) == 180deg { |
| 689 | let se = vector.dist(s, e) |
| 690 | let sB = vector.dist(s, B) |
| 691 | let eB = vector.dist(e, B) |
| 692 | |
| 693 | if sB >= se and sB >= eB { |
| 694 | return (s, B, s, B) |
| 695 | } else if eB >= se and eB >= sB { |
| 696 | return (e, B, e, B) |
| 697 | } |
| 698 | return (s, e, s, e) |
| 699 | } |
| 700 | |
| 701 | let bc = (if angle < 0deg or angle > 180deg { -1 } else { 1 }) * vector.dist(s, e) / 3 |
| 702 | let de1 = t * bc |
| 703 | let de2 = (1 - t) * bc |
| 704 | |
| 705 | let (ax, ay, az) = A |
| 706 | let (sx, sy, sz) = s |
| 707 | let (ex, ey, ez) = e |
| 708 | let (bx, by, bz) = B |
| 709 | |
| 710 | import "/src/util.typ": calculate-circle-center-3pt |
| 711 | let (cx, cy, cz) = calculate-circle-center-3pt(s, B, e) |
| 712 | let tangent = ( |
| 713 | (bx - (by - cy), by + (bx - cx), bz), |
| 714 | (bx + (by - cy), by - (bx - cx), bz)) |
| 715 | let (dx, dy, dz) = vector.norm(vector.sub(tangent.at(1), tangent.at(0))) |
| 716 | |
| 717 | let (e1x, e1y) = ( |
| 718 | bx + de1 * dx, |
| 719 | by + de1 * dy) |
| 720 | let (e2x, e2y) = ( |
| 721 | bx - de2 * dx, |
| 722 | by - de2 * dy) |
| 723 | let (v1x, v1y) = ( |
| 724 | ax + (e1x - ax) / (1 - t), |
| 725 | ay + (e1y - ay) / (1 - t)) |
| 726 | let (v2x, v2y) = ( |
| 727 | ax + (e2x - ax) / t, |
| 728 | ay + (e2y - ay) / t) |
| 729 | let c1 = ( |
| 730 | sx + (v1x - sx) / t, |
| 731 | sy + (v1y - sy) / t) |
| 732 | let c2 = ( |
| 733 | ex + (v2x - ex) / (1 - t), |
| 734 | ey + (v2y - ey) / (1 - t)) |
| 735 | |
| 736 | return (s, e, c1, c2) |
| 737 | } |
| 738 | |
| 739 | /// Split a cubic bezier into two cubic beziers at the point `t`. Returns an <Type>array</Type> of two <Type>bezier</Type>. The first holds the original curve start `s`, and the second holds the original curve end `e`. |
| 740 | /// |
| 741 | /// - s (vector): Curve start |
| 742 | /// - e (vector): Curve end |
| 743 | /// - c1 (vector): Control point 1 |
| 744 | /// - c2 (vector): Control point 2 |
| 745 | /// - t (float): The point on the bezier to split, $[0, 1]$ |
| 746 | /// -> array |
| 747 | #let split(s, e, c1, c2, t) = { |
| 748 | t = calc.max(0, calc.min(t, 1)) |
| 749 | |
| 750 | let split-rec(pts, t, left, right) = { |
| 751 | if pts.len() == 1 { |
| 752 | left.push(pts.at(0)) |
| 753 | right.push(pts.at(0)) |
| 754 | } else { |
| 755 | let new-pts = () |
| 756 | for i in range(0, pts.len() - 1) { |
| 757 | if i == 0 { |
| 758 | left.push(pts.at(i)) |
| 759 | } |
| 760 | if i == pts.len() - 2 { |
| 761 | right.push(pts.at(i + 1)) |
| 762 | } |
| 763 | new-pts.push(vector.add(vector.scale(pts.at(i), (1 - t)), |
| 764 | vector.scale(pts.at(i + 1), t))) |
| 765 | } |
| 766 | (left, right) = split-rec(new-pts, t, left, right) |
| 767 | } |
| 768 | return (left, right) |
| 769 | } |
| 770 | let (left, right) = split-rec((s, c1, c2, e), t, (), ()) |
| 771 | |
| 772 | return ((left.at(0), left.at(3), left.at(1), left.at(2)), |
| 773 | (right.at(3), right.at(0), right.at(2), right.at(1))) |
| 774 | } |
| 775 | |
| 776 | /// Get the approximate cubic curve length |
| 777 | /// - s (vector): Curve start |
| 778 | /// - e (vector): Curve end |
| 779 | /// - c1 (vector): Control point 1 |
| 780 | /// - c2 (vector): Control point 2 |
| 781 | /// -> float |
| 782 | #let cubic-arclen(s, e, c1, c2, samples: 10) = { |
| 783 | let d = 0 |
| 784 | let last = none |
| 785 | for t in range(0, samples + 1) { |
| 786 | let pt = cubic-point(s, e, c1, c2, t / samples) |
| 787 | if last != none { |
| 788 | d += vector.dist(last, pt) |
| 789 | } |
| 790 | last = pt |
| 791 | } |
| 792 | return d |
| 793 | } |
| 794 | |
| 795 | /// Shorten the curve by offsetting s and c1 or e and c2 by distance d. If d is positive the curve gets shortened by moving s and c1 closer to e, if d is negative, e and c2 get moved closer to s. |
| 796 | /// |
| 797 | /// - s (vector): Curve start |
| 798 | /// - e (vector): Curve end |
| 799 | /// - c1 (vector): Control point 1 |
| 800 | /// - c2 (vector): Control point 2 |
| 801 | /// - d (float): Distance to shorten by |
| 802 | /// -> bezier |
| 803 | #let cubic-shorten-linear(s, e, c1, c2, d) = { |
| 804 | if d == 0 { return (s, e, c1, c2) } |
| 805 | |
| 806 | let t = if d < 0 { 1 } else { 0 } |
| 807 | let sign = if d < 0 { -1 } else { 1 } |
| 808 | |
| 809 | let a = cubic-point(s, e, c1, c2, t) |
| 810 | let b = cubic-point(s, e, c1, c2, t + sign * 0.01) |
| 811 | let offset = vector.scale(vector.norm(vector.sub(b, a)), |
| 812 | calc.abs(d)) |
| 813 | if d > 0 { |
| 814 | s = vector.add(s, offset) |
| 815 | c1 = vector.add(c1, offset) |
| 816 | } else { |
| 817 | e = vector.add(e, offset) |
| 818 | c2 = vector.add(c2, offset) |
| 819 | } |
| 820 | return (s, e, c1, c2) |
| 821 | } |
| 822 | |
| 823 | /// Approximate bezier interval `t` for a given distance `d`. If `d` is positive, the functions starts from the curve's start `s`, if `d` is negative, it starts form the curve's end `e`. |
| 824 | /// - s (vector): Curve start |
| 825 | /// - e (vector): Curve end |
| 826 | /// - c1 (vector): Control point 1 |
| 827 | /// - c2 (vector): Control point 2 |
| 828 | /// - d (float): The distance along the bezier to find `t`. |
| 829 | /// -> float |
| 830 | #let cubic-t-for-distance(s, e, c1, c2, d, samples: 20) = { |
| 831 | let travel-forwards(s, e, c1, c2, d) = { |
| 832 | let sum = 0 |
| 833 | for n in range(1, samples + 1) { |
| 834 | let t0 = (n - 1) / samples |
| 835 | let t1 = n / samples |
| 836 | |
| 837 | let segment-dist = vector.dist(cubic-point(s, e, c1, c2, t0), |
| 838 | cubic-point(s, e, c1, c2, t1)) |
| 839 | if sum <= d and d <= sum + segment-dist { |
| 840 | return t0 + (d - sum) / segment-dist / samples |
| 841 | } |
| 842 | sum += segment-dist |
| 843 | } |
| 844 | return 1 |
| 845 | } |
| 846 | |
| 847 | if d == 0 { |
| 848 | return 0 |
| 849 | } |
| 850 | |
| 851 | if d > 0 { |
| 852 | return travel-forwards(s, e, c1, c2, d) |
| 853 | } else { |
| 854 | return 1 - travel-forwards(e, s, c2, c1, -d) |
| 855 | } |
| 856 | } |
| 857 | |
| 858 | /// Shorten curve by distance `d`. This keeps the curvature of the curve by finding new values along the original curve. If `d` is positive the curve gets shortened by moving `s` closer to `e`, if `d` is negative, `e` is moved closer to `s`. The points `s` and `e` are moved along the curve, keeping the curve's curvature the same (the control points get recalculated). |
| 859 | /// |
| 860 | /// - s (vector): Curve start |
| 861 | /// - e (vector): Curve end |
| 862 | /// - c1 (vector): Control point 1 |
| 863 | /// - c2 (vector): Control point 2 |
| 864 | /// - d (float): Distance to shorten by |
| 865 | /// - samples (int): Maximum of samples/steps to use |
| 866 | /// -> bezier |
| 867 | #let cubic-shorten(s, e, c1, c2, d, samples: 15) = { |
| 868 | if d == 0 { return (s, e, c1, c2) } |
| 869 | |
| 870 | let (left, right) = split(s, e, c1, c2, cubic-t-for-distance(s, e, c1, c2, d, samples: samples)) |
| 871 | return if d > 0 { |
| 872 | right |
| 873 | } else { |
| 874 | left |
| 875 | } |
| 876 | } |
| 877 | |
| 878 | /// Find cubic curve extrema by calculating the roots of the curve's first derivative. Returns an <Type>array</Type> of <Type>vector</Type> ordered by distance along the curve from the start to its end. |
| 879 | /// - s (vector): Curve start |
| 880 | /// - e (vector): Curve end |
| 881 | /// - c1 (vector): Control point 1 |
| 882 | /// - c2 (vector): Control point 2 |
| 883 | /// -> array |
| 884 | #let cubic-extrema(s, e, c1, c2) = { |
| 885 | // Compute roots of a single dimension (x, y, z) of the |
| 886 | // curve by using the abc formula for finding roots of |
| 887 | // the curves first derivative. |
| 888 | let dim-extrema(a, b, c1, c2) = { |
| 889 | let f0 = calc.round(3*(c1 - a), digits: 8) |
| 890 | let f1 = calc.round(6*(c2 - 2*c1 + a), digits: 8) |
| 891 | let f2 = calc.round(3*(b - 3*c2 + 3*c1 - a), digits: 8) |
| 892 | |
| 893 | if f1 == 0 and f2 == 0 { |
| 894 | return () |
| 895 | } |
| 896 | |
| 897 | // Linear function |
| 898 | if f2 == 0 { |
| 899 | return (-f0 / f1,) |
| 900 | } |
| 901 | |
| 902 | // No real roots |
| 903 | let discriminant = f1*f1 - 4*f0*f2 |
| 904 | if discriminant < 0 { |
| 905 | return () |
| 906 | } |
| 907 | |
| 908 | if discriminant == 0 { |
| 909 | return (-f1 / (2*f2),) |
| 910 | } |
| 911 | |
| 912 | return ((-f1 - calc.sqrt(discriminant)) / (2*f2), |
| 913 | (-f1 + calc.sqrt(discriminant)) / (2*f2)) |
| 914 | } |
| 915 | |
| 916 | let pts = () |
| 917 | let dims = calc.max(s.len(), e.len()) |
| 918 | for dim in range(dims) { |
| 919 | let ts = dim-extrema( |
| 920 | s.at(dim, default: 0), |
| 921 | e.at(dim, default: 0), |
| 922 | c1.at(dim, default: 0), |
| 923 | c2.at(dim, default: 0) |
| 924 | ) |
| 925 | for t in ts { |
| 926 | // Discard any root outside the bezier range |
| 927 | if t >= 0 and t <= 1 { |
| 928 | pts.push(cubic-point(s, e, c1, c2, t)) |
| 929 | } |
| 930 | } |
| 931 | } |
| 932 | return pts |
| 933 | } |
| 934 | |
| 935 | /// Returns axis aligned bounding box coordinates `(bottom-left, top-right)` for a cubic bezier curve. |
| 936 | /// |
| 937 | /// - s (vector): Curve start |
| 938 | /// - e (vector): Curve end |
| 939 | /// - c1 (vector): Control point 1 |
| 940 | /// - c2 (vector): Control point 2 |
| 941 | /// -> array |
| 942 | #let cubic-aabb(s, e, c1, c2) = { |
| 943 | let (lo, hi) = (s, e) |
| 944 | for dim in range(lo.len()) { |
| 945 | if lo.at(dim) > hi.at(dim) { |
| 946 | (lo.at(dim), hi.at(dim)) = (hi.at(dim), lo.at(dim)) |
| 947 | } |
| 948 | } |
| 949 | for pt in cubic-extrema(s, e, c1, c2) { |
| 950 | for dim in range(pt.len()) { |
| 951 | lo.at(dim) = calc.min(lo.at(dim), hi.at(dim), pt.at(dim)) |
| 952 | hi.at(dim) = calc.max(lo.at(dim), hi.at(dim), pt.at(dim)) |
| 953 | } |
| 954 | } |
| 955 | return (lo, hi) |
| 956 | } |
| 957 | |
| 958 | /// Returns a cubic bezier between points `p2` and `p3` for a catmull-rom curve through all four points. |
| 959 | /// |
| 960 | /// - p1 (vector): Point 1 |
| 961 | /// - p2 (vector): Point 2 |
| 962 | /// - p3 (vector): Point 3 |
| 963 | /// - p4 (vector): Point 4 |
| 964 | /// - k (float): The tension of the catmull-rom curve. Must be in the range $[0, 1]$ |
| 965 | /// -> bezier |
| 966 | #let _catmull-section-to-cubic(p1, p2, p3, p4, k) = { |
| 967 | return (p2, p3, |
| 968 | vector.add(p2, vector.scale(vector.sub(p3, p1), 1/(k * 6))), |
| 969 | vector.sub(p3, vector.scale(vector.sub(p4, p2), 1/(k * 6)))) |
| 970 | } |
| 971 | |
| 972 | /// Returns an array of cubic <Type>bezier</Type> for a catmull curve through an array of points. |
| 973 | /// |
| 974 | /// - points (array): Array of 2d points |
| 975 | /// - k (float): Strength between 0 and 1 |
| 976 | /// - close (bool): |
| 977 | /// -> array |
| 978 | #let catmull-to-cubic(points, k, close: false) = { |
| 979 | k = calc.max(k, 0.1) |
| 980 | k = if k < .5 { |
| 981 | 1 / _map(k, .5, 0, 1, 10) |
| 982 | } else { |
| 983 | _map(k, .5, 1, 1, 10) |
| 984 | } |
| 985 | |
| 986 | let len = points.len() |
| 987 | if len == 2 { |
| 988 | return ((points.at(0), points.at(1), |
| 989 | points.at(0), points.at(1)),) |
| 990 | } else if len > 2 { |
| 991 | let curves = () |
| 992 | |
| 993 | let (i0, iN) = if close { |
| 994 | (-1, 0) |
| 995 | } else { |
| 996 | (0, -1) |
| 997 | } |
| 998 | |
| 999 | curves.push(_catmull-section-to-cubic(points.at(i0), points.at(0), |
| 1000 | points.at(1), points.at(2), k)) |
| 1001 | for i in range(1, len - 2, step: 1) { |
| 1002 | curves.push(_catmull-section-to-cubic( |
| 1003 | ..range(i - 1, i + 3).map(i => points.at(i)), k)) |
| 1004 | } |
| 1005 | |
| 1006 | curves.push(_catmull-section-to-cubic( |
| 1007 | points.at(-3), points.at(-2), points.at(-1), points.at(iN), k)) |
| 1008 | |
| 1009 | if close { |
| 1010 | curves.push(_catmull-section-to-cubic( |
| 1011 | points.at(-2), points.at(-1), points.at(0), points.at(1), k)) |
| 1012 | } |
| 1013 | |
| 1014 | return curves |
| 1015 | } |
| 1016 | return () |
| 1017 | } |
| 1018 | |
| 1019 | /// Find the roots of a cubic polynomial with the coefficients a, b, c and d. |
| 1020 | /// |
| 1021 | /// -> array Array of roots |
| 1022 | #let _cubic-roots(a, b, c, d) = { |
| 1023 | let epsilon = 1e-6 |
| 1024 | if calc.abs(a) < epsilon { |
| 1025 | if calc.abs(b) < epsilon { |
| 1026 | // Constant |
| 1027 | if c == 0 { |
| 1028 | return () |
| 1029 | } |
| 1030 | |
| 1031 | // Linear |
| 1032 | let root = -1 * d / c |
| 1033 | if root < 0 - epsilon and root > 1 + epsilon { |
| 1034 | return () |
| 1035 | } |
| 1036 | return (root,) |
| 1037 | } |
| 1038 | |
| 1039 | // Quadratic |
| 1040 | let dq = calc.pow(c, 2) - 4 * b * d |
| 1041 | if dq >= 0 { |
| 1042 | dq = calc.sqrt(dq) |
| 1043 | let roots = (-1 * (dq + c) / (2 * b), |
| 1044 | (dq - c) / (2 * b)) |
| 1045 | return roots.filter(t => t >= 0 - epsilon and t <= 1 + epsilon) |
| 1046 | } else { |
| 1047 | // No real roots |
| 1048 | return () |
| 1049 | } |
| 1050 | } |
| 1051 | |
| 1052 | let (A, B, C) = (b/a, c/a, d/a) |
| 1053 | let Q = (3 * B - calc.pow(A, 2)) / 9 |
| 1054 | let R = (9 * A * B - 27 * C - 2 * calc.pow(A, 3)) / 54 |
| 1055 | let D = calc.pow(Q, 3) + calc.pow(R, 2) |
| 1056 | let aa = -A / 3 |
| 1057 | |
| 1058 | let sgn = x => { if x < 0 { -1 } else { 1 } } |
| 1059 | let roots = if D >= 0 { |
| 1060 | let S = sgn(R + calc.sqrt(D)) * calc.pow(calc.abs(R + calc.sqrt(D)), 1/3) |
| 1061 | let T = sgn(R - calc.sqrt(D)) * calc.pow(calc.abs(R - calc.sqrt(D)), 1/3) |
| 1062 | |
| 1063 | if (S - T) != 0 { |
| 1064 | // Roots 2 and 3 are complex |
| 1065 | (aa + (S + T),) |
| 1066 | } else { |
| 1067 | (aa + (S + T), aa - (S + T) / 2) |
| 1068 | } |
| 1069 | } else { |
| 1070 | let th = calc.acos(R / calc.sqrt(-calc.pow(Q, 3))) / 1rad |
| 1071 | let qq = 2 * calc.sqrt(-Q) |
| 1072 | (qq * calc.cos(th / 3) + aa, |
| 1073 | qq * calc.cos((th + 2 * calc.pi) / 3) + aa, |
| 1074 | qq * calc.cos((th + 4 * calc.pi) / 3) + aa) |
| 1075 | } |
| 1076 | |
| 1077 | return roots.filter(t => t >= 0 - epsilon and t <= 1 + epsilon) |
| 1078 | } |
| 1079 | |
| 1080 | /// Calculate the intersection points between a 2D cubic-bezier and a straight line. Returns an array of <Type>vector</Type> |
| 1081 | /// |
| 1082 | /// - s (vector): Bezier start point |
| 1083 | /// - e (vector): Bezier end point |
| 1084 | /// - c1 (vector): Bezier control point 1 |
| 1085 | /// - c2 (vector): Bezier control point 2 |
| 1086 | /// - la (vector): Line start point |
| 1087 | /// - lb (vector): Line end point |
| 1088 | /// - ray (bool): If set to true, ignore line length |
| 1089 | /// -> array |
| 1090 | #let line-cubic-intersections(la, lb, s, e, c1, c2, ray: false) = { |
| 1091 | // Based on: |
| 1092 | // http://www.particleincell.com/blog/2013/cubic-line-intersection/ |
| 1093 | // with some rounding improvements |
| 1094 | let a = lb.at(1) - la.at(1) |
| 1095 | let b = la.at(0) - lb.at(0) |
| 1096 | let c = la.at(0) * (la.at(1) - lb.at(1)) + la.at(1) * (lb.at(0) - la.at(0)) |
| 1097 | |
| 1098 | /// Get cubic bezier function coefficients |
| 1099 | let _cubic-coeff(a, b, c, d) = ( |
| 1100 | -a + 3*b - 3*c + d, |
| 1101 | 3*a - 6*b + 3*c, |
| 1102 | -3*a +3*b, |
| 1103 | a) |
| 1104 | |
| 1105 | let x-coeff = _cubic-coeff(s.at(0), c1.at(0), c2.at(0), e.at(0)) |
| 1106 | let y-coeff = _cubic-coeff(s.at(1), c1.at(1), c2.at(1), e.at(1)) |
| 1107 | |
| 1108 | let roots = _cubic-roots(a * x-coeff.at(0) + b * y-coeff.at(0), |
| 1109 | a * x-coeff.at(1) + b * y-coeff.at(1), |
| 1110 | a * x-coeff.at(2) + b * y-coeff.at(2), |
| 1111 | a * x-coeff.at(3) + b * y-coeff.at(3) + c) |
| 1112 | |
| 1113 | let pts = () |
| 1114 | for t in roots { |
| 1115 | let pt = cubic-point(s, e, c1, c2, t) |
| 1116 | if ray { |
| 1117 | pts.push(pt) |
| 1118 | } else { |
| 1119 | let s = if calc.abs(lb.at(0) - la.at(0)) >= 1e-6 { |
| 1120 | (pt.at(0) - la.at(0)) / (lb.at(0) - la.at(0)) |
| 1121 | } else { |
| 1122 | (pt.at(1) - la.at(1)) / (lb.at(1) - la.at(1)) |
| 1123 | } |
| 1124 | if s >= 0 and s <= 1 { |
| 1125 | pts.push(pt) |
| 1126 | } |
| 1127 | } |
| 1128 | } |
| 1129 | return pts |
| 1130 | } |
| 1131 | #import "matrix.typ" |
| 1132 | #import "vector.typ" |
| 1133 | #import "util.typ" |
| 1134 | #import "path-util.typ" |
| 1135 | #import "aabb.typ" |
| 1136 | #import "styles.typ" |
| 1137 | #import "process.typ" |
| 1138 | #import "version.typ" |
| 1139 | |
| 1140 | /// Sets up a canvas for drawing on. |
| 1141 | /// |
| 1142 | /// - length (length, ratio): Used to specify what 1 coordinate unit is. If given a ratio, that ratio is relative to the containing elements width! |
| 1143 | /// - body (none, array, element): A code block in which functions from the `draw` module have been called. |
| 1144 | /// - background (none, color): A color to be used for the background of the canvas. |
| 1145 | /// - padding (none, number, array, dictionary) = none: How much padding to add to the canvas. `none` applies no padding. A number applies padding to all sides equally. A dictionary applies padding following Typst's `pad` function: https://typst.app/docs/reference/layout/pad/. An array follows CSS like padding: `(y, x)`, `(top, x, bottom)` or `(top, right, bottom, left)`. |
| 1146 | /// - debug (bool): Shows the bounding boxes of each element when `true`. |
| 1147 | /// -> content |
| 1148 | #let canvas(length: 1cm, debug: false, background: none, padding: none, body) = context { layout(ly => { |
| 1149 | if body == none { |
| 1150 | return [] |
| 1151 | } |
| 1152 | assert( |
| 1153 | type(body) == array, |
| 1154 | message: "Incorrect type for body: " + repr(type(body)), |
| 1155 | ) |
| 1156 | |
| 1157 | assert(type(length) in (std.length, ratio), message: "Expected `length` to be of type length or ratio, got " + repr(length)) |
| 1158 | let length = if type(length) == ratio { |
| 1159 | length * ly.width |
| 1160 | } else { |
| 1161 | length.to-absolute() |
| 1162 | } |
| 1163 | assert(length / 1cm != 0, |
| 1164 | message: "Canvas length must be != 0!") |
| 1165 | |
| 1166 | let ctx = ( |
| 1167 | version: version.version, |
| 1168 | length: length, |
| 1169 | debug: debug, |
| 1170 | background: background, |
| 1171 | // Previous element position & bbox |
| 1172 | prev: (pt: (0, 0, 0)), |
| 1173 | style: styles.default, |
| 1174 | // Current transformation matrix, a rhs coordinate system |
| 1175 | // where z is sheared by a half x and y. |
| 1176 | // +x = right, +y = up, +z = 1/2 (left + down) |
| 1177 | transform: |
| 1178 | ((1, 0,-.5, 0), |
| 1179 | (0,-1,+.5, 0), |
| 1180 | (0, 0, 0, 0), // FIXME: This should not be zero for Z! Changing it destroys mark & decorations in 3D space. |
| 1181 | (0, 0, .0, 1)), |
| 1182 | // Nodes, stores anchors and paths |
| 1183 | nodes: (:), |
| 1184 | // group stack |
| 1185 | groups: (), |
| 1186 | // user defined marks |
| 1187 | marks: ( |
| 1188 | mnemonics: (:), |
| 1189 | marks: (:), |
| 1190 | ) |
| 1191 | ) |
| 1192 | |
| 1193 | let (ctx, bounds, drawables) = process.many(ctx, body) |
| 1194 | if bounds == none { |
| 1195 | return [] |
| 1196 | } |
| 1197 | |
| 1198 | // Filter hidden drawables |
| 1199 | drawables = drawables.filter(d => not d.hidden) |
| 1200 | |
| 1201 | // Order draw commands by z-index |
| 1202 | drawables = drawables.sorted(key: (cmd) => { |
| 1203 | return cmd.at("z-index", default: 0) |
| 1204 | }) |
| 1205 | |
| 1206 | // Apply padding |
| 1207 | let padding = util.as-padding-dict(padding) |
| 1208 | bounds = aabb.padded(bounds, padding) |
| 1209 | |
| 1210 | let (offset-x, offset-y, ..) = bounds.low |
| 1211 | |
| 1212 | // Final canvas size |
| 1213 | let (width, height, ..) = vector.scale(aabb.size(bounds), length) |
| 1214 | |
| 1215 | let relative = (orig, c) => { |
| 1216 | return vector.sub(c, orig) |
| 1217 | } |
| 1218 | |
| 1219 | box(width: width, height: height, fill: background, align(top, { |
| 1220 | for drawable in drawables { |
| 1221 | // Typst path elements have strange bounding boxes. We need to |
| 1222 | // offset all paths to start at (0, 0) to make gradients work. |
| 1223 | let (segment-x, segment-y, _) = if drawable.type == "path" { |
| 1224 | vector.sub( |
| 1225 | aabb.aabb(path-util.bounds(drawable.segments)).low, |
| 1226 | bounds.low) |
| 1227 | } else { |
| 1228 | (0, 0, 0) |
| 1229 | } |
| 1230 | |
| 1231 | place(top + left, float: false, if drawable.type == "path" { |
| 1232 | let vertices = () |
| 1233 | |
| 1234 | let transform-point((x, y, _)) = { |
| 1235 | ((x - offset-x - segment-x) * length, |
| 1236 | (y - offset-y - segment-y) * length) |
| 1237 | } |
| 1238 | |
| 1239 | let last-point = none |
| 1240 | for ((kind, ..rest)) in drawable.segments { |
| 1241 | if kind == "sub" { |
| 1242 | // TODO: Support sub-paths by converting |
| 1243 | // Also support move commands. |
| 1244 | // Refactor path arrays to typst style curves. |
| 1245 | } else if kind == "cubic" { |
| 1246 | let pts = rest.map(transform-point) |
| 1247 | |
| 1248 | if last-point != pts.at(0) { |
| 1249 | vertices.push(curve.move(pts.at(0))) |
| 1250 | } |
| 1251 | vertices.push(curve.cubic(pts.at(2), pts.at(3), pts.at(1))) |
| 1252 | last-point = pts.at(1) |
| 1253 | } else { |
| 1254 | let pts = rest.map(transform-point) |
| 1255 | |
| 1256 | if last-point != pts.at(0) { |
| 1257 | vertices.push(curve.move(pts.at(0))) |
| 1258 | } |
| 1259 | for i in range(1, pts.len()) { |
| 1260 | vertices.push(curve.line(pts.at(i))) |
| 1261 | } |
| 1262 | last-point = pts.last() |
| 1263 | } |
| 1264 | } |
| 1265 | |
| 1266 | if (drawable.at("close", default: false)) { |
| 1267 | vertices.push(curve.close(mode: "straight")) |
| 1268 | } |
| 1269 | |
| 1270 | if type(drawable.stroke) == dictionary and "thickness" in drawable.stroke and type(drawable.stroke.thickness) != std.length { |
| 1271 | drawable.stroke.thickness *= length |
| 1272 | } |
| 1273 | std.curve( |
| 1274 | stroke: drawable.stroke, |
| 1275 | fill: drawable.fill, |
| 1276 | fill-rule: drawable.at("fill-rule", default: "non-zero"), |
| 1277 | ..vertices, |
| 1278 | ) |
| 1279 | } else if drawable.type == "content" { |
| 1280 | let (width, height) = std.measure(drawable.body) |
| 1281 | move( |
| 1282 | dx: (drawable.pos.at(0) - offset-x) * length - width / 2, |
| 1283 | dy: (drawable.pos.at(1) - offset-y) * length - height / 2, |
| 1284 | drawable.body, |
| 1285 | ) |
| 1286 | }, dx: segment-x * length, dy: segment-y * length) |
| 1287 | } |
| 1288 | })) |
| 1289 | })} |
| 1290 | /// Returns the real part of a complex number. |
| 1291 | /// - V (complex): A complex number. |
| 1292 | /// -> float |
| 1293 | #let re(V) = V.at(0) |
| 1294 | |
| 1295 | /// Returns the imaginary part of a complex number. |
| 1296 | /// - V (complex): A complex number. |
| 1297 | /// -> float |
| 1298 | #let im(V) = V.at(1) |
| 1299 | |
| 1300 | |
| 1301 | /// Multiplies two complex numbers together and returns the result $V W$. |
| 1302 | /// - V (complex): The complex number on the left hand side. |
| 1303 | /// - W (complex): The complex number on the right hand side. |
| 1304 | #let mul(V, W) = (re(V) * re(W) - im(V) * im(W), im(V) * re(W) + re(V) * im(W)) |
| 1305 | |
| 1306 | /// Calculates the conjugate of a complex number. |
| 1307 | /// - V (complex): A complex number. |
| 1308 | /// -> complex |
| 1309 | #let conj(V) = (re(V),-im(V)) |
| 1310 | |
| 1311 | // TODO: check what "in R^2" means. |
| 1312 | /// Calculates the dot product of two complex numbers in R^2 $V \cdot W$. |
| 1313 | /// - V (complex): The complex number on the left hand side. |
| 1314 | /// - W (complex): The complex number on the right hand side. |
| 1315 | /// -> float |
| 1316 | #let dot(V,W) = re(mul(V,conj(W))) |
| 1317 | |
| 1318 | /// Calculates the squared normal of a complex number. |
| 1319 | /// - V (complex): The complex number. |
| 1320 | /// -> float |
| 1321 | #let normsq(V) = dot(V,V) |
| 1322 | |
| 1323 | |
| 1324 | /// Calculates the normal of a complex number |
| 1325 | /// - V (complex): The complex number. |
| 1326 | /// -> float |
| 1327 | #let norm(V) = calc.sqrt(normsq(V)) |
| 1328 | |
| 1329 | /// Multiplies a complex number by a scale factor. |
| 1330 | /// - V (complex): The complex number to scale. |
| 1331 | /// - t (float): The scale factor. |
| 1332 | /// -> complex |
| 1333 | #let scale(V,t) = mul(V,(t,0)) |
| 1334 | |
| 1335 | /// Returns a unit vector in the direction of a complex number. |
| 1336 | /// - V (complex): The complex number. |
| 1337 | /// -> vector |
| 1338 | #let unit(V) = scale(V, 1/norm(V)) |
| 1339 | |
| 1340 | /// Inverts a complex number. |
| 1341 | /// - V (complex): The complex number |
| 1342 | /// -> complex |
| 1343 | #let inv(V) = scale(conj(V), 1/normsq(V)) |
| 1344 | |
| 1345 | /// Divides two complex numbers. |
| 1346 | /// - V (complex): The complex number of the numerator. |
| 1347 | /// - W (complex): The complex number of the denominator. |
| 1348 | /// -> complex |
| 1349 | #let div(V,W) = mul(V,inv(W)) |
| 1350 | |
| 1351 | /// Adds two complex numbers together. |
| 1352 | /// - V (complex): The complex number on the left hand side. |
| 1353 | /// - W (complex): The complex number on the right hand side. |
| 1354 | /// -> complex |
| 1355 | #let add(V,W) = (re(V) + re(W),im(V) + im(W)) |
| 1356 | |
| 1357 | /// Subtracts two complex numbers together. |
| 1358 | /// - V (complex): The complex number on the left hand side. |
| 1359 | /// - W (complex): The complex number on the right hand side. |
| 1360 | /// -> complex |
| 1361 | #let sub(V,W) = (re(V) - re(W),im(V) - im(W)) |
| 1362 | |
| 1363 | /// Calculates the argument of a complex number. |
| 1364 | /// - V (complex): The complex number. |
| 1365 | #let arg(V) = calc.atan2(..V) / 1rad |
| 1366 | |
| 1367 | /// Get the signed angle of two complex numbers from V to W. |
| 1368 | /// - V (complex): A complex number. |
| 1369 | /// - W (complex): A complex number. |
| 1370 | #let ang(V,W) = arg(div(W,V)) |
| 1371 | |
| 1372 | // exp(i*a) |
| 1373 | #let expi(a) = (calc.cos(a),calc.sin(a)) |
| 1374 | |
| 1375 | // Rotate by angle a |
| 1376 | #let rot(v,a) = mul(v,expi(a)) |
| 1377 | #import "vector.typ" |
| 1378 | #import "util.typ" |
| 1379 | #import "deps.typ" |
| 1380 | #import deps.oxifmt: strfmt |
| 1381 | |
| 1382 | #let resolve-xyz(c) = { |
| 1383 | // (x: <number> or <none>, y: <number> or <none>, z: <number> or <none>) |
| 1384 | // (x, y) |
| 1385 | // (x, y, z) |
| 1386 | |
| 1387 | return if type(c) == array { |
| 1388 | vector.as-vec(c) |
| 1389 | } else { |
| 1390 | ( |
| 1391 | c.at("x", default: 0), |
| 1392 | c.at("y", default: 0), |
| 1393 | c.at("z", default: 0), |
| 1394 | ) |
| 1395 | } |
| 1396 | } |
| 1397 | |
| 1398 | |
| 1399 | #let resolve-polar(c) = { |
| 1400 | // (angle: <angle>, radius: <number>) |
| 1401 | // (angle: <angle>, radius: (x, y)) |
| 1402 | // (angle, radius) |
| 1403 | // (angle, (x-radius, y-radius)) |
| 1404 | |
| 1405 | let (angle, xr, yr) = if type(c) == array { |
| 1406 | ( |
| 1407 | c.first(), |
| 1408 | ..if type(c.last()) == array { |
| 1409 | c.last() |
| 1410 | } else { |
| 1411 | (c.last(), c.last()) |
| 1412 | } |
| 1413 | ) |
| 1414 | } else { |
| 1415 | ( |
| 1416 | c.angle, |
| 1417 | ..if type(c.radius) == array { |
| 1418 | c.radius |
| 1419 | } else { |
| 1420 | (c.radius, c.radius) |
| 1421 | } |
| 1422 | ) |
| 1423 | } |
| 1424 | return ( |
| 1425 | xr * calc.cos(angle), |
| 1426 | yr * calc.sin(angle), |
| 1427 | 0 |
| 1428 | ) |
| 1429 | } |
| 1430 | |
| 1431 | |
| 1432 | #let resolve-anchor(ctx, c) = { |
| 1433 | // (name: <string>, anchor: <number, angle, string> or <none>) |
| 1434 | // "name.anchor" |
| 1435 | // "name" |
| 1436 | let (name, anchor) = if type(c) == str { |
| 1437 | let (name, ..anchor) = c.split(".") |
| 1438 | if anchor.len() == 0 { |
| 1439 | anchor = "default" |
| 1440 | } |
| 1441 | (name, anchor) |
| 1442 | } else { |
| 1443 | (c.name, c.at("anchor", default: "default")) |
| 1444 | } |
| 1445 | |
| 1446 | // Check if node is known |
| 1447 | assert(name in ctx.nodes, |
| 1448 | message: "Unknown element '" + name + "' in elements " + repr(ctx.nodes.keys())) |
| 1449 | |
| 1450 | // Resolve length anchors |
| 1451 | if type(anchor) == length { |
| 1452 | anchor = util.resolve-number(ctx, anchor) |
| 1453 | } |
| 1454 | |
| 1455 | // Check if anchor is known |
| 1456 | let node = ctx.nodes.at(name) |
| 1457 | let pos = (node.anchors)(anchor) |
| 1458 | |
| 1459 | let pos = util.revert-transform( |
| 1460 | ctx.transform, |
| 1461 | pos |
| 1462 | ) |
| 1463 | |
| 1464 | return pos |
| 1465 | } |
| 1466 | |
| 1467 | #let resolve-barycentric(ctx, c) = { |
| 1468 | // dictionary of numbers |
| 1469 | return vector.scale( |
| 1470 | c.bary.pairs().fold( |
| 1471 | (0, 0, 0), |
| 1472 | (vec, (k, v)) => { |
| 1473 | vector.add( |
| 1474 | vec, |
| 1475 | vector.scale( |
| 1476 | resolve-anchor(ctx, k), |
| 1477 | v |
| 1478 | ) |
| 1479 | ) |
| 1480 | } |
| 1481 | ), |
| 1482 | 1 / c.bary.values().sum() |
| 1483 | ) |
| 1484 | } |
| 1485 | |
| 1486 | #let resolve-relative(resolve, ctx, c) = { |
| 1487 | // (rel: <coordinate>, update: <bool> or <none>, to: <coordinate>) |
| 1488 | let update = c.at("update", default: true) |
| 1489 | let (ctx, rel) = resolve(ctx, c.rel, update: false) |
| 1490 | let (ctx, to) = if "to" in c { |
| 1491 | resolve(ctx, c.to, update: false) |
| 1492 | } else { |
| 1493 | (ctx, ctx.prev.pt) |
| 1494 | } |
| 1495 | c = vector.add( |
| 1496 | rel, |
| 1497 | to, |
| 1498 | ) |
| 1499 | c.insert(0, update) |
| 1500 | return c |
| 1501 | } |
| 1502 | |
| 1503 | #let resolve-tangent(resolve, ctx, c) = { |
| 1504 | // (element: <string>, point: <coordinate>, solution: <integer>) |
| 1505 | |
| 1506 | // https://stackoverflow.com/a/69641745/7142815 |
| 1507 | let C = resolve-anchor(ctx, c.element) |
| 1508 | let (ctx, P) = resolve(ctx, c.point, update: false) |
| 1509 | // Radius |
| 1510 | let r = vector.len(vector.sub(resolve-anchor(ctx, c.element + ".north"), C)) |
| 1511 | // Vector between C and P |
| 1512 | let D = vector.sub(P, C) // C - P |
| 1513 | // Distance between C and P |
| 1514 | let pc = vector.len(D) |
| 1515 | if pc < r { |
| 1516 | panic("No tangent solution for element " + c.element + " and point " + repr(c.point)) |
| 1517 | } |
| 1518 | // Distance between P and X0 |
| 1519 | let d = r*r / pc |
| 1520 | // Distance between X0 and X1(X2) |
| 1521 | let h = calc.sqrt(r*r - d*d) |
| 1522 | |
| 1523 | return if c.solution == 1 { |
| 1524 | ( |
| 1525 | C.at(0) + (D.at(0) * d - D.at(1) * h) / pc, |
| 1526 | C.at(1) + (D.at(1) * d + D.at(0) * h) / pc, |
| 1527 | 0 |
| 1528 | ) |
| 1529 | } else { |
| 1530 | ( |
| 1531 | C.at(0) + (D.at(0) * d + D.at(1) * h) / pc, |
| 1532 | C.at(1) + (D.at(1) * d - D.at(0) * h) / pc, |
| 1533 | 0 |
| 1534 | ) |
| 1535 | } |
| 1536 | } |
| 1537 | |
| 1538 | #let resolve-perpendicular(resolve, ctx, c) = { |
| 1539 | // (horizontal: <coordinate>, vertical: <coordinate>) |
| 1540 | // (horizontal, "-|", vertical) |
| 1541 | // (vertical, "|-", horizontal) |
| 1542 | |
| 1543 | let (ctx, horizontal, vertical) = resolve(ctx, ..if type(c) == array { |
| 1544 | if c.at(1) == "|-" { |
| 1545 | (c.first(), c.last()) |
| 1546 | } else { |
| 1547 | // c.at(1) == "-|" |
| 1548 | (c.last(), c.first()) |
| 1549 | } |
| 1550 | } else { |
| 1551 | (c.horizontal, c.vertical) |
| 1552 | }, update: false) |
| 1553 | |
| 1554 | return ( |
| 1555 | horizontal.at(0), |
| 1556 | vertical.at(1), |
| 1557 | 0 |
| 1558 | ) |
| 1559 | } |
| 1560 | |
| 1561 | #let resolve-lerp(resolve, ctx, c) = { |
| 1562 | // (a: <coordinate>, number: <number,ratio>, |
| 1563 | // angle?: <angle>, b: <coordinate>) |
| 1564 | // (a, <number, ratio>, b) |
| 1565 | // (a, <number, ratio>, angle, b) |
| 1566 | |
| 1567 | let (a, number, angle, b) = if type(c) == array { |
| 1568 | if c.len() == 3 { |
| 1569 | ( |
| 1570 | ..c.slice(0, 2), |
| 1571 | none, // angle |
| 1572 | c.last(), |
| 1573 | ) |
| 1574 | } else { |
| 1575 | c |
| 1576 | } |
| 1577 | } else { |
| 1578 | ( |
| 1579 | c.a, |
| 1580 | c.number, |
| 1581 | c.at("angle", default: 0deg), |
| 1582 | c.b |
| 1583 | ) |
| 1584 | } |
| 1585 | |
| 1586 | (ctx, a, b) = resolve(ctx, a, b) |
| 1587 | |
| 1588 | if angle != none { |
| 1589 | let (x, y, _) = vector.sub(b,a) |
| 1590 | b = vector.add( |
| 1591 | ( |
| 1592 | calc.cos(angle) * x - calc.sin(angle) * y, |
| 1593 | calc.sin(angle) * x + calc.cos(angle) * y, |
| 1594 | 0 |
| 1595 | ), |
| 1596 | a, |
| 1597 | ) |
| 1598 | } |
| 1599 | |
| 1600 | let ab = vector.sub(b, a) |
| 1601 | |
| 1602 | let is-absolute = type(number) != ratio |
| 1603 | let distance = if is-absolute { |
| 1604 | let dist = vector.len(ab) |
| 1605 | if dist != 0 { |
| 1606 | util.resolve-number(ctx, number) / dist |
| 1607 | } else { |
| 1608 | 0 |
| 1609 | } |
| 1610 | } else { |
| 1611 | number / 100% |
| 1612 | } |
| 1613 | |
| 1614 | return vector.add(a, vector.scale(ab, distance)) |
| 1615 | } |
| 1616 | |
| 1617 | #let resolve-function(resolve, ctx, c) = { |
| 1618 | let (func, ..c) = c |
| 1619 | (ctx, ..c) = resolve(ctx, ..c) |
| 1620 | func(..c) |
| 1621 | } |
| 1622 | |
| 1623 | #let resolve-pos(ctx, c) = { |
| 1624 | // (name: str, pos: float, auto?: left|right, swap?: bool) |
| 1625 | } |
| 1626 | |
| 1627 | /// Figures out what system a coordinate belongs to and returns the corresponding string. |
| 1628 | /// - c (coordinate): The coordinate to find the system of. |
| 1629 | /// -> str |
| 1630 | #let resolve-system(c) = { |
| 1631 | let t = if type(c) == dictionary { |
| 1632 | let keys = c.keys() |
| 1633 | let len = c.len() |
| 1634 | if len in (1, 2, 3) and keys.all(k => k in ("x", "y", "z")) { |
| 1635 | "xyz" |
| 1636 | } else if len == 2 and keys.all(k => k in ("angle", "radius")) and (type(c.radius) in (int, float, length) or (type(c.radius) == array and c.radius.len() == 2)) { |
| 1637 | "polar" |
| 1638 | } else if len == 1 and keys == ("bary",) { |
| 1639 | "barycentric" |
| 1640 | } else if len in (1, 2) and keys.all(k => k in ("name", "anchor")) { |
| 1641 | "anchor" |
| 1642 | } else if len == 3 and keys.all(k => k in ("element", "point", "solution")) { |
| 1643 | "tangent" |
| 1644 | } else if len == 2 and keys.all(k => k in ("horizontal", "vertical")) { |
| 1645 | "perpendicular" |
| 1646 | } else if len in (1, 2, 3) and keys.all(k => k in ("rel", "to", "update")) { |
| 1647 | "relative" |
| 1648 | } else if len in (3, 4) and keys.all(k => k in ("a", "number", "angle", "abs", "b")) { |
| 1649 | "lerp" |
| 1650 | } |
| 1651 | } else if type(c) == array { |
| 1652 | let len = c.len() |
| 1653 | let types = c.map(type) |
| 1654 | if len == 0 { |
| 1655 | "previous" |
| 1656 | } else if len in (2, 3) and types.all(t => t in (int, float, length)) { |
| 1657 | "xyz" |
| 1658 | } else if len == 2 and types.first() == angle { |
| 1659 | "polar" |
| 1660 | } else if len == 3 and c.at(1) in ("-|", "|-") { |
| 1661 | "perpendicular" |
| 1662 | } else if len in (3, 4) and types.at(1) in (int, float, length, ratio) and (len == 3 or (len == 4 and types.at(2) == angle)) { |
| 1663 | "lerp" |
| 1664 | } else if len >= 2 and types.first() == function { |
| 1665 | "function" |
| 1666 | } |
| 1667 | } else if type(c) == str { |
| 1668 | if c.contains(".") { |
| 1669 | "anchor" |
| 1670 | } else { |
| 1671 | "element" |
| 1672 | } |
| 1673 | } |
| 1674 | |
| 1675 | if t == none { |
| 1676 | panic("Failed to resolve coordinate: " + repr(c)) |
| 1677 | } |
| 1678 | return t |
| 1679 | } |
| 1680 | |
| 1681 | /// Resolve a list of coordinates to absolute vectors. Returns an array of the new <Type>context</Type> then the resolved coordinate vectors. |
| 1682 | /// |
| 1683 | /// ```typc example |
| 1684 | /// line((0,0), (1,1), name: "l") |
| 1685 | /// get-ctx(ctx => { |
| 1686 | /// // Get the vector of coordinate "l.start" and "l.end" |
| 1687 | /// let (ctx, a, b) = cetz.coordinate.resolve(ctx, "l.start", "l.end") |
| 1688 | /// content("l.start", [#a], frame: "rect", stroke: none, fill: white) |
| 1689 | /// content("l.end", [#b], frame: "rect", stroke: none, fill: white) |
| 1690 | /// }) |
| 1691 | /// ``` |
| 1692 | /// |
| 1693 | /// - ctx (context): Canvas context object |
| 1694 | /// - ..coordinates (coordinate): List of coordinates |
| 1695 | /// - update (bool): Update the context's last position |
| 1696 | /// -> array |
| 1697 | #let resolve(ctx, ..coordinates, update: true) = { |
| 1698 | let result = () |
| 1699 | for c in coordinates.pos() { |
| 1700 | let t = resolve-system(c) |
| 1701 | let out = if t == "xyz" { |
| 1702 | resolve-xyz(c) |
| 1703 | } else if t == "previous" { |
| 1704 | ctx.prev.pt |
| 1705 | } else if t == "polar" { |
| 1706 | resolve-polar(c) |
| 1707 | } else if t == "barycentric" { |
| 1708 | resolve-barycentric(ctx, c) |
| 1709 | } else if t in ("element", "anchor") { |
| 1710 | resolve-anchor(ctx, c) |
| 1711 | } else if t == "tangent" { |
| 1712 | resolve-tangent(resolve, ctx, c) |
| 1713 | } else if t == "perpendicular" { |
| 1714 | resolve-perpendicular(resolve, ctx, c) |
| 1715 | } else if t == "relative" { |
| 1716 | (update, ..c) = resolve-relative(resolve, ctx, c) |
| 1717 | c |
| 1718 | } else if t == "lerp" { |
| 1719 | resolve-lerp(resolve, ctx, c) |
| 1720 | } else if t == "function" { |
| 1721 | resolve-function(resolve, ctx, c) |
| 1722 | } else { |
| 1723 | panic("Failed to resolve coordinate of format: " + repr(c)) |
| 1724 | }.map(util.resolve-number.with(ctx)) |
| 1725 | |
| 1726 | if update { |
| 1727 | ctx.prev.pt = out |
| 1728 | } |
| 1729 | result.push(out) |
| 1730 | } |
| 1731 | |
| 1732 | return (ctx, ..result) |
| 1733 | } |
| 1734 | #import "@preview/oxifmt:0.2.1" |
| 1735 | #import "draw/grouping.typ": intersections, group, scope, anchor, copy-anchors, set-ctx, get-ctx, for-each-anchor, on-layer, hide, floating |
| 1736 | #import "draw/transformations.typ": set-transform, rotate, translate, scale, set-origin, move-to, set-viewport |
| 1737 | #import "draw/styling.typ": set-style, fill, stroke, register-mark |
| 1738 | #import "draw/shapes.typ": circle, circle-through, arc, arc-through, mark, line, grid, content, rect, bezier, bezier-through, catmull, hobby, merge-path, polygon |
| 1739 | #import "draw/projection.typ": ortho, on-xy, on-xz, on-yz |
| 1740 | #import "draw/util.typ": assert-version |
| 1741 | #import "/src/process.typ" |
| 1742 | #import "/src/intersection.typ" |
| 1743 | #import "/src/path-util.typ" |
| 1744 | #import "/src/styles.typ" |
| 1745 | #import "/src/drawable.typ" |
| 1746 | #import "/src/vector.typ" |
| 1747 | #import "/src/util.typ" |
| 1748 | #import "/src/coordinate.typ" |
| 1749 | #import "/src/aabb.typ" |
| 1750 | #import "/src/anchor.typ" as anchor_ |
| 1751 | #import "/src/matrix.typ" |
| 1752 | #import "/src/deps.typ" |
| 1753 | #import deps.oxifmt: strfmt |
| 1754 | |
| 1755 | #import "transformations.typ": move-to |
| 1756 | |
| 1757 | /// Hides an element. |
| 1758 | /// |
| 1759 | /// Hidden elements are not drawn to the canvas, are ignored when calculating bounding boxes and discarded by [merge-path](../shapes/merge-path). All other behaviours remain the same as a non-hidden element. |
| 1760 | /// |
| 1761 | /// ```typc example |
| 1762 | /// set-style(radius: .5) |
| 1763 | /// intersections("i", { |
| 1764 | /// circle((0,0), name: "a") |
| 1765 | /// circle((1,2), name: "b") |
| 1766 | /// // Use a hidden line to find the border intersections |
| 1767 | /// hide(line("a.center", "b.center")) |
| 1768 | /// }) |
| 1769 | /// line("i.0", "i.1") |
| 1770 | /// ``` |
| 1771 | /// |
| 1772 | /// - body (element): One or more elements to hide |
| 1773 | /// - bounds (bool): If true, respect the bounding box of the hidden elements for resizing the canvas |
| 1774 | #let hide(body, bounds: false) = { |
| 1775 | if type(body) == array { |
| 1776 | return body.map(f => { |
| 1777 | (ctx) => { |
| 1778 | let element = f(ctx) |
| 1779 | if "drawables" in element { |
| 1780 | element.drawables = element.drawables.map(d => { |
| 1781 | d.hidden = true |
| 1782 | d.bounds = bounds |
| 1783 | return d |
| 1784 | }) |
| 1785 | } |
| 1786 | return element |
| 1787 | } |
| 1788 | }) |
| 1789 | } |
| 1790 | return body |
| 1791 | } |
| 1792 | |
| 1793 | /// Places an element without affecting bounding boxes. |
| 1794 | /// |
| 1795 | /// Floating elements are drawn to the canvas but are ignored when calculating bouding boxes. All other behaviours remain the same. |
| 1796 | /// |
| 1797 | /// ```typc example |
| 1798 | /// group(name: "g", { |
| 1799 | /// content((1,0), [Normal]) |
| 1800 | /// content((0,1), [Normal]) |
| 1801 | /// floating(content((.5,1.5), [Floating])) |
| 1802 | /// }) |
| 1803 | /// set-style(stroke: red) |
| 1804 | /// rect("g.north-west", "g.south-east") |
| 1805 | /// ``` |
| 1806 | /// |
| 1807 | /// - body (element): One or more elements to place |
| 1808 | #let floating(body) = { |
| 1809 | if type(body) == array { |
| 1810 | return body.map(f => { |
| 1811 | ctx => { |
| 1812 | let element = f(ctx) |
| 1813 | if "drawables" in element { |
| 1814 | element.drawables = element.drawables.map(d => { |
| 1815 | d.bounds = false |
| 1816 | return d |
| 1817 | }) |
| 1818 | } |
| 1819 | return element |
| 1820 | } |
| 1821 | }) |
| 1822 | } |
| 1823 | return body |
| 1824 | } |
| 1825 | |
| 1826 | /// Calculates the intersections between multiple paths and creates one anchor per intersection point. |
| 1827 | /// |
| 1828 | /// All resulting anchors will be named numerically, starting at `0`. i.e., a call `intersections("a", ...)` will generate the anchors `"a.0"`, `"a.1"`, `"a.2"` to `"a.n"`, depending of the number of intersections. |
| 1829 | /// |
| 1830 | /// ```typc example |
| 1831 | /// intersections("i", { |
| 1832 | /// circle((0, 0)) |
| 1833 | /// bezier((0,0), (3,0), (1,-1), (2,1)) |
| 1834 | /// line((0,-1), (0,1)) |
| 1835 | /// rect((1.5,-1),(2.5,1)) |
| 1836 | /// }) |
| 1837 | /// for-each-anchor("i", (name) => { |
| 1838 | /// circle("i." + name, radius: .1, fill: blue) |
| 1839 | /// }) |
| 1840 | /// ``` |
| 1841 | /// |
| 1842 | /// You can also use named elements: |
| 1843 | /// |
| 1844 | /// ```typc example |
| 1845 | /// circle((0,0), name: "a") |
| 1846 | /// rect((0,0), (1,1), name: "b") |
| 1847 | /// intersections("i", "a", "b") |
| 1848 | /// for-each-anchor("i", (name) => { |
| 1849 | /// circle("i." + name, radius: .1, fill: blue) |
| 1850 | /// }) |
| 1851 | /// ``` |
| 1852 | /// |
| 1853 | /// You can calculate intersections with hidden elements by using [hide](./hide). |
| 1854 | /// |
| 1855 | /// - name (str): Name to prepend to the generated anchors. (Not to be confused with other `name` arguments that allow the use of anchor coordinates.) |
| 1856 | /// - ..elements (elements,str): Elements and/or element names to calculate intersections with. Elements referred to by name are (unlike elements passed) not drawn by the intersections function! |
| 1857 | /// - samples (int): Number of samples to use for non-linear path segments. A higher sample count can give more precise results but worse performance. |
| 1858 | /// - sort (none,function): A function of the form `(context, array<vector>) -> array<vector>` |
| 1859 | /// that gets called with the list of intersection points. |
| 1860 | /// |
| 1861 | /// CeTZ provides the following sorting functions: |
| 1862 | /// - sorting.points-by-distace(points, reference: (0, 0, 0)) |
| 1863 | /// - sorting.points-by-angle(points, reference: (0, 0, 0)) |
| 1864 | #let intersections(name, ..elements, samples: 10, sort: none) = { |
| 1865 | samples = calc.clamp(samples, 2, 2500) |
| 1866 | |
| 1867 | assert(type(name) == str and name != "", |
| 1868 | message: "Intersection must have a name, got:" + repr(name)) |
| 1869 | assert(elements.pos() != (), |
| 1870 | message: "You must at least give one element to intersections.") |
| 1871 | |
| 1872 | return (ctx => { |
| 1873 | let ctx = ctx |
| 1874 | |
| 1875 | // List of drawables to calc intersections for; |
| 1876 | // grouped by element. |
| 1877 | let named-drawables = () |
| 1878 | // List of drawables passed as elements to calc intersections for; |
| 1879 | // grouped by element. |
| 1880 | let drawables = () |
| 1881 | |
| 1882 | for elem in elements.pos() { |
| 1883 | if type(elem) == str { |
| 1884 | assert(elem in ctx.nodes, |
| 1885 | message: "No such element '" + elem + "' in elements " + repr(ctx.nodes.keys())) |
| 1886 | named-drawables.push(ctx.nodes.at(elem).drawables) |
| 1887 | } else { |
| 1888 | for sub in elem { |
| 1889 | let sub-drawables = () |
| 1890 | (ctx: ctx, drawables: sub-drawables, ..) = process.element(ctx, sub) |
| 1891 | if sub-drawables != none and sub-drawables != () { |
| 1892 | drawables.push(sub-drawables) |
| 1893 | } |
| 1894 | } |
| 1895 | } |
| 1896 | } |
| 1897 | |
| 1898 | let elems = named-drawables + drawables |
| 1899 | let pts = () |
| 1900 | if elems.len() > 1 { |
| 1901 | for (i, elem-1) in elems.enumerate() { |
| 1902 | for j in range(i + 1, elems.len()) { |
| 1903 | let elem-2 = elems.at(j) |
| 1904 | for path-1 in elem-1 { |
| 1905 | for path-2 in elem-2 { |
| 1906 | for pt in intersection.path-path( |
| 1907 | path-1, |
| 1908 | path-2, |
| 1909 | samples: samples |
| 1910 | ) { |
| 1911 | if pt not in pts { pts.push(pt) } |
| 1912 | } |
| 1913 | } |
| 1914 | } |
| 1915 | } |
| 1916 | } |
| 1917 | } |
| 1918 | |
| 1919 | if sort != none { |
| 1920 | pts = (sort)(ctx, pts) |
| 1921 | } |
| 1922 | |
| 1923 | let anchors = (:) |
| 1924 | for (i, pt) in pts.enumerate() { |
| 1925 | anchors.insert(str(i), pt) |
| 1926 | } |
| 1927 | |
| 1928 | return ( |
| 1929 | ctx: ctx, |
| 1930 | name: name, |
| 1931 | anchors: anchor_.setup( |
| 1932 | anchor => { |
| 1933 | anchors.at(anchor) |
| 1934 | }, |
| 1935 | anchors.keys(), |
| 1936 | transform: none, |
| 1937 | name: name |
| 1938 | ).last(), |
| 1939 | drawables: drawables.flatten() |
| 1940 | ) |
| 1941 | },) |
| 1942 | } |
| 1943 | |
| 1944 | /// Groups one or more elements together. This element acts as a scope, all state changes such as transformations and styling only affect the elements in the group. Elements after the group are not affected by the changes inside the group. |
| 1945 | /// |
| 1946 | /// ```typc example |
| 1947 | /// // Create group |
| 1948 | /// group({ |
| 1949 | /// stroke(5pt) |
| 1950 | /// scale(.5); rotate(45deg) |
| 1951 | /// rect((-1,-1),(1,1)) |
| 1952 | /// }) |
| 1953 | /// rect((-1,-1),(1,1)) |
| 1954 | /// ``` |
| 1955 | /// |
| 1956 | /// - body (elements, function): Elements to group together. A least one is required. A function that accepts `ctx` and returns elements is also accepted. |
| 1957 | /// - anchor (none, str): Anchor to position the group and it's children relative to. For translation the difference between the groups `"default"` anchor and the passed anchor is used. |
| 1958 | /// - name (none, str): |
| 1959 | /// - ..style (style): |
| 1960 | /// |
| 1961 | /// ## Styling |
| 1962 | /// *Root:* `group` |
| 1963 | /// |
| 1964 | /// - padding (none, number, array, dictionary) = none: How much padding to add around the group's bounding box. `none` applies no padding. A number applies padding to all sides equally. A dictionary applies padding following Typst's `pad` function: https://typst.app/docs/reference/layout/pad/. An array follows CSS like padding: `(y, x)`, `(top, x, bottom)` or `(top, right, bottom, left)`. |
| 1965 | /// |
| 1966 | /// ## Anchors |
| 1967 | /// Supports border and path anchors of the axis aligned bounding box of all the child elements of the group. |
| 1968 | /// |
| 1969 | /// You can add custom named anchors to the group by using the [anchor](./anchor) element while in the scope of said group, see [anchor](./anchor) for more details. |
| 1970 | /// |
| 1971 | /// The default anchor is `"center"` but this can be overridden by using [anchor](./anchor) to place a new anchor called `"default"`. |
| 1972 | /// |
| 1973 | /// When using named elements within a group, you can access the element's anchors outside of the group by using the implicit anchor coordinate. e.g. `"a.b.north"` |
| 1974 | /// ```typc example |
| 1975 | /// group(name: "a", { |
| 1976 | /// circle((), name: "b") |
| 1977 | /// }) |
| 1978 | /// circle("a.b.south", radius: 0.2) |
| 1979 | /// circle((name: "a", anchor: "b.north"), radius: 0.2) |
| 1980 | /// ``` |
| 1981 | #let group(body, name: none, anchor: none, ..style) = { |
| 1982 | // No extra positional arguments from the style sink |
| 1983 | assert.eq(style.pos(), (), |
| 1984 | message: "Unexpected positional arguments: " + repr(style.pos()),) |
| 1985 | util.assert-body(body) |
| 1986 | |
| 1987 | (ctx => { |
| 1988 | let style = styles.resolve(ctx.style, merge: style.named(), root: "group") |
| 1989 | |
| 1990 | let bounds = none |
| 1991 | let drawables = () |
| 1992 | let group-ctx = ctx |
| 1993 | group-ctx.groups.push(()) |
| 1994 | |
| 1995 | (ctx: group-ctx, drawables, bounds) = process.many(group-ctx, util.resolve-body(group-ctx, body)) |
| 1996 | |
| 1997 | // Apply bounds padding |
| 1998 | bounds = if bounds != none { |
| 1999 | let padding = util.as-padding-dict(style.padding) |
| 2000 | padding = padding.pairs().map( |
| 2001 | ((k, v)) => ( |
| 2002 | (k): util.resolve-number(ctx, v) |
| 2003 | ) |
| 2004 | ).join() |
| 2005 | |
| 2006 | aabb.padded(bounds, padding) |
| 2007 | } |
| 2008 | |
| 2009 | // Calculate a bounding box path used for border |
| 2010 | // anchor calculation. |
| 2011 | let (center, width, height, path) = if bounds != none { |
| 2012 | (bounds.low.at(1), bounds.high.at(1)) = (bounds.high.at(1), bounds.low.at(1)) |
| 2013 | let center = aabb.mid(bounds) |
| 2014 | let (width, height, _) = aabb.size(bounds) |
| 2015 | let path = drawable.path( |
| 2016 | path-util.line-segment(( |
| 2017 | (bounds.low.at(0), bounds.high.at(1)), |
| 2018 | bounds.high, |
| 2019 | (bounds.high.at(0), bounds.low.at(1)), |
| 2020 | bounds.low, |
| 2021 | )), close: true) |
| 2022 | (center, width, height, path) |
| 2023 | } else { (none,) * 4 } |
| 2024 | |
| 2025 | let children = group-ctx.groups.last().map(name => ((name): group-ctx.nodes.at(name))).join() |
| 2026 | |
| 2027 | // Children can be none if the groups array is empty |
| 2028 | let anchors = if children != none { |
| 2029 | children.pairs().map(((name, child)) => { |
| 2030 | if "anchors" in child { |
| 2031 | ((name): child.anchors) |
| 2032 | } |
| 2033 | }).join() |
| 2034 | } else { |
| 2035 | (:) |
| 2036 | } |
| 2037 | |
| 2038 | let (transform, anchors) = anchor_.setup( |
| 2039 | anchor => { |
| 2040 | let (name, ..nested-anchors) = if type(anchor) == array { |
| 2041 | anchor |
| 2042 | } else { |
| 2043 | (anchor,) |
| 2044 | } |
| 2045 | anchor = ( |
| 2046 | if bounds != none { |
| 2047 | (default: center, center: center) |
| 2048 | } + anchors |
| 2049 | ).at(name) |
| 2050 | if type(anchor) == function { |
| 2051 | anchor(if nested-anchors == () { "default" } else { nested-anchors }) |
| 2052 | } else { |
| 2053 | anchor |
| 2054 | } |
| 2055 | }, |
| 2056 | (anchors.keys() + if bounds != none { ("center",) }).dedup(), |
| 2057 | name: name, |
| 2058 | default: if bounds != none or "default" in anchors { "default" }, |
| 2059 | offset-anchor: anchor, |
| 2060 | path-anchors: bounds != none, |
| 2061 | border-anchors: bounds != none, |
| 2062 | radii: (width, height), |
| 2063 | path: path, |
| 2064 | nested-anchors: true |
| 2065 | ) |
| 2066 | |
| 2067 | return ( |
| 2068 | ctx: ctx, |
| 2069 | name: name, |
| 2070 | anchors: anchors, |
| 2071 | drawables: drawable.apply-transform(transform, drawables), |
| 2072 | ) |
| 2073 | },) |
| 2074 | } |
| 2075 | |
| 2076 | /// This element acts as a scope, all state changes such as transformations and styling only affect the elements in the group. Elements after the scope are not affected by the changes inside the scope. |
| 2077 | /// In contrast to `group`, the `scope` element does not create a named element itself and "leaks" body element to the outside. |
| 2078 | /// |
| 2079 | /// - body (elements, function): Elements to group together. A least one is required. A function that accepts `ctx` and returns elements is also accepted. |
| 2080 | #let scope(body) = (ctx => { |
| 2081 | let bounds = none |
| 2082 | let drawables = () |
| 2083 | let group-ctx = ctx |
| 2084 | group-ctx.groups.push(()) |
| 2085 | |
| 2086 | (ctx: group-ctx, drawables, bounds) = process.many(group-ctx, util.resolve-body(group-ctx, body)) |
| 2087 | |
| 2088 | // Leak nodes |
| 2089 | ctx.nodes += group-ctx.nodes |
| 2090 | |
| 2091 | return ( |
| 2092 | ctx: ctx, |
| 2093 | drawables: drawables, |
| 2094 | ) |
| 2095 | },) |
| 2096 | |
| 2097 | /// Creates a new anchor for the current group. The new anchor will be accessible from inside the group by using just the anchor's name as a coordinate. |
| 2098 | /// |
| 2099 | /// ```typc example |
| 2100 | /// // Inside a group |
| 2101 | /// group(name: "g", { |
| 2102 | /// circle((0,0)) |
| 2103 | /// anchor("x", (.4, .1)) |
| 2104 | /// circle("x", radius: .2) |
| 2105 | /// }) |
| 2106 | /// circle("g.x", radius: .1) |
| 2107 | /// ``` |
| 2108 | |
| 2109 | /// ```typc example |
| 2110 | /// // At the root scope |
| 2111 | /// anchor("x", (1, 1)) |
| 2112 | /// // ... |
| 2113 | /// circle("x", radius: .1) |
| 2114 | /// ``` |
| 2115 | /// |
| 2116 | /// - name (str): The name of the anchor |
| 2117 | /// - position (coordinate): The position of the anchor |
| 2118 | #let anchor(name, position) = { |
| 2119 | assert(name != none and name != "" and not name.starts-with("."), |
| 2120 | message: "Anchors must not be none, \"\" or start with \".\"!") |
| 2121 | |
| 2122 | coordinate.resolve-system(position) |
| 2123 | return (ctx => { |
| 2124 | let (ctx, position) = coordinate.resolve(ctx, position) |
| 2125 | position = util.apply-transform(ctx.transform, position) |
| 2126 | return ( |
| 2127 | ctx: ctx, |
| 2128 | name: name, |
| 2129 | anchors: anchor_.setup( |
| 2130 | anchor => position, |
| 2131 | ("default",), |
| 2132 | default: "default", |
| 2133 | name: name, |
| 2134 | transform: none |
| 2135 | ).last() |
| 2136 | ) |
| 2137 | },) |
| 2138 | } |
| 2139 | |
| 2140 | /// Copies multiple anchors from one element into the current group. Panics when used outside of a group. Copied anchors will be accessible in the same way anchors created by the `anchor` element are. |
| 2141 | /// |
| 2142 | /// - element (str): The name of the element to copy anchors from. |
| 2143 | /// - filter (auto,array): When set to `auto` all anchors will be copied to the group. An array of anchor names can instead be given so only the anchors that are in the element and the list will be copied over. |
| 2144 | #let copy-anchors(element, filter: auto) = { |
| 2145 | (ctx => { |
| 2146 | assert( |
| 2147 | ctx.groups.len() > 0, |
| 2148 | message: "copy-anchors cannot be used outside of a group.", |
| 2149 | ) |
| 2150 | assert( |
| 2151 | element in ctx.nodes, |
| 2152 | message: "copy-anchors: Could not find element '" + element + "'", |
| 2153 | ) |
| 2154 | |
| 2155 | let calc-anchors = ctx.nodes.at(element).anchors |
| 2156 | let anchors = calc-anchors(()) |
| 2157 | if filter != auto { |
| 2158 | anchors = anchors.filter(a => a in filter) |
| 2159 | } |
| 2160 | |
| 2161 | // Add each anchor as own element |
| 2162 | for anchor in anchors { |
| 2163 | ctx.nodes.insert( |
| 2164 | anchor, |
| 2165 | (anchors: name => { |
| 2166 | if name == "default" { |
| 2167 | calc-anchors(anchor) |
| 2168 | } else if name == () { |
| 2169 | ("default",) |
| 2170 | } else { |
| 2171 | calc-anchors((anchor,) + name) |
| 2172 | } |
| 2173 | }) |
| 2174 | ) |
| 2175 | ctx.groups.last().push(anchor) |
| 2176 | } |
| 2177 | |
| 2178 | return (ctx: ctx) |
| 2179 | },) |
| 2180 | } |
| 2181 | |
| 2182 | /// An advanced element that allows you to modify the current canvas {{context}}. |
| 2183 | /// Note: The transformation matrix (`transform`) is rounded after calling the `callback` function and therefore might be not exactly the matrix specified. This is due to rounding errors and should not cause any problems. |
| 2184 | /// |
| 2185 | /// ```typc example |
| 2186 | /// // Setting a custom transformation matrix |
| 2187 | /// set-ctx(ctx => { |
| 2188 | /// let mat = ((1, 0, .5, 0), |
| 2189 | /// (0, 1, 0, 0), |
| 2190 | /// (0, 0, 1, 0), |
| 2191 | /// (0, 0, 0, 1)) |
| 2192 | /// ctx.transform = mat |
| 2193 | /// return ctx |
| 2194 | /// }) |
| 2195 | /// circle((z: 0), fill: red) |
| 2196 | /// circle((z: 1), fill: blue) |
| 2197 | /// circle((z: 2), fill: green) |
| 2198 | /// ``` |
| 2199 | /// |
| 2200 | /// - callback (function): A function that accepts the context dictionary and only returns a new one. |
| 2201 | #let set-ctx(callback) = { |
| 2202 | assert(type(callback) == function) |
| 2203 | return (ctx => { |
| 2204 | let new-ctx = callback(ctx) |
| 2205 | assert(new-ctx != none, message: "set-ctx must return a context!") |
| 2206 | |
| 2207 | if new-ctx.transform != ctx.transform { |
| 2208 | // User supplied matrices can cause rounding issues |
| 2209 | new-ctx.transform = matrix.round(new-ctx.transform) |
| 2210 | } |
| 2211 | (ctx: new-ctx) |
| 2212 | },) |
| 2213 | } |
| 2214 | |
| 2215 | /// An advanced element that allows you to read the current {{context}} through a callback and return {{element}}s based on it. |
| 2216 | /// |
| 2217 | /// ```typc example |
| 2218 | /// // Print the transformation matrix |
| 2219 | /// get-ctx(ctx => { |
| 2220 | /// content((), [#repr(ctx.transform)]) |
| 2221 | /// }) |
| 2222 | /// ``` |
| 2223 | /// |
| 2224 | /// - callback (function): A function that accepts the {{context}} and can return elements. |
| 2225 | #let get-ctx(callback) = { |
| 2226 | assert(type(callback) == function) |
| 2227 | (ctx => { |
| 2228 | let body = callback(ctx) |
| 2229 | if body != none { |
| 2230 | let (ctx, drawables) = process.many(ctx, callback(ctx)) |
| 2231 | return (ctx: ctx, drawables: drawables) |
| 2232 | } |
| 2233 | return (ctx: ctx) |
| 2234 | },) |
| 2235 | } |
| 2236 | |
| 2237 | /// Iterates through all named anchors of an element and calls a callback for each one. |
| 2238 | /// |
| 2239 | /// ```typc example |
| 2240 | /// // Label nodes anchors |
| 2241 | /// rect((0, 0), (2,2), name: "my-rect") |
| 2242 | /// for-each-anchor("my-rect", exclude: ("start", "mid", "end"), (name) => { |
| 2243 | /// content((), box(inset: 1pt, fill: white, text(8pt, [#name])), angle: -30deg) |
| 2244 | /// }) |
| 2245 | /// ``` |
| 2246 | /// |
| 2247 | /// - name (str): The name of the element with the anchors to loop through. |
| 2248 | /// - callback (function): A function that takes the anchor name and can return elements. |
| 2249 | /// - exclude (array): An array of anchor names to not include in the loop. |
| 2250 | #let for-each-anchor(name, callback, exclude: ()) = { |
| 2251 | get-ctx(ctx => { |
| 2252 | assert( |
| 2253 | name in ctx.nodes, |
| 2254 | message: strfmt("Unknown element {} in elements {}", name, repr(ctx.nodes.keys())) |
| 2255 | ) |
| 2256 | for anchor in (ctx.nodes.at(name).anchors)(()) { |
| 2257 | if anchor == none or (anchor in exclude) { continue } |
| 2258 | move-to(name + "." + anchor) |
| 2259 | callback(anchor) |
| 2260 | } |
| 2261 | }) |
| 2262 | } |
| 2263 | |
| 2264 | /// Places elements on a specific layer. |
| 2265 | /// |
| 2266 | /// A layer determines the position of an element in the draw queue. A lower layer is drawn before a higher layer. |
| 2267 | /// |
| 2268 | /// Layers can be used to draw behind or in front of other elements, even if the other elements were created before or after. An example would be drawing a background behind a text, but using the text's calculated bounding box for positioning the background. |
| 2269 | /// |
| 2270 | /// ```typc example |
| 2271 | /// // Draw something behind text |
| 2272 | /// set-style(stroke: none) |
| 2273 | /// content((0, 0), [This is an example.], name: "text") |
| 2274 | /// on-layer(-1, { |
| 2275 | /// circle("text.north-east", radius: .3, fill: red) |
| 2276 | /// circle("text.south", radius: .4, fill: green) |
| 2277 | /// circle("text.north-west", radius: .2, fill: blue) |
| 2278 | /// }) |
| 2279 | /// ``` |
| 2280 | /// |
| 2281 | /// - layer (float, int): The layer to place the elements on. Elements placed without `on-layer` are always placed on layer 0. |
| 2282 | /// - body (elements, function): Elements to draw on the layer specified. A function that accepts `ctx` and returns elements is also accepted. |
| 2283 | #let on-layer(layer, body) = { |
| 2284 | util.assert-body(body) |
| 2285 | assert(type(layer) in (int, float), |
| 2286 | message: "Layer must be a float or integer, 0 being the default layer. Got: " + repr(layer)) |
| 2287 | |
| 2288 | return (ctx => { |
| 2289 | let (ctx, drawables, ..) = process.many(ctx, util.resolve-body(ctx, body)) |
| 2290 | drawables = drawables.map(d => { |
| 2291 | if d.at("z-index", default: none) == none { |
| 2292 | d.z-index = layer |
| 2293 | } |
| 2294 | return d |
| 2295 | }) |
| 2296 | |
| 2297 | return ( |
| 2298 | ctx: ctx, |
| 2299 | drawables: drawables |
| 2300 | ) |
| 2301 | },) |
| 2302 | } |
| 2303 | #import "grouping.typ": group, get-ctx, set-ctx, scope |
| 2304 | #import "transformations.typ": set-transform |
| 2305 | #import "/src/process.typ" |
| 2306 | #import "/src/matrix.typ" |
| 2307 | #import "/src/drawable.typ" |
| 2308 | #import "/src/util.typ" |
| 2309 | #import "/src/polygon.typ" |
| 2310 | |
| 2311 | // Get an orthographic view matrix for 3 angles |
| 2312 | #let ortho-matrix(x, y, z) = matrix.mul-mat( |
| 2313 | matrix.ident(4), |
| 2314 | matrix.transform-rotate-x(x), |
| 2315 | matrix.transform-rotate-y(y), |
| 2316 | matrix.transform-rotate-z(z), |
| 2317 | ) |
| 2318 | |
| 2319 | #let ortho-projection-matrix = ( |
| 2320 | (1, 0, 0, 0), |
| 2321 | (0, 1, 0, 0), |
| 2322 | (0, 0, 0, 0), |
| 2323 | (0, 0, 0, 1), |
| 2324 | ) |
| 2325 | |
| 2326 | #let _sort-by-distance(drawables) = { |
| 2327 | return drawables.sorted(key: d => { |
| 2328 | let z = none |
| 2329 | for ((kind, ..pts)) in d.segments { |
| 2330 | pts = pts.map(p => p.at(2)) |
| 2331 | z = if z == none { |
| 2332 | calc.max(..pts) |
| 2333 | } else { |
| 2334 | calc.max(z, ..pts) |
| 2335 | } |
| 2336 | } |
| 2337 | return z |
| 2338 | }) |
| 2339 | } |
| 2340 | |
| 2341 | // Filter out all clock-wise polygons, or if `invert` is true, |
| 2342 | // all counter clock-wise ones. |
| 2343 | #let _filter-cw-faces(drawables, mode: "cw") = { |
| 2344 | return drawables.filter(d => { |
| 2345 | let poly = polygon.from-segments(d.segments) |
| 2346 | poly.first() != poly.last() or polygon.winding-order(poly) == mode |
| 2347 | }) |
| 2348 | } |
| 2349 | |
| 2350 | // Sets up a view matrix to transform all `body` elements. The current context |
| 2351 | // transform is not modified. |
| 2352 | // |
| 2353 | // - body (element): Elements |
| 2354 | // - view-matrix (matrix): View matrix |
| 2355 | // - projection-matrix (matrix): Projection matrix |
| 2356 | // - reset-transform (bool): Ignore the current transformation matrix |
| 2357 | // - sorted (bool): Sort drawables by maximum distance (front to back) |
| 2358 | // - cull-face (none,str): Enable back-face culling if set to `"cw"` for clockwise |
| 2359 | // or `"ccw"` for counter-clockwise. Polygons of the specified order will not get drawn. |
| 2360 | #let _projection(body, view-matrix, projection-matrix, reset-transform: true, sorted: true, cull-face: "cw") = { |
| 2361 | (ctx => { |
| 2362 | let transform = ctx.transform |
| 2363 | ctx.transform = view-matrix |
| 2364 | |
| 2365 | let (ctx, drawables, bounds) = process.many(ctx, util.resolve-body(ctx, body)) |
| 2366 | |
| 2367 | if cull-face != none { |
| 2368 | assert(cull-face in ("cw", "ccw"), |
| 2369 | message: "cull-face must be none, cw or ccw.") |
| 2370 | drawables = _filter-cw-faces(drawables, mode: cull-face) |
| 2371 | } |
| 2372 | if sorted { |
| 2373 | drawables = _sort-by-distance(drawables) |
| 2374 | } |
| 2375 | |
| 2376 | if projection-matrix != none { |
| 2377 | drawables = drawable.apply-transform(projection-matrix, drawables) |
| 2378 | } |
| 2379 | |
| 2380 | ctx.transform = transform |
| 2381 | if not reset-transform { |
| 2382 | drawables = drawable.apply-transform(ctx.transform, drawables) |
| 2383 | } |
| 2384 | |
| 2385 | return ( |
| 2386 | ctx: ctx, |
| 2387 | bounds: bounds, |
| 2388 | drawables: drawables, |
| 2389 | ) |
| 2390 | },) |
| 2391 | } |
| 2392 | |
| 2393 | // Apply function `fn` to all vertices of all |
| 2394 | // elements in `body`. |
| 2395 | // |
| 2396 | // - body (element): Elements |
| 2397 | // - ..mat (matrix): Transformation matrices |
| 2398 | #let scoped-transform(body, ..mat) = { |
| 2399 | scope({ |
| 2400 | set-ctx(ctx => { |
| 2401 | ctx.transform = matrix.mul-mat(ctx.transform, ..mat.pos().filter(m => m != none)) |
| 2402 | return ctx |
| 2403 | }) |
| 2404 | body |
| 2405 | }) |
| 2406 | } |
| 2407 | |
| 2408 | /// Set-up an orthographic projection environment. |
| 2409 | /// |
| 2410 | /// This is a transformation matrix that rotates elements around the x, the y and the z axis by the parameters given. |
| 2411 | /// |
| 2412 | /// By default an isometric projection (x ≈ 35.264°, y = 45°) is set. |
| 2413 | /// |
| 2414 | /// ```typc example |
| 2415 | /// ortho({ |
| 2416 | /// on-xz({ |
| 2417 | /// rect((-1,-1), (1,1)) |
| 2418 | /// }) |
| 2419 | /// }) |
| 2420 | /// ``` |
| 2421 | /// |
| 2422 | /// - x (angle): X-axis rotation angle |
| 2423 | /// - y (angle): Y-axis rotation angle |
| 2424 | /// - z (angle): Z-axis rotation angle |
| 2425 | /// - sorted (bool): Sort drawables by maximum distance (front to back) |
| 2426 | /// - cull-face (none,str): Enable back-face culling if set to `"cw"` for clockwise |
| 2427 | /// or `"ccw"` for counter-clockwise. Polygons of the specified order will not get drawn. |
| 2428 | /// - reset-transform (bool): Ignore the current transformation matrix |
| 2429 | /// - body (element): Elements to draw |
| 2430 | #let ortho(x: 35.264deg, y: 45deg, z: 0deg, sorted: true, cull-face: none, reset-transform: false, body, name: none) = group(name: name, ctx => { |
| 2431 | _projection(body, ortho-matrix(x, y, z), ortho-projection-matrix, |
| 2432 | sorted: sorted, |
| 2433 | cull-face: cull-face, |
| 2434 | reset-transform: reset-transform) |
| 2435 | }) |
| 2436 | |
| 2437 | /// Draw elements on the xy-plane with optional z offset. |
| 2438 | /// |
| 2439 | /// All vertices of all elements will be changed in the following way: $\begin{pmatrix} x \\ y \\ z_\text{argument}\end{pmatrix}$, where $z_\text{argument}$ is the z-value given as argument. |
| 2440 | /// |
| 2441 | /// ```typc example |
| 2442 | /// on-xy({ |
| 2443 | /// rect((-1, -1), (1, 1)) |
| 2444 | /// }) |
| 2445 | /// ``` |
| 2446 | /// |
| 2447 | /// - z (number): Z offset for all coordinates |
| 2448 | /// - body (element): Elements to draw |
| 2449 | #let on-xy(z: 0, body) = get-ctx(ctx => { |
| 2450 | let z = util.resolve-number(ctx, z) |
| 2451 | scoped-transform(body, if z != 0 { |
| 2452 | matrix.transform-translate(0, 0, z) |
| 2453 | }, matrix.ident(4)) |
| 2454 | }) |
| 2455 | |
| 2456 | /// Draw elements on the xz-plane with optional y offset. |
| 2457 | /// |
| 2458 | /// All vertices of all elements will be changed in the following way: $\begin{pmatrix} x \\ y_\text{argument} \\ y \end{pmatrix}$, where $y_\text{argument}$ is the y-value given as argument. |
| 2459 | /// |
| 2460 | /// ```typc example |
| 2461 | /// on-xz({ |
| 2462 | /// rect((-1, -1), (1, 1)) |
| 2463 | /// }) |
| 2464 | /// ``` |
| 2465 | /// |
| 2466 | /// - y (number): Y offset for all coordinates |
| 2467 | /// - body (element): Elements to draw |
| 2468 | #let on-xz(y: 0, body) = get-ctx(ctx => { |
| 2469 | let y = util.resolve-number(ctx, y) |
| 2470 | scoped-transform(body, if y != 0 { |
| 2471 | matrix.transform-translate(0, y, 0) |
| 2472 | }, matrix.transform-rotate-x(90deg)) |
| 2473 | }) |
| 2474 | |
| 2475 | /// Draw elements on the yz-plane with optional x offset. |
| 2476 | /// |
| 2477 | /// All vertices of all elements will be changed in the following way: $\begin{pmatrix} x_\text{argument} \\ x \\ y \end{pmatrix}$, where $x_\text{argument}$ is the x-value given as argument. |
| 2478 | /// |
| 2479 | /// ```typc example |
| 2480 | /// on-yz({ |
| 2481 | /// rect((-1, -1), (1, 1)) |
| 2482 | /// }) |
| 2483 | /// ``` |
| 2484 | /// |
| 2485 | /// - x (number): X offset for all coordinates |
| 2486 | /// - body (element): Elements to draw |
| 2487 | #let on-yz(x: 0, body) = get-ctx(ctx => { |
| 2488 | let x = util.resolve-number(ctx, x) |
| 2489 | scoped-transform(body, if x != 0 { |
| 2490 | matrix.transform-translate(x, 0, 0) |
| 2491 | }, matrix.transform-rotate-y(90deg)) |
| 2492 | }) |
| 2493 | #let typst-angle = angle |
| 2494 | #let typst-rotate = rotate |
| 2495 | |
| 2496 | #import "/src/coordinate.typ" |
| 2497 | #import "/src/drawable.typ" |
| 2498 | #import "/src/styles.typ" |
| 2499 | #import "/src/path-util.typ" |
| 2500 | #import "/src/util.typ" |
| 2501 | #import "/src/vector.typ" |
| 2502 | #import "/src/matrix.typ" |
| 2503 | #import "/src/process.typ" |
| 2504 | #import "/src/bezier.typ" as bezier_ |
| 2505 | #import "/src/hobby.typ" as hobby_ |
| 2506 | #import "/src/anchor.typ" as anchor_ |
| 2507 | #import "/src/mark.typ" as mark_ |
| 2508 | #import "/src/mark-shapes.typ" as mark-shapes_ |
| 2509 | #import "/src/polygon.typ" as polygon_ |
| 2510 | #import "/src/aabb.typ" |
| 2511 | |
| 2512 | #import "transformations.typ": * |
| 2513 | #import "styling.typ": * |
| 2514 | #import "grouping.typ": * |
| 2515 | |
| 2516 | /// Draws a circle or ellipse. |
| 2517 | /// |
| 2518 | /// ```typc example |
| 2519 | /// circle((0,0)) |
| 2520 | /// // Draws an ellipse |
| 2521 | /// circle((0,-2), radius: (0.75, 0.5)) |
| 2522 | /// // Draws a circle at (0, 2) through point (1, 3) |
| 2523 | /// circle((0,2), (rel: (1,1))) |
| 2524 | /// ``` |
| 2525 | /// |
| 2526 | /// - ..points-style (coordinate, style): The position to place the circle on. |
| 2527 | /// If given two coordinates, the distance between them is used as radius. |
| 2528 | /// If given a single coordinate, the radius can be set via the `radius` (style) |
| 2529 | /// argument. |
| 2530 | /// - name (none,str): |
| 2531 | /// - anchor (none, str): |
| 2532 | /// |
| 2533 | /// ### Styling |
| 2534 | /// *Root*: `circle` |
| 2535 | /// |
| 2536 | /// - radius (number, array) = 1: A number that defines the size of the circle's radius. Can also be set to a tuple of two numbers to define the radii of an ellipse, the first number is the `x` radius and the second is the `y` radius. |
| 2537 | /// |
| 2538 | /// ### Anchors |
| 2539 | /// Supports border and path anchors. The `"center"` anchor is the default. |
| 2540 | /// |
| 2541 | #let circle(..points-style, name: none, anchor: none) = { |
| 2542 | let style = points-style.named() |
| 2543 | let points = points-style.pos() |
| 2544 | assert(points.len() in (1, 2), |
| 2545 | message: "circle expects one or two points, got " + repr(points)) |
| 2546 | assert(points.len() != 2 or "radius" not in style, |
| 2547 | message: "unexpected radius for circle constructed by two points") |
| 2548 | |
| 2549 | (ctx => { |
| 2550 | let (center, outer) = if points.len() == 1 { |
| 2551 | (points.at(0), none) |
| 2552 | } else { |
| 2553 | points |
| 2554 | } |
| 2555 | |
| 2556 | let (ctx, center) = coordinate.resolve(ctx, center) |
| 2557 | let style = styles.resolve(ctx.style, merge: style, root: "circle") |
| 2558 | |
| 2559 | // If we got two points, use the second one to calculate |
| 2560 | // the radius. |
| 2561 | let (rx, ry) = if outer != none { |
| 2562 | (ctx, outer) = coordinate.resolve(ctx, outer, update: false) |
| 2563 | (vector.dist(center, outer),) * 2 |
| 2564 | } else { |
| 2565 | util.resolve-radius(style.radius).map(util.resolve-number.with(ctx)) |
| 2566 | } |
| 2567 | let (cx, cy, cz) = center |
| 2568 | |
| 2569 | let drawables = drawable.ellipse( |
| 2570 | cx, cy, cz, |
| 2571 | rx, ry, |
| 2572 | fill: style.fill, |
| 2573 | stroke: style.stroke |
| 2574 | ) |
| 2575 | |
| 2576 | let (transform, anchors) = anchor_.setup( |
| 2577 | (_) => center, |
| 2578 | ("center",), |
| 2579 | default: "center", |
| 2580 | name: name, |
| 2581 | offset-anchor: anchor, |
| 2582 | transform: ctx.transform, |
| 2583 | border-anchors: true, |
| 2584 | path-anchors: true, |
| 2585 | radii: (rx*2, ry*2), |
| 2586 | path: drawables, |
| 2587 | ) |
| 2588 | |
| 2589 | return ( |
| 2590 | ctx: ctx, |
| 2591 | name: name, |
| 2592 | anchors: anchors, |
| 2593 | drawables: drawable.apply-transform(transform, drawables), |
| 2594 | ) |
| 2595 | },) |
| 2596 | } |
| 2597 | |
| 2598 | /// Draws a circle through three coordinates. |
| 2599 | /// |
| 2600 | /// ```typc example |
| 2601 | /// let (a, b, c) = ((0,0), (2,-.5), (1,1)) |
| 2602 | /// line(a, b, c, close: true, stroke: gray) |
| 2603 | /// circle-through(a, b, c, name: "c") |
| 2604 | /// circle("c.center", radius: .05, fill: red) |
| 2605 | /// ``` |
| 2606 | /// |
| 2607 | /// - a (coordinate): Coordinate a. |
| 2608 | /// - b (coordinate): Coordinate b. |
| 2609 | /// - c (coordinate): Coordinate c. |
| 2610 | /// - name (none,str): |
| 2611 | /// - anchor (none,str): |
| 2612 | /// - ..style (style): |
| 2613 | /// |
| 2614 | /// ### Styling |
| 2615 | /// *Root*: `circle` |
| 2616 | /// |
| 2617 | /// `circle-through` has the same styling as [circle](./circle#styling) except for `radius` as the circle's radius is calculated by the given coordinates. |
| 2618 | /// |
| 2619 | /// ### Anchors |
| 2620 | /// Supports the same anchors as [circle](./circle#anchors) as well as: |
| 2621 | /// - **a**: Coordinate a |
| 2622 | /// - **b**: Coordinate b |
| 2623 | /// - **c**: Coordinate c |
| 2624 | #let circle-through(a, b, c, name: none, anchor: none, ..style) = { |
| 2625 | assert.eq(style.pos(), (), message: "Unexpected positional arguments: " + repr(style.pos())) |
| 2626 | style = style.named() |
| 2627 | |
| 2628 | (a, b, c).map(coordinate.resolve-system) |
| 2629 | |
| 2630 | return (ctx => { |
| 2631 | let (ctx, a, b, c) = coordinate.resolve(ctx, a, b, c) |
| 2632 | |
| 2633 | let center = util.calculate-circle-center-3pt(a, b, c) |
| 2634 | |
| 2635 | let style = styles.resolve(ctx.style, merge: style, root: "circle") |
| 2636 | let (cx, cy, cz) = center |
| 2637 | let r = vector.dist(a, (cx, cy)) |
| 2638 | |
| 2639 | let drawables = drawable.ellipse( |
| 2640 | cx, cy, 0, |
| 2641 | r, r, |
| 2642 | fill: style.fill, |
| 2643 | stroke: style.stroke |
| 2644 | ) |
| 2645 | |
| 2646 | let (transform, anchors) = anchor_.setup( |
| 2647 | (anchor) => ( |
| 2648 | center: center, |
| 2649 | a: a, |
| 2650 | b: b, |
| 2651 | c: c |
| 2652 | ).at(anchor), |
| 2653 | ("center", "a", "b", "c"), |
| 2654 | default: "center", |
| 2655 | name: name, |
| 2656 | offset-anchor: anchor, |
| 2657 | transform: ctx.transform, |
| 2658 | border-anchors: true, |
| 2659 | path-anchors: true, |
| 2660 | radii: (r*2, r*2), |
| 2661 | path: drawables, |
| 2662 | ) |
| 2663 | |
| 2664 | return ( |
| 2665 | ctx: ctx, |
| 2666 | name: name, |
| 2667 | anchors: anchors, |
| 2668 | drawables: drawable.apply-transform( |
| 2669 | transform, |
| 2670 | drawables |
| 2671 | ) |
| 2672 | ) |
| 2673 | },) |
| 2674 | } |
| 2675 | |
| 2676 | /// Draws a circular segment. |
| 2677 | /// |
| 2678 | /// ```typc example |
| 2679 | /// arc((0,0), start: 45deg, stop: 135deg) |
| 2680 | /// arc((0,-0.5), start: 45deg, delta: 90deg, mode: "CLOSE") |
| 2681 | /// arc((0,-1), stop: 135deg, delta: 90deg, mode: "PIE") |
| 2682 | /// ``` |
| 2683 | /// |
| 2684 | /// Note that two of the three angle arguments (`start`, `stop` and `delta`) must be set. |
| 2685 | /// The current position `()` gets updated to the arc's end coordinate (anchor `arc-end`). |
| 2686 | /// |
| 2687 | /// - position (coordinate): Position to place the arc at. |
| 2688 | /// - start (auto,angle): The angle at which the arc should start. Remember that `0deg` points directly towards the right and `90deg` points up. |
| 2689 | /// - stop (auto,angle): The angle at which the arc should stop. |
| 2690 | /// - delta (auto,angle): The change in angle away start or stop. |
| 2691 | /// - name (none,str): |
| 2692 | /// - anchor (none, str): |
| 2693 | /// - ..style (style): |
| 2694 | /// |
| 2695 | /// ## Styling |
| 2696 | /// *Root*: `arc`\ |
| 2697 | /// - radius (number, array) = 1: The radius of the arc. An elliptical arc can be created by passing a tuple of numbers where the first element is the x radius and the second element is the y radius. |
| 2698 | /// - mode (str) = "OPEN": The options are: `"OPEN"` no additional lines are drawn so just the arc is shown; `"CLOSE"` a line is drawn from the start to the end of the arc creating a circular segment; `"PIE"` lines are drawn from the start and end of the arc to the origin creating a circular sector. |
| 2699 | /// - update-position (bool) = true: Update the current canvas position to the arc's end point (anchor `"arc-end"`). This overrides the default of `true`, that allows chaining of (arc) elements. |
| 2700 | /// |
| 2701 | /// ## Anchors |
| 2702 | /// Supports border and path anchors. |
| 2703 | /// - **arc-start**: The position at which the arc's curve starts, this is the default. |
| 2704 | /// - **arc-end**: The position of the arc's curve end. |
| 2705 | /// - **arc-center**: The midpoint of the arc's curve. |
| 2706 | /// - **center**: The center of the arc, this position changes depending on if the arc is closed or not. |
| 2707 | /// - **chord-center**: Center of chord of the arc drawn between the start and end point. |
| 2708 | /// - **origin**: The origin of the arc's circle. |
| 2709 | #let arc( |
| 2710 | position, |
| 2711 | start: auto, |
| 2712 | stop: auto, |
| 2713 | delta: auto, |
| 2714 | name: none, |
| 2715 | anchor: none, |
| 2716 | ..style, |
| 2717 | ) = { |
| 2718 | // Start, stop, delta check |
| 2719 | assert( |
| 2720 | (start, stop, delta).filter(it => { it == auto }).len() == 1, |
| 2721 | message: "Exactly two of three options start, stop and delta should be defined.", |
| 2722 | ) |
| 2723 | |
| 2724 | // No extra positional arguments from the style sink |
| 2725 | assert.eq( |
| 2726 | style.pos(), |
| 2727 | (), |
| 2728 | message: "Unexpected positional arguments: " + repr(style.pos()), |
| 2729 | ) |
| 2730 | let style = style.named() |
| 2731 | |
| 2732 | // Coordinate check |
| 2733 | let t = coordinate.resolve-system(position) |
| 2734 | |
| 2735 | let start-angle = if start == auto { stop - delta } else { start } |
| 2736 | let stop-angle = if stop == auto { start + delta } else { stop } |
| 2737 | // Border angles can break if the angle is 0. |
| 2738 | assert.ne(start-angle, stop-angle, message: "Angle must be greater than 0deg") |
| 2739 | |
| 2740 | return (ctx => { |
| 2741 | let style = styles.resolve(ctx.style, merge: style, root: "arc") |
| 2742 | assert(style.mode in ("OPEN", "PIE", "CLOSE")) |
| 2743 | |
| 2744 | let (ctx, arc-start) = coordinate.resolve(ctx, position) |
| 2745 | let (rx, ry) = util.resolve-radius(style.radius).map(util.resolve-number.with(ctx)) |
| 2746 | |
| 2747 | let (x, y, z) = arc-start |
| 2748 | let drawables = drawable.arc( |
| 2749 | ..arc-start, |
| 2750 | start-angle, |
| 2751 | stop-angle, |
| 2752 | rx, |
| 2753 | ry, |
| 2754 | stroke: style.stroke, |
| 2755 | fill: style.fill, |
| 2756 | mode: style.mode |
| 2757 | ) |
| 2758 | |
| 2759 | let sector-center = ( |
| 2760 | x - rx * calc.cos(start-angle), |
| 2761 | y - ry * calc.sin(start-angle), |
| 2762 | z |
| 2763 | ) |
| 2764 | let arc-end = ( |
| 2765 | sector-center.first() + rx * calc.cos(stop-angle), |
| 2766 | sector-center.at(1) + ry * calc.sin(stop-angle), |
| 2767 | z |
| 2768 | ) |
| 2769 | let chord-center = vector.lerp(arc-start, arc-end, 0.5) |
| 2770 | let arc-center = ( |
| 2771 | sector-center.first() + rx * calc.cos((stop-angle + start-angle)/2), |
| 2772 | sector-center.at(1) + ry * calc.sin((stop-angle + start-angle)/2), |
| 2773 | z |
| 2774 | ) |
| 2775 | |
| 2776 | // Set the last position to arc-end |
| 2777 | if style.update-position { |
| 2778 | ctx.prev.pt = arc-end |
| 2779 | } |
| 2780 | |
| 2781 | // Center is calculated based on observations of tikz's circular sector and semi circle shapes. |
| 2782 | let center = if style.mode != "CLOSE" { |
| 2783 | // A circular sector's center anchor is placed half way between the sector-center and arc-center when the angle is 180deg. At 60deg it is placed 1/3 of the way between, this is mirrored at 300deg. |
| 2784 | vector.lerp( |
| 2785 | arc-center, |
| 2786 | sector-center, |
| 2787 | if (stop-angle + start-angle) > 180deg { (stop-angle + start-angle) } else { (stop-angle + start-angle) + 180deg } / 720deg |
| 2788 | ) |
| 2789 | } else { |
| 2790 | // A semi circle's center anchor is placed half way between the sector-center and arc-center, so that is always `center` when the arc is closed. Otherwise the point at which compass anchors are calculated from will be outside the lines. |
| 2791 | vector.lerp( |
| 2792 | arc-center, |
| 2793 | chord-center, |
| 2794 | 0.5 |
| 2795 | ) |
| 2796 | } |
| 2797 | |
| 2798 | let (transform, anchors) = anchor_.setup( |
| 2799 | anchor => ( |
| 2800 | arc-start: arc-start, |
| 2801 | origin: sector-center, |
| 2802 | arc-end: arc-end, |
| 2803 | arc-center: arc-center, |
| 2804 | chord-center: chord-center, |
| 2805 | center: center, |
| 2806 | ).at(anchor), |
| 2807 | ("arc-center", "chord-center", "origin", "arc-start", "arc-end", "center"), |
| 2808 | default: "arc-start", |
| 2809 | name: name, |
| 2810 | offset-anchor: anchor, |
| 2811 | transform: ctx.transform, |
| 2812 | border-anchors: true, |
| 2813 | path-anchors: true, |
| 2814 | radii: (rx, ry), // Don't multiply as its not from the arc's center |
| 2815 | path: drawables |
| 2816 | ) |
| 2817 | |
| 2818 | if mark_.check-mark(style.mark) { |
| 2819 | drawables = mark_.place-marks-along-path(ctx, style.mark, transform, drawables) |
| 2820 | } else { |
| 2821 | drawables = drawable.apply-transform(transform, drawables) |
| 2822 | } |
| 2823 | |
| 2824 | return ( |
| 2825 | ctx: ctx, |
| 2826 | name: name, |
| 2827 | anchors: anchors, |
| 2828 | drawables: drawables, |
| 2829 | ) |
| 2830 | },) |
| 2831 | } |
| 2832 | |
| 2833 | /// Draws an arc that passes through three points a, b and c. |
| 2834 | /// |
| 2835 | /// Note that all three points must not lie on a straight line, otherwise |
| 2836 | /// the function fails. |
| 2837 | /// |
| 2838 | /// ```typc example |
| 2839 | /// arc-through((0,1), (1,1), (1,0)) |
| 2840 | /// ``` |
| 2841 | /// |
| 2842 | /// - a (coordinate): Start position of the arc |
| 2843 | /// - b (coordinate): Position the arc passes through |
| 2844 | /// - c (coordinate): End position of the arc |
| 2845 | /// - name (none, str): |
| 2846 | /// - ..style (style): |
| 2847 | /// |
| 2848 | /// ### Styling |
| 2849 | /// *Root*: `arc` |
| 2850 | /// |
| 2851 | /// Uses the same styling as [arc](./arc#styling) |
| 2852 | /// |
| 2853 | /// ### Anchors |
| 2854 | /// For anchors see [arc](./arc#anchors). |
| 2855 | /// |
| 2856 | #let arc-through( |
| 2857 | a, |
| 2858 | b, |
| 2859 | c, |
| 2860 | name: none, |
| 2861 | ..style, |
| 2862 | ) = get-ctx(ctx => { |
| 2863 | let (ctx, a, b, c) = coordinate.resolve(ctx, a, b, c) |
| 2864 | assert(a.at(2) == b.at(2) and b.at(2) == c.at(2), |
| 2865 | message: "The z coordinate of all points must be equal, but is: " + repr((a, b, c).map(v => v.at(2)))) |
| 2866 | |
| 2867 | // Calculate the circle center from three points or fails if all |
| 2868 | // three points are on one straight line. |
| 2869 | let center = util.calculate-circle-center-3pt(a, b, c) |
| 2870 | let radius = vector.dist(center, a) |
| 2871 | |
| 2872 | // Find the start and inner angle between a-center-c |
| 2873 | let start = vector.angle2(center, a) |
| 2874 | let delta = vector.angle(a, center, c) |
| 2875 | |
| 2876 | // Returns a negative number if pt is left of the line a-b, |
| 2877 | // if pt is right to a-b, a positive number is returned, |
| 2878 | // otherwise zero. |
| 2879 | let side-on-line(a, b, pt) = { |
| 2880 | let (x1, y1, ..) = a |
| 2881 | let (x2, y2, ..) = b |
| 2882 | let (x, y, ..) = pt |
| 2883 | return (x - x1) * (y2 - y1) - (y - y1) * (x2 - x1) |
| 2884 | } |
| 2885 | |
| 2886 | // Center & b b is left, |
| 2887 | // are left center not |
| 2888 | // |
| 2889 | // +-b-+ +-b-+ |
| 2890 | // / \ / \ |
| 2891 | // | C | --a-------c-- |
| 2892 | // \ / \ C / |
| 2893 | // ---a---c--- +---+ |
| 2894 | // |
| 2895 | // If b and C are on the same side of a-c, the arcs radius is >= 180deg, |
| 2896 | // otherwise the radius is < 180deg. |
| 2897 | let center-is-left = side-on-line(a, c, center) < 0 |
| 2898 | let b-is-left = side-on-line(a, c, b) < 0 |
| 2899 | |
| 2900 | // If the center and point b are on the same side of a-c, |
| 2901 | // the arcs delta must be > 180deg. Note, that delta is |
| 2902 | // the inner angle between a-center-c, so we need to calculate |
| 2903 | // the outer angle by subtracting from 360deg. |
| 2904 | if center-is-left == b-is-left { |
| 2905 | delta = 360deg - delta |
| 2906 | } |
| 2907 | |
| 2908 | // If b is left of a-c, swap a-c to c-a by using a negative delta |
| 2909 | if b-is-left { |
| 2910 | delta *= -1 |
| 2911 | } |
| 2912 | |
| 2913 | return arc( |
| 2914 | a, |
| 2915 | start: start, |
| 2916 | delta: delta, |
| 2917 | radius: radius, |
| 2918 | anchor: "arc-start", |
| 2919 | name: name, |
| 2920 | ..style |
| 2921 | ) |
| 2922 | }) |
| 2923 | |
| 2924 | /// Draws a single mark pointing towards a target coordinate. |
| 2925 | /// |
| 2926 | /// ```typc example |
| 2927 | /// mark((0,0), (1,0), symbol: ">", fill: black) |
| 2928 | /// mark((0,0), (1,1), symbol: "stealth", scale: 3, fill: black) |
| 2929 | /// ``` |
| 2930 | /// |
| 2931 | /// Note: To place a mark centered at the first coodinate (`from`) use |
| 2932 | /// the marks `anchor: "center"` style. |
| 2933 | /// |
| 2934 | /// - from (coordinate): The position to place the mark. |
| 2935 | /// - to (coordinate,angle): The position or angle the mark should point towards. |
| 2936 | /// - ..style (style): |
| 2937 | /// |
| 2938 | /// ## Styling |
| 2939 | /// *Root*: `mark` |
| 2940 | /// |
| 2941 | /// You can directly use the styling from [Mark Styling](/docs/basics/marks). |
| 2942 | #let mark(from, to, ..style) = { |
| 2943 | assert.eq( |
| 2944 | style.pos(), |
| 2945 | (), |
| 2946 | message: "Unexpected positional arguments: " + repr(style.pos()), |
| 2947 | ) |
| 2948 | |
| 2949 | let style = style.named() |
| 2950 | |
| 2951 | if type(to) == angle { |
| 2952 | // Construct a coordinate pointing (+1, 0) away from |
| 2953 | // `from`, rotated by the angle given. |
| 2954 | to = ((rel: (to, 1), to: from)) |
| 2955 | } |
| 2956 | |
| 2957 | (from, to).map(coordinate.resolve-system) |
| 2958 | |
| 2959 | return (ctx => { |
| 2960 | let (ctx, ..pts) = coordinate.resolve(ctx, from, to) |
| 2961 | let style = styles.resolve(ctx.style, merge: style, root: "mark") |
| 2962 | |
| 2963 | if style.end == none { |
| 2964 | style.end = style.symbol |
| 2965 | } |
| 2966 | style.start = none |
| 2967 | style.symbol = none |
| 2968 | |
| 2969 | let (to, from) = (..pts) |
| 2970 | from = vector.sub(to, vector.sub(from, to)) |
| 2971 | |
| 2972 | let drawables = drawable.path((path-util.line-segment((from, to)),)) |
| 2973 | drawables = mark_.place-marks-along-path(ctx, style, none, drawables, add-path: false) |
| 2974 | return ( |
| 2975 | ctx: ctx, |
| 2976 | drawables: drawable.apply-transform(ctx.transform, drawables) |
| 2977 | ) |
| 2978 | },) |
| 2979 | } |
| 2980 | |
| 2981 | /// Draws a line, more than two points can be given to create a line-strip. |
| 2982 | /// |
| 2983 | /// ```typc example |
| 2984 | /// line((-1.5, 0), (1.5, 0)) |
| 2985 | /// line((0, -1.5), (0, 1.5)) |
| 2986 | /// line((-1, -1), (-0.5, 0.5), (0.5, 0.5), (1, -1), close: true) |
| 2987 | /// ``` |
| 2988 | /// |
| 2989 | /// If the first or last coordinates are given as the name of an element, |
| 2990 | /// that has a `"default"` anchor, the intersection of that element's border |
| 2991 | /// and a line from the first or last two coordinates given is used as coordinate. |
| 2992 | /// This is useful to span a line between the borders of two elements. |
| 2993 | /// |
| 2994 | /// ```typc example |
| 2995 | /// circle((1,2), radius: .5, name: "a") |
| 2996 | /// rect((2,1), (rel: (1,1)), name: "b") |
| 2997 | /// line("a", "b") |
| 2998 | /// ``` |
| 2999 | /// - ..pts-style (coordinate,style): Positional two or more coordinates to draw lines between. Accepts style key-value pairs. |
| 3000 | /// - close (bool): If true, the line-strip gets closed to form a polygon |
| 3001 | /// - name (none,str): |
| 3002 | /// |
| 3003 | /// ## Styling |
| 3004 | /// *Root:* `line` |
| 3005 | /// |
| 3006 | /// Supports mark styling. |
| 3007 | /// |
| 3008 | /// ## Anchors |
| 3009 | /// Supports path anchors. |
| 3010 | /// - **centroid**: The centroid anchor is calculated for _closed non self-intersecting_ polygons if all vertices share the same z value. |
| 3011 | #let line(..pts-style, close: false, name: none) = { |
| 3012 | // Extra positional arguments from the pts-style sink are interpreted as coordinates. |
| 3013 | let pts = pts-style.pos() |
| 3014 | let style = pts-style.named() |
| 3015 | |
| 3016 | assert(pts.len() >= 2, message: "Line must have a minimum of two points") |
| 3017 | |
| 3018 | // Coordinate check |
| 3019 | let pts-system = pts.map(coordinate.resolve-system) |
| 3020 | |
| 3021 | // Find the intersection between line a-b next to b |
| 3022 | // if no intersection could be found, return a. |
| 3023 | let element-line-intersection(ctx, elem, a, b) = { |
| 3024 | // Vectors a and b are not transformed yet, but the vectors of the |
| 3025 | // drawable are. |
| 3026 | let (ta, tb) = util.apply-transform(ctx.transform, a, b) |
| 3027 | |
| 3028 | let pts = () |
| 3029 | for drawable in elem.at("drawables", default: ()).filter(d => d.type == "path") { |
| 3030 | pts += intersection.line-path(ta, tb, drawable) |
| 3031 | } |
| 3032 | return if pts == () { |
| 3033 | a |
| 3034 | } else { |
| 3035 | // Find the nearest point |
| 3036 | let pt = util.sort-points-by-distance(tb, pts).first() |
| 3037 | |
| 3038 | // Reverse the transformation |
| 3039 | return util.revert-transform(ctx.transform, pt) |
| 3040 | } |
| 3041 | } |
| 3042 | |
| 3043 | return (ctx => { |
| 3044 | let first-elem = pts.first() |
| 3045 | let last-elem = pts.last() |
| 3046 | let (ctx, ..pts) = coordinate.resolve(ctx, ..pts) |
| 3047 | |
| 3048 | // If the first/last element, test for intersection |
| 3049 | // of that element and a line from the two first/last coordinates of this |
| 3050 | // line strip. |
| 3051 | if pts-system.first() == "element" { |
| 3052 | let elem = ctx.nodes.at(first-elem) |
| 3053 | pts.first() = element-line-intersection(ctx, elem, ..pts.slice(0, 2)) |
| 3054 | } |
| 3055 | if pts-system.last() == "element" { |
| 3056 | let elem = ctx.nodes.at(last-elem) |
| 3057 | pts.last() = element-line-intersection(ctx, elem, ..pts.slice(-2).rev()) |
| 3058 | } |
| 3059 | |
| 3060 | let style = styles.resolve(ctx.style, merge: style, root: "line") |
| 3061 | |
| 3062 | let drawables = drawable.path( |
| 3063 | (path-util.line-segment(pts),), |
| 3064 | fill: style.fill, |
| 3065 | fill-rule: style.fill-rule, |
| 3066 | stroke: style.stroke, |
| 3067 | close: close |
| 3068 | ) |
| 3069 | |
| 3070 | // Get bounds |
| 3071 | let (transform, anchors) = anchor_.setup( |
| 3072 | name => { |
| 3073 | if name == "centroid" { |
| 3074 | return polygon_.simple-centroid(pts) |
| 3075 | } |
| 3076 | }, |
| 3077 | if close != none { ("centroid",) } else { () }, |
| 3078 | default: if close != none { "centroid" }, |
| 3079 | name: name, |
| 3080 | transform: ctx.transform, |
| 3081 | path-anchors: true, |
| 3082 | path: drawables |
| 3083 | ) |
| 3084 | |
| 3085 | // Place marks and adjust segments |
| 3086 | if mark_.check-mark(style.mark) { |
| 3087 | drawables = mark_.place-marks-along-path(ctx, style.mark, transform, drawables) |
| 3088 | } else { |
| 3089 | drawables = drawable.apply-transform(transform, drawables) |
| 3090 | } |
| 3091 | |
| 3092 | return ( |
| 3093 | ctx: ctx, |
| 3094 | name: name, |
| 3095 | anchors: anchors, |
| 3096 | drawables: drawables, |
| 3097 | ) |
| 3098 | },) |
| 3099 | } |
| 3100 | |
| 3101 | /// Draws a regular polygon. |
| 3102 | /// |
| 3103 | /// ```typc example |
| 3104 | /// polygon((0,0), 3, angle: 90deg) |
| 3105 | /// polygon((2,0), 5) |
| 3106 | /// polygon((4,0), 7) |
| 3107 | /// ``` |
| 3108 | /// |
| 3109 | /// - origin (coordinate): Coordinate to draw the polygon at |
| 3110 | /// - sides (int): Number of sides of the polygon (>= 3) |
| 3111 | /// - angle (angle) = 0deg: Angle angle to rotate the polygon arround its origin |
| 3112 | /// - name (none, str): |
| 3113 | /// |
| 3114 | /// ## Styling |
| 3115 | /// *Root*: `polygon` |
| 3116 | /// - radius (number) = 1: Radius of the polygon |
| 3117 | #let polygon(origin, sides, angle: 0deg, name: none, anchor: none, ..style) = { |
| 3118 | coordinate.resolve-system(origin) |
| 3119 | |
| 3120 | assert(type(sides) == int and sides >= 3, |
| 3121 | message: "Invalid number of sides: " + repr(sides)) |
| 3122 | |
| 3123 | let style = style.named() |
| 3124 | return (ctx => { |
| 3125 | let anchors = () |
| 3126 | |
| 3127 | let style = styles.resolve(ctx.style, merge: style, root: "polygon") |
| 3128 | |
| 3129 | let (ctx, origin) = coordinate.resolve(ctx, origin) |
| 3130 | let (rx, ry) = util.resolve-radius(style.radius) |
| 3131 | |
| 3132 | let points = range(0, sides).map(i => { |
| 3133 | let alpha = angle + 360deg / sides * i |
| 3134 | vector.add(origin, (calc.cos(alpha) * rx, calc.sin(alpha) * ry, 0)) |
| 3135 | }) |
| 3136 | |
| 3137 | let drawables = drawable.path( |
| 3138 | (path-util.line-segment(points),), |
| 3139 | fill: style.fill, |
| 3140 | stroke: style.stroke, |
| 3141 | close: true) |
| 3142 | |
| 3143 | let edge-anchors = range(0, sides).map(i => "edge-" + str(i)) |
| 3144 | let corner-anchors = range(0, sides).map(i => "corner-" + str(i)) |
| 3145 | |
| 3146 | let (transform, anchors) = anchor_.setup( |
| 3147 | name => { |
| 3148 | return if name == "center" or name == "default" { |
| 3149 | origin |
| 3150 | } else if name in edge-anchors { |
| 3151 | let idx = edge-anchors.position(item => item == name) |
| 3152 | vector.lerp( |
| 3153 | points.at(idx), |
| 3154 | points.at(idx + 1, default: points.first()), |
| 3155 | .5) |
| 3156 | } else if name in corner-anchors { |
| 3157 | let idx = corner-anchors.position(item => item == name) |
| 3158 | points.at(idx) |
| 3159 | } |
| 3160 | }, |
| 3161 | ("center",) + edge-anchors + corner-anchors, |
| 3162 | name: name, |
| 3163 | transform: ctx.transform, |
| 3164 | path-anchors: false, |
| 3165 | border-anchors: true, |
| 3166 | radii: (rx*2, ry*2), |
| 3167 | path: drawables, |
| 3168 | offset-anchor: anchor, |
| 3169 | default: "center", |
| 3170 | ) |
| 3171 | |
| 3172 | return ( |
| 3173 | ctx: ctx, |
| 3174 | name: name, |
| 3175 | anchors: anchors, |
| 3176 | drawables: drawable.apply-transform(transform, drawables), |
| 3177 | ) |
| 3178 | },) |
| 3179 | } |
| 3180 | |
| 3181 | /// Draws a grid between two coordinates |
| 3182 | /// |
| 3183 | /// ```typc example |
| 3184 | /// // Draw a grid |
| 3185 | /// grid((0,0), (2,2)) |
| 3186 | /// |
| 3187 | /// // Draw a smaller blue grid |
| 3188 | /// grid((1,1), (2,2), stroke: blue, step: .25) |
| 3189 | /// ``` |
| 3190 | /// |
| 3191 | /// - from (coordinate): The top left of the grid |
| 3192 | /// - to (coordinate): The bottom right of the grid |
| 3193 | /// - name (none,str): |
| 3194 | /// - ..style (style): |
| 3195 | /// |
| 3196 | /// ## Styling |
| 3197 | /// *Root*: `grid` |
| 3198 | /// - step (number, array, dictionary) = 1: Distance between grid lines. A distance of $1$ means to draw a grid line every $1$ length units in x- and y-direction. If given a dictionary with `x` and `y` keys or a tuple, the step is set per axis. |
| 3199 | /// - help-lines (bool) = false: If true, force the stroke style to `gray + 0.2pt` |
| 3200 | /// |
| 3201 | /// ## Anchors |
| 3202 | /// Supports border anchors. |
| 3203 | #let grid(from, to, name: none, ..style) = { |
| 3204 | (from, to).map(coordinate.resolve-system) |
| 3205 | |
| 3206 | assert.eq(style.pos(), (), message: "Unexpected positional arguments: " + repr(style.pos())) |
| 3207 | style = style.named() |
| 3208 | |
| 3209 | return (ctx => { |
| 3210 | let (ctx, from, to) = coordinate.resolve(ctx, from, to) |
| 3211 | |
| 3212 | (from, to) = { |
| 3213 | let pairs = ((from.at(0), to.at(0)), (from.at(1), to.at(1)), (from.at(2), from.at(2))) |
| 3214 | ( |
| 3215 | pairs.map(e => calc.min(..e)), |
| 3216 | pairs.map(e => calc.max(..e)) |
| 3217 | ) |
| 3218 | } |
| 3219 | |
| 3220 | let style = styles.resolve(ctx.style, merge: style, root: "grid", base: ( |
| 3221 | step: 1, |
| 3222 | stroke: auto, |
| 3223 | help-lines: false, |
| 3224 | )) |
| 3225 | if style.help-lines { |
| 3226 | style.stroke = 0.2pt + gray |
| 3227 | } |
| 3228 | |
| 3229 | let (x-step, y-step) = if type(style.step) == dictionary { |
| 3230 | (style.step.at("x", default: 1), style.step.at("y", default: 1)) |
| 3231 | } else if type(style.step) == array { |
| 3232 | style.step |
| 3233 | } else { |
| 3234 | (style.step, style.step) |
| 3235 | }.map(util.resolve-number.with(ctx)) |
| 3236 | |
| 3237 | let drawables = { |
| 3238 | if x-step != 0 { |
| 3239 | range(int((to.at(0) - from.at(0)) / x-step)+1).map(x => { |
| 3240 | x *= x-step |
| 3241 | x += from.at(0) |
| 3242 | drawable.path( |
| 3243 | path-util.line-segment(((x, from.at(1)), (x, to.at(1)))), |
| 3244 | stroke: style.stroke |
| 3245 | ) |
| 3246 | }) |
| 3247 | } else { |
| 3248 | () |
| 3249 | } |
| 3250 | if y-step != 0 { |
| 3251 | range(int((to.at(1) - from.at(1)) / y-step)+1).map(y => { |
| 3252 | y *= y-step |
| 3253 | y += from.at(1) |
| 3254 | drawable.path( |
| 3255 | path-util.line-segment(((from.at(0), y), (to.at(0), y))), |
| 3256 | stroke: style.stroke |
| 3257 | ) |
| 3258 | }) |
| 3259 | } else { |
| 3260 | () |
| 3261 | } |
| 3262 | } |
| 3263 | |
| 3264 | let center = vector.lerp(from, to, .5) |
| 3265 | let (transform, anchors) = anchor_.setup( |
| 3266 | _ => center, |
| 3267 | ("center",), |
| 3268 | name: name, |
| 3269 | transform: ctx.transform, |
| 3270 | border-anchors: true, |
| 3271 | radii: (vector.dist(center, from) * 2,) * 2, |
| 3272 | path: drawable.path( |
| 3273 | path-util.line-segment(( |
| 3274 | from, |
| 3275 | (from.first(), to.at(1), 0), |
| 3276 | to, |
| 3277 | (to.first(), from.at(1), 0) |
| 3278 | )), |
| 3279 | close: true |
| 3280 | ) |
| 3281 | ) |
| 3282 | |
| 3283 | return ( |
| 3284 | ctx: ctx, |
| 3285 | name: name, |
| 3286 | anchors: anchors, |
| 3287 | drawables: drawable.apply-transform( |
| 3288 | transform, |
| 3289 | drawables |
| 3290 | ) |
| 3291 | ) |
| 3292 | },) |
| 3293 | } |
| 3294 | |
| 3295 | /// Positions Typst content in the canvas. Note that the content itself is not transformed only its position is. |
| 3296 | /// |
| 3297 | /// ```typc example |
| 3298 | /// content((0,0), [Hello World!]) |
| 3299 | /// ``` |
| 3300 | /// To put text on a line you can let the function calculate the angle between its position and a second coordinate by passing it to `angle`: |
| 3301 | /// |
| 3302 | /// ```typc example |
| 3303 | /// line((0, 0), (3, 1), name: "line") |
| 3304 | /// content( |
| 3305 | /// ("line.start", 50%, "line.end"), |
| 3306 | /// angle: "line.end", |
| 3307 | /// padding: .1, |
| 3308 | /// anchor: "south", |
| 3309 | /// [Text on a line] |
| 3310 | /// ) |
| 3311 | /// ``` |
| 3312 | /// |
| 3313 | /// ```typc example |
| 3314 | /// // Place content in a rect between two coordinates |
| 3315 | /// content( |
| 3316 | /// (0, 0), |
| 3317 | /// (2, 2), |
| 3318 | /// box( |
| 3319 | /// par(justify: false)[This is a long text.], |
| 3320 | /// stroke: 1pt, |
| 3321 | /// width: 100%, |
| 3322 | /// height: 100%, |
| 3323 | /// inset: 1em |
| 3324 | /// ) |
| 3325 | /// ) |
| 3326 | /// ``` |
| 3327 | /// |
| 3328 | /// - ..args-style (coordinate, content, style): When one coordinate is given as a positional argument, the content will be placed at that position. When two coordinates are given as positional arguments, the content will be placed inside a rectangle between the two positions. All named arguments are styling and any additional positional arguments will panic. |
| 3329 | /// - angle (angle,coordinate): Rotates the content by the given angle. A coordinate can be given to rotate the content by the angle between it and the first coordinate given in `args`. This effectively points the right hand side of the content towards the coordinate. This currently exists because Typst's rotate function does not change the width and height of content. |
| 3330 | /// - anchor (none, str): |
| 3331 | /// - name (none, str): |
| 3332 | /// |
| 3333 | /// ## Styling |
| 3334 | /// *Root*: `content` |
| 3335 | /// - padding (number, dictionary) = 0: Sets the spacing around content. Can be a single number to set padding on all sides or a dictionary to specify each side specifically. The dictionary follows Typst's `pad` function: https://typst.app/docs/reference/layout/pad/ |
| 3336 | /// - frame (str, none) = none: Sets the frame style. Can be {{none}}, `"rect"` or `"circle"` and inherits the `stroke` and `fill` style. |
| 3337 | /// - auto-scale (bool): If `true`, apply current canvas scaling to the content. Defaults to `false`. |
| 3338 | /// |
| 3339 | /// ## Anchors |
| 3340 | /// Supports border anchors, the default anchor is set to **center**. |
| 3341 | /// - **mid**: Content center, from baseline to top bounds |
| 3342 | /// - **mid-east**: Content center extended to the east |
| 3343 | /// - **mid-west**: Content center extended to the west |
| 3344 | /// - **base**: Horizontally centered baseline of the content |
| 3345 | /// - **base-east**: Baseline height extended to the east |
| 3346 | /// - **base-west**: Baseline height extended to the west |
| 3347 | /// - **text**: Position at the content start on the baseline of the content |
| 3348 | #let content( |
| 3349 | ..args-style, |
| 3350 | angle: 0deg, |
| 3351 | anchor: none, |
| 3352 | name: none, |
| 3353 | ) = { |
| 3354 | let (args, style) = (args-style.pos(), args-style.named()) |
| 3355 | |
| 3356 | let (a, b, body) = if args.len() == 2 { |
| 3357 | args.insert(1, auto) |
| 3358 | args |
| 3359 | } else if args.len() == 3 { |
| 3360 | args |
| 3361 | } else { |
| 3362 | panic("Expected 2 or 3 positional arguments, got " + str(args.len())) |
| 3363 | } |
| 3364 | |
| 3365 | coordinate.resolve-system(a) |
| 3366 | |
| 3367 | if b != auto { |
| 3368 | coordinate.resolve-system(b) |
| 3369 | } |
| 3370 | |
| 3371 | if type(angle) != typst-angle { |
| 3372 | coordinate.resolve-system(angle) |
| 3373 | } |
| 3374 | |
| 3375 | return (ctx => { |
| 3376 | let body = body |
| 3377 | let style = styles.resolve(ctx.style, merge: style, root: "content") |
| 3378 | let padding = util.as-padding-dict(style.padding) |
| 3379 | for (k, v) in padding { |
| 3380 | padding.insert(k, util.resolve-number(ctx, v)) |
| 3381 | } |
| 3382 | |
| 3383 | let (ctx, a) = coordinate.resolve(ctx, a) |
| 3384 | let b = b |
| 3385 | let auto-size = b == auto |
| 3386 | if not auto-size { |
| 3387 | (ctx, b) = coordinate.resolve(ctx, b) |
| 3388 | } |
| 3389 | |
| 3390 | let angle = if type(angle) != typst-angle { |
| 3391 | let c |
| 3392 | (ctx, c) = coordinate.resolve(ctx, angle) |
| 3393 | vector.angle2(a, c) |
| 3394 | } else { |
| 3395 | angle |
| 3396 | } |
| 3397 | |
| 3398 | // Typst's `rotate` function is clockwise relative to x-axis, which is backwards from us |
| 3399 | angle = angle * -1 |
| 3400 | |
| 3401 | // Optionally scale content with current canvas scaling |
| 3402 | if style.auto-scale == true { |
| 3403 | let sx = vector.len(matrix.column(ctx.transform, 0)) |
| 3404 | let sy = vector.len(matrix.column(ctx.transform, 1)) |
| 3405 | |
| 3406 | body = std.scale(x: sx * 100%, y: sy * 100%, body, reflow: true) |
| 3407 | } |
| 3408 | |
| 3409 | // Height from the baseline to content-north |
| 3410 | let (content-width, baseline-height) = util.measure(ctx, text(top-edge: "cap-height", bottom-edge: "baseline", body)) |
| 3411 | |
| 3412 | // Size of the bounding box |
| 3413 | let (width, height, ..) = if auto-size { |
| 3414 | util.measure(ctx, text(top-edge: "cap-height", bottom-edge: "bounds", body)) |
| 3415 | } else { |
| 3416 | vector.sub(b, a) |
| 3417 | } |
| 3418 | |
| 3419 | let bounds-width = calc.abs(width) |
| 3420 | let bounds-height = calc.abs(height) |
| 3421 | baseline-height = bounds-height - baseline-height |
| 3422 | |
| 3423 | width = calc.max(0, bounds-width + padding.left + padding.right) |
| 3424 | height = calc.max(0, bounds-height + padding.top + padding.bottom) |
| 3425 | |
| 3426 | let anchors = { |
| 3427 | let w = width / 2 |
| 3428 | let h = height / 2 |
| 3429 | let bh = (baseline-height - padding.top - padding.bottom) / 2 |
| 3430 | |
| 3431 | let bounds-center = if auto-size { |
| 3432 | a |
| 3433 | } else { |
| 3434 | vector.lerp(a, b, .5) |
| 3435 | } |
| 3436 | |
| 3437 | // Only the center anchor gets transformed. All other anchors |
| 3438 | // must be calculated relative to the transformed center! |
| 3439 | bounds-center = matrix.mul4x4-vec3(ctx.transform, |
| 3440 | vector.as-vec(bounds-center, init: (0,0,0))) |
| 3441 | |
| 3442 | let east-dir = vector.rotate-z((1, 0, 0), angle) |
| 3443 | let north-dir = vector.rotate-z((-1, 0, 0), angle + 90deg) |
| 3444 | let east-scaled = vector.scale(east-dir, +w) |
| 3445 | let west-scaled = vector.scale(east-dir, -w) |
| 3446 | let north-scaled = vector.scale(north-dir, +h) |
| 3447 | let south-scaled = vector.scale(north-dir, -h) |
| 3448 | |
| 3449 | let north = vector.add(bounds-center, north-scaled) |
| 3450 | let south = vector.add(bounds-center, south-scaled) |
| 3451 | let east = vector.add(bounds-center, east-scaled) |
| 3452 | let west = vector.add(bounds-center, west-scaled) |
| 3453 | let north-east = vector.add(bounds-center, vector.add(north-scaled, east-scaled)) |
| 3454 | let north-west = vector.sub(bounds-center, vector.add(south-scaled, east-scaled)) |
| 3455 | let south-east = vector.add(bounds-center, vector.add(south-scaled, east-scaled)) |
| 3456 | let south-west = vector.sub(bounds-center, vector.add(north-scaled, east-scaled)) |
| 3457 | |
| 3458 | let base = vector.add(south, |
| 3459 | vector.scale(north-dir, padding.bottom + baseline-height)) |
| 3460 | let mid = vector.lerp( |
| 3461 | vector.sub(north, vector.scale(north-dir, padding.top)), |
| 3462 | base, |
| 3463 | 0.5) |
| 3464 | let base-east = vector.add(base, east-scaled) |
| 3465 | let base-west = vector.add(base, west-scaled) |
| 3466 | let text = vector.add(base, vector.scale(east-dir, -content-width / 2)) |
| 3467 | let mid-east = vector.add(mid, east-scaled) |
| 3468 | let mid-west = vector.add(mid, west-scaled) |
| 3469 | |
| 3470 | ( |
| 3471 | center: bounds-center, |
| 3472 | mid: mid, |
| 3473 | mid-east: mid-east, |
| 3474 | mid-west: mid-west, |
| 3475 | base: base, |
| 3476 | base-east: base-east, |
| 3477 | base-west: base-west, |
| 3478 | text: text, |
| 3479 | north: north, |
| 3480 | north-east: north-east, |
| 3481 | north-west: north-west, |
| 3482 | south: south, |
| 3483 | south-east: south-east, |
| 3484 | south-west: south-west, |
| 3485 | east: east, |
| 3486 | west: west, |
| 3487 | ) |
| 3488 | } |
| 3489 | |
| 3490 | let frame-stroke = if style.frame != none { |
| 3491 | style.stroke |
| 3492 | } |
| 3493 | let frame-fill = if style.frame != none { |
| 3494 | style.fill |
| 3495 | } |
| 3496 | let frame-shape = if style.frame in (none, "rect") { |
| 3497 | drawable.path( |
| 3498 | path-util.line-segment(( |
| 3499 | anchors.north-west, |
| 3500 | anchors.north-east, |
| 3501 | anchors.south-east, |
| 3502 | anchors.south-west |
| 3503 | )), |
| 3504 | close: true, |
| 3505 | stroke: frame-stroke, |
| 3506 | fill: frame-fill,) |
| 3507 | } else if style.frame == "circle" { |
| 3508 | let (x, y, z) = util.calculate-circle-center-3pt(anchors.north-west, anchors.south-west, anchors.south-east) |
| 3509 | let r = vector.dist((x, y, z), anchors.north-west) |
| 3510 | drawable.ellipse( |
| 3511 | x, y, z, |
| 3512 | r, r, |
| 3513 | stroke: frame-stroke, |
| 3514 | fill: frame-fill,) |
| 3515 | } |
| 3516 | |
| 3517 | let (aabb-width, aabb-height, ..) = aabb.size(aabb.aabb( |
| 3518 | (anchors.north-west, anchors.north-east, |
| 3519 | anchors.south-west, anchors.south-east))) |
| 3520 | |
| 3521 | let drawables = () |
| 3522 | if frame-shape != none { |
| 3523 | drawables.push(frame-shape) |
| 3524 | } |
| 3525 | |
| 3526 | // Because of precision problems with some fonts (e.g. "Source Sans 3") |
| 3527 | // we need to round the block sizes up. Otherwise, unwanted hyphenation |
| 3528 | // gets introduced. |
| 3529 | let round-up(v, digits: 8) = { |
| 3530 | calc.ceil(v * calc.pow(10, digits)) / calc.pow(10, digits) |
| 3531 | } |
| 3532 | |
| 3533 | drawables.push( |
| 3534 | drawable.content( |
| 3535 | anchors.center, |
| 3536 | aabb-width, |
| 3537 | aabb-height, |
| 3538 | frame-shape.segments, |
| 3539 | typst-rotate(angle, |
| 3540 | reflow: true, |
| 3541 | origin: center + horizon, |
| 3542 | block( |
| 3543 | width: round-up(width) * ctx.length, |
| 3544 | height: round-up(height) * ctx.length, |
| 3545 | inset: ( |
| 3546 | top: padding.at("top", default: 0) * ctx.length, |
| 3547 | left: padding.at("left", default: 0) * ctx.length, |
| 3548 | bottom: padding.at("bottom", default: 0) * ctx.length, |
| 3549 | right: padding.at("right", default: 0) * ctx.length, |
| 3550 | ), |
| 3551 | text(top-edge: "cap-height", bottom-edge: "baseline", body) |
| 3552 | ) |
| 3553 | ) |
| 3554 | ) |
| 3555 | ) |
| 3556 | |
| 3557 | let (transform, anchors) = anchor_.setup( |
| 3558 | anchor => { |
| 3559 | if type(anchor) == str { |
| 3560 | anchors.at(anchor) |
| 3561 | } |
| 3562 | }, |
| 3563 | anchors.keys(), |
| 3564 | default: if auto-size { "center" } else { "north-west" }, |
| 3565 | offset-anchor: anchor, |
| 3566 | transform: none, // Content does not get transformed, see the calculation of anchors. |
| 3567 | name: name, |
| 3568 | ) |
| 3569 | |
| 3570 | return ( |
| 3571 | ctx: ctx, |
| 3572 | name: name, |
| 3573 | anchors: anchors, |
| 3574 | drawables: drawable.apply-transform( |
| 3575 | transform, |
| 3576 | drawables |
| 3577 | ) |
| 3578 | ) |
| 3579 | },) |
| 3580 | } |
| 3581 | |
| 3582 | /// Draws a rectangle between two coordinates. |
| 3583 | /// ```typc example |
| 3584 | /// rect((0,0), (1,1)) |
| 3585 | /// rect( |
| 3586 | /// (-.5, -.5), |
| 3587 | /// (rel: (2, 2)), |
| 3588 | /// radius: ( |
| 3589 | /// north-east: (100%, .5), |
| 3590 | /// south-west: (100%, .5), |
| 3591 | /// rest: .2 |
| 3592 | /// ), |
| 3593 | /// stroke: red |
| 3594 | /// ) |
| 3595 | /// rect((-1, -1), (rel: (3, 3)), radius: .5, stroke: blue) |
| 3596 | /// ``` |
| 3597 | /// |
| 3598 | /// - a (coordinate): Coordinate of the bottom left corner of the rectangle. |
| 3599 | /// - b (coordinate): Coordinate of the top right corner of the rectangle. You can draw a rectangle with a specified width and height by using relative coordinates for this parameter `(rel: (width, height))`. |
| 3600 | /// - name (none,str): |
| 3601 | /// - anchor (none, str): |
| 3602 | /// - ..style (style): |
| 3603 | /// |
| 3604 | /// ## Styling |
| 3605 | /// *Root*: `rect` |
| 3606 | /// <Parameter name="radius" types="number,ratio,dictionary" default_value="0"> |
| 3607 | /// The rectangle's corner radius. If set to a single number, that radius is applied to all four corners of the rectangle. If passed a dictionary you can set the radii per corner. The following keys support either a <Type>number</Type>, <Type>ratio</Type> or an array of <Type>number</Type> or <Type>ratio</Type> for specifying a different x- and y-radius: `north`, `east`, `south`, `west`, `north-west`, `north-east`, `south-west` and `south-east`. To set a default value for remaining corners, the `rest` key can be used. |
| 3608 | /// |
| 3609 | /// Ratio values are relative to the rectangle's width and height. |
| 3610 | /// |
| 3611 | /// ```typc example vertical |
| 3612 | /// rect((0,0), (rel: (1,1)), radius: 0) |
| 3613 | /// rect((2,0), (rel: (1,1)), radius: 25%) |
| 3614 | /// rect((4,0), (rel: (1,1)), radius: (north: 50%)) |
| 3615 | /// rect((6,0), (rel: (1,1)), radius: (north-east: 50%)) |
| 3616 | /// rect((8,0), (rel: (1,1)), radius: (south-west: 0, rest: 50%)) |
| 3617 | /// rect((10,0), (rel: (1,1)), radius: (rest: (20%, 50%))) |
| 3618 | /// ``` |
| 3619 | /// </Parameter> |
| 3620 | /// |
| 3621 | /// ## Anchors |
| 3622 | /// Supports border and path anchors. It's default is the `"center"` anchor. |
| 3623 | /// |
| 3624 | #let rect(a, b, name: none, anchor: none, ..style) = { |
| 3625 | // Coordinate check |
| 3626 | let t = (a, b).map(coordinate.resolve-system) |
| 3627 | |
| 3628 | // No extra positional arguments from the style sink |
| 3629 | assert.eq( |
| 3630 | style.pos(), |
| 3631 | (), |
| 3632 | message: "Unexpected positional arguments: " + repr(style.pos()), |
| 3633 | ) |
| 3634 | let style = style.named() |
| 3635 | |
| 3636 | return ( |
| 3637 | ctx => { |
| 3638 | let ctx = ctx |
| 3639 | let (ctx, a, b) = coordinate.resolve(ctx, a, b) |
| 3640 | assert(a.at(2) == b.at(2), |
| 3641 | message: "Both rectangle points must have the same z value.") |
| 3642 | (a, b) = { |
| 3643 | let lo = ( |
| 3644 | calc.min(a.at(0), b.at(0)), |
| 3645 | calc.min(a.at(1), b.at(1)), |
| 3646 | calc.min(a.at(2), b.at(2)), |
| 3647 | ) |
| 3648 | let hi = ( |
| 3649 | calc.max(a.at(0), b.at(0)), |
| 3650 | calc.max(a.at(1), b.at(1)), |
| 3651 | calc.max(a.at(2), b.at(2)), |
| 3652 | ) |
| 3653 | (lo, hi) |
| 3654 | } |
| 3655 | |
| 3656 | let style = styles.resolve(ctx.style, merge: style, root: "rect") |
| 3657 | let (x1, y1, z1) = a |
| 3658 | let (x2, y2, z2) = b |
| 3659 | |
| 3660 | let size = (calc.abs(x2 - x1), calc.abs(y2 - y1)) |
| 3661 | let (north-west: nw, north-east: ne, |
| 3662 | south-west: sw, south-east: se) = util.as-corner-radius-dict(ctx, style.radius, size) |
| 3663 | |
| 3664 | let drawables = { |
| 3665 | let z = z1 |
| 3666 | |
| 3667 | // Compute two corner points offset by radius from origin pt. |
| 3668 | // |
| 3669 | // x radius * a |
| 3670 | // |----| |
| 3671 | // --p1←--pt --- |
| 3672 | // | | y radius * b |
| 3673 | // ↓ | |
| 3674 | // p2 --- |
| 3675 | // | |
| 3676 | // |
| 3677 | // parameters a and b function as direction vectors in which |
| 3678 | // direction the resulting points p1 and p2 should get offset to. |
| 3679 | // |
| 3680 | // The point pt is the corner point of the non-rounded rectangle. |
| 3681 | // If the radius is zero, we can just return that point for both |
| 3682 | // new corners. |
| 3683 | let get-corner-pts(radius, pt, a, b) = { |
| 3684 | let (rx, ry) = radius |
| 3685 | if rx > 0 or ry > 0 { |
| 3686 | let (xa, ya) = a |
| 3687 | let (xb, yb) = b |
| 3688 | (vector.add(pt, (xa * rx, ya * ry)), |
| 3689 | vector.add(pt, (xb * rx, yb * ry))) |
| 3690 | } else { |
| 3691 | (pt, pt) |
| 3692 | } |
| 3693 | } |
| 3694 | |
| 3695 | // Get segments for arc between start- and stop angle, starting |
| 3696 | // at point. If radius is zero for both axes, x and y, nothing |
| 3697 | // gets returned. |
| 3698 | // |
| 3699 | // s----p0/ |
| 3700 | // p1 |
| 3701 | // | |
| 3702 | // e |
| 3703 | // |
| 3704 | // Returns a cubic bezier curve between s and e |
| 3705 | // with the control points pointing from s in direction |
| 3706 | // p0 * radius and from e in direction p1 * radius. |
| 3707 | // The bezier approximates a 90 degree arc. |
| 3708 | let corner-arc(radius, s, e, p0, p1) = { |
| 3709 | let (rx, ry) = radius |
| 3710 | if rx > 0 or ry > 0 { |
| 3711 | let m = 0.551784 |
| 3712 | let p0 = (p0.at(0) * m * rx, |
| 3713 | p0.at(1) * m * ry) |
| 3714 | let p1 = (p1.at(0) * m * rx, |
| 3715 | p1.at(1) * m * ry) |
| 3716 | (path-util.cubic-segment(s, e, |
| 3717 | vector.add(s, p0), |
| 3718 | vector.add(e, p1)),) |
| 3719 | } |
| 3720 | } |
| 3721 | |
| 3722 | // Compute all eight corner points: |
| 3723 | // |
| 3724 | // p1-------p2 |
| 3725 | // / | | \ |
| 3726 | // p0--+ +--p3 |
| 3727 | // | | |
| 3728 | // p7--+ +--p4 |
| 3729 | // \ | | / |
| 3730 | // p6-------p5 |
| 3731 | // |
| 3732 | // If a corner has radius (0,0), both of its |
| 3733 | // corner points are the same. See the comment on get-corner-pts |
| 3734 | // on how the corners get computed. |
| 3735 | let (p0, p1) = get-corner-pts(nw, (x1, y2, z), ( 0,-1), ( 1, 0)) |
| 3736 | let (p2, p3) = get-corner-pts(ne, (x2, y2, z), (-1, 0), ( 0,-1)) |
| 3737 | let (p4, p5) = get-corner-pts(se, (x2, y1, z), ( 0, 1), (-1, 0)) |
| 3738 | let (p6, p7) = get-corner-pts(sw, (x1, y1, z), ( 1, 0), ( 0, 1)) |
| 3739 | |
| 3740 | let segments = () |
| 3741 | segments += corner-arc(nw, p1, p0, (-1,0), (0, 1)) |
| 3742 | if p0 != p7 { segments += (path-util.line-segment((p0, p7)),) } |
| 3743 | segments += corner-arc(sw, p7, p6, (0,-1), (-1,0)) |
| 3744 | if p6 != p5 { segments += (path-util.line-segment((p6, p5)),) } |
| 3745 | segments += corner-arc(se, p5, p4, (1, 0), (0,-1)) |
| 3746 | if p4 != p3 { segments += (path-util.line-segment((p4, p3)),) } |
| 3747 | segments += corner-arc(ne, p3, p2, (0, 1), (1, 0)) |
| 3748 | if p2 != p1 { segments += (path-util.line-segment((p2, p1)),) } |
| 3749 | |
| 3750 | drawable.path(segments, fill: style.fill, stroke: style.stroke, close: true) |
| 3751 | } |
| 3752 | |
| 3753 | // Calculate border anchors |
| 3754 | let center = vector.lerp(a, b, .5) |
| 3755 | let (width, height, ..) = size |
| 3756 | let (transform, anchors) = anchor_.setup( |
| 3757 | _ => center, |
| 3758 | ("center",), |
| 3759 | default: "center", |
| 3760 | name: name, |
| 3761 | offset-anchor: anchor, |
| 3762 | transform: ctx.transform, |
| 3763 | border-anchors: true, |
| 3764 | path-anchors: true, |
| 3765 | radii: (width, height), |
| 3766 | path: drawables, |
| 3767 | ) |
| 3768 | |
| 3769 | return ( |
| 3770 | ctx: ctx, |
| 3771 | name: name, |
| 3772 | anchors: anchors, |
| 3773 | drawables: drawable.apply-transform(transform, drawables), |
| 3774 | ) |
| 3775 | }, |
| 3776 | ) |
| 3777 | } |
| 3778 | |
| 3779 | /// Draws a quadratic or cubic bezier curve |
| 3780 | /// |
| 3781 | /// ```typc example |
| 3782 | /// let (a, b, c) = ((0, 0), (2, 0), (1, 1)) |
| 3783 | /// line(a, c, b, stroke: gray) |
| 3784 | /// bezier(a, b, c) |
| 3785 | /// |
| 3786 | /// let (a, b, c, d) = ((0, -1), (2, -1), (.5, -2), (1.5, 0)) |
| 3787 | /// line(a, c, d, b, stroke: gray) |
| 3788 | /// bezier(a, b, c, d) |
| 3789 | /// ``` |
| 3790 | /// |
| 3791 | /// - start (coordinate): Start position |
| 3792 | /// - end (coordinate): End position (last coordinate) |
| 3793 | /// - name (none,str): |
| 3794 | /// - ..ctrl-style (coordinate,style): The first two positional arguments are taken as cubic bezier control points, where the first is the start control point and the second is the end control point. One control point can be given for a quadratic bezier curve instead. Named arguments are for styling. |
| 3795 | /// |
| 3796 | /// ## Styling |
| 3797 | /// *Root* `bezier` |
| 3798 | /// |
| 3799 | /// Supports marks. |
| 3800 | /// |
| 3801 | /// ## Anchors |
| 3802 | /// Supports path anchors. |
| 3803 | /// - **ctrl-n**: nth control point where n is an integer starting at 0 |
| 3804 | /// |
| 3805 | #let bezier(start, end, ..ctrl-style, name: none) = { |
| 3806 | // Extra positional arguments are treated like control points. |
| 3807 | let (ctrl, style) = (ctrl-style.pos(), ctrl-style.named()) |
| 3808 | |
| 3809 | // Control point check |
| 3810 | let len = ctrl.len() |
| 3811 | assert( |
| 3812 | len in (1, 2), |
| 3813 | message: "Bezier curve expects 1 or 2 control points. Got " + str(len), |
| 3814 | ) |
| 3815 | let coordinates = (start, ..ctrl, end) |
| 3816 | |
| 3817 | // Coordinates check |
| 3818 | let t = coordinates.map(coordinate.resolve-system) |
| 3819 | |
| 3820 | return ( |
| 3821 | ctx => { |
| 3822 | let (ctx, start, ..ctrl, end) = coordinate.resolve(ctx, ..coordinates) |
| 3823 | |
| 3824 | if ctrl.len() == 1 { |
| 3825 | (start, end, ..ctrl) = bezier_.quadratic-to-cubic(start, end, ..ctrl) |
| 3826 | } |
| 3827 | |
| 3828 | let style = styles.resolve(ctx.style, merge: style, root: "bezier") |
| 3829 | let drawables = drawable.path( |
| 3830 | (path-util.cubic-segment(start, end, ..ctrl),), |
| 3831 | fill: style.fill, |
| 3832 | fill-rule: style.fill-rule, |
| 3833 | stroke: style.stroke, |
| 3834 | ) |
| 3835 | |
| 3836 | let (transform, anchors) = anchor_.setup( |
| 3837 | anchor => ( |
| 3838 | ctrl-0: ctrl.at(0), |
| 3839 | ctrl-1: ctrl.at(1), |
| 3840 | ).at(anchor), |
| 3841 | ("ctrl-0", "ctrl-1"), |
| 3842 | default: "start", |
| 3843 | name: name, |
| 3844 | transform: ctx.transform, |
| 3845 | path-anchors: true, |
| 3846 | path: drawables, |
| 3847 | ) |
| 3848 | |
| 3849 | if mark_.check-mark(style.mark) { |
| 3850 | drawables = mark_.place-marks-along-path(ctx, style.mark, transform, drawables) |
| 3851 | } else { |
| 3852 | drawables = drawable.apply-transform(transform, drawables) |
| 3853 | } |
| 3854 | |
| 3855 | return ( |
| 3856 | ctx: ctx, |
| 3857 | name: name, |
| 3858 | anchors: anchors, |
| 3859 | drawables: drawables, |
| 3860 | ) |
| 3861 | }, |
| 3862 | ) |
| 3863 | } |
| 3864 | |
| 3865 | /// Draws a cubic bezier curve through a set of three points. See [bezier](./bezier) for style and anchor details. |
| 3866 | /// |
| 3867 | /// ```typc example |
| 3868 | /// let (a, b, c) = ((0, 0), (1, 1), (2, -1)) |
| 3869 | /// line(a, b, c, stroke: gray) |
| 3870 | /// bezier-through(a, b, c, name: "b") |
| 3871 | /// |
| 3872 | /// // Show calculated control points |
| 3873 | /// line(a, "b.ctrl-0", "b.ctrl-1", c, stroke: gray) |
| 3874 | /// ``` |
| 3875 | /// |
| 3876 | /// - start (coordinate): The position to start the curve. |
| 3877 | /// - pass-through (coordinate): The position to pass the curve through. |
| 3878 | /// - end (coordinate): The position to end the curve. |
| 3879 | /// - name (none,str): |
| 3880 | /// - ..style (style): |
| 3881 | #let bezier-through(start, pass-through, end, name: none, ..style) = { |
| 3882 | assert.eq(style.pos(), (), message: "Unexpected positional arguments: " + repr(style.pos())) |
| 3883 | style = style.named() |
| 3884 | |
| 3885 | return (ctx => { |
| 3886 | let (ctx, start, pass-through, end) = coordinate.resolve(ctx, start, pass-through, end) |
| 3887 | |
| 3888 | let (start, end, ..control) = bezier_.cubic-through-3points(start, pass-through, end) |
| 3889 | |
| 3890 | return bezier(start, end, ..control, ..style, name: name).first()(ctx) |
| 3891 | },) |
| 3892 | } |
| 3893 | |
| 3894 | /// Draws a Catmull-Rom curve through a set of points. |
| 3895 | /// |
| 3896 | /// ```typc example |
| 3897 | /// catmull((0,0), (1,1), (2,-1), (3,0), tension: .4, stroke: blue) |
| 3898 | /// catmull((0,0), (1,1), (2,-1), (3,0), tension: .5, stroke: red) |
| 3899 | /// ``` |
| 3900 | /// |
| 3901 | /// - ..pts-style (coordinate,style): Positional arguments should be coordinates that the curve should pass through. Named arguments are for styling. |
| 3902 | /// - close (bool): Closes the curve with a straight line between the start and end of the curve. |
| 3903 | /// - name (none,str): |
| 3904 | /// |
| 3905 | /// ## Styling |
| 3906 | /// *Root*: `catmull` |
| 3907 | /// |
| 3908 | /// Supports marks. |
| 3909 | /// |
| 3910 | /// - tension (float) = 0.5: How tight the curve should fit to the points. The higher the tension the less curvy the curve. |
| 3911 | /// |
| 3912 | /// ## Anchors |
| 3913 | /// Supports path anchors. |
| 3914 | /// - **pt-n**: The nth given position (0 indexed so "pt-0" is equal to "start") |
| 3915 | #let catmull(..pts-style, close: false, name: none) = { |
| 3916 | let (pts, style) = (pts-style.pos(), pts-style.named()) |
| 3917 | |
| 3918 | assert(pts.len() >= 2, message: "Catmull-rom curve requires at least two points. Got " + repr(pts.len()) + "instead.") |
| 3919 | |
| 3920 | pts.map(coordinate.resolve-system) |
| 3921 | |
| 3922 | return (ctx => { |
| 3923 | let (ctx, ..pts) = coordinate.resolve(ctx, ..pts) |
| 3924 | let style = styles.resolve(ctx.style, merge: style, root: "catmull") |
| 3925 | |
| 3926 | let curves = bezier_.catmull-to-cubic( |
| 3927 | pts, |
| 3928 | style.tension, |
| 3929 | close: close) |
| 3930 | |
| 3931 | let segments = curves.map(c => path-util.cubic-segment(..c)) |
| 3932 | let drawables = drawable.path( |
| 3933 | segments, |
| 3934 | fill: style.fill, |
| 3935 | fill-rule: style.fill-rule, |
| 3936 | stroke: style.stroke, |
| 3937 | close: close) |
| 3938 | |
| 3939 | let (transform, anchors) = { |
| 3940 | let a = for (i, pt) in pts.enumerate() { |
| 3941 | (("pt-" + str(i)): pt) |
| 3942 | } |
| 3943 | anchor_.setup( |
| 3944 | anchor => a.at(anchor), // Would like to return just `a.at` but Typst is mean :< |
| 3945 | a.keys(), |
| 3946 | name: name, |
| 3947 | default: "start", |
| 3948 | transform: ctx.transform, |
| 3949 | path-anchors: true, |
| 3950 | path: drawables, |
| 3951 | ) |
| 3952 | } |
| 3953 | |
| 3954 | if mark_.check-mark(style.mark) { |
| 3955 | drawables = mark_.place-marks-along-path(ctx, style.mark, transform, drawables) |
| 3956 | } else { |
| 3957 | drawables = drawable.apply-transform(transform, drawables) |
| 3958 | } |
| 3959 | |
| 3960 | return ( |
| 3961 | ctx: ctx, |
| 3962 | name: name, |
| 3963 | anchors: anchors, |
| 3964 | drawables: drawables, |
| 3965 | ) |
| 3966 | },) |
| 3967 | } |
| 3968 | |
| 3969 | /// Draws a Hobby curve through a set of points. |
| 3970 | /// |
| 3971 | /// ```typc example |
| 3972 | /// hobby((0, 0), (1, 1), (2, -1), (3, 0), omega: 0, stroke: blue) |
| 3973 | /// hobby((0, 0), (1, 1), (2, -1), (3, 0), omega: 1, stroke: red) |
| 3974 | /// ``` |
| 3975 | /// |
| 3976 | /// - ..pts-style (coordinate,style): Positional arguments are the coordinates to use to draw the curve with, a minimum of two is required. Named arguments are for styling. |
| 3977 | /// - tb (auto,array): Incoming tension at `pts.at(n+1)` from `pts.at(n)` to `pts.at(n+1)`. The number given must be one less than the number of points. |
| 3978 | /// - ta (auto, array): Outgoing tension at `pts.at(n)` from `pts.at(n)` to `pts.at(n+1)`. The number given must be one less than the number of points. |
| 3979 | /// - close (bool): Closes the curve with a proper smooth curve between the start and end of the curve. |
| 3980 | /// - name (none,str): |
| 3981 | /// |
| 3982 | /// ## Styling |
| 3983 | /// *Root* `hobby` |
| 3984 | /// |
| 3985 | /// Supports marks. |
| 3986 | /// - omega (array) = (1, 1): A tuple of floats that describe how curly the curve should be at each endpoint. When the curl is close to zero, the spline approaches a straight line near the endpoints. When the curl is close to one, it approaches a circular arc. |
| 3987 | /// |
| 3988 | /// ## Anchors |
| 3989 | /// Supports path anchors. |
| 3990 | /// - **pt-n**: The nth given position (0 indexed, so "pt-0" is equal to "start") |
| 3991 | #let hobby(..pts-style, ta: auto, tb: auto, close: false, name: none) = { |
| 3992 | let (pts, style) = (pts-style.pos(), pts-style.named()) |
| 3993 | |
| 3994 | assert(pts.len() >= 2, message: "Hobby curve requires at least two points. Got " + repr(pts.len()) + "instead.") |
| 3995 | |
| 3996 | pts.map(coordinate.resolve-system) |
| 3997 | |
| 3998 | return (ctx => { |
| 3999 | let (ctx, ..pts) = coordinate.resolve(ctx, ..pts) |
| 4000 | let style = styles.resolve(ctx.style, merge: style, root: "hobby") |
| 4001 | |
| 4002 | let curves = hobby_.hobby-to-cubic( |
| 4003 | pts, |
| 4004 | ta: ta, |
| 4005 | tb: tb, |
| 4006 | omega: style.omega, |
| 4007 | close: close) |
| 4008 | |
| 4009 | let segments = curves.map(c => path-util.cubic-segment(..c)) |
| 4010 | let drawables = drawable.path( |
| 4011 | segments, |
| 4012 | fill: style.fill, |
| 4013 | fill-rule: style.fill-rule, |
| 4014 | stroke: style.stroke, |
| 4015 | close: close) |
| 4016 | |
| 4017 | let (transform, anchors) = { |
| 4018 | let a = for (i, pt) in pts.enumerate() { |
| 4019 | (("pt-" + str(i)): pt) |
| 4020 | } |
| 4021 | anchor_.setup( |
| 4022 | anchor => { |
| 4023 | if type(anchor) == str and anchor in a { |
| 4024 | return a.at(anchor) |
| 4025 | } |
| 4026 | }, |
| 4027 | a.keys(), |
| 4028 | name: name, |
| 4029 | default: "start", |
| 4030 | transform: ctx.transform, |
| 4031 | path-anchors: true, |
| 4032 | path: drawables, |
| 4033 | ) |
| 4034 | } |
| 4035 | |
| 4036 | if mark_.check-mark(style.mark) { |
| 4037 | drawables = mark_.place-marks-along-path(ctx, style.mark, transform, drawables) |
| 4038 | } else { |
| 4039 | drawables = drawable.apply-transform(transform, drawables) |
| 4040 | } |
| 4041 | |
| 4042 | return ( |
| 4043 | ctx: ctx, |
| 4044 | name: name, |
| 4045 | anchors: anchors, |
| 4046 | drawables: drawables, |
| 4047 | ) |
| 4048 | },) |
| 4049 | } |
| 4050 | |
| 4051 | /// Merges two or more paths by concattenating their elements. Anchors and visual styling, such as `stroke` and `fill`, are not preserved. When an element's path does not start at the same position the previous element's path ended, a straight line is drawn between them so that the final path is continuous. You must then pay attention to the direction in which element paths are drawn. |
| 4052 | /// |
| 4053 | /// ```typc example |
| 4054 | /// merge-path(fill: white, { |
| 4055 | /// line((0, 0), (1, 0)) |
| 4056 | /// bezier((), (0, 0), (1,1), (0,1)) |
| 4057 | /// }) |
| 4058 | /// ``` |
| 4059 | /// |
| 4060 | /// Elements hidden via @@hide() are ignored. |
| 4061 | /// |
| 4062 | /// ## Anchors |
| 4063 | /// **centroid**: Centroid of the _closed and non self-intersecting_ shape. Only exists if `close` is true. |
| 4064 | /// Supports path anchors and shapes where all vertices share the same z-value. |
| 4065 | /// |
| 4066 | /// - body (elements): Elements with paths to be merged together. |
| 4067 | /// - close (bool): Close the path with a straight line from the start of the path to its end. |
| 4068 | /// - name (none,str): |
| 4069 | /// - ..style (style): |
| 4070 | #let merge-path(body, close: false, name: none, ..style) = { |
| 4071 | // No extra positional arguments from the style sink |
| 4072 | assert.eq( |
| 4073 | style.pos(), |
| 4074 | (), |
| 4075 | message: "Unexpected positional arguments: " + repr(style.pos()), |
| 4076 | ) |
| 4077 | let style = style.named() |
| 4078 | |
| 4079 | return ( |
| 4080 | ctx => { |
| 4081 | let ctx = ctx |
| 4082 | let segments = () |
| 4083 | for element in body { |
| 4084 | let r = process.element(ctx, element) |
| 4085 | if r != none { |
| 4086 | ctx = r.ctx |
| 4087 | if segments != () and r.drawables != () { |
| 4088 | assert.eq(r.drawables.first().type, "path") |
| 4089 | let start = path-util.segment-end(segments.last()) |
| 4090 | let end = path-util.segment-start(r.drawables.first().segments.first()) |
| 4091 | if vector.dist(start, end) > 0 { |
| 4092 | segments.push(path-util.line-segment((start, end,))) |
| 4093 | } |
| 4094 | } |
| 4095 | for drawable in r.drawables { |
| 4096 | if drawable.hidden { continue } |
| 4097 | assert.eq(drawable.type, "path") |
| 4098 | segments += drawable.segments |
| 4099 | } |
| 4100 | } |
| 4101 | } |
| 4102 | |
| 4103 | let style = styles.resolve(ctx.style, merge: style) |
| 4104 | let drawables = drawable.path(fill: style.fill, fill-rule: style.fill-rule, stroke: style.stroke, close: close, segments) |
| 4105 | |
| 4106 | let (transform, anchors) = anchor_.setup( |
| 4107 | name => { |
| 4108 | if name == "centroid" { |
| 4109 | // Try finding a closed shapes center by |
| 4110 | // Sampling it to a polygon. |
| 4111 | return polygon_.simple-centroid(polygon_.from-segments(drawables.segments)) |
| 4112 | } |
| 4113 | }, |
| 4114 | if close != none { ("centroid",) } else { () }, |
| 4115 | name: name, |
| 4116 | transform: none, |
| 4117 | path-anchors: true, |
| 4118 | path: drawables, |
| 4119 | ) |
| 4120 | |
| 4121 | return ( |
| 4122 | ctx: ctx, |
| 4123 | name: name, |
| 4124 | anchors: anchors, |
| 4125 | drawables: drawables, |
| 4126 | ) |
| 4127 | }, |
| 4128 | ) |
| 4129 | } |
| 4130 | #import "/src/util.typ" |
| 4131 | |
| 4132 | /// Set current style |
| 4133 | /// |
| 4134 | /// - ..style (style): Style key-value pairs |
| 4135 | #let set-style(..style) = { |
| 4136 | assert.eq( |
| 4137 | style.pos().len(), |
| 4138 | 0, |
| 4139 | message: "set-style takes no positional arguments", |
| 4140 | ) |
| 4141 | |
| 4142 | (ctx => { |
| 4143 | ctx.style = util.merge-dictionary(ctx.style, style.named()) |
| 4144 | |
| 4145 | return (ctx: ctx) |
| 4146 | },) |
| 4147 | } |
| 4148 | |
| 4149 | /// Set current fill style |
| 4150 | /// |
| 4151 | /// Shorthand for `set-style(fill: <fill>)` |
| 4152 | /// |
| 4153 | /// - fill (paint): Fill style |
| 4154 | #let fill(fill) = set-style(fill: fill) |
| 4155 | |
| 4156 | /// Set current stroke style |
| 4157 | /// |
| 4158 | /// Shorthand for `set-style(stroke: <fill>)` |
| 4159 | /// |
| 4160 | /// - stroke (stroke): Stroke style |
| 4161 | #let stroke(stroke) = set-style(stroke: stroke) |
| 4162 | |
| 4163 | /// Register a custom mark to the canvas |
| 4164 | /// |
| 4165 | /// The mark should contain both anchors called **tip** and **base** that are used to determine the marks orientation. If unset both default to `(0, 0)`. |
| 4166 | /// An anchor named **center** is used as center of the mark, if present. Otherwise the mid between **tip** and **base** is used. |
| 4167 | /// |
| 4168 | /// ```typc example |
| 4169 | /// register-mark(":)", style => { |
| 4170 | /// circle((0,0), radius: .5, fill: yellow) |
| 4171 | /// arc((0,0), start: 180deg + 30deg, delta: 180deg - 60deg, anchor: "origin", radius: .3) |
| 4172 | /// circle((-0.15, 0.15), radius: .1, fill: white) |
| 4173 | /// circle((-0.10, 0.10), radius: .025, fill: black) |
| 4174 | /// circle(( 0.15, 0.15), radius: .1, fill: white) |
| 4175 | /// circle(( 0.20, 0.10), radius: .025, fill: black) |
| 4176 | /// |
| 4177 | /// anchor("tip", ( 0.5, 0)) |
| 4178 | /// anchor("base", (-0.5, 0)) |
| 4179 | /// }) |
| 4180 | /// |
| 4181 | /// line((0,0), (3,0), mark: (end: ":)")) |
| 4182 | /// ``` |
| 4183 | /// |
| 4184 | /// - symbol (str): Mark name |
| 4185 | /// - mnemonic (none,str): Mark short name |
| 4186 | /// - body (function): Mark drawing callback, receiving the mark style as argument and returning elements. Format `(styles) => elements`. |
| 4187 | #let register-mark(symbol, body, mnemonic: none) = { |
| 4188 | assert(type(symbol) == str) |
| 4189 | assert(type(body) == function) |
| 4190 | |
| 4191 | (ctx => { |
| 4192 | ctx.marks.marks.insert(symbol, body) |
| 4193 | if type(mnemonic) == str and mnemonic.len() > 0 { |
| 4194 | ctx.marks.mnemonics.insert(mnemonic, symbol) |
| 4195 | } |
| 4196 | return (ctx: ctx) |
| 4197 | },) |
| 4198 | } |
| 4199 | #import "/src/coordinate.typ" |
| 4200 | #import "/src/matrix.typ" |
| 4201 | #import "/src/vector.typ" |
| 4202 | #import "/src/util.typ" |
| 4203 | |
| 4204 | // Utility for applying translation to and from |
| 4205 | // the origin to apply a transformation matrix to. |
| 4206 | // |
| 4207 | // - ctx (context): Context |
| 4208 | // - transform (matrix): Transformation matrix |
| 4209 | // - origin (coordinate): Origin coordinate or none |
| 4210 | #let _transform-around-origin(ctx, transform, origin) = { |
| 4211 | if origin != none { |
| 4212 | let (_, origin) = coordinate.resolve(ctx, origin, update: false) |
| 4213 | let a = matrix.transform-translate(..origin) |
| 4214 | let b = matrix.transform-translate(..vector.scale(origin, -1)) |
| 4215 | |
| 4216 | matrix.mul-mat(a, matrix.mul-mat(transform, b)) |
| 4217 | } else { |
| 4218 | transform |
| 4219 | } |
| 4220 | } |
| 4221 | |
| 4222 | /// Sets the transformation matrix. |
| 4223 | /// |
| 4224 | /// - mat (none, matrix): The 4x4 transformation matrix to set. If `none` is passed, the transformation matrix is set to the identity matrix (`matrix.ident()`). |
| 4225 | #let set-transform(mat) = { |
| 4226 | let mat = if mat == none { |
| 4227 | matrix.ident(4) |
| 4228 | } else { |
| 4229 | matrix.round(mat) |
| 4230 | } |
| 4231 | |
| 4232 | assert( |
| 4233 | type(mat) == array, |
| 4234 | message: "Transformtion matrix must be of type array, got: " + repr(mat) |
| 4235 | ) |
| 4236 | assert.eq( |
| 4237 | mat.len(), |
| 4238 | 4, |
| 4239 | message: "Transformation matrix must be of size 4x4, got: " + repr(mat) |
| 4240 | ) |
| 4241 | |
| 4242 | (ctx => { |
| 4243 | ctx.transform = mat |
| 4244 | return (ctx: ctx) |
| 4245 | },) |
| 4246 | } |
| 4247 | |
| 4248 | /// Rotates the transformation matrix on the z-axis by a given angle or other axes when specified. |
| 4249 | /// |
| 4250 | /// ```typc example |
| 4251 | /// // Rotate on z-axis |
| 4252 | /// rotate(z: 45deg) |
| 4253 | /// rect((-1,-1), (1,1)) |
| 4254 | /// // Rotate on y-axis |
| 4255 | /// rotate(y: 80deg) |
| 4256 | /// circle((0,0)) |
| 4257 | /// ``` |
| 4258 | /// |
| 4259 | /// - ..angles (angle): A single angle as a positional argument to rotate on the z-axis by. |
| 4260 | /// Named arguments of `x`, `y` or `z` can be given to rotate on their respective axis. |
| 4261 | /// You can give named arguments of `yaw`, `pitch` or `roll`, too. |
| 4262 | /// - origin (none,coordinate): Origin to rotate around, or (0, 0, 0) if set to `none`. |
| 4263 | #let rotate(..angles, origin: none) = { |
| 4264 | assert(angles.pos().len() == 1 or angles.named().len() > 0, |
| 4265 | message: "Rotate takes a single z-angle or angles " + |
| 4266 | "(x, y, z or yaw, pitch, roll) as named arguments, got: " + repr(angles)) |
| 4267 | |
| 4268 | let named = angles.named() |
| 4269 | let names = named.keys() |
| 4270 | |
| 4271 | let mat = if angles.pos().len() == 1 { |
| 4272 | matrix.transform-rotate-z(angles.pos().at(0)) |
| 4273 | } else if names.all(n => n in ("x", "y", "z")) { |
| 4274 | matrix.transform-rotate-xyz(named.at("x", default: 0deg), |
| 4275 | named.at("y", default: 0deg), |
| 4276 | named.at("z", default: 0deg)) |
| 4277 | } else if names.all(n => n in ("yaw", "pitch", "roll")) { |
| 4278 | matrix.transform-rotate-ypr(named.at("yaw", default: 0deg), |
| 4279 | named.at("pitch", default: 0deg), |
| 4280 | named.at("roll", default: 0deg)) |
| 4281 | } else { |
| 4282 | panic("Invalid rotate arguments." + |
| 4283 | "Rotate expects: A single (z-axis) angle or any combination of x, y,z or any combination of yaw, pitch, roll. " + |
| 4284 | "Got: " + repr(named)) |
| 4285 | } |
| 4286 | |
| 4287 | (ctx => { |
| 4288 | ctx.transform = matrix.mul-mat(ctx.transform, |
| 4289 | _transform-around-origin(ctx, mat, origin)) |
| 4290 | return (ctx: ctx) |
| 4291 | },) |
| 4292 | } |
| 4293 | |
| 4294 | /// Translates the transformation matrix by the given vector or dictionary. |
| 4295 | /// |
| 4296 | /// ```typc example |
| 4297 | /// // Outer rect |
| 4298 | /// rect((0, 0), (2, 2)) |
| 4299 | /// // Inner rect |
| 4300 | /// translate(x: .5, y: .5) |
| 4301 | /// rect((0, 0), (1, 1)) |
| 4302 | /// ``` |
| 4303 | /// |
| 4304 | /// - ..args (vector, float, length): A single vector or any combination of the named arguments `x`, `y` and `z` to translate by. |
| 4305 | /// A translation matrix with the given offsets gets multiplied with the current transformation depending on the value of `pre`. |
| 4306 | /// - pre (bool): Specify matrix multiplication order |
| 4307 | /// - false: `World = World * Translate` |
| 4308 | /// - true: `World = Translate * World` |
| 4309 | #let translate(..args, pre: false) = { |
| 4310 | assert((args.pos().len() == 1 and args.named() == (:)) or |
| 4311 | (args.pos() == () and args.named() != (:)), |
| 4312 | message: "Expected a single positional argument or one or more named arguments, got: " + repr(args)) |
| 4313 | |
| 4314 | let pos = args.pos() |
| 4315 | let named = args.named() |
| 4316 | |
| 4317 | let vec = if named != (:) { |
| 4318 | (named.at("x", default: 0), named.at("y", default: 0), named.at("z", default: 0)) |
| 4319 | } else { |
| 4320 | vector.as-vec(pos.at(0), init: (0, 0, 0)) |
| 4321 | } |
| 4322 | |
| 4323 | (ctx => { |
| 4324 | // Allow translating by length values |
| 4325 | let vec = vec.map(v => if type(v) == length { |
| 4326 | util.resolve-number(ctx, v) |
| 4327 | } else { |
| 4328 | v |
| 4329 | }) |
| 4330 | |
| 4331 | let t = matrix.transform-translate(..vec) |
| 4332 | if pre { |
| 4333 | ctx.transform = matrix.mul-mat(t, ctx.transform) |
| 4334 | } else { |
| 4335 | ctx.transform = matrix.mul-mat(ctx.transform, t) |
| 4336 | } |
| 4337 | return (ctx: ctx) |
| 4338 | },) |
| 4339 | } |
| 4340 | |
| 4341 | /// Scales the transformation matrix by the given factor(s). |
| 4342 | /// |
| 4343 | /// ```typc example |
| 4344 | /// // Scale the y-axis |
| 4345 | /// scale(y: 50%) |
| 4346 | /// circle((0,0)) |
| 4347 | /// ``` |
| 4348 | /// |
| 4349 | /// Note that content like text does not scale automatically. See `auto-scale` styling of content for that. |
| 4350 | /// |
| 4351 | /// - ..args (float, ratio): A single value to scale the transformation matrix by or per axis |
| 4352 | /// scaling factors. Accepts a single float or ratio value or any combination of the named arguments |
| 4353 | /// `x`, `y` and `z` to set per axis scaling factors. A ratio of 100% is the same as the value $1$. |
| 4354 | /// - origin (none,coordinate): Origin to rotate around, or (0, 0, 0) if set to `none`. |
| 4355 | #let scale(..args, origin: none) = { |
| 4356 | assert((args.pos().len() == 1 and args.named() == (:)) or |
| 4357 | (args.pos() == () and args.named() != (:)), |
| 4358 | message: "Expected a single positional argument or one or more named arguments, got: " + repr(args)) |
| 4359 | |
| 4360 | let pos = args.pos() |
| 4361 | let named = args.named() |
| 4362 | |
| 4363 | let vec = if args.named() != (:) { |
| 4364 | (named.at("x", default: 1), named.at("y", default: 1), named.at("z", default: 1)) |
| 4365 | } else if type(pos.at(0)) == array { |
| 4366 | vector.as-vec(pos, init: (1, 1, 1)) |
| 4367 | } else { |
| 4368 | let factor = pos.at(0) |
| 4369 | (factor, factor, factor) |
| 4370 | } |
| 4371 | |
| 4372 | // Allow scaling using ratio values |
| 4373 | vec = vec.map(v => if type(v) == ratio { |
| 4374 | v / 100% |
| 4375 | } else { |
| 4376 | v |
| 4377 | }) |
| 4378 | |
| 4379 | (ctx => { |
| 4380 | let mat = matrix.transform-scale(vec) |
| 4381 | ctx.transform = matrix.mul-mat(ctx.transform, |
| 4382 | _transform-around-origin(ctx, mat, origin)) |
| 4383 | return (ctx: ctx) |
| 4384 | },) |
| 4385 | } |
| 4386 | |
| 4387 | /// Sets the given position as the new origin `(0, 0, 0)` |
| 4388 | /// |
| 4389 | /// ```typc example |
| 4390 | /// // Outer rect |
| 4391 | /// rect((0,0), (2,2), name: "r") |
| 4392 | /// // Move origin to top edge |
| 4393 | /// set-origin("r.north") |
| 4394 | /// circle((0, 0), radius: .1) |
| 4395 | /// ``` |
| 4396 | /// |
| 4397 | /// - origin (coordinate): Coordinate to set as new origin `(0,0,0)` |
| 4398 | #let set-origin(origin) = { |
| 4399 | ( |
| 4400 | ctx => { |
| 4401 | let (ctx, c) = coordinate.resolve(ctx, origin) |
| 4402 | let (x, y, z) = vector.sub( |
| 4403 | util.apply-transform(ctx.transform, c), |
| 4404 | util.apply-transform(ctx.transform, (0, 0, 0)), |
| 4405 | ) |
| 4406 | ctx.transform = matrix.mul-mat(matrix.transform-translate(x, y, z), ctx.transform) |
| 4407 | return (ctx: ctx) |
| 4408 | }, |
| 4409 | ) |
| 4410 | } |
| 4411 | |
| 4412 | /// Sets the previous coordinate. |
| 4413 | /// |
| 4414 | /// The previous coordinate can be used via `()` (empty coordinate). |
| 4415 | /// It is also used as base for relative coordinates if not specified |
| 4416 | /// otherwise. |
| 4417 | /// |
| 4418 | /// ```typc example |
| 4419 | /// circle((), radius: .25) |
| 4420 | /// move-to((1,0)) |
| 4421 | /// circle((), radius: .15) |
| 4422 | /// ``` |
| 4423 | /// |
| 4424 | /// - pt (coordinate): The coordinate to move to. |
| 4425 | #let move-to(pt) = { |
| 4426 | let t = coordinate.resolve-system(pt) |
| 4427 | |
| 4428 | return (ctx => { |
| 4429 | let (ctx, pt) = coordinate.resolve(ctx, pt) |
| 4430 | return (ctx: ctx) |
| 4431 | },) |
| 4432 | } |
| 4433 | |
| 4434 | /// Span viewport between two coordinates and set-up scaling and translation |
| 4435 | /// |
| 4436 | /// ```typc example |
| 4437 | /// rect((0,0), (2,2)) |
| 4438 | /// set-viewport((0,0), (2,2), bounds: (10, 10)) |
| 4439 | /// circle((5,5)) |
| 4440 | /// ``` |
| 4441 | /// |
| 4442 | /// - from (coordinate): Bottom left corner coordinate |
| 4443 | /// - to (coordinate): Top right corner coordinate |
| 4444 | /// - bounds (vector): Viewport bounds vector that describes the inner width, |
| 4445 | /// height and depth of the viewport |
| 4446 | #let set-viewport(from, to, bounds: (1, 1, 1)) = { |
| 4447 | (from, to).map(coordinate.resolve-system) |
| 4448 | |
| 4449 | return (ctx => { |
| 4450 | let bounds = vector.as-vec(bounds, init: (1, 1, 1)) |
| 4451 | |
| 4452 | let (ctx, from, to) = coordinate.resolve(ctx, from, to) |
| 4453 | let (fx, fy, fz) = from |
| 4454 | let (tx, ty, tz) = to |
| 4455 | |
| 4456 | // Compute scaling |
| 4457 | let (sx, sy, sz) = vector.sub((tx, ty, tz), |
| 4458 | (fx, fy, fz)).enumerate().map(((i, v)) => if bounds.at(i) == 0 { |
| 4459 | 0 |
| 4460 | } else { |
| 4461 | v / bounds.at(i) |
| 4462 | }) |
| 4463 | |
| 4464 | ctx.transform = matrix.mul-mat(ctx.transform, |
| 4465 | matrix.transform-translate(fx, fy, fz)) |
| 4466 | ctx.transform = matrix.mul-mat(ctx.transform, |
| 4467 | matrix.transform-scale((sx, sy, sz))) |
| 4468 | return (ctx: ctx) |
| 4469 | },) |
| 4470 | } |
| 4471 | |
| 4472 | /// Assert that the cetz version of the canvas matches the given version (range). |
| 4473 | /// |
| 4474 | /// min (version): Minimum version (current >= min) |
| 4475 | /// max (none, version): First unsupported version (current < max) |
| 4476 | /// hint (string): Name of the function/module this assert is called from |
| 4477 | #let assert-version(min, max: none, hint: "") = { |
| 4478 | if hint != "" { hint = " by " + hint } |
| 4479 | (ctx => { |
| 4480 | /* Default to 2.0.0, as this is the first version that had elements as single functions. */ |
| 4481 | let v = ctx.at("version", default: version(0,2,0)) |
| 4482 | assert(min <= v, |
| 4483 | message: "CeTZ canvas version is " + str(v) + ", but the minimum required version" + hint + " is " + str(min)) |
| 4484 | if max != none { |
| 4485 | assert(max > v, |
| 4486 | message: "CeTZ canvas version is " + str(v) + ", but the maximum supported version" + hint + " is " + str(min)) |
| 4487 | } |
| 4488 | |
| 4489 | return (ctx: ctx) |
| 4490 | },) |
| 4491 | } |
| 4492 | #import "vector.typ" |
| 4493 | #import "util.typ" |
| 4494 | #import "path-util.typ" |
| 4495 | |
| 4496 | /// Applies a transform to drawables. If a single drawable is given it will be returned in a single element <Type>array</Type>. |
| 4497 | /// - transform (matrix): The transformation matrix. |
| 4498 | /// - drawables (drawable): The drawables to transform. |
| 4499 | /// -> drawable |
| 4500 | #let apply-transform(transform, drawables) = { |
| 4501 | if type(drawables) == dictionary { |
| 4502 | drawables = (drawables,) |
| 4503 | } |
| 4504 | if drawables.len() == 0 { |
| 4505 | return () |
| 4506 | } |
| 4507 | if transform == none { |
| 4508 | return drawables |
| 4509 | } |
| 4510 | for drawable in drawables { |
| 4511 | assert(type(drawable) != array, |
| 4512 | message: "Expected drawable, got array: " + repr(drawable)) |
| 4513 | if drawable.type == "path" { |
| 4514 | drawable.segments = drawable.segments.map(((kind, ..pts)) => { |
| 4515 | return (kind,) + util.apply-transform(transform, ..pts) |
| 4516 | }) |
| 4517 | } else if drawable.type == "content" { |
| 4518 | drawable.pos = util.apply-transform(transform, drawable.pos) |
| 4519 | } else { |
| 4520 | panic() |
| 4521 | } |
| 4522 | (drawable,) |
| 4523 | } |
| 4524 | } |
| 4525 | |
| 4526 | /// Creates a path drawable from path segements. |
| 4527 | /// - segments (array): The segments to create the path from. |
| 4528 | /// - close (bool): If `true` the path will be closed. |
| 4529 | /// - fill (color,none): The color to fill the path with. |
| 4530 | /// - fill-rule (string): One of "even-odd" or "non-zero". |
| 4531 | /// - stroke (stroke): The stroke of the path. |
| 4532 | /// -> drawable |
| 4533 | #let path(close: false, fill: none, stroke: none, fill-rule: "non-zero", segments) = { |
| 4534 | let segments = segments |
| 4535 | // Handle case where only one segment has been passed |
| 4536 | if type(segments.first()) == str { |
| 4537 | segments = (segments,) |
| 4538 | } |
| 4539 | |
| 4540 | segments = path-util.normalize(segments) |
| 4541 | if close and path-util.segment-end(segments.last()) != path-util.segment-start(segments.first()) { |
| 4542 | segments.push(path-util.line-segment(( |
| 4543 | path-util.segment-end(segments.last()), |
| 4544 | path-util.segment-start(segments.first()), |
| 4545 | ))) |
| 4546 | } |
| 4547 | |
| 4548 | return ( |
| 4549 | type: "path", |
| 4550 | close: close, |
| 4551 | segments: segments, |
| 4552 | fill: fill, |
| 4553 | fill-rule: fill-rule, |
| 4554 | stroke: stroke, |
| 4555 | hidden: false, |
| 4556 | bounds: true, |
| 4557 | ) |
| 4558 | } |
| 4559 | |
| 4560 | |
| 4561 | /// Creates a content drawable. |
| 4562 | /// - pos (vector): The position of the drawable. |
| 4563 | /// - width (float): The width of the drawable. |
| 4564 | /// - height (float): The height of the drawable. |
| 4565 | /// - border (segment): A segment to define the border of the drawable with. |
| 4566 | /// - body (content): The content of the drawable. |
| 4567 | /// -> drawable |
| 4568 | #let content(pos, width, height, border, body) = { |
| 4569 | return ( |
| 4570 | type: "content", |
| 4571 | pos: pos, |
| 4572 | width: width, |
| 4573 | height: height, |
| 4574 | segments: border, |
| 4575 | body: body, |
| 4576 | hidden: false, |
| 4577 | bounds: true, |
| 4578 | ) |
| 4579 | } |
| 4580 | |
| 4581 | /// Creates a path drawable in the shape of an ellipse. |
| 4582 | /// - x (float): The $x$ position of the ellipse. |
| 4583 | /// - y (float): The $y$ position of the ellipse. |
| 4584 | /// - z (float): The $z$ position of the ellipse. |
| 4585 | /// - rx (float): The radius of the ellipse in the $x$ axis. |
| 4586 | /// - ry (float): The radius of the ellipse in the $y$ axis. |
| 4587 | /// - fill (color,none): The color to fill the ellipse with. |
| 4588 | /// - stroke (stroke): The stroke of the ellipse's path. |
| 4589 | /// -> drawable |
| 4590 | #let ellipse(x, y, z, rx, ry, fill: none, stroke: none) = { |
| 4591 | let m = 0.551784 |
| 4592 | let mx = m * rx |
| 4593 | let my = m * ry |
| 4594 | let left = x - rx |
| 4595 | let right = x + rx |
| 4596 | let top = y + ry |
| 4597 | let bottom = y - ry |
| 4598 | |
| 4599 | path( |
| 4600 | ( |
| 4601 | path-util.cubic-segment( |
| 4602 | (x, top, z), |
| 4603 | (left, y, z), |
| 4604 | (x - m * rx, top, z), |
| 4605 | (left, y + m * ry, z), |
| 4606 | ), |
| 4607 | path-util.cubic-segment( |
| 4608 | (left, y, z), |
| 4609 | (x, bottom, z), |
| 4610 | (left, y - m * ry, z), |
| 4611 | (x - m * rx, bottom, z), |
| 4612 | ), |
| 4613 | path-util.cubic-segment( |
| 4614 | (x, bottom, z), |
| 4615 | (right, y, z), |
| 4616 | (x + m * rx, bottom, z), |
| 4617 | (right, y - m * ry, z), |
| 4618 | ), |
| 4619 | path-util.cubic-segment( |
| 4620 | (right, y, z), |
| 4621 | (x, top, z), |
| 4622 | (right, y + m * ry, z), |
| 4623 | (x + m * rx, top, z) |
| 4624 | ), |
| 4625 | ), |
| 4626 | stroke: stroke, |
| 4627 | fill: fill, |
| 4628 | close: true, |
| 4629 | ) |
| 4630 | } |
| 4631 | |
| 4632 | /// Creates a path drawable in the shape of an arc. |
| 4633 | /// - x (float): The $x$ position of the start of the arc. |
| 4634 | /// - y (float): The $y$ position of the start of the arc. |
| 4635 | /// - z (float): The $z$ position of the start of the arc. |
| 4636 | /// - start (angle): The angle along an ellipse to start drawing the arc from. |
| 4637 | /// - stop (angle): The angle along an ellipse to stop drawing the arc at. |
| 4638 | /// - rx (float): The radius of the arc in the $x$ axis. |
| 4639 | /// - ry (float): The radius of the arc in the $y$ axis. |
| 4640 | /// - mode (str): How to draw the arc: `"OPEN"` leaves the path open, `"CLOSED"` closes the arc by drawing a straight line between the end of the arc and its start, `"PIE"` also closes the arc by drawing a line from its end to its origin then to its start. |
| 4641 | /// - fill (color,none): The color to fill the arc with. |
| 4642 | /// - stroke (stroke): The stroke of the arc's path. |
| 4643 | /// -> drawable |
| 4644 | #let arc(x, y, z, start, stop, rx, ry, mode: "OPEN", fill: none, stroke: none) = { |
| 4645 | let delta = calc.max(-360deg, calc.min(stop - start, 360deg)) |
| 4646 | let num-curves = calc.max(1, calc.min(calc.ceil(calc.abs(delta) / 90deg), 4)) |
| 4647 | |
| 4648 | // Move x/y to the center |
| 4649 | x -= rx * calc.cos(start) |
| 4650 | y -= ry * calc.sin(start) |
| 4651 | |
| 4652 | // Calculation of control points is based on the method described here: |
| 4653 | // https://pomax.github.io/bezierinfo/#circles_cubic |
| 4654 | let segments = () |
| 4655 | for n in range(0, num-curves) { |
| 4656 | let start = start + delta / num-curves * n |
| 4657 | let stop = start + delta / num-curves |
| 4658 | |
| 4659 | let d = delta / num-curves |
| 4660 | let k = 4 / 3 * calc.tan(d / 4) |
| 4661 | |
| 4662 | let sx = x + rx * calc.cos(start) |
| 4663 | let sy = y + ry * calc.sin(start) |
| 4664 | let ex = x + rx * calc.cos(stop) |
| 4665 | let ey = y + ry * calc.sin(stop) |
| 4666 | |
| 4667 | let s = (sx, sy, z) |
| 4668 | let c1 = ( |
| 4669 | x + rx * (calc.cos(start) - k * calc.sin(start)), |
| 4670 | y + ry * (calc.sin(start) + k * calc.cos(start)), |
| 4671 | z, |
| 4672 | ) |
| 4673 | let c2 = ( |
| 4674 | x + rx * (calc.cos(stop) + k * calc.sin(stop)), |
| 4675 | y + ry * (calc.sin(stop) - k * calc.cos(stop)), |
| 4676 | z, |
| 4677 | ) |
| 4678 | let e = (ex, ey, z) |
| 4679 | |
| 4680 | segments.push(path-util.cubic-segment(s, e, c1, c2)) |
| 4681 | } |
| 4682 | |
| 4683 | if mode == "PIE" and calc.abs(delta) < 360deg { |
| 4684 | segments.push(path-util.line-segment(( |
| 4685 | path-util.segment-end(segments.last()), |
| 4686 | (x, y, z), |
| 4687 | path-util.segment-start(segments.first())))) |
| 4688 | } |
| 4689 | |
| 4690 | return path( |
| 4691 | fill: fill, |
| 4692 | stroke: stroke, |
| 4693 | close: mode != "OPEN", |
| 4694 | segments |
| 4695 | ) |
| 4696 | } |
| 4697 | #import "/src/vector.typ" |
| 4698 | #import "/src/complex.typ" |
| 4699 | |
| 4700 | // Implementation by @Enivex |
| 4701 | // |
| 4702 | // Notation comes from https://tug.org/TUGboat/tb34-2/tb107jackowski.pdf |
| 4703 | // |
| 4704 | // points = (P_0, P_1, ... , P_n) |
| 4705 | // |
| 4706 | // ta = (tau_(a,0),tau_(a,1), ... , tau_(a,n-1)) |
| 4707 | // ta(i) = tau_(a,i) is the outgoing tension at P_i on the curve from P_i to P_(i+1) |
| 4708 | // |
| 4709 | // tb = (tau_(b,0),tau(b,1), ..., tau_(b,n-1)) |
| 4710 | // tb(i) = tau_(b,i) is the incoming tension at P_(i+1) on the curve from P_i to P_(i+1) |
| 4711 | // |
| 4712 | // omega = (omega_0,omega_n) |
| 4713 | // curl at the start and end of curve (size of mock curvature relative to nearest point) |
| 4714 | // |
| 4715 | // v(i) = P_(i+1) - P_i |
| 4716 | // vector pointing from point i to point i + 1 (direction of ith chord) |
| 4717 | // |
| 4718 | // d(i) = |v(i)| |
| 4719 | // length of the ith chord |
| 4720 | // |
| 4721 | // gamma(i) = signed angle from v(i - 1) to v(i) (change in angle at P_i) |
| 4722 | // Note: gamma(0) = gamma(n) = 0 |
| 4723 | // |
| 4724 | // ca(i) = first control point on chord i |
| 4725 | // cb(i) = second control point on chord i |
| 4726 | // |
| 4727 | // alpha(i) = signed angle from v(i) to ca(i) - P(i) |
| 4728 | // beta(i) = signed angle from P(i + 1) - cb(i) to v(i) |
| 4729 | |
| 4730 | // Solve tridiagonal system |
| 4731 | // |
| 4732 | // - a,b,c,d have the same length, n + 1 |
| 4733 | // - a(0) and c(n) are not used |
| 4734 | // |
| 4735 | // Solves Ax=d |
| 4736 | // where A= |
| 4737 | // [ b_0 c_0 |
| 4738 | // a_1 b_1 c_1 |
| 4739 | // ...... |
| 4740 | // a_(n-1) b_(n-1) c_(n-1) |
| 4741 | // a_n b_n ] |
| 4742 | #let thomas(a, b, c, d) = { |
| 4743 | let n = a.len() - 1 |
| 4744 | |
| 4745 | for i in range(1,n + 1) { |
| 4746 | let w = a.at(i) / b.at(i - 1) |
| 4747 | b.at(i) = b.at(i) - w*c.at(i - 1) |
| 4748 | d.at(i) = d.at(i) - w*d.at(i - 1) |
| 4749 | } |
| 4750 | |
| 4751 | let x = (0,)*(n + 1) |
| 4752 | x.last() = d.last() / b.last() |
| 4753 | for i in range(n - 1, -1 , step: -1) { |
| 4754 | x.at(i) = (d.at(i) - c.at(i)*x.at(i + 1)) / b.at(i) |
| 4755 | } |
| 4756 | return x |
| 4757 | } |
| 4758 | |
| 4759 | // Solve cyclic tridiagonal system |
| 4760 | // |
| 4761 | // - a,b,c,d have the same length, n+1 |
| 4762 | // |
| 4763 | // Solves Ax=d |
| 4764 | // where A= |
| 4765 | // [ b_0 c_0 a_0 |
| 4766 | // a_1 b_1 c_1 |
| 4767 | // ...... |
| 4768 | // a_(n-1) b_(n-1) c_(n-1) |
| 4769 | // c_n a_n b_n ] |
| 4770 | #let thomas-cyclic(a, b, c, d) = { |
| 4771 | let n = a.len() - 1 |
| 4772 | |
| 4773 | let u = (a.first(),) + (0,)*(n - 1) + (c.last(),) |
| 4774 | let v = (1,) + (0,)*(n - 1) + (1,) |
| 4775 | |
| 4776 | let bp = array.zip(b,u).map(((s,t)) => s - t) |
| 4777 | let y = thomas(a, bp, c, d) |
| 4778 | let z = thomas(a, bp, c, u) |
| 4779 | |
| 4780 | // Sherman-Morrison formula |
| 4781 | return vector.sub(y, vector.scale(z, vector.dot(v,y) / (1 + vector.dot(v, z)))) |
| 4782 | } |
| 4783 | |
| 4784 | /// Calculates a bezier spline for an open Hobby curve through a list of points. Returns an {{array}} of {{bezier}}s |
| 4785 | /// |
| 4786 | /// - points (array): List of points |
| 4787 | /// - ta (auto,array): Outgoing tension per point |
| 4788 | /// - tb (auto,array): Incoming tension per point |
| 4789 | /// - rho (auto,function): The rho function of the form `(float, float) => float` |
| 4790 | /// - omega (auto,array): Tuple of the curl at the start end end of the curve `(start, end)` as floats |
| 4791 | /// |
| 4792 | /// -> array |
| 4793 | #let hobby-to-cubic-open(points, ta: auto, tb: auto, rho: auto, omega: auto) = { |
| 4794 | let n = points.len() - 1 |
| 4795 | |
| 4796 | if ta == auto { |
| 4797 | ta = (1,)*n |
| 4798 | } else { |
| 4799 | assert.eq(type(ta), array, message: "ta must be an array") |
| 4800 | assert.eq(ta.len(), n, message: "ta must have length n for n + 1 points") |
| 4801 | assert(ta.all(x => x > 0), message: "ta must contain only positive numbers") |
| 4802 | } |
| 4803 | if tb == auto { |
| 4804 | tb = (1,)*n |
| 4805 | } else { |
| 4806 | assert.eq(type(tb), array, message: "tb must be an array") |
| 4807 | assert.eq(tb.len(), n, message: "tb must have length n for n + 1 points") |
| 4808 | assert(tb.all(x => x > 0), message: "tb must contain only positive numbers") |
| 4809 | } |
| 4810 | if rho == auto { |
| 4811 | rho = (a,b) => { |
| 4812 | (2 + calc.sqrt(2)*(calc.sin(a) - calc.sin(b)/16)*(calc.sin(b)-calc.sin(a)/16)*(calc.cos(a)-calc.cos(b)))/(1 + calc.cos(a)*(calc.sqrt(5)-1)/2 + calc.cos(b)*(3-calc.sqrt(5))/2) |
| 4813 | } |
| 4814 | } else { |
| 4815 | assert.eq(type(rho), function, |
| 4816 | message: "rho must be a function") |
| 4817 | } |
| 4818 | |
| 4819 | let v = range(n).map(i => complex.sub(points.at(i + 1),points.at(i))) |
| 4820 | let d = v.map(complex.norm) |
| 4821 | |
| 4822 | let gamma = (0,) + range(n - 1).map(i => complex.ang(v.at(i),v.at(i + 1))) + (0,) |
| 4823 | |
| 4824 | let ita = ta.map(x => 1/x) |
| 4825 | let itasq = ita.map(x => x*x) |
| 4826 | |
| 4827 | let itb = tb.map(x => 1/x) |
| 4828 | let itbsq = itb.map(x => x*x) |
| 4829 | |
| 4830 | let (omega0, omegan) = omega |
| 4831 | |
| 4832 | let A = (0,) * (n + 1); let B = A; let C = A; let D = A; let E = A |
| 4833 | |
| 4834 | C.at(0) = omega0 * ita.at(0) * itasq.at(0) / itbsq.at(0) + 3 - itb.at(0) |
| 4835 | D.at(0) = omega0 * itasq.at(0) / itbsq.at(0) * (3 - ita.at(0)) + itb.at(0) |
| 4836 | E.at(0) = - D.at(0) * gamma.at(1) |
| 4837 | |
| 4838 | for i in range(1, n) { |
| 4839 | A.at(i) = ita.at(i - 1) / (d.at(i - 1) * itbsq.at(i - 1)) |
| 4840 | B.at(i) = (3 - ita.at(i - 1))/(d.at(i - 1) * itbsq.at(i - 1)) |
| 4841 | C.at(i) = (3 - itb.at(i))/(d.at(i) * itasq.at(i)) |
| 4842 | D.at(i) = itb.at(i) / (d.at(i) * itasq.at(i)) |
| 4843 | E.at(i) = - B.at(i) * gamma.at(i) - D.at(i) * gamma.at(i + 1) |
| 4844 | } |
| 4845 | |
| 4846 | A.at(n) = omegan * itbsq.at(n - 1) / itasq.at(n - 1) * ( 3 - itb.at(n - 1)) + ita.at(n - 1) |
| 4847 | B.at(n) = omegan * itb.at(n - 1) * itbsq.at(n - 1) / itasq.at(n - 1) + 3 - ita.at(n - 1) |
| 4848 | |
| 4849 | let alpha = thomas(A,vector.add(B,C), D, E) |
| 4850 | let beta = vector.scale(vector.add(alpha,gamma).slice(1), -1) |
| 4851 | |
| 4852 | let ca = (0,)*n; let cb = ca |
| 4853 | for i in range(n) { |
| 4854 | let a = rho(alpha.at(i),beta.at(i)) * d.at(i) / 3 |
| 4855 | let b = rho(beta.at(i),alpha.at(i)) * d.at(i) / 3 |
| 4856 | ca.at(i) = complex.add(points.at(i), complex.scale(complex.unit(complex.rot(v.at(i),alpha.at(i))), a)) |
| 4857 | cb.at(i) = complex.sub(points.at(i + 1), complex.scale(complex.unit(complex.rot(v.at(i),-beta.at(i))), b)) |
| 4858 | } |
| 4859 | return range(n).map(i => (points.at(i), points.at(i+1), ca.at(i), cb.at(i))) |
| 4860 | } |
| 4861 | |
| 4862 | /// Calculates a bezier spline for a closed Hobby curve through a list of points. Returns an {{array}} of {{bezier}}s. |
| 4863 | /// |
| 4864 | /// - points (array): List of points |
| 4865 | /// - ta (auto,array): Outgoing tension per point |
| 4866 | /// - tb (auto,array): Incoming tension per point |
| 4867 | /// - rho (auto,array): The rho function of the form `(float, b) => float` |
| 4868 | /// |
| 4869 | /// -> array |
| 4870 | #let hobby-to-cubic-closed(points, ta: auto, tb: auto, rho: auto) = { |
| 4871 | if points.first() != points.last() { |
| 4872 | points.push(points.first()) |
| 4873 | } |
| 4874 | |
| 4875 | let n = points.len() - 1 |
| 4876 | points.push(points.at(1)) |
| 4877 | |
| 4878 | if ta == auto { |
| 4879 | ta = (1,)*n |
| 4880 | } else { |
| 4881 | assert.eq(type(ta), array, message: "ta must be an array") |
| 4882 | assert.eq(ta.len(), n, message: "ta must have length n for n + 1 points") |
| 4883 | assert(ta.all(x => x > 0), message: "ta must contain only positive numbers") |
| 4884 | } |
| 4885 | if tb == auto { |
| 4886 | tb = (1,)*n |
| 4887 | } else { |
| 4888 | assert.eq(type(tb), array, message: "tb must be an array") |
| 4889 | assert.eq(tb.len(), n, message: "tb must have length n for n + 1 points") |
| 4890 | assert(tb.all(x => x > 0), message: "tb must contain only positive numbers") |
| 4891 | } |
| 4892 | if rho == auto { |
| 4893 | rho = (a,b) => { |
| 4894 | (2 + calc.sqrt(2)*(calc.sin(a) - calc.sin(b)/16)*(calc.sin(b)-calc.sin(a)/16)*(calc.cos(a)-calc.cos(b)))/(1 + calc.cos(a)*(calc.sqrt(5)-1)/2 + calc.cos(b)*(3-calc.sqrt(5))/2) |
| 4895 | } |
| 4896 | } else { |
| 4897 | assert.eq(type(rho), function, |
| 4898 | message: "rho must be a function") |
| 4899 | } |
| 4900 | |
| 4901 | let v = range(n + 1).map(i => complex.sub(points.at(i + 1),points.at(i))) |
| 4902 | let d = v.map(complex.norm) |
| 4903 | |
| 4904 | let gamma = range(n).map(i => complex.ang(v.at(i),v.at(i + 1))) |
| 4905 | gamma = (gamma.last(),..gamma) |
| 4906 | |
| 4907 | let ita = ta.map(x => 1/x) |
| 4908 | let itasq = ita.map(x => x*x) |
| 4909 | |
| 4910 | let itb = tb.map(x => 1/x) |
| 4911 | let itbsq = itb.map(x => x*x) |
| 4912 | |
| 4913 | let A = (0,) * n; let B = A; let C = A; let D = A; let E = A |
| 4914 | |
| 4915 | A.at(0) = ita.at(n - 1) / (d.at(n - 1) * itbsq.at(n - 1)) |
| 4916 | B.at(0) = (3 - ita.at(n - 1))/(d.at(n - 1) * itbsq.at(n - 1)) |
| 4917 | C.at(0) = (3 - itb.at(0))/(d.at(0) * itasq.at(0)) |
| 4918 | D.at(0) = itb.at(0) / (d.at(0) * itasq.at(0)) |
| 4919 | E.at(0) = - B.at(0) * gamma.at(0) - D.at(0) * gamma.at(1) |
| 4920 | |
| 4921 | for i in range(1, n) { |
| 4922 | A.at(i) = ita.at(i - 1) / (d.at(i - 1) * itbsq.at(i - 1)) |
| 4923 | B.at(i) = (3 - ita.at(i - 1))/(d.at(i - 1) * itbsq.at(i - 1)) |
| 4924 | C.at(i) = (3 - itb.at(i))/(d.at(i) * itasq.at(i)) |
| 4925 | D.at(i) = itb.at(i) / (d.at(i) * itasq.at(i)) |
| 4926 | E.at(i) = - B.at(i) * gamma.at(i) - D.at(i) * gamma.at(i + 1) |
| 4927 | } |
| 4928 | |
| 4929 | let alpha = thomas-cyclic(A,vector.add(B,C), D, E) |
| 4930 | alpha.push(alpha.at(0)) |
| 4931 | let beta = vector.scale(vector.add(alpha,gamma), -1) |
| 4932 | beta = (..beta.slice(1),beta.at(0)) |
| 4933 | |
| 4934 | let ca = (0,) * n; let cb = ca |
| 4935 | for i in range(n) { |
| 4936 | let a = rho(alpha.at(i),beta.at(i)) * d.at(i) / 3 |
| 4937 | let b = rho(beta.at(i),alpha.at(i)) * d.at(i) / 3 |
| 4938 | ca.at(i) = complex.add(points.at(i), complex.scale(complex.unit(complex.rot(v.at(i),alpha.at(i))), a)) |
| 4939 | cb.at(i) = complex.sub(points.at(i + 1), complex.scale(complex.unit(complex.rot(v.at(i),-beta.at(i))), b)) |
| 4940 | } |
| 4941 | |
| 4942 | return range(n).map(i => (points.at(i), points.at(i+1), ca.at(i), cb.at(i))) |
| 4943 | } |
| 4944 | |
| 4945 | /// Calculates a bezier spline for a Hobby curve through a list of points. Returns an {{array}} of {{bezier}}s. |
| 4946 | /// |
| 4947 | /// - points (array): List of points |
| 4948 | /// - ta (auto,array): Outgoing tension per point |
| 4949 | /// - tb (auto,array): Incoming tension per point |
| 4950 | /// - rho (auto,array): The rho function of the form `(float, float) => float` |
| 4951 | /// - omega (auto,array): Tuple of the curl at the start end end of the curve `(start, end)` as floats |
| 4952 | /// - close (bool): Close the curve |
| 4953 | /// |
| 4954 | /// -> array |
| 4955 | #let hobby-to-cubic(points, ta: auto, tb: auto, rho: auto, omega: auto, close: false) = { |
| 4956 | let omega = if omega == auto { |
| 4957 | (1, 1) |
| 4958 | } else if type(omega) == array { |
| 4959 | omega |
| 4960 | } else { |
| 4961 | (omega, omega) |
| 4962 | } |
| 4963 | assert.eq(type(omega), array, |
| 4964 | message: "Omega must be of type array") |
| 4965 | assert.eq(omega.len(), 2, |
| 4966 | message: "Omega must be of length 2") |
| 4967 | assert(omega.all(x => x >= 0), |
| 4968 | message: "Omega must contain positive values only") |
| 4969 | |
| 4970 | if not close and points.len() == 2 { |
| 4971 | let (a, b) = points |
| 4972 | return ((a, b, a, b),) |
| 4973 | } |
| 4974 | |
| 4975 | return if close { |
| 4976 | hobby-to-cubic-closed(points, ta: ta, tb: tb, rho: rho) |
| 4977 | } else { |
| 4978 | hobby-to-cubic-open(points, ta: ta, tb: tb, rho: rho, omega: omega) |
| 4979 | } |
| 4980 | } |
| 4981 | #import "vector.typ" |
| 4982 | #import "util.typ" |
| 4983 | |
| 4984 | /// Checks for a line-line intersection between the given points and returns its position, otherwise {{none}}. |
| 4985 | /// |
| 4986 | /// - a (vector): Line 1 point 1 |
| 4987 | /// - b (vector): Line 1 point 2 |
| 4988 | /// - c (vector): Line 2 point 1 |
| 4989 | /// - d (vector): Line 2 point 2 |
| 4990 | /// - ray (bool): When `true`, intersections will be found for the whole line instead of inbetween the given points. |
| 4991 | /// -> vector,none |
| 4992 | #let line-line(a, b, c, d, ray: false) = { |
| 4993 | let lli8(x1, y1, x2, y2, x3, y3, x4, y4) = { |
| 4994 | let nx = (x1*y2 - y1*x2)*(x3 - x4)-(x1 - x2)*(x3*y4 - y3*x4) |
| 4995 | let ny = (x1*y2 - y1*x2)*(y3 - y4)-(y1 - y2)*(x3*y4 - y3*x4) |
| 4996 | let d = (x1 - x2)*(y3 - y4)-(y1 - y2)*(x3 - x4) |
| 4997 | if d == 0 { |
| 4998 | return none |
| 4999 | } |
| 5000 | return (nx / d, ny / d, 0) |
| 5001 | } |
| 5002 | let pt = lli8(a.at(0), a.at(1), b.at(0), b.at(1), |
| 5003 | c.at(0), c.at(1), d.at(0), d.at(1)) |
| 5004 | if pt != none { |
| 5005 | let on-line(pt, a, b) = { |
| 5006 | let (x, y, ..) = pt |
| 5007 | let epsilon = util.float-epsilon |
| 5008 | let mx = calc.min(a.at(0), b.at(0)) - epsilon |
| 5009 | let my = calc.min(a.at(1), b.at(1)) - epsilon |
| 5010 | let Mx = calc.max(a.at(0), b.at(0)) + epsilon |
| 5011 | let My = calc.max(a.at(1), b.at(1)) + epsilon |
| 5012 | return mx <= x and Mx >= x and my <= y and My >= y |
| 5013 | } |
| 5014 | if ray or (on-line(pt, a, b) and on-line(pt, c, d)) { |
| 5015 | return pt |
| 5016 | } |
| 5017 | } |
| 5018 | } |
| 5019 | |
| 5020 | /// Finds the intersections of a line and cubic bezier. |
| 5021 | /// |
| 5022 | /// - s (vector): Bezier start point |
| 5023 | /// - e (vector): Bezier end point |
| 5024 | /// - c1 (vector): Bezier control point 1 |
| 5025 | /// - c2 (vector): Bezier control point 2 |
| 5026 | /// - la (vector): Line start point |
| 5027 | /// - lb (vector): Line end point |
| 5028 | /// - ray (bool): When `true`, intersections will be found for the whole line instead of inbetween the given points. |
| 5029 | /// -> array |
| 5030 | #let line-cubic(la, lb, s, e, c1, c2) = { |
| 5031 | import "/src/bezier.typ": line-cubic-intersections as line-cubic |
| 5032 | return line-cubic(la, lb, s, e, c1, c2) |
| 5033 | } |
| 5034 | |
| 5035 | /// Finds the intersections of a line and linestrip. |
| 5036 | /// - la (vector): Line start point. |
| 5037 | /// - lb (vector): Line end point. |
| 5038 | /// - v (array): An {{array}} of {{vector}}s that define each point on the linestrip. |
| 5039 | /// -> array |
| 5040 | #let line-linestrip(la, lb, v) = { |
| 5041 | let pts = () |
| 5042 | for i in range(0, v.len() - 1) { |
| 5043 | let pt = line-line(la, lb, v.at(i), v.at(i + 1)) |
| 5044 | if pt != none { |
| 5045 | pts.push(pt) |
| 5046 | } |
| 5047 | } |
| 5048 | return pts |
| 5049 | } |
| 5050 | |
| 5051 | /// Finds the intersections of a line and path in 2D. The path should be given as a {{drawable}} of type `path`. |
| 5052 | /// |
| 5053 | /// - la (vector): Line start |
| 5054 | /// - lb (vector): Line end |
| 5055 | /// - path (drawable): The path. |
| 5056 | /// -> array |
| 5057 | #let line-path(la, lb, path) = { |
| 5058 | let segment(s) = { |
| 5059 | let (k, ..v) = s |
| 5060 | if k == "line" { |
| 5061 | return line-linestrip(la, lb, v) |
| 5062 | } else if k == "cubic" { |
| 5063 | return line-cubic(la, lb, ..v) |
| 5064 | } else { |
| 5065 | return () |
| 5066 | } |
| 5067 | } |
| 5068 | |
| 5069 | let pts = () |
| 5070 | for s in path.at("segments", default: ()) { |
| 5071 | pts += segment(s) |
| 5072 | } |
| 5073 | return pts |
| 5074 | } |
| 5075 | |
| 5076 | /// Finds the intersections between two path {{drawable}}s in 2D. |
| 5077 | /// |
| 5078 | /// - a (path): Path a |
| 5079 | /// - b (path): Path b |
| 5080 | /// - samples (int): Number of samples to use for bezier curves |
| 5081 | /// -> array |
| 5082 | #let path-path(a, b, samples: 8) = { |
| 5083 | import "bezier.typ": cubic-point |
| 5084 | |
| 5085 | // Convert segment to vertices by sampling curves |
| 5086 | let linearize-segment(s) = { |
| 5087 | let t = s.at(0) |
| 5088 | if t == "line" { |
| 5089 | return s.slice(1) |
| 5090 | } else if t == "cubic" { |
| 5091 | return range(samples + 1).map( |
| 5092 | t => cubic-point(..s.slice(1), t/samples) |
| 5093 | ) |
| 5094 | } |
| 5095 | } |
| 5096 | |
| 5097 | let pts = () |
| 5098 | for s in a.at("segments", default: ()) { |
| 5099 | let sv = linearize-segment(s) |
| 5100 | for ai in range(0, sv.len() - 1) { |
| 5101 | pts += line-path(sv.at(ai), sv.at(ai + 1), b) |
| 5102 | } |
| 5103 | } |
| 5104 | return pts |
| 5105 | } |
| 5106 | #import "version.typ": version |
| 5107 | |
| 5108 | #import "canvas.typ": canvas |
| 5109 | #import "draw.typ" |
| 5110 | |
| 5111 | // Expose utilities |
| 5112 | #import "vector.typ" |
| 5113 | #import "matrix.typ" |
| 5114 | #import "styles.typ" |
| 5115 | #import "coordinate.typ" |
| 5116 | #import "intersection.typ" |
| 5117 | #import "drawable.typ" |
| 5118 | #import "process.typ" |
| 5119 | #import "util.typ" |
| 5120 | #import "path-util.typ" |
| 5121 | #import "mark.typ" |
| 5122 | #import "mark-shapes.typ" |
| 5123 | #import "sorting.typ" |
| 5124 | |
| 5125 | // Libraries |
| 5126 | #import "lib/palette.typ" |
| 5127 | #import "lib/angle.typ" |
| 5128 | #import "lib/tree.typ" |
| 5129 | #import "lib/decorations.typ" |
| 5130 | #import "/src/drawable.typ" |
| 5131 | #import "/src/styles.typ" |
| 5132 | #import "/src/vector.typ" |
| 5133 | #import "/src/util.typ" |
| 5134 | #import "/src/coordinate.typ" |
| 5135 | #import "/src/anchor.typ" as anchor_ |
| 5136 | #import "/src/draw.typ" |
| 5137 | |
| 5138 | // Angle default-style |
| 5139 | #let default-style = ( |
| 5140 | fill: none, |
| 5141 | stroke: auto, |
| 5142 | radius: .5, |
| 5143 | label-radius: 50%, |
| 5144 | mark: auto, |
| 5145 | ) |
| 5146 | |
| 5147 | /// Draw an angle counter-clock-wise between `a` and `b` through origin `origin` |
| 5148 | /// |
| 5149 | /// ```typc example |
| 5150 | /// line((0,0), (1,1.5), name: "a") |
| 5151 | /// line((0,0), (2,-1), name: "b") |
| 5152 | /// |
| 5153 | /// // Draw an angle between the two lines |
| 5154 | /// cetz.angle.angle("a.start", "a.end", "b.end", label: $ alpha $, |
| 5155 | /// mark: (end: ">"), radius: 1.5) |
| 5156 | /// cetz.angle.angle("a.start", "b.end", "a.end", label: $ alpha' $, |
| 5157 | /// radius: 50%, direction: "cw") |
| 5158 | /// ``` |
| 5159 | /// |
| 5160 | /// - origin (coordinate): Angle origin |
| 5161 | /// - a (coordinate): Coordinate of side `a`, containing an angle between `origin` and `b`. |
| 5162 | /// - b (coordinate): Coordinate of side `b`, containing an angle between `origin` and `a`. |
| 5163 | /// - direction (string): Direction of the angle. Accepts "cw" (clockwise) and "ccw" (counter-clockwise), the latter being the default. |
| 5164 | /// - label (none,content,function): Draw a label at the angles "label" anchor. If label is a function, it gets the angle value passed as argument. The function must be of the format `angle => content`. |
| 5165 | /// - name (none,str): Element name, used for querying anchors. |
| 5166 | /// - ..style (style): Style key-value pairs. |
| 5167 | /// |
| 5168 | /// ## Styling |
| 5169 | /// *Root:* `angle` \ |
| 5170 | /// |
| 5171 | /// - radius (number) = 0.5: The radius of the angles arc. If of type `ratio`, it is relative to the smaller distance of either origin to a or origin to b. |
| 5172 | /// - label-radius (number, ratio) = 50%: The radius of the angles label origin. If of type `ratio`, it is relative to `radius`. |
| 5173 | /// |
| 5174 | /// ## Anchors |
| 5175 | /// - **a** Point a |
| 5176 | /// - **b** Point b |
| 5177 | /// - **origin** Origin |
| 5178 | /// - **label** Label center |
| 5179 | /// - **start** Arc start |
| 5180 | /// - **end** Arc end |
| 5181 | #let angle( |
| 5182 | origin, |
| 5183 | a, |
| 5184 | b, |
| 5185 | direction: "ccw", |
| 5186 | label: none, |
| 5187 | name: none, |
| 5188 | ..style |
| 5189 | ) = draw.group(name: name, ctx => { |
| 5190 | let style = styles.resolve(ctx.style, merge: style.named(), base: default-style, root: "angle") |
| 5191 | let radius = util.resolve-number(ctx, style.radius) |
| 5192 | let label-radius = util.resolve-number(ctx, style.label-radius) |
| 5193 | |
| 5194 | let (ctx, origin) = coordinate.resolve(ctx, origin) |
| 5195 | let (ctx, a, b) = coordinate.resolve(ctx, a, b, update: false) |
| 5196 | |
| 5197 | assert(origin.at(2) == a.at(2) and a.at(2) == b.at(2), |
| 5198 | message: "Angle z coordinates of all three points must be equal") |
| 5199 | |
| 5200 | assert(direction in ("cw", "ccw"), |
| 5201 | message: "Invalid angle direction " + repr(direction)) |
| 5202 | |
| 5203 | let (start, delta, ccw) = { |
| 5204 | let ccw = direction == "ccw" |
| 5205 | |
| 5206 | let s = vector.angle2(origin, a) |
| 5207 | if s < 0deg { s += 360deg } |
| 5208 | |
| 5209 | let e = vector.angle2(origin, b) |
| 5210 | if e < 0deg { e += 360deg } |
| 5211 | |
| 5212 | if e < s { |
| 5213 | e += 360deg |
| 5214 | } |
| 5215 | |
| 5216 | if ccw { |
| 5217 | (s, (e - s), ccw) |
| 5218 | } else { |
| 5219 | (s, -(360deg - (e - s)), ccw) |
| 5220 | } |
| 5221 | } |
| 5222 | |
| 5223 | let mid = start + delta / 2 |
| 5224 | |
| 5225 | // Radius can be relative to the min-distance between origin-a and origin-b |
| 5226 | if type(radius) == ratio { |
| 5227 | radius = radius * calc.min(vector.dist(origin, a), vector.dist(origin, b)) / 100% |
| 5228 | } |
| 5229 | |
| 5230 | // Label radius can be relative to radius |
| 5231 | if type(label-radius) == ratio { |
| 5232 | label-radius = label-radius * radius / 100% |
| 5233 | } |
| 5234 | |
| 5235 | let label-pt = vector.add(origin, (calc.cos(mid) * label-radius, calc.sin(mid) * label-radius, 0)) |
| 5236 | let start-pt = vector.add(origin, (calc.cos(start) * radius, calc.sin(start) * radius, 0)) |
| 5237 | let end-pt = vector.add(origin, (calc.cos(start + delta) * radius, calc.sin(start + delta) * radius, 0)) |
| 5238 | draw.anchor("origin", origin) |
| 5239 | draw.anchor("label", label-pt) |
| 5240 | draw.anchor("start", start-pt) |
| 5241 | draw.anchor("end", end-pt) |
| 5242 | draw.anchor("a", a) |
| 5243 | draw.anchor("b", b) |
| 5244 | |
| 5245 | if delta != 0deg { |
| 5246 | if style.fill != none { |
| 5247 | draw.arc(origin, start: start, delta: delta, anchor: "origin", |
| 5248 | name: "arc", ..style, radius: radius, mode: "PIE", mark: none, stroke: none) |
| 5249 | } |
| 5250 | if style.stroke != none { |
| 5251 | draw.arc(origin, start: start, delta: delta, anchor: "origin", |
| 5252 | name: "arc", ..style, radius: radius, fill: none) |
| 5253 | } |
| 5254 | } |
| 5255 | |
| 5256 | let label = if type(label) == function { label(calc.abs(delta)) } else { label } |
| 5257 | if label != none { |
| 5258 | draw.content(label-pt, label) |
| 5259 | } |
| 5260 | }) |
| 5261 | |
| 5262 | /// Draw a right angle between `a` and `b` through origin `origin` |
| 5263 | /// |
| 5264 | /// ```typc example |
| 5265 | /// line((0,0), (1,2), name: "a") |
| 5266 | /// line((0,0), (2,-1), name: "b") |
| 5267 | /// |
| 5268 | /// // Draw an angle between the two lines |
| 5269 | /// cetz.angle.right-angle( |
| 5270 | /// "a.start", |
| 5271 | /// "a.end", |
| 5272 | /// "b.end", |
| 5273 | /// radius: 1.5 |
| 5274 | /// ) |
| 5275 | /// ``` |
| 5276 | /// |
| 5277 | /// - origin (coordinate): Angle origin |
| 5278 | /// - a (coordinate): Coordinate of side `a`, containing an angle between `origin` and `b`. |
| 5279 | /// - b (coordinate): Coordinate of side `b`, containing an angle between `origin` and `a`. |
| 5280 | /// - label (none,content): Draw a label at the angles "label" anchor. |
| 5281 | /// - name (none,str): Element name, used for querying anchors. |
| 5282 | /// - ..style (style): Style key-value pairs. |
| 5283 | /// |
| 5284 | /// ## Styling |
| 5285 | /// Styling is the same as the `angle` function. |
| 5286 | /// |
| 5287 | /// ## Anchors |
| 5288 | /// Anchors are the same as the `angle` function |
| 5289 | /// |
| 5290 | #let right-angle( |
| 5291 | origin, |
| 5292 | a, |
| 5293 | b, |
| 5294 | label: "•", |
| 5295 | name: none, |
| 5296 | ..style |
| 5297 | ) = draw.group(name: name, ctx => { |
| 5298 | let style = styles.resolve(ctx.style, merge: style.named(), base: default-style, root: "angle") |
| 5299 | let (ctx, origin) = coordinate.resolve(ctx, origin) |
| 5300 | let (ctx, a, b) = coordinate.resolve(ctx, a, b, update: false) |
| 5301 | let vo = origin; let va = a; let vb = b |
| 5302 | |
| 5303 | // Radius can be relative to the min-distance between origin-a and origin-b |
| 5304 | if type(style.radius) == ratio { |
| 5305 | style.radius = style.radius * calc.min(vector.dist(vo, va), vector.dist(vo, vb)) / 100% |
| 5306 | } |
| 5307 | let (r, _) = util.resolve-radius(style.radius).map(util.resolve-number.with(ctx)) |
| 5308 | |
| 5309 | let va = vector.add(vo, vector.scale(vector.norm(vector.sub(va, vo)), r)) |
| 5310 | let vb = vector.add(vo, vector.scale(vector.norm(vector.sub(vb, vo)), r)) |
| 5311 | let angle-b = vector.angle2(vo, vb) |
| 5312 | let vm = vector.add(va, (calc.cos(angle-b) * r, calc.sin(angle-b) * r, 0)) |
| 5313 | |
| 5314 | // Label radius can be relative to the distance between origin and the |
| 5315 | // angle corner |
| 5316 | if type(style.label-radius) == ratio { |
| 5317 | style.label-radius = style.label-radius * vector.dist(vm, vo) / 100% |
| 5318 | } |
| 5319 | let (ra, _) = util.resolve-radius(style.label-radius).map(util.resolve-number.with(ctx)) |
| 5320 | |
| 5321 | if style.fill != none { |
| 5322 | draw.line(vo, va, vm, vb, close: true, stroke: none, fill: style.fill) |
| 5323 | } |
| 5324 | draw.line(va, vm, vb, ..style, fill: none) |
| 5325 | |
| 5326 | let label-pt = vector.add(vo, vector.scale(vector.norm(vector.sub(vm, vo)), ra)) |
| 5327 | if label != none { |
| 5328 | draw.content(label-pt, label) |
| 5329 | } |
| 5330 | |
| 5331 | draw.anchor("a", a) |
| 5332 | draw.anchor("b", b) |
| 5333 | draw.anchor("origin", origin) |
| 5334 | draw.anchor("corner", vm) |
| 5335 | draw.anchor("label", label-pt) |
| 5336 | }) |
| 5337 | #import "decorations/brace.typ": brace, brace-default-style, flat-brace, flat-brace-default-style |
| 5338 | #import "decorations/path.typ": zigzag, wave, coil, square |
| 5339 | #import "/src/vector.typ" |
| 5340 | #import "/src/matrix.typ" |
| 5341 | #import "/src/util.typ" |
| 5342 | #import "/src/draw.typ": * |
| 5343 | #import "/src/coordinate.typ" |
| 5344 | #import "/src/styles.typ" |
| 5345 | |
| 5346 | // Rotates the vector 'ab' around 'a' and scales it to 'len', returns the absolute point 'c'. |
| 5347 | #let _rotate-around(a, b, angle: 90deg, len: auto) = { |
| 5348 | let rel = vector.sub(b, a) |
| 5349 | let rotated = util.apply-transform(matrix.transform-rotate-z(angle), rel) |
| 5350 | let scaled = if len == auto { |
| 5351 | rotated |
| 5352 | } else { |
| 5353 | vector.scale(vector.norm(rotated), len) |
| 5354 | } |
| 5355 | return vector.add(a, scaled) |
| 5356 | } |
| 5357 | |
| 5358 | #let brace-default-style = ( |
| 5359 | amplitude: .5, |
| 5360 | pointiness: 15deg, |
| 5361 | outer-pointiness: 0deg, |
| 5362 | content-offset: .3, |
| 5363 | flip: false, |
| 5364 | stroke: auto, |
| 5365 | fill: none, |
| 5366 | ) |
| 5367 | |
| 5368 | /// Draw a curly brace between two points. |
| 5369 | /// |
| 5370 | /// ```typc example |
| 5371 | /// cetz.decorations.brace((0,1),(2,1)) |
| 5372 | /// |
| 5373 | /// cetz.decorations.brace((0,0),(2,0), |
| 5374 | /// pointiness: 45deg, outer-pointiness: 45deg) |
| 5375 | /// cetz.decorations.brace((0,-1),(2,-1), |
| 5376 | /// pointiness: 90deg, outer-pointiness: 90deg) |
| 5377 | /// ``` |
| 5378 | /// |
| 5379 | /// - start (coordinate): Start point |
| 5380 | /// - end (coordinate): End point |
| 5381 | /// - name (string, none): Element name used for querying anchors |
| 5382 | /// - ..style (style): Style key-value pairs |
| 5383 | /// |
| 5384 | /// ## Styling |
| 5385 | /// |
| 5386 | /// *Root:* `brace` |
| 5387 | /// - amplitude (number) = 0.5: Sets the height of the brace, from its baseline to its middle tip. |
| 5388 | /// - pointiness (ratio, angle) = 15deg: How pointy the spike should be. `0deg` or `100%` for maximum pointiness, `90deg` or `0%` for minimum. |
| 5389 | /// - outer-pointiness (ratio, angle) = 15deg: How pointy the outer edges should be. `0deg` or `100%` for maximum pointiness (allowing for a smooth transition to a straight line), `90deg` or `0%` for minimum. Setting this to <Type>auto</Type> will use the value set for `pointiness`. |
| 5390 | /// - content-offset (number) = 0.3: Offset of the `"content"` anchor from the spike of the brace. |
| 5391 | /// - flip (bool) = false: Mirror the brace along the line between start and end. |
| 5392 | /// |
| 5393 | /// ## Anchors |
| 5394 | /// - **start** Where the brace starts, same as the `start` parameter. |
| 5395 | /// - **end** Where the brace end, same as the `end` parameter. |
| 5396 | /// - **spike** Point of the spike, halfway between `start` and `end` and shifted by `amplitude` towards the pointing direction. |
| 5397 | /// - **content** Point to place content/text at, in front of the spike. |
| 5398 | /// - **center** Center of the enclosing rectangle. |
| 5399 | #let brace(start, end, ..style, name: none) = { |
| 5400 | assert.eq(style.pos().len(), 0, |
| 5401 | message: "Brace takes no additional positional arugments.") |
| 5402 | |
| 5403 | // Validate coordinates |
| 5404 | let _ = (start, end).map(coordinate.resolve-system) |
| 5405 | |
| 5406 | group(name: name, ctx => { |
| 5407 | // Resolve all coordinates |
| 5408 | let (ctx, start, end) = coordinate.resolve(ctx, start, end) |
| 5409 | |
| 5410 | // Query and resolve style |
| 5411 | let style = styles.resolve(ctx.style, root: "brace", base: brace-default-style, merge: style.named()) |
| 5412 | |
| 5413 | let amplitude = util.resolve-number(ctx, style.amplitude) |
| 5414 | let content-offset = util.resolve-number(ctx, style.content-offset) |
| 5415 | let pointiness = if type(style.pointiness) == ratio { |
| 5416 | (1 - style.pointiness / 100%) * 90deg |
| 5417 | } else { style.pointiness } |
| 5418 | pointiness = calc.max(0deg, calc.min(pointiness, 90deg)) |
| 5419 | |
| 5420 | let outer-pointiness = if type(style.outer-pointiness) == ratio { |
| 5421 | (1 - style.outer-pointiness / 100%) * 90deg |
| 5422 | } else { style.outer-pointiness } |
| 5423 | outer-pointiness = calc.max(0deg, calc.min(outer-pointiness, 90deg)) * -1 |
| 5424 | |
| 5425 | let up = (0, 0, -1) |
| 5426 | let mid = vector.lerp(start, end, .5) |
| 5427 | |
| 5428 | let dir = vector.norm(vector.sub(end, start)) |
| 5429 | let normal = vector.cross(dir, up) |
| 5430 | if style.flip { |
| 5431 | normal = vector.scale(normal, -1) |
| 5432 | pointiness *= -1 |
| 5433 | outer-pointiness *= -1 |
| 5434 | } |
| 5435 | |
| 5436 | // Compute tip coordinate |
| 5437 | let tip = vector.add(mid, vector.scale(normal, calc.abs(amplitude))) |
| 5438 | |
| 5439 | // Measure distance between midpoint on start-end and tip |
| 5440 | let amplitude = vector.dist(mid, tip) |
| 5441 | |
| 5442 | // Add anchors |
| 5443 | anchor("start", start) |
| 5444 | anchor("end", end) |
| 5445 | anchor("default", mid) |
| 5446 | anchor("spike", tip) |
| 5447 | |
| 5448 | // Offset content anchor |
| 5449 | anchor("content", vector.add(tip, vector.scale(normal, content-offset))) |
| 5450 | |
| 5451 | merge-path({ |
| 5452 | let scale-amplitude(v) = { |
| 5453 | let max = vector.dist(start, end) / 2 |
| 5454 | vector.scale(v, calc.min(amplitude, max)) |
| 5455 | } |
| 5456 | |
| 5457 | let rotate-inner(factor) = { |
| 5458 | vector.rotate-z(normal, pointiness * factor) |
| 5459 | } |
| 5460 | |
| 5461 | let rotate-outer(factor) = { |
| 5462 | vector.rotate-z(normal, outer-pointiness * factor) |
| 5463 | } |
| 5464 | |
| 5465 | let dist = vector.dist(start, tip) + vector.dist(tip, end) |
| 5466 | let ratio = vector.dist(start, tip) / dist |
| 5467 | let b = vector.dist(end, tip) / dist |
| 5468 | |
| 5469 | bezier(start, tip, |
| 5470 | vector.add(start, scale-amplitude(rotate-outer(+1))), |
| 5471 | vector.sub(tip, scale-amplitude(rotate-inner(-1)))) |
| 5472 | bezier(tip, end, |
| 5473 | vector.sub(tip, scale-amplitude(rotate-inner(+1))), |
| 5474 | vector.add(end, scale-amplitude(rotate-outer(-1)))) |
| 5475 | }, stroke: style.stroke, fill: style.fill) |
| 5476 | |
| 5477 | move-to(end) |
| 5478 | }) |
| 5479 | } |
| 5480 | |
| 5481 | |
| 5482 | #let flat-brace-default-style = ( |
| 5483 | stroke: auto, |
| 5484 | fill: none, |
| 5485 | amplitude: .3, |
| 5486 | aspect: 50%, |
| 5487 | curves: (1, .5, .6, .15), |
| 5488 | outer-curves: auto, |
| 5489 | content-offset: .3, |
| 5490 | debug-text-size: 6pt, |
| 5491 | ) |
| 5492 | |
| 5493 | /// Draw a flat curly brace between two points. |
| 5494 | /// |
| 5495 | /// ```typc example |
| 5496 | /// cetz.decorations.flat-brace((0,1),(2,1)) |
| 5497 | /// |
| 5498 | /// cetz.decorations.flat-brace((0,0),(2,0), |
| 5499 | /// curves: .2, |
| 5500 | /// aspect: 25%) |
| 5501 | /// cetz.decorations.flat-brace((0,-1),(2,-1), |
| 5502 | /// outer-curves: 0, |
| 5503 | /// aspect: 75%) |
| 5504 | /// ``` |
| 5505 | /// |
| 5506 | /// This mimics the braces from TikZ's [`decorations.pathreplacing` library](https://github.com/pgf-tikz/pgf/blob/6e5fd71581ab04351a89553a259b57988bc28140/tex/generic/pgf/libraries/decorations/pgflibrarydecorations.pathreplacing.code.tex#L136-L185). |
| 5507 | /// In contrast to the `brace` function, these braces use straight line segments, resulting in better looks for long braces with a small amplitude. |
| 5508 | /// |
| 5509 | /// - start (coordinate): Start point |
| 5510 | /// - end (coordinate): End point |
| 5511 | /// - flip (bool): Flip the brace around |
| 5512 | /// - name (str, none): Element name for querying anchors |
| 5513 | /// - debug (bool): |
| 5514 | /// - ..style (style): Style key-value pairs |
| 5515 | /// |
| 5516 | /// ## Styling |
| 5517 | /// |
| 5518 | /// *Root:* `flat-brace` |
| 5519 | /// - amplitude (number) = 0.3: Determines how much the brace rises above the base line. |
| 5520 | /// - aspect (ratio) = 50% Determines the fraction of the total length where the spike will be placed. |
| 5521 | /// - curves (number, auto, array) = auto: Curviness factor of the brace, a factor of 0 means no curves. |
| 5522 | /// - outer-curves (number, auto, array) = auto: Curviness factor of the outer curves of the brace. A factor of 0 means no curves. |
| 5523 | /// |
| 5524 | /// ## Anchors |
| 5525 | /// - **start** Where the brace starts, same as the `start` parameter. |
| 5526 | /// - **end** Where the brace end, same as the `end` parameter. |
| 5527 | /// - **spike** Point of the spike's top. |
| 5528 | /// - **content** Point to place content/text at, in front of the spike. |
| 5529 | /// - **center** Center of the enclosing rectangle. |
| 5530 | #let flat-brace( |
| 5531 | start, |
| 5532 | end, |
| 5533 | flip: false, |
| 5534 | debug: false, |
| 5535 | name: none, |
| 5536 | ..style, |
| 5537 | ) = { |
| 5538 | // Validate coordinates |
| 5539 | let _ = (start, end).map(coordinate.resolve-system) |
| 5540 | |
| 5541 | group(name: name, ctx => { |
| 5542 | // Get styles and validate their types and values |
| 5543 | let style = styles.resolve(ctx.style, merge: style.named(), |
| 5544 | root: "flat-brace", base: flat-brace-default-style) |
| 5545 | |
| 5546 | let amplitude = style.amplitude |
| 5547 | assert( |
| 5548 | type(amplitude) in (int, float), |
| 5549 | message: "amplitude must be a number, got " + repr(amplitude), |
| 5550 | ) |
| 5551 | |
| 5552 | let aspect = style.aspect |
| 5553 | assert( |
| 5554 | (type(aspect) == ratio |
| 5555 | and aspect >= 0% and aspect <= 100%) |
| 5556 | or (type(aspect) in (int, float) |
| 5557 | and aspect >= 0 and aspect <= 1), |
| 5558 | message: "aspect must be a ratio between 0% and 100%, got " + repr(aspect), |
| 5559 | ) |
| 5560 | if type(aspect) == ratio { aspect /= 100% } |
| 5561 | |
| 5562 | let inner-curves = style.curves |
| 5563 | assert( |
| 5564 | type(inner-curves) in (int, float) |
| 5565 | or type(inner-curves) == array |
| 5566 | and inner-curves.all(v => type(v) in (int, float, type(auto))), |
| 5567 | message: "curves must be a number, or an array of numbers or auto, got " + repr(inner-curves), |
| 5568 | ) |
| 5569 | if type(inner-curves) in (int, float) { inner-curves = (inner-curves,) } |
| 5570 | while inner-curves.len() < flat-brace-default-style.curves.len() { |
| 5571 | inner-curves.push(auto) |
| 5572 | } |
| 5573 | inner-curves = inner-curves.enumerate().map(((idx, v)) => if v == auto { |
| 5574 | flat-brace-default-style.curves.at(idx) |
| 5575 | } else { v }) |
| 5576 | |
| 5577 | let outer-curves = style.outer-curves |
| 5578 | assert( |
| 5579 | type(outer-curves) in (int, float, type(auto)) |
| 5580 | or type(outer-curves) == array |
| 5581 | and outer-curves.all(v => type(v) in (int, float, type(auto))), |
| 5582 | message: "outer-curves must be auto, a number, or an array of numbers or auto, got " + repr(outer-curves), |
| 5583 | ) |
| 5584 | if outer-curves == auto { |
| 5585 | outer-curves = inner-curves |
| 5586 | } else { |
| 5587 | if type(outer-curves) in (int, float) { outer-curves = (outer-curves,) } |
| 5588 | while outer-curves.len() < inner-curves.len() { outer-curves.push(auto) } |
| 5589 | outer-curves = outer-curves.enumerate() |
| 5590 | .map(((idx, v)) => if v == auto { inner-curves.at(idx) } else { v }) |
| 5591 | } |
| 5592 | |
| 5593 | let content-offset = style.content-offset |
| 5594 | assert( |
| 5595 | type(content-offset) in (int, float), |
| 5596 | message: "content-offset must be a number, got " + repr(content-offset), |
| 5597 | ) |
| 5598 | |
| 5599 | // all the following code assumes the brace to start at (0, 0), growing to the right, |
| 5600 | // pointing upwards, so we set the origin and rotate the entire group accordingly |
| 5601 | let (_, start, end) = coordinate.resolve(ctx, start, end) |
| 5602 | set-origin(start) |
| 5603 | rotate(vector.angle2(start, end)) |
| 5604 | |
| 5605 | // we achieve flipping by inverting the amplitude |
| 5606 | if flip { |
| 5607 | amplitude *= -1 |
| 5608 | content-offset *= -1 |
| 5609 | } |
| 5610 | |
| 5611 | let length = vector.dist(start, end) |
| 5612 | let middle = aspect * length |
| 5613 | let horizon = amplitude / 2 |
| 5614 | |
| 5615 | let normal-outer = calc.abs(amplitude * outer-curves.at(0)) |
| 5616 | let normal-inner = calc.abs(amplitude * inner-curves.at(0)) |
| 5617 | let length-left = middle |
| 5618 | let length-right = length - middle |
| 5619 | |
| 5620 | // width of left-outer, left-inner, right-inner, right-outer curve segments |
| 5621 | let lo = if 2 * normal-outer > length-left { length-left / 2 } else { normal-outer } |
| 5622 | let li = if 2 * normal-inner > length-left { length-left / 2 } else { normal-inner } |
| 5623 | let ri = if 2 * normal-inner > length-right { length-right / 2 } else { normal-inner } |
| 5624 | let ro = if 2 * normal-outer > length-right { length-right / 2 } else { normal-outer } |
| 5625 | |
| 5626 | // 'a' and 'b' are start and end |
| 5627 | let a = ( 0, 0) |
| 5628 | let b = (length, 0) |
| 5629 | // 'c' is the spike's top |
| 5630 | let c = (middle, amplitude) |
| 5631 | // 'de' is the left line, 'fg' is the right line |
| 5632 | let d = ( lo, horizon) |
| 5633 | let e = (middle - li, horizon) |
| 5634 | let f = (middle + ri, horizon) |
| 5635 | let g = (length - ro, horizon) |
| 5636 | // 'h' is where to place content, above the spike |
| 5637 | let h = (middle, amplitude + content-offset) |
| 5638 | |
| 5639 | // list of all named points to show in debug mode |
| 5640 | let points = (a: a, b: b, c: c, d: d, e: e, f: f, g: g, h: h) |
| 5641 | |
| 5642 | // bezier control points: in 'dlc' 'd' stands for the point 'd' where the control point is used, |
| 5643 | // 'l' stands for left of spike, 'c' stands for control point |
| 5644 | let dlc = ( (1 - outer-curves.at(1)) * lo, horizon) |
| 5645 | let elc = (middle - (1 - inner-curves.at(1)) * li, horizon) |
| 5646 | let frc = (middle + (1 - inner-curves.at(1)) * ri, horizon) |
| 5647 | let grc = (length - (1 - outer-curves.at(1)) * ro, horizon) |
| 5648 | let alc = ( outer-curves.at(3) * lo, outer-curves.at(2) / 2 * amplitude) |
| 5649 | let clc = (middle - inner-curves.at(3) * li, (1 - inner-curves.at(2) / 2) * amplitude) |
| 5650 | let crc = (middle + inner-curves.at(3) * ri, (1 - inner-curves.at(2) / 2) * amplitude) |
| 5651 | let brc = (length - outer-curves.at(3) * ro, outer-curves.at(2) / 2 * amplitude) |
| 5652 | |
| 5653 | merge-path({ |
| 5654 | bezier(a, d, alc, dlc) |
| 5655 | bezier(e, c, elc, clc) |
| 5656 | bezier(c, f, crc, frc) |
| 5657 | bezier(g, b, grc, brc) |
| 5658 | }, stroke: style.stroke, fill: style.fill) |
| 5659 | |
| 5660 | // Define some named anchors |
| 5661 | anchor("spike", c) |
| 5662 | anchor("content", h) |
| 5663 | anchor("start", a) |
| 5664 | anchor("end", b) |
| 5665 | anchor("default", (d, 50%, g)) |
| 5666 | |
| 5667 | // Define anchors for all points |
| 5668 | for (name, point) in points { |
| 5669 | anchor(name, point) |
| 5670 | } |
| 5671 | |
| 5672 | if debug { |
| 5673 | // Show bezier control points using colored lines |
| 5674 | line(stroke: purple, a, alc) |
| 5675 | line(stroke: blue, d, dlc) |
| 5676 | line(stroke: olive, e, elc) |
| 5677 | line(stroke: red, c, clc) |
| 5678 | line(stroke: red, c, crc) |
| 5679 | line(stroke: olive, f, frc) |
| 5680 | line(stroke: blue, g, grc) |
| 5681 | line(stroke: purple, b, brc) |
| 5682 | // Show all named points |
| 5683 | for (name, point) in points { |
| 5684 | content(point, box(fill: luma(240), inset: .5pt, text(style.debug-text-size, raw(name)))) |
| 5685 | } |
| 5686 | } |
| 5687 | }) |
| 5688 | |
| 5689 | // Move to end point so the current position after this is the end position |
| 5690 | move-to(end) |
| 5691 | } |
| 5692 | // Library for drawing springs |
| 5693 | #import "/src/draw.typ" |
| 5694 | #import "/src/styles.typ" |
| 5695 | #import "/src/coordinate.typ" |
| 5696 | #import "/src/vector.typ" |
| 5697 | #import "/src/process.typ" |
| 5698 | #import "/src/path-util.typ" |
| 5699 | #import "/src/util.typ" |
| 5700 | #import "/src/bezier.typ" |
| 5701 | |
| 5702 | #let default-style = ( |
| 5703 | /// Number of segments |
| 5704 | segments: 10, |
| 5705 | /// Length of a single segments |
| 5706 | segment-length: none, |
| 5707 | |
| 5708 | /// Amplitude of a segment in the direction of the segments normal. |
| 5709 | /// The following types are supported: |
| 5710 | /// - float |
| 5711 | /// - function ratio -> float (the segment ratio is given as argument) |
| 5712 | /// - array of floats (the rounded down segment number is used as index modulo the array length) |
| 5713 | amplitude: 1, |
| 5714 | /// Decoration start |
| 5715 | start: 0%, |
| 5716 | /// Decoration stop |
| 5717 | stop: 100%, |
| 5718 | /// Decoration alignment on the target path |
| 5719 | align: "START", |
| 5720 | /// Draw remaining space as line ("LINE") or none |
| 5721 | rest: "LINE", |
| 5722 | |
| 5723 | /// Up-vector for 3D lines |
| 5724 | z-up: (0, 1, 0), |
| 5725 | /// Up-vector for 2D lines |
| 5726 | xy-up: (0, 0, -1), |
| 5727 | |
| 5728 | stroke: auto, |
| 5729 | fill: none, |
| 5730 | mark: auto, |
| 5731 | ) |
| 5732 | |
| 5733 | // Zig-Zag default style |
| 5734 | #let zigzag-default-style = ( |
| 5735 | ..default-style, |
| 5736 | /// Midpoint factor |
| 5737 | /// 0%: Sawtooth (up-down) |
| 5738 | /// 50%: Triangle |
| 5739 | /// 100%: Sawtooth (down-up) |
| 5740 | factor: 50%, |
| 5741 | ) |
| 5742 | |
| 5743 | // Wave default style |
| 5744 | #let wave-default-style = ( |
| 5745 | ..default-style, |
| 5746 | /// Wave (catmull-rom) tension |
| 5747 | tension: .5, |
| 5748 | ) |
| 5749 | |
| 5750 | // Coil default style |
| 5751 | #let coil-default-style = ( |
| 5752 | ..default-style, |
| 5753 | /// Coil "overshoot" factor |
| 5754 | factor: 150%, |
| 5755 | ) |
| 5756 | |
| 5757 | // Square default style |
| 5758 | #let square-default-style = ( |
| 5759 | ..default-style, |
| 5760 | /// Midpoint factor |
| 5761 | factor: 50%, |
| 5762 | ) |
| 5763 | |
| 5764 | #let resolve-amplitude(ctx, amplitude, segment, num-segments) = { |
| 5765 | segment = calc.max(0, calc.min(segment, num-segments)) |
| 5766 | let amp = if type(amplitude) == function { |
| 5767 | (amplitude)(segment / num-segments * 100%) |
| 5768 | } else if type(amplitude) == array { |
| 5769 | amplitude.at(calc.rem(int(2*segment), amplitude.len()), default: 0) |
| 5770 | } else { |
| 5771 | amplitude |
| 5772 | } |
| 5773 | return util.resolve-number(ctx, amp) |
| 5774 | } |
| 5775 | |
| 5776 | #let resolve-style(ctx, segments, style) = { |
| 5777 | assert(not (style.segments == none and style.segment-length == none), |
| 5778 | message: "Only one of segments or segment-length must be set, while the other must be auto") |
| 5779 | assert(style.segments != none or style.segment-length != none, |
| 5780 | message: "Either segments or segment-length must be not equal to none") |
| 5781 | |
| 5782 | // Calculate absolute start/stop distances |
| 5783 | let len = path-util.length(segments) |
| 5784 | if type(style.start) == ratio { |
| 5785 | style.start = len * style.start / 100% |
| 5786 | } |
| 5787 | style.start = calc.max(0, calc.min(style.start, len)) |
| 5788 | if type(style.stop) == ratio { |
| 5789 | style.stop = len * style.stop / 100% |
| 5790 | } |
| 5791 | style.stop = calc.max(0, calc.min(style.stop, len)) |
| 5792 | |
| 5793 | if style.segment-length != none { |
| 5794 | // Calculate number of divisions |
| 5795 | let n = (style.stop - style.start) / style.segment-length |
| 5796 | style.segments = calc.floor(n) |
| 5797 | |
| 5798 | // Divides the rest between start, stop or both |
| 5799 | let r = (n - calc.floor(n)) * style.segment-length |
| 5800 | if style.align == "MID" { |
| 5801 | let m = (style.start + style.stop) / 2 |
| 5802 | style.start = m - n * style.segment-length / 2 |
| 5803 | style.stop = m + n * style.segment-length / 2 |
| 5804 | } else if style.align == "STOP" { |
| 5805 | style.start = style.stop - n * style.segment-length |
| 5806 | } else if style.align == "START" { |
| 5807 | style.stop = style.start + n * style.segment-length |
| 5808 | } |
| 5809 | } |
| 5810 | |
| 5811 | return style |
| 5812 | } |
| 5813 | |
| 5814 | #let get-segments(ctx, target) = { |
| 5815 | if type(target) == array { |
| 5816 | assert.eq(target.len(), 1, |
| 5817 | message: "Expected a single element, got " + str(target.len())) |
| 5818 | target = target.first() |
| 5819 | } |
| 5820 | |
| 5821 | let (ctx, drawables, ..) = process.element(ctx, target) |
| 5822 | if drawables == none or drawables == () { |
| 5823 | return () |
| 5824 | } |
| 5825 | |
| 5826 | let first = drawables.first() |
| 5827 | return (segments: first.segments, close: first.close) |
| 5828 | } |
| 5829 | |
| 5830 | // Add optional line elements from segments start to mid-path start |
| 5831 | // and mid-path end to sgements end |
| 5832 | #let finalize-path(ctx, segments, style, mid-path, close: false) = { |
| 5833 | let add = style.rest == "LINE" and not close |
| 5834 | |
| 5835 | let (ctx, drawables, ..) = process.many(ctx, mid-path) |
| 5836 | let mid-first = drawables.first().segments.first() |
| 5837 | let mid-last = drawables.last().segments.last() |
| 5838 | |
| 5839 | if add { |
| 5840 | let start = path-util.segment-start(segments.first()) |
| 5841 | start = util.revert-transform(ctx.transform, start) |
| 5842 | |
| 5843 | let mid-start = path-util.segment-start(mid-first) |
| 5844 | mid-start = util.revert-transform(ctx.transform, mid-start) |
| 5845 | draw.line(start, mid-start, mark: none) |
| 5846 | } |
| 5847 | mid-path; |
| 5848 | if add { |
| 5849 | let end = path-util.segment-end(segments.last()) |
| 5850 | end = util.revert-transform(ctx.transform, end) |
| 5851 | |
| 5852 | let mid-end = path-util.segment-end(mid-last) |
| 5853 | mid-end = util.revert-transform(ctx.transform, mid-end) |
| 5854 | draw.line(mid-end, end, mark: none) |
| 5855 | } |
| 5856 | // TODO: Add marks on path. |
| 5857 | } |
| 5858 | |
| 5859 | // Call callback `fn` for each decoration segment |
| 5860 | // on path `segments`. |
| 5861 | // |
| 5862 | // The callback gets called with the following arguments: |
| 5863 | // - i Segment index |
| 5864 | // - start Segment start point |
| 5865 | // - end Segment end point |
| 5866 | // - norm Normal vector (length 1) |
| 5867 | // Result values get returned as an array |
| 5868 | #let _path-effect(ctx, segments, fn, close: false, style) = { |
| 5869 | let n = style.segments |
| 5870 | assert(n > 0, |
| 5871 | message: "Number of segments must be greater than 0") |
| 5872 | |
| 5873 | let (start, stop) = (style.start, style.stop) |
| 5874 | let inc = (stop - start) / n |
| 5875 | let pts = () |
| 5876 | let len = path-util.length(segments) |
| 5877 | for i in range(0, n) { |
| 5878 | let p0 = path-util.point-on-path(segments, calc.max(start, |
| 5879 | start + inc * i)) |
| 5880 | let p1 = path-util.point-on-path(segments, calc.min(stop, |
| 5881 | start + inc * (i + 1))) |
| 5882 | if p0 == p1 { continue } |
| 5883 | |
| 5884 | (p0, p1) = util.revert-transform(ctx.transform, p0, p1) |
| 5885 | |
| 5886 | let dir = vector.norm(vector.sub(p1, p0)) |
| 5887 | let norm = vector.cross(dir, if p0.at(2) != p1.at(2) { |
| 5888 | style.z-up |
| 5889 | } else { |
| 5890 | style.xy-up |
| 5891 | }) |
| 5892 | |
| 5893 | pts += fn(i, p0, p1, norm) |
| 5894 | } |
| 5895 | return pts |
| 5896 | } |
| 5897 | |
| 5898 | /// Draw a zig-zag or saw-tooth wave along a path. |
| 5899 | /// |
| 5900 | /// The number of tooths can be controlled via the `segments` or `segment-length` style key, and the width via `amplitude`. |
| 5901 | /// |
| 5902 | /// ```typc example |
| 5903 | /// line((0,0), (2,1), stroke: gray) |
| 5904 | /// cetz.decorations.zigzag(line((0,0), (2,1)), amplitude: .25, start: 10%, stop: 90%) |
| 5905 | /// ``` |
| 5906 | /// |
| 5907 | /// - target (drawable): Target path |
| 5908 | /// - close (auto,bool): Close the path |
| 5909 | /// - name (none,string): Element name |
| 5910 | /// - ..style (style): Style |
| 5911 | /// |
| 5912 | /// ## Styling |
| 5913 | /// *Root*: `zigzag` |
| 5914 | /// - factor (ratio) = 100%: Triangle mid between its start and end. Setting this to 0% leads to a falling sawtooth shape, while 100% results in a raising sawtooth. |
| 5915 | #let zigzag(target, name: none, close: auto, ..style) = draw.get-ctx(ctx => { |
| 5916 | let style = styles.resolve(ctx, merge: style.named(), |
| 5917 | base: zigzag-default-style, root: "zigzag") |
| 5918 | |
| 5919 | let (segments, close) = get-segments(ctx, target) |
| 5920 | let style = resolve-style(ctx, segments, style) |
| 5921 | let num-segments = style.segments |
| 5922 | |
| 5923 | // Return points for a zigzag line |
| 5924 | // |
| 5925 | // m1 ▲ |
| 5926 | // / \ │ Up |
| 5927 | // ..a....\....b.. ' |
| 5928 | // \ / |
| 5929 | // m2 |
| 5930 | // |--| |
| 5931 | // q-dir (quarter length between a and b) |
| 5932 | // |
| 5933 | // For the first/last segment, a/b get added. For all |
| 5934 | // other segments we only have to add m1 and m2 to the |
| 5935 | // list of points for the line-strip. |
| 5936 | let fn(i, a, b, norm) = { |
| 5937 | let ab = vector.sub(b, a) |
| 5938 | let f = .25 - (50% - style.factor) / 50% * .25 |
| 5939 | let q-dir = vector.scale(ab, f) |
| 5940 | let up = vector.scale(norm, resolve-amplitude(ctx, style.amplitude, i + .25, num-segments) / 2) |
| 5941 | let down = vector.scale(norm, -resolve-amplitude(ctx, style.amplitude, i + .75, num-segments) / 2) |
| 5942 | |
| 5943 | let m1 = vector.add(vector.add(a, q-dir), up) |
| 5944 | let m2 = vector.add(vector.sub(b, q-dir), down) |
| 5945 | |
| 5946 | return if not close and i == 0 { |
| 5947 | (a, m1, m2) // First segment: add a |
| 5948 | } else if not close and i == num-segments - 1 { |
| 5949 | (m1, m2, b) // Last segment: add b |
| 5950 | } else { |
| 5951 | (m1, m2) |
| 5952 | } |
| 5953 | } |
| 5954 | |
| 5955 | let pts = _path-effect(ctx, segments, fn, close: close, style) |
| 5956 | return draw.merge-path( |
| 5957 | finalize-path(ctx, segments, style, |
| 5958 | draw.line(..pts, name: name, ..style, mark: none), |
| 5959 | close: close), |
| 5960 | close: close, |
| 5961 | ..style) |
| 5962 | }) |
| 5963 | |
| 5964 | /// Draw a stretched coil/loop spring along a path |
| 5965 | /// |
| 5966 | /// The number of windings can be controlled via the `segments` or `segment-length` style key, and the width via `amplitude`. |
| 5967 | /// |
| 5968 | /// ```typc example |
| 5969 | /// line((0,0), (2,1), stroke: gray) |
| 5970 | /// cetz.decorations.coil(line((0,0), (2,1)), amplitude: .25, start: 10%, stop: 90%) |
| 5971 | /// ``` |
| 5972 | /// - target (drawable): Target path |
| 5973 | /// - close (auto,bool): Close the path |
| 5974 | /// - name (none,string): Element name |
| 5975 | /// - ..style (style): Style |
| 5976 | /// |
| 5977 | /// ## Styling |
| 5978 | /// *Root*: `coil` |
| 5979 | /// - factor (ratio) = 150%: Factor of how much the coil overextends its length to form a curl. |
| 5980 | #let coil(target, close: auto, name: none, ..style) = draw.get-ctx(ctx => { |
| 5981 | let style = styles.resolve(ctx, merge: style.named(), |
| 5982 | base: coil-default-style, root: "coil") |
| 5983 | |
| 5984 | let (segments, close) = get-segments(ctx, target) |
| 5985 | let style = resolve-style(ctx, segments, style) |
| 5986 | |
| 5987 | let num-segments = calc.max(style.segments, 1) |
| 5988 | let length = path-util.length(segments) |
| 5989 | let phase-length = length / num-segments |
| 5990 | let overshoot = calc.max(0, (style.factor - 100%) / 100% * phase-length) |
| 5991 | |
| 5992 | // Offset both control points so the curve approximates |
| 5993 | // an elliptic arc |
| 5994 | let ellipsize-cubic(s, e, c1, c2) = { |
| 5995 | let m = vector.scale(vector.add(c1, c2), .5) |
| 5996 | let d = vector.sub(e, s) |
| 5997 | |
| 5998 | c1 = vector.sub(m, vector.scale(d, .5)) |
| 5999 | c2 = vector.add(m, vector.scale(d, .5)) |
| 6000 | |
| 6001 | return (s, e, c1, c2) |
| 6002 | } |
| 6003 | |
| 6004 | // Return a list of drawables to form a coil-like loop |
| 6005 | // |
| 6006 | // ____ ┐ |
| 6007 | // / \ │ Upper curve |
| 6008 | // | | ┘ |
| 6009 | // ..a...b..|.. ┐ Lower curve |
| 6010 | // \_/ ┘ |
| 6011 | // |
| 6012 | // └──┘ |
| 6013 | // Overshoot |
| 6014 | // |
| 6015 | let fn(i, a, b, norm) = { |
| 6016 | let ab = vector.sub(b, a) |
| 6017 | let amplitude = resolve-amplitude(ctx, style.amplitude, i, num-segments) |
| 6018 | let up = vector.scale(norm, amplitude / 2) |
| 6019 | let dist = vector.dist(a, b) |
| 6020 | |
| 6021 | let d = vector.norm(ab) |
| 6022 | let overshoot-at(i) = if num-segments <= 1 { |
| 6023 | 0 |
| 6024 | } else if close { |
| 6025 | overshoot / 2 |
| 6026 | } else { |
| 6027 | i / (num-segments - 1) * overshoot |
| 6028 | } |
| 6029 | |
| 6030 | let next-a = vector.sub(b, vector.scale(d, overshoot-at(i + 1))) |
| 6031 | let a = vector.sub(a, vector.scale(d, overshoot-at(i))) |
| 6032 | let b = vector.add(b, vector.scale(d, overshoot-at(num-segments - i))) |
| 6033 | let m = vector.scale(vector.add(a, b), .5) |
| 6034 | let m-up = vector.add(m, up) |
| 6035 | let m-down = vector.sub(vector.scale(vector.add(next-a, b), .5), up) |
| 6036 | |
| 6037 | let upper = bezier.cubic-through-3points(a, m-up, b) |
| 6038 | upper = ellipsize-cubic(..upper) |
| 6039 | |
| 6040 | let lower = bezier.cubic-through-3points(b, m-down, next-a) |
| 6041 | lower = ellipsize-cubic(..lower) |
| 6042 | |
| 6043 | if i < num-segments - 1 or close { |
| 6044 | return ( |
| 6045 | draw.bezier(..upper, mark: none), |
| 6046 | draw.bezier(..lower, mark: none), |
| 6047 | ) |
| 6048 | } else { |
| 6049 | return (draw.bezier(..upper, mark: none),) |
| 6050 | } |
| 6051 | } |
| 6052 | |
| 6053 | return draw.merge-path( |
| 6054 | finalize-path(ctx, segments, style, |
| 6055 | _path-effect(ctx, segments, fn, close: close, style).flatten(), |
| 6056 | close: close), |
| 6057 | ..style, |
| 6058 | name: name, |
| 6059 | close: close) |
| 6060 | }) |
| 6061 | |
| 6062 | /// Draw a wave along a path using a catmull-rom curve |
| 6063 | /// |
| 6064 | /// The number of phases can be controlled via the `segments` or `segment-length` style key, and the width via `amplitude`. |
| 6065 | /// |
| 6066 | /// ```typc example |
| 6067 | /// line((0,0), (2,1), stroke: gray) |
| 6068 | /// cetz.decorations.wave(line((0,0), (2,1)), amplitude: .25, start: 10%, stop: 90%) |
| 6069 | /// ``` |
| 6070 | /// |
| 6071 | /// - target (drawable): Target path |
| 6072 | /// - close (auto,bool): Close the path |
| 6073 | /// - name (none,string): Element name |
| 6074 | /// - ..style (style): Style |
| 6075 | /// |
| 6076 | /// ## Styling |
| 6077 | /// *Root*: `wave` |
| 6078 | /// |
| 6079 | /// - tension (float) = 0.5 Catmull-Rom curve tension, see [Catmull](/api/draw-functions/shapes/catmull) |
| 6080 | #let wave(target, close: auto, name: none, ..style) = draw.get-ctx(ctx => { |
| 6081 | let style = styles.resolve(ctx, merge: style.named(), |
| 6082 | base: wave-default-style, root: "wave") |
| 6083 | |
| 6084 | let (segments, close) = get-segments(ctx, target) |
| 6085 | let style = resolve-style(ctx, segments, style) |
| 6086 | let num-segments = style.segments |
| 6087 | |
| 6088 | // Return a list of points for the catmull-rom curve |
| 6089 | // |
| 6090 | // ╭ ma ╮ ▲ |
| 6091 | // │ │ │ Up |
| 6092 | // ..a....m....b.. ' |
| 6093 | // │ │ |
| 6094 | // ╰ mb ╯ |
| 6095 | // |
| 6096 | let fn(i, a, b, norm) = { |
| 6097 | let ab = vector.sub(b, a) |
| 6098 | let up = vector.scale(norm, +resolve-amplitude(ctx, style.amplitude, i + .25, num-segments) / 2) |
| 6099 | let down = vector.scale(norm, -resolve-amplitude(ctx, style.amplitude, i + .75, num-segments) / 2) |
| 6100 | |
| 6101 | let ma = vector.add(vector.add(a, vector.scale(ab, .25)), up) |
| 6102 | let m = vector.add(a, vector.scale(ab, .50)) |
| 6103 | let mb = vector.add(vector.sub(b, vector.scale(ab, .25)), down) |
| 6104 | |
| 6105 | if not close { |
| 6106 | if num-segments == 1 { |
| 6107 | return (a, ma, mb, b) |
| 6108 | } else if i == 0 { |
| 6109 | return (a, ma, mb) |
| 6110 | } else if i == num-segments - 1 { |
| 6111 | return (ma, mb, b,) |
| 6112 | } |
| 6113 | } |
| 6114 | |
| 6115 | return (ma, mb) |
| 6116 | } |
| 6117 | |
| 6118 | return draw.merge-path( |
| 6119 | finalize-path(ctx, segments, style, draw.catmull( |
| 6120 | .._path-effect(ctx, segments, fn, close: close, style), |
| 6121 | close: close), close: close) , |
| 6122 | name: name, |
| 6123 | close: close, |
| 6124 | ..style) |
| 6125 | }) |
| 6126 | |
| 6127 | /// Draw a square-wave along a path using a line-strip |
| 6128 | /// |
| 6129 | /// The number of phases can be controlled via the `segments` or `segment-length` style key, and the width via `amplitude`. |
| 6130 | /// |
| 6131 | /// ```typc example |
| 6132 | /// line((0,0), (2,1), stroke: gray) |
| 6133 | /// cetz.decorations.square(line((0,0), (2,1)), amplitude: .25, start: 10%, stop: 90%) |
| 6134 | /// ``` |
| 6135 | /// |
| 6136 | /// - target (drawable): Target path |
| 6137 | /// - close (auto,bool): Close the path |
| 6138 | /// - name (none,string): Element name |
| 6139 | /// - ..style (style): Style |
| 6140 | /// |
| 6141 | /// ## Styling |
| 6142 | /// *Root*: `squre` |
| 6143 | /// |
| 6144 | /// - factor (ratio) = 50% Square-Wave midpoint |
| 6145 | #let square(target, close: auto, name: none, ..style) = draw.get-ctx(ctx => { |
| 6146 | let style = styles.resolve(ctx, merge: style.named(), |
| 6147 | base: square-default-style, root: "square") |
| 6148 | |
| 6149 | let (segments, close) = get-segments(ctx, target) |
| 6150 | let style = resolve-style(ctx, segments, style) |
| 6151 | let num-segments = style.segments |
| 6152 | let factor = calc.max(0, calc.min(style.factor / 100%, 1)) |
| 6153 | |
| 6154 | // Return a list of points for the line-strip |
| 6155 | // |
| 6156 | // +----+ ▲ |
| 6157 | // | | │ Up |
| 6158 | // ..a....m....b.. ' |
| 6159 | // | | |
| 6160 | // +----+ |
| 6161 | // |
| 6162 | let fn(i, a, b, norm) = { |
| 6163 | let ab = vector.sub(b, a) |
| 6164 | let up = vector.scale(norm, +resolve-amplitude(ctx, style.amplitude, i + .25, num-segments) / 2) |
| 6165 | let down = vector.scale(norm, -resolve-amplitude(ctx, style.amplitude, i + .75, num-segments) / 2) |
| 6166 | let m = vector.add(a, vector.scale(ab, factor)) |
| 6167 | |
| 6168 | if not close { |
| 6169 | if i == 0 { |
| 6170 | return (a, vector.add(a, up), |
| 6171 | vector.add(m, up), vector.add(m, down), |
| 6172 | vector.add(b, down)) |
| 6173 | } else if i == num-segments - 1 { |
| 6174 | return (vector.add(a, up), |
| 6175 | vector.add(m, up), vector.add(m, down), |
| 6176 | vector.add(b, down), b) |
| 6177 | } |
| 6178 | } |
| 6179 | |
| 6180 | return (vector.add(a, up), |
| 6181 | vector.add(m, up), vector.add(m, down), |
| 6182 | vector.add(b, down)) |
| 6183 | } |
| 6184 | |
| 6185 | return draw.merge-path( |
| 6186 | finalize-path(ctx, segments, style, draw.line( |
| 6187 | .._path-effect(ctx, segments, fn, close: close, style), |
| 6188 | close: close), close: close) , |
| 6189 | name: name, |
| 6190 | close: close, |
| 6191 | ..style) |
| 6192 | }) |
| 6193 | #let base-style = (stroke: (paint: black), fill: none) |
| 6194 | |
| 6195 | /// Create a new palette based on a base style |
| 6196 | /// |
| 6197 | /// ```typc example |
| 6198 | /// let p = cetz.palette.new(colors: (red, blue, green)) |
| 6199 | /// for i in range(0, p("len")) { |
| 6200 | /// set-style(..p(i)) |
| 6201 | /// circle((0,0), radius: .5) |
| 6202 | /// set-origin((1.1, 0)) |
| 6203 | /// } |
| 6204 | /// ``` |
| 6205 | /// |
| 6206 | /// The functions returned by this function have the following named arguments: |
| 6207 | /// - fill (bool) = true: If true, the returned fill color is one of the colors from the `colors` list, otherwise the base styles fill is used. |
| 6208 | /// - stroke (bool) = false: If true, the returned stroke color is one of the colors from the `colors` list, otherwise the base styles stroke color is used. |
| 6209 | /// |
| 6210 | /// You can use a palette for stroking via: `red.with(stroke: true)`. |
| 6211 | /// |
| 6212 | /// - base (style): Style dictionary to use as base style for the styles generated per color |
| 6213 | /// - colors (none, array): List of colors the returned palette should return styles with. |
| 6214 | /// - dash (none, array): List of stroke dash patterns the returned palette should return styles with. |
| 6215 | /// -> function |
| 6216 | #let new(base: base-style, colors: (), dash: ()) = { |
| 6217 | if not "stroke" in base { base.stroke = (paint: black, thickness: 1pt, dash: "solid") } |
| 6218 | if not "fill" in base { base.fill = none } |
| 6219 | |
| 6220 | let color-n = colors.len() |
| 6221 | let pattern-n = dash.len() |
| 6222 | return (index, fill: true, stroke: false) => { |
| 6223 | if index == "len" { return calc.max(color-n, pattern-n, 1) } |
| 6224 | |
| 6225 | let style = base |
| 6226 | if pattern-n > 0 { |
| 6227 | style.stroke.dash = dash.at(calc.rem(index, pattern-n)) |
| 6228 | } |
| 6229 | if color-n > 0 { |
| 6230 | if stroke { |
| 6231 | style.stroke.paint = colors.at(calc.rem(index, color-n)) |
| 6232 | } |
| 6233 | if fill { |
| 6234 | style.fill = colors.at(calc.rem(index, color-n)) |
| 6235 | } |
| 6236 | } |
| 6237 | return style |
| 6238 | } |
| 6239 | } |
| 6240 | |
| 6241 | // Predefined color themes |
| 6242 | #let tango-colors = ( |
| 6243 | "edd400", "f57900", "c17d11", |
| 6244 | "73d216", "3465a4", "75507b", |
| 6245 | "cc0000", "d3d7cf", "555753").map(rgb) |
| 6246 | #let tango-light-colors = ( |
| 6247 | "fce94f", "fcaf3e", "e9b96e", |
| 6248 | "8ae234", "729fcf", "ad7fa8", |
| 6249 | "ef2929", "eeeeec", "888a85").map(rgb) |
| 6250 | #let tango-dark-colors = ( |
| 6251 | "c4a000", "ce5c00", "8f5902", |
| 6252 | "4e9a06", "204a87", "5c3566", |
| 6253 | "a40000", "babdb6", "2e3436").map(rgb) |
| 6254 | #let rainbow-colors = ( |
| 6255 | "#9400D4", "#4B0082", "#0000FF", |
| 6256 | "#00FF00", "#FFFF00", "#FF7F00", |
| 6257 | "#FF0000").map(rgb) |
| 6258 | |
| 6259 | #let red-colors = ( |
| 6260 | "#FFCCCC", "#FF9999", "#FF6666", |
| 6261 | "#FF3333", "#CC0000").map(rgb) |
| 6262 | #let orange-colors = ( |
| 6263 | "#FFE5CC", "#FFCC99", "#FFB266", |
| 6264 | "#FF9933", "#FF8000").map(rgb) |
| 6265 | #let light-green-colors = ( |
| 6266 | "#E5FFCC", "#CCFF99", "#B2FF66", |
| 6267 | "#99FF33", "#72E300", "#66CC00", |
| 6268 | "#55A800", "#478F00", "#3A7300", |
| 6269 | "#326300").map(rgb) |
| 6270 | #let dark-green-colors = ( |
| 6271 | "#80E874", "#5DD45D", "#3CC23C", |
| 6272 | "#009900", "#006E00").map(rgb) |
| 6273 | #let turquoise-colors = ( |
| 6274 | "#C0FFD3", "#99FFCC", "#66FFB2", |
| 6275 | "#33FF99", "#4BD691").map(rgb) |
| 6276 | #let cyan-colors = ( |
| 6277 | "#CCFFFF", "#99FFFF", "#66FFFF", |
| 6278 | "#00F3F3", "#00DADA").map(rgb) |
| 6279 | #let blue-colors = ( |
| 6280 | "#BABAFF", "#9999FF", "#6666FF", |
| 6281 | "#3333FF", "#0000CC").map(rgb) |
| 6282 | #let indigo-colors = ( |
| 6283 | "#BABAFF", "#9999FF", "#6666FF", |
| 6284 | "#3333FF", "#0000CC").map(rgb) |
| 6285 | #let purple-colors = ( |
| 6286 | "#E0C2FF", "#CC99FF", "#B266FF", |
| 6287 | "#9933FF", "#7F00FF").map(rgb) |
| 6288 | #let magenta-colors = ( |
| 6289 | "#FFD4FF", "#FF99FF", "#FF66FF", |
| 6290 | "#F331F3", "#DA00DA").map(rgb) |
| 6291 | #let pink-colors = ( |
| 6292 | "#FFCCE5", "#FF99CC", "#FF66B2", |
| 6293 | "#FF3399", "#F20C7F", "#DB006B", |
| 6294 | "#C30061", "#99004C", "#800040", |
| 6295 | "#660033").map(rgb) |
| 6296 | |
| 6297 | |
| 6298 | // Predefined palettes |
| 6299 | #let gray = new(colors: range(90, 40, step: -12).map(v => luma(v * 1%))) |
| 6300 | |
| 6301 | #let red = new(colors: red-colors) |
| 6302 | #let orange = new(colors: orange-colors) |
| 6303 | #let light-green = new(colors: light-green-colors) |
| 6304 | #let dark-green = new(colors: dark-green-colors) |
| 6305 | #let turquoise = new(colors: turquoise-colors) |
| 6306 | #let cyan = new(colors: cyan-colors) |
| 6307 | #let blue = new(colors: blue-colors) |
| 6308 | #let indigo = new(colors: indigo-colors) |
| 6309 | #let purple = new(colors: purple-colors) |
| 6310 | #let magenta = new(colors: magenta-colors) |
| 6311 | #let pink = new(colors: pink-colors) |
| 6312 | |
| 6313 | #let rainbow = new(colors: rainbow-colors) |
| 6314 | |
| 6315 | #let tango = new(colors: tango-colors) |
| 6316 | #let tango-light = new(colors: tango-light-colors) |
| 6317 | #let tango-dark = new(colors: tango-dark-colors) |
| 6318 | #let _pin-name(name) = "cetz-pin-" + name |
| 6319 | |
| 6320 | /// Place a pin aka. cetz anchor in the document |
| 6321 | #let pin(name) = [ #metadata("cetz-pin-tracker") #label(_pin-name(name)) ] |
| 6322 | |
| 6323 | /// Returns all pins |
| 6324 | #let get-pin() = context { |
| 6325 | let result = () |
| 6326 | for item in locate(metadata) { |
| 6327 | if item.value.starts-with("cetz-pin-") { |
| 6328 | let name = item.value.slice(9) |
| 6329 | |
| 6330 | result.insert(name, item) |
| 6331 | } |
| 6332 | } |
| 6333 | panic(result) |
| 6334 | } |
| 6335 | // CeTZ Library for Layouting Tree-Nodes |
| 6336 | #import "/src/util.typ" |
| 6337 | #import "/src/draw.typ" |
| 6338 | #import "/src/coordinate.typ" |
| 6339 | #import "/src/vector.typ" |
| 6340 | #import "/src/matrix.typ" |
| 6341 | #import "/src/process.typ" |
| 6342 | #import "/src/anchor.typ" as anchor_ |
| 6343 | |
| 6344 | #let typst-content = content |
| 6345 | |
| 6346 | // Default edge draw callback |
| 6347 | // |
| 6348 | // - from (string): Source element name |
| 6349 | // - to (string): Target element name |
| 6350 | // - parent (node): Parent (source) tree node |
| 6351 | // - child (node): Child (target) tree node |
| 6352 | #let default-draw-edge(from, to, parent, child) = { |
| 6353 | draw.line(from, to) |
| 6354 | } |
| 6355 | |
| 6356 | // Default node draw callback |
| 6357 | // |
| 6358 | // - node (node): The node to draw |
| 6359 | #let default-draw-node(node, _) = { |
| 6360 | let text = if type(node) in (content, str, int, float) { |
| 6361 | [#node] |
| 6362 | } else if type(node) == dictionary { |
| 6363 | node.content |
| 6364 | } |
| 6365 | |
| 6366 | draw.get-ctx(ctx => { |
| 6367 | draw.content((), text) |
| 6368 | }) |
| 6369 | } |
| 6370 | |
| 6371 | /// Lays out and renders tree nodes. |
| 6372 | /// |
| 6373 | /// For each node, the `tree` function creates an anchor of the format `"node-<depth>-<child-index>"` that can be used to query a nodes position on the canvas. |
| 6374 | /// |
| 6375 | /// ```typc example |
| 6376 | /// import cetz.tree |
| 6377 | /// set-style(content: (padding: .1)) |
| 6378 | /// tree.tree(([Root], ([A], [A.A], [A.B]), ([B], [B.A]))) |
| 6379 | /// ``` |
| 6380 | /// |
| 6381 | /// - root (array): A nested array of content that describes the structure the tree should take. Example: `([root], [child 1], ([child 2], [grandchild 1]))` |
| 6382 | /// - draw-node (auto,function): The function to call to draw a node. The function will be passed two positional arguments, the node to draw and the node's parent, and is expected to return elements (`(node, parent-node) => elements`). The node's position is accessible through the "center" anchor or by using the previous position coordinate `()`. If `auto` is given, just the node's value will be drawn as content. The following predefined styles can be used: |
| 6383 | /// - draw-edge (none,auto,function): The function to call draw an edge between two nodes. The function will be passed the name of the starting node, the name of the ending node, the start node, the end node, and is expected to return elements (`(source-name, target-name, parent-node, child-node) => elements`). If `auto` is given, a straight line will be drawn between nodes. |
| 6384 | /// - direction (str): A string describing the direction the tree should grow in ("up", "down", "left", "right") |
| 6385 | /// - parent-position (str): Positioning of parent nodes (begin, center, end) |
| 6386 | /// - grow (float): Depth grow factor |
| 6387 | /// - spread (float): Sibling spread factor |
| 6388 | /// - name (none,str): The tree element's name |
| 6389 | /// - node-layer (int): Layer to draw nodes on |
| 6390 | /// - edge-layer (int): Layer to draw edges on |
| 6391 | #let tree( |
| 6392 | root, |
| 6393 | draw-node: auto, |
| 6394 | draw-edge: auto, |
| 6395 | direction: "down", |
| 6396 | parent-position: "center", |
| 6397 | grow: 1, |
| 6398 | spread: 1, |
| 6399 | name: none, |
| 6400 | node-layer: 1, |
| 6401 | edge-layer: 0 |
| 6402 | ) = { |
| 6403 | assert(parent-position in ("begin", "center","end", "after-end")) |
| 6404 | assert(grow > 0) |
| 6405 | assert(spread > 0) |
| 6406 | |
| 6407 | direction = ( |
| 6408 | up: "north", |
| 6409 | down: "south", |
| 6410 | right: "east", |
| 6411 | left: "west" |
| 6412 | ).at(direction) |
| 6413 | |
| 6414 | if draw-edge == auto { |
| 6415 | draw-edge = default-draw-edge |
| 6416 | } else if draw-edge == none { |
| 6417 | draw-edge = (..) => () |
| 6418 | } |
| 6419 | |
| 6420 | if draw-node == auto { |
| 6421 | draw-node = default-draw-node |
| 6422 | } |
| 6423 | assert(draw-node != none, message: "Node draw callback must be set!") |
| 6424 | |
| 6425 | let build-node(tree, depth: 0, sibling: 0) = { |
| 6426 | let children = () |
| 6427 | let content = none |
| 6428 | if type(tree) == array { |
| 6429 | children = tree.slice(1).enumerate().map( |
| 6430 | ((n, c)) => build-node(c, depth: depth + 1, sibling: n) |
| 6431 | ) |
| 6432 | content = tree.at(0) |
| 6433 | } else { |
| 6434 | content = tree |
| 6435 | } |
| 6436 | |
| 6437 | return ( |
| 6438 | x: 0, |
| 6439 | y: depth * grow, |
| 6440 | n: sibling, |
| 6441 | depth: depth, |
| 6442 | children: children, |
| 6443 | content: content |
| 6444 | ) |
| 6445 | } |
| 6446 | |
| 6447 | // Layout node recursive |
| 6448 | // |
| 6449 | // return: |
| 6450 | // (node, left-x, right-x) |
| 6451 | let layout-node(node, shift-x) = { |
| 6452 | if node.children.len() == 0 { |
| 6453 | node.x = shift-x |
| 6454 | return (node, node.x, node.x) |
| 6455 | } else { |
| 6456 | let (min-x, max-x) = (none, none) |
| 6457 | let (left, right) = (none, none) |
| 6458 | |
| 6459 | let n-children = node.children.len() |
| 6460 | for i in range(0, n-children) { |
| 6461 | let child = node.children.at(i) |
| 6462 | let (child-min-x, child-max-x) = (none, none) |
| 6463 | |
| 6464 | (child, child-min-x, child-max-x) = layout-node(child, shift-x) |
| 6465 | node.children.at(i) = child |
| 6466 | |
| 6467 | left = util.min(child.x, left) |
| 6468 | right = util.max(child.x, right) |
| 6469 | |
| 6470 | min-x = util.min(min-x, child-min-x) |
| 6471 | max-x = util.max(max-x, child-max-x) |
| 6472 | |
| 6473 | shift-x = child-max-x + spread |
| 6474 | } |
| 6475 | |
| 6476 | if parent-position == "begin" { |
| 6477 | node.x = left |
| 6478 | } else if parent-position == "center" { |
| 6479 | node.x = left + (right - left) / 2 |
| 6480 | } else if parent-position == "end" { |
| 6481 | node.x = right |
| 6482 | } else { //after-end |
| 6483 | node.x = right+spread |
| 6484 | max-x = max-x + spread |
| 6485 | } |
| 6486 | |
| 6487 | node.direct-min-x = left |
| 6488 | node.direct-max-x = right |
| 6489 | node.min-x = min-x |
| 6490 | node.max-x = max-x |
| 6491 | |
| 6492 | return (node, min-x, max-x) |
| 6493 | } |
| 6494 | } |
| 6495 | |
| 6496 | let node-position(node) = { |
| 6497 | if direction == "south" { |
| 6498 | return (node.x, -node.y) |
| 6499 | } else if direction == "north" { |
| 6500 | return (node.x, node.y) |
| 6501 | } else if direction == "west" { |
| 6502 | return (-node.y, node.x) |
| 6503 | } else if direction == "east" { |
| 6504 | return (node.y, node.x) |
| 6505 | } else { |
| 6506 | panic(message: "Invalid tree direction.") |
| 6507 | } |
| 6508 | } |
| 6509 | |
| 6510 | let anchors(node, parent-path) = { |
| 6511 | if parent-path != none { |
| 6512 | parent-path += "-" |
| 6513 | } else { |
| 6514 | parent-path = "" |
| 6515 | } |
| 6516 | |
| 6517 | let d = (:) |
| 6518 | d.insert(parent-path + str(node.n), node-position(node)) |
| 6519 | for child in node.children { |
| 6520 | d += anchors(child, parent-path + str(node.n)) |
| 6521 | } |
| 6522 | return d |
| 6523 | } |
| 6524 | |
| 6525 | let build-element(node, parent-name) = { |
| 6526 | let name = if parent-name != none { |
| 6527 | parent-name + "-" + str(node.n) |
| 6528 | } else { |
| 6529 | "0" |
| 6530 | } |
| 6531 | |
| 6532 | // Render element |
| 6533 | node.name = name |
| 6534 | node.group-name = "g" + name |
| 6535 | node.element = { |
| 6536 | draw.anchor(node.name, node-position(node)) |
| 6537 | draw.group(name: node.group-name, { |
| 6538 | draw.move-to(node-position(node)) |
| 6539 | draw.anchor("default", ()) |
| 6540 | draw-node(node, parent-name) |
| 6541 | }) |
| 6542 | } |
| 6543 | |
| 6544 | // Render children |
| 6545 | node.children = node.children.map(c => build-element(c, name)) |
| 6546 | |
| 6547 | // Render edges |
| 6548 | node.edges = if node.children != () { |
| 6549 | draw.group({ |
| 6550 | for child in node.children { |
| 6551 | draw-edge(node.group-name, child.group-name, node, child) |
| 6552 | } |
| 6553 | }) |
| 6554 | } else { () } |
| 6555 | |
| 6556 | return node |
| 6557 | } |
| 6558 | |
| 6559 | let root = build-node(root) |
| 6560 | let (nodes, ..) = layout-node(root, 0) |
| 6561 | let node = build-element(nodes, none) |
| 6562 | |
| 6563 | // Render node recursive |
| 6564 | let render(node) = { |
| 6565 | if node.element != none { |
| 6566 | draw.on-layer(node-layer, node.element) |
| 6567 | if "children" in node { |
| 6568 | for child in node.children { |
| 6569 | render(child) |
| 6570 | } |
| 6571 | } |
| 6572 | draw.on-layer(edge-layer, node.edges) |
| 6573 | } |
| 6574 | } |
| 6575 | |
| 6576 | draw.group(name: name, render(node)) |
| 6577 | } |
| 6578 | #import "drawable.typ" |
| 6579 | #import "path-util.typ" |
| 6580 | #import "vector.typ" |
| 6581 | |
| 6582 | // Calculate triangular tip offset, depending on the strokes |
| 6583 | // join type. |
| 6584 | // |
| 6585 | // The angle is calculated for an isosceles triangle of base style.widh |
| 6586 | // and height style.length |
| 6587 | #let _calculate-tip-offset(style) = { |
| 6588 | if style.stroke.join == "round" { |
| 6589 | return style.stroke.thickness / 2 |
| 6590 | } |
| 6591 | |
| 6592 | if style.length == 0 { |
| 6593 | return 0 |
| 6594 | } |
| 6595 | |
| 6596 | let angle = calc.atan(style.width / (2 * style.length) / if style.harpoon { 2 } else { 1 } ) * 2 |
| 6597 | // If the miter length divided by the stroke width exceeds |
| 6598 | // the stroke miter limit then the miter join is converted to a bevel. |
| 6599 | // See: https://svgwg.org/svg2-draft/painting.html#LineJoin |
| 6600 | if style.stroke.join == "miter" { |
| 6601 | let angle = calc.abs(angle) |
| 6602 | if angle > 0deg { |
| 6603 | let miter-limit = 1 / calc.sin(angle / 2) |
| 6604 | if miter-limit <= style.stroke.miter-limit { |
| 6605 | return miter-limit * (style.stroke.thickness / 2) |
| 6606 | } |
| 6607 | } |
| 6608 | } |
| 6609 | |
| 6610 | // style.stroke.join must be "bevel" |
| 6611 | return calc.sin(angle/2) * (style.stroke.thickness / 2) |
| 6612 | } |
| 6613 | |
| 6614 | #let create-tip-and-base-anchor(style, tip, base, center: none) = { |
| 6615 | if base == tip { base = vector.add(tip, (1e-8, 0, 0)) } |
| 6616 | let dir = vector.norm(vector.sub(tip, base)) |
| 6617 | |
| 6618 | import "/src/draw.typ": * |
| 6619 | anchor("tip", vector.add(tip, vector.scale(dir, style.stroke.thickness / 2))) |
| 6620 | anchor("base", vector.sub(base, vector.scale(dir, style.stroke.thickness / 2))) |
| 6621 | } |
| 6622 | |
| 6623 | #let create-triangle-tip-and-base-anchor(style, tip, base, center: none) = { |
| 6624 | if base == tip { base = vector.add(tip, (1e-8, 0, 0)) } |
| 6625 | let dir = vector.norm(vector.sub(tip, base)) |
| 6626 | |
| 6627 | import "/src/draw.typ": * |
| 6628 | if style.reverse { |
| 6629 | // Since tip and base are now "swapped", we add the stroke thickness to the triangle |
| 6630 | // base. To get smooth looking connections between the triangle tip and a connecting line, |
| 6631 | // we do not add the tip-offset. |
| 6632 | anchor("tip", vector.add(tip, vector.scale(dir, style.stroke.thickness / 2))) |
| 6633 | anchor("base", base) |
| 6634 | } else { |
| 6635 | anchor("tip", vector.add(tip, vector.scale(dir, _calculate-tip-offset(style)))) |
| 6636 | anchor("base", vector.sub(base, vector.scale(dir, style.stroke.thickness / 2))) |
| 6637 | } |
| 6638 | } |
| 6639 | |
| 6640 | #let create-diamond-tip-and-base-anchor(style, tip, base, center: none, ratio: 50%) = { |
| 6641 | if base == tip { base = vector.add(tip, (1e-8, 0, 0)) } |
| 6642 | let dir = vector.norm(vector.sub(tip, base)) |
| 6643 | |
| 6644 | import "/src/draw.typ": * |
| 6645 | |
| 6646 | let tip-style = style |
| 6647 | tip-style.length = style.length * (ratio / 100%) |
| 6648 | if style.reverse { |
| 6649 | anchor("tip", tip) |
| 6650 | } else { |
| 6651 | anchor("tip", vector.add(tip, vector.scale(dir, _calculate-tip-offset(tip-style)))) |
| 6652 | } |
| 6653 | if style.reverse { |
| 6654 | anchor("base", vector.sub(base, vector.scale(dir, _calculate-tip-offset(tip-style)))) |
| 6655 | } else { |
| 6656 | anchor("base", base) |
| 6657 | } |
| 6658 | } |
| 6659 | |
| 6660 | // Dictionary of built-in mark styles |
| 6661 | // |
| 6662 | // (style) => (<elements..>) |
| 6663 | #let marks = ( |
| 6664 | triangle: (style) => { |
| 6665 | import "/src/draw.typ": * |
| 6666 | |
| 6667 | if style.harpoon { |
| 6668 | line((0,0), (style.length, 0), (style.length, +style.width / 2), close: true) |
| 6669 | } else { |
| 6670 | line((0,0), (style.length, -style.width / 2), (style.length, +style.width / 2), close: true) |
| 6671 | } |
| 6672 | |
| 6673 | create-triangle-tip-and-base-anchor(style, (0, 0), (style.length, 0)) |
| 6674 | }, |
| 6675 | // A mark in the shape of an arrow tip. |
| 6676 | stealth: (style) => { |
| 6677 | import "/src/draw.typ": * |
| 6678 | |
| 6679 | let (l, w, i) = (style.length, style.width, style.inset) |
| 6680 | |
| 6681 | if style.harpoon { |
| 6682 | line((0,0), (l, w / 2), (l - i, 0), close: true) |
| 6683 | } else { |
| 6684 | line((0,0), (l, w / 2), (l - i, 0), (l, -w / 2), close: true) |
| 6685 | } |
| 6686 | |
| 6687 | create-triangle-tip-and-base-anchor(style, (0, 0), (l - i, 0)) |
| 6688 | }, |
| 6689 | bar: (style) => { |
| 6690 | import "/src/draw.typ": * |
| 6691 | |
| 6692 | let w = style.width |
| 6693 | |
| 6694 | if style.harpoon { |
| 6695 | line((0, w / 2), (0, 0)) |
| 6696 | } else { |
| 6697 | line((0, w / 2), (0, -w / 2)) |
| 6698 | } |
| 6699 | |
| 6700 | create-tip-and-base-anchor(style, (0, 0), (0, 0)) |
| 6701 | }, |
| 6702 | ellipse: (style) => { |
| 6703 | import "/src/draw.typ": * |
| 6704 | |
| 6705 | let r = (style.length / 2, style.width / 2) |
| 6706 | |
| 6707 | if style.harpoon { |
| 6708 | arc((0, 0), delta: -180deg, start: 0deg, radius: r, anchor: "origin", mode: "PIE") |
| 6709 | } else { |
| 6710 | circle((0, 0), radius: r) |
| 6711 | } |
| 6712 | |
| 6713 | create-tip-and-base-anchor(style, (r.at(0), 0), (-r.at(0), 0)) |
| 6714 | }, |
| 6715 | circle: (style) => { |
| 6716 | import "/src/draw.typ": * |
| 6717 | |
| 6718 | let r = calc.min(style.length, style.width) / 2 |
| 6719 | |
| 6720 | if style.harpoon { |
| 6721 | arc((0, 0), delta: -180deg, start: 0deg, radius: r, anchor: "origin", mode: "PIE") |
| 6722 | } else { |
| 6723 | circle((0, 0), radius: r) |
| 6724 | } |
| 6725 | |
| 6726 | create-tip-and-base-anchor(style, (r, 0), (-r, 0)) |
| 6727 | }, |
| 6728 | bracket: (style) => { |
| 6729 | import "/src/draw.typ": * |
| 6730 | |
| 6731 | let (l, w, i) = (style.length, style.width, style.inset) |
| 6732 | |
| 6733 | if style.harpoon { |
| 6734 | line((-l - i, w / 2), (0, w / 2), (0, 0), fill: none) |
| 6735 | } else { |
| 6736 | line((-l - i, w / 2), (0, w / 2), (0, -w / 2), (-l - i, -w / 2), fill: none) |
| 6737 | } |
| 6738 | |
| 6739 | create-tip-and-base-anchor(style, (0, 0), (-1e-8, 0), center: ((-l - i) / 2, 0)) |
| 6740 | }, |
| 6741 | diamond: (style) => { |
| 6742 | import "/src/draw.typ": * |
| 6743 | |
| 6744 | let (l, w) = (style.length, style.width) |
| 6745 | |
| 6746 | if style.harpoon { |
| 6747 | line((0,0), (l / 2, w / 2), (l, 0), close: true) |
| 6748 | } else { |
| 6749 | line((0,0), (l / 2, w / 2), (l, 0), (l / 2, -w / 2), close: true) |
| 6750 | } |
| 6751 | |
| 6752 | create-diamond-tip-and-base-anchor(style, (0, 0), (l, 0)) |
| 6753 | }, |
| 6754 | rect: (style) => { |
| 6755 | import "/src/draw.typ": * |
| 6756 | |
| 6757 | let (l, w) = (style.length, style.width) |
| 6758 | |
| 6759 | if style.harpoon { |
| 6760 | rect((0, -w / 2), (-l, +w / 2)) |
| 6761 | } else { |
| 6762 | rect((0, -w / 2), (-l, +w / 2)) |
| 6763 | } |
| 6764 | |
| 6765 | create-tip-and-base-anchor(style, (0, 0), (-l, 0)) |
| 6766 | }, |
| 6767 | hook: (style) => { |
| 6768 | import "/src/draw.typ": * |
| 6769 | |
| 6770 | let r = calc.min(style.length, style.width / 2) / 2 |
| 6771 | let (l, i) = (style.length, style.inset) |
| 6772 | |
| 6773 | merge-path({ |
| 6774 | line((i, -2 * r), (0, -2 * r)) |
| 6775 | arc((0, 0), delta: -180deg, start: -90deg, radius: r, anchor: "end") |
| 6776 | if not style.harpoon { |
| 6777 | arc((0, 0), delta: -180deg, start: -90deg, radius: r, anchor: "start") |
| 6778 | line((i, +2 * r), (0, +2 * r)) |
| 6779 | } |
| 6780 | }, fill: none) |
| 6781 | |
| 6782 | line((0, 0), (l - r, 0)) |
| 6783 | |
| 6784 | create-tip-and-base-anchor(style, (-r, 0), (l - r, 0), center: ((-r + i) / 2, 0)) |
| 6785 | }, |
| 6786 | // An unfilled mark in the shape of an angle bracket (>). |
| 6787 | straight: (style) => { |
| 6788 | import "/src/draw.typ": * |
| 6789 | |
| 6790 | let (l, w) = (style.length, style.width) |
| 6791 | |
| 6792 | if style.harpoon { |
| 6793 | line((l, w / 2), (0, 0), fill: none) |
| 6794 | } else { |
| 6795 | line((l, w / 2), (0, 0), (l, -w / 2), fill: none) |
| 6796 | } |
| 6797 | |
| 6798 | create-triangle-tip-and-base-anchor(style, (0, 0), (0, 0)) |
| 6799 | }, |
| 6800 | barbed: (style) => { |
| 6801 | import "/src/draw.typ": * |
| 6802 | |
| 6803 | let style = style |
| 6804 | style.stroke.join = "round" |
| 6805 | |
| 6806 | let (l, w) = (style.length, style.width) |
| 6807 | |
| 6808 | let ctrl-a = (l, 0) |
| 6809 | let ctrl-b = (0, 0) |
| 6810 | |
| 6811 | merge-path({ |
| 6812 | bezier((l, w / 2), (0, 0), ctrl-a, ctrl-b) |
| 6813 | if not style.harpoon { |
| 6814 | bezier((0, 0), (l, -w / 2), ctrl-b, ctrl-a) |
| 6815 | } |
| 6816 | }, ..style) |
| 6817 | |
| 6818 | create-tip-and-base-anchor(style, (0, 0), (1e-6, 0)) |
| 6819 | }, |
| 6820 | plus: (style) => { |
| 6821 | import "/src/draw.typ": * |
| 6822 | |
| 6823 | let style = style |
| 6824 | style.stroke.join = "round" |
| 6825 | |
| 6826 | let (l, w) = (style.length, style.width) |
| 6827 | |
| 6828 | line((-l / 2, 0), (+l / 2, 0)) |
| 6829 | line((0, -w / 2), (0, +w / 2)) |
| 6830 | |
| 6831 | create-tip-and-base-anchor(style, (0, 0), (l / 2, 0)) |
| 6832 | }, |
| 6833 | x: (style) => { |
| 6834 | import "/src/draw.typ": * |
| 6835 | |
| 6836 | let style = style |
| 6837 | style.stroke.join = "round" |
| 6838 | |
| 6839 | let (l, w) = (style.length, style.width) |
| 6840 | |
| 6841 | line((-l / 2, w / 2), (+l / 2, -w / 2)) |
| 6842 | line((-l / 2, -w / 2), (+l / 2, +w / 2)) |
| 6843 | |
| 6844 | create-tip-and-base-anchor(style, (0, 0), (0, 0)) |
| 6845 | }, |
| 6846 | star: (style) => { |
| 6847 | import "/src/draw.typ": * |
| 6848 | |
| 6849 | let (l, w) = (style.length, style.width) |
| 6850 | |
| 6851 | let n = 5 |
| 6852 | for i in range(0, n) { |
| 6853 | let a = 360deg / n * i |
| 6854 | line((0, 0), (calc.cos(a) * l / 2, calc.sin(a) * w / 2)) |
| 6855 | } |
| 6856 | |
| 6857 | create-tip-and-base-anchor(style, (0, 0), (l / 2, 0)) |
| 6858 | }, |
| 6859 | ) |
| 6860 | #let names = marks.keys() |
| 6861 | |
| 6862 | // Mark mnemonics |
| 6863 | // Each mnemonic maps to a dictionary of: |
| 6864 | // - reverse (bool) |
| 6865 | // - flip (bool) |
| 6866 | // - harpoon (bool) |
| 6867 | // TODO: Resolve mark styles at a later point, to support all style keys here |
| 6868 | #let mnemonics = ( |
| 6869 | ">": ("triangle", (:)), |
| 6870 | "<": ("triangle", (reverse: true)), |
| 6871 | "<>": ("diamond", (:)), |
| 6872 | "[]": ("rect", (:)), |
| 6873 | "]": ("bracket", (:)), |
| 6874 | "[": ("bracket", (reverse: true)), |
| 6875 | "|": ("bar", (:)), |
| 6876 | "o": ("circle", (:)), |
| 6877 | "+": ("plus", (:)), |
| 6878 | "x": ("x", (:)), |
| 6879 | "*": ("star", (:)), |
| 6880 | ) |
| 6881 | |
| 6882 | // Get a mark shape + reverse tuple for a mark name |
| 6883 | #let get-mark(ctx, symbol) = { |
| 6884 | symbol = ctx.marks.mnemonics.at(symbol, default: symbol) |
| 6885 | if symbol in ctx.marks.marks { |
| 6886 | return (ctx.marks.marks.at(symbol), (:)) |
| 6887 | } |
| 6888 | |
| 6889 | let (symbol, defaults) = mnemonics.at(symbol, default: (symbol, (:))) |
| 6890 | assert(symbol in marks, message: "Unknown mark '" + symbol + "'") |
| 6891 | return (marks.at(symbol), defaults) |
| 6892 | } |
| 6893 | #import "drawable.typ" |
| 6894 | #import "vector.typ" |
| 6895 | #import "matrix.typ" |
| 6896 | #import "util.typ" |
| 6897 | #import "path-util.typ" |
| 6898 | #import "styles.typ" |
| 6899 | #import "mark-shapes.typ": get-mark |
| 6900 | #import "process.typ" |
| 6901 | |
| 6902 | /// Checks if a mark should be drawn according to the current style. |
| 6903 | /// - style (style): The current style. |
| 6904 | /// -> bool |
| 6905 | #let check-mark(style) = { |
| 6906 | style != none and ("start", "end", "symbol").any(key => |
| 6907 | style.at(key, default: none) != none) |
| 6908 | } |
| 6909 | |
| 6910 | /// Processes the mark styling. |
| 6911 | /// TODO: remember what is actually going on here. |
| 6912 | /// |
| 6913 | /// - ctx (context): The context object. |
| 6914 | /// - style (style): The current style. |
| 6915 | /// - root (str): Where the mark is being placed, normally either `"start"` or `"end"`. Allows different styling for marks in different directions. |
| 6916 | /// - path-length (float): The length of the path. This is used for relative offsets. |
| 6917 | #let process-style(ctx, style, root, path-length) = { |
| 6918 | let base-style = ( |
| 6919 | symbol: auto, |
| 6920 | fill: auto, |
| 6921 | stroke: auto, |
| 6922 | slant: auto, |
| 6923 | harpoon: auto, |
| 6924 | flip: auto, |
| 6925 | reverse: auto, |
| 6926 | inset: auto, |
| 6927 | width: auto, |
| 6928 | scale: auto, |
| 6929 | length: auto, |
| 6930 | sep: auto, |
| 6931 | pos: auto, |
| 6932 | offset: auto, |
| 6933 | flex: auto, |
| 6934 | xy-up: auto, |
| 6935 | z-up: auto, |
| 6936 | shorten-to: auto, |
| 6937 | position-samples: auto, |
| 6938 | anchor: auto, |
| 6939 | ) |
| 6940 | |
| 6941 | if type(style.at(root)) != array { |
| 6942 | style.at(root) = (style.at(root),) |
| 6943 | } |
| 6944 | if type(style.symbol) != array { |
| 6945 | style.symbol = (style.symbol,) |
| 6946 | } |
| 6947 | |
| 6948 | let out = () |
| 6949 | for i in range(calc.max(style.at(root).len(), style.symbol.len())) { |
| 6950 | let style = style |
| 6951 | style.symbol = style.symbol.at(i, default: auto) |
| 6952 | style.at(root) = style.at(root).at(i, default: auto) |
| 6953 | |
| 6954 | if type(style.symbol) == dictionary { |
| 6955 | style = styles.resolve(style, merge: style.symbol) |
| 6956 | } |
| 6957 | |
| 6958 | if type(style.at(root)) == str { |
| 6959 | style.symbol = style.at(root) |
| 6960 | } else if type(style.at(root)) == dictionary { |
| 6961 | style = styles.resolve(style, root: root, base: base-style) |
| 6962 | } |
| 6963 | |
| 6964 | style.stroke = util.resolve-stroke(style.stroke) |
| 6965 | style.stroke.thickness = util.resolve-number(ctx, style.stroke.thickness) |
| 6966 | |
| 6967 | if "angle" in style and type(style.angle) == angle { |
| 6968 | style.width = calc.tan(style.angle / 2) * style.length * 2 |
| 6969 | } |
| 6970 | |
| 6971 | // Stroke thickness relative attributes |
| 6972 | for (k, v) in style { |
| 6973 | if k in ("length", "width", "inset", "sep") { |
| 6974 | style.insert(k, if type(v) == ratio { |
| 6975 | style.stroke.thickness * v / 100% |
| 6976 | } else { |
| 6977 | util.resolve-number(ctx, v) |
| 6978 | } * style.scale) |
| 6979 | } |
| 6980 | } |
| 6981 | |
| 6982 | // Path length relative attributes |
| 6983 | for k in ("offset", "pos",) { |
| 6984 | let v = style.at(k) |
| 6985 | if v != none and v != auto { |
| 6986 | style.insert(k, if type(v) == ratio { |
| 6987 | v * path-length / 100% |
| 6988 | } else { |
| 6989 | util.resolve-number(ctx, v) |
| 6990 | }) |
| 6991 | } |
| 6992 | } |
| 6993 | |
| 6994 | out.push(style) |
| 6995 | } |
| 6996 | return out |
| 6997 | } |
| 6998 | |
| 6999 | #let transform-mark(style, mark, pos, dir, flip: false, reverse: false, slant: none, harpoon: false) = { |
| 7000 | let up = style.xy-up |
| 7001 | if dir.at(2) != 0 { |
| 7002 | up = style.z-up |
| 7003 | } |
| 7004 | |
| 7005 | assert(style.anchor in ("tip", "base", "center")) |
| 7006 | let tip = mark.tip |
| 7007 | let base = mark.base |
| 7008 | let origin = mark.at(style.anchor) |
| 7009 | |
| 7010 | // Mirror anchors on mark center |
| 7011 | if reverse { |
| 7012 | (tip, base) = (base, tip) |
| 7013 | origin = vector.sub(mark.center, vector.sub(origin, mark.center)) |
| 7014 | } |
| 7015 | |
| 7016 | mark.offset = vector.dist(origin, tip) |
| 7017 | |
| 7018 | let t = ( |
| 7019 | // Translate & rotate to the target coordinate & direction |
| 7020 | matrix.transform-translate(..pos), |
| 7021 | matrix.transform-rotate-dir(dir, up), |
| 7022 | matrix.transform-rotate-z(-90deg), |
| 7023 | |
| 7024 | // Rotate mark to have base->tip on the x-axis |
| 7025 | matrix.transform-rotate-z(if reverse { |
| 7026 | vector.angle2(tip, base) |
| 7027 | } else { |
| 7028 | vector.angle2(base, tip) |
| 7029 | }), |
| 7030 | |
| 7031 | // Translate mark to have its anchor (tip, base, center) at (0,0) |
| 7032 | matrix.transform-translate(..vector.scale(origin, if reverse {1} else {-1})), |
| 7033 | |
| 7034 | // Mirror on x and/or y axis |
| 7035 | if flip or reverse { |
| 7036 | matrix.transform-scale({ |
| 7037 | if flip { |
| 7038 | (y: -1) |
| 7039 | } |
| 7040 | if reverse { |
| 7041 | (x: -1) |
| 7042 | } |
| 7043 | }) |
| 7044 | }, |
| 7045 | |
| 7046 | // Slant on x axis |
| 7047 | if slant not in (none, 0%) { |
| 7048 | if type(slant) == ratio { |
| 7049 | slant /= 100% |
| 7050 | } |
| 7051 | matrix.transform-shear-x(slant) |
| 7052 | }, |
| 7053 | ) |
| 7054 | |
| 7055 | mark.drawables = drawable.apply-transform( |
| 7056 | matrix.mul-mat(..t.filter(m => m != none)), |
| 7057 | mark.drawables |
| 7058 | ) |
| 7059 | |
| 7060 | return mark |
| 7061 | } |
| 7062 | |
| 7063 | #let _eval-mark-shape-and-anchors(ctx, mark, style) = { |
| 7064 | if "eval-mark-guard" in ctx { |
| 7065 | panic("Recursive mark drawing is not allowed") |
| 7066 | } |
| 7067 | ctx.eval-mark-guard = true |
| 7068 | |
| 7069 | ctx.groups = () |
| 7070 | ctx.nodes = (:) |
| 7071 | ctx.transform = matrix.ident(4) |
| 7072 | |
| 7073 | import "/src/draw.typ" |
| 7074 | let body = draw.group({ |
| 7075 | draw.set-style( |
| 7076 | stroke: style.at("stroke", default: none), |
| 7077 | fill: style.at("fill", default: none), |
| 7078 | mark: none, |
| 7079 | line: (mark: none), |
| 7080 | bezier: (mark: none), |
| 7081 | arc: (mark: none), |
| 7082 | ) |
| 7083 | mark |
| 7084 | }, name: "mark") |
| 7085 | let (ctx: ctx, bounds: bounds, drawables: drawables) = process.many(ctx, body) |
| 7086 | let anchor-fn = ctx.nodes.at("mark").anchors |
| 7087 | |
| 7088 | // Check if the mark has named anchor |
| 7089 | let has-anchor(name) = { |
| 7090 | return name in (anchor-fn)(()) |
| 7091 | } |
| 7092 | |
| 7093 | // Fetch special mark anchors |
| 7094 | let get-anchor(name, default: none) = { |
| 7095 | if default != none { |
| 7096 | if not has-anchor(name) { |
| 7097 | return default |
| 7098 | } |
| 7099 | } |
| 7100 | return (anchor-fn)(name) |
| 7101 | } |
| 7102 | |
| 7103 | let tip = get-anchor("tip", default: (0, 0, 0)) |
| 7104 | let base = get-anchor("base", default: tip) |
| 7105 | let center = get-anchor("center", default: vector.lerp(tip, base, .5)) |
| 7106 | |
| 7107 | return ( |
| 7108 | tip: tip, |
| 7109 | base: base, |
| 7110 | center: center, |
| 7111 | length: vector.dist(tip, base), |
| 7112 | drawables: drawables, |
| 7113 | ) |
| 7114 | } |
| 7115 | |
| 7116 | /// Places a mark on the given path. Returns a {{dictionary}} with the following keys: |
| 7117 | /// - drawables (drawable): The mark drawables. |
| 7118 | /// - distance (float): The length to shorten the path by. |
| 7119 | /// - pos (float): The position of the mark, can be used to snap the end of the path to after shortening. |
| 7120 | /// |
| 7121 | /// --- |
| 7122 | /// |
| 7123 | /// - ctx (context): The canvas context object. |
| 7124 | /// - styles (style): A processed mark styling. |
| 7125 | /// - segments (drawable): The path to place the mark on. |
| 7126 | /// - is-end (bool): TODO |
| 7127 | /// -> dictionary |
| 7128 | #let place-mark-on-path(ctx, styles, segments, is-end: false) = { |
| 7129 | if type(styles) != array { |
| 7130 | styles = (styles,) |
| 7131 | } |
| 7132 | let distance = 0 |
| 7133 | let shorten-distance = 0 |
| 7134 | let shorten-pos = none |
| 7135 | let drawables = () |
| 7136 | for (i, style) in styles.enumerate() { |
| 7137 | let is-last = i + 1 == styles.len() |
| 7138 | if style.symbol == none { |
| 7139 | continue |
| 7140 | } |
| 7141 | |
| 7142 | // Override position, if set |
| 7143 | if style.pos != none { |
| 7144 | distance = style.pos |
| 7145 | } |
| 7146 | |
| 7147 | // Apply mark offset |
| 7148 | distance += style.offset |
| 7149 | |
| 7150 | let (mark-fn, defaults) = get-mark(ctx, style.symbol) |
| 7151 | |
| 7152 | let merge-flag(style, key, default: false) = { |
| 7153 | let old = style.at(key) |
| 7154 | let def = defaults.at(key, default: default) |
| 7155 | style.insert(key, (old or def) and not (old and def)) |
| 7156 | return style |
| 7157 | } |
| 7158 | |
| 7159 | style = merge-flag(style, "reverse") |
| 7160 | style = merge-flag(style, "flip") |
| 7161 | style = merge-flag(style, "harpoon") |
| 7162 | |
| 7163 | let mark = _eval-mark-shape-and-anchors(ctx, mark-fn(style), style) |
| 7164 | |
| 7165 | let pos = if style.flex { |
| 7166 | path-util.point-on-path( |
| 7167 | segments, |
| 7168 | if distance != 0 { |
| 7169 | distance * if is-end { -1 } else { 1 } |
| 7170 | } else { |
| 7171 | if is-end { |
| 7172 | 100% |
| 7173 | } else { |
| 7174 | 0% |
| 7175 | } |
| 7176 | }, extrapolate: true) |
| 7177 | } else { |
| 7178 | let (_, dir) = path-util.direction( |
| 7179 | segments, |
| 7180 | if is-end { |
| 7181 | 100% |
| 7182 | } else { |
| 7183 | 0% |
| 7184 | }, |
| 7185 | clamp: true) |
| 7186 | let pt = if is-end { |
| 7187 | path-util.segment-end(segments.last()) |
| 7188 | } else { |
| 7189 | path-util.segment-start(segments.first()) |
| 7190 | } |
| 7191 | vector.sub(pt, vector.scale(vector.norm(dir), distance * if is-end { 1 } else { -1 })) |
| 7192 | } |
| 7193 | assert.ne(pos, none, |
| 7194 | message: "Could not determine mark position") |
| 7195 | |
| 7196 | let dir = if style.flex { |
| 7197 | let a = pos |
| 7198 | let b = path-util.point-on-path( |
| 7199 | segments, |
| 7200 | (mark.length + distance) * if is-end { -1 } else { 1 }, |
| 7201 | samples: style.position-samples, |
| 7202 | extrapolate: true) |
| 7203 | if b != none and a != b { |
| 7204 | vector.sub(b, a) |
| 7205 | } else { |
| 7206 | let (_, dir) = path-util.direction( |
| 7207 | segments, |
| 7208 | distance, |
| 7209 | clamp: true) |
| 7210 | vector.scale(dir, if is-end { -1 } else { 1 }) |
| 7211 | } |
| 7212 | } else { |
| 7213 | let (_, dir) = path-util.direction( |
| 7214 | segments, |
| 7215 | if is-end { |
| 7216 | 100% |
| 7217 | } else { |
| 7218 | 0% |
| 7219 | }, |
| 7220 | clamp: true) |
| 7221 | if dir != none { |
| 7222 | vector.scale(dir, if is-end { -1 } else { 1 }) |
| 7223 | } |
| 7224 | } |
| 7225 | assert.ne(pos, none, |
| 7226 | message: "Could not determine mark direction") |
| 7227 | |
| 7228 | mark = transform-mark( |
| 7229 | style, |
| 7230 | mark, |
| 7231 | pos, |
| 7232 | dir, |
| 7233 | reverse: style.reverse, |
| 7234 | slant: style.slant, |
| 7235 | flip: style.flip, |
| 7236 | harpoon: style.harpoon, |
| 7237 | ) |
| 7238 | |
| 7239 | // Shorten path to this mark |
| 7240 | let inset = mark.at("inset", default: 0) |
| 7241 | if style.shorten-to != none and (style.shorten-to == auto or i <= style.shorten-to) { |
| 7242 | let offset = mark.offset |
| 7243 | inset += offset |
| 7244 | |
| 7245 | shorten-distance = distance + mark.length - inset |
| 7246 | shorten-pos = vector.add(pos, |
| 7247 | vector.scale(vector.norm(dir), mark.length - inset)) |
| 7248 | } |
| 7249 | |
| 7250 | drawables += mark.drawables |
| 7251 | distance += mark.length |
| 7252 | |
| 7253 | // Add separator |
| 7254 | distance += style.sep |
| 7255 | } |
| 7256 | |
| 7257 | return ( |
| 7258 | drawables: drawables, |
| 7259 | distance: shorten-distance, |
| 7260 | pos: shorten-pos |
| 7261 | ) |
| 7262 | } |
| 7263 | |
| 7264 | /// Places marks along a path. Returns them as an {{array}} of {{drawable}}. |
| 7265 | /// |
| 7266 | /// - ctx (context): The context object. |
| 7267 | /// - style (style): The current mark styling. |
| 7268 | /// - transform (matrix): The current transformation matrix. |
| 7269 | /// - path (drawable): The path to place the marks on. |
| 7270 | /// - add-path (bool): When `true` the shortened path will returned as the first {{drawable}} in the {{array}} |
| 7271 | /// -> array |
| 7272 | #let place-marks-along-path(ctx, style, transform, path, add-path: true) = { |
| 7273 | let distance = (0, 0) |
| 7274 | let snap-to = (none, none) |
| 7275 | let drawables = () |
| 7276 | |
| 7277 | if style == none { |
| 7278 | style = (start: none, end: none, symbol: none) |
| 7279 | } |
| 7280 | let both-symbol = style.at("symbol", default: none) |
| 7281 | let start-symbol = style.at("start", |
| 7282 | default: both-symbol) |
| 7283 | if start-symbol == none { |
| 7284 | start-symbol = both-symbol |
| 7285 | } |
| 7286 | let end-symbol = style.at("end", |
| 7287 | default: both-symbol) |
| 7288 | if end-symbol == none { |
| 7289 | end-symbol = both-symbol |
| 7290 | } |
| 7291 | |
| 7292 | let (path, is-transformed) = if not style.at("transform-shape", default: true) and transform != none { |
| 7293 | (drawable.apply-transform(transform, path).first(), true) |
| 7294 | } else { |
| 7295 | (path, false) |
| 7296 | } |
| 7297 | |
| 7298 | let segments = path.segments |
| 7299 | if start-symbol != none { |
| 7300 | let (drawables: start-drawables, distance: start-distance, pos: pt) = place-mark-on-path( |
| 7301 | ctx, |
| 7302 | process-style(ctx, style, "start", path-util.length(segments)), |
| 7303 | segments |
| 7304 | ) |
| 7305 | drawables += start-drawables |
| 7306 | distance.first() = start-distance |
| 7307 | snap-to.first() = pt |
| 7308 | } |
| 7309 | if end-symbol != none { |
| 7310 | let (drawables: end-drawables, distance: end-distance, pos: pt) = place-mark-on-path( |
| 7311 | ctx, |
| 7312 | process-style(ctx, style, "end", path-util.length(segments)), |
| 7313 | segments, |
| 7314 | is-end: true |
| 7315 | ) |
| 7316 | drawables += end-drawables |
| 7317 | distance.last() = end-distance |
| 7318 | snap-to.last() = pt |
| 7319 | } |
| 7320 | if distance != (0, 0) { |
| 7321 | segments = path-util.shorten-path( |
| 7322 | segments, |
| 7323 | ..distance, |
| 7324 | mode: if style.flex { "CURVED" } else { "LINEAR" }, |
| 7325 | samples: style.position-samples, |
| 7326 | snap-to: snap-to) |
| 7327 | } |
| 7328 | |
| 7329 | if add-path { |
| 7330 | path.segments = segments |
| 7331 | drawables.insert(0, path) |
| 7332 | } |
| 7333 | |
| 7334 | // If not transformed pre mark placement, |
| 7335 | // transform everything after mark placement. |
| 7336 | if not is-transformed { |
| 7337 | drawables = drawable.apply-transform(transform, drawables) |
| 7338 | } |
| 7339 | |
| 7340 | return drawables |
| 7341 | } |
| 7342 | #import "vector.typ" |
| 7343 | |
| 7344 | // Global rounding precision |
| 7345 | #let precision = 8 |
| 7346 | |
| 7347 | #let _round = calc.round.with(digits: precision) |
| 7348 | |
| 7349 | #let cos(x) = { |
| 7350 | _round(calc.cos(x)) |
| 7351 | } |
| 7352 | |
| 7353 | #let sin(x) = { |
| 7354 | _round(calc.sin(x)) |
| 7355 | } |
| 7356 | |
| 7357 | #let pi = calc.pi |
| 7358 | |
| 7359 | /// Create a (square) identity matrix with dimensions $size \times size$ |
| 7360 | /// |
| 7361 | /// - size (int): Size of the matrix |
| 7362 | /// -> matrix |
| 7363 | #let ident(size) = { |
| 7364 | assert(size >= 1, message: "Invalid dimension") |
| 7365 | |
| 7366 | range(0, size).map(j => range(0, size).map(k => { |
| 7367 | if j == k { 1 } else { 0 } |
| 7368 | })) |
| 7369 | } |
| 7370 | |
| 7371 | /// Create a square matrix with the diagonal set to the |
| 7372 | /// given values |
| 7373 | /// |
| 7374 | /// - ..diag (float): Diagonal values |
| 7375 | /// -> matrix |
| 7376 | #let diag(..diag) = { |
| 7377 | assert(diag.pos().len() >= 1, message: "Invalid dimension") |
| 7378 | assert.eq(diag.named(), (), messaged: "Unexpected named argument") |
| 7379 | |
| 7380 | let diag = diag.pos() |
| 7381 | range(0, diag.len()).map(m => range(0, diag.len()).map(n => { |
| 7382 | if n == m { diag.at(m) } else { 0 } |
| 7383 | })) |
| 7384 | } |
| 7385 | |
| 7386 | /// Returns the dimension of the given matrix as `(m, n)` |
| 7387 | /// - m (matrix): The matrix |
| 7388 | /// -> array |
| 7389 | #let dim(m) = { |
| 7390 | return (m.len(), if m.len() > 0 {m.at(0).len()} else {0}) |
| 7391 | } |
| 7392 | |
| 7393 | /// Returns the nth column of a matrix as a {{vector}} |
| 7394 | /// - mat (matrix): Input matrix |
| 7395 | /// - n (int): The column's index |
| 7396 | /// -> vector |
| 7397 | #let column(mat, n) = { |
| 7398 | range(0, mat.len()).map(m => mat.at(m).at(n)) |
| 7399 | } |
| 7400 | |
| 7401 | /// Replaces the nth column of a matrix with the given vector. |
| 7402 | /// - mat (matrix): Input matrix. |
| 7403 | /// - n (int): The index of the column to replace |
| 7404 | /// - vec (vector): The column data to insert. |
| 7405 | /// -> matrix |
| 7406 | #let set-column(mat, n, vec) = { |
| 7407 | assert(vec.len() == matrix.len()) |
| 7408 | for m in range(0, mat.len()) { |
| 7409 | mat.at(m).at(n) = vec.at(n) |
| 7410 | } |
| 7411 | } |
| 7412 | |
| 7413 | /// Rounds each value in the matrix to a precision. |
| 7414 | /// - mat (matrix): Input matrix |
| 7415 | /// - precision (int) = 8: Rounding precision (digits) |
| 7416 | /// -> matrix |
| 7417 | #let round(mat, precision: precision) = { |
| 7418 | mat.map(r => r.map(v => _round(v, digits: precision))) |
| 7419 | } |
| 7420 | |
| 7421 | /// Returns a $4 \times 4$ translation matrix |
| 7422 | /// - x (float): The translation in the $x$ direction. |
| 7423 | /// - y (float): The translation in the $y$ direction. |
| 7424 | /// - z (float): The translation in the $x$ direction. |
| 7425 | /// -> matrix |
| 7426 | #let transform-translate(x, y, z) = { |
| 7427 | ((1, 0, 0, x), |
| 7428 | (0, 1, 0, y), |
| 7429 | (0, 0, 1, z), |
| 7430 | (0, 0, 0, 1)) |
| 7431 | } |
| 7432 | |
| 7433 | /// Returns a $4 \times 4$ x-shear matrix |
| 7434 | /// - factor (float): The shear in the $x$ direction. |
| 7435 | /// -> matrix |
| 7436 | #let transform-shear-x(factor) = { |
| 7437 | ((1, factor, 0, 0), |
| 7438 | (0, 1, 0, 0), |
| 7439 | (0, 0, 1, 0), |
| 7440 | (0, 0, 0, 1)) |
| 7441 | } |
| 7442 | |
| 7443 | |
| 7444 | /// Returns a $4 \times 4$ z-shear matrix |
| 7445 | /// - factor (float): The shear in the $z$ direction. |
| 7446 | /// -> matrix |
| 7447 | #let transform-shear-z(factor) = { |
| 7448 | ((1, 0, factor, 0), |
| 7449 | (0, 1,-factor, 0), |
| 7450 | (0, 0, 1, 0), |
| 7451 | (0, 0, 0, 1)) |
| 7452 | } |
| 7453 | |
| 7454 | /// Returns a $4 \times 4$ scale matrix |
| 7455 | /// - f (float,array,dictionary): The scale factor(s) of the matrix. An {{array}} of at least 3 {{float}}s sets the x, y and z scale factors. A {{dictionary}} sets the scale in the direction of the corresponding x, y and z keys. A single {{float}} sets the scale for all directions. |
| 7456 | /// -> matrix |
| 7457 | #let transform-scale(f) = { |
| 7458 | let (x, y, z) = if type(f) == array { |
| 7459 | vector.as-vec(f, init: (1, 1, 1)) |
| 7460 | } else if type(f) == dictionary { |
| 7461 | (f.at("x", default: 1), |
| 7462 | f.at("y", default: 1), |
| 7463 | f.at("z", default: 1)) |
| 7464 | } else { |
| 7465 | (f, f, f) |
| 7466 | } |
| 7467 | return( |
| 7468 | (x, 0, 0, 0), |
| 7469 | (0, y, 0, 0), |
| 7470 | (0, 0, z, 0), |
| 7471 | (0, 0, 0, 1)) |
| 7472 | } |
| 7473 | |
| 7474 | /// Returns a $4 \times 4$ rotation xyz matrix for a direction and up vector |
| 7475 | /// - dir (vector): idk |
| 7476 | /// - up (vector): idk |
| 7477 | /// -> matrix |
| 7478 | #let transform-rotate-dir(dir, up) = { |
| 7479 | dir = vector.norm(dir) |
| 7480 | up = vector.norm(up) |
| 7481 | |
| 7482 | let (dx, dy, dz) = dir |
| 7483 | let (ux, uy, uz) = up |
| 7484 | let (rx, ry, rz) = vector.norm(vector.cross(dir, up)) |
| 7485 | |
| 7486 | ((rx, dx, ux, 0), |
| 7487 | (ry, dy, uy, 0), |
| 7488 | (rz, dz, uz, 0), |
| 7489 | (0, 0, 0, 1)) |
| 7490 | } |
| 7491 | |
| 7492 | // Return 4x4 rotate x matrix |
| 7493 | /// Returns a $4 \times 4$ $x$ rotation matrix |
| 7494 | /// - angle (angle): The angle to rotate around the $x$ axis |
| 7495 | /// -> matrix |
| 7496 | #let transform-rotate-x(angle) = { |
| 7497 | ((1, 0, 0, 0), |
| 7498 | (0, cos(angle), -sin(angle), 0), |
| 7499 | (0, sin(angle), cos(angle), 0), |
| 7500 | (0, 0, 0, 1)) |
| 7501 | } |
| 7502 | |
| 7503 | // Return 4x4 rotate y matrix |
| 7504 | /// Returns a $4 \times 4$ $y$ rotation matrix |
| 7505 | /// - angle (angle): The angle to rotate around the $y$ axis |
| 7506 | /// -> matrix |
| 7507 | #let transform-rotate-y(angle) = { |
| 7508 | ((cos(angle), 0, -sin(angle), 0), |
| 7509 | (0, 1, 0, 0), |
| 7510 | (sin(angle), 0, cos(angle), 0), |
| 7511 | (0, 0, 0, 1)) |
| 7512 | } |
| 7513 | |
| 7514 | // Return 4x4 rotate z matrix |
| 7515 | /// Returns a $4 \times 4$ $z$ rotation matrix |
| 7516 | /// - angle (angle): The angle to rotate around the $z$ axis |
| 7517 | /// -> matrix |
| 7518 | #let transform-rotate-z(angle) = { |
| 7519 | ((cos(angle), -sin(angle), 0, 0), |
| 7520 | (sin(angle), cos(angle), 0, 0), |
| 7521 | (0, 0, 1, 0), |
| 7522 | (0, 0, 0, 1)) |
| 7523 | } |
| 7524 | |
| 7525 | // Return 4x4 rotate xz matrix |
| 7526 | /// Returns a $4 \times 4$ $x z$ rotation matrix |
| 7527 | /// - x (angle): The angle to rotate around the $x$ axis |
| 7528 | /// - z (angle): The angle to rotate around the $z$ axis |
| 7529 | /// -> matrix |
| 7530 | #let transform-rotate-xz(x, z) = { |
| 7531 | ((cos(z), sin(z), 0, 0), |
| 7532 | (-cos(x)*sin(z), cos(x)*cos(z), -sin(x), 0), |
| 7533 | (sin(x)*sin(z), -sin(x)*cos(z), cos(x), 1), |
| 7534 | (0, 0, 0, 1)) |
| 7535 | } |
| 7536 | |
| 7537 | /// Returns a $4 \times 4$ rotation matrix - yaw-pitch-roll |
| 7538 | /// |
| 7539 | /// Calculates the product of the three rotation matrices |
| 7540 | /// $R = Rz(a) Ry(b) Rx(c)$ |
| 7541 | /// |
| 7542 | /// - a (angle): Yaw |
| 7543 | /// - b (angle): Pitch |
| 7544 | /// - c (angle): Roll |
| 7545 | /// -> matrix |
| 7546 | #let transform-rotate-ypr(a, b, c) = { |
| 7547 | ((cos(a)*cos(b), cos(a)*sin(b)*sin(c) - sin(a)*cos(c), cos(a)*sin(b)*cos(c) + sin(a)*sin(c), 0), |
| 7548 | (sin(a)*cos(b), sin(a)*sin(b)*sin(c) + cos(a)*cos(c), sin(a)*sin(b)*cos(c) - cos(a)*sin(c), 0), |
| 7549 | (-sin(b), cos(b)*sin(c), cos(b)*cos(c), 1), |
| 7550 | (0,0,0,1)) |
| 7551 | } |
| 7552 | |
| 7553 | /// Returns a $4 \times 4$ rotation matrix - euler angles |
| 7554 | /// |
| 7555 | /// Calculates the product of the three rotation matrices |
| 7556 | /// $R = Rz(z) Ry(y) Rx(x)$ |
| 7557 | /// |
| 7558 | /// - x (angle): Rotation about x |
| 7559 | /// - y (angle): Rotation about y |
| 7560 | /// - z (angle): Rotation about z |
| 7561 | /// -> matrix |
| 7562 | #let transform-rotate-xyz(x, y, z) = { |
| 7563 | ((cos(y)*cos(z), sin(x)*sin(y)*cos(z) - cos(x)*sin(z), cos(x)*sin(y)*cos(z) + sin(x)*sin(z), 0), |
| 7564 | (cos(y)*sin(z), sin(x)*sin(y)*sin(z) + cos(x)*cos(z), cos(x)*sin(y)*sin(z) - sin(x)*cos(z), 0), |
| 7565 | (-sin(y), sin(x)*cos(y), cos(x)*cos(y), 0), |
| 7566 | (0,0,0,1)) |
| 7567 | } |
| 7568 | |
| 7569 | /// Multiplies matrices on top of each other. |
| 7570 | /// - ..matrices (matrix): The matrices to multiply from left to right. |
| 7571 | /// -> matrix |
| 7572 | #let mul-mat(..matrices) = { |
| 7573 | matrices = matrices.pos() |
| 7574 | let out = matrices.remove(0) |
| 7575 | for matrix in matrices { |
| 7576 | let (m, n, p) = ( |
| 7577 | ..dim(out), |
| 7578 | dim(matrix).last() |
| 7579 | ) |
| 7580 | out = ( |
| 7581 | for i in range(m) { |
| 7582 | ( |
| 7583 | for j in range(p) { |
| 7584 | (_round(range(n).map(k => out.at(i).at(k) * matrix.at(k).at(j)).sum(), digits: precision),) |
| 7585 | } |
| 7586 | ,) |
| 7587 | } |
| 7588 | ) |
| 7589 | } |
| 7590 | return out |
| 7591 | } |
| 7592 | |
| 7593 | // Multiply 4x4 matrix with vector of size 3 or 4. |
| 7594 | // The value of vec_4 defaults to w (1). |
| 7595 | // |
| 7596 | // The resulting vector is of dimension 3 |
| 7597 | /// Multiplies a $4 \times 4$ matrix with a vector of size 3 or 4. The resulting is three dimensional |
| 7598 | /// - mat (matrix): The matrix to multiply |
| 7599 | /// - vec (vector): The vector to multiply |
| 7600 | /// - w (float): The default value for the fourth element of the vector if it is three dimensional. |
| 7601 | /// -> vector |
| 7602 | #let mul4x4-vec3(mat, vec, w: 1) = { |
| 7603 | assert(vec.len() <= 4) |
| 7604 | |
| 7605 | let x = vec.at(0) |
| 7606 | let y = vec.at(1) |
| 7607 | let z = vec.at(2, default: 0) |
| 7608 | let w = vec.at(3, default: w) |
| 7609 | |
| 7610 | let ((a1,a2,a3,a4), (b1,b2,b3,b4), (c1,c2,c3,c4), _) = mat |
| 7611 | return ( |
| 7612 | a1 * x + a2 * y + a3 * z + a4 * w, |
| 7613 | b1 * x + b2 * y + b3 * z + b4 * w, |
| 7614 | c1 * x + c2 * y + c3 * z + c4 * w) |
| 7615 | } |
| 7616 | |
| 7617 | // Multiply matrix with vector |
| 7618 | /// Multiplies an $m \times n$ matrix with an $m$th dimensional vector where $m \lte 4$. Prefer the use of `mul4x4-vec3` when possible as it does not use loops. |
| 7619 | /// - mat (matrix): The matrix to multiply |
| 7620 | /// - vec (vector): The vector to multiply |
| 7621 | /// -> vector |
| 7622 | #let mul-vec(mat, vec) = { |
| 7623 | let m = mat.len() |
| 7624 | let n = mat.at(0).len() |
| 7625 | assert(n == vec.len(), message: "Matrix columns must be equal to vector rows") |
| 7626 | |
| 7627 | let new = (0,) * m |
| 7628 | for i in range(0, m) { |
| 7629 | for j in range(0, n) { |
| 7630 | new.at(i) = new.at(i) + mat.at(i).at(j) * vec.at(j) |
| 7631 | } |
| 7632 | } |
| 7633 | return new |
| 7634 | } |
| 7635 | |
| 7636 | /// Calculates the inverse matrix of any size. |
| 7637 | /// - matrix (matrix): The matrix to inverse. |
| 7638 | /// -> matrix |
| 7639 | #let inverse(matrix) = { |
| 7640 | let n = { |
| 7641 | let size = dim(matrix) |
| 7642 | assert.eq(size.first(), size.last(), message: "Matrix must be square to perform inversion.") |
| 7643 | size.first() |
| 7644 | } |
| 7645 | |
| 7646 | let N = range(n) |
| 7647 | let inverted = ident(n) |
| 7648 | let p |
| 7649 | for j in N { |
| 7650 | for i in range(j, n) { |
| 7651 | if matrix.at(i).at(j) != 0 { |
| 7652 | (matrix.at(j), matrix.at(i)) = (matrix.at(i), matrix.at(j)) |
| 7653 | (inverted.at(j), inverted.at(i)) = (inverted.at(i), inverted.at(j)) |
| 7654 | |
| 7655 | p = 1 / matrix.at(j).at(j) |
| 7656 | for k in N { |
| 7657 | matrix.at(j).at(k) *= p |
| 7658 | inverted.at(j).at(k) *= p |
| 7659 | } |
| 7660 | |
| 7661 | for L in N { |
| 7662 | if L != j { |
| 7663 | p = -matrix.at(L).at(j) |
| 7664 | for k in N { |
| 7665 | matrix.at(L).at(k) += _round(p * matrix.at(j).at(k)) |
| 7666 | inverted.at(L).at(k) += _round(p * inverted.at(j).at(k)) |
| 7667 | } |
| 7668 | } |
| 7669 | } |
| 7670 | } |
| 7671 | } |
| 7672 | } |
| 7673 | |
| 7674 | return inverted |
| 7675 | } |
| 7676 | |
| 7677 | /// Swaps the a-th column with the b-th column. |
| 7678 | /// |
| 7679 | /// - mat (matrix): Matrix |
| 7680 | /// - a (int): The index of column a. |
| 7681 | /// - b (int): The index of column b. |
| 7682 | /// -> matrix |
| 7683 | #let swap-cols(mat, a, b) = { |
| 7684 | let new = mat |
| 7685 | for m in range(mat.len()) { |
| 7686 | new.at(m).at(a) = mat.at(m).at(b) |
| 7687 | new.at(m).at(b) = mat.at(m).at(a) |
| 7688 | } |
| 7689 | return new |
| 7690 | } |
| 7691 | |
| 7692 | /// Translates a matrix by a vector. |
| 7693 | /// - mat (matrix): The matrix to translate |
| 7694 | /// - vec (vector): The vector to translate by. |
| 7695 | #let translate(mat, vec) = { |
| 7696 | return mul-mat( |
| 7697 | mat, |
| 7698 | transform-translate( |
| 7699 | vec.at(0), |
| 7700 | -vec.at(1), |
| 7701 | vec.at(2), |
| 7702 | ), |
| 7703 | ) |
| 7704 | } |
| 7705 | // This file contains utility functions for path calculation |
| 7706 | #import "util.typ" |
| 7707 | #import "vector.typ" |
| 7708 | #import "bezier.typ" |
| 7709 | #import "deps.typ" |
| 7710 | #import deps.oxifmt: strfmt |
| 7711 | |
| 7712 | #let default-samples = 25 |
| 7713 | |
| 7714 | /// Returns the first position vector of a path segment. |
| 7715 | /// |
| 7716 | /// - s (segment): Path segment |
| 7717 | /// -> vector |
| 7718 | #let segment-start(s) = { |
| 7719 | return s.at(1) |
| 7720 | } |
| 7721 | |
| 7722 | /// Returns the last position vector of a path segment |
| 7723 | /// |
| 7724 | /// - s (segment): Path segment |
| 7725 | /// -> vector |
| 7726 | #let segment-end(s) = { |
| 7727 | if s.at(0) == "line" { |
| 7728 | return s.last() |
| 7729 | } |
| 7730 | return s.at(2) |
| 7731 | } |
| 7732 | |
| 7733 | /// Calculates the bounding points for a list of path segments |
| 7734 | /// |
| 7735 | /// - segments (array): List of path segments |
| 7736 | /// -> array |
| 7737 | #let bounds(segments) = { |
| 7738 | let bounds = () |
| 7739 | |
| 7740 | for s in segments { |
| 7741 | let (kind, ..pts) = s |
| 7742 | if kind == "line" { |
| 7743 | bounds += pts |
| 7744 | } else if kind == "cubic" { |
| 7745 | bounds.push(pts.at(0)) |
| 7746 | bounds.push(pts.at(1)) |
| 7747 | bounds += bezier.cubic-extrema(..pts) |
| 7748 | } |
| 7749 | } |
| 7750 | return bounds |
| 7751 | } |
| 7752 | |
| 7753 | /// Calculates the length of a single path segment |
| 7754 | /// |
| 7755 | /// - s (array): Path segment |
| 7756 | /// -> float |
| 7757 | #let _segment-length(s, samples: default-samples) = { |
| 7758 | let (kind, ..pts) = s |
| 7759 | if kind == "line" { |
| 7760 | let len = 0 |
| 7761 | for i in range(1, pts.len()) { |
| 7762 | len += vector.len(vector.sub(pts.at(i - 1), pts.at(i))) |
| 7763 | } |
| 7764 | return len |
| 7765 | } else if kind == "cubic" { |
| 7766 | return bezier.cubic-arclen(..pts, samples: samples) |
| 7767 | } else { |
| 7768 | panic("Invalid segment: " + kind, s) |
| 7769 | } |
| 7770 | } |
| 7771 | |
| 7772 | /// Calculates the length of a path |
| 7773 | /// |
| 7774 | /// - segments (array): List of path segments |
| 7775 | /// -> float |
| 7776 | #let length(segments) = { |
| 7777 | return segments.map(_segment-length).sum() |
| 7778 | } |
| 7779 | |
| 7780 | /// Finds the two points that enclose a distance along a line segment. |
| 7781 | /// |
| 7782 | /// Returns a {{dictionary}} with the following key values: |
| 7783 | /// - start (int): The index of the point that is before the distance. |
| 7784 | /// - end (int): The index of the point that is after the distance. |
| 7785 | /// - distance (float): The distance along the line segment to the point with the `start` index. |
| 7786 | /// - length (float): The distance between the found points. |
| 7787 | /// |
| 7788 | /// --- |
| 7789 | /// |
| 7790 | /// - pts (array): The array of points of a line segment. |
| 7791 | /// - distance (float): The distance along the line segment. |
| 7792 | /// -> dictionary |
| 7793 | #let _points-between-distance(pts, distance) = { |
| 7794 | let travelled = 0 |
| 7795 | let length = 0 |
| 7796 | for i in range(1, pts.len()) { |
| 7797 | length = vector.dist(pts.at(i - 1), pts.at(i)) |
| 7798 | if travelled <= distance and distance <= travelled + length { |
| 7799 | return ( |
| 7800 | start: i - 1, |
| 7801 | end: i, |
| 7802 | distance: distance - travelled, |
| 7803 | length: length |
| 7804 | ) |
| 7805 | } |
| 7806 | travelled += length |
| 7807 | } |
| 7808 | return ( |
| 7809 | start: pts.len() - 2, |
| 7810 | end: pts.len() - 1, |
| 7811 | distance: length, |
| 7812 | length: length |
| 7813 | ) |
| 7814 | } |
| 7815 | |
| 7816 | /// Finds the point at a given distance from the start of a line segment. Distances greater than the length of the segment return the end of the line segment. Distances less than zero return the start of the segment. |
| 7817 | /// |
| 7818 | /// - segment (array): The line segment |
| 7819 | /// - distance (float): The distance along the line segment to find the point |
| 7820 | /// -> vector |
| 7821 | #let _point-on-line-segment(segment, distance) = { |
| 7822 | let pts = segment.slice(1) |
| 7823 | |
| 7824 | let (start, end, distance, length) = _points-between-distance(pts, distance) |
| 7825 | return if length == 0 { |
| 7826 | // length can be zero if start and end are at the same position |
| 7827 | // this can occur for several reasons, user input, group has zero width or height (not both) |
| 7828 | pts.at(end) |
| 7829 | } else { |
| 7830 | vector.lerp(pts.at(start), pts.at(end), distance / length) |
| 7831 | } |
| 7832 | } |
| 7833 | |
| 7834 | /// Finds the point at a given distance from the start of a path segment. Distances greater than the length of the segment return the end of the path segment. Distances less than zero return the start of the segment. |
| 7835 | /// |
| 7836 | /// - segment (segment): Path segment |
| 7837 | /// - distance (float): The distance along the path segment to find the point |
| 7838 | /// - extrapolate (bool): If true, use linear extrapolation for distances outsides the path |
| 7839 | /// -> vector |
| 7840 | #let _point-on-segment(segment, distance, samples: default-samples, extrapolate: false) = { |
| 7841 | let (kind, ..pts) = segment |
| 7842 | if kind == "line" { |
| 7843 | return _point-on-line-segment(segment, distance) |
| 7844 | } else if kind == "cubic" { |
| 7845 | return bezier.cubic-point( |
| 7846 | ..pts, |
| 7847 | bezier.cubic-t-for-distance( |
| 7848 | ..pts, |
| 7849 | distance, |
| 7850 | samples: samples |
| 7851 | ) |
| 7852 | ) |
| 7853 | } |
| 7854 | } |
| 7855 | |
| 7856 | /// Finds the segment that contains a point that is distance `t` from the start of a path. |
| 7857 | /// - segments (array): The array of segments that make up the path. |
| 7858 | /// - t (float,ratio): The distance to find the segment with. A {{float}} will be in absolute distance along the path. A {{ratio}} will be relative to the length of the path. Will panic if the distance is greater than the length of the path. |
| 7859 | /// - rev (bool): When true the path will be reversed, effectively looking for the segment from the end of the path. |
| 7860 | /// - samples (int): The number of samples to use when calculating the length of a cubic segment. |
| 7861 | /// - clamp (bool): Clamps the distance to the length of the path, so the function won't panic. |
| 7862 | /// -> dictionary |
| 7863 | /// --- |
| 7864 | /// Returns a {{dictionary}} with the folloing key-value pairs: |
| 7865 | /// - index (int): The index of the segment in the given array of segments. |
| 7866 | /// - segment (segment): The found segment. |
| 7867 | /// - travelled (float): The absolute distance travelled along the path to find the segment. |
| 7868 | /// - distance (float): The distance left to travel along the path. |
| 7869 | /// - length (float): The length of the returned segment. |
| 7870 | #let segment-at-t(segments, t, rev: false, samples: default-samples, clamp: false) = { |
| 7871 | let lengths = segments.map(_segment-length.with(samples: samples)) |
| 7872 | let total = lengths.sum() |
| 7873 | |
| 7874 | if type(t) == ratio { |
| 7875 | t = total * t / 100% |
| 7876 | } |
| 7877 | if not clamp { |
| 7878 | assert(t >= 0 and t <= total, |
| 7879 | message: strfmt("t is expected to be between 0 and the length of the path ({}), got: {}", total, t)) |
| 7880 | } |
| 7881 | |
| 7882 | if rev { |
| 7883 | segments = segments.rev() |
| 7884 | lengths = lengths.rev() |
| 7885 | } |
| 7886 | let travelled = 0 |
| 7887 | for (i, segment-length) in segments.zip(lengths).enumerate() { |
| 7888 | let (segment, length) = segment-length |
| 7889 | if travelled <= t and t <= travelled + length { |
| 7890 | return ( |
| 7891 | index: if rev { segments.len() - i } else { i }, |
| 7892 | segment: segment, |
| 7893 | // Distance travelled |
| 7894 | travelled: travelled, |
| 7895 | // Distance left |
| 7896 | distance: t - travelled, |
| 7897 | // The length of the segment |
| 7898 | length: length |
| 7899 | ) |
| 7900 | } |
| 7901 | travelled += length |
| 7902 | } |
| 7903 | return (index: if rev { 0 } else { segments.len() - 1 }, |
| 7904 | segment: segments.last(), |
| 7905 | travelled: total, |
| 7906 | distance: t, |
| 7907 | length: lengths.last()) |
| 7908 | } |
| 7909 | |
| 7910 | /// Extrapolates a point from a segment. It finds the direction the end of the segment is pointing and scales the normalised vector from the end of the segment. Returns {{none}} if the segment has no direction. |
| 7911 | /// - segment (segment): The segment to extrapolate from. |
| 7912 | /// - distance (float): The distance to extrapolate the point to. |
| 7913 | /// - rev (bool): If `true` the segment will be reversed, effectively extrapolating the point from its start. |
| 7914 | /// - samples (int): This isn't used. |
| 7915 | #let _extrapolated-point-on-segment(segment, distance, rev: false, samples: 100) = { |
| 7916 | let (kind, ..pts) = segment |
| 7917 | let (pt, dir) = if kind == "line" { |
| 7918 | let (a, b) = if rev { |
| 7919 | (pts.at(0), pts.at(1)) |
| 7920 | } else { |
| 7921 | (pts.at(-2), pts.at(-1)) |
| 7922 | } |
| 7923 | (if rev {a} else {b}, vector.sub(b, a)) |
| 7924 | } else { |
| 7925 | let dir = bezier.cubic-derivative(..pts, if rev { 0 } else { 1 }) |
| 7926 | if vector.len(dir) == 0 { |
| 7927 | dir = vector.sub(pts.at(1), pts.at(0)) |
| 7928 | } |
| 7929 | (if rev {pts.at(0)} else {pts.at(1)}, dir) |
| 7930 | } |
| 7931 | |
| 7932 | if vector.len(dir) != 0 { |
| 7933 | return vector.add(pt, vector.scale(vector.norm(dir), distance * if rev { -1 } else { 1 })) |
| 7934 | } |
| 7935 | return none |
| 7936 | } |
| 7937 | |
| 7938 | /// Finds the position of a point a distance from the start of a path. If the path is empty {{none}} will be returned. |
| 7939 | /// |
| 7940 | /// - segments (array): List of path segments |
| 7941 | /// - t (int,float,ratio): Absolute position on the path if given an float or integer, or relative position if given a ratio from 0% to 100%. When this value is negative, the point will be found from the end of the path instaed of the start. |
| 7942 | /// - extrapolate (bool): If true, use linear extrapolation if distance is outsides the path's range |
| 7943 | /// -> none,vector |
| 7944 | #let point-on-path(segments, t, samples: default-samples, extrapolate: false) = { |
| 7945 | assert( |
| 7946 | type(t) in (int, float, ratio), |
| 7947 | message: "Distance t must be of type int, float or ratio" |
| 7948 | ) |
| 7949 | let rev = if type(t) == ratio and t < 0% or type(t) in (int, float) and t < 0 { |
| 7950 | true |
| 7951 | } else { |
| 7952 | false |
| 7953 | } |
| 7954 | if rev { |
| 7955 | t *= -1 |
| 7956 | } |
| 7957 | |
| 7958 | // Extrapolate at path boundaries if enabled |
| 7959 | if extrapolate { |
| 7960 | let total = length(segments) |
| 7961 | let absolute-t = if type(t) == ratio { t / 100% * total } else { t } |
| 7962 | if absolute-t > total { |
| 7963 | return _extrapolated-point-on-segment(segments.first(), absolute-t - total, rev: rev, samples: samples) |
| 7964 | } |
| 7965 | } |
| 7966 | |
| 7967 | let segment = segment-at-t(segments, t, samples: samples, rev: rev) |
| 7968 | return if segment != none { |
| 7969 | let (distance, segment, length, ..) = segment |
| 7970 | _point-on-segment(segment, if rev { length - distance } else { distance }, samples: samples, extrapolate: extrapolate) |
| 7971 | } |
| 7972 | } |
| 7973 | |
| 7974 | /// Finds the position and direction of a point a distance along from the start of a path. Returns an {{array}} of two vectors where the first is the position, and the second is the direction. |
| 7975 | /// |
| 7976 | /// - segments (array): List of path segments |
| 7977 | /// - t (int,float,ratio): Absolute position on the path if given an float or integer, or relative position if given a ratio from 0% to 100%. When this value is negative, the point will be found from the end of the path instaed of the start. |
| 7978 | /// - extrapolate (bool): If true, use linear extrapolation if distance is outsides the path's range |
| 7979 | /// - clamp (bool): Clamps the distance between the start and end of a path. |
| 7980 | /// -> array |
| 7981 | #let direction(segments, t, samples: default-samples, clamp: false) = { |
| 7982 | let (segment, distance, length, ..) = segment-at-t(segments, t, samples: samples, clamp: clamp) |
| 7983 | let (kind, ..pts) = segment |
| 7984 | return ( |
| 7985 | _point-on-segment(segment, distance, samples: samples), |
| 7986 | if kind == "line" { |
| 7987 | let (start, end, distance, length) = _points-between-distance(pts, distance) |
| 7988 | vector.norm(vector.sub(segment.at(end+1), segment.at(start+1))) |
| 7989 | } else { |
| 7990 | let t = bezier.cubic-t-for-distance(..pts, distance, samples: samples) |
| 7991 | let dir = bezier.cubic-derivative(..pts, t) |
| 7992 | if vector.len(dir) == 0 { |
| 7993 | vector.norm(vector.sub(pts.at(1), pts.at(0))) |
| 7994 | } else { |
| 7995 | dir |
| 7996 | } |
| 7997 | } |
| 7998 | ) |
| 7999 | } |
| 8000 | |
| 8001 | /// Creates a line segment with points |
| 8002 | /// |
| 8003 | /// - points (array): List of points |
| 8004 | /// -> segment |
| 8005 | #let line-segment(points) = { |
| 8006 | ("line",) + points |
| 8007 | } |
| 8008 | |
| 8009 | /// Creates a cubic bezier segment |
| 8010 | /// |
| 8011 | /// - a (vector): Start |
| 8012 | /// - b (vector): End |
| 8013 | /// - ctrl-a (vector): Control point a |
| 8014 | /// - ctrl-b (vector): Control point b |
| 8015 | /// -> segment |
| 8016 | #let cubic-segment(a, b, ctrl-a, ctrl-b) = { |
| 8017 | ("cubic", a, b, ctrl-a, ctrl-b) |
| 8018 | } |
| 8019 | |
| 8020 | /// Normalize segments by connecting gaps via straight line segments and merging multiple line segments into a single one. |
| 8021 | /// |
| 8022 | /// - segments (array): The path segments to normalize. |
| 8023 | /// -> array |
| 8024 | #let normalize(segments) = { |
| 8025 | let new = () |
| 8026 | for s in segments { |
| 8027 | if new == () { |
| 8028 | new.push(s) |
| 8029 | } else { |
| 8030 | let head = new.last() |
| 8031 | let (kind, ..pts) = s |
| 8032 | |
| 8033 | if kind == "line" and head.at(0) == kind { |
| 8034 | // Merge consecutive line segments |
| 8035 | if new.last().len() > 0 and new.last().last() == pts.first() { |
| 8036 | new.last() += pts.slice(1) |
| 8037 | } else { |
| 8038 | new.last() += pts |
| 8039 | } |
| 8040 | } else if segment-start(s) != segment-end(head) { |
| 8041 | // Push a new line or line point if the current segment |
| 8042 | // does not start where the previous segment ended |
| 8043 | if head.at(0) == "line" { |
| 8044 | new.last().push(pts.first()) |
| 8045 | } else { |
| 8046 | new.push(line-segment((segment-end(head), segment-start(s)))) |
| 8047 | } |
| 8048 | // Push the segment |
| 8049 | new.push(s) |
| 8050 | } else { |
| 8051 | new.push(s) |
| 8052 | } |
| 8053 | } |
| 8054 | } |
| 8055 | return new |
| 8056 | } |
| 8057 | |
| 8058 | /// Shortens a segment by a given distance. |
| 8059 | /// - segment (segment): The segment to shorten. |
| 8060 | /// - distance (float): The distance to move the start of the segment towards the end of the segment. If this value is negative, the end of the segment will be moved towards the start. |
| 8061 | /// - snap-to (none, vector): Shortening bezier curves suffers from rounding and precision errors so a position can be given to "snap" a curve's start/end point to. |
| 8062 | /// - mode (str): How cubic segments should be shortned. Can be `"LINEAR"` to use `bezier.cubic-shorten-linear` or `"CURVED"` to use `bezier.cubic-shorten`. |
| 8063 | /// - samples (int): The number of samples to use when shortening a cubic segment. |
| 8064 | /// -> segment |
| 8065 | #let shorten-segment(segment, distance, snap-to: none, mode: "CURVED", samples: default-samples) = { |
| 8066 | let rev = distance < 0 |
| 8067 | if distance >= _segment-length(segment) { |
| 8068 | return line-segment(if rev { |
| 8069 | (segment-start(segment), segment-start(segment)) |
| 8070 | } else { |
| 8071 | (segment-end(segment), segment-end(segment)) |
| 8072 | }) |
| 8073 | } |
| 8074 | |
| 8075 | let (kind, ..s) = segment |
| 8076 | if kind == "line" { |
| 8077 | if rev { |
| 8078 | distance *= -1 |
| 8079 | s = s.rev() |
| 8080 | } |
| 8081 | let (start, end, distance, length) = _points-between-distance(s, distance) |
| 8082 | if length != 0 { |
| 8083 | s = (vector.lerp(s.at(start), s.at(end), distance / length),) + s.slice(end) |
| 8084 | } |
| 8085 | |
| 8086 | if rev { |
| 8087 | s = s.rev() |
| 8088 | } |
| 8089 | } else { |
| 8090 | s = if mode == "LINEAR" { |
| 8091 | bezier.cubic-shorten-linear(..s, distance) |
| 8092 | } else { |
| 8093 | bezier.cubic-shorten(..s, distance, samples: samples) |
| 8094 | } |
| 8095 | |
| 8096 | // Shortening beziers suffers from rounding or precision errors |
| 8097 | // so we "snap" the curve start/end to the snap-points, if provided. |
| 8098 | if snap-to != none { |
| 8099 | if rev { s.at(1) = snap-to } else { s.at(0) = snap-to } |
| 8100 | } |
| 8101 | } |
| 8102 | return (kind,) + s |
| 8103 | } |
| 8104 | |
| 8105 | /// Shortens a path's segments by the given distances. The start of the path is shortened first by moving the point along the path towards the end. The end of the path is then shortened in the same way. When a distance is 0 no other calculations are made. |
| 8106 | /// |
| 8107 | /// - segments (segments): The segments of the path to shorten. |
| 8108 | /// - start-distance (int, float): The distance to shorten from the start of the path. |
| 8109 | /// - end-distance (int, float): The distance to shorten from the end of the path |
| 8110 | /// - pos (none, tuple): Tuple of points to "snap" the path ends to |
| 8111 | /// -> segments Segments of the path that have been shortened |
| 8112 | #let shorten-path(segments, start-distance, end-distance, snap-to: none, mode: "CURVED", samples: default-samples) = { |
| 8113 | let total = length(segments) |
| 8114 | let (snap-start, snap-end) = if snap-to == none { |
| 8115 | (none, none) |
| 8116 | } else { |
| 8117 | snap-to |
| 8118 | } |
| 8119 | |
| 8120 | if start-distance > 0 { |
| 8121 | let (segment, distance, index, ..) = segment-at-t( |
| 8122 | segments, |
| 8123 | start-distance, |
| 8124 | clamp: true, |
| 8125 | ) |
| 8126 | segments = segments.slice(index + 1) |
| 8127 | segments.insert(0, |
| 8128 | shorten-segment( |
| 8129 | segment, |
| 8130 | distance, |
| 8131 | mode: mode, |
| 8132 | samples: samples, |
| 8133 | snap-to: snap-start |
| 8134 | ) |
| 8135 | ) |
| 8136 | } |
| 8137 | if end-distance > 0 { |
| 8138 | let (segment, distance, index, ..) = segment-at-t( |
| 8139 | segments, |
| 8140 | end-distance, |
| 8141 | rev: true, |
| 8142 | clamp: true, |
| 8143 | ) |
| 8144 | segments = segments.slice(0, index - 1) |
| 8145 | segments.push( |
| 8146 | shorten-segment( |
| 8147 | segment, |
| 8148 | -distance, |
| 8149 | mode: mode, |
| 8150 | samples: samples, |
| 8151 | snap-to: snap-end |
| 8152 | ) |
| 8153 | ) |
| 8154 | } |
| 8155 | return segments |
| 8156 | } |
| 8157 | #import "/src/vector.typ" |
| 8158 | |
| 8159 | /// Returns a list of polygon points from |
| 8160 | /// a list of segments. |
| 8161 | /// |
| 8162 | /// Cubic segments get linearized by sampling. |
| 8163 | /// |
| 8164 | /// - segment (array): List of segments |
| 8165 | /// - samples (int): Number of samples |
| 8166 | /// -> array |
| 8167 | #let from-segments(segments, samples: 10) = { |
| 8168 | import "/src/bezier.typ": cubic-point |
| 8169 | let poly = () |
| 8170 | for ((kind, ..pts)) in segments { |
| 8171 | if kind == "cubic" { |
| 8172 | poly += range(0, samples).map(t => { |
| 8173 | cubic-point(..pts, t / (samples - 1)) |
| 8174 | }) |
| 8175 | } else { |
| 8176 | poly += pts |
| 8177 | } |
| 8178 | } |
| 8179 | return poly |
| 8180 | } |
| 8181 | |
| 8182 | /// Computes the signed area of a 2D polygon. |
| 8183 | /// |
| 8184 | /// The formula used is the following: |
| 8185 | /// $ 1/2 \sum_{i}=0^{n-1} x_i*y_i+1 - x_i+1*y_i $ |
| 8186 | /// |
| 8187 | /// - points (array): List of Vectors of dimension >= 2 |
| 8188 | /// -> float |
| 8189 | #let signed-area(points) = { |
| 8190 | let a = 0 |
| 8191 | let n = points.len() |
| 8192 | let (cx, cy) = (0, 0) |
| 8193 | for i in range(0, n) { |
| 8194 | let (x0, y0, ..) = points.at(i) |
| 8195 | let (x1, y1, ..) = points.at(calc.rem(i + 1, n)) |
| 8196 | cx += (x0 + x1) * (x0 * y1 - x1 * y0) |
| 8197 | cy += (y0 + y1) * (x0 * y1 - x1 * y0) |
| 8198 | a += x0 * y1 - x1 * y0 |
| 8199 | } |
| 8200 | return .5 * a |
| 8201 | } |
| 8202 | |
| 8203 | /// Returns the winding order of a 2D polygon |
| 8204 | /// by using it's signed area. |
| 8205 | /// |
| 8206 | /// Returns either "ccw" (counter clock-wise) or "cw" (clock-wise) or none. |
| 8207 | /// |
| 8208 | /// - point (array): List of polygon points |
| 8209 | /// -> str,none |
| 8210 | #let winding-order(points) = { |
| 8211 | let area = signed-area(points) |
| 8212 | if area > 0 { |
| 8213 | "cw" |
| 8214 | } else if area < 0 { |
| 8215 | "ccw" |
| 8216 | } else { |
| 8217 | none |
| 8218 | } |
| 8219 | } |
| 8220 | |
| 8221 | // Calculate triangle centroid |
| 8222 | #let triangle-centroid(points) = { |
| 8223 | assert.eq(points.len(), 3) |
| 8224 | |
| 8225 | let (mx, my, mz) = (0, 0, 0) |
| 8226 | for p in points { |
| 8227 | let (x, y, z) = p |
| 8228 | mx += x |
| 8229 | my += y |
| 8230 | mz += z |
| 8231 | } |
| 8232 | return (mx / 3, my / 3, mz / 3) |
| 8233 | } |
| 8234 | |
| 8235 | // Calculate the centroid of a line, triangle or simple polygon |
| 8236 | // Formulas: |
| 8237 | // https://en.wikipedia.org/wiki/Centroid |
| 8238 | #let simple-centroid(points) = { |
| 8239 | return if points.len() <= 1 { |
| 8240 | none |
| 8241 | } else if points.len() == 2 { |
| 8242 | vector.lerp(..points, .5) |
| 8243 | } else if points.len() == 3 { |
| 8244 | triangle-centroid(points) |
| 8245 | } else if points.len() >= 3 { |
| 8246 | // Skip polygons with multiple z values |
| 8247 | let z = points.first().at(2, default: 0) |
| 8248 | if points.any(p => p.at(2) != z) { |
| 8249 | return none |
| 8250 | } |
| 8251 | |
| 8252 | let a = 0 |
| 8253 | let n = points.len() |
| 8254 | let (cx, cy) = (0, 0) |
| 8255 | for i in range(0, n) { |
| 8256 | let (x0, y0, ..) = points.at(i) |
| 8257 | let (x1, y1, ..) = points.at(calc.rem(i + 1, n)) |
| 8258 | cx += (x0 + x1) * (x0 * y1 - x1 * y0) |
| 8259 | cy += (y0 + y1) * (x0 * y1 - x1 * y0) |
| 8260 | a += x0 * y1 - x1 * y0 |
| 8261 | } |
| 8262 | return (cx/(3*a), cy/(3*a), z) |
| 8263 | } |
| 8264 | } |
| 8265 | #import "util.typ" |
| 8266 | #import "path-util.typ" |
| 8267 | #import "aabb.typ" |
| 8268 | #import "drawable.typ" |
| 8269 | #import "vector.typ" |
| 8270 | |
| 8271 | |
| 8272 | /// Processes an element's function to get its drawables and bounds. Returns a {{dictionary}} with the key-values: `ctx` The modified context object, `bounds` The {{aabb}} of the element's drawables, `drawables` An {{array}} of the element's {{drawable}}s. |
| 8273 | /// |
| 8274 | /// - ctx (ctx): The current context object. |
| 8275 | /// - element-func (function): A function that when passed {{ctx}}, it should return an element dictionary. |
| 8276 | #let element(ctx, element-func) = { |
| 8277 | let bounds = none |
| 8278 | let element |
| 8279 | let anchors = (:) |
| 8280 | |
| 8281 | (ctx, ..element,) = element-func(ctx) |
| 8282 | if "drawables" in element { |
| 8283 | if type(element.drawables) == dictionary { |
| 8284 | element.drawables = (element.drawables,) |
| 8285 | } |
| 8286 | for drawable in element.drawables { |
| 8287 | if drawable.bounds { |
| 8288 | bounds = aabb.aabb( |
| 8289 | if drawable.type == "path" { |
| 8290 | path-util.bounds(drawable.segments) |
| 8291 | } else if drawable.type == "content" { |
| 8292 | let (x, y, _, w, h,) = drawable.pos + (drawable.width, drawable.height) |
| 8293 | ((x + w / 2, y - h / 2, 0), (x - w / 2, y + h / 2, 0)) |
| 8294 | }, |
| 8295 | init: bounds |
| 8296 | ) |
| 8297 | } |
| 8298 | } |
| 8299 | } |
| 8300 | |
| 8301 | let name = element.at("name", default: none) |
| 8302 | if name != none { |
| 8303 | assert.eq(type(name), str, |
| 8304 | message: "Element name must be a string") |
| 8305 | assert(not name.contains("."), |
| 8306 | message: "Invalid name for element '" + element.name + "'; name must not contain '.'") |
| 8307 | |
| 8308 | if "anchors" in element { |
| 8309 | ctx.nodes.insert(name, element) |
| 8310 | if ctx.groups.len() > 0 { |
| 8311 | ctx.groups.last().push(name) |
| 8312 | } |
| 8313 | } |
| 8314 | } |
| 8315 | |
| 8316 | if ctx.debug and bounds != none { |
| 8317 | element.drawables.push(drawable.path( |
| 8318 | path-util.line-segment(( |
| 8319 | bounds.low, |
| 8320 | (bounds.high.at(0), bounds.low.at(1), 0), |
| 8321 | bounds.high, |
| 8322 | (bounds.low.at(0), bounds.high.at(1), 0) |
| 8323 | )), |
| 8324 | stroke: red, |
| 8325 | close: true |
| 8326 | )) |
| 8327 | } |
| 8328 | |
| 8329 | return ( |
| 8330 | ctx: ctx, |
| 8331 | bounds: bounds, |
| 8332 | drawables: element.at("drawables", default: ()), |
| 8333 | ) |
| 8334 | } |
| 8335 | |
| 8336 | /// Runs the `element` function for a list of element functions and aggregates the results. |
| 8337 | /// - ctx (ctx): The current context object. |
| 8338 | /// - body (array): The array of element functions to process. |
| 8339 | /// -> dictionary |
| 8340 | #let many(ctx, body) = { |
| 8341 | let drawables = () |
| 8342 | let bounds = none |
| 8343 | |
| 8344 | for el in body { |
| 8345 | let r = element(ctx, el) |
| 8346 | if r != none { |
| 8347 | if r.bounds != none { |
| 8348 | bounds = aabb.aabb(r.bounds, init: bounds) |
| 8349 | } |
| 8350 | ctx = r.ctx |
| 8351 | drawables += r.drawables |
| 8352 | } |
| 8353 | } |
| 8354 | return (ctx: ctx, bounds: bounds, drawables: drawables) |
| 8355 | } |
| 8356 | #import "/src/vector.typ" |
| 8357 | #import "/src/util.typ" |
| 8358 | |
| 8359 | /// Sort list of points by distance to a |
| 8360 | /// reference point. |
| 8361 | /// |
| 8362 | /// - points (array): List of points to sort |
| 8363 | /// - reference (vec): Reference point |
| 8364 | /// -> List of points |
| 8365 | #let points-by-distance(ctx, points, reference: (0, 0, 0)) = { |
| 8366 | let reference = util.apply-transform(ctx.transform, reference) |
| 8367 | return points.sorted(key: pt => { |
| 8368 | vector.dist(pt, reference) |
| 8369 | }) |
| 8370 | } |
| 8371 | |
| 8372 | /// Sort list of 2D points by angle to a |
| 8373 | /// reference 2D point in CCW order. |
| 8374 | /// Z component is ignored. |
| 8375 | /// |
| 8376 | /// - points (array): List of points to sort |
| 8377 | /// - reference (vec): Reference point |
| 8378 | /// -> List of points |
| 8379 | #let points-by-angle(ctx, points, reference: (0, 0, 0)) = { |
| 8380 | let (rx, ry, ..) = util.apply-transform(ctx.transform, reference) |
| 8381 | return points.sorted(key: ((px, py, ..)) => { |
| 8382 | 360deg - calc.atan2(rx - px, ry - py) |
| 8383 | }) |
| 8384 | } |
| 8385 | #import "util.typ" |
| 8386 | |
| 8387 | #let default = ( |
| 8388 | fill: none, |
| 8389 | fill-rule: "non-zero", |
| 8390 | stroke: black + 1pt, |
| 8391 | radius: 1, |
| 8392 | /// Bezier shortening mode: |
| 8393 | /// - "LINEAR" Moving the affected point and it's next control point (like TikZ "quick" key) |
| 8394 | /// - "CURVED" Preserving the bezier curve by calculating new control points |
| 8395 | shorten: "LINEAR", |
| 8396 | |
| 8397 | // Allowed values: |
| 8398 | // - none |
| 8399 | // - Number |
| 8400 | // - Array: (y, x), (top, y, bottom), (top, right, bottom, left) |
| 8401 | // - Dictionary: (top:, right:, bottom:, left:) |
| 8402 | padding: none, |
| 8403 | mark: ( |
| 8404 | scale: 1, // A factor that is applied to length, width, and inset. |
| 8405 | length: .2cm, // The size of the mark along its direction |
| 8406 | width: 0.15cm, // The size of the mark along the normal of its direction |
| 8407 | inset: .05cm, // The inner length of some mark shapes, like triangles and brackets |
| 8408 | sep: .1cm, // The distance between multiple marks along their path |
| 8409 | pos: none, // Position override on the path (none, number or path-length ratio) |
| 8410 | offset: 0, // Mark extra offset (number or path-length ratio) |
| 8411 | start: none, // Mark start symbol(s) |
| 8412 | end: none, // Mark end symbol(s) |
| 8413 | symbol: none, // Mark symbol |
| 8414 | xy-up: (0, 0, 1), // Up vector for 2D marks |
| 8415 | z-up: (0, 1, 0), // Up vector for 3D marks |
| 8416 | stroke: auto, |
| 8417 | fill: auto, |
| 8418 | slant: none, // Slant factor - 0%: no slant, 100%: 45 degree slant |
| 8419 | harpoon: false, |
| 8420 | flip: false, |
| 8421 | reverse: false, |
| 8422 | /// If false, the mark points in the direction of the paths start/end direction. |
| 8423 | /// Curved paths get shortened linearly. |
| 8424 | flex: true, |
| 8425 | /// Max. number of samples to use for calculating curve positions |
| 8426 | /// a higher number gives better results but may slow down compilation. |
| 8427 | position-samples: 30, |
| 8428 | /// Index of the mark the path should get shortened to, or auto |
| 8429 | /// to shorten to the last mark. To apply different values per side, |
| 8430 | /// set the default to `0` and to `auto` for the mark you want to |
| 8431 | /// shorten the path to. Set to `none` to disable path shortening. |
| 8432 | shorten-to: auto, |
| 8433 | /// Apply shape transforms for marks. This is not honored per mark, but |
| 8434 | /// for all marks on a path. If set to false, marks get placed after the |
| 8435 | /// shape they are placed on got transformed. |
| 8436 | transform-shape: true, |
| 8437 | /// Mark anchor used for placement |
| 8438 | /// Possible values are: |
| 8439 | /// - "tip" |
| 8440 | /// - "center" |
| 8441 | /// - "base" |
| 8442 | anchor: "tip", |
| 8443 | ), |
| 8444 | circle: ( |
| 8445 | radius: auto, |
| 8446 | stroke: auto, |
| 8447 | fill: auto |
| 8448 | ), |
| 8449 | group: ( |
| 8450 | padding: auto, |
| 8451 | fill: auto, |
| 8452 | stroke: auto |
| 8453 | ), |
| 8454 | line: ( |
| 8455 | mark: auto, |
| 8456 | fill: auto, |
| 8457 | fill-rule: auto, |
| 8458 | stroke: auto, |
| 8459 | ), |
| 8460 | bezier: ( |
| 8461 | stroke: auto, |
| 8462 | fill: auto, |
| 8463 | fill-rule: auto, |
| 8464 | mark: auto, |
| 8465 | shorten: auto, |
| 8466 | ), |
| 8467 | catmull: ( |
| 8468 | tension: .5, |
| 8469 | mark: auto, |
| 8470 | shorten: auto, |
| 8471 | stroke: auto, |
| 8472 | fill: auto, |
| 8473 | fill-rule: auto, |
| 8474 | ), |
| 8475 | hobby: ( |
| 8476 | /// Curve start and end omega (curlyness) |
| 8477 | omega: (0,0), |
| 8478 | mark: auto, |
| 8479 | shorten: auto, |
| 8480 | stroke: auto, |
| 8481 | fill: auto, |
| 8482 | fill-rule: auto, |
| 8483 | ), |
| 8484 | rect: ( |
| 8485 | /// Rect corner radius that supports the following types: |
| 8486 | /// - <radius>: Same x and y radius for all corners |
| 8487 | /// - (west: <radius>, east: <radius>, north: <radius>, south: <radius>, |
| 8488 | /// north-west: <radius>, north-east: <radius>, south-west: <radius>, south-east: <radius>, |
| 8489 | /// rest: <radius: 0>) |
| 8490 | /// |
| 8491 | /// A radius can be either a number, a ratio or a tuple of numbers or ratios. |
| 8492 | /// Ratios represent a value relative to the rects height or width. |
| 8493 | /// E.g. the radius `50%` is equal to `(50%, 50%)` and represents a x and y radius |
| 8494 | /// of 50% of the rects width/height. |
| 8495 | radius: 0, |
| 8496 | stroke: auto, |
| 8497 | fill: auto, |
| 8498 | ), |
| 8499 | arc: ( |
| 8500 | // Supported values: |
| 8501 | // - "OPEN" |
| 8502 | // - "CLOSE" |
| 8503 | // - "PIE" |
| 8504 | mode: "OPEN", |
| 8505 | update-position: true, |
| 8506 | mark: auto, |
| 8507 | stroke: auto, |
| 8508 | fill: auto, |
| 8509 | radius: auto |
| 8510 | ), |
| 8511 | polygon: ( |
| 8512 | radius: auto, |
| 8513 | stroke: auto, |
| 8514 | fill: auto, |
| 8515 | ), |
| 8516 | content: ( |
| 8517 | padding: auto, |
| 8518 | // Supported values |
| 8519 | // - none |
| 8520 | // - "rect" |
| 8521 | // - "circle" |
| 8522 | frame: none, |
| 8523 | fill: auto, |
| 8524 | stroke: auto, |
| 8525 | // Apply canvas scaling to content |
| 8526 | auto-scale: false, |
| 8527 | ), |
| 8528 | ) |
| 8529 | |
| 8530 | /// You can use this to combine the style in `ctx`, the style given by a user for a single element and an element's default style. |
| 8531 | /// |
| 8532 | /// `base` is first merged onto `dict` without overwriting existing values, and if `root` is given it is merged onto that key of `dict`. `merge` is then merged onto `dict` but does overwrite existing entries, if `root` is given it is merged onto that key of `dict`. Then entries in `dict` that are {{auto}} inherit values from their nearest ancestor and entries of type {{dictionary}} are merged with their closest ancestor. |
| 8533 | /// ```typ example |
| 8534 | /// #let dict = ( |
| 8535 | /// stroke: "black", |
| 8536 | /// fill: none, |
| 8537 | /// mark: (stroke: auto, fill: "blue"), |
| 8538 | /// line: (stroke: auto, mark: auto, fill: "red") |
| 8539 | /// ) |
| 8540 | /// #cetz.styles.resolve(dict, merge: (mark: (stroke: "yellow")), root: "line") |
| 8541 | /// ``` |
| 8542 | /// The following is a more detailed explanation of how the algorithm works to use as a reference if needed. It should be updated whenever changes are made. |
| 8543 | /// Remember that dictionaries are recursively merged, if an entry is any other type it is simply updated. (dict + dict = merged dict, value + dict = dict, dict + value = value) |
| 8544 | /// First if `base` is given, it will be merged without overwriting values onto `dict`. If `root` is given it will be merged onto that key of `dict`. |
| 8545 | /// Each level of `dict` is then processed with these steps. If `root` is given the level with that key will be the first, otherwise the whole of `dict` is processed. |
| 8546 | /// + Values on the corresponding level of `merge` are inserted into the level if the key does not exist on the level or if they are not both dictionaries. If they are both dictionaries their values will be inserted in the same stage at a lower level. |
| 8547 | /// + If an entry is `auto` or a dictionary, the tree is travelled back up until an entry with the same key is found. If the current entry is `auto` the value of the ancestor's entry is copied. Or if the current entry and ancestor entry is a dictionary, they are merged with the current entry overwriting any values in it's ancestors. |
| 8548 | /// + Each entry that is a dictionary is then resolved from step 1. |
| 8549 | /// |
| 8550 | /// ```typc example |
| 8551 | /// get-ctx(ctx => { |
| 8552 | /// // Get the current "mark" style |
| 8553 | /// content((0,0), [#cetz.styles.resolve(ctx.style, root: "mark")]) |
| 8554 | /// }) |
| 8555 | /// ``` |
| 8556 | /// |
| 8557 | /// - dict (style): Current context style from `ctx.style`. |
| 8558 | /// - merge (style): Style values overwriting the current style. I.e. inline styles passed with an element: `line(.., stroke: red)`. |
| 8559 | /// - root (none, str): Style root element name. |
| 8560 | /// - base (none, style): Style values to merge into `dict` without overwriting it. |
| 8561 | /// -> style |
| 8562 | #let resolve(dict, root: none, merge: (:), base: (:)) = { |
| 8563 | let resolve(dict, ancestors, merge) = { |
| 8564 | // Merge. If both values are dictionaries, merge's values will be inserted at a lower level in this step. |
| 8565 | for (k, v) in merge { |
| 8566 | if k not in dict or not (type(v) == dictionary and type(dict.at(k)) == dictionary) { |
| 8567 | dict.insert(k, v) |
| 8568 | } |
| 8569 | } |
| 8570 | |
| 8571 | // For each entry that is a dictionary or `auto`, travel back up the tree until it finds an entry with the same key. |
| 8572 | for (k, v) in dict { |
| 8573 | let is-dict = type(v) == dictionary |
| 8574 | if is-dict or v == auto { |
| 8575 | for ancestor in ancestors { |
| 8576 | if k in ancestor { |
| 8577 | // If v is auto and the ancestor's value is not auto, update v. |
| 8578 | if ancestor.at(k) != auto and v == auto { |
| 8579 | v = ancestor.at(k) |
| 8580 | // If both values are dictionaries, merge them. Values in v overwrite its ancestor's value. |
| 8581 | } else if is-dict and type(ancestor.at(k)) == dictionary { |
| 8582 | v = util.merge-dictionary(ancestor.at(k), v) |
| 8583 | } |
| 8584 | // Retain the updated value. Because all of the ancestors have already been processed even if a v is still auto that just means the key at the highest level either is auto or doesn't exist. |
| 8585 | dict.insert(k, v) |
| 8586 | break |
| 8587 | } |
| 8588 | } |
| 8589 | } |
| 8590 | } |
| 8591 | |
| 8592 | // Record history here so it doesn't change. |
| 8593 | ancestors = (dict,) + ancestors |
| 8594 | // Because only keys on this level have been processed, process all children of this level. |
| 8595 | for (k, v) in dict { |
| 8596 | if type(v) == dictionary { |
| 8597 | dict.insert(k, resolve(v, ancestors, merge.at(k, default: (:)))) |
| 8598 | } |
| 8599 | } |
| 8600 | return dict |
| 8601 | } |
| 8602 | |
| 8603 | if base != (:) { |
| 8604 | if root != none { |
| 8605 | let a = (:) |
| 8606 | a.insert(root, base) |
| 8607 | base = a |
| 8608 | } |
| 8609 | dict = util.merge-dictionary(dict, base, overwrite: false) |
| 8610 | } |
| 8611 | return resolve( |
| 8612 | if root != none { dict.at(root) } else { dict }, |
| 8613 | if root != none {(dict,)} else {()}, |
| 8614 | merge |
| 8615 | ) |
| 8616 | } |
| 8617 | #import "deps.typ" |
| 8618 | #import deps.oxifmt: strfmt |
| 8619 | |
| 8620 | #import "matrix.typ" |
| 8621 | #import "vector.typ" |
| 8622 | #import "bezier.typ" |
| 8623 | |
| 8624 | /// Constant to be used as float rounding error |
| 8625 | #let float-epsilon = 0.000001 |
| 8626 | |
| 8627 | /// Multiplies vectors by a transformation matrix. If multiple vectors are given they are returned as an array, if only one vector is given only one will be returned, if a dictionary is given they will be returned in the dictionary with the same keys. |
| 8628 | /// |
| 8629 | /// - transform (matrix,function): The $4 \times 4$ transformation matrix or a function that accepts and returns a vector. |
| 8630 | /// - ..vecs (vector): Vectors to get transformed. Only the positional part of the sink is used. A dictionary of vectors can also be passed and all will be transformed. |
| 8631 | /// -> vector,array,dictionary |
| 8632 | #let apply-transform(transform, ..vecs) = { |
| 8633 | let t = if type(transform) != function { |
| 8634 | matrix.mul4x4-vec3.with(transform) |
| 8635 | } else { |
| 8636 | transform |
| 8637 | } |
| 8638 | if type(vecs.pos().first()) == dictionary { |
| 8639 | vecs = vecs.pos().first() |
| 8640 | for (k, vec) in vecs { |
| 8641 | vecs.insert(k, t(vec)) |
| 8642 | } |
| 8643 | } else { |
| 8644 | vecs = vecs.pos().map(t) |
| 8645 | if vecs.len() == 1 { |
| 8646 | return vecs.first() |
| 8647 | } |
| 8648 | } |
| 8649 | return vecs |
| 8650 | } |
| 8651 | |
| 8652 | /// Reverts the transform of the given vector |
| 8653 | /// |
| 8654 | /// - transform (matrix): Transformation matrix |
| 8655 | /// - vec (vector): Vector to be transformed |
| 8656 | /// -> vector |
| 8657 | #let revert-transform(transform, ..vecs) = { |
| 8658 | apply-transform(matrix.inverse(transform), ..vecs) |
| 8659 | } |
| 8660 | |
| 8661 | /// Linearly interpolates between two points and returns its position |
| 8662 | /// |
| 8663 | /// - a (vector): Start point |
| 8664 | /// - b (vector): End point |
| 8665 | /// - t (float): Position on the line $[0, 1]$ |
| 8666 | /// -> vector |
| 8667 | #let line-pt(a, b, t) = { |
| 8668 | return vector.add(a, vector.scale(vector.sub(b, a), t)) |
| 8669 | } |
| 8670 | |
| 8671 | /// Get orthogonal vector to line |
| 8672 | /// |
| 8673 | /// - a (vector): Start point |
| 8674 | /// - b (vector): End point |
| 8675 | /// -> vector |
| 8676 | #let line-normal(a, b) = { |
| 8677 | let v = vector.norm(vector.sub(b, a)) |
| 8678 | return (0 - v.at(1), v.at(0), v.at(2, default: 0)) |
| 8679 | } |
| 8680 | |
| 8681 | /// Calculates the arc-length of a circle or arc |
| 8682 | /// |
| 8683 | /// - radius (float): Circle or arc radius |
| 8684 | /// - angle (angle): The angle of the arc. |
| 8685 | /// -> float |
| 8686 | #let circle-arclen(radius, angle: 360deg) = { |
| 8687 | calc.abs(angle / 360deg * 2 * calc.pi) |
| 8688 | } |
| 8689 | |
| 8690 | /// Get point on an ellipse for an angle |
| 8691 | /// |
| 8692 | /// - center (vector): Center |
| 8693 | /// - radius (float,array): Radius or tuple of x/y radii |
| 8694 | /// - angled (angle): Angle to get the point at |
| 8695 | /// -> vector |
| 8696 | #let ellipse-point(center, radius, angle) = { |
| 8697 | let (rx, ry) = if type(radius) == array { |
| 8698 | radius |
| 8699 | } else { |
| 8700 | (radius, radius) |
| 8701 | } |
| 8702 | |
| 8703 | let (x, y, z) = center |
| 8704 | return (calc.cos(angle) * rx + x, calc.sin(angle) * ry + y, z) |
| 8705 | } |
| 8706 | |
| 8707 | /// Calculates the center of a circle from 3 points. The z coordinate is taken from point a. |
| 8708 | /// |
| 8709 | /// - a (vector): Point 1 |
| 8710 | /// - b (vector): Point 2 |
| 8711 | /// - c (vector): Point 3 |
| 8712 | /// -> vector |
| 8713 | #let calculate-circle-center-3pt(a, b, c) = { |
| 8714 | let m-ab = line-pt(a, b, .5) |
| 8715 | let m-bc = line-pt(b, c, .5) |
| 8716 | let m-cd = line-pt(c, a, .5) |
| 8717 | |
| 8718 | let args = () // a, c, b, d |
| 8719 | for i in range(0, 3) { |
| 8720 | let (p1, p2) = ((a,b,c).at(calc.rem(i,3)), |
| 8721 | (b,c,a).at(calc.rem(i,3))) |
| 8722 | let m = line-pt(p1, p2, .5) |
| 8723 | let n = line-normal(p1, p2) |
| 8724 | |
| 8725 | // Find a line with a non upwards normal |
| 8726 | if n.at(0) == 0 { continue } |
| 8727 | |
| 8728 | let la = n.at(1) / n.at(0) |
| 8729 | args.push(la) |
| 8730 | args.push(m.at(1) - la * m.at(0)) |
| 8731 | |
| 8732 | // We need only 2 lines |
| 8733 | if args.len() == 4 { break } |
| 8734 | } |
| 8735 | |
| 8736 | // Find intersection point of two 2d lines |
| 8737 | // L1: a*x + c |
| 8738 | // L2: b*x + d |
| 8739 | let line-intersection-2d(a, c, b, d) = { |
| 8740 | if a - b == 0 { |
| 8741 | if c == d { |
| 8742 | return (0, c, 0) |
| 8743 | } |
| 8744 | return none |
| 8745 | } |
| 8746 | let x = (d - c)/(a - b) |
| 8747 | let y = a * x + c |
| 8748 | return (x, y) |
| 8749 | } |
| 8750 | |
| 8751 | assert(args.len() == 4, message: "Could not find circle center") |
| 8752 | return vector.as-vec(line-intersection-2d(..args), init: (0, 0, a.at(2))) |
| 8753 | } |
| 8754 | |
| 8755 | /// Converts a {{number}} to "canvas units" |
| 8756 | /// - ctx (context): The current context object. |
| 8757 | /// - num (number): The number to resolve. |
| 8758 | /// -> float |
| 8759 | #let resolve-number(ctx, num) = { |
| 8760 | return if type(num) == length { |
| 8761 | float(num.to-absolute() / ctx.length) |
| 8762 | } else if type(num) == ratio { |
| 8763 | num |
| 8764 | } else { |
| 8765 | float(num) |
| 8766 | } |
| 8767 | } |
| 8768 | |
| 8769 | /// Ensures that a radius has an `x` and `y` component. |
| 8770 | /// - radius (number, array): |
| 8771 | /// -> array |
| 8772 | #let resolve-radius(radius) = { |
| 8773 | return if type(radius) == array {radius} else {(radius, radius)} |
| 8774 | } |
| 8775 | |
| 8776 | /// Finds the minimum of a set of values while ignoring {{none}} values. |
| 8777 | /// - a (float,none): |
| 8778 | /// -> float |
| 8779 | #let min(..a) = { |
| 8780 | let a = a.pos().filter(v => v != none) |
| 8781 | return calc.min(..a) |
| 8782 | } |
| 8783 | |
| 8784 | /// Finds the maximum of a set of values while ignoring {{none}} values. |
| 8785 | /// - ..a (float,none): |
| 8786 | /// -> float |
| 8787 | #let max(..a) = { |
| 8788 | let a = a.pos().filter(v => v != none) |
| 8789 | return calc.max(..a) |
| 8790 | } |
| 8791 | |
| 8792 | /// Merges dictionary `b` onto dictionary `a`. If a key does not exist in `a` but does in `b`, it is inserted into `a` with `b`'s value. If a key does exist in `a` and `b`, the value in `b` is only inserted into `a` if the `overwrite` argument is `true`. If a key does exist both in `a` and `b` and both values are of type {{dictionary}} they will be recursively merged with this same function. |
| 8793 | /// |
| 8794 | /// - a (dictionary): Dictionary a |
| 8795 | /// - b (dictionary): Dictionary b |
| 8796 | /// - overwrite (bool): Whether to override an entry in `a` that also exists in `b` with the value in `b`. |
| 8797 | /// -> dictionary |
| 8798 | #let merge-dictionary(a, b, overwrite: true) = { |
| 8799 | for (k, v) in b { |
| 8800 | if type(a) == dictionary and k in a and type(v) == dictionary and type(a.at(k)) == dictionary { |
| 8801 | a.insert(k, merge-dictionary(a.at(k), v, overwrite: overwrite)) |
| 8802 | } else if overwrite or k not in a { |
| 8803 | a.insert(k, v) |
| 8804 | } |
| 8805 | } |
| 8806 | return a |
| 8807 | } |
| 8808 | |
| 8809 | /// Measures the size of some {{content}} in canvas coordinates. |
| 8810 | /// - ctx (context): The current context object. |
| 8811 | /// - cnt (content): The content to measure. |
| 8812 | /// -> vector |
| 8813 | #let measure(ctx, cnt) = { |
| 8814 | let size = std.measure(cnt) |
| 8815 | return ( |
| 8816 | calc.abs(size.width / ctx.length), |
| 8817 | calc.abs(size.height / ctx.length) |
| 8818 | ) |
| 8819 | } |
| 8820 | |
| 8821 | /// Get a padding/margin dictionary with keys `(top, left, bottom, right)` from a padding value. |
| 8822 | /// |
| 8823 | /// |
| 8824 | /// Type of `padding`: |
| 8825 | /// - {{none}}: All sides padded by 0 |
| 8826 | /// - {{number}}: All sides are padded by the same value |
| 8827 | /// - {{array}}: CSS like padding: `(y, x)`, `(top, x, bottom)` or `(top, right, bottom, left)` |
| 8828 | /// - {{dictionary}}: Converts a Typst padding dictionary (top, left, bottom, right, x, y, rest) to a dictionary containing top, left, bottom and right. |
| 8829 | /// |
| 8830 | /// - padding (none, number, array, dictionary): Padding specification |
| 8831 | /// |
| 8832 | /// -> dictionary |
| 8833 | #let as-padding-dict(padding) = { |
| 8834 | if padding == none { |
| 8835 | padding = 0 |
| 8836 | } |
| 8837 | |
| 8838 | if type(padding) == array { |
| 8839 | // Allow CSS like padding array |
| 8840 | assert(padding.len() in (2, 3, 4), |
| 8841 | message: "Padding array formats are: (y, x), (top, x, bottom), (top, right, bottom, left)") |
| 8842 | if padding.len() == 2 { |
| 8843 | let (y, x) = padding |
| 8844 | return (top: y, right: x, bottom: y, left: x) |
| 8845 | } else if padding.len() == 3 { |
| 8846 | let (top, x, bottom) = padding |
| 8847 | return (top: top, right: x, bottom: bottom, left: x) |
| 8848 | } else if padding.len() == 4 { |
| 8849 | let (top, right, bottom, left) = padding |
| 8850 | return (top: top, right: right, bottom: bottom, left: left) |
| 8851 | } |
| 8852 | } else if type(padding) == dictionary { |
| 8853 | // Support typst padding dictionary |
| 8854 | let rest = padding.at("rest", default: 0) |
| 8855 | let x = padding.at("x", default: rest) |
| 8856 | let y = padding.at("y", default: rest) |
| 8857 | if not "left" in padding { padding.left = x } |
| 8858 | if not "right" in padding { padding.right = x } |
| 8859 | if not "top" in padding { padding.top = y } |
| 8860 | if not "bottom" in padding { padding.bottom = y } |
| 8861 | |
| 8862 | return padding |
| 8863 | } else { |
| 8864 | return (top: padding, left: padding, bottom: padding, right: padding) |
| 8865 | } |
| 8866 | } |
| 8867 | |
| 8868 | /// Creates a corner-radius dictionary with keys `north-east`, `north-west`, `south-east` and `south-west` with values of a two element {{array}} of the radius in the `x` and `y` direction. Returns none if all radii are zero or none. |
| 8869 | /// |
| 8870 | /// - ctx (context): The current canvas context object |
| 8871 | /// - radii (none, number, dictionary): The radius specification. A {{number}} will cause all corners to have the same radius. An {{array}} with two items will cause all corners to have the same rx and ry radius. A {{dictionary}} can be given where the key specifies the corner and the value specifies the radius. The value can be either {{number}} for a circle radius or {{array}} for an x and y radius. The keys `north`, `south`, `east` and `west` targets both corners in that cardinal direction e.g. `south` sets the south west and south east corners. The keys `north-east`, `north-west`, `south-east` and `south-west` targets the corresponding corner. The key `rest` targets all other corners that have not been target by other keys. |
| 8872 | /// - size (???): I'm not sure what this does. |
| 8873 | /// |
| 8874 | /// -> dictionary |
| 8875 | #let as-corner-radius-dict(ctx, radii, size) = { |
| 8876 | if radii == none or radii == 0 { |
| 8877 | return (north-west: (0,0), north-east: (0,0), |
| 8878 | south-west: (0,0), south-east: (0,0)) |
| 8879 | } |
| 8880 | |
| 8881 | let radii = (if type(radii) == dictionary { |
| 8882 | let rest = radii.at("rest", default: (0,0)) |
| 8883 | let north = radii.at("north", default: auto) |
| 8884 | let south = radii.at("south", default: auto) |
| 8885 | let west = radii.at("west", default: auto) |
| 8886 | let east = radii.at("east", default: auto) |
| 8887 | |
| 8888 | if north != auto or south != auto { |
| 8889 | assert(west == auto and east == auto, |
| 8890 | message: "Corner radius north/south and west/east are mutually exclusive! Use per corner radii: north-west, .. instead.") |
| 8891 | } |
| 8892 | if west != auto or east != auto { |
| 8893 | assert(north == auto and south == auto, |
| 8894 | message: "Corner radius north/south and west/east are mutually exclusive! Use per corner radii: north-west, .. instead.") |
| 8895 | } |
| 8896 | |
| 8897 | let north-east = if north != auto { north } else if east != auto { east } else {rest} |
| 8898 | let north-west = if north != auto { north } else if west != auto { west } else {rest} |
| 8899 | let south-east = if south != auto { south } else if east != auto { east } else {rest} |
| 8900 | let south-west = if south != auto { south } else if west != auto { west } else {rest} |
| 8901 | |
| 8902 | (radii.at("north-west", default: north-west), |
| 8903 | radii.at("north-east", default: north-east), |
| 8904 | radii.at("south-west", default: south-west), |
| 8905 | radii.at("south-east", default: south-east)) |
| 8906 | } else if type(radii) == array { |
| 8907 | panic("Invalid corner radius type: " + type(radii)) |
| 8908 | } else { |
| 8909 | (radii, radii, radii, radii) |
| 8910 | }).map(v => if type(v) != array { (v, v) } else { v }) |
| 8911 | |
| 8912 | // Resolve lengths to floats |
| 8913 | radii = radii.map(t => t.map(resolve-number.with(ctx))) |
| 8914 | |
| 8915 | // Clamp radii to half the size |
| 8916 | radii = radii.map(t => t.enumerate().map(((i, v)) => { |
| 8917 | calc.max(0, calc.min(if type(v) == ratio { |
| 8918 | v * size.at(i) / 100% |
| 8919 | } else { v }, size.at(i) / 2)) |
| 8920 | })) |
| 8921 | |
| 8922 | let (nw, ne, sw, se) = radii |
| 8923 | return ( |
| 8924 | north-west: nw, |
| 8925 | north-east: ne, |
| 8926 | south-west: sw, |
| 8927 | south-east: se, |
| 8928 | ) |
| 8929 | } |
| 8930 | |
| 8931 | /// Sorts an array of vectors by distance to a common position. |
| 8932 | /// - base (vector): The position to measure the distance of the other vectors from. |
| 8933 | /// - pts (array): The array of vectors to sort. |
| 8934 | /// -> array |
| 8935 | #let sort-points-by-distance(base, pts) = { |
| 8936 | if pts.len() == 1 { |
| 8937 | return pts |
| 8938 | } |
| 8939 | |
| 8940 | // Sort by transforming points into tuples of (point, distance), |
| 8941 | // sorting them by key 1 and then transforming them back to points. |
| 8942 | return pts.map(p => { |
| 8943 | return (p, vector.dist(p, base)) |
| 8944 | }) |
| 8945 | .sorted(key: t => t.at(1)) |
| 8946 | .map(t => t.at(0)) |
| 8947 | } |
| 8948 | |
| 8949 | /// Resolves a stroke into a usable dictionary with all fields that are missing or auto set to their Typst defaults. |
| 8950 | /// - stroke (none, stroke): The stroke to resolve. |
| 8951 | /// -> dictionary |
| 8952 | #let resolve-stroke(stroke) = { |
| 8953 | if stroke == none { |
| 8954 | return (paint: none, thickness: 0pt, join: none, cap: none, miter-limit: 4) |
| 8955 | } |
| 8956 | |
| 8957 | let default = ( |
| 8958 | paint: black, |
| 8959 | thickness: 1pt, |
| 8960 | join: "miter", |
| 8961 | cap: "butt", |
| 8962 | miter-limit: 4 |
| 8963 | ) |
| 8964 | let s = line(stroke: stroke).stroke |
| 8965 | let stroke = (:) |
| 8966 | for (k, v) in (paint: s.paint, thickness: s.thickness, join: s.join, cap: s.cap, miter-limit: s.miter-limit) { |
| 8967 | if v == auto { |
| 8968 | stroke.insert(k, default.at(k)) |
| 8969 | } else { |
| 8970 | stroke.insert(k, v) |
| 8971 | } |
| 8972 | } |
| 8973 | return stroke |
| 8974 | } |
| 8975 | |
| 8976 | /// Asserts whether a "body" has the correct type. |
| 8977 | #let assert-body(body) = { |
| 8978 | assert(body == none or type(body) in (array, function), |
| 8979 | message: "Body must be of type none, array or function") |
| 8980 | } |
| 8981 | |
| 8982 | // Returns body if of type array, an |
| 8983 | // empty array if body is none or |
| 8984 | // the result of body called with ctx if of type |
| 8985 | // function. A function result of none will return |
| 8986 | // an empty array. |
| 8987 | #let resolve-body(ctx, body) = { |
| 8988 | if type(body) == function { |
| 8989 | body = body(ctx) |
| 8990 | } |
| 8991 | if body == none { |
| 8992 | body = () |
| 8993 | } |
| 8994 | return body |
| 8995 | } |
| 8996 | |
| 8997 | |
| 8998 | #let str-to-number-regex = regex("^(-?\d*\.?\d+)(cm|mm|pt|em|in|%|deg|rad)?$") |
| 8999 | #let number-units = ( |
| 9000 | "%": 1%, |
| 9001 | "cm": 1cm, |
| 9002 | "mm": 1mm, |
| 9003 | "pt": 1pt, |
| 9004 | "em": 1em, |
| 9005 | "in": 1in, |
| 9006 | "deg": 1deg, |
| 9007 | "rad": 1rad |
| 9008 | ) |
| 9009 | #let str-is-number(string) = string.match(str-to-number-regex) != none |
| 9010 | #let str-to-number(string) = { |
| 9011 | let (num, unit) = string.match(str-to-number-regex).captures |
| 9012 | num = float(num) |
| 9013 | if unit != none and unit in number-units { |
| 9014 | num *= number-units.at(unit) |
| 9015 | } |
| 9016 | return num |
| 9017 | } |
| 9018 | /// Converts a vector to a row or column matrix. |
| 9019 | /// |
| 9020 | /// - v (vector): The vector to convert. |
| 9021 | /// - mode (str): The type of matrix to convert into. Must be one of `"row"` or `"column"`. |
| 9022 | /// -> matrix |
| 9023 | #let as-mat(v, mode: "row") = { |
| 9024 | if mode == "column" { |
| 9025 | return (v,) |
| 9026 | } else if mode == "row" { |
| 9027 | return (for c in v { (c,) }, ) |
| 9028 | } else { |
| 9029 | panic("Invalid mode " + mode) |
| 9030 | } |
| 9031 | } |
| 9032 | |
| 9033 | /// Ensures a vector has an exact number of components. This is done by passing another vector `init` that has the required dimension. If the original vector does not have enough dimensions, the values from `init` will be inserted. It is recommended to use a zero vector for `init`. |
| 9034 | /// |
| 9035 | /// - v (vector): The vector to ensure. |
| 9036 | /// - init (vector): The vector to check the dimension against. |
| 9037 | /// -> vector |
| 9038 | #let as-vec(v, init: (0, 0, 0)) = { |
| 9039 | for i in range(0, calc.min(v.len(), init.len())) { |
| 9040 | init.at(i) = v.at(i) |
| 9041 | } |
| 9042 | return init |
| 9043 | } |
| 9044 | |
| 9045 | |
| 9046 | /// Return length/magnitude of a vector. |
| 9047 | /// |
| 9048 | /// - v (vector): The vector to find the magnitude of. |
| 9049 | /// -> float |
| 9050 | #let len(v) = { |
| 9051 | return calc.sqrt(v.fold(0, (s, c) => s + c * c)) |
| 9052 | } |
| 9053 | |
| 9054 | /// Adds two vectors of the same dimension |
| 9055 | /// |
| 9056 | /// - v1 (vector): The vector on the left hand side. |
| 9057 | /// - v2 (vector): The vector on the right hand side. |
| 9058 | /// -> vector |
| 9059 | #let add(v1, v2) = { |
| 9060 | range(0, calc.max(v1.len(), v2.len())).map(i => { |
| 9061 | v1.at(i, default: 0) + v2.at(i, default: 0) |
| 9062 | }) |
| 9063 | } |
| 9064 | |
| 9065 | /// Subtracts two vectors of the same dimension |
| 9066 | /// |
| 9067 | /// - v1 (vector): The vector on the left hand side. |
| 9068 | /// - v2 (vector): The vector on the right hand side. |
| 9069 | /// -> vector |
| 9070 | #let sub(v1, v2) = { |
| 9071 | range(0, calc.max(v1.len(), v2.len())).map(i => { |
| 9072 | v1.at(i, default: 0) - v2.at(i, default: 0) |
| 9073 | }) |
| 9074 | } |
| 9075 | |
| 9076 | /// Calculates the distance between two vectors by subtracting the length of vector `a` from vector `b`. |
| 9077 | /// |
| 9078 | /// - a (vector): Vector a |
| 9079 | /// - b (vector): Vector b |
| 9080 | /// -> float |
| 9081 | #let dist(a, b) = len(sub(b, a)) |
| 9082 | |
| 9083 | /// Multiplys a vector with scalar `x` |
| 9084 | /// - v (vector): The vector to scale. |
| 9085 | /// - x (float): The scale factor. |
| 9086 | /// -> vector |
| 9087 | #let scale(v, x) = v.map(s => s * x) |
| 9088 | |
| 9089 | /// Divides a vector by scalar `x` |
| 9090 | /// - v (vector): The vector to be divded. |
| 9091 | /// - x (float): The inverse scale factor. |
| 9092 | #let div(v, x) = v.map(s => s / x) |
| 9093 | |
| 9094 | /// Negates each value in a vector |
| 9095 | /// - v (vector): The vector to negate. |
| 9096 | /// -> vector |
| 9097 | #let neg(v) = scale(v, -1) |
| 9098 | |
| 9099 | /// Normalizes a vector (divide by its length) |
| 9100 | /// - v (vector): The vector to normalize. |
| 9101 | /// -> vector |
| 9102 | #let norm(v) = div(v, len(v)) |
| 9103 | |
| 9104 | /// Multiply two vectors component-wise |
| 9105 | /// - a (vector): First vector. |
| 9106 | /// - b (vector): Second vector. |
| 9107 | #let element-product(a, b) = a.enumerate().map(((i, v)) => v * b.at(i)) |
| 9108 | |
| 9109 | /// Calculates the dot product between two vectors. |
| 9110 | /// - v1 (vector): The vector on the left hand side. |
| 9111 | /// - v2 (vector): The vector on the right hand side. |
| 9112 | /// -> float |
| 9113 | #let dot(v1, v2) = { |
| 9114 | assert(v1.len() == v2.len()) |
| 9115 | return v1.enumerate().fold(0, (s, t) => s + t.at(1) * v2.at(t.at(0))) |
| 9116 | } |
| 9117 | |
| 9118 | /// Calculates the cross product of two vectors with a dimension of three. |
| 9119 | /// - v1 (vector): The vector on the left hand side. |
| 9120 | /// - v2 (vector): The vector on the right hand side. |
| 9121 | /// -> vector |
| 9122 | #let cross(v1, v2) = { |
| 9123 | assert(v1.len() == 3 and v2.len() == 3) |
| 9124 | |
| 9125 | let (x1, y1, z1) = v1 |
| 9126 | let (x2, y2, z2) = v2 |
| 9127 | |
| 9128 | return (y1 * z2 - z1 * y2, |
| 9129 | z1 * x2 - x1 * z2, |
| 9130 | x1 * y2 - y1 * x2) |
| 9131 | } |
| 9132 | |
| 9133 | /// Calculates the angle between two vectors and the x-axis in 2d space |
| 9134 | /// - a (vector): The vector to measure the angle from. |
| 9135 | /// - b (vector): The vector to measure the angle to. |
| 9136 | /// -> angle |
| 9137 | #let angle2(a, b) = { |
| 9138 | // Typst's atan2 is (x, y) order, not (y, x) |
| 9139 | return calc.atan2(b.at(0) - a.at(0), b.at(1) - a.at(1)) |
| 9140 | } |
| 9141 | |
| 9142 | /// Calculates the angle between three vectors |
| 9143 | /// - v1 (vector): The vector to measure the angle from. |
| 9144 | /// - c (vector): The vector to measure the angle at. |
| 9145 | /// - v2 (vector): The vector to measure the angle to. |
| 9146 | #let angle(v1, c, v2) = { |
| 9147 | assert(v1.len() == v2.len(), |
| 9148 | message: "Vectors " + repr(v1) + " and " + repr(v2) + " do not have the same dimensions.") |
| 9149 | if v1.len() == 2 or v1.len() == 3 { |
| 9150 | v1 = sub(v1, c) |
| 9151 | v2 = sub(v2, c) |
| 9152 | return calc.acos(dot(norm(v1), norm(v2))) |
| 9153 | } else { |
| 9154 | panic("Invalid vector dimension") |
| 9155 | } |
| 9156 | } |
| 9157 | |
| 9158 | /// Linear interpolation between two vectors. |
| 9159 | /// - v1 (vector): The vector to interpolate from. |
| 9160 | /// - v2 (vector): The vector to interpolate to. |
| 9161 | /// - t (float): The factor to interpolate by. A value of `0` is `v1` and a value of `1` is `v2`. |
| 9162 | #let lerp(v1, v2, t) = { |
| 9163 | return add( |
| 9164 | v1, |
| 9165 | scale( |
| 9166 | sub( |
| 9167 | v2, |
| 9168 | v1 |
| 9169 | ), |
| 9170 | t, |
| 9171 | ) |
| 9172 | ) |
| 9173 | } |
| 9174 | |
| 9175 | /// Rotates a vector of dimension 2 or 3 around the z-axis by an angle. |
| 9176 | /// - v (vector): The vector to rotate. |
| 9177 | /// - angle (angle): The angle to rotate by. |
| 9178 | /// -> vector |
| 9179 | #let rotate-z(v, angle) = { |
| 9180 | assert(v.len() >= 2, |
| 9181 | message: "Vector size must be >= 2") |
| 9182 | let (x, y, ..) = v |
| 9183 | v.at(0) = x * calc.cos(angle) - y * calc.sin(angle) |
| 9184 | v.at(1) = x * calc.sin(angle) + y * calc.cos(angle) |
| 9185 | return v |
| 9186 | } |
| 9187 | #let version = version(0,3,3) |
| 9188 | [package] |
| 9189 | name = "cetz" |
| 9190 | version = "0.3.4" |
| 9191 | compiler = "0.13.0" |
| 9192 | repository = "https://github.com/cetz-package/cetz" |
| 9193 | homepage = "https://cetz-package.github.io/" |
| 9194 | entrypoint = "src/lib.typ" |
| 9195 | authors = [ |
| 9196 | "Johannes Wolf <https://github.com/johannes-wolf>", |
| 9197 | "fenjalien <https://github.com/fenjalien>" |
| 9198 | ] |
| 9199 | categories = [ "visualization" ] |
| 9200 | license = "LGPL-3.0-or-later" |
| 9201 | description = "Drawing with Typst made easy, providing an API inspired by TikZ and Processing. Includes modules for plotting, charts and tree layout." |
| 9202 | keywords = [ "draw", "canvas", "tree" ] |
| 9203 | exclude = [ "/gallery/*", "manual.pdf", "manual.typ" ] |