How to define custom element in cetz?

Hi,
I am playing around with cetz and trying to create a custom element that behave like native ones.

I first tried a naive approach with this code (I am currently focusing on being able to use the anchors facilities) :


#import "@preview/cetz:0.4.2"
#set page(width: auto, height: auto, margin: .5cm)

#cetz.canvas({
  import cetz.draw: *
  grid((-10, -10), (10, 10), stroke: gray + 0.5pt)

  let head = (pos, anchor: none, radius: 4, name: none) => group(name: name, {
    let r = radius / 3
    set-origin(pos)

    cetz.draw.anchor("left-eye", (r, -r))
    cetz.draw.anchor("right-eye", (-r, -r))
    cetz.draw.anchor("mouth", (0, r))
    if anchor != none { set-origin(anchor) }

    circle((0, 0), radius: radius)
    circle((-r, r), radius: r / 2)
    circle((r, r), radius: r / 2)
    line((-r, -r), (r, -r))

    cetz.draw.anchor("left-eye", (-r, r))
    cetz.draw.anchor("right-eye", (r, r))
    cetz.draw.anchor("mouth", (0, -r))
  })

  circle((-4, 4), radius: .2, fill: green, name: "green")
  head("green")

  circle((-2, -4), radius: .2, fill: red, name: "red")
  head("red", anchor: "left-eye")

  head((5, 5), name: "a")
  circle("a.left-eye", radius: .2, fill: blue)
})

But obviously, this is not the right approach… :sweat_smile:

I took a look at cetz code and tried to copy the def of circle from shape.typ to create my head element.

#import "@preview/cetz:0.4.2"
#set page(width: auto, height: auto, margin: .5cm)



#let head(..points-style, name: none, anchor: none) = {
  import cetz.draw: *
  //import cetz.coordinate: *
  let style = points-style.named()
  let points = points-style.pos()
  assert(points.len() in (1, 2),
    message: "circle expects one or two points, got " + repr(points))
  assert(points.len() != 2 or "radius" not in style,
    message: "unexpected radius for circle constructed by two points")

  (ctx => {
    let (center, outer) = if points.len() == 1 {
      (points.at(0), none)
    } else {
      points
    }

    let (ctx, center) = cetz.coordinate.resolve(ctx, center)
    let style = cetz.styles.resolve(ctx.style, merge: style, root: "circle")

    // If we got two points, use the second one to calculate
    // the radius.
    let (rx, ry) = if outer != none {
      (ctx, outer) = coordinate.resolve(ctx, outer, update: false)
      (vector.dist(center, outer),) * 2
    } else {
      cetz.util.resolve-radius(style.radius).map(cetz.util.resolve-number.with(ctx))
    }
    let (cx, cy, cz) = center

    let drawables = cetz.drawable.ellipse(
      cx, cy, cz,
      rx, ry,
      fill: style.fill,
      stroke: style.stroke
    )

    let (transform, anchors) = anchor_.setup(
      (_) => center,
      ("center",),
      default: "center",
      name: name,
      offset-anchor: anchor,
      transform: ctx.transform,
      border-anchors: true,
      path-anchors: true,
      radii: (rx*2, ry*2),
      path: drawables,
    )

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

#cetz.canvas({
  import cetz.draw: *
  grid((-10, -10), (10, 10), stroke: gray + 0.5pt)
  head((0,0))
})

I wasn’t even able to compile it since cetz does not export functions from anchor (Anchor | CeTZ Documentation), at least, I couldn’t find it. So I have the error unknown variable: anchor_.

So, my question is, does anyone has an example of how to create a custom element ?

Why do you say your first approach is obviously not right? :) That’s how I would make a custom element: by putting cetz objects into a named group and defining some anchors.

1 Like

It’s the problem of anchoring.
I have to define them twice (at mirror places) to be able to use them, and I don’t have access to automatic anchors (like “south.east” and the like).

The right way seems to use anchor.setu(), but the doc is not really helping (I miss a toy example).

I took a look at other package using cetz, but didn’t find one managing anchoring.

I see what you mean. Have a look at this – it employs a bit of trickery to reduce the need to repeat yourself. Edit: you can use the group anchor to achieve what you want!

#import "@preview/cetz:0.4.2"
#set page(width: auto, height: auto, margin: .5cm)

#let head(pos, anchor: none, radius: 4, name: none) = {
  let r = radius/3
  cetz.draw.group(name: name, anchor: anchor, {
    import cetz.draw: *
    set-origin(pos)

    circle((0, 0), radius: radius, name: "head")
    circle((-r, r), radius: r / 2, name: "left-eye")
    circle((r, r), radius: r / 2, name: "right-eye")
    line((-r, -r), (r, -r), name: "mouth")
  })
}

#cetz.canvas({
  import cetz.draw: *
  grid((-10, -10), (10, 10), stroke: gray + 0.5pt)

  circle((-4, 4), radius: .2, fill: green, name: "green")
  head("green")

  circle((-2, -4), radius: .2, fill: red, name: "red")
  head("red", anchor: "left-eye")

  head((5, 5), name: "a")
  set-style(circle: (radius: .2, fill: blue))
  circle("a.left-eye")
  circle("a.right-eye.west")
  circle("a.head.north-east")
  circle("a.mouth")
  circle("a.head.135deg")
})

Render

Older, more complicated solution
#import "@preview/cetz:0.4.2"
#set page(width: auto, height: auto, margin: .5cm)

#let head(pos, anchor: none, radius: 4, name: none) = {
  let _anchor = anchor
  let r = radius/3

  import cetz.draw: *
  group(name: name, {
    set-origin(pos)

    let objs = {
      circle((0, 0), radius: radius, name: "head")
      circle((-r, r), radius: r / 2, name: "left-eye")
      circle((r, r), radius: r / 2, name: "right-eye")
      line((-r, -r), (r, -r), name: "mouth")
    }

    hide(objs) // place objects invisibly so we have access to them by name
    if _anchor != none {
      // set origin does the inverse of what we want --
      // so we negate the coordinate
      set-origin((k => cetz.vector.scale(k, -1), _anchor))
    }
    objs
  })
}

#cetz.canvas({
  import cetz.draw: *
  grid((-10, -10), (10, 10), stroke: gray + 0.5pt)

  circle((-4, 4), radius: .2, fill: green, name: "green")
  head("green")

  circle((-2, -4), radius: .2, fill: red, name: "red")
  head("red", anchor: "left-eye")

  head((5, 5), name: "a")
  set-style(circle: (radius: .2, fill: blue))
  circle("a.left-eye")
  circle("a.right-eye.west")
  circle("a.head.north-east")
  circle("a.mouth")
  circle("a.head.135deg")
})
2 Likes

I was studying your first (complicated) solution, then…tada, the anchor of the group appear ! Yeeeha !

It’s much cleaner than my original code.

It just misses the automatic anchoring of the global face. Sure, I can reference “a.head.north-east” as you did, but it would be cleaner to be able to have “a.north-east” directly. I am toying for a chemistry package, I prefer to have something like “beaker1.west” instead of “beaker1.beaker.west”.

You can do this too! Although it involves escaping cetz’s “normal” API.

#import "@preview/cetz:0.4.2"
#set page(width: auto, height: auto, margin: .5cm)

#let head(pos, anchor: none, radius: 4, name: none) = {
  let r = radius/3
  let (callback,) = cetz.draw.group(name: name, anchor: anchor, {
    import cetz.draw: *
    set-origin(pos)

    circle((0, 0), radius: radius, name: "head")
    circle((-r, r), radius: r / 2, name: "left-eye")
    circle((r, r), radius: r / 2, name: "right-eye")
    line((-r, -r), (r, -r), name: "mouth")
  })

  // enable selective "anchor forwarding" to the head node
  (ctx => {
    let group = callback(ctx)

    let nodes = (
      "head",
      "left-eye",
      "right-eye",
      "mouth",
    )

    let new-anchors(k) = if k.first() in nodes {
      (group.anchors)(k)
    } else {
      // forward all other anchors to head
      (group.anchors)(("head", ..k))
    }

    return group + (anchors: new-anchors)
  },)
}

#cetz.canvas({
  import cetz.draw: *
  grid((-10, -10), (10, 10), stroke: gray + 0.5pt)

  head((5, 5), name: "a")
  set-style(circle: (radius: .2, fill: blue))
  circle("a.left-eye") // left as is
  circle("a.south-east") // forwarded to a.head.south-east
  circle("a.north-east")
  circle("a.135deg")
})
2 Likes

If you only need the named anchors of the circle to “leak”, you can use copy-anchors("head") in the group. This does not copy the border anchors (distance, angle, …) though.

I am not sure how a potential user API for that could look like. The last one wins? copy-anchors could take a flag to override/copy the groups path anchor, so the group itself has the shape of one of its elements.

2 Likes

Thank you @Jollywatt and @jwolf.

That was too good to be true…

I tried the code from @Jollywatt in my real use case :

#import "@preview/cetz:0.4.2"
#set page(width: auto, height: auto, margin: .5cm)

#let becher(pos, height: 4, width: 2, name: none, anchor: none) ={
  let h = height / 2
  let w = width / 2
  let bec = .5
  let retourbec = .2
  cetz.draw.group(name: name, anchor: anchor, {
    import cetz.draw: *
    set-origin(pos)
    line((-w, h),
      (-w, -h),
      (w, -h),
      (w, h - bec - retourbec),
      (rel: (bec, bec)),
      (rel: (-bec, retourbec)),
      (-w, h)
    )
    anchor("bec", (w, h))
    anchor("fond", (0, -h))
  })
}

#cetz.canvas(length: 3cm, {
  import cetz.draw: *
  grid((-3, -3), (5, 5), stroke: gray + 0.5pt)
  let st = (stroke: red, fill: blue)
  circle((0, 0), radius: .2, fill: red)
  becher((0, 0), name: "b1", anchor: "fond")
  becher((0, 0), name: "b3")
  circle("b3.fond", radius: .2, fill: blue)
  //content("b1.bec", [hello])
  //becher(name: "b2", pos: "b1.bec", anchor: "fond")
})

(“becher” is the french for beaker, “fond” is for bottom)

I was expecting that both beaker were vertically aligned as the “fond” anchor is vertically aligned with the center. The red dot and the blue dot are not centered at the bottom of the beaker.
If you change the value of bec , the misalignment is changing. It’s like the group command is computing a bounding box. In the face example, this problem did not show up since the drawing was symmetric.

It works by adding an ad-hoc padding to the group

  cetz.draw.group(name: name, anchor: anchor, padding: (0,-bec,0,0), {

But, this solution doesn’t resist to a rotation.

#import "@preview/cetz:0.4.2"
#set page(width: auto, height: auto, margin: .5cm)

#let becher(pos, height: 4, width: 2, name: none, anchor: none, angle : 0) ={
  let h = height / 2
  let w = width / 2
  let bec = .5
  let retourbec = .2
  cetz.draw.group(name: name, anchor: anchor, padding: (0,-bec,0,0), {
    import cetz.draw: *
    set-origin(pos)
    rotate(angle)
    line((-w, h),
      (-w, -h),
      (w, -h),
      (w, h - bec - retourbec),
      (rel: (bec, bec)),
      (rel: (-bec, retourbec)),
      (-w, h)
    )
    anchor("bec", (w, h))
    anchor("fond", (0, -h))
    line("fond", (rel: (0,1)))
  })
}

#cetz.canvas(length: 3cm, {
  import cetz.draw: *
  grid((-5, -5), (6, 6), stroke: gray + 0.5pt)
  let st = (stroke: red, fill: blue)
  
  circle((1, 2), radius: .2, fill: red, name: "red")
  becher("red", name: "b1", anchor: "fond", angle: 30deg)
  
  becher((-2, -2), name: "b3", angle: -30deg)
  circle("b3.fond", radius: .2, fill: blue)

  //content("b1.bec", [hello])
  //becher(name: "b2", pos: "b1.bec", anchor: "fond")
})

(I added a line starting at "fond" to see the misalignment)

Add a anchor("default", (0,0)) after the set-origin to set the groups default anchor to that point – that should fix the groups anchor alignment/positioning.

1 Like