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