This commit is contained in:
Bane Stojkovic
2026-11-10 22:50:16 +01:00
parent 9f6124f719
commit f73574641c
35 changed files with 729 additions and 67 deletions
@@ -72,5 +72,9 @@ public enum DrawOption {
/**
* When applied, all colors will be converted to blackest black or whitest white
*/
MONOCHROMATIC;
MONOCHROMATIC,
/**
* Paint on the bottom side of a board is drawn at reduced opacity, as if seen through the board.
*/
FADE_FAR_SIDE;
}
@@ -110,6 +110,8 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP
public static final String LOCKED_ALPHA = "lockedAlpha";
public static final String FADE_FAR_SIDE_KEY = "fadeFarSide";
public static final String LANGUAGE = "language";
public static final String LANGUAGE_DEFAULT = "English";
@@ -25,7 +25,11 @@ import org.diylc.core.gerber.GerberLayer;
public enum PCBLayer {
_1("Bottom"), _2("Top"), _3("Inner 1"), _4("Inner 2"), _5("Inner 3"), _6("Inner 4"), _7("Inner 5"), _8("Inner 6");
_1("Bottom"), _2("Top"),
// DIYLC models two-sided boards only. The inner layers are kept, in order, so that files using
// them still load with the same layer ids; the editor hides them and Gerber export rejects them.
@Deprecated _3("Inner 1"), @Deprecated _4("Inner 2"), @Deprecated _5("Inner 3"),
@Deprecated _6("Inner 4"), @Deprecated _7("Inner 5"), @Deprecated _8("Inner 6");
private String label;
@@ -21,6 +21,8 @@
*/
package org.diylc.core;
import org.diylc.common.PCBLayer;
/**
* Interface for tracking component drawing process. Components may use it to stop or restart
* tracking. Anything drawn while tracking is stopped will not be added to the mouse hot-spot area.
@@ -65,4 +67,26 @@ public interface IDrawingObserver {
* @param marker
*/
void setContinuityMarker(String marker);
/**
* Notifies the observer that what follows is painted on the given side of the board, until
* {@link #stopDrawingSide()}. Paint that is not bracketed is on the top (component) side. The
* observer may draw paint on the side facing away from the viewer at reduced opacity, as if seen
* through the board. Inner layers count as the bottom side.
*
* @param side the layer the paint lies on
*/
default void startDrawingSide(PCBLayer side) {}
/**
* Notifies the observer that what follows is visible from both sides of the board, such as a
* drilled hole or a cut through the copper, and is never drawn faded, until
* {@link #stopDrawingSide()}.
*/
default void startDrawingBothSides() {}
/**
* Notifies the observer that paint is back on the top (component) side.
*/
default void stopDrawingSide() {}
}
@@ -65,7 +65,7 @@ import org.diylc.utils.Constants;
public class DrawingManager {
private static final Logger LOG = Logger.getLogger(DrawingManager.class);
public static final Percentage MIRROR_ALPHA = new Percentage(50);
public static Percentage FAR_SIDE_ALPHA = new Percentage(55);
public static int CONTROL_POINT_SIZE = 7;
public static double EXTRA_SPACE = 0.25;
@@ -536,13 +536,58 @@ public class DrawingManager {
private static void drawMirroredComponent(Project project, IBoard board, IDIYComponent<?> component,
G2DWrapper g2dWrapper, ComponentState state, boolean outlineMode) {
if (board.getUndersideDisplay() == null || board.getUndersideDisplay() == BoardUndersideDisplay.NONE) {
AffineTransform mirrorTx = createMirrorTransform(board);
if (mirrorTx == null) {
return;
}
G2DWrapper.MirrorDirection mirrorDirection;
switch (board.getUndersideDisplay()) {
case ABOVE:
case BELOW:
mirrorDirection = G2DWrapper.MirrorDirection.VERTICAL;
break;
case LEFT:
case RIGHT:
mirrorDirection = G2DWrapper.MirrorDirection.HORIZONTAL;
break;
default:
mirrorDirection = G2DWrapper.MirrorDirection.NONE;
break;
}
AffineTransform oldTx = g2dWrapper.getTransform();
Composite oldComposite = g2dWrapper.getComposite();
g2dWrapper.setMirrorDirection(mirrorDirection);
try {
g2dWrapper.transform(mirrorTx);
// the mirror is the copper side, so copper is drawn solid and the component side faded
g2dWrapper.setViewedSide(PCBLayer._1, Boolean.TRUE.equals(board.getUndersideTransparency()),
component instanceof IBoard ? PCBLayer._1 : PCBLayer._2);
component.draw(g2dWrapper, state, outlineMode, project, g2dWrapper);
} catch (Exception e) {
LOG.error("Error drawing mirrored " + component.getName(), e);
} finally {
g2dWrapper.setMirrorDirection(G2DWrapper.MirrorDirection.NONE);
g2dWrapper.setTransform(oldTx);
g2dWrapper.stopDrawingSide();
g2dWrapper.resetViewedSide();
g2dWrapper.setComposite(oldComposite);
}
}
/**
* Returns the reflection that maps the board onto its underside mirror, or null when the board
* has no underside display.
*/
private static AffineTransform createMirrorTransform(IBoard board) {
if (board.getUndersideDisplay() == null || board.getUndersideDisplay() == BoardUndersideDisplay.NONE) {
return null;
}
double offset = board.getUndersideOffset() == null ? 0 : board.getUndersideOffset().convertToPixels();
Rectangle2D boardRectangle = board.getBoardRectangle();
if (boardRectangle == null) {
return;
return null;
}
double minX = Math.min(boardRectangle.getMinX(), boardRectangle.getMaxX());
@@ -581,40 +626,9 @@ public class DrawingManager {
break;
}
default:
return;
}
G2DWrapper.MirrorDirection mirrorDirection;
switch (board.getUndersideDisplay()) {
case ABOVE:
case BELOW:
mirrorDirection = G2DWrapper.MirrorDirection.VERTICAL;
break;
case LEFT:
case RIGHT:
mirrorDirection = G2DWrapper.MirrorDirection.HORIZONTAL;
break;
default:
mirrorDirection = G2DWrapper.MirrorDirection.NONE;
break;
}
AffineTransform oldTx = g2dWrapper.getTransform();
Composite oldComposite = g2dWrapper.getComposite();
g2dWrapper.setMirrorDirection(mirrorDirection);
try {
g2dWrapper.transform(mirrorTx);
if (Boolean.TRUE.equals(board.getUndersideTransparency())) {
g2dWrapper.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, (float)(MIRROR_ALPHA.getValue().doubleValue() / 100d)));
}
component.draw(g2dWrapper, state, outlineMode, project, g2dWrapper);
} catch (Exception e) {
LOG.error("Error drawing mirrored " + component.getName(), e);
} finally {
g2dWrapper.setMirrorDirection(G2DWrapper.MirrorDirection.NONE);
g2dWrapper.setTransform(oldTx);
g2dWrapper.setComposite(oldComposite);
return null;
}
return mirrorTx;
}
private static void drawComponent(Project project, Set<DrawOption> drawOptions, Double scaleFactor,
@@ -17,6 +17,7 @@
*/
package org.diylc.presenter;
import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Composite;
@@ -60,6 +61,7 @@ import java.util.Map.Entry;
import org.apache.log4j.Logger;
import org.diylc.common.DrawOption;
import org.diylc.common.ObjectCache;
import org.diylc.common.PCBLayer;
import org.diylc.common.ZoomableStroke;
import org.diylc.core.IDrawingObserver;
@@ -118,6 +120,17 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
private MirrorDirection mirrorDirection = MirrorDirection.NONE;
private PCBLayer viewedSide = PCBLayer._2;
// the side assumed for paint a component does not bracket with startDrawingSide
private PCBLayer undeclaredSide = PCBLayer._2;
private PCBLayer paintedSide = PCBLayer._2;
private boolean fadeFarSide;
// the composite the component asked for, held while far-side paint is drawn faded on top of it
private Composite unfadedComposite;
// boards switch sides once per strip or pad, so the last faded composite is reused
private Composite lastFadeInput;
private Composite lastFadeOutput;
private double zoom;
private int zOrder;
@@ -131,6 +144,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
super();
this.canvasGraphics = canvasGraphics;
this.drawOptions = drawOptions;
this.fadeFarSide = drawOptions.contains(DrawOption.FADE_FAR_SIDE);
String debugAreaPerformanceStr = System.getProperty(DEBUG_AREA_PERFORMANCE);
debugAreaPerformance = "true".equalsIgnoreCase(debugAreaPerformanceStr);
this.zoom = zoom;
@@ -154,6 +168,31 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
return mirrorDirection;
}
/**
* Sets the side of the board the viewer is looking at and whether paint on the other side is
* drawn faded. The main view looks at the top side; the underside mirror looks at the bottom.
*
* @param viewedSide
* @param fadeFarSide
* @param undeclaredSide the side assumed for paint that is not bracketed with
* {@link #startDrawingSide(PCBLayer)}; a board body is seen from both sides, so the mirror
* draws it as the viewed side
*/
public void setViewedSide(PCBLayer viewedSide, boolean fadeFarSide, PCBLayer undeclaredSide) {
this.viewedSide = viewedSide;
this.fadeFarSide = fadeFarSide;
this.undeclaredSide = undeclaredSide;
this.paintedSide = undeclaredSide;
updateFarSideFade();
}
/**
* Returns to the top-side view, fading the bottom side only if the draw options ask for it.
*/
public void resetViewedSide() {
setViewedSide(PCBLayer._2, drawOptions.contains(DrawOption.FADE_FAR_SIDE), PCBLayer._2);
}
/**
* Clears out the current area and caches canvas settings.
*
@@ -168,6 +207,8 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
this.debugOutline = new Area();
// trackingStacks.clear();
this.continuityMarker = null;
this.paintedSide = undeclaredSide;
updateFarSideFade();
this.originalStroke = canvasGraphics.getStroke();
this.originalColor = canvasGraphics.getColor();
this.originalTx = canvasGraphics.getTransform();
@@ -189,6 +230,8 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
public ComponentArea finishedDrawingComponent() {
drawingComponent = false;
recordingDrawnBounds = false;
paintedSide = undeclaredSide;
unfadedComposite = null;
canvasGraphics.setStroke(originalStroke);
canvasGraphics.setColor(originalColor);
canvasGraphics.setTransform(originalTx);
@@ -280,6 +323,54 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
continuityMarker = marker;
}
@Override
public void startDrawingSide(PCBLayer side) {
this.paintedSide = side == null ? undeclaredSide : side;
updateFarSideFade();
}
@Override
public void startDrawingBothSides() {
this.paintedSide = viewedSide;
updateFarSideFade();
}
@Override
public void stopDrawingSide() {
this.paintedSide = undeclaredSide;
updateFarSideFade();
}
private void updateFarSideFade() {
boolean fade = fadeFarSide && !drawOptions.contains(DrawOption.OUTLINE_MODE)
&& isBottom(paintedSide) != isBottom(viewedSide);
if (fade && unfadedComposite == null) {
unfadedComposite = canvasGraphics.getComposite();
canvasGraphics.setComposite(fade(unfadedComposite));
} else if (!fade && unfadedComposite != null) {
canvasGraphics.setComposite(unfadedComposite);
unfadedComposite = null;
}
}
private static boolean isBottom(PCBLayer layer) {
return layer != PCBLayer._2;
}
// the stronger of the two fades wins rather than multiplying, so that copper on a locked board,
// already drawn at the locked alpha, does not all but disappear
private Composite fade(Composite composite) {
if (composite != lastFadeInput || lastFadeOutput == null) {
float alpha = (float) (DrawingManager.FAR_SIDE_ALPHA.getValue().doubleValue() / 100d);
if (composite instanceof AlphaComposite ac && ac.getRule() == AlphaComposite.SRC_OVER) {
alpha = Math.min(alpha, ac.getAlpha());
}
lastFadeInput = composite;
lastFadeOutput = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha);
}
return lastFadeOutput;
}
/**
* Appends shape interior to the current component area.
*
@@ -669,7 +760,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public Composite getComposite() {
return canvasGraphics.getComposite();
return unfadedComposite != null ? unfadedComposite : canvasGraphics.getComposite();
}
@Override
@@ -738,7 +829,13 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void setComposite(Composite comp) {
if (!drawOptions.contains(DrawOption.OUTLINE_MODE)) {
if (drawOptions.contains(DrawOption.OUTLINE_MODE)) {
return;
}
if (unfadedComposite != null) {
unfadedComposite = comp;
canvasGraphics.setComposite(fade(comp));
} else {
canvasGraphics.setComposite(comp);
}
// FIXME: check this.
@@ -1,9 +1,12 @@
package org.diylc.presenter;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Composite;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
@@ -20,6 +23,7 @@ import org.diylc.appframework.miscutils.InMemoryConfigurationManager;
import org.diylc.appframework.simplemq.MessageDispatcher;
import org.diylc.common.DrawOption;
import org.diylc.common.EventType;
import org.diylc.common.PCBLayer;
import org.diylc.core.BoardUndersideDisplay;
import org.diylc.core.ComponentState;
import org.diylc.core.IBoard;
@@ -175,10 +179,10 @@ public class DrawingManagerTest {
assertEquals(150.0, mirroredP.getX(), 0.001);
assertEquals(670.0, mirroredP.getY(), 0.001);
// Check transparency
// the mirror fades through the wrapper, so the component still sees its own composite
assertEquals(2, component.capturedAlphas.size());
assertEquals(1.0f, component.capturedAlphas.get(0), 0.01f);
assertEquals(0.5f, component.capturedAlphas.get(1), 0.01f);
assertEquals(1.0f, component.capturedAlphas.get(1), 0.01f);
}
@Test
@@ -213,7 +217,6 @@ public class DrawingManagerTest {
assertEquals(150.0, mirroredP.getX(), 0.001);
assertEquals(10.0, mirroredP.getY(), 0.001);
// Transparency should not be 0.5 when undersideTransparency is false
assertEquals(1.0f, component.capturedAlphas.get(1), 0.01f);
}
@@ -647,6 +650,177 @@ public class DrawingManagerTest {
assertTrue(recorded.contains(50, 5));
}
@Test
public void testBottomSideFadedOnlyWithOption() {
Project project = new Project();
project.getComponents().add(new SidePaintingComponent(new Rectangle2D.Double(150, 150, 10, 10), null));
project.getComponents().add(new SidePaintingComponent(new Rectangle2D.Double(200, 200, 10, 10), PCBLayer._1));
project.getComponents().add(new SidePaintingComponent(new Rectangle2D.Double(250, 250, 10, 10), PCBLayer._3));
BufferedImage faded = renderSides(project, EnumSet.of(DrawOption.ZOOM, DrawOption.FADE_FAR_SIDE));
assertSolid(faded, 155, 155);
assertFaded(faded, 205, 205);
// inner layers count as the bottom side
assertFaded(faded, 255, 255);
BufferedImage plain = renderSides(project, EnumSet.of(DrawOption.ZOOM));
assertSolid(plain, 205, 205);
assertSolid(plain, 255, 255);
}
@Test
public void testBothSidesNeverFaded() {
Project project = new Project();
MockBoard board = new MockBoard();
board.setBoardRectangle(new Rectangle2D.Double(100, 100, 200, 300));
board.setUndersideDisplay(BoardUndersideDisplay.BELOW);
board.setUndersideOffset(new Size(20.0, SizeUnit.px));
project.getComponents().add(board);
project.getComponents().add(SidePaintingComponent.bothSides(new Rectangle2D.Double(150, 150, 10, 10)));
BufferedImage img = renderSides(project, EnumSet.of(DrawOption.ZOOM, DrawOption.FADE_FAR_SIDE));
assertSolid(img, 155, 155);
assertSolid(img, 155, 665);
}
@Test
public void testLockedBottomSideNotFadedTwice() {
Project project = new Project();
SidePaintingComponent bottom = new SidePaintingComponent(new Rectangle2D.Double(200, 200, 10, 10), PCBLayer._1);
project.getComponents().add(bottom);
DrawingManager drawingManager = createDrawingManager();
BufferedImage img = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = img.createGraphics();
drawingManager.drawProject(g2d, project,
EnumSet.of(DrawOption.ZOOM, DrawOption.FADE_FAR_SIDE, DrawOption.LOCKED_ALPHA), null, null,
Collections.emptyList(), Collections.singleton(bottom), Collections.emptySet(), null, null,
false, Collections.emptySet(), 1.0, 1.0, null);
g2d.dispose();
// the stronger of the 50% locked alpha and the far-side alpha decides, not their product
assertFaded(img, 205, 205, Math.min(0.5f, farSideAlpha()));
}
@Test
public void testMirrorShowsCopperSideSolid() {
Project project = new Project();
MockBoard board = new MockBoard();
board.setBoardRectangle(new Rectangle2D.Double(100, 100, 200, 300));
board.setUndersideDisplay(BoardUndersideDisplay.BELOW);
board.setUndersideOffset(new Size(20.0, SizeUnit.px));
board.setUndersideTransparency(true);
project.getComponents().add(board);
project.getComponents().add(new SidePaintingComponent(new Rectangle2D.Double(150, 150, 10, 10), null));
project.getComponents().add(new SidePaintingComponent(new Rectangle2D.Double(200, 200, 10, 10), PCBLayer._1));
// reflection across y = 410 puts the top-side square at y 660..670 and the bottom one at 610..620
BufferedImage img = renderSides(project, EnumSet.of(DrawOption.ZOOM, DrawOption.FADE_FAR_SIDE));
assertFaded(img, 155, 665);
assertSolid(img, 205, 615);
board.setUndersideTransparency(false);
img = renderSides(project, EnumSet.of(DrawOption.ZOOM, DrawOption.FADE_FAR_SIDE));
assertSolid(img, 155, 665);
assertSolid(img, 205, 615);
}
@Test
public void testFarSideFadeKeepsComponentComposite() {
BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB);
Graphics2D canvas = img.createGraphics();
G2DWrapper wrapper = new G2DWrapper(canvas, 1.0, EnumSet.of(DrawOption.FADE_FAR_SIDE));
float farSideAlpha = farSideAlpha();
Composite half = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f);
wrapper.setComposite(half);
wrapper.startDrawingSide(PCBLayer._1);
assertSame(half, wrapper.getComposite());
// the stronger of the two fades wins rather than multiplying
assertEquals(Math.min(0.5f, farSideAlpha), ((AlphaComposite) canvas.getComposite()).getAlpha(), 0.001f);
wrapper.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.25f));
assertEquals(Math.min(0.25f, farSideAlpha), ((AlphaComposite) canvas.getComposite()).getAlpha(), 0.001f);
wrapper.setComposite(half);
wrapper.stopDrawingSide();
assertSame(half, canvas.getComposite());
canvas.dispose();
}
@Test
public void testFarSideNotFadedInOutlineMode() {
BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB);
Graphics2D canvas = img.createGraphics();
Composite original = canvas.getComposite();
G2DWrapper wrapper = new G2DWrapper(canvas, 1.0,
EnumSet.of(DrawOption.FADE_FAR_SIDE, DrawOption.OUTLINE_MODE));
wrapper.startDrawingSide(PCBLayer._1);
assertSame(original, canvas.getComposite());
wrapper.stopDrawingSide();
canvas.dispose();
}
@Test
public void testFarSideFadeLeavesContinuityUnchanged() {
Area faded = drawBottomConductor(EnumSet.of(DrawOption.FADE_FAR_SIDE));
Area plain = drawBottomConductor(EnumSet.noneOf(DrawOption.class));
assertTrue(faded.equals(plain));
assertEquals(new Rectangle2D.Double(10, 10, 30, 20), faded.getBounds2D());
}
private Area drawBottomConductor(Set<DrawOption> drawOptions) {
BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB);
Graphics2D canvas = img.createGraphics();
G2DWrapper wrapper = new G2DWrapper(canvas, 1.0, drawOptions);
wrapper.startedDrawingComponent(0);
wrapper.startDrawingSide(PCBLayer._1);
wrapper.startTrackingContinuityArea(true);
wrapper.fill(new Rectangle2D.Double(10, 10, 30, 20));
wrapper.stopTrackingContinuityArea();
wrapper.stopDrawingSide();
ComponentArea componentArea = wrapper.finishedDrawingComponent();
canvas.dispose();
Area area = new Area();
for (Area a : componentArea.getContinuityPositiveAreas()) {
area.add(a);
}
return area;
}
private BufferedImage renderSides(Project project, Set<DrawOption> drawOptions) {
DrawingManager drawingManager = createDrawingManager();
BufferedImage img = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = img.createGraphics();
drawingManager.drawProject(g2d, project, drawOptions, null, null, Collections.emptyList(),
Collections.emptySet(), Collections.emptySet(), null, null, false, Collections.emptySet(),
1.0, 1.0, null);
g2d.dispose();
return img;
}
private static float farSideAlpha() {
return (float) (DrawingManager.FAR_SIDE_ALPHA.getValue().doubleValue() / 100d);
}
private static int red(BufferedImage img, int x, int y) {
return new Color(img.getRGB(x, y)).getRed();
}
// the squares are painted black, so the red channel alone shows how much of the background is left
private static void assertSolid(BufferedImage img, int x, int y) {
assertEquals(0, red(img, x, y));
}
private static void assertFaded(BufferedImage img, int x, int y) {
assertFaded(img, x, y, farSideAlpha());
}
private static void assertFaded(BufferedImage img, int x, int y, float alpha) {
double background = red(img, 5, 5);
assertEquals(background * (1 - alpha), red(img, x, y), 3);
}
private void drawVisibleRect(DrawingManager drawingManager, Project project,
Rectangle2D visibleRect) {
BufferedImage img = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB);
@@ -732,6 +906,50 @@ public class DrawingManagerTest {
}
}
// paints an opaque black square, declared on the given side or, when null, left undeclared
public static class SidePaintingComponent extends MockDIYComponent {
private static final long serialVersionUID = 1L;
private Rectangle2D shape;
private PCBLayer side;
private boolean bothSides;
public SidePaintingComponent(Rectangle2D shape, PCBLayer side) {
this.shape = shape;
this.side = side;
}
public static SidePaintingComponent bothSides(Rectangle2D shape) {
SidePaintingComponent component = new SidePaintingComponent(shape, null);
component.bothSides = true;
return component;
}
@Override
public int getControlPointCount() {
return 1;
}
@Override
public Point2D getControlPoint(int index) {
return new Point2D.Double(shape.getCenterX(), shape.getCenterY());
}
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode,
Project project, IDrawingObserver drawingObserver) {
if (bothSides) {
drawingObserver.startDrawingBothSides();
} else if (side != null) {
drawingObserver.startDrawingSide(side);
}
g2d.setColor(Color.black);
g2d.fill(shape);
drawingObserver.stopDrawingSide();
}
}
// Simple delegating Graphics2D proxy for testing
private static class Graphics2DProxy extends Graphics2D {
private final Graphics2D delegate;