Merge branch 'master' into feature/multimeter3

This commit is contained in:
Branislav Stojkovic
2025-03-25 13:18:37 +01:00
80 changed files with 1036 additions and 864 deletions
@@ -76,7 +76,7 @@ public abstract class AbstractCurvedComponent<T> extends AbstractTransparentComp
* @param curve
* @param g2d
* @param componentState
* @param theme
* @param drawingObserver
*/
protected abstract void drawCurve(Path2D curve, Graphics2D g2d, ComponentState componentState,
IDrawingObserver drawingObserver);
@@ -29,10 +29,13 @@ public abstract class AbstractTransparentComponent<T> extends AbstractComponent<
public static byte MAX_ALPHA = 127;
protected byte alpha = MAX_ALPHA;
protected Byte alpha = MAX_ALPHA;
@EditableProperty
public Byte getAlpha() {
if (alpha == null) {
alpha = MAX_ALPHA;
}
return alpha;
}
@@ -0,0 +1,10 @@
package org.diylc.core;
public enum BoardUndersideDisplay {
NONE, ABOVE, BELOW, LEFT, RIGHT;
@Override
public String toString() {
return super.toString().charAt(0) + super.toString().substring(1).toLowerCase();
}
}
@@ -0,0 +1,16 @@
package org.diylc.core;
import org.diylc.core.measures.Size;
import java.awt.geom.Rectangle2D;
public interface IBoard {
Rectangle2D getBoardRectangle();
BoardUndersideDisplay getBoardUndersideDisplay();
Size getUndersideOffset();
boolean shouldExportToGerber();
}
@@ -17,6 +17,7 @@ import org.apache.log4j.Logger;
import com.bancika.gerberwriter.DataLayer;
import org.diylc.core.ComponentState;
import org.diylc.core.IBoard;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IView;
import org.diylc.core.Project;
@@ -30,44 +31,47 @@ public class GerberExporter {
private static final Logger LOG = Logger.getLogger(GerberExporter.class);
public static void exportGerber(String fileNameBase, Project currentProject, IView view,
Graphics2D graphics2d, String diylcVersion) {
List<IGerberBoard> gerberBoards =
currentProject.getComponents().stream().filter(c -> c instanceof IGerberBoard)
.map(x -> (IGerberBoard) x).collect(Collectors.toList());
Graphics2D graphics2d, String diylcVersion) {
Map<IGerberBoard, List<IDIYComponent<?>>> boardComponentMap =
new HashMap<IGerberBoard, List<IDIYComponent<?>>>();
List<IBoard> gerberBoards =
currentProject.getComponents().stream()
.filter(IBoard.class::isInstance)
.map(IBoard.class::cast)
.filter(IBoard::shouldExportToGerber).collect(Collectors.toList());
Map<IBoard, List<IDIYComponent<?>>> boardComponentMap =
new HashMap<IBoard, List<IDIYComponent<?>>>();
// DefaultGerberBoard defaultGerberBoard = new DefaultGerberBoard();
List<IDIYComponent<?>> componentsWithoutBoard = new ArrayList<IDIYComponent<?>>();
currentProject.getComponents().stream().filter(c -> c instanceof IGerberComponent)
.forEach(c -> {
Point2D firstPoint = c.getControlPoint(0);
IGerberBoard board = gerberBoards.stream()
.filter(b -> b.getBoardRectangle().contains(firstPoint)).findFirst().orElse(null);
if (board == null) {
componentsWithoutBoard.add(c);
} else {
boardComponentMap.computeIfAbsent(board, k -> new ArrayList<IDIYComponent<?>>()).add(c);
}
});
.forEach(c -> {
Point2D firstPoint = c.getControlPoint(0);
IBoard board = gerberBoards.stream()
.filter(b -> b.getBoardRectangle().contains(firstPoint)).findFirst().orElse(null);
if (board == null) {
componentsWithoutBoard.add(c);
} else {
boardComponentMap.computeIfAbsent(board, k -> new ArrayList<IDIYComponent<?>>()).add(c);
}
});
// defaultGerberBoard.setComponents(boardComponentMap.get(defaultGerberBoard));
if (boardComponentMap.isEmpty()) {
view.showMessage(LangUtil.translate("Nothing to export."),
LangUtil.translate("Gerber Export"), IView.WARNING_MESSAGE);
LangUtil.translate("Gerber Export"), IView.WARNING_MESSAGE);
return;
}
if (componentsWithoutBoard.size() > 0) {
if (view.showConfirmDialog(String.format(
LangUtil.translate("There are some components that are outside of the bounds of boards.%s"
+ "They will be ignored unless you add a board component underneath them.%sDo you want to continue?"),
"\n", "\n"), LangUtil.translate("Gerber Export"), IView.YES_NO_OPTION,
IView.WARNING_MESSAGE) != IView.YES_OPTION) {
LangUtil.translate("There are some components that are outside of the bounds of boards.%s"
+ "They will be ignored unless you add a board component underneath them.%sDo you want to continue?"),
"\n", "\n"), LangUtil.translate("Gerber Export"), IView.YES_NO_OPTION,
IView.WARNING_MESSAGE) != IView.YES_OPTION) {
return;
}
}
@@ -88,15 +92,15 @@ public class GerberExporter {
String fileNameOutline = GerberLayer.Outline.formatFileName(fileNameBase, boardComponent.getName());
try {
LOG.info(String.format(LangUtil.translate("Exporting outline for board %s to file %s"),
boardComponent.getName(), fileNameOutline));
boardComponent.getName(), fileNameOutline));
outlineLayer.dumpGerberToFile(fileNameOutline);
generatedFiles.add(fileNameOutline);
} catch (IOException e) {
LOG.error("Error writing gerber file: " + e.getMessage());
view.showMessage(
LangUtil.translate(
"Failed to export the project to gerber. Please check the log for details"),
LangUtil.translate("Gerber Export"), IView.ERROR_MESSAGE);
LangUtil.translate(
"Failed to export the project to gerber. Please check the log for details"),
LangUtil.translate("Gerber Export"), IView.ERROR_MESSAGE);
return;
}
@@ -119,30 +123,30 @@ public class GerberExporter {
try {
String fileName = layer.formatFileName(fileNameBase, boardComponent.getName());
LOG.info("Exporting layer: " + layer + " for board " + boardComponent.getName()
+ " to file: " + fileName);
+ " to file: " + fileName);
dataLayer.dumpGerberToFile(fileName);
generatedFiles.add(fileName);
} catch (IOException e) {
LOG.error("Error writing gerber file: " + e.getMessage());
view.showMessage(
LangUtil.translate(
"Failed to export the project to gerber. Please check the log for details"),
LangUtil.translate("Gerber Export"), IView.ERROR_MESSAGE);
LangUtil.translate(
"Failed to export the project to gerber. Please check the log for details"),
LangUtil.translate("Gerber Export"), IView.ERROR_MESSAGE);
return;
}
});
});
view.showMessage(
String.format(
LangUtil.translate(
"Gerber export completed successfully. The following files were created:%s"),
"\n\n" + String.join("\n", generatedFiles)),
LangUtil.translate("Gerber Export"), IView.INFORMATION_MESSAGE);
String.format(
LangUtil.translate(
"Gerber export completed successfully. The following files were created:%s"),
"\n\n" + String.join("\n", generatedFiles)),
LangUtil.translate("Gerber Export"), IView.INFORMATION_MESSAGE);
LOG.info("Completed export to gerber");
}
private static void drawComponent(Project currentProject, GerberG2DWrapper g2d,
IDIYComponent<?> c, boolean outlineMode) {
IDIYComponent<?> c, boolean outlineMode) {
IGerberComponent gerberComponent = (IGerberComponent) c;
g2d.startedDrawingComponent();
if (gerberComponent instanceof IGerberComponentCustom) {
@@ -151,9 +155,9 @@ public class GerberExporter {
} else if (gerberComponent instanceof IGerberComponentSimple) {
IGerberComponentSimple gerberComponentSimple = (IGerberComponentSimple) gerberComponent;
g2d.startGerberOutput(gerberComponentSimple.getGerberLayer(),
gerberComponentSimple.getGerberFunction(), gerberComponentSimple.isGerberNegative());
gerberComponentSimple.getGerberFunction(), gerberComponentSimple.isGerberNegative());
c.draw(g2d, ComponentState.NORMAL, outlineMode, currentProject, g2d);
g2d.stopGerberOutput();
}
}
}
}
@@ -1,8 +0,0 @@
package org.diylc.core.gerber;
import java.awt.geom.Rectangle2D;
public interface IGerberBoard {
Rectangle2D getBoardRectangle();
}
@@ -125,25 +125,28 @@ public class LangUtil {
public static List<Language> getAvailableLanguages() {
List<Language> res = new ArrayList<Language>();
List<File> files = ResourceUtils.getResourceFiles(LANG_DIR);
Map<String, String> langContents = ResourceUtils.getResourceContents(LANG_DIR);
try {
for (File file : files) {
String key = file.getName().replaceFirst("[.][^.]+$", "");
Map<String, String> details = new HashMap<String, String>();
try (Stream<String> stream = Files.lines(
Paths.get(file.getAbsolutePath()), StandardCharsets.UTF_8)) {
stream.forEach(s -> {
if (s.contains("=")) {
String[] parts = s.split("=");
if (parts.length == 2)
details.put(parts[0].toLowerCase().trim(), parts[1].trim());
langContents.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> {
String key = entry.getKey().replaceFirst("[.][^.]+$", "");
Map<String, String> details = new HashMap<String, String>();
try {
String content = entry.getValue();
String[] lines = content.split("\n");
for (String line : lines) {
if (line.contains("=")) {
String[] parts = line.split("=");
if (parts.length == 2)
details.put(parts[0].toLowerCase().trim(), parts[1].trim());
}
}
} catch (Exception e) {
LOG.error("Error loading language file", e);
}
res.add(new Language(key, details));
});
} catch (IOException e) {
LOG.error("Error loading language file", e);
}
res.add(new Language(key, details));
}
Collections.sort(res);
return res;
} catch (Exception e) {
@@ -18,7 +18,6 @@
package org.diylc.presenter;
import java.io.*;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -42,7 +41,7 @@ import org.diylc.serialization.ProjectFileManager;
public class BuildingBlockManager {
private static final String BLOCKS_FILE_NAME = "blocks.xml";
private static final String BLOCKS_FILE_NAME = "import-defaults/blocks.xml";
private static final Logger LOG = Logger.getLogger(VariantManager.class);
@@ -26,12 +26,8 @@ import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.lang.reflect.Modifier;
import java.util.*;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import org.apache.log4j.Logger;
@@ -52,6 +48,11 @@ import org.diylc.core.annotations.EditableProperty;
import org.diylc.core.annotations.KeywordPolicy;
import org.diylc.lang.LangUtil;
import org.reflections.Reflections;
import org.reflections.util.ConfigurationBuilder;
import org.reflections.util.FilterBuilder;
import static org.reflections.scanners.Scanners.TypesAnnotated;
/**
* Utility class with component processing methods.
@@ -68,6 +69,7 @@ public class ComponentProcessor {
private Map<String, IPropertyValidator> propertyValidatorCache;
private Map<String, ComponentType> componentTypeMap;
private Map<String, IComponentTransformer> componentTransformerMap;
private Map<String, List<ComponentType>> componentTypes;
public static ComponentProcessor getInstance() {
if (instance == null) {
@@ -138,6 +140,10 @@ public class ComponentProcessor {
Graphics2D g2d = (Graphics2D) image.getGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS,
RenderingHints.VALUE_FRACTIONALMETRICS_ON);
// g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL,
// RenderingHints.VALUE_STROKE_PURE);
componentInstance.drawIcon(g2d, Presenter.ICON_SIZE, Presenter.ICON_SIZE);
@@ -225,6 +231,50 @@ public class ComponentProcessor {
return result;
}
public Map<String, List<ComponentType>> getComponentTypes() {
if (componentTypes == null) {
LOG.info("Loading component types.");
componentTypes = new HashMap<String, List<ComponentType>>();
Reflections reflections = new Reflections(
new ConfigurationBuilder()
.forPackage("org.diylc")
.filterInputsBy(new FilterBuilder().includePackage("org.diylc.components"))
.setScanners(TypesAnnotated));
Set<Class<?>> componentTypeClasses = null;
try {
componentTypeClasses = reflections.getTypesAnnotatedWith(ComponentDescriptor.class, false);
for (Class<?> clazz : componentTypeClasses) {
if (!Modifier.isAbstract(clazz.getModifiers()) && IDIYComponent.class.isAssignableFrom(clazz)) {
ComponentType componentType =
ComponentProcessor.getInstance().extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) clazz);
if (componentType == null)
continue;
// just to store in the cache
ComponentProcessor.getInstance().extractProperties(clazz);
List<ComponentType> nestedList;
if (componentTypes.containsKey(componentType.getCategory())) {
nestedList = componentTypes.get(componentType.getCategory());
} else {
nestedList = new ArrayList<ComponentType>();
componentTypes.put(componentType.getCategory(), nestedList);
}
nestedList.add(componentType);
}
}
for (Map.Entry<String, List<ComponentType>> e : componentTypes.entrySet()) {
LOG.debug(e.getKey() + ": " + e.getValue());
}
} catch (Exception e) {
LOG.error("Error loading component types", e);
}
}
return componentTypes;
}
/**
* Returns properties that have the same value for all the selected components.
*
@@ -17,10 +17,7 @@
*/
package org.diylc.presenter;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.*;
import java.net.URL;
import java.util.HashMap;
import java.util.List;
@@ -55,16 +52,15 @@ public class DatasheetService {
private DatasheetService() {}
public List<String[]> loadDatasheet(Class<?> clazz) {
ClassLoader classloader = Thread.currentThread().getContextClassLoader();
return datasheetCache.computeIfAbsent(clazz.getCanonicalName(), key -> {
String fileName = clazz.getSimpleName() + ".datasheet";
LOG.info("Loading datasheet file for " + fileName);
URL resource = classloader.getResource(fileName);
if (resource == null) {
InputStream inputStream = clazz.getResourceAsStream("/datasheets/" + fileName);
if (inputStream == null) {
LOG.warn("Datasheet file not found: " + fileName);
return null;
}
try (BufferedInputStream in = new BufferedInputStream(resource.openStream())) {
try (BufferedInputStream in = new BufferedInputStream(inputStream)) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
List<String[]> types =
@@ -49,20 +49,9 @@ import org.apache.log4j.Logger;
import org.diylc.appframework.miscutils.IConfigurationManager;
import org.diylc.appframework.simplemq.MessageDispatcher;
import org.diylc.common.DrawOption;
import org.diylc.common.EventType;
import org.diylc.common.GridType;
import org.diylc.common.IComponentFilter;
import org.diylc.common.IPlugInPort;
import org.diylc.common.ObjectCache;
import org.diylc.core.ComponentForRender;
import org.diylc.core.ComponentState;
import org.diylc.core.IContinuity;
import org.diylc.core.IDIYComponent;
import org.diylc.core.ILayeredComponent;
import org.diylc.core.Project;
import org.diylc.core.Theme;
import org.diylc.core.VisibilityPolicy;
import org.diylc.common.*;
import org.diylc.components.AbstractTransparentComponent;
import org.diylc.core.*;
import org.diylc.core.measures.Size;
import org.diylc.core.measures.SizeUnit;
import org.diylc.netlist.ContinuityGraph;
@@ -78,6 +67,7 @@ import org.diylc.utils.Constants;
public class DrawingManager {
private static final Logger LOG = Logger.getLogger(DrawingManager.class);
public static final byte MIRROR_ALPHA = 64;
public static int CONTROL_POINT_SIZE = 7;
public static double EXTRA_SPACE = 0.25;
@@ -195,38 +185,35 @@ public class DrawingManager {
G2DWrapper g2dWrapper = new G2DWrapper(g2d, zoom);
if (drawOptions.contains(DrawOption.ANTIALIASING)) {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
} else {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_OFF);
configureRenderingHints(g2d, drawOptions);
// group components with boards that contain them
Map<IBoard, List<IDIYComponent<?>>> boardMap = project.getComponents().stream()
.filter(IBoard.class::isInstance)
.map(IBoard.class::cast)
.filter(b -> b.getBoardUndersideDisplay() != BoardUndersideDisplay.NONE)
.collect(Collectors.toMap(x -> x, x -> new ArrayList<IDIYComponent<?>>()));
Map<IDIYComponent<?>, Set<IBoard>> componentBoardMap = new HashMap<IDIYComponent<?>, Set<IBoard>>();
for (IBoard board : boardMap.keySet()) {
Rectangle2D boardRect = board.getBoardRectangle();
for (IDIYComponent<?> c : project.getComponents()) {
if (c == board) {
continue;
}
boolean include = true;
for (int i = 0; i < c.getControlPointCount(); i++) {
if (!boardRect.contains(c.getControlPoint(i))) {
include = false;
break;
}
}
if (include) {
boardMap.get(board).add(c);
componentBoardMap.computeIfAbsent(c, key -> new HashSet<IBoard>()).add(board);
}
}
}
if (configManager.readBoolean(IPlugInPort.HI_QUALITY_RENDER_KEY, false)) {
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_ENABLE);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
// g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL,
// RenderingHints.VALUE_STROKE_PURE);
} else {
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED);
g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_SPEED);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED);
g2d.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_DISABLE);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
}
g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS,
RenderingHints.VALUE_FRACTIONALMETRICS_OFF);
// AffineTransform initialTx = g2d.getTransform();
Dimension canvasDimension =
@@ -297,7 +284,7 @@ public class DrawingManager {
// drawn in the same state.
boolean trackArea = lastDrawnStateMap.get(component) != state;
synchronized (g2d) {
synchronized (this) {
g2dWrapper.startedDrawingComponent(i);
if (!trackArea) {
g2dWrapper.stopTracking();
@@ -312,11 +299,7 @@ public class DrawingManager {
long componentStart = System.nanoTime();
try {
if (drawOptions.contains(DrawOption.ENABLE_CACHING)) // go through the DrawingCache
DrawingCache.Instance.draw(component, g2dWrapper, state,
drawOptions.contains(DrawOption.OUTLINE_MODE), project, zoom, scaleFactor, i, visibleRect);
else // go straight to the wrapper
component.draw(g2dWrapper, state, outlineMode, project, g2dWrapper);
drawComponent(project, drawOptions, scaleFactor, visibleRect, component, g2dWrapper, state, zoom, i, outlineMode);
if (g2dWrapper.isTrackingContinuityArea()) {
LOG.info("Component " + component.getName() + " of type "
@@ -349,6 +332,29 @@ public class DrawingManager {
componentAreaMap.put(component, area);
lastDrawnStateMap.put(component, state);
}
// Mirror components that belong to boards that need to be mirrored
if (componentBoardMap.containsKey(component)) {
@SuppressWarnings("unchecked")
ComponentType componentType = ComponentProcessor.getInstance()
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) component.getClass());
if (componentType.getTransformer() != null) {
Set<IBoard> boards = componentBoardMap.get(component);
for (IBoard board : boards) {
drawMirroredComponent(project, board, component, componentType, g2dWrapper, state, outlineMode);
}
}
}
// if a component itself is a board that needs to be mirrored, do it
if (IBoard.class.isInstance(component) && componentBoardMap.values().stream()
.anyMatch(boards -> boards.contains(component))) {
ComponentType componentType = ComponentProcessor.getInstance()
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) component.getClass());
if (componentType.getTransformer() != null) {
drawMirroredComponent(project, (IBoard) component, component, componentType, g2dWrapper, state, outlineMode);
}
}
}
}
@@ -510,6 +516,86 @@ public class DrawingManager {
return failedComponents;
}
private static void drawMirroredComponent(Project project, IBoard board, IDIYComponent<?> component,
ComponentType componentType, G2DWrapper g2dWrapper, ComponentState state, boolean outlineMode) {
double offset = board.getUndersideOffset().convertToPixels();
Rectangle2D boardRectangle = board.getBoardRectangle();
try {
IDIYComponent<?> clonedComponent = component.clone();
int direction = 0;
Point2D pivotPoint = new Point2D.Double();
switch (board.getBoardUndersideDisplay()) {
case ABOVE:
direction = IComponentTransformer.VERTICAL;
pivotPoint.setLocation(boardRectangle.getMinX(), boardRectangle.getMinY() - offset / 2);
break;
case BELOW:
direction = IComponentTransformer.VERTICAL;
pivotPoint.setLocation(boardRectangle.getMinX(), boardRectangle.getMaxY() + offset / 2);
break;
case LEFT:
direction = IComponentTransformer.HORIZONTAL;
pivotPoint.setLocation(boardRectangle.getMinX() - offset / 2, boardRectangle.getMinY());
break;
case RIGHT:
direction = IComponentTransformer.HORIZONTAL;
pivotPoint.setLocation(boardRectangle.getMaxX() + offset / 2, boardRectangle.getMinY());
break;
}
componentType.getTransformer().mirror(clonedComponent, pivotPoint, direction);
if (AbstractTransparentComponent.class.isInstance(clonedComponent)) {
AbstractTransparentComponent<?> transparentComponent = (AbstractTransparentComponent<?>) clonedComponent;
transparentComponent.setAlpha(MIRROR_ALPHA);
}
clonedComponent.draw(g2dWrapper, state, outlineMode, project, g2dWrapper);
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
private static void drawComponent(Project project, Set<DrawOption> drawOptions, Double scaleFactor, Rectangle2D visibleRect, IDIYComponent<?> component, G2DWrapper g2dWrapper, ComponentState state, double zoom, int i, boolean outlineMode) {
if (drawOptions.contains(DrawOption.ENABLE_CACHING)) // go through the DrawingCache
DrawingCache.Instance.draw(component, g2dWrapper, state,
drawOptions.contains(DrawOption.OUTLINE_MODE), project, zoom, scaleFactor, i, visibleRect);
else // go straight to the wrapper
component.draw(g2dWrapper, state, outlineMode, project, g2dWrapper);
}
private void configureRenderingHints(Graphics2D g2d, Set<DrawOption> drawOptions) {
if (drawOptions.contains(DrawOption.ANTIALIASING)) {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
} else {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_OFF);
}
if (configManager.readBoolean(IPlugInPort.HI_QUALITY_RENDER_KEY, false)) {
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_ENABLE);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
// g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL,
// RenderingHints.VALUE_STROKE_PURE);
} else {
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED);
g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING,
RenderingHints.VALUE_COLOR_RENDER_SPEED);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED);
g2d.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_DISABLE);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
}
g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS,
RenderingHints.VALUE_FRACTIONALMETRICS_OFF);
}
private double zoomCached = 0;
private Dimension canvasDimensionCached;
private double extraSpaceCached;
@@ -78,7 +78,6 @@ import org.diylc.core.Project;
import org.diylc.core.Template;
import org.diylc.core.Theme;
import org.diylc.core.VisibilityPolicy;
import org.diylc.core.annotations.ComponentDescriptor;
import org.diylc.core.annotations.IAutoCreator;
import org.diylc.core.gerber.GerberExporter;
import org.diylc.core.measures.Size;
@@ -94,11 +93,6 @@ import org.diylc.test.DIYTest;
import org.diylc.test.Snapshot;
import org.diylc.utils.Constants;
import org.diylc.utils.ReflectionUtils;
import org.reflections.Reflections;
import org.reflections.util.ConfigurationBuilder;
import org.reflections.util.FilterBuilder;
import static org.reflections.scanners.Scanners.TypesAnnotated;
/**
* The main presenter class, contains core app logic and drawing routines.
@@ -135,6 +129,9 @@ public class Presenter implements IPlugInPort, IConfigListener {
private static final Logger LOG = Logger.getLogger(Presenter.class);
public static final Double[] ZOOM_LEVELS =
{0.25d, 0.3333d, 0.5d, 0.6667d, 0.75d, 1d, 1.25d, 1.5d, 2d, 2.5d, 3d};
public static VersionNumber CURRENT_VERSION = null;
private static List<Version> RECENT_VERSIONS = null;
@@ -147,7 +144,6 @@ public class Presenter implements IPlugInPort, IConfigListener {
private static final int MAX_RECENT_FILES = 20;
private Project currentProject;
private Map<String, List<ComponentType>> componentTypes;
// Maps component class names to ComponentType objects.
private List<IPlugIn> plugIns;
@@ -242,7 +238,7 @@ public class Presenter implements IPlugInPort, IConfigListener {
@Override
public java.lang.Double[] getAvailableZoomLevels() {
return new java.lang.Double[] {0.25d, 0.3333d, 0.5d, 0.6667d, 0.75d, 1d, 1.25d, 1.5d, 2d, 2.5d, 3d};
return ZOOM_LEVELS;
}
@Override
@@ -424,47 +420,7 @@ public class Presenter implements IPlugInPort, IConfigListener {
@SuppressWarnings("unchecked")
@Override
public Map<String, List<ComponentType>> getComponentTypes() {
if (componentTypes == null) {
LOG.info("Loading component types.");
componentTypes = new HashMap<String, List<ComponentType>>();
Reflections reflections = new Reflections(
new ConfigurationBuilder()
.forPackage("org.diylc")
.filterInputsBy(new FilterBuilder().includePackage("org.diylc.components"))
.setScanners(TypesAnnotated));
Set<Class<?>> componentTypeClasses = null;
try {
componentTypeClasses = reflections.getTypesAnnotatedWith(ComponentDescriptor.class, false);
for (Class<?> clazz : componentTypeClasses) {
if (!Modifier.isAbstract(clazz.getModifiers()) && IDIYComponent.class.isAssignableFrom(clazz)) {
ComponentType componentType =
ComponentProcessor.getInstance().extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) clazz);
if (componentType == null)
continue;
// just to store in the cache
ComponentProcessor.getInstance().extractProperties(clazz);
List<ComponentType> nestedList;
if (componentTypes.containsKey(componentType.getCategory())) {
nestedList = componentTypes.get(componentType.getCategory());
} else {
nestedList = new ArrayList<ComponentType>();
componentTypes.put(componentType.getCategory(), nestedList);
}
nestedList.add(componentType);
}
}
for (Map.Entry<String, List<ComponentType>> e : componentTypes.entrySet()) {
LOG.debug(e.getKey() + ": " + e.getValue());
}
} catch (Exception e) {
LOG.error("Error loading component types", e);
}
}
return componentTypes;
return ComponentProcessor.getInstance().getComponentTypes();
}
@SuppressWarnings({"unchecked"})
@@ -2559,7 +2515,8 @@ public class Presenter implements IPlugInPort, IConfigListener {
}
}
try {
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream("variants.xml"));
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(
"import-defaults/variants.xml"));
XStream xStream = new XStream(new DomDriver());
xStream.addPermission(AnyTypePermission.ANY);
ProjectFileManager.xStreamSerializer.toXML(defaultVariantMap, out);
@@ -21,7 +21,6 @@ import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
@@ -46,7 +45,7 @@ import org.diylc.serialization.ProjectFileManager;
public class VariantManager {
private static final String VARIANTS_FILE_NAME = "variants.xml";
private static final String VARIANTS_FILE_NAME = "import-defaults/variants.xml";
private static final Logger LOG = Logger.getLogger(VariantManager.class);
@@ -24,36 +24,78 @@ package org.diylc.utils;
import org.apache.log4j.Logger;
import org.diylc.lang.LangUtil;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class ResourceUtils {
private static final Logger LOG = Logger.getLogger(LangUtil.class);
private static final Logger LOG = Logger.getLogger(ResourceUtils.class);
public static List<File> getResourceFiles(String dir) {
List<File> files = new ArrayList<>();
URL url = LangUtil.class.getResource("/" + dir);
Path path = null;
public static Map<String, String> getResourceContents(String dir) {
Map<String, String> contents = new HashMap<>();
try {
path = Paths.get(url.toURI());
Files.walk(path, 5).forEach(p -> {
if (!p.toFile().isDirectory()) {
files.add(p.toFile());
// Get all resources in the directory
URL dirUrl = ResourceUtils.class.getClassLoader().getResource(dir);
if (dirUrl != null) {
if (dirUrl.getProtocol().equals("jar")) {
// Running from JAR
String jarPath = dirUrl.getPath();
// Remove the "file:" prefix and decode the URL
jarPath = URLDecoder.decode(jarPath.substring(5, jarPath.indexOf("!")), StandardCharsets.UTF_8);
JarFile jarFile = new JarFile(jarPath);
try {
jarFile.stream()
.filter(entry -> entry.getName().startsWith(dir + "/") && !entry.isDirectory())
.forEach(entry -> {
try {
String relativePath = entry.getName().substring(dir.length() + 1);
// Read the entire file content as string
String content = new String(jarFile.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8);
contents.put(relativePath, content);
} catch (IOException e) {
LOG.warn("Could not read resource: " + entry.getName(), e);
}
});
} finally {
try {
jarFile.close();
} catch (IOException e) {
LOG.warn("Could not close JAR file", e);
}
}
} else {
// Running from filesystem
java.io.File dirFile = new java.io.File(dirUrl.toURI());
if (dirFile.exists() && dirFile.isDirectory()) {
java.io.File[] files = dirFile.listFiles();
if (files != null) {
for (java.io.File file : files) {
if (file.isFile()) {
try {
String relativePath = file.getName();
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
contents.put(relativePath, content);
} catch (IOException e) {
LOG.warn("Could not read file: " + file, e);
}
}
}
}
}
}
});
} catch (URISyntaxException e) {
LOG.error("Error loading language file", e);
} catch (IOException e) {
LOG.error("Error loading language file", e);
}
} catch (Exception e) {
LOG.error("Error loading resources from directory: " + dir, e);
}
return files;
return contents;
}
}
@@ -0,0 +1,27 @@
<org.diylc.core.Theme>
<name>Gray</name>
<bgColor>
<red>170</red>
<green>170</green>
<blue>170</blue>
<alpha>255</alpha>
</bgColor>
<gridColor>
<red>150</red>
<green>150</green>
<blue>150</blue>
<alpha>255</alpha>
</gridColor>
<dotColor>
<red>100</red>
<green>100</green>
<blue>100</blue>
<alpha>255</alpha>
</dotColor>
<outlineColor>
<red>0</red>
<green>0</green>
<blue>0</blue>
<alpha>255</alpha>
</outlineColor>
</org.diylc.core.Theme>
+14 -2
View File
@@ -246,9 +246,13 @@
<minor>1</minor>
<build>0</build>
</versionNumber>
<releaseDate>2025-03-09 00:00:00.000 CET</releaseDate>
<releaseDate>2025-03-24 00:00:00.000 CET</releaseDate>
<name></name>
<changes>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>Added ability to render the underside of boards along with the top side for easier validation, building and debugging</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>Configurable 'High DPI Rendering' feature which makes the diagram smoother on high-resolution displays but can affect performance</description>
@@ -257,6 +261,10 @@
<changeType>IMPROVEMENT</changeType>
<description>Significantly improved rendering performance</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Increased initial memory allocation to 1GB for smoother performance</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Upgraded source code structure to modern standards and introduced Maven</description>
@@ -271,7 +279,7 @@
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Updated the windows installer to avoid false positive virus detection</description>
<description>Updated the windows installer (hopefully) to avoid false positive anti-virus detection</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
@@ -281,6 +289,10 @@
<changeType>BUG_FIX</changeType>
<description>Rotating selected components sometimes wacks the alignment off the grid</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>Ctrl+X (Command+X) shortcut for Cut action doesn't work because 'Project Explorer' steals the focus on X key press</description>
</org.diylc.appframework.update.Change>
</changes>
<url>https://github.com/bancika/diy-layout-creator/releases</url>
</org.diylc.appframework.update.Version>
@@ -1,266 +1,80 @@
package org.diylc.presenter;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
@RunWith(MockitoJUnitRunner.class)
import java.util.List;
import static org.junit.Assert.*;
public class DatasheetServiceTest {
private DatasheetService service;
@Mock
private ClassLoader mockClassLoader;
private static final Class<?> TEST_CLASS = DatasheetServiceTest.class;
@Before
public void setUp() {
service = DatasheetService.getInstance();
service.clearCache(); // Clear cache before each test
Thread.currentThread().setContextClassLoader(mockClassLoader);
service.clearCache();
}
@Test
public void testLoadDatasheet() throws Exception {
// Create a mock datasheet content
String datasheetContent =
"1N4001,50V,1A,1.1V\n" +
"1N4002,100V,1A,1.1V\n" +
"1N4003,200V,1A,1.1V";
// Create a mock URL that returns our test content
URL mockUrl = mock(URL.class);
when(mockUrl.openStream()).thenReturn(
new ByteArrayInputStream(datasheetContent.getBytes()));
// Setup the mock ClassLoader to return our mock URL
when(mockClassLoader.getResource("TestComponent.datasheet"))
.thenReturn(mockUrl);
// Test loading the datasheet
List<String[]> result = service.loadDatasheet(TestComponent.class);
// Verify results
assertNotNull("Datasheet should not be null", result);
assertEquals("Should have 3 entries", 3, result.size());
// Verify first entry
String[] firstEntry = result.get(0);
assertEquals("1N4001", firstEntry[0]);
assertEquals("50V", firstEntry[1]);
assertEquals("1A", firstEntry[2]);
assertEquals("1.1V", firstEntry[3]);
// Verify second entry
String[] secondEntry = result.get(1);
assertEquals("1N4002", secondEntry[0]);
assertEquals("100V", secondEntry[1]);
// Verify third entry
String[] thirdEntry = result.get(2);
assertEquals("1N4003", thirdEntry[0]);
assertEquals("200V", thirdEntry[1]);
public void testLoadDatasheet() {
List<String[]> result = service.loadDatasheet(TEST_CLASS);
assertNotNull(result);
assertEquals(4, result.size());
assertArrayEquals(new String[]{"TEST", "KEY", "10.0", "20.0"}, result.get(0));
}
@Test
public void testLoadDatasheetFileNotFound() {
// Setup the mock ClassLoader to return null (file not found)
when(mockClassLoader.getResource("TestComponent.datasheet"))
.thenReturn(null);
List<String[]> result = service.loadDatasheet(TestComponent.class);
assertNull("Result should be null when file is not found", result);
List<String[]> result = service.loadDatasheet(String.class);
assertNull(result);
}
@Test
public void testLookupExactMatch() throws Exception {
// Create a mock datasheet content
String datasheetContent =
"TEST,KEY,10.0\n" +
"TEST,KEY,20.0\n" +
"TEST,OTHER,15.0\n" +
"OTHER,KEY,10.0";
URL mockUrl = mock(URL.class);
when(mockUrl.openStream()).thenReturn(
new ByteArrayInputStream(datasheetContent.getBytes()));
when(mockClassLoader.getResource("DatasheetServiceTest.datasheet"))
.thenReturn(mockUrl);
// Create test data
String[] result = service.lookup(
DatasheetServiceTest.class, // Using test class itself as component class
0.1, // 0.1% tolerance
"TEST|KEY", // Key to look up
10.0 // Value to match
);
assertNotNull("Should find matching entry", result);
assertEquals("TEST", result[0]);
assertEquals("KEY", result[1]);
public void testLookupExactMatch() {
String[] result = service.lookup(TEST_CLASS, 0.0, "TEST|KEY", 10.0);
assertNotNull(result);
assertEquals("10.0", result[2]);
}
@Test
public void testLookupWithinTolerance() throws Exception {
// Create a mock datasheet content
String datasheetContent =
"TEST,KEY,10.0\n" +
"TEST,KEY,20.0\n" +
"TEST,OTHER,15.0\n" +
"OTHER,KEY,10.0";
URL mockUrl = mock(URL.class);
when(mockUrl.openStream()).thenReturn(
new ByteArrayInputStream(datasheetContent.getBytes()));
when(mockClassLoader.getResource("DatasheetServiceTest.datasheet"))
.thenReturn(mockUrl);
// Should match 10.0 with 5% tolerance when looking for 10.2
String[] result = service.lookup(
DatasheetServiceTest.class,
5.0, // 5% tolerance
"TEST|KEY",
10.2 // Should match 10.0 within 5% tolerance
);
assertNotNull("Should find matching entry within tolerance", result);
public void testLookupWithinTolerance() {
String[] result = service.lookup(TEST_CLASS, 5.0, "TEST|KEY", 10.5);
assertNotNull(result);
assertEquals("10.0", result[2]);
}
@Test
public void testLookupNoMatch() throws Exception {
// Create a mock datasheet content
String datasheetContent =
"TEST,KEY,10.0\n" +
"TEST,KEY,20.0\n" +
"TEST,OTHER,15.0\n" +
"OTHER,KEY,10.0";
URL mockUrl = mock(URL.class);
when(mockUrl.openStream()).thenReturn(
new ByteArrayInputStream(datasheetContent.getBytes()));
when(mockClassLoader.getResource("DatasheetServiceTest.datasheet"))
.thenReturn(mockUrl);
// Should not match when value is outside tolerance
String[] result = service.lookup(
DatasheetServiceTest.class,
1.0, // 1% tolerance
"TEST|KEY",
15.0 // No value close enough to match
);
assertNull("Should not find matching entry", result);
public void testLookupOutsideTolerance() {
String[] result = service.lookup(TEST_CLASS, 5.0, "TEST|KEY", 12.0);
assertNull(result);
}
@Test
public void testLookupNonexistentKey() throws Exception {
// Create a mock datasheet content
String datasheetContent =
"TEST,KEY,10.0\n" +
"TEST,KEY,20.0\n" +
"TEST,OTHER,15.0\n" +
"OTHER,KEY,10.0";
URL mockUrl = mock(URL.class);
when(mockUrl.openStream()).thenReturn(
new ByteArrayInputStream(datasheetContent.getBytes()));
when(mockClassLoader.getResource("DatasheetServiceTest.datasheet"))
.thenReturn(mockUrl);
String[] result = service.lookup(
DatasheetServiceTest.class,
0.1,
"NONEXISTENT|KEY", // Key that doesn't exist in datasheet
10.0
);
assertNull("Should not find matching entry for nonexistent key", result);
public void testLookupNoMatch() {
String[] result = service.lookup(TEST_CLASS, 0.0, "TEST,KEY", 30.0);
assertNull(result);
}
@Test
public void testLookupWithTolerance() throws Exception {
// Create a mock datasheet content with resistor values
String datasheetContent =
"0805,1%,100,0.125W\n" +
"0805,1%,220,0.125W\n" +
"0805,1%,470,0.125W";
URL mockUrl = mock(URL.class);
when(mockUrl.openStream()).thenReturn(
new ByteArrayInputStream(datasheetContent.getBytes()));
when(mockClassLoader.getResource("TestComponent.datasheet"))
.thenReturn(mockUrl);
// Test lookup with 5% tolerance (should match 220 when looking for 225)
String[] result = service.lookup(TestComponent.class, 5.0, "0805|1%", 225.0);
assertNotNull("Should find matching entry within tolerance", result);
assertEquals("0805", result[0]);
assertEquals("1%", result[1]);
assertEquals("220", result[2]);
public void testLookupNonexistentKey() {
String[] result = service.lookup(TEST_CLASS, 0.0, "NONEXISTENT,KEY", 10.0);
assertNull(result);
}
@Test
public void testLookupOutsideTolerance() throws Exception {
// Create a mock datasheet content
String datasheetContent =
"0805,1%,100,0.125W\n" +
"0805,1%,220,0.125W\n" +
"0805,1%,470,0.125W";
URL mockUrl = mock(URL.class);
when(mockUrl.openStream()).thenReturn(
new ByteArrayInputStream(datasheetContent.getBytes()));
when(mockClassLoader.getResource("TestComponent.datasheet"))
.thenReturn(mockUrl);
// Test lookup with value outside tolerance
String[] result = service.lookup(TestComponent.class, 1.0, "0805|1%", 250.0);
assertNull("Should not find matching entry outside tolerance", result);
public void testLookupWithTolerance() {
String[] result = service.lookup(TEST_CLASS, 50.0, "TEST|KEY", 15.0);
assertNotNull(result);
assertEquals("10.0", result[2]);
}
@Test
public void testLookupMultipleValues() throws Exception {
// Create a mock datasheet content with multiple value matches
String datasheetContent =
"TO220,NPN,50,5,0.7\n" + // Vce, Ic, Vbe
"TO220,NPN,60,8,0.7\n" +
"TO220,NPN,100,10,0.7";
URL mockUrl = mock(URL.class);
when(mockUrl.openStream()).thenReturn(
new ByteArrayInputStream(datasheetContent.getBytes()));
when(mockClassLoader.getResource("TestComponent.datasheet"))
.thenReturn(mockUrl);
// Test lookup matching multiple values within tolerance
String[] result = service.lookup(TestComponent.class, 5.0, "TO220|NPN",
98.0, 9.8, 0.71);
assertNotNull("Should find matching entry for multiple values", result);
assertEquals("TO220", result[0]);
assertEquals("NPN", result[1]);
assertEquals("100", result[2]);
assertEquals("10", result[3]);
assertEquals("0.7", result[4]);
public void testLookupMultipleValues() {
String[] result = service.lookup(TEST_CLASS, 0.0, "TEST|KEY", 10.0, 20.0);
assertNotNull(result);
assertEquals("10.0", result[2]);
assertEquals("20.0", result[3]);
}
// Test component class
private static class TestComponent {
// Empty class just for testing
}
}
}
@@ -0,0 +1,4 @@
TEST,KEY,10.0,20.0
TEST,KEY,20.0,30.0
TEST,OTHER,15.0,25.0
OTHER,KEY,10.0,20.0
@@ -1,4 +0,0 @@
TEST,KEY,10.0 mm
TEST,KEY,20.0 mm
TEST,OTHER,15.0 mm
OTHER,KEY,10.0 mm