New orx-axidraw (#356)

This commit is contained in:
Abe Pazos
2025-09-02 06:52:20 +02:00
committed by GitHub
parent 15dad438b9
commit 02afa415a9
13 changed files with 1038 additions and 2 deletions

View File

@@ -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.

View File

@@ -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"))
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<String> {
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<String>, 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> ((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
}

View File

@@ -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 `<g>` 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 = """
<sodipodi:namedview
id="namedview7112"
pagecolor="#ffffff"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="1.0"
inkscape:cx="${bounds.width.value / 2}"
inkscape:cy="${bounds.height.value / 2}"
inkscape:window-width="${bounds.width}"
inkscape:window-height="${bounds.height}"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="openrndr-svg" />
""".trimIndent()
// Remove the wrapping <g>, otherwise layers don't work.
// Also remove duplicated <g><g> and </g></g> which show up when
// drawing a composition into another composition.
val updated = svg.replace(
Regex("""(<g\s?>(.*)</g>)""", RegexOption.DOT_MATCHES_ALL), "$2"
).replace(
"(<g >\\W?)+<g ".toRegex(setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)),
"<g "
).replace(
"(\\W?</g>)+".toRegex(setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)),
"\n</g>"
).replace(
Regex("""(<svg.*?>)""", RegexOption.DOT_MATCHES_ALL), "$1$header"
)
file.writeText(postProcess(updated))
}

View File

@@ -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<String>, 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
}