Beyond Freehand

Vector networks are powerful. They're also tedious for common shapes.

Nobody wants to place six vertices and connect them to draw a hexagon. They want to click, drag, and have a hexagon appear.

The Star Problem

A star has two parameters: point count and inner radius.

var cfg = {
  "id": "star1",
  "type": "star-editor",
  "bindings": {
    "pointCount": "star.pointCount",
    "innerRadius": "star.innerRadius"
  },
  "width": 500,
  "height": 200
}

Points: {{star.pointCount}}, Inner radius: {{star.innerRadius}}

The geometry is generated, not drawn. Change a parameter, regenerate the vertices.

Generating Star Vertices

First attempt: polar coordinates.

function generateStar(cx, cy, points, outerR, innerR) {
  const vertices = [];
  for (let i = 0; i < points * 2; i++) {
    const angle = (i * Math.PI) / points - Math.PI / 2;
    const r = i % 2 === 0 ? outerR : innerR;
    vertices.push({
      x: cx + Math.cos(angle) * r,
      y: cy + Math.sin(angle) * r
    });
  }
  return vertices;
}

Alternating between outer and inner radius. Even indices go to outer points, odd indices to inner. Simple.

The Polygon: Even Simpler

A polygon is just a star with innerRadius = outerRadius.

var cfg = {
  "id": "poly1",
  "type": "polygon-editor",
  "bindings": {
    "sides": "polygon.sides"
  },
  "width": 500,
  "height": 200
}

Sides: {{polygon.sides}}

function generatePolygon(cx, cy, sides, radius) {
  return generateStar(cx, cy, sides, radius, radius);
}

One line. Reuse the star generator.

The Gizmo System

Here's the real challenge: users need to modify these parameters visually.

A gizmo is a draggable handle that maps mouse movement to shape parameters.

    Point count gizmo (circular)
           ↓
        ●─────●
       / \   / \
      /   \ /   \
     ●─────●─────● ← Inner radius gizmo (radial)
      \   / \   /
       \ /   \ /
        ●─────●

Problem: How do you drag to change "number of points"? It's not a spatial value.

Gizmo Types

We ended up with three patterns:

Radial gizmo: Drag toward/away from center changes a radius value.

const innerRadiusGizmo = {
  getPosition(shape) {
    const angle = Math.PI / shape.pointCount;
    return {
      x: shape.cx + Math.cos(angle) * shape.innerRadius,
      y: shape.cy + Math.sin(angle) * shape.innerRadius
    };
  },
  onDrag(shape, mousePos) {
    const dist = distance(mousePos, { x: shape.cx, y: shape.cy });
    shape.innerRadius = clamp(dist, 10, shape.outerRadius - 10);
  }
};

Angular gizmo: Drag around the center changes a count or angle.

const pointCountGizmo = {
  getPosition(shape) {
    return { x: shape.cx + shape.outerRadius, y: shape.cy };
  },
  onDrag(shape, mousePos) {
    const angle = Math.atan2(
      mousePos.y - shape.cy,
      mousePos.x - shape.cx
    );
    const normalized = (angle + Math.PI * 2) % (Math.PI * 2);
    shape.pointCount = Math.round(normalized / (Math.PI / 6)) + 3;
  }
};

Linear gizmo: Drag along an axis. Used for rectangles, corner radii.

The Shape Class

Every primitive shape follows this pattern:

class Star {
  constructor(params) {
    this.cx = params.cx;
    this.cy = params.cy;
    this.pointCount = params.pointCount || 5;
    this.outerRadius = params.outerRadius || 100;
    this.innerRadius = params.innerRadius || 50;
    this.gizmos = [innerRadiusGizmo, pointCountGizmo];
  }

  toVectorNetwork() {
    const vertices = generateStar(
      this.cx, this.cy,
      this.pointCount,
      this.outerRadius,
      this.innerRadius
    );
    return createLoopFromVertices(vertices);
  }
}

The shape stores parameters. toVectorNetwork() generates geometry on demand. Gizmos provide interactive editing.

Why Not Just Store Vertices?

We tried this. Store the generated vertices, let users edit them directly.

Problems:

  1. Lost semantics: Once you edit one vertex, it's no longer "a star." It's just a collection of points.

  2. No parameter recovery: Can't change point count after editing. The shape forgot what it was.

  3. Broken constraints: Inner points should stay on a circle. Manual editing breaks this.

The solution: shapes are parametric until converted. Edit parameters through gizmos. Only convert to raw vertices when necessary (boolean operations, direct vertex editing).

Rectangle: A Deceptively Complex Shape

Rectangles seem simple. Four corners, four sides.

But users expect:

var cfg = {
  "id": "rect1",
  "type": "rect-editor",
  "bindings": {
    "width": "rect.width",
    "height": "rect.height",
    "cornerRadius": "rect.cornerRadius"
  },
  "width": 500,
  "height": 200
}

Width: {{rect.width}}, Height: {{rect.height}}, Radius: {{rect.cornerRadius}}

A rectangle has 8 resize gizmos, 4 corner gizmos, and needs to handle the case where corner radius exceeds the shortest side.

The Gizmo Rendering Pipeline

User moves mouse
      ↓
Hit test gizmos (in reverse z-order)
      ↓
If gizmo hit: call gizmo.onDrag(shape, mousePos)
      ↓
Shape updates its parameters
      ↓
Shape.toVectorNetwork() regenerates geometry
      ↓
Renderer draws new geometry + gizmo handles

Gizmos are tested after shapes. They render on top of shapes. This creates the expected layering: click shape to select, drag gizmo to modify.

Summary

The user draws a star with two drags. Under the hood, we store two numbers. The geometry is always derived.

This is the difference between a drawing program and a design tool. Drawing tools store marks. Design tools store intent.

Next: Transformation Matrices →