diff --git a/orx-g-code/README.md b/orx-g-code/README.md new file mode 100644 index 000000000..f4a7a863b --- /dev/null +++ b/orx-g-code/README.md @@ -0,0 +1,46 @@ +# orx-g-code + +Utilities for generating *g-code* from compositions. + +>*Make sure to verify compatibility to your hardware and that the +commands do not exceed the machines limits before running any output of this.* + +## Generator + +A [Generator](src/commonMain/kotlin/Generator.kt) generates g-code for the following operations: +- `setup`: Setup code at the beginning of a file. +- `moveTo`: A move operation to the given location. +- `preDraw`: Start drawing sequence. Pen down, laser on, etc. +- `drawTo`: A draw operation to the given location. +- `postDraw`: End draw sequence. Lift pen, turn laser off, etc. +- `end`: End of file sequence. +- `comment`: Insert a comment. + +These are used by the `toCommands()` extensions in [shape.kt](src/commonMain/kotlin/extensions/shape.kt) to convert a composition +to a set of Commands. + +It only supports absolute moves, so `G90 absolute positioning` should be included in the setup. + +`basicGrblSetup` defines an example that outputs g-code, which *should be* +compatible with [grbl v1.1](https://github.com/grbl/grbl). + +See [DemoGcodeGenerator.kt](src/jvmDemo/kotlin/DemoGcodeGenerator.kt) for an example of how to use. + +## Plot (jvm only) + +The [Plot Extension](src/jvmMain/kotlin/Plot.kt) provides a quick setup for drawing, rendering and exporting files for +a pen plotter. +See [DemoSimplePlot.kt](src/jvmDemo/kotlin/DemoSimplePlot.kt) for an example of how to use. + + + +## Demos +### DemoInteractivePlot +[source code](src/jvmDemo/kotlin/DemoInteractivePlot.kt) + +![DemoInteractivePlotKt](https://raw.githubusercontent.com/openrndr/orx/media/orx-g-code/images/DemoInteractivePlotKt.png) + +### DemoSimplePlot +[source code](src/jvmDemo/kotlin/DemoSimplePlot.kt) + +![DemoSimplePlotKt](https://raw.githubusercontent.com/openrndr/orx/media/orx-g-code/images/DemoSimplePlotKt.png) diff --git a/orx-g-code/build.gradle.kts b/orx-g-code/build.gradle.kts new file mode 100644 index 000000000..28dc3e617 --- /dev/null +++ b/orx-g-code/build.gradle.kts @@ -0,0 +1,41 @@ +import ScreenshotsHelper.collectScreenshots + +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + org.openrndr.extra.convention.`kotlin-multiplatform` +} + +kotlin { + jvm { + @Suppress("UNUSED_VARIABLE") + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } + + sourceSets { + @Suppress("UNUSED_VARIABLE") + val commonMain by getting { + dependencies { + implementation(libs.openrndr.application) + implementation(libs.openrndr.draw) + implementation(libs.openrndr.shape) + implementation(libs.openrndr.extensions) + } + } + + @Suppress("UNUSED_VARIABLE") + val jvmTest by getting { + dependencies { + runtimeOnly(libs.slf4j.simple) + } + } + + @Suppress("UNUSED_VARIABLE") + val jvmDemo by getting { + dependencies { + runtimeOnly(libs.slf4j.simple) + } + } + } +} diff --git a/orx-g-code/images/DemoInteractivePlotKt.png b/orx-g-code/images/DemoInteractivePlotKt.png new file mode 100644 index 000000000..4753da32f Binary files /dev/null and b/orx-g-code/images/DemoInteractivePlotKt.png differ diff --git a/orx-g-code/images/DemoSimplePlotKt.png b/orx-g-code/images/DemoSimplePlotKt.png new file mode 100644 index 000000000..edaf2c41e Binary files /dev/null and b/orx-g-code/images/DemoSimplePlotKt.png differ diff --git a/orx-g-code/src/commonMain/kotlin/Generator.kt b/orx-g-code/src/commonMain/kotlin/Generator.kt new file mode 100644 index 000000000..4e282e7a5 --- /dev/null +++ b/orx-g-code/src/commonMain/kotlin/Generator.kt @@ -0,0 +1,129 @@ +package org.openrndr.extra.gcode + +import org.openrndr.math.Vector2 +import kotlin.math.absoluteValue +import kotlin.math.pow +import kotlin.math.roundToInt + +typealias Command = String +typealias Commands = List + +fun Command.asCommands(): Commands = listOf(this) + +fun Commands.toGcode(): String = StringBuilder() + .also { sb -> this.forEach { sb.appendLine(it) } } + .toString() + +/** + * Consecutive identical commands are removed, ignoring comments. + * Comments are lines starting with ";" or "(". + */ +fun Commands.withoutDuplicates(): Commands { + var lastCommand = 0 + return this.filterIndexed { i, c -> + when (i) { + 0 -> true + else -> { + if (c.startsWith(';') || c.startsWith('(')) { + true + } else { + !c.contentEquals(this[lastCommand]).also { lastCommand = i } + } + } + } + } +} + +/** + * Double to String rounded to absolute value of [decimals]. + * Helper to be used in generator functions. + */ +fun Double.roundedTo(decimals: Int = 3): String { + val f = 10.0.pow(decimals.absoluteValue) + return when { + decimals != 0 -> "${this.times(f).roundToInt().div(f)}" + else -> "${this.roundToInt()}" + } +} + +/** + * Generates g-code for defined operations. + */ +data class Generator( + + /** + * Setup code at the beginning of a file. + */ + val setup: Commands, + + /** + * A move operation to the given location. + */ + val moveTo: (Vector2) -> Commands, + + /** + * Start drawing sequence. Pen down, laser on, etc. + */ + val preDraw: Commands, + + /** + * A draw operation to the given location. + */ + val drawTo: (Vector2) -> Commands, + + /** + * End draw sequence. Lift pen, turn laser off, etc. + * Called after a draw action before a move is performed. + */ + val postDraw: Commands, + + /** + * End of file sequence. + */ + val end: Commands = emptyList(), + + /** + * Insert a comment. + */ + val comment: (String) -> Commands +) + +/** + * All operations are empty command lists on default and have to be defined explicitly. + */ +fun noopGenerator() = Generator( + setup = emptyList(), + moveTo = { _ -> emptyList() }, + preDraw = emptyList(), + drawTo = { _ -> emptyList() }, + postDraw = emptyList(), +) { _ -> emptyList() } + +/** + * Creates a [Generator] to be used by grbl controlled pen plotters. + * [drawRate] sets the feed rate used for drawing operations. + * Moves are performed with G0. When [moveRate] is set, moves are instead + * done with G1 and the given rate as feedRate. + * Can be customized by overwriting individual fields with *.copy*. + */ +fun basicGrblSetup( + drawRate: Double = 500.0, + moveRate: Double? = null, +) = Generator( + setup = listOf( + "G21", // mm + "G90", // Absolute positioning + ), + moveTo = when (moveRate) { + null -> { p -> "G0 X${p.x.roundedTo()} Y${p.y.roundedTo()}".asCommands() } + else -> { p -> "G1 X${p.x.roundedTo()} Y${p.y.roundedTo()} F$drawRate".asCommands() } + }, + preDraw = listOf("M3 S255"), + drawTo = { p -> "G1 X${p.x.roundedTo()} Y${p.y.roundedTo()} F$drawRate".asCommands() }, + postDraw = listOf("M3 S0"), + end = listOf( + "G0 X0 Y0", + "G90", + ), + comment = { c -> listOf(";$c") } +) \ No newline at end of file diff --git a/orx-g-code/src/commonMain/kotlin/extensions/shape.kt b/orx-g-code/src/commonMain/kotlin/extensions/shape.kt new file mode 100644 index 000000000..8eb496579 --- /dev/null +++ b/orx-g-code/src/commonMain/kotlin/extensions/shape.kt @@ -0,0 +1,75 @@ +package org.openrndr.extra.gcode.extensions + +import org.openrndr.extra.gcode.Command +import org.openrndr.extra.gcode.Commands +import org.openrndr.extra.gcode.Generator +import org.openrndr.shape.Composition +import org.openrndr.shape.Segment +import org.openrndr.shape.ShapeContour + +fun List.toCommands(generator: Generator, distanceTolerance: Double): Commands = this + .flatMap {it.toCommands(generator, distanceTolerance) } + .let { commands -> (generator.setup + commands + generator.end) } + +fun Composition.toCommands(generator: Generator, distanceTolerance: Double): Commands { + + val sequence = generator.comment("begin composition").toMutableList() + + this.findShapes().forEachIndexed { index, shapeNode -> + sequence += generator.comment("begin shape: $index") + shapeNode.shape.contours.forEach { + sequence += it.toCommands(generator, distanceTolerance) + } + sequence += generator.comment("end shape: $index") + } + + sequence += generator.comment("end composition") + + return sequence +} + +fun ShapeContour.toCommands(generator: Generator, distanceTolerance: Double): Commands { + + val sequence = mutableListOf() + + val isDot = + this.segments.size == 1 && this.segments.first().start.squaredDistanceTo(this.segments.first().end) < distanceTolerance + if (isDot) { + sequence += generator.moveTo(this.segments.first().start) + sequence += generator.preDraw + sequence += generator.comment("dot") + sequence += generator.postDraw + return sequence + } + + this.segments.forEachIndexed { i, segment -> + + // On first segment, move to beginning of segment and tool down + if (i == 0) { + sequence += generator.moveTo(segment.start) + sequence += generator.preDraw + } + + // Draw segment + sequence += segment.toCommands(generator, distanceTolerance) + } + + // Close after last segment + if (this.closed) { + generator.drawTo(this.segments.first().start) + } + + sequence += generator.postDraw + + return sequence.toList() +} + +fun Segment.toCommands(generator: Generator, distanceTolerance: Double): Commands { + return if (this.control.isEmpty()) { + generator.drawTo(this.end) + } else { + this.adaptivePositions(distanceTolerance).flatMap { + generator.drawTo(it) + } + } +} \ No newline at end of file diff --git a/orx-g-code/src/jvmDemo/kotlin/DemoGcodeGenerator.kt b/orx-g-code/src/jvmDemo/kotlin/DemoGcodeGenerator.kt new file mode 100644 index 000000000..bbf1b34c7 --- /dev/null +++ b/orx-g-code/src/jvmDemo/kotlin/DemoGcodeGenerator.kt @@ -0,0 +1,64 @@ +import org.openrndr.application +import org.openrndr.drawComposition +import org.openrndr.extra.gcode.asCommands +import org.openrndr.extra.gcode.basicGrblSetup +import org.openrndr.extra.gcode.extensions.toCommands +import org.openrndr.extra.gcode.toGcode +import org.openrndr.extra.gcode.withoutDuplicates + +fun main() = application { + configure { + width = 800 + height = 800 + } + + program { + + val generator = basicGrblSetup(drawRate = 500.0).copy( + preDraw = (40..80 step 5).flatMap { listOf("M3 S$it", "G4 P0.08") }, + postDraw = "M3 S40".asCommands(), + ) + + val comp = drawComposition { + lineSegment(10.0, 10.0, 20.0, 20.0) + } + + // Convert composition to g-code, print to console and exit + val commands = generator.setup + comp.toCommands(generator, 0.05) + generator.end + val gCode = commands.withoutDuplicates().toGcode() + println(gCode) + application.exit() + + /* Output: + G21 + G90 + ;begin composition + ;begin shape: 0 + G0 X10.0 Y10.0 + M3 S40 + G4 P0.08 + M3 S45 + G4 P0.08 + M3 S50 + G4 P0.08 + M3 S55 + G4 P0.08 + M3 S60 + G4 P0.08 + M3 S65 + G4 P0.08 + M3 S70 + G4 P0.08 + M3 S75 + G4 P0.08 + M3 S80 + G4 P0.08 + G1 X20.0 Y20.0 F500.0 + M3 S40 + ;end shape: 0 + ;end composition + G0 X0 Y0 + G90 + */ + } +} \ No newline at end of file diff --git a/orx-g-code/src/jvmDemo/kotlin/DemoInteractivePlot.kt b/orx-g-code/src/jvmDemo/kotlin/DemoInteractivePlot.kt new file mode 100644 index 000000000..33a4e791a --- /dev/null +++ b/orx-g-code/src/jvmDemo/kotlin/DemoInteractivePlot.kt @@ -0,0 +1,66 @@ +import org.openrndr.application +import org.openrndr.extensions.Screenshots +import org.openrndr.extra.gcode.Origin +import org.openrndr.extra.gcode.Plot +import org.openrndr.extra.gcode.basicGrblSetup +import org.openrndr.math.Vector2 +import org.openrndr.shape.ContourBuilder + +fun main() = application { + configure { + width = 600 + height = 800 + } + + program { + extend(Screenshots()) + + val plot = Plot( + dimensions = Vector2(210.0, 297.0), + manualRedraw = false, + origin = Origin.CENTER + ) + extend(plot) { + generator = basicGrblSetup() + + // Set output files to be exported to tmp + // "g" to export g-code. + folder = "/tmp" + + draw { + rectangle(docBounds.offsetEdges(-9.0)) + println(docBounds) + } + } + + val drawingArea = plot.docBounds.offsetEdges(-10.0) + + val cb = ContourBuilder(true) + + // Handle mouse events and restrict drawing to drawing area + mouse.buttonDown.listen { + val p = plot.toDocumentSpace(it.position) + if (drawingArea.contains(p)) { + cb.moveTo(p) + } else { + cb.moveTo(drawingArea.contour.nearest(p).position) + } + } + mouse.dragged.listen { + val p = plot.toDocumentSpace(it.position) + if (drawingArea.contains(p)) { + cb.moveOrLineTo(p) + } else { + cb.moveOrLineTo(drawingArea.contour.nearest(p).position) + } + } + + // Draw contours of contour builder on every frame + extend { + plot.layer("drawing") { + strokeWeight = .5 + contours(cb.result) + } + } + } +} \ No newline at end of file diff --git a/orx-g-code/src/jvmDemo/kotlin/DemoSimplePlot.kt b/orx-g-code/src/jvmDemo/kotlin/DemoSimplePlot.kt new file mode 100644 index 000000000..b43a614e8 --- /dev/null +++ b/orx-g-code/src/jvmDemo/kotlin/DemoSimplePlot.kt @@ -0,0 +1,46 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa + +import org.openrndr.extra.gcode.LayerMode +import org.openrndr.extra.gcode.Plot + +import org.openrndr.extra.gcode.basicGrblSetup + +import org.openrndr.math.Vector2 + +fun main() = application { + configure { + width = 800 + height = 800 + } + + program { + + + // A4 Portrait + extend(Plot(dimensions = Vector2(210.0, 297.0))) { + generator = basicGrblSetup() + + // Export each layer to separate file + layerMode = LayerMode.MULTI_FILE + + // Set output files to be exported to tmp + // "g" to export g-code. + folder = "/tmp" + + draw { + // Rectangle in default layer + rectangle(docBounds.offsetEdges(-10.0)) + } + + layer("circles") { + // Stroke changes do not affect g-code + stroke = ColorRGBa.PINK + strokeWeight = .5 + (10..90 step 10).forEach { r -> + circle(docBounds.center, r.toDouble()) + } + } + } + } +} \ No newline at end of file diff --git a/orx-g-code/src/jvmMain/kotlin/Plot.kt b/orx-g-code/src/jvmMain/kotlin/Plot.kt new file mode 100644 index 000000000..cf998a8e1 --- /dev/null +++ b/orx-g-code/src/jvmMain/kotlin/Plot.kt @@ -0,0 +1,266 @@ +package org.openrndr.extra.gcode + +import mu.KotlinLogging +import org.openrndr.Extension +import org.openrndr.PresentationMode +import org.openrndr.Program +import org.openrndr.color.ColorRGBa +import org.openrndr.draw.Drawer +import org.openrndr.draw.isolated +import org.openrndr.extra.gcode.extensions.toCommands +import org.openrndr.math.Vector2 +import org.openrndr.shape.Composition +import org.openrndr.shape.CompositionDrawer +import org.openrndr.shape.Rectangle +import org.openrndr.shape.drawComposition +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.min + + +enum class RenderMode { + BEFORE, + AFTER, + MANUAL, +} + +enum class LayerMode { + SINGLE_FILE, MULTI_FILE, +} + +enum class Origin { + BOTTOM_LEFT, TOP_LEFT, CENTER +} + +typealias DrawFunction = CompositionDrawer.() -> Unit + +/** + * Configuration: + * When [manualRedraw] is true, the programs presentation mode is set to Manual on startup. + * "r" to trigger redraw. + * When [renderMode] is set to manual, the plot will not be rendered to the programms drawer. + * Then [render] has to be called to draw the plot. [origin] + */ +class Plot( + // Document + dimensions: Vector2, // Document size in mm + var name: String? = null, + val origin: Origin = Origin.BOTTOM_LEFT, + + // G-code + var generator: Generator = noopGenerator(), + var distanceTolerance: Double = 0.5, + var layerMode: LayerMode = LayerMode.SINGLE_FILE, + + // Rendering Properties + var defaultDrawColor: ColorRGBa = ColorRGBa.BLACK, + var defaultPenWeight: Double = 1.0, // In mm + var backgroundColor: ColorRGBa = ColorRGBa.WHITE, + val manualRedraw: Boolean = true, + var renderMode: RenderMode = RenderMode.AFTER, + + // Key Binds. Set null to disable + val gCodeBind: String? = "g", + val redrawBind: String? = "r", + + /** + * The folder where the g-code will be saved to. Default value is "gcode", saves in current working + * directory when set to null. + */ + var folder: String? = "gcode" + +) : Extension { + companion object { + val logger = KotlinLogging.logger {} + } + + override var enabled: Boolean = true + + val docBounds = when (origin) { + Origin.CENTER -> Rectangle(-dimensions.times(.5), dimensions.x, dimensions.y) + Origin.BOTTOM_LEFT, + Origin.TOP_LEFT -> Rectangle(0.0, 0.0, dimensions.x, dimensions.y) + } + + val layers: MutableMap = mutableMapOf() + private var order: List = listOf() + + private var scale = 1.0 + + private var program: Program? = null + + + override fun setup(program: Program) { + this.program = program + // Scale to fit in viewport + scale = min(program.width / docBounds.width, program.height / docBounds.height) + + if (name == null ) { + name = program.name + } + + if (!enabled) { + return + } + + if (manualRedraw) { + program.window.presentationMode = PresentationMode.MANUAL + program.window.requestDraw() + } + + program.keyboard.keyUp.listen { event -> + + if (redrawBind != null && event.name == redrawBind) { + program.window.requestDraw() + } + + if (gCodeBind != null && event.name == gCodeBind) { + when (layerMode) { + LayerMode.SINGLE_FILE -> { + writeFile(name ?: "plot", toCombinedGcode()) + } + + LayerMode.MULTI_FILE -> { + toSplitGcode().forEach { + writeFile("${name?:""}-${it.key}", it.value) + } + } + } + } + } + } + + override fun beforeDraw(drawer: Drawer, program: Program) { + if (enabled && renderMode == RenderMode.BEFORE) { + render(drawer) + } + } + + override fun afterDraw(drawer: Drawer, program: Program) { + if (enabled && renderMode == RenderMode.AFTER) { + render(drawer) + } + } + + /** + * Draws to the "default" layer. See [layer]. + */ + fun draw(drawFunction: DrawFunction) = layer("default", drawFunction) + + /** + * Draws to the given layer. + * If the layer with given name already exists, it is replaced. + */ + fun layer(name: String, drawFunction: DrawFunction) { + + val composition = layers[name] + + composition?.clear() + + // Append new layers to order + order = order.filter { it != name } + name + + layers[name] = drawComposition(composition = composition) { + this.composition.clear() + // Set default + fill = ColorRGBa.TRANSPARENT + strokeWeight = defaultPenWeight + stroke = defaultDrawColor + drawFunction(this) + } + } + + /** + * Renders this plot to the given drawer. + */ + fun render(drawer: Drawer) = scaled(drawer) { scaled -> + + // Draw background surface + drawer.isolated { + strokeWeight = 0.0 + fill = backgroundColor + rectangle(docBounds) + } + + // Layers + order.mapNotNull { layers[it] } + .forEach { scaled.composition(it) } + } + + /** + * Drawer scaled to document space, to fit to the window. + */ + fun scaled(drawer: Drawer, drawFunction: (Drawer) -> Unit) = drawer.isolated { + when (origin) { + Origin.BOTTOM_LEFT -> { + // Scale to fit screen and flip y-axis + translate(0.0, scaled(docBounds.height)) + scale(scale, -scale) + } + + Origin.TOP_LEFT -> { + // Scale to fit screen + scale(scale, scale) + } + + Origin.CENTER -> { + translate(.5 * scaled(docBounds.width), .5 * scaled(docBounds.height)) + scale(scale, -scale) + } + } + drawFunction(this) + } + + /** + * Scales and translates the given position from screen space to document space. + * Can be used to translate mouse events to draw to the plot. + */ + fun toDocumentSpace(p: Vector2): Vector2 { + val s = 1.0 / scale + return when (origin) { + Origin.BOTTOM_LEFT -> Vector2(p.x * s, docBounds.height - p.y * s) + Origin.TOP_LEFT -> p.times(s) + Origin.CENTER -> p.times(Vector2(s,-s)).plus(Vector2(-docBounds.width, docBounds.height) *.5) + } + } + + /** + * Double [v] scaled from document space to screen space. + */ + fun scaled(v: Double) = v * scale + + /** + * Scale from document space to screen space. + */ + fun scale() = scale + + /** + * Converts all layers to a single g-code string in the order they were added. + */ + fun toCombinedGcode(): String = order + .mapNotNull { l -> layers[l] } + .toCommands(generator, distanceTolerance).withoutDuplicates() + .toGcode() + + /** + * Converts each layer to a g-code string. + */ + fun toSplitGcode(): Map = layers.mapValues { e -> + val commands = e.value.toCommands(generator, distanceTolerance).withoutDuplicates() + (generator.setup + commands + generator.end).toGcode() + } + + /** + * Writes [content] to file "[folder]/timestamp-[name].[extension]" + */ + fun writeFile(name: String, content: String, extension: String = "gcode") { + val formatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmSS") + val timestamp = formatter.format(LocalDateTime.now()) + val fileName = "$timestamp-$name.$extension" + + File(File(folder ?: "."), fileName) + .also { logger.info { "šŸ’¾Writing g-code $name to ${it.path}" } } + .writeText(content) + } +} \ No newline at end of file diff --git a/orx-g-code/src/jvmTest/kotlin/TestCommands.kt b/orx-g-code/src/jvmTest/kotlin/TestCommands.kt new file mode 100644 index 000000000..2c2a537f4 --- /dev/null +++ b/orx-g-code/src/jvmTest/kotlin/TestCommands.kt @@ -0,0 +1,36 @@ +package test + +import kotlin.test.* +import org.openrndr.extra.gcode.Command +import org.openrndr.extra.gcode.withoutDuplicates + +class TestCommands { + + @Test + fun `empty commands`() { + assertContentEquals(emptyList(), emptyList().withoutDuplicates()) + } + + @Test + fun `should remove duplicate commands, keeping comments`() { + val commands = listOf( + "G0 X1 Y2 F3", + "G0 X2 Y2 F3", + "G0 X2 Y2 F3", + ";G0 X2 Y2 F3", + "(G0 X2 Y2 F3)", + "G0 X2 Y2 F3", + "G0 X3 Y2 F3", + ) + + val expected = listOf( + "G0 X1 Y2 F3", + "G0 X2 Y2 F3", + ";G0 X2 Y2 F3", + "(G0 X2 Y2 F3)", + "G0 X3 Y2 F3", + ) + + assertContentEquals(expected, commands.withoutDuplicates()) + } +} \ No newline at end of file diff --git a/orx-g-code/src/jvmTest/kotlin/TestDouble.kt b/orx-g-code/src/jvmTest/kotlin/TestDouble.kt new file mode 100644 index 000000000..e574f0152 --- /dev/null +++ b/orx-g-code/src/jvmTest/kotlin/TestDouble.kt @@ -0,0 +1,27 @@ +package test + +import org.openrndr.extra.gcode.roundedTo +import kotlin.test.Test +import kotlin.test.assertEquals + +class TestDouble { + @Test + fun roundedTo() { + data class Test(val decimals: Int, val value: Double, val want: String) + + listOf( + Test(3, 0.0, "0.0"), + Test(0, 0.0, "0"), + Test(3, 123.0, "123.0"), + Test(0, 123.0, "123"), + Test(3, 123.1234567890, "123.123"), + Test(0, 123.1234567890, "123"), + Test(3, 123.1239, "123.124"), + Test(3, -123.1239, "-123.124"), + Test(0, -123.1239, "-123"), + Test(-2, -123.1239, "-123.12"), + ).forEach { + assertEquals(it.want, it.value.roundedTo(it.decimals)) + } + } +} \ No newline at end of file diff --git a/orx-g-code/src/jvmTest/kotlin/TestPlotRegression.kt b/orx-g-code/src/jvmTest/kotlin/TestPlotRegression.kt new file mode 100644 index 000000000..7b633cdcf --- /dev/null +++ b/orx-g-code/src/jvmTest/kotlin/TestPlotRegression.kt @@ -0,0 +1,208 @@ +package test + + +import org.openrndr.extra.gcode.Plot +import org.openrndr.extra.gcode.basicGrblSetup +import org.openrndr.math.Vector2 +import kotlin.test.* + + +class TestPlotRegression { + + lateinit var simplePlot: Plot + + lateinit var multilayerPlot: Plot + + + @BeforeTest + fun setup() { + + simplePlot = Plot(dimensions = Vector2(210.0, 297.0),generator = basicGrblSetup()) + simplePlot.draw { + circle(100.0, 100.0, 30.0) + } + + multilayerPlot = Plot(dimensions = Vector2(210.0, 297.0),generator = basicGrblSetup()) + multilayerPlot.draw { + lineSegment(0.0, 20.0, 100.0, 200.0) + lineSegment(0.5, 20.5, 100.5, 200.5) + } + multilayerPlot.layer("rect") { + rectangle(10.1234, 30.1234, 50.1234, 70.1234) + } + multilayerPlot.layer("dot") { + lineSegment(25.55555, 35.55555,25.55555, 35.55555 ) + } + } + + + @Test + fun `single file with circle`() { + + + val expected = """ + G21 + G90 + ;begin composition + ;begin shape: 0 + G0 X70.0 Y100.0 + M3 S255 + G1 X70.0 Y100.0 F500.0 + G1 X70.105 Y96.918 F500.0 + G1 X71.304 Y91.059 F500.0 + G1 X73.581 Y85.675 F500.0 + G1 X76.816 Y80.887 F500.0 + G1 X80.887 Y76.816 F500.0 + G1 X85.675 Y73.581 F500.0 + G1 X91.059 Y71.304 F500.0 + G1 X96.918 Y70.105 F500.0 + G1 X100.0 Y70.0 F500.0 + G1 X103.082 Y70.105 F500.0 + G1 X108.941 Y71.304 F500.0 + G1 X114.325 Y73.581 F500.0 + G1 X119.113 Y76.816 F500.0 + G1 X123.184 Y80.887 F500.0 + G1 X126.419 Y85.675 F500.0 + G1 X128.696 Y91.059 F500.0 + G1 X129.895 Y96.918 F500.0 + G1 X130.0 Y100.0 F500.0 + G1 X129.895 Y103.082 F500.0 + G1 X128.696 Y108.941 F500.0 + G1 X126.419 Y114.325 F500.0 + G1 X123.184 Y119.113 F500.0 + G1 X119.113 Y123.184 F500.0 + G1 X114.325 Y126.419 F500.0 + G1 X108.941 Y128.696 F500.0 + G1 X103.082 Y129.895 F500.0 + G1 X100.0 Y130.0 F500.0 + G1 X96.918 Y129.895 F500.0 + G1 X91.059 Y128.696 F500.0 + G1 X85.675 Y126.419 F500.0 + G1 X80.887 Y123.184 F500.0 + G1 X76.816 Y119.113 F500.0 + G1 X73.581 Y114.325 F500.0 + G1 X71.304 Y108.941 F500.0 + G1 X70.105 Y103.082 F500.0 + G1 X70.0 Y100.0 F500.0 + M3 S0 + ;end shape: 0 + ;end composition + G0 X0 Y0 + G90 + + """.trimIndent() + + assertEquals(expected, simplePlot.toCombinedGcode()) + } + + + @Test + fun `multiple files from layers`() { + val default = """ + G21 + G90 + ;begin composition + ;begin shape: 0 + G0 X0.0 Y20.0 + M3 S255 + G1 X100.0 Y200.0 F500.0 + M3 S0 + ;end shape: 0 + ;begin shape: 1 + G0 X0.5 Y20.5 + M3 S255 + G1 X100.5 Y200.5 F500.0 + M3 S0 + ;end shape: 1 + ;end composition + G0 X0 Y0 + G90 + + """.trimIndent() + val rect = """ + G21 + G90 + ;begin composition + ;begin shape: 0 + G0 X10.123 Y30.123 + M3 S255 + G1 X60.247 Y30.123 F500.0 + G1 X60.247 Y100.247 F500.0 + G1 X10.123 Y100.247 F500.0 + G1 X10.123 Y30.123 F500.0 + M3 S0 + ;end shape: 0 + ;end composition + G0 X0 Y0 + G90 + + """.trimIndent() + + val dot = """ + G21 + G90 + ;begin composition + ;begin shape: 0 + G0 X25.556 Y35.556 + M3 S255 + ;dot + M3 S0 + ;end shape: 0 + ;end composition + G0 X0 Y0 + G90 + + """.trimIndent() + + val got = multilayerPlot.toSplitGcode() + assertEquals(default, got["default"]) + assertEquals(rect, got["rect"]) + assertEquals(dot, got["dot"]) + } + + @Test + fun `single file from layers`() { + val expected = """ + G21 + G90 + ;begin composition + ;begin shape: 0 + G0 X0.0 Y20.0 + M3 S255 + G1 X100.0 Y200.0 F500.0 + M3 S0 + ;end shape: 0 + ;begin shape: 1 + G0 X0.5 Y20.5 + M3 S255 + G1 X100.5 Y200.5 F500.0 + M3 S0 + ;end shape: 1 + ;end composition + ;begin composition + ;begin shape: 0 + G0 X10.123 Y30.123 + M3 S255 + G1 X60.247 Y30.123 F500.0 + G1 X60.247 Y100.247 F500.0 + G1 X10.123 Y100.247 F500.0 + G1 X10.123 Y30.123 F500.0 + M3 S0 + ;end shape: 0 + ;end composition + ;begin composition + ;begin shape: 0 + G0 X25.556 Y35.556 + M3 S255 + ;dot + M3 S0 + ;end shape: 0 + ;end composition + G0 X0 Y0 + G90 + + """.trimIndent() + assertEquals(expected, multilayerPlot.toCombinedGcode()) + } + +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index c387c17d8..3e47910cd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -88,6 +88,7 @@ include( "orx-depth-camera", "orx-jvm:orx-depth-camera-calibrator", "orx-view-box", - "orx-turtle" + "orx-turtle", + "orx-g-code", ) ) \ No newline at end of file