UNPKG

turtleman

Version:

A JavaScript library for creating SVG graphics using turtle graphics programming, inspired by Logo. Control a virtual turtle to draw geometric shapes and designs programmatically.

1,093 lines (974 loc) 28.7 kB
"use strict"; const EPSILON = 0.0001; const inside_rect = (a, minX, maxX, minY, maxY) => { return a.x >= minX && a.x <= maxX && a.y >= minY && a.y <= maxY; }; const find_segment_intersect = (l1p1, l1p2, l2p1, l2p2) => { const d = (l2p2.y - l2p1.y) * (l1p2.x - l1p1.x) - (l2p2.x - l2p1.x) * (l1p2.y - l1p1.y); const n_a = (l2p2.x - l2p1.x) * (l1p1.y - l2p1.y) - (l2p2.y - l2p1.y) * (l1p1.x - l2p1.x); const n_b = (l1p2.x - l1p1.x) * (l1p1.y - l2p1.y) - (l1p2.y - l1p1.y) * (l1p1.x - l2p1.x); if (d === 0) { return false; } const ua = n_a / d; const ub = n_b / d; if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) { return { x: l1p1.x + ua * (l1p2.x - l1p1.x), y: l1p1.y + ua * (l1p2.y - l1p1.y), }; } return false; }; function clipPathsRect(paths, minX, maxX, minY, maxY) { let clippedPaths = []; let rectCorners = [ { x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY }, { x: minX, y: minY }, ]; for (let path of paths) { if (!path || path.length === 0) continue; let currentClippedPath = []; let prevInside = false; for (let i = 0; i < path.length - 1; i++) { let pointA = path[i]; let pointB = path[i + 1]; let insideA = inside_rect(pointA, minX, maxX, minY, maxY); let insideB = inside_rect(pointB, minX, maxX, minY, maxY); if (insideA) { if (!prevInside || i === 0) { if (currentClippedPath.length > 0) { clippedPaths.push(currentClippedPath); } currentClippedPath = []; currentClippedPath.push(pointA); } if (insideB) { currentClippedPath.push(pointB); prevInside = true; continue; } } // At least one point is outside, or a segment crosses the boundary. let segmentIntersectsBoundary = false; let intersectionPoint = null; for (let j = 0; j < rectCorners.length - 1; j++) { let cornerA = rectCorners[j]; let cornerB = rectCorners[j + 1]; let intersection = find_segment_intersect( pointA, pointB, cornerA, cornerB ); if (intersection) { segmentIntersectsBoundary = true; intersectionPoint = intersection; if ( currentClippedPath.length === 0 || !( Math.abs( currentClippedPath[currentClippedPath.length - 1].x - intersection.x ) < EPSILON && Math.abs( currentClippedPath[currentClippedPath.length - 1].y - intersection.y ) < EPSILON ) ) { currentClippedPath.push(intersectionPoint); } } } if (!insideA && insideB) { currentClippedPath.push(pointB); } else if (!segmentIntersectsBoundary && !insideA && !insideB) { if (currentClippedPath.length > 0) { clippedPaths.push(currentClippedPath); } currentClippedPath = []; } prevInside = insideB; } if (currentClippedPath.length > 0) { clippedPaths.push(currentClippedPath); } } return clippedPaths.filter((p) => p.length > 1); } /** * Turtleman - A JavaScript library for creating SVG graphics using turtle graphics programming * * Turtleman lets you control a virtual "turtle" that moves around an SVG canvas, leaving a trail behind it. * This makes it intuitive to create geometric shapes and designs programmatically. * * @example * ```javascript * const turtle = new Turtleman({ * width: 600, * height: 400, * strokeColour: "blue", * strokeWidth: 2 * }); * * // Draw a square * for (let i = 0; i < 4; i++) { * turtle.forward(100); * turtle.right(90); * } * ``` */ export class Turtleman { /** * Creates a new Turtleman instance * * @param {Object} options - Configuration options for the turtle * @param {number} [options.width=500] - Width of the SVG canvas * @param {number} [options.height=500] - Height of the SVG canvas * @param {Object} [options.startPosition] - Initial coordinates of the turtle * @param {number} [options.startPosition.x] - Initial x coordinate (defaults to width/2) * @param {number} [options.startPosition.y] - Initial y coordinate (defaults to height/2) * @param {number} [options.heading=0] - Initial direction of the turtle * @param {boolean} [options.penDown=true] - Whether the pen is down for drawing * @param {string} [options.strokeColour="black"] - Color of the drawn lines * @param {number} [options.strokeWidth=2] - Thickness of the drawn lines * @param {string} [options.angleType="degrees"] - "degrees" or "radians" for angle units * @param {string} [options.filename="turtleman_drawing"] - Default filename for SVG downloads * @param {number} [options.precision=2] - Number of decimal places for coordinate rounding * @param {string} [options.mode="contiguous"] - "contiguous" or "discrete" rendering mode */ constructor({ width = 500, height = 500, startPosition = { x: width / 2, y: height / 2 }, heading = 0, penDown = true, strokeColour = "black", strokeWidth = 2, angleType = "degrees", filename = "turtleman_drawing", precision = 2, mode = "contiguous", // 'contiguous' or 'discrete' crop = true, offset = { x: 0, y: 0 }, } = {}) { // Create a wrapper div to contain the SVG this.wrapperDiv = document.createElement("div"); this.wrapperDiv.classList.add("turtleman-container"); this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); this.svg.setAttribute("width", width); this.svg.setAttribute("height", height); this.svg.setAttribute("viewBox", `0 0 ${width} ${height}`); this.wrapperDiv.appendChild(this.svg); // Create the download link this.downloadLink = document.createElement("a"); this.downloadLink.textContent = "Download SVG"; this.downloadLink.href = "#"; this.wrapperDiv.appendChild(this.downloadLink); this.reset({ width, height, startPosition, heading, penDown, strokeColour, strokeWidth, angleType, precision, mode, crop, offset, }); this.filename = filename; this.addDownloadListeners(); } // ============================================================================ // CORE PROPERTIES AND GETTERS/SETTERS // ============================================================================ /** * Gets the wrapper div containing the SVG and download link * @returns {HTMLElement} The wrapper div element */ get element() { return this.wrapperDiv; } /** * Gets the SVG element * @returns {HTMLElement} The SVG element */ get svg() { return this._svg; } /** * Sets the SVG element * @param {HTMLElement} value - The SVG element */ set svg(value) { this._svg = value; } /** * Gets the current heading of the turtle * @returns {number} The current heading (normalized to 0-360 degrees or 0-2π radians) */ get heading() { return this._heading; } /** * Sets the heading of the turtle * @param {number} value - The new heading value */ set heading(value) { if (this.angleType === "radians") { const TAU = Math.PI * 2; this._heading = ((value % TAU) + TAU) % TAU; } else { this._heading = ((value % 360) + 360) % 360; } } /** * Gets the current heading in radians * @returns {number} The current heading in radians */ get radians() { if (this.angleType === "radians") { return this.heading; } return (this.heading * Math.PI) / 180; } /** * Sets the heading using radians * @param {number} value - The new heading in radians */ set radians(value) { if (this.angleType === "radians") { this.heading = value; } else { this.heading = (value * 180) / Math.PI; } } /** * Gets all drawing commands (read-only) * @returns {Array} Array of all drawing commands */ get commands() { return [...this.drawingCommands]; } /** * Gets whether coordinate capture mode is currently active * @returns {boolean} True if capture mode is active */ get capturing() { return this.isCapturing; } /** * Gets groups of connected lines for contiguous rendering mode * @returns {Array} Array of line groups */ get lineGroups() { const pathGroups = new Map(); this.drawingCommands.forEach((command) => { if (command.type === "line") { const key = `${command.strokeColour}-${command.strokeWidth}-${command.i}`; if (!pathGroups.has(key)) { pathGroups.set(key, { strokeColour: command.strokeColour, strokeWidth: command.strokeWidth, index: command.i, points: [], }); } const group = pathGroups.get(key); if (group.points.length === 0) { group.points.push({ x: command.from.x, y: command.from.y }); } group.points.push({ x: command.to.x, y: command.to.y }); } }); return Array.from(pathGroups.values()); } // ============================================================================ // STATE MANAGEMENT // ============================================================================ /** * Resets the turtle's state to its initial configuration or new options * * @param {Object} [options] - New configuration options (uses current if not provided) * @param {number} [options.width] - Width of the SVG canvas * @param {number} [options.height] - Height of the SVG canvas * @param {Object} [options.startPosition] - Initial coordinates * @param {number} [options.heading] - Initial direction * @param {boolean} [options.penDown] - Whether the pen is down * @param {string} [options.strokeColour] - Color of drawn lines * @param {number} [options.strokeWidth] - Thickness of lines * @param {string} [options.angleType] - "degrees" or "radians" * @param {number} [options.precision] - Decimal places for rounding * @param {string} [options.mode] - "contiguous" or "discrete" */ reset( { width, height, startPosition, heading, penDown, strokeColour, strokeWidth, angleType, precision, mode, crop, offset, } = this.initializedProps ) { this.width = width; this.height = height; this.position = startPosition; this.home = { ...startPosition }; this.heading = heading; this.penDown = penDown; this.strokeColour = strokeColour; this.strokeWidth = strokeWidth; this.angleType = angleType; this.precision = precision; this.mode = mode; this.lineIndex = 0; this.crop = crop; this.offset = offset; this.isCapturing = false; this.capturedPoints = []; this.initializedProps = { width, height, startPosition, heading, penDown, strokeColour, strokeWidth, angleType, precision, mode, crop, offset, }; // Update dimensions on both SVG and wrapper for consistency this.svg.setAttribute("width", width); this.svg.setAttribute("height", height); this.svg.setAttribute("viewBox", `0 0 ${width} ${height}`); // Clear the drawing commands array instead of SVG directly this.drawingCommands = []; this.currentPathCommands = []; this.needsRender = true; // Clear the SVG while (this.svg.firstChild) { this.svg.removeChild(this.svg.firstChild); } } /** * Clears all drawing commands and re-renders */ clearDrawing() { this.drawingCommands = []; this.needsRender = true; this.render(); } // ============================================================================ // UTILITY METHODS // ============================================================================ /** * Rounds a number to the specified precision * @param {number} n - The number to round * @returns {number} The rounded number * @private */ round(n) { const factor = this.precision * 10; return Math.floor(n * factor) / factor; } /** * Adds a drawing command to the internal command array * @param {Object} command - The drawing command to add * @private */ addDrawingCommand(command) { this.drawingCommands.push(command); this.needsRender = true; } /** * Starts capturing coordinates instead of drawing * When capture mode is active, movement commands will collect points * in a temporary array instead of adding them to the drawing commands. */ startCapture() { this.isCapturing = true; this.capturedPoints = []; } /** * Ends coordinate capture and returns the collected points * @returns {Array} Array of captured coordinate objects {x, y} */ endCapture() { this.isCapturing = false; const points = [...this.capturedPoints]; this.capturedPoints = []; return points; } /** * Sets the offset of the toy * @param {number} x - X offset * @param {number} y - Y offset */ setOffset(x, y) { this.offset = { x, y }; } // ============================================================================ // DRAWING METHODS // ============================================================================ /** * Draws a line between two points * @param {Object} a - First point {x, y} * @param {Object} b - Second point {x, y} * @private */ drawLine(a, b) { if (this.isCapturing) { // Add points to captured array instead of drawing if (this.capturedPoints.length === 0) { this.capturedPoints.push({ x: a.x, y: a.y }); } this.capturedPoints.push({ x: b.x, y: b.y }); } else { this.addDrawingCommand({ type: "line", from: { x: a.x + this.offset.x, y: a.y + this.offset.y }, to: { x: b.x + this.offset.x, y: b.y + this.offset.y }, strokeColour: this.strokeColour, strokeWidth: this.strokeWidth, i: this.lineIndex, }); } } /** * Moves the turtle forward by the specified distance * @param {number} distance - Distance to move forward * @throws {Error} If distance is not a valid number */ forward(distance) { if (typeof distance !== "number" || isNaN(distance)) { throw new Error("Distance must be a valid number"); } const newPos = { ...this.position }; newPos.x = this.position.x + distance * Math.cos(this.radians); newPos.y = this.position.y + distance * Math.sin(this.radians); if (this.penDown) { this.drawLine(this.position, newPos); } this.position = newPos; } /** * Shorthand for forward() * @param {number} distance - Distance to move forward */ fw(distance) { this.forward(distance); } /** * Moves the turtle backward by the specified distance * @param {number} distance - Distance to move backward * @throws {Error} If distance is not a valid number */ backward(distance) { if (typeof distance !== "number" || isNaN(distance)) { throw new Error("Distance must be a valid number"); } const newPos = { ...this.position }; newPos.x = this.position.x - distance * Math.cos(this.radians); newPos.y = this.position.y - distance * Math.sin(this.radians); if (this.penDown) { this.drawLine(this.position, newPos); } this.position = newPos; } /** * Shorthand for backward() * @param {number} distance - Distance to move backward */ bk(distance) { this.backward(distance); } /** * Moves the turtle to the specified coordinates, drawing a line if pen is down * @param {number} x - X coordinate * @param {number} y - Y coordinate * @throws {Error} If coordinates are not valid numbers */ goto(x, y) { if ( typeof x !== "number" || isNaN(x) || typeof y !== "number" || isNaN(y) ) { throw new Error("Coordinates must be valid numbers"); } const newPos = { ...this.position }; newPos.x = x; newPos.y = y; if (this.penDown) { this.drawLine(this.position, newPos); } this.position = newPos; } /** * Shorthand for goto() * @param {number} x - X coordinate * @param {number} y - Y coordinate */ setxy(x, y) { this.goto(x, y); } /** * Moves the turtle by the specified offset, drawing a line if pen is down * @param {number} x - X offset * @param {number} y - Y offset * @throws {Error} If offsets are not valid numbers */ moveby(x, y) { if ( typeof x !== "number" || isNaN(x) || typeof y !== "number" || isNaN(y) ) { throw new Error("Coordinates must be valid numbers"); } const newPos = { ...this.position }; newPos.x += x; newPos.y += y; if (this.penDown) { this.drawLine(this.position, newPos); } this.position = newPos; } mb(x, y) { this.moveby(x, y); } /** * Moves the turtle to its home position, drawing a line if pen is down */ home() { const newPos = { ...this.home }; if (this.penDown) { this.drawLine(this.position, newPos); } this.position = newPos; } /** * Shorthand for home() */ hm() { this.home(); } /** * Moves the turtle to the specified coordinates without drawing * @param {number} x - X coordinate * @param {number} y - Y coordinate * @throws {Error} If coordinates are not valid numbers */ jumpto(x, y) { if ( typeof x !== "number" || isNaN(x) || typeof y !== "number" || isNaN(y) ) { throw new Error("Coordinates must be valid numbers"); } const isPenDown = this.penDown; this.pu(); this.goto(x, y); if (isPenDown) { this.pd(); } } /** * Shorthand for jumpto() * @param {number} x - X coordinate * @param {number} y - Y coordinate */ jump(x, y) { this.jumpto(x, y); } /** * Turns the turtle right by the specified angle * @param {number} angle - Angle to turn right * @throws {Error} If angle is not a valid number */ right(angle) { if (typeof angle !== "number" || isNaN(angle)) { throw new Error("Angle must be a valid number"); } this.heading += angle; } /** * Shorthand for right() * @param {number} angle - Angle to turn right */ rt(angle) { this.right(angle); } /** * Turns the turtle left by the specified angle * @param {number} angle - Angle to turn left * @throws {Error} If angle is not a valid number */ left(angle) { if (typeof angle !== "number" || isNaN(angle)) { throw new Error("Angle must be a valid number"); } this.heading -= angle; } /** * Shorthand for left() * @param {number} angle - Angle to turn left */ lt(angle) { this.left(angle); } /** * Sets the turtle's absolute heading * @param {number} angle - New heading angle * @throws {Error} If angle is not a valid number */ setheading(angle) { if (typeof angle !== "number" || isNaN(angle)) { throw new Error("Heading must be a valid number"); } this.heading = angle; } /** * Shorthand for setheading() * @param {number} angle - New heading angle */ seth(angle) { this.setheading(angle); } /** * Sets the pen state * @param {boolean} isDown - Whether the pen should be down * @throws {Error} If isDown is not a boolean */ setPenDown(isDown) { if (typeof isDown !== "boolean") { throw new Error("Pen state must be a boolean"); } if (isDown) { this.penDown = true; } else { this.penDown = false; this.lineIndex++; } } /** * Lifts the pen (stops drawing) */ pu() { this.setPenDown(false); } /** * Shorthand for setPenDown(false) */ penup() { this.setPenDown(false); } /** * Puts the pen down (starts drawing) */ pd() { this.setPenDown(true); } /** * Shorthand for setPenDown(true) */ pendown() { this.setPenDown(true); } // ============================================================================ // COMMAND PROCESSING // ============================================================================ /** * Processes a single string command * @param {string} commandLine - The command string to process */ processCommand(commandLine) { const parts = commandLine.toLowerCase().trim().split(/\s+/); const command = parts[0]; const args = parts.slice(1).map(Number); try { switch (command) { case "forward": case "fd": this.forward(args[0]); break; case "backward": case "bk": this.backward(args[0]); break; case "goto": case "setxy": this.goto(args[0], args[1]); break; case "home": case "hm": this.home(); break; case "jump": case "jumpto": this.jumpto(args[0], args[1]); break; case "setheading": case "seth": this.setheading(args[0]); break; case "right": case "rt": this.right(args[0]); break; case "left": case "lt": this.left(args[0]); break; case "penup": case "pu": this.setPenDown(false); break; case "pendown": case "pd": this.setPenDown(true); break; case "setcolor": case "setcolour": case "color": case "colour": case "sc": this.strokeColour = parts[1] || "black"; break; case "setwidth": case "width": case "sw": this.strokeWidth = args[0] || 2; break; default: console.warn(`Unknown command: ${commandLine}`); } } catch (error) { console.warn( `Error processing command "${commandLine}": ${error.message}, skipping.` ); } } /** * Parses and executes a multiline string of turtle commands * @param {string} commandInput - Multiline string of commands */ drawCommands(commandInput) { this.reset(this.initializedProps); const commands = commandInput.split("\n"); for (const commandLine of commands) { if (commandLine.trim() !== "") { this.processCommand(commandLine); } } } // ============================================================================ // RENDERING // ============================================================================ /** * Renders all drawing commands to SVG */ render(onlyNew = false) { if (!this.needsRender) return; // Clear the SVG while (this.svg.firstChild) { this.svg.removeChild(this.svg.firstChild); } if (this.mode === "discrete") { this.renderDiscreteMode(onlyNew); } else { this.renderContiguousMode(onlyNew); } this.needsRender = false; } /** * Renders in discrete mode (each line as separate SVG element) * @private */ renderDiscreteMode(onlyNew = false) { this.drawingCommands.forEach((command) => { if (command.type === "line") { const line = document.createElementNS( "http://www.w3.org/2000/svg", "line" ); line.setAttribute("x1", this.round(command.from.x)); line.setAttribute("y1", this.round(command.from.y)); line.setAttribute("x2", this.round(command.to.x)); line.setAttribute("y2", this.round(command.to.y)); line.setAttribute("stroke", command.strokeColour); line.setAttribute("stroke-width", command.strokeWidth); this.svg.appendChild(line); } }); } /** * Renders in contiguous mode (connected lines as SVG paths) * @private */ renderContiguousMode(onlyNew = false) { const lineGroups = this.lineGroups; let croppedLineGroups = []; if (this.crop) { let i = 0; lineGroups.forEach((group) => { if (onlyNew && group.rendered) return; group.rendered = true; const newGroups = clipPathsRect( [group.points], 0, this.width, 0, this.height ); newGroups.forEach((newGroup) => { croppedLineGroups.push({ points: newGroup, strokeColour: group.strokeColour, strokeWidth: group.strokeWidth, index: i++, }); }); }); } else { croppedLineGroups = lineGroups; } croppedLineGroups.forEach((group) => { if (group.points.length === 0) return; const path = document.createElementNS( "http://www.w3.org/2000/svg", "path" ); path.setAttribute("fill", "none"); path.setAttribute("stroke", group.strokeColour); path.setAttribute("stroke-width", group.strokeWidth); let pathData = ""; let isFirst = true; group.points.forEach((point) => { if (isFirst) { pathData = `M${this.round(point.x)},${this.round(point.y)}`; isFirst = false; } else { pathData += ` L${this.round(point.x)},${this.round(point.y)}`; } }); path.setAttribute("d", pathData); this.svg.appendChild(path); }); } /** * Triggers a re-render of the drawing */ update() { this.render(); } // ============================================================================ // ADVANCED DRAWING // ============================================================================ /** * Adds a group of connected lines with shared properties * @param {Object} lineGroup - The line group to add * @param {Array} lineGroup.points - Array of coordinate objects {x, y} * @param {string} [lineGroup.strokeColour] - Color of the lines * @param {number} [lineGroup.strokeWidth] - Width of the lines */ addLineGroup(lineGroup) { if (!lineGroup.points || lineGroup.points.length < 2) { console.warn("LineGroup must have at least 2 points"); return; } lineGroup.index = ++this.lineIndex; lineGroup.strokeColour = lineGroup.strokeColour || this.strokeColour; lineGroup.strokeWidth = lineGroup.strokeWidth || this.strokeWidth; for (let i = 0; i < lineGroup.points.length - 1; i++) { const from = lineGroup.points[i]; const to = lineGroup.points[i + 1]; this.addDrawingCommand({ type: "line", from: { x: from.x, y: from.y }, to: { x: to.x, y: to.y }, strokeColour: lineGroup.strokeColour, strokeWidth: lineGroup.strokeWidth, i: lineGroup.index || this.lineIndex, }); } } /** * Adds multiple line groups * @param {Array} lineGroups - Array of line group objects */ addLineGroups(lineGroups) { lineGroups.forEach((lineGroup) => { this.addLineGroup(lineGroup); }); } // ============================================================================ // DOWNLOAD & EXPORT // ============================================================================ /** * Downloads the current drawing as an SVG file */ downloadSVG() { try { // Ensure the SVG is rendered before downloading this.render(); const svgContent = this.svg.outerHTML; const blob = new Blob([svgContent], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${this.filename}.svg`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); console.log(`SVG downloaded as ${this.filename}.svg`); } catch (error) { console.error("Error downloading SVG:", error); alert("Could not download SVG. Check console for details."); } } /** * Adds event listeners for the download functionality * @private */ addDownloadListeners() { this.wrapperDiv.addEventListener("mouseenter", () => { this.downloadLink.style.display = "block"; }); this.wrapperDiv.addEventListener("mouseleave", () => { this.downloadLink.style.display = "none"; }); this.downloadLink.addEventListener("click", (e) => { e.preventDefault(); this.downloadSVG(); }); } }