From 460bfad9700e7b67c05423646e1a1928163f4b23 Mon Sep 17 00:00:00 2001 From: SequentialEntropy Date: Mon, 15 Sep 2025 18:00:38 +0100 Subject: [PATCH 1/3] Implement circle tool --- assets/fire_charge.png | Bin 0 -> 205 bytes assets/snowball.png | Bin 0 -> 183 bytes components/Canvas.ts | 94 +++++++++++++++++++++++++++++++++++++++++ components/Toolbar.ts | 26 +++++++++++- index.html | 4 ++ styles.css | 6 ++- 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 assets/fire_charge.png create mode 100644 assets/snowball.png diff --git a/assets/fire_charge.png b/assets/fire_charge.png new file mode 100644 index 0000000000000000000000000000000000000000..6dd710a26dbb1e8c01f985d4a32382a888775519 GIT binary patch literal 205 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFv3GfMV1=8=gn-VQZT% z%@s`$3$LCLe3VUK@evIR*&`7TwX$AK;Z0fN!&TC`hRtXi`)(;;M#HilQcIVWGfUoC ys(JaeS%bvfrYY{rncQYQ`{*~1OAg2B_(&t;ucLK6T?2SgPB literal 0 HcmV?d00001 diff --git a/assets/snowball.png b/assets/snowball.png new file mode 100644 index 0000000000000000000000000000000000000000..8b9e06a71709a1704989483d327937be1fb4e3b1 GIT binary patch literal 183 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Xn;?ME0F&G|No00KQ4Uyc<9!x z^`}l%FI$$bd*c^Sg0UpXFPOpM*^M+HC&1IiF{I*FZ@(ws0R6yBEV1xTtYg5IWgFT*=Jn>B`gZHmkJE)7(w4G& dLaTp2<38fd*cNi{P$ { @@ -156,6 +203,12 @@ export function Canvas(WORLD: World) { const gridPos = snap(canvasToGrid(pos)) BEZIER.points[BEZIER.dragging] = gridPos markDirty() + } else if (selectedTool === ToolTypes.CIRCLE) { + const gridPos = snap(canvasToGrid(pos)) + const c = CIRCLE.circles[CIRCLE.dragging] + c.x = gridPos.x + c.z = gridPos.z + markDirty() } else { draw(pos) MOUSE.lastPos = pos @@ -401,6 +454,47 @@ export function Canvas(WORLD: World) { ctx.fillRect(Math.floor((c2.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((c2.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) } + for (const c of CIRCLE.circles) { + const cx = c.x + const cy = c.z + const radius = c.radius + + if (CIRCLE.focus === c) { + ctx.fillStyle = "#ff0000" + } else { + ctx.fillStyle = "#0000ff" + } + + ctx.fillRect(Math.floor((c.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((c.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) + + // 4-connected circle algorithm (in-place, no storage) + let x = radius; + let y = 0; + + ctx.fillStyle = "#918470" + while (x > 0 && y <= radius) { + // Reflect this point into all 8 octants + ctx.fillRect(Math.floor((cx + x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx + y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx - y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx - x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx - x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx - y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx + y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx + x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + + // Choose direction (up or left) + const errUp = Math.abs(x ** 2 + (y + 1) ** 2 - radius * radius); + const errLeft = Math.abs((x - 1) ** 2 + y ** 2 - radius * radius); + + if (errUp < errLeft) { + y++; + } else { + x--; + } + } + } + ctx.restore() } diff --git a/components/Toolbar.ts b/components/Toolbar.ts index 0605c68..dc04193 100644 --- a/components/Toolbar.ts +++ b/components/Toolbar.ts @@ -1,4 +1,4 @@ -import { BACKGROUND, BEZIER, GridPosition, markDirty, MIPMAP_LEVELS, MipmapImage } from "./Canvas.js" +import { BACKGROUND, BEZIER, CIRCLE, GridPosition, markDirty, MIPMAP_LEVELS, MipmapImage } from "./Canvas.js" import { World } from "../world/World.js" import { RailShape } from "../world/RailShape.js" @@ -17,6 +17,7 @@ export enum ToolTypes { NW = "NW.png", SW = "SW.png", ERASE = "ERASE.png", + CIRCLE = "snowball.png", BEZIER = "BEZIER.png", } @@ -137,6 +138,29 @@ export function Toolbar(WORLD: World) { } tickcounter.onmouseout = tickcounter.onmouseup } + + const radiusSlider = document.getElementById("radiusSlider") as HTMLInputElement + const radiusValue = document.getElementById("radiusValue") + if (!radiusSlider) throw new Error("#radiusSlider not found - unable to load radiusSlider") + if (!radiusValue) throw new Error("#radiusValue not found - unable to load radiusValue") + radiusSlider.addEventListener("input", () => { + const r = parseInt(radiusSlider.value) + radiusValue.textContent = `R = ${radiusSlider.value}` + + if (CIRCLE.focus) { + CIRCLE.focus.radius = r + markDirty() + } + }) + + const DeleteButton = document.getElementById("delete") + if (!DeleteButton) throw new Error("#delete not found - unable to load delete") + DeleteButton.addEventListener("click", () => { + if (CIRCLE.focus) { + CIRCLE.circles = CIRCLE.circles.filter(c => c !== CIRCLE.focus) + CIRCLE.focus = null + } + }) } function importJSON(file: File, WORLD: World) { diff --git a/index.html b/index.html index 1ece3b6..e9e19d9 100644 --- a/index.html +++ b/index.html @@ -25,6 +25,10 @@

+ + +

R = 25

+ diff --git a/styles.css b/styles.css index 1ad5f72..1b496f7 100644 --- a/styles.css +++ b/styles.css @@ -26,7 +26,7 @@ body { height: 32px; } -#toolbar img, p, button { +#toolbar img, p, button, input { border: 2px solid transparent; margin: 2px; } @@ -65,6 +65,10 @@ body { background-color: rgb(220, 220, 220); } +#toolbar input { + width: 300px; +} + #upload { display: none; } From 43edb9b1ac4daec53c14bbd201b23df06de56908 Mon Sep 17 00:00:00 2001 From: SequentialEntropy Date: Mon, 15 Sep 2025 20:14:56 +0100 Subject: [PATCH 2/3] Refactor each tool into its own class --- components/Canvas.ts | 335 ++++----------------------------- components/Toolbar.ts | 76 +------- components/Tools/BezierTool.ts | 190 +++++++++++++++++++ components/Tools/CircleTool.ts | 119 ++++++++++++ components/Tools/DrawTool.ts | 82 ++++++++ 5 files changed, 434 insertions(+), 368 deletions(-) create mode 100644 components/Tools/BezierTool.ts create mode 100644 components/Tools/CircleTool.ts create mode 100644 components/Tools/DrawTool.ts diff --git a/components/Canvas.ts b/components/Canvas.ts index 7c6f0bc..dcf320a 100644 --- a/components/Canvas.ts +++ b/components/Canvas.ts @@ -1,16 +1,10 @@ -import { cubicBezier, selectedTool, ToolTypes } from "./Toolbar.js" +import { selectedTool, ToolTypes } from "./Toolbar.js" import { World } from "../world/World.js" import { RailShape } from "../world/RailShape.js" -export const BEZIER = { - points: [ - null, - null, - null, - null, - ] as (GridPosition | null)[], - dragging: -1, -} +import * as DrawTool from "./Tools/DrawTool.js" +import * as BezierTool from "./Tools/BezierTool.js" +import * as CircleTool from "./Tools/CircleTool.js" export const BACKGROUND: { image: MipmapImage[], @@ -18,24 +12,13 @@ export const BACKGROUND: { z: number }[] = [] -interface Circle { - x: number, - z: number, - radius: number -} -export const CIRCLE = { - circles: [] as Circle[], - dragging: -1, - focus: null as (Circle | null) -} - export const TRANSFORM = { x: 600, y: 250, scale: 2.4, } -const MOUSE: {draw: boolean, lastPos: null | CanvasPosition} = { +export const MOUSE: {draw: boolean, lastPos: null | GridPosition} = { draw: false, lastPos: null } @@ -58,6 +41,19 @@ interface CanvasPosition { y: number } +export function snap({x, z}: GridPosition) { + return { + x: Math.floor(x), + z: Math.floor(z) + } +} + +const ZOOM_SPEED = 0.01 +const RENDER_IMAGE_THRESHOLD = 8 +const MINECART_ALPHA = 0.8 +export const LINE_WIDTH = 0.1875 +export const FIXED_WIDTH = 8 + export function Canvas(WORLD: World) { const CANVAS = document.getElementById("canvas") as HTMLCanvasElement const ctx = CANVAS.getContext("2d", { alpha: false }) as CanvasRenderingContext2D @@ -73,12 +69,6 @@ export function Canvas(WORLD: World) { const textures: Record = {} - const ZOOM_SPEED = 0.01 - const RENDER_IMAGE_THRESHOLD = 8 - const MINECART_ALPHA = 0.8 - const LINE_WIDTH = 0.1875 - const FIXED_WIDTH = 8 - for (const shapeName in RailShape) { const shape = RailShape[shapeName as keyof typeof RailShape] textures[shape] = new Image() @@ -113,183 +103,44 @@ export function Canvas(WORLD: World) { CANVAS.addEventListener("mousedown", e => { if (e.button === 0) { MOUSE.draw = true - const pos = Object.freeze({x: e.clientX, y: e.clientY}) + const canvasPos = Object.freeze({x: e.clientX, y: e.clientY}) + const gridPos = canvasToGrid(canvasPos) if (selectedTool === ToolTypes.BEZIER) { - const gridDecimalPos = canvasToGrid(pos) - const gridPos = snap(gridDecimalPos) - - if (BEZIER.points[0] === null) { // If first drag, set p1 and c1 - BEZIER.points[0] = gridPos - BEZIER.points[1] = gridPos - BEZIER.dragging = 1 - markDirty() - return - } else if (BEZIER.points[3] === null) { // If second drag, set p2 and c2 - BEZIER.points[3] = gridPos - BEZIER.points[2] = gridPos - BEZIER.dragging = 2 - markDirty() - return - } - - if (BEZIER.points[1] === null || BEZIER.points[2] === null) { - return - } - - const fixed_square_width_in_grid = Math.max(1, FIXED_WIDTH / TRANSFORM.scale) - for (let i = 0; i < 4; i++) { - const point = BEZIER.points[i] as GridPosition - const dist = Math.max(Math.abs(point.x + .5 - gridDecimalPos.x), Math.abs(point.z + .5 - gridDecimalPos.z)) - - if (dist < fixed_square_width_in_grid / 2) { - BEZIER.dragging = i - break - } - } + BezierTool.mousedown(gridPos) } else if (selectedTool === ToolTypes.CIRCLE) { - const gridDecimalPos = canvasToGrid(pos) - const gridPos = snap(gridDecimalPos) - - const radiusSlider = document.getElementById("radiusSlider") as HTMLInputElement - const radiusValue = document.getElementById("radiusValue") - if (!radiusSlider) throw new Error("#radiusSlider not found - unable to load radiusSlider") - if (!radiusValue) throw new Error("#radiusValue not found - unable to load radiusValue") - - let found = false - const fixed_square_width_in_grid = Math.max(1, FIXED_WIDTH / TRANSFORM.scale) - for (let i = 0; i < CIRCLE.circles.length; i++) { - const point = CIRCLE.circles[i] - const dist = Math.max(Math.abs(point.x + .5 - gridDecimalPos.x), Math.abs(point.z + .5 - gridDecimalPos.z)) - - if (dist < fixed_square_width_in_grid / 2) { - CIRCLE.dragging = i - CIRCLE.focus = CIRCLE.circles[i] - radiusSlider.value = CIRCLE.circles[i].radius.toString() - radiusValue.textContent = `R = ${CIRCLE.circles[i].radius}` - found = true - break - } - } - - if (!found) { - const c = { - x: gridPos.x, - z: gridPos.z, - radius: parseInt(radiusSlider.value, 10), - } - CIRCLE.dragging = CIRCLE.circles.length - CIRCLE.focus = c - CIRCLE.circles.push(c) - } + CircleTool.mousedown(gridPos) } else { - draw(pos) - MOUSE.lastPos = pos + DrawTool.mousedown(gridPos, WORLD) } + + MOUSE.lastPos = gridPos } }) CANVAS.addEventListener("mouseup", () => { MOUSE.draw = false MOUSE.lastPos = null - BEZIER.dragging = -1 - CIRCLE.dragging = -1 + BezierTool.mouseup() + CircleTool.mouseup() }) CANVAS.addEventListener("mousemove", e => { if (MOUSE.draw) { - const pos = Object.freeze({x: e.clientX, y: e.clientY}) + const canvasPos = Object.freeze({x: e.clientX, y: e.clientY}) + const gridPos = canvasToGrid(canvasPos) + if (selectedTool === ToolTypes.BEZIER) { - const gridPos = snap(canvasToGrid(pos)) - BEZIER.points[BEZIER.dragging] = gridPos - markDirty() + BezierTool.mousemove(gridPos) } else if (selectedTool === ToolTypes.CIRCLE) { - const gridPos = snap(canvasToGrid(pos)) - const c = CIRCLE.circles[CIRCLE.dragging] - c.x = gridPos.x - c.z = gridPos.z - markDirty() + CircleTool.mousemove(gridPos) } else { - draw(pos) - MOUSE.lastPos = pos - } - } - }) - - /** - * Bresenham's Line Algorithm - */ - function interpolateLine(pos0: GridPosition, pos1: GridPosition, callback: (pos: GridPosition) => void) { - let x = pos0.x - let z = pos0.z - const x1 = pos1.x - const z1 = pos1.z - const dx = Math.abs(x1 - x); - const dz = Math.abs(z1 - z); - const sx = x < x1 ? 1 : -1; - const sz = z < z1 ? 1 : -1; - let err = dx - dz; - - while (true) { - callback({x, z}); - if (x === x1 && z === z1) break; - const e2 = 2 * err; - if (e2 > -dz) { - err -= dz; - x += sx; - } - if (e2 < dx) { - err += dx; - z += sz; + DrawTool.mousemove(gridPos, WORLD) } - } - } - function draw(pos: CanvasPosition) { - if (!MOUSE.lastPos) { - placeOrErase(snap(canvasToGrid(pos))) - } else { - interpolateLine(snap(canvasToGrid(MOUSE.lastPos)), snap(canvasToGrid(pos)), placeOrErase) + MOUSE.lastPos = gridPos } - } - - function placeOrErase(pos: GridPosition) { - switch(selectedTool) { - case ToolTypes.ERASE: - erase(pos) - break - case ToolTypes.NS: - place(pos, "NS") - break - case ToolTypes.EW: - place(pos, "EW") - break - case ToolTypes.NE: - place(pos, "NE") - break - case ToolTypes.SE: - place(pos, "SE") - break - case ToolTypes.NW: - place(pos, "NW") - break - case ToolTypes.SW: - place(pos, "SW") - break - } - } - - function place({x, z}: GridPosition, shape: string) { - const key = `${x},${z}` - WORLD.grid[key] = shape - markDirty() - } - - function erase({x, z}: GridPosition) { - const key = `${x},${z}` - delete WORLD.grid[key] - markDirty() - } + }) function canvasToGrid({x, y}: CanvasPosition) { const rect = CANVAS.getBoundingClientRect(); @@ -299,13 +150,6 @@ export function Canvas(WORLD: World) { }; } - function snap({x, z}: GridPosition) { - return { - x: Math.floor(x), - z: Math.floor(z) - } - } - function drawRotatedImage(image: HTMLImageElement, x: number, y: number, angle: number, width: number, height: number) { ctx.save(); // Save current state @@ -386,114 +230,9 @@ export function Canvas(WORLD: World) { } } - const [p1, c1, c2, p2] = BEZIER.points - - let last: GridPosition | null = null - - if (p1 && c1 && c2 && p2) { - ctx.fillStyle = "#918470" - ctx.lineWidth = LINE_WIDTH * TRANSFORM.scale - ctx.beginPath() - for (let t = 0; t <= 1; t += 0.0001) { - const x = cubicBezier(p1.x, c1.x, c2.x, p2.x, t) + .5 - const z = cubicBezier(p1.z, c1.z, c2.z, p2.z, t) + .5 - - if (t === 0) ctx.moveTo(x * TRANSFORM.scale + TRANSFORM.x, z * TRANSFORM.scale + TRANSFORM.y); - else ctx.lineTo(x * TRANSFORM.scale + TRANSFORM.x, z * TRANSFORM.scale + TRANSFORM.y); + BezierTool.render(ctx) - const gridPoint = snap({x, z}) - - const dx = last ? Math.abs(gridPoint.x - last.x) : null - const dz = last ? Math.abs(gridPoint.z - last.z) : null - - if (last && dx === 1 && dz === 1) { - const cornerPoint: GridPosition = { - x: last.x, - z: gridPoint.z - } - ctx.fillRect(Math.floor(cornerPoint.x * TRANSFORM.scale + TRANSFORM.x), Math.floor(cornerPoint.z * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - last = cornerPoint - } - - if (last && !(last.x === gridPoint.x && last.z === gridPoint.z)) { - ctx.fillRect(Math.floor(gridPoint.x * TRANSFORM.scale + TRANSFORM.x), Math.floor(gridPoint.z * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - } - last = gridPoint - } - } - - const fixed_line_width = Math.max(Math.ceil(LINE_WIDTH * FIXED_WIDTH), Math.ceil(LINE_WIDTH * TRANSFORM.scale)) - const fixed_square_width = Math.max(FIXED_WIDTH, Math.ceil(TRANSFORM.scale)) - if (p1 && c1) { - ctx.lineWidth = fixed_line_width - ctx.beginPath() - ctx.moveTo((p1.x + .5) * TRANSFORM.scale + TRANSFORM.x, (p1.z + .5) * TRANSFORM.scale + TRANSFORM.y) - ctx.lineTo((c1.x + .5) * TRANSFORM.scale + TRANSFORM.x, (c1.z + .5) * TRANSFORM.scale + TRANSFORM.y) - ctx.strokeStyle = "#0000ff" - ctx.stroke() - - ctx.fillStyle = "#ff0000" - ctx.fillRect(Math.floor((p1.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((p1.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) - - ctx.fillStyle = "#0000ff" - ctx.fillRect(Math.floor((c1.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((c1.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) - } - - if (p2 && c2) { - ctx.lineWidth = fixed_line_width - ctx.beginPath() - ctx.moveTo((p2.x + .5) * TRANSFORM.scale + TRANSFORM.x, (p2.z + .5) * TRANSFORM.scale + TRANSFORM.y) - ctx.lineTo((c2.x + .5) * TRANSFORM.scale + TRANSFORM.x, (c2.z + .5) * TRANSFORM.scale + TRANSFORM.y) - ctx.strokeStyle = "#0000ff" - ctx.stroke() - - ctx.fillStyle = "#ff0000" - ctx.fillRect(Math.floor((p2.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((p2.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) - - ctx.fillStyle = "#0000ff" - ctx.fillRect(Math.floor((c2.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((c2.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) - } - - for (const c of CIRCLE.circles) { - const cx = c.x - const cy = c.z - const radius = c.radius - - if (CIRCLE.focus === c) { - ctx.fillStyle = "#ff0000" - } else { - ctx.fillStyle = "#0000ff" - } - - ctx.fillRect(Math.floor((c.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((c.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) - - // 4-connected circle algorithm (in-place, no storage) - let x = radius; - let y = 0; - - ctx.fillStyle = "#918470" - while (x > 0 && y <= radius) { - // Reflect this point into all 8 octants - ctx.fillRect(Math.floor((cx + x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - ctx.fillRect(Math.floor((cx + y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - ctx.fillRect(Math.floor((cx - y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - ctx.fillRect(Math.floor((cx - x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - ctx.fillRect(Math.floor((cx - x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - ctx.fillRect(Math.floor((cx - y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - ctx.fillRect(Math.floor((cx + y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - ctx.fillRect(Math.floor((cx + x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) - - // Choose direction (up or left) - const errUp = Math.abs(x ** 2 + (y + 1) ** 2 - radius * radius); - const errLeft = Math.abs((x - 1) ** 2 + y ** 2 - radius * radius); - - if (errUp < errLeft) { - y++; - } else { - x--; - } - } - } + CircleTool.render(ctx) ctx.restore() } diff --git a/components/Toolbar.ts b/components/Toolbar.ts index dc04193..ac86b45 100644 --- a/components/Toolbar.ts +++ b/components/Toolbar.ts @@ -1,13 +1,8 @@ -import { BACKGROUND, BEZIER, CIRCLE, GridPosition, markDirty, MIPMAP_LEVELS, MipmapImage } from "./Canvas.js" +import { BACKGROUND, markDirty, MIPMAP_LEVELS, MipmapImage } from "./Canvas.js" import { World } from "../world/World.js" -import { RailShape } from "../world/RailShape.js" -export function cubicBezier(p0: number, p1: number, p2: number, p3: number, t: number): number { - return (1 - t) ** 3 * p0 - + 3 * (1 - t) ** 2 * t * p1 - + 3 * (1 - t) * t ** 2 * p2 - + t ** 3 * p3; -} +import * as BezierTool from "./Tools/BezierTool.js" +import * as CircleTool from "./Tools/CircleTool.js" export enum ToolTypes { NS = "NS.png", @@ -63,40 +58,7 @@ export function Toolbar(WORLD: World) { const CommitButton = document.getElementById("commit") if (!CommitButton) throw new Error("#commit not found - unable to load commit button") CommitButton.onclick = () => { - const [p1, c1, c2, p2] = BEZIER.points - let last: GridPosition | null = null - - BEZIER.points = [null, null, null, null] - BEZIER.dragging = -1 - - if (!(p1 && c1 && c2 && p2)) return - - for (let t = 0; t <= 1; t += 0.0001) { - const x = Math.floor(cubicBezier(p1.x, c1.x, c2.x, p2.x, t) + 0.5) - const z = Math.floor(cubicBezier(p1.z, c1.z, c2.z, p2.z, t) + 0.5) - - const dx = last ? Math.abs(x - last.x) : null - const dz = last ? Math.abs(z - last.z) : null - - // Fill in corner-corner connection - if (last && dx === 1 && dz === 1) { - const gridPoint = { - x: last.x, - z: z - } as GridPosition - placeSmoothTrack(gridPoint, WORLD) - placeSmoothTrack(last, WORLD) - last = gridPoint - } - - const gridPoint = {x, z} as GridPosition - if (last && !(last.x === gridPoint.x && last.z === gridPoint.z)) { - placeSmoothTrack(gridPoint, WORLD) - placeSmoothTrack(last, WORLD) - } - last = gridPoint - } - markDirty() + BezierTool.commit(WORLD) } const UploadButton = document.getElementById("upload") @@ -147,19 +109,13 @@ export function Toolbar(WORLD: World) { const r = parseInt(radiusSlider.value) radiusValue.textContent = `R = ${radiusSlider.value}` - if (CIRCLE.focus) { - CIRCLE.focus.radius = r - markDirty() - } + CircleTool.setRadiusSelectedCircle(r) }) const DeleteButton = document.getElementById("delete") if (!DeleteButton) throw new Error("#delete not found - unable to load delete") DeleteButton.addEventListener("click", () => { - if (CIRCLE.focus) { - CIRCLE.circles = CIRCLE.circles.filter(c => c !== CIRCLE.focus) - CIRCLE.focus = null - } + CircleTool.deleteSelectedCircle() }) } @@ -213,24 +169,4 @@ function generate_mipmaps(img: MipmapImage) { } return mipmaps -} - -function placeSmoothTrack({x, z}: GridPosition, world: World) { - const above = `${x},${z - 1}` in world.grid; - const below = `${x},${z + 1}` in world.grid; - const left = `${x - 1},${z}` in world.grid; - const right = `${x + 1},${z}` in world.grid; - - let orientation = RailShape.NORTH_SOUTH - - if (above && below) orientation = RailShape.NORTH_SOUTH; - else if (left && right) orientation = RailShape.EAST_WEST; - else if (above && right) orientation = RailShape.NORTH_EAST; - else if (above && left) orientation = RailShape.NORTH_WEST; - else if (below && right) orientation = RailShape.SOUTH_EAST; - else if (below && left) orientation = RailShape.SOUTH_WEST; - else if (above || below) orientation = RailShape.NORTH_SOUTH; - else if (left || right) orientation = RailShape.EAST_WEST; - - world.grid[`${x},${z}`] = orientation } \ No newline at end of file diff --git a/components/Tools/BezierTool.ts b/components/Tools/BezierTool.ts new file mode 100644 index 0000000..1ba8913 --- /dev/null +++ b/components/Tools/BezierTool.ts @@ -0,0 +1,190 @@ +import { RailShape } from "../../world/RailShape.js"; +import { World } from "../../world/World.js"; +import { FIXED_WIDTH, GridPosition, LINE_WIDTH, markDirty, snap, TRANSFORM } from "../Canvas.js" + +const BEZIER = { + points: [ + null, + null, + null, + null, + ] as (GridPosition | null)[], + dragging: -1, +} + +export function mousedown(gridDecimalPos: GridPosition) { + const gridIntegerPos = snap(gridDecimalPos) + + if (BEZIER.points[0] === null) { // If first drag, set p1 and c1 + BEZIER.points[0] = gridIntegerPos + BEZIER.points[1] = gridIntegerPos + BEZIER.dragging = 1 + markDirty() + return + } else if (BEZIER.points[3] === null) { // If second drag, set p2 and c2 + BEZIER.points[3] = gridIntegerPos + BEZIER.points[2] = gridIntegerPos + BEZIER.dragging = 2 + markDirty() + return + } + + if (BEZIER.points[1] === null || BEZIER.points[2] === null) { + return + } + + const fixed_square_width_in_grid = Math.max(1, FIXED_WIDTH / TRANSFORM.scale) + for (let i = 0; i < 4; i++) { + const point = BEZIER.points[i] as GridPosition + const dist = Math.max(Math.abs(point.x + .5 - gridDecimalPos.x), Math.abs(point.z + .5 - gridDecimalPos.z)) + + if (dist < fixed_square_width_in_grid / 2) { + BEZIER.dragging = i + break + } + } +} + +export function mouseup() { + BEZIER.dragging = -1 +} + +export function mousemove(pos: GridPosition) { + const gridPos = snap(pos) + BEZIER.points[BEZIER.dragging] = gridPos + markDirty() +} + +export function render(ctx: CanvasRenderingContext2D) { + const [p1, c1, c2, p2] = BEZIER.points + + let last: GridPosition | null = null + + if (p1 && c1 && c2 && p2) { + ctx.fillStyle = "#918470" + ctx.lineWidth = LINE_WIDTH * TRANSFORM.scale + ctx.beginPath() + for (let t = 0; t <= 1; t += 0.0001) { + const x = cubicBezier(p1.x, c1.x, c2.x, p2.x, t) + .5 + const z = cubicBezier(p1.z, c1.z, c2.z, p2.z, t) + .5 + + if (t === 0) ctx.moveTo(x * TRANSFORM.scale + TRANSFORM.x, z * TRANSFORM.scale + TRANSFORM.y); + else ctx.lineTo(x * TRANSFORM.scale + TRANSFORM.x, z * TRANSFORM.scale + TRANSFORM.y); + + const gridPoint = snap({x, z}) + + const dx = last ? Math.abs(gridPoint.x - last.x) : null + const dz = last ? Math.abs(gridPoint.z - last.z) : null + + if (last && dx === 1 && dz === 1) { + const cornerPoint: GridPosition = { + x: last.x, + z: gridPoint.z + } + ctx.fillRect(Math.floor(cornerPoint.x * TRANSFORM.scale + TRANSFORM.x), Math.floor(cornerPoint.z * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + last = cornerPoint + } + + if (last && !(last.x === gridPoint.x && last.z === gridPoint.z)) { + ctx.fillRect(Math.floor(gridPoint.x * TRANSFORM.scale + TRANSFORM.x), Math.floor(gridPoint.z * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + } + last = gridPoint + } + } + + const fixed_line_width = Math.max(Math.ceil(LINE_WIDTH * FIXED_WIDTH), Math.ceil(LINE_WIDTH * TRANSFORM.scale)) + const fixed_square_width = Math.max(FIXED_WIDTH, Math.ceil(TRANSFORM.scale)) + if (p1 && c1) { + ctx.lineWidth = fixed_line_width + ctx.beginPath() + ctx.moveTo((p1.x + .5) * TRANSFORM.scale + TRANSFORM.x, (p1.z + .5) * TRANSFORM.scale + TRANSFORM.y) + ctx.lineTo((c1.x + .5) * TRANSFORM.scale + TRANSFORM.x, (c1.z + .5) * TRANSFORM.scale + TRANSFORM.y) + ctx.strokeStyle = "#0000ff" + ctx.stroke() + + ctx.fillStyle = "#ff0000" + ctx.fillRect(Math.floor((p1.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((p1.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) + + ctx.fillStyle = "#0000ff" + ctx.fillRect(Math.floor((c1.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((c1.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) + } + + if (p2 && c2) { + ctx.lineWidth = fixed_line_width + ctx.beginPath() + ctx.moveTo((p2.x + .5) * TRANSFORM.scale + TRANSFORM.x, (p2.z + .5) * TRANSFORM.scale + TRANSFORM.y) + ctx.lineTo((c2.x + .5) * TRANSFORM.scale + TRANSFORM.x, (c2.z + .5) * TRANSFORM.scale + TRANSFORM.y) + ctx.strokeStyle = "#0000ff" + ctx.stroke() + + ctx.fillStyle = "#ff0000" + ctx.fillRect(Math.floor((p2.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((p2.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) + + ctx.fillStyle = "#0000ff" + ctx.fillRect(Math.floor((c2.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((c2.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) + } +} + +export function commit(WORLD: World) { + const [p1, c1, c2, p2] = BEZIER.points + let last: GridPosition | null = null + + BEZIER.points = [null, null, null, null] + BEZIER.dragging = -1 + + if (!(p1 && c1 && c2 && p2)) return + + for (let t = 0; t <= 1; t += 0.0001) { + const x = Math.floor(cubicBezier(p1.x, c1.x, c2.x, p2.x, t) + 0.5) + const z = Math.floor(cubicBezier(p1.z, c1.z, c2.z, p2.z, t) + 0.5) + + const dx = last ? Math.abs(x - last.x) : null + const dz = last ? Math.abs(z - last.z) : null + + // Fill in corner-corner connection + if (last && dx === 1 && dz === 1) { + const gridPoint = { + x: last.x, + z: z + } as GridPosition + placeSmoothTrack(gridPoint, WORLD) + placeSmoothTrack(last, WORLD) + last = gridPoint + } + + const gridPoint = {x, z} as GridPosition + if (last && !(last.x === gridPoint.x && last.z === gridPoint.z)) { + placeSmoothTrack(gridPoint, WORLD) + placeSmoothTrack(last, WORLD) + } + last = gridPoint + } + markDirty() +} + +function placeSmoothTrack({x, z}: GridPosition, world: World) { + const above = `${x},${z - 1}` in world.grid; + const below = `${x},${z + 1}` in world.grid; + const left = `${x - 1},${z}` in world.grid; + const right = `${x + 1},${z}` in world.grid; + + let orientation = RailShape.NORTH_SOUTH + + if (above && below) orientation = RailShape.NORTH_SOUTH; + else if (left && right) orientation = RailShape.EAST_WEST; + else if (above && right) orientation = RailShape.NORTH_EAST; + else if (above && left) orientation = RailShape.NORTH_WEST; + else if (below && right) orientation = RailShape.SOUTH_EAST; + else if (below && left) orientation = RailShape.SOUTH_WEST; + else if (above || below) orientation = RailShape.NORTH_SOUTH; + else if (left || right) orientation = RailShape.EAST_WEST; + + world.grid[`${x},${z}`] = orientation +} + +function cubicBezier(p0: number, p1: number, p2: number, p3: number, t: number): number { + return (1 - t) ** 3 * p0 + + 3 * (1 - t) ** 2 * t * p1 + + 3 * (1 - t) * t ** 2 * p2 + + t ** 3 * p3; +} \ No newline at end of file diff --git a/components/Tools/CircleTool.ts b/components/Tools/CircleTool.ts new file mode 100644 index 0000000..26777a0 --- /dev/null +++ b/components/Tools/CircleTool.ts @@ -0,0 +1,119 @@ +import { FIXED_WIDTH, GridPosition, markDirty, snap, TRANSFORM } from "../Canvas.js" + +interface CircleType { + x: number, + z: number, + radius: number +} +export const CIRCLE = { + circles: [] as CircleType[], + dragging: -1, + focus: null as (CircleType | null) +} + +export function mousedown(gridDecimalPos: GridPosition) { + const gridPos = snap(gridDecimalPos) + + const radiusSlider = document.getElementById("radiusSlider") as HTMLInputElement + const radiusValue = document.getElementById("radiusValue") + if (!radiusSlider) throw new Error("#radiusSlider not found - unable to load radiusSlider") + if (!radiusValue) throw new Error("#radiusValue not found - unable to load radiusValue") + + let found = false + const fixed_square_width_in_grid = Math.max(1, FIXED_WIDTH / TRANSFORM.scale) + for (let i = 0; i < CIRCLE.circles.length; i++) { + const point = CIRCLE.circles[i] + const dist = Math.max(Math.abs(point.x + .5 - gridDecimalPos.x), Math.abs(point.z + .5 - gridDecimalPos.z)) + + if (dist < fixed_square_width_in_grid / 2) { + CIRCLE.dragging = i + CIRCLE.focus = CIRCLE.circles[i] + radiusSlider.value = CIRCLE.circles[i].radius.toString() + radiusValue.textContent = `R = ${CIRCLE.circles[i].radius}` + found = true + break + } + } + + if (!found) { + const c = { + x: gridPos.x, + z: gridPos.z, + radius: parseInt(radiusSlider.value, 10), + } + CIRCLE.dragging = CIRCLE.circles.length + CIRCLE.focus = c + CIRCLE.circles.push(c) + markDirty() + } +} + +export function mouseup() { + CIRCLE.dragging = -1 +} + +export function mousemove(gridDecimalPos: GridPosition) { + const gridIntegerPos = snap(gridDecimalPos) + const c = CIRCLE.circles[CIRCLE.dragging] + c.x = gridIntegerPos.x + c.z = gridIntegerPos.z + markDirty() +} + +export function render(ctx: CanvasRenderingContext2D) { + const fixed_square_width = Math.max(FIXED_WIDTH, Math.ceil(TRANSFORM.scale)) + for (const c of CIRCLE.circles) { + const cx = c.x + const cy = c.z + const radius = c.radius + + if (CIRCLE.focus === c) { + ctx.fillStyle = "#ff0000" + } else { + ctx.fillStyle = "#0000ff" + } + + ctx.fillRect(Math.floor((c.x + .5) * TRANSFORM.scale + TRANSFORM.x - fixed_square_width / 2), Math.floor((c.z + .5) * TRANSFORM.scale + TRANSFORM.y - fixed_square_width / 2), fixed_square_width, fixed_square_width) + + // 4-connected circle algorithm (in-place, no storage) + let x = radius; + let y = 0; + + ctx.fillStyle = "#918470" + while (x > 0 && y <= radius) { + // Reflect this point into all 8 octants + ctx.fillRect(Math.floor((cx + x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx + y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx - y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx - x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy + y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx - x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx - y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx + y) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - x) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + ctx.fillRect(Math.floor((cx + x) * TRANSFORM.scale + TRANSFORM.x), Math.floor((cy - y) * TRANSFORM.scale + TRANSFORM.y), Math.ceil(TRANSFORM.scale), Math.ceil(TRANSFORM.scale)) + + // Choose direction (up or left) + const errUp = Math.abs(x ** 2 + (y + 1) ** 2 - radius * radius); + const errLeft = Math.abs((x - 1) ** 2 + y ** 2 - radius * radius); + + if (errUp < errLeft) { + y++; + } else { + x--; + } + } + } +} + +export function setRadiusSelectedCircle(radius: number) { + if (CIRCLE.focus) { + CIRCLE.focus.radius = radius + markDirty() + } +} + +export function deleteSelectedCircle() { + if (CIRCLE.focus) { + CIRCLE.circles = CIRCLE.circles.filter(c => c !== CIRCLE.focus) + CIRCLE.focus = null + } +} \ No newline at end of file diff --git a/components/Tools/DrawTool.ts b/components/Tools/DrawTool.ts new file mode 100644 index 0000000..93a6d3d --- /dev/null +++ b/components/Tools/DrawTool.ts @@ -0,0 +1,82 @@ +import { World } from "../../world/World.js" +import { GridPosition, markDirty, MOUSE, snap } from "../Canvas.js" +import { selectedTool, ToolTypes } from "../Toolbar.js" + +export function mousedown(gridDecimalPos: GridPosition, WORLD: World) { + if (!MOUSE.lastPos) { + placeOrErase(snap(gridDecimalPos), WORLD) + } else { + interpolateLine(snap(MOUSE.lastPos), snap(gridDecimalPos), WORLD) + } +} + +export function mouseup() {} + +export const mousemove = mousedown + +/** + * Bresenham's Line Algorithm + */ +function interpolateLine(pos0: GridPosition, pos1: GridPosition, WORLD: World) { + let x = pos0.x + let z = pos0.z + const x1 = pos1.x + const z1 = pos1.z + const dx = Math.abs(x1 - x); + const dz = Math.abs(z1 - z); + const sx = x < x1 ? 1 : -1; + const sz = z < z1 ? 1 : -1; + let err = dx - dz; + + while (true) { + placeOrErase({x, z}, WORLD); + if (x === x1 && z === z1) break; + const e2 = 2 * err; + if (e2 > -dz) { + err -= dz; + x += sx; + } + if (e2 < dx) { + err += dx; + z += sz; + } + } +} + +function placeOrErase(pos: GridPosition, WORLD: World) { + switch(selectedTool) { + case ToolTypes.ERASE: + erase(pos, WORLD) + break + case ToolTypes.NS: + place(pos, WORLD, "NS") + break + case ToolTypes.EW: + place(pos, WORLD, "EW") + break + case ToolTypes.NE: + place(pos, WORLD, "NE") + break + case ToolTypes.SE: + place(pos, WORLD, "SE") + break + case ToolTypes.NW: + place(pos, WORLD, "NW") + break + case ToolTypes.SW: + place(pos, WORLD, "SW") + break + } +} + +function place({x, z}: GridPosition, WORLD: World, shape: string) { + const key = `${x},${z}` + WORLD.grid[key] = shape + markDirty() +} + +function erase({x, z}: GridPosition, WORLD: World) { + const key = `${x},${z}` + delete WORLD.grid[key] + markDirty() +} \ No newline at end of file From f11e00f84cd5c0c7fc5b017082940e78fc0f36a0 Mon Sep 17 00:00:00 2001 From: SequentialEntropy Date: Tue, 16 Sep 2025 10:50:24 +0100 Subject: [PATCH 3/3] Add radius label to Bezier tool --- components/Tools/BezierTool.ts | 49 ++++++++++++++++++++++++++++++++++ index.html | 1 + 2 files changed, 50 insertions(+) diff --git a/components/Tools/BezierTool.ts b/components/Tools/BezierTool.ts index 1ba8913..4a77fb5 100644 --- a/components/Tools/BezierTool.ts +++ b/components/Tools/BezierTool.ts @@ -53,6 +53,14 @@ export function mousemove(pos: GridPosition) { const gridPos = snap(pos) BEZIER.points[BEZIER.dragging] = gridPos markDirty() + + const [p1, c1, c2, p2] = BEZIER.points + if (p1 && c1 && c2 && p2) { + const curvatureValue = document.getElementById("curvatureValue") + if (curvatureValue) { + curvatureValue.textContent = `C = ${Math.floor(minRadiusOfCurvature(p1, c1, c2, p2, 100))}` + } + } } export function render(ctx: CanvasRenderingContext2D) { @@ -187,4 +195,45 @@ function cubicBezier(p0: number, p1: number, p2: number, p3: number, t: number): + 3 * (1 - t) ** 2 * t * p1 + 3 * (1 - t) * t ** 2 * p2 + t ** 3 * p3; +} + +function bezierDerivatives(p0: GridPosition, p1: GridPosition, p2: GridPosition, p3: GridPosition, t: number) { + // First derivative + const dx = 3 * (1 - t) ** 2 * (p1.x - p0.x) + + 6 * (1 - t) * t * (p2.x - p1.x) + + 3 * t ** 2 * (p3.x - p2.x); + + const dz = 3 * (1 - t) ** 2 * (p1.z - p0.z) + + 6 * (1 - t) * t * (p2.z - p1.z) + + 3 * t ** 2 * (p3.z - p2.z); + + // Second derivative + const ddx = 6 * (1 - t) * (p2.x - 2 * p1.x + p0.x) + + 6 * t * (p3.x - 2 * p2.x + p1.x); + + const ddz = 6 * (1 - t) * (p2.z - 2 * p1.z + p0.z) + + 6 * t * (p3.z - 2 * p2.z + p1.z); + + return { dx, dz, ddx, ddz }; +} + +function radiusOfCurvature(p0: GridPosition, p1: GridPosition, p2: GridPosition, p3: GridPosition, t: number): number { + const { dx, dz, ddx, ddz } = bezierDerivatives(p0, p1, p2, p3, t); + + const numerator = Math.pow(dx * dx + dz * dz, 1.5); + const denominator = Math.abs(dx * ddz - dz * ddx); + + if (denominator === 0) return Infinity; // Straight line section + + return numerator / denominator; +} + +function minRadiusOfCurvature(p0: GridPosition, p1: GridPosition, p2: GridPosition, p3: GridPosition, samples = 100): number { + let minR = Infinity; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const R = radiusOfCurvature(p0, p1, p2, p3, t); + if (R < minR) minR = R; + } + return minR; } \ No newline at end of file diff --git a/index.html b/index.html index e9e19d9..c5e98aa 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@

R = 25

+

C = 0