Back to Napplet Machines V8
SOURCE / PINNED RELEASE

Made of little things.

Napplet Machines V8

Release
b3f614e739f0…
Author-recorded commit
534f19acb4ba…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b5572362d4a

Archive hash verified: 0f7feb97e2c3d336…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/editor.ts
import {
  Road,
  clamp,
  roadEdge,
  TRACK_HALF_SIZE,
  PLAN_SPAN,
  type Track,
  type Knot,
} from './track.js';
export type EditorTool = 'move' | 'add' | 'walls' | 'draw';
export class TrackEditor {
  selected = 0;
  tool: EditorTool = 'move';
  stroke: Knot[] = [];
  dragging = false;
  zoom = 1;
  hover: { x: number; y: number } | null = null;
  road: Road;
  onChange: () => void = () => {};
  onSelect: () => void = () => {};
  onTool: () => void = () => {};
  onNotice: (message: string) => void = () => {};
  constructor(
    public canvas: HTMLCanvasElement,
    public track: Track,
  ) {
    this.road = new Road(track);
    this.fit();
    canvas.addEventListener('pointerdown', (e) => {
      canvas.setPointerCapture(e.pointerId);
      const p = this.fromScreen(e.offsetX, e.offsetY);
      if (this.tool === 'add') {
        this.addAt(p.x, p.y);
        return;
      }
      if (this.tool === 'walls') {
        const q = this.road.nearest(p.x, p.y);
        if (Math.hypot(p.x - q.x, p.y - q.y) < q.width / 2 + 5 / this.scale) {
          this.selected = q.section;
          this.onSelect();
        }
        return;
      }
      if (this.tool === 'draw') {
        const template = this.track.points[Math.max(0, this.selected)];
        this.stroke = [{ ...template, ...p, z: 3 }];
        this.dragging = true;
        return;
      }
      let best = 10 / this.scale;
      this.selected = -1;
      this.track.points.forEach((k, i) => {
        const d = Math.hypot(p.x - k.x, p.y - k.y);
        if (d < best) {
          best = d;
          this.selected = i;
        }
      });
      if (this.selected >= 0) this.dragging = true;
      this.onSelect();
    });
    canvas.addEventListener('pointermove', (e) => {
      const p = this.fromScreen(e.offsetX, e.offsetY);
      this.hover = p;
      if (!this.dragging) return;
      if (this.tool === 'draw') {
        const last = this.stroke.at(-1)!;
        if (
          Math.hypot(last.x - p.x, last.y - p.y) > 5 &&
          this.stroke.length < 32
        )
          this.stroke.push({ ...last, ...p });
        return;
      }
      const k = this.track.points[this.selected];
      if (!k) return;
      const prev =
          this.track.points[
            (this.selected + this.track.points.length - 1) %
              this.track.points.length
          ],
        next =
          this.track.points[(this.selected + 1) % this.track.points.length];
      if (
        Math.hypot(prev.x - p.x, prev.y - p.y) < 3 ||
        Math.hypot(next.x - p.x, next.y - p.y) < 3
      )
        return;
      k.x = p.x;
      k.y = p.y;
      this.onChange();
    });
    canvas.addEventListener('pointerleave', () => {
      this.hover = null;
    });
    canvas.addEventListener('pointerup', () => {
      if (this.tool === 'draw' && this.dragging && this.stroke.length >= 4) {
        if (
          Math.hypot(
            this.stroke[0].x - this.stroke.at(-1)!.x,
            this.stroke[0].y - this.stroke.at(-1)!.y,
          ) < 3
        )
          this.stroke.pop();
        if (this.stroke.length >= 4) {
          this.track.points = this.stroke;
          this.selected = 0;
          this.onChange();
          this.onSelect();
          this.setTool('move');
        }
      }
      this.stroke = [];
      this.dragging = false;
    });
    canvas.addEventListener('pointercancel', () => {
      this.dragging = false;
      this.stroke = [];
    });
  }
  setTool(tool: EditorTool) {
    this.tool = tool;
    this.stroke = [];
    this.dragging = false;
    this.hover = null;
    this.canvas.style.cursor =
      tool === 'add' || tool === 'draw' ? 'crosshair' : 'pointer';
    this.onTool();
  }
  fit() {
    const radius = Math.max(
      ...this.track.points.flatMap((p) => [Math.abs(p.x), Math.abs(p.y)]),
    );
    this.zoom = clamp(PLAN_SPAN / (radius * 2 + 32), 1, 3);
  }
  get scale() {
    return (
      (Math.min(this.canvas.clientWidth, this.canvas.clientHeight) /
        PLAN_SPAN) *
      this.zoom
    );
  }
  fromScreen(x: number, y: number) {
    return {
      x: clamp(
        (x - this.canvas.clientWidth / 2) / this.scale,
        -TRACK_HALF_SIZE,
        TRACK_HALF_SIZE,
      ),
      y: clamp(
        (y - this.canvas.clientHeight / 2) / this.scale,
        -TRACK_HALF_SIZE,
        TRACK_HALF_SIZE,
      ),
    };
  }
  placement(x: number, y: number) {
    const p = this.road.nearest(x, y),
      i = p.section,
      a = this.track.points[i],
      b = this.track.points[(i + 1) % this.track.points.length];
    return {
      index: i + 1,
      knot: {
        x,
        y,
        z: clamp(p.z, 2, 24),
        width: p.width,
        wall: a.wall,
        wallThickness: a.wallThickness,
      },
      valid:
        Math.abs(x) <= TRACK_HALF_SIZE &&
        Math.abs(y) <= TRACK_HALF_SIZE &&
        this.track.points.length < 32 &&
        Math.hypot(x - a.x, y - a.y) >= 3 &&
        Math.hypot(x - b.x, y - b.y) >= 3,
    };
  }
  addAt(x: number, y: number) {
    const insertion = this.placement(x, y);
    if (!insertion.valid) {
      this.onNotice(
        this.track.points.length >= 32
          ? 'This loop already has 32 points.'
          : 'Place the new point at least 3 m from its neighbors.',
      );
      return false;
    }
    this.track.points.splice(insertion.index, 0, insertion.knot);
    this.selected = insertion.index;
    this.road = new Road(this.track);
    this.onChange();
    this.onSelect();
    this.onNotice('Point placed. Click to add another, or choose Edit points.');
    return true;
  }
  remove() {
    if (this.track.points.length <= 4 || this.selected < 0) return;
    this.track.points.splice(this.selected, 1);
    this.selected = Math.min(this.selected, this.track.points.length - 1);
    this.onChange();
    this.onSelect();
  }
  draw() {
    const c = this.canvas,
      ctx = c.getContext('2d')!,
      dpr = Math.min(devicePixelRatio, 2),
      w = c.clientWidth,
      h = c.clientHeight;
    if (!w || !h) return;
    if (c.width !== w * dpr || c.height !== h * dpr) {
      c.width = w * dpr;
      c.height = h * dpr;
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);
    const fg = getComputedStyle(c).color;
    ctx.strokeStyle = fg;
    ctx.globalAlpha = 0.1;
    ctx.lineWidth = 1;
    for (let x = (w / 2) % (10 * this.scale); x < w; x += 10 * this.scale) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, h);
      ctx.stroke();
    }
    for (let y = (h / 2) % (10 * this.scale); y < h; y += 10 * this.scale) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(w, y);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
    ctx.save();
    ctx.translate(w / 2, h / 2);
    ctx.scale(this.scale, this.scale);
    this.canvas.dataset.scale = String(this.scale);
    this.canvas.dataset.span = String(PLAN_SPAN / this.zoom);
    ctx.strokeStyle = '#8298b9';
    ctx.lineWidth = 1 / this.scale;
    ctx.setLineDash([5 / this.scale, 5 / this.scale]);
    ctx.strokeRect(
      -TRACK_HALF_SIZE,
      -TRACK_HALF_SIZE,
      TRACK_HALF_SIZE * 2,
      TRACK_HALF_SIZE * 2,
    );
    ctx.setLineDash([]);
    const road = this.road;
    const polygon = (points: { x: number; y: number }[]) => {
      points.forEach((p, i) =>
        i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y),
      );
      ctx.closePath();
    };
    // Fill each surface in one canvas operation to avoid antialiased seams
    // between adjacent sampled quads.
    const surface = (selectedOnly: boolean, color: string) => {
      ctx.beginPath();
      for (let i = 0; i < road.samples.length - 1; i++) {
        const a = road.samples[i],
          b = road.samples[i + 1];
        if (selectedOnly && a.section !== this.selected) continue;
        polygon([
          roadEdge(a, -a.width / 2),
          roadEdge(b, -b.width / 2),
          roadEdge(b, b.width / 2),
          roadEdge(a, a.width / 2),
        ]);
      }
      ctx.fillStyle = color;
      ctx.fill();
    };
    surface(false, '#35476b');
    ctx.globalAlpha = 0.25;
    surface(true, '#ffda45');
    ctx.globalAlpha = 1;
    ctx.beginPath();
    for (const wall of road.walls) polygon(wall.corners);
    ctx.fillStyle = '#25ddc6';
    ctx.fill();
    ctx.beginPath();
    road.samples.forEach((p, i) =>
      i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y),
    );
    ctx.closePath();
    ctx.lineWidth = 0.3;
    ctx.setLineDash([1.4, 1.4]);
    ctx.strokeStyle = '#d1ebff';
    ctx.stroke();
    ctx.setLineDash([]);
    this.track.points.forEach((p, i) => {
      ctx.beginPath();
      ctx.arc(
        p.x,
        p.y,
        (i === this.selected ? 7 : 5.5) / this.scale,
        0,
        Math.PI * 2,
      );
      ctx.fillStyle = i === this.selected ? '#ffda45' : '#fff8e7';
      ctx.fill();
      ctx.lineWidth = 2 / this.scale;
      ctx.strokeStyle = '#17254d';
      ctx.stroke();
      ctx.fillStyle = fg;
      ctx.font = `${10 / this.scale}px monospace`;
      ctx.fillText(`${i + 1}`, p.x + 8 / this.scale, p.y - 7 / this.scale);
    });
    if (this.tool === 'add' && this.hover) {
      const { index, knot, valid } = this.placement(this.hover.x, this.hover.y),
        a = this.track.points[index - 1],
        b = this.track.points[index % this.track.points.length];
      ctx.beginPath();
      ctx.moveTo(a.x, a.y);
      ctx.lineTo(knot.x, knot.y);
      ctx.lineTo(b.x, b.y);
      ctx.setLineDash([3 / this.scale, 3 / this.scale]);
      ctx.lineWidth = 2 / this.scale;
      ctx.strokeStyle = valid ? '#ffda45' : '#ff596b';
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.beginPath();
      ctx.arc(knot.x, knot.y, 6 / this.scale, 0, Math.PI * 2);
      ctx.stroke();
    }
    if (this.stroke.length) {
      ctx.beginPath();
      this.stroke.forEach((p, i) =>
        i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y),
      );
      ctx.lineWidth = 2 / this.scale;
      ctx.strokeStyle = '#ffda45';
      ctx.stroke();
    }
    ctx.restore();
  }
}