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.
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();
}
}
