diff --git a/src/main/java/de/thomas_oster/liblasercut/LaserJob.java b/src/main/java/de/thomas_oster/liblasercut/LaserJob.java index d229ea8b..6b11f16b 100644 --- a/src/main/java/de/thomas_oster/liblasercut/LaserJob.java +++ b/src/main/java/de/thomas_oster/liblasercut/LaserJob.java @@ -76,7 +76,7 @@ public double getStartY() { return startY; } - + /** * Get the X-coordinate of the origin (top-left corner of laser bed) in mm. * Initially 0, but changes if applyStartPoint() is used. @@ -166,6 +166,9 @@ public void applyStartPoint() c.setY((int) (c.getY() - Util.mm2inch(startY)*p.getDPI())); } } + // keep the cached bounding box (built incrementally as points were added) in sync, + // otherwise getMinX/Y()/getMaxX/Y() - and anything sent from them, e.g. Ruida's frame/outline - go stale + ((VectorPart) p).shiftBounds(Util.mm2inch(startX) * p.getDPI(), Util.mm2inch(startY) * p.getDPI()); } else if (p instanceof RasterPart) { diff --git a/src/main/java/de/thomas_oster/liblasercut/VectorPart.java b/src/main/java/de/thomas_oster/liblasercut/VectorPart.java index 40aa8af5..37557389 100644 --- a/src/main/java/de/thomas_oster/liblasercut/VectorPart.java +++ b/src/main/java/de/thomas_oster/liblasercut/VectorPart.java @@ -73,6 +73,22 @@ public VectorCommand[] getCommandList() return commands.toArray(new VectorCommand[0]); } + /** + * Shifts the cached bounding box by (dx,dy). Used by LaserJob.applyStartPoint(), which shifts + * every command's coordinates directly and must keep this cache (built incrementally as points + * are added, see checkMin()/checkMax()) in sync - otherwise getMinX/Y()/getMaxX/Y() go stale. + */ + void shiftBounds(double dx, double dy) + { + if (minX != Double.POSITIVE_INFINITY) + { + minX -= dx; + maxX -= dx; + minY -= dy; + maxY -= dy; + } + } + private void checkMin(double x, double y) { if (x < minX) diff --git a/src/main/java/de/thomas_oster/liblasercut/drivers/GenericGcodeDriver.java b/src/main/java/de/thomas_oster/liblasercut/drivers/GenericGcodeDriver.java index 5c3b593e..5e765ea4 100644 --- a/src/main/java/de/thomas_oster/liblasercut/drivers/GenericGcodeDriver.java +++ b/src/main/java/de/thomas_oster/liblasercut/drivers/GenericGcodeDriver.java @@ -529,9 +529,17 @@ protected void setFocus(PrintStream out, double focus) throws IOException { } } + // Pivot for mirroring a flipped axis: bedWidth/bedHeight when the job spans the whole bed (home-referenced, + // absolute execution) - but applyStartPoint() re-references coordinates to a local offset from the start + // point (0..a few mm, not 0..bedWidth), so mirroring around the far bed edge is meaningless there and pins + // the start point at the wrong end of travel. Pivot is 0 in that case, so the start point lands at machine + // home (0) instead. Set per job in sendJob(). + protected transient double flipPivotX = 0; + protected transient double flipPivotY = 0; + protected void move(PrintStream out, double x, double y, double resolution) throws IOException { - x = isFlipXaxis() ? getBedWidth() - Util.px2mm(x, resolution) : Util.px2mm(x, resolution); - y = isFlipYaxis() ? getBedHeight() - Util.px2mm(y, resolution) : Util.px2mm(y, resolution); + x = isFlipXaxis() ? flipPivotX - Util.px2mm(x, resolution) : Util.px2mm(x, resolution); + y = isFlipYaxis() ? flipPivotY - Util.px2mm(y, resolution) : Util.px2mm(y, resolution); currentSpeed = getTravel_speed(); if (blankLaserDuringRapids) @@ -546,8 +554,8 @@ protected void move(PrintStream out, double x, double y, double resolution) thro } protected void line(PrintStream out, double x, double y, double resolution) throws IOException { - x = isFlipXaxis() ? getBedWidth() - Util.px2mm(x, resolution) : Util.px2mm(x, resolution); - y = isFlipYaxis() ? getBedHeight() - Util.px2mm(y, resolution) : Util.px2mm(y, resolution); + x = isFlipXaxis() ? flipPivotX - Util.px2mm(x, resolution) : Util.px2mm(x, resolution); + y = isFlipYaxis() ? flipPivotY - Util.px2mm(y, resolution) : Util.px2mm(y, resolution); String append = ""; if (nextPower != currentPower) @@ -982,6 +990,8 @@ public void sendJob(LaserJob job, ProgressListener pl, List warnings) th } public void writeJobCode(LaserJob job, ProgressListener pl) throws IOException { + flipPivotX = job.getTransformedOriginX() == 0 ? getBedWidth() : 0; + flipPivotY = job.getTransformedOriginY() == 0 ? getBedHeight() : 0; writeInitializationCode(); pl.progressChanged(this, 20); int i = 0; diff --git a/src/main/java/de/thomas_oster/liblasercut/drivers/Grbl.java b/src/main/java/de/thomas_oster/liblasercut/drivers/Grbl.java index 43ac8e72..dc2124fe 100644 --- a/src/main/java/de/thomas_oster/liblasercut/drivers/Grbl.java +++ b/src/main/java/de/thomas_oster/liblasercut/drivers/Grbl.java @@ -168,8 +168,8 @@ protected String waitForIdentificationLine(ProgressListener pl) throws IOExcepti */ @Override protected void move(PrintStream out, double x, double y, double resolution) throws IOException { - x = isFlipXaxis() ? getBedWidth() - Util.px2mm(x, resolution) : Util.px2mm(x, resolution); - y = isFlipYaxis() ? getBedHeight() - Util.px2mm(y, resolution) : Util.px2mm(y, resolution); + x = isFlipXaxis() ? flipPivotX - Util.px2mm(x, resolution) : Util.px2mm(x, resolution); + y = isFlipYaxis() ? flipPivotY - Util.px2mm(y, resolution) : Util.px2mm(y, resolution); currentSpeed = getTravel_speed(); if (blankLaserDuringRapids) { diff --git a/src/main/java/de/thomas_oster/liblasercut/drivers/Ruida.java b/src/main/java/de/thomas_oster/liblasercut/drivers/Ruida.java index 0cf8fdab..84f36cf5 100644 --- a/src/main/java/de/thomas_oster/liblasercut/drivers/Ruida.java +++ b/src/main/java/de/thomas_oster/liblasercut/drivers/Ruida.java @@ -213,6 +213,14 @@ public double getRasterPadding() { return 0; } + // Pivot for mirroring a flipped axis: bedWidth/bedHeight when the job spans the whole bed (home-referenced, + // absolute execution) - but applyStartPoint() re-references coordinates to a local offset from the start + // point (0..a few mm, not 0..bedWidth), so mirroring around the far bed edge is meaningless there and pins + // the start point at the wrong end of travel. Pivot is 0 in that case, so the start point lands at machine + // home (0) instead. Set per job in writeJobCode(). + private transient double flipPivotX = 0; + private transient double flipPivotY = 0; + private void find_and_write_bounding_box(LaserJob job) throws IOException { double minX = 0.0; @@ -223,10 +231,15 @@ private void find_and_write_bounding_box(LaserJob job) throws IOException /* compute bounding box */ for (JobPart p : job.getParts()) { - double min_x = isFlipXaxis() ? getBedWidth() - Util.px2mm(p.getMinX(), p.getDPI()) : Util.px2mm(p.getMinX(), p.getDPI()); - double min_y = isFlipYaxis() ? getBedHeight() - Util.px2mm(p.getMinY(), p.getDPI()) : Util.px2mm(p.getMinY(), p.getDPI()); - double max_x = isFlipXaxis() ? getBedWidth() - Util.px2mm(p.getMaxX(), p.getDPI()) : Util.px2mm(p.getMaxX(), p.getDPI()); - double max_y = isFlipYaxis() ? getBedHeight() - Util.px2mm(p.getMaxY(), p.getDPI()) : Util.px2mm(p.getMaxY(), p.getDPI()); + // flipping reverses min/max order, so re-sort instead of flipping each bound independently + double x1 = isFlipXaxis() ? flipPivotX - Util.px2mm(p.getMinX(), p.getDPI()) : Util.px2mm(p.getMinX(), p.getDPI()); + double x2 = isFlipXaxis() ? flipPivotX - Util.px2mm(p.getMaxX(), p.getDPI()) : Util.px2mm(p.getMaxX(), p.getDPI()); + double y1 = isFlipYaxis() ? flipPivotY - Util.px2mm(p.getMinY(), p.getDPI()) : Util.px2mm(p.getMinY(), p.getDPI()); + double y2 = isFlipYaxis() ? flipPivotY - Util.px2mm(p.getMaxY(), p.getDPI()) : Util.px2mm(p.getMaxY(), p.getDPI()); + double min_x = Math.min(x1, x2); + double max_x = Math.max(x1, x2); + double min_y = Math.min(y1, y2); + double max_y = Math.max(y1, y2); if (first) { minX = min_x; maxX = max_x; @@ -259,8 +272,8 @@ private void find_and_write_bounding_box(LaserJob job) throws IOException private void vector(double x, double y, double dpi, boolean as_cut, boolean force_abs) throws IOException { - double x_mm = isFlipXaxis() ? getBedWidth() - Util.px2mm(x, dpi) : Util.px2mm(x, dpi); - double y_mm = isFlipYaxis() ? getBedHeight() - Util.px2mm(y, dpi) : Util.px2mm(y, dpi); + double x_mm = isFlipXaxis() ? flipPivotX - Util.px2mm(x, dpi) : Util.px2mm(x, dpi); + double y_mm = isFlipYaxis() ? flipPivotY - Util.px2mm(y, dpi) : Util.px2mm(y, dpi); boolean as_absolute; /* compute distance to last known position */ @@ -543,6 +556,8 @@ public void writeJobCode(LaserJob job, ProgressListener pl) throws IOException { last_y = Double.NaN; vector_count = 0; travel_distance = 0; + flipPivotX = job.getTransformedOriginX() == 0 ? getBedWidth() : 0; + flipPivotY = job.getTransformedOriginY() == 0 ? getBedHeight() : 0; try { stream = new ByteStream(out, (byte)0x88); // 0x11, 0x38 @@ -593,10 +608,15 @@ public void writeJobCode(LaserJob job, ProgressListener pl) throws IOException { /* FALLTHRU */ if (p instanceof VectorPart) { - double top_left_x = Util.px2mm(p.getMinX(), p.getDPI()); - double top_left_y = Util.px2mm(p.getMinY(), p.getDPI()); - double bottom_right_x = Util.px2mm(p.getMaxX(), p.getDPI()); - double bottom_right_y = Util.px2mm(p.getMaxY(), p.getDPI()); + // must match the flip applied to the actual move commands in vector() + double lx1 = isFlipXaxis() ? flipPivotX - Util.px2mm(p.getMinX(), p.getDPI()) : Util.px2mm(p.getMinX(), p.getDPI()); + double lx2 = isFlipXaxis() ? flipPivotX - Util.px2mm(p.getMaxX(), p.getDPI()) : Util.px2mm(p.getMaxX(), p.getDPI()); + double ly1 = isFlipYaxis() ? flipPivotY - Util.px2mm(p.getMinY(), p.getDPI()) : Util.px2mm(p.getMinY(), p.getDPI()); + double ly2 = isFlipYaxis() ? flipPivotY - Util.px2mm(p.getMaxY(), p.getDPI()) : Util.px2mm(p.getMaxY(), p.getDPI()); + double top_left_x = Math.min(lx1, lx2); + double top_left_y = Math.min(ly1, ly2); + double bottom_right_x = Math.max(lx1, lx2); + double bottom_right_y = Math.max(ly1, ly2); /* write dimensions */ stream.hex("E752").byteint(part_number).absoluteMM(top_left_x).absoluteMM(top_left_y); stream.hex("E753").byteint(part_number).absoluteMM(bottom_right_x).absoluteMM(bottom_right_y); diff --git a/src/test/java/de/thomas_oster/liblasercut/drivers/RuidaTest.java b/src/test/java/de/thomas_oster/liblasercut/drivers/RuidaTest.java new file mode 100644 index 00000000..f7d62de6 --- /dev/null +++ b/src/test/java/de/thomas_oster/liblasercut/drivers/RuidaTest.java @@ -0,0 +1,157 @@ +/* + This file is part of LibLaserCut. + Copyright (C) 2011 - 2014 Thomas Oster + + LibLaserCut is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + LibLaserCut is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with LibLaserCut. If not, see . + + */ +package de.thomas_oster.liblasercut.drivers; + +import de.thomas_oster.liblasercut.LaserJob; +import de.thomas_oster.liblasercut.ProgressListenerDummy; +import de.thomas_oster.liblasercut.VectorPart; +import de.thomas_oster.liblasercut.properties.FloatMinMaxPowerSpeedFrequencyProperty; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Field; +import static org.junit.Assert.*; +import org.junit.Test; + +/** + * Verifies the E7 03 bounding-box preamble (used by the laser's own "trace outline" feature) stays in + * sync with a job's start point, and stays correctly ordered (min < max) when an axis is flipped. + * Decodes the actual RD byte stream (Ruida's 7-bit-packed, XOR-scrambled encoding) instead of just + * checking driver-internal state, so this also catches encoding mistakes. + */ +public class RuidaTest +{ + /** Inverts ByteStream.write(): XOR with magic, undo the +1, undo the bit0/bit7 swap. */ + private static int unscramble(int raw, int magic) + { + int i = ((raw - 1) & 0xff) ^ (magic & 0xff); + i ^= (i >> 7) & 0xff; + i ^= (i << 7) & 0xff; + i ^= (i >> 7) & 0xff; + return i & 0xff; + } + + /** Decodes a 5-byte, 7-bit-packed, MSB-first absolute value (as written by ByteStream.absoluteMM) to mm. */ + private static double decodeAbsoluteMM(byte[] raw, int offset, int magic) + { + long val = 0; + for (int i = 0; i < 5; i++) + { + val = (val << 7) | unscramble(raw[offset + i] & 0xff, magic); + } + return val / 1000.0; + } + + private LaserJob buildJobWithOnePoint(double xMm, double yMm) throws Exception + { + LaserJob job = new LaserJob("test", "test", "test"); + VectorPart vp = new VectorPart(new FloatMinMaxPowerSpeedFrequencyProperty(), 1000); + double dpmm = 1000 / 25.4; + vp.moveto((int) Math.round(xMm * dpmm), (int) Math.round(yMm * dpmm)); + vp.lineto((int) Math.round(xMm * dpmm), (int) Math.round(yMm * dpmm)); + job.addPart(vp); + return job; + } + + /** + * Sends the job and decodes the E7 03 (bounding box top-left) command. Calls writeJobCode() directly + * (like sendJob() eventually does, after its own checkJob() on the original, unshifted job) rather + * than through saveJob(), which - unlike sendJob() - doesn't call applyStartPoint() itself (a separate, + * pre-existing gap not under test here). + */ + private double[] sendAndDecodeBoundingBoxMin(Ruida ruida, LaserJob job) throws Exception + { + job.applyStartPoint(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Field outField = Ruida.class.getDeclaredField("out"); + outField.setAccessible(true); + outField.set(ruida, new PrintStream(out)); + ruida.writeJobCode(job, new ProgressListenerDummy()); + byte[] raw = out.toByteArray(); + for (int i = 0; i + 11 < raw.length; i++) + { + if (unscramble(raw[i] & 0xff, 0x88) == 0xE7 && unscramble(raw[i + 1] & 0xff, 0x88) == 0x03) + { + double x = decodeAbsoluteMM(raw, i + 2, 0x88); + double y = decodeAbsoluteMM(raw, i + 7, 0x88); + return new double[]{x, y}; + } + } + fail("E7 03 (bounding box) command not found in output"); + return null; + } + + @Test + public void boundingBoxReflectsStartPoint_noFlip() throws Exception + { + Ruida ruida = new Ruida(); + ruida.setBedWidth(600.0); + ruida.setBedHeigth(400.0); + ruida.setFlipXaxis(false); + ruida.setFlipYaxis(false); + + // design point at canonical (120, 80); start point anchored at canonical (100, 60) + // -> after the shift, the point is at local (20, 20). + LaserJob job = buildJobWithOnePoint(120, 80); + job.setStartPoint(100, 60); + + double[] xy = sendAndDecodeBoundingBoxMin(ruida, job); + assertEquals(20, xy[0], 0.05); + assertEquals(20, xy[1], 0.05); + } + + @Test + public void boundingBoxReflectsStartPoint_flippedX() throws Exception + { + Ruida ruida = new Ruida(); + ruida.setBedWidth(600.0); + ruida.setBedHeigth(400.0); + ruida.setFlipXaxis(true); + ruida.setFlipYaxis(false); + + // On a flipped axis, machine home is 0, not bedWidth - mirroring a *local* (start-point-relative) + // offset around the far bed edge would place the start point at completely the wrong end of travel. + // A valid anchor for a flipped axis must be >= the design's own extent (mirror image of the + // non-flipped case, where the anchor must be <=), so the design extends validly from home outward. + LaserJob job = buildJobWithOnePoint(120, 80); + job.setStartPoint(150, 60); + + double[] xy = sendAndDecodeBoundingBoxMin(ruida, job); + // local x = 120 - 150 = -30; flipped + start-point-referenced -> mirrored around machine home (0): 0 - (-30) = 30 + assertEquals(30, xy[0], 0.05); + // y not flipped: local y = 80 - 60 = 20 + assertEquals(20, xy[1], 0.05); + } + + @Test + public void boundingBoxUnaffectedWithoutStartPoint() throws Exception + { + Ruida ruida = new Ruida(); + ruida.setBedWidth(600.0); + ruida.setBedHeigth(400.0); + ruida.setFlipXaxis(true); + ruida.setFlipYaxis(false); + + LaserJob job = buildJobWithOnePoint(120, 80); + // no setStartPoint() call at all + + double[] xy = sendAndDecodeBoundingBoxMin(ruida, job); + assertEquals(600 - 120, xy[0], 0.05); + assertEquals(80, xy[1], 0.05); + } +}