diff --git a/buildSrc/src/main/kotlin/org/openrndr/extra/convention/kotlin-jvm.gradle.kts b/buildSrc/src/main/kotlin/org/openrndr/extra/convention/kotlin-jvm.gradle.kts index 52739bb5..d45704e1 100644 --- a/buildSrc/src/main/kotlin/org/openrndr/extra/convention/kotlin-jvm.gradle.kts +++ b/buildSrc/src/main/kotlin/org/openrndr/extra/convention/kotlin-jvm.gradle.kts @@ -35,7 +35,16 @@ val main: SourceSet by sourceSets.getting @Suppress("UNUSED_VARIABLE") val demo: SourceSet by sourceSets.creating { - val skipDemos = setOf("openrndr-demos", "orx-minim", "orx-realsense2", "orx-runway", "orx-video-profiles", "orx-midi", "orx-syphon") + val skipDemos = setOf( + "openrndr-demos", + "orx-axidraw", + "orx-midi", + "orx-minim", + "orx-realsense2", + "orx-runway", + "orx-syphon", + "orx-video-profiles", + ) if (project.name !in skipDemos) { collectScreenshots(project, this@creating) { } } @@ -141,4 +150,4 @@ if (shouldPublish) { setRequired({ isReleaseVersion && gradle.taskGraph.hasTask("publish") }) sign(publishing.publications) } -} \ No newline at end of file +} diff --git a/orx-composition/src/commonMain/kotlin/CompositionDrawer.kt b/orx-composition/src/commonMain/kotlin/CompositionDrawer.kt index 264e4633..64bb8583 100644 --- a/orx-composition/src/commonMain/kotlin/CompositionDrawer.kt +++ b/orx-composition/src/commonMain/kotlin/CompositionDrawer.kt @@ -765,6 +765,7 @@ class CompositionDrawer( groupNode.children.forEach { it.parent = groupNode } + groupNode.attributes.putAll(node.attributes) groupNode } } diff --git a/orx-jvm/orx-axidraw/README.md b/orx-jvm/orx-axidraw/README.md new file mode 100644 index 00000000..18247210 --- /dev/null +++ b/orx-jvm/orx-axidraw/README.md @@ -0,0 +1,66 @@ +# orx-axidraw + +GUI for configuring and plotting with an Axidraw pen-plotter. + +Uses the [AxiCLI](https://axidraw.com/doc/cli_api/#introduction) command line tool +to communicate with the pen plotter. + +Requires: Python 3.8 or higher. + +This orx create a Python virtual environment and downloads AxiCLI automatically. + +## Usage + +```kotlin +fun main() = application { + program { + val axi = Axidraw(this, PaperSize.A5) + axi.resizeWindow() + + val gui = WindowedGUI() + gui.add(axi) + + axi.draw { + fill = null + axi.bounds.grid(4, 6).flatten().forEach { + circle(it.center, Double.uniform(20.0, 50.0)) + } + } + + extend(gui) + extend { + drawer.clear(ColorRGBa.WHITE) + axi.display(drawer) + } + } +} +``` + +Study the inputs available in the GUI. Most are explained in the [AxiCLI](https://axidraw.com/doc/cli_api/#introduction) documentation page. + +### Important + +* Choose the correct pen-plotter model and servo type in the GUI before plotting. +* Always make sure the pen is at the home position before starting to plot. If it's not, unpower the steppers, +drag the carriage home (near the Axidraw's CPU), then power the steppers back on. + +### Tips + +* One can repeatedly click on `toggle up/down` and adjust `pen pos down` and `pen pos up` +to find the ideal heights for the pen. +* Enable `fills occlude strokes` and increase margin value to hide elements near +the borders of the paper. +* Click `save` to save your SVG file. +* Click `plot` to plot the visible design using the current settings. +* A [2D camera](https://guide.openrndr.org/extensions/camera2D.html) is enabled by default to place your design on the paper. +* Click `resume plotting` after pressing the hardware pause button (or including a pause +command on a layer) to continue. +* To get a plotting time estimate, enable `preview` and click `plot`. Nothing will be plotted, but the estimate will be shown in the IDE console. + +The `Load` and `Save` buttons *at the top of the GUI* can be used to load and save the plotting settings. In a future version we may embed the plotting settings into the SVG file. + +### Multi color plots + +orx-axidraw makes it easy to create multi-pen plots. To do that, use two or more stroke colors in your design. The order of the lines does not matter. Then, before plotting, call `axi.groupStrokeColors()`. This will group curves into layers based on their stroke colors and insert a pause between layers, allowing you to change the pen. + +When the plotter pauses during plotting, change the pen and click `resume plotting` to continue. diff --git a/orx-jvm/orx-axidraw/build.gradle.kts b/orx-jvm/orx-axidraw/build.gradle.kts new file mode 100644 index 00000000..be263db9 --- /dev/null +++ b/orx-jvm/orx-axidraw/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + org.openrndr.extra.convention.`kotlin-jvm` +} + +dependencies { + implementation(libs.openrndr.application) + implementation(libs.openrndr.dialogs) + implementation(project(":orx-jvm:orx-gui")) + implementation(project(":orx-composition")) + implementation(project(":orx-svg")) + implementation(project(":orx-image-fit")) + implementation(project(":orx-shapes")) + implementation(project(":orx-camera")) + demoImplementation(project(":orx-camera")) + demoImplementation(project(":orx-noise")) + demoImplementation(project(":orx-parameters")) + demoImplementation(project(":orx-jvm:orx-axidraw")) +} diff --git a/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw01.kt b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw01.kt new file mode 100644 index 00000000..3e869020 --- /dev/null +++ b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw01.kt @@ -0,0 +1,59 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.extra.axidraw.Axidraw +import org.openrndr.extra.axidraw.PaperSize +import org.openrndr.extra.gui.GUI +import org.openrndr.extra.noise.uniform +import org.openrndr.extra.parameters.ActionParameter +import org.openrndr.extra.parameters.Description +import org.openrndr.extra.parameters.IntParameter +import kotlin.math.min + +/** + * Demonstrates: + * - how to create an AxiDraw GUI + * - how to add a slider and a button to that GUI + * - how to include code to generate new random designs that match + * the paper size via `axi.bounds`. + * - how to display the generated design using `axi.display`. + * + * Toggle the GUI by pressing F11. + */ +fun main() = application { + configure { + width = PaperSize.A5.size.x * 5 + height = PaperSize.A5.size.y * 5 + } + program { + val axi = Axidraw(this, PaperSize.A5) + axi.resizeWindow() + + val gui = GUI() + gui.add(axi) + + val settings = @Description("Main") object { + @IntParameter("count", 1, 50) + var count = 20 + + @ActionParameter("generate") + fun generate() { + axi.clear() + axi.draw { + val l = min(axi.bounds.width, axi.bounds.height) / 2.0 + repeat(count) { + circle(axi.bounds.center, Double.uniform(l / 4.0, l)) + } + } + } + } + gui.add(settings) + + settings.generate() + + extend(gui) + extend { + drawer.clear(ColorRGBa.WHITE) + axi.display(drawer) + } + } +} \ No newline at end of file diff --git a/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw02.kt b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw02.kt new file mode 100644 index 00000000..60bc2b4b --- /dev/null +++ b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw02.kt @@ -0,0 +1,45 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.extra.axidraw.Axidraw +import org.openrndr.extra.axidraw.PaperOrientation +import org.openrndr.extra.axidraw.PaperSize +import org.openrndr.extra.gui.WindowedGUI +import org.openrndr.extra.noise.uniform +import org.openrndr.extra.parameters.ActionParameter +import org.openrndr.extra.parameters.Description + +/** + * Demonstrates: + * - How to set the window size based on the chosen paper size. + * - How to use a windowed GUI. + * + */ +fun main() = application { + program { + val axi = Axidraw(this, PaperSize.A5, PaperOrientation.LANDSCAPE) + axi.resizeWindow() + + val gui = WindowedGUI() + gui.add(axi) + + val settings = @Description("Main") object { + + @ActionParameter("generate") + fun generate() { + axi.clear() + axi.draw { + repeat(20) { + circle(axi.bounds.center, Double.uniform(50.0, 200.0)) + } + } + } + } + gui.add(settings) + + extend(gui) + extend { + drawer.clear(ColorRGBa.WHITE) + axi.display(drawer) + } + } +} \ No newline at end of file diff --git a/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw03.kt b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw03.kt new file mode 100644 index 00000000..9ee7ca5c --- /dev/null +++ b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw03.kt @@ -0,0 +1,44 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.extra.axidraw.Axidraw +import org.openrndr.extra.axidraw.PaperOrientation +import org.openrndr.extra.axidraw.PaperSize +import org.openrndr.extra.axidraw.configure +import org.openrndr.extra.gui.WindowedGUI +import org.openrndr.extra.noise.uniform +import org.openrndr.extra.shapes.primitives.grid + +/** + * Demonstrates: + * - How to create layers via `group` and give each layer + * a unique pen height and pen speed. + * + */ +fun main() = application { + program { + val axi = Axidraw(this, PaperSize.A5, PaperOrientation.PORTRAIT) + axi.resizeWindow(100.0) + + val gui = WindowedGUI() + gui.add(axi) + + axi.clear() + axi.draw { + fill = null + axi.bounds.grid(4, 6).flatten().forEach { + group { + circle(it.center, 50.0) + }.configure( + penHeight = Int.uniform(30, 60), + penSpeed = Int.uniform(20, 50) + ) + } + } + + extend(gui) + extend { + drawer.clear(ColorRGBa.WHITE) + axi.display(drawer) + } + } +} \ No newline at end of file diff --git a/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw04.kt b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw04.kt new file mode 100644 index 00000000..59f643b8 --- /dev/null +++ b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw04.kt @@ -0,0 +1,48 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.extra.axidraw.* +import org.openrndr.extra.gui.WindowedGUI +import org.openrndr.extra.shapes.primitives.grid + +/** + * Demonstrates: + * - How to create a flattened grid of with 24 items + * - How to randomize the order of those items + * - How to take chunks of 10 items, then make + * a pause to change the pen after plotting each chunk + * + * Operation: After plotting ten circles, plotting will stop to let you change the pen. + * With the second pen installed, click `resume`. It will plot ten circles more. + * Change the pen again and click `resume` to plot the remaining 4 circles. + * Once done, click `resume` one more time to bring the pen home. + */ +fun main() = application { + program { + val axi = Axidraw(this, PaperSize.A5, PaperOrientation.PORTRAIT) + axi.resizeWindow(100.0) + + val gui = WindowedGUI() + gui.add(axi) + + axi.clear() + axi.draw { + fill = null + axi.bounds.grid(4, 6).flatten() + .shuffled().chunked(10).forEach { chunk -> + group { + chunk.forEach { + circle(it.center, 50.0) + } + } + group { + }.configure(layerMode = AxiLayerMode.PAUSE) + } + } + + extend(gui) + extend { + drawer.clear(ColorRGBa.WHITE) + axi.display(drawer) + } + } +} \ No newline at end of file diff --git a/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw05.kt b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw05.kt new file mode 100644 index 00000000..a6506043 --- /dev/null +++ b/orx-jvm/orx-axidraw/src/demo/kotlin/DemoAxidraw05.kt @@ -0,0 +1,46 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.extra.axidraw.Axidraw +import org.openrndr.extra.axidraw.PaperOrientation +import org.openrndr.extra.axidraw.PaperSize +import org.openrndr.extra.gui.WindowedGUI +import org.openrndr.extra.shapes.primitives.grid + +/** + * Demonstrates: + * - How to create a flattened grid of with 24 items + * - How to apply random colors from a palette to each item. + * - How to use `groupStrokeColors()` to plot a multi-pen design. + * + */ +fun main() = application { + program { + val axi = Axidraw(this, PaperSize.A5, PaperOrientation.PORTRAIT) + axi.resizeWindow(100.0) + + val gui = WindowedGUI() + gui.add(axi) + + val palette = listOf( + ColorRGBa.RED, + ColorRGBa.GREEN, + ColorRGBa.BLUE + ) + + axi.clear() + axi.draw { + fill = null + axi.bounds.grid(4, 6).flatten().forEach { + stroke = palette.random() + circle(it.center, 50.0) + } + } + axi.groupStrokeColors() + + extend(gui) + extend { + drawer.clear(ColorRGBa.WHITE) + axi.display(drawer) + } + } +} diff --git a/orx-jvm/orx-axidraw/src/main/kotlin/Axidraw.kt b/orx-jvm/orx-axidraw/src/main/kotlin/Axidraw.kt new file mode 100644 index 00000000..72ce0d69 --- /dev/null +++ b/orx-jvm/orx-axidraw/src/main/kotlin/Axidraw.kt @@ -0,0 +1,506 @@ +package org.openrndr.extra.axidraw + +import io.github.oshai.kotlinlogging.KotlinLogging +import offset.offset +import org.openrndr.Program +import org.openrndr.color.ColorRGBa +import org.openrndr.dialogs.openFileDialog +import org.openrndr.dialogs.saveFileDialog +import org.openrndr.draw.Drawer +import org.openrndr.draw.isolated +import org.openrndr.extra.camera.Camera2D +import org.openrndr.extra.composition.* +import org.openrndr.extra.imageFit.fit +import org.openrndr.extra.parameters.* +import org.openrndr.extra.svg.loadSVG +import org.openrndr.extra.svg.toSVG +import org.openrndr.math.IntVector2 +import org.openrndr.math.Matrix44 +import org.openrndr.math.Vector2 +import org.openrndr.shape.IntRectangle +import org.openrndr.shape.SegmentJoin +import org.openrndr.shape.Shape +import java.io.File +import java.util.* +import kotlin.io.path.createTempFile + +private val logger = KotlinLogging.logger {} + +/** + * Axidraw reordering optimization types. + * See: https://axidraw.com/doc/cli_api/#reordering + */ +@Suppress("unused") +enum class AxidrawOptimizationTypes(val id: Int) { + /** + * No optimization. Strictly preserve file order. + */ + None(4), + + /** + * Least; Only connect adjoining paths. + */ + ConnectPaths(0), + + /** + * Basic; Also reorder paths for speed + */ + ReorderPaths(1), + + /** + * Full; Also allow path reversal + */ + ReversePaths(2) +} + +@Suppress("unused") +enum class AxidrawModel(val id: Int) { + AxiDrawV2(1), + AxidrawV3(1), + AxidrawSE_A4(1), + AxiDrawV3_A3(2), + AxidrawSE_A3(2), + AxiDrawV3_XLX(3), + AxiDrawMiniKit(4), + AxiDrawSE_A1(5), + AxiDrawSE_A2(6), + AxiDrawV3_B6(7), +} + +@Suppress("unused") +enum class AxidrawServo(val id: Int) { + Standard(2), + Brushless(3), +} + +@Suppress("unused") +enum class PaperSize(val size: IntVector2) { + `A-1`(IntVector2(1682, 2378)), + `A-2`(IntVector2(1189, 1682)), + A0(IntVector2(841, 1189)), + A1(IntVector2(594, 841)), + A2(IntVector2(420, 594)), + A3(IntVector2(297, 420)), + A4(IntVector2(210, 297)), + A5(IntVector2(148, 210)), + A6(IntVector2(105, 148)), + A7(IntVector2(74, 105)), + A8(IntVector2(52, 74)), + A9(IntVector2(37, 52)), + A10(IntVector2(26, 37)) +} + +enum class PaperOrientation { + LANDSCAPE, + PORTRAIT +} + +/** + * Class to talk to the axicli command line program + * + */ +@Description("Axidraw") +class Axidraw(val program: Program, paperSize: PaperSize, orientation: PaperOrientation = PaperOrientation.PORTRAIT) { + + fun setupAxidrawCli() { + + if (!File("axidraw-venv").exists()) { + logger.info { "installing axidraw-cli virtual environment" } + invokePython(listOf("-m", "venv", "axidraw-venv")) + } + val python = venvPython(File("axidraw-venv")) + logger.info { "installing axidraw-cli in virtual environment $python" } + invokePython( + listOf("-m", "pip", "install", "https://cdn.evilmadscientist.com/dl/ad/public/AxiDraw_API.zip"), + python + ) + } + + init { + setupAxidrawCli() + } + + val actualPaperSize = when (orientation) { + PaperOrientation.LANDSCAPE -> paperSize.size.yx.vector2 + PaperOrientation.PORTRAIT -> paperSize.size.vector2 + } + + /** + * API URL to call once plotting is complete. If the string contains + * `[filename]` it will be replaced by the name of the file being plotted. + * This URL should be URL encoded (for instance use %20 instead of a space). + */ + var apiURL = "" + + @OptionParameter("model", 50) + var model = AxidrawModel.AxiDrawV3_A3 + + @OptionParameter("servo", 60) + var servo = AxidrawServo.Standard + + @IntParameter("speed pen down", 1, 110, 100) + var speedPenDown = 25 + + @IntParameter("speed pen up", 1, 110, 110) + var speedPenUp = 70 + + @IntParameter("acceleration", 1, 100, 120) + var acceleration = 75 + + /** + * Toggle the pen up/down state by powering the pen plotter servo. + * Useful for calibrating the pen height. Cover the paper with a + * plastic sheet before running this command to avoid accidentally + * leaving ink on the paper. + */ + @ActionParameter("toggle up/down", 125) + fun toggleUpDown() { + runCMD( + listOf( + "--mode", "toggle", + "--penlift", servo.id.toString(), + "--model", model.id.toString(), + "--pen_pos_down", "$penPosDown", + "--pen_pos_up", "$penPosUp", + ), false + ) + } + + @IntParameter("pen pos down", 1, 100, 130) + var penPosDown = 40 + + @IntParameter("pen pos up", 1, 100, 140) + var penPosUp = 60 + + @IntParameter("pen rate lower", 1, 100, 150) + var penRateLower = 50 + + @IntParameter("pen rate raise", 1, 100, 160) + var penRateRaise = 75 + + @IntParameter("pen delay down", -500, 500, 170) + var penDelayDown = 0 + + @IntParameter("pen delay up", -500, 500, 180) + var penDelayUp = 0 + + @OptionParameter("optimization", 185) + var optimization = AxidrawOptimizationTypes.ConnectPaths + + @BooleanParameter("random start", 190) + var randomStart = false + + @BooleanParameter("fills occlude strokes", 200) + var occlusion = false + + @IntParameter("margin", 0, 100, 205) + var margin = 0 + + @BooleanParameter("preview", 210) + var preview = false + + @BooleanParameter("const speed", 220) + var constSpeed = false + + @BooleanParameter("webhook", 230) + var webhook = false + + /** + * Creates a temporary SVG file. Used by the AxiCLI "resume" methods. When plotting, + * the temporary SVG file is updated to keep track of progress and allow resuming. + */ + private fun makeTempSVGFile(): File { + val tmpFile = createTempFile("axi_${UUID.randomUUID()}", ".svg").toFile() + tmpFile.deleteOnExit() + return tmpFile + } + + /** + * Keeps track of the most recent output file. Used to resume plotting after a pause. + */ + private var lastOutputFile = makeTempSVGFile() + + private fun plotArgs(plotFile: File, outputFile: File): List { + lastOutputFile = outputFile + return listOf( + plotFile.absolutePath, + "--progress", + "--report_time", + "--reordering", optimization.id.toString(), + if (randomStart) "--random_start" else "", + if (occlusion) "--hiding" else "", + if (preview) "--preview" else "", + if (webhook && apiURL.isNotEmpty()) + "--webhook" else "", + if (webhook && apiURL.isNotEmpty()) + "--webhook_url ${apiURL.replace("[filename]", plotFile.name)}" else "", + "--speed_pendown", "$speedPenDown", + "--speed_penup", "$speedPenUp", + "--accel", "$acceleration", + if (constSpeed) "--const_speed" else "", + "--pen_pos_down", "$penPosDown", + "--pen_pos_up", "$penPosUp", + "--pen_rate_lower", "$penRateLower", + "--pen_rate_raise", "$penRateRaise", + "--pen_delay_down", "$penDelayDown", + "--pen_delay_up", "$penDelayUp", + "--penlift", servo.id.toString(), + "--model", model.id.toString(), + "--output_file", outputFile.absolutePath, + ).filter { it.isNotEmpty() } + } + + private fun compositionDimensions(): CompositionDimensions { + return CompositionDimensions( + 0.0.pixels, + 0.0.pixels, + Length.Pixels.fromMillimeters(actualPaperSize.x), + Length.Pixels.fromMillimeters(actualPaperSize.y) + ) + } + + /** + * Main variable holding the design to save or plot. + */ + private val design = drawComposition(compositionDimensions()) { } + + /** + * Returns the bounds of the drawable area so user code can draw things + * whithout leaving the paper. + */ + val bounds = IntRectangle( + 0, 0, + (96.0 * actualPaperSize.x / 25.4).toInt(), + (96.0 * actualPaperSize.y / 25.4).toInt() + ).rectangle + + /** + * Clears the current design wiping any shapes the user might have + * added. + * + */ + fun clear() = design.clear() + + /** + * The core method that allows the user to append content to the design. + * Use any methods and properties like contour(), segment(), fill, stroke, etc. + */ + fun draw(f: CompositionDrawer.() -> Unit) { + design.draw(f) + } + + private fun runCMD(args: List, hold: Boolean = true) { + val python = venvPython(File("axidraw-venv")) + invokePython(listOf("-m", "axicli") + args, python) + } + + /** + * Display Axidraw software version + */ + @ActionParameter("info: version", 300) + fun version() = runCMD(listOf("--mode", "version")) + + /** + * Display Axidraw system info + */ + @ActionParameter("info: system", 310) + fun sysInfo() = runCMD(listOf("--mode", "sysinfo")) + + @ActionParameter("load", 330) + fun onLoad() = openFileDialog(supportedExtensions = listOf("SVG" to listOf("svg"))) { + clear() + camera.view = Matrix44.IDENTITY + val loaded = loadSVG(it) + draw { + loaded.findGroups().forEach { gn -> + if (gn.findGroups().size == 1) { + val g = group { + gn.findShapes().forEach { shp -> + if (shp.attributes["type"] != "margin") { + stroke = shp.stroke + fill = shp.fill + shape(shp.shape) + } + } + } + g.attributes.putAll(gn.attributes) + } + } + } + } + + /** + * Save current design as SVG + */ + @ActionParameter("save", 340) + fun onSave() = saveFileDialog(supportedExtensions = listOf("SVG" to listOf("svg"))) { save(it) } + + private fun save(svgFile: File) { + // Create a new SVG with the frame and camera applied + val designRendered = drawComposition(compositionDimensions()) { + val m = camera.view + + design.findGroups().forEach { gn -> + if (gn.findGroups().size == 1) { + val g = group { + gn.findShapes().forEach { shp -> + stroke = shp.stroke + fill = shp.fill + shape(shp.shape.transform(m)) + } + } + g.attributes.putAll(gn.attributes) + } + } + + // If the user wants a frame covering the design... + if (occlusion) { + fill = ColorRGBa.WHITE + stroke = null + shape(makeFrame(margin.toDouble()))?.attributes?.put("type", "margin") + } + } + designRendered.saveToInkscapeFile(svgFile) + } + + /** + * Plot design using the current settings + */ + @ActionParameter("plot", 350) + fun onPlot() { + val svgFile = makeTempSVGFile() + save(svgFile) + runCMD(plotArgs(svgFile, makeTempSVGFile())) + } + + /** + * After hitting pause, use this to move the pen home + */ + @ActionParameter("resume to home", 360) + fun goHome() { + runCMD(plotArgs(lastOutputFile, makeTempSVGFile()) + listOf("--mode", "res_home")) + } + + /** + * After hitting pause, use this to continue plotting + * + */ + @ActionParameter("resume plotting", 370) + fun resume() { + runCMD(plotArgs(lastOutputFile, makeTempSVGFile()) + listOf("--mode", "res_plot")) + } + + /** + * Optimization. This can be applied to a lambda function that takes one argument + * so it caches the calculation while the argument does not change. + */ + private fun ((A) -> B).lastArgMemo(): (A) -> B { + var lastArg: A? = null + var lastResult: B? = null + + return { arg -> + if (arg == lastArg) { + @Suppress("UNCHECKED_CAST") + lastResult as B + } else { + val result = this(arg) + lastArg = arg + lastResult = result + result + } + } + } + + /** + * Makes a white frame to cover the borders of the page, to avoid plotting + * on the edge of papers, which may damage the pen or make a mess. + */ + private val makeFrame = { width: Double -> + Shape( + listOf( + bounds.contour.offset(1000.0, SegmentJoin.MITER), + bounds.contour.offset(-width).reversed + ) + ) + }.lastArgMemo() + + /** + * Display the composition using [drawer]. + */ + fun display(drawer: Drawer) { + drawer.isolated { + view *= bounds.fit(drawer.bounds) + + isolated { + view *= camera.view + composition(design) + } + + // Draw frame + if (occlusion) { + fill = ColorRGBa.WHITE + stroke = null + shape(makeFrame(margin.toDouble())) + } + } + } + + /** + * Resizes the program window to match + * the paper size according to the + * [ppi] (Pixels Per Inch) value. + */ + fun resizeWindow(ppi: Double = 96.0) { + val app = program.application + val resizable = app.windowResizable + app.windowResizable = true + app.windowSize = Vector2( + ppi * actualPaperSize.x / 25.4, + ppi * actualPaperSize.y / 25.4 + ) + app.windowResizable = resizable + } + + val camera by lazy { + Camera2D().also { + it.setup(program) + } + } + + /** + * Rebuilds the design putting shapes under groups based on stroke colors and inserts a pause + * after each group. + * + * Call this method after creating a draw composition that uses several stroke colors. + * When plotting, change pens after each pause, then click "resume plotting". + * + * NOTE: this method changes line order. Therefore, avoid it if order is important, + * for instance with designs using fill colors to occlude. + * + */ + fun groupStrokeColors() { + val colorGroups = design.findShapes().filter { it.stroke != null }.groupBy { it.stroke!! } + design.clear() + design.draw { + var i = 0 + colorGroups.forEach { (color, nodes) -> + val hexColor = "%06x".format( + ((color.r * 255).toInt() shl 16) + ((color.g * 255).toInt() shl 8) + ((color.b * 255).toInt()) + ) + group { cursor.children.addAll(nodes) }.configure(hexColor) + + // Add a pause if it's not the last layer + if(++i < colorGroups.size) { + group { }.configure(layerMode = AxiLayerMode.PAUSE) + } + } + } + } + + /** + * Read-only String variable to inspect the current design in SVG format for debugging purposes. + */ + var svg: String = "" + get() = design.toSVG() + private set +} diff --git a/orx-jvm/orx-axidraw/src/main/kotlin/SVG.kt b/orx-jvm/orx-axidraw/src/main/kotlin/SVG.kt new file mode 100644 index 00000000..ec5bf438 --- /dev/null +++ b/orx-jvm/orx-axidraw/src/main/kotlin/SVG.kt @@ -0,0 +1,122 @@ +package org.openrndr.extra.axidraw +import org.openrndr.extra.composition.Composition +import org.openrndr.extra.composition.GroupNode +import org.openrndr.extra.composition.findGroups +import org.openrndr.extra.svg.toSVG +import java.io.File + +/** + * Axidraw layer mode. The [command] argument will be prepended to the layer name. + */ +@Suppress("unused") +enum class AxiLayerMode(val command: String) { + /** + * The default mode prepends nothing. + */ + DEFAULT(""), + + /** + * Layer names starting with `%` are not plotted. + */ + IGNORE("%"), + + /** + * Layer names starting with `!` trigger a pause. + */ + PAUSE("!") +} + +/** + * Configure an SVG layer name. Certain character sequences are used + * by the Axidraw software to control layer speed, height and delay. + * Other characters make the layer be ignored, or trigger a pause. + * The arguments in this function provide a typed approach to construct + * the layer name. + * See https://wiki.evilmadscientist.com/AxiDraw_Layer_Control + * + * @param layerName Human-readable layer name. Multiple layer can use the same name. + * @param penSpeed Pen down speed (1..100) + * @param penHeight Pen down height (0..100) + * @param plotDelay Delay before plotting this layer, in milliseconds + * @param layerMode The plotting mode for this layer. See [AxiLayerMode]. + */ +fun GroupNode.configure( + layerName: String = "layer", + penSpeed: Int? = null, + penHeight: Int? = null, + plotDelay: Int? = null, + layerMode: AxiLayerMode = AxiLayerMode.DEFAULT +) { + val layerNumber = (parent?.findGroups()?.size ?: 2) - 1 + + require(penSpeed == null || penSpeed in 1..100) { "Speed out of 1 .. 100 range" } + val actualSpeed = penSpeed?.let { "+S$it" } ?: "" + + require(penHeight == null || penHeight in 0..100) { "Height out of 0 .. 100 range" } + val actualHeight = penHeight?.let { "+H$it" } ?: "" + + require(plotDelay == null || plotDelay > 0) { "Delay value should null or above 0" } + val actualDelay = plotDelay?.let { "+D$it" } ?: "" + + attributes["inkscape:groupmode"] = "layer" + + attributes["inkscape:label"] = layerMode.command + layerNumber + + actualSpeed + actualHeight + actualDelay + " " + layerName +} + +/** + * Save a [Composition] to an Inkscape file. Includes expected XML namespaces + * and sets an XML header with the view window size. Strips an extra wrapping `` tag to + * make special layer names work with the Axidraw pen plotter. + * + * @param file Should point to the desired file name and path. + * @param postProcess Optional function to do post-processing on the SVG XML before saving it. + */ +fun Composition.saveToInkscapeFile( + file: File, + postProcess: (String) -> String = { xml -> xml } +) { + namespaces["xmlns:inkscape"] = "http://www.inkscape.org/namespaces/inkscape" + namespaces["xmlns:sodipodi"] = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + namespaces["xmlns:svg"] = "http://www.w3.org/2000/svg" + + val svg = StringBuilder(toSVG()) + + val header = """ + + """.trimIndent() + + // Remove the wrapping , otherwise layers don't work. + // Also remove duplicated and which show up when + // drawing a composition into another composition. + val updated = svg.replace( + Regex("""((.*))""", RegexOption.DOT_MATCHES_ALL), "$2" + ).replace( + "(\\W?)+)+".toRegex(setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)), + "\n" + ).replace( + Regex("""()""", RegexOption.DOT_MATCHES_ALL), "$1$header" + ) + file.writeText(postProcess(updated)) +} diff --git a/orx-jvm/orx-axidraw/src/main/kotlin/python.kt b/orx-jvm/orx-axidraw/src/main/kotlin/python.kt new file mode 100644 index 00000000..727136be --- /dev/null +++ b/orx-jvm/orx-axidraw/src/main/kotlin/python.kt @@ -0,0 +1,71 @@ +package org.openrndr.extra.axidraw + +import java.io.BufferedInputStream +import java.io.File +import java.io.IOException + +/** + * Determines the appropriate Python executable name based on the operating system. + * + * On Windows systems, it returns "python.exe", while on other operating systems, it returns "python3". + * + * @return The name of the Python executable appropriate for the current operating system. + */ +fun systemPython(): String { + val executable = if (System.getProperty("os.name").lowercase().contains("windows")) { + "python.exe" + } else { + "python3" + } + return executable +} + +/** + * Returns the path to the Python executable in a given virtual environment. + * The path varies depending on the operating system. + * + * @param venv the directory of the virtual environment + * @return the absolute path to the Python executable within the virtual environment + */ +fun venvPython(venv: File): String { + val executable = if (System.getProperty("os.name").lowercase().contains("windows")) { + "${venv.absolutePath}/Scripts/python.exe" + } else { + "${venv.absolutePath}/bin/python" + } + return executable +} + + +fun invokePython(arguments: List, executable: String = systemPython()): String { + val result: String + try { + + val pb = ProcessBuilder() + .let { + it.command(listOf(executable) + arguments) + //it.redirectError(File("python.error.txt")) + it.inheritIO() + } + .start() + .let { + val `is` = it.inputStream + val bis = BufferedInputStream(`is`) + val br = bis.bufferedReader() + result = br.readText().trim() + val error = it.waitFor() + println("Python returned: $error") + + // Error detection disabled because pressing the pause button on the Axidraw + // returns "1", and we don't want the program to close when that happens. + // There's no obvious way to distinguish between actual errors and pressing the pause button. + // if (error != 0) { + // error("Python invoke failed with error $error") + // } + } + } catch (e: IOException) { + error("\n\nPython 3.8 or higher is required but failed to run. Is it installed?\n\n") + } + + return result +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f1fa48d6..e8dc0cf2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ val openrndrClassifier: String by (gradle as ExtensionAware).extra( include( listOf( "openrndr-demos", + "orx-jvm:orx-axidraw", "orx-jvm:orx-boofcv", "orx-camera", "orx-jvm:orx-chataigne",