Skip to content

Pulley Diagram

A constraint-based physics diagram of three pulley wheels suspended from a ceiling bar by labeled ropes, lifting two hanging weights.

ts
import { Connect, Constraint, Layer, circle, createMark, createName, polygon, rect, ref, text } from "gofish-graphics";

const r = 25;

const w2jut = 10;

const rope = {
  stroke: "#774e32",
  strokeWidth: 3,
  mixBlendMode: "normal",
} as const;

const PulleyCircle = createMark(({ r = 25 }: { r?: number }) =>
  Layer([
    circle({ r, stroke: "#828282", strokeWidth: 3, fill: "#C1C1C1" }).name(
      "wheel"
    ),
    circle({ r: 5, fill: "#555555" }).name("hub"),
  ]).constrain(({ wheel, hub }) => [
    Constraint.align({ x: "middle", y: "middle" }, [wheel, hub]),
  ])
);

const Weight = createMark(
  ({
    width,
    height,
    label,
  }: {
    width: number;
    height: number;
    label: string;
  }) =>
    Layer([
      polygon({
        // GoFish y-up: full-width bottom edge at y=0, inset top edge at y=height.
        points: [
          [0, 0],
          [width, 0],
          [width - 10, height],
          [10, height],
        ],
        fill: "#545454",
        stroke: "#545454",
      }).name("body"),
      text({ text: label, fontSize: 10, fill: "white" }).name("label"),
    ]).constrain(({ body, label }) => [
      Constraint.align({ x: "middle", y: "middle" }, [body, label]),
    ])
);

const container = document.getElementById("app");

// Cross-tier names: the ropes (outer layer) reference the shapes (inner
// layer). String names are layer-scoped; `createName` tokens register
// globally, so `ref(token)` resolves across the layer boundary.
const ceiling = createName("ceiling");
const A = createName("A");
const B = createName("B");
const C = createName("C");
const w1 = createName("w1");
const w2 = createName("w2");

// x/y shift the resolved bounding box to start at (20, 20) — the constraint
// layout produces negative coordinates and the root render does not auto-fit.
Layer({ x: 20, y: 20 }, [
  // ── tier 1: shapes + letter labels — a finished, fully-placed unit ──
  Layer([
    rect({
      h: 20,
      w: 9 * r,
      fill: "#C9C9C9",
      stroke: "#000",
      strokeWidth: 2,
    }).name(ceiling),
    PulleyCircle({ r }).name(A),
    PulleyCircle({ r }).name(B),
    PulleyCircle({ r }).name(C),
    Weight({ width: 30, height: 30, label: "W1" }).name(w1),
    Weight({ width: 3 * r + w2jut, height: 30, label: "W2" }).name(w2),
    text({ text: "A", fontSize: 12 }).name("Alabel"),
    text({ text: "B", fontSize: 12 }).name("Blabel"),
    text({ text: "C", fontSize: 12 }).name("Clabel"),
  ]).constrain((c) => [
    // horizontal pulley cluster — each adjacent pair shares an edge:
    // B.start sits on A.middle (overlap by half a wheel), C.start on B.end.
    Constraint.align({ x: ["middle", "start"] }, [c.A, c.B]),
    Constraint.align({ x: ["end", "start"] }, [c.B, c.C]),

    // vertical placement (GoFish is y-up; pair order flipped vs Bluefish)
    Constraint.distribute({ dir: "y", spacing: 40, mode: "edge" }, [c.B, c.ceiling]),
    Constraint.distribute({ dir: "y", spacing: 30, mode: "edge" }, [c.A, c.B]),
    Constraint.distribute({ dir: "y", spacing: 50, mode: "edge" }, [c.C, c.B]),

    // ceiling centered over the cluster (substitute for Bluefish <Group>)
    Constraint.align({ x: "middle" }, [c.B, c.ceiling]),

    // weights (negative spacing offsets each weight so its inset trapezoid
    // top sits under the rope source points — not natural anchor points,
    // so these stay as `distribute`)
    Constraint.distribute({ dir: "y", spacing: 50, mode: "edge" }, [c.w2, c.C]),
    Constraint.distribute({ dir: "x", spacing: -20, mode: "edge" }, [c.A, c.w2]),
    Constraint.distribute({ dir: "x", spacing: -15, mode: "edge" }, [c.w1, c.A]),
    Constraint.align({ y: "middle" }, [c.w2, c.w1]),

    // pulley letter labels — 1px gap from the wheel; the label sits on
    // one side and y-anchors to one corner of the wheel.
    ...(
      [
        { pulley: c.A, label: c.Alabel, side: "left", y: "end" },
        { pulley: c.B, label: c.Blabel, side: "right", y: "end" },
        { pulley: c.C, label: c.Clabel, side: "right", y: "start" },
      ] as const
    ).flatMap(({ pulley, label, side, y }) => [
      Constraint.distribute(
        { dir: "x", spacing: 1, mode: "edge" },
        side === "right" ? [pulley, label] : [label, pulley]
      ),
      Constraint.align({ y }, [pulley, label]),
    ]),
  ]),

  // ── tier 2: rope segments — read the placed shapes ──────────────────
  // Declared after tier 1 so their ref()s resolve against placed shapes.
  // zOrder(-1): painted behind tier 1, so the wheels draw over rope ends.
  // `ropeSupport` is the unlabeled support rope from the ceiling to B; the
  // rest are named after the dimension letter (x/y/z/p/q/s) they carry.
  Connect({ ...rope, target: "middle" }, [ref(ceiling), ref(B)])
    .name("ropeSupport")
    .zOrder(-1),
  Connect({ ...rope, source: ["start", "middle"], target: "middle" }, [
    ref(B),
    ref(A),
  ])
    .name("ropeX")
    .zOrder(-1),
  Connect(
    { ...rope, source: ["end", "middle"], target: ["start", "middle"] },
    [ref(B), ref(C)]
  )
    .name("ropeY")
    .zOrder(-1),
  Connect({ ...rope, target: ["end", "middle"] }, [ref(ceiling), ref(C)])
    .name("ropeZ")
    .zOrder(-1),
  Connect({ ...rope, source: ["start", "middle"] }, [ref(A), ref(w1)])
    .name("ropeP")
    .zOrder(-1),
  Connect({ ...rope, source: ["end", "middle"] }, [ref(A), ref(w2)])
    .name("ropeQ")
    .zOrder(-1),
  Connect({ ...rope, source: "middle" }, [ref(C), ref(w2)])
    .name("ropeS")
    .zOrder(-1),

  // ── tier 3: dimension labels ────────────────────────────────────────
  text({ text: "x" }).name("labelX"),
  text({ text: "y" }).name("labelY"),
  text({ text: "z" }).name("labelZ"),
  text({ text: "p" }).name("labelP"),
  text({ text: "q" }).name("labelQ"),
  text({ text: "s" }).name("labelS"),
])
  .constrain((c) => [
    // Each dimension label sits 5px right of its rope on x. On y, the
    // upper trio (x/y/z) shares ropeX's centerY; the lower trio (p/q/s)
    // shares ropeS's — à la Bluefish's `Align centerY [t1,t2,t3]` /
    // `[t6,t5,t4]`.
    ...[
      { rope: c.ropeX, label: c.labelX, yAnchorTo: c.ropeX },
      { rope: c.ropeY, label: c.labelY, yAnchorTo: c.labelX },
      { rope: c.ropeZ, label: c.labelZ, yAnchorTo: c.labelX },
      { rope: c.ropeS, label: c.labelS, yAnchorTo: c.ropeS },
      { rope: c.ropeQ, label: c.labelQ, yAnchorTo: c.labelS },
      { rope: c.ropeP, label: c.labelP, yAnchorTo: c.labelS },
    ].flatMap(({ rope, label, yAnchorTo }) => [
      Constraint.distribute({ dir: "x", spacing: 5, mode: "edge" }, [rope, label]),
      Constraint.align({ y: "middle" }, [yAnchorTo, label]),
    ]),

    // ── granular paint order: relative z-order constraints ────────────
    // Cross-tier refs (c.A, c.B, c.C) work because collectConstraintRefs
    // descends into the (plain) inner shapes layer. The ropes' default
    // .zOrder(-1) keeps the unmentioned ropes (Y/Z/P/Q) behind their
    // circles; these constraints carve out the four exceptions.
    Constraint.zAbove(c.ropeX, c.A), // x over A
    Constraint.zBelow(c.ropeX, c.B), // x under B
    Constraint.zAbove(c.ropeSupport, c.B), // ceiling→B over B
    Constraint.zAbove(c.ropeS, c.C), // s over C
  ])
  .render(container, {});