Skip to content

Commit 44e11f4

Browse files
authored
Fx designer fixes (#3130)
* Fix primary mouse binding * Remember the drawer state * Adjusted the scroll bar styling * Add a file loader service * Fix bounds calculation for arcs * Now render the gcode model with rotations and fix bounds * Fix problem with rendering when start position is NaN * Reapplied depth testing on models
1 parent 85251d3 commit 44e11f4

28 files changed

Lines changed: 795 additions & 151 deletions

File tree

ugs-core/src/com/willwinder/universalgcodesender/gcode/processors/Stats.java

Lines changed: 157 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ This file is part of Universal Gcode Sender (UGS).
1818
*/
1919
package com.willwinder.universalgcodesender.gcode.processors;
2020

21+
import com.willwinder.universalgcodesender.gcode.GcodeParser.GcodeMeta;
22+
import com.willwinder.universalgcodesender.gcode.GcodePreprocessorUtils;
2123
import com.willwinder.universalgcodesender.gcode.GcodeState;
2224
import com.willwinder.universalgcodesender.gcode.GcodeStats;
25+
import com.willwinder.universalgcodesender.gcode.util.Code;
2326
import com.willwinder.universalgcodesender.gcode.util.GcodeParserException;
27+
import com.willwinder.universalgcodesender.gcode.util.GcodeParserUtils;
28+
import com.willwinder.universalgcodesender.gcode.util.PlaneFormatter;
2429
import com.willwinder.universalgcodesender.model.Position;
2530
import com.willwinder.universalgcodesender.model.UnitUtils.Units;
31+
import com.willwinder.universalgcodesender.types.PointSegment;
2632

2733
import java.util.Collections;
2834
import java.util.List;
@@ -34,35 +40,174 @@ This file is part of Universal Gcode Sender (UGS).
3440
public class Stats implements CommandProcessor, GcodeStats {
3541
private static Units defaultUnits = Units.MM;
3642

43+
/** Cardinal angles (0, 90, 180, 270 degrees) where a circle reaches its axis-aligned extremes. */
44+
private static final double[] CARDINAL_ANGLES = {0, Math.PI / 2, Math.PI, 3 * Math.PI / 2};
45+
private static final double TWO_PI = 2 * Math.PI;
46+
private static final double EPSILON = 1e-9;
47+
3748
private Position min = new Position(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE, Units.MM);
3849
private Position max = new Position(Double.MIN_VALUE, Double.MIN_VALUE, Double.MIN_VALUE, Units.MM);
3950

4051
private long commandCount = 0;
52+
private Position previous = null;
53+
54+
/** Step size used when sampling a rotary move so its swept cartesian extents are captured. */
55+
private static final double MAX_DEGREES_PER_STEP = 5;
4156

4257
@Override
4358
public List<String> processCommand(String command, GcodeState state) throws GcodeParserException {
4459
Position c = state.currentPoint;
4560
if (c != null) {
46-
Position p = new Position(c.x, c.y, c.z, state.isMetric ? Units.MM : Units.INCH)
47-
.getPositionIn(defaultUnits);
48-
49-
// Update min
50-
min.x = getMin(min.x, p.x);
51-
min.y = getMin(min.y, p.y);
52-
min.z = getMin(min.z, p.z);
53-
54-
// Update max
55-
max.x = getMax(max.x, p.x);
56-
max.y = getMax(max.y, p.y);
57-
max.z = getMax(max.z, p.z);
61+
record(c, state.isMetric);
62+
if (isArcMotion(state.currentMotionMode) && previous != null && !isSamePoint(previous, c)) {
63+
expandArcBounds(command, state, previous);
64+
} else if (previous != null && hasRotationChange(previous, c)) {
65+
expandRotationBounds(previous, c, state.isMetric);
66+
}
5867

5968
// Num commands
6069
commandCount++;
70+
previous = new Position(c);
6171
}
6272

6373
return Collections.singletonList(command);
6474
}
6575

76+
private void expandRotationBounds(Position start, Position end, boolean metric) {
77+
double startA = zeroIfNaN(start.a);
78+
double startB = zeroIfNaN(start.b);
79+
double startC = zeroIfNaN(start.c);
80+
double endA = zeroIfNaN(end.a);
81+
double endB = zeroIfNaN(end.b);
82+
double endC = zeroIfNaN(end.c);
83+
double maxDelta = Math.max(Math.abs(endA - startA), Math.max(Math.abs(endB - startB), Math.abs(endC - startC)));
84+
int steps = (int) Math.ceil(maxDelta / MAX_DEGREES_PER_STEP);
85+
86+
for (int i = 1; i < steps; i++) {
87+
double t = (double) i / steps;
88+
Position sample = new Position(
89+
lerp(start.x, end.x, t),
90+
lerp(start.y, end.y, t),
91+
lerp(start.z, end.z, t),
92+
lerp(startA, endA, t),
93+
lerp(startB, endB, t),
94+
lerp(startC, endC, t),
95+
metric ? Units.MM : Units.INCH);
96+
record(sample, metric);
97+
}
98+
}
99+
100+
// An arc only reports its end point, so add the cardinal crossings (0/90/180/270 degrees from
101+
// its center) that fall within the swept angle, which is where it reaches its axis extremes.
102+
private void expandArcBounds(String command, GcodeState endState, Position start) {
103+
try {
104+
GcodeState startState = endState.copy();
105+
startState.currentPoint = new Position(start);
106+
107+
PointSegment arc = findArc(GcodeParserUtils.processCommand(command, 0, startState, true));
108+
if (arc == null) {
109+
return;
110+
}
111+
112+
PlaneFormatter plane = new PlaneFormatter(arc.getPlaneState());
113+
Position center = arc.center();
114+
Position end = arc.point();
115+
double radius = arc.getRadius();
116+
if (radius <= 0) {
117+
radius = Math.hypot(plane.axis0(start) - plane.axis0(center), plane.axis1(start) - plane.axis1(center));
118+
}
119+
120+
double startAngle = GcodePreprocessorUtils.getAngle(center, start, plane);
121+
double endAngle = GcodePreprocessorUtils.getAngle(center, end, plane);
122+
123+
for (double cardinal : CARDINAL_ANGLES) {
124+
if (!arcContainsAngle(startAngle, endAngle, cardinal, arc.isClockwise())) {
125+
continue;
126+
}
127+
128+
Position extreme = new Position(start);
129+
plane.setAxis0(extreme, plane.axis0(center) + radius * Math.cos(cardinal));
130+
plane.setAxis1(extreme, plane.axis1(center) + radius * Math.sin(cardinal));
131+
record(extreme, endState.isMetric);
132+
}
133+
} catch (GcodeParserException e) {
134+
// If the arc can't be parsed, fall back to the start/end points already recorded.
135+
}
136+
}
137+
138+
private void record(Position rawPoint, boolean metric) {
139+
Position p = new Position(rawPoint.x, rawPoint.y, rawPoint.z,
140+
rawPoint.a, rawPoint.b, rawPoint.c, metric ? Units.MM : Units.INCH)
141+
.getPositionIn(defaultUnits);
142+
143+
// Only fold rotations into cartesian coordinates when the point actually rotates. Doing it
144+
// unconditionally would turn undefined (NaN) axes into 0 and wrongly pull the bounds to the
145+
// origin for plain XYZ moves.
146+
if (p.hasRotation()) {
147+
p = p.getCartesian();
148+
}
149+
150+
min.x = getMin(min.x, p.x);
151+
min.y = getMin(min.y, p.y);
152+
min.z = getMin(min.z, p.z);
153+
154+
max.x = getMax(max.x, p.x);
155+
max.y = getMax(max.y, p.y);
156+
max.z = getMax(max.z, p.z);
157+
}
158+
159+
private static boolean isArcMotion(Code motionMode) {
160+
return motionMode == Code.G2 || motionMode == Code.G3;
161+
}
162+
163+
private static boolean isSamePoint(Position a, Position b) {
164+
return a.x == b.x && a.y == b.y && a.z == b.z;
165+
}
166+
167+
private static boolean hasRotationChange(Position from, Position to) {
168+
return zeroIfNaN(from.a) != zeroIfNaN(to.a)
169+
|| zeroIfNaN(from.b) != zeroIfNaN(to.b)
170+
|| zeroIfNaN(from.c) != zeroIfNaN(to.c);
171+
}
172+
173+
private static double lerp(double from, double to, double t) {
174+
return from + (to - from) * t;
175+
}
176+
177+
private static double zeroIfNaN(double value) {
178+
return Double.isNaN(value) ? 0 : value;
179+
}
180+
181+
private static PointSegment findArc(List<GcodeMeta> commands) {
182+
if (commands == null) {
183+
return null;
184+
}
185+
for (GcodeMeta meta : commands) {
186+
if (meta.point != null && meta.point.isArc()) {
187+
return meta.point;
188+
}
189+
}
190+
return null;
191+
}
192+
193+
/**
194+
* @return true if {@code angle} is swept when travelling along the arc from {@code startAngle}
195+
* to {@code endAngle} in the given direction. All angles are in radians.
196+
*/
197+
private static boolean arcContainsAngle(double startAngle, double endAngle, double angle, boolean clockwise) {
198+
double sweep = clockwise ? mod2pi(startAngle - endAngle) : mod2pi(endAngle - startAngle);
199+
if (sweep < EPSILON) {
200+
sweep = TWO_PI; // a full circle (start == end) reaches every extreme
201+
}
202+
double toAngle = clockwise ? mod2pi(startAngle - angle) : mod2pi(angle - startAngle);
203+
return toAngle <= sweep + EPSILON;
204+
}
205+
206+
private static double mod2pi(double value) {
207+
double remainder = value % TWO_PI;
208+
return remainder < 0 ? remainder + TWO_PI : remainder;
209+
}
210+
66211
private double getMin(double value1, double value2) {
67212
if (Double.isNaN(value1)) {
68213
return value2;

ugs-core/src/com/willwinder/universalgcodesender/model/GUIBackend.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ This file is part of Universal Gcode Sender (UGS).
4040
import com.willwinder.universalgcodesender.model.events.ControllerStateEvent;
4141
import com.willwinder.universalgcodesender.model.events.FileState;
4242
import com.willwinder.universalgcodesender.model.events.FileStateEvent;
43+
import com.willwinder.universalgcodesender.services.BackendFileLoader;
44+
import com.willwinder.universalgcodesender.services.FileLoader;
45+
import com.willwinder.universalgcodesender.services.LookupService;
4346
import com.willwinder.universalgcodesender.services.MessageService;
4447
import com.willwinder.universalgcodesender.types.GcodeCommand;
4548
import com.willwinder.universalgcodesender.utils.FirmwareUtils;
@@ -413,7 +416,9 @@ public void openWorkspaceFile(String file) throws Exception {
413416

414417
String workspaceDirectory = settings.getWorkspaceDirectory();
415418
String filename = workspaceDirectory + File.separatorChar + file;
416-
setGcodeFile(new File(filename));
419+
FileLoader fileLoader = LookupService.lookupOptional(FileLoader.class)
420+
.orElseGet(() -> new BackendFileLoader(this));
421+
fileLoader.openFile(new File(filename));
417422
}
418423

419424
@Override

ugs-core/src/com/willwinder/universalgcodesender/model/Position.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,72 @@ public boolean hasRotation() {
156156
(c != 0 && !Double.isNaN(c));
157157
}
158158

159+
/**
160+
* Returns a copy of this position with any A/B/C axis rotations folded into the X/Y/Z cartesian
161+
* coordinates. This is what turns a wrapped/rotary toolpath into the actual position in space.
162+
* Undefined (NaN) linear axes are treated as 0 so a rotation can still be calculated for files
163+
* that never define a start point on a given axis.
164+
*
165+
* @return the cartesian position with the rotations applied
166+
*/
167+
public Position getCartesian() {
168+
double sx = zeroIfNaN(x);
169+
double sy = zeroIfNaN(y);
170+
double sz = zeroIfNaN(z);
171+
172+
if (!hasRotation()) {
173+
return new Position(sx, sy, sz, 0, 0, 0, units);
174+
}
175+
176+
// Negate the angles so the unwrapped toolpath matches how the post wrapped it (e.g. +A
177+
// maps to +Y); the plain right-hand rotation would wrap the opposite way and mirror it.
178+
double sa = -zeroIfNaN(a);
179+
double sb = -zeroIfNaN(b);
180+
double sc = -zeroIfNaN(c);
181+
182+
// Apply each axis rotation in turn, feeding the result of one into the next so combined
183+
// rotations compose correctly instead of each axis overwriting the others.
184+
double px = sx;
185+
double py = sy;
186+
double pz = sz;
187+
188+
// X-Axis rotation
189+
if (sa != 0) {
190+
double sinA = Math.sin(Math.toRadians(sa));
191+
double cosA = Math.cos(Math.toRadians(sa));
192+
double ny = py * cosA - pz * sinA;
193+
double nz = py * sinA + pz * cosA;
194+
py = ny;
195+
pz = nz;
196+
}
197+
198+
// Y-Axis rotation
199+
if (sb != 0) {
200+
double sinB = Math.sin(Math.toRadians(sb));
201+
double cosB = Math.cos(Math.toRadians(sb));
202+
double nx = px * cosB + pz * sinB;
203+
double nz = -px * sinB + pz * cosB;
204+
px = nx;
205+
pz = nz;
206+
}
207+
208+
// Z-Axis rotation
209+
if (sc != 0) {
210+
double sinC = Math.sin(Math.toRadians(sc));
211+
double cosC = Math.cos(Math.toRadians(sc));
212+
double nx = px * cosC - py * sinC;
213+
double ny = px * sinC + py * cosC;
214+
px = nx;
215+
py = ny;
216+
}
217+
218+
return new Position(px, py, pz, 0, 0, 0, units);
219+
}
220+
221+
private static double zeroIfNaN(double value) {
222+
return Double.isNaN(value) ? 0 : value;
223+
}
224+
159225
public void set(Axis axis, double value) {
160226
switch (axis) {
161227
case X:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
Copyright 2026 Joacim Breiler
3+
4+
This file is part of Universal Gcode Sender (UGS).
5+
6+
UGS is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
UGS is distributed in the hope that it will be useful,
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU General Public License for more details.
15+
16+
You should have received a copy of the GNU General Public License
17+
along with UGS. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
package com.willwinder.universalgcodesender.services;
20+
21+
import com.willwinder.universalgcodesender.model.BackendAPI;
22+
23+
import java.io.File;
24+
25+
/**
26+
* The default {@link FileLoader} which loads the gcode file directly into the {@link BackendAPI}.
27+
* This is used when no other loader has been registered.
28+
*
29+
* @author Joacim Breiler
30+
*/
31+
public class BackendFileLoader implements FileLoader {
32+
private final BackendAPI backend;
33+
34+
public BackendFileLoader(BackendAPI backend) {
35+
this.backend = backend;
36+
}
37+
38+
@Override
39+
public void openFile(File file) throws Exception {
40+
backend.setGcodeFile(file);
41+
}
42+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
Copyright 2026 Joacim Breiler
3+
4+
This file is part of Universal Gcode Sender (UGS).
5+
6+
UGS is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
UGS is distributed in the hope that it will be useful,
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU General Public License for more details.
15+
16+
You should have received a copy of the GNU General Public License
17+
along with UGS. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
package com.willwinder.universalgcodesender.services;
20+
21+
import java.io.File;
22+
23+
/**
24+
* Opens a file into the application.
25+
*
26+
* <p>This abstracts <em>how</em> a file is opened so that different editions can plug in their own
27+
* behaviour. The JavaFX edition opens the file as a workspace, the platform edition opens it through
28+
* its {@code OpenFileAction}, and when no loader has been registered the file is loaded directly into
29+
* the {@link com.willwinder.universalgcodesender.model.BackendAPI} by {@link BackendFileLoader}.
30+
*
31+
* <p>Register an implementation through {@link LookupService#register(Object)} during application
32+
* startup. Callers that want to open a file should resolve the registered loader, for example:
33+
*
34+
* <pre>{@code
35+
* FileLoader loader = LookupService.lookupOptional(FileLoader.class)
36+
* .orElseGet(() -> new BackendFileLoader(backend));
37+
* loader.openFile(file);
38+
* }</pre>
39+
*
40+
* @author Joacim Breiler
41+
*/
42+
public interface FileLoader {
43+
44+
/**
45+
* Opens the given file.
46+
*
47+
* @param file the file to open
48+
* @throws Exception if the file could not be opened
49+
*/
50+
void openFile(File file) throws Exception;
51+
}

0 commit comments

Comments
 (0)