From 16be2bce3d204e775a1afa003a3a9e8264fdb0e9 Mon Sep 17 00:00:00 2001 From: Scott Richmond Date: Sat, 5 Jul 2025 23:20:41 -0400 Subject: [PATCH] factor svg/p5 into modules; fix svg rendering Former-commit-id: f635e878c9419195a30a6f448e7bb5b0c5b84c5e --- pkg/ludus.js | 398 +---------------------------------------- pkg/p5.js | 91 ++++++++++ pkg/svg.js | 120 +++++++++++++ pkg/turtle_geometry.js | 169 +++++++++++++++++ 4 files changed, 389 insertions(+), 389 deletions(-) create mode 100644 pkg/p5.js create mode 100644 pkg/svg.js create mode 100644 pkg/turtle_geometry.js diff --git a/pkg/ludus.js b/pkg/ludus.js index 1781b3f..5dbdd46 100644 --- a/pkg/ludus.js +++ b/pkg/ludus.js @@ -1,4 +1,8 @@ -if (window) window.ludus = {run, kill, flush_stdout, stdout, p5, svg, flush_commands, commands, result, flush_result, input, is_running, key_down, key_up, is_starting_up} +import {p5} from "./p5.js" + +import {svg as svg_2} from "./svg.js" + +if (window) window.ludus = {run, kill, flush_stdout, stdout, p5, flush_commands, commands, result, flush_result, input, is_running, key_down, key_up, is_starting_up, p5, svg} const worker_url = new URL("worker.js", import.meta.url) const worker = new Worker(worker_url, {type: "module"}) @@ -104,7 +108,7 @@ export function run (source) { ludus_console = "" ludus_commands = [] ludus_result = null - code = null + code = source running = true ready = false keys_down = new Set(); @@ -180,394 +184,10 @@ export function key_up (str) { if (is_running()) keys_down.delete(str) } -//////////// turtle plumbing below -// TODO: refactor this out into modules -const turtle_init = { - position: [0, 0], - heading: 0, - pendown: true, - pencolor: "white", - penwidth: 1, - visible: true -} +export {p5} from "./p5.js" -const colors = { - black: [0, 0, 0, 255], - silver: [192, 192, 192, 255], - gray: [128, 128, 128, 255], - white: [255, 255, 255, 255], - maroon: [128, 0, 0, 255], - red: [255, 0, 0, 255], - purple: [128, 0, 128, 255], - fuchsia: [255, 0, 255, 255], - green: [0, 128, 0, 255], - lime: [0, 255, 0, 255], - olive: [128, 128, 0, 255], - yellow: [255, 255, 0, 255], - navy: [0, 0, 128, 255], - blue: [0, 0, 255, 255], - teal: [0, 128, 128, 255], - aqua: [0, 255, 255, 255], -} - -function resolve_color (color) { - if (typeof color === 'string') return colors[color] - if (typeof color === 'number') return [color, color, color, 255] - if (Array.isArray(color)) return color - return [0, 0, 0, 255] // default to black? -} - -let background_color = "black" - -function add (v1, v2) { - const [x1, y1] = v1 - const [x2, y2] = v2 - return [x1 + x2, y1 + y2] -} - -function mult (vector, scalar) { - const [x, y] = vector - return [x * scalar, y * scalar] -} - -function unit_of (heading) { - const turns = -heading + 0.25 - const radians = turn_to_rad(turns) - return [Math.cos(radians), Math.sin(radians)] -} - -function command_to_state (prev_state, curr_command) { - const [verb] = curr_command - switch (verb) { - case "goto": { - const [_, x, y] = curr_command - return {...prev_state, position: [x, y]} - } - case "home": { - return {...prev_state, position: [0, 0], heading: 0} - } - case "right": { - const [_, angle] = curr_command - const {heading} = prev_state - return {...prev_state, heading: heading + angle} - } - case "left": { - const [_, angle] = curr_command - const {heading} = prev_state - return {...prev_state, heading: heading - angle} - } - case "forward": { - const [_, steps] = curr_command - const {heading, position} = prev_state - const unit = unit_of(heading) - const move = mult(unit, steps) - return {...prev_state, position: add(position, move)} - } - case "back": { - const [_, steps] = curr_command - const {heading, position} = prev_state - const unit = unit_of(heading) - const move = mult(unit, -steps) - return {...prev_state, position: add(position, move)} - } - case "penup": { - return {...prev_state, pendown: false} - } - case "pendown": { - return {...prev_state, pendown: true} - } - case "penwidth": { - const [_, width] = curr_command - return {...prev_state, penwidth: width} - } - case "pencolor": { - const [_, color] = curr_command - return {...prev_state, pencolor: color} - } - case "setheading": { - const [_, heading] = curr_command - return {...prev_state, heading: heading} - } - case "loadstate": { - // console.log("LOADSTATE: ", curr_command) - const [_, [x, y], heading, visible, pendown, penwidth, pencolor] = curr_command - return {position: [x, y], heading, visible, pendown, penwidth, pencolor} - } - case "show": { - return {...prev_state, visible: true} - } - case "hide": { - return {...prev_state, visible: false} - } - case "background": { - background_color = curr_command[1] - return prev_state - } - } -} - -function eq_vect (v1, v2) { - const [x1, y1] = v1 - const [x2, y2] = v2 - return (x1 === x2) && (y1 === y2) -} - -function eq_color (c1, c2) { - if (c1 === c2) return true - const res1 = resolve_color(c1) - const res2 = resolve_color(c2) - for (let i = 0; i < res1.length; ++i) { - if (res1[i] !== res2[i]) return false - } - return true -} - -function states_to_call (prev, curr) { - const calls = [] - // whose state should we use? - // pen states will only differ on more than one property - // if we use `loadstate` - // my sense is `prev`, but that may change - if (prev.pendown && !eq_vect(prev.position, curr.position)) { - calls.push(["line", prev.position[0], prev.position[1], curr.position[0], curr.position[1]]) - } - if (!eq_color(curr.pencolor, prev.pencolor)) { - calls.push(["stroke", ...resolve_color(curr.pencolor)]) - } - if (curr.penwidth !== prev.penwidth) { - calls.push(["strokeWeight", curr.penwidth]) - } - return calls -} - -const turtle_radius = 20 - -const turtle_angle = 0.385 - -let turtle_color = [255, 255, 255, 150] - -function p5_call_root () { - return [ - ["background", ...resolve_color(background_color)], - ["push"], - ["rotate", Math.PI], - ["scale", -1, 1], - ["stroke", ...resolve_color(turtle_init.pencolor)], - ] -} - -function rotate (vector, heading) { - const radians = turn_to_rad(heading) - const [x, y] = vector - return [ - (x * Math.cos (radians)) - (y * Math.sin (radians)), - (x * Math.sin (radians)) + (y * Math.cos (radians)) - ] -} - -function turn_to_rad (heading) { - return (heading % 1) * 2 * Math.PI -} - -function turn_to_deg (heading) { - return (heading % 1) * 360 -} - -function hex (n) { - return n.toString(16).padStart(2, "0") -} - -function svg_render_line (prev, curr) { - if (!prev.pendown) return "" - if (eq_vect(prev.position, curr.position)) return "" - const {position: [x1, y1], pencolor, penwidth} = prev - const {position: [x2, y2]} = curr - const [r, g, b, a] = resolve_color(pencolor) - return ` - - ` -} - -function escape_svg (svg) { - return svg - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") -} - -export function extract_ludus (svg) { - const code = svg.split("")[1]?.split("")[0] ?? "" - return code - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, `"`) - .replace(/'/g, `'`) -} - -function svg_render_path (states) { - const path = [] - for (let i = 1; i < states.length; ++i) { - const prev = states[i - 1] - const curr = states[i] - path.push(svg_render_line(prev, curr)) - } - return path.join("") -} - -function svg_render_turtle (state) { - if (!state.visible) return "" - const [fr, fg, fb, fa] = turtle_color - const fill_alpha = fa/255 - const {heading, pencolor, position: [x, y], pendown, penwidth} = state - const origin = [0, turtle_radius] - const [x1, y1] = origin - const [x2, y2] = rotate(origin, turtle_angle) - const [x3, y3] = rotate(origin, -turtle_angle) - const [pr, pg, pb, pa] = resolve_color(pencolor) - const pen_alpha = pa/255 - const ink = pendown ? `` : "" - return ` - - - ${ink} - - ` -} - -// TODO: update this to match the new `p5` function export function svg (commands) { - // console.log(commands) - const states = [turtle_init] - commands.reduce((prev_state, command) => { - const new_state = command_to_state(prev_state, command) - states.push(new_state) - return new_state - }, turtle_init) - // console.log(states) - const {maxX, maxY, minX, minY} = states.reduce((accum, {position: [x, y]}) => { - accum.maxX = Math.max(accum.maxX, x) - accum.maxY = Math.max(accum.maxY, y) - accum.minX = Math.min(accum.minX, x) - accum.minY = Math.min(accum.minY, y) - return accum - - }, {maxX: 0, maxY: 0, minX: 0, minY: 0}) - const [r, g, b] = resolve_color(background_color) - if ((r+g+b)/3 > 128) turtle_color = [0, 0, 0, 150] - const view_width = (maxX - minX) * 1.2 - const view_height = (maxY - minY) * 1.2 - const margin = Math.max(view_width, view_height) * 0.1 - const x_origin = minX - margin - const y_origin = -maxY - margin - const path = svg_render_path(states) - const turtle = svg_render_turtle(states[states.length - 1]) - return ` - - - - - - ${path} - ${turtle} - - - -${escape_svg(code)} - - - ` + console.log("generating svg for ${code}") + return svg_2(commands, code) } -function p5_render_turtle (state, calls) { - if (!state.visible) return - calls.push(["push"]) - const [r, g, b, a] = turtle_color - calls.push(["fill", r, g, b, a]) - const {heading, pencolor, position: [x, y], pendown, penwidth} = state - const origin = [0, turtle_radius] - const [x1, y1] = origin - const [x2, y2] = rotate(origin, turtle_angle) - const [x3, y3] = rotate(origin, -turtle_angle) - calls.push(["translate", x, y]) - // need negative turtle rotation with the other p5 translations - calls.push(["rotate", -turn_to_rad(heading)]) - calls.push(["noStroke"]) - calls.push(["beginShape"]) - calls.push(["vertex", x1, y1]) - calls.push(["vertex", x2, y2]) - calls.push(["vertex", x3, y3]) - calls.push(["endShape"]) - calls.push(["strokeWeight", penwidth]) - calls.push(["stroke", ...resolve_color(pencolor)]) - if (pendown) calls.push(["line", 0, 0, x1, y1]) - calls.push(["pop"]) - return calls -} - -// export function p5 (commands) { -// const states = [turtle_init] -// commands.reduce((prev_state, command) => { -// const new_state = command_to_state(prev_state, command) -// states.push(new_state) -// return new_state -// }, turtle_init) -// // console.log(states) -// const [r, g, b, _] = resolve_color(background_color) -// if ((r + g + b)/3 > 128) turtle_color = [0, 0, 0, 150] -// const p5_calls = [...p5_call_root()] -// for (let i = 1; i < states.length; ++i) { -// const prev = states[i - 1] -// const curr = states[i] -// const calls = states_to_call(prev, curr) -// for (const call of calls) { -// p5_calls.push(call) -// } -// } -// p5_calls[0] = ["background", ...resolve_color(background_color)] -// p5_render_turtle(states[states.length - 1], p5_calls) -// p5_calls.push(["pop"]) -// return p5_calls -// } - -function last (arr) { - return arr[arr.length - 1] -} - -export function p5 (commands) { - const all_states = {} - for (const command of commands) { - const [turtle_id, _, this_command] = command - let stack = all_states[turtle_id] - if (!stack) { - const new_stack = [turtle_init] - all_states[turtle_id] = new_stack - stack = new_stack - } - let prev_state = last(all_states[turtle_id]) - const new_state = command_to_state(prev_state, this_command) - all_states[turtle_id].push(new_state) - } - const [r, g, b, _] = resolve_color(background_color) - if ((r + g + b)/3 > 128) turtle_color = [0, 0, 0, 150] - const p5_calls = [...p5_call_root()] - for (const states of Object.values(all_states)) { - console.log(states) - for (let i = 1; i < states.length; ++i) { - const prev = states[i - 1] - const curr = states[i] - const calls = states_to_call(prev, curr) - for (const call of calls) { - p5_calls.push(call) - } - } - p5_calls[0] = ["background", ...resolve_color(background_color)] - p5_render_turtle(states[states.length - 1], p5_calls) - } - p5_calls.push(["pop"]) - return p5_calls -} - - diff --git a/pkg/p5.js b/pkg/p5.js new file mode 100644 index 0000000..0f803ab --- /dev/null +++ b/pkg/p5.js @@ -0,0 +1,91 @@ +import {eq_vect, eq_color, resolve_color, turtle_color, turtle_radius, turtle_angle, turn_to_rad, turtle_init, command_to_state, background_color, rotate, last} from "./turtle_geometry.js" + +function states_to_call (prev, curr) { + const calls = [] + // whose state should we use? + // pen states will only differ on more than one property + // if we use `loadstate` + // my sense is `prev`, but that may change + if (prev.pendown && !eq_vect(prev.position, curr.position)) { + calls.push(["line", prev.position[0], prev.position[1], curr.position[0], curr.position[1]]) + } + if (!eq_color(curr.pencolor, prev.pencolor)) { + calls.push(["stroke", ...resolve_color(curr.pencolor)]) + } + if (curr.penwidth !== prev.penwidth) { + calls.push(["strokeWeight", curr.penwidth]) + } + return calls +} + +function p5_call_root () { + return [ + ["background", ...resolve_color(background_color)], + ["push"], + ["rotate", Math.PI], + ["scale", -1, 1], + ["stroke", ...resolve_color(turtle_init.pencolor)], + ] +} + +function p5_render_turtle (state, calls) { + if (!state.visible) return + calls.push(["push"]) + const [r, g, b, a] = turtle_color + calls.push(["fill", r, g, b, a]) + const {heading, pencolor, position: [x, y], pendown, penwidth} = state + const origin = [0, turtle_radius] + const [x1, y1] = origin + const [x2, y2] = rotate(origin, turtle_angle) + const [x3, y3] = rotate(origin, -turtle_angle) + calls.push(["translate", x, y]) + // need negative turtle rotation with the other p5 translations + calls.push(["rotate", -turn_to_rad(heading)]) + calls.push(["noStroke"]) + calls.push(["beginShape"]) + calls.push(["vertex", x1, y1]) + calls.push(["vertex", x2, y2]) + calls.push(["vertex", x3, y3]) + calls.push(["endShape"]) + calls.push(["strokeWeight", penwidth]) + calls.push(["stroke", ...resolve_color(pencolor)]) + if (pendown) calls.push(["line", 0, 0, x1, y1]) + calls.push(["pop"]) + return calls +} + +export function p5 (commands) { + const all_states = {} + for (const command of commands) { + const [turtle_id, _, this_command] = command + let stack = all_states[turtle_id] + if (!stack) { + const new_stack = [turtle_init] + all_states[turtle_id] = new_stack + stack = new_stack + } + let prev_state = last(all_states[turtle_id]) + const new_state = command_to_state(prev_state, this_command) + all_states[turtle_id].push(new_state) + } + const [r, g, b, _] = resolve_color(background_color) + if ((r + g + b)/3 > 128) turtle_color = [0, 0, 0, 150] + const p5_calls = [...p5_call_root()] + for (const states of Object.values(all_states)) { + console.log(states) + for (let i = 1; i < states.length; ++i) { + const prev = states[i - 1] + const curr = states[i] + const calls = states_to_call(prev, curr) + for (const call of calls) { + p5_calls.push(call) + } + } + p5_calls[0] = ["background", ...resolve_color(background_color)] + p5_render_turtle(states[states.length - 1], p5_calls) + } + p5_calls.push(["pop"]) + return p5_calls +} + + diff --git a/pkg/svg.js b/pkg/svg.js new file mode 100644 index 0000000..e751c8b --- /dev/null +++ b/pkg/svg.js @@ -0,0 +1,120 @@ +import {eq_vect, resolve_color, turtle_color, turtle_radius, rotate, turn_to_deg, command_to_state, turtle_init, background_color, turtle_angle, last} from "./turtle_geometry.js" + +function hex (n) { + return n.toString(16).padStart(2, "0") +} + +function svg_render_line (prev, curr) { + if (!prev.pendown) return "" + if (eq_vect(prev.position, curr.position)) return "" + const {position: [x1, y1], pencolor, penwidth} = prev + const {position: [x2, y2]} = curr + const [r, g, b, a] = resolve_color(pencolor) + return ` + + ` +} + +function escape_svg (svg) { + return svg + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +export function extract_ludus (svg) { + const code = svg.split("")[1]?.split("")[0] ?? "" + return code + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, `"`) + .replace(/'/g, `'`) +} + +function svg_render_path (states) { + const path = [] + for (let i = 1; i < states.length; ++i) { + const prev = states[i - 1] + const curr = states[i] + path.push(svg_render_line(prev, curr)) + } + return path.join("") +} + +function svg_render_turtle (state) { + if (!state.visible) return "" + const [fr, fg, fb, fa] = turtle_color + const fill_alpha = fa/255 + const {heading, pencolor, position: [x, y], pendown, penwidth} = state + const origin = [0, turtle_radius] + const [x1, y1] = origin + const [x2, y2] = rotate(origin, turtle_angle) + const [x3, y3] = rotate(origin, -turtle_angle) + const [pr, pg, pb, pa] = resolve_color(pencolor) + const pen_alpha = pa/255 + const ink = pendown ? `` : "" + return ` + + + ${ink} + + ` +} + +// TODO: update this to match the new `p5` function +export function svg (commands, code) { + const all_states = {} + for (const command of commands) { + const [turtle_id, _, this_command] = command + let stack = all_states[turtle_id] + if (!stack) { + const new_stack = [turtle_init] + all_states[turtle_id] = new_stack + stack = new_stack + } + const prev_state = last(all_states[turtle_id]) + const new_state = command_to_state(prev_state, this_command) + all_states[turtle_id].push(new_state) + } + let maxX = -Infinity, maxY = -Infinity, minX = Infinity, minY = Infinity + for (const states of Object.values(all_states)) { + for (const {position: [x, y]} of states) { + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + minX = Math.min(minX, x) + minY = Math.min(minY, y) + } + } + const [r, g, b] = resolve_color(background_color) + if ((r+g+b)/3 > 128) turtle_color = [0, 0, 0, 150] + const view_width = (maxX - minX) * 1.2 + const view_height = (maxY - minY) * 1.2 + const margin = Math.max(view_width, view_height) * 0.1 + const x_origin = minX - margin + const y_origin = -maxY - margin + let path = "" + let turtle = "" + for (const states of Object.values(all_states)) { + path = path + svg_render_path(states) + turtle = svg_render_turtle(last(states)) + } + return ` + + + + + + ${path} + ${turtle} + + + +${escape_svg(code)} + + + ` +} + diff --git a/pkg/turtle_geometry.js b/pkg/turtle_geometry.js new file mode 100644 index 0000000..f8de0b3 --- /dev/null +++ b/pkg/turtle_geometry.js @@ -0,0 +1,169 @@ +export const turtle_init = { + position: [0, 0], + heading: 0, + pendown: true, + pencolor: "white", + penwidth: 1, + visible: true +} + +export const turtle_radius = 20 + +export const turtle_angle = 0.385 + +export let turtle_color = [255, 255, 255, 150] + +export const colors = { + black: [0, 0, 0, 255], + silver: [192, 192, 192, 255], + gray: [128, 128, 128, 255], + grey: [128, 128, 128, 255], + white: [255, 255, 255, 255], + maroon: [128, 0, 0, 255], + red: [255, 0, 0, 255], + purple: [128, 0, 128, 255], + fuchsia: [255, 0, 255, 255], + green: [0, 128, 0, 255], + lime: [0, 255, 0, 255], + olive: [128, 128, 0, 255], + yellow: [255, 255, 0, 255], + navy: [0, 0, 128, 255], + blue: [0, 0, 255, 255], + teal: [0, 128, 128, 255], + aqua: [0, 255, 255, 255], +} + +export function resolve_color (color) { + if (typeof color === 'string') return colors[color] + if (typeof color === 'number') return [color, color, color, 255] + if (Array.isArray(color)) return color + return [0, 0, 0, 255] // default to black? +} + +export let background_color = "black" + +export function add (v1, v2) { + const [x1, y1] = v1 + const [x2, y2] = v2 + return [x1 + x2, y1 + y2] +} + +export function mult (vector, scalar) { + const [x, y] = vector + return [x * scalar, y * scalar] +} + +export function unit_of (heading) { + const turns = -heading + 0.25 + const radians = turn_to_rad(turns) + return [Math.cos(radians), Math.sin(radians)] +} + +export function command_to_state (prev_state, curr_command) { + const [verb] = curr_command + switch (verb) { + case "goto": { + const [_, x, y] = curr_command + return {...prev_state, position: [x, y]} + } + case "home": { + return {...prev_state, position: [0, 0], heading: 0} + } + case "right": { + const [_, angle] = curr_command + const {heading} = prev_state + return {...prev_state, heading: heading + angle} + } + case "left": { + const [_, angle] = curr_command + const {heading} = prev_state + return {...prev_state, heading: heading - angle} + } + case "forward": { + const [_, steps] = curr_command + const {heading, position} = prev_state + const unit = unit_of(heading) + const move = mult(unit, steps) + return {...prev_state, position: add(position, move)} + } + case "back": { + const [_, steps] = curr_command + const {heading, position} = prev_state + const unit = unit_of(heading) + const move = mult(unit, -steps) + return {...prev_state, position: add(position, move)} + } + case "penup": { + return {...prev_state, pendown: false} + } + case "pendown": { + return {...prev_state, pendown: true} + } + case "penwidth": { + const [_, width] = curr_command + return {...prev_state, penwidth: width} + } + case "pencolor": { + const [_, color] = curr_command + return {...prev_state, pencolor: color} + } + case "setheading": { + const [_, heading] = curr_command + return {...prev_state, heading: heading} + } + case "loadstate": { + // console.log("LOADSTATE: ", curr_command) + const [_, [x, y], heading, visible, pendown, penwidth, pencolor] = curr_command + return {position: [x, y], heading, visible, pendown, penwidth, pencolor} + } + case "show": { + return {...prev_state, visible: true} + } + case "hide": { + return {...prev_state, visible: false} + } + case "background": { + background_color = curr_command[1] + return prev_state + } + } +} + +export function eq_vect (v1, v2) { + const [x1, y1] = v1 + const [x2, y2] = v2 + return (x1 === x2) && (y1 === y2) +} + +export function eq_color (c1, c2) { + if (c1 === c2) return true + const res1 = resolve_color(c1) + const res2 = resolve_color(c2) + for (let i = 0; i < res1.length; ++i) { + if (res1[i] !== res2[i]) return false + } + return true +} + +export function rotate (vector, heading) { + const radians = turn_to_rad(heading) + const [x, y] = vector + return [ + (x * Math.cos (radians)) - (y * Math.sin (radians)), + (x * Math.sin (radians)) + (y * Math.cos (radians)) + ] +} + +export function turn_to_rad (heading) { + return (heading % 1) * 2 * Math.PI +} + +export function turn_to_deg (heading) { + return (heading % 1) * 360 +} + +export function last (arr) { + return arr[arr.length - 1] +} + +