From a743de40242a1c165447a138dad9d20e72ee54f8 Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Fri, 9 Oct 2026 09:38:09 +0200 Subject: [PATCH] Controllers (#1080) * initial * more components * fixes * Update TreePanel.java * fix boost converter * Improve wrapping of component names * Update ULN2003Driver.java * Update NRF24L01Transceiver.java * imp * updates * improvements * more components * arduino * uno * icon * cleanup * mega * mega * improveent * nano * esp boards * esp * esp32 * esp32 * Update ESP8266NodeMCU.java * d1 * Update WemosD1Mini.java * pi * consolidate esp32 * pi pico updates * pi zero * Update RaspberryPiZero.java * pi pico * cleanup * pi improvements * consolidate pins * antenna consolidation * improve solder pads * teensy * headers * teensy fix, test cleanup * s3 * Update ESP32DevKit.java * usb standardization * pico w * button standardization * arduino improvements * Update AbstractMakerBoard.java * Update RaspberryPiZero.java * Update RaspberryPi.java * pi 3 * pi 5 improvements * Update RaspberryPiZero.java * fix tests * esp pinout fix * Update ESP32DevKit.java * pin name improvements * Create tier-1-modern-boards.md * new versions * pinouts * nano improvements * sanitize node names * label improvements * minima fixes * arduino uno labels * nano labels * Update ArduinoNano.java * mega * mega * comment out future stuff * release prep * Update RaspberryPiPico.java * Refactor abstract class * labeling improvement * label offset fixes * Update Teensy.java * consolidate tests * micro variants * Update RaspberryPi.java * displays tweaks * display improvements * neopix * displays * improvements * led matrix * strip * led bar graph * jewel * tft * tft * Update maker-peripherals.md * nokia * led bar * labels * tft * cleanup * oled * cleanup * display enhancement * displays * neopixel * displays * displays * new tft * displays * icon * variants * 2-digit 0.56 inch 7 segment * Added ability to hide component categories in the component tree and toolbar from the Config menu * led bar * Update variants.xml * color selection for bar graph * Added 1.8" sd * tft fixes * Update WS2812BRing.java * neopixel improvements * netlist and node name improvements * oled tweak * cleanup * mockito fix --------- Co-authored-by: Claude Opus 5 --- diylc/.claude/skills/add-component/SKILL.md | 7 + diylc/CLAUDE.md | 5 + diylc/diylc-core/pom.xml | 11 + .../main/java/org/diylc/common/EventType.java | 8 +- .../java/org/diylc/common/IPlugInPort.java | 15 + .../org/diylc/netlist/NetlistBuilder.java | 17 + .../src/main/java/org/diylc/netlist/Node.java | 34 +- .../java/org/diylc/presenter/Presenter.java | 54 + .../resources/import-defaults/variants.xml | 1082 ++++++++--------- .../diylc-core/src/main/resources/update.xml | 80 ++ .../java/org/diylc/netlist/NodeNameTest.java | 37 + .../org.mockito.plugins.MemberAccessor | 1 + .../org.mockito.plugins.MockMaker | 1 + .../AbstractAddressableLedBoard.java | 74 ++ .../diylc/components/AbstractMakerBoard.java | 329 +++-- .../diylc/components/MakerBoardPainter.java | 188 ++- .../components/displays/CharacterLCD.java | 278 +++-- .../components/displays/LEDBarGraph.java | 589 +++++++++ .../diylc/components/displays/LEDMatrix.java | 374 ++++++ .../components/displays/LEDMatrix8x8.java | 235 ---- .../components/displays/Nokia5110LCD.java | 291 +++++ .../components/displays/OLEDDisplay.java | 406 ++++++- .../displays/SevenSegmentDisplay.java | 663 ++++++---- .../diylc/components/displays/TFTDisplay.java | 541 ++++++++- .../components/displays/WS2812BBreakout.java | 232 ++++ .../components/displays/WS2812BJewel.java | 256 ++++ .../components/displays/WS2812BPanel.java | 326 +++++ .../components/displays/WS2812BRing.java | 238 ++-- .../components/displays/WS2812BStick.java | 192 +-- .../components/displays/WS2812BStrip.java | 373 ++++++ .../diylc/components/micro/ArduinoMega.java | 17 +- .../diylc/components/micro/ArduinoNano.java | 154 ++- .../diylc/components/micro/ArduinoUno.java | 78 +- .../diylc/components/micro/ESP32DevKit.java | 55 +- .../diylc/components/micro/RaspberryPi.java | 51 +- .../components/micro/RaspberryPiPico.java | 56 +- .../components/micro/RaspberryPiZero.java | 47 +- .../org/diylc/components/micro/Teensy.java | 76 +- .../diylc/components/micro/WemosD1Mini.java | 54 +- .../modules/MT3608BoostConverter.java | 2 +- .../components/modules/TP4056Charger.java | 2 +- .../diylc/components/sensors/DHTSensor.java | 5 +- .../transform/LEDBarGraphTransformer.java | 129 ++ .../components/tube/Jumbo4PinTubeSocket.java | 2 +- .../diylc/components/FactoryVariantsTest.java | 138 +++ .../components/displays/CharacterLCDTest.java | 106 ++ .../components/displays/LEDBarGraphTest.java | 412 +++++++ .../components/displays/LEDMatrixTest.java | 151 +++ .../components/displays/Nokia5110LCDTest.java | 195 +++ .../components/displays/OLEDDisplayTest.java | 621 ++++++++++ .../displays/SevenSegmentDisplayTest.java | 240 ++++ .../components/displays/TFTDisplayTest.java | 673 ++++++++++ .../displays/WS2812BBreakoutTest.java | 215 ++++ .../components/displays/WS2812BJewelTest.java | 257 ++++ .../components/displays/WS2812BPanelTest.java | 276 +++++ .../components/displays/WS2812BRingTest.java | 197 +++ .../components/displays/WS2812BStickTest.java | 195 +++ .../components/displays/WS2812BStripTest.java | 286 +++++ .../diylc/components/maker/BoardRailTest.java | 152 +++ .../MakerBoardTestSupport.java | 103 +- .../components/maker/MakerComponentsTest.java | 29 +- .../maker/MakerVariantSweepTest.java | 252 ++++ .../components/maker/ScreenTextTest.java | 101 ++ .../components/micro/ArduinoMegaTest.java | 2 + .../components/micro/ArduinoNanoTest.java | 5 +- .../components/micro/ArduinoUnoTest.java | 26 +- .../components/micro/ESP32DevKitTest.java | 1 + .../components/micro/ESP8266NodeMCUTest.java | 1 + .../components/micro/RaspberryPiPicoTest.java | 1 + .../components/micro/RaspberryPiTest.java | 1 + .../components/micro/RaspberryPiZeroTest.java | 1 + .../diylc/components/micro/TeensyTest.java | 3 +- .../components/micro/WemosD1MiniTest.java | 31 +- .../java/org/diylc/swing/ActionFactory.java | 11 + .../actions/ComponentCategoryAction.java | 63 + .../swing/actions/ResetCategoriesAction.java | 45 + .../swing/plugins/config/ConfigPlugin.java | 11 +- .../plugins/toolbox/ComponentTabbedPane.java | 62 +- .../diylc/swing/plugins/toolbox/ToolBox.java | 8 +- .../swing/plugins/tree/ComponentTree.java | 8 +- .../swing/plugins/tree/CustomTreeModel.java | 24 +- .../main/java/org/diylc/utils/IconLoader.java | 2 +- .../diylc-swing-images/folder_preferences.png | Bin 0 -> 863 bytes .../test/java/org/diylc/PresenterTests.java | 125 +- diylc/docs/plans/category-visibility.md | 288 +++++ diylc/docs/plans/maker-board-roadmap.md | 35 +- diylc/docs/plans/maker-peripherals.md | 258 ++++ 87 files changed, 11343 insertions(+), 1933 deletions(-) create mode 100644 diylc/diylc-core/src/test/resources/mockito-extensions/org.mockito.plugins.MemberAccessor create mode 100644 diylc/diylc-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/AbstractAddressableLedBoard.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDBarGraph.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDMatrix.java delete mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDMatrix8x8.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/displays/Nokia5110LCD.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BBreakout.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BJewel.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BPanel.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BStrip.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/transform/LEDBarGraphTransformer.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/FactoryVariantsTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/CharacterLCDTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/LEDBarGraphTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/LEDMatrixTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/Nokia5110LCDTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/OLEDDisplayTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/SevenSegmentDisplayTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/TFTDisplayTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BBreakoutTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BJewelTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BPanelTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BRingTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BStickTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BStripTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/maker/BoardRailTest.java rename diylc/diylc-library/src/test/java/org/diylc/components/{micro => maker}/MakerBoardTestSupport.java (50%) create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerVariantSweepTest.java create mode 100644 diylc/diylc-library/src/test/java/org/diylc/components/maker/ScreenTextTest.java create mode 100644 diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ComponentCategoryAction.java create mode 100644 diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ResetCategoriesAction.java create mode 100644 diylc/diylc-swing/src/main/resources/diylc-swing-images/folder_preferences.png create mode 100644 diylc/docs/plans/category-visibility.md create mode 100644 diylc/docs/plans/maker-peripherals.md diff --git a/diylc/.claude/skills/add-component/SKILL.md b/diylc/.claude/skills/add-component/SKILL.md index 25cf0f21c..af3e61729 100644 --- a/diylc/.claude/skills/add-component/SKILL.md +++ b/diylc/.claude/skills/add-component/SKILL.md @@ -78,6 +78,10 @@ public class MyPart extends AbstractLeadedComponent { @Override public void drawIcon(Graphics2D g2d, int width, int height) { ... } + + public enum Version { // nested types last, just before the closing brace + ... + } } ``` @@ -90,6 +94,9 @@ Requirements the framework enforces: depend on neighbouring components. - Appearance constants are `public static` and deliberately **not final**, so they can be overridden at runtime. Follow that. +- Nested types — a `Version` enum, a package or size enum, a small helper class — go at the **bottom + of the file**, after every method, so the outer class reads top to bottom without a block of enum + declarations in the middle of it. ## Editable properties diff --git a/diylc/CLAUDE.md b/diylc/CLAUDE.md index 7d17e421b..86d9807e3 100644 --- a/diylc/CLAUDE.md +++ b/diylc/CLAUDE.md @@ -74,6 +74,11 @@ The formatter is `eclipse-java-google-style.xml` (Google Java Style): runtime; keep that convention when adding new ones. - `private static final Logger LOG = Logger.getLogger(TheClass.class);` — log4j 1.2, declared first among the static members. Only declare one on a class that actually needs it; see *Logging* below. +- **Nested types go at the bottom of the file**, after every field, constructor and method of the + enclosing class, immediately before its closing brace. A version enum or a small helper class + declared halfway down the file pushes the code a reader came for out of sight; keeping them last + means the outer class reads top to bottom without interruption. Several nested types keep their + relative order. ### Comments diff --git a/diylc/diylc-core/pom.xml b/diylc/diylc-core/pom.xml index e521a4d8f..629df5baa 100644 --- a/diylc/diylc-core/pom.xml +++ b/diylc/diylc-core/pom.xml @@ -74,6 +74,17 @@ 5.11.0 test + + + org.mockito + mockito-subclass + 5.11.0 + test + diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/EventType.java b/diylc/diylc-core/src/main/java/org/diylc/common/EventType.java index 2fbd22888..0c8b8727f 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/common/EventType.java +++ b/diylc/diylc-core/src/main/java/org/diylc/common/EventType.java @@ -117,5 +117,11 @@ public enum EventType { * Called when the mouse hovers over sticky control points (nodes) in tooltip mode. * Parameters: String text (semicolon-separated component and node names, or null), Point point. */ - NODE_NAME_HOVER_TOOLTIP; + NODE_NAME_HOVER_TOOLTIP, + /** + * Called when the set of enabled component categories changes. The first parameter is a + * Set<String> of disabled category names. All categories not included are + * enabled. + */ + COMPONENT_CATEGORIES_CHANGED; } diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java b/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java index 03cf41c6b..3118b19fa 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java +++ b/diylc/diylc-core/src/main/java/org/diylc/common/IPlugInPort.java @@ -83,6 +83,7 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP public static final String HARDWARE_ACCELERATION = "hardwareAcceleration"; public static final String EXTRA_SPACE_KEY = "extraSpace"; public static final String FAVORITES_KEY = "favorites"; + public static final String DISABLED_CATEGORIES_KEY = "disabledComponentCategories"; public static final String RENUMBER_ON_PASTE_KEY = "renumberOnPaste"; public static final String RULER_IN_SUBDIVISION_KEY = "rulerInSubdivision"; public static final String CACHING_ENABLED_KEY = "cachingEnabled"; @@ -213,6 +214,20 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP */ Map> getComponentTypes(); + /** + * Returns the {@link ComponentType}s that should be offered to the user, i.e. the result of + * {@link #getComponentTypes()} without the categories the user disabled. Intended for component + * browsers; anything that needs to resolve an arbitrary component class must use + * {@link #getComponentTypes()} instead. + */ + Map> getVisibleComponentTypes(); + + boolean isCategoryEnabled(String category); + + void setCategoryEnabled(String category, boolean enabled); + + void resetCategoryVisibility(); + /** * Draws project on the provided {@link Graphics2D}. If the provided filter is not null, it will * be used to filter the components that are shown. diff --git a/diylc/diylc-core/src/main/java/org/diylc/netlist/NetlistBuilder.java b/diylc/diylc-core/src/main/java/org/diylc/netlist/NetlistBuilder.java index bebc62ec2..e040fd82f 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/netlist/NetlistBuilder.java +++ b/diylc/diylc-core/src/main/java/org/diylc/netlist/NetlistBuilder.java @@ -411,6 +411,23 @@ public class NetlistBuilder { if (((IContinuity) c).arePointsConnected(i, j)) connections.add(new Connection(c.getControlPoint(i), c.getControlPoint(j), z)); } + // Pins of one component that name the same rail are the same node: a board's eight ground + // pins land on one plane, and grounding one of them must not leave the rest floating. + // Switches are excluded because their connectivity depends on the position, which a name + // cannot express -- a switch states it through ISwitch.arePointsConnected instead. + if (!(c instanceof ISwitch)) { + for (int i = 0; i < c.getControlPointCount() - 1; i++) { + String rail = Node.railName(c.getControlPointNodeName(i)); + if (rail == null) { + continue; + } + for (int j = i + 1; j < c.getControlPointCount(); j++) { + if (rail.equals(Node.railName(c.getControlPointNodeName(j)))) { + connections.add(new Connection(c.getControlPoint(i), c.getControlPoint(j), z)); + } + } + } + } // handle switches if (ISwitch.class.isAssignableFrom(type.getInstanceClass()) && switchPositions.containsKey(c)) { diff --git a/diylc/diylc-core/src/main/java/org/diylc/netlist/Node.java b/diylc/diylc-core/src/main/java/org/diylc/netlist/Node.java index 902dfa5af..0c0296708 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/netlist/Node.java +++ b/diylc/diylc-core/src/main/java/org/diylc/netlist/Node.java @@ -22,7 +22,10 @@ package org.diylc.netlist; import java.awt.geom.Point2D; +import java.util.Arrays; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; import org.diylc.core.ICommonNode; import org.diylc.core.IDIYComponent; @@ -34,6 +37,10 @@ import org.diylc.core.IDIYComponent; */ public class Node implements Comparable { + /** Pin names that mark an absent connection, so repeats of them are not one net. */ + private static final Set NON_NET_NAMES = + new HashSet(Arrays.asList("NC", "RSV")); + private IDIYComponent component; private int pointIndex; @@ -60,8 +67,9 @@ public class Node implements Comparable { * MCU1.D11 rather than MCU1.D11 (~, MOSI). The bare name identifies the * pin; everything in parentheses documents its alternate functions and only adds noise here. * - *

A trailing disambiguator such as the _1 in GND_1 is deliberately - * kept, because those mark genuinely different pins that a netlist has to tell apart. + *

A trailing disambiguator such as the _1 in GND_1 is kept, so the + * netlist can still say which pad a connection reached. Whether two such pins are the same + * net is a separate question, answered by {@link #railName(String)}. * * @param nodeName raw name from {@link IDIYComponent#getControlPointNodeName(int)} * @return the sanitized name, or null if the raw name was null @@ -75,6 +83,28 @@ public class Node implements Comparable { // Never let sanitizing leave a pin nameless return sanitized.isEmpty() ? nodeName.trim() : sanitized; } + + /** + * The rail a pin belongs to: its sanitized name with a trailing _1, _2 + * disambiguator removed, so GND_1 and GND_2 report the same rail. + * + *

Only a numeric suffix is dropped. An underscore does three unrelated jobs across the + * pinouts in this library: it disambiguates a repeated rail, it namespaces a connector's signals + * (ETH_TX-, USB_D+), and it qualifies a genuinely separate net + * (MISO_16U2, 3V3_EN). Cutting at the first underscore instead would + * report an Ethernet header as one rail. + * + * @param nodeName raw name from {@link IDIYComponent#getControlPointNodeName(int)} + * @return the rail name, or null if the pin has no name or names no net at all + */ + public static String railName(String nodeName) { + String sanitized = sanitizeNodeName(nodeName); + if (sanitized == null || sanitized.isEmpty()) { + return null; + } + String rail = sanitized.replaceAll("_\\d+$", ""); + return NON_NET_NAMES.contains(rail) ? null : rail; + } public Point2D getPoint2D() { return component.getControlPoint(pointIndex); diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java index a1f364333..5968cbe79 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java @@ -202,6 +202,14 @@ public class Presenter implements IPlugInPort { messageDispatcher.dispatchMessage(EventType.NODE_NAME_HOVER_TOOLTIP, null, null); } }); + + configManager.addConfigListener(IPlugInPort.DISABLED_CATEGORIES_KEY, new IConfigListener() { + @Override + public void valueChanged(String key, Object value) { + messageDispatcher.dispatchMessage(EventType.COMPONENT_CATEGORIES_CHANGED, + getDisabledCategories()); + } + }); } public void installPlugin(Supplier plugInSupplier) { @@ -408,12 +416,58 @@ public class Presenter implements IPlugInPort { return projectFileManager.isModified(); } + @SuppressWarnings("unchecked") + private Set getDisabledCategories() { + List list = (List) configManager.readObject( + IPlugInPort.DISABLED_CATEGORIES_KEY, new ArrayList()); + if (list == null) { + return new HashSet(); + } + return new HashSet(list); + } + @SuppressWarnings("unchecked") @Override public Map> getComponentTypes() { return ComponentProcessor.getInstance().getComponentTypes(); } + @Override + public Map> getVisibleComponentTypes() { + Set disabled = getDisabledCategories(); + Map> all = getComponentTypes(); + Map> visible = new HashMap>(); + for (Map.Entry> entry : all.entrySet()) { + if (!disabled.contains(entry.getKey())) { + visible.put(entry.getKey(), entry.getValue()); + } + } + return visible; + } + + @Override + public boolean isCategoryEnabled(String category) { + return !getDisabledCategories().contains(category); + } + + @Override + public void setCategoryEnabled(String category, boolean enabled) { + Set disabled = getDisabledCategories(); + if (enabled) { + disabled.remove(category); + } else { + disabled.add(category); + } + List list = new ArrayList(disabled); + Collections.sort(list); + configManager.writeValue(IPlugInPort.DISABLED_CATEGORIES_KEY, list); + } + + @Override + public void resetCategoryVisibility() { + configManager.writeValue(IPlugInPort.DISABLED_CATEGORIES_KEY, new ArrayList()); + } + @SuppressWarnings({"unchecked"}) private boolean isComponentVisible(IDIYComponent component) { ComponentType componentType = diff --git a/diylc/diylc-core/src/main/resources/import-defaults/variants.xml b/diylc/diylc-core/src/main/resources/import-defaults/variants.xml index db9957af4..2a712037b 100644 --- a/diylc/diylc-core/src/main/resources/import-defaults/variants.xml +++ b/diylc/diylc-core/src/main/resources/import-defaults/variants.xml @@ -6,7 +6,7 @@ Covered PAF-Style - Bobin 1 + Bobbin 1 @@ -18,17 +18,13 @@ true - Bobin 2 + Bobbin 2 Color - - Alpha - 127 - Model @@ -60,7 +56,7 @@ Uncovered PAF-Style - Bobin 1 + Bobbin 1 @@ -72,17 +68,13 @@ false - Bobin 2 + Bobbin 2 Color - - Alpha - 127 - Pole Color @@ -114,7 +106,7 @@ EMG-style - Bobin 1 + Bobbin 1 @@ -126,17 +118,13 @@ true - Bobin 2 + Bobbin 2 Color - - Alpha - 127 - Pole Color @@ -168,7 +156,7 @@ Filtertron-style - Bobin 1 + Bobbin 1 @@ -184,17 +172,13 @@ true - Bobin 2 + Bobbin 2 Color - - Alpha - 127 - Model @@ -239,7 +223,7 @@ - Power rating + Power Rating @@ -247,11 +231,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -262,10 +246,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -302,7 +282,7 @@ - Power rating + Power Rating @@ -310,11 +290,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -325,10 +305,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -365,7 +341,7 @@ - Power rating + Power Rating @@ -373,11 +349,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -388,10 +364,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -428,7 +400,7 @@ - Power rating + Power Rating @@ -436,11 +408,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -451,10 +423,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -491,7 +459,7 @@ - Power rating + Power Rating @@ -499,11 +467,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -514,10 +482,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -554,7 +518,7 @@ - Power rating + Power Rating @@ -562,11 +526,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -577,10 +541,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -617,7 +577,7 @@ - Power rating + Power Rating @@ -625,11 +585,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -640,10 +600,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -680,7 +636,7 @@ - Power rating + Power Rating @@ -688,11 +644,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -703,10 +659,6 @@ Value - - Alpha - 127 - Color code NONE @@ -743,7 +695,7 @@ - Power rating + Power Rating @@ -751,11 +703,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -766,10 +718,6 @@ Value - - Alpha - 127 - Color code NONE @@ -806,7 +754,7 @@ - Power rating + Power Rating @@ -814,11 +762,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -829,10 +777,6 @@ Value - - Alpha - 127 - Color code NONE @@ -869,7 +813,7 @@ - Power rating + Power Rating @@ -877,11 +821,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -892,10 +836,6 @@ Value - - Alpha - 127 - Color code NONE @@ -932,7 +872,7 @@ - Power rating + Power Rating @@ -940,11 +880,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -955,10 +895,6 @@ Value - - Alpha - 127 - Color code NONE @@ -995,7 +931,7 @@ - Power rating + Power Rating @@ -1003,11 +939,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -1018,10 +954,6 @@ Value - - Alpha - 127 - Color code NONE @@ -1058,7 +990,7 @@ - Power rating + Power Rating @@ -1066,11 +998,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -1081,10 +1013,6 @@ Value - - Alpha - 127 - Color code NONE @@ -1121,7 +1049,7 @@ - Power rating + Power Rating @@ -1129,11 +1057,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -1144,10 +1072,6 @@ Value - - Alpha - 127 - Color code NONE @@ -1184,7 +1108,7 @@ - Power rating + Power Rating @@ -1192,11 +1116,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -1207,10 +1131,6 @@ Value - - Alpha - 127 - Color code NONE @@ -1247,7 +1167,7 @@ - Power rating + Power Rating @@ -1255,11 +1175,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -1270,10 +1190,6 @@ Value - - Alpha - 127 - Color code NONE @@ -1310,7 +1226,7 @@ - Power rating + Power Rating @@ -1318,11 +1234,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -1333,10 +1249,6 @@ Value - - Alpha - 127 - Color code NONE @@ -1373,7 +1285,7 @@ - Power rating + Power Rating @@ -1381,11 +1293,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -1396,10 +1308,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -1436,7 +1344,7 @@ - Power rating + Power Rating @@ -1444,11 +1352,11 @@ false - Lead color + Lead Color - Label color + Label Color @@ -1459,10 +1367,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -1534,10 +1438,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -1609,10 +1509,6 @@ Value - - Alpha - 127 - Color code _5_BAND @@ -1653,10 +1549,6 @@ Color - - Alpha - 127 - Pole Color @@ -1695,10 +1587,6 @@ Color - - Alpha - 127 - Pole Color @@ -1737,10 +1625,6 @@ Color - - Alpha - 127 - Pole Color @@ -1779,10 +1663,6 @@ Color - - Alpha - 127 - Pole Color @@ -1821,10 +1701,6 @@ Color - - Alpha - 127 - Pole Color @@ -1863,10 +1739,6 @@ Color - - Alpha - 127 - Pole Color @@ -1906,14 +1778,6 @@ Type MONO - - Alpha - 127 - - - Value - - Orientation DEFAULT @@ -1935,14 +1799,6 @@ Type STEREO - - Alpha - 127 - - - Value - - Orientation DEFAULT @@ -1979,10 +1835,6 @@ Pin Spacing - - Alpha - 127 - Value @@ -2044,10 +1896,6 @@ Pin Spacing - - Alpha - 127 - Value @@ -2118,10 +1966,6 @@ Voltage - - Alpha - 127 - Value @@ -2175,10 +2019,6 @@ Voltage - - Alpha - 100 - Value @@ -2232,10 +2072,6 @@ Voltage - - Alpha - 100 - Value @@ -2293,10 +2129,6 @@ Value - - Alpha - 100 - Label @@ -2343,22 +2175,10 @@ PCB Noval - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Base B9A @@ -2382,22 +2202,10 @@ PCB Octal - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Base OCTAL @@ -2420,22 +2228,10 @@ Chassis Noval - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Mount CHASSIS @@ -2471,22 +2267,10 @@ Chassis Octal - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Mount CHASSIS @@ -2521,22 +2305,10 @@ Chassis Noval 9A (12A?7) - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Mount CHASSIS @@ -2572,22 +2344,10 @@ Chassis Noval 9CV (EL84) - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Mount CHASSIS @@ -2623,22 +2383,10 @@ Chassis Octal 5T - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Mount CHASSIS @@ -2673,22 +2421,10 @@ Chassis Octal 7AC (6?6) - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Mount CHASSIS @@ -2723,22 +2459,10 @@ Chassis 7-pin 7BK - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Mount CHASSIS @@ -2772,22 +2496,10 @@ Chassis Octal 8ET (EL34) - - Type - - - - Angle - 0 - Color - - Alpha - 127 - Mount CHASSIS @@ -2831,18 +2543,10 @@ Type MONO - - Alpha - 127 - Labels true - - Orientation - DEFAULT - @@ -2859,18 +2563,10 @@ Type STEREO - - Alpha - 127 - Labels true - - Orientation - DEFAULT - @@ -2887,18 +2583,10 @@ Type SWITCHED - - Alpha - 127 - Labels true - - Orientation - DEFAULT - @@ -2920,14 +2608,6 @@ Type DP3T - - Alpha - 127 - - - Orientation - DEFAULT - @@ -2949,14 +2629,6 @@ Type _4P5T - - Alpha - 127 - - - Orientation - DEFAULT - @@ -3012,25 +2684,17 @@ - Lug size + Lug Size Type ThroughHole - - Show shaft - false - Value - - Alpha - 127 - Body @@ -3076,25 +2740,17 @@ - Lug size + Lug Size Type ThroughHole - - Show shaft - false - Value - - Alpha - 127 - Body @@ -3146,10 +2802,6 @@ - - Alpha - 127 - Label @@ -3197,10 +2849,6 @@ Voltage - - Alpha - 127 - Value @@ -3267,10 +2915,6 @@ - - Alpha - 127 - Label @@ -3328,10 +2972,6 @@ - - Alpha - 127 - Label @@ -3425,10 +3065,6 @@ Core Border - - Alpha - 100 - Value @@ -3508,10 +3144,6 @@ Core Border - - Alpha - 100 - Value @@ -3591,10 +3223,6 @@ Core Border - - Alpha - 100 - Value @@ -3639,16 +3267,12 @@ Color - - Alpha - 127 - Border - Border thickness + Border Thickness @@ -3670,16 +3294,12 @@ Color - - Alpha - 127 - Border - Border thickness + Border Thickness @@ -3701,16 +3321,12 @@ Color - - Alpha - 127 - Border - Border thickness + Border Thickness @@ -3732,16 +3348,12 @@ Color - - Alpha - 127 - Border - Border thickness + Border Thickness @@ -3764,10 +3376,6 @@ Type DPDT - - Alpha - 127 - Spacing @@ -3803,10 +3411,6 @@ Type SPDT - - Alpha - 127 - Spacing @@ -3839,10 +3443,6 @@ Type _3PDT - - Alpha - 127 - Spacing @@ -3995,10 +3595,6 @@ Label - - Alpha - 127 - Timing @@ -4052,10 +3648,6 @@ Label - - Alpha - 127 - Timing @@ -4110,10 +3702,6 @@ Label - - Alpha - 127 - Timing @@ -4169,10 +3757,6 @@ Label - - Alpha - 127 - Timing @@ -4240,10 +3824,6 @@ Label - - Alpha - 127 - Timing @@ -4302,10 +3882,6 @@ Label - - Alpha - 127 - Timing @@ -4366,10 +3942,6 @@ Label - - Alpha - 127 - Timing @@ -4440,10 +4012,6 @@ Label - - Alpha - 127 - Timing @@ -4505,10 +4073,6 @@ 0 - - Alpha - 127 - @@ -4535,10 +4099,6 @@ 0 - - Alpha - 127 - @@ -4567,10 +4127,6 @@ 0 - - Alpha - 127 - @@ -4614,10 +4170,6 @@ 0 - - Alpha - 127 - @@ -4644,10 +4196,6 @@ 0 - - Alpha - 127 - @@ -4675,10 +4223,6 @@ 0 - - Alpha - 127 - @@ -4702,11 +4246,11 @@ 6x6 (e.g. Omron B3F-1000) - Spacing X + Pin Spacing - Spacing Y + Row Spacing @@ -4718,7 +4262,7 @@ DEFAULT - Case + Case Size @@ -4733,10 +4277,6 @@ Value - - Alpha - 127 - Body @@ -4767,11 +4307,11 @@ 12x12 (e.g. Omron B3F-4000) - Spacing X + Pin Spacing - Spacing Y + Row Spacing @@ -4783,7 +4323,7 @@ DEFAULT - Case + Case Size @@ -4798,10 +4338,6 @@ Value - - Alpha - 127 - Body @@ -4832,11 +4368,11 @@ 5x5 Low Profile SMD (e.g. Alps SKQG) - Spacing X + Pin Spacing - Spacing Y + Row Spacing @@ -4848,7 +4384,7 @@ DEFAULT - Case + Case Size @@ -4863,10 +4399,6 @@ Value - - Alpha - 127 - Border @@ -5355,4 +4887,452 @@ + + org.diylc.components.displays.CharacterLCD + + + 16x2 I2C Backpack + + Size_16x2 + InterfaceI2C_Backpack + + false + 2026-10-11T00:00:00.0000000 + + + 16x2 Parallel HD44780 + + Size_16x2 + InterfaceParallel_16Pin + + false + 2026-10-11T00:00:00.0000000 + + + 20x4 I2C Backpack + + Size_20x4 + InterfaceI2C_Backpack + + false + 2026-10-11T00:00:00.0000000 + + + 20x4 Parallel HD44780 + + Size_20x4 + InterfaceParallel_16Pin + + false + 2026-10-11T00:00:00.0000000 + + + + + org.diylc.components.displays.OLEDDisplay + + + 0.96" SSD1306 I2C + + SizeSSD1306_0_96 + InterfaceI2C_4Pin + + false + 2026-10-11T00:00:00.0000000 + + + 0.96" SSD1306 SPI (7-Pin) + + SizeSSD1306_0_96 + InterfaceSPI_7Pin + + false + 2026-10-11T00:00:00.0000000 + + + 0.96" SSD1306 SPI (6-Pin) + + SizeSSD1306_0_96 + InterfaceSPI_6Pin + + false + 2026-10-11T00:00:00.0000000 + + + 0.96" SSD1306 SPI (8-Pin) + + SizeSSD1306_0_96 + InterfaceSPI_8Pin + + false + 2026-10-11T00:00:00.0000000 + + + 0.91" SSD1306 I2C + + SizeSSD1306_0_91 + InterfaceI2C_4Pin + + false + 2026-10-11T00:00:00.0000000 + + + 0.91" SSD1306 SPI (7-Pin) + + SizeSSD1306_0_91 + InterfaceSPI_7Pin + + false + 2026-10-11T00:00:00.0000000 + + + 0.91" SSD1306 SPI (6-Pin) + + SizeSSD1306_0_91 + InterfaceSPI_6Pin + + false + 2026-10-11T00:00:00.0000000 + + + 0.91" SSD1306 SPI (8-Pin) + + SizeSSD1306_0_91 + InterfaceSPI_8Pin + + false + 2026-10-11T00:00:00.0000000 + + + 1.3" SH1106 I2C + + SizeSH1106_1_3 + InterfaceI2C_4Pin + + false + 2026-10-11T00:00:00.0000000 + + + 1.3" SH1106 SPI (7-Pin) + + SizeSH1106_1_3 + InterfaceSPI_7Pin + + false + 2026-10-11T00:00:00.0000000 + + + 1.3" SH1106 SPI (6-Pin) + + SizeSH1106_1_3 + InterfaceSPI_6Pin + + false + 2026-10-11T00:00:00.0000000 + + + 1.3" SH1106 SPI (8-Pin) + + SizeSH1106_1_3 + InterfaceSPI_8Pin + + false + 2026-10-11T00:00:00.0000000 + + + + + org.diylc.components.displays.TFTDisplay + + + 2.8" ILI9341 (Touch + SD) + + ControllerILI9341_2_8 + + false + 2026-10-11T00:00:00.0000000 + + + 2.4" ILI9341 (Touch + SD) + + ControllerILI9341_2_4 + + false + 2026-10-11T00:00:00.0000000 + + + 0.96" ST7735 80x160 + + ControllerST7735_0_96 + + false + 2026-10-11T00:00:00.0000000 + + + 1.8" ST7735 128x160 + + ControllerST7735_1_8 + + false + 2026-10-11T00:00:00.0000000 + + + 1.8" ST7735 128x160 (SD) + + ControllerST7735_1_8_SD + + false + 2026-10-11T00:00:00.0000000 + + + 1.54" ST7789 240x240 + + ControllerST7789_1_54 + + false + 2026-10-11T00:00:00.0000000 + + + 1.28" GC9A01 Round + + ControllerGC9A01_1_28 + + false + 2026-10-11T00:00:00.0000000 + + + + + org.diylc.components.displays.SevenSegmentDisplay + + + 1-Digit 0.56" Common Cathode + + TypeSingleDigit_10Pin + CommonCathode + + false + 2026-10-11T00:00:00.0000000 + + + 1-Digit 0.56" Common Anode + + TypeSingleDigit_10Pin + CommonAnode + + false + 2026-10-11T00:00:00.0000000 + + + 2-Digit 0.56" Common Cathode + + TypeTwoDigit_0_56_10Pin + CommonCathode + + false + 2026-10-11T00:00:00.0000000 + + + 2-Digit 0.56" Common Anode + + TypeTwoDigit_0_56_10Pin + CommonAnode + + false + 2026-10-11T00:00:00.0000000 + + + 4-Digit 0.36" Common Cathode + + TypeFourDigit_0_36_12Pin + CommonCathode + + false + 2026-10-11T00:00:00.0000000 + + + 4-Digit 0.36" Common Anode + + TypeFourDigit_0_36_12Pin + CommonAnode + + false + 2026-10-11T00:00:00.0000000 + + + 4-Digit 0.56" Common Cathode + + TypeFourDigit_0_56_12Pin + CommonCathode + + false + 2026-10-11T00:00:00.0000000 + + + 4-Digit 0.56" Common Anode + + TypeFourDigit_0_56_12Pin + CommonAnode + + false + 2026-10-11T00:00:00.0000000 + + + 4-Digit TM1637 Module + + TypeTM1637_Module_4Pin + + false + 2026-10-11T00:00:00.0000000 + + + + + org.diylc.components.displays.LEDMatrix + + + Single 8x8 (MAX7219) + + ModulesSingle_8x8 + + false + 2026-10-11T00:00:00.0000000 + + + Compact 8x8 (MAX7219) + + ModulesCompact_8x8 + + false + 2026-10-11T00:00:00.0000000 + + + 4-in-1 32x8 (MAX7219) + + ModulesFourInOne_32x8 + + false + 2026-10-11T00:00:00.0000000 + + + 8-in-1 64x8 (MAX7219) + + ModulesEightInOne_64x8 + + false + 2026-10-11T00:00:00.0000000 + + + + + org.diylc.components.displays.LEDBarGraph + + + 4 Segment + + Segments_4 + + false + 2026-10-11T00:00:00.0000000 + + + 5 Segment + + Segments_5 + + false + 2026-10-11T00:00:00.0000000 + + + 6 Segment + + Segments_6 + + false + 2026-10-11T00:00:00.0000000 + + + 8 Segment + + Segments_8 + + false + 2026-10-11T00:00:00.0000000 + + + 10 Segment + + Segments_10 + + false + 2026-10-11T00:00:00.0000000 + + + 12 Segment + + Segments_12 + + false + 2026-10-11T00:00:00.0000000 + + + + + org.diylc.components.displays.WS2812BRing + + + 12 LEDs + + Ring Size_12_LED + + false + 2026-10-11T00:00:00.0000000 + + + 16 LEDs + + Ring Size_16_LED + + false + 2026-10-11T00:00:00.0000000 + + + 24 LEDs + + Ring Size_24_LED + + false + 2026-10-11T00:00:00.0000000 + + + + + org.diylc.components.displays.WS2812BStrip + + + 30 LED/m + + Density_30 + + false + 2026-10-11T00:00:00.0000000 + + + 60 LED/m + + Density_60 + + false + 2026-10-11T00:00:00.0000000 + + + 144 LED/m + + Density_144 + + false + 2026-10-11T00:00:00.0000000 + + + diff --git a/diylc/diylc-core/src/main/resources/update.xml b/diylc/diylc-core/src/main/resources/update.xml index 5f8d0c4de..95923f9a9 100644 --- a/diylc/diylc-core/src/main/resources/update.xml +++ b/diylc/diylc-core/src/main/resources/update.xml @@ -496,5 +496,85 @@ https://github.com/bancika/diy-layout-creator/releases + + + 6 + 7 + 0 + + 2026-10-11 00:00:00.000 CET + + + + NEW_FEATURE + 7-Segment Display - 1-digit 0.56 inch, 2-digit 0.56 inch, 4-digit in 0.36 inch and 0.56 inch, and TM1637 driver module + + + NEW_FEATURE + Character LCD - 16x2 and 20x4, with I2C backpack or 16-pin parallel interface + + + NEW_FEATURE + LED Bar Graph - 4, 5, 6, 8, 10 and 12 segments + + + NEW_FEATURE + LED Matrix (MAX7219) - single 8x8, compact 8x8, 4-in-1 32x8, 8-in-1 64x8 + + + NEW_FEATURE + NeoPixel Breakout (WS2812B) - single LED + + + NEW_FEATURE + NeoPixel Jewel (WS2812B) - 7 LEDs + + + NEW_FEATURE + NeoPixel Panel (WS2812B) - 8x8, 64 LEDs + + + NEW_FEATURE + NeoPixel Ring (WS2812B) - 12, 16 and 24 LEDs + + + NEW_FEATURE + NeoPixel Stick (WS2812B) - 8 LEDs + + + NEW_FEATURE + NeoPixel Strip (WS2812B) - 30, 60 and 144 LEDs per meter, up to 144 LEDs + + + NEW_FEATURE + Nokia 5110 LCD (PCD8544) + + + NEW_FEATURE + OLED Display - 0.96 inch SSD1306, 0.91 inch SSD1306, 1.3 inch SH1106, in I2C, 7-pin SPI and 6-pin SPI + + + NEW_FEATURE + TFT Display - 2.8 inch and 2.4 inch ILI9341, 1.8 inch and 0.96 inch ST7735, 1.54 inch ST7789, 1.28 inch round GC9A01 + + + NEW_FEATURE + Added ability to hide component categories in the component tree and toolbar from the Config menu + + + IMPROVEMENT + Bill of Materials now lists the selected variant of microcontroller boards and displays, so different versions of the same board are no longer merged into a single row + + + BUG_FIX + 4-pin Jumbo Tube Socket - the two filament pins are now labeled F1 and F2 instead of both being labeled F, so they no longer read as the same node + + + IMPROVEMENT + Netlists now treat the repeated supply pins of a microcontroller or display board as one node, so grounding a single GND pin no longer leaves the others floating + + + https://github.com/bancika/diy-layout-creator/releases + diff --git a/diylc/diylc-core/src/test/java/org/diylc/netlist/NodeNameTest.java b/diylc/diylc-core/src/test/java/org/diylc/netlist/NodeNameTest.java index 99c7978f1..286fcc0f9 100644 --- a/diylc/diylc-core/src/test/java/org/diylc/netlist/NodeNameTest.java +++ b/diylc/diylc-core/src/test/java/org/diylc/netlist/NodeNameTest.java @@ -48,4 +48,41 @@ public class NodeNameTest { Assert.assertEquals("(only annotation)", Node.sanitizeNodeName("(only annotation)")); Assert.assertNull(Node.sanitizeNodeName(null)); } + + @Test + public void railNameDropsOnlyANumericDisambiguator() { + Assert.assertEquals("GND", Node.railName("GND_1")); + Assert.assertEquals("GND", Node.railName("GND_12")); + Assert.assertEquals("GND", Node.railName("GND")); + Assert.assertEquals("GND", Node.railName("GND (Pin 6)")); + Assert.assertEquals("+5V", Node.railName("+5V_2")); + // the annotation is stripped before the disambiguator, so an annotated rail still resolves + Assert.assertEquals("3.3V", Node.railName("3.3V_2 (250mA)")); + Assert.assertEquals("GND", Node.railName("GND_4 (Mid)")); + } + + @Test + public void railNameKeepsNonNumericQualifiers() { + // namespaced connector signals + Assert.assertEquals("ETH_TX-", Node.railName("ETH_TX-")); + Assert.assertEquals("USB_D+", Node.railName("USB_D+")); + // qualifiers that name a separate net + Assert.assertEquals("MISO_16U2", Node.railName("MISO_16U2")); + Assert.assertEquals("3V3_EN", Node.railName("3V3_EN")); + // a bare trailing digit is part of the name, not a disambiguator + Assert.assertEquals("GP1", Node.railName("GP1")); + Assert.assertEquals("GND1", Node.railName("GND1")); + // digits that are not at the end are part of the name too: the 16U2 bridge's ground and 5V + // are separate nets from the board's own, and USB_5V is a connector pin, not a 5V rail + Assert.assertEquals("GND_16U2", Node.railName("GND_16U2")); + Assert.assertEquals("5V_16U2", Node.railName("5V_16U2")); + Assert.assertEquals("USB_5V", Node.railName("USB_5V")); + } + + @Test + public void railNameRejectsPinsThatNameNoNet() { + Assert.assertNull(Node.railName("NC_1")); + Assert.assertNull(Node.railName("RSV_2")); + Assert.assertNull(Node.railName(null)); + } } diff --git a/diylc/diylc-core/src/test/resources/mockito-extensions/org.mockito.plugins.MemberAccessor b/diylc/diylc-core/src/test/resources/mockito-extensions/org.mockito.plugins.MemberAccessor new file mode 100644 index 000000000..71111e337 --- /dev/null +++ b/diylc/diylc-core/src/test/resources/mockito-extensions/org.mockito.plugins.MemberAccessor @@ -0,0 +1 @@ +member-accessor-reflection diff --git a/diylc/diylc-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/diylc/diylc-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..fdbd0b157 --- /dev/null +++ b/diylc/diylc-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/AbstractAddressableLedBoard.java b/diylc/diylc-library/src/main/java/org/diylc/components/AbstractAddressableLedBoard.java new file mode 100644 index 000000000..51e24e683 --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/AbstractAddressableLedBoard.java @@ -0,0 +1,74 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.components; + +import java.awt.Color; + +import org.diylc.core.annotations.EditableProperty; + +/** + * Base for the boards built out of addressable 5050 LEDs -- the rings, the jewel, the stick, the + * strip, the matrix and the bare breakout. + * + *

Every one of them is sold fitted with any of the packages {@link LedType} lists, on the same + * PCB with the same footprint, so the package is a property of the board rather than a board of + * its own. What it changes is what the LEDs can emit, which is the palette here, and what the BOM + * has to ask for, which each board folds into its own variant label. + * + * @author Branislav Stojkovic + */ +public abstract class AbstractAddressableLedBoard extends AbstractMakerBoard { + + private static final long serialVersionUID = 1L; + + protected LedType ledType = LedType.RGB; + + // Rebuilt only when the count or the package changes, because draw() runs on every repaint. + private transient Color[] ledColors; + + @EditableProperty(name = "LED Type") + public LedType getLedType() { + if (ledType == null) { + ledType = LedType.RGB; + } + return ledType; + } + + public void setLedType(LedType ledType) { + this.ledType = ledType; + this.ledColors = null; + invalidateCache(); + } + + /** + * The colours {@code count} LEDs are drawn lit with. These parts are drawn lit because unlit + * they are a dark board with white specks on it, which reads as no particular component; running + * the palette around the part also says at a glance which package it is fitted with, a WWA one + * having no colour to show. + */ + protected Color[] getLedColors(int count) { + if (ledColors == null || ledColors.length != count) { + ledColors = buildLedGradient(count, getLedType().getGradient()); + } + return ledColors; + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/AbstractMakerBoard.java b/diylc/diylc-library/src/main/java/org/diylc/components/AbstractMakerBoard.java index 5adcb953e..ac0e0feaf 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/AbstractMakerBoard.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/AbstractMakerBoard.java @@ -24,6 +24,7 @@ package org.diylc.components; import java.awt.Color; import java.awt.Font; import java.awt.Graphics2D; +import java.awt.Rectangle; import java.awt.Shape; import java.awt.geom.AffineTransform; import java.awt.geom.Area; @@ -90,12 +91,28 @@ public abstract class AbstractMakerBoard extends AbstractTransparentComponent "3V3", "A0 (ADC0)" -> "A0"). + * Formats a pin name for silkscreen display, e.g. "3V3_1" to "3V3" and "A0 (ADC0)" to "A0". * *

Stripping the parenthesized annotation is the same operation the netlist performs, so it is - * delegated to {@link Node#sanitizeNodeName(String)} rather than duplicated. The silkscreen goes - * one step further and also drops the trailing disambiguator, because a board prints "GND" on - * every ground pin even though the netlist has to tell "GND_1" from "GND_2". + * delegated to {@link Node#sanitizeNodeName(String)}. The silkscreen also drops the trailing + * disambiguator, because a board prints "GND" on every ground pin even though the netlist has to + * tell "GND_1" from "GND_2". */ public static String getDisplayPinLabel(String name) { String sanitized = Node.sanitizeNodeName(name); @@ -286,17 +327,15 @@ public abstract class AbstractMakerBoard extends AbstractTransparentComponentBoth sizes are in pixels rather than {@link Size}, a part that caps its pad against the room + * a variant leaves having already done the arithmetic. + * + * @param padWidth Pad size across the part + * @param padLength Pad size along the part, before the cut -- half of this is drawn + * @param reachesRight Whether the surviving half reaches to the right of the cut + */ + protected void drawBisectedPads(Graphics2D g2d, int startIndex, int count, double padWidth, + double padLength, boolean reachesRight, boolean outlineMode, + IDrawingObserver drawingObserver) { + if (outlineMode) { + return; + } + double w = padWidth; + double depth = padLength / 2.0; + double theta = orientation.toRadians(); + + drawingObserver.startTrackingContinuityArea(true); + for (int i = startIndex; i < startIndex + count && i < controlPoints.length; i++) { + Point2D p = controlPoints[i]; + if (theta != 0) { + g2d.rotate(theta, p.getX(), p.getY()); + } + + Area pad = new Area(new RoundRectangle2D.Double(p.getX() - depth, p.getY() - w / 2.0, + depth * 2, w, w, w)); + pad.intersect(new Area(new Rectangle2D.Double( + reachesRight ? p.getX() : p.getX() - depth, p.getY() - w / 2.0, depth, w))); + + g2d.setColor(PAD_COLOR); + g2d.fill(pad); + g2d.setColor(PAD_COLOR.darker()); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); + g2d.draw(pad); + + if (theta != 0) { + g2d.rotate(-theta, p.getX(), p.getY()); + } + } + drawingObserver.stopTrackingContinuityArea(); + } + /** * Pad, drill-hole and edge-notch diameters used by {@link #drawCastellatedPads} and * {@link #subtractCastellationNotches}. A board whose castellations differ from the package @@ -618,12 +783,12 @@ public abstract class AbstractMakerBoard extends AbstractTransparentComponentUnlike the control-point based pad helpers this one works in unrotated board coordinates, - * so it has to be called while the board rotation is still applied to {@code g2d}. + *

Unlike the control-point based pad helpers this works in unrotated board coordinates, so it + * has to be called while the board rotation is still applied to {@code g2d}. * * @param g2d Graphics2D context, already transformed for board orientation * @param pinX Unrotated X coordinate of the pad column @@ -662,7 +827,7 @@ public abstract class AbstractMakerBoard extends AbstractTransparentComponentRGBW adds a fourth, white die to the RGB three and is sold by the tint of that die; WWA + * replaces the colour dies altogether with warm white, white and amber. + */ + public enum LedType { + RGB("RGB"), + RGBW_WARM("RGBW Warm White"), + RGBW_NATURAL("RGBW Natural White"), + RGBW_COOL("RGBW Cool White"), + WWA("WWA"); + + private final String label; + + LedType(String label) { + this.label = label; + } + + @Override public String toString() { return label; } + + /** True for the packages that carry a white die beside the colour ones. */ + public boolean hasWhiteDie() { return this != RGB && this != WWA; } + + /** The wheel a part fitted with this package is drawn lit with. */ + public Color[] getGradient() { return this == WWA ? WWA_LED_GRADIENT : RGB_LED_GRADIENT; } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/MakerBoardPainter.java b/diylc/diylc-library/src/main/java/org/diylc/components/MakerBoardPainter.java index 7f6bd2e1f..4f6576025 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/MakerBoardPainter.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/MakerBoardPainter.java @@ -22,13 +22,16 @@ package org.diylc.components; import java.awt.Color; +import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.geom.Ellipse2D; import java.awt.geom.Path2D; import java.awt.geom.Rectangle2D; import java.awt.geom.RoundRectangle2D; +import java.util.List; import org.diylc.awt.StringUtils; +import org.diylc.common.Display; import org.diylc.common.HorizontalAlignment; import org.diylc.common.ObjectCache; import org.diylc.common.VerticalAlignment; @@ -48,9 +51,6 @@ public class MakerBoardPainter { private MakerBoardPainter() {} - /** - * Helper to draw a mounting hole. - */ public static void drawMountingHole(Graphics2D g2d, double cx, double cy, double diameter) { g2d.setColor(Constants.CANVAS_COLOR); g2d.fill(new Ellipse2D.Double(cx - diameter / 2.0, cy - diameter / 2.0, diameter, diameter)); @@ -59,9 +59,6 @@ public class MakerBoardPainter { g2d.draw(new Ellipse2D.Double(cx - diameter / 2.0, cy - diameter / 2.0, diameter, diameter)); } - /** - * Helper to draw an IC chip on the board. - */ public static void drawChip(Graphics2D g2d, double x, double y, double w, double h, String label) { g2d.setColor(AbstractMakerBoard.IC_BODY_COLOR); g2d.fill(new RoundRectangle2D.Double(x, y, w, h, 4, 4)); @@ -80,9 +77,7 @@ public class MakerBoardPainter { } } - /** - * Helper to draw an FPC / ribbon cable connector (e.g. MIPI CSI/DSI, PCIe FPC). - */ + /** An FPC / ribbon cable connector, such as a MIPI CSI/DSI or PCIe socket. */ public static void drawFpcConnector(Graphics2D g2d, double x, double y, double w, double h, boolean vertical, String label) { g2d.setColor(AbstractMakerBoard.IC_BODY_COLOR); g2d.fill(new RoundRectangle2D.Double(x, y, w, h, 2, 2)); @@ -111,37 +106,23 @@ public class MakerBoardPainter { } } - /** - * Helper to draw a standard Micro-USB connector. - */ public static void drawMicroUsb(Graphics2D g2d, double x, double y, double w, double h, String label) { drawMetalConnector(g2d, x, y, w, h, label); } - /** - * Helper to draw a standard USB Type-C connector. - */ public static void drawUsbC(Graphics2D g2d, double x, double y, double w, double h, String label) { drawMetalConnector(g2d, x, y, w, h, label); } - /** - * Helper to draw a standard USB Type-A connector. - */ public static void drawUsbA(Graphics2D g2d, double x, double y, double w, double h, String label) { drawMetalConnector(g2d, x, y, w, h, label); } - /** - * Helper to draw a standard USB Type-B connector. - */ public static void drawUsbB(Graphics2D g2d, double x, double y, double w, double h, String label) { drawMetalConnector(g2d, x, y, w, h, label); } - /** - * Helper to draw a metal connector / shield (e.g. RF shield cans, SD card slots, HDMI). - */ + /** A metal shield or connector body: an RF can, an SD card slot, an HDMI socket. */ public static void drawMetalConnector(Graphics2D g2d, double x, double y, double w, double h, String label) { g2d.setColor(AbstractMakerBoard.USB_METAL_COLOR); g2d.fill(new RoundRectangle2D.Double(x, y, w, h, 3, 3)); @@ -156,20 +137,18 @@ public class MakerBoardPainter { } /** - * Helper to draw a standard ESP-style PCB meander antenna with a dark (#1E1E1E) substrate rectangle underneath. + * An ESP-style PCB meander antenna over a dark substrate rectangle. * * @param g2d Graphics2D context * @param x top-left X coordinate of the antenna substrate rectangle * @param y top-left Y coordinate of the antenna substrate rectangle - * @param width width of the antenna substrate rectangle (e.g. 15.0mm, matching main chip width) - * @param height height of the antenna substrate rectangle (e.g. 7.0mm) + * @param width width of the antenna substrate rectangle + * @param height height of the antenna substrate rectangle */ public static void drawPcbAntenna(Graphics2D g2d, double x, double y, double width, double height) { - // Dark rectangle underneath (#1E1E1E) g2d.setColor(AbstractMakerBoard.ANTENNA_BG_COLOR); g2d.fill(new Rectangle2D.Double(x, y, width, height)); - // Serpentine antenna trace (gold/copper) g2d.setColor(AbstractMakerBoard.ANTENNA_COLOR); g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); Path2D.Double antPath = new Path2D.Double(); @@ -212,15 +191,13 @@ public class MakerBoardPainter { g2d.draw(antPath); } - /** - * Helper to draw a standard ESP-style PCB meander antenna using default 15.0mm x 7.0mm dimensions. - */ + /** As above, at the default 15.0 x 7.0 mm. */ public static void drawPcbAntenna(Graphics2D g2d, double x, double y) { drawPcbAntenna(g2d, x, y, AbstractMakerBoard.ANTENNA_WIDTH.convertToPixels(), AbstractMakerBoard.ANTENNA_LENGTH.convertToPixels()); } /** - * Helper to draw a standard SMD tactile push button (housing + circular actuator). + * An SMD tactile push button: housing plus circular actuator. * * @param g2d Graphics2D context * @param x Top-left X coordinate of the button body @@ -239,4 +216,149 @@ public class MakerBoardPainter { g2d.setColor(AbstractMakerBoard.BUTTON_ACTUATOR_COLOR); g2d.fill(new Ellipse2D.Double(x + (w - actuatorD) / 2.0, y + (h - actuatorD) / 2.0, actuatorD, actuatorD)); } + + /** + * An addressable RGB LED in a 5050 package: the white body, the milky phosphor lens over it and + * the driver die visible through the lens. Shared by every WS2812B part. + * + * @param g2d Graphics2D context + * @param cx centre X coordinate of the package + * @param cy centre Y coordinate of the package + * @param size edge length of the square package; lens and die scale with it + */ + public static void drawAddressableLed(Graphics2D g2d, double cx, double cy, double size) { + drawAddressableLed(g2d, cx, cy, size, null); + } + + /** + * Draws one 5050 addressable RGB package. A {@code litColor} shows the LED emitting that colour; + * {@code null} draws it dark, which is how an unpowered part looks sitting on a layout. + */ + public static void drawAddressableLed(Graphics2D g2d, double cx, double cy, double size, + Color litColor) { + drawAddressableLed(g2d, cx, cy, size, litColor, 0); + } + + /** + * As above, turned by {@code rotation} radians about its own centre, for parts laid out on a + * circle and mounted facing outwards. The package is square, so the turn only tells modulo a + * quarter turn -- still enough to distinguish a radial arrangement from an axis-aligned one. + */ + public static void drawAddressableLed(Graphics2D g2d, double cx, double cy, double size, + Color litColor, double rotation) { + drawAddressableLed(g2d, cx, cy, size, litColor, rotation, AbstractMakerBoard.LedType.RGB); + } + + /** + * As above, for a package other than the plain RGB one. An RGBW is told from an RGB on sight + * only by the white die beside the colour dies, so that is what is drawn; WWA has the same + * three-die layout and is told apart by what it emits, which is the caller's palette. + */ + public static void drawAddressableLed(Graphics2D g2d, double cx, double cy, double size, + Color litColor, double rotation, AbstractMakerBoard.LedType ledType) { + if (rotation != 0) { + g2d.rotate(rotation, cx, cy); + } + + double half = size / 2.0; + + g2d.setColor(AbstractMakerBoard.RGB_LED_BODY_COLOR); + g2d.fill(new RoundRectangle2D.Double(cx - half, cy - half, size, size, 2, 2)); + g2d.setColor(AbstractMakerBoard.RGB_LED_BODY_BORDER); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(0.5f)); + g2d.draw(new RoundRectangle2D.Double(cx - half, cy - half, size, size, 2, 2)); + + double lensR = half * 0.7; + g2d.setColor(litColor == null ? AbstractMakerBoard.RGB_LED_LENS_COLOR : litColor); + g2d.fill(new Ellipse2D.Double(cx - lensR, cy - lensR, lensR * 2, lensR * 2)); + g2d.setColor(litColor == null ? AbstractMakerBoard.RGB_LED_LENS_BORDER : litColor.darker()); + g2d.draw(new Ellipse2D.Double(cx - lensR, cy - lensR, lensR * 2, lensR * 2)); + + double dieSize = Math.max(2.0, lensR * 0.4); + // the colour dies sit centred on their own, and shoulder aside to make room for a white one + double dieOffset = ledType != null && ledType.hasWhiteDie() ? dieSize * 0.7 : 0; + g2d.setColor(litColor == null ? AbstractMakerBoard.RGB_LED_CHIP_COLOR : litColor.brighter()); + g2d.fill(new Rectangle2D.Double(cx - dieOffset - dieSize / 2.0, cy - dieSize / 2.0, dieSize, + dieSize)); + if (dieOffset > 0) { + g2d.setColor(litColor == null ? AbstractMakerBoard.RGB_LED_CHIP_COLOR : Color.WHITE); + g2d.fill(new Rectangle2D.Double(cx + dieOffset - dieSize / 2.0, cy - dieSize / 2.0, dieSize, + dieSize)); + } + + // turned back rather than saving the transform, so nothing is allocated per LED + if (rotation != 0) { + g2d.rotate(-rotation, cx, cy); + } + } + + /** + * Prints a display's own description on its lit area, wrapped and centred. + * + *

What it prints is what the component already answers for the BOM, so the glass cannot fall + * out of step with the part list. Nothing is drawn unless the whole block fits, so a panel too + * small for its description shows a bare screen rather than a crop -- the same rule the pin-name + * silkscreen follows. + * + *

The area is the part of the panel the text may occupy rather than the panel itself, so a + * round display hands over the square inscribed in its glass. + */ + public static void drawScreenText(Graphics2D g2d, Rectangle2D area, Color inkColor, + Display display, String name, String value) { + String text = screenText(display, name, value); + if (text == null) { + return; + } + + g2d.setFont(AbstractMakerBoard.SILK_FONT); + FontMetrics metrics = g2d.getFontMetrics(); + double margin = AbstractMakerBoard.SCREEN_TEXT_MARGIN.convertToPixels(); + double maxWidth = area.getWidth() - 2 * margin; + double maxHeight = area.getHeight() - 2 * margin; + double lineHeight = metrics.getHeight(); + + List lines = StringUtils.wrap(text, metrics, (int) maxWidth); + if (lines.isEmpty() || lines.size() * lineHeight > maxHeight) { + return; + } + // a single word wider than the area cannot be broken, so the width is checked after wrapping + // rather than trusted to it + for (String line : lines) { + if (metrics.stringWidth(line) > maxWidth) { + return; + } + } + + g2d.setColor(inkColor); + double y = area.getCenterY() - (lines.size() - 1) * lineHeight / 2.0; + for (String line : lines) { + StringUtils.drawCenteredText(g2d, line, area.getCenterX(), y, HorizontalAlignment.CENTER, + VerticalAlignment.CENTER); + y += lineHeight; + } + } + + /** + * What each {@link Display} setting puts on the glass, or {@code null} for a screen that prints + * nothing. The variant string reads "size, interface", and breaking it at the comma gives one + * property per line, which keeps the longest line well inside the glass instead of spanning it. + */ + private static String screenText(Display display, String name, String value) { + boolean hasValue = value != null && !value.trim().isEmpty(); + String variant = hasValue ? value.replace(", ", "\n") : null; + + if (display == null) { + return variant; + } + switch (display) { + case NONE: + return null; + case NAME: + return name; + case BOTH: + return variant == null ? name : name + "\n" + variant; + default: + return variant; + } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/CharacterLCD.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/CharacterLCD.java index c1c05ac70..b12103340 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/displays/CharacterLCD.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/CharacterLCD.java @@ -32,6 +32,7 @@ import java.awt.geom.Rectangle2D; import java.awt.geom.RoundRectangle2D; import org.diylc.awt.StringUtils; +import org.diylc.common.Display; import org.diylc.common.HorizontalAlignment; import org.diylc.common.ObjectCache; import org.diylc.common.Orientation; @@ -48,66 +49,54 @@ import org.diylc.core.annotations.EditableProperty; import org.diylc.core.annotations.KeywordPolicy; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; +import org.diylc.presenter.CalcUtils; import org.diylc.utils.Constants; -// @ComponentDescriptor(name = "Character LCD (16x2 / 20x4)", category = "Displays & Outputs", -// author = "Branislav Stojkovic", description = "HD44780-Compatible Character LCD Display (Parallel / I2C Backpack)", -// instanceNamePrefix = "LCD", zOrder = IDIYComponent.BOARD, -// bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, -// enableCache = true) +@ComponentDescriptor(name = "Character LCD", category = "Displays & Outputs", + author = "Branislav Stojkovic", description = "HD44780-Compatible Character LCD Display (Parallel / I2C Backpack)", + instanceNamePrefix = "LCD", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) public class CharacterLCD extends AbstractMakerBoard { private static final long serialVersionUID = 1L; - public enum LCDSize { - _16x2("16x2 (80x36mm)", 16, 2, 80.0, 36.0), - _20x4("20x4 (98x60mm)", 20, 4, 98.0, 60.0); - - private final String label; - private final int cols; - private final int rows; - private final double widthMm; - private final double heightMm; - - LCDSize(String label, int cols, int rows, double widthMm, double heightMm) { - this.label = label; - this.cols = cols; - this.rows = rows; - this.widthMm = widthMm; - this.heightMm = heightMm; - } - - @Override public String toString() { return label; } - public int getCols() { return cols; } - public int getRows() { return rows; } - public double getWidthMm() { return widthMm; } - public double getHeightMm() { return heightMm; } - } - - public enum LCDInterface { - I2C_Backpack("I2C Backpack (4-Pin)"), - Parallel_16Pin("Parallel HD44780 (16-Pin)"); - - private final String label; - LCDInterface(String label) { this.label = label; } - @Override public String toString() { return label; } - } + public static Size HEADER_OFFSET = new Size(2.54d, SizeUnit.mm); + // The parallel row is not centred: its leftmost pin starts this far in from the left edge. + public static Size PARALLEL_HEADER_INSET = new Size(10.0d, SizeUnit.mm); + // The I2C backpack's four pins stand in a column against the left edge, centred on the height. + public static Size I2C_HEADER_INSET = new Size(2.5d, SizeUnit.mm); + // A 3 mm hole centred 2.5 mm in leaves only 1 mm of board outside it, which is what the part + // does. + public static Size MOUNTING_HOLE_INSET = new Size(2.5d, SizeUnit.mm); + public static Size MOUNTING_HOLE_SIZE = new Size(3.0d, SizeUnit.mm); public static Color PCB_GREEN = Color.decode("#1B5E20"); public static Color SCREEN_BG = Color.decode("#1E88E5"); - public static Color SCREEN_TEXT = Color.decode("#FFFFFF"); public static Color BEZEL_COLOR = Color.decode("#212121"); + // Both inks are real: the blue-backlit module shows light characters and the yellow-green one + // dark, so the ink follows the screen colour. + public static Color SCREEN_INK_LIGHT = Color.decode("#F5F5F5"); + public static Color SCREEN_INK_DARK = Color.decode("#1B2631"); public static final String[] PIN_NAMES_I2C = new String[] {"GND", "VCC", "SDA", "SCL"}; public static final String[] PIN_NAMES_PARALLEL = new String[] { - "VSS (GND)", "VDD (+5V)", "V0 (Contrast)", "RS", "RW", "E", + "VSS (GND)", "VCC (+5V)", "VEE (Contrast)", "RS", "RW", "E", "D0", "D1", "D2", "D3", "D4", "D5", "D6", "D7", "A (Backlight +)", "K (Backlight -)" }; + // The bare designations the module prints. The supply and backlight pins carry their function + // in the node name instead, there being no room for it along a 0.1" pitch. + public static final String[] SILK_NAMES_PARALLEL = new String[] { + "VSS", "VCC", "VEE", "RS", "RW", "E", + "D0", "D1", "D2", "D3", "D4", "D5", "D6", "D7", "A", "K" + }; + private LCDSize lcdSize = LCDSize._16x2; private LCDInterface lcdInterface = LCDInterface.I2C_Backpack; private Color screenColor = SCREEN_BG; + private Display screen = Display.VALUE; public CharacterLCD() { super(); @@ -137,6 +126,26 @@ public class CharacterLCD extends AbstractMakerBoard { invalidateCache(); } + @EditableProperty + public Display getScreen() { + return screen == null ? Display.VALUE : screen; + } + + public void setScreen(Display screen) { + this.screen = screen; + invalidateCache(); + } + + @Override + protected String getVariantLabel() { + LCDSize lcdSize = getLcdSize(); + if (lcdSize == null) { + return null; + } + LCDInterface lcdInterface = getLcdInterface(); + return lcdInterface == null ? lcdSize.toString() : lcdSize + ", " + lcdInterface; + } + @EditableProperty(name = "Backlight Color") public Color getScreenColor() { return screenColor; @@ -158,31 +167,76 @@ public class CharacterLCD extends AbstractMakerBoard { } @Override - protected void updateControlPoints() { - Point2D firstPoint = controlPoints[0]; - double spacing = PIN_SPACING.convertToPixels(); - - int pinCount = (lcdInterface == LCDInterface.I2C_Backpack) ? 4 : 16; - double[][] relativeOffsets = new double[pinCount][2]; - - for (int i = 0; i < pinCount; i++) { - relativeOffsets[i][0] = i * spacing; - relativeOffsets[i][1] = 0; + protected String getSilkPinLabel(int index) { + if (lcdInterface == LCDInterface.Parallel_16Pin && index >= 0 + && index < SILK_NAMES_PARALLEL.length) { + return SILK_NAMES_PARALLEL[index]; } + return super.getSilkPinLabel(index); + } - rotatePoints(firstPoint, relativeOffsets); + private int getPinCount() { + return lcdInterface == LCDInterface.I2C_Backpack ? PIN_NAMES_I2C.length + : PIN_NAMES_PARALLEL.length; + } + + /** The ink that contrasts with the backlight the user has chosen. */ + private Color getScreenInk() { + return CalcUtils.calculateLuminance(screenColor) < 128d ? SCREEN_INK_LIGHT : SCREEN_INK_DARK; + } + + private double getBoardWidth() { + return new Size(lcdSize.getWidthMm(), SizeUnit.mm).convertToPixels(); + } + + private double getBoardLength() { + return new Size(lcdSize.getHeightMm(), SizeUnit.mm).convertToPixels(); + } + + /** + * Neither header is centred: the parallel row starts a fixed distance in from the left, and the + * I2C column stands against that same edge. + */ + private double getBoardX(double x) { + Size inset = + lcdInterface == LCDInterface.I2C_Backpack ? I2C_HEADER_INSET : PARALLEL_HEADER_INSET; + return x - inset.convertToPixels(); + } + + /** + * The parallel row hangs below the top edge by a fixed clearance; the I2C column is centred on + * the board's height instead. + */ + private double getBoardY(double y) { + if (lcdInterface == LCDInterface.I2C_Backpack) { + double columnSpan = (getPinCount() - 1) * PIN_SPACING.convertToPixels(); + return y - (getBoardLength() - columnSpan) / 2.0; + } + return y - HEADER_OFFSET.convertToPixels(); + } + + private double[][] getRelativeOffsets() { + double spacing = PIN_SPACING.convertToPixels(); + boolean vertical = lcdInterface == LCDInterface.I2C_Backpack; + + double[][] relativeOffsets = new double[getPinCount()][2]; + for (int i = 0; i < relativeOffsets.length; i++) { + relativeOffsets[i][0] = vertical ? 0 : i * spacing; + relativeOffsets[i][1] = vertical ? i * spacing : 0; + } + return relativeOffsets; + } + + @Override + protected void updateControlPoints() { + rotatePoints(controlPoints[0], getRelativeOffsets()); } @Override public Shape getBodyShape() { Point2D p0 = controlPoints[0]; - double x = p0.getX(); - double y = p0.getY(); - double boardW = new Size(lcdSize.getWidthMm(), SizeUnit.mm).convertToPixels(); - double boardH = new Size(lcdSize.getHeightMm(), SizeUnit.mm).convertToPixels(); - double boardX = x - 60; - double boardY = y - 20; - return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 10, 10); + return new RoundRectangle2D.Double(getBoardX(p0.getX()), getBoardY(p0.getY()), getBoardWidth(), + getBoardLength(), 10, 10); } @Override @@ -201,11 +255,10 @@ public class CharacterLCD extends AbstractMakerBoard { g2d.rotate(orientation.toRadians(), x, y); } - double boardW = new Size(lcdSize.getWidthMm(), SizeUnit.mm).convertToPixels(); - double boardH = new Size(lcdSize.getHeightMm(), SizeUnit.mm).convertToPixels(); - - double boardX = x - 60; - double boardY = y - 20; + double boardW = getBoardWidth(); + double boardH = getBoardLength(); + double boardX = getBoardX(x); + double boardY = getBoardY(y); Shape boardShape = getBodyShape(); @@ -221,39 +274,45 @@ public class CharacterLCD extends AbstractMakerBoard { g2d.draw(boardShape); if (!outlineMode) { - // 4 Mounting holes in corners - MakerBoardPainter.drawMountingHole(g2d, boardX + 20, boardY + 20, 20); - MakerBoardPainter.drawMountingHole(g2d, boardX + 20, boardY + boardH - 20, 20); - MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - 20, boardY + 20, 20); - MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - 20, boardY + boardH - 20, 20); + double holeInset = MOUNTING_HOLE_INSET.convertToPixels(); + double holeSize = MOUNTING_HOLE_SIZE.convertToPixels(); + MakerBoardPainter.drawMountingHole(g2d, boardX + holeInset, boardY + holeInset, holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + holeInset, boardY + boardH - holeInset, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - holeInset, boardY + holeInset, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - holeInset, + boardY + boardH - holeInset, holeSize); - // Metal Bezel around screen - double bezelMarginX = (lcdSize == LCDSize._16x2) ? 45 : 40; - double bezelW = boardW - 2 * bezelMarginX; - double bezelX = boardX + bezelMarginX; - double bezelY = boardY + 45; - double bezelH = boardH - 65; + // The bezel's size is the module's own, not a margin taken off the board, so it stays put + // when the board changes. + double bezelW = new Size(lcdSize.getBezelWidthMm(), SizeUnit.mm).convertToPixels(); + double bezelH = new Size(lcdSize.getBezelHeightMm(), SizeUnit.mm).convertToPixels(); + double bezelX = boardX + (boardW - bezelW) / 2.0; + double bezelY = boardY + (boardH - bezelH) / 2.0; g2d.setColor(BEZEL_COLOR); g2d.fill(new RoundRectangle2D.Double(bezelX, bezelY, bezelW, bezelH, 6, 6)); - // LCD Screen area - double screenMargin = 12; - double screenW = bezelW - 2 * screenMargin; - double screenH = bezelH - 2 * screenMargin; - double screenX = bezelX + screenMargin; - double screenY = bezelY + screenMargin; + double screenW = new Size(lcdSize.getDisplayWidthMm(), SizeUnit.mm).convertToPixels(); + double screenH = new Size(lcdSize.getDisplayHeightMm(), SizeUnit.mm).convertToPixels(); + double screenX = bezelX + (bezelW - screenW) / 2.0; + double screenY = bezelY + (bezelH - screenH) / 2.0; g2d.setColor(screenColor); g2d.fill(new Rectangle2D.Double(screenX, screenY, screenW, screenH)); - // Render character boxes - g2d.setColor(SCREEN_TEXT); - g2d.setFont(new Font("Monospaced", Font.BOLD, (lcdSize == LCDSize._16x2) ? 14 : 11)); - String line1 = (lcdSize == LCDSize._16x2) ? "HELLO WORLD! 16x2" : "DIYLC LCD MODULE"; - String line2 = (lcdSize == LCDSize._16x2) ? "DIY LAYOUT CREATOR" : "20x4 CHARACTER LCD"; - StringUtils.drawCenteredText(g2d, line1, screenX + screenW / 2.0, screenY + screenH / 3.0, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - StringUtils.drawCenteredText(g2d, line2, screenX + screenW / 2.0, screenY + screenH * 2.0 / 3.0, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); + MakerBoardPainter.drawScreenText(g2d, + new Rectangle2D.Double(screenX, screenY, screenW, screenH), getScreenInk(), getScreen(), + getName(), getValueForDisplay()); + + // Only the parallel row has anywhere to print. The backpack's column sits 2.5 mm from the + // left edge with the bezel 1.4 mm further in, so a label beside it would lie across the metal + // frame; the backpack prints its names on its own PCB behind the module, not drawn here. + if (lcdInterface == LCDInterface.Parallel_16Pin) { + drawFlatRowPinLabels(g2d, x, y, getRelativeOffsets(), 0, controlPoints.length, true, + SILK_COLOR); + } } g2d.setTransform(oldTx); @@ -270,7 +329,6 @@ public class CharacterLCD extends AbstractMakerBoard { g2d.setColor(PCB_GREEN.darker()); g2d.draw(new RoundRectangle2D.Double(2, 6, width - 4, height - 12, 3, 3)); - // Screen g2d.setColor(SCREEN_BG); g2d.fillRect(6, 10, width - 12, height - 20); @@ -278,4 +336,48 @@ public class CharacterLCD extends AbstractMakerBoard { g2d.setFont(new Font("SansSerif", Font.BOLD, 6)); StringUtils.drawCenteredText(g2d, "LCD", width / 2, height / 2 + 1, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); } + + public enum LCDSize { + // Labels reach the BOM's value column, so they stay short; the dimensions are carried by the + // fields beside them. The bezel and lit area are measured off the module rather than derived as + // margins off the board, which made the window grow with the board. + _16x2("16x2", 80.0, 35.0, 72.2, 24.1, 64.5, 14.5), + _20x4("20x4", 98.0, 60.0, 96.8, 39.3, 77.0, 25.2); + + private final String label; + private final double widthMm; + private final double heightMm; + private final double bezelWidthMm; + private final double bezelHeightMm; + private final double displayWidthMm; + private final double displayHeightMm; + + LCDSize(String label, double widthMm, double heightMm, double bezelWidthMm, + double bezelHeightMm, double displayWidthMm, double displayHeightMm) { + this.label = label; + this.widthMm = widthMm; + this.heightMm = heightMm; + this.bezelWidthMm = bezelWidthMm; + this.bezelHeightMm = bezelHeightMm; + this.displayWidthMm = displayWidthMm; + this.displayHeightMm = displayHeightMm; + } + + @Override public String toString() { return label; } + public double getWidthMm() { return widthMm; } + public double getHeightMm() { return heightMm; } + public double getBezelWidthMm() { return bezelWidthMm; } + public double getBezelHeightMm() { return bezelHeightMm; } + public double getDisplayWidthMm() { return displayWidthMm; } + public double getDisplayHeightMm() { return displayHeightMm; } + } + + public enum LCDInterface { + I2C_Backpack("I2C Backpack"), + Parallel_16Pin("Parallel HD44780"); + + private final String label; + LCDInterface(String label) { this.label = label; } + @Override public String toString() { return label; } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDBarGraph.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDBarGraph.java new file mode 100644 index 000000000..94faa0870 --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDBarGraph.java @@ -0,0 +1,589 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.geom.Area; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.geom.RoundRectangle2D; + +import org.diylc.common.ObjectCache; +import org.diylc.common.Orientation; +import org.diylc.components.AbstractLabeledComponent; +import org.diylc.components.transform.LEDBarGraphTransformer; +import org.diylc.core.ComponentState; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.Project; +import org.diylc.core.VisibilityPolicy; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.EditableProperty; +import org.diylc.core.annotations.KeywordPolicy; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.diylc.utils.Constants; + +/** + * A segmented LED bar in a dual-in-line package, as used for VU and level meters. + * + *

Not an {@code AbstractMakerBoard} like the rest of this category: it is a DIP-outline part, + * so it follows {@code DIL_IC} on {@link AbstractLabeledComponent} and carries its own + * transformer. + * + *

It differs from a DIP IC in the way that governs the whole geometry: an IC's leads bend out + * of the sides of the plastic, so its body sits between the pin rows, while a bar + * graph's pins leave the underside within the footprint and the package spans the rows. The pins + * are nonetheless drawn on top, because a pin that cannot be seen cannot be lined up against a + * board. + * + * @author Branislav Stojkovic + */ +@ComponentDescriptor(name = "LED Bar Graph", author = "Branislav Stojkovic", + category = "Displays & Outputs", instanceNamePrefix = "BAR", + description = "Segmented LED bar in a DIP package, for VU and level meters", + zOrder = IDIYComponent.COMPONENT, bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, + keywordPolicy = KeywordPolicy.SHOW_VALUE, transformer = LEDBarGraphTransformer.class, + enableCache = true) +public class LEDBarGraph extends AbstractLabeledComponent { + + private static final long serialVersionUID = 1L; + + public static Color BODY_COLOR = Color.decode("#2B2B2B"); + public static Color BORDER_COLOR = Color.decode("#1A1A1A"); + public static Color PIN_COLOR = METAL_COLOR; + public static Color PIN_BORDER_COLOR = PIN_COLOR.darker(); + // A bar graph is a scale, not a lamp: green most of the way up, yellow for the last two and + // red for the topmost, as on a level or VU meter. + public static Color SEGMENT_GREEN = Color.decode("#43A047"); + public static Color SEGMENT_YELLOW = Color.decode("#FDD835"); + public static Color SEGMENT_RED = Color.decode("#E53935"); + public static Color SEGMENT_BLUE = Color.decode("#1E88E5"); + public static Color SEGMENT_ORANGE = Color.decode("#FB8C00"); + public static Color SEGMENT_WHITE = Color.decode("#EEEEEE"); + public static int EDGE_RADIUS = 4; + + public static Size PIN_SIZE = new Size(0.04d, SizeUnit.in); + public static Size PIN_SPACING = new Size(0.1d, SizeUnit.in); + // The package being wider than the row spacing is the whole difference from a DIP IC: 10.3 + // against 7.62 leaves about 1.34 mm of body outboard of each row, so no pin shows from above. + public static Size ROW_SPACING = new Size(7.62d, SizeUnit.mm); + public static Size PACKAGE_WIDTH = new Size(10.3d, SizeUnit.mm); + // Sizes of their own rather than the package less a margin, so a segment stays the size it is + // when the package or the pin pitch changes. + public static Size SEGMENT_WIDTH = new Size(4.8d, SizeUnit.mm); + public static Size SEGMENT_LENGTH = new Size(1.7d, SizeUnit.mm); + + private String value = ""; + private Orientation orientation = Orientation.DEFAULT; + private Segments segments = Segments._10; + private BarColor barColor = BarColor.GREEN_YELLOW_RED; + private Color bodyColor = BODY_COLOR; + private Color borderColor = BORDER_COLOR; + private Point2D[] controlPoints = new Point2D[] {new Point2D.Double(0, 0)}; + + transient private Area body; + + public LEDBarGraph() { + super(); + updateControlPoints(); + } + + /** + * Nothing draws this -- unlike {@code DIL_IC}, which renders it as the chip's part number -- so + * it reaches only the BOM and keyword search. + */ + @EditableProperty + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @EditableProperty + public Orientation getOrientation() { + return orientation; + } + + public void setOrientation(Orientation orientation) { + this.orientation = orientation; + updateControlPoints(); + body = null; + } + + @EditableProperty(name = "Segments") + public Segments getSegments() { + if (segments == null) { + segments = Segments._10; + } + return segments; + } + + /** Changing this changes the pin count, so the control points are rebuilt with it. */ + public void setSegments(Segments segments) { + this.segments = segments; + updateControlPoints(); + body = null; + } + + @EditableProperty(name = "Color") + public BarColor getBarColor() { + if (barColor == null) { + barColor = BarColor.GREEN_YELLOW_RED; + } + return barColor; + } + + public void setBarColor(BarColor barColor) { + this.barColor = barColor; + } + + /** + * The colour of one segment, counted from the top of the scale. Delegates to the selected + * {@link BarColor}. + */ + private Color getSegmentColor(int index) { + return getBarColor().getSegmentColor(index, getSegments().getCount()); + } + + @EditableProperty(name = "Body") + public Color getBodyColor() { + if (bodyColor == null) { + bodyColor = BODY_COLOR; + } + return bodyColor; + } + + public void setBodyColor(Color bodyColor) { + this.bodyColor = bodyColor; + } + + @EditableProperty(name = "Border") + public Color getBorderColor() { + if (borderColor == null) { + borderColor = BORDER_COLOR; + } + return borderColor; + } + + public void setBorderColor(Color borderColor) { + this.borderColor = borderColor; + } + + /** + * Puts the segment count and colour in the BOM's value column, which otherwise groups every + * variant into one row: {@code BomMaker} groups on type name and value, and the inherited value + * is the part number and usually blank. {@code AbstractMakerBoard.getVariantLabel} does this for + * the boards in this category; the bar graph is on a different base class, so it is written out + * here. A part number the user has typed is kept and appended. + */ + @Override + public String getValueForDisplay() { + String variant = getSegments().toString(); + if (getBarColor() != BarColor.GREEN_YELLOW_RED) { + variant += ", " + getBarColor(); + } + String partNumber = getValue(); + return partNumber == null || partNumber.trim().isEmpty() ? variant + : variant + ", " + partNumber.trim(); + } + + @Override + public int getControlPointCount() { + return controlPoints.length; + } + + @Override + public Point2D getControlPoint(int index) { + return controlPoints[index]; + } + + @Override + public boolean isControlPointSticky(int index) { + return true; + } + + @Override + public VisibilityPolicy getControlPointVisibilityPolicy(int index) { + return VisibilityPolicy.NEVER; + } + + /** + * Mutates the existing point rather than replacing it, as {@code DIL_IC} does, since callers may + * hold the instance. + */ + @Override + public void setControlPoint(Point2D point, int index) { + controlPoints[index].setLocation(point); + body = null; + } + + @Override + public boolean canPointMoveFreely(int pointIndex) { + return false; + } + + /** Pin 1 is first on the near row and numbering runs round the package, as on any DIP outline. */ + @Override + public String getControlPointNodeName(int index) { + int pinCount = getSegments().getPinCount(); + int pin = index + 1; + if (pin > pinCount / 2) { + return Integer.toString(pinCount * 3 / 2 - pin + 1); + } + return Integer.toString(pin); + } + + private void updateControlPoints() { + Point2D firstPoint = controlPoints[0]; + int pinCount = getSegments().getPinCount(); + Point2D[] points = new Point2D[pinCount]; + points[0] = firstPoint; + double pinSpacing = PIN_SPACING.convertToPixels(); + double rowSpacing = ROW_SPACING.convertToPixels(); + + for (int i = 0; i < pinCount / 2; i++) { + double dx1; + double dy1; + double dx2; + double dy2; + switch (orientation) { + case DEFAULT: + dx1 = 0; + dy1 = i * pinSpacing; + dx2 = rowSpacing; + dy2 = i * pinSpacing; + break; + case _90: + dx1 = -i * pinSpacing; + dy1 = 0; + dx2 = -i * pinSpacing; + dy2 = rowSpacing; + break; + case _180: + dx1 = 0; + dy1 = -i * pinSpacing; + dx2 = -rowSpacing; + dy2 = -i * pinSpacing; + break; + case _270: + dx1 = i * pinSpacing; + dy1 = 0; + dx2 = i * pinSpacing; + dy2 = -rowSpacing; + break; + default: + throw new RuntimeException("Unexpected orientation: " + orientation); + } + points[i] = new Point2D.Double(Math.round(firstPoint.getX() + dx1), + Math.round(firstPoint.getY() + dy1)); + points[i + pinCount / 2] = new Point2D.Double(Math.round(firstPoint.getX() + dx2), + Math.round(firstPoint.getY() + dy2)); + } + controlPoints = points; + } + + /** How far the plastic reaches past each pin row. */ + private static double getSideOverhang() { + return (PACKAGE_WIDTH.convertToPixels() - ROW_SPACING.convertToPixels()) / 2.0; + } + + /** + * The package outline. Unlike a DIP IC this spans the pin rows rather than sitting between them, + * and runs half a pin pitch past the end pins at each end, so the pins fall entirely underneath. + */ + public Area getBody() { + if (body == null) { + double x = controlPoints[0].getX(); + double y = controlPoints[0].getY(); + double width; + double height; + double pinSpacing = PIN_SPACING.convertToPixels(); + double rowSpacing = ROW_SPACING.convertToPixels(); + double packageWidth = PACKAGE_WIDTH.convertToPixels(); + double overhang = getSideOverhang(); + double runLength = getSegments().getCount() * pinSpacing; + + switch (orientation) { + case DEFAULT: + width = packageWidth; + height = runLength; + x -= overhang; + y -= pinSpacing / 2; + break; + case _90: + width = runLength; + height = packageWidth; + x -= runLength - pinSpacing / 2; + y -= overhang; + break; + case _180: + width = packageWidth; + height = runLength; + x -= rowSpacing + overhang; + y -= runLength - pinSpacing / 2; + break; + case _270: + width = runLength; + height = packageWidth; + x -= pinSpacing / 2; + y -= rowSpacing + overhang; + break; + default: + throw new RuntimeException("Unexpected orientation: " + orientation); + } + body = new Area(new RoundRectangle2D.Double(x, y, width, height, EDGE_RADIUS, EDGE_RADIUS)); + } + return body; + } + + /** + * Each segment is centred on the pin pair that drives it, as on the part. Taking the centre from + * the two control points rather than dividing the body also makes it orientation-proof: the + * midpoint of a pin pair is the package centreline at that segment whichever way it is turned. + */ + private Rectangle2D[] getSegmentRects() { + int count = getSegments().getCount(); + double across = SEGMENT_WIDTH.convertToPixels(); + double along = SEGMENT_LENGTH.convertToPixels(); + boolean vertical = orientation == Orientation.DEFAULT || orientation == Orientation._180; + + Rectangle2D[] rects = new Rectangle2D[count]; + for (int i = 0; i < count; i++) { + Point2D near = controlPoints[i]; + Point2D far = controlPoints[i + count]; + double cx = (near.getX() + far.getX()) / 2.0; + double cy = (near.getY() + far.getY()) / 2.0; + if (vertical) { + rects[i] = new Rectangle2D.Double(cx - across / 2.0, cy - along / 2.0, across, along); + } else { + rects[i] = new Rectangle2D.Double(cx - along / 2.0, cy - across / 2.0, along, across); + } + } + return rects; + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, + Project project, IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + + Area mainArea = getBody(); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f)); + + Composite oldComposite = applyAlpha(g2d, componentState); + g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : getBodyColor()); + drawingObserver.startTracking(); + g2d.fill(mainArea); + drawingObserver.stopTracking(); + g2d.setComposite(oldComposite); + + Color finalBorderColor = + componentState == ComponentState.SELECTED || componentState == ComponentState.DRAGGING + ? SELECTION_COLOR + : getBorderColor(); + g2d.setColor(finalBorderColor); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); + g2d.draw(mainArea); + + if (!outlineMode) { + // Drawn lit, as the Ring and the Strip are: an unlit bar graph is a black block with nothing + // to say what it is. + Rectangle2D[] segments = getSegmentRects(); + for (int i = 0; i < segments.length; i++) { + g2d.setColor(getSegmentColor(i)); + g2d.fill(segments[i]); + g2d.setColor(getSegmentColor(i).darker()); + g2d.draw(segments[i]); + } + + // Over the package and the segments: on the real part they are underneath and invisible from + // above, but a pin you cannot see is a pin you cannot line up. Round, because a bar graph's + // leads are drawn wire rather than an IC's stamped leadframe. + double pinSize = (int) PIN_SIZE.convertToPixels() / 2 * 2; + for (Point2D point : controlPoints) { + Ellipse2D pin = new Ellipse2D.Double(point.getX() - pinSize / 2, + point.getY() - pinSize / 2, pinSize, pinSize); + g2d.setColor(PIN_COLOR); + drawingObserver.startTracking(); + g2d.fill(pin); + drawingObserver.stopTracking(); + g2d.setColor(PIN_BORDER_COLOR); + g2d.draw(pin); + } + } + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + g2d.setColor(BODY_COLOR); + g2d.fillRoundRect(width / 4, 2, width / 2, height - 4, 2, 2); + g2d.setColor(BORDER_COLOR); + g2d.drawRoundRect(width / 4, 2, width / 2, height - 4, 2, 2); + + // compressed to five bands so the icon reads at toolbox size + for (int i = 0; i < 5; i++) { + g2d.setColor(i == 0 ? SEGMENT_RED : i == 1 ? SEGMENT_YELLOW : SEGMENT_GREEN); + g2d.fillRect(width / 4 + 2, 4 + i * (height - 8) / 5, width / 2 - 3, (height - 8) / 5 - 1); + } + } + + @Override + public Rectangle2D getCachingBounds() { + double minX = Integer.MAX_VALUE; + double maxX = Integer.MIN_VALUE; + double minY = Integer.MAX_VALUE; + double maxY = Integer.MIN_VALUE; + int margin = 50; + for (int i = 0; i < getControlPointCount(); i++) { + Point2D p = getControlPoint(i); + if (p.getX() < minX) { + minX = p.getX(); + } + if (p.getX() > maxX) { + maxX = p.getX(); + } + if (p.getY() < minY) { + minY = p.getY(); + } + if (p.getY() > maxY) { + maxY = p.getY(); + } + } + return new Rectangle2D.Double(minX - margin, minY - margin, maxX - minX + 2 * margin, + maxY - minY + 2 * margin); + } + + /** + * Decides the rest of the package: each segment has its own anode and cathode, so the pin count + * is twice the segment count and the body lengthens by one pin pitch per segment. + */ + public enum Segments { + _4("4 Segment", 4), + _5("5 Segment", 5), + _6("6 Segment", 6), + _8("8 Segment", 8), + _10("10 Segment", 10), + _12("12 Segment", 12); + + private final String label; + private final int count; + + Segments(String label, int count) { + this.label = label; + this.count = count; + } + + @Override public String toString() { return label; } + public int getCount() { return count; } + + /** Anode and cathode per segment, split evenly between the two rows. */ + public int getPinCount() { return count * 2; } + } + + public enum BarColor { + GREEN_YELLOW_RED("Green-Yellow-Red"), + BLUE_GREEN_YELLOW_RED("Blue-Green-Yellow-Red"), + RED("Red"), + GREEN("Green"), + YELLOW("Yellow"), + BLUE("Blue"), + ORANGE("Orange"), + WHITE("White"); + + private final String label; + + BarColor(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + + public Color getSegmentColor(int index, int totalSegments) { + switch (this) { + case RED: + return SEGMENT_RED; + case GREEN: + return SEGMENT_GREEN; + case YELLOW: + return SEGMENT_YELLOW; + case BLUE: + return SEGMENT_BLUE; + case ORANGE: + return SEGMENT_ORANGE; + case WHITE: + return SEGMENT_WHITE; + case BLUE_GREEN_YELLOW_RED: + int redCount; + int yellowCount; + int blueCount; + if (totalSegments >= 12) { + redCount = 2; + yellowCount = 3; + blueCount = 2; + } else if (totalSegments >= 10) { + redCount = 2; + yellowCount = 3; + blueCount = 1; + } else if (totalSegments >= 8) { + redCount = 1; + yellowCount = 2; + blueCount = 1; + } else { + redCount = 1; + yellowCount = 1; + blueCount = 1; + } + if (index < redCount) { + return SEGMENT_RED; + } + if (index < redCount + yellowCount) { + return SEGMENT_YELLOW; + } + if (index >= totalSegments - blueCount) { + return SEGMENT_BLUE; + } + return SEGMENT_GREEN; + case GREEN_YELLOW_RED: + default: + if (index == 0) { + return SEGMENT_RED; + } + if (index <= 2) { + return SEGMENT_YELLOW; + } + return SEGMENT_GREEN; + } + } + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDMatrix.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDMatrix.java new file mode 100644 index 000000000..acda95a2a --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDMatrix.java @@ -0,0 +1,374 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.geom.RoundRectangle2D; + +import org.diylc.common.ObjectCache; +import org.diylc.common.Orientation; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.MakerBoardPainter; +import org.diylc.core.ComponentState; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.Project; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.EditableProperty; +import org.diylc.core.annotations.KeywordPolicy; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.diylc.utils.Constants; + +@ComponentDescriptor(name = "LED Matrix", category = "Displays & Outputs", + author = "Branislav Stojkovic", description = "MAX7219 Dot LED Matrix Display Module (Cascadable SPI)", + instanceNamePrefix = "DISP", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) +public class LEDMatrix extends AbstractMakerBoard { + + private static final long serialVersionUID = 1L; + + public static Color PCB_BLUE = Color.decode("#0055A5"); + public static Color MATRIX_BODY = Color.decode("#1A1A1A"); + public static Color LED_RED = Color.decode("#E53935"); + public static Color LED_OFF = Color.decode("#333333"); + + // The modules are flush with each other and with the board, so the dot pitch runs unbroken + // across the whole strip. + public static Size MATRIX_SIZE = new Size(32.0d, SizeUnit.mm); + // Clearance from a board edge to the pin row nearest it. Both headers keep the same distance + // from their own edge, so the spacing between the rows follows from the board size. + public static Size HEADER_OFFSET = new Size(2.54d, SizeUnit.mm); + public static Size CHIP_MARGIN_X = new Size(2.54d, SizeUnit.mm); + public static Size CHIP_LENGTH = new Size(5.7d, SizeUnit.mm); + public static Size CHIP_GAP = new Size(1.9d, SizeUnit.mm); + + public static final String[] PIN_NAMES = new String[] { + "VCC_IN", "GND_IN", "DIN", "CS_IN", "CLK_IN", + "VCC_OUT", "GND_OUT", "DOUT", "CS_OUT", "CLK_OUT" + }; + + private Modules modules = Modules.Single_8x8; + private Color dotColor = LED_RED; + + public LEDMatrix() { + super(); + this.bodyColor = PCB_BLUE; + updateControlPoints(); + } + + @EditableProperty(name = "Modules") + public Modules getModules() { + if (modules == null) { + modules = Modules.Single_8x8; + } + return modules; + } + + public void setModules(Modules modules) { + this.modules = modules; + updateControlPoints(); + invalidateCache(); + } + + @Override + protected String getVariantLabel() { + Modules modules = getModules(); + return modules == null ? null : modules.toString(); + } + + @EditableProperty(name = "Dot Color") + public Color getDotColor() { + return dotColor; + } + + public void setDotColor(Color dotColor) { + this.dotColor = dotColor; + invalidateCache(); + } + + @Override + public String getControlPointNodeName(int index) { + if (index >= 0 && index < PIN_NAMES.length) { + return PIN_NAMES[index]; + } + return "Pin " + (index + 1); + } + + private double[][] getRelativeOffsets() { + double spacing = PIN_SPACING.convertToPixels(); + + double[][] relativeOffsets = new double[PIN_NAMES.length][2]; + + // The rows lie across the original single's height and stand on end on the boards that bring + // their headers out on the short edges. + boolean vertical = getModules().hasVerticalHeaders(); + double far = vertical ? getHeaderSpacing() : -getHeaderSpacing(); + for (int i = 0; i < 5; i++) { + relativeOffsets[i][0] = vertical ? 0 : i * spacing; + relativeOffsets[i][1] = vertical ? i * spacing : 0; + relativeOffsets[5 + i][0] = vertical ? far : i * spacing; + relativeOffsets[5 + i][1] = vertical ? i * spacing : far; + } + return relativeOffsets; + } + + @Override + protected void updateControlPoints() { + rotatePoints(controlPoints[0], getRelativeOffsets()); + } + + private double getBoardWidth() { + return new Size(getModules().getBoardWidthMm(), SizeUnit.mm).convertToPixels(); + } + + private double getBoardLength() { + return new Size(getModules().getBoardHeightMm(), SizeUnit.mm).convertToPixels(); + } + + /** The span a five-pin row occupies. */ + private static double getPinRunLength() { + return 4 * PIN_SPACING.convertToPixels(); + } + + /** + * The board along the axis the rows face each other across, less the clearance each keeps from + * its own edge. + */ + private double getHeaderSpacing() { + double span = getModules().hasVerticalHeaders() ? getBoardWidth() : getBoardLength(); + return span - 2 * HEADER_OFFSET.convertToPixels(); + } + + /** + * The original single centres its horizontal row across the width; a board with vertical + * headers stands its row against the left edge, one clearance in. + */ + private double getBoardX(double x) { + if (getModules().hasVerticalHeaders()) { + return x - HEADER_OFFSET.convertToPixels(); + } + return x - (getBoardWidth() - getPinRunLength()) / 2.0; + } + + /** + * Control point 0 is an input pin: on the single it sits one clearance up from the bottom edge, + * with vertical headers it is the top pin of a column centred on the height. + */ + private double getBoardY(double y) { + if (getModules().hasVerticalHeaders()) { + return y - (getBoardLength() - getPinRunLength()) / 2.0; + } + return y - getBoardLength() + HEADER_OFFSET.convertToPixels(); + } + + @Override + public Shape getBodyShape() { + Point2D p0 = controlPoints[0]; + return new RoundRectangle2D.Double(getBoardX(p0.getX()), getBoardY(p0.getY()), getBoardWidth(), + getBoardLength(), 8, 8); + } + + /** The demo pattern, in the coordinates of a single 8x8 module. */ + private static boolean isDotLit(int row, int col) { + return (row == 0 && (col == 2 || col == 5)) + || (row == 1 && (col == 1 || col == 3 || col == 4 || col == 6)) + || (row == 2 && col >= 1 && col <= 6) + || (row == 3 && col >= 1 && col <= 6) + || (row == 4 && col >= 2 && col <= 5) + || (row == 5 && col >= 3 && col <= 4); + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project, + IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + + Point2D p0 = controlPoints[0]; + double x = p0.getX(); + double y = p0.getY(); + + AffineTransform oldTx = g2d.getTransform(); + if (orientation != Orientation.DEFAULT) { + g2d.rotate(orientation.toRadians(), x, y); + } + + double boardW = getBoardWidth(); + double boardH = getBoardLength(); + double boardX = getBoardX(x); + double boardY = getBoardY(y); + + Shape boardShape = getBodyShape(); + + Composite oldComposite = applyAlpha(g2d, componentState); + + drawingObserver.startTracking(); + g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); + g2d.fill(boardShape); + drawingObserver.stopTracking(); + + g2d.setColor(getFinalBorderColor(componentState, outlineMode)); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); + g2d.draw(boardShape); + + // Any header the modules cover is drawn first, so that they cover it. These are drawn and + // painted over rather than skipped, because drawPinHeader is what registers the pins as a + // conductive area and leaving a call out would quietly drop five pins from continuity. The + // header works in unrotated control-point coordinates, so the board rotation comes off for it. + boolean coversBothHeaders = getModules().hasVerticalHeaders(); + g2d.setTransform(oldTx); + drawPinHeader(g2d, 5, 5, outlineMode, drawingObserver); + if (coversBothHeaders) { + drawPinHeader(g2d, 0, 5, outlineMode, drawingObserver); + } + if (orientation != Orientation.DEFAULT) { + g2d.rotate(orientation.toRadians(), x, y); + } + + if (!outlineMode) { + Modules modules = getModules(); + double matrixSize = MATRIX_SIZE.convertToPixels(); + double matrixX = boardX + (boardW - modules.getCount() * matrixSize) / 2.0; + double matrixY = boardY; + + // the board's corners are rounded and the modules are not, so the outline clips them + Shape oldClip = g2d.getClip(); + g2d.clip(boardShape); + + g2d.setColor(MATRIX_BODY); + g2d.fill(new Rectangle2D.Double(matrixX, matrixY, modules.getCount() * matrixSize, + matrixSize)); + + // One unbroken grid rather than a tiled one, and centred across the full width: a cascade + // shows one image, not the same image once per module. + double dotPitch = matrixSize / 8.0; + double dotR = dotPitch * 0.7; + int cols = 8 * modules.getCount(); + int patternOffset = (cols - 8) / 2; + for (int row = 0; row < 8; row++) { + for (int col = 0; col < cols; col++) { + double dx = matrixX + col * dotPitch + (dotPitch - dotR) / 2.0; + double dy = matrixY + row * dotPitch + (dotPitch - dotR) / 2.0; + g2d.setColor(isDotLit(row, col - patternOffset) ? dotColor : LED_OFF); + g2d.fill(new Ellipse2D.Double(dx, dy, dotR, dotR)); + } + } + + g2d.setClip(oldClip); + + // Only the tall single exposes its driver; hiding it is what makes the others tileable. + if (modules == Modules.Single_8x8) { + double chipMarginX = CHIP_MARGIN_X.convertToPixels(); + MakerBoardPainter.drawChip(g2d, boardX + chipMarginX, + matrixY + matrixSize + CHIP_GAP.convertToPixels(), boardW - 2 * chipMarginX, + CHIP_LENGTH.convertToPixels(), "MAX7219"); + } + + // No IN/OUT silkscreen: with the modules flush there is no strip left for the OUT label, and + // labelling one of a matched pair reads worse than labelling neither. + // + // The single does have bare board for its pin names. They go above its input row rather than + // below, where a label would overrun the 2.54 mm to the board edge. Every other row has a + // module over it, so those pins are named by their tooltips alone. + if (modules == Modules.Single_8x8) { + drawFlatRowPinLabels(g2d, x, y, getRelativeOffsets(), 0, 5, false, SILK_COLOR); + } + } + + g2d.setTransform(oldTx); + + // Where the modules cover both rows the input row went down with the output row before them. + if (!coversBothHeaders) { + drawPinHeader(g2d, 0, 5, outlineMode, drawingObserver); + } + + g2d.setComposite(oldComposite); + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + g2d.setColor(PCB_BLUE); + g2d.fill(new RoundRectangle2D.Double(4, 2, width - 8, height - 4, 3, 3)); + g2d.setColor(PCB_BLUE.darker()); + g2d.draw(new RoundRectangle2D.Double(4, 2, width - 8, height - 4, 3, 3)); + + g2d.setColor(MATRIX_BODY); + g2d.fillRect(6, 4, width - 12, height - 12); + + g2d.setColor(LED_RED); + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 4; c++) { + g2d.fillOval(9 + c * 4, 7 + r * 4, 2, 2); + } + } + } + + /** + * Which board the modules sit on, which is not the same question as how many there are: two of + * these carry one module each. The original single puts its driver and both headers on a strip + * below the module; the compact one fits the driver underneath, leaving a board that is only + * the module and can therefore be butted to others, which the 4-in-1 and 8-in-1 are. + * + *

The cascaded boards carry no dimensions of their own: a strip of flush 32 mm modules is + * 32 mm tall and as wide as it has modules. + */ + public enum Modules { + Single_8x8("Single 8x8 (32x50mm)", 1, 32.0d, 50.0d, false), + Compact_8x8("Compact 8x8 (32x32mm)", 1, 32.0d, 32.0d, true), + FourInOne_32x8("4-in-1 32x8 (128x32mm)", 4, 128.0d, 32.0d, true), + EightInOne_64x8("8-in-1 64x8 (256x32mm)", 8, 256.0d, 32.0d, true); + + private final String label; + private final int count; + private final double boardWidthMm; + private final double boardHeightMm; + private final boolean verticalHeaders; + + Modules(String label, int count, double boardWidthMm, double boardHeightMm, + boolean verticalHeaders) { + this.label = label; + this.count = count; + this.boardWidthMm = boardWidthMm; + this.boardHeightMm = boardHeightMm; + this.verticalHeaders = verticalHeaders; + } + + @Override public String toString() { return label; } + public int getCount() { return count; } + public double getBoardWidthMm() { return boardWidthMm; } + public double getBoardHeightMm() { return boardHeightMm; } + + /** True where the headers leave by the short edges, so their rows run down the board. */ + public boolean hasVerticalHeaders() { return verticalHeaders; } + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDMatrix8x8.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDMatrix8x8.java deleted file mode 100644 index b1bb2e2f1..000000000 --- a/diylc/diylc-library/src/main/java/org/diylc/components/displays/LEDMatrix8x8.java +++ /dev/null @@ -1,235 +0,0 @@ -/* - * - * DIY Layout Creator (DIYLC). - * Copyright (c) 2009-2025 held jointly by the individual authors. - * - * This file is part of DIYLC. - * - * DIYLC is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * DIYLC 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with DIYLC. If not, see . - * - */ -package org.diylc.components.displays; - -import java.awt.Color; -import java.awt.Composite; -import java.awt.Font; -import java.awt.Graphics2D; -import java.awt.Shape; -import java.awt.geom.AffineTransform; -import java.awt.geom.Ellipse2D; -import java.awt.geom.Point2D; -import java.awt.geom.RoundRectangle2D; - -import org.diylc.awt.StringUtils; -import org.diylc.common.HorizontalAlignment; -import org.diylc.common.ObjectCache; -import org.diylc.common.Orientation; -import org.diylc.common.VerticalAlignment; -import org.diylc.components.AbstractMakerBoard; -import org.diylc.components.MakerBoardPainter; -import org.diylc.core.ComponentState; -import org.diylc.core.IDIYComponent; -import org.diylc.core.IDrawingObserver; -import org.diylc.core.Project; -import org.diylc.core.annotations.BomPolicy; -import org.diylc.core.annotations.ComponentDescriptor; -import org.diylc.core.annotations.EditableProperty; -import org.diylc.core.annotations.KeywordPolicy; -import org.diylc.core.measures.Size; -import org.diylc.core.measures.SizeUnit; -import org.diylc.utils.Constants; - -// @ComponentDescriptor(name = "8x8 LED Matrix (MAX7219)", category = "Displays & Outputs", -// author = "Branislav Stojkovic", description = "MAX7219 Dot LED Matrix Display Module (Cascadable SPI)", -// instanceNamePrefix = "DISP", zOrder = IDIYComponent.COMPONENT, -// bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, -// enableCache = true) -public class LEDMatrix8x8 extends AbstractMakerBoard { - - private static final long serialVersionUID = 1L; - - public static Color PCB_BLUE = Color.decode("#0055A5"); - public static Color MATRIX_BODY = Color.decode("#1A1A1A"); - public static Color LED_RED = Color.decode("#E53935"); - public static Color LED_OFF = Color.decode("#333333"); - - public static Size BOARD_WIDTH = new Size(32.0d, SizeUnit.mm); - public static Size BOARD_HEIGHT = new Size(50.0d, SizeUnit.mm); - - public static final String[] PIN_NAMES = new String[] { - // Input Header (0..4) - "VCC_IN", "GND_IN", "DIN", "CS_IN", "CLK_IN", - // Output Header (5..9) - "VCC_OUT", "GND_OUT", "DOUT", "CS_OUT", "CLK_OUT" - }; - - private Color dotColor = LED_RED; - - public LEDMatrix8x8() { - super(); - this.bodyColor = PCB_BLUE; - updateControlPoints(); - } - - @EditableProperty(name = "Dot Color") - public Color getDotColor() { - return dotColor; - } - - public void setDotColor(Color dotColor) { - this.dotColor = dotColor; - invalidateCache(); - } - - @Override - public String getControlPointNodeName(int index) { - if (index >= 0 && index < PIN_NAMES.length) { - return PIN_NAMES[index]; - } - return "Pin " + (index + 1); - } - - @Override - protected void updateControlPoints() { - Point2D firstPoint = controlPoints[0]; - double spacing = PIN_SPACING.convertToPixels(); - - double[][] relativeOffsets = new double[10][2]; - - // Input header (0..4) at bottom - for (int i = 0; i < 5; i++) { - relativeOffsets[i][0] = i * spacing; - relativeOffsets[i][1] = 0; - } - // Output header (5..9) at top - double topY = -new Size(44.0d, SizeUnit.mm).convertToPixels(); - for (int i = 0; i < 5; i++) { - relativeOffsets[5 + i][0] = i * spacing; - relativeOffsets[5 + i][1] = topY; - } - - rotatePoints(firstPoint, relativeOffsets); - } - - @Override - public Shape getBodyShape() { - Point2D p0 = controlPoints[0]; - double x = p0.getX(); - double y = p0.getY(); - double boardW = BOARD_WIDTH.convertToPixels(); - double boardH = BOARD_HEIGHT.convertToPixels(); - double boardX = x - (boardW - 4 * PIN_SPACING.convertToPixels()) / 2.0; - double boardY = y - boardH + 20; - return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 8, 8); - } - - @Override - public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project, - IDrawingObserver drawingObserver) { - if (checkPointsClipped(g2d.getClip())) { - return; - } - - Point2D p0 = controlPoints[0]; - double x = p0.getX(); - double y = p0.getY(); - - AffineTransform oldTx = g2d.getTransform(); - if (orientation != Orientation.DEFAULT) { - g2d.rotate(orientation.toRadians(), x, y); - } - - double boardW = BOARD_WIDTH.convertToPixels(); - double boardH = BOARD_HEIGHT.convertToPixels(); - double boardX = x - (boardW - 4 * PIN_SPACING.convertToPixels()) / 2.0; - double boardY = y - boardH + 20; - - Shape boardShape = getBodyShape(); - - Composite oldComposite = applyAlpha(g2d, componentState); - - drawingObserver.startTracking(); - g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); - g2d.fill(boardShape); - drawingObserver.stopTracking(); - - g2d.setColor(getFinalBorderColor(componentState, outlineMode)); - g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); - g2d.draw(boardShape); - - if (!outlineMode) { - // 8x8 LED Matrix block (square 32mm) - double matrixSize = boardW - 10; - double matrixX = boardX + 5; - double matrixY = boardY + 30; - - g2d.setColor(MATRIX_BODY); - g2d.fill(new RoundRectangle2D.Double(matrixX, matrixY, matrixSize, matrixSize, 6, 6)); - - // Draw 64 circular LED dots - double dotPitch = matrixSize / 8.0; - double dotR = dotPitch * 0.7; - for (int row = 0; row < 8; row++) { - for (int col = 0; col < 8; col++) { - double dx = matrixX + col * dotPitch + (dotPitch - dotR) / 2.0; - double dy = matrixY + row * dotPitch + (dotPitch - dotR) / 2.0; - // create a demo heart or smile pattern - boolean on = (row == 0 && (col == 2 || col == 5)) || - (row == 1 && (col == 1 || col == 3 || col == 4 || col == 6)) || - (row == 2 && col >= 1 && col <= 6) || - (row == 3 && col >= 1 && col <= 6) || - (row == 4 && col >= 2 && col <= 5) || - (row == 5 && col >= 3 && col <= 4); - g2d.setColor(on ? dotColor : LED_OFF); - g2d.fill(new Ellipse2D.Double(dx, dy, dotR, dotR)); - } - } - - // MAX7219 IC - MakerBoardPainter.drawChip(g2d, boardX + 20, matrixY + matrixSize + 15, boardW - 40, 45, "MAX7219"); - - // Silkscreen - g2d.setColor(Color.WHITE); - g2d.setFont(SILK_FONT_SMALL); - StringUtils.drawCenteredText(g2d, "IN", x + 2 * PIN_SPACING.convertToPixels(), y - 16, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - StringUtils.drawCenteredText(g2d, "OUT", x + 2 * PIN_SPACING.convertToPixels(), y - boardH + 34, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - } - - g2d.setTransform(oldTx); - - drawPinHeader(g2d, 0, controlPoints.length, outlineMode, drawingObserver); - - g2d.setComposite(oldComposite); - } - - @Override - public void drawIcon(Graphics2D g2d, int width, int height) { - g2d.setColor(PCB_BLUE); - g2d.fill(new RoundRectangle2D.Double(4, 2, width - 8, height - 4, 3, 3)); - g2d.setColor(PCB_BLUE.darker()); - g2d.draw(new RoundRectangle2D.Double(4, 2, width - 8, height - 4, 3, 3)); - - // Matrix block - g2d.setColor(MATRIX_BODY); - g2d.fillRect(6, 4, width - 12, height - 12); - - // 4x4 sample dots - g2d.setColor(LED_RED); - for (int r = 0; r < 4; r++) { - for (int c = 0; c < 4; c++) { - g2d.fillOval(9 + c * 4, 7 + r * 4, 2, 2); - } - } - } -} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/Nokia5110LCD.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/Nokia5110LCD.java new file mode 100644 index 000000000..3e677c122 --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/Nokia5110LCD.java @@ -0,0 +1,291 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.geom.RoundRectangle2D; + +import org.diylc.awt.StringUtils; +import org.diylc.common.Display; +import org.diylc.common.HorizontalAlignment; +import org.diylc.common.ObjectCache; +import org.diylc.common.Orientation; +import org.diylc.common.VerticalAlignment; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.MakerBoardPainter; +import org.diylc.core.ComponentState; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.Project; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.EditableProperty; +import org.diylc.core.annotations.KeywordPolicy; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.diylc.presenter.CalcUtils; +import org.diylc.utils.Constants; + +@ComponentDescriptor(name = "Nokia 5110 LCD", category = "Displays & Outputs", + author = "Branislav Stojkovic", + description = "84x48 graphic LCD module with a PCD8544 controller and an SPI interface", + instanceNamePrefix = "LCD", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) +public class Nokia5110LCD extends AbstractMakerBoard { + + private static final long serialVersionUID = 1L; + + public static Color PCB_BLUE = Color.decode("#15548C"); + public static Color BEZEL_COLOR = LIGHT_METAL_COLOR; + public static Color BEZEL_BORDER_COLOR = LIGHT_METAL_COLOR.darker(); + public static Color LCD_COLOR = Color.decode("#6B7668"); + public static Color LCD_INK_LIGHT = Color.decode("#F5F5F5"); + public static Color LCD_INK_DARK = Color.decode("#2B2B2B"); + + public static Size BOARD_WIDTH = new Size(43.8d, SizeUnit.mm); + public static Size BOARD_LENGTH = new Size(45.8d, SizeUnit.mm); + // The metal frame holding the glass, centred across the board and measured down from the top + // edge; the pin row takes the strip left at the bottom. + public static Size BEZEL_WIDTH = new Size(40.0d, SizeUnit.mm); + public static Size BEZEL_LENGTH = new Size(35.0d, SizeUnit.mm); + public static Size BEZEL_TOP_OFFSET = new Size(4.5d, SizeUnit.mm); + // The glass sits lower in the frame than centred, so it is placed from the board's top edge. + public static Size DISPLAY_WIDTH = new Size(37.0d, SizeUnit.mm); + public static Size DISPLAY_LENGTH = new Size(27.0d, SizeUnit.mm); + public static Size DISPLAY_TOP_OFFSET = new Size(11.0d, SizeUnit.mm); + // Centre-to-centre, which is how the pattern is specified and what a builder drills. + public static Size MOUNTING_HOLE_SPACING_X = new Size(34.5d, SizeUnit.mm); + public static Size MOUNTING_HOLE_SPACING_Y = new Size(41.0d, SizeUnit.mm); + public static Size MOUNTING_HOLE_SIZE = new Size(3.0d, SizeUnit.mm); + public static Size HEADER_OFFSET = new Size(3.5d, SizeUnit.mm); + public static Size CORNER_RADIUS = new Size(1.5d, SizeUnit.mm); + public static Size SILK_TEXT_GAP = new Size(1.0d, SizeUnit.mm); + + public static final String[] PIN_NAMES = + new String[] {"RST", "CE", "DC", "DIN", "CLK", "VCC", "BL", "GND"}; + + private Display screen = Display.VALUE; + private Color screenColor = LCD_COLOR; + + public Nokia5110LCD() { + super(); + this.bodyColor = PCB_BLUE; + updateControlPoints(); + } + + @EditableProperty + public Display getScreen() { + return screen == null ? Display.VALUE : screen; + } + + public void setScreen(Display screen) { + this.screen = screen; + invalidateCache(); + } + + /** + * The same module is sold with a blue, a white and a green backlight and nothing else about it + * changes, so it is a colour rather than a variant. + */ + @EditableProperty(name = "Backlight Color") + public Color getScreenColor() { + return screenColor == null ? LCD_COLOR : screenColor; + } + + public void setScreenColor(Color screenColor) { + this.screenColor = screenColor; + invalidateCache(); + } + + /** The ink that contrasts with the backlight the user has chosen. */ + private Color getScreenInk() { + return CalcUtils.calculateLuminance(getScreenColor()) < 128d ? LCD_INK_LIGHT : LCD_INK_DARK; + } + + /** There is only one of these modules, so this is for the BOM's value column and the glass. */ + @Override + protected String getVariantLabel() { + return "84x48 PCD8544"; + } + + @Override + public String getControlPointNodeName(int index) { + if (index >= 0 && index < PIN_NAMES.length) { + return PIN_NAMES[index]; + } + return "Pin " + (index + 1); + } + + /** Left edge of the board, derived so that the pin row sits centred on the bottom edge. */ + private double getBoardX(double x) { + double span = (PIN_NAMES.length - 1) * PIN_SPACING.convertToPixels(); + return x - (BOARD_WIDTH.convertToPixels() - span) / 2.0; + } + + /** A whole board above the pin row rather than below it. */ + private double getBoardY(double y) { + return y - BOARD_LENGTH.convertToPixels() + HEADER_OFFSET.convertToPixels(); + } + + private double[][] getRelativeOffsets() { + double spacing = PIN_SPACING.convertToPixels(); + double[][] relativeOffsets = new double[PIN_NAMES.length][2]; + for (int i = 0; i < relativeOffsets.length; i++) { + relativeOffsets[i][0] = i * spacing; + relativeOffsets[i][1] = 0; + } + return relativeOffsets; + } + + @Override + protected void updateControlPoints() { + rotatePoints(controlPoints[0], getRelativeOffsets()); + } + + @Override + public Shape getBodyShape() { + Point2D p0 = controlPoints[0]; + double radius = CORNER_RADIUS.convertToPixels(); + return new RoundRectangle2D.Double(getBoardX(p0.getX()), getBoardY(p0.getY()), + BOARD_WIDTH.convertToPixels(), BOARD_LENGTH.convertToPixels(), radius, radius); + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, + Project project, IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + + Point2D p0 = controlPoints[0]; + double x = p0.getX(); + double y = p0.getY(); + + AffineTransform oldTx = g2d.getTransform(); + if (orientation != Orientation.DEFAULT) { + g2d.rotate(orientation.toRadians(), x, y); + } + + double boardW = BOARD_WIDTH.convertToPixels(); + double boardH = BOARD_LENGTH.convertToPixels(); + double boardX = getBoardX(x); + double boardY = getBoardY(y); + + Shape boardShape = getBodyShape(); + + Composite oldComposite = applyAlpha(g2d, componentState); + + drawingObserver.startTracking(); + g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); + g2d.fill(boardShape); + drawingObserver.stopTracking(); + + g2d.setColor(getFinalBorderColor(componentState, outlineMode)); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); + g2d.draw(boardShape); + + if (!outlineMode) { + double holeInsetX = (boardW - MOUNTING_HOLE_SPACING_X.convertToPixels()) / 2.0; + double holeInsetY = (boardH - MOUNTING_HOLE_SPACING_Y.convertToPixels()) / 2.0; + double holeSize = MOUNTING_HOLE_SIZE.convertToPixels(); + MakerBoardPainter.drawMountingHole(g2d, boardX + holeInsetX, boardY + holeInsetY, holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - holeInsetX, boardY + holeInsetY, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + holeInsetX, boardY + boardH - holeInsetY, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - holeInsetX, + boardY + boardH - holeInsetY, holeSize); + + // clear of the top left hole and level with it + g2d.setColor(SILK_COLOR); + g2d.setFont(SILK_FONT_LARGE); + StringUtils.drawCenteredText(g2d, "Nokia 5110", + boardX + holeInsetX + holeSize / 2.0 + SILK_TEXT_GAP.convertToPixels(), + boardY + holeInsetY, HorizontalAlignment.LEFT, VerticalAlignment.CENTER); + + double bezelW = BEZEL_WIDTH.convertToPixels(); + double bezelH = BEZEL_LENGTH.convertToPixels(); + double bezelX = boardX + (boardW - bezelW) / 2.0; + double bezelY = boardY + BEZEL_TOP_OFFSET.convertToPixels(); + + g2d.setColor(BEZEL_COLOR); + g2d.fill(new Rectangle2D.Double(bezelX, bezelY, bezelW, bezelH)); + g2d.setColor(BEZEL_BORDER_COLOR); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); + g2d.draw(new Rectangle2D.Double(bezelX, bezelY, bezelW, bezelH)); + + double displayW = DISPLAY_WIDTH.convertToPixels(); + double displayH = DISPLAY_LENGTH.convertToPixels(); + double displayX = boardX + (boardW - displayW) / 2.0; + double displayY = boardY + DISPLAY_TOP_OFFSET.convertToPixels(); + double displayRadius = CORNER_RADIUS.convertToPixels(); + + g2d.setColor(getScreenColor()); + g2d.fill(new RoundRectangle2D.Double(displayX, displayY, displayW, displayH, displayRadius, + displayRadius)); + g2d.setColor(getScreenColor().darker()); + g2d.draw(new RoundRectangle2D.Double(displayX, displayY, displayW, displayH, displayRadius, + displayRadius)); + + MakerBoardPainter.drawScreenText(g2d, + new Rectangle2D.Double(displayX, displayY, displayW, displayH), getScreenInk(), + getScreen(), getName(), getValueForDisplay()); + + // Names lie flat along the row: the strip is 3.5 mm deep and a label stood on end needs + // 4.7 mm. + drawFlatRowPinLabels(g2d, x, y, getRelativeOffsets(), 0, PIN_NAMES.length, true, + SILK_COLOR); + } + + g2d.setTransform(oldTx); + + drawPinHeader(g2d, 0, controlPoints.length, outlineMode, drawingObserver); + + g2d.setComposite(oldComposite); + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + g2d.setColor(PCB_BLUE); + g2d.fill(new RoundRectangle2D.Double(2, 1, width - 4, height - 5, 3, 3)); + g2d.setColor(PCB_BLUE.darker()); + g2d.draw(new RoundRectangle2D.Double(2, 1, width - 4, height - 5, 3, 3)); + + g2d.setColor(BEZEL_COLOR); + g2d.fillRect(4, 3, width - 8, height - 10); + + g2d.setColor(LCD_COLOR); + g2d.fill(new RoundRectangle2D.Double(5, 7, width - 10, height - 15, 2, 2)); + + g2d.setColor(PIN_COLOR); + for (int i = 0; i < 4; i++) { + g2d.fillRect(width / 2 - 5 + i * 3, height - 4, 2, 2); + } + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/OLEDDisplay.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/OLEDDisplay.java index 5b6428918..b6a346a4a 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/displays/OLEDDisplay.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/OLEDDisplay.java @@ -24,6 +24,7 @@ package org.diylc.components.displays; import java.awt.Color; import java.awt.Composite; import java.awt.Font; +import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.Shape; import java.awt.geom.AffineTransform; @@ -32,6 +33,7 @@ import java.awt.geom.Rectangle2D; import java.awt.geom.RoundRectangle2D; import org.diylc.awt.StringUtils; +import org.diylc.common.Display; import org.diylc.common.HorizontalAlignment; import org.diylc.common.ObjectCache; import org.diylc.common.Orientation; @@ -50,35 +52,51 @@ import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; import org.diylc.utils.Constants; -// @ComponentDescriptor(name = "0.96\" OLED Display (SSD1306)", category = "Displays & Outputs", -// author = "Branislav Stojkovic", description = "0.96\" Monochrome 128x64 OLED Display Module (I2C / SPI)", -// instanceNamePrefix = "DISP", zOrder = IDIYComponent.COMPONENT, -// bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, -// enableCache = true) +@ComponentDescriptor(name = "OLED Display", category = "Displays & Outputs", + author = "Branislav Stojkovic", + description = "Monochrome OLED Display Module (SSD1306 / SH1106, I2C or SPI)", + instanceNamePrefix = "DISP", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) public class OLEDDisplay extends AbstractMakerBoard { private static final long serialVersionUID = 1L; - public enum OLEDInterface { - I2C_4Pin("I2C (4-Pin: GND, VCC, SCL, SDA)"), - SPI_7Pin("SPI (7-Pin: GND, VCC, D0, D1, RES, DC, CS)"); - - private final String label; - OLEDInterface(String label) { this.label = label; } - @Override public String toString() { return label; } - } - public static Color OLED_BLUE = Color.decode("#004488"); public static Color GLASS_COLOR = Color.decode("#0D1B2A"); + public static Color GLASS_BORDER_COLOR = Color.decode("#334E68"); + public static Color ACTIVE_AREA_COLOR = Color.decode("#060D15"); public static Color PIXEL_BLUE = Color.decode("#00D4FF"); - public static Color PIXEL_YELLOW = Color.decode("#FFD700"); - public static Size BOARD_SIZE = new Size(27.0d, SizeUnit.mm); + // Clearance a name keeps from the edge of its pad. Tighter than the offset + // drawFlatRowPinLabels uses, which does not fit the 2.27 mm strip on the 0.96". + public static Size LABEL_GAP = new Size(0.5d, SizeUnit.mm); public static final String[] PIN_NAMES_I2C = new String[] {"GND", "VCC", "SCL", "SDA"}; - public static final String[] PIN_NAMES_SPI = new String[] {"GND", "VCC", "D0 (CLK)", "D1 (MOSI)", "RES", "DC", "CS"}; + /** + * Clock and data keep their I2C names in both interfaces, since the same two pads carry them. + * SPI-labelled boards print SCK or CLK and MOSI, and the SSD1306 datasheet D0 and D1. + */ + public static final String[] PIN_NAMES_SPI = + new String[] {"GND", "VCC", "SCL", "SDA", "RES", "DC", "CS"}; + + /** Chip select tied low on the board instead of brought out, so the bus cannot be shared. */ + public static final String[] PIN_NAMES_SPI_NO_CS = + new String[] {"GND", "VCC", "SCL", "SDA", "RES", "DC"}; + + /** + * VIN (4.8 - 5.2 V) and VDD (2.8 - 3.3 V logic) are separate rails and must not be tied + * together. The row follows the controller's pin numbering, which runs the reverse of the silk + * on the other boards. + */ + public static final String[] PIN_NAMES_SPI_SPLIT_SUPPLY = + new String[] {"SDA", "SCL", "DC", "RES", "CS", "VDD", "VIN", "GND"}; + + private Version version = Version.SSD1306_0_96; private OLEDInterface oledInterface = OLEDInterface.I2C_4Pin; + private Display screen = Display.VALUE; + private Color pixelColor = PIXEL_BLUE; public OLEDDisplay() { super(); @@ -86,9 +104,20 @@ public class OLEDDisplay extends AbstractMakerBoard { updateControlPoints(); } + @EditableProperty(name = "Size") + public Version getVersion() { + return version == null ? Version.SSD1306_0_96 : version; + } + + public void setVersion(Version version) { + this.version = version; + updateControlPoints(); + invalidateCache(); + } + @EditableProperty(name = "Interface") public OLEDInterface getOledInterface() { - return oledInterface; + return oledInterface == null ? OLEDInterface.I2C_4Pin : oledInterface; } public void setOledInterface(OLEDInterface oledInterface) { @@ -97,42 +126,155 @@ public class OLEDDisplay extends AbstractMakerBoard { invalidateCache(); } + @EditableProperty + public Display getScreen() { + return screen == null ? Display.VALUE : screen; + } + + public void setScreen(Display screen) { + this.screen = screen; + invalidateCache(); + } + + @EditableProperty(name = "Pixel Color") + public Color getPixelColor() { + return pixelColor == null ? PIXEL_BLUE : pixelColor; + } + + public void setPixelColor(Color pixelColor) { + this.pixelColor = pixelColor; + invalidateCache(); + } + + @Override + protected String getVariantLabel() { + return getVersion() + ", " + getOledInterface(); + } + + /** + * The 0.91" is sold as two different boards, one with a column on its left edge and one with a + * row on top, so outline, header edge, holes and panel position follow from size and interface + * together rather than from the size alone. + */ + private Layout getLayout() { + return getVersion().getLayout(getOledInterface()); + } + @Override public String getControlPointNodeName(int index) { - if (oledInterface == OLEDInterface.I2C_4Pin) { - if (index >= 0 && index < PIN_NAMES_I2C.length) return PIN_NAMES_I2C[index]; - } else { - if (index >= 0 && index < PIN_NAMES_SPI.length) return PIN_NAMES_SPI[index]; + String[] pinNames = getOledInterface().getPinNames(); + if (index >= 0 && index < pinNames.length) { + return pinNames[index]; } return "Pin " + (index + 1); } + private int getPinCount() { + return getOledInterface().getPinNames().length; + } + + private static double px(double millimetres) { + return new Size(millimetres, SizeUnit.mm).convertToPixels(); + } + + /** A top-edge row is centred across the board; a left-edge column stands its clearance in. */ + private double getBoardX(double x) { + Layout layout = getLayout(); + if (layout.isHeaderOnLeftEdge()) { + return x - px(layout.getHeaderOffsetMm()); + } + double span = (getPinCount() - 1) * PIN_SPACING.convertToPixels(); + return x - (px(layout.getBoardWidthMm()) - span) / 2.0; + } + + /** A top-edge row hangs below the edge by its clearance; a left-edge column is centred on the + * board's height. */ + private double getBoardY(double y) { + Layout layout = getLayout(); + if (layout.isHeaderOnLeftEdge()) { + double columnSpan = (getPinCount() - 1) * PIN_SPACING.convertToPixels(); + return y - (px(layout.getBoardLengthMm()) - columnSpan) / 2.0; + } + return y - px(layout.getHeaderOffsetMm()); + } + + private double[][] getRelativeOffsets() { + double spacing = PIN_SPACING.convertToPixels(); + boolean vertical = getLayout().isHeaderOnLeftEdge(); + + double[][] relativeOffsets = new double[getPinCount()][2]; + for (int i = 0; i < relativeOffsets.length; i++) { + relativeOffsets[i][0] = vertical ? 0 : i * spacing; + relativeOffsets[i][1] = vertical ? i * spacing : 0; + } + return relativeOffsets; + } + @Override protected void updateControlPoints() { - Point2D firstPoint = controlPoints[0]; - double spacing = PIN_SPACING.convertToPixels(); - - int count = (oledInterface == OLEDInterface.I2C_4Pin) ? 4 : 7; - double[][] relativeOffsets = new double[count][2]; - - for (int i = 0; i < count; i++) { - relativeOffsets[i][0] = i * spacing; - relativeOffsets[i][1] = 0; - } - - rotatePoints(firstPoint, relativeOffsets); + rotatePoints(controlPoints[0], getRelativeOffsets()); } @Override public Shape getBodyShape() { Point2D p0 = controlPoints[0]; - double x = p0.getX(); - double y = p0.getY(); - double boardSizePx = BOARD_SIZE.convertToPixels(); - int count = (oledInterface == OLEDInterface.I2C_4Pin) ? 4 : 7; - double boardX = x - (boardSizePx - (count - 1) * PIN_SPACING.convertToPixels()) / 2.0; - double boardY = y - 14; - return new RoundRectangle2D.Double(boardX, boardY, boardSizePx, boardSizePx, 8, 8); + Layout layout = getLayout(); + return new RoundRectangle2D.Double(getBoardX(p0.getX()), getBoardY(p0.getY()), + px(layout.getBoardWidthMm()), px(layout.getBoardLengthMm()), 8, 8); + } + + /** + * Pin names lying flat in the strip between the header and the glass. On the 0.91" I2C board the + * header is a column, so its names go to the right of the pins rather than below them. + */ + private void drawPinNames(Graphics2D g2d, double boardX, double boardY) { + Layout layout = getLayout(); + double[][] offsets = getRelativeOffsets(); + Point2D p0 = controlPoints[0]; + double gap = LABEL_GAP.convertToPixels(); + + // drawPinHeader fills a square one pixel wider than PIN_SIZE either side of the pin, which is + // the edge a name has to clear rather than the pin itself. + double padHalf = Math.round(PIN_SIZE.convertToPixels()) + 1; + + g2d.setFont(PIN_ROW_FLAT_FONT); + g2d.setColor(SILK_COLOR); + FontMetrics metrics = g2d.getFontMetrics(); + + // One reference glyph keeps a row of names on a single line. Every name here is upper case + // with no descender, so centring on the cap height lands them all alike. + double capHeight = g2d.getFont().createGlyphVector(g2d.getFontRenderContext(), "X") + .getVisualBounds().getHeight(); + + boolean column = layout.isHeaderOnLeftEdge(); + double glassEdge = column ? boardX + px(layout.getPanelLeftMm()) + : boardY + px(layout.getPanelTopMm()); + + for (int i = 0; i < offsets.length; i++) { + String label = getSilkPinLabel(i); + if (label == null || label.isEmpty()) { + continue; + } + double pinX = p0.getX() + offsets[i][0]; + double pinY = p0.getY() + offsets[i][1]; + double room = glassEdge - (column ? pinX : pinY) - padHalf; + double extent = column ? metrics.stringWidth(label) : capHeight; + if (extent > room) { + continue; + } + + // Centred in the strip where it is too shallow for the full gap, which the 0.96" is: 1.13 mm + // between pad and glass to print 0.73 mm of lettering in. + double offset = padHalf + Math.min(gap, (room - extent) / 2.0); + + if (column) { + StringUtils.drawCenteredText(g2d, label, pinX + offset, pinY, HorizontalAlignment.LEFT, + VerticalAlignment.CENTER); + } else { + StringUtils.drawCenteredText(g2d, label, pinX, pinY + offset + capHeight / 2.0, + HorizontalAlignment.CENTER, VerticalAlignment.CENTER); + } + } } @Override @@ -151,10 +293,11 @@ public class OLEDDisplay extends AbstractMakerBoard { g2d.rotate(orientation.toRadians(), x, y); } - double boardSizePx = BOARD_SIZE.convertToPixels(); - int count = (oledInterface == OLEDInterface.I2C_4Pin) ? 4 : 7; - double boardX = x - (boardSizePx - (count - 1) * PIN_SPACING.convertToPixels()) / 2.0; - double boardY = y - 14; + Layout layout = getLayout(); + double boardW = px(layout.getBoardWidthMm()); + double boardH = px(layout.getBoardLengthMm()); + double boardX = getBoardX(x); + double boardY = getBoardY(y); Shape boardShape = getBodyShape(); @@ -170,31 +313,44 @@ public class OLEDDisplay extends AbstractMakerBoard { g2d.draw(boardShape); if (!outlineMode) { - // 4 Corner mounting holes - MakerBoardPainter.drawMountingHole(g2d, boardX + 16, boardY + 16, 16); - MakerBoardPainter.drawMountingHole(g2d, boardX + 16, boardY + boardSizePx - 16, 16); - MakerBoardPainter.drawMountingHole(g2d, boardX + boardSizePx - 16, boardY + 16, 16); - MakerBoardPainter.drawMountingHole(g2d, boardX + boardSizePx - 16, boardY + boardSizePx - 16, 16); + if (layout.hasMountingHoles()) { + double holeInset = px(layout.getHoleInsetMm()); + double holeSize = px(layout.getHoleSizeMm()); + MakerBoardPainter.drawMountingHole(g2d, boardX + holeInset, boardY + holeInset, holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - holeInset, boardY + holeInset, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + holeInset, boardY + boardH - holeInset, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - holeInset, + boardY + boardH - holeInset, holeSize); + } - // Glass OLED Panel - double glassMarginX = 18; - double glassW = boardSizePx - 2 * glassMarginX; - double glassH = boardSizePx - 65; - double glassX = boardX + glassMarginX; - double glassY = boardY + 45; + Version version = getVersion(); + double glassW = px(version.getPanelWidthMm()); + double glassH = px(version.getPanelLengthMm()); + double glassX = boardX + px(layout.getPanelLeftMm()); + double glassY = boardY + px(layout.getPanelTopMm()); g2d.setColor(GLASS_COLOR); g2d.fill(new RoundRectangle2D.Double(glassX, glassY, glassW, glassH, 4, 4)); - g2d.setColor(Color.decode("#334E68")); + g2d.setColor(GLASS_BORDER_COLOR); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); g2d.draw(new RoundRectangle2D.Double(glassX, glassY, glassW, glassH, 4, 4)); - // Display demo graphics (yellow top banner + blue body) - g2d.setColor(PIXEL_YELLOW); - g2d.setFont(SILK_FONT_SMALL); - StringUtils.drawCenteredText(g2d, "SSD1306 128x64", glassX + glassW / 2.0, glassY + 15, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); + // The driver's bonding region takes one end of the glass and its bottom, so the lit area is + // offset within the panel and sits high in it. Its top is measured from the board's edge. + double activeW = px(version.getActiveWidthMm()); + double activeH = px(version.getActiveLengthMm()); + double activeX = glassX + px(version.getActiveLeftInPanelMm()); + double activeY = boardY + px(layout.getActiveTopMm()); + g2d.setColor(ACTIVE_AREA_COLOR); + g2d.fill(new RoundRectangle2D.Double(activeX, activeY, activeW, activeH, 2, 2)); - g2d.setColor(PIXEL_BLUE); - StringUtils.drawCenteredText(g2d, "OLED DISPLAY", glassX + glassW / 2.0, glassY + glassH / 2.0 + 8, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); + MakerBoardPainter.drawScreenText(g2d, + new Rectangle2D.Double(activeX, activeY, activeW, activeH), getPixelColor(), getScreen(), + getName(), getValueForDisplay()); + + drawPinNames(g2d, boardX, boardY); } g2d.setTransform(oldTx); @@ -211,7 +367,6 @@ public class OLEDDisplay extends AbstractMakerBoard { g2d.setColor(OLED_BLUE.darker()); g2d.draw(new RoundRectangle2D.Double(4, 4, width - 8, height - 8, 3, 3)); - // Screen g2d.setColor(GLASS_COLOR); g2d.fillRect(7, 12, width - 14, height - 18); @@ -219,10 +374,131 @@ public class OLEDDisplay extends AbstractMakerBoard { g2d.setFont(new Font("SansSerif", Font.BOLD, 5)); StringUtils.drawCenteredText(g2d, "OLED", width / 2, height / 2 + 3, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - // Pins g2d.setColor(PIN_COLOR); for (int i = 0; i < 4; i++) { g2d.fillRect(10 + i * 3, 5, 2, 2); } } + + public enum OLEDInterface { + // Labels reach the BOM's value column and are printed on the lit area, which the 0.91" has + // little of, so they stay short. Each SPI label states its pin count, the only thing that + // tells the three apart. + I2C_4Pin("I2C", PIN_NAMES_I2C), + SPI_6Pin("SPI-6", PIN_NAMES_SPI_NO_CS), + SPI_7Pin("SPI-7", PIN_NAMES_SPI), + SPI_8Pin("SPI-8", PIN_NAMES_SPI_SPLIT_SUPPLY); + + private final String label; + private final String[] pinNames; + + OLEDInterface(String label, String[] pinNames) { + this.label = label; + this.pinNames = pinNames; + } + + @Override public String toString() { return label; } + public String[] getPinNames() { return pinNames; } + } + + public enum Version { + // Panel and lit-area sizes belong to the display part and are shared by both of a size's + // boards. Everything belonging to a PCB is on the Layout, including where the lit area sits + // down the board, which the two 0.91" boards disagree on. + SSD1306_0_91("0.91\" SSD1306 128x32", 30.0d, 12.0d, 22.384d, 5.584d, 2.1d, + new Layout(35.8d, 12.0d, 1.5d, true, 0d, 0d, 5.0d, 0d, 2.35d), + new Layout(32.5d, 20.5d, 1.5d, false, 2.5d, 2.0d, 1.25d, 4.0d, 6.6d)), + SSD1306_0_96("0.96\" SSD1306 128x64", 26.7d, 19.26d, 21.74d, 11.0d, 2.48d, + new Layout(27.0d, 27.0d, 1.6d, false, 2.0d, 2.0d, 0.15d, 3.87d, 6.14d)), + SH1106_1_3("1.3\" SH1106 128x64", 35.4d, 23.0d, 29.42d, 14.7d, 2.99d, + new Layout(35.4d, 33.5d, 1.5d, false, 2.5d, 3.0d, 0d, 5.25d, 7.35d)); + + private final String label; + private final double panelWidthMm; + private final double panelLengthMm; + private final double activeWidthMm; + private final double activeLengthMm; + private final double activeLeftInPanelMm; + private final Layout i2cLayout; + private final Layout spiLayout; + + /** For a size sold on one board, which both headers are built onto. */ + Version(String label, double panelWidthMm, double panelLengthMm, double activeWidthMm, + double activeLengthMm, double activeLeftInPanelMm, Layout layout) { + this(label, panelWidthMm, panelLengthMm, activeWidthMm, activeLengthMm, activeLeftInPanelMm, + layout, layout); + } + + Version(String label, double panelWidthMm, double panelLengthMm, double activeWidthMm, + double activeLengthMm, double activeLeftInPanelMm, Layout i2cLayout, Layout spiLayout) { + this.label = label; + this.panelWidthMm = panelWidthMm; + this.panelLengthMm = panelLengthMm; + this.activeWidthMm = activeWidthMm; + this.activeLengthMm = activeLengthMm; + this.activeLeftInPanelMm = activeLeftInPanelMm; + this.i2cLayout = i2cLayout; + this.spiLayout = spiLayout; + } + + @Override public String toString() { return label; } + public double getPanelWidthMm() { return panelWidthMm; } + public double getPanelLengthMm() { return panelLengthMm; } + public double getActiveWidthMm() { return activeWidthMm; } + public double getActiveLengthMm() { return activeLengthMm; } + public double getActiveLeftInPanelMm() { return activeLeftInPanelMm; } + + /** The 6-pin and 8-pin modules borrow the 7-pin board; the header is centred either way. */ + public Layout getLayout(OLEDInterface oledInterface) { + return oledInterface == OLEDInterface.I2C_4Pin ? i2cLayout : spiLayout; + } + } + + /** + * The PCB a size is sold on in one interface. Hole positions are an inset from each of the two + * edges nearest the hole. + */ + public static class Layout { + + private final double boardWidthMm; + private final double boardLengthMm; + private final double headerOffsetMm; + private final boolean headerOnLeftEdge; + private final double holeInsetMm; + private final double holeSizeMm; + private final double panelLeftMm; + private final double panelTopMm; + private final double activeTopMm; + + Layout(double boardWidthMm, double boardLengthMm, double headerOffsetMm, + boolean headerOnLeftEdge, double holeInsetMm, double holeSizeMm, double panelLeftMm, + double panelTopMm, double activeTopMm) { + this.boardWidthMm = boardWidthMm; + this.boardLengthMm = boardLengthMm; + this.headerOffsetMm = headerOffsetMm; + this.headerOnLeftEdge = headerOnLeftEdge; + this.holeInsetMm = holeInsetMm; + this.holeSizeMm = holeSizeMm; + this.panelLeftMm = panelLeftMm; + this.panelTopMm = panelTopMm; + this.activeTopMm = activeTopMm; + } + + public double getBoardWidthMm() { return boardWidthMm; } + public double getBoardLengthMm() { return boardLengthMm; } + public double getHoleInsetMm() { return holeInsetMm; } + public double getHoleSizeMm() { return holeSizeMm; } + public double getPanelLeftMm() { return panelLeftMm; } + public double getPanelTopMm() { return panelTopMm; } + + /** Measured from the board's top edge rather than from the glass. */ + public double getActiveTopMm() { return activeTopMm; } + + /** Clearance from the edge the header sits on, whichever edge that is. */ + public double getHeaderOffsetMm() { return headerOffsetMm; } + + public boolean isHeaderOnLeftEdge() { return headerOnLeftEdge; } + + public boolean hasMountingHoles() { return holeSizeMm > 0; } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/SevenSegmentDisplay.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/SevenSegmentDisplay.java index 330a208a7..500c67ce0 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/displays/SevenSegmentDisplay.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/SevenSegmentDisplay.java @@ -51,25 +51,15 @@ import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; import org.diylc.utils.Constants; -// @ComponentDescriptor(name = "7-Segment Display", category = "Displays & Outputs", -// author = "Branislav Stojkovic", description = "7-Segment LED Display (1-Digit, 4-Digit, or TM1637 I2C Driver Module)", -// instanceNamePrefix = "DISP", zOrder = IDIYComponent.COMPONENT, -// bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, -// enableCache = true) +@ComponentDescriptor(name = "7-Segment Display", category = "Displays & Outputs", + author = "Branislav Stojkovic", description = "7-Segment LED Display (1-Digit, 2-Digit, 4-Digit, or TM1637 I2C Driver Module)", + instanceNamePrefix = "DISP", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) public class SevenSegmentDisplay extends AbstractMakerBoard { private static final long serialVersionUID = 1L; - public enum DisplayType { - SingleDigit_10Pin("1-Digit 0.56\" (10-Pin DIP)"), - FourDigit_12Pin("4-Digit Bare (12-Pin DIP)"), - TM1637_Module_4Pin("4-Digit TM1637 Module (4-Pin)"); - - private final String label; - DisplayType(String label) { this.label = label; } - @Override public String toString() { return label; } - } - public static Color BODY_BLACK = Color.decode("#1C1C1C"); public static Color FACE_BLACK = Color.decode("#111111"); public static Color FACE_BORDER = Color.decode("#333333"); @@ -77,26 +67,50 @@ public class SevenSegmentDisplay extends AbstractMakerBoard { public static Color LED_OFF = Color.decode("#2E2E2E"); public static Color LED_OFF_BORDER = Color.decode("#222222"); - // Exact physical proportions from datasheet - public static Size DIGIT_HEIGHT = new Size(14.2d, SizeUnit.mm); // 0.56" - public static Size DIGIT_WIDTH = new Size(8.1d, SizeUnit.mm); - public static Size DIGIT_PITCH = new Size(12.7d, SizeUnit.mm); public static Size SEGMENT_THICKNESS = new Size(1.4d, SizeUnit.mm); - public static Size SEGMENT_GAP = new Size(0.35d, SizeUnit.mm); - public static double SLANT_DEGREES = 8.0d; + // How far a module's header column sits in from the right edge. The DIP packages straddle their + // pins with a row either side; a module carries its header down one edge instead. + public static Size HEADER_EDGE_OFFSET = new Size(2.5d, SizeUnit.mm); + /** + * The digit is drawn at this fraction of the package's measured size. On the real part the + * digit window runs almost to the pin rows, so a faithful drawing has the pins sitting on the + * digit; this is a deliberate departure for legibility, which is why it is a separate factor + * rather than smaller digit sizes on {@link DisplayType}. It is shared, so changing it to chase + * an exact drawn size moves every package. + */ + public static double DIGIT_DRAW_SCALE = 0.88d; + /** + * Pin arrays are in DIP order: index 0 is pin 1 at the bottom left, counting right along the + * bottom row and then back right-to-left along the top, which is how {@code updateControlPoints} + * places the control points. The two commons are the same net on the part and are numbered only + * to keep the node names distinct. + */ public static final String[] PIN_NAMES_1DIGIT = new String[] { - "e", "d", "COM1", "c", "DP", "b", "a", "COM2", "f", "g" + "E", "D", "COM1", "C", "DP", "B", "A", "COM2", "F", "G" }; + /** The two-digit member of the same family as the 5641AS, with two digit commons instead of + * four. */ + public static final String[] PIN_NAMES_2DIGIT = new String[] { + "E", "D", "DP", "C", "G", "B", "D2", "D1", "F", "A" + }; + + /** Shared by both bare four-digit packages, which differ in size but not in pin function. */ public static final String[] PIN_NAMES_4DIGIT = new String[] { - "D1", "a", "f", "D2", "D3", "b", "e", "d", "DP", "c", "g", "D4" + "E", "D", "DP", "C", "G", "D4", "B", "D3", "D2", "F", "A", "D1" }; public static final String[] PIN_NAMES_TM1637 = new String[] {"CLK", "DIO", "VCC", "GND"}; + /** The blue of a bare PCB, matching the board under the OLED module. */ + public static Color PCB_BLUE = Color.decode("#004488"); + private DisplayType displayType = DisplayType.SingleDigit_10Pin; + private Common common = Common.Cathode; + private Punctuation punctuation = Punctuation.DecimalPoints; private Color ledColor = LED_RED; + private Color boardColor = PCB_BLUE; public SevenSegmentDisplay() { super(); @@ -115,6 +129,54 @@ public class SevenSegmentDisplay extends AbstractMakerBoard { invalidateCache(); } + /** + * No drawing path reads this: the two kinds share a pinout, a package and a set of dimensions, + * so it exists to split them in the BOM. Defaulted in the getter so that a project saved before + * the property existed still loads. + */ + @EditableProperty(name = "Common") + public Common getCommon() { + if (common == null) { + common = Common.Cathode; + } + return common; + } + + public void setCommon(Common common) { + this.common = common; + } + + /** + * Decides whether the decimal points and the colon are drawn. The colon is meaningless on the + * single-digit package and is ignored there. + */ + @EditableProperty(name = "Punctuation") + public Punctuation getPunctuation() { + if (punctuation == null) { + punctuation = Punctuation.DecimalPoints; + } + return punctuation; + } + + public void setPunctuation(Punctuation punctuation) { + this.punctuation = punctuation; + invalidateCache(); + } + + @Override + protected String getVariantLabel() { + DisplayType displayType = getDisplayType(); + if (displayType == null) { + return null; + } + // A module drives the digits itself and brings out no common pins, so polarity is not part of + // what you order -- but which punctuation it carries still is. + if (displayType.isModule()) { + return displayType + ", " + getPunctuation(); + } + return displayType + ", " + getCommon() + ", " + getPunctuation(); + } + @EditableProperty(name = "LED Color") public Color getLedColor() { return ledColor; @@ -125,89 +187,113 @@ public class SevenSegmentDisplay extends AbstractMakerBoard { invalidateCache(); } + /** Only the TM1637 has one; the bare packages are a moulding with pins in it. */ + @EditableProperty(name = "Board Color") + public Color getBoardColor() { + return boardColor; + } + + public void setBoardColor(Color boardColor) { + this.boardColor = boardColor; + invalidateCache(); + } + + /** + * The display device itself: the whole part on a bare package, and the module mounted on the + * board on a TM1637. Relabelled from the inherited "Board Color", which fits neither. + */ + @EditableProperty(name = "Body") + @Override + public Color getBodyColor() { + return super.getBodyColor(); + } + + private String[] getPinNames() { + return displayType.getPinNames(); + } + @Override public String getControlPointNodeName(int index) { - switch (displayType) { - case SingleDigit_10Pin: - if (index >= 0 && index < PIN_NAMES_1DIGIT.length) return PIN_NAMES_1DIGIT[index]; - break; - case FourDigit_12Pin: - if (index >= 0 && index < PIN_NAMES_4DIGIT.length) return PIN_NAMES_4DIGIT[index]; - break; - case TM1637_Module_4Pin: - if (index >= 0 && index < PIN_NAMES_TM1637.length) return PIN_NAMES_TM1637[index]; - break; + String[] names = getPinNames(); + if (index >= 0 && index < names.length) { + return names[index]; } return "Pin " + (index + 1); } + private static double px(double millimetres) { + return new Size(millimetres, SizeUnit.mm).convertToPixels(); + } + + /** + * The colon sits in the gap between the two middle digits, which exists only on an even count. + */ + private boolean hasMiddlePair() { + int digitCount = displayType.getDigitCount(); + return digitCount >= 2 && digitCount % 2 == 0; + } + + /** + * Draws the package's digits on their own pitch, centred on {@code rowCentreX}. Bare packages + * and modules differ only in the figures they pass, which is what lets a two-, six- or + * eight-digit part be a constant rather than a branch. + */ + private void drawDigitRow(Graphics2D g2d, double rowCentreX, double dy, double dw, double dh, + double pitch, double nudge, boolean decimalPoints) { + int digitCount = displayType.getDigitCount(); + double firstCentreX = rowCentreX - (digitCount - 1) * pitch / 2.0; + for (int d = 0; d < digitCount; d++) { + double dx = firstCentreX + d * pitch - dw / 2.0 - nudge; + drawSevenSegmentDigit(g2d, dx, dy, dw, dh, "8.", ledColor, decimalPoints); + } + } + @Override protected void updateControlPoints() { Point2D firstPoint = controlPoints[0]; double spacing = PIN_SPACING.convertToPixels(); - if (displayType == DisplayType.SingleDigit_10Pin) { - double rowSpacing = new Size(0.6d, SizeUnit.in).convertToPixels(); // 120px - double[][] relativeOffsets = new double[10][2]; - for (int i = 0; i < 5; i++) { + int pinCount = getPinNames().length; + double[][] relativeOffsets = new double[pinCount][2]; + + if (displayType.isDualRow()) { + int perRow = pinCount / 2; + double rowSpacing = + new Size(displayType.getRowSpacingMm(), SizeUnit.mm).convertToPixels(); + for (int i = 0; i < perRow; i++) { relativeOffsets[i][0] = i * spacing; relativeOffsets[i][1] = 0; + relativeOffsets[perRow + i][0] = (perRow - 1 - i) * spacing; + relativeOffsets[perRow + i][1] = -rowSpacing; } - for (int i = 0; i < 5; i++) { - relativeOffsets[5 + i][0] = (4 - i) * spacing; - relativeOffsets[5 + i][1] = -rowSpacing; - } - rotatePoints(firstPoint, relativeOffsets); - } else if (displayType == DisplayType.FourDigit_12Pin) { - double rowSpacing = new Size(0.6d, SizeUnit.in).convertToPixels(); - double[][] relativeOffsets = new double[12][2]; - for (int i = 0; i < 6; i++) { - relativeOffsets[i][0] = i * spacing; - relativeOffsets[i][1] = 0; - } - for (int i = 0; i < 6; i++) { - relativeOffsets[6 + i][0] = (5 - i) * spacing; - relativeOffsets[6 + i][1] = -rowSpacing; - } - rotatePoints(firstPoint, relativeOffsets); } else { - // TM1637 Module 4-pin - double[][] relativeOffsets = new double[4][2]; - for (int i = 0; i < 4; i++) { - relativeOffsets[i][0] = i * spacing; - relativeOffsets[i][1] = 0; + for (int i = 0; i < pinCount; i++) { + relativeOffsets[i][0] = 0; + relativeOffsets[i][1] = i * spacing; } - rotatePoints(firstPoint, relativeOffsets); } + + rotatePoints(firstPoint, relativeOffsets); } @Override public Shape getBodyShape() { Point2D p0 = controlPoints[0]; - double x = p0.getX(); - double y = p0.getY(); - double boardW; - double boardH; + double spacing = PIN_SPACING.convertToPixels(); + double boardW = new Size(displayType.getBodyWidthMm(), SizeUnit.mm).convertToPixels(); + double boardH = new Size(displayType.getBodyLengthMm(), SizeUnit.mm).convertToPixels(); + int pinCount = getPinNames().length; double boardX; double boardY; - if (displayType == DisplayType.SingleDigit_10Pin) { - boardW = new Size(12.6d, SizeUnit.mm).convertToPixels(); - boardH = new Size(19.0d, SizeUnit.mm).convertToPixels(); - double rowSpacing = new Size(0.6d, SizeUnit.in).convertToPixels(); - boardX = x - (boardW - 4 * PIN_SPACING.convertToPixels()) / 2.0; - boardY = y - rowSpacing - (boardH - rowSpacing) / 2.0; - } else if (displayType == DisplayType.FourDigit_12Pin) { - boardW = new Size(50.3d, SizeUnit.mm).convertToPixels(); - boardH = new Size(19.0d, SizeUnit.mm).convertToPixels(); - double rowSpacing = new Size(0.6d, SizeUnit.in).convertToPixels(); - boardX = x - (boardW - 5 * PIN_SPACING.convertToPixels()) / 2.0; - boardY = y - rowSpacing - (boardH - rowSpacing) / 2.0; + if (displayType.isDualRow()) { + double rowSpacing = + new Size(displayType.getRowSpacingMm(), SizeUnit.mm).convertToPixels(); + boardX = p0.getX() - (boardW - (pinCount / 2 - 1) * spacing) / 2.0; + boardY = p0.getY() - rowSpacing - (boardH - rowSpacing) / 2.0; } else { - boardW = new Size(42.0d, SizeUnit.mm).convertToPixels(); - boardH = new Size(24.0d, SizeUnit.mm).convertToPixels(); - boardX = x - (boardW - 3 * PIN_SPACING.convertToPixels()) / 2.0; - boardY = y - (boardH - PIN_SPACING.convertToPixels()) / 2.0; + boardX = p0.getX() - boardW + HEADER_EDGE_OFFSET.convertToPixels(); + boardY = p0.getY() - (boardH - (pinCount - 1) * spacing) / 2.0; } return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 6, 6); @@ -238,8 +324,10 @@ public class SevenSegmentDisplay extends AbstractMakerBoard { Composite oldComposite = applyAlpha(g2d, componentState); + Color outlineFill = displayType.isModule() ? boardColor : bodyColor; + drawingObserver.startTracking(); - g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); + g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : outlineFill); g2d.fill(boardShape); drawingObserver.stopTracking(); @@ -248,89 +336,78 @@ public class SevenSegmentDisplay extends AbstractMakerBoard { g2d.draw(boardShape); if (!outlineMode) { - if (displayType == DisplayType.SingleDigit_10Pin) { - // Dark display face - g2d.setColor(FACE_BLACK); + if (!displayType.isModule()) { + // the recessed window is a shade of the moulding, so it follows the body colour + g2d.setColor(bodyColor.darker()); g2d.fill(new RoundRectangle2D.Double(boardX + 3, boardY + 3, boardW - 6, boardH - 6, 3, 3)); g2d.setColor(FACE_BORDER); g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); g2d.draw(new RoundRectangle2D.Double(boardX + 3, boardY + 3, boardW - 6, boardH - 6, 3, 3)); - // Single 0.56" 7-segment digit with exact datasheet dimensions - double dw = DIGIT_WIDTH.convertToPixels(); - double dh = DIGIT_HEIGHT.convertToPixels(); - double dx = boardX + (boardW - dw) / 2.0 - 0.5 * SEGMENT_THICKNESS.convertToPixels(); + // The pitch is unscaled while the digits are drawn slightly under size, so they keep their + // true centres and only the gaps widen. The row is centred on the body rather than measured + // from its left edge, so it stays put across packages of different widths. A single-digit + // package is the same construction with a count of one. + double dw = px(displayType.getDigitWidthMm()) * DIGIT_DRAW_SCALE; + double dh = px(displayType.getDigitHeightMm()) * DIGIT_DRAW_SCALE; + double pitch = px(displayType.getDigitPitchMm()); double dy = boardY + (boardH - dh) / 2.0; - drawSevenSegmentDigit(g2d, dx, dy, dw, dh, "8.", ledColor); + double rowCentreX = boardX + boardW / 2.0; - } else if (displayType == DisplayType.FourDigit_12Pin) { - // Dark display face - g2d.setColor(FACE_BLACK); - g2d.fill(new RoundRectangle2D.Double(boardX + 3, boardY + 3, boardW - 6, boardH - 6, 3, 3)); - g2d.setColor(FACE_BORDER); - g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); - g2d.draw(new RoundRectangle2D.Double(boardX + 3, boardY + 3, boardW - 6, boardH - 6, 3, 3)); + Punctuation punctuation = getPunctuation(); + drawDigitRow(g2d, rowCentreX, dy, dw, dh, pitch, 0.5 * SEGMENT_THICKNESS.convertToPixels(), + punctuation.hasDecimalPoints()); - // 4 digits across with 12.70mm center-to-center pitch - double dw = DIGIT_WIDTH.convertToPixels(); - double dh = DIGIT_HEIGHT.convertToPixels(); - double pitch = DIGIT_PITCH.convertToPixels(); - double firstCenterX = boardX + new Size(6.1d, SizeUnit.mm).convertToPixels(); - double dy = boardY + (boardH - dh) / 2.0; - - for (int d = 0; d < 4; d++) { - double centerX = firstCenterX + d * pitch; - double dx = centerX - dw / 2.0 - 0.5 * SEGMENT_THICKNESS.convertToPixels(); - drawSevenSegmentDigit(g2d, dx, dy, dw, dh, "8.", ledColor); + if (punctuation.hasColon() && hasMiddlePair()) { + // Colon sized off the glyph grid like the decimal point, so it tracks the digit rather + // than staying a fixed few pixels across. The row is centred, so the gap between the two + // middle digits is the body's centre line. + double dotD = dw / GLYPH_WIDTH * 2.0; + double colonX = rowCentreX - dotD / 2.0; + g2d.setColor(ledColor); + g2d.fill(new Ellipse2D.Double(colonX, dy + dh * 0.35 - dotD / 2.0, dotD, dotD)); + g2d.fill(new Ellipse2D.Double(colonX, dy + dh * 0.65 - dotD / 2.0, dotD, dotD)); } - // Center colon dots between digits 1 & 2 - double colonX = boardX + boardW / 2.0; - double colonY1 = dy + dh * 0.35; - double colonY2 = dy + dh * 0.65; - g2d.setColor(ledColor); - g2d.fill(new Ellipse2D.Double(colonX - 2.0, colonY1 - 2.0, 4.0, 4.0)); - g2d.fill(new Ellipse2D.Double(colonX - 2.0, colonY2 - 2.0, 4.0, 4.0)); - } else { - // TM1637 4-Digit Display Module - MakerBoardPainter.drawMountingHole(g2d, boardX + 14, boardY + 14, 12); - MakerBoardPainter.drawMountingHole(g2d, boardX + 14, boardY + boardH - 14, 12); - MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - 14, boardY + 14, 12); - MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - 14, boardY + boardH - 14, 12); + Module module = displayType.getModule(); + double holeInset = px(module.getHoleInsetMm()); + double holeSize = px(module.getHoleSizeMm()); + MakerBoardPainter.drawMountingHole(g2d, boardX + holeInset, boardY + holeInset, holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + holeInset, boardY + boardH - holeInset, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - holeInset, boardY + holeInset, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - holeInset, + boardY + boardH - holeInset, holeSize); - // Display Bezel (Center) - double bezelW = new Size(30.0d, SizeUnit.mm).convertToPixels(); - double bezelH = new Size(14.0d, SizeUnit.mm).convertToPixels(); - double bezelX = boardX + (boardW - bezelW) / 2.0 - 6.0; + double bezelW = px(module.getBezelWidthMm()); + double bezelH = px(module.getBezelLengthMm()); + double bezelX = boardX + (boardW - bezelW) / 2.0; double bezelY = boardY + (boardH - bezelH) / 2.0; - g2d.setColor(FACE_BLACK); + g2d.setColor(bodyColor); g2d.fill(new RoundRectangle2D.Double(bezelX, bezelY, bezelW, bezelH, 4, 4)); g2d.setColor(FACE_BORDER); g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); g2d.draw(new RoundRectangle2D.Double(bezelX, bezelY, bezelW, bezelH, 4, 4)); - // 4 Digits inside 0.36" bezel - double dw = new Size(5.5d, SizeUnit.mm).convertToPixels(); - double dh = new Size(9.2d, SizeUnit.mm).convertToPixels(); - double pitch = new Size(7.62d, SizeUnit.mm).convertToPixels(); - double firstCenterX = bezelX + (bezelW - 3 * pitch) / 2.0; + // at full size here: the module's digits sit on a bezel rather than between pin rows + double dw = px(displayType.getDigitWidthMm()); + double dh = px(displayType.getDigitHeightMm()); + double pitch = px(displayType.getDigitPitchMm()); double dy = bezelY + (bezelH - dh) / 2.0; - for (int d = 0; d < 4; d++) { - double centerX = firstCenterX + d * pitch; - double dx = centerX - dw / 2.0 - 0.5 * SEGMENT_THICKNESS.convertToPixels() * 0.65; - drawSevenSegmentDigit(g2d, dx, dy, dw, dh, "8.", ledColor); + Punctuation punctuation = getPunctuation(); + drawDigitRow(g2d, bezelX + bezelW / 2.0, dy, dw, dh, pitch, + 0.5 * SEGMENT_THICKNESS.convertToPixels() * 0.65, punctuation.hasDecimalPoints()); + if (punctuation.hasColon() && hasMiddlePair()) { + double dotD = dw / GLYPH_WIDTH * 2.0; + double colonX = bezelX + bezelW / 2.0 - dotD / 2.0; + g2d.setColor(ledColor); + g2d.fill(new Ellipse2D.Double(colonX, dy + dh * 0.35 - dotD / 2.0, dotD, dotD)); + g2d.fill(new Ellipse2D.Double(colonX, dy + dh * 0.65 - dotD / 2.0, dotD, dotD)); } - - // Center colon dots - double colonX = bezelX + bezelW / 2.0; - double colonY1 = bezelY + bezelH / 2.0 - 8.0; - double colonY2 = bezelY + bezelH / 2.0 + 6.0; - g2d.setColor(ledColor); - g2d.fill(new Ellipse2D.Double(colonX - 1.5, colonY1 - 1.5, 3.0, 3.0)); - g2d.fill(new Ellipse2D.Double(colonX - 1.5, colonY2 - 1.5, 3.0, 3.0)); } } @@ -375,126 +452,88 @@ public class SevenSegmentDisplay extends AbstractMakerBoard { } /** - * Draws an authentic 7-segment digit matching exact engineering drawing specifications: - * - 8.0° italic forward slant - * - Realistic polygon segment geometry (trapezoids/hexagons with clean junction bevels) - * - 1.40mm segment thickness, 0.35mm inter-segment gaps without overlap - * - Unlit segments rendered in dim silhouette and active segments lit in onColor - * - 1.40mm Decimal point circle at bottom-right + * The seven segments as a unit-grid glyph: a 10 x 18 box with segments two units thick, mitred + * at 45 degrees. Neighbours meet at a shared vertex rather than being held apart by a gap, so + * the separation between them comes from outlining each segment in the background colour. Index + * order is A through G, matching the SEG_* bits. */ - private void drawSevenSegmentDigit(Graphics2D g2d, double x, double y, double w, double h, String charToDisplay, Color onColor) { + private static final double[][][] SEGMENT_POINTS = new double[][][] { + {{1, 1}, {2, 0}, {8, 0}, {9, 1}, {8, 2}, {2, 2}}, // A, top + {{9, 1}, {10, 2}, {10, 8}, {9, 9}, {8, 8}, {8, 2}}, // B, upper right + {{9, 9}, {10, 10}, {10, 16}, {9, 17}, {8, 16}, {8, 10}}, // C, lower right + {{9, 17}, {8, 18}, {2, 18}, {1, 17}, {2, 16}, {8, 16}}, // D, bottom + {{1, 17}, {0, 16}, {0, 10}, {1, 9}, {2, 10}, {2, 16}}, // E, lower left + {{1, 9}, {0, 8}, {0, 2}, {1, 1}, {2, 2}, {2, 8}}, // F, upper left + {{1, 9}, {2, 8}, {8, 8}, {9, 9}, {8, 10}, {2, 10}} // G, middle + }; + + /** Width of the glyph grid above, in its own units. */ + private static final double GLYPH_WIDTH = 10.0d; + + /** Height of the glyph grid above, in its own units. */ + private static final double GLYPH_HEIGHT = 18.0d; + + /** + * Draws one upright digit into the box at {@code x, y, w, h} from {@link #SEGMENT_POINTS}. + * Unlit segments are drawn first as a dim silhouette, then the lit ones, then every segment is + * outlined in the face colour so that neighbours sharing a vertex still read as separate. + */ + private void drawSevenSegmentDigit(Graphics2D g2d, double x, double y, double w, double h, + String charToDisplay, Color onColor, boolean showDecimalPoint) { AffineTransform orig = g2d.getTransform(); - // Exact 8.0° forward italic slant from engineering drawing - g2d.translate(x + w / 2.0, y + h / 2.0); - g2d.shear(-Math.tan(Math.toRadians(SLANT_DEGREES)), 0); - g2d.translate(-w / 2.0, -h / 2.0); + g2d.translate(x, y); - double t = Math.max(3.0, SEGMENT_THICKNESS.convertToPixels() * (h / DIGIT_HEIGHT.convertToPixels())); - double g = Math.max(1.0, SEGMENT_GAP.convertToPixels() * (h / DIGIT_HEIGHT.convertToPixels())); - double segW = w - t * 0.8; + double sx = w / GLYPH_WIDTH; + double sy = h / GLYPH_HEIGHT; - double x0 = 0; - double x1 = segW; - double y0 = 0; - double ym = h / 2.0; - double y1 = h; + Shape[] segments = new Shape[SEGMENT_POINTS.length]; + for (int i = 0; i < SEGMENT_POINTS.length; i++) { + double[][] points = SEGMENT_POINTS[i]; + Path2D.Double path = new Path2D.Double(); + path.moveTo(points[0][0] * sx, points[0][1] * sy); + for (int p = 1; p < points.length; p++) { + path.lineTo(points[p][0] * sx, points[p][1] * sy); + } + path.closePath(); + segments[i] = path; + } - // Segment A (Top horizontal) - Path2D pathA = new Path2D.Double(); - pathA.moveTo(x0 + t * 0.6 + g, y0); - pathA.lineTo(x1 - t * 0.6 - g, y0); - pathA.lineTo(x1 - g, y0 + t * 0.45); - pathA.lineTo(x1 - t - g, y0 + t); - pathA.lineTo(x0 + t + g, y0 + t); - pathA.lineTo(x0 + g, y0 + t * 0.45); - pathA.closePath(); + // the decimal point is not part of the glyph grid, so it is placed in the same units: one + // segment thickness across, sitting on the baseline just clear of the digit + double dpD = 2.0 * sx; + Shape dpShape = new Ellipse2D.Double(GLYPH_WIDTH * sx + 0.5 * sx, 18.0 * sy - dpD, dpD, dpD); - // Segment B (Top-Right vertical) - Path2D pathB = new Path2D.Double(); - pathB.moveTo(x1, y0 + t * 0.5 + g); - pathB.lineTo(x1 - t * 0.35, y0 + g); - pathB.lineTo(x1 - t, y0 + t + 1.4 * g); - pathB.lineTo(x1 - t, ym - t * 0.5 - 0.8 * g); - pathB.lineTo(x1, ym - 0.8 * g); - pathB.closePath(); - - // Segment C (Bottom-Right vertical) - Path2D pathC = new Path2D.Double(); - pathC.moveTo(x1, ym + 0.8 * g); - pathC.lineTo(x1 - t, ym + t * 0.5 + 0.8 * g); - pathC.lineTo(x1 - t, y1 - t - 1.4 * g); - pathC.lineTo(x1 - t * 0.35, y1 - g); - pathC.lineTo(x1, y1 - t * 0.5 - g); - pathC.closePath(); - - // Segment D (Bottom horizontal) - Path2D pathD = new Path2D.Double(); - pathD.moveTo(x0 + t + g, y1 - t); - pathD.lineTo(x1 - t - g, y1 - t); - pathD.lineTo(x1 - g, y1 - t * 0.45); - pathD.lineTo(x1 - t * 0.6 - g, y1); - pathD.lineTo(x0 + t * 0.6 + g, y1); - pathD.lineTo(x0 + g, y1 - t * 0.45); - pathD.closePath(); - - // Segment E (Bottom-Left vertical) - Path2D pathE = new Path2D.Double(); - pathE.moveTo(x0, ym + 0.8 * g); - pathE.lineTo(x0 + t, ym + t * 0.5 + 0.8 * g); - pathE.lineTo(x0 + t, y1 - t - 1.4 * g); - pathE.lineTo(x0 + t * 0.35, y1 - g); - pathE.lineTo(x0, y1 - t * 0.5 - g); - pathE.closePath(); - - // Segment F (Top-Left vertical) - Path2D pathF = new Path2D.Double(); - pathF.moveTo(x0, y0 + t * 0.5 + g); - pathF.lineTo(x0 + t * 0.35, y0 + g); - pathF.lineTo(x0 + t, y0 + t + 1.4 * g); - pathF.lineTo(x0 + t, ym - t * 0.5 - 0.8 * g); - pathF.lineTo(x0, ym - 0.8 * g); - pathF.closePath(); - - // Segment G (Middle horizontal hexagon) - Path2D pathG = new Path2D.Double(); - pathG.moveTo(x0 + 1.4 * g, ym); - pathG.lineTo(x0 + t * 0.6 + 1.4 * g, ym - t * 0.48); - pathG.lineTo(x1 - t * 0.6 - 1.4 * g, ym - t * 0.48); - pathG.lineTo(x1 - 1.4 * g, ym); - pathG.lineTo(x1 - t * 0.6 - 1.4 * g, ym + t * 0.48); - pathG.lineTo(x0 + t * 0.6 + 1.4 * g, ym + t * 0.48); - pathG.closePath(); - - // Decimal Point (DP) - double dpD = t * 0.95; - Shape dpShape = new Ellipse2D.Double(x1 + t * 0.25, y1 - dpD, dpD, dpD); - - Shape[] segments = new Shape[] { pathA, pathB, pathC, pathD, pathE, pathF, pathG }; int mask = getSegmentMask(charToDisplay.isEmpty() ? '8' : charToDisplay.charAt(0)); - // 1. Draw unlit segment silhouettes (dim background) g2d.setColor(LED_OFF); for (int i = 0; i < 7; i++) { if ((mask & (1 << i)) == 0) { g2d.fill(segments[i]); } } - if ((mask & SEG_DP) == 0 && !charToDisplay.contains(".")) { + if (showDecimalPoint && (mask & SEG_DP) == 0 && !charToDisplay.contains(".")) { g2d.fill(dpShape); } - // 2. Draw lit active segments g2d.setColor(onColor); for (int i = 0; i < 7; i++) { if ((mask & (1 << i)) != 0) { g2d.fill(segments[i]); } } - if ((mask & SEG_DP) != 0 || charToDisplay.contains(".")) { + if (showDecimalPoint && ((mask & SEG_DP) != 0 || charToDisplay.contains("."))) { g2d.fill(dpShape); } + // Outlining every segment in the background colour is what stops neighbours that share a + // vertex from fusing into one shape. + g2d.setColor(FACE_BLACK); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke((float) (0.25 * (sx + sy) / 2.0))); + for (Shape segment : segments) { + g2d.draw(segment); + } + g2d.setTransform(orig); } @@ -505,7 +544,157 @@ public class SevenSegmentDisplay extends AbstractMakerBoard { g2d.setColor(Color.DARK_GRAY); g2d.draw(new RoundRectangle2D.Double(6, 3, width - 12, height - 6, 3, 3)); - // Draw single stylized '8' - drawSevenSegmentDigit(g2d, 9, 5, width - 18, height - 10, "8.", LED_RED); + // the icon stands for the component type, so it keeps its decimal point whatever + // Punctuation is set to + drawSevenSegmentDigit(g2d, 9, 5, width - 18, height - 10, "8.", LED_RED, true); + } + + /** + * The packages this component can draw, each carrying its own dimensions in millimetres. + * + *

Digit heights are definitional -- 0.36 and 0.56 inches are what the parts are named for. + * The 0.56 inch body, digit width and pitch have never been checked against a datasheet, and + * the 0.36 inch equivalents are placeholders scaled from the digit height rather than + * measurements. + */ + public enum DisplayType { + // 5161AS, 5621AS, 3641AS and 5641AS. The two four-digit parts differ in size but share a + // pinout, which is why one array serves both. + SingleDigit_10Pin("1-Digit 0.56\"", 12.7d, 19.0d, 8.1d, 14.2d, 0d, 15.24d, true, 1, + PIN_NAMES_1DIGIT, null), + TwoDigit_0_56_10Pin("2-Digit 0.56\"", 25.0d, 19.0d, 8.1d, 14.2d, 12.7d, 15.24d, true, 2, + PIN_NAMES_2DIGIT, null), + FourDigit_0_36_12Pin("4-Digit 0.36\"", 30.0d, 14.0d, 5.2d, 9.14d, 7.5d, 10.16d, true, 4, + PIN_NAMES_4DIGIT, null), + FourDigit_0_56_12Pin("4-Digit 0.56\"", 50.3d, 19.0d, 8.1d, 14.2d, 12.7d, 15.24d, true, 4, + PIN_NAMES_4DIGIT, null), + TM1637_Module_4Pin("4-Digit TM1637 Module", 42.0d, 24.0d, 5.5d, 9.2d, 7.62d, 0d, false, 4, + PIN_NAMES_TM1637, new Module(30.0d, 14.0d, 1.778d, 1.524d)); + + private final String label; + private final double bodyWidthMm; + private final double bodyLengthMm; + private final double digitWidthMm; + private final double digitHeightMm; + private final double digitPitchMm; + private final double rowSpacingMm; + private final boolean dualRow; + private final int digitCount; + private final String[] pinNames; + private final Module module; + + DisplayType(String label, double bodyWidthMm, double bodyLengthMm, double digitWidthMm, + double digitHeightMm, double digitPitchMm, double rowSpacingMm, boolean dualRow, + int digitCount, String[] pinNames, Module module) { + this.label = label; + this.bodyWidthMm = bodyWidthMm; + this.bodyLengthMm = bodyLengthMm; + this.digitWidthMm = digitWidthMm; + this.digitHeightMm = digitHeightMm; + this.digitPitchMm = digitPitchMm; + this.rowSpacingMm = rowSpacingMm; + this.dualRow = dualRow; + this.digitCount = digitCount; + this.pinNames = pinNames; + this.module = module; + } + + /** + * Distance between the two pin rows. It cannot be shared: a package only works if its rows sit + * within its body, and the 0.36 inch part is shorter than the 0.6 inch the larger ones use. + */ + public double getRowSpacingMm() { return rowSpacingMm; } + + @Override public String toString() { return label; } + public double getBodyWidthMm() { return bodyWidthMm; } + public double getBodyLengthMm() { return bodyLengthMm; } + public double getDigitWidthMm() { return digitWidthMm; } + public double getDigitHeightMm() { return digitHeightMm; } + public double getDigitPitchMm() { return digitPitchMm; } + + /** True for the DIP packages, which carry their pins in two rows rather than one. */ + public boolean isDualRow() { return dualRow; } + + /** Carried rather than inferred, so a two-, six- or eight-digit part is a constant rather than + * another branch through {@code draw}. */ + public int getDigitCount() { return digitCount; } + + /** The part's pins in DIP order. */ + public String[] getPinNames() { return pinNames; } + + /** + * The board a driven module is built on, or {@code null} for a bare package. A module carries + * its own driver, so it brings out no common pins and its polarity is not something you order. + */ + public Module getModule() { return module; } + + public boolean isModule() { return module != null; } + } + + /** + * Which electrode the digits share. The two are identical in pinout, package and dimensions, so + * this changes nothing that is drawn and reaches only the BOM. + */ + public enum Common { + Cathode("Common Cathode"), + Anode("Common Anode"); + + private final String label; + Common(String label) { this.label = label; } + @Override public String toString() { return label; } + } + + /** + * What the package carries besides the digits. On a twelve-pin part these are not independent: + * A-G plus DP and four digit commons already account for every pin, so a colon has to share the + * decimal point's line or replace it. The property describes the face of the part, not a pin. + */ + public enum Punctuation { + None("No Punctuation"), + DecimalPoints("Decimal Points"), + Colon("Colon"), + Both("Colon + Decimal Points"); + + private final String label; + Punctuation(String label) { this.label = label; } + @Override public String toString() { return label; } + + public boolean hasDecimalPoints() { return this == DecimalPoints || this == Both; } + public boolean hasColon() { return this == Colon || this == Both; } + } + + /** + * The circuit board a driven module is built on, with the display mounted on it as a bezel and + * mounting holes at the corners. A bare package has none of this. + * + *

The hole figures are the pixel offsets this class drew before they were figures at all, + * converted at the canvas scale rather than measured. The exact conversions are kept rather + * than two-decimal roundings because the roundings moved the holes by a fiftieth of a pixel, + * which antialiasing turned into a visible difference. + * + * @author Branislav Stojkovic + */ + public static class Module { + + private final double bezelWidthMm; + private final double bezelLengthMm; + private final double holeInsetMm; + private final double holeSizeMm; + + public Module(double bezelWidthMm, double bezelLengthMm, double holeInsetMm, + double holeSizeMm) { + this.bezelWidthMm = bezelWidthMm; + this.bezelLengthMm = bezelLengthMm; + this.holeInsetMm = holeInsetMm; + this.holeSizeMm = holeSizeMm; + } + + public double getBezelWidthMm() { return bezelWidthMm; } + public double getBezelLengthMm() { return bezelLengthMm; } + + /** Centre of a corner hole, from each of the two edges nearest it. */ + public double getHoleInsetMm() { return holeInsetMm; } + + public double getHoleSizeMm() { return holeSizeMm; } } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/TFTDisplay.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/TFTDisplay.java index 43e32036b..c4afaced1 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/displays/TFTDisplay.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/TFTDisplay.java @@ -1,23 +1,23 @@ /* - * + * * DIY Layout Creator (DIYLC). * Copyright (c) 2009-2025 held jointly by the individual authors. - * + * * This file is part of DIYLC. - * + * * DIYLC is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * DIYLC 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 General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with DIYLC. If not, see . - * + * */ package org.diylc.components.displays; @@ -27,11 +27,14 @@ import java.awt.Font; import java.awt.Graphics2D; import java.awt.Shape; import java.awt.geom.AffineTransform; +import java.awt.geom.Area; +import java.awt.geom.Ellipse2D; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.awt.geom.RoundRectangle2D; import org.diylc.awt.StringUtils; +import org.diylc.common.Display; import org.diylc.common.HorizontalAlignment; import org.diylc.common.ObjectCache; import org.diylc.common.Orientation; @@ -44,30 +47,60 @@ import org.diylc.core.IDrawingObserver; import org.diylc.core.Project; import org.diylc.core.annotations.BomPolicy; import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.EditableProperty; import org.diylc.core.annotations.KeywordPolicy; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; import org.diylc.utils.Constants; -// @ComponentDescriptor(name = "TFT Touch Screen (ILI9341)", category = "Displays & Outputs", -// author = "Branislav Stojkovic", description = "2.8\" 240x320 Color TFT LCD Display with SPI Interface, Touch, and SD Slot", -// instanceNamePrefix = "DISP", zOrder = IDIYComponent.BOARD, -// bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, -// enableCache = true) +@ComponentDescriptor(name = "TFT Display", category = "Displays & Outputs", + author = "Branislav Stojkovic", + description = "Color TFT LCD display module with an SPI interface", + instanceNamePrefix = "DISP", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) public class TFTDisplay extends AbstractMakerBoard { private static final long serialVersionUID = 1L; + /** + * Silkscreen names for the 2.8". The default label would strip the touch prefix and print five + * pins all reading "T", and the names it keeps do not fit the 0.1" pitch. The nodes keep the + * full forms for the tooltips and the netlist. + */ + public static final String[] SILK_NAMES_ILI9341 = new String[] {"VCC", "GND", "CS", "RST", "DC", + "SDI", "SCK", "LED", "SDO", "TCK", "TCS", "TDI", "TDO", "IRQ"}; + + /** The 2.4" and the 2.8" are the same product with a different panel in it. */ + public static final String[] PIN_NAMES_ILI9341 = new String[] {"VCC", "GND", "CS", "RESET", "DC", + "MOSI (SDI)", "SCK", "LED", "MISO (SDO)", "T_CLK", "T_CS", "T_DIN", "T_DO", "T_IRQ"}; + + /** The micro-SD socket, brought out on its own row on the opposite edge from everything else. */ + public static final String[] PIN_NAMES_SD = + new String[] {"SD_CS", "SD_MOSI", "SD_MISO", "SD_SCK"}; + + /** + * The SD row drops the prefix its node names carry, which the default label would reduce to four + * pins all reading "SD". Only the row's position marks these as the card socket's. + */ + public static final String[] SILK_NAMES_SD = new String[] {"CS", "MOSI", "MISO", "SCK"}; + + /** + * Four-wire SPI plus reset, data/command and the backlight, which is what a board of this size + * brings out whichever controller is behind it. + */ + public static final String[] PIN_NAMES_SPI_8PIN = + new String[] {"GND", "VCC", "SCL", "SDA", "RES", "DC", "CS", "BLK"}; + public static Color TFT_RED = Color.decode("#C0392B"); public static Color SCREEN_BG = Color.decode("#111111"); + public static Color GLASS_COLOR = Color.decode("#1A1A1A"); + public static Color SCREEN_INK = Color.decode("#E0E0E0"); - public static Size BOARD_WIDTH = new Size(86.0d, SizeUnit.mm); - public static Size BOARD_HEIGHT = new Size(50.0d, SizeUnit.mm); + public static Size CORNER_RADIUS = new Size(1.5d, SizeUnit.mm); - public static final String[] PIN_NAMES = new String[] { - "VCC", "GND", "CS", "RESET", "DC", "MOSI (SDI)", "SCK", "LED", "MISO (SDO)", - "T_CLK", "T_CS", "T_DIN", "T_DO", "T_IRQ" - }; + private Controller controller = Controller.ILI9341_2_8; + private Display screen = Display.VALUE; public TFTDisplay() { super(); @@ -75,43 +108,149 @@ public class TFTDisplay extends AbstractMakerBoard { updateControlPoints(); } + @EditableProperty(name = "Controller") + public Controller getController() { + return controller == null ? Controller.ILI9341_2_8 : controller; + } + + public void setController(Controller controller) { + this.controller = controller; + updateControlPoints(); + invalidateCache(); + } + + @EditableProperty + public Display getScreen() { + return screen == null ? Display.VALUE : screen; + } + + public void setScreen(Display screen) { + this.screen = screen; + invalidateCache(); + } + + @Override + protected String getVariantLabel() { + return getController().toString(); + } + @Override public String getControlPointNodeName(int index) { - if (index >= 0 && index < PIN_NAMES.length) { - return PIN_NAMES[index]; + Controller controller = getController(); + String[] pinNames = controller.getPinNames(); + if (index >= 0 && index < pinNames.length) { + return pinNames[index]; + } + String[] secondary = controller.getSecondaryPinNames(); + if (secondary != null && index >= pinNames.length + && index < pinNames.length + secondary.length) { + return secondary[index - pinNames.length]; } return "Pin " + (index + 1); } @Override - protected void updateControlPoints() { - Point2D firstPoint = controlPoints[0]; - double spacing = PIN_SPACING.convertToPixels(); + protected String getSilkPinLabel(int index) { + Controller controller = getController(); + if (controller.getPinNames() == PIN_NAMES_ILI9341 && index >= 0 + && index < SILK_NAMES_ILI9341.length) { + return SILK_NAMES_ILI9341[index]; + } + int secondaryIndex = index - controller.getPinNames().length; + if (controller.getSecondaryPinNames() == PIN_NAMES_SD && secondaryIndex >= 0 + && secondaryIndex < SILK_NAMES_SD.length) { + return SILK_NAMES_SD[secondaryIndex]; + } + return super.getSilkPinLabel(index); + } - double[][] relativeOffsets = new double[14][2]; - for (int i = 0; i < 14; i++) { + private static double px(double millimetres) { + return new Size(millimetres, SizeUnit.mm).convertToPixels(); + } + + /** Left edge of the board, derived so that the primary pin row sits centred across it. */ + private double getBoardX(double x) { + Controller controller = getController(); + double spacing = PIN_SPACING.convertToPixels(); + double span = (controller.getPinNames().length - 1) * spacing; + return x - (px(controller.getBoardWidthMm()) - span) / 2.0; + } + + /** + * A board headed at the bottom stands above control point zero; one headed at the top hangs + * below it. + */ + private double getBoardY(double y) { + Controller controller = getController(); + if (controller.isHeaderAtTop()) { + return y - px(controller.getHeaderOffsetMm()); + } + return y - px(controller.getBoardLengthMm() - controller.getHeaderOffsetMm()); + } + + private double[][] getRelativeOffsets() { + double spacing = PIN_SPACING.convertToPixels(); + Controller controller = getController(); + int primaryCount = controller.getPinNames().length; + String[] secondary = controller.getSecondaryPinNames(); + int secondaryCount = secondary == null ? 0 : secondary.length; + + double[][] relativeOffsets = new double[primaryCount + secondaryCount][2]; + for (int i = 0; i < primaryCount; i++) { relativeOffsets[i][0] = i * spacing; relativeOffsets[i][1] = 0; } - rotatePoints(firstPoint, relativeOffsets); + // The second row faces the first, so its distance from control point zero is what the two + // offsets leave of the length. Both rows are centred on the width. + if (secondaryCount > 0) { + double indent = (primaryCount - secondaryCount) * spacing / 2.0; + double across = px(controller.getBoardLengthMm() - controller.getHeaderOffsetMm() + - controller.getSecondaryHeaderOffsetMm()); + if (!controller.isHeaderAtTop()) { + across = -across; + } + for (int i = 0; i < secondaryCount; i++) { + relativeOffsets[primaryCount + i][0] = indent + i * spacing; + relativeOffsets[primaryCount + i][1] = across; + } + } + return relativeOffsets; + } + + @Override + protected void updateControlPoints() { + rotatePoints(controlPoints[0], getRelativeOffsets()); } @Override public Shape getBodyShape() { Point2D p0 = controlPoints[0]; - double x = p0.getX(); - double y = p0.getY(); - double boardW = BOARD_WIDTH.convertToPixels(); - double boardH = BOARD_HEIGHT.convertToPixels(); - double boardX = x - 70; - double boardY = y - 20; - return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 10, 10); + Controller controller = getController(); + double boardX = getBoardX(p0.getX()); + double boardY = getBoardY(p0.getY()); + double boardW = px(controller.getBoardWidthMm()); + double boardH = px(controller.getBoardLengthMm()); + + if (controller.isRound()) { + // The tab has to reach the disc's centre line before the two are unioned: stopped at the + // bottom of the disc it would meet it at a tangent and leave a notch either side. + double discR = boardW / 2.0; + double discCy = boardY + discR; + double tabW = px(controller.getTabWidthMm()); + Area body = new Area(new Ellipse2D.Double(boardX, boardY, boardW, boardW)); + body.add(new Area(new Rectangle2D.Double(boardX + (boardW - tabW) / 2.0, discCy, tabW, + boardY + boardH - discCy))); + return body; + } + + double radius = CORNER_RADIUS.convertToPixels(); + return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, radius, radius); } @Override - public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project, - IDrawingObserver drawingObserver) { + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, + Project project, IDrawingObserver drawingObserver) { if (checkPointsClipped(g2d.getClip())) { return; } @@ -125,10 +264,11 @@ public class TFTDisplay extends AbstractMakerBoard { g2d.rotate(orientation.toRadians(), x, y); } - double boardW = BOARD_WIDTH.convertToPixels(); - double boardH = BOARD_HEIGHT.convertToPixels(); - double boardX = x - 70; - double boardY = y - 20; + Controller controller = getController(); + double boardW = px(controller.getBoardWidthMm()); + double boardH = px(controller.getBoardLengthMm()); + double boardX = getBoardX(x); + double boardY = getBoardY(y); Shape boardShape = getBodyShape(); @@ -144,38 +284,103 @@ public class TFTDisplay extends AbstractMakerBoard { g2d.draw(boardShape); if (!outlineMode) { - // 4 Mounting holes in corners - MakerBoardPainter.drawMountingHole(g2d, boardX + 20, boardY + 20, 20); - MakerBoardPainter.drawMountingHole(g2d, boardX + 20, boardY + boardH - 20, 20); - MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - 20, boardY + 20, 20); - MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - 20, boardY + boardH - 20, 20); + // Centring the panel between the hole rows rather than on an edge is what keeps it clear of + // both pairs. A board without holes falls back to the space below the pin row. + double bandTop = boardY + px(controller.getHeaderOffsetMm()); + double bandBottom = boardY + boardH; - // Color Screen Area - double screenMarginX = 35; - double screenW = boardW - 2 * screenMarginX - 25; - double screenH = boardH - 65; - double screenX = boardX + screenMarginX; - double screenY = boardY + 45; + if (controller.hasMountingHoles()) { + double holeInset = px(controller.getHoleInsetMm()); + double sideInset = px(controller.getHoleSideInsetMm()); + double headerSideInset = px(controller.getHeaderSideHoleInsetMm()); + double holeSize = px(controller.getHoleSizeMm()); + double topInset = controller.isHeaderAtTop() ? headerSideInset : holeInset; + double bottomInset = controller.isHeaderAtTop() ? holeInset : headerSideInset; + MakerBoardPainter.drawMountingHole(g2d, boardX + sideInset, boardY + topInset, holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - sideInset, boardY + topInset, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + sideInset, boardY + boardH - bottomInset, + holeSize); + MakerBoardPainter.drawMountingHole(g2d, boardX + boardW - sideInset, + boardY + boardH - bottomInset, holeSize); + bandTop = boardY + topInset; + bandBottom = boardY + boardH - bottomInset; + } + + double screenW = px(controller.getScreenWidthMm()); + double screenH = px(controller.getScreenLengthMm()); + Shape panel; + + if (controller.isRound()) { + // the lit circle is centred on the disc, which sits above the tab rather than on the + // centre line of the bounding box + double discR = boardW / 2.0; + double discCx = boardX + discR; + double discCy = boardY + discR; + panel = new Ellipse2D.Double(discCx - screenW / 2.0, discCy - screenH / 2.0, screenW, + screenH); + } else { + double bandCentre = (bandTop + bandBottom) / 2.0; + double screenX; + double screenY; + if (controller.hasGlass()) { + Glass glass = controller.getGlass(); + double glassH = px(glass.getLengthMm()); + double glassW = glass.spansTheBoard() ? boardW : px(glass.getWidthMm()); + double glassX = glass.isPlacedFromTheRightEdge() + ? boardX + boardW - px(glass.getRightInsetMm()) - glassW + : boardX + (boardW - glassW) / 2.0; + double glassY = bandCentre - glassH / 2.0; + g2d.setColor(GLASS_COLOR); + g2d.fill(new Rectangle2D.Double(glassX, glassY, glassW, glassH)); + + // Centred in the glass, and vertically too unless the board states its own figure: the + // driver's bonding region takes the bottom of the panel, so centring would drop it. + screenX = glassX + (glassW - screenW) / 2.0; + screenY = controller.hasMeasuredScreenTop() ? boardY + px(controller.getScreenTopMm()) + : glassY + (glassH - screenH) / 2.0; + } else { + screenX = boardX + (boardW - screenW) / 2.0; + screenY = controller.hasMeasuredScreenTop() ? boardY + px(controller.getScreenTopMm()) + : bandCentre - screenH / 2.0; + } + panel = new Rectangle2D.Double(screenX, screenY, screenW, screenH); + } g2d.setColor(SCREEN_BG); - g2d.fill(new Rectangle2D.Double(screenX, screenY, screenW, screenH)); + g2d.fill(panel); g2d.setColor(Color.DARK_GRAY); - g2d.draw(new Rectangle2D.Double(screenX, screenY, screenW, screenH)); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); + g2d.draw(panel); - // Display Demo UI - g2d.setColor(Color.decode("#2980B9")); - g2d.fillRect((int) screenX, (int) screenY, (int) screenW, 25); - g2d.setColor(Color.WHITE); - g2d.setFont(SILK_FONT); - StringUtils.drawCenteredText(g2d, "TFT 2.8\" 320x240 SPI", screenX + screenW / 2.0, screenY + 12, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); + // Only the inscribed square is safely inside a round panel. + Rectangle2D textArea = panel.getBounds2D(); + if (controller.isRound()) { + double side = textArea.getWidth() / Math.sqrt(2.0); + textArea = new Rectangle2D.Double(textArea.getCenterX() - side / 2.0, + textArea.getCenterY() - side / 2.0, side, side); + } + MakerBoardPainter.drawScreenText(g2d, textArea, SCREEN_INK, getScreen(), getName(), + getValueForDisplay()); - // SD Card holder on right edge - MakerBoardPainter.drawMetalConnector(g2d, boardX + boardW - 28, boardY + 100, 36, 120, "SD"); + // Names lie flat: the strip is 3.5 mm deep on the two smallest boards and a label stood on + // end needs 4.7 mm. Each row prints into the board rather than off its own edge. + double[][] offsets = getRelativeOffsets(); + drawFlatRowPinLabels(g2d, x, y, offsets, 0, controller.getPinNames().length, + controller.isHeaderAtTop(), SILK_COLOR); + if (controller.hasSecondaryHeader()) { + drawFlatRowPinLabels(g2d, x, y, offsets, controller.getPinNames().length, + controller.getSecondaryPinNames().length, !controller.isHeaderAtTop(), SILK_COLOR); + } } g2d.setTransform(oldTx); - drawPinHeader(g2d, 0, controlPoints.length, outlineMode, drawingObserver); + drawPinHeader(g2d, 0, controller.getPinNames().length, outlineMode, drawingObserver); + if (controller.hasSecondaryHeader()) { + drawPinHeader(g2d, controller.getPinNames().length, + controller.getSecondaryPinNames().length, outlineMode, drawingObserver); + } g2d.setComposite(oldComposite); } @@ -183,18 +388,220 @@ public class TFTDisplay extends AbstractMakerBoard { @Override public void drawIcon(Graphics2D g2d, int width, int height) { g2d.setColor(TFT_RED); - g2d.fill(new RoundRectangle2D.Double(2, 4, width - 4, height - 8, 3, 3)); + g2d.fill(new RoundRectangle2D.Double(8, 1, width - 16, height - 2, 3, 3)); g2d.setColor(TFT_RED.darker()); - g2d.draw(new RoundRectangle2D.Double(2, 4, width - 4, height - 8, 3, 3)); + g2d.draw(new RoundRectangle2D.Double(8, 1, width - 16, height - 2, 3, 3)); - // Screen + // The glass reaches both edges of the board and is centred down it. g2d.setColor(SCREEN_BG); - g2d.fillRect(6, 9, width - 12, height - 16); - g2d.setColor(Color.decode("#2980B9")); - g2d.fillRect(6, 9, width - 12, 5); + g2d.fillRect(8, 6, width - 16, height - 12); g2d.setColor(Color.WHITE); g2d.setFont(new Font("SansSerif", Font.BOLD, 5)); - StringUtils.drawCenteredText(g2d, "TFT", width / 2, height / 2 + 3, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); + StringUtils.drawCenteredText(g2d, "TFT", width / 2, height / 2, HorizontalAlignment.CENTER, + VerticalAlignment.CENTER); + } + + public enum Controller { + // Ordered by diagonal; a new variant goes in at its place in that run rather than at the end. + // A glass length of zero means the module has no dark panel wider than its own lit area, so + // only the screen is drawn, and a hole size of zero means no mounting holes at all. The round + // board carries the same two outline figures as the rest: width is the disc diameter and + // length runs from the tab's outer edge to the far side of the disc, so the tab's projection + // is the difference between them. + // + // A board headed at the top hangs below its row and one headed at the bottom grows upwards + // from it. Both occur here, so every constant states its own edge rather than taking a + // default. + + // The one board whose bezel does not span it, being pushed into the right-hand side with the + // driver's circuitry filling the bare L that leaves. + ST7735_0_96("0.96\" ST7735 80x160", 30.0d, 24.0d, 21.7d, 10.8d, + new Glass(12.8d, 23.7d, 2.0d), 1.44d, 2.5d, 2.0d, 0d, PIN_NAMES_SPI_8PIN, 0d, null, 0d, + 2.5d, true), + GC9A01_1_28("1.28\" GC9A01 240x240 Round", 38.0d, 45.5d, 32.5d, 32.5d, null, 1.76d, 0d, 0d, + 22.9d, + new String[] {"RST", "CS", "DC", "SDA", "SCL", "GND", "VCC"}, false), + // Lit area 1.5 mm above the middle of the bezel; rederive it if the hole rows move. + ST7789_1_54("1.54\" ST7789 240x240", 32.0d, 43.72d, 27.66d, 27.66d, new Glass(33.7d), 1.5d, + 2.5d, 2.0d, 0d, PIN_NAMES_SPI_8PIN, 6.53d, null, 0d, 2.5d, true), + // Bezel narrower than the PCB but centred across it, unlike the 0.96"'s. Lit area 2 mm above + // the middle of the bezel; rederive it if the bezel moves. + ST7735_1_8("1.8\" ST7735 128x160", 35.0d, 56.0d, 28.0d, 35.0d, new Glass(45.83d, 33.0d), 1.41d, + 2.5d, 2.0d, 0d, PIN_NAMES_SPI_8PIN, 8.5d, null, 0d, 2.5d, true), + // The plain 1.8"'s panel on a longer board with a micro-SD row, which turns the module round. + // The one board whose holes are not the same distance in from every edge, and whose 8-pin row + // runs alongside the holes on its edge rather than between them. + ST7735_1_8_SD("1.8\" ST7735 128x160 (SD)", 34.0d, 58.0d, 28.0d, 35.0d, + new Glass(45.83d, 33.0d), 2.5d, 3.0d, 2.0d, 0d, PIN_NAMES_SPI_8PIN, 9.5d, PIN_NAMES_SD, + 2.5d, 3.0d, 2.75d, false), + // The pair of holes beside the 14-pin row is set deeper than the rest: at the shared 3 mm the + // row's 14 pins, spanning 33 mm of a 42.72 mm board, would sit on them. + ILI9341_2_4("2.4\" ILI9341 240x320 (Touch + SD)", 42.72d, 77.18d, 36.72d, 48.96d, + new Glass(60.26d), 2.0d, 3.0d, 3.0d, 0d, PIN_NAMES_ILI9341, 9.26d, PIN_NAMES_SD, 2.0d, + 6.92d, false), + // As the 2.4", with the pair beside the pin row set deeper again. That carries the panel with + // it, the glass being centred between the hole rows, so the lit area's 9.70 mm has to be + // rederived if the holes move. + ILI9341_2_8("2.8\" ILI9341 240x320 (Touch + SD)", 50.0d, 86.0d, 43.2d, 57.6d, new Glass(69.2d), + 2.0d, 3.0d, 3.0d, 0d, PIN_NAMES_ILI9341, 9.70d, PIN_NAMES_SD, 2.5d, 6.50d, false); + private final String label; + private final double boardWidthMm; + private final double boardLengthMm; + private final double screenWidthMm; + private final double screenLengthMm; + private final Glass glass; + private final double headerOffsetMm; + private final double holeInsetMm; + private final double holeSizeMm; + private final double tabWidthMm; + private final String[] pinNames; + private final double screenTopMm; + private final String[] secondaryPinNames; + private final double secondaryHeaderOffsetMm; + private final double headerSideHoleInsetMm; + private final double holeSideInsetMm; + private final boolean headerAtTop; + + /** For a board whose lit area is centred and whose pins all leave on one row. */ + Controller(String label, double boardWidthMm, double boardLengthMm, double screenWidthMm, + double screenLengthMm, Glass glass, double headerOffsetMm, double holeInsetMm, + double holeSizeMm, double tabWidthMm, String[] pinNames, boolean headerAtTop) { + this(label, boardWidthMm, boardLengthMm, screenWidthMm, screenLengthMm, glass, headerOffsetMm, + holeInsetMm, holeSizeMm, tabWidthMm, pinNames, 0d, null, 0d, holeInsetMm, headerAtTop); + } + + /** For a board whose holes are the same distance in from every edge they are near. */ + Controller(String label, double boardWidthMm, double boardLengthMm, double screenWidthMm, + double screenLengthMm, Glass glass, double headerOffsetMm, double holeInsetMm, + double holeSizeMm, double tabWidthMm, String[] pinNames, double screenTopMm, + String[] secondaryPinNames, double secondaryHeaderOffsetMm, double headerSideHoleInsetMm, + boolean headerAtTop) { + this(label, boardWidthMm, boardLengthMm, screenWidthMm, screenLengthMm, glass, headerOffsetMm, + holeInsetMm, holeSizeMm, tabWidthMm, pinNames, screenTopMm, secondaryPinNames, + secondaryHeaderOffsetMm, headerSideHoleInsetMm, holeInsetMm, headerAtTop); + } + + Controller(String label, double boardWidthMm, double boardLengthMm, double screenWidthMm, + double screenLengthMm, Glass glass, double headerOffsetMm, double holeInsetMm, + double holeSizeMm, double tabWidthMm, String[] pinNames, double screenTopMm, + String[] secondaryPinNames, double secondaryHeaderOffsetMm, double headerSideHoleInsetMm, + double holeSideInsetMm, boolean headerAtTop) { + this.label = label; + this.boardWidthMm = boardWidthMm; + this.boardLengthMm = boardLengthMm; + this.screenWidthMm = screenWidthMm; + this.screenLengthMm = screenLengthMm; + this.glass = glass; + this.headerOffsetMm = headerOffsetMm; + this.holeInsetMm = holeInsetMm; + this.holeSizeMm = holeSizeMm; + this.tabWidthMm = tabWidthMm; + this.pinNames = pinNames; + this.screenTopMm = screenTopMm; + this.secondaryPinNames = secondaryPinNames; + this.secondaryHeaderOffsetMm = secondaryHeaderOffsetMm; + this.headerSideHoleInsetMm = headerSideHoleInsetMm; + this.holeSideInsetMm = holeSideInsetMm; + this.headerAtTop = headerAtTop; + } + + @Override public String toString() { return label; } + public double getBoardWidthMm() { return boardWidthMm; } + public double getBoardLengthMm() { return boardLengthMm; } + public double getScreenWidthMm() { return screenWidthMm; } + public double getScreenLengthMm() { return screenLengthMm; } + /** The dark panel, or {@code null} on a board whose lit area is all there is to draw. */ + public Glass getGlass() { return glass; } + public double getHeaderOffsetMm() { return headerOffsetMm; } + public double getHoleSizeMm() { return holeSizeMm; } + public String[] getPinNames() { return pinNames; } + + /** + * Inset from each of the two edges nearest a hole, except where + * {@link #getHeaderSideHoleInsetMm()} or {@link #getHoleSideInsetMm()} overrides it. + */ + public double getHoleInsetMm() { return holeInsetMm; } + + /** Overrides the shared inset across the board; only the 1.8" with the SD socket needs it. */ + public double getHoleSideInsetMm() { return holeSideInsetMm; } + + /** + * Inset of the two holes on the header's edge, set deeper on boards whose row would reach them. + */ + public double getHeaderSideHoleInsetMm() { return headerSideHoleInsetMm; } + + public boolean hasMountingHoles() { return holeSizeMm > 0; } + + public boolean hasGlass() { return glass != null; } + + /** True where the main row leaves by the top edge, so the board hangs below it. */ + public boolean isHeaderAtTop() { return headerAtTop; } + + /** Width of the tab the header sits on; meaningful only for a round board. */ + public double getTabWidthMm() { return tabWidthMm; } + + /** How far the tab projects past the disc: whatever the outline is longer than it is wide. */ + public double getTabProjectionMm() { return boardLengthMm - boardWidthMm; } + + public boolean isRound() { return tabWidthMm > 0; } + + /** Top of the lit area from the board's top edge; zero where it is centred in the glass. */ + public double getScreenTopMm() { return screenTopMm; } + + public boolean hasMeasuredScreenTop() { return screenTopMm > 0; } + + /** The second pin row, or {@code null} on a board that brings everything out on one. */ + public String[] getSecondaryPinNames() { return secondaryPinNames; } + + public boolean hasSecondaryHeader() { return secondaryPinNames != null; } + + /** Clearance from the second row to the top edge, the one the main header does not use. */ + public double getSecondaryHeaderOffsetMm() { return secondaryHeaderOffsetMm; } + + } + + /** + * The dark panel the lit area sits in, whose frame is the display's visible border. Centred + * between the board's mounting holes, and across the board unless it states an inset from the + * right edge. + * + * @author Branislav Stojkovic + */ + public static class Glass { + + private final double lengthMm; + private final double widthMm; + private final double rightInsetMm; + + /** A panel that spans the board. */ + public Glass(double lengthMm) { + this(lengthMm, 0d, 0d); + } + + /** A panel narrower than its board, centred across it. */ + public Glass(double lengthMm, double widthMm) { + this(lengthMm, widthMm, 0d); + } + + /** A panel narrower than its board and pushed towards the right edge. */ + public Glass(double lengthMm, double widthMm, double rightInsetMm) { + this.lengthMm = lengthMm; + this.widthMm = widthMm; + this.rightInsetMm = rightInsetMm; + } + + public double getLengthMm() { return lengthMm; } + + /** Zero where the panel is as wide as the board. */ + public double getWidthMm() { return widthMm; } + + /** How far the panel stops short of the right edge; meaningful only where it is narrower. */ + public double getRightInsetMm() { return rightInsetMm; } + + public boolean spansTheBoard() { return widthMm <= 0; } + + /** True for a narrower panel placed from the right edge rather than centred on the board. */ + public boolean isPlacedFromTheRightEdge() { return rightInsetMm > 0; } } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BBreakout.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BBreakout.java new file mode 100644 index 000000000..ff9666681 --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BBreakout.java @@ -0,0 +1,232 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.geom.RoundRectangle2D; + +import org.diylc.common.ObjectCache; +import org.diylc.common.Orientation; +import org.diylc.components.AbstractAddressableLedBoard; +import org.diylc.components.MakerBoardPainter; +import org.diylc.core.ComponentState; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.Project; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.KeywordPolicy; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.diylc.utils.Constants; + +@ComponentDescriptor(name = "NeoPixel Breakout", category = "Displays & Outputs", + author = "Branislav Stojkovic", + description = "Single-LED Addressable RGB WS2812B NeoPixel Breakout", + instanceNamePrefix = "LED", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) +public class WS2812BBreakout extends AbstractAddressableLedBoard { + + private static final long serialVersionUID = 1L; + + public static Color NEO_BLACK = Color.decode("#111111"); + + // A three-pad column up each short edge, with the two mounting holes on the centre line + // between them. + public static Size BOARD_WIDTH = new Size(0.5d, SizeUnit.in); + public static Size BOARD_HEIGHT = new Size(0.4d, SizeUnit.in); + + // The real board's three-way connectors are drawn as plain pads: at this size a connector body + // would hide the LED, and what a layout needs is where the three wires land. The pads are flush + // with their own edge, so how far in their centres sit is half their reach. + public static Size PAD_WIDTH = new Size(1.5d, SizeUnit.mm); + public static Size PAD_LENGTH = new Size(1.5d, SizeUnit.mm); + + public static Size MOUNTING_HOLE_SIZE = new Size(2.0d, SizeUnit.mm); + public static Size MOUNTING_HOLE_SPACING = new Size(0.3d, SizeUnit.in); + + // The two grounds are the same net on the board, as are the two supplies, and are numbered only + // to keep the node names distinct. + private static final String[] PIN_NAMES = { + "GND_1", "VIN_1", "IN", + "GND_2", "VIN_2", "OUT" + }; + + public WS2812BBreakout() { + super(); + this.bodyColor = NEO_BLACK; + updateControlPoints(); + } + + @Override + protected String getVariantLabel() { + return getLedType().toString(); + } + + @Override + public String getControlPointNodeName(int index) { + if (index >= 0 && index < PIN_NAMES.length) { + return PIN_NAMES[index]; + } + return Integer.toString(index + 1); + } + + @Override + protected void updateControlPoints() { + Point2D firstPoint = controlPoints[0]; + double spacing = PIN_SPACING.convertToPixels(); + // the columns are flush against their own edges, so what separates them is the board less one + // whole pad + double columnSpacing = BOARD_WIDTH.convertToPixels() - PAD_WIDTH.convertToPixels(); + + double[][] relativeOffsets = new double[PIN_NAMES.length][2]; + for (int i = 0; i < 3; i++) { + relativeOffsets[i][0] = 0; + relativeOffsets[i][1] = i * spacing; + relativeOffsets[3 + i][0] = columnSpacing; + relativeOffsets[3 + i][1] = i * spacing; + } + + rotatePoints(firstPoint, relativeOffsets); + } + + /** The first pad column is flush with this edge, so its centres sit half a pad inboard. */ + private double getBoardX(double x) { + return x - PAD_WIDTH.convertToPixels() / 2.0; + } + + /** Derived so that the three-pad column is centred across the height. */ + private double getBoardY(double y) { + double padSpan = 2 * PIN_SPACING.convertToPixels(); + return y - (BOARD_HEIGHT.convertToPixels() - padSpan) / 2.0; + } + + Point2D getLedCentre() { + Point2D p0 = controlPoints[0]; + return new Point2D.Double(getBoardX(p0.getX()) + BOARD_WIDTH.convertToPixels() / 2.0, + getBoardY(p0.getY()) + BOARD_HEIGHT.convertToPixels() / 2.0); + } + + /** + * The holes sit on the centre line astride the LED and are what the board is tightest against: + * 0.27 mm of board outside each rim and 0.31 mm between the rim and the package. + */ + Point2D getHoleCentre(boolean bottom) { + Point2D centre = getLedCentre(); + double offset = MOUNTING_HOLE_SPACING.convertToPixels() / 2.0; + return new Point2D.Double(centre.getX(), centre.getY() + (bottom ? offset : -offset)); + } + + @Override + public Shape getBodyShape() { + Point2D p0 = controlPoints[0]; + return new RoundRectangle2D.Double(getBoardX(p0.getX()), getBoardY(p0.getY()), + BOARD_WIDTH.convertToPixels(), BOARD_HEIGHT.convertToPixels(), 4, 4); + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, + Project project, IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + + Point2D p0 = controlPoints[0]; + double x = p0.getX(); + double y = p0.getY(); + + AffineTransform oldTx = g2d.getTransform(); + if (orientation != Orientation.DEFAULT) { + g2d.rotate(orientation.toRadians(), x, y); + } + + Shape boardShape = getBodyShape(); + + Composite oldComposite = applyAlpha(g2d, componentState); + + drawingObserver.startTracking(); + g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); + g2d.fill(boardShape); + drawingObserver.stopTracking(); + + g2d.setColor(getFinalBorderColor(componentState, outlineMode)); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); + g2d.draw(boardShape); + + if (!outlineMode) { + double holeSize = MOUNTING_HOLE_SIZE.convertToPixels(); + for (boolean bottom : new boolean[] {false, true}) { + Point2D hole = getHoleCentre(bottom); + MakerBoardPainter.drawMountingHole(g2d, hole.getX(), hole.getY(), holeSize); + } + + // The 2.35 mm between a pad and the package is not room beside a pad, so there is nowhere a + // name would go and this face carries no silkscreen. Drawn lit off the family's wheel, so a + // breakout reads as the same part as one pixel of a stick. + Point2D led = getLedCentre(); + MakerBoardPainter.drawAddressableLed(g2d, led.getX(), led.getY(), + RGB_LED_SIZE.convertToPixels(), getLedColors(1)[0], 0, getLedType()); + } + + g2d.setTransform(oldTx); + + drawSurfacePads(g2d, 0, controlPoints.length, PAD_WIDTH, PAD_LENGTH, outlineMode, + drawingObserver); + + g2d.setComposite(oldComposite); + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + // landscape in the board's own 5:4, so the icon does not read as one of the portrait modules + double rectW = width - 4; + double rectH = rectW * 0.8; + double rectX = 2; + double rectY = (height - rectH) / 2.0; + + g2d.setColor(NEO_BLACK); + g2d.fill(new RoundRectangle2D.Double(rectX, rectY, rectW, rectH, 3, 3)); + g2d.setColor(Color.DARK_GRAY); + g2d.draw(new RoundRectangle2D.Double(rectX, rectY, rectW, rectH, 3, 3)); + + double padW = 3; + double padH = 2; + double padStep = 5; + g2d.setColor(PAD_COLOR); + for (int i = 0; i < 3; i++) { + double padY = height / 2.0 - padStep + i * padStep - padH / 2.0; + g2d.fill(new Rectangle2D.Double(rectX, padY, padW, padH)); + g2d.fill(new Rectangle2D.Double(rectX + rectW - padW, padY, padW, padH)); + } + + double ledSize = Math.min(rectW - 6, 12); + MakerBoardPainter.drawAddressableLed(g2d, width / 2.0, height / 2.0, ledSize, + Color.decode("#FF3333")); + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BJewel.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BJewel.java new file mode 100644 index 000000000..93bda2438 --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BJewel.java @@ -0,0 +1,256 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Point2D; + +import org.diylc.common.ObjectCache; +import org.diylc.common.Orientation; +import org.diylc.components.AbstractAddressableLedBoard; +import org.diylc.components.MakerBoardPainter; +import org.diylc.core.ComponentState; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.Project; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.KeywordPolicy; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.diylc.utils.Constants; + +// A sibling of WS2812BRing rather than another RingSize constant: the Jewel is a solid disc +// with no inner rim, six LEDs on a circle with a seventh in the middle, pads inside that circle +// rather than out by the rim, and two mounting holes no ring has. +@ComponentDescriptor(name = "NeoPixel Jewel", category = "Displays & Outputs", + author = "Branislav Stojkovic", + description = "Addressable RGB WS2812B NeoPixel Jewel (7 LEDs)", + instanceNamePrefix = "LED", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) +public class WS2812BJewel extends AbstractAddressableLedBoard { + + private static final long serialVersionUID = 1L; + + public static Color NEO_BLACK = Color.decode("#111111"); + + public static Size OUTER_DIAMETER = new Size(23.0d, SizeUnit.mm); + // Measured: with no inner rim there is no midpoint to place the LEDs on, as the rings do. + public static Size LED_CIRCLE_DIAMETER = new Size(16.0d, SizeUnit.mm); + // Inside the LED circle, where the rings put theirs just in from the outer rim. + public static Size PAD_CIRCLE_DIAMETER = new Size(9.0d, SizeUnit.mm); + public static Size MOUNTING_HOLE_SPACING = new Size(19.0d, SizeUnit.mm); + public static Size MOUNTING_HOLE_SIZE = new Size(3.0d, SizeUnit.mm); + + /** The LEDs on the circle. The seventh sits in the middle and is not one of them. */ + public static final int RING_LED_COUNT = 6; + + private static final String[] PAD_NAMES = new String[] {"OUT", "G_1", "G_2", "PWR", "IN"}; + + // Which gap between two consecutive ring LEDs each pad sits in. OUT is anticlockwise of the + // top LED and the rest follow clockwise, so the sixth gap carries no pad: it is the one due + // left, where the left mounting hole sits. + private static final int[] PAD_GAP_INDEX = new int[] {0, 1, 2, 3, 4}; + + public WS2812BJewel() { + super(); + this.bodyColor = NEO_BLACK; + updateControlPoints(); + } + + @Override + protected String getVariantLabel() { + return getLedType().toString(); + } + + @Override + public String getControlPointNodeName(int index) { + if (index >= 0 && index < PAD_NAMES.length) { + return PAD_NAMES[index]; + } + return Integer.toString(index + 1); + } + + private double getOuterRadius() { + return OUTER_DIAMETER.convertToPixels() / 2.0; + } + + /** + * The one place the pad radius is decided: {@link #updateControlPoints()} offsets every pad from + * the first by it and {@link #getCenter()} works backwards from the first pad, so the two + * disagreeing would draw the body away from its own control points. + */ + private double getPadRadius() { + return PAD_CIRCLE_DIAMETER.convertToPixels() / 2.0; + } + + /** + * Each pad sits in a gap between two consecutive ring LEDs, the gaps {@link #PAD_GAP_INDEX} + * records. Where the sequence starts is arbitrary, since the component rotates. + */ + private double getPadAngle(int index) { + double gap = PAD_GAP_INDEX[index] + RING_LED_COUNT - 0.5; + return 2 * Math.PI * gap / RING_LED_COUNT - Math.PI / 2.0; + } + + @Override + protected void updateControlPoints() { + Point2D firstPoint = controlPoints[0]; + double padR = getPadRadius(); + double theta0 = getPadAngle(0); + + double[][] relativeOffsets = new double[PAD_NAMES.length][2]; + for (int i = 0; i < PAD_NAMES.length; i++) { + double theta = getPadAngle(i); + relativeOffsets[i][0] = padR * (Math.cos(theta) - Math.cos(theta0)); + relativeOffsets[i][1] = padR * (Math.sin(theta) - Math.sin(theta0)); + } + + rotatePoints(firstPoint, relativeOffsets); + } + + private Point2D getCenter() { + Point2D p0 = controlPoints[0]; + double padR = getPadRadius(); + double theta0 = getPadAngle(0); + // unrotated, the centre sits one pad radius back along the first pad's angle + return new Point2D.Double(p0.getX() - padR * Math.cos(theta0), + p0.getY() - padR * Math.sin(theta0)); + } + + @Override + public Shape getBodyShape() { + Point2D center = getCenter(); + double outerR = getOuterRadius(); + return new Ellipse2D.Double(center.getX() - outerR, center.getY() - outerR, outerR * 2, + outerR * 2); + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, + Project project, IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + + Point2D p0 = controlPoints[0]; + double x = p0.getX(); + double y = p0.getY(); + + AffineTransform oldTx = g2d.getTransform(); + if (orientation != Orientation.DEFAULT) { + g2d.rotate(orientation.toRadians(), x, y); + } + + Point2D center = getCenter(); + double cx = center.getX(); + double cy = center.getY(); + double outerR = getOuterRadius(); + + Shape disc = new Ellipse2D.Double(cx - outerR, cy - outerR, outerR * 2, outerR * 2); + + Composite oldComposite = applyAlpha(g2d, componentState); + + drawingObserver.startTracking(); + g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); + g2d.fill(disc); + drawingObserver.stopTracking(); + + g2d.setColor(getFinalBorderColor(componentState, outlineMode)); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); + g2d.draw(disc); + + if (!outlineMode) { + double holeOffset = MOUNTING_HOLE_SPACING.convertToPixels() / 2.0; + double holeSize = MOUNTING_HOLE_SIZE.convertToPixels(); + MakerBoardPainter.drawMountingHole(g2d, cx - holeOffset, cy, holeSize); + MakerBoardPainter.drawMountingHole(g2d, cx + holeOffset, cy, holeSize); + + double ledR = LED_CIRCLE_DIAMETER.convertToPixels() / 2.0; + double ledSize = RGB_LED_SIZE.convertToPixels(); + Color[] ledColors = getLedColors(RING_LED_COUNT + 1); + LedType ledType = getLedType(); + + for (int i = 0; i < RING_LED_COUNT; i++) { + double angle = 2 * Math.PI * i / RING_LED_COUNT - Math.PI / 2.0; + MakerBoardPainter.drawAddressableLed(g2d, cx + ledR * Math.cos(angle), + cy + ledR * Math.sin(angle), ledSize, ledColors[i % ledColors.length], + angle + Math.PI / 2.0, ledType); + } + + // the centre package has no radius to face along, so it stays square to the board + MakerBoardPainter.drawAddressableLed(g2d, cx, cy, ledSize, + ledColors[RING_LED_COUNT % ledColors.length], 0, ledType); + + // A pad sits in the gap between two LEDs, nowhere near enough room for its name. + } + + g2d.setTransform(oldTx); + + drawPcbSolderPads(g2d, 0, PAD_NAMES.length, false, outlineMode, drawingObserver); + + g2d.setComposite(oldComposite); + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + int cx = width / 2; + int cy = height / 2; + int outerR = width / 2 - 2; + + Ellipse2D disc = new Ellipse2D.Double(cx - outerR, cy - outerR, outerR * 2, outerR * 2); + g2d.setColor(NEO_BLACK); + g2d.fill(disc); + g2d.setColor(Color.DARK_GRAY); + g2d.draw(disc); + + Color[] rainbow = new Color[] { + Color.decode("#FF3333"), + Color.decode("#FFD700"), + Color.decode("#00E676"), + Color.decode("#00E5FF"), + Color.decode("#E040FB"), + Color.decode("#FF6B35") + }; + + double ledR = outerR * 0.6; + int dotR = Math.max(2, (int) (outerR * 0.22)); + + for (int i = 0; i < RING_LED_COUNT; i++) { + double angle = 2 * Math.PI * i / RING_LED_COUNT - Math.PI / 2.0; + double lx = cx + ledR * Math.cos(angle); + double ly = cy + ledR * Math.sin(angle); + g2d.setColor(rainbow[i % rainbow.length]); + g2d.fill(new Ellipse2D.Double(lx - dotR, ly - dotR, dotR * 2, dotR * 2)); + } + + // the filled centre is what separates this icon from the ring's + g2d.setColor(Color.WHITE); + g2d.fill(new Ellipse2D.Double(cx - dotR, cy - dotR, dotR * 2, dotR * 2)); + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BPanel.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BPanel.java new file mode 100644 index 000000000..c4d1625ee --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BPanel.java @@ -0,0 +1,326 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.Composite; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Point2D; +import java.awt.geom.RoundRectangle2D; + +import org.diylc.awt.StringUtils; +import org.diylc.common.HorizontalAlignment; +import org.diylc.common.ObjectCache; +import org.diylc.common.Orientation; +import org.diylc.common.VerticalAlignment; +import org.diylc.components.AbstractAddressableLedBoard; +import org.diylc.components.MakerBoardPainter; +import org.diylc.core.ComponentState; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.Project; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.KeywordPolicy; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.diylc.utils.Constants; + +// Figures are read off Adafruit's published EagleCAD board file rather than a product listing, +// which gives only the outline. +// +// Three relationships hold on this board and are recorded as the measurements they are rather +// than derived, because a panel from another maker need not share them: the board is exactly +// eight pitches across, the pixel grid is centred leaving half a pitch outside the outer +// pixels, and the port row lands in the gap between the first and second rows of pixels. +@ComponentDescriptor(name = "NeoPixel Panel", category = "Displays & Outputs", + author = "Branislav Stojkovic", + description = "Addressable RGB WS2812B NeoPixel Matrix Panel (8x8, 64 LEDs)", + instanceNamePrefix = "LED", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) +public class WS2812BPanel extends AbstractAddressableLedBoard { + + private static final long serialVersionUID = 1L; + + public static Color NEO_BLACK = Color.decode("#111111"); + + public static Size BOARD_SIZE = new Size(71.12d, SizeUnit.mm); + public static Size PIXEL_PITCH = new Size(8.89d, SizeUnit.mm); + + /** How far the outer pad of a port sits in from the side edge its port stands against. */ + public static Size PAD_EDGE_INSET = new Size(1.905d, SizeUnit.mm); + + /** How far a port row sits in from the end of the board it runs beside. */ + public static Size PAD_ROW_INSET = new Size(8.89d, SizeUnit.mm); + + public static Size MOUNTING_HOLE_SIZE = new Size(2.8d, SizeUnit.mm); + + public static Size CORNER_RADIUS = new Size(1.0d, SizeUnit.mm); + + /** Pixels along one edge of the square matrix. */ + public static final int MATRIX_ORDER = 8; + + /** + * Hole centres from the middle of the board. The pattern is symmetric about both axes, so which + * way the vertical axis points does not change the drawing. + */ + private static final double[][] MOUNTING_HOLES = new double[][] { + {-33.02d, 17.78d}, {-33.02d, 0d}, {-33.02d, -17.78d}, + {33.02d, 17.78d}, {33.02d, 0d}, {33.02d, -17.78d}, + {-8.89d, 26.67d}, {8.89d, 26.67d}, + {-8.89d, 0d}, {8.89d, 0d}, + {-8.89d, -26.67d}, {8.89d, -26.67d}}; + + /** + * Input first, then output. Both ports carry the same two rails, numbered to keep the node names + * distinct; the board prints them +5V and GND, on its back rather than this face. + */ + public static final String[] PAD_NAMES = + new String[] {"DIN", "+5V_1", "GND_1", "DOUT", "+5V_2", "GND_2"}; + + /** + * Eagle specifies text by cap height and a Java font by em size, about a third larger again for + * this family. Converting is what makes the drawn strings reach as far across the board as the + * real artwork; set at the cap height they come out well short. + */ + private static final double CAP_HEIGHT_RATIO = 0.72d; + + /** + * What the face prints, from layer 21 of the board file. Every line lies in a gap between two + * rows of pixels, which is the only bare board this part has. The port names are on layer 22, + * its back. + */ + static final Silk[] SILKSCREEN = new Silk[] { + new Silk(-19.685d, -17.653d, "Adafruit NeoPixel 8X8", silkFont(2.286d)), + new Silk(-29.972d, 0.0635d, "64 RGB LEDs", silkFont(1.778d)), + new Silk(-6.858d, -0.1397d, "24 bit Color", silkFont(1.4224d)), + new Silk(12.446d, -0.1905d, "Only ONE Pin", silkFont(1.778d)), + new Silk(-25.4d, 17.8435d, "bLiNkY bLiNkY bLiNkY bLiNkY bLiNkY", silkFont(1.778d))}; + + public WS2812BPanel() { + super(); + this.bodyColor = NEO_BLACK; + updateControlPoints(); + } + + /** The panel comes in one size, so the package rather than the grid keeps the BOM rows apart. */ + @Override + protected String getVariantLabel() { + return "8x8, 64 LEDs, " + getLedType(); + } + + @Override + public String getControlPointNodeName(int index) { + if (index >= 0 && index < PAD_NAMES.length) { + return PAD_NAMES[index]; + } + return Integer.toString(index + 1); + } + + private static double px(double millimetres) { + return new Size(millimetres, SizeUnit.mm).convertToPixels(); + } + + private static Font silkFont(double capHeightMm) { + return new Font(SILK_FONT_FAMILY, Font.PLAIN, + (int) Math.round(px(capHeightMm) / CAP_HEIGHT_RATIO)); + } + + /** Left edge of the board; the input port's first pad sits one inset in from it. */ + private double getBoardX(double x) { + return x - PAD_EDGE_INSET.convertToPixels(); + } + + /** Top edge of the board; the input port's row sits its own inset down from it. */ + private double getBoardY(double y) { + return y - PAD_ROW_INSET.convertToPixels(); + } + + /** + * The two ports are each other turned about the centre of the board, which is what lets panels + * be chained: the input runs inward from the left edge near the top, the output inward from the + * right edge near the bottom. + */ + @Override + protected void updateControlPoints() { + Point2D firstPoint = controlPoints[0]; + double spacing = PIN_SPACING.convertToPixels(); + double board = BOARD_SIZE.convertToPixels(); + double acrossBoard = board - 2 * PAD_EDGE_INSET.convertToPixels(); + double downBoard = board - 2 * PAD_ROW_INSET.convertToPixels(); + + double[][] relativeOffsets = new double[PAD_NAMES.length][2]; + for (int i = 0; i < 3; i++) { + relativeOffsets[i][0] = i * spacing; + relativeOffsets[i][1] = 0; + relativeOffsets[3 + i][0] = acrossBoard - i * spacing; + relativeOffsets[3 + i][1] = downBoard; + } + + rotatePoints(firstPoint, relativeOffsets); + } + + @Override + public Shape getBodyShape() { + Point2D p0 = controlPoints[0]; + double board = BOARD_SIZE.convertToPixels(); + double radius = CORNER_RADIUS.convertToPixels(); + return new RoundRectangle2D.Double(getBoardX(p0.getX()), getBoardY(p0.getY()), board, board, + radius, radius); + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, + Project project, IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + + Point2D p0 = controlPoints[0]; + double x = p0.getX(); + double y = p0.getY(); + + AffineTransform oldTx = g2d.getTransform(); + if (orientation != Orientation.DEFAULT) { + g2d.rotate(orientation.toRadians(), x, y); + } + + double board = BOARD_SIZE.convertToPixels(); + double boardX = getBoardX(x); + double boardY = getBoardY(y); + + Shape boardShape = getBodyShape(); + + Composite oldComposite = applyAlpha(g2d, componentState); + + drawingObserver.startTracking(); + g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); + g2d.fill(boardShape); + drawingObserver.stopTracking(); + + g2d.setColor(getFinalBorderColor(componentState, outlineMode)); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); + g2d.draw(boardShape); + + if (!outlineMode) { + double centreX = boardX + board / 2.0; + double centreY = boardY + board / 2.0; + double holeSize = MOUNTING_HOLE_SIZE.convertToPixels(); + for (double[] hole : MOUNTING_HOLES) { + MakerBoardPainter.drawMountingHole(g2d, centreX + px(hole[0]), centreY + px(hole[1]), + holeSize); + } + + // printed before the packages, so a glyph straying under one is covered as it is on the board + g2d.setColor(SILK_COLOR); + for (Silk silk : SILKSCREEN) { + g2d.setFont(silk.getFont()); + StringUtils.drawCenteredText(g2d, silk.getText(), centreX + px(silk.getXMm()), + centreY + px(silk.getYMm()), HorizontalAlignment.LEFT, VerticalAlignment.CENTER); + } + + double pitch = PIXEL_PITCH.convertToPixels(); + double margin = (board - (MATRIX_ORDER - 1) * pitch) / 2.0; + double ledSize = RGB_LED_SIZE.convertToPixels(); + + // The sweep follows the data chain, so it shows the addressing order: a progressive raster, + // left to right along each row and back, not the serpentine most cheap panels use. + int ledCount = MATRIX_ORDER * MATRIX_ORDER; + Color[] ledColors = getLedColors(ledCount); + LedType ledType = getLedType(); + for (int i = 0; i < ledCount; i++) { + MakerBoardPainter.drawAddressableLed(g2d, boardX + margin + (i % MATRIX_ORDER) * pitch, + boardY + margin + (i / MATRIX_ORDER) * pitch, ledSize, ledColors[i], 0, ledType); + } + + // The port names are printed on the back of the real board, so this face carries none. + } + + g2d.setTransform(oldTx); + + drawPcbSolderPads(g2d, 0, PAD_NAMES.length, false, outlineMode, drawingObserver); + + g2d.setComposite(oldComposite); + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + double boardSize = Math.min(width, height) - 4; + double boardX = (width - boardSize) / 2.0; + double boardY = (height - boardSize) / 2.0; + + g2d.setColor(NEO_BLACK); + g2d.fill(new RoundRectangle2D.Double(boardX, boardY, boardSize, boardSize, 2, 2)); + g2d.setColor(Color.DARK_GRAY); + g2d.draw(new RoundRectangle2D.Double(boardX, boardY, boardSize, boardSize, 2, 2)); + + // four by four rather than eight by eight: at toolbox size eight rows fill in to a block + Color[] rainbow = new Color[] { + Color.decode("#FF3333"), + Color.decode("#FFD700"), + Color.decode("#00E676"), + Color.decode("#00E5FF"), + Color.decode("#E040FB"), + Color.decode("#FF6B35") + }; + + int order = 4; + double pitch = boardSize / order; + double dotSize = Math.max(2.0, pitch * 0.55); + + for (int i = 0; i < order * order; i++) { + double dotX = boardX + (i % order + 0.5) * pitch - dotSize / 2.0; + double dotY = boardY + (i / order + 0.5) * pitch - dotSize / 2.0; + g2d.setColor(rainbow[i % rainbow.length]); + g2d.fill(new Ellipse2D.Double(dotX, dotY, dotSize, dotSize)); + } + } + + /** + * One line of silkscreen, positioned by its left edge and vertical centre from the middle of the + * board, with y running down the drawing rather than up as Eagle records it. + */ + static class Silk { + + private final double xMm; + private final double yMm; + private final String text; + private final Font font; + + Silk(double xMm, double yMm, String text, Font font) { + this.xMm = xMm; + this.yMm = yMm; + this.text = text; + this.font = font; + } + + public double getXMm() { return xMm; } + public double getYMm() { return yMm; } + public String getText() { return text; } + public Font getFont() { return font; } + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BRing.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BRing.java index c360abc2d..5efd2df82 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BRing.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BRing.java @@ -23,22 +23,20 @@ package org.diylc.components.displays; import java.awt.Color; import java.awt.Composite; -import java.awt.Font; import java.awt.Graphics2D; import java.awt.Shape; import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.Ellipse2D; import java.awt.geom.Point2D; -import java.awt.geom.Rectangle2D; -import java.awt.geom.RoundRectangle2D; import org.diylc.awt.StringUtils; import org.diylc.common.HorizontalAlignment; import org.diylc.common.ObjectCache; import org.diylc.common.Orientation; import org.diylc.common.VerticalAlignment; -import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.AbstractAddressableLedBoard; +import org.diylc.components.MakerBoardPainter; import org.diylc.core.ComponentState; import org.diylc.core.IDIYComponent; import org.diylc.core.IDrawingObserver; @@ -51,49 +49,22 @@ import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; import org.diylc.utils.Constants; -// @ComponentDescriptor(name = "NeoPixel Ring (WS2812B)", category = "Displays & Outputs", -// author = "Branislav Stojkovic", description = "Addressable RGB WS2812B NeoPixel Ring (12/16/24 LEDs)", -// instanceNamePrefix = "LED", zOrder = IDIYComponent.COMPONENT, -// bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, -// enableCache = true) -public class WS2812BRing extends AbstractMakerBoard { +@ComponentDescriptor(name = "NeoPixel Ring", category = "Displays & Outputs", + author = "Branislav Stojkovic", description = "Addressable RGB WS2812B NeoPixel Ring (12/16/24 LEDs)", + instanceNamePrefix = "LED", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) +public class WS2812BRing extends AbstractAddressableLedBoard { private static final long serialVersionUID = 1L; - public enum RingSize { - _12_LED("12 LEDs (37mm OD)", 12, 37.0, 27.0), - _16_LED("16 LEDs (44.5mm OD)", 16, 44.5, 32.0), - _24_LED("24 LEDs (66mm OD)", 24, 66.0, 52.0); - - private final String label; - private final int ledCount; - private final double outerDiameterMm; - private final double innerDiameterMm; - - RingSize(String label, int ledCount, double outerDiameterMm, double innerDiameterMm) { - this.label = label; - this.ledCount = ledCount; - this.outerDiameterMm = outerDiameterMm; - this.innerDiameterMm = innerDiameterMm; - } - - @Override public String toString() { return label; } - public int getLedCount() { return ledCount; } - public double getOuterDiameterMm() { return outerDiameterMm; } - public double getInnerDiameterMm() { return innerDiameterMm; } - } - - // Colors shared with WS2812BStick public static Color NEO_BLACK = Color.decode("#111111"); - public static Color LED_PACKAGE = Color.decode("#FDFEFE"); - public static Color LED_BORDER = Color.decode("#D0D3D4"); - public static Color LED_DIFFUSER = Color.decode("#EAECEE"); - public static Color DIFFUSER_BORDER = Color.decode("#BDC3C7"); - public static Color CHIP_DOT_COLOR = Color.decode("#333333"); - public static Color SOLDER_PAD_COLOR = Color.decode("#D4AC0D"); - public static Color SOLDER_PAD_BORDER = Color.decode("#9A7D0A"); - private static final String[] PIN_NAMES = {"DIN", "+5V", "GND", "DOUT"}; + // The inherited solder pad footprint is sized for a breakout board rather than a ring. + public static Size PAD_EDGE_INSET = new Size(1.4d, SizeUnit.mm); + + // Measured from the pad's edge, so the gap stays the same if the pad footprint changes. + public static Size PAD_LABEL_GAP = new Size(0.4d, SizeUnit.mm); private RingSize ringSize = RingSize._16_LED; @@ -114,39 +85,63 @@ public class WS2812BRing extends AbstractMakerBoard { invalidateCache(); } + // Every ring is sold in each package, so neither half identifies what to buy on its own. + @Override + protected String getVariantLabel() { + RingSize ringSize = getRingSize(); + return ringSize == null ? null : ringSize + ", " + getLedType(); + } + @Override public String getControlPointNodeName(int index) { - if (index >= 0 && index < PIN_NAMES.length) { - return PIN_NAMES[index]; + String[] padNames = ringSize.getPadNames(); + if (index >= 0 && index < padNames.length) { + return padNames[index]; } return Integer.toString(index + 1); } + /** The circle the LED centres sit on: the midpoint between the inner and outer rims. */ private double getMidRadius() { double outerR = new Size(ringSize.getOuterDiameterMm() / 2.0, SizeUnit.mm).convertToPixels(); double innerR = new Size(ringSize.getInnerDiameterMm() / 2.0, SizeUnit.mm).convertToPixels(); return (outerR + innerR) / 2.0; } + /** + * Pads sit out towards the rim rather than on the LED circle. This has to be the one place the + * radius is decided: {@link #updateControlPoints()} offsets every pad from the first by it and + * {@link #getCenter()} works backwards from the first pad, so the two disagreeing would draw the + * body away from its own control points. + */ + private double getPadRadius() { + double outerR = new Size(ringSize.getOuterDiameterMm() / 2.0, SizeUnit.mm).convertToPixels(); + return outerR - PAD_EDGE_INSET.convertToPixels(); + } + + /** + * Each pad sits in a gap between two consecutive LEDs, the gaps {@link RingSize} records. The + * sequence is anchored at the gap just past the bottom of the ring and runs clockwise; where it + * starts is arbitrary, since the component rotates. + */ private double getPadAngle(int index) { - double spacing = PIN_SPACING.convertToPixels(); - double midR = getMidRadius(); - double deltaTheta = spacing / midR; - // 4 pads placed symmetrically at the bottom of the circular ring (near angle PI / 2) - return Math.PI / 2.0 + (index - 1.5) * deltaTheta; + int ledCount = ringSize.getLedCount(); + double gap = ringSize.getPadGapIndex(index) + ledCount / 2.0 + 0.5; + return 2 * Math.PI * gap / ledCount - Math.PI / 2.0; } @Override protected void updateControlPoints() { Point2D firstPoint = controlPoints[0]; - double midR = getMidRadius(); + double padR = getPadRadius(); double theta0 = getPadAngle(0); - double[][] relativeOffsets = new double[4][2]; - for (int i = 0; i < 4; i++) { + int padCount = ringSize.getPadNames().length; + double[][] relativeOffsets = new double[padCount][2]; + for (int i = 0; i < padCount; i++) { double theta = getPadAngle(i); - relativeOffsets[i][0] = midR * (Math.cos(theta) - Math.cos(theta0)); - relativeOffsets[i][1] = midR * (Math.sin(theta) - Math.sin(theta0)); + relativeOffsets[i][0] = padR * (Math.cos(theta) - Math.cos(theta0)); + relativeOffsets[i][1] = padR * (Math.sin(theta) - Math.sin(theta0)); } rotatePoints(firstPoint, relativeOffsets); @@ -154,10 +149,10 @@ public class WS2812BRing extends AbstractMakerBoard { private Point2D getCenter() { Point2D p0 = controlPoints[0]; - double midR = getMidRadius(); + double padR = getPadRadius(); double theta0 = getPadAngle(0); - // When unrotated, center is at p0 - (midR*cos(theta0), midR*sin(theta0)) - return new Point2D.Double(p0.getX() - midR * Math.cos(theta0), p0.getY() - midR * Math.sin(theta0)); + // unrotated, the centre sits one pad radius back along the first pad's angle + return new Point2D.Double(p0.getX() - padR * Math.cos(theta0), p0.getY() - padR * Math.sin(theta0)); } @Override @@ -189,9 +184,7 @@ public class WS2812BRing extends AbstractMakerBoard { double outerR = new Size(ringSize.getOuterDiameterMm() / 2.0, SizeUnit.mm).convertToPixels(); double innerR = new Size(ringSize.getInnerDiameterMm() / 2.0, SizeUnit.mm).convertToPixels(); - double midR = (outerR + innerR) / 2.0; - // Annular ring shape: outer circle minus inner circle Area outerArea = new Area(new Ellipse2D.Double(cx - outerR, cy - outerR, outerR * 2, outerR * 2)); Area innerArea = new Area(new Ellipse2D.Double(cx - innerR, cy - innerR, innerR * 2, innerR * 2)); outerArea.subtract(innerArea); @@ -209,58 +202,49 @@ public class WS2812BRing extends AbstractMakerBoard { if (!outlineMode) { int ledCount = ringSize.getLedCount(); - double ledSize = Math.max(8.0, Math.min(15.0, (2 * Math.PI * midR / ledCount) * 0.60)); - double halfLed = ledSize / 2.0; + double ledR = getMidRadius(); + double ledSize = RGB_LED_SIZE.convertToPixels(); + Color[] ledColors = getLedColors(ledCount); + LedType ledType = getLedType(); - // Draw all LEDs around the circular ring + // each package is turned to face out along its own radius, the way it is mounted for (int i = 0; i < ledCount; i++) { double angle = 2 * Math.PI * i / ledCount - Math.PI / 2.0; - double lx = cx + midR * Math.cos(angle); - double ly = cy + midR * Math.sin(angle); - - // 5050 White Package - g2d.setColor(LED_PACKAGE); - g2d.fill(new RoundRectangle2D.Double(lx - halfLed, ly - halfLed, ledSize, ledSize, 2, 2)); - g2d.setColor(LED_BORDER); - g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(0.5f)); - g2d.draw(new RoundRectangle2D.Double(lx - halfLed, ly - halfLed, ledSize, ledSize, 2, 2)); - - // Milky phosphor lens - double diffR = halfLed * 0.7; - g2d.setColor(LED_DIFFUSER); - g2d.fill(new Ellipse2D.Double(lx - diffR, ly - diffR, diffR * 2, diffR * 2)); - g2d.setColor(DIFFUSER_BORDER); - g2d.draw(new Ellipse2D.Double(lx - diffR, ly - diffR, diffR * 2, diffR * 2)); - - // Tiny IC dot - g2d.setColor(CHIP_DOT_COLOR); - double dotS = Math.max(2.0, diffR * 0.4); - g2d.fill(new Rectangle2D.Double(lx - dotS / 2.0, ly - dotS / 2.0, dotS, dotS)); + MakerBoardPainter.drawAddressableLed(g2d, cx + ledR * Math.cos(angle), + cy + ledR * Math.sin(angle), ledSize, ledColors[i % ledColors.length], + angle + Math.PI / 2.0, ledType); } - // Center silkscreen text - g2d.setColor(Color.decode("#666666")); - g2d.setFont(SILK_FONT_SMALL); - StringUtils.drawCenteredText(g2d, "NeoPixel", cx, cy - 6, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - StringUtils.drawCenteredText(g2d, ringSize.getLedCount() + "x LED", cx, cy + 6, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); + // Names run out along their own radius, turned with the pad. A pad sits in the gap between + // two consecutive LEDs, and that is the only room a name has: square to the board it would + // lie across the LEDs either side. + g2d.setColor(SILK_COLOR); + g2d.setFont(PIN_FONT); + double labelR = + getPadRadius() - PAD_SIZE.convertToPixels() / 2.0 - PAD_LABEL_GAP.convertToPixels(); + String[] padNames = ringSize.getPadNames(); + for (int i = 0; i < padNames.length; i++) { + String label = getSilkPinLabel(i); + if (label == null || label.isEmpty()) { + continue; + } + double angle = getPadAngle(i); + double textX = cx + labelR * Math.cos(angle); + double textY = cy + labelR * Math.sin(angle); - // Silkscreen labels next to the 4 solder pads on the ring - g2d.setColor(Color.WHITE); - g2d.setFont(new Font("SansSerif", Font.BOLD, 8)); - String[] labels = new String[] {"DIN", "5V", "GND", "DOUT"}; - for (int i = 0; i < 4; i++) { - double theta = getPadAngle(i); - double labelR = innerR + 5; - double px = cx + labelR * Math.cos(theta); - double py = cy + labelR * Math.sin(theta); - StringUtils.drawCenteredText(g2d, labels[i], px, py, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); + // the turn maps the text advance direction onto the outward radius, so RIGHT alignment ends + // the name at the pad + AffineTransform oldLabelTx = g2d.getTransform(); + g2d.rotate(angle, textX, textY); + StringUtils.drawCenteredText(g2d, label, textX, textY, HorizontalAlignment.RIGHT, + VerticalAlignment.CENTER); + g2d.setTransform(oldLabelTx); } } g2d.setTransform(oldTx); - // Draw the 4 solder pads directly on the circular ring PCB - drawSolderPads(g2d, 0, 4, outlineMode, drawingObserver); + drawPcbSolderPads(g2d, 0, ringSize.getPadNames().length, false, outlineMode, drawingObserver); g2d.setComposite(oldComposite); } @@ -301,4 +285,58 @@ public class WS2812BRing extends AbstractMakerBoard { g2d.fill(new Ellipse2D.Double(lx - dotR, ly - dotR, dotR * 2, dotR * 2)); } } + + public enum RingSize { + // Dimensioned in inches, so both diameters are exact conversions rather than roundings. LEDs + // sit on the midpoint between the rims, which misses the measured LED circle by 0.64 mm on the + // 12-LED ring and 0.51 mm on the 24 rather than carrying a third diameter per ring. + // + // Each ring lists its pads in order around the circle with the number of LEDs following each + // one. The final figure wraps back to the first pad, so the gaps must sum to the LED count; + // the constructor checks that. + _12_LED("12 LEDs", 12, 36.83, 23.368, + new String[] {"OUT", "IN", "GND", "PWR"}, + new int[] {2, 4, 2, 4}), + _16_LED("16 LEDs", 16, 44.45, 31.75, + new String[] {"IN", "OUT", "G_1", "G_2", "V+_1", "V+_2"}, + new int[] {2, 5, 1, 4, 1, 3}), + _24_LED("24 LEDs", 24, 65.532, 52.324, + new String[] {"OUT", "IN", "G_1", "G_2", "PWR_1", "PWR_2"}, + new int[] {2, 8, 2, 2, 2, 8}); + + private final String label; + private final int ledCount; + private final double outerDiameterMm; + private final double innerDiameterMm; + private final String[] padNames; + private final int[] padGapIndex; + + RingSize(String label, int ledCount, double outerDiameterMm, double innerDiameterMm, + String[] padNames, int[] ledsAfterPad) { + this.label = label; + this.ledCount = ledCount; + this.outerDiameterMm = outerDiameterMm; + this.innerDiameterMm = innerDiameterMm; + this.padNames = padNames; + this.padGapIndex = new int[padNames.length]; + int gap = 0; + for (int i = 0; i < padNames.length; i++) { + padGapIndex[i] = gap; + gap += ledsAfterPad[i]; + } + if (gap != ledCount) { + throw new IllegalArgumentException( + label + ": pad gaps sum to " + gap + " but the ring carries " + ledCount + " LEDs"); + } + } + + @Override public String toString() { return label; } + public int getLedCount() { return ledCount; } + public double getOuterDiameterMm() { return outerDiameterMm; } + public double getInnerDiameterMm() { return innerDiameterMm; } + public String[] getPadNames() { return padNames; } + + /** Which gap between LEDs the pad at {@code index} sits in, counted from the first pad. */ + public int getPadGapIndex(int index) { return padGapIndex[index]; } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BStick.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BStick.java index 2c0d4f25f..c2a8711ba 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BStick.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BStick.java @@ -23,7 +23,6 @@ package org.diylc.components.displays; import java.awt.Color; import java.awt.Composite; -import java.awt.Font; import java.awt.Graphics2D; import java.awt.Shape; import java.awt.geom.AffineTransform; @@ -32,12 +31,10 @@ import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.awt.geom.RoundRectangle2D; -import org.diylc.awt.StringUtils; -import org.diylc.common.HorizontalAlignment; import org.diylc.common.ObjectCache; import org.diylc.common.Orientation; -import org.diylc.common.VerticalAlignment; -import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.AbstractAddressableLedBoard; +import org.diylc.components.MakerBoardPainter; import org.diylc.core.ComponentState; import org.diylc.core.IDIYComponent; import org.diylc.core.IDrawingObserver; @@ -49,30 +46,40 @@ import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; import org.diylc.utils.Constants; -// @ComponentDescriptor(name = "NeoPixel Stick (8x WS2812B)", category = "Displays & Outputs", -// author = "Branislav Stojkovic", description = "8-LED Addressable RGB WS2812B NeoPixel Stick", -// instanceNamePrefix = "LED", zOrder = IDIYComponent.COMPONENT, -// bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, -// enableCache = true) -public class WS2812BStick extends AbstractMakerBoard { +@ComponentDescriptor(name = "NeoPixel Stick", category = "Displays & Outputs", + author = "Branislav Stojkovic", description = "8-LED Addressable RGB WS2812B NeoPixel Stick", + instanceNamePrefix = "LED", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) +public class WS2812BStick extends AbstractAddressableLedBoard { private static final long serialVersionUID = 1L; public static Color NEO_BLACK = Color.decode("#111111"); - public static Color LED_PACKAGE = Color.decode("#FDFEFE"); - public static Color LED_BORDER = Color.decode("#D0D3D4"); - public static Color LED_DIFFUSER = Color.decode("#EAECEE"); - public static Color DIFFUSER_BORDER = Color.decode("#BDC3C7"); - public static Color CHIP_DOT_COLOR = Color.decode("#333333"); - public static Font PIN_LABEL_FONT = new Font("SansSerif", Font.BOLD, 9); + public static Size BOARD_WIDTH = new Size(51.1d, SizeUnit.mm); + public static Size BOARD_HEIGHT = new Size(10.22d, SizeUnit.mm); - public static Size BOARD_WIDTH = new Size(53.0d, SizeUnit.mm); - public static Size BOARD_HEIGHT = new Size(10.2d, SizeUnit.mm); + // Surface pads flush with the cut end, so how far in their centres sit is half their width. + // The width is the reach inward from the edge; the length has to fit the 0.1 inch pad pitch. + public static Size PAD_WIDTH = new Size(3.0d, SizeUnit.mm); + public static Size PAD_LENGTH = new Size(1.5d, SizeUnit.mm); + // Air between a pad and the nearest package. Spreading the row to the pads' inner edge instead + // leaves the two touching, which draws as copper against plastic with no board between. + public static Size PAD_CLEARANCE = new Size(0.5d, SizeUnit.mm); + + public static Size MOUNTING_HOLE_SIZE = new Size(2.0d, SizeUnit.mm); + public static Size MOUNTING_HOLE_SPACING = new Size(1.0d, SizeUnit.in); + public static Size MOUNTING_HOLE_TOP_OFFSET = new Size(2.0d, SizeUnit.mm); + + public static final int LED_COUNT = 8; + + // Both grounds on an end are the same net, numbered only to keep the node names distinct; the + // silkscreen prints "GND" for all four. private static final String[] PIN_NAMES = { - "DIN", "+5V (In)", "GND (In)", "GND (In)", - "DOUT", "+5V (Out)", "GND (Out)", "GND (Out)" + "GND_1", "DIN", "+5V_1", "GND_2", + "GND_3", "DOUT", "+5V_2", "GND_4" }; public WS2812BStick() { @@ -81,6 +88,11 @@ public class WS2812BStick extends AbstractMakerBoard { updateControlPoints(); } + @Override + protected String getVariantLabel() { + return getLedType().toString(); + } + @Override public String getControlPointNodeName(int index) { if (index >= 0 && index < PIN_NAMES.length) { @@ -93,33 +105,66 @@ public class WS2812BStick extends AbstractMakerBoard { protected void updateControlPoints() { Point2D firstPoint = controlPoints[0]; double spacing = PIN_SPACING.convertToPixels(); - double w = BOARD_WIDTH.convertToPixels(); + // the columns are flush against their own ends, so what separates them is the board less one + // whole pad + double columnSpacing = BOARD_WIDTH.convertToPixels() - PAD_WIDTH.convertToPixels(); - // 8 pins total: - // Left input header (4 pins): 0 = DIN, 1 = +5V, 2 = GND, 3 = GND - // Right output header (4 pins): 4 = DOUT, 5 = +5V, 6 = GND, 7 = GND - double[][] relativeOffsets = new double[][] { - { 0, 0 }, - { 0, spacing }, - { 0, spacing * 2 }, - { 0, spacing * 3 }, - { w - 20, 0 }, - { w - 20, spacing }, - { w - 20, spacing * 2 }, - { w - 20, spacing * 3 } - }; + double[][] relativeOffsets = new double[PIN_NAMES.length][2]; + for (int i = 0; i < 4; i++) { + relativeOffsets[i][0] = 0; + relativeOffsets[i][1] = i * spacing; + relativeOffsets[4 + i][0] = columnSpacing; + relativeOffsets[4 + i][1] = i * spacing; + } rotatePoints(firstPoint, relativeOffsets); } + /** The first pad column is flush with this edge, so its centres sit half a pad inboard. */ + private double getBoardX(double x) { + return x - PAD_WIDTH.convertToPixels() / 2.0; + } + + /** Derived so that the four-pad column is centred across the width. */ + private double getBoardY(double y) { + double padSpan = 3 * PIN_SPACING.convertToPixels(); + return y - (BOARD_HEIGHT.convertToPixels() - padSpan) / 2.0; + } + + /** + * The row is spread evenly between the inner edges of the two pad columns rather than packed + * edge to edge, and sits low on the board: the mounting holes take the top, so the packages are + * centred in what is left below them. + */ + Point2D getLedCentre(int index) { + Point2D p0 = controlPoints[0]; + double boardX = getBoardX(p0.getX()); + double boardY = getBoardY(p0.getY()); + double ledSize = RGB_LED_SIZE.convertToPixels(); + double inset = PAD_WIDTH.convertToPixels() + PAD_CLEARANCE.convertToPixels(); + double fieldStart = boardX + inset; + double fieldEnd = boardX + BOARD_WIDTH.convertToPixels() - inset; + double pitch = (fieldEnd - fieldStart - ledSize) / (LED_COUNT - 1); + + return new Point2D.Double(fieldStart + ledSize / 2.0 + index * pitch, + (getHoleCentre(false).getY() + MOUNTING_HOLE_SIZE.convertToPixels() / 2.0 + boardY + + BOARD_HEIGHT.convertToPixels()) / 2.0); + } + + /** The two holes sit a fixed span apart astride the board's centre. */ + Point2D getHoleCentre(boolean right) { + Point2D p0 = controlPoints[0]; + double offset = MOUNTING_HOLE_SPACING.convertToPixels() / 2.0; + return new Point2D.Double( + getBoardX(p0.getX()) + BOARD_WIDTH.convertToPixels() / 2.0 + (right ? offset : -offset), + getBoardY(p0.getY()) + MOUNTING_HOLE_TOP_OFFSET.convertToPixels()); + } + @Override public Shape getBodyShape() { Point2D p0 = controlPoints[0]; - double x = p0.getX(); - double y = p0.getY(); - double boardW = BOARD_WIDTH.convertToPixels(); - double boardH = BOARD_HEIGHT.convertToPixels(); - return new RoundRectangle2D.Double(x - 10, y - 10, boardW, boardH, 4, 4); + return new RoundRectangle2D.Double(getBoardX(p0.getX()), getBoardY(p0.getY()), + BOARD_WIDTH.convertToPixels(), BOARD_HEIGHT.convertToPixels(), 4, 4); } @Override @@ -138,11 +183,6 @@ public class WS2812BStick extends AbstractMakerBoard { g2d.rotate(orientation.toRadians(), x, y); } - double boardW = BOARD_WIDTH.convertToPixels(); - double boardH = BOARD_HEIGHT.convertToPixels(); - double boardX = x - 10; - double boardY = y - 10; - Shape boardShape = getBodyShape(); Composite oldComposite = applyAlpha(g2d, componentState); @@ -157,56 +197,30 @@ public class WS2812BStick extends AbstractMakerBoard { g2d.draw(boardShape); if (!outlineMode) { - // 8x 5050 SMD WS2812B RGB LEDs (centered between left and right pin areas) - double spacing = PIN_SPACING.convertToPixels(); - double ledAreaStartX = boardX + 68; - double ledAreaEndX = boardX + boardW - 68; - double ledPitch = (ledAreaEndX - ledAreaStartX) / 7.0; - - for (int i = 0; i < 8; i++) { - double lx = ledAreaStartX + i * ledPitch; - double ly = boardY + boardH / 2.0; - - // 5050 White Package (28x28) - g2d.setColor(LED_PACKAGE); - g2d.fill(new RoundRectangle2D.Double(lx - 14, ly - 14, 28, 28, 3, 3)); - g2d.setColor(LED_BORDER); - g2d.draw(new RoundRectangle2D.Double(lx - 14, ly - 14, 28, 28, 3, 3)); - - // Circular milky phosphor/lens - g2d.setColor(LED_DIFFUSER); - g2d.fill(new Ellipse2D.Double(lx - 10, ly - 10, 20, 20)); - g2d.setColor(DIFFUSER_BORDER); - g2d.draw(new Ellipse2D.Double(lx - 10, ly - 10, 20, 20)); - - // Internal tiny IC dot - g2d.setColor(CHIP_DOT_COLOR); - g2d.fill(new Rectangle2D.Double(lx - 2.5, ly - 2.5, 5, 5)); + double holeSize = MOUNTING_HOLE_SIZE.convertToPixels(); + for (boolean right : new boolean[] {false, true}) { + Point2D hole = getHoleCentre(right); + MakerBoardPainter.drawMountingHole(g2d, hole.getX(), hole.getY(), holeSize); } - // Silk Screen Pin Labels — all 4 pins labeled on both sides with clean spacing - g2d.setColor(Color.WHITE); - g2d.setFont(PIN_LABEL_FONT); - - // Left Input Header Labels (DIN, 5V, GND, GND) - String[] inLabels = {"DIN", "5V", "GND", "GND"}; - for (int i = 0; i < 4; i++) { - StringUtils.drawCenteredText(g2d, inLabels[i], boardX + 23, y + i * spacing, - HorizontalAlignment.LEFT, VerticalAlignment.CENTER); - } - - // Right Output Header Labels (DOUT, 5V, GND, GND) - String[] outLabels = {"DOUT", "5V", "GND", "GND"}; - for (int i = 0; i < 4; i++) { - StringUtils.drawCenteredText(g2d, outLabels[i], boardX + boardW - 23, y + i * spacing, - HorizontalAlignment.RIGHT, VerticalAlignment.CENTER); + // The packages go down before the pads, so the row starts at the pads' inner edge rather than + // under them. The pad names are printed on the back of the real board. + double ledSize = RGB_LED_SIZE.convertToPixels(); + Color[] ledColors = getLedColors(LED_COUNT); + LedType ledType = getLedType(); + for (int i = 0; i < LED_COUNT; i++) { + Point2D led = getLedCentre(i); + MakerBoardPainter.drawAddressableLed(g2d, led.getX(), led.getY(), ledSize, ledColors[i], 0, + ledType); } } g2d.setTransform(oldTx); - // Draw input and output header pins - drawPinHeader(g2d, 0, controlPoints.length, outlineMode, drawingObserver); + // The stick ships bare and is wired by soldering, so it carries surface pads at the cut ends + // rather than the plated holes the ring and the jewel have. + drawSurfacePads(g2d, 0, controlPoints.length, PAD_WIDTH, PAD_LENGTH, outlineMode, + drawingObserver); g2d.setComposite(oldComposite); } @@ -239,11 +253,9 @@ public class WS2812BStick extends AbstractMakerBoard { double dotX = rectX + margin + i * step; double dotY = height / 2.0 - dotSize / 2.0; - // 5050 package mini white backing g2d.setColor(Color.WHITE); g2d.fill(new Rectangle2D.Double(dotX - 0.5, dotY - 0.5, dotSize + 1, dotSize + 1)); - // RGB LED dot g2d.setColor(rainbow[i]); g2d.fill(new Ellipse2D.Double(dotX, dotY, dotSize, dotSize)); } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BStrip.java b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BStrip.java new file mode 100644 index 000000000..a0f36ab38 --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BStrip.java @@ -0,0 +1,373 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; +import java.awt.geom.RoundRectangle2D; + +import org.diylc.awt.StringUtils; +import org.diylc.common.HorizontalAlignment; +import org.diylc.common.ObjectCache; +import org.diylc.common.Orientation; +import org.diylc.common.VerticalAlignment; +import org.diylc.components.AbstractAddressableLedBoard; +import org.diylc.components.MakerBoardPainter; +import org.diylc.core.ComponentState; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.Project; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.EditableProperty; +import org.diylc.core.annotations.KeywordPolicy; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.diylc.presenter.CalcUtils; +import org.diylc.utils.Constants; + +@ComponentDescriptor(name = "NeoPixel Strip", category = "Displays & Outputs", + author = "Branislav Stojkovic", + description = "Addressable RGB WS2812B LED Strip (30, 60 or 144 LEDs per metre)", + instanceNamePrefix = "LED", zOrder = IDIYComponent.COMPONENT, + bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME, + enableCache = true) +public class WS2812BStrip extends AbstractAddressableLedBoard { + + private static final long serialVersionUID = 1L; + + public static Color TAPE_WHITE = Color.decode("#F2F2F2"); + + // White tape is the one light-coloured board in this family, so the ink follows the board + // colour rather than being fixed. + public static Color SILK_DARK_COLOR = Color.decode("#333333"); + + // The uncut pad is a pill lying along the tape with the cut through its middle, so a cut end + // keeps half of one. Along the tape it is this nominal where there is room and otherwise as + // much as the density leaves, which at 144/m brings it down to its own width. + public static Size PAD_WIDTH = new Size(1.5d, SizeUnit.mm); + public static Size PAD_LENGTH = new Size(3.0d, SizeUnit.mm); + + // Air left between a pad and the nearest package, and between a pad and its printed name. + public static Size PAD_CLEARANCE = new Size(0.2d, SizeUnit.mm); + public static Size LABEL_GAP = new Size(0.5d, SizeUnit.mm); + + /** + * A cut length of tape rather than a board, so the count is a free number. The ceiling is about + * the shape rather than the control-point array, which stays at six: 144 LEDs of 30/m tape is + * already most of five metres. + */ + public static final int MIN_LED_COUNT = 1; + public static final int MAX_LED_COUNT = 144; + public static final int DEFAULT_LED_COUNT = 8; + + // Both cuts carry all three lines, which is what makes tape chainable: the rails run the whole + // length as continuous copper and only the data pad changes sense. The rails are numbered to + // keep the node names distinct; the tape prints them +5V and GND at both ends. + public static final String[] PIN_NAMES = + new String[] {"+5V_1", "DIN", "GND_1", "+5V_2", "DOUT", "GND_2"}; + + // The bare designations the tape prints: the rails are not numbered on the part, and the data + // pad is printed two letters whichever end it is. + public static final String[] SILK_NAMES = + new String[] {"+5V", "DI", "GND", "+5V", "DO", "GND"}; + + private Density density = Density._60; + private int ledCount = DEFAULT_LED_COUNT; + + public WS2812BStrip() { + super(); + this.bodyColor = TAPE_WHITE; + updateControlPoints(); + } + + @EditableProperty(name = "Density") + public Density getDensity() { + if (density == null) { + density = Density._60; + } + return density; + } + + public void setDensity(Density density) { + this.density = density; + updateControlPoints(); + invalidateCache(); + } + + @EditableProperty(name = "LEDs") + public int getLedCount() { + if (ledCount < MIN_LED_COUNT) { + ledCount = DEFAULT_LED_COUNT; + } + return ledCount; + } + + public void setLedCount(int ledCount) { + this.ledCount = Math.max(MIN_LED_COUNT, Math.min(MAX_LED_COUNT, ledCount)); + updateControlPoints(); + invalidateCache(); + } + + @Override + protected String getVariantLabel() { + return getDensity() + ", " + getLedCount() + " LEDs, " + getLedType(); + } + + @Override + public String getControlPointNodeName(int index) { + if (index >= 0 && index < PIN_NAMES.length) { + return PIN_NAMES[index]; + } + return Integer.toString(index + 1); + } + + @Override + protected String getSilkPinLabel(int index) { + if (index >= 0 && index < SILK_NAMES.length) { + return SILK_NAMES[index]; + } + return super.getSilkPinLabel(index); + } + + /** The lettering that contrasts with the tape colour the user has chosen. */ + private Color getSilkInk() { + return CalcUtils.calculateLuminance(bodyColor) < 128d ? SILK_COLOR : SILK_DARK_COLOR; + } + + private double getPitch() { + return new Size(getDensity().getPitchMm(), SizeUnit.mm).convertToPixels(); + } + + private double getTapeWidth() { + return new Size(getDensity().getWidthMm(), SizeUnit.mm).convertToPixels(); + } + + /** Linear in the count rather than proportional to it, since the two cut ends do not scale. */ + private double getTapeLength() { + return (getLedCount() - 1) * getPitch() + 2 * getLeadIn(); + } + + /** The span a column of three pads occupies across the tape. */ + private static double getPadRunLength() { + return 2 * PIN_SPACING.convertToPixels(); + } + + /** + * Tape is a repeating cell one pitch long with its package in the middle and a cut falling on a + * cell boundary, so this is half a pitch. That is what makes a length exactly as many pitches + * long as it has LEDs, and keeps the pitch across the joint when two are butted together. + */ + double getLeadIn() { + return getPitch() / 2.0; + } + + /** + * Half of the pill it was cut from: the nominal length where the density leaves room, otherwise + * as much as fits between the cut and the first package, never less than its own width. + */ + double getPadDepth() { + return getPadLength() / 2.0; + } + + double getPadLength() { + double room = 2 * (getLeadIn() - RGB_LED_SIZE.convertToPixels() / 2.0 + - PAD_CLEARANCE.convertToPixels()); + return Math.max(PAD_WIDTH.convertToPixels(), + Math.min(PAD_LENGTH.convertToPixels(), room)); + } + + /** Control point zero sits here, on the cut, which runs through the pad. */ + private double getBoardX(double x) { + return x; + } + + /** Each column of pads is centred across the width, so the first sits above centre. */ + private double getBoardY(double y) { + return y - (getTapeWidth() - getPadRunLength()) / 2.0; + } + + @Override + protected void updateControlPoints() { + Point2D firstPoint = controlPoints[0]; + double spacing = PIN_SPACING.convertToPixels(); + + // Spacing the three rows by the pad pitch rather than by a fraction of the width keeps them + // apart: fractions of a 10 mm tape would leave them almost edge to edge. + // + // The cut runs through the middle of a pad, so a control point sits on the cut itself and the + // two columns are a whole tape length apart. That is also what makes two lengths chain. + double columnSpacing = getTapeLength(); + + double[][] relativeOffsets = new double[PIN_NAMES.length][2]; + for (int i = 0; i < 3; i++) { + relativeOffsets[i][0] = 0; + relativeOffsets[i][1] = i * spacing; + relativeOffsets[3 + i][0] = columnSpacing; + relativeOffsets[3 + i][1] = i * spacing; + } + + rotatePoints(firstPoint, relativeOffsets); + } + + @Override + public Shape getBodyShape() { + Point2D p0 = controlPoints[0]; + return new RoundRectangle2D.Double(getBoardX(p0.getX()), getBoardY(p0.getY()), getTapeLength(), + getTapeWidth(), 4, 4); + } + + /** + * Names go in the bare strip between the pad and the first package, which the density decides: + * 12.7 mm at 30/m, 4.3 mm at 60/m and 0.2 mm at 144/m. They are left off where they do not fit + * rather than shrunk to suit the worst case. + */ + private void drawCutEndLabels(Graphics2D g2d, double boardX, double boardY) { + double room = getLeadIn() - RGB_LED_SIZE.convertToPixels() / 2.0 - getPadDepth(); + double gap = LABEL_GAP.convertToPixels(); + double spacing = PIN_SPACING.convertToPixels(); + double firstRow = boardY + (getTapeWidth() - getPadRunLength()) / 2.0; + + g2d.setFont(PIN_ROW_FLAT_FONT); + g2d.setColor(getSilkInk()); + FontMetrics metrics = g2d.getFontMetrics(); + + for (int i = 0; i < PIN_NAMES.length; i++) { + String label = getSilkPinLabel(i); + if (label == null || label.isEmpty() || metrics.stringWidth(label) + gap > room) { + continue; + } + + boolean input = i < 3; + double x = input ? boardX + getPadDepth() + gap + : boardX + getTapeLength() - getPadDepth() - gap; + StringUtils.drawCenteredText(g2d, label, x, firstRow + (i % 3) * spacing, + input ? HorizontalAlignment.LEFT : HorizontalAlignment.RIGHT, + VerticalAlignment.CENTER); + } + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, + Project project, IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + + Point2D p0 = controlPoints[0]; + double x = p0.getX(); + double y = p0.getY(); + + AffineTransform oldTx = g2d.getTransform(); + if (orientation != Orientation.DEFAULT) { + g2d.rotate(orientation.toRadians(), x, y); + } + + double boardX = getBoardX(x); + double boardY = getBoardY(y); + double tapeH = getTapeWidth(); + + Shape boardShape = getBodyShape(); + + Composite oldComposite = applyAlpha(g2d, componentState); + + drawingObserver.startTracking(); + g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); + g2d.fill(boardShape); + drawingObserver.stopTracking(); + + g2d.setColor(getFinalBorderColor(componentState, outlineMode)); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); + g2d.draw(boardShape); + + if (!outlineMode) { + drawCutEndLabels(g2d, boardX, boardY); + + double pitch = getPitch(); + double ledSize = RGB_LED_SIZE.convertToPixels(); + double firstCenter = boardX + getLeadIn(); + int count = getLedCount(); + Color[] colors = getLedColors(count); + LedType ledType = getLedType(); + for (int i = 0; i < count; i++) { + MakerBoardPainter.drawAddressableLed(g2d, firstCenter + i * pitch, boardY + tapeH / 2.0, + ledSize, colors[i], 0, ledType); + } + } + + g2d.setTransform(oldTx); + + double padWidth = PAD_WIDTH.convertToPixels(); + drawBisectedPads(g2d, 0, 3, padWidth, getPadLength(), true, outlineMode, drawingObserver); + drawBisectedPads(g2d, 3, 3, padWidth, getPadLength(), false, outlineMode, drawingObserver); + + g2d.setComposite(oldComposite); + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + g2d.setColor(TAPE_WHITE); + g2d.fill(new RoundRectangle2D.Double(1, height / 2.0 - 5, width - 2, 10, 2, 2)); + g2d.setColor(Color.GRAY); + g2d.draw(new RoundRectangle2D.Double(1, height / 2.0 - 5, width - 2, 10, 2, 2)); + + Color[] colors = new Color[] {Color.decode("#FFEE00"), Color.decode("#FF1122"), + Color.decode("#2255FF"), Color.decode("#22DD44")}; + for (int i = 0; i < 4; i++) { + g2d.setColor(colors[i]); + g2d.fillRect(3 + i * (width - 6) / 4, (int) (height / 2.0 - 3), 5, 6); + } + } + + /** + * Tape is sold by LEDs per metre, so the pitch is derived rather than stored. Width does not + * follow and is a stated norm rather than a measurement: 10 mm at 30 and 60, 12 mm at 144, + * where the package leaves no room for the conductor beside it. + */ + public enum Density { + _30("30 LED/m", 30, 10.0d), + _60("60 LED/m", 60, 10.0d), + _144("144 LED/m", 144, 12.0d); + + private final String label; + private final int ledsPerMetre; + private final double widthMm; + + Density(String label, int ledsPerMetre, double widthMm) { + this.label = label; + this.ledsPerMetre = ledsPerMetre; + this.widthMm = widthMm; + } + + @Override public String toString() { return label; } + public int getLedsPerMetre() { return ledsPerMetre; } + public double getWidthMm() { return widthMm; } + + /** Centre-to-centre LED spacing: one metre shared out between that many LEDs. */ + public double getPitchMm() { return 1000.0d / ledsPerMetre; } + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoMega.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoMega.java index 2222b8bf2..df97e4667 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoMega.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoMega.java @@ -64,7 +64,7 @@ public class ArduinoMega extends AbstractMakerBoard { // Pin names in sequence (98 pins total) public static final String[] PIN_NAMES = new String[] { // Power Header (0..7) - "NC", "IOREF", "RESET", "3.3V", "5V", "GND1", "GND2", "VIN", + "NC", "IOREF", "RESET", "3.3V", "5V", "GND_1", "GND_2", "VIN", // Analog Low A0..A7 (8..15) "A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7", // Analog High A8..A15 (16..23) @@ -72,7 +72,7 @@ public class ArduinoMega extends AbstractMakerBoard { // Digital Low D0..D7 (24..31) "D0 (RX0)", "D1 (TX0)", "D2 (~)", "D3 (~)", "D4 (~)", "D5 (~)", "D6 (~)", "D7 (~)", // Digital High D8..D13, GND, AREF, SDA, SCL (32..41) - "D8 (~)", "D9 (~)", "D10 (~)", "D11 (~)", "D12 (~)", "D13 (~)", "GND3", "AREF", "SDA", "SCL", + "D8 (~)", "D9 (~)", "D10 (~)", "D11 (~)", "D12 (~)", "D13 (~)", "GND_3", "AREF", "SDA", "SCL", // Communication Header D14..D21 (42..49) "D14 (TX3)", "D15 (RX3)", "D16 (TX2)", "D17 (RX2)", "D18 (TX1)", "D19 (RX1)", "D20 (SDA)", "D21 (SCL)", // Double Digital 2x18 Header (50..85: D22..D53, GNDx2, 5Vx2) @@ -294,7 +294,6 @@ public class ArduinoMega extends AbstractMakerBoard { Composite oldComposite = applyAlpha(g2d, componentState); - // Draw PCB body drawingObserver.startTracking(); g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); g2d.fill(boardShape); @@ -305,7 +304,7 @@ public class ArduinoMega extends AbstractMakerBoard { g2d.draw(boardShape); if (!outlineMode) { - // Mounting holes (6 mounting holes from technical design) + // six, from the technical design double holeDiameter = new Size(0.12d, SizeUnit.in).convertToPixels(); MakerBoardPainter.drawMountingHole(g2d, boardX + new Size(0.55d, SizeUnit.in).convertToPixels(), boardY + new Size(2.0d, SizeUnit.in).convertToPixels(), holeDiameter); // (550, 100 mils) MakerBoardPainter.drawMountingHole(g2d, boardX + new Size(0.6d, SizeUnit.in).convertToPixels(), boardY + new Size(0.1d, SizeUnit.in).convertToPixels(), holeDiameter); // (600, 2000 mils) @@ -343,11 +342,9 @@ public class ArduinoMega extends AbstractMakerBoard { StringUtils.drawCenteredText(g2d, "RST", btnX + btnW / 2.0, btnY + btnH + new Size(1.0d, SizeUnit.mm).convertToPixels(), HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - // Arduino Infinity Logo MakerBoardLogos.drawArduinoLogo(g2d, boardX + new Size(1.2d, SizeUnit.in).convertToPixels(), boardY + new Size(0.4d, SizeUnit.in).convertToPixels()); - // Silkscreen text & branding g2d.setColor(SILK_COLOR); g2d.setFont(SILK_FONT_LARGE); StringUtils.drawCenteredText(g2d, "ARDUINO", boardX + new Size(1.44d, SizeUnit.in).convertToPixels(), @@ -403,7 +400,6 @@ public class ArduinoMega extends AbstractMakerBoard { g2d.setTransform(oldTx); - // Draw header pins with continuity tracking drawPinHeader(g2d, 0, controlPoints.length, outlineMode, drawingObserver); g2d.setComposite(oldComposite); @@ -416,38 +412,32 @@ public class ArduinoMega extends AbstractMakerBoard { double boardW = width - boardX - 2; double boardH = height - 6; - // USB Type-B Jack (metallic silver, top left protruding) double usbW = 7; double usbH = 8; double usbX = 1; double usbY = boardY + 2; - // DC Power Jack (dark body, bottom left protruding) double dcW = 7; double dcH = 6; double dcX = 1; double dcY = boardY + boardH - dcH - 3; - // Board PCB g2d.setColor(ARDUINO_TEAL); g2d.fill(new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 4, 4)); g2d.setColor(ARDUINO_TEAL.darker()); g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); g2d.draw(new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 4, 4)); - // Draw USB Jack g2d.setColor(USB_METAL_COLOR); g2d.fill(new RoundRectangle2D.Double(usbX, usbY, usbW, usbH, 2, 2)); g2d.setColor(METAL_SHIELD_BORDER); g2d.draw(new RoundRectangle2D.Double(usbX, usbY, usbW, usbH, 2, 2)); - // Draw DC Jack g2d.setColor(IC_BODY_COLOR); g2d.fill(new RoundRectangle2D.Double(dcX, dcY, dcW, dcH, 2, 2)); g2d.setColor(Color.BLACK); g2d.draw(new RoundRectangle2D.Double(dcX, dcY, dcW, dcH, 2, 2)); - // Arduino Infinity logo double scale = 14.0 / 95.56; double logoW = 95.56 * scale; double logoH = 45.33 * scale; @@ -455,7 +445,6 @@ public class ArduinoMega extends AbstractMakerBoard { double logoY = boardY + 3.5; MakerBoardLogos.drawArduinoLogo(g2d, logoX, logoY, scale); - // MEGA text below logo g2d.setColor(SILK_COLOR); int fontSize = Math.max(6, (int) Math.round(boardH * 0.26)); g2d.setFont(ICON_FONT.deriveFont((float) fontSize)); diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoNano.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoNano.java index f166f0c81..96a744ab0 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoNano.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoNano.java @@ -76,63 +76,11 @@ public class ArduinoNano extends AbstractMakerBoard { public static final int PINS_PER_ROW = 15; - // Ordered by label so the Version drop-down reads alphabetically. XStream serializes enum - // constants by name, so the order here is free to change without affecting existing .diy files. - public enum NanoVersion { - CLASSIC("Nano (ATmega328)", "NANO", "m328P", null), - NANO_33_BLE("Nano 33 BLE", "33 BLE", null, "NINA-B306"), - NANO_33_BLE_SENSE("Nano 33 BLE Sense", "33 BLE SENSE", null, "NINA-B306"), - NANO_33_IOT("Nano 33 IoT", "33 IOT", "SAMD21", "NINA-W102"), - NANO_ESP32("Nano ESP32", "NANO ESP32", null, "NORA-W106"), - EVERY("Nano Every", "EVERY", "m4809", "SAMD11"), - NANO_R4("Nano R4", "NANO R4", "RA4M1", "QWIIC"), - // Silkscreened "CONNECT" rather than the full name, which does not fit between the two rows of - // pad names; the RP2040 package right below it carries the part number - NANO_RP2040_CONNECT("Nano RP2040 Connect", "CONNECT", "RP2040", "NINA-W102"); - - private final String label; - private final String silkLabel; - private final String mcuLabel; - private final String moduleLabel; - - NanoVersion(String label, String silkLabel, String mcuLabel, String moduleLabel) { - this.label = label; - this.silkLabel = silkLabel; - this.mcuLabel = mcuLabel; - this.moduleLabel = moduleLabel; - } - - public String getSilkLabel() { - return silkLabel; - } - - /** - * Marking of the separately visible MCU package, or null when the MCU is a die inside the - * module rather than its own part on the board. - */ - public String getMcuLabel() { - return mcuLabel; - } - - /** - * Marking of the part occupying the end of the board opposite the USB jack, where the classic - * Nano has its ICSP header. Null on the classic Nano, which has the header instead. - */ - public String getModuleLabel() { - return moduleLabel; - } - - @Override - public String toString() { - return label; - } - } - public static final String[] PIN_NAMES = new String[] { // Left row (0..14, top to bottom) - "D1 (TX)", "D0 (RX)", "RST1", "GND1", "D2", "D3 (~)", "D4", "D5 (~)", "D6 (~)", "D7", "D8", "D9 (~)", "D10 (~)", "D11 (~)", "D12", + "D1 (TX)", "D0 (RX)", "RST1", "GND_1", "D2", "D3 (~)", "D4", "D5 (~)", "D6 (~)", "D7", "D8", "D9 (~)", "D10 (~)", "D11 (~)", "D12", // Right row (15..29, top to bottom) - "VIN", "GND2", "RST2", "5V", "A7", "A6", "A5", "A4", "A3", "A2", "A1", "A0", "AREF", "3.3V", "D13", + "VIN", "GND_2", "RST2", "5V", "A7", "A6", "A5", "A4", "A3", "A2", "A1", "A0", "AREF", "3.3V", "D13", // ICSP (30..35) "MISO", "5V_ICSP", "SCK", "MOSI", "RST_ICSP", "GND_ICSP" }; @@ -145,26 +93,26 @@ public class ArduinoNano extends AbstractMakerBoard { // Nano Every (ABX00028): PWM on D3, D5, D6, D9, D10 only, and D13 is SCK. public static final String[] PIN_NAMES_EVERY = new String[] { // Left row (0..14, top to bottom) - "D1 (TX)", "D0 (RX)", "RST1", "GND1", "D2", "D3 (~)", "D4", "D5 (~)", "D6 (~)", "D7", "D8", "D9 (~)", "D10 (~)", "D11 (MOSI)", "D12 (MISO)", + "D1 (TX)", "D0 (RX)", "RST1", "GND_1", "D2", "D3 (~)", "D4", "D5 (~)", "D6 (~)", "D7", "D8", "D9 (~)", "D10 (~)", "D11 (MOSI)", "D12 (MISO)", // Right row (15..29, top to bottom) - "VIN", "GND2", "RST2", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3", "A2", "A1", "A0 (DAC0)", "AREF", "3.3V", "D13 (SCK)" + "VIN", "GND_2", "RST2", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3", "A2", "A1", "A0 (DAC0)", "AREF", "3.3V", "D13 (SCK)" }; // Nano 33 IoT (ABX00027): the SAMD21 adds PWM on D2 relative to the Every. public static final String[] PIN_NAMES_33_IOT = new String[] { // Left row (0..14, top to bottom) - "D1 (TX)", "D0 (RX)", "RST1", "GND1", "D2 (~)", "D3 (~)", "D4", "D5 (~)", "D6 (~)", "D7", "D8", "D9 (~)", "D10 (~)", "D11 (MOSI)", "D12 (MISO)", + "D1 (TX)", "D0 (RX)", "RST1", "GND_1", "D2 (~)", "D3 (~)", "D4", "D5 (~)", "D6 (~)", "D7", "D8", "D9 (~)", "D10 (~)", "D11 (MOSI)", "D12 (MISO)", // Right row (15..29, top to bottom) - "VIN", "GND2", "RST2", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3", "A2", "A1", "A0 (DAC0)", "AREF", "3.3V", "D13 (SCK)" + "VIN", "GND_2", "RST2", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3", "A2", "A1", "A0 (DAC0)", "AREF", "3.3V", "D13 (SCK)" }; // Nano 33 BLE and BLE Sense (ABX00030): the nRF52840 routes PWM to every digital pin, D13 // included. public static final String[] PIN_NAMES_33_BLE = new String[] { // Left row (0..14, top to bottom) - "D1 (TX)", "D0 (RX)", "RST1", "GND1", "D2 (~)", "D3 (~)", "D4 (~)", "D5 (~)", "D6 (~)", "D7 (~)", "D8 (~)", "D9 (~)", "D10 (~)", "D11 (~, MOSI)", "D12 (~, MISO)", + "D1 (TX)", "D0 (RX)", "RST1", "GND_1", "D2 (~)", "D3 (~)", "D4 (~)", "D5 (~)", "D6 (~)", "D7 (~)", "D8 (~)", "D9 (~)", "D10 (~)", "D11 (~, MOSI)", "D12 (~, MISO)", // Right row (15..29, top to bottom) - "VIN", "GND2", "RST2", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3", "A2", "A1", "A0 (DAC0)", "AREF", "3.3V", "D13 (~, SCK)" + "VIN", "GND_2", "RST2", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3", "A2", "A1", "A0 (DAC0)", "AREF", "3.3V", "D13 (~, SCK)" }; // Nano RP2040 Connect (ABX00053): every RP2040 GPIO has a PWM slice. Note REC (BOOTSEL) where @@ -173,17 +121,17 @@ public class ArduinoNano extends AbstractMakerBoard { // Left row (0..14, top to bottom) "D1 (TX)", "D0 (RX)", "RST1", "GND_1", "D2 (~)", "D3 (~)", "D4 (~)", "D5 (~)", "D6 (~)", "D7 (~)", "D8 (~)", "D9 (~)", "D10 (~)", "D11 (~, MOSI)", "D12 (~, MISO)", // Right row (15..29, top to bottom) - "VIN", "GND2", "REC", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3", "A2", "A1", "A0", "AREF", "3.3V", "D13 (~, SCK)" + "VIN", "GND_2", "REC", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3", "A2", "A1", "A0", "AREF", "3.3V", "D13 (~, SCK)" }; // Nano R4 (ABX00142): the Renesas RA4M1 board. Same PWM set as the classic, but D4 / D5 double as // the CAN pins, A1-A3 reach the on-chip OPAMP, A0 is the DAC, and BOOT replaces the second RESET. public static final String[] PIN_NAMES_R4 = new String[] { // Left row (0..14, top to bottom) - "D1 (TX)", "D0 (RX)", "RST1", "GND1", "D2", "D3 (~)", "D4 (CAN TX)", "D5 (~, CAN RX)", "D6 (~)", + "D1 (TX)", "D0 (RX)", "RST1", "GND_1", "D2", "D3 (~)", "D4 (CAN TX)", "D5 (~, CAN RX)", "D6 (~)", "D7", "D8", "D9 (~)", "D10 (~, CS)", "D11 (~, MOSI)", "D12 (MISO)", // Right row (15..29, top to bottom) - "VIN", "GND2", "BOOT", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3 (OPAMP OUT)", + "VIN", "GND_2", "BOOT", "5V", "A7", "A6", "A5 (SCL)", "A4 (SDA)", "A3 (OPAMP OUT)", "A2 (OPAMP -)", "A1 (OPAMP +)", "A0 (DAC)", "AREF", "3.3V", "D13 (SCK)", // Qwiic socket (30), a single point at the centre of the connector so a wire can land on it "QWIIC" @@ -194,11 +142,11 @@ public class ArduinoNano extends AbstractMakerBoard { // number, which is what the ESP-IDF documentation refers to. public static final String[] PIN_NAMES_ESP32 = new String[] { // Left row (0..14, top to bottom) - "D1 (~, TX/GPIO43)", "D0 (~, RX/GPIO44)", "RST1", "GND1", "D2 (~, GPIO5)", "D3 (~, GPIO6)", + "D1 (~, TX/GPIO43)", "D0 (~, RX/GPIO44)", "RST1", "GND_1", "D2 (~, GPIO5)", "D3 (~, GPIO6)", "D4 (~, GPIO7)", "D5 (~, GPIO8)", "D6 (~, GPIO9)", "D7 (~, GPIO10)", "D8 (~, GPIO17)", "D9 (~, GPIO18)", "D10 (~, GPIO21)", "D11 (~, MOSI/GPIO38)", "D12 (~, MISO/GPIO47)", // Right row (15..29, top to bottom) - "VIN", "GND2", "B1", "VUSB", "A7 (~, GPIO14)", "A6 (~, GPIO13)", "A5 (~, SCL/GPIO12)", + "VIN", "GND_2", "B1", "VUSB", "A7 (~, GPIO14)", "A6 (~, GPIO13)", "A5 (~, SCL/GPIO12)", "A4 (~, SDA/GPIO11)", "A3 (~, GPIO4)", "A2 (~, GPIO3)", "A1 (~, GPIO2)", "A0 (~, GPIO1)", "B0", "3.3V", "D13 (~, SCK/GPIO48)" }; @@ -262,6 +210,11 @@ public class ArduinoNano extends AbstractMakerBoard { invalidateCache(); } + @Override + protected String getVariantLabel() { + return getVersion().toString(); + } + private String[] getPinNames() { switch (getVersion()) { case EVERY: @@ -391,9 +344,8 @@ public class ArduinoNano extends AbstractMakerBoard { return relativeOffsets; } - // ICSP header (2x3 pins, 30..35) flush with top edge: - // Outer row is 0.05" (10px) from top edge (-20px / -0.10" relative to Pin 0) - // Inner row is 0.15" (30px) from top edge (0px / 0.00" relative to Pin 0, aligned with Pin 0 & Pin 15) + // ICSP block flush with the top edge: the outer row 0.05" from it and the inner row + // 0.15", which puts the inner row level with pins 0 and 15. double icspOuterY = -spacing; // -20px (-0.10") double icspInnerY = 0; // 0px (0.00") @@ -401,17 +353,11 @@ public class ArduinoNano extends AbstractMakerBoard { double col1X = rowSpacing / 2.0; // 60px (0.30") double col2X = rowSpacing / 2.0 + spacing; // 80px (0.40") - // Pin 1 (MISO at col2X, outer row) relativeOffsets[30] = new double[] {col2X, icspOuterY}; - // Pin 2 (5V_ICSP at col2X, inner row) relativeOffsets[31] = new double[] {col2X, icspInnerY}; - // Pin 3 (SCK at col1X, outer row) relativeOffsets[32] = new double[] {col1X, icspOuterY}; - // Pin 4 (MOSI at col1X, inner row) relativeOffsets[33] = new double[] {col1X, icspInnerY}; - // Pin 5 (RST_ICSP at col0X, outer row) relativeOffsets[34] = new double[] {col0X, icspOuterY}; - // Pin 6 (GND_ICSP at col0X, inner row) relativeOffsets[35] = new double[] {col0X, icspInnerY}; return relativeOffsets; @@ -480,7 +426,6 @@ public class ArduinoNano extends AbstractMakerBoard { Composite oldComposite = applyAlpha(g2d, componentState); - // Draw PCB body drawingObserver.startTracking(); g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); g2d.fill(boardShape); @@ -529,7 +474,6 @@ public class ArduinoNano extends AbstractMakerBoard { g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); g2d.draw(new RoundRectangle2D.Double(-chipSize / 2.0, -chipSize / 2.0, chipSize, chipSize, 4, 4)); - // Pin 1 dot g2d.setColor(PIN_MARKER_COLOR); g2d.fill(new Ellipse2D.Double(-chipSize / 2.0 + 3, -chipSize / 2.0 + 3, 3, 3)); @@ -583,7 +527,6 @@ public class ArduinoNano extends AbstractMakerBoard { } } - // Reset Button double btnW = BUTTON_WIDTH.convertToPixels(); double btnH = BUTTON_LENGTH.convertToPixels(); double btnX = boardX + (boardW - btnW) / 2.0; @@ -615,7 +558,6 @@ public class ArduinoNano extends AbstractMakerBoard { g2d.setTransform(oldTx); - // Draw pins or solder pads if (headers) { drawPinHeader(g2d, 0, getHeaderPinCount(), outlineMode, drawingObserver); } else if (getVersion() == NanoVersion.CLASSIC) { @@ -637,15 +579,12 @@ public class ArduinoNano extends AbstractMakerBoard { g2d.setColor(ARDUINO_TEAL.darker()); g2d.draw(new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 4, 4)); - // ICSP header at top g2d.setColor(HEADER_BODY_COLOR); g2d.fill(new Rectangle2D.Double(width / 2.0 - 4, boardY, 8, 4)); - // Mini USB at bottom g2d.setColor(USB_METAL_COLOR); g2d.fillRect((int) (width / 2.0 - 4), (int) (boardY + boardH - 4), 8, 4); - // 45-degree rotated diamond IC chip in center AffineTransform oldTx = g2d.getTransform(); g2d.translate(width / 2.0, boardY + boardH / 2.0 + 1); g2d.rotate(Math.PI / 4.0); @@ -655,11 +594,62 @@ public class ArduinoNano extends AbstractMakerBoard { g2d.draw(new RoundRectangle2D.Double(-4, -4, 8, 8, 1, 1)); g2d.setTransform(oldTx); - // Pin strips on sides g2d.setColor(PIN_COLOR); for (int y = 5; y < height - 5; y += 3) { g2d.fillRect(7, y, 2, 2); g2d.fillRect(width - 9, y, 2, 2); } } + + // Ordered by label so the Version drop-down reads alphabetically. XStream serializes enum + // constants by name, so the order here is free to change without affecting existing .diy files. + public enum NanoVersion { + CLASSIC("Nano (ATmega328)", "NANO", "m328P", null), + NANO_33_BLE("Nano 33 BLE", "33 BLE", null, "NINA-B306"), + NANO_33_BLE_SENSE("Nano 33 BLE Sense", "33 BLE SENSE", null, "NINA-B306"), + NANO_33_IOT("Nano 33 IoT", "33 IOT", "SAMD21", "NINA-W102"), + NANO_ESP32("Nano ESP32", "NANO ESP32", null, "NORA-W106"), + EVERY("Nano Every", "EVERY", "m4809", "SAMD11"), + NANO_R4("Nano R4", "NANO R4", "RA4M1", "QWIIC"), + // Silkscreened "CONNECT" rather than the full name, which does not fit between the two rows of + // pad names; the RP2040 package right below it carries the part number + NANO_RP2040_CONNECT("Nano RP2040 Connect", "CONNECT", "RP2040", "NINA-W102"); + + private final String label; + private final String silkLabel; + private final String mcuLabel; + private final String moduleLabel; + + NanoVersion(String label, String silkLabel, String mcuLabel, String moduleLabel) { + this.label = label; + this.silkLabel = silkLabel; + this.mcuLabel = mcuLabel; + this.moduleLabel = moduleLabel; + } + + public String getSilkLabel() { + return silkLabel; + } + + /** + * Marking of the separately visible MCU package, or null when the MCU is a die inside the + * module rather than its own part on the board. + */ + public String getMcuLabel() { + return mcuLabel; + } + + /** + * Marking of the part occupying the end of the board opposite the USB jack, where the classic + * Nano has its ICSP header. Null on the classic Nano, which has the header instead. + */ + public String getModuleLabel() { + return moduleLabel; + } + + @Override + public String toString() { + return label; + } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoUno.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoUno.java index d96dbe8c3..dbf8ca9f7 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoUno.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/ArduinoUno.java @@ -61,24 +61,6 @@ public class ArduinoUno extends AbstractMakerBoard { private static final long serialVersionUID = 1L; - public enum ArduinoUnoVersion { - LEONARDO("Leonardo"), - REV3("UNO R3"), - R4_MINIMA("UNO R4 Minima"), - R4_WIFI("UNO R4 WiFi"); - - private String label; - - private ArduinoUnoVersion(String label) { - this.label = label; - } - - @Override - public String toString() { - return label; - } - } - private ArduinoUnoVersion version = ArduinoUnoVersion.REV3; public static Size BOARD_WIDTH = new Size(68.6d, SizeUnit.mm); @@ -93,13 +75,13 @@ public class ArduinoUno extends AbstractMakerBoard { // Pin names in sequence (44 pins total) public static final String[] PIN_NAMES = new String[] { // Power Header (0..7) - "NC", "IOREF", "RESET", "3.3V", "5V", "GND1", "GND2", "VIN", + "NC", "IOREF", "RESET", "3.3V", "5V", "GND_1", "GND_2", "VIN", // Analog Header (8..13) "A0", "A1", "A2", "A3", "A4", "A5", // Digital Low (14..21) "D0 (RX)", "D1 (TX)", "D2", "D3 (~)", "D4", "D5 (~)", "D6 (~)", "D7", // Digital High (22..31) - "D8", "D9 (~)", "D10 (~)", "D11 (~)", "D12", "D13", "GND3", "AREF", "SDA", "SCL", + "D8", "D9 (~)", "D10 (~)", "D11 (~)", "D12", "D13", "GND_3", "AREF", "SDA", "SCL", // Main ICSP Header (32..37, ATmega328P) "MISO", "5V_ICSP", "SCK", "MOSI", "RST_ICSP", "GND_ICSP", // Top-Left ICSP Header (38..43, ATmega16U2) @@ -130,13 +112,13 @@ public class ArduinoUno extends AbstractMakerBoard { // ICSP header, and I2C is shared with D2 / D3 rather than living solely on A4 / A5. public static final String[] PIN_NAMES_LEONARDO = new String[] { // Power Header (0..7) - "NC", "IOREF", "RESET", "3.3V", "5V", "GND1", "GND2", "VIN", + "NC", "IOREF", "RESET", "3.3V", "5V", "GND_1", "GND_2", "VIN", // Analog Header (8..13) "A0", "A1", "A2", "A3", "A4", "A5", // Digital Low (14..21) "D0 (RX)", "D1 (TX)", "D2 (SDA)", "D3 (~, SCL)", "D4", "D5 (~)", "D6 (~)", "D7", // Digital High (22..31) - "D8", "D9 (~)", "D10 (~)", "D11 (~)", "D12", "D13", "GND3", "AREF", "SDA", "SCL", + "D8", "D9 (~)", "D10 (~)", "D11 (~)", "D12", "D13", "GND_3", "AREF", "SDA", "SCL", // Main ICSP Header (32..37, ATmega32U4) "MISO", "5V_ICSP", "SCK", "MOSI", "RST_ICSP", "GND_ICSP" }; @@ -159,13 +141,13 @@ public class ArduinoUno extends AbstractMakerBoard { // carries a 10-pin SWD debug connector in the top right corner, modelled as a single node. public static final String[] PIN_NAMES_R4_MINIMA = new String[] { // Power Header (0..7) - "BOOT", "IOREF", "RESET", "3.3V", "5V", "GND1", "GND2", "VIN", + "BOOT", "IOREF", "RESET", "3.3V", "5V", "GND_1", "GND_2", "VIN", // Analog Header (8..13) "A0", "A1", "A2", "A3", "A4", "A5", // Digital Low (14..21) "D0 (RX)", "D1 (TX)", "D2", "D3 (~)", "D4", "D5 (~)", "D6 (~)", "D7", // Digital High (22..31) - "D8", "D9 (~)", "D10 (~)", "D11 (~)", "D12", "D13", "GND3", "AREF", "SDA", "SCL", + "D8", "D9 (~)", "D10 (~)", "D11 (~)", "D12", "D13", "GND_3", "AREF", "SDA", "SCL", // Main ICSP Header (32..37, RA4M1) "MISO", "5V_ICSP", "SCK", "MOSI", "RST_ICSP", "GND_ICSP", // SWD Connector (38) @@ -222,15 +204,20 @@ public class ArduinoUno extends AbstractMakerBoard { public void setVersion(ArduinoUnoVersion version) { this.version = version; - if (isR4()) { - this.bodyColor = ARDUINO_BLUE; - } else { - this.bodyColor = ARDUINO_TEAL; + // The two generations are different colours, but the board follows the version only while its + // colour is still one of ours; a colour the user chose survives a version switch. + if (ARDUINO_TEAL.equals(bodyColor) || ARDUINO_BLUE.equals(bodyColor)) { + this.bodyColor = isR4() ? ARDUINO_BLUE : ARDUINO_TEAL; } updateControlPoints(); invalidateCache(); } + @Override + protected String getVariantLabel() { + return getVersion().toString(); + } + /** * True for the Renesas RA4M1 generation, which shares a USB-C connector and the RA4M1 in place of * the ATmega328P. @@ -442,7 +429,6 @@ public class ArduinoUno extends AbstractMakerBoard { g2d.rotate(orientation.toRadians(), x, y); } - // Board bounding origin double boardX = x - new Size(1.1d, SizeUnit.in).convertToPixels(); double boardY = y - new Size(2.0d, SizeUnit.in).convertToPixels(); @@ -450,7 +436,6 @@ public class ArduinoUno extends AbstractMakerBoard { Composite oldComposite = applyAlpha(g2d, componentState); - // Draw PCB body drawingObserver.startTracking(); g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); g2d.fill(boardShape); @@ -461,7 +446,6 @@ public class ArduinoUno extends AbstractMakerBoard { g2d.draw(boardShape); if (!outlineMode) { - // Mounting holes double holeDiameter = new Size(0.12d, SizeUnit.in).convertToPixels(); MakerBoardPainter.drawMountingHole(g2d, boardX + new Size(0.6d, SizeUnit.in).convertToPixels(), boardY + new Size(0.1d, SizeUnit.in).convertToPixels(), holeDiameter); MakerBoardPainter.drawMountingHole(g2d, boardX + new Size(0.55d, SizeUnit.in).convertToPixels(), boardY + new Size(2.0d, SizeUnit.in).convertToPixels(), holeDiameter); @@ -544,7 +528,6 @@ public class ArduinoUno extends AbstractMakerBoard { new Size(0.22d, SizeUnit.in).convertToPixels(), "ATmega328P"); } - // Reset Button near USB double btnW = BUTTON_WIDTH.convertToPixels(); double btnH = BUTTON_LENGTH.convertToPixels(); double btnX = boardX + new Size(0.235d, SizeUnit.in).convertToPixels() - btnW / 2.0; @@ -560,11 +543,9 @@ public class ArduinoUno extends AbstractMakerBoard { double brandShiftY = getVersion() == ArduinoUnoVersion.R4_MINIMA ? new Size(24d, SizeUnit.mm).convertToPixels() : 0; - // Arduino Infinity Logo MakerBoardLogos.drawArduinoLogo(g2d, boardX + new Size(1.26d, SizeUnit.in).convertToPixels() - new Size(3.0d, SizeUnit.mm).convertToPixels(), boardY + new Size(0.4411d, SizeUnit.in).convertToPixels() - new Size(3.0d, SizeUnit.mm).convertToPixels() + brandShiftY); - // Silkscreen text & branding g2d.setColor(SILK_COLOR); double arduinoX = boardX + new Size(1.5d, SizeUnit.in).convertToPixels() - new Size(3.0d, SizeUnit.mm).convertToPixels(); @@ -577,7 +558,6 @@ public class ArduinoUno extends AbstractMakerBoard { g2d.setFont(SILK_FONT_LARGE); StringUtils.drawCenteredText(g2d, subText, arduinoX, subTextY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - // Header silkscreen labels g2d.setFont(SILK_FONT_SMALL); // the header captions sit beyond the rotated pin names, which take up the space right next to // the headers themselves @@ -606,8 +586,7 @@ public class ArduinoUno extends AbstractMakerBoard { g2d.setTransform(oldTx); - // Draw header pins with continuity tracking; the Minima's SWD point sits inside the connector - // body drawn above and gets no header pin of its own + // the Minima's SWD point sits inside the connector body drawn above and gets no header pin int headerPinCount = getVersion() == ArduinoUnoVersion.R4_MINIMA ? controlPoints.length - 1 : controlPoints.length; drawPinHeader(g2d, 0, headerPinCount, outlineMode, drawingObserver); @@ -622,38 +601,32 @@ public class ArduinoUno extends AbstractMakerBoard { double boardW = width - boardX - 2; double boardH = height - 6; - // USB Type-B Jack (metallic silver, top left protruding) double usbW = 7; double usbH = 8; double usbX = 1; double usbY = boardY + 2; - // DC Power Jack (dark body, bottom left protruding) double dcW = 7; double dcH = 6; double dcX = 1; double dcY = boardY + boardH - dcH - 3; - // Board PCB g2d.setColor(this.bodyColor); g2d.fill(new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 4, 4)); g2d.setColor(this.bodyColor.darker()); g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); g2d.draw(new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 4, 4)); - // Draw USB Jack g2d.setColor(USB_METAL_COLOR); g2d.fill(new RoundRectangle2D.Double(usbX, usbY, usbW, usbH, 2, 2)); g2d.setColor(METAL_SHIELD_BORDER); g2d.draw(new RoundRectangle2D.Double(usbX, usbY, usbW, usbH, 2, 2)); - // Draw DC Jack g2d.setColor(IC_BODY_COLOR); g2d.fill(new RoundRectangle2D.Double(dcX, dcY, dcW, dcH, 2, 2)); g2d.setColor(Color.BLACK); g2d.draw(new RoundRectangle2D.Double(dcX, dcY, dcW, dcH, 2, 2)); - // Arduino Infinity logo double scale = 15.0 / 95.56; double logoW = 95.56 * scale; double logoH = 45.33 * scale; @@ -661,7 +634,6 @@ public class ArduinoUno extends AbstractMakerBoard { double logoY = boardY + 3.5; MakerBoardLogos.drawArduinoLogo(g2d, logoX, logoY, scale); - // UNO text below logo g2d.setColor(SILK_COLOR); String iconText = getVersion().toString().toUpperCase(Locale.ROOT).replace("UNO R4 ", "R4 "); int fontSize = Math.max(5, (int) Math.round(boardH * 0.20)); @@ -670,4 +642,22 @@ public class ArduinoUno extends AbstractMakerBoard { double textX = boardX + (boardW / 2.0) + 0.5; StringUtils.drawCenteredText(g2d, iconText, textX, textY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); } + + public enum ArduinoUnoVersion { + LEONARDO("Leonardo"), + REV3("UNO R3"), + R4_MINIMA("UNO R4 Minima"), + R4_WIFI("UNO R4 WiFi"); + + private String label; + + private ArduinoUnoVersion(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/ESP32DevKit.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/ESP32DevKit.java index d077b3776..8448186dc 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/ESP32DevKit.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/ESP32DevKit.java @@ -62,25 +62,6 @@ public class ESP32DevKit extends AbstractMakerBoard { private static final long serialVersionUID = 1L; - public enum DevKitVersion { - DevKit_V1_30Pin("ESP32 DevKit V1 (30-Pin)"), - DevKitC_V4_38Pin("ESP32 DevKitC V4 (38-Pin)"), - ESP32_S3_DevKitC_44Pin("ESP32-S3 DevKitC-1 (44-Pin)"), - ESP32_C3_DevKitM_1("ESP32-C3 DevKitM-1 (30-Pin)"), - ESP32_C6_DevKitC_1("ESP32-C6 DevKitC-1 (32-Pin)"); - - private final String label; - - DevKitVersion(String label) { - this.label = label; - } - - @Override - public String toString() { - return label; - } - } - public static Size BOARD_WIDTH_30 = new Size(28.33d, SizeUnit.mm); public static Size BOARD_LENGTH_30 = new Size(51.45d, SizeUnit.mm); public static Size TOP_MARGIN_30 = new Size(6.7d, SizeUnit.mm); @@ -354,6 +335,11 @@ public class ESP32DevKit extends AbstractMakerBoard { invalidateCache(); } + @Override + protected String getVariantLabel() { + return getVersion().toString(); + } + @EditableProperty(name = "Headers") public boolean getHeaders() { return headers; @@ -373,9 +359,6 @@ public class ESP32DevKit extends AbstractMakerBoard { return "Pin " + (index + 1); } - /** - * Calculates unrotated relative offsets of all control points for the current version. - */ private double[][] getRelativeOffsets() { double spacing = PIN_SPACING.convertToPixels(); @@ -522,7 +505,6 @@ public class ESP32DevKit extends AbstractMakerBoard { Shape boardShape = getBodyShape(); Composite oldComposite = applyAlpha(g2d, componentState); - // Determine the center X for the current version double centerX; if (getVersion() == DevKitVersion.ESP32_S3_DevKitC_44Pin || isRiscV()) { centerX = x + ROW_SPACING_S3.convertToPixels() / 2.0; @@ -554,7 +536,6 @@ public class ESP32DevKit extends AbstractMakerBoard { g2d.setColor(bodyColor); g2d.fill(new Rectangle2D.Double(mainX, mainY, boardW, boardH)); - // Antenna tab in #1e1e1e g2d.setColor(ANTENNA_BG_COLOR); g2d.fill(new Rectangle2D.Double(antennaX, antennaY, antennaW, antennaH)); } else if (isRiscV()) { @@ -572,7 +553,6 @@ public class ESP32DevKit extends AbstractMakerBoard { g2d.setColor(bodyColor); g2d.fill(new Rectangle2D.Double(mainX, mainY, boardW, boardH)); - // Antenna tab in #1e1e1e g2d.setColor(ANTENNA_BG_COLOR); g2d.fill(new Rectangle2D.Double(antennaX, antennaY, antennaW, antennaH)); } else { @@ -590,7 +570,6 @@ public class ESP32DevKit extends AbstractMakerBoard { g2d.setColor(bodyColor); g2d.fill(new RoundRectangle2D.Double(mainX, mainY, mainW, mainH, 8, 8)); - // Antenna tab in #1e1e1e g2d.setColor(ANTENNA_BG_COLOR); g2d.fill(new Rectangle2D.Double(antennaX, antennaY, antennaW, antennaH)); } @@ -657,7 +636,6 @@ public class ESP32DevKit extends AbstractMakerBoard { double btnRightX = rightHoleX - 14 - 0.5 * shift1mm - btnW; drawButtons(g2d, btnLeftX, btnRightX, btnY, btnW, btnH, "EN", "BOOT"); - // Silkscreen g2d.setColor(SILK_COLOR); g2d.setFont(SILK_FONT); StringUtils.drawCenteredText(g2d, "ESP32 DevKit V1", centerX, shieldY + shieldH + 45, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); @@ -704,7 +682,6 @@ public class ESP32DevKit extends AbstractMakerBoard { MakerBoardPainter.drawMicroUsb(g2d, usbLeftX, usbY, usbW, usbH, "UART"); MakerBoardPainter.drawMicroUsb(g2d, usbRightX, usbY, usbW, usbH, "USB"); - // Silkscreen g2d.setColor(SILK_COLOR); g2d.setFont(SILK_FONT); StringUtils.drawCenteredText(g2d, "ESP32-S3 DevKitC-1", centerX, shieldY + shieldH + 45, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); @@ -759,7 +736,6 @@ public class ESP32DevKit extends AbstractMakerBoard { MakerBoardPainter.drawUsbC(g2d, mainX + boardW - usbEdgeDist - usbW / 2.0, usbY, usbW, usbH, "USB"); } - // Silkscreen g2d.setColor(SILK_COLOR); g2d.setFont(SILK_FONT); StringUtils.drawCenteredText(g2d, isC3 ? "ESP32-C3 DevKitM-1" : "ESP32-C6 DevKitC-1", centerX, @@ -804,13 +780,11 @@ public class ESP32DevKit extends AbstractMakerBoard { double btnRightX = mainX + mainW - 38 - btnW; drawButtons(g2d, btnLeftX, btnRightX, btnY, btnW, btnH, "EN", "BOOT"); - // Silkscreen g2d.setColor(SILK_COLOR); g2d.setFont(SILK_FONT); StringUtils.drawCenteredText(g2d, "ESP32 DevKitC V4", centerX, shieldY + shieldH + 45, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); } - // Draw pin labels next to control points drawPinLabels(g2d, x, y, getRelativeOffsets(), SILK_COLOR); } @@ -854,4 +828,23 @@ public class ESP32DevKit extends AbstractMakerBoard { g2d.setFont(ICON_FONT); StringUtils.drawCenteredText(g2d, "ESP32", width / 2, height / 2 + 8, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); } + + public enum DevKitVersion { + DevKit_V1_30Pin("ESP32 DevKit V1 (30-Pin)"), + DevKitC_V4_38Pin("ESP32 DevKitC V4 (38-Pin)"), + ESP32_S3_DevKitC_44Pin("ESP32-S3 DevKitC-1 (44-Pin)"), + ESP32_C3_DevKitM_1("ESP32-C3 DevKitM-1 (30-Pin)"), + ESP32_C6_DevKitC_1("ESP32-C6 DevKitC-1 (32-Pin)"); + + private final String label; + + DevKitVersion(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPi.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPi.java index 68a19e240..5d80cf8bf 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPi.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPi.java @@ -83,23 +83,6 @@ public class RaspberryPi extends AbstractMakerBoard { "PCIe", "MIPI 1", "MIPI 0", "UART" }; - public enum RaspberryPiVersion { - PI_3_B("Pi 3 (Model B+)"), - PI_4_B("Pi 4 (Model B)"), - PI_5("Pi 5"); - - private String label; - - private RaspberryPiVersion(String label) { - this.label = label; - } - - @Override - public String toString() { - return label; - } - } - private RaspberryPiVersion version = RaspberryPiVersion.PI_5; @org.diylc.core.annotations.EditableProperty(name = "Version") @@ -113,6 +96,12 @@ public class RaspberryPi extends AbstractMakerBoard { invalidateCache(); } + @Override + protected String getVariantLabel() { + RaspberryPiVersion version = getVersion(); + return version == null ? null : version.toString(); + } + public RaspberryPi() { super(); this.bodyColor = RPI_GREEN; @@ -258,7 +247,6 @@ public class RaspberryPi extends AbstractMakerBoard { MakerBoardPainter.drawMountingHole(g2d, boardX + new Size(61.5d, SizeUnit.mm).convertToPixels(), boardY + new Size(52.5d, SizeUnit.mm).convertToPixels(), holeDiameter); - // USB & Ethernet Ports on the right edge double topDistFromBottom = (version == RaspberryPiVersion.PI_4_B) ? 45.75d : 47.0d; double midDistFromBottom = (version == RaspberryPiVersion.PI_4_B) ? 27.0d : 29.0d; double botDistFromBottom = (version == RaspberryPiVersion.PI_4_B) ? 9.0d : 10.25d; @@ -270,25 +258,21 @@ public class RaspberryPi extends AbstractMakerBoard { double ethernetCenterY = (version == RaspberryPiVersion.PI_4_B) ? topCenterY : bottomCenterY; double topUsbCenterY = (version == RaspberryPiVersion.PI_4_B) ? bottomCenterY : topCenterY; - // Top USB slot (USB 3.0) MakerBoardPainter.drawUsbA(g2d, boardX + boardW - new Size(14.5d, SizeUnit.mm).convertToPixels(), boardY + new Size(topUsbCenterY - 7.0d, SizeUnit.mm).convertToPixels(), USB_A_DUAL_LENGTH.convertToPixels(), new Size(14.0d, SizeUnit.mm).convertToPixels(), "USB 3.0"); - // USB 2.0 (middle) MakerBoardPainter.drawUsbA(g2d, boardX + boardW - new Size(14.5d, SizeUnit.mm).convertToPixels(), boardY + new Size(middleCenterY - 7.0d, SizeUnit.mm).convertToPixels(), USB_A_DUAL_LENGTH.convertToPixels(), new Size(14.0d, SizeUnit.mm).convertToPixels(), "USB 2.0"); - // Ethernet MakerBoardPainter.drawMetalConnector(g2d, boardX + boardW - new Size(18.0d, SizeUnit.mm).convertToPixels(), boardY + new Size(ethernetCenterY - 8.0d, SizeUnit.mm).convertToPixels(), new Size(21.0d, SizeUnit.mm).convertToPixels(), new Size(16.0d, SizeUnit.mm).convertToPixels(), "ETHERNET"); - // Broadcom SoC double socW; double socH; double socX; @@ -492,7 +476,6 @@ public class RaspberryPi extends AbstractMakerBoard { StringUtils.drawCenteredText(g2d, "GPIO", boardX + new Size(32.5d, SizeUnit.mm).convertToPixels(), boardY + new Size(7.5d, SizeUnit.mm).convertToPixels(), HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - // Raspberry Pi Silkscreen text below 40-pin header g2d.setColor(SILK_COLOR); g2d.setFont(SILK_FONT_TITLE); String versionLabel = version.toString(); @@ -511,7 +494,6 @@ public class RaspberryPi extends AbstractMakerBoard { g2d.setTransform(oldTx); - // Draw 40 GPIO header pins and 4 PoE header pins drawPinHeader(g2d, 0, 44, outlineMode, drawingObserver); g2d.setComposite(oldComposite); @@ -524,7 +506,6 @@ public class RaspberryPi extends AbstractMakerBoard { g2d.setColor(RPI_GREEN.darker()); g2d.draw(new RoundRectangle2D.Double(2, 4, width - 4, height - 8, 4, 4)); - // USB & Ethernet (Top USB 3.0, Middle USB 2.0, Bottom Ethernet) g2d.setColor(USB_METAL_COLOR); if (version == RaspberryPiVersion.PI_4_B) { g2d.fillRect(width - 9, 5, 7, 6); @@ -536,7 +517,6 @@ public class RaspberryPi extends AbstractMakerBoard { g2d.fillRect(width - 9, 20, 7, 6); } - // SoC g2d.setColor(IC_BODY_COLOR); if (version == RaspberryPiVersion.PI_5) { g2d.fillRect(13, 14, 7, 7); @@ -546,11 +526,9 @@ public class RaspberryPi extends AbstractMakerBoard { g2d.fillRect(10, 11, 5, 5); } - // GPIO Header g2d.setColor(HEADER_BODY_COLOR); g2d.fillRect(4, 5, 18, 3); - // PCIe & MIPI connectors & PoE g2d.setColor(IC_BODY_COLOR); if (version == RaspberryPiVersion.PI_5) { g2d.setColor(IC_BODY_COLOR); @@ -575,4 +553,21 @@ public class RaspberryPi extends AbstractMakerBoard { g2d.setFont(ICON_FONT_LARGE); StringUtils.drawCenteredText(g2d, "RPi", 14, 25, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); } + + public enum RaspberryPiVersion { + PI_3_B("Pi 3 (Model B+)"), + PI_4_B("Pi 4 (Model B)"), + PI_5("Pi 5"); + + private String label; + + private RaspberryPiVersion(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPiPico.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPiPico.java index 3fd993ce5..12cace5b9 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPiPico.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPiPico.java @@ -61,24 +61,6 @@ public class RaspberryPiPico extends AbstractMakerBoard { private static final long serialVersionUID = 1L; - public enum PicoVersion { - PICO("Pi Pico"), - PICO_W("Pi Pico W"), - PICO_2("Pi Pico 2"), - PICO_2_W("Pi Pico 2 W"); - - private final String label; - - PicoVersion(String label) { - this.label = label; - } - - @Override - public String toString() { - return label; - } - } - public static Size BOARD_WIDTH = new Size(21.0d, SizeUnit.mm); public static Size BOARD_LENGTH = new Size(51.0d, SizeUnit.mm); @@ -107,11 +89,11 @@ public class RaspberryPiPico extends AbstractMakerBoard { public static final String[] PIN_NAMES = new String[] { // Left row (pins 0..19) - "GP0", "GP1", "GND1", "GP2", "GP3", "GP4", "GP5", "GND2", "GP6", "GP7", - "GP8", "GP9", "GND3", "GP10", "GP11", "GP12", "GP13", "GND4", "GP14", "GP15", + "GP0", "GP1", "GND_1", "GP2", "GP3", "GP4", "GP5", "GND_2", "GP6", "GP7", + "GP8", "GP9", "GND_3", "GP10", "GP11", "GP12", "GP13", "GND_4", "GP14", "GP15", // Right row (pins 20..39) - "VBUS", "VSYS", "GND5", "3V3_EN", "3V3 (OUT)", "ADC_VREF", "GP28", "GND6", "GP27", "GP26", - "RUN", "GP22", "GND7", "GP21", "GP20", "GP19", "GP18", "GND8", "GP17", "GP16", + "VBUS", "VSYS", "GND_5", "3V3_EN", "3V3 (OUT)", "ADC_VREF", "GP28", "GND_6", "GP27", "GP26", + "RUN", "GP22", "GND_7", "GP21", "GP20", "GP19", "GP18", "GND_8", "GP17", "GP16", // SWD debug pins (pins 40..42) "SWCLK", "GND_SWD", "SWDIO" }; @@ -139,6 +121,11 @@ public class RaspberryPiPico extends AbstractMakerBoard { invalidateCache(); } + @Override + protected String getVariantLabel() { + return getVersion().toString(); + } + /** * True for the wireless variants, which carry the CYW43439 radio and move the SWD pads out of * the bottom edge to make room for it. @@ -290,7 +277,6 @@ public class RaspberryPiPico extends AbstractMakerBoard { MakerBoardPainter.drawMountingHole(g2d, holeCenterX - holeDistX, bottomHoleY, holeDiameter); MakerBoardPainter.drawMountingHole(g2d, holeCenterX + holeDistX, bottomHoleY, holeDiameter); - // Micro USB Connector double usbW = USB_MICRO_WIDTH.convertToPixels(); double usbH = USB_MICRO_LENGTH.convertToPixels(); double usbOverhang = new Size(1.3d, SizeUnit.mm).convertToPixels(); @@ -405,10 +391,6 @@ public class RaspberryPiPico extends AbstractMakerBoard { return NOTCH_SIZE; } - /** - * Helper to draw Raspberry Pi Pico's castellated solder pads along the left and right edges - * and the SWD round pads. - */ protected void drawCastellatedPads(Graphics2D g2d, double boardX, double boardY, double boardW, double boardH, double pin1OffsetX, double pin1OffsetY, boolean outlineMode, IDrawingObserver drawingObserver) { if (outlineMode) return; @@ -485,14 +467,12 @@ public class RaspberryPiPico extends AbstractMakerBoard { g2d.setColor(RPI_GREEN.darker()); g2d.draw(new RoundRectangle2D.Double(6, 2, width - 12, height - 4, 3, 3)); - // Castellated edge pads on left and right in icon g2d.setColor(PAD_COLOR); for (int y = 5; y <= height - 6; y += 3) { g2d.fillRect(6, y, 3, 2); g2d.fillRect(width - 9, y, 3, 2); } - // USB Connector g2d.setColor(USB_METAL_COLOR); g2d.fillRect(11, 2, 10, 3); @@ -504,4 +484,22 @@ public class RaspberryPiPico extends AbstractMakerBoard { g2d.setFont(ICON_FONT); StringUtils.drawCenteredText(g2d, "PICO", width / 2, height / 2 + 10, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); } + + public enum PicoVersion { + PICO("Pi Pico"), + PICO_W("Pi Pico W"), + PICO_2("Pi Pico 2"), + PICO_2_W("Pi Pico 2 W"); + + private final String label; + + PicoVersion(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPiZero.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPiZero.java index da4a839e9..711e36e56 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPiZero.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/RaspberryPiZero.java @@ -75,23 +75,6 @@ public class RaspberryPiZero extends AbstractMakerBoard { PIN_NAMES[44] = "TV 2"; } - public enum ZeroVersion { - PI_ZERO("Pi Zero"), - PI_ZERO_W("Pi Zero W"), - PI_ZERO_2_W("Pi Zero 2 W"); - - private final String label; - - ZeroVersion(String label) { - this.label = label; - } - - @Override - public String toString() { - return label; - } - } - protected ZeroVersion version = ZeroVersion.PI_ZERO; protected boolean headers = false; @@ -115,6 +98,11 @@ public class RaspberryPiZero extends AbstractMakerBoard { invalidateCache(); } + @Override + protected String getVariantLabel() { + return getVersion().toString(); + } + @EditableProperty(name = "Headers") public boolean getHeaders() { return headers; @@ -288,7 +276,6 @@ public class RaspberryPiZero extends AbstractMakerBoard { double socY = boardY + new Size(11.5d, SizeUnit.mm).convertToPixels(); MakerBoardPainter.drawChip(g2d, socX, socY, socW, socH, ""); - // Raspberry Pi logo on SoC chip double logoSize = new Size(7.0d, SizeUnit.mm).convertToPixels(); double logoW = logoSize * (72.51 / 92.604); double logoX = socX + (socW - logoW) / 2.0; @@ -324,7 +311,6 @@ public class RaspberryPiZero extends AbstractMakerBoard { StringUtils.drawCenteredText(g2d, "GPIO", boardX + new Size(42.5d, SizeUnit.mm).convertToPixels(), y + PIN_SPACING.convertToPixels(), HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - // Silkscreen for RUN and TV pads double spacing = PIN_SPACING.convertToPixels(); double runBoxX = x + 18 * spacing - spacing / 2.0; double runBoxY = y + 1 * spacing - spacing / 2.0; @@ -357,9 +343,6 @@ public class RaspberryPiZero extends AbstractMakerBoard { g2d.setComposite(oldComposite); } - /** - * Helper to draw GPIO solder pads (copper pads with drill holes, square for Pin 1). - */ protected void drawSolderPads(Graphics2D g2d, boolean outlineMode, IDrawingObserver drawingObserver) { drawPcbSolderPads(g2d, 0, 40, true, outlineMode, drawingObserver); drawPcbSolderPads(g2d, 41, 2, true, outlineMode, drawingObserver); @@ -374,17 +357,14 @@ public class RaspberryPiZero extends AbstractMakerBoard { g2d.setColor(RPI_GREEN.darker()); g2d.draw(new RoundRectangle2D.Double(2, 6, width - 4, height - 12, 3, 3)); - // GPIO Solder Pads in icon g2d.setColor(PAD_COLOR); for (int x = 6; x <= width - 8; x += 3) { g2d.fillRect(x, 7, 2, 2); } - // SoC g2d.setColor(IC_BODY_COLOR); g2d.fillRect(10, 12, 6, 6); - // Connectors on bottom g2d.setColor(USB_METAL_COLOR); g2d.fillRect(6, height - 7, 4, 3); g2d.fillRect(18, height - 7, 3, 3); @@ -394,4 +374,21 @@ public class RaspberryPiZero extends AbstractMakerBoard { g2d.setFont(ICON_FONT); StringUtils.drawCenteredText(g2d, "ZERO", width / 2 + 4, 21, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); } + + public enum ZeroVersion { + PI_ZERO("Pi Zero"), + PI_ZERO_W("Pi Zero W"), + PI_ZERO_2_W("Pi Zero 2 W"); + + private final String label; + + ZeroVersion(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/Teensy.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/Teensy.java index 04e916968..03142fe17 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/Teensy.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/Teensy.java @@ -58,23 +58,6 @@ public class Teensy extends AbstractMakerBoard { private static final long serialVersionUID = 1L; - public enum TeensyVersion { - Teensy_3_2("Teensy 3.2"), - Teensy_4_0("Teensy 4.0"), - Teensy_4_1("Teensy 4.1"); - - private final String label; - - TeensyVersion(String label) { - this.label = label; - } - - @Override - public String toString() { - return label; - } - } - // gap between the Ethernet header pads and the silkscreen box printed around them public static Size ETH_BOX_MARGIN = new Size(1.2d, SizeUnit.mm); @@ -106,15 +89,15 @@ public class Teensy extends AbstractMakerBoard { // VUSB (33, near VIN): VUSB public static final String[] PIN_NAMES_40 = new String[] { // Left row (0..13) - "GND", "0 (RX1/CS1/CRX2)", "1 (TX1/MISO1/CTX2)", "2 (OUT2)", "3 (LRCLK2)", "4 (BCLK2)", + "GND_1", "0 (RX1/CS1/CRX2)", "1 (TX1/MISO1/CTX2)", "2 (OUT2)", "3 (LRCLK2)", "4 (BCLK2)", "5 (IN2)", "6 (OUT1D)", "7 (RX2/OUT1A)", "8 (TX2/IN1)", "9 (OUT1C)", "10 (CS/MQSR)", "11 (MOSI/CTX1)", "12 (MISO/MQSL)", // Right row (14..27) - "VIN (3.6-5.5V)", "GND", "3.3V (250mA)", "23 (A9/CRX1)", "22 (A8/CTX1)", "21 (A7/RX5)", + "VIN (3.6-5.5V)", "GND_2", "3.3V_1 (250mA)", "23 (A9/CRX1)", "22 (A8/CTX1)", "21 (A7/RX5)", "20 (A6/TX5)", "19 (A5/SCL0)", "18 (A4/SDA0)", "17 (A3/TX4/SDA1)", "16 (A2/RX4/SCL1)", "15 (A1/RX3/SPDIF IN)", "14 (A0/TX3/SPDIF OUT)", "13 (SCK/CRX1/LED)", // End row (28..32, left to right along bottom edge) - "VBAT", "3.3V (End)", "GND (End)", "Program", "On/Off", + "VBAT", "3.3V_2 (End)", "GND_3 (End)", "Program", "On/Off", // VUSB (33) "VUSB" }; @@ -142,19 +125,19 @@ public class Teensy extends AbstractMakerBoard { // VUSB (64, near VIN): VUSB public static final String[] PIN_NAMES_41 = new String[] { // Left row (0..23) - "GND", "0 (RX1/CS1/CRX2)", "1 (TX1/MISO1/CTX2)", "2 (OUT2)", "3 (LRCLK2)", "4 (BCLK2)", + "GND_1", "0 (RX1/CS1/CRX2)", "1 (TX1/MISO1/CTX2)", "2 (OUT2)", "3 (LRCLK2)", "4 (BCLK2)", "5 (IN2)", "6 (OUT1D)", "7 (RX2/OUT1A)", "8 (TX2/IN1)", "9 (OUT1C)", "10 (CS/MQSR)", - "11 (MOSI/CTX1)", "12 (MISO/MQSL)", "3.3V", "24 (A10/TX6/SCL2)", "25 (A11/RX6/SDA2)", + "11 (MOSI/CTX1)", "12 (MISO/MQSL)", "3.3V_1", "24 (A10/TX6/SCL2)", "25 (A11/RX6/SDA2)", "26 (A12/MOSI1)", "27 (A13/SCK1)", "28 (RX7)", "29 (TX7)", "30 (CRX3)", "31 (CTX3)", "32 (OUT1B)", // Right row (24..47) - "VIN (3.6-5.5V)", "GND", "3.3V (250mA)", "23 (A9/CRX1)", "22 (A8/CTX1)", "21 (A7/RX5)", + "VIN (3.6-5.5V)", "GND_2", "3.3V_2 (250mA)", "23 (A9/CRX1)", "22 (A8/CTX1)", "21 (A7/RX5)", "20 (A6/TX5)", "19 (A5/SCL)", "18 (A4/SDA)", "17 (A3/TX4/SDA1)", "16 (A2/RX4/SCL1)", - "15 (A1/RX3/SPDIF IN)", "14 (A0/TX3/SPDIF OUT)", "13 (SCK/LED)", "GND", "41 (A17)", + "15 (A1/RX3/SPDIF IN)", "14 (A0/TX3/SPDIF OUT)", "13 (SCK/LED)", "GND_3", "41 (A17)", "40 (A16)", "39 (A15/MISO1)", "38 (A14/CS1)", "37 (CS)", "36 (CS)", "35 (TX8)", "34 (RX8)", "33 (MCLK2)", // Middle cluster (48..52, left to right near push button) - "VBAT", "3.3V (Mid)", "GND (Mid)", "Program", "On/Off", + "VBAT", "3.3V_3 (Mid)", "GND_4 (Mid)", "Program", "On/Off", // Ethernet header (53..58, 3x2) "ETH_TX-", "ETH_LED", "ETH_RX+", "ETH_TX+", "ETH_GND", "ETH_RX-", // USB Host header (59..63, 1x5) @@ -177,14 +160,14 @@ public class Teensy extends AbstractMakerBoard { // severable VUSB pad next to Vin. Labels follow the PJRC Teensy 3.2 pinout card. public static final String[] PIN_NAMES_32 = new String[] { // Left row (0..13) - "GND", "0 (RX1)", "1 (TX1)", "2", "3 (PWM)", "4 (PWM)", "5 (PWM)", "6 (PWM)", "7 (RX3)", + "GND_1", "0 (RX1)", "1 (TX1)", "2", "3 (PWM)", "4 (PWM)", "5 (PWM)", "6 (PWM)", "7 (RX3)", "8 (TX3)", "9 (RX2/PWM)", "10 (TX2/CS/PWM)", "11 (MOSI)", "12 (MISO)", // Right row (14..27) - "Vin (3.6-6.0V)", "AGND", "3.3V (100mA)", "23 (A9/PWM)", "22 (A8/PWM)", "21 (A7/PWM)", + "Vin (3.6-6.0V)", "AGND", "3.3V_1 (100mA)", "23 (A9/PWM)", "22 (A8/PWM)", "21 (A7/PWM)", "20 (A6/PWM)", "19 (A5/SCL0)", "18 (A4/SDA0)", "17 (A3)", "16 (A2)", "15 (A1/CS)", "14 (A0)", "13 (SCK/LED)", // End row (28..32, left to right along bottom edge) - "VBAT", "3.3V (End)", "GND (End)", "Program", "A14/DAC", + "VBAT", "3.3V_2 (End)", "GND_2 (End)", "Program", "A14/DAC", // VUSB (33) "VUSB" }; @@ -238,6 +221,11 @@ public class Teensy extends AbstractMakerBoard { invalidateCache(); } + @Override + protected String getVariantLabel() { + return getVersion().toString(); + } + @EditableProperty(name = "Headers") public boolean getHeaders() { return headers; @@ -415,11 +403,9 @@ public class Teensy extends AbstractMakerBoard { drawingObserver.startTracking(); - // Fill board body (green PCB) g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor); g2d.fill(boardShape); - // Board outline g2d.setColor(getFinalBorderColor(componentState, outlineMode)); g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f)); g2d.draw(boardShape); @@ -453,7 +439,6 @@ public class Teensy extends AbstractMakerBoard { MakerBoardPainter.drawChip(g2d, chipX, chipY, chipW, chipH, getVersion() == TeensyVersion.Teensy_3_2 ? "MK20DX256" : "iMXRT1062"); - // Pushbutton (Program button) double btnW = BUTTON_WIDTH.convertToPixels(); double btnH = BUTTON_LENGTH.convertToPixels(); double btnX = centerX - btnW / 2.0; @@ -462,9 +447,9 @@ public class Teensy extends AbstractMakerBoard { : boardY + new Size(35.5d, SizeUnit.mm).convertToPixels() + new Size(0.25d, SizeUnit.in).convertToPixels(); MakerBoardPainter.drawButton(g2d, btnX, btnY, btnW, btnH); - // Teensy 4.1 extras: Ethernet PHY + SD card slot + // the 4.1's Ethernet PHY and SD card slot if (getVersion() == TeensyVersion.Teensy_4_1) { - // MicroSD card slot at bottom edge (12mm long, flush with bottom edge so it does not stick out) + // flush with the bottom edge so it does not stick out double sdW = new Size(12.0d, SizeUnit.mm).convertToPixels(); double sdH = new Size(12.0d, SizeUnit.mm).convertToPixels(); double sdX = centerX - sdW / 2.0; @@ -494,7 +479,6 @@ public class Teensy extends AbstractMakerBoard { g2d.draw(new Rectangle2D.Double(usbBoxX, usbBoxY, usbBoxW, usbBoxW)); } - // Silkscreen "TEENSY" label g2d.setColor(SILK_COLOR); g2d.setFont(SILK_FONT); String silkText = getVersion().toString(); @@ -516,7 +500,6 @@ public class Teensy extends AbstractMakerBoard { g2d.setTransform(oldTx); - // Render pin headers or gold solder pads with drill holes if (headers) { drawPinHeader(g2d, 0, controlPoints.length, outlineMode, drawingObserver); } else { @@ -526,9 +509,6 @@ public class Teensy extends AbstractMakerBoard { g2d.setComposite(oldComposite); } - /** - * Helper to draw GPIO solder pads (copper/gold pads with drill holes, square for Pin 1). - */ protected void drawSolderPads(Graphics2D g2d, boolean outlineMode, IDrawingObserver drawingObserver) { drawPcbSolderPads(g2d, 0, controlPoints.length, true, outlineMode, drawingObserver); } @@ -540,18 +520,15 @@ public class Teensy extends AbstractMakerBoard { g2d.setColor(TEENSY_GREEN.darker()); g2d.draw(new RoundRectangle2D.Double(5, 2, width - 10, height - 4, 3, 3)); - // GPIO Solder Pads in icon g2d.setColor(PAD_COLOR); for (int y = 5; y <= height - 6; y += 3) { g2d.fillRect(6, y, 2, 2); g2d.fillRect(width - 8, y, 2, 2); } - // MCU chip g2d.setColor(IC_BODY_COLOR); g2d.fillRect(10, 8, 12, 8); - // USB connector at top g2d.setColor(USB_METAL_COLOR); g2d.fillRect(12, 2, 8, 3); @@ -560,4 +537,21 @@ public class Teensy extends AbstractMakerBoard { StringUtils.drawCenteredText(g2d, "TEENSY", width / 2, height / 2 + 8, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); } + + public enum TeensyVersion { + Teensy_3_2("Teensy 3.2"), + Teensy_4_0("Teensy 4.0"), + Teensy_4_1("Teensy 4.1"); + + private final String label; + + TeensyVersion(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/micro/WemosD1Mini.java b/diylc/diylc-library/src/main/java/org/diylc/components/micro/WemosD1Mini.java index 6d869d42d..9b879f024 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/micro/WemosD1Mini.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/micro/WemosD1Mini.java @@ -59,6 +59,8 @@ public class WemosD1Mini extends AbstractMakerBoard { private static final long serialVersionUID = 1L; + private static final int PINS_PER_ROW = 8; + public static Size BOARD_WIDTH = new Size(1.0d, SizeUnit.in); public static Size BOARD_LENGTH = new Size(1.34d, SizeUnit.in); public static Size ROW_SPACING = new Size(0.9d, SizeUnit.in); @@ -92,14 +94,6 @@ public class WemosD1Mini extends AbstractMakerBoard { "5V", "G (GND)", "D4 (GPIO2)", "D3 (GPIO0)", "D2 (GPIO4)", "D1 (GPIO5)", "RX (GPIO3)", "TX (GPIO1)" }; - public static final String[] SILK_PIN_NAMES_LEFT = new String[] { - "RST", "A0", "D0", "D5", "D6", "D7", "D8", "3V3" - }; - - public static final String[] SILK_PIN_NAMES_RIGHT = new String[] { - "TX", "RX", "D1", "D2", "D3", "D4", "G", "5V" - }; - protected boolean headers = false; public WemosD1Mini() { @@ -126,23 +120,28 @@ public class WemosD1Mini extends AbstractMakerBoard { return "Pin " + (index + 1); } - @Override - protected void updateControlPoints() { - Point2D firstPoint = controlPoints[0]; + private double[][] getRelativeOffsets() { double spacing = PIN_SPACING.convertToPixels(); double rowSpacing = ROW_SPACING.convertToPixels(); - double[][] relativeOffsets = new double[16][2]; - for (int i = 0; i < 8; i++) { + double[][] relativeOffsets = new double[PIN_NAMES.length][2]; + // Left row (pins 0..7, top to bottom) + for (int i = 0; i < PINS_PER_ROW; i++) { relativeOffsets[i][0] = 0; relativeOffsets[i][1] = i * spacing; } - for (int i = 0; i < 8; i++) { - relativeOffsets[8 + i][0] = rowSpacing; - relativeOffsets[8 + i][1] = (7 - i) * spacing; + // Right row (pins 8..15, bottom to top, which is the order the board numbers them in) + for (int i = 0; i < PINS_PER_ROW; i++) { + relativeOffsets[PINS_PER_ROW + i][0] = rowSpacing; + relativeOffsets[PINS_PER_ROW + i][1] = (PINS_PER_ROW - 1 - i) * spacing; } - rotatePoints(firstPoint, relativeOffsets); + return relativeOffsets; + } + + @Override + protected void updateControlPoints() { + rotatePoints(controlPoints[0], getRelativeOffsets()); } @Override @@ -287,18 +286,19 @@ public class WemosD1Mini extends AbstractMakerBoard { StringUtils.drawCenteredText(g2d, "D1 mini", x + rowSpacing / 2.0, labelY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER); - // Silkscreen: Pin labels + // Silkscreen: pin names, lying flat beside their row as the board prints them rather than + // standing on end like drawPinLabels' -- the 0.9" row spacing leaves room for them. Both the + // text and the position come from the control point, so the right row's bottom-to-top order + // is followed rather than mirrored by a second array kept in step by hand. g2d.setFont(SILK_FONT_TINY); - double spacing = PIN_SPACING.convertToPixels(); double labelOffset = new Size(1.4d, SizeUnit.mm).convertToPixels(); - for (int i = 0; i < 8; i++) { - double pinY = y + i * spacing; - // Left pin labels - StringUtils.drawCenteredText(g2d, SILK_PIN_NAMES_LEFT[i], x + labelOffset, pinY, - HorizontalAlignment.LEFT, VerticalAlignment.CENTER); - // Right pin labels - StringUtils.drawCenteredText(g2d, SILK_PIN_NAMES_RIGHT[i], x + rowSpacing - labelOffset, pinY, - HorizontalAlignment.RIGHT, VerticalAlignment.CENTER); + double[][] offsets = getRelativeOffsets(); + for (int i = 0; i < offsets.length; i++) { + boolean leftRow = i < PINS_PER_ROW; + StringUtils.drawCenteredText(g2d, getSilkPinLabel(i), + x + offsets[i][0] + (leftRow ? labelOffset : -labelOffset), y + offsets[i][1], + leftRow ? HorizontalAlignment.LEFT : HorizontalAlignment.RIGHT, + VerticalAlignment.CENTER); } } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/modules/MT3608BoostConverter.java b/diylc/diylc-library/src/main/java/org/diylc/components/modules/MT3608BoostConverter.java index a8f52f741..e78ea52e1 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/modules/MT3608BoostConverter.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/modules/MT3608BoostConverter.java @@ -216,7 +216,7 @@ public class MT3608BoostConverter extends AbstractMakerBoard { g2d.setTransform(oldTx); // Draw square/rounded rectangular tinned solder pads using LIGHT_METAL_COLOR - drawSolderPads(g2d, 0, controlPoints.length, outlineMode, drawingObserver); + drawPcbSolderPads(g2d, 0, controlPoints.length, false, outlineMode, drawingObserver); g2d.setComposite(oldComposite); } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/modules/TP4056Charger.java b/diylc/diylc-library/src/main/java/org/diylc/components/modules/TP4056Charger.java index 02c2c379c..ff9dc847b 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/modules/TP4056Charger.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/modules/TP4056Charger.java @@ -188,7 +188,7 @@ public class TP4056Charger extends AbstractMakerBoard { g2d.setTransform(oldTx); // Draw tinned solder pads - drawSolderPads(g2d, 0, controlPoints.length, outlineMode, drawingObserver); + drawPcbSolderPads(g2d, 0, controlPoints.length, false, outlineMode, drawingObserver); g2d.setComposite(oldComposite); } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/sensors/DHTSensor.java b/diylc/diylc-library/src/main/java/org/diylc/components/sensors/DHTSensor.java index dfd3142f6..0fdf239ae 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/sensors/DHTSensor.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/sensors/DHTSensor.java @@ -88,7 +88,10 @@ public class DHTSensor extends AbstractMakerBoard { public void setModel(DHTModel model) { this.model = model; - this.bodyColor = (model == DHTModel.DHT11) ? DHT11_BLUE : DHT22_WHITE; + // Follows the model only while the colour is still a default of ours; see ArduinoUno. + if (DHT11_BLUE.equals(bodyColor) || DHT22_WHITE.equals(bodyColor)) { + this.bodyColor = model == DHTModel.DHT11 ? DHT11_BLUE : DHT22_WHITE; + } invalidateCache(); } diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/transform/LEDBarGraphTransformer.java b/diylc/diylc-library/src/main/java/org/diylc/components/transform/LEDBarGraphTransformer.java new file mode 100644 index 000000000..584fec817 --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/transform/LEDBarGraphTransformer.java @@ -0,0 +1,129 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.transform; + +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; + +import org.diylc.common.IComponentTransformer; +import org.diylc.common.Orientation; +import org.diylc.components.displays.LEDBarGraph; +import org.diylc.core.IDIYComponent; + +/** + * Rotation and mirroring for {@link LEDBarGraph}, modelled on {@link SIL_ICTransformer}. A sibling + * rather than a reuse of {@link DIL_ICTransformer}: that one guards on + * {@code getClass().equals(DIL_IC.class)} and casts to {@code DIL_IC}, which is the convention + * across this package, so it would refuse a bar graph rather than rotate it. + * + * @author Branislav Stojkovic + */ +public class LEDBarGraphTransformer implements IComponentTransformer { + + @Override + public boolean canRotate(IDIYComponent component) { + return component.getClass().equals(LEDBarGraph.class); + } + + @Override + public boolean canMirror(IDIYComponent component) { + return component.getClass().equals(LEDBarGraph.class); + } + + /** + * Mirroring reverses which end carries the first segment, so it reorders the pins without + * changing what any of them is connected to. That is unlike a DIP IC, where the function of a + * given pin number moves, which is why this returns false where {@code DIL_ICTransformer} does + * not. + */ + @Override + public boolean mirroringChangesCircuit() { + return false; + } + + @Override + public void rotate(IDIYComponent component, Point2D center, int direction) { + AffineTransform rotate = + AffineTransform.getRotateInstance(Math.PI / 2 * direction, center.getX(), center.getY()); + for (int index = 0; index < component.getControlPointCount(); index++) { + Point2D p = new Point2D.Double(); + rotate.transform(component.getControlPoint(index), p); + component.setControlPoint(p, index); + } + + LEDBarGraph bar = (LEDBarGraph) component; + Orientation o = bar.getOrientation(); + int oValue = o.ordinal(); + oValue += direction; + if (oValue < 0) { + oValue = Orientation.values().length - 1; + } + if (oValue >= Orientation.values().length) { + oValue = 0; + } + bar.setOrientation(Orientation.values()[oValue]); + } + + @SuppressWarnings("incomplete-switch") + @Override + public void mirror(IDIYComponent component, Point2D center, int direction) { + LEDBarGraph bar = (LEDBarGraph) component; + double dx = center.getX() - bar.getControlPoint(0).getX(); + double dy = center.getY() - bar.getControlPoint(0).getY(); + + // The orientation has to turn with the points: the body is drawn from it, so reflecting the + // pins alone would leave the package facing the way it did before. + if (direction == IComponentTransformer.HORIZONTAL) { + Orientation o = bar.getOrientation(); + switch (o) { + case _90: + o = Orientation._270; + break; + case _270: + o = Orientation._90; + } + + for (int i = 0; i < bar.getControlPointCount(); i++) { + Point2D p = bar.getControlPoint(i); + bar.setControlPoint(new Point2D.Double(p.getX() + 2 * dx, p.getY()), i); + } + + bar.setOrientation(o); + } else { + Orientation o = bar.getOrientation(); + switch (o) { + case DEFAULT: + o = Orientation._180; + break; + case _180: + o = Orientation.DEFAULT; + } + + for (int i = 0; i < bar.getControlPointCount(); i++) { + Point2D p = bar.getControlPoint(i); + bar.setControlPoint(new Point2D.Double(p.getX(), p.getY() + 2 * dy), i); + } + + bar.setOrientation(o); + } + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/tube/Jumbo4PinTubeSocket.java b/diylc/diylc-library/src/main/java/org/diylc/components/tube/Jumbo4PinTubeSocket.java index 0cceeba0f..25976fd05 100644 --- a/diylc/diylc-library/src/main/java/org/diylc/components/tube/Jumbo4PinTubeSocket.java +++ b/diylc/diylc-library/src/main/java/org/diylc/components/tube/Jumbo4PinTubeSocket.java @@ -74,7 +74,7 @@ public class Jumbo4PinTubeSocket extends AbstractAngledComponent { public static Size PIN_DIAMETER = new Size(1d, SizeUnit.mm); public static Size HOLE_SIZE = new Size(5d, SizeUnit.mm); public static Size OCTAL_TICK_SIZE = new Size(2d, SizeUnit.mm); - private static String[] electrodeLabels = new String[] {"G", "F", "P", "F"}; + private static String[] electrodeLabels = new String[] {"G", "F1", "P", "F2"}; private String type = ""; diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/FactoryVariantsTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/FactoryVariantsTest.java new file mode 100644 index 000000000..fb1dfdd38 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/FactoryVariantsTest.java @@ -0,0 +1,138 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.common.PropertyWrapper; +import org.diylc.core.IDIYComponent; +import org.diylc.core.Template; +import org.diylc.presenter.ComponentProcessor; +import org.diylc.serialization.ProjectFileManager; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Checks every factory variant shipped in {@code import-defaults/variants.xml} against the class it + * configures. Nothing else does, and the failure is silent: a template names its properties as + * plain strings and its enum constants through an XStream package alias, so a renamed property, a + * renamed constant or a mistyped alias gives a variant that loads and then sets nothing. The user + * picks it from the palette and gets a default component. + * + *

This goes through {@link ComponentProcessor#extractProperties} and {@link PropertyWrapper}, + * which is exactly what {@code InstantiationManager} does when it applies a template, so a variant + * that passes here is one the application can really apply. That matters more than it sounds: + * matching properties by hand gets autoboxing and inherited accessors subtly wrong, and would + * report drift that is not there. + */ +public class FactoryVariantsTest { + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + @SuppressWarnings("unchecked") + private static Map> loadVariants() { + InputStream in = FactoryVariantsTest.class.getClassLoader() + .getResourceAsStream("import-defaults/variants.xml"); + Assert.assertNotNull("variants.xml is not on the classpath", in); + return (Map>) ProjectFileManager.xStreamSerializer.fromXML(in); + } + + /** + * Problems are collected rather than asserted one at a time, so a run reports every stale + * template at once instead of stopping at the first. + */ + @Test + public void everyVariantNamesLivePropertiesAndApplies() throws Exception { + List problems = new ArrayList(); + int checked = 0; + + for (Map.Entry> entry : loadVariants().entrySet()) { + Class type; + try { + type = Class.forName(entry.getKey()); + } catch (ClassNotFoundException e) { + problems.add(entry.getKey() + ": no such class"); + continue; + } + + Map properties = new HashMap(); + for (PropertyWrapper property : ComponentProcessor.getInstance().extractProperties(type)) { + properties.put(property.getName(), property); + } + + for (Template variant : entry.getValue()) { + String where = type.getSimpleName() + " / " + variant.getName(); + if (variant.getValues() == null || variant.getValues().isEmpty()) { + problems.add(where + ": sets nothing"); + continue; + } + checked++; + + for (Map.Entry value : variant.getValues().entrySet()) { + PropertyWrapper property = properties.get(value.getKey()); + if (property == null) { + problems.add(where + ": no property named \"" + value.getKey() + "\""); + continue; + } + if (value.getValue() == null) { + continue; + } + + IDIYComponent component = + (IDIYComponent) type.getDeclaredConstructor().newInstance(); + PropertyWrapper applied = (PropertyWrapper) property.clone(); + applied.setValue(value.getValue()); + try { + applied.writeTo(component); + } catch (Exception e) { + problems.add(where + ": \"" + value.getKey() + "\" will not apply -- " + + e.getClass().getSimpleName() + " writing a " + + value.getValue().getClass().getSimpleName() + " to a " + + property.getType().getSimpleName()); + continue; + } + applied.readFrom(component); + if (!value.getValue().equals(applied.getValue())) { + problems.add(where + ": \"" + value.getKey() + "\" set to " + value.getValue() + + " but reads back " + applied.getValue()); + } + } + } + } + + Assert.assertTrue("no variants were found at all", checked > 0); + Assert.assertEquals(problems.size() + " stale factory variant value(s):\n " + + String.join("\n ", problems) + "\n", 0, problems.size()); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/CharacterLCDTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/CharacterLCDTest.java new file mode 100644 index 000000000..eb5f2969a --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/CharacterLCDTest.java @@ -0,0 +1,106 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.common.Display; +import org.diylc.components.displays.CharacterLCD.LCDInterface; +import org.diylc.components.displays.CharacterLCD.LCDSize; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * What the character LCD prints on its screen. The size and the interface are the whole of what a + * buyer chooses between and neither is legible from the drawing, so the lit area carries them. + */ +public class CharacterLCDTest { + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static CharacterLCD of(LCDSize lcdSize, LCDInterface lcdInterface) { + CharacterLCD display = new CharacterLCD(); + display.setLcdSize(lcdSize); + display.setLcdInterface(lcdInterface); + return display; + } + + @Test + public void everyScreenPrintsItsSizeAndInterface() { + for (LCDSize lcdSize : LCDSize.values()) { + for (LCDInterface lcdInterface : LCDInterface.values()) { + CharacterLCD printed = of(lcdSize, lcdInterface); + CharacterLCD blank = of(lcdSize, lcdInterface); + blank.setScreen(Display.NONE); + + Assert.assertEquals(Display.VALUE, printed.getScreen()); + Assert.assertEquals(lcdSize + ", " + lcdInterface, printed.getValueForDisplay()); + MakerBoardTestSupport.assertScreenTextIsDrawn(printed, blank); + } + } + } + + /** + * Both inks exist on real modules, so the text has to survive a screen colour the user picks. Two + * backlights at opposite ends of the range must not render the same, or one is printing its text + * in the backlight's own colour. + */ + @Test + public void theInkFollowsTheBacklight() { + CharacterLCD dark = new CharacterLCD(); + dark.setScreenColor(Color.decode("#1E88E5")); + CharacterLCD pale = new CharacterLCD(); + pale.setScreenColor(Color.decode("#CDDC39")); + + CharacterLCD darkBlank = new CharacterLCD(); + darkBlank.setScreenColor(Color.decode("#1E88E5")); + darkBlank.setScreen(Display.NONE); + CharacterLCD paleBlank = new CharacterLCD(); + paleBlank.setScreenColor(Color.decode("#CDDC39")); + paleBlank.setScreen(Display.NONE); + + MakerBoardTestSupport.assertScreenTextIsDrawn(dark, darkBlank); + MakerBoardTestSupport.assertScreenTextIsDrawn(pale, paleBlank); + } + + @Test + public void drawsCleanlyInEveryState() { + for (LCDSize lcdSize : LCDSize.values()) { + for (LCDInterface lcdInterface : LCDInterface.values()) { + for (Display screen : Display.values()) { + CharacterLCD display = of(lcdSize, lcdInterface); + display.setScreen(screen); + MakerBoardTestSupport.assertDrawsCleanly(display); + } + } + } + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/LEDBarGraphTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/LEDBarGraphTest.java new file mode 100644 index 000000000..a1f4da56b --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/LEDBarGraphTest.java @@ -0,0 +1,412 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.common.IComponentTransformer; +import org.diylc.common.Orientation; +import org.diylc.components.displays.LEDBarGraph.BarColor; +import org.diylc.components.displays.LEDBarGraph.Segments; +import org.diylc.components.transform.LEDBarGraphTransformer; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry and transform tests for the bar graph. Deliberately not in + * {@code MakerComponentsTest}: that suite covers maker boards, and this is a DIP-outline part on + * a different base class. + */ +public class LEDBarGraphTest { + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + /** Each segment has its own anode and cathode, so the package grows with the count. */ + @Test + public void pinCountIsTwicePerSegment() { + Assert.assertEquals(16, bar(Segments._8).getControlPointCount()); + Assert.assertEquals(20, bar(Segments._10).getControlPointCount()); + Assert.assertEquals(24, bar(Segments._12).getControlPointCount()); + } + + /** One pin pitch of package per segment, so the body lengthens as segments are added. */ + @Test + public void packageLengthensWithSegmentCount() { + double pitch = LEDBarGraph.PIN_SPACING.convertToPixels(); + for (Segments segments : Segments.values()) { + Rectangle2D body = bar(segments).getBody().getBounds2D(); + Assert.assertEquals("length for " + segments, segments.getCount() * pitch, body.getHeight(), + 0.5d); + } + } + + /** The width is the package's own and does not vary with the count. */ + @Test + public void packageWidthIsTheSameForEveryCount() { + double expected = LEDBarGraph.PACKAGE_WIDTH.convertToPixels(); + for (Segments segments : Segments.values()) { + Assert.assertEquals("width for " + segments, expected, + bar(segments).getBody().getBounds2D().getWidth(), 0.5d); + } + } + + @Test + public void pinsFormTwoRowsAtTheRightPitch() { + LEDBarGraph graph = bar(Segments._10); + double pitch = LEDBarGraph.PIN_SPACING.convertToPixels(); + double rowSpacing = LEDBarGraph.ROW_SPACING.convertToPixels(); + Point2D first = graph.getControlPoint(0); + + Assert.assertEquals("pitch along the near row", pitch, + graph.getControlPoint(1).getY() - first.getY(), 0.5d); + Assert.assertEquals("near row stays on one line", first.getX(), + graph.getControlPoint(9).getX(), 0.5d); + Assert.assertEquals("row spacing", rowSpacing, + graph.getControlPoint(10).getX() - first.getX(), 0.5d); + } + + /** + * The defining difference from a DIP IC: its leads leave the sides of the plastic, so its body + * sits between the rows. A bar graph's pins leave the underside, so the package is wider than the + * row spacing and every pin falls beneath it. + */ + @Test + public void bodyCoversEveryPin() { + double pinSize = (int) LEDBarGraph.PIN_SIZE.convertToPixels() / 2 * 2; + for (Orientation orientation : Orientation.values()) { + for (Segments segments : Segments.values()) { + LEDBarGraph graph = new LEDBarGraph(); + graph.setOrientation(orientation); + graph.setSegments(segments); + Rectangle2D body = graph.getBody().getBounds2D(); + for (int i = 0; i < graph.getControlPointCount(); i++) { + Point2D pin = graph.getControlPoint(i); + Rectangle2D pinRect = new Rectangle2D.Double(pin.getX() - pinSize / 2, + pin.getY() - pinSize / 2, pinSize, pinSize); + Assert.assertTrue("pin " + i + " is not covered at " + orientation + " " + segments, + body.contains(pinRect)); + } + } + } + } + + /** DIP numbering: down one side and back up the other, whatever the pin count. */ + @Test + public void pinsAreNumberedRoundThePackage() { + for (Segments segments : Segments.values()) { + LEDBarGraph graph = bar(segments); + int pins = segments.getPinCount(); + int half = pins / 2; + Assert.assertEquals("first pin, " + segments, "1", graph.getControlPointNodeName(0)); + Assert.assertEquals("last of near row, " + segments, Integer.toString(half), + graph.getControlPointNodeName(half - 1)); + Assert.assertEquals("first of far row, " + segments, Integer.toString(pins), + graph.getControlPointNodeName(half)); + Assert.assertEquals("last pin, " + segments, Integer.toString(half + 1), + graph.getControlPointNodeName(pins - 1)); + } + } + + /** + * The segment field is derived from the body, so an orientation the derivation does not handle + * shows up here rather than in a drawing nobody checks. {@code getSegmentRects} is private, so + * this reaches it reflectively rather than widening the class's surface for a test. + */ + @Test + public void segmentsStayInsideTheBody() throws Exception { + java.lang.reflect.Method segmentRects = LEDBarGraph.class.getDeclaredMethod("getSegmentRects"); + segmentRects.setAccessible(true); + + for (Orientation orientation : Orientation.values()) { + for (Segments segments : Segments.values()) { + LEDBarGraph graph = new LEDBarGraph(); + graph.setOrientation(orientation); + graph.setSegments(segments); + Rectangle2D body = graph.getBody().getBounds2D(); + Rectangle2D[] rects = (Rectangle2D[]) segmentRects.invoke(graph); + + Assert.assertEquals("segment count at " + orientation + " " + segments, + segments.getCount(), rects.length); + for (int i = 0; i < rects.length; i++) { + Assert.assertTrue("segment " + i + " escapes the body at " + orientation + " " + segments, + body.contains(rects[i])); + Assert.assertTrue("segment " + i + " has no area at " + orientation + " " + segments, + rects[i].getWidth() > 0 && rects[i].getHeight() > 0); + } + } + } + } + + /** + * Not implied by {@link #segmentsStayInsideTheBody()}: dividing the body into equal bars leaves + * every segment inside the package and still about 5 px off its own pins on a ten segment part. + */ + @Test + public void segmentsAreCentredOnTheirPins() throws Exception { + java.lang.reflect.Method segmentRects = LEDBarGraph.class.getDeclaredMethod("getSegmentRects"); + segmentRects.setAccessible(true); + + for (Orientation orientation : Orientation.values()) { + for (Segments segments : Segments.values()) { + LEDBarGraph graph = new LEDBarGraph(); + graph.setOrientation(orientation); + graph.setSegments(segments); + Rectangle2D[] rects = (Rectangle2D[]) segmentRects.invoke(graph); + + int count = segments.getCount(); + for (int i = 0; i < count; i++) { + Point2D near = graph.getControlPoint(i); + Point2D far = graph.getControlPoint(i + count); + double pinCentreX = (near.getX() + far.getX()) / 2.0; + double pinCentreY = (near.getY() + far.getY()) / 2.0; + String where = "segment " + i + " at " + orientation + " " + segments; + Assert.assertEquals(where + " is off its pins horizontally", pinCentreX, + rects[i].getCenterX(), 0.01d); + Assert.assertEquals(where + " is off its pins vertically", pinCentreY, + rects[i].getCenterY(), 0.01d); + } + } + } + } + + /** Neighbouring segments must not touch, or the bar reads as one block rather than a scale. */ + @Test + public void segmentsDoNotTouchEachOther() throws Exception { + java.lang.reflect.Method segmentRects = LEDBarGraph.class.getDeclaredMethod("getSegmentRects"); + segmentRects.setAccessible(true); + + for (Segments segments : Segments.values()) { + LEDBarGraph graph = new LEDBarGraph(); + graph.setSegments(segments); + Rectangle2D[] rects = (Rectangle2D[]) segmentRects.invoke(graph); + for (int i = 1; i < rects.length; i++) { + Assert.assertTrue("segments " + (i - 1) + " and " + i + " overlap at " + segments, + rects[i].getMinY() >= rects[i - 1].getMaxY() - 0.01d); + } + } + } + + /** + * One red at the top, two yellow below it and green for the rest, so the green band grows with + * the segment count rather than the bands being fixed fractions. + */ + @Test + public void segmentColoursRunUpTheScale() throws Exception { + java.lang.reflect.Method segmentColor = + LEDBarGraph.class.getDeclaredMethod("getSegmentColor", int.class); + segmentColor.setAccessible(true); + + for (Segments segments : Segments.values()) { + LEDBarGraph graph = bar(segments); + int count = segments.getCount(); + int red = 0; + int yellow = 0; + int green = 0; + for (int i = 0; i < count; i++) { + Object colour = segmentColor.invoke(graph, i); + if (LEDBarGraph.SEGMENT_RED.equals(colour)) { + red++; + } else if (LEDBarGraph.SEGMENT_YELLOW.equals(colour)) { + yellow++; + } else if (LEDBarGraph.SEGMENT_GREEN.equals(colour)) { + green++; + } else { + Assert.fail("segment " + i + " of " + segments + " is none of the three scale colours"); + } + } + Assert.assertEquals("red segments on " + segments, 1, red); + Assert.assertEquals("yellow segments on " + segments, 2, yellow); + Assert.assertEquals("green segments on " + segments, count - 3, green); + } + } + + /** Red belongs at the top of the scale, which is the first segment as the part is drawn. */ + @Test + public void redIsAtTheTopOfTheScale() throws Exception { + java.lang.reflect.Method segmentColor = + LEDBarGraph.class.getDeclaredMethod("getSegmentColor", int.class); + segmentColor.setAccessible(true); + + LEDBarGraph graph = bar(Segments._10); + Assert.assertEquals(LEDBarGraph.SEGMENT_RED, segmentColor.invoke(graph, 0)); + Assert.assertEquals(LEDBarGraph.SEGMENT_YELLOW, segmentColor.invoke(graph, 1)); + Assert.assertEquals(LEDBarGraph.SEGMENT_YELLOW, segmentColor.invoke(graph, 2)); + Assert.assertEquals(LEDBarGraph.SEGMENT_GREEN, segmentColor.invoke(graph, 3)); + Assert.assertEquals(LEDBarGraph.SEGMENT_GREEN, segmentColor.invoke(graph, 9)); + } + + /** + * {@code BomMaker} keys rows on the type name and the value together, so a blank value would + * merge every variant into one line. + */ + @Test + public void everyVariantGetsItsOwnBomRow() { + java.util.Set values = new java.util.HashSet(); + for (Segments segments : Segments.values()) { + String value = bar(segments).getValueForDisplay(); + Assert.assertNotNull("value for " + segments, value); + Assert.assertFalse("value for " + segments + " is blank", value.trim().isEmpty()); + values.add(value); + } + Assert.assertEquals("each variant needs a distinct BOM value", Segments.values().length, + values.size()); + } + + /** A part number the user has typed is the more specific answer, so it is kept and appended. */ + @Test + public void aTypedPartNumberIsKeptBesideTheVariant() { + LEDBarGraph graph = bar(Segments._10); + Assert.assertEquals("10 Segment", graph.getValueForDisplay()); + + graph.setValue("LTA-1000G"); + Assert.assertTrue("variant should survive a part number being typed", + graph.getValueForDisplay().contains("10 Segment")); + Assert.assertTrue("part number should reach the BOM", + graph.getValueForDisplay().contains("LTA-1000G")); + + graph.setValue(" "); + Assert.assertEquals("whitespace is not a part number", "10 Segment", + graph.getValueForDisplay()); + } + + @Test + public void transformerAcceptsThisComponent() { + LEDBarGraphTransformer transformer = new LEDBarGraphTransformer(); + LEDBarGraph graph = new LEDBarGraph(); + Assert.assertTrue("must be rotatable", transformer.canRotate(graph)); + Assert.assertTrue("must be mirrorable", transformer.canMirror(graph)); + Assert.assertFalse("mirroring reorders pins but changes no connection", + transformer.mirroringChangesCircuit()); + } + + @Test + public void fourRotationsReturnToStart() { + LEDBarGraphTransformer transformer = new LEDBarGraphTransformer(); + LEDBarGraph graph = new LEDBarGraph(); + Point2D start = + new Point2D.Double(graph.getControlPoint(0).getX(), graph.getControlPoint(0).getY()); + Point2D centre = new Point2D.Double(100, 100); + for (int i = 0; i < 4; i++) { + transformer.rotate(graph, centre, 1); + } + Assert.assertEquals("orientation returns", Orientation.DEFAULT, graph.getOrientation()); + Assert.assertTrue("anchor returns", graph.getControlPoint(0).distance(start) < 0.5d); + } + + /** Mirroring has to turn the orientation too, or the body faces the way it did before. */ + @Test + public void mirroringFlipsTheOrientation() { + LEDBarGraphTransformer transformer = new LEDBarGraphTransformer(); + Point2D centre = new Point2D.Double(100, 100); + + LEDBarGraph horizontal = new LEDBarGraph(); + horizontal.setOrientation(Orientation._90); + transformer.mirror(horizontal, centre, IComponentTransformer.HORIZONTAL); + Assert.assertEquals(Orientation._270, horizontal.getOrientation()); + + LEDBarGraph vertical = new LEDBarGraph(); + vertical.setOrientation(Orientation.DEFAULT); + transformer.mirror(vertical, centre, IComponentTransformer.VERTICAL); + Assert.assertEquals(Orientation._180, vertical.getOrientation()); + } + + @Test + public void singleColorsRenderUniformSegments() throws Exception { + java.lang.reflect.Method segmentColor = + LEDBarGraph.class.getDeclaredMethod("getSegmentColor", int.class); + segmentColor.setAccessible(true); + + BarColor[] singleColors = new BarColor[] { + BarColor.RED, BarColor.GREEN, BarColor.YELLOW, BarColor.BLUE, BarColor.ORANGE, + BarColor.WHITE + }; + + for (BarColor color : singleColors) { + LEDBarGraph graph = bar(Segments._10); + graph.setBarColor(color); + for (int i = 0; i < 10; i++) { + Assert.assertEquals("segment " + i + " for " + color, + color.getSegmentColor(i, 10), segmentColor.invoke(graph, i)); + } + } + } + + @Test + public void blueGreenYellowRedDistribution() throws Exception { + java.lang.reflect.Method segmentColor = + LEDBarGraph.class.getDeclaredMethod("getSegmentColor", int.class); + segmentColor.setAccessible(true); + + LEDBarGraph graph10 = bar(Segments._10); + graph10.setBarColor(BarColor.BLUE_GREEN_YELLOW_RED); + Assert.assertEquals(LEDBarGraph.SEGMENT_RED, segmentColor.invoke(graph10, 0)); + Assert.assertEquals(LEDBarGraph.SEGMENT_RED, segmentColor.invoke(graph10, 1)); + Assert.assertEquals(LEDBarGraph.SEGMENT_YELLOW, segmentColor.invoke(graph10, 2)); + Assert.assertEquals(LEDBarGraph.SEGMENT_YELLOW, segmentColor.invoke(graph10, 3)); + Assert.assertEquals(LEDBarGraph.SEGMENT_YELLOW, segmentColor.invoke(graph10, 4)); + Assert.assertEquals(LEDBarGraph.SEGMENT_GREEN, segmentColor.invoke(graph10, 5)); + Assert.assertEquals(LEDBarGraph.SEGMENT_GREEN, segmentColor.invoke(graph10, 8)); + Assert.assertEquals(LEDBarGraph.SEGMENT_BLUE, segmentColor.invoke(graph10, 9)); + + LEDBarGraph graph4 = bar(Segments._4); + graph4.setBarColor(BarColor.BLUE_GREEN_YELLOW_RED); + Assert.assertEquals(LEDBarGraph.SEGMENT_RED, segmentColor.invoke(graph4, 0)); + Assert.assertEquals(LEDBarGraph.SEGMENT_YELLOW, segmentColor.invoke(graph4, 1)); + Assert.assertEquals(LEDBarGraph.SEGMENT_GREEN, segmentColor.invoke(graph4, 2)); + Assert.assertEquals(LEDBarGraph.SEGMENT_BLUE, segmentColor.invoke(graph4, 3)); + } + + @Test + public void colorVariantDistinguishesBomRow() { + LEDBarGraph defaultGraph = bar(Segments._10); + LEDBarGraph redGraph = bar(Segments._10); + redGraph.setBarColor(BarColor.RED); + LEDBarGraph bgyrGraph = bar(Segments._10); + bgyrGraph.setBarColor(BarColor.BLUE_GREEN_YELLOW_RED); + + Assert.assertEquals("10 Segment", defaultGraph.getValueForDisplay()); + Assert.assertEquals("10 Segment, Red", redGraph.getValueForDisplay()); + Assert.assertEquals("10 Segment, Blue-Green-Yellow-Red", bgyrGraph.getValueForDisplay()); + } + + @Test + public void barColorDefaultsWhenNull() { + LEDBarGraph graph = new LEDBarGraph(); + graph.setBarColor(null); + Assert.assertEquals(BarColor.GREEN_YELLOW_RED, graph.getBarColor()); + } + + private static LEDBarGraph bar(Segments segments) { + LEDBarGraph graph = new LEDBarGraph(); + graph.setSegments(segments); + return graph; + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/LEDMatrixTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/LEDMatrixTest.java new file mode 100644 index 000000000..335adeff9 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/LEDMatrixTest.java @@ -0,0 +1,151 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.geom.Rectangle2D; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.components.displays.LEDMatrix.Modules; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Basic checks for the MAX7219 matrix. The three variants differ in outline and in which edge + * their headers leave by, so each one places the same ten pins differently. + */ +public class LEDMatrixTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static LEDMatrix of(Modules modules) { + LEDMatrix matrix = new LEDMatrix(); + matrix.setModules(modules); + return matrix; + } + + @Test + public void boardSizeFollowsTheModules() { + for (Modules modules : Modules.values()) { + Rectangle2D bounds = of(modules).getBodyShape().getBounds2D(); + Assert.assertEquals(modules + " width", modules.getBoardWidthMm(), mm(bounds.getWidth()), + 0.01d); + Assert.assertEquals(modules + " length", modules.getBoardHeightMm(), mm(bounds.getHeight()), + 0.01d); + } + } + + /** + * Every board is exactly as wide as its modules. Asserted as an equality rather than a fit, + * because that is where the cascaded boards' widths come from: a transcribed 128 or 256 that did + * not match the module count would otherwise pass. + */ + @Test + public void theBoardIsExactlyAsWideAsItsModules() { + for (Modules modules : Modules.values()) { + double matrixSize = mm(LEDMatrix.MATRIX_SIZE.convertToPixels()); + Assert.assertEquals(modules + " width", modules.getCount() * matrixSize, + modules.getBoardWidthMm(), 0.01d); + } + } + + @Test + public void controlPointsAreTheTwoHeadersInOrder() { + String[] expected = new String[] {"VCC_IN", "GND_IN", "DIN", "CS_IN", "CLK_IN", "VCC_OUT", + "GND_OUT", "DOUT", "CS_OUT", "CLK_OUT"}; + + for (Modules modules : Modules.values()) { + LEDMatrix matrix = of(modules); + Assert.assertEquals(modules.toString(), expected.length, matrix.getControlPointCount()); + + Set names = new HashSet(); + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals(modules + " pin " + i, expected[i], matrix.getControlPointNodeName(i)); + Assert.assertTrue(modules + " duplicate node name " + expected[i], names.add(expected[i])); + } + } + } + + /** + * The tall single runs its rows across the width; the tileable variants stand them on end at the + * short edges. + */ + @Test + public void eachHeaderIsAFivePinRow() { + for (Modules modules : Modules.values()) { + LEDMatrix matrix = of(modules); + MakerBoardTestSupport.assertRow(matrix, 0, 4); + MakerBoardTestSupport.assertRow(matrix, 5, 9); + } + } + + @Test + public void everyPinSitsOnTheBoard() { + for (Modules modules : Modules.values()) { + LEDMatrix matrix = of(modules); + Rectangle2D board = matrix.getBodyShape().getBounds2D(); + for (int i = 0; i < matrix.getControlPointCount(); i++) { + Assert.assertTrue(modules + " pin " + i + " is off the board", + board.contains(matrix.getControlPoint(i))); + } + } + } + + @Test + public void modulesRoundTripThroughTheSetter() { + Assert.assertEquals(Modules.Single_8x8, new LEDMatrix().getModules()); + for (Modules modules : Modules.values()) { + LEDMatrix matrix = of(modules); + Assert.assertEquals(modules, matrix.getModules()); + Assert.assertEquals(modules.toString(), matrix.getValueForDisplay()); + } + } + + /** A project saved before the variant property existed deserialises with no modules set. */ + @Test + public void aMissingModulesValueFallsBackToTheSingle() { + LEDMatrix matrix = of(null); + Assert.assertEquals(Modules.Single_8x8, matrix.getModules()); + } + + @Test + public void drawsCleanlyInEveryState() { + for (Modules modules : Modules.values()) { + MakerBoardTestSupport.assertDrawsCleanly(of(modules)); + } + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/Nokia5110LCDTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/Nokia5110LCDTest.java new file mode 100644 index 000000000..b99b74502 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/Nokia5110LCDTest.java @@ -0,0 +1,195 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.common.Display; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry tests for the Nokia 5110 module. Its pin row is on the bottom edge, so the board grows + * upwards from control point zero and every feature is measured down from an edge the control + * points do not touch. + */ +public class Nokia5110LCDTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static Nokia5110LCD of(Color screenColor) { + Nokia5110LCD lcd = new Nokia5110LCD(); + lcd.setScreenColor(screenColor); + return lcd; + } + + @Test + public void boardIsTheModuleOutline() { + Rectangle2D bounds = new Nokia5110LCD().getBodyShape().getBounds2D(); + Assert.assertEquals("width", 43.8d, mm(bounds.getWidth()), 0.01d); + Assert.assertEquals("length", 45.8d, mm(bounds.getHeight()), 0.01d); + } + + @Test + public void controlPointsAreTheEightPinsInOrder() { + Nokia5110LCD display = new Nokia5110LCD(); + String[] expected = new String[] {"RST", "CE", "DC", "DIN", "CLK", "VCC", "BL", "GND"}; + Assert.assertEquals(expected.length, display.getControlPointCount()); + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals("pin " + i, expected[i], display.getControlPointNodeName(i)); + } + } + + @Test + public void pinsSitAtHeaderPitchAlongOneRow() { + Nokia5110LCD display = new Nokia5110LCD(); + for (int i = 1; i < display.getControlPointCount(); i++) { + Point2D previous = display.getControlPoint(i - 1); + Point2D current = display.getControlPoint(i); + Assert.assertEquals("pitch at pin " + i, 2.54d, mm(current.getX() - previous.getX()), 0.01d); + Assert.assertEquals("pin " + i + " left the row", previous.getY(), current.getY(), 0.01d); + } + } + + /** Clear of the bottom edge rather than the top, which is the easy thing to get backwards. */ + @Test + public void headerIsCentredOnTheBottomEdge() { + Nokia5110LCD display = new Nokia5110LCD(); + Rectangle2D board = display.getBodyShape().getBounds2D(); + Point2D first = display.getControlPoint(0); + Point2D last = display.getControlPoint(display.getControlPointCount() - 1); + + Assert.assertEquals("left margin", board.getMaxX() - last.getX(), first.getX() - board.getX(), + 0.01d); + Assert.assertEquals("clearance from the bottom edge", 3.5d, mm(board.getMaxY() - first.getY()), + 0.01d); + Assert.assertTrue("the row sits below the glass", first.getY() > board.getCenterY()); + } + + /** + * The glass is placed from the board's top edge rather than centred in the frame, so nothing + * keeps the two in step on its own. + */ + @Test + public void glassSitsInsideTheBezel() { + double bezelTop = Nokia5110LCD.BEZEL_TOP_OFFSET.convertToPixels(); + double bezelBottom = bezelTop + Nokia5110LCD.BEZEL_LENGTH.convertToPixels(); + double glassTop = Nokia5110LCD.DISPLAY_TOP_OFFSET.convertToPixels(); + double glassBottom = glassTop + Nokia5110LCD.DISPLAY_LENGTH.convertToPixels(); + + Assert.assertTrue("glass reaches above the bezel", glassTop >= bezelTop); + Assert.assertTrue("glass reaches below the bezel", glassBottom <= bezelBottom); + Assert.assertTrue("glass is wider than the bezel", Nokia5110LCD.DISPLAY_WIDTH + .convertToPixels() <= Nokia5110LCD.BEZEL_WIDTH.convertToPixels()); + Assert.assertTrue("bezel runs off the bottom of the board", + bezelBottom <= Nokia5110LCD.BOARD_LENGTH.convertToPixels()); + } + + /** + * Catches the outline being entered the wrong way round: with the two figures swapped the + * vertical pair sits 1.4 mm from the edge and a 3 mm hole breaches it. + */ + @Test + public void mountingHolesClearTheBoardEdges() { + double insetX = (Nokia5110LCD.BOARD_WIDTH.convertToPixels() + - Nokia5110LCD.MOUNTING_HOLE_SPACING_X.convertToPixels()) / 2.0; + double insetY = (Nokia5110LCD.BOARD_LENGTH.convertToPixels() + - Nokia5110LCD.MOUNTING_HOLE_SPACING_Y.convertToPixels()) / 2.0; + double radius = Nokia5110LCD.MOUNTING_HOLE_SIZE.convertToPixels() / 2.0; + + Assert.assertTrue("holes breach the side edges", insetX >= radius); + Assert.assertTrue("holes breach the top and bottom edges", insetY >= radius); + } + + /** + * The strip between the pin row and the bottom edge is the one place on this board where a label + * can run out of room, being only as deep as the header's clearance from the edge. + */ + @Test + public void silkLabelsStayInTheStripBelowThePins() { + Assert.assertTrue("the labels sit past the bottom edge", + AbstractMakerBoard.PIN_ROW_FLAT_LABEL_OFFSET.convertToPixels() < Nokia5110LCD.HEADER_OFFSET + .convertToPixels()); + Assert.assertTrue("the labels sit on top of the pins", + AbstractMakerBoard.PIN_ROW_FLAT_LABEL_OFFSET.convertToPixels() > AbstractMakerBoard.PIN_SIZE + .convertToPixels()); + } + + /** No variant property, but a resolution and a controller worth printing. */ + @Test + public void theGlassPrintsTheResolutionAndController() { + Nokia5110LCD printed = new Nokia5110LCD(); + Nokia5110LCD blank = new Nokia5110LCD(); + blank.setScreen(Display.NONE); + + Assert.assertEquals("84x48 PCD8544", printed.getValueForDisplay()); + Assert.assertEquals(Display.VALUE, printed.getScreen()); + MakerBoardTestSupport.assertScreenTextIsDrawn(printed, blank); + } + + /** + * The dark ink the glass normally carries disappears into the darker backlights, so two at + * opposite ends of the range must each still show their text. + */ + @Test + public void theInkFollowsTheBacklight() { + Nokia5110LCD dark = of(Color.decode("#1B3C73")); + Nokia5110LCD pale = of(Color.decode("#6B7668")); + + Nokia5110LCD darkBlank = of(Color.decode("#1B3C73")); + darkBlank.setScreen(Display.NONE); + Nokia5110LCD paleBlank = of(Color.decode("#6B7668")); + paleBlank.setScreen(Display.NONE); + + MakerBoardTestSupport.assertScreenTextIsDrawn(dark, darkBlank); + MakerBoardTestSupport.assertScreenTextIsDrawn(pale, paleBlank); + } + + /** A project saved before the backlight colour existed deserialises with none set. */ + @Test + public void aMissingBacklightColourFallsBackToTheGlass() { + Assert.assertEquals(Nokia5110LCD.LCD_COLOR, of(null).getScreenColor()); + } + + @Test + public void drawsCleanlyInEveryState() { + MakerBoardTestSupport.assertDrawsCleanly(new Nokia5110LCD()); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/OLEDDisplayTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/OLEDDisplayTest.java new file mode 100644 index 000000000..1157becc6 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/OLEDDisplayTest.java @@ -0,0 +1,621 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Color; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.common.Display; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.displays.OLEDDisplay.Layout; +import org.diylc.components.displays.OLEDDisplay.OLEDInterface; +import org.diylc.components.displays.OLEDDisplay.Version; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry tests for the three OLED modules. The interface is not orthogonal to the size: the + * 0.91" is sold as two different boards, so every check that reads a board figure reads it for a + * size and an interface together. + */ +public class OLEDDisplayTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static OLEDDisplay of(Version version, OLEDInterface oledInterface) { + OLEDDisplay display = new OLEDDisplay(); + display.setVersion(version); + display.setOledInterface(oledInterface); + return display; + } + + @Test + public void boardSizeFollowsTheVersionAndInterface() { + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + Layout layout = version.getLayout(oledInterface); + Rectangle2D bounds = of(version, oledInterface).getBodyShape().getBounds2D(); + Assert.assertEquals(version + " " + oledInterface + " width", layout.getBoardWidthMm(), + mm(bounds.getWidth()), 0.01d); + Assert.assertEquals(version + " " + oledInterface + " length", layout.getBoardLengthMm(), + mm(bounds.getHeight()), 0.01d); + } + } + } + + /** + * On the 0.91" the two interfaces are not one board with a longer header but two boards, + * differing in outline, in which edge the header sits on and in whether there are mounting holes + * at all. The other two sizes do carry one board across both interfaces. + */ + @Test + public void theSmallBoardIsTwoDifferentBoards() { + Layout i2c = Version.SSD1306_0_91.getLayout(OLEDInterface.I2C_4Pin); + Layout spi = Version.SSD1306_0_91.getLayout(OLEDInterface.SPI_7Pin); + + Assert.assertEquals("I2C width", 35.8d, i2c.getBoardWidthMm(), 0.01d); + Assert.assertEquals("I2C length", 12.0d, i2c.getBoardLengthMm(), 0.01d); + Assert.assertEquals("SPI width", 32.5d, spi.getBoardWidthMm(), 0.01d); + Assert.assertEquals("SPI length", 20.5d, spi.getBoardLengthMm(), 0.01d); + Assert.assertTrue("the I2C column should stand on the left edge", i2c.isHeaderOnLeftEdge()); + Assert.assertFalse("the SPI row should lie along the top edge", spi.isHeaderOnLeftEdge()); + Assert.assertFalse("the I2C board has no mounting holes", i2c.hasMountingHoles()); + Assert.assertTrue("the SPI board has four mounting holes", spi.hasMountingHoles()); + + for (Version version : new Version[] {Version.SSD1306_0_96, Version.SH1106_1_3}) { + Assert.assertSame(version + " should carry one board across both interfaces", + version.getLayout(OLEDInterface.I2C_4Pin), version.getLayout(OLEDInterface.SPI_7Pin)); + } + } + + @Test + public void controlPointsAreTheI2CPinsInOrder() { + OLEDDisplay display = of(Version.SSD1306_0_96, OLEDInterface.I2C_4Pin); + String[] expected = new String[] {"GND", "VCC", "SCL", "SDA"}; + Assert.assertEquals(expected.length, display.getControlPointCount()); + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals("pin " + i, expected[i], display.getControlPointNodeName(i)); + } + } + + @Test + public void controlPointsAreTheSpiPinsInOrder() { + OLEDDisplay display = of(Version.SSD1306_0_96, OLEDInterface.SPI_7Pin); + String[] expected = new String[] {"GND", "VCC", "SCL", "SDA", "RES", "DC", "CS"}; + Assert.assertEquals(expected.length, display.getControlPointCount()); + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals("pin " + i, expected[i], display.getControlPointNodeName(i)); + } + } + + @Test + public void controlPointsAreTheEightPinRowInOrder() { + OLEDDisplay display = of(Version.SSD1306_0_96, OLEDInterface.SPI_8Pin); + String[] expected = new String[] {"SDA", "SCL", "DC", "RES", "CS", "VDD", "VIN", "GND"}; + Assert.assertEquals(expected.length, display.getControlPointCount()); + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals("pin " + i, expected[i], display.getControlPointNodeName(i)); + } + } + + /** Every size is sold in every interface, so no size may carry a shorter pin array. */ + @Test + public void everyInterfaceExistsAtEverySize() { + for (Version version : Version.values()) { + Assert.assertEquals(version + " I2C", 4, + of(version, OLEDInterface.I2C_4Pin).getControlPointCount()); + Assert.assertEquals(version + " SPI", 7, + of(version, OLEDInterface.SPI_7Pin).getControlPointCount()); + Assert.assertEquals(version + " SPI-6", 6, + of(version, OLEDInterface.SPI_6Pin).getControlPointCount()); + Assert.assertEquals(version + " SPI-8", 8, + of(version, OLEDInterface.SPI_8Pin).getControlPointCount()); + } + } + + /** + * The 6-pin board is the 7-pin one with the chip select tied low, so its row is the other's + * without that pin and in the same order. It borrows the 7-pin board's outline, which is a + * derivation rather than a measurement. + */ + @Test + public void theSixPinBoardDropsOnlyTheChipSelect() { + String[] seven = OLEDInterface.SPI_7Pin.getPinNames(); + String[] six = OLEDInterface.SPI_6Pin.getPinNames(); + + Assert.assertEquals("one pin fewer", seven.length - 1, six.length); + Assert.assertEquals("CS is the pin that goes", "CS", seven[seven.length - 1]); + for (int i = 0; i < six.length; i++) { + Assert.assertEquals("pin " + i, seven[i], six[i]); + } + + for (Version version : Version.values()) { + Assert.assertSame(version + " shares the SPI board", + version.getLayout(OLEDInterface.SPI_7Pin), version.getLayout(OLEDInterface.SPI_6Pin)); + } + } + + /** + * The eight-pin row cannot be compared in order, following the controller's pin numbering rather + * than the silk the others print, so what has to hold is that the set has grown by exactly VDD. + * VDD and VIN are separate rails, so neither may be renamed to the other or to VCC, which a set + * comparison alone would let through. + */ + @Test + public void theEightPinBoardAddsOnlyTheLogicSupply() { + Set seven = new HashSet(Arrays.asList(OLEDInterface.SPI_7Pin.getPinNames())); + Set eight = new HashSet(Arrays.asList(OLEDInterface.SPI_8Pin.getPinNames())); + + Assert.assertEquals("no pin names repeat in the row", 8, + OLEDInterface.SPI_8Pin.getPinNames().length); + Assert.assertEquals("no pin names repeat in the row", 8, eight.size()); + Assert.assertTrue("VIN replaces the shorter rows' VCC", eight.contains("VIN")); + Assert.assertTrue("the logic rail is the pin that is added", eight.contains("VDD")); + Assert.assertFalse("VCC belongs to the rows with one supply", eight.contains("VCC")); + + seven.remove("VCC"); + eight.remove("VIN"); + eight.remove("VDD"); + Assert.assertEquals("the signal lines are the seven-pin row's", seven, eight); + } + + /** Switching either property must resize the array, not leave the longer one's points behind. */ + @Test + public void switchingInterfaceResizesTheControlPoints() { + OLEDDisplay display = new OLEDDisplay(); + Assert.assertEquals(4, display.getControlPointCount()); + display.setOledInterface(OLEDInterface.SPI_7Pin); + Assert.assertEquals(7, display.getControlPointCount()); + display.setVersion(Version.SSD1306_0_91); + Assert.assertEquals(7, display.getControlPointCount()); + display.setOledInterface(OLEDInterface.SPI_6Pin); + Assert.assertEquals(6, display.getControlPointCount()); + display.setOledInterface(OLEDInterface.SPI_8Pin); + Assert.assertEquals(8, display.getControlPointCount()); + display.setOledInterface(OLEDInterface.I2C_4Pin); + Assert.assertEquals(4, display.getControlPointCount()); + } + + @Test + public void pinsSitAtHeaderPitchAlongOneLine() { + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + OLEDDisplay display = of(version, oledInterface); + boolean vertical = version.getLayout(oledInterface).isHeaderOnLeftEdge(); + for (int i = 1; i < display.getControlPointCount(); i++) { + Point2D previous = display.getControlPoint(i - 1); + Point2D current = display.getControlPoint(i); + String pin = version + " " + oledInterface + " pin " + i; + Assert.assertEquals(pin + " pitch", 2.54d, + mm(vertical ? current.getY() - previous.getY() : current.getX() - previous.getX()), + 0.01d); + Assert.assertEquals(pin + " left the line", vertical ? previous.getX() : previous.getY(), + vertical ? current.getX() : current.getY(), 0.01d); + } + } + } + } + + /** + * Nothing else in the geometry would notice if a board were placed from its first pin instead of + * being centred: a four-pin row would sit in a corner and a seven-pin one would look very nearly + * centred anyway. + */ + @Test + public void headerRowIsCentredOnTheTopEdge() { + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + Layout layout = version.getLayout(oledInterface); + if (layout.isHeaderOnLeftEdge()) { + continue; + } + OLEDDisplay display = of(version, oledInterface); + Rectangle2D board = display.getBodyShape().getBounds2D(); + Point2D first = display.getControlPoint(0); + Point2D last = display.getControlPoint(display.getControlPointCount() - 1); + + Assert.assertEquals(version + " " + oledInterface + " margins", + board.getMaxX() - last.getX(), first.getX() - board.getX(), 0.01d); + Assert.assertEquals(version + " " + oledInterface + " clearance from the top edge", + layout.getHeaderOffsetMm(), mm(first.getY() - board.getY()), 0.01d); + } + } + } + + /** + * The column stands its clearance in from the left edge and is centred on the height. A board + * drawn down and left from its first pin instead would keep every pitch and size assertion above. + */ + @Test + public void headerColumnStandsAgainstTheLeftEdge() { + OLEDDisplay display = of(Version.SSD1306_0_91, OLEDInterface.I2C_4Pin); + Rectangle2D board = display.getBodyShape().getBounds2D(); + Point2D first = display.getControlPoint(0); + Point2D last = display.getControlPoint(display.getControlPointCount() - 1); + + Assert.assertEquals("clearance from the left edge", 1.5d, mm(first.getX() - board.getX()), + 0.01d); + Assert.assertEquals("the column is centred on the height", board.getMaxY() - last.getY(), + first.getY() - board.getY(), 0.01d); + } + + /** + * A lit area's aspect is very nearly the pixel aspect: 2:1 on the two 128 x 64 panels and 4:1 on + * the 128 x 32. The two derived panels sit exactly on it; the measured 0.96" comes out a little + * under, which the tolerance allows while still catching a transposition. + */ + @Test + public void litAreaMatchesThePixelAspect() { + Assert.assertEquals(2.0d, aspect(Version.SSD1306_0_96), 0.05d); + Assert.assertEquals(4.0d, aspect(Version.SSD1306_0_91), 0.01d); + Assert.assertEquals(2.0d, aspect(Version.SH1106_1_3), 0.01d); + } + + private static double aspect(Version version) { + return version.getActiveWidthMm() / version.getActiveLengthMm(); + } + + @Test + public void litAreaSitsWithinThePanel() { + for (Version version : Version.values()) { + Assert.assertTrue(version + " lit area is wider than its panel", + version.getActiveLeftInPanelMm() + version.getActiveWidthMm() <= version + .getPanelWidthMm()); + Assert.assertTrue(version + " lit area is longer than its panel", + version.getActiveLengthMm() <= version.getPanelLengthMm()); + Assert.assertTrue(version + " lit area starts off the panel", + version.getActiveLeftInPanelMm() >= 0); + + for (OLEDInterface oledInterface : OLEDInterface.values()) { + Layout layout = version.getLayout(oledInterface); + String board = version + " " + oledInterface; + Assert.assertTrue(board + " lit area starts above its panel", + layout.getActiveTopMm() >= layout.getPanelTopMm()); + Assert.assertTrue(board + " lit area runs off the bottom of its panel", + layout.getActiveTopMm() + version.getActiveLengthMm() <= layout.getPanelTopMm() + + version.getPanelLengthMm()); + } + } + } + + @Test + public void panelFitsWithinTheBoard() { + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + Layout layout = version.getLayout(oledInterface); + String board = version + " " + oledInterface; + Assert.assertTrue(board + " panel runs off the right edge", layout.getPanelLeftMm() + + version.getPanelWidthMm() <= layout.getBoardWidthMm()); + Assert.assertTrue(board + " panel runs off the bottom edge", layout.getPanelTopMm() + + version.getPanelLengthMm() <= layout.getBoardLengthMm()); + } + } + } + + /** + * Where the lit area lands on the board. Getting either half wrong moves it while leaving every + * size and clearance assertion intact, and swapping the two between the boards leaves both + * plausible -- which is what {@link #pinsStayOffThePanel} is for. + */ + @Test + public void litAreaSitsWhereItWasMeasured() { + Assert.assertEquals("0.91\" I2C", 7.1d, + litAreaLeftMm(Version.SSD1306_0_91, OLEDInterface.I2C_4Pin), 0.01d); + Assert.assertEquals("0.91\" SPI", 3.35d, + litAreaLeftMm(Version.SSD1306_0_91, OLEDInterface.SPI_7Pin), 0.01d); + } + + /** + * No pin may land on the glass. Every other check measures features against each other, so the + * two 0.91" panel offsets could be swapped between the boards and pass all of them -- and + * swapped, the I2C board's four pins are drawn on the display. + */ + @Test + public void pinsStayOffThePanel() { + double pinRadius = AbstractMakerBoard.PIN_SIZE.convertToPixels() / 2.0; + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + Layout layout = version.getLayout(oledInterface); + OLEDDisplay display = of(version, oledInterface); + Rectangle2D board = display.getBodyShape().getBounds2D(); + Rectangle2D panel = new Rectangle2D.Double( + board.getX() + mmToPx(layout.getPanelLeftMm()), + board.getY() + mmToPx(layout.getPanelTopMm()), + mmToPx(version.getPanelWidthMm()), mmToPx(version.getPanelLengthMm())); + + for (int i = 0; i < display.getControlPointCount(); i++) { + Point2D pin = display.getControlPoint(i); + Rectangle2D pad = new Rectangle2D.Double(pin.getX() - pinRadius, pin.getY() - pinRadius, + 2 * pinRadius, 2 * pinRadius); + Assert.assertFalse(version + " " + oledInterface + " pin " + i + " sits on the panel", + panel.intersects(pad)); + } + } + } + } + + private static double mmToPx(double millimetres) { + return millimetres * PX_PER_MM; + } + + private static double litAreaLeftMm(Version version, OLEDInterface oledInterface) { + return version.getLayout(oledInterface).getPanelLeftMm() + version.getActiveLeftInPanelMm(); + } + + /** + * The 0.91" SPI board's measured 1.25 mm panel offset is exactly centred on its measured 32.5 mm + * width, two independent readings agreeing. The vertical pair do not: the panel hangs below the + * header rather than sitting centred, so there is nothing to cross-check down the board. + */ + @Test + public void theSmallSpiBoardCentresItsPanelAcrossTheBoard() { + Version version = Version.SSD1306_0_91; + Layout layout = version.getLayout(OLEDInterface.SPI_7Pin); + Assert.assertEquals("the panel is off centre across the board", + (layout.getBoardWidthMm() - version.getPanelWidthMm()) / 2.0, layout.getPanelLeftMm(), + 0.01d); + } + + /** + * Every lit area is measured down from its board's top edge rather than centred on the panel, + * the driver's bonding region taking the bottom of the glass. The centred position is asserted + * beside each measurement because a stale one would still land inside the panel and clear every + * pin and hole. + */ + @Test + public void measuredLitAreasSitHighInTheirPanels() { + assertLitAreaTop(Version.SSD1306_0_96, OLEDInterface.I2C_4Pin, 6.14d, 8.0d); + assertLitAreaTop(Version.SSD1306_0_91, OLEDInterface.I2C_4Pin, 2.35d, 3.208d); + assertLitAreaTop(Version.SSD1306_0_91, OLEDInterface.SPI_7Pin, 6.6d, 7.208d); + assertLitAreaTop(Version.SH1106_1_3, OLEDInterface.I2C_4Pin, 7.35d, 9.4d); + } + + private static void assertLitAreaTop(Version version, OLEDInterface oledInterface, + double measuredMm, double centredMm) { + Layout layout = version.getLayout(oledInterface); + Assert.assertEquals(version + " measured from the top edge", measuredMm, + layout.getActiveTopMm(), 0.01d); + Assert.assertEquals(version + " centring would drop it", centredMm, layout.getPanelTopMm() + + (version.getPanelLengthMm() - version.getActiveLengthMm()) / 2.0, 0.01d); + } + + /** + * The panel shares the holes' horizontal span on every board, so only the vertical axis can keep + * them apart and a symmetric check would fail on a board that is drawn correctly. The 0.91" SPI + * board is the tight one, clearing its header-side holes by 0.5 mm. + */ + @Test + public void panelClearsTheMountingHolesVertically() { + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + Layout layout = version.getLayout(oledInterface); + if (!layout.hasMountingHoles()) { + continue; + } + double holeRadius = layout.getHoleSizeMm() / 2.0; + double panelTop = layout.getPanelTopMm(); + double panelBottom = panelTop + version.getPanelLengthMm(); + String board = version + " " + oledInterface; + + Assert.assertTrue(board + " panel overlaps the header-side holes", + panelTop >= layout.getHoleInsetMm() + holeRadius); + Assert.assertTrue(board + " panel overlaps the far holes", panelBottom <= layout + .getBoardLengthMm() - layout.getHoleInsetMm() - holeRadius); + } + } + } + + /** + * The strip the pin names have to fit into. The 0.96"'s 2.27 mm is the shallowest, and the + * reason these boards place their own labels rather than using the shared helper. + */ + @Test + public void panelClearsThePinRow() { + Assert.assertEquals("0.96\"", 2.27d, stripDepthMm(Version.SSD1306_0_96), 0.01d); + Assert.assertEquals("0.91\" SPI", 2.5d, stripDepthMm(Version.SSD1306_0_91), 0.01d); + Assert.assertEquals("1.3\"", 3.75d, stripDepthMm(Version.SH1106_1_3), 0.01d); + } + + private static double stripDepthMm(Version version) { + Layout layout = version.getLayout(OLEDInterface.SPI_7Pin); + return layout.getPanelTopMm() - layout.getHeaderOffsetMm(); + } + + @Test + public void mountingHolesSitInsideTheBoard() { + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + Layout layout = version.getLayout(oledInterface); + if (!layout.hasMountingHoles()) { + continue; + } + Assert.assertTrue(version + " " + oledInterface + " holes breach an edge", + layout.getHoleInsetMm() >= layout.getHoleSizeMm() / 2.0); + } + } + } + + /** + * Two panels sit centred on their board, and their stored offsets are that centred position + * rather than a measurement. Checking it keeps the two in step, so that a later correction to a + * panel or a board cannot leave a stale offset behind. + */ + @Test + public void centredPanelsStoreTheCentredOffsets() { + for (Version version : new Version[] {Version.SSD1306_0_96, Version.SH1106_1_3}) { + Layout layout = version.getLayout(OLEDInterface.I2C_4Pin); + Assert.assertEquals(version + " panel is off centre horizontally", + (layout.getBoardWidthMm() - version.getPanelWidthMm()) / 2.0, layout.getPanelLeftMm(), + 0.01d); + Assert.assertEquals(version + " panel is off centre vertically", + (layout.getBoardLengthMm() - version.getPanelLengthMm()) / 2.0, layout.getPanelTopMm(), + 0.01d); + Assert.assertEquals(version + " lit area is off centre within its panel", + (version.getPanelWidthMm() - version.getActiveWidthMm()) / 2.0, + version.getActiveLeftInPanelMm(), 0.01d); + } + } + + /** + * Every board prints its pin names and none prints one on the glass. No geometry assertion here + * would notice a label drawn over the display, and the shared helper's offset would put one + * there on the 0.96". + * + *

The silkscreen colour is repointed at a colour nothing else on these boards draws in, since + * mounting holes are filled in the canvas's white and would otherwise count as lettering. + */ + @Test + public void pinNamesAreDrawnAndStayOffThePanel() { + Color silk = AbstractMakerBoard.SILK_COLOR; + AbstractMakerBoard.SILK_COLOR = Color.MAGENTA; + try { + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + Layout layout = version.getLayout(oledInterface); + OLEDDisplay display = of(version, oledInterface); + int[] pixels = MakerBoardTestSupport.renderPixels(display); + int side = (int) Math.round(Math.sqrt(pixels.length)); + + Rectangle2D outline = display.getBodyShape().getBounds2D(); + Rectangle2D glass = new Rectangle2D.Double( + outline.getX() + mmToPx(layout.getPanelLeftMm()), + outline.getY() + mmToPx(layout.getPanelTopMm()), + mmToPx(version.getPanelWidthMm()), mmToPx(version.getPanelLengthMm())); + + int printed = 0; + int onGlass = 0; + for (int i = 0; i < pixels.length; i++) { + if (!isSilk(pixels[i])) { + continue; + } + printed++; + if (glass.contains(i % side, i / side)) { + onGlass++; + } + } + + String board = version + " " + oledInterface; + Assert.assertTrue(board + " prints no pin names at all", printed > 0); + Assert.assertEquals(board + " prints a pin name on the panel", 0, onGlass); + } + } + } finally { + AbstractMakerBoard.SILK_COLOR = silk; + } + } + + /** + * Matching hue rather than value catches lettering that antialiasing has dimmed where it clips + * the glass. Nothing else drawn on these boards leaves red and blue both well ahead of green. + */ + private static boolean isSilk(int argb) { + int red = (argb >> 16) & 0xFF; + int green = (argb >> 8) & 0xFF; + int blue = argb & 0xFF; + return red > 60 && blue > 60 && red > green * 1.5 && blue > green * 1.5; + } + + /** Without this the BOM collapses six different modules into one or two rows. */ + @Test + public void bomValueCarriesSizeAndInterface() { + Assert.assertEquals("0.96\" SSD1306 128x64, I2C", + of(Version.SSD1306_0_96, OLEDInterface.I2C_4Pin).getValueForDisplay()); + Assert.assertEquals("1.3\" SH1106 128x64, SPI-7", + of(Version.SH1106_1_3, OLEDInterface.SPI_7Pin).getValueForDisplay()); + + Set values = new HashSet(); + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + values.add(of(version, oledInterface).getValueForDisplay()); + } + } + Assert.assertEquals("every board should get its own BOM row", + Version.values().length * OLEDInterface.values().length, values.size()); + } + + /** + * Rendered rather than asked for: a screen that found no room for the text would still answer + * the getter. + */ + @Test + public void everyPanelPrintsItsDescription() { + for (Version version : Version.values()) { + for (OLEDInterface oledInterface : OLEDInterface.values()) { + OLEDDisplay printed = of(version, oledInterface); + OLEDDisplay blank = of(version, oledInterface); + blank.setScreen(Display.NONE); + + Assert.assertEquals(Display.VALUE, printed.getScreen()); + MakerBoardTestSupport.assertScreenTextIsDrawn(printed, blank); + } + } + } + + /** + * The pixels are the only thing the colour touches -- there is no backlight behind them and no + * ink in front -- and the text has to come through whichever one is picked. + */ + @Test + public void theLitAreaFollowsThePixelColour() { + OLEDDisplay white = new OLEDDisplay(); + white.setPixelColor(Color.decode("#F5F5F5")); + OLEDDisplay whiteBlank = new OLEDDisplay(); + whiteBlank.setPixelColor(Color.decode("#F5F5F5")); + whiteBlank.setScreen(Display.NONE); + + OLEDDisplay yellow = new OLEDDisplay(); + yellow.setPixelColor(Color.decode("#FFD54F")); + OLEDDisplay yellowBlank = new OLEDDisplay(); + yellowBlank.setPixelColor(Color.decode("#FFD54F")); + yellowBlank.setScreen(Display.NONE); + + MakerBoardTestSupport.assertScreenTextIsDrawn(white, whiteBlank); + MakerBoardTestSupport.assertScreenTextIsDrawn(yellow, yellowBlank); + } + + /** A project saved before the pixel colour existed deserialises with none set. */ + @Test + public void aMissingPixelColourFallsBackToBlue() { + OLEDDisplay display = new OLEDDisplay(); + display.setPixelColor(null); + Assert.assertEquals(OLEDDisplay.PIXEL_BLUE, display.getPixelColor()); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/SevenSegmentDisplayTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/SevenSegmentDisplayTest.java new file mode 100644 index 000000000..6221feaaf --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/SevenSegmentDisplayTest.java @@ -0,0 +1,240 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.geom.Rectangle2D; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.components.displays.SevenSegmentDisplay.Common; +import org.diylc.components.displays.SevenSegmentDisplay.DisplayType; +import org.diylc.components.displays.SevenSegmentDisplay.Punctuation; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Basic checks for the seven-segment displays. All but one are DIP packages straddling their pins + * with a row either side; the last is a module carrying a single header down one edge. + */ +public class SevenSegmentDisplayTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static SevenSegmentDisplay of(DisplayType displayType) { + SevenSegmentDisplay display = new SevenSegmentDisplay(); + display.setDisplayType(displayType); + return display; + } + + @Test + public void bodySizeFollowsThePackage() { + for (DisplayType displayType : DisplayType.values()) { + Rectangle2D bounds = of(displayType).getBodyShape().getBounds2D(); + Assert.assertEquals(displayType + " width", displayType.getBodyWidthMm(), + mm(bounds.getWidth()), 0.01d); + Assert.assertEquals(displayType + " length", displayType.getBodyLengthMm(), + mm(bounds.getHeight()), 0.01d); + } + } + + @Test + public void pinCountFollowsThePackage() { + Assert.assertEquals(10, of(DisplayType.SingleDigit_10Pin).getControlPointCount()); + Assert.assertEquals(10, of(DisplayType.TwoDigit_0_56_10Pin).getControlPointCount()); + Assert.assertEquals(12, of(DisplayType.FourDigit_0_36_12Pin).getControlPointCount()); + Assert.assertEquals(12, of(DisplayType.FourDigit_0_56_12Pin).getControlPointCount()); + Assert.assertEquals(4, of(DisplayType.TM1637_Module_4Pin).getControlPointCount()); + } + + /** + * Pin arrays are in DIP order: pin 1 bottom left, along the bottom row, then back along the top. + */ + @Test + public void pinNamesAreInDipOrder() { + Assert.assertArrayEquals(new String[] {"E", "D", "COM1", "C", "DP", "B", "A", "COM2", "F", "G"}, + SevenSegmentDisplay.PIN_NAMES_1DIGIT); + Assert.assertArrayEquals(new String[] {"E", "D", "DP", "C", "G", "B", "D2", "D1", "F", "A"}, + SevenSegmentDisplay.PIN_NAMES_2DIGIT); + Assert.assertArrayEquals( + new String[] {"E", "D", "DP", "C", "G", "D4", "B", "D3", "D2", "F", "A", "D1"}, + SevenSegmentDisplay.PIN_NAMES_4DIGIT); + + for (DisplayType displayType : DisplayType.values()) { + SevenSegmentDisplay display = of(displayType); + Set names = new HashSet(); + for (int i = 0; i < display.getControlPointCount(); i++) { + String name = display.getControlPointNodeName(i); + Assert.assertFalse(displayType + " pin " + i + " is unnamed", name.isEmpty()); + Assert.assertTrue(displayType + " duplicate node name " + name, names.add(name)); + } + } + } + + /** The 3641AS and the 5641AS differ in size but not in pin function, so one array serves both. */ + @Test + public void bothFourDigitPackagesSharePinNames() { + SevenSegmentDisplay small = of(DisplayType.FourDigit_0_36_12Pin); + SevenSegmentDisplay large = of(DisplayType.FourDigit_0_56_12Pin); + + for (int i = 0; i < small.getControlPointCount(); i++) { + Assert.assertEquals("pin " + i, small.getControlPointNodeName(i), + large.getControlPointNodeName(i)); + } + Assert.assertNotEquals(small.getBodyShape().getBounds2D(), large.getBodyShape().getBounds2D()); + Assert.assertSame("shared by identity, not by two matching copies", + DisplayType.FourDigit_0_36_12Pin.getPinNames(), + DisplayType.FourDigit_0_56_12Pin.getPinNames()); + } + + /** + * The digit count is the variant's own figure rather than something the drawing infers from + * which constant it is looking at, which is what lets a two-, six- or eight-digit part be a + * constant beside these. + */ + @Test + public void digitCountComesFromThePackage() { + Assert.assertEquals(1, DisplayType.SingleDigit_10Pin.getDigitCount()); + Assert.assertEquals(2, DisplayType.TwoDigit_0_56_10Pin.getDigitCount()); + Assert.assertEquals(4, DisplayType.FourDigit_0_36_12Pin.getDigitCount()); + Assert.assertEquals(4, DisplayType.FourDigit_0_56_12Pin.getDigitCount()); + Assert.assertEquals(4, DisplayType.TM1637_Module_4Pin.getDigitCount()); + + for (DisplayType displayType : DisplayType.values()) { + Assert.assertTrue(displayType + " shows no digits", displayType.getDigitCount() >= 1); + Assert.assertEquals(displayType + " pin count", displayType.getPinNames().length, + of(displayType).getControlPointCount()); + } + } + + /** + * A driven module brings out no common pins, so its polarity is not something you order and the + * BOM line omits it. Paired with the pin arrangement so the two cannot become one question: they + * agree on every variant today, but a single-in-line bare package would separate them. + */ + @Test + public void onlyModulesCarryABoard() { + for (DisplayType displayType : DisplayType.values()) { + Assert.assertEquals(displayType + " module", displayType == DisplayType.TM1637_Module_4Pin, + displayType.isModule()); + Assert.assertEquals(displayType + " board figures", displayType.isModule(), + displayType.getModule() != null); + Assert.assertNotEquals(displayType + " row arrangement", displayType.isModule(), + displayType.isDualRow()); + } + } + + @Test + public void dipPackagesCarryTwoRowsAndTheModuleOne() { + for (DisplayType displayType : DisplayType.values()) { + SevenSegmentDisplay display = of(displayType); + int pinCount = display.getControlPointCount(); + + if (displayType.isDualRow()) { + int perRow = pinCount / 2; + MakerBoardTestSupport.assertRow(display, 0, perRow - 1); + MakerBoardTestSupport.assertRow(display, perRow, pinCount - 1); + Assert.assertEquals(displayType + " row spacing", displayType.getRowSpacingMm(), + mm(Math.abs(display.getControlPoint(0).getY() + - display.getControlPoint(pinCount - 1).getY())), + 0.01d); + } else { + MakerBoardTestSupport.assertRow(display, 0, pinCount - 1); + } + } + } + + /** A package only works if its pin rows sit within its body. */ + @Test + public void everyPinSitsOnTheBody() { + for (DisplayType displayType : DisplayType.values()) { + SevenSegmentDisplay display = of(displayType); + Rectangle2D body = display.getBodyShape().getBounds2D(); + for (int i = 0; i < display.getControlPointCount(); i++) { + Assert.assertTrue(displayType + " pin " + i + " is off the body", + body.contains(display.getControlPoint(i))); + } + } + } + + @Test + public void propertiesRoundTripThroughTheirSetters() { + SevenSegmentDisplay display = new SevenSegmentDisplay(); + Assert.assertEquals(DisplayType.SingleDigit_10Pin, display.getDisplayType()); + Assert.assertEquals(Common.Cathode, display.getCommon()); + Assert.assertEquals(Punctuation.DecimalPoints, display.getPunctuation()); + + for (DisplayType displayType : DisplayType.values()) { + Assert.assertEquals(displayType, of(displayType).getDisplayType()); + } + for (Common common : Common.values()) { + display.setCommon(common); + Assert.assertEquals(common, display.getCommon()); + } + for (Punctuation punctuation : Punctuation.values()) { + display.setPunctuation(punctuation); + Assert.assertEquals(punctuation, display.getPunctuation()); + } + } + + /** The module's polarity is not part of what you order, so its BOM value leaves it out. */ + @Test + public void bomValueNamesThePackageAndWhatVariesWithIt() { + SevenSegmentDisplay module = of(DisplayType.TM1637_Module_4Pin); + module.setCommon(Common.Anode); + module.setPunctuation(Punctuation.Colon); + Assert.assertEquals(DisplayType.TM1637_Module_4Pin + ", " + Punctuation.Colon, + module.getValueForDisplay()); + + SevenSegmentDisplay bare = of(DisplayType.SingleDigit_10Pin); + bare.setCommon(Common.Anode); + bare.setPunctuation(Punctuation.None); + Assert.assertEquals( + DisplayType.SingleDigit_10Pin + ", " + Common.Anode + ", " + Punctuation.None, + bare.getValueForDisplay()); + } + + @Test + public void drawsCleanlyInEveryState() { + for (DisplayType displayType : DisplayType.values()) { + for (Punctuation punctuation : Punctuation.values()) { + SevenSegmentDisplay display = of(displayType); + display.setPunctuation(punctuation); + MakerBoardTestSupport.assertDrawsCleanly(display); + } + } + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/TFTDisplayTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/TFTDisplayTest.java new file mode 100644 index 000000000..7c9e1a484 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/TFTDisplayTest.java @@ -0,0 +1,673 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.FontMetrics; +import java.awt.Shape; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.common.Display; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.displays.TFTDisplay.Controller; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry tests for the controller variants. The pin count differs between them, so the checks + * that matter are the ones that catch an array and an outline disagreeing: that the header still + * fits across its board, and that the screen and the mounting holes stay off each other. + */ +public class TFTDisplayTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static double px(double millimetres) { + return new Size(millimetres, SizeUnit.mm).convertToPixels(); + } + + private static TFTDisplay of(Controller controller) { + TFTDisplay display = new TFTDisplay(); + display.setController(controller); + return display; + } + + @Test + public void boardSizeFollowsTheController() { + for (Controller controller : Controller.values()) { + Rectangle2D bounds = of(controller).getBodyShape().getBounds2D(); + Assert.assertEquals(controller + " width", controller.getBoardWidthMm(), + mm(bounds.getWidth()), 0.01d); + Assert.assertEquals(controller + " length", controller.getBoardLengthMm(), + mm(bounds.getHeight()), 0.01d); + } + } + + @Test + public void controlPointCountFollowsThePinArray() { + for (Controller controller : Controller.values()) { + int secondary = + controller.hasSecondaryHeader() ? controller.getSecondaryPinNames().length : 0; + Assert.assertEquals(controller.toString(), controller.getPinNames().length + secondary, + of(controller).getControlPointCount()); + } + Assert.assertEquals(14, Controller.ILI9341_2_8.getPinNames().length); + Assert.assertEquals(14, Controller.ILI9341_2_4.getPinNames().length); + Assert.assertEquals(8, Controller.ST7735_0_96.getPinNames().length); + Assert.assertEquals(8, Controller.ST7735_1_8.getPinNames().length); + Assert.assertEquals(8, Controller.ST7735_1_8_SD.getPinNames().length); + Assert.assertEquals(8, Controller.ST7789_1_54.getPinNames().length); + Assert.assertEquals(7, Controller.GC9A01_1_28.getPinNames().length); + + Assert.assertEquals("the 2.4\" carries a second row", 18, + of(Controller.ILI9341_2_4).getControlPointCount()); + Assert.assertEquals("the 2.8\" carries the same second row", 18, + of(Controller.ILI9341_2_8).getControlPointCount()); + Assert.assertEquals("the 1.8\" with the socket carries one too", 12, + of(Controller.ST7735_1_8_SD).getControlPointCount()); + } + + /** + * The 2.4" is the 2.8" board with a smaller panel, so it shares the pin array. Asserted by + * identity: a copy would pass every name check and drift the day one of the two is corrected. + */ + @Test + public void bothILI9341BoardsShareOnePinArray() { + Assert.assertSame(Controller.ILI9341_2_8.getPinNames(), Controller.ILI9341_2_4.getPinNames()); + Assert.assertEquals(TFTDisplay.SILK_NAMES_ILI9341.length, + Controller.ILI9341_2_4.getPinNames().length); + } + + /** + * Neither row may print a name the default label would mangle: it keeps what stands before the + * underscore, which would leave five pins reading "T" and four reading "SD". Widths are asserted + * against the font the row is drawn in, a flat label having the 0.1" pitch and no more. + */ + @Test + public void everyPrintedPinNameIsDistinctAndFitsThePitch() { + FontMetrics metrics = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).createGraphics() + .getFontMetrics(AbstractMakerBoard.PIN_ROW_FLAT_FONT); + double pitch = AbstractMakerBoard.PIN_SPACING.convertToPixels(); + + for (Controller controller : Controller.values()) { + TFTDisplay display = of(controller); + Set rowNames = new HashSet(); + int primaryCount = controller.getPinNames().length; + + for (int i = 0; i < display.getControlPointCount(); i++) { + if (i == primaryCount) { + rowNames.clear(); + } + String label = display.getSilkPinLabel(i); + Assert.assertTrue(controller + " pin " + i + " prints nothing", + label != null && !label.isEmpty()); + Assert.assertTrue(controller + " prints " + label + " twice on one row", + rowNames.add(label)); + Assert.assertTrue(controller + " " + label + " is wider than the pitch", + metrics.stringWidth(label) <= pitch); + } + } + } + + /** Eighteen pins on one part, so a repeated name would reach the netlist as one node. */ + @Test + public void everyNodeNameIsUnique() { + for (Controller controller : Controller.values()) { + TFTDisplay display = of(controller); + Set names = new HashSet(); + for (int i = 0; i < display.getControlPointCount(); i++) { + String name = display.getControlPointNodeName(i); + Assert.assertNotNull(controller + " pin " + i + " has no name", name); + Assert.assertTrue(controller + " duplicate node name " + name, names.add(name)); + } + } + } + + /** + * What a module of this size brings out does not vary with the driver behind it. Shared by + * identity rather than by three copies, so a correction to one reaches all of them. + */ + @Test + public void theSmallBoardsShareOnePinArray() { + Controller[] small = {Controller.ST7735_0_96, Controller.ST7735_1_8, + Controller.ST7735_1_8_SD, Controller.ST7789_1_54}; + String[] expected = new String[] {"GND", "VCC", "SCL", "SDA", "RES", "DC", "CS", "BLK"}; + + for (Controller controller : small) { + Assert.assertSame(controller.toString(), TFTDisplay.PIN_NAMES_SPI_8PIN, + controller.getPinNames()); + TFTDisplay display = of(controller); + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals(controller + " pin " + i, expected[i], + display.getControlPointNodeName(i)); + } + } + } + + /** Switching variants must resize the array, not leave the longer one's points behind. */ + @Test + public void switchingControllerResizesTheControlPoints() { + TFTDisplay display = new TFTDisplay(); + Assert.assertEquals(18, display.getControlPointCount()); + display.setController(Controller.ST7735_1_8); + Assert.assertEquals(8, display.getControlPointCount()); + display.setController(Controller.ILI9341_2_8); + Assert.assertEquals(18, display.getControlPointCount()); + } + + @Test + public void pinsSitAtHeaderPitchAlongEachRow() { + for (Controller controller : Controller.values()) { + TFTDisplay display = of(controller); + int primaryCount = controller.getPinNames().length; + MakerBoardTestSupport.assertRow(display, 0, primaryCount - 1); + if (controller.hasSecondaryHeader()) { + MakerBoardTestSupport.assertRow(display, primaryCount, + primaryCount + controller.getSecondaryPinNames().length - 1); + } + } + } + + /** + * The SD row faces the 14-pin row and is centred on the width like it. Both are worth pinning: + * the across-the-board figure is what the two offsets leave of the length rather than a + * measurement, and the horizontal placement was not supplied at all. + */ + @Test + public void theSdRowStandsOffTheOppositeEdge() { + for (Controller controller : Controller.values()) { + if (!controller.hasSecondaryHeader()) { + continue; + } + TFTDisplay display = of(controller); + Rectangle2D board = display.getBodyShape().getBounds2D(); + int primaryCount = controller.getPinNames().length; + + Point2D firstSd = display.getControlPoint(primaryCount); + double fromItsEdge = controller.isHeaderAtTop() ? board.getMaxY() - firstSd.getY() + : firstSd.getY() - board.getY(); + Assert.assertEquals(controller + " SD row offset from its own edge", + controller.getSecondaryHeaderOffsetMm(), mm(fromItsEdge), 0.01d); + + double between = controller.isHeaderAtTop() + ? firstSd.getY() - display.getControlPoint(0).getY() + : display.getControlPoint(0).getY() - firstSd.getY(); + Assert.assertEquals(controller + " rows are not on opposite edges", + controller.getBoardLengthMm() - controller.getHeaderOffsetMm() + - controller.getSecondaryHeaderOffsetMm(), + mm(between), 0.01d); + + Point2D lastSd = display.getControlPoint(display.getControlPointCount() - 1); + Assert.assertEquals(controller + " SD row is not centred across the board", + firstSd.getX() - board.getX(), board.getMaxX() - lastSd.getX(), 0.01d); + + // Short enough to pass between the mounting holes it shares an edge with, which is why the + // top pair keeps the shared inset where the 2.4"'s header-side pair could not. + double holeRim = controller.getHoleSideInsetMm() + controller.getHoleSizeMm() / 2.0; + Assert.assertTrue(controller + " SD row runs into the holes on its edge", + mm(firstSd.getX() - board.getX()) > holeRim); + Assert.assertTrue(controller + " SD row runs into the holes on its edge", + mm(board.getMaxX() - lastSd.getX()) > holeRim); + } + } + + /** A pin array longer than its board would hang the header off both ends. */ + @Test + public void headerFitsAcrossTheBoard() { + for (Controller controller : Controller.values()) { + TFTDisplay display = of(controller); + Rectangle2D board = display.getBodyShape().getBounds2D(); + for (int i = 0; i < display.getControlPointCount(); i++) { + Point2D pin = display.getControlPoint(i); + Assert.assertTrue(controller + " pin " + i + " hangs off the board", + pin.getX() >= board.getX() && pin.getX() <= board.getMaxX()); + } + } + } + + @Test + public void screenFitsWithinTheBoard() { + for (Controller controller : Controller.values()) { + Assert.assertTrue(controller + " screen is wider than its board", + controller.getScreenWidthMm() < controller.getBoardWidthMm()); + Assert.assertTrue(controller + " screen is longer than its board", + controller.getScreenLengthMm() < controller.getBoardLengthMm()); + } + } + + /** + * The screen is centred between the hole rows, so moving a board's holes has to be rechecked: on + * the 1.8" the two pairs are only 3 mm in and the panel very nearly reaches them. The rows need + * not share an inset, so the band is taken from both figures rather than from the board's middle. + */ + @Test + public void screenClearsTheMountingHoles() { + for (Controller controller : Controller.values()) { + if (!controller.hasMountingHoles()) { + continue; + } + double holeRadius = px(controller.getHoleSizeMm()) / 2.0; + double boardH = px(controller.getBoardLengthMm()); + double bandTop = px(topInsetOf(controller)); + double bandBottom = boardH - px(bottomInsetOf(controller)); + + double panelH = panelLengthOf(controller); + double panelTop = panelTopOf(controller); + double panelBottom = panelTop + panelH; + + Assert.assertTrue(controller + " panel overlaps the holes on its top edge", + panelTop >= bandTop + holeRadius); + Assert.assertTrue(controller + " panel overlaps the holes on its bottom edge", + panelBottom <= bandBottom - holeRadius); + } + } + + /** + * No mounting hole may be drilled where a pin is. This has to be two-dimensional to say anything + * useful: the 2.4"'s 14-pin row spans 33 mm of a 42.72 mm board, so its end pads reach the hole + * columns and that pair had to move, while the wider 2.8"'s row passes inside them. A one-axis + * clearance would have forced a deeper inset on boards that do not need one. + */ + @Test + public void mountingHolesDoNotOverlapAnyPin() { + double pin = AbstractMakerBoard.PIN_SIZE.convertToPixels(); + for (Controller controller : Controller.values()) { + if (!controller.hasMountingHoles()) { + continue; + } + TFTDisplay display = of(controller); + Rectangle2D board = display.getBodyShape().getBounds2D(); + double inset = px(controller.getHoleInsetMm()); + double sideInset = px(controller.getHoleSideInsetMm()); + double headerSide = px(controller.getHeaderSideHoleInsetMm()); + double size = px(controller.getHoleSizeMm()); + double topInset = controller.isHeaderAtTop() ? headerSide : inset; + double bottomInset = controller.isHeaderAtTop() ? inset : headerSide; + + Rectangle2D[] holes = new Rectangle2D[] { + hole(board.getX() + sideInset, board.getY() + topInset, size), + hole(board.getMaxX() - sideInset, board.getY() + topInset, size), + hole(board.getX() + sideInset, board.getMaxY() - bottomInset, size), + hole(board.getMaxX() - sideInset, board.getMaxY() - bottomInset, size)}; + + for (int i = 0; i < display.getControlPointCount(); i++) { + Point2D p = display.getControlPoint(i); + Rectangle2D pad = + new Rectangle2D.Double(p.getX() - pin / 2.0, p.getY() - pin / 2.0, pin, pin); + for (Rectangle2D h : holes) { + Assert.assertFalse(controller + " a mounting hole is drilled through pin " + i, + h.intersects(pad)); + } + } + } + } + + private static Rectangle2D hole(double cx, double cy, double size) { + return new Rectangle2D.Double(cx - size / 2.0, cy - size / 2.0, size, size); + } + + private static double topInsetOf(Controller controller) { + return controller.isHeaderAtTop() ? controller.getHeaderSideHoleInsetMm() + : controller.getHoleInsetMm(); + } + + private static double bottomInsetOf(Controller controller) { + return controller.isHeaderAtTop() ? controller.getHoleInsetMm() + : controller.getHeaderSideHoleInsetMm(); + } + + /** The dark panel where there is one, and the lit area alone where there is not. */ + private static double panelLengthOf(Controller controller) { + return px(controller.hasGlass() ? controller.getGlass().getLengthMm() + : controller.getScreenLengthMm()); + } + + /** Mirrors the drawing rule: every panel is centred between the hole rows. */ + private static double panelTopOf(Controller controller) { + double bandTop = controller.hasMountingHoles() ? px(topInsetOf(controller)) + : px(controller.getHeaderOffsetMm()); + double bandBottom = controller.hasMountingHoles() + ? px(controller.getBoardLengthMm() - bottomInsetOf(controller)) + : px(controller.getBoardLengthMm()); + return (bandTop + bandBottom) / 2.0 - panelLengthOf(controller) / 2.0; + } + + /** + * The panel has to leave the pin row uncovered. Being placed between the hole rows rather than + * from an edge, a long panel on a board whose holes sit close to the edges can reach back over + * its own header: the 1.54" clears by 3.51 mm with its row only 1.5 mm in. The 2.4" is the only + * board that must clear a row at each end. + */ + @Test + public void panelLeavesThePinRowUncovered() { + for (Controller controller : Controller.values()) { + if (controller.isRound()) { + continue; + } + double boardH = controller.getBoardLengthMm(); + double panelTop = mm(panelTopOf(controller)); + double panelBottom = panelTop + mm(panelLengthOf(controller)); + + double mainRow = controller.isHeaderAtTop() ? controller.getHeaderOffsetMm() + : boardH - controller.getHeaderOffsetMm(); + if (controller.isHeaderAtTop()) { + Assert.assertTrue(controller + " panel covers the main row", panelTop > mainRow); + } else { + Assert.assertTrue(controller + " panel covers the main row", panelBottom < mainRow); + } + if (controller.hasSecondaryHeader()) { + Assert.assertTrue(controller + " panel covers the SD row", + panelTop > controller.getSecondaryHeaderOffsetMm()); + } + } + } + + /** + * Every board with a bezel but the 0.96" states its own lit area: their panels carry the driver's + * bonding region along the bottom, so the centring rule would drop it, by 4.85 mm on the 2.4" + * down to 1.5 mm on the 1.54". The assertion is that the two disagree -- if a correction ever + * makes them agree, the field has stopped earning its place. + */ + @Test + public void theLitAreaIsMeasuredOnlyWhereCentringWouldBeWrong() { + for (Controller controller : Controller.values()) { + Assert.assertEquals(controller + " measured lit area", + controller.hasGlass() && controller != Controller.ST7735_0_96, + controller.hasMeasuredScreenTop()); + if (!controller.hasMeasuredScreenTop()) { + continue; + } + + double glassH = controller.getGlass().getLengthMm(); + double glassTop = mm(panelTopOf(controller)); + double screenTop = controller.getScreenTopMm(); + + Assert.assertTrue(controller + " lit area starts above its glass", screenTop >= glassTop); + Assert.assertTrue(controller + " lit area runs past its glass", + screenTop + controller.getScreenLengthMm() <= glassTop + glassH); + Assert.assertTrue(controller + " is measured where centring would have done", + Math.abs(screenTop - (glassTop + (glassH - controller.getScreenLengthMm()) / 2.0)) > 1d); + + // Each figure is stored from the board's top edge but was arrived at against the bezel, so it + // holds only while the glass stays where the hole rows put it. Move a hole row and these are + // the assertions that say the stored figure has to be rederived. + if (controller == Controller.ILI9341_2_4 || controller == Controller.ILI9341_2_8) { + double frameAbove = controller == Controller.ILI9341_2_4 ? 2.76d : 3.05d; + Assert.assertEquals(controller + " frame above the lit area", frameAbove, + screenTop - glassTop, 0.01d); + } else { + double lift = controller == Controller.ST7789_1_54 ? 1.5d : 2.0d; + Assert.assertEquals(controller + " lift off the centred lit area", lift, + glassTop + (glassH - controller.getScreenLengthMm()) / 2.0 - screenTop, 0.01d); + } + } + } + + /** + * Two boards carry a bezel narrower than their PCB and only the 0.96" has it off centre, pushed + * right so the driver's circuitry has the bare L it leaves. The offset is across the board only, + * and is a measurement: centring it across too would move it 1.15 mm. The lit area is centred + * inside it with a 1 mm frame, which would silently break if it went back to being centred on + * the board. + */ + @Test + public void theOffsetPanelIsPlacedFromTheRightEdge() { + for (Controller controller : Controller.values()) { + boolean narrower = controller == Controller.ST7735_0_96 + || controller == Controller.ST7735_1_8 || controller == Controller.ST7735_1_8_SD; + Assert.assertEquals(controller + " panel spans the board", !narrower, + !controller.hasGlass() || controller.getGlass().spansTheBoard()); + Assert.assertEquals(controller + " panel is placed from the right edge", + controller == Controller.ST7735_0_96, + controller.hasGlass() && controller.getGlass().isPlacedFromTheRightEdge()); + } + + Controller controller = Controller.ST7735_0_96; + TFTDisplay.Glass glass = controller.getGlass(); + + double leftMargin = controller.getBoardWidthMm() - glass.getRightInsetMm() - glass.getWidthMm(); + Assert.assertEquals("left margin", 4.3d, leftMargin, 0.01d); + Assert.assertTrue("the bezel is where centring would have put it", + Math.abs(leftMargin - (controller.getBoardWidthMm() - glass.getWidthMm()) / 2.0) > 0.1d); + + Assert.assertEquals("frame left and right", 1.0d, + (glass.getWidthMm() - controller.getScreenWidthMm()) / 2.0, 0.01d); + Assert.assertEquals("frame top and bottom", 1.0d, + (glass.getLengthMm() - controller.getScreenLengthMm()) / 2.0, 0.01d); + Assert.assertFalse("its lit area is measured, not derived from a centred glass", + controller.hasMeasuredScreenTop()); + } + + @Test + public void mountingHolesSitInsideTheBoard() { + for (Controller controller : Controller.values()) { + if (!controller.hasMountingHoles()) { + continue; + } + double holeRadius = controller.getHoleSizeMm() / 2.0; + Assert.assertTrue(controller + " holes breach an edge", + controller.getHoleInsetMm() >= holeRadius); + Assert.assertTrue(controller + " far holes breach the bottom edge", + controller.getHoleInsetMm() + holeRadius <= controller.getBoardLengthMm()); + } + } + + /** + * Each board's holes are one drill at one inset from each of the two edges nearest them, but the + * figures are the board's own rather than the package's. The 2.8" had a narrower 2.5 mm drill + * for a while and this guards against it coming back. The ILI9341s' deeper header-side pair is + * asserted separately so the two cannot be read as one rule. + */ + @Test + public void mountingHoleFiguresAreEachBoardsOwn() { + double[][] expected = {{3.0d, 3.0d}, {3.0d, 3.0d}, {2.5d, 2.0d}, {2.5d, 2.0d}, {3.0d, 2.0d}, + {2.5d, 2.0d}}; + Controller[] withHoles = {Controller.ILI9341_2_8, Controller.ILI9341_2_4, + Controller.ST7735_0_96, Controller.ST7735_1_8, Controller.ST7735_1_8_SD, + Controller.ST7789_1_54}; + for (int i = 0; i < withHoles.length; i++) { + Assert.assertEquals(withHoles[i] + " inset", expected[i][0], withHoles[i].getHoleInsetMm(), + 0.01d); + Assert.assertEquals(withHoles[i] + " diameter", expected[i][1], withHoles[i].getHoleSizeMm(), + 0.01d); + } + + for (Controller controller : Controller.values()) { + double expectedHeaderSide = controller.getHoleInsetMm(); + if (controller == Controller.ILI9341_2_4) { + expectedHeaderSide = 6.92d; + } else if (controller == Controller.ILI9341_2_8) { + expectedHeaderSide = 6.50d; + } + Assert.assertEquals(controller + " header-side inset", expectedHeaderSide, + controller.getHeaderSideHoleInsetMm(), 0.01d); + + double expectedSide = controller == Controller.ST7735_1_8_SD ? 2.75d + : controller.getHoleInsetMm(); + Assert.assertEquals(controller + " side inset", expectedSide, + controller.getHoleSideInsetMm(), 0.01d); + } + + // The one board whose holes are not square to the outline was given as two hole-to-hole spans + // rather than as insets, so those are what the three figures have to come back to. + Controller sd = Controller.ST7735_1_8_SD; + Assert.assertEquals("1.8\" SD holes across", 28.5d, + sd.getBoardWidthMm() - 2 * sd.getHoleSideInsetMm(), 0.01d); + Assert.assertEquals("1.8\" SD holes down", 52.0d, + sd.getBoardLengthMm() - sd.getHoleInsetMm() - sd.getHeaderSideHoleInsetMm(), 0.01d); + } + + /** Without this the BOM collapses every variant of the part into a single row. */ + @Test + public void bomValueCarriesTheController() { + for (Controller controller : Controller.values()) { + Assert.assertEquals(controller.toString(), of(controller).getValueForDisplay()); + } + Assert.assertNotEquals(of(Controller.ILI9341_2_8).getValueForDisplay(), + of(Controller.ST7735_1_8).getValueForDisplay()); + } + + /** + * The 1.8" is the board whose bezel was measured down the board as well as across it, and the + * family's centring rule is what places it, so the two have to keep agreeing. + */ + @Test + public void glassPanelFollowsTheModule() { + Assert.assertTrue(Controller.ILI9341_2_8.hasGlass()); + Assert.assertTrue(Controller.ST7789_1_54.hasGlass()); + Assert.assertTrue(Controller.ST7735_1_8.hasGlass()); + Assert.assertFalse(Controller.GC9A01_1_28.hasGlass()); + + Assert.assertEquals("1.8\" bezel from the top edge", 5.1d, + mm(panelTopOf(Controller.ST7735_1_8)), 0.02d); + } + + @Test + public void onlyTheRoundBoardHasATab() { + Assert.assertFalse(Controller.ILI9341_2_8.isRound()); + Assert.assertFalse(Controller.ST7735_1_8.isRound()); + Assert.assertFalse(Controller.ST7789_1_54.isRound()); + Assert.assertTrue(Controller.GC9A01_1_28.isRound()); + Assert.assertEquals(22.9d, Controller.GC9A01_1_28.getTabWidthMm(), 0.01d); + } + + /** The projection is the outline's length beyond its width, not a figure of its own. */ + @Test + public void tabProjectionFollowsFromTheOutline() { + Assert.assertEquals(7.5d, Controller.GC9A01_1_28.getTabProjectionMm(), 0.01d); + } + + @Test + public void roundBoardHasNoMountingHoles() { + Assert.assertFalse(Controller.GC9A01_1_28.hasMountingHoles()); + } + + /** The round board reads RST first, which is to say leftmost. */ + @Test + public void roundPinNamesComeFromTheController() { + TFTDisplay display = of(Controller.GC9A01_1_28); + String[] expected = new String[] {"RST", "CS", "DC", "SDA", "SCL", "GND", "VCC"}; + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals("pin " + i, expected[i], display.getControlPointNodeName(i)); + } + } + + /** + * A disc with a narrower tab below it, so the corners of the bounding box are off the board + * while the middles of the top and bottom edges are on it. Taking the tab only as far as the + * bottom of the disc would leave a notch either side of the tangent point, which these corners + * would not catch but the centre-line check would. + */ + @Test + public void roundBodyIsADiscWithATab() { + TFTDisplay display = of(Controller.GC9A01_1_28); + Shape body = display.getBodyShape(); + Rectangle2D bounds = body.getBounds2D(); + + Assert.assertEquals("disc diameter across", 38.0d, mm(bounds.getWidth()), 0.01d); + Assert.assertEquals("disc top to tab tip", 45.5d, mm(bounds.getHeight()), 0.01d); + + double x = bounds.getX(); + double y = bounds.getY(); + + Assert.assertTrue("the top of the disc is off the board", + body.contains(x + px(19.0d), y + px(1.0d))); + Assert.assertTrue("the middle of the tab is off the board", + body.contains(x + px(19.0d), y + px(44.5d))); + Assert.assertFalse("the top left corner is on the board", + body.contains(x + px(1.0d), y + px(1.0d))); + Assert.assertFalse("the bottom left corner is on the board", + body.contains(x + px(1.0d), y + px(44.5d))); + + // at the centre line the board is wider than the tab, which proves the union took the disc's + // outline; a third further down only the tab is left and the same point is off the board + Assert.assertTrue("the disc is no wider than its tab", + body.contains(x + px(2.0d), y + px(19.0d))); + Assert.assertFalse("the board is as wide as its disc below the centre line", + body.contains(x + px(2.0d), y + px(30.0d))); + } + + /** + * Which edge the main row leaves by is a fact about each board rather than a drawing convention: + * with the pin order fixed left to right, a header at the top and one at the bottom are mirror + * images and not the same part. Asserted for every variant because nothing else in the geometry + * would notice one being flipped. + */ + @Test + public void headerSitsOnItsOwnEdge() { + for (Controller controller : Controller.values()) { + TFTDisplay display = of(controller); + Rectangle2D bounds = display.getBodyShape().getBounds2D(); + Point2D firstPin = display.getControlPoint(0); + double fromEdge = controller.isHeaderAtTop() ? firstPin.getY() - bounds.getY() + : bounds.getMaxY() - firstPin.getY(); + + Assert.assertEquals(controller + " header offset", controller.getHeaderOffsetMm(), + mm(fromEdge), 0.01d); + } + // A roll call rather than a rule: a future variant must be sourced from the part too rather + // than inheriting whichever way the majority happens to go. + Assert.assertTrue("0.96\"", Controller.ST7735_0_96.isHeaderAtTop()); + Assert.assertTrue("1.8\"", Controller.ST7735_1_8.isHeaderAtTop()); + Assert.assertTrue("1.54\"", Controller.ST7789_1_54.isHeaderAtTop()); + Assert.assertFalse("2.8\"", Controller.ILI9341_2_8.isHeaderAtTop()); + Assert.assertFalse("2.4\"", Controller.ILI9341_2_4.isHeaderAtTop()); + Assert.assertFalse("1.28\" round", Controller.GC9A01_1_28.isHeaderAtTop()); + Assert.assertFalse("1.8\" with the socket", Controller.ST7735_1_8_SD.isHeaderAtTop()); + } + + /** + * The round one is the only variant whose text has to fit a circle, which it does by being given + * the square inscribed in the glass. + */ + @Test + public void everyPanelPrintsItsDescription() { + for (Controller controller : Controller.values()) { + TFTDisplay printed = of(controller); + TFTDisplay blank = of(controller); + blank.setScreen(Display.NONE); + + Assert.assertEquals(Display.VALUE, printed.getScreen()); + MakerBoardTestSupport.assertScreenTextIsDrawn(printed, blank); + } + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BBreakoutTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BBreakoutTest.java new file mode 100644 index 000000000..f99123a73 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BBreakoutTest.java @@ -0,0 +1,215 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry tests for the single-LED breakout. Three things compete for a board half an inch by + * four tenths: a three-pad column up each short edge, the two mounting holes between them, and + * the package in the middle. The holes leave 0.27 mm of board outside each rim and 0.31 mm + * between a rim and the package, so what is asserted is that none has taken another's room. + */ +public class WS2812BBreakoutTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static Rectangle2D padRect(WS2812BBreakout breakout, int index) { + Point2D pad = breakout.getControlPoint(index); + double w = WS2812BBreakout.PAD_WIDTH.convertToPixels(); + double h = WS2812BBreakout.PAD_LENGTH.convertToPixels(); + return new Rectangle2D.Double(pad.getX() - w / 2.0, pad.getY() - h / 2.0, w, h); + } + + private static Rectangle2D holeRect(WS2812BBreakout breakout, boolean bottom) { + Point2D hole = breakout.getHoleCentre(bottom); + double r = WS2812BBreakout.MOUNTING_HOLE_SIZE.convertToPixels() / 2.0; + return new Rectangle2D.Double(hole.getX() - r, hole.getY() - r, r * 2, r * 2); + } + + private static Rectangle2D ledPackage(WS2812BBreakout breakout) { + Point2D led = breakout.getLedCentre(); + double half = AbstractMakerBoard.RGB_LED_SIZE.convertToPixels() / 2.0; + return new Rectangle2D.Double(led.getX() - half, led.getY() - half, half * 2, half * 2); + } + + @Test + public void boardIsHalfAnInchByFourTenths() { + MakerBoardTestSupport.assertBoardSize(new WS2812BBreakout(), WS2812BBreakout.BOARD_WIDTH, + WS2812BBreakout.BOARD_HEIGHT); + } + + /** Ground, power and a data line up each long edge, and no two pads sharing a name. */ + @Test + public void eachEdgeCarriesThreeDistinctlyNamedPads() { + WS2812BBreakout breakout = new WS2812BBreakout(); + Assert.assertEquals(6, breakout.getControlPointCount()); + + Set names = new HashSet(); + for (int i = 0; i < breakout.getControlPointCount(); i++) { + Assert.assertTrue("duplicate node name " + breakout.getControlPointNodeName(i), + names.add(breakout.getControlPointNodeName(i))); + } + Assert.assertEquals("IN", breakout.getControlPointNodeName(2)); + Assert.assertEquals("OUT", breakout.getControlPointNodeName(5)); + } + + /** The two grounds are one net on the board, as are the two supplies. */ + @Test + public void theNumberedPadsReadAsOneName() { + WS2812BBreakout breakout = new WS2812BBreakout(); + for (int i : new int[] {0, 3}) { + Assert.assertEquals("GND", + AbstractMakerBoard.getDisplayPinLabel(breakout.getControlPointNodeName(i))); + } + for (int i : new int[] {1, 4}) { + Assert.assertEquals("VIN", + AbstractMakerBoard.getDisplayPinLabel(breakout.getControlPointNodeName(i))); + } + } + + @Test + public void padColumnsRunUpTheShortEdgesAtHeaderPitch() { + WS2812BBreakout breakout = new WS2812BBreakout(); + MakerBoardTestSupport.assertRow(breakout, 0, 2); + MakerBoardTestSupport.assertRow(breakout, 3, 5); + + Rectangle2D board = breakout.getBodyShape().getBounds2D(); + for (int i = 0; i < 3; i++) { + Assert.assertEquals("left pad " + i, board.getX(), padRect(breakout, i).getX(), 0.01d); + Assert.assertEquals("right pad " + i, board.getMaxX(), padRect(breakout, 3 + i).getMaxX(), + 0.01d); + } + } + + @Test + public void holesSitOnTheCentreLineASpanApartAstrideTheLed() { + WS2812BBreakout breakout = new WS2812BBreakout(); + Rectangle2D board = breakout.getBodyShape().getBounds2D(); + Point2D top = breakout.getHoleCentre(false); + Point2D bottom = breakout.getHoleCentre(true); + + Assert.assertEquals("span", WS2812BBreakout.MOUNTING_HOLE_SPACING.convertToPixels(), + bottom.getY() - top.getY(), 0.01d); + Assert.assertEquals("not centred across the board", board.getCenterX(), top.getX(), 0.01d); + Assert.assertEquals("not centred along the board", board.getCenterY(), + (top.getY() + bottom.getY()) / 2.0, 0.01d); + } + + /** + * Asserted as the figure it works out to rather than as mere containment, a quarter of a + * millimetre being the whole of it. + */ + @Test + public void aQuarterMillimetreOfBoardSurvivesOutsideEachHole() { + WS2812BBreakout breakout = new WS2812BBreakout(); + Rectangle2D board = breakout.getBodyShape().getBounds2D(); + + Assert.assertEquals("above the top hole", 0.27d, + mm(holeRect(breakout, false).getY() - board.getY()), 0.01d); + Assert.assertEquals("below the bottom hole", 0.27d, + mm(board.getMaxY() - holeRect(breakout, true).getMaxY()), 0.01d); + } + + /** + * 0.31 mm of board separates a rim from the package, so this fails on any revision that brings + * the two together rather than letting them overlap unnoticed. + */ + @Test + public void thePackageClearsTheHolesAndThePads() { + WS2812BBreakout breakout = new WS2812BBreakout(); + Rectangle2D pack = ledPackage(breakout); + + for (boolean bottom : new boolean[] {false, true}) { + Assert.assertFalse("the package sits over a mounting hole", + pack.intersects(holeRect(breakout, bottom))); + } + for (int i = 0; i < breakout.getControlPointCount(); i++) { + Assert.assertFalse("the package runs under pad " + i, + pack.intersects(padRect(breakout, i))); + } + } + + @Test + public void theHolesClearThePads() { + WS2812BBreakout breakout = new WS2812BBreakout(); + for (boolean bottom : new boolean[] {false, true}) { + Rectangle2D hole = holeRect(breakout, bottom); + for (int i = 0; i < breakout.getControlPointCount(); i++) { + Assert.assertFalse("a mounting hole runs under pad " + i, + hole.intersects(padRect(breakout, i))); + } + } + } + + @Test + public void everythingStaysOnTheBoard() { + WS2812BBreakout breakout = new WS2812BBreakout(); + Rectangle2D board = breakout.getBodyShape().getBounds2D(); + + for (boolean bottom : new boolean[] {false, true}) { + Rectangle2D hole = holeRect(breakout, bottom); + Assert.assertTrue("a mounting hole breaches the board edge", + board.contains(hole.getX(), hole.getY(), hole.getWidth(), hole.getHeight())); + } + Rectangle2D pack = ledPackage(breakout); + Assert.assertTrue("the package hangs off the board", + board.contains(pack.getX(), pack.getY(), pack.getWidth(), pack.getHeight())); + for (int i = 0; i < breakout.getControlPointCount(); i++) { + Rectangle2D pad = padRect(breakout, i); + Assert.assertTrue("pad " + i + " runs off the board", + board.contains(pad.getX(), pad.getY(), pad.getWidth(), pad.getHeight())); + } + } + + @Test + public void drawsCleanlyInEveryState() { + MakerBoardTestSupport.assertDrawsCleanly(new WS2812BBreakout()); + } + + @Test + public void honoursTheLedPackage() { + MakerBoardTestSupport.assertAddressableLedTypes(WS2812BBreakout::new); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BJewelTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BJewelTest.java new file mode 100644 index 000000000..eb1f8c0c1 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BJewelTest.java @@ -0,0 +1,257 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Shape; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry tests for the Jewel. The arrangement matters more than the dimensions: five pads share + * six gaps between the ring LEDs, so one gap is empty, and getting the wrong one empty would + * still place five pads on the right circle and pass any count or radius check. + */ +public class WS2812BJewelTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static double mmToPx(double value) { + return new Size(value, SizeUnit.mm).convertToPixels(); + } + + private static Point2D centerOf(WS2812BJewel jewel) { + Rectangle2D bounds = jewel.getBodyShape().getBounds2D(); + return new Point2D.Double(bounds.getCenterX(), bounds.getCenterY()); + } + + /** The ring LED centres, recomputed from the constants the way {@code draw} places them. */ + private static Point2D[] ringLeds(WS2812BJewel jewel) { + Point2D center = centerOf(jewel); + double ledR = WS2812BJewel.LED_CIRCLE_DIAMETER.convertToPixels() / 2.0; + Point2D[] leds = new Point2D[WS2812BJewel.RING_LED_COUNT]; + for (int i = 0; i < leds.length; i++) { + double angle = 2 * Math.PI * i / WS2812BJewel.RING_LED_COUNT - Math.PI / 2.0; + leds[i] = new Point2D.Double(center.getX() + ledR * Math.cos(angle), + center.getY() + ledR * Math.sin(angle)); + } + return leds; + } + + private static Rectangle2D ledRect(Point2D led) { + double half = WS2812BJewel.RGB_LED_SIZE.convertToPixels() / 2.0; + double size = half * 2; + return new Rectangle2D.Double(led.getX() - half, led.getY() - half, size, size); + } + + /** The shared round pad's bounding box; pads are not turned to face the centre. */ + private static Rectangle2D padRect(Point2D pad) { + double size = WS2812BJewel.PAD_SIZE.convertToPixels(); + return new Rectangle2D.Double(pad.getX() - size / 2.0, pad.getY() - size / 2.0, size, size); + } + + @Test + public void fivePadsAreTheControlPoints() { + Assert.assertEquals(5, new WS2812BJewel().getControlPointCount()); + } + + @Test + public void padNamesAreTheClockwiseOrderFromOut() { + WS2812BJewel jewel = new WS2812BJewel(); + String[] expected = new String[] {"OUT", "G_1", "G_2", "PWR", "IN"}; + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals("pad " + i, expected[i], jewel.getControlPointNodeName(i)); + } + } + + /** The two grounds are distinct nodes; the silk would read both as GND. */ + @Test + public void theTwoGroundsAreSeparateNodes() { + WS2812BJewel jewel = new WS2812BJewel(); + Assert.assertNotEquals(jewel.getControlPointNodeName(1), jewel.getControlPointNodeName(2)); + } + + @Test + public void bodyIsATwentyThreeMillimetreDisc() { + Rectangle2D bounds = new WS2812BJewel().getBodyShape().getBounds2D(); + Assert.assertEquals("diameter across", 23.0d, mm(bounds.getWidth()), 0.01d); + Assert.assertEquals("a disc, not an ellipse", bounds.getWidth(), bounds.getHeight(), 0.01d); + } + + /** + * Ties {@code getPadRadius} to {@code getCenter}: if the two disagreed the disc would be drawn + * off its own control points. + */ + @Test + public void everyPadSitsOnTheNineMillimetreCircle() { + WS2812BJewel jewel = new WS2812BJewel(); + Point2D center = centerOf(jewel); + for (int i = 0; i < jewel.getControlPointCount(); i++) { + Assert.assertEquals("pad " + i + " off the pad circle", 4.5d, + mm(center.distance(jewel.getControlPoint(i))), 0.01d); + } + } + + /** + * Pads belong in the gaps between consecutive ring LEDs, and with five pads in six gaps the one + * left empty must be the one due left, where the left mounting hole is. + */ + @Test + public void padsFillEveryGapButTheOneDueLeft() { + WS2812BJewel jewel = new WS2812BJewel(); + Point2D center = centerOf(jewel); + boolean[] occupied = new boolean[WS2812BJewel.RING_LED_COUNT]; + + for (int i = 0; i < jewel.getControlPointCount(); i++) { + Point2D pad = jewel.getControlPoint(i); + double angle = Math.atan2(pad.getY() - center.getY(), pad.getX() - center.getX()); + // gap g is centred half a step past LED g, LED 0 being at the top + double steps = (angle + Math.PI / 2.0) / (2 * Math.PI / WS2812BJewel.RING_LED_COUNT) - 0.5; + long whole = Math.round(steps); + Assert.assertEquals("pad " + i + " does not sit in the middle of a gap", whole, steps, + 0.001d); + int gap = (int) (((whole % WS2812BJewel.RING_LED_COUNT) + WS2812BJewel.RING_LED_COUNT) + % WS2812BJewel.RING_LED_COUNT); + Assert.assertFalse("two pads in gap " + gap, occupied[gap]); + occupied[gap] = true; + } + + // gap 4 spans LED 4 to LED 5, so on a hexagon anchored at the top it is the one pointing left + for (int gap = 0; gap < occupied.length; gap++) { + if (gap == 4) { + Assert.assertFalse("the empty gap should be the one due left", occupied[gap]); + } else { + Assert.assertTrue("gap " + gap + " has no pad", occupied[gap]); + } + } + } + + @Test + public void theEmptyGapPointsAtAMountingHole() { + WS2812BJewel jewel = new WS2812BJewel(); + Point2D center = centerOf(jewel); + + // the left hole sits at pi from the centre, and no pad may lie along that radius + for (int i = 0; i < jewel.getControlPointCount(); i++) { + Point2D pad = jewel.getControlPoint(i); + double angle = Math.atan2(pad.getY() - center.getY(), pad.getX() - center.getX()); + Assert.assertTrue("pad " + i + " lies along the left mounting hole's radius", + Math.PI - Math.abs(angle) > 0.1d); + } + } + + /** No pad may sit under a package, the centre LED included: it is nearest to the pads. */ + @Test + public void noPadSitsUnderAnLed() { + WS2812BJewel jewel = new WS2812BJewel(); + Point2D center = centerOf(jewel); + Point2D[] leds = ringLeds(jewel); + + for (int i = 0; i < jewel.getControlPointCount(); i++) { + Rectangle2D pad = padRect(jewel.getControlPoint(i)); + Assert.assertFalse("pad " + i + " sits under the centre LED", + pad.intersects(ledRect(center))); + for (int l = 0; l < leds.length; l++) { + Assert.assertFalse("pad " + i + " sits under ring LED " + l, + pad.intersects(ledRect(leds[l]))); + } + } + } + + @Test + public void everyPadAndLedSitsOnTheDisc() { + WS2812BJewel jewel = new WS2812BJewel(); + Shape disc = jewel.getBodyShape(); + Point2D center = centerOf(jewel); + double outerR = WS2812BJewel.OUTER_DIAMETER.convertToPixels() / 2.0; + + for (int i = 0; i < jewel.getControlPointCount(); i++) { + Assert.assertTrue("pad " + i + " hangs off the disc", + disc.contains(padRect(jewel.getControlPoint(i)))); + } + for (Point2D led : ringLeds(jewel)) { + Assert.assertTrue("an LED package hangs off the rim", + center.distance(led) + WS2812BJewel.RGB_LED_SIZE.convertToPixels() / 2.0 <= outerR); + } + } + + /** + * The holes are 19 mm apart on a 23 mm disc, clearing the rim by 0.5 mm and coming within a + * fraction of a millimetre of the neighbouring packages, which is why this asserts containment + * rather than any comfortable clearance. + */ + @Test + public void mountingHolesSitInsideTheDisc() { + WS2812BJewel jewel = new WS2812BJewel(); + Point2D center = centerOf(jewel); + double holeOffset = WS2812BJewel.MOUNTING_HOLE_SPACING.convertToPixels() / 2.0; + double holeR = WS2812BJewel.MOUNTING_HOLE_SIZE.convertToPixels() / 2.0; + double outerR = WS2812BJewel.OUTER_DIAMETER.convertToPixels() / 2.0; + + Assert.assertEquals("holes 19 mm apart", 19.0d, mm(holeOffset * 2), 0.01d); + Assert.assertTrue("mounting holes breach the rim", holeOffset + holeR <= outerR); + Assert.assertTrue("a hole is not inside the disc", + jewel.getBodyShape().contains(center.getX() - holeOffset - holeR, center.getY() - holeR, + holeR * 2, holeR * 2)); + } + + /** The package it is fitted with is the only thing to tell a buyer about this one. */ + @Test + public void bomValueNamesThePackage() { + Assert.assertEquals("RGB", new WS2812BJewel().getValueForDisplay()); + } + + @Test + public void honoursTheLedPackage() { + MakerBoardTestSupport.assertAddressableLedTypes(WS2812BJewel::new); + } + + @Test + public void padsAreAllDistinctPositions() { + WS2812BJewel jewel = new WS2812BJewel(); + for (int i = 0; i < jewel.getControlPointCount(); i++) { + for (int j = i + 1; j < jewel.getControlPointCount(); j++) { + Assert.assertTrue("pads " + i + " and " + j + " coincide", + jewel.getControlPoint(i).distance(jewel.getControlPoint(j)) > mmToPx(0.5d)); + } + } + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BPanelTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BPanelTest.java new file mode 100644 index 000000000..f37b88be1 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BPanelTest.java @@ -0,0 +1,276 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.Graphics2D; +import java.awt.font.FontRenderContext; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.displays.WS2812BPanel.Silk; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry tests for the NeoPixel panel. Dimensions can be checked against the board file they + * came from; the arrangement cannot, so that is what is pinned here -- the two ports are each + * other turned about the centre, and ports and holes sit in the gaps of the pixel grid rather + * than on bare margin. Each of those passes whatever the dimensions are. + */ +public class WS2812BPanelTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double px(double millimetres) { + return millimetres * PX_PER_MM; + } + + private static final int ORDER = WS2812BPanel.MATRIX_ORDER; + + /** The board file's hole pattern, in millimetres from the middle of the board. */ + private static final double[][] HOLES_MM = new double[][] { + {-33.02d, 17.78d}, {-33.02d, 0d}, {-33.02d, -17.78d}, + {33.02d, 17.78d}, {33.02d, 0d}, {33.02d, -17.78d}, + {-8.89d, 26.67d}, {8.89d, 26.67d}, + {-8.89d, 0d}, {8.89d, 0d}, + {-8.89d, -26.67d}, {8.89d, -26.67d}}; + + /** Where a pixel's centre sits, counted along the data chain, in component coordinates. */ + private static Point2D ledCentre(WS2812BPanel panel, int index) { + Rectangle2D board = panel.getBodyShape().getBounds2D(); + double pitch = WS2812BPanel.PIXEL_PITCH.convertToPixels(); + double margin = (board.getWidth() - (ORDER - 1) * pitch) / 2.0; + return new Point2D.Double(board.getX() + margin + (index % ORDER) * pitch, + board.getY() + margin + (index / ORDER) * pitch); + } + + @Test + public void theBoardIsTheSquareItWasMeasuredAs() { + Rectangle2D board = new WS2812BPanel().getBodyShape().getBounds2D(); + + Assert.assertEquals(px(71.12d), board.getWidth(), 0.01d); + Assert.assertEquals("the board is not square", board.getWidth(), board.getHeight(), 0.01d); + } + + /** + * The board is exactly eight pitches across, which puts a half-pitch of margin outside the outer + * pixels and lands the port row in the first row gap. A property of this board rather than of + * panels in general, so a later panel that does not share it should fail here. + */ + @Test + public void theBoardIsAWholeNumberOfPitchesAcross() { + Assert.assertEquals(ORDER * WS2812BPanel.PIXEL_PITCH.convertToPixels(), + WS2812BPanel.BOARD_SIZE.convertToPixels(), 0.01d); + } + + @Test + public void thePixelGridIsCentredOnTheBoard() { + WS2812BPanel panel = new WS2812BPanel(); + Rectangle2D board = panel.getBodyShape().getBounds2D(); + Point2D first = ledCentre(panel, 0); + Point2D last = ledCentre(panel, ORDER * ORDER - 1); + + Assert.assertEquals("left margin", first.getX() - board.getX(), + board.getMaxX() - last.getX(), 0.01d); + Assert.assertEquals("top margin", first.getY() - board.getY(), + board.getMaxY() - last.getY(), 0.01d); + } + + /** + * A progressive raster rather than the serpentine most panels use. The colours are handed out + * along the chain, so getting it wrong shows as a gradient that folds back every row. + */ + @Test + public void theChainIsAProgressiveRaster() { + WS2812BPanel panel = new WS2812BPanel(); + + Assert.assertEquals("the row does not advance along the chain", ledCentre(panel, 0).getY(), + ledCentre(panel, ORDER - 1).getY(), 0.01d); + Assert.assertEquals("the chain does not return to the left edge", + ledCentre(panel, 0).getX(), ledCentre(panel, ORDER).getX(), 0.01d); + Assert.assertEquals("the chain does not step down a row", + WS2812BPanel.PIXEL_PITCH.convertToPixels(), + ledCentre(panel, ORDER).getY() - ledCentre(panel, 0).getY(), 0.01d); + } + + @Test + public void controlPointsAreTheTwoPortsInOrder() { + WS2812BPanel panel = new WS2812BPanel(); + Assert.assertEquals(6, panel.getControlPointCount()); + + Set names = new HashSet(); + for (int i = 0; i < panel.getControlPointCount(); i++) { + String name = panel.getControlPointNodeName(i); + Assert.assertNotNull("pin " + i, name); + Assert.assertTrue("duplicate node name " + name, names.add(name)); + } + Assert.assertEquals("DIN", panel.getControlPointNodeName(0)); + Assert.assertEquals("DOUT", panel.getControlPointNodeName(3)); + } + + /** + * Half a turn about the middle of the board, which is what lets panels be chained. Nothing in the + * drawing shows this is wrong; the output simply ends up where a wire cannot reach. + */ + @Test + public void thePortsAreEachOtherTurnedAboutTheCentre() { + WS2812BPanel panel = new WS2812BPanel(); + Rectangle2D board = panel.getBodyShape().getBounds2D(); + + for (int i = 0; i < 3; i++) { + Point2D in = panel.getControlPoint(i); + Point2D out = panel.getControlPoint(3 + i); + + Assert.assertEquals("pad " + i + " x", 2 * board.getCenterX() - in.getX(), out.getX(), + 0.01d); + Assert.assertEquals("pad " + i + " y", 2 * board.getCenterY() - in.getY(), out.getY(), + 0.01d); + } + } + + @Test + public void portPadsSitAtHeaderPitchAndStayOnTheBoard() { + WS2812BPanel panel = new WS2812BPanel(); + Rectangle2D board = panel.getBodyShape().getBounds2D(); + double padHalf = AbstractMakerBoard.PAD_SIZE.convertToPixels() / 2.0; + + MakerBoardTestSupport.assertRow(panel, 0, 2); + MakerBoardTestSupport.assertRow(panel, 3, 5); + + for (int i = 0; i < panel.getControlPointCount(); i++) { + Point2D pad = panel.getControlPoint(i); + Assert.assertTrue("pad " + i + " runs off the board", + board.contains(pad.getX() - padHalf, pad.getY() - padHalf, padHalf * 2, padHalf * 2)); + } + } + + /** + * This board has no bare strip -- the grid reaches within half a pitch of every edge -- so a port + * drawn a row out would land under the packages. + */ + @Test + public void portsLieInTheGapBetweenTwoPixelRows() { + WS2812BPanel panel = new WS2812BPanel(); + double firstRow = ledCentre(panel, 0).getY(); + double secondRow = ledCentre(panel, ORDER).getY(); + + Assert.assertEquals("input port", (firstRow + secondRow) / 2.0, + panel.getControlPoint(0).getY(), 0.01d); + } + + /** + * The holes are drilled in the gaps of the grid, leaving 3.89 mm of bare board for a 2.8 mm hole. + * A hole over a package is only visible in a render. + */ + @Test + public void mountingHolesClearThePixelsAndStayOnTheBoard() { + WS2812BPanel panel = new WS2812BPanel(); + Rectangle2D board = panel.getBodyShape().getBounds2D(); + double holeR = WS2812BPanel.MOUNTING_HOLE_SIZE.convertToPixels() / 2.0; + double packageHalf = AbstractMakerBoard.RGB_LED_SIZE.convertToPixels() / 2.0; + + // the holes are private to the part, so they are reached the way the drawing reaches them + for (int i = 0; i < ORDER * ORDER; i++) { + Point2D led = ledCentre(panel, i); + Rectangle2D pack = new Rectangle2D.Double(led.getX() - packageHalf, + led.getY() - packageHalf, packageHalf * 2, packageHalf * 2); + + for (double[] hole : HOLES_MM) { + double hx = board.getCenterX() + px(hole[0]); + double hy = board.getCenterY() + px(hole[1]); + + Assert.assertTrue("hole breaches the board edge", + board.contains(hx - holeR, hy - holeR, holeR * 2, holeR * 2)); + Assert.assertFalse("hole sits under a package", + pack.intersects(hx - holeR, hy - holeR, holeR * 2, holeR * 2)); + } + } + } + + @Test + public void drawsCleanlyInEveryState() { + MakerBoardTestSupport.assertDrawsCleanly(new WS2812BPanel()); + } + + @Test + public void bomValueNamesTheMatrix() { + Assert.assertEquals("8x8, 64 LEDs, RGB", new WS2812BPanel().getValueForDisplay()); + } + + /** + * Every printed line lies in a gap between two rows of pixels, the only bare board this part has. + * The positions come from the board file, but how much room a line takes is the font's business, + * so a size change could put lettering under a package without anything else noticing. + */ + @Test + public void silkscreenClearsThePixelsAndTheBoardEdge() { + WS2812BPanel panel = new WS2812BPanel(); + Rectangle2D board = panel.getBodyShape().getBounds2D(); + double packageHalf = AbstractMakerBoard.RGB_LED_SIZE.convertToPixels() / 2.0; + Graphics2D g2d = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).createGraphics(); + FontRenderContext context = g2d.getFontRenderContext(); + + try { + for (Silk silk : WS2812BPanel.SILKSCREEN) { + // the inked rectangle rather than the line box, since leading and descender space the + // string does not use cannot collide with anything + Rectangle2D glyphs = + silk.getFont().createGlyphVector(context, silk.getText()).getVisualBounds(); + Rectangle2D line = new Rectangle2D.Double(board.getCenterX() + px(silk.getXMm()), + board.getCenterY() + px(silk.getYMm()) - glyphs.getHeight() / 2.0, glyphs.getWidth(), + glyphs.getHeight()); + + Assert.assertTrue(silk.getText() + " runs off the board", + board.contains(line.getX(), line.getY(), line.getWidth(), line.getHeight())); + + for (int i = 0; i < ORDER * ORDER; i++) { + Point2D led = ledCentre(panel, i); + Assert.assertFalse(silk.getText() + " runs under a package", + line.intersects(led.getX() - packageHalf, led.getY() - packageHalf, + packageHalf * 2, packageHalf * 2)); + } + } + } finally { + g2d.dispose(); + } + } + + @Test + public void honoursTheLedPackage() { + MakerBoardTestSupport.assertAddressableLedTypes(WS2812BPanel::new); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BRingTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BRingTest.java new file mode 100644 index 000000000..1b3f2b3a5 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BRingTest.java @@ -0,0 +1,197 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.components.AbstractMakerBoard.LedType; +import org.diylc.components.displays.WS2812BRing.RingSize; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Basic checks for the NeoPixel ring. {@link RingSize}'s constructor guards the pad arrangement, + * throwing unless each ring's LED gaps sum to its LED count, so what is left is that the three + * sizes carry the pads and diameters they were measured with and that the body is concentric. + */ +public class WS2812BRingTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static WS2812BRing of(RingSize ringSize) { + WS2812BRing ring = new WS2812BRing(); + ring.setRingSize(ringSize); + return ring; + } + + @Test + public void bodyIsTheOuterDiameterOfEachRing() { + for (RingSize ringSize : RingSize.values()) { + Rectangle2D bounds = of(ringSize).getBodyShape().getBounds2D(); + Assert.assertEquals(ringSize + " width", ringSize.getOuterDiameterMm(), mm(bounds.getWidth()), + 0.01d); + Assert.assertEquals(ringSize + " height", ringSize.getOuterDiameterMm(), + mm(bounds.getHeight()), 0.01d); + } + } + + /** Four pads on the 12-LED ring, six on the two larger ones, and no two sharing a name. */ + @Test + public void padCountFollowsTheRingSize() { + Assert.assertEquals(4, of(RingSize._12_LED).getControlPointCount()); + Assert.assertEquals(6, of(RingSize._16_LED).getControlPointCount()); + Assert.assertEquals(6, of(RingSize._24_LED).getControlPointCount()); + + for (RingSize ringSize : RingSize.values()) { + WS2812BRing ring = of(ringSize); + Set names = new HashSet(); + for (int i = 0; i < ring.getControlPointCount(); i++) { + String name = ring.getControlPointNodeName(i); + Assert.assertNotNull(ringSize + " pad " + i, name); + Assert.assertFalse(ringSize + " pad " + i + " is unnamed", name.isEmpty()); + Assert.assertTrue(ringSize + " duplicate node name " + name, names.add(name)); + } + } + } + + /** + * Pads sit on a circle of their own rather than the LEDs'. This also says the body is concentric + * with them: the outline is placed by working backwards from the first pad, so a disagreement + * would show up as an off-centre radius here. + */ + @Test + public void everyPadSitsOnItsOwnCircleInsideTheRim() { + for (RingSize ringSize : RingSize.values()) { + WS2812BRing ring = of(ringSize); + Rectangle2D bounds = ring.getBodyShape().getBounds2D(); + Point2D centre = new Point2D.Double(bounds.getCenterX(), bounds.getCenterY()); + double expected = ringSize.getOuterDiameterMm() / 2.0 + - mm(WS2812BRing.PAD_EDGE_INSET.convertToPixels()); + + for (int i = 0; i < ring.getControlPointCount(); i++) { + Assert.assertEquals(ringSize + " pad " + i + " radius", expected, + mm(centre.distance(ring.getControlPoint(i))), 0.01d); + } + } + } + + @Test + public void ringSizeRoundTripsThroughTheSetter() { + Assert.assertEquals(RingSize._16_LED, new WS2812BRing().getRingSize()); + for (RingSize ringSize : RingSize.values()) { + WS2812BRing ring = of(ringSize); + Assert.assertEquals(ringSize, ring.getRingSize()); + Assert.assertTrue(ring.getValueForDisplay().startsWith(ringSize.toString())); + } + } + + /** A ring saved before the property existed deserializes without one and has to read as RGB. */ + @Test + public void ledTypeDefaultsToRgb() { + Assert.assertEquals(LedType.RGB, new WS2812BRing().getLedType()); + WS2812BRing ring = new WS2812BRing(); + ring.setLedType(null); + Assert.assertEquals(LedType.RGB, ring.getLedType()); + } + + /** Both halves name a different thing to buy, so the BOM's value column has to carry both. */ + @Test + public void variantLabelCarriesSizeAndLedType() { + for (RingSize ringSize : RingSize.values()) { + for (LedType ledType : LedType.values()) { + WS2812BRing ring = of(ringSize); + ring.setLedType(ledType); + Assert.assertEquals(ringSize + ", " + ledType, ring.getValueForDisplay()); + } + } + } + + /** The packages are pin-compatible, so fitting a different one moves no pad. */ + @Test + public void ledTypeLeavesTheFootprintAlone() { + for (RingSize ringSize : RingSize.values()) { + WS2812BRing reference = of(ringSize); + for (LedType ledType : LedType.values()) { + WS2812BRing ring = of(ringSize); + ring.setLedType(ledType); + MakerBoardTestSupport.assertSharedFootprint(reference, ring, ring.getControlPointCount()); + } + } + } + + /** + * Comparing renderings is what makes this a check on the drawing: a draw() that ignored the + * property would pass the getter. + */ + @Test + public void eachPackageIsDrawnDifferently() { + int[] rgb = renderWith(LedType.RGB); + Assert.assertFalse("RGBW should show its white die, and does not", + Arrays.equals(rgb, renderWith(LedType.RGBW_WARM))); + Assert.assertFalse("A WWA ring should not be drawn lit in colour", + Arrays.equals(rgb, renderWith(LedType.WWA))); + } + + /** The three RGBW tints differ only in what you buy, so they are drawn the same. */ + @Test + public void theRgbwTintsShareOneDrawing() { + int[] warm = renderWith(LedType.RGBW_WARM); + Assert.assertArrayEquals(warm, renderWith(LedType.RGBW_NATURAL)); + Assert.assertArrayEquals(warm, renderWith(LedType.RGBW_COOL)); + } + + private static int[] renderWith(LedType ledType) { + WS2812BRing ring = of(RingSize._16_LED); + ring.setLedType(ledType); + return MakerBoardTestSupport.renderPixels(ring); + } + + @Test + public void drawsCleanlyInEveryState() { + for (RingSize ringSize : RingSize.values()) { + for (LedType ledType : LedType.values()) { + WS2812BRing ring = of(ringSize); + ring.setLedType(ledType); + MakerBoardTestSupport.assertDrawsCleanly(ring); + } + } + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BStickTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BStickTest.java new file mode 100644 index 000000000..2805c2ef4 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BStickTest.java @@ -0,0 +1,195 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry tests for the NeoPixel stick. Everything competes for 10.22 mm of height: the holes + * take the top, the packages what is left, and the pads run the whole of it at each end. None can + * move without crowding another, and nothing but a render shows it. + */ +public class WS2812BStickTest { + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static Rectangle2D ledPackage(WS2812BStick stick, int index) { + Point2D led = stick.getLedCentre(index); + double half = AbstractMakerBoard.RGB_LED_SIZE.convertToPixels() / 2.0; + return new Rectangle2D.Double(led.getX() - half, led.getY() - half, half * 2, half * 2); + } + + private static Rectangle2D padRect(WS2812BStick stick, int index) { + Point2D pad = stick.getControlPoint(index); + double w = WS2812BStick.PAD_WIDTH.convertToPixels(); + double h = WS2812BStick.PAD_LENGTH.convertToPixels(); + return new Rectangle2D.Double(pad.getX() - w / 2.0, pad.getY() - h / 2.0, w, h); + } + + private static Rectangle2D holeRect(WS2812BStick stick, boolean right) { + Point2D hole = stick.getHoleCentre(right); + double r = WS2812BStick.MOUNTING_HOLE_SIZE.convertToPixels() / 2.0; + return new Rectangle2D.Double(hole.getX() - r, hole.getY() - r, r * 2, r * 2); + } + + @Test + public void boardIsTheMeasuredSize() { + MakerBoardTestSupport.assertBoardSize(new WS2812BStick(), WS2812BStick.BOARD_WIDTH, + WS2812BStick.BOARD_HEIGHT); + } + + /** Both grounds on an end are the same net on the board, so the node names must still differ. */ + @Test + public void eachEndCarriesFourDistinctlyNamedPads() { + WS2812BStick stick = new WS2812BStick(); + Assert.assertEquals(8, stick.getControlPointCount()); + + Set names = new HashSet(); + for (int i = 0; i < stick.getControlPointCount(); i++) { + Assert.assertTrue("duplicate node name " + stick.getControlPointNodeName(i), + names.add(stick.getControlPointNodeName(i))); + } + Assert.assertEquals("DIN", stick.getControlPointNodeName(1)); + Assert.assertEquals("DOUT", stick.getControlPointNodeName(5)); + } + + /** + * Surface copper flush with the cut ends rather than plated holes set in from them, so each + * column's outer edge lands exactly on the board edge: half a pad out and it overhangs, half a + * pad in and there is a bare strip the real board does not have. + */ + @Test + public void padsAreFlushWithTheCutEnds() { + WS2812BStick stick = new WS2812BStick(); + Rectangle2D board = stick.getBodyShape().getBounds2D(); + + for (int i = 0; i < 4; i++) { + Assert.assertEquals("input pad " + i, board.getX(), padRect(stick, i).getX(), 0.01d); + Assert.assertEquals("output pad " + i, board.getMaxX(), padRect(stick, 4 + i).getMaxX(), + 0.01d); + } + for (int i = 0; i < stick.getControlPointCount(); i++) { + Rectangle2D pad = padRect(stick, i); + Assert.assertTrue("pad " + i + " runs off the board", + board.contains(pad.getX(), pad.getY(), pad.getWidth(), pad.getHeight())); + } + } + + /** A 1.5 mm pad on a 0.1 inch pitch leaves about a millimetre; any taller and they merge. */ + @Test + public void padsInAColumnDoNotTouch() { + WS2812BStick stick = new WS2812BStick(); + for (int i = 0; i < 3; i++) { + Assert.assertFalse("input pads " + i + " and " + (i + 1) + " overlap", + padRect(stick, i).intersects(padRect(stick, i + 1))); + Assert.assertFalse("output pads " + i + " and " + (i + 1) + " overlap", + padRect(stick, 4 + i).intersects(padRect(stick, 5 + i))); + } + } + + @Test + public void mountingHolesAreASpanApartAstrideTheCentre() { + WS2812BStick stick = new WS2812BStick(); + Rectangle2D board = stick.getBodyShape().getBounds2D(); + Point2D left = stick.getHoleCentre(false); + Point2D right = stick.getHoleCentre(true); + + Assert.assertEquals("span", WS2812BStick.MOUNTING_HOLE_SPACING.convertToPixels(), + right.getX() - left.getX(), 0.01d); + Assert.assertEquals("not centred", board.getCenterX(), (left.getX() + right.getX()) / 2.0, + 0.01d); + Assert.assertEquals("depth from the top edge", + WS2812BStick.MOUNTING_HOLE_TOP_OFFSET.convertToPixels(), left.getY() - board.getY(), + 0.01d); + } + + /** The holes and the packages cannot both have the middle, so the packages were moved down. */ + @Test + public void packagesClearTheMountingHoles() { + WS2812BStick stick = new WS2812BStick(); + + for (int i = 0; i < 8; i++) { + Rectangle2D pack = ledPackage(stick, i); + Assert.assertFalse("package " + i + " sits over the left hole", + pack.intersects(holeRect(stick, false))); + Assert.assertFalse("package " + i + " sits over the right hole", + pack.intersects(holeRect(stick, true))); + } + } + + @Test + public void packagesAndHolesStayOnTheBoard() { + WS2812BStick stick = new WS2812BStick(); + Rectangle2D board = stick.getBodyShape().getBounds2D(); + + for (boolean right : new boolean[] {false, true}) { + Rectangle2D hole = holeRect(stick, right); + Assert.assertTrue("a mounting hole breaches the board edge", + board.contains(hole.getX(), hole.getY(), hole.getWidth(), hole.getHeight())); + } + for (int i = 0; i < 8; i++) { + Rectangle2D pack = ledPackage(stick, i); + Assert.assertTrue("package " + i + " hangs off the board", + board.contains(pack.getX(), pack.getY(), pack.getWidth(), pack.getHeight())); + } + } + + /** The pads are drawn after the packages, so an overlap would be hidden under copper. */ + @Test + public void packagesClearThePads() { + WS2812BStick stick = new WS2812BStick(); + + for (int i = 0; i < 8; i++) { + Rectangle2D pack = ledPackage(stick, i); + for (int pad = 0; pad < stick.getControlPointCount(); pad++) { + Assert.assertFalse("package " + i + " runs under pad " + pad, + pack.intersects(padRect(stick, pad))); + } + } + } + + @Test + public void drawsCleanlyInEveryState() { + MakerBoardTestSupport.assertDrawsCleanly(new WS2812BStick()); + } + + @Test + public void honoursTheLedPackage() { + MakerBoardTestSupport.assertAddressableLedTypes(WS2812BStick::new); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BStripTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BStripTest.java new file mode 100644 index 000000000..ad1b055eb --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/displays/WS2812BStripTest.java @@ -0,0 +1,286 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.displays; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.HashSet; +import java.util.Set; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.displays.WS2812BStrip.Density; +import org.diylc.components.maker.MakerBoardTestSupport; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Geometry tests for the strip. The assertions that matter are the ones the numeric checks miss: + * that no pad hangs off the tape, and that no pad sits under an LED package. + */ +public class WS2812BStripTest { + + private static final double PX_PER_MM = 200.0d / 25.4d; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + private static double mm(double px) { + return px / PX_PER_MM; + } + + private static double padHalfWidth() { + return WS2812BStrip.PAD_WIDTH.convertToPixels() / 2.0d; + } + + /** + * The copper a cut leaves: half the pill, reaching inboard from the cut the control point sits + * on. This is what has to stay on the tape and off the packages. + */ + private static Rectangle2D padRect(WS2812BStrip strip, int index) { + Point2D pad = strip.getControlPoint(index); + double w = WS2812BStrip.PAD_WIDTH.convertToPixels(); + double depth = strip.getPadDepth(); + boolean input = index < 3; + return new Rectangle2D.Double(input ? pad.getX() : pad.getX() - depth, pad.getY() - w / 2.0, + depth, w); + } + + private static double ledHalfWidth() { + return new Size(5.0d, SizeUnit.mm).convertToPixels() / 2.0d; + } + + @Test + public void pitchFollowsFromDensity() { + Assert.assertEquals(1000.0d / 30.0d, Density._30.getPitchMm(), 0.01d); + Assert.assertEquals(1000.0d / 60.0d, Density._60.getPitchMm(), 0.01d); + Assert.assertEquals(1000.0d / 144.0d, Density._144.getPitchMm(), 0.01d); + } + + @Test + public void tapeWidthFollowsFromDensity() { + for (Density density : Density.values()) { + WS2812BStrip strip = new WS2812BStrip(); + strip.setDensity(density); + Assert.assertEquals("tape width for " + density, density.getWidthMm(), + mm(strip.getBodyShape().getBounds2D().getHeight()), 0.01d); + } + } + + /** The count drives the length, and the two lead-ins do not scale, so it is linear not doubled. */ + @Test + public void lengthIsLinearInLedCount() { + WS2812BStrip strip = new WS2812BStrip(); + strip.setDensity(Density._60); + strip.setLedCount(8); + double eight = strip.getBodyShape().getBounds2D().getWidth(); + strip.setLedCount(16); + double sixteen = strip.getBodyShape().getBounds2D().getWidth(); + double pitch = new Size(Density._60.getPitchMm(), SizeUnit.mm).convertToPixels(); + Assert.assertEquals("eight more LEDs adds eight pitches", 8 * pitch, sixteen - eight, 0.5d); + } + + /** + * Tape is a repeating cell one pitch long, so a length of it is exactly as many pitches as it has + * LEDs, the two half-cells a pair of cuts leave making one whole one between them. + */ + @Test + public void aLengthOfTapeIsOnePitchPerLed() { + for (Density density : Density.values()) { + for (int count : new int[] {1, 8, 40}) { + WS2812BStrip strip = new WS2812BStrip(); + strip.setDensity(density); + strip.setLedCount(count); + double pitch = new Size(density.getPitchMm(), SizeUnit.mm).convertToPixels(); + + Assert.assertEquals(density + " x " + count, count * pitch, + strip.getBodyShape().getBounds2D().getWidth(), 0.01d); + Assert.assertEquals(density + " lead-in", pitch / 2.0, strip.getLeadIn(), 0.01d); + } + } + } + + /** + * The pill is the nominal length where the density leaves room and otherwise as much as fits, + * never shorter than its own width. At 144/m it bottoms out at a circle. + */ + @Test + public void theDensestTapeCarriesCircularPads() { + WS2812BStrip strip = new WS2812BStrip(); + double width = WS2812BStrip.PAD_WIDTH.convertToPixels(); + + strip.setDensity(Density._30); + Assert.assertEquals("30/m should get the nominal pill", + WS2812BStrip.PAD_LENGTH.convertToPixels(), strip.getPadLength(), 0.01d); + + strip.setDensity(Density._144); + Assert.assertTrue("144/m pad should be about as long as it is wide", + Math.abs(strip.getPadLength() - width) < width * 0.1d); + Assert.assertTrue("a pad may never be shorter than it is wide", + strip.getPadLength() >= width - 0.01d); + } + + /** Six pads whatever the count: the LEDs are drawn, not wired. */ + @Test + public void controlPointCountIsFixedAtSix() { + for (Density density : Density.values()) { + for (int count : new int[] {1, 8, 60, 144}) { + WS2812BStrip strip = new WS2812BStrip(); + strip.setDensity(density); + strip.setLedCount(count); + Assert.assertEquals("control points at " + density + " x " + count, 6, + strip.getControlPointCount()); + } + } + } + + /** + * Both cuts carry all three lines, which is what lets a length be chained to the next. An end + * short of its supply pads cannot be chained, and nothing in the drawing says so. + */ + @Test + public void bothCutsCarryAllThreeLines() { + WS2812BStrip strip = new WS2812BStrip(); + + Set names = new HashSet(); + for (int i = 0; i < strip.getControlPointCount(); i++) { + Assert.assertTrue("duplicate node name " + strip.getControlPointNodeName(i), + names.add(strip.getControlPointNodeName(i))); + } + Assert.assertEquals("DIN", strip.getControlPointNodeName(1)); + Assert.assertEquals("DOUT", strip.getControlPointNodeName(4)); + + // the rails are continuous copper down the tape, so each sits in the same row at both ends + for (int i = 0; i < 3; i++) { + Assert.assertEquals("row " + i + " is not level across the tape", + strip.getControlPoint(i).getY(), strip.getControlPoint(3 + i).getY(), 0.01d); + } + } + + /** A cut leaves half a pad on the very edge rather than a pad set in from it. */ + @Test + public void padsAreFlushWithTheCuts() { + for (Density density : Density.values()) { + for (int count : new int[] {1, 8, 40}) { + WS2812BStrip strip = new WS2812BStrip(); + strip.setDensity(density); + strip.setLedCount(count); + Rectangle2D tape = strip.getBodyShape().getBounds2D(); + + for (int i = 0; i < 3; i++) { + Assert.assertEquals(density + " x " + count + " input pad " + i, tape.getX(), + strip.getControlPoint(i).getX(), 0.01d); + Assert.assertEquals(density + " x " + count + " output pad " + i, tape.getMaxX(), + strip.getControlPoint(3 + i).getX(), 0.01d); + } + } + } + } + + @Test + public void ledCountIsClamped() { + WS2812BStrip strip = new WS2812BStrip(); + strip.setLedCount(100000); + Assert.assertEquals(WS2812BStrip.MAX_LED_COUNT, strip.getLedCount()); + strip.setLedCount(0); + Assert.assertEquals(WS2812BStrip.MIN_LED_COUNT, strip.getLedCount()); + strip.setLedCount(-5); + Assert.assertEquals(WS2812BStrip.MIN_LED_COUNT, strip.getLedCount()); + } + + @Test + public void everyPadSitsOnTheTape() { + for (Density density : Density.values()) { + for (int count : new int[] {1, 8, 40}) { + WS2812BStrip strip = new WS2812BStrip(); + strip.setDensity(density); + strip.setLedCount(count); + Rectangle2D tape = strip.getBodyShape().getBounds2D(); + for (int i = 0; i < strip.getControlPointCount(); i++) { + Rectangle2D pad = padRect(strip, i); + String where = density + " x " + count + " pad " + i; + Assert.assertTrue(where + " runs off the left cut", pad.getX() >= tape.getX() - 0.5d); + Assert.assertTrue(where + " runs off the right cut", + pad.getMaxX() <= tape.getMaxX() + 0.5d); + Assert.assertTrue(where + " runs off the top edge", pad.getY() >= tape.getY() - 0.5d); + Assert.assertTrue(where + " runs off the bottom edge", + pad.getMaxY() <= tape.getMaxY() + 0.5d); + } + } + } + } + + /** + * The output pad must clear the last LED package. At 144/m the pitch is 6.94 mm against a 5 mm + * package, so there is very little room to be wrong in. + */ + @Test + public void outputPadClearsTheLastLed() { + for (Density density : Density.values()) { + for (int count : new int[] {1, 8, 40}) { + WS2812BStrip strip = new WS2812BStrip(); + strip.setDensity(density); + strip.setLedCount(count); + Rectangle2D tape = strip.getBodyShape().getBounds2D(); + double pitch = new Size(density.getPitchMm(), SizeUnit.mm).convertToPixels(); + double lastLed = tape.getX() + strip.getLeadIn() + (count - 1) * pitch; + for (int i = 0; i < 3; i++) { + Assert.assertTrue(density + " x " + count + ": output pad " + i + " overlaps the last LED", + padRect(strip, 3 + i).getX() >= lastLed + ledHalfWidth() - 0.5d); + } + } + } + } + + /** The same, mirrored, at the input end. */ + @Test + public void inputPadsClearTheFirstLed() { + for (Density density : Density.values()) { + WS2812BStrip strip = new WS2812BStrip(); + strip.setDensity(density); + strip.setLedCount(8); + Rectangle2D tape = strip.getBodyShape().getBounds2D(); + double firstLed = tape.getX() + strip.getLeadIn(); + for (int i = 0; i < 3; i++) { + Assert.assertTrue(density + ": input pad " + i + " overlaps the first LED", + padRect(strip, i).getMaxX() <= firstLed - ledHalfWidth() + 0.5d); + } + } + } + + private static double mmToPx(double value) { + return new Size(value, SizeUnit.mm).convertToPixels(); + } + + @Test + public void honoursTheLedPackage() { + MakerBoardTestSupport.assertAddressableLedTypes(WS2812BStrip::new); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/maker/BoardRailTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/maker/BoardRailTest.java new file mode 100644 index 000000000..8708df8e0 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/maker/BoardRailTest.java @@ -0,0 +1,152 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.maker; + +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.core.IDIYComponent; +import org.diylc.netlist.Node; +import org.diylc.components.displays.WS2812BStrip; +import org.diylc.components.micro.ArduinoUno; +import org.diylc.components.micro.ESP32DevKit; +import org.diylc.components.micro.ESP8266NodeMCU; +import org.diylc.components.micro.RaspberryPi; +import org.diylc.components.micro.RaspberryPiPico; +import org.diylc.components.micro.Teensy; + +/** + * Pins a board joins inside itself, as the netlist decides them through + * {@link Node#railName(String)}. + *

+ * The negative cases carry the weight. An underscore does three unrelated jobs across these + * pinouts - it disambiguates a repeated rail, it namespaces a connector's signals, and it + * qualifies a separate net - so a rule keyed on the text before the underscore would short an + * Ethernet or USB header together. + * + * @author Branislav Stojkovic + */ +public class BoardRailTest { + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + @Test + public void repeatedRailsAreOneNode() throws Exception { + ESP32DevKit esp = new ESP32DevKit(); + assertJoined(esp, "GND_1", "GND_2"); + + RaspberryPi pi = new RaspberryPi(); + assertJoined(pi, "GND (Pin 6)", "GND (Pin 9)"); + assertJoined(pi, "5V (Pin 2)", "5V (Pin 4)"); + assertJoined(pi, "3.3V (Pin 1)", "3.3V (Pin 17)"); + + RaspberryPiPico pico = new RaspberryPiPico(); + assertJoined(pico, "GND_1", "GND_8"); + + WS2812BStrip strip = new WS2812BStrip(); + assertJoined(strip, "GND_1", "GND_2"); + assertJoined(strip, "+5V_1", "+5V_2"); + } + + @Test + public void annotatedRailsAreOneNode() throws Exception { + Teensy teensy = new Teensy(); + teensy.setVersion(Teensy.TeensyVersion.Teensy_4_1); + assertJoined(teensy, "GND_1", "GND_4 (Mid)"); + assertJoined(teensy, "3.3V_1", "3.3V_2 (250mA)"); + } + + @Test + public void connectorSignalsSharingAPrefixAreNotJoined() throws Exception { + Teensy teensy = new Teensy(); + teensy.setVersion(Teensy.TeensyVersion.Teensy_4_1); + assertSeparate(teensy, "ETH_TX-", "ETH_RX+"); + assertSeparate(teensy, "ETH_TX-", "ETH_GND"); + assertSeparate(teensy, "USB_D-", "USB_D+"); + assertSeparate(teensy, "USB_5V", "USB_GND1"); + } + + @Test + public void qualifiedNamesAreSeparateNets() throws Exception { + ArduinoUno uno = new ArduinoUno(); + uno.setVersion(ArduinoUno.ArduinoUnoVersion.REV3); + // the USB bridge has its own ICSP header; it is not the main MCU's SPI + assertSeparate(uno, "MISO", "MISO_16U2"); + assertSeparate(uno, "SCK", "SCK_16U2"); + + RaspberryPiPico pico = new RaspberryPiPico(); + // the regulator's enable input is not its output + assertSeparate(pico, "3V3_EN", "3V3 (OUT)"); + } + + @Test + public void absentConnectionsAreNotANet() throws Exception { + ESP32DevKit esp = new ESP32DevKit(); + esp.setVersion(ESP32DevKit.DevKitVersion.ESP32_C6_DevKitC_1); + assertSeparate(esp, "NC_1", "NC_2"); + + ESP8266NodeMCU nodeMcu = new ESP8266NodeMCU(); + assertSeparate(nodeMcu, "RSV_1", "RSV_2"); + } + + @Test + public void signalPinsAreNeverJoined() throws Exception { + RaspberryPiPico pico = new RaspberryPiPico(); + assertSeparate(pico, "GP0", "GP1"); + assertSeparate(pico, "GP0", "GP28"); + } + + private static void assertJoined(IDIYComponent board, String first, String second) { + Assert.assertTrue(board.getClass().getSimpleName() + ": \"" + first + "\" and \"" + second + + "\" should be one node", + sameRail(board, indexOf(board, first), indexOf(board, second))); + } + + private static void assertSeparate(IDIYComponent board, String first, String second) { + Assert.assertFalse(board.getClass().getSimpleName() + ": \"" + first + "\" and \"" + second + + "\" should stay separate nodes", + sameRail(board, indexOf(board, first), indexOf(board, second))); + } + + private static boolean sameRail(IDIYComponent board, int index1, int index2) { + String rail = Node.railName(board.getControlPointNodeName(index1)); + return rail != null && rail.equals(Node.railName(board.getControlPointNodeName(index2))); + } + + private static int indexOf(IDIYComponent board, String nodeName) { + for (int i = 0; i < board.getControlPointCount(); i++) { + if (nodeName.equals(board.getControlPointNodeName(i))) { + return i; + } + } + throw new IllegalArgumentException( + board.getClass().getSimpleName() + " has no pin named \"" + nodeName + "\""); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/MakerBoardTestSupport.java b/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerBoardTestSupport.java similarity index 50% rename from diylc/diylc-library/src/test/java/org/diylc/components/micro/MakerBoardTestSupport.java rename to diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerBoardTestSupport.java index 6378b72f7..3fc045860 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/MakerBoardTestSupport.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerBoardTestSupport.java @@ -19,15 +19,19 @@ * along with DIYLC. If not, see . * */ -package org.diylc.components.micro; +package org.diylc.components.maker; import java.awt.Graphics2D; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; +import java.util.Arrays; +import java.util.function.Supplier; import org.diylc.common.Orientation; +import org.diylc.components.AbstractAddressableLedBoard; import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.AbstractMakerBoard.LedType; import org.diylc.core.ComponentState; import org.diylc.core.IDrawingObserver; import org.diylc.core.Project; @@ -38,14 +42,16 @@ import org.junit.Assert; /** * Shared checks for the maker board tests. * - *

What every board has in common -- naming all of its pins, keeping them apart, drawing in each - * state, rotating -- is asserted once for all of them in {@code MakerComponentsTest}, but only for - * the version each board starts with. The tests in this package cover what is specific to a board: - * its pinout, its geometry and the way those change from one version to the next. + *

What every board has in common -- naming its pins, keeping them apart, drawing, rotating -- + * is asserted for all of them in {@code MakerComponentsTest}, but only for the version each board + * starts with. The per-board tests cover a board's own pinout and geometry. */ -class MakerBoardTestSupport { +public class MakerBoardTestSupport { - static final double PIN_SPACING = new Size(0.1d, SizeUnit.in).convertToPixels(); + public static final double PIN_SPACING = new Size(0.1d, SizeUnit.in).convertToPixels(); + + // large enough for the longest board in the package, with its origin in the middle + private static final int RENDER_SIZE = 1400; private static final Project PROJECT = new Project(); @@ -73,7 +79,7 @@ class MakerBoardTestSupport { }; /** Asserts that pins {@code from} through {@code to} form a straight row on 0.1" pitch. */ - static void assertRow(AbstractMakerBoard board, int from, int to) { + public static void assertRow(AbstractMakerBoard board, int from, int to) { assertRow(board, from, to, PIN_SPACING); } @@ -81,7 +87,7 @@ class MakerBoardTestSupport { * Asserts that pins {@code from} through {@code to} form a straight row on the given pitch. The * axis comes from the first pair, so a row that bends part way along fails. */ - static void assertRow(AbstractMakerBoard board, int from, int to, double pitch) { + public static void assertRow(AbstractMakerBoard board, int from, int to, double pitch) { boolean vertical = Math.abs( board.getControlPoint(from).getX() - board.getControlPoint(from + 1).getX()) < 0.01; for (int i = from; i < to; i++) { @@ -95,7 +101,7 @@ class MakerBoardTestSupport { } /** Asserts the distance between the first pins of two parallel rows. */ - static void assertRowSpacing(AbstractMakerBoard board, int first, int second, Size spacing) { + public static void assertRowSpacing(AbstractMakerBoard board, int first, int second, Size spacing) { Point2D p1 = board.getControlPoint(first); Point2D p2 = board.getControlPoint(second); Assert.assertEquals("Row spacing", spacing.convertToPixels(), p1.distance(p2), 0.01); @@ -103,7 +109,7 @@ class MakerBoardTestSupport { } /** Asserts the outline size, which is what a shield, a case or an enclosure has to fit. */ - static void assertBoardSize(AbstractMakerBoard board, Size width, Size length) { + public static void assertBoardSize(AbstractMakerBoard board, Size width, Size length) { Rectangle2D bounds = board.getBodyShape().getBounds2D(); Assert.assertEquals("Board width", width.convertToPixels(), bounds.getWidth(), 0.1); Assert.assertEquals("Board length", length.convertToPixels(), bounds.getHeight(), 0.1); @@ -114,7 +120,7 @@ class MakerBoardTestSupport { * control points in the same place. A revision that keeps its footprint has to keep its pin * positions too, or switching the version moves every connection in an existing file. */ - static void assertSharedFootprint(AbstractMakerBoard reference, AbstractMakerBoard variant, + public static void assertSharedFootprint(AbstractMakerBoard reference, AbstractMakerBoard variant, int sharedPins) { Assert.assertEquals("Outline", reference.getBodyShape().getBounds2D(), variant.getBodyShape().getBounds2D()); @@ -123,8 +129,79 @@ class MakerBoardTestSupport { } } + /** + * Renders the board alone at a fixed position. Two renderings differing in one property can then + * be compared, which is the only way to catch a drawing change that moves no geometry. + */ + public static int[] renderPixels(AbstractMakerBoard board) { + BufferedImage image = new BufferedImage(RENDER_SIZE, RENDER_SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = image.createGraphics(); + board.setControlPoint(new Point2D.Double(RENDER_SIZE / 2.0, RENDER_SIZE / 2.0), 0); + try { + board.draw(g2d, ComponentState.NORMAL, false, PROJECT, OBSERVER); + } finally { + g2d.dispose(); + } + return image.getRGB(0, 0, RENDER_SIZE, RENDER_SIZE, null, 0, RENDER_SIZE); + } + + /** + * Asserts that a board prints its description on its lit area and that turning the property off + * takes it away again. A screen that silently refused to print would pass the getter. + */ + public static void assertScreenTextIsDrawn(AbstractMakerBoard withText, + AbstractMakerBoard withoutText) { + Assert.assertFalse("The lit area should carry the description, and does not", + Arrays.equals(renderPixels(withText), renderPixels(withoutText))); + } + + /** + * Asserts that a board built out of addressable LEDs honours the package it is fitted with: a + * board saved before the property existed reads as RGB, the packages are pin-compatible so + * fitting one in place of another moves no pad, RGBW is drawn with the white die that is the + * only thing telling it from RGB on sight, and WWA is drawn off the warm white palette rather + * than the colour wheel it cannot emit. + * + * @param factory Builds a fresh board, since each assertion needs one of its own + */ + public static void assertAddressableLedTypes(Supplier factory) { + Assert.assertEquals("A board with no stored package should read as RGB", LedType.RGB, + factory.get().getLedType()); + AbstractAddressableLedBoard stale = factory.get(); + stale.setLedType(null); + Assert.assertEquals("A board saved before the property existed should read as RGB", + LedType.RGB, stale.getLedType()); + + AbstractAddressableLedBoard reference = factory.get(); + for (LedType ledType : LedType.values()) { + AbstractAddressableLedBoard board = factory.get(); + board.setLedType(ledType); + assertSharedFootprint(reference, board, reference.getControlPointCount()); + Assert.assertTrue(ledType + " should be named in the BOM's value column", + board.getValueForDisplay().contains(ledType.toString())); + } + + int[] rgb = renderWithLedType(factory, LedType.RGB); + Assert.assertFalse("RGBW should show the white die RGB does not carry", + Arrays.equals(rgb, renderWithLedType(factory, LedType.RGBW_WARM))); + Assert.assertFalse("WWA should not be drawn lit in colour", + Arrays.equals(rgb, renderWithLedType(factory, LedType.WWA))); + + // the three RGBW tints name different parts to buy but the same thing to draw + int[] warm = renderWithLedType(factory, LedType.RGBW_WARM); + Assert.assertArrayEquals(warm, renderWithLedType(factory, LedType.RGBW_NATURAL)); + Assert.assertArrayEquals(warm, renderWithLedType(factory, LedType.RGBW_COOL)); + } + + private static int[] renderWithLedType(Supplier factory, + LedType ledType) { + AbstractAddressableLedBoard board = factory.get(); + board.setLedType(ledType); + return renderPixels(board); + } + /** Draws the board in every state and orientation; anything that throws fails the test. */ - static void assertDrawsCleanly(AbstractMakerBoard board) { + public static void assertDrawsCleanly(AbstractMakerBoard board) { BufferedImage image = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d = image.createGraphics(); Orientation original = board.getOrientation(); diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerComponentsTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerComponentsTest.java index e6d574f50..3572f6d27 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerComponentsTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerComponentsTest.java @@ -72,6 +72,21 @@ public class MakerComponentsTest { ESP8266NodeMCU.class, WemosD1Mini.class, + // Displays & Outputs + CharacterLCD.class, + OLEDDisplay.class, + SevenSegmentDisplay.class, + LEDMatrix.class, + LEDBarGraph.class, + TFTDisplay.class, + WS2812BStick.class, + WS2812BRing.class, + WS2812BStrip.class, + WS2812BJewel.class, + WS2812BPanel.class, + WS2812BBreakout.class, + Nokia5110LCD.class, + // Electro-Mechanical BatteryHolder18650.class ); @@ -90,15 +105,6 @@ public class MakerComponentsTest { TCRT5000Sensor.class, IRReceiverModule.class, - // Displays & Outputs - CharacterLCD.class, - OLEDDisplay.class, - SevenSegmentDisplay.class, - LEDMatrix8x8.class, - TFTDisplay.class, - WS2812BStick.class, - WS2812BRing.class, - // Modules & Breakouts LogicLevelConverter.class, L298NMotorDriver.class, @@ -186,13 +192,9 @@ public class MakerComponentsTest { IDIYComponent component = clazz.getDeclaredConstructor().newInstance(); component.setControlPoint(new Point2D.Double(200, 200), 0); - // Normal mode component.draw(g2d, ComponentState.NORMAL, false, project, observer); - // Selected mode component.draw(g2d, ComponentState.SELECTED, false, project, observer); - // Outline mode component.draw(g2d, ComponentState.NORMAL, true, project, observer); - // Icon drawing component.drawIcon(g2d, 32, 32); } g2d.dispose(); @@ -222,7 +224,6 @@ public class MakerComponentsTest { Assert.assertTrue("Component " + clazz.getSimpleName() + " should be rotatable", transformer.canRotate(component)); - // Rotate 4 times clockwise (360 degrees back to original) for (int r = 0; r < 4; r++) { transformer.rotate(component, center, 1); } diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerVariantSweepTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerVariantSweepTest.java new file mode 100644 index 000000000..398962a9a --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/maker/MakerVariantSweepTest.java @@ -0,0 +1,252 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.maker; + +import java.awt.Graphics2D; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.common.ComponentType; +import org.diylc.common.PropertyWrapper; +import org.diylc.core.ComponentState; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.Project; +import org.diylc.presenter.ComponentProcessor; + +/** + * Sweeps every registered maker component across the cross product of its enum properties. + *

+ * {@link MakerComponentsTest} and the per-class tests exercise the default variant only. The + * variants are where the geometry differs, and a release makes control-point counts and positions + * permanent, so this walks all of them. The cross product rather than one property at a time, + * because a variant is usually a pair and only the combination decides the pin count. + * + * @author Branislav Stojkovic + */ +public class MakerVariantSweepTest { + + private static final String[] PACKAGES = + {"org.diylc.components.displays", "org.diylc.components.micro"}; + + /** + * Orientation is swept inside the drawing check rather than joining the cross product: it moves + * the control points but cannot change how many there are or what they are called. + */ + private static final String ORIENTATION = "Orientation"; + + private static final Project PROJECT = new Project(); + + private static final IDrawingObserver OBSERVER = new IDrawingObserver() { + + @Override + public void stopTracking() {} + + @Override + public void startTracking() {} + + @Override + public void startTrackingContinuityArea(boolean positive) {} + + @Override + public void stopTrackingContinuityArea() {} + + @Override + public boolean isTrackingContinuityArea() { + return false; + } + + @Override + public void setContinuityMarker(String marker) {} + }; + + @BeforeClass + public static void setUp() { + try { + ConfigurationManager.getInstance().initialize("diylc"); + } catch (Exception ignored) { + } + } + + /** Problems are collected so that a run reports every bad variant rather than just the first. */ + @Test + public void everyVariantHasSoundControlPointsAndDraws() throws Exception { + List problems = new ArrayList(); + int swept = 0; + + for (Class> clazz : makerComponentClasses()) { + List enums = new ArrayList(); + PropertyWrapper orientation = null; + for (PropertyWrapper property : ComponentProcessor.getInstance().extractProperties(clazz)) { + if (!property.getType().isEnum()) { + continue; + } + if (ORIENTATION.equals(property.getName())) { + orientation = property; + } else { + enums.add(property); + } + } + + int combinations = 1; + for (PropertyWrapper property : enums) { + combinations *= property.getType().getEnumConstants().length; + } + + for (int index = 0; index < combinations; index++) { + IDIYComponent component = clazz.getDeclaredConstructor().newInstance(); + StringBuilder where = new StringBuilder(clazz.getSimpleName()); + int remaining = index; + boolean complete = true; + + for (PropertyWrapper property : enums) { + Object[] values = property.getType().getEnumConstants(); + Object value = values[remaining % values.length]; + remaining /= values.length; + where.append(" / ").append(property.getName()).append("=").append(value); + + PropertyWrapper applied = (PropertyWrapper) property.clone(); + applied.setValue(value); + try { + applied.writeTo(component); + } catch (Exception e) { + problems.add(where + ": will not apply -- " + e.getClass().getSimpleName()); + complete = false; + break; + } + } + + if (!complete) { + continue; + } + swept++; + + checkNodeNamesAreUnique(component, where.toString(), problems); + checkControlPointsAreDistinct(component, where.toString(), problems); + checkDrawsInEveryOrientation(component, orientation, where.toString(), problems); + } + } + + Assert.assertTrue("No maker components were discovered", swept > 0); + Assert.assertTrue(swept + " variants swept, " + problems.size() + " problem(s):\n" + + String.join("\n", problems), problems.isEmpty()); + } + + private static void checkNodeNamesAreUnique(IDIYComponent component, String where, + List problems) { + Set seen = new HashSet(); + for (int i = 0; i < component.getControlPointCount(); i++) { + String name = component.getControlPointNodeName(i); + // A null name is a point that carries no node, which is not a collision. + if (name != null && !seen.add(name)) { + problems.add(where + ": duplicate node name \"" + name + "\" at point " + i); + } + } + } + + private static void checkControlPointsAreDistinct(IDIYComponent component, String where, + List problems) { + int count = component.getControlPointCount(); + for (int i = 0; i < count; i++) { + Point2D first = component.getControlPoint(i); + for (int j = i + 1; j < count; j++) { + if (first.distance(component.getControlPoint(j)) <= 0.001) { + problems.add(where + ": control points " + i + " and " + j + " coincide"); + } + } + } + } + + private static void checkDrawsInEveryOrientation(IDIYComponent component, + PropertyWrapper orientation, String where, List problems) + throws CloneNotSupportedException { + Object[] orientations = + orientation == null ? new Object[] {null} : orientation.getType().getEnumConstants(); + + for (Object value : orientations) { + String at = where; + if (value != null) { + at = where + " / " + orientation.getName() + "=" + value; + PropertyWrapper applied = (PropertyWrapper) orientation.clone(); + applied.setValue(value); + try { + applied.writeTo(component); + } catch (Exception e) { + problems.add(at + ": will not apply -- " + e.getClass().getSimpleName()); + continue; + } + } + + BufferedImage image = new BufferedImage(600, 600, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = image.createGraphics(); + // Translating the canvas rather than moving control point 0 keeps the component's geometry + // intact: a drag sets every control point, so moving only the first leaves the rest stale. + g2d.translate(300, 300); + try { + component.draw(g2d, ComponentState.NORMAL, false, PROJECT, OBSERVER); + component.draw(g2d, ComponentState.SELECTED, false, PROJECT, OBSERVER); + component.draw(g2d, ComponentState.NORMAL, true, PROJECT, OBSERVER); + component.drawIcon(g2d, 32, 32); + } catch (Exception e) { + problems.add(at + ": draw threw " + e.getClass().getSimpleName() + " -- " + e.getMessage()); + } finally { + g2d.dispose(); + } + } + } + + private static List>> makerComponentClasses() { + List>> classes = + new ArrayList>>(); + for (List category : ComponentProcessor.getInstance().getComponentTypes() + .values()) { + for (ComponentType type : category) { + String name = type.getInstanceClass().getName(); + for (String prefix : PACKAGES) { + if (name.startsWith(prefix + ".")) { + classes.add(type.getInstanceClass()); + } + } + } + } + // discovery order is not stable, and a failure message naming variants reads better sorted + Collections.sort(classes, new Comparator>>() { + + @Override + public int compare(Class> a, Class> b) { + return a.getName().compareTo(b.getName()); + } + }); + return classes; + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/maker/ScreenTextTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/maker/ScreenTextTest.java new file mode 100644 index 000000000..7d3ce0741 --- /dev/null +++ b/diylc/diylc-library/src/test/java/org/diylc/components/maker/ScreenTextTest.java @@ -0,0 +1,101 @@ +/* + * + * DIY Layout Creator (DIYLC). + * Copyright (c) 2009-2025 held jointly by the individual authors. + * + * This file is part of DIYLC. + * + * DIYLC is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * DIYLC 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DIYLC. If not, see . + * + */ +package org.diylc.components.maker; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; + +import org.diylc.common.Display; +import org.diylc.components.MakerBoardPainter; +import org.junit.Assert; +import org.junit.Test; + +/** + * What {@code MakerBoardPainter.drawScreenText} promises, independently of its callers: it prints + * nothing at all rather than a crop, so a panel too small for its description is left dark. + */ +public class ScreenTextTest { + + private static final String VALUE = "0.96\" SSD1306 128x64, I2C"; + + private static int inkedPixels(Rectangle2D area, Display display, String name, String value) { + BufferedImage image = new BufferedImage(600, 400, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = image.createGraphics(); + try { + MakerBoardPainter.drawScreenText(g2d, area, Color.WHITE, display, name, value); + } finally { + g2d.dispose(); + } + int count = 0; + for (int rgb : image.getRGB(0, 0, 600, 400, null, 0, 600)) { + if ((rgb >>> 24) != 0) { + count++; + } + } + return count; + } + + @Test + public void aPanelWithRoomPrintsItsDescription() { + Assert.assertTrue( + inkedPixels(new Rectangle2D.Double(10, 10, 400, 200), Display.VALUE, "DISP1", VALUE) > 0); + } + + @Test + public void aPanelWithoutRoomPrintsNothing() { + Assert.assertEquals(0, + inkedPixels(new Rectangle2D.Double(10, 10, 24, 10), Display.VALUE, "DISP1", VALUE)); + } + + @Test + public void aPanelTooShallowForTheBlockPrintsNothing() { + // wide enough for every line, but with room for one of the two + Assert.assertEquals(0, + inkedPixels(new Rectangle2D.Double(10, 10, 400, 16), Display.VALUE, "DISP1", VALUE)); + } + + @Test + public void noneLeavesTheScreenDark() { + Assert.assertEquals(0, + inkedPixels(new Rectangle2D.Double(10, 10, 400, 200), Display.NONE, "DISP1", VALUE)); + } + + @Test + public void aPartWithNoVariantPrintsNothingForItsValue() { + Assert.assertEquals(0, + inkedPixels(new Rectangle2D.Double(10, 10, 400, 200), Display.VALUE, "DISP1", "")); + } + + @Test + public void eachSettingPrintsSomethingDifferent() { + Rectangle2D area = new Rectangle2D.Double(10, 10, 400, 200); + int name = inkedPixels(area, Display.NAME, "DISP1", VALUE); + int value = inkedPixels(area, Display.VALUE, "DISP1", VALUE); + int both = inkedPixels(area, Display.BOTH, "DISP1", VALUE); + + Assert.assertTrue(name > 0); + Assert.assertTrue(value > name); + Assert.assertTrue(both > value); + } +} diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoMegaTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoMegaTest.java index 3eca17e65..9d735e06d 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoMegaTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoMegaTest.java @@ -23,6 +23,8 @@ package org.diylc.components.micro; import java.awt.geom.Point2D; +import org.diylc.components.maker.MakerBoardTestSupport; + import org.junit.Assert; import org.junit.Test; diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoNanoTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoNanoTest.java index b5bfd1649..5ef309000 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoNanoTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoNanoTest.java @@ -21,6 +21,7 @@ */ package org.diylc.components.micro; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.components.micro.ArduinoNano.NanoVersion; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; @@ -46,8 +47,8 @@ public class ArduinoNanoTest { Assert.assertEquals("MISO", classic.getControlPointNodeName(30)); // the silkscreen prints what is on the board: both grounds and both resets are printed alike - Assert.assertEquals("GND1", classic.getControlPointNodeName(3)); - Assert.assertEquals("GND2", classic.getControlPointNodeName(16)); + Assert.assertEquals("GND_1", classic.getControlPointNodeName(3)); + Assert.assertEquals("GND_2", classic.getControlPointNodeName(16)); Assert.assertEquals("GND", classic.getSilkPinLabel(3)); Assert.assertEquals("GND", classic.getSilkPinLabel(16)); Assert.assertEquals("RST", classic.getSilkPinLabel(2)); diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoUnoTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoUnoTest.java index 7ee3c6a3d..260819435 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoUnoTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ArduinoUnoTest.java @@ -21,9 +21,12 @@ */ package org.diylc.components.micro; +import java.awt.Color; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.components.micro.ArduinoUno.ArduinoUnoVersion; import org.junit.Assert; import org.junit.Test; @@ -52,8 +55,8 @@ public class ArduinoUnoTest { // the silkscreen prints what is on the board rather than the node name: both grounds of the // power header say "GND" and the reserved first pin says nothing at all - Assert.assertEquals("GND1", r3.getControlPointNodeName(5)); - Assert.assertEquals("GND2", r3.getControlPointNodeName(6)); + Assert.assertEquals("GND_1", r3.getControlPointNodeName(5)); + Assert.assertEquals("GND_2", r3.getControlPointNodeName(6)); Assert.assertEquals("GND", r3.getSilkPinLabel(5)); Assert.assertEquals("GND", r3.getSilkPinLabel(6)); Assert.assertEquals("", r3.getSilkPinLabel(0)); @@ -119,6 +122,25 @@ public class ArduinoUnoTest { } } + /** + * The board colour follows the version only while it is still a default: the R3 and the R4 are + * genuinely different colours, so switching repaints a board the user has not touched, but a + * colour they chose survives. + */ + @Test + public void aChosenColourSurvivesAVersionSwitch() { + ArduinoUno uno = version(ArduinoUnoVersion.REV3); + Assert.assertEquals(AbstractMakerBoard.ARDUINO_TEAL, uno.getBodyColor()); + uno.setVersion(ArduinoUnoVersion.R4_WIFI); + Assert.assertEquals("an untouched board should follow its version", + AbstractMakerBoard.ARDUINO_BLUE, uno.getBodyColor()); + + Color chosen = Color.decode("#8A2BE2"); + uno.setBodyColor(chosen); + uno.setVersion(ArduinoUnoVersion.REV3); + Assert.assertEquals("a chosen colour should survive the switch", chosen, uno.getBodyColor()); + } + private static ArduinoUno version(ArduinoUnoVersion version) { ArduinoUno uno = new ArduinoUno(); uno.setVersion(version); diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ESP32DevKitTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ESP32DevKitTest.java index 66f3429ce..6df9b2319 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ESP32DevKitTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ESP32DevKitTest.java @@ -24,6 +24,7 @@ package org.diylc.components.micro; import java.awt.geom.Rectangle2D; import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.components.micro.ESP32DevKit.DevKitVersion; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ESP8266NodeMCUTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ESP8266NodeMCUTest.java index ec7ab4d07..1477d230b 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/ESP8266NodeMCUTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/ESP8266NodeMCUTest.java @@ -23,6 +23,7 @@ package org.diylc.components.micro; import java.awt.geom.Rectangle2D; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiPicoTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiPicoTest.java index 4e49f7139..5283f1bd0 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiPicoTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiPicoTest.java @@ -24,6 +24,7 @@ package org.diylc.components.micro; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.components.micro.RaspberryPiPico.PicoVersion; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiTest.java index 7c92ce0af..4fe2c9b3e 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiTest.java @@ -24,6 +24,7 @@ package org.diylc.components.micro; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.components.micro.RaspberryPi.RaspberryPiVersion; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiZeroTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiZeroTest.java index 8a946f046..46068db65 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiZeroTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/RaspberryPiZeroTest.java @@ -24,6 +24,7 @@ package org.diylc.components.micro; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.components.micro.RaspberryPiZero.ZeroVersion; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/TeensyTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/TeensyTest.java index d1f0f36d3..61a87abfa 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/TeensyTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/TeensyTest.java @@ -21,6 +21,7 @@ */ package org.diylc.components.micro; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.components.micro.Teensy.TeensyVersion; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; @@ -35,7 +36,7 @@ public class TeensyTest { Teensy teensy40 = new Teensy(); Assert.assertEquals(TeensyVersion.Teensy_4_0, teensy40.getVersion()); Assert.assertEquals(34, teensy40.getControlPointCount()); - Assert.assertEquals("GND", teensy40.getControlPointNodeName(0)); + Assert.assertEquals("GND_1", teensy40.getControlPointNodeName(0)); Assert.assertEquals("0 (RX1/CS1/CRX2)", teensy40.getControlPointNodeName(1)); Assert.assertEquals("12 (MISO/MQSL)", teensy40.getControlPointNodeName(13)); Assert.assertEquals("VIN (3.6-5.5V)", teensy40.getControlPointNodeName(14)); diff --git a/diylc/diylc-library/src/test/java/org/diylc/components/micro/WemosD1MiniTest.java b/diylc/diylc-library/src/test/java/org/diylc/components/micro/WemosD1MiniTest.java index 374a67ceb..41aeeacf6 100644 --- a/diylc/diylc-library/src/test/java/org/diylc/components/micro/WemosD1MiniTest.java +++ b/diylc/diylc-library/src/test/java/org/diylc/components/micro/WemosD1MiniTest.java @@ -23,6 +23,8 @@ package org.diylc.components.micro; import java.awt.geom.Rectangle2D; +import org.diylc.components.AbstractMakerBoard; +import org.diylc.components.maker.MakerBoardTestSupport; import org.diylc.core.measures.Size; import org.diylc.core.measures.SizeUnit; @@ -42,7 +44,7 @@ public class WemosD1MiniTest { Assert.assertEquals("D0 (GPIO16)", mcu.getControlPointNodeName(2)); Assert.assertEquals("3V3", mcu.getControlPointNodeName(7)); - // right row, top to bottom; the board prints the ground pad as "G" + // right row in array order, which runs bottom to top on the board; the ground pad prints "G" Assert.assertEquals("5V", mcu.getControlPointNodeName(8)); Assert.assertEquals("G (GND)", mcu.getControlPointNodeName(9)); Assert.assertEquals("D1 (GPIO5)", mcu.getControlPointNodeName(13)); @@ -67,4 +69,31 @@ public class WemosD1MiniTest { new Size(0.275d, SizeUnit.in).convertToPixels(), mcu.getControlPoint(0).getY() - bounds.getY(), 0.1); } + + /** + * The right row runs bottom to top, so its first pin is level with the left row's last. The + * silkscreen has to follow that, which is why the labels are taken from each control point + * rather than from a parallel array. + */ + @Test + public void theRightRowRunsBottomToTop() { + WemosD1Mini mcu = new WemosD1Mini(); + Assert.assertEquals("the rows should start and end level", mcu.getControlPoint(7).getY(), + mcu.getControlPoint(8).getY(), 0.01); + Assert.assertTrue("pin 8 should sit below pin 15", + mcu.getControlPoint(8).getY() > mcu.getControlPoint(15).getY()); + } + + /** The silkscreen is the node names with their annotations stripped. */ + @Test + public void silkLabelsComeFromTheNodeNames() { + WemosD1Mini mcu = new WemosD1Mini(); + String[] expected = new String[] {"RST", "A0", "D0", "D5", "D6", "D7", "D8", "3V3", "5V", "G", + "D4", "D3", "D2", "D1", "RX", "TX"}; + Assert.assertEquals(expected.length, mcu.getControlPointCount()); + for (int i = 0; i < expected.length; i++) { + Assert.assertEquals("pin " + i, expected[i], + AbstractMakerBoard.getDisplayPinLabel(mcu.getControlPointNodeName(i))); + } + } } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/ActionFactory.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/ActionFactory.java index 5ac45f234..65813ab5b 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/ActionFactory.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/ActionFactory.java @@ -30,12 +30,14 @@ import org.diylc.core.ExpansionMode; import org.diylc.core.Theme; import org.diylc.swing.actions.CheckProximityAction; import org.diylc.swing.actions.ComponentBrowserAction; +import org.diylc.swing.actions.ComponentCategoryAction; import org.diylc.swing.actions.ConfigAction; import org.diylc.swing.actions.ExportGerberAction; import org.diylc.swing.actions.ExportPNGAction; import org.diylc.swing.actions.FindAction; import org.diylc.swing.actions.FlexibleLeadsAction; import org.diylc.swing.actions.RenumberAction; +import org.diylc.swing.actions.ResetCategoriesAction; import org.diylc.swing.actions.ThemeAction; import org.diylc.swing.actions.ToggleAction; import org.diylc.swing.actions.edit.BringToFrontAction; @@ -264,6 +266,15 @@ public class ActionFactory { return new ComponentBrowserAction(browserType); } + public ComponentCategoryAction createComponentCategoryAction(IPlugInPort plugInPort, + String category) { + return new ComponentCategoryAction(plugInPort, category); + } + + public ResetCategoriesAction createResetCategoriesAction(IPlugInPort plugInPort) { + return new ResetCategoriesAction(plugInPort); + } + public RenumberAction createRenumberAction(IPlugInPort plugInPort, boolean xAxisFirst) { return new RenumberAction(plugInPort, xAxisFirst); } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ComponentCategoryAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ComponentCategoryAction.java new file mode 100644 index 000000000..3dbf9ef89 --- /dev/null +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ComponentCategoryAction.java @@ -0,0 +1,63 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.swing.actions; + +import java.awt.event.ActionEvent; +import javax.swing.AbstractAction; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.appframework.miscutils.IConfigListener; + +import org.diylc.common.IPlugInPort; +import org.diylc.core.IView; + +public class ComponentCategoryAction extends AbstractAction { + + private static final long serialVersionUID = 1L; + + private final IPlugInPort plugInPort; + private final String category; + + public ComponentCategoryAction(IPlugInPort plugInPort, String category) { + super(); + this.plugInPort = plugInPort; + this.category = category; + putValue(AbstractAction.NAME, category); + putValue(IView.CHECK_BOX_MENU_ITEM, true); + putValue(AbstractAction.SELECTED_KEY, plugInPort.isCategoryEnabled(category)); + + ConfigurationManager.getInstance().addConfigListener( + IPlugInPort.DISABLED_CATEGORIES_KEY, new IConfigListener() { + + @Override + public void valueChanged(String key, Object value) { + putValue(AbstractAction.SELECTED_KEY, plugInPort.isCategoryEnabled(category)); + } + }); + } + + @Override + public void actionPerformed(ActionEvent e) { + boolean selected = Boolean.TRUE.equals(getValue(AbstractAction.SELECTED_KEY)); + plugInPort.setCategoryEnabled(category, selected); + } +} diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ResetCategoriesAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ResetCategoriesAction.java new file mode 100644 index 000000000..d1e176b33 --- /dev/null +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/ResetCategoriesAction.java @@ -0,0 +1,45 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.swing.actions; + +import java.awt.event.ActionEvent; +import javax.swing.AbstractAction; + +import org.diylc.common.IPlugInPort; + +public class ResetCategoriesAction extends AbstractAction { + + private static final long serialVersionUID = 1L; + + private final IPlugInPort plugInPort; + + public ResetCategoriesAction(IPlugInPort plugInPort) { + super(); + this.plugInPort = plugInPort; + putValue(AbstractAction.NAME, "Reset All"); + } + + @Override + public void actionPerformed(ActionEvent e) { + plugInPort.resetCategoryVisibility(); + } +} diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java index fa51c1306..88becfee0 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/config/ConfigPlugin.java @@ -58,6 +58,7 @@ public class ConfigPlugin implements IPlugIn { private static final String THEME_MENU = "Theme"; private static final String RULER_MENU = "Ruler Inch Subdivision..."; private static final String SNAP_MENU = "Snap To"; + private static final String CATEGORIES_MENU = "Component Categories"; private static final String COMPONENT_BROWSER_MENU = "Toolbox"; public static final String COMPONENT_BROWSER = "componentBrowser"; public static final String PROJECT_EXPLORER = "projectExplorer"; @@ -89,7 +90,15 @@ public class ConfigPlugin implements IPlugIn { IPlugInPort.AUTO_UNIT_CONVERSION_KEY, true), CONFIG_MENU); swingUI.injectMenuAction( ActionFactory.getInstance().createConfigAction(plugInPort, "Auto-Edit Mode", IPlugInPort.AUTO_EDIT_KEY, true), - CONFIG_MENU); + CONFIG_MENU); + swingUI.injectSubmenu(CATEGORIES_MENU, IconLoader.FolderPreferences.getIcon(), CONFIG_MENU); + swingUI.injectMenuAction( + ActionFactory.getInstance().createResetCategoriesAction(plugInPort), CATEGORIES_MENU); + swingUI.injectMenuAction(null, CATEGORIES_MENU); + plugInPort.getComponentTypes().keySet().stream().sorted() + .forEach(category -> swingUI.injectMenuAction( + ActionFactory.getInstance().createComponentCategoryAction(plugInPort, category), + CATEGORIES_MENU)); swingUI.injectMenuAction( ActionFactory.getInstance().createConfigAction(plugInPort, "Continuous Creation", IPlugInPort.CONTINUOUS_CREATION_KEY, false), CONFIG_MENU); diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java index 4de76b9ca..bccc4934a 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java @@ -89,6 +89,9 @@ class ComponentTabbedPane extends JTabbedPane { private List pendingRecentComponents = null; private List blocks; + private final Map categoryPanels = new HashMap(); + private boolean refreshing = false; + public ComponentTabbedPane(IPlugInPort plugInPort, ISwingUI swingUI) { super(); this.plugInPort = plugInPort; @@ -100,23 +103,22 @@ class ComponentTabbedPane extends JTabbedPane { addTab(LangUtil.translate("(Favorites)"), createFavoritesPanel()); addTab(LangUtil.translate("(Recently Used)"), createRecentComponentsPanel()); addTab(LangUtil.translate("(Building Blocks)"), createBuildingBlocksPanel()); - Map> componentTypes = plugInPort.getComponentTypes(); - List categories = new ArrayList(componentTypes.keySet()); - Collections.sort(categories); - for (String category : categories) { - JPanel panel = createTab((componentTypes.get(category))); - addTab(category, panel); - } + + refreshCategoryTabs(); // restore last selected tab int lastSelectedTab = ConfigurationManager.getInstance().readInt(LAST_SELECTED_TAB, -1); - if (lastSelectedTab >= 0) + if (lastSelectedTab >= 0 && lastSelectedTab < getTabCount()) { setSelectedIndex(lastSelectedTab); + } addChangeListener(new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { + if (refreshing) { + return; + } ConfigurationManager.getInstance().writeValue(LAST_SELECTED_TAB, getSelectedIndex()); ComponentTabbedPane.this.plugInPort.setNewComponentTypeSlot(null, null, null, false); // Refresh recent components if needed @@ -131,6 +133,50 @@ class ComponentTabbedPane extends JTabbedPane { setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); } + public void refreshCategoryTabs() { + refreshing = true; + int restoredIndex = -1; + try { + int previousIndex = getSelectedIndex(); + String selectedTitle = + previousIndex >= 0 && previousIndex < getTabCount() ? getTitleAt(previousIndex) : null; + + while (getTabCount() > 3) { + removeTabAt(3); + } + + Map> visibleComponentTypes = + plugInPort.getVisibleComponentTypes(); + List categories = new ArrayList(visibleComponentTypes.keySet()); + Collections.sort(categories); + for (String category : categories) { + Component panel = categoryPanels.get(category); + if (panel == null) { + panel = createTab(visibleComponentTypes.get(category)); + categoryPanels.put(category, panel); + } + addTab(category, panel); + } + + if (selectedTitle != null) { + for (int i = 0; i < getTabCount(); i++) { + if (selectedTitle.equals(getTitleAt(i))) { + restoredIndex = i; + break; + } + } + } + } finally { + refreshing = false; + } + + if (restoredIndex >= 0) { + setSelectedIndex(restoredIndex); + } else if (getTabCount() > 0) { + setSelectedIndex(0); + } + } + @SuppressWarnings("unchecked") private void initialize() { Map> componentTypes = plugInPort.getComponentTypes(); diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ToolBox.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ToolBox.java index a80248032..a099d9174 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ToolBox.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ToolBox.java @@ -79,13 +79,13 @@ public class ToolBox implements IPlugIn { @Override public EnumSet getSubscribedEventTypes() { - // TODO Auto-generated method stub - return null; + return EnumSet.of(EventType.COMPONENT_CATEGORIES_CHANGED); } @Override public void processMessage(EventType eventType, Object... params) { - // TODO Auto-generated method stub - + if (eventType == EventType.COMPONENT_CATEGORIES_CHANGED) { + getComponentTabbedPane().refreshCategoryTabs(); + } } } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/ComponentTree.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/ComponentTree.java index db14d14d7..4b79e43ff 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/ComponentTree.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/ComponentTree.java @@ -103,13 +103,13 @@ public class ComponentTree implements IPlugIn { @Override public EnumSet getSubscribedEventTypes() { - // TODO Auto-generated method stub - return null; + return EnumSet.of(EventType.COMPONENT_CATEGORIES_CHANGED); } @Override public void processMessage(EventType eventType, Object... params) { - // TODO Auto-generated method stub - + if (eventType == EventType.COMPONENT_CATEGORIES_CHANGED) { + getTreePanel().getTreeModel().refresh(); + } } } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java index 44ae73238..081462c1f 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java @@ -76,20 +76,26 @@ public class CustomTreeModel implements TreeModel { public CustomTreeModel(IPlugInPort plugInPort) { super(); this.plugInPort = plugInPort; - this.componentTypes = plugInPort.getComponentTypes(); + this.visibleLeaves = new HashMap>(); + + this.typesByClass = new HashMap(); + for (Map.Entry> e : plugInPort.getComponentTypes().entrySet()) { + for (ComponentType c : e.getValue()) { + typesByClass.put(c.getInstanceClass().getCanonicalName(), c); + } + } + + this.initialize(); + refresh(); + } + + public void refresh() { + this.componentTypes = plugInPort.getVisibleComponentTypes(); this.categories = new ArrayList(this.componentTypes.keySet()); Collections.sort(this.categories); this.categories.add(0, BUILDING_BLOCKS); this.categories.add(0, RECENTLY_USED); this.categories.add(0, FAVORITES); - this.visibleLeaves = new HashMap>(); - - this.typesByClass = new HashMap(); - for (Map.Entry> e : componentTypes.entrySet()) - for (ComponentType c : e.getValue()) - typesByClass.put(c.getInstanceClass().getCanonicalName(), c); - - this.initialize(); updateVisibleCategories(); } diff --git a/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java b/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java index 38c263c67..2a67f5259 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/utils/IconLoader.java @@ -89,7 +89,7 @@ public enum IconLoader { "trace_mask.png"), TraceProximity("trace_proximity.png"), Undo("undo.png"), Ungroup( "ungroup.png"), Unlock("lock_open.png"), Upload("upload.png"), User("user1.png"), Warning( "warning.png"), Web("web.png"), WindowColors("window_colors.png"), WindowGear( - "window_gear.png"), Wrench("wrench.png"), ZoomSmall("zoom_small.png"); + "window_gear.png"), Wrench("wrench.png"), ZoomSmall("zoom_small.png"), FolderPreferences("folder_preferences.png"); protected String name; diff --git a/diylc/diylc-swing/src/main/resources/diylc-swing-images/folder_preferences.png b/diylc/diylc-swing/src/main/resources/diylc-swing-images/folder_preferences.png new file mode 100644 index 0000000000000000000000000000000000000000..2f7fad4461bbd877ccf3f76e7dba300802d3b329 GIT binary patch literal 863 zcmV-l1EBngP)WdKBJAT%INa(W;#FfcP9F*!OhI65#jAS*C2FfihJXe$5!00(qQO+^RR z1s4r64|s}icK`qY32;bRa{vGe@Bjb`@Bu=sG?)MY00d`2O+f$vv5yP74%raws;AgI;ZgX>I+xgh(ImctO=%N>XhnM&HIM4Hc9tQt$ ze68q>@Nj1`fXe&=9$jol6Pd#Q6VLSB1~2f-%KGVPhC=V>hyvrk0zP-3E*yoM?$x4* zeAbl5nXJyg{VIsTKN1YyL%_JTvW&60P*PQcypj{(wiLi%u#q)Lq=c~htBpxN{xb#&LR4}2kCeSPy|SlfYSQwTAVCrW7vTfRvfr*mnYvgXH)|p9!6yT z88XrsoZB5J-d~C0s=e53=O84$QHYGnhPK~TvuRODRG)vrl+<3+O55agA-}X7_M$?V ztt|ORN%|EO?Q$VJKS3cJj`h}M69}SVJQ|##>#E*cf&!K`M}UNr!#W%suH!iQU==f~ zrfdQ}C5=oCwu!1hS^_yjV*I4OE8w5pfn=ruFrA@%Zrt>U@C?Oa;IhISBXRUw0{smV zA8=~oh*WZl6gQ@E>FzSjHU@VZ?dTqs(dIY7WX<5zZV8>xb^+{#R&+(uMxX!5t+fgS zs(}ANl_cTHk?~*Y9z}lCh-&{&!=Go;SjK->mDS*=*rH`CjKH zMEZM0~-^l)#T`? pt);cK11$PdsBg;JqF_n4;}=(^Fnw8N0IL81002ovPDHLkV1f**i(3Ey literal 0 HcmV?d00001 diff --git a/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java b/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java index 53debf6b0..989fb3b1f 100644 --- a/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java +++ b/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java @@ -2,12 +2,27 @@ package org.diylc; import java.awt.Point; import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.Map; +import java.util.Set; -import org.junit.Test; import junit.framework.Assert; +import org.junit.Test; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.appframework.miscutils.InMemoryConfigurationManager; import org.diylc.common.ComponentType; +import org.diylc.common.EventType; +import org.diylc.common.IPlugIn; +import org.diylc.common.IPlugInPort; import org.diylc.core.Template; +import org.diylc.presenter.Presenter; +import org.diylc.swing.ActionFactory; +import org.diylc.swing.actions.ComponentCategoryAction; +import org.diylc.swing.actions.ResetCategoriesAction; +import org.diylc.swing.plugins.tree.CustomTreeModel; public class PresenterTests extends TestBase { @@ -98,6 +113,114 @@ public class PresenterTests extends TestBase { presenter.mouseMoved(new Point(1000, 1000), false, false, false); Assert.assertNull(receivedTooltip[0]); } + + @Test + public void testCategoryVisibilityDefaults() { + presenter.resetCategoryVisibility(); + Map> all = presenter.getComponentTypes(); + Map> visible = presenter.getVisibleComponentTypes(); + Assert.assertEquals(all.keySet(), visible.keySet()); + for (String category : all.keySet()) { + Assert.assertTrue(presenter.isCategoryEnabled(category)); + } + } + + @Test + @SuppressWarnings("unchecked") + public void testDisableAndEnableCategory() { + presenter.resetCategoryVisibility(); + + final Set[] lastEventDisabled = new Set[1]; + IPlugIn testPlugin = new IPlugIn() { + @Override + public void connect(IPlugInPort plugInPort) {} + + @Override + public EnumSet getSubscribedEventTypes() { + return EnumSet.of(EventType.COMPONENT_CATEGORIES_CHANGED); + } + + @Override + public void processMessage(EventType eventType, Object... params) { + if (eventType == EventType.COMPONENT_CATEGORIES_CHANGED && params.length > 0) { + lastEventDisabled[0] = (Set) params[0]; + } + } + }; + presenter.installPlugin(() -> testPlugin); + + // Disable "Tubes" + presenter.setCategoryEnabled("Tubes", false); + Assert.assertFalse(presenter.isCategoryEnabled("Tubes")); + Assert.assertFalse(presenter.getVisibleComponentTypes().containsKey("Tubes")); + Assert.assertTrue(presenter.getComponentTypes().containsKey("Tubes")); + Assert.assertNotNull(lastEventDisabled[0]); + Assert.assertTrue(lastEventDisabled[0].contains("Tubes")); + + // Also disable "Robotics" + presenter.setCategoryEnabled("Robotics", false); + Assert.assertFalse(presenter.isCategoryEnabled("Robotics")); + Assert.assertFalse(presenter.getVisibleComponentTypes().containsKey("Robotics")); + Assert.assertTrue(lastEventDisabled[0].contains("Robotics")); + Assert.assertTrue(lastEventDisabled[0].contains("Tubes")); + + // Check config stores sorted list + List stored = (List) InMemoryConfigurationManager.getInstance() + .readObject(IPlugInPort.DISABLED_CATEGORIES_KEY, new ArrayList()); + Assert.assertEquals(2, stored.size()); + Assert.assertEquals("Robotics", stored.get(0)); + Assert.assertEquals("Tubes", stored.get(1)); + + // Re-enable "Tubes" + presenter.setCategoryEnabled("Tubes", true); + Assert.assertTrue(presenter.isCategoryEnabled("Tubes")); + Assert.assertTrue(presenter.getVisibleComponentTypes().containsKey("Tubes")); + Assert.assertFalse(lastEventDisabled[0].contains("Tubes")); + Assert.assertTrue(lastEventDisabled[0].contains("Robotics")); + + // Reset all + presenter.resetCategoryVisibility(); + Assert.assertTrue(presenter.isCategoryEnabled("Robotics")); + Assert.assertTrue(presenter.isCategoryEnabled("Tubes")); + Assert.assertTrue(lastEventDisabled[0].isEmpty()); + } + + @Test + public void testComponentCategoryActions() { + Presenter prodPresenter = new Presenter(view, ConfigurationManager.getInstance()); + prodPresenter.resetCategoryVisibility(); + + ComponentCategoryAction catAction = + ActionFactory.getInstance().createComponentCategoryAction(prodPresenter, "Tubes"); + Assert.assertEquals(true, catAction.getValue(javax.swing.AbstractAction.SELECTED_KEY)); + + // Toggle off + catAction.putValue(javax.swing.AbstractAction.SELECTED_KEY, false); + catAction.actionPerformed(null); + Assert.assertFalse(prodPresenter.isCategoryEnabled("Tubes")); + + // Reset via ResetCategoriesAction + ResetCategoriesAction resetAction = + ActionFactory.getInstance().createResetCategoriesAction(prodPresenter); + resetAction.actionPerformed(null); + Assert.assertTrue(prodPresenter.isCategoryEnabled("Tubes")); + Assert.assertEquals(true, catAction.getValue(javax.swing.AbstractAction.SELECTED_KEY)); + } + + @Test + public void testCustomTreeModelRefresh() { + presenter.resetCategoryVisibility(); + CustomTreeModel treeModel = new CustomTreeModel(presenter); + Assert.assertTrue(presenter.isCategoryEnabled("Tubes")); + + presenter.setCategoryEnabled("Tubes", false); + treeModel.refresh(); + Assert.assertFalse(presenter.getVisibleComponentTypes().containsKey("Tubes")); + + presenter.resetCategoryVisibility(); + treeModel.refresh(); + Assert.assertTrue(presenter.getVisibleComponentTypes().containsKey("Tubes")); + } // @Test // public void testLoadVariants() { diff --git a/diylc/docs/plans/category-visibility.md b/diylc/docs/plans/category-visibility.md new file mode 100644 index 000000000..2fbf91e5c --- /dev/null +++ b/diylc/docs/plans/category-visibility.md @@ -0,0 +1,288 @@ +# Component Category Visibility + +A `Config` submenu that lets the user hide whole component categories from the +component tree and the tabbed component bar, with the filtering applied in the +`Presenter` and announced to the views through a new event. + +## 1. Goal + +The library ships sixteen categories (`Boards`, `Connectivity`, `Controllers`, +`Displays & Outputs`, `Electro-Mechanical`, `Guitar`, `Misc`, +`Modules & Breakouts`, `Passive`, `Robotics`, `SMD`, `Schematic Symbols`, +`Semiconductors`, `Sensors`, `Shapes`, `Tubes`). A user who only ever draws +guitar wiring has to scroll past tabs and tree nodes they will never open. The +feature is a per-user toggle list: every category visible by default, any subset +hidden, the change visible immediately in whichever browser is active. + +New in `ConfigPlugin`: + +``` +Config + ... + Auto-Edit Mode + Component Categories > + Reset All + --------------- + [x] Boards + [x] Connectivity + ... + [x] Tubes + Continuous Creation + ... +``` + +Disabling a category changes **only what the two browsers list**. Nothing is +hidden on the canvas, no existing project is altered, and no `.diy` file is +affected, so the serialized format and the regression references are untouched. + +## 2. Four decisions taken up front + +**The filtered view is a second accessor, not a change to `getComponentTypes()`.** +That method has six callers and only two of them are browsers: + +| Caller | Uses the map for | Needs every category | +| --- | --- | --- | +| `CustomTreeModel` | tree nodes | no - filter here | +| `ComponentTabbedPane` | category tabs | no - filter here | +| `CanvasPanel` | type cache, keyboard-shortcut lookup | yes | +| `ExplorerPane` | list cell renderer | yes | +| `NetlistImportDialog` | netlist type mapping | yes | +| `ExportVariantsAction` | variant export tree | yes | + +Had the existing method started filtering, a hidden category would silently +break the explorer's rendering of components already on the canvas and the +netlist importer's type lookup. So `getComponentTypes()` keeps its contract and +a new `getVisibleComponentTypes()` is added for the browsers. + +**Favorites, Recently Used and keyboard shortcuts stay reachable.** Only the +per-category listings are filtered. A part the user explicitly pinned does not +vanish because its category was collapsed, and `CustomTreeModel`'s favorites +branch - which dereferences `typesByClass.get(fav.getName())` without a null +check - keeps working because that map is still built from the complete type +list. + +**One config key holds the disabled categories**, as a `List` written +through `writeValue` / read through `readObject`, the same shape as +`FAVORITES_KEY` and `RECENT_COMPONENTS_KEY`: + +``` +disabledComponentCategories = ["Robotics", "Tubes"] +``` + +Storing the *disabled* set rather than the enabled one means an empty value is +the default state, and a category introduced in a future release shows up +enabled for every existing user without a migration. "Reset All" is then simply +writing an empty list. The alternative - one boolean key per category - would +have let `ConfigAction` be reused verbatim, at the cost of config keys derived +from display names and one listener per category inside the `Presenter`. + +**The submenu sits directly under `Config`**, a sibling of `Snap To`, `Theme` +and `Toolbox`, rather than nested inside `Toolbox`. + +## 3. Core: the event + +`diylc-core/src/main/java/org/diylc/common/EventType.java` gains a constant at +the end of the enum, documented like its neighbours: + +```java +/** + * Called when the set of enabled component categories changes. The first parameter is a + * Set<String> of disabled category names. All categories not included are + * enabled. + */ +COMPONENT_CATEGORIES_CHANGED +``` + +The payload mirrors `LAYER_VISIBILITY_CHANGED`, which passes the set of hidden +layers. Views are free to ignore it and re-read `getVisibleComponentTypes()`, +which is what both of them will do. + +## 4. Core: `IPlugInPort` + +Next to `FAVORITES_KEY`: + +```java +public static final String DISABLED_CATEGORIES_KEY = "disabledComponentCategories"; +``` + +and four methods, the accessor documented immediately after +`getComponentTypes()` so the difference between the two is impossible to miss: + +```java +/** + * Returns the {@link ComponentType}s that should be offered to the user, i.e. the result of + * {@link #getComponentTypes()} without the categories the user disabled. Intended for component + * browsers; anything that needs to resolve an arbitrary component class must use + * {@link #getComponentTypes()} instead. + */ +Map> getVisibleComponentTypes(); + +boolean isCategoryEnabled(String category); + +void setCategoryEnabled(String category, boolean enabled); + +void resetCategoryVisibility(); +``` + +Keeping the set arithmetic behind these three mutators means the Swing action +never parses the stored list and never touches the config key, so the meaning of +"disabled" lives in exactly one place. + +## 5. Core: `Presenter` + +The disabled set is read from `configManager` on demand rather than cached in a +field. None of these calls is on a hot path - they run when a browser rebuilds +or a menu item is toggled - and a stateless read cannot go stale: + +```java +@SuppressWarnings("unchecked") +private Set getDisabledCategories() { + return new HashSet( + (List) configManager.readObject(DISABLED_CATEGORIES_KEY, new ArrayList())); +} +``` + +`getVisibleComponentTypes()` copies the entries it keeps into a new map; +`ComponentProcessor` hands back its own cached instance, which must not be +mutated. + +`setCategoryEnabled` adds or removes one name and writes a sorted +`ArrayList` back, so the config file stays stable across sessions. +`resetCategoryVisibility()` writes an empty list. + +The event is fired from a config listener, registered in the constructor next to +the existing `SHOW_NODE_NAME_TOOLTIPS_KEY` one, rather than from the setters: + +```java +configManager.addConfigListener(IPlugInPort.DISABLED_CATEGORIES_KEY, new IConfigListener() { + @Override + public void valueChanged(String key, Object value) { + messageDispatcher.dispatchMessage(EventType.COMPONENT_CATEGORIES_CHANGED, + getDisabledCategories()); + } +}); +``` + +Any writer then refreshes the views, not just the menu. The dispatcher is +constructed with `new MessageDispatcher(true)` - synchronous - so the +views are rebuilt on the same EDT call that toggled the checkbox, and no +`invokeLater` is needed. + +## 6. Swing: the menu + +Two new action classes beside `ConfigAction` and `ComponentBrowserAction` in +`org.diylc.swing.actions`: + +- **`ComponentCategoryAction`** - one per category. Modelled on `ConfigAction`: + `NAME` is the category, `IView.CHECK_BOX_MENU_ITEM` is `true`, + `SELECTED_KEY` is seeded from `plugInPort.isCategoryEnabled(category)`, and a + listener on `DISABLED_CATEGORIES_KEY` re-reads that flag so "Reset All" + re-checks every item in the submenu. `actionPerformed` calls + `setCategoryEnabled`. +- **`ResetCategoriesAction`** - a plain item calling + `resetCategoryVisibility()`. + +`ActionFactory` gets `createComponentCategoryAction(plugInPort, category)` and +`createResetCategoriesAction(plugInPort)` next to +`createComponentBrowserAction`. + +`ConfigPlugin` gains `CATEGORIES_MENU = "Component Categories"` and, placed +alphabetically between "Auto-Edit Mode" and "Continuous Creation" to match the +ordering of the rest of the menu: + +```java +swingUI.injectSubmenu(CATEGORIES_MENU, IconLoader.FolderPreferences.getIcon(), CONFIG_MENU); +swingUI.injectMenuAction( + ActionFactory.getInstance().createResetCategoriesAction(plugInPort), CATEGORIES_MENU); +swingUI.injectMenuAction(null, CATEGORIES_MENU); +plugInPort.getComponentTypes().keySet().stream().sorted() + .forEach(category -> swingUI.injectMenuAction( + ActionFactory.getInstance().createComponentCategoryAction(plugInPort, category), + CATEGORIES_MENU)); +``` + +The items are built from the **complete** keyset - a disabled category still +needs a checkbox to turn it back on. `injectMenuAction(null, ...)` is the +established way to add a separator, and `MainFrame.injectMenuAction` already +pushes action names through `LangUtil.translate`, so category names are +translated like every other menu label. + +## 7. Swing: the component tree reacts + +`CustomTreeModel` takes its categories from the filtered map while keeping the +class lookup complete: + +```java +this.componentTypes = plugInPort.getVisibleComponentTypes(); +... +for (Map.Entry> e : plugInPort.getComponentTypes().entrySet()) + for (ComponentType c : e.getValue()) + typesByClass.put(c.getInstanceClass().getCanonicalName(), c); +``` + +and gains a `refresh()` that re-reads the visible map, rebuilds `categories` +with `(Favorites)`, `(Recently Used)` and `(Building Blocks)` back on front, and +calls the existing `updateVisibleCategories()`, which already clears the leaf +cache and fires `treeStructureChanged`. The constructor's category setup moves +into that method so there is one copy of it. `TreePanel` exposes it through its +existing `getTreeModel()`. + +`ComponentTree` currently returns `null` from `getSubscribedEventTypes()` with a +TODO and does nothing in `processMessage`. Both are filled in: + +```java +@Override +public EnumSet getSubscribedEventTypes() { + return EnumSet.of(EventType.COMPONENT_CATEGORIES_CHANGED); +} + +@Override +public void processMessage(EventType eventType, Object... params) { + if (eventType == EventType.COMPONENT_CATEGORIES_CHANGED) { + getTreePanel().getTreeModel().refresh(); + } +} +``` + +Search text survives the refresh because `updateVisibleCategories()` re-applies +it; tree expansion state does not, which is acceptable for an explicit menu +action. + +## 8. Swing: the tabbed toolbar reacts + +`ComponentTabbedPane` builds its category tabs in the constructor, after the +three fixed tabs. That loop moves into `refreshCategoryTabs()`, which removes +every tab from index 3 onwards and re-adds one per visible category. Each +category's panel is cached in a `Map`, because +`createComponentPanel` instantiates a `ComponentButtonFactory` button - with its +icon and variant popup - for every type in the category; toggling a category +must not pay that cost again for the fifteen categories that did not change. + +The selected tab is remembered by title and restored if it is still present. A +`refreshing` guard keeps the existing `ChangeListener` from persisting +intermediate `lastSelectedTab` indices while tabs are being removed. + +`ToolBox` fills in the same `null`/TODO pair as `ComponentTree`, delegating to +`getComponentTabbedPane().refreshCategoryTabs()`. + +## 9. What is deliberately not touched + +`CanvasPanel`, `ExplorerPane`, `NetlistImportDialog` and `ExportVariantsAction` +keep calling `getComponentTypes()` and keep seeing every category, so keyboard +shortcuts, the project explorer's renderer, netlist import and variant export +behave identically whatever is hidden. + +## 10. Verification + +```bash +mvn -q -pl diylc-swing -am compile # core + library + swing +mvn -q test # reactor, since core changed +``` + +Then a manual pass in the app: toggle a category with the tree active and with +the tabbed toolbar active, confirm both update without reopening the menu, +confirm "Reset All" re-checks every item, confirm a favorited part from a hidden +category is still clickable, and confirm the setting survives a restart. + +The change touches neither rendering, serialization nor netlist output, so a +regression-suite run is not called for. diff --git a/diylc/docs/plans/maker-board-roadmap.md b/diylc/docs/plans/maker-board-roadmap.md index e952ba7ee..e2c0d839d 100644 --- a/diylc/docs/plans/maker-board-roadmap.md +++ b/diylc/docs/plans/maker-board-roadmap.md @@ -413,11 +413,27 @@ Small, independent, and worth folding into whichever Tier 1 item is done first. `bomPolicy = SHOW_ONLY_TYPE_NAME`, so a Uno R3 and a Uno R4 WiFi both print as "Arduino Uno". A maker's BOM is a shopping list and the variant is the part they buy. Worth deciding on a uniform approach before the roadmap roughly triples the number of variants. -3. **Editable-property set drifts across the family.** `Headers` exists on `ArduinoNano` and - `RaspberryPiZero` but not on `ArduinoUno`, `Teensy` or `ESP32DevKit`; there is no "show pin - labels" toggle anywhere; `WemosD1Mini` hardcodes `SILK_PIN_NAMES_LEFT` / `SILK_PIN_NAMES_RIGHT` - instead of using the shared `drawPinLabels`. Settling a uniform Version / Headers / Show Labels - / Board Color set now stops ten more boards inheriting the inconsistency. +3. **Editable-property set drifts across the family** -- **mostly withdrawn; see the peripherals + plan's §11.2 for the decision.** Two of the three facts here have not held up. `Headers` is now + on seven boards including `Teensy` and `ESP32DevKit`, and the three that lack it -- `ArduinoUno`, + `ArduinoMega` and the full-size `RaspberryPi` -- are exactly the boards that ship with their + headers soldered, so the distribution is the product line rather than drift; adding it to the Uno + would offer a choice the part does not have. The "show pin labels" toggle is rejected outright, + chiefly because a new boolean deserializes as `false` and would strip the silkscreen from every + existing file. The last clause was the real one and is now **fixed**, though not the way it + was written. `WemosD1Mini` did hardcode `SILK_PIN_NAMES_LEFT` / `SILK_PIN_NAMES_RIGHT`, and they + were `PIN_NAMES` with the annotations stripped -- the right one also **reversed**, because that + row's control points run bottom to top. The labels were correct on screen, so what this cost was + not a visible defect but a duplicate that had to be kept in step by hand and would have drifted + silently the first time a pin was renamed. + + Moving it to the shared `drawPinLabels` would have been the wrong fix: that helper stands its + labels on end, and the D1 mini prints its names flat between its rows, which its 0.9" row + spacing has room for. So the drawing stays local and only the *source* changed -- text from + `getSilkPinLabel(i)` and position from `getRelativeOffsets()`, both indexed by control point, so + the row direction is followed rather than mirrored and the reversed array disappears rather than + being corrected. The render is byte-identical with and without headers. + `silkLabelsComeFromTheNodeNames` and `theRightRowRunsBottomToTop` cover it. 4. **Pin-name conventions differ.** `ESP32DevKit` uses `GND_1`, `ArduinoNano` uses `GND1`. And `AbstractMakerBoard.getDisplayPinLabel` truncates at the first `_` and `(`, so `ArduinoNano`'s `D3 (~)` silently loses its PWM marker on the silkscreen. Pick one convention before adding @@ -442,10 +458,11 @@ Each item is an independent commit; none depends on another except where sequenc 1. **BOM treatment of `Version`** (§8.2). Options: leave as-is; switch these boards to a BOM policy that includes the variant; or expose the variant through a value-like property. Affects every board in the package, so worth deciding once. -2. **Uniform property set** (§8.3). Which of `Headers` and a new "Show Pin Labels" toggle should - exist on all boards, and whether retrofitting `Headers` onto `ArduinoUno` / `Teensy` / - `ESP32DevKit` is wanted. New boolean fields deserialize as `false` in old files, so the default - must be chosen to preserve the current look. +2. ~~**Uniform property set** (§8.3)~~ -- **decided in the peripherals plan's §11.2.** Neither + `Headers` nor a "Show Pin Labels" toggle is universalised: a maker component gets a property + when the real part gives the buyer a choice, and a drawing preference is not a property. The + serialization warning recorded here is what kills the toggle rather than merely constraining + its default. 3. **ESP32-C6 in the same pass as C3?** §5.2 Item 7 is written for both since they share a body. If time-boxing, ship C3 first and add C6 as a one-branch follow-up. 4. **Nano pin-label verbosity** (§5.2 Item 6). Recommendation as written: annotate bus pins on all diff --git a/diylc/docs/plans/maker-peripherals.md b/diylc/docs/plans/maker-peripherals.md new file mode 100644 index 000000000..28b43f0e6 --- /dev/null +++ b/diylc/docs/plans/maker-peripherals.md @@ -0,0 +1,258 @@ +# Maker Peripherals — `Displays & Outputs` + +Status: **13 components shipped**, in the as-yet-unreleased `6.7.0` block of +`diylc-core/src/main/resources/update.xml`. +Scope: `diylc-library`, package `org.diylc.components.displays`. +Companion: `maker-board-roadmap.md`, which covers `org.diylc.components.micro`. + +Nothing in this package has been in a release, so defaults, control-point counts and serialized +field names can still be changed freely. **That stops the moment 6.7.0 ships.** + +## 1. What is in the palette + +| Class | Variants | Other properties | +|---|---|---| +| `CharacterLCD` | 16x2, 20x4 × I2C backpack, parallel HD44780 | Backlight Color, Screen | +| `OLEDDisplay` | 0.96" SSD1306 128x64, 0.91" SSD1306 128x32, 1.3" SH1106 128x64 × I2C, 7-pin SPI, 6-pin SPI | Pixel Color, Screen | +| `TFTDisplay` | 2.8" / 2.4" ILI9341, 0.96" / 1.8" ST7735, 1.54" ST7789, 1.28" GC9A01 round | Screen | +| `Nokia5110LCD` | single part (PCD8544 84x48) | Backlight Color, Screen | +| `SevenSegmentDisplay` | 1-digit 0.56", 4-digit 0.36", 4-digit 0.56", 4-digit TM1637 module | Common, Punctuation, LED / Board / Body colour | +| `LEDMatrix` | MAX7219 single 8x8, compact 8x8, 4-in-1 32x8, 8-in-1 64x8 | Dot Color | +| `LEDBarGraph` | 8, 10, 12 segments | Orientation, body / border colour | +| `WS2812BBreakout` | single LED | — | +| `WS2812BStick` | 8 LEDs | — | +| `WS2812BJewel` | 7 LEDs | — | +| `WS2812BRing` | 12, 16, 24 LEDs | — | +| `WS2812BStrip` | 30 / 60 / 144 LED·m⁻¹ × 1–144 LEDs | — | +| `WS2812BPanel` | Adafruit 8x8 NeoMatrix | — | + +Twelve extend `AbstractMakerBoard`. `LEDBarGraph` is the exception: a DIP-outline part, so it +follows `DIL_IC` on `AbstractLabeledComponent` with its own transformer and inherits none of the +maker helpers. It is still in this category because that is where someone looks for a bar graph. + +## 2. Conventions this package follows + +These are the rules worth knowing before touching anything here. Most were learned by getting them +wrong once. + +**Variants.** A `Version`-style enum on the existing class where the footprint family is shared; a +new class where it is not. The test is structural, not cosmetic: `WS2812BJewel` is a separate class +from `WS2812BRing` because it is a disc rather than an annulus, with a centre LED and its own pad +arrangement — five variant-specific structures is a different part. + +**Figures live on the variant.** Hardcoded `Size` constants and `String[]` arrays in the component +class, per-variant wherever a variant changes them. No pin-definition resource, and no pixel +literals in `draw()` — every dimension derives from a `Size`. + +**A different silkscreen is not a variant; a different pin count is.** The same two pads on an +SSD1306 carry the clock and data lines in both interfaces — the module's BS jumpers choose the +protocol, not the pins — so `SCL`/`SCK`/`CLK`/`D0` are one pin and `SDA`/`MOSI`/`D1` are another, +and boards that print them differently are the same board. Node names pick one spelling and move on. +A 6-pin SPI board is the opposite case: its chip select is tied low on the PCB rather than brought +out, so it cannot share a bus, and that is a variant because it reaches the netlist. + +**Say whether a figure is measured or derived.** Active areas computed from a nominal diagonal at a +panel's aspect ratio are derivations, and are commented as such so nothing is built on them. Where a +part's own measurement contradicts a derivation the measurement wins and carries its own field — but +check first that it really contradicts it. A supplied figure sitting within half a millimetre of +what an existing rule already produces is more likely a reading than a departure, and has twice +turned out to be one; query it before it earns a mechanism. + +**Orientation is sourced per variant, never standardised.** Which edge a board's header leaves by is +a fact about the part. Control point zero is always the leftmost pin, so with the pin order fixed a +top header and a bottom header are mirror images, not the same board turned round. `TFTDisplay` +divides three and three, and every constant states its own edge — the short constructor takes the +flag too, so none can inherit a default. Standardising this family onto one orientation was tried +and had to be undone: it had moved two boards away from the truth. + +**Do not let one property stand in for another.** `isRound()` was serving as "the header is at the +bottom" and broke the moment a rectangular board had a bottom header; `isDualRow()` was serving as +"this is a bare package". Carry both questions explicitly even while the answers agree. + +**Pins are drawn and covered, never skipped.** `drawPinHeader` is what registers a conductive area, +so a header hidden under a module or a moulding is drawn first and painted over. Leaving the call +out looks identical and silently drops those pins from continuity and the netlist. + +**Silk where it fits, nowhere else.** Pin names go through `getSilkPinLabel`, so a name is written +once. A row with no clear strip beside it gets no labels rather than crowded ones — the matrix's +covered rows, the LCD's I2C backpack and the TFTs' SD rows all go unlabelled deliberately. A +`SILK_NAMES` array exists only where the node name is actively wrong on the board, not merely long. + +**The glass prints the part's own spec.** `Display.VALUE` puts the variant string on the lit area, +generated from the properties rather than typed, carrying no prose and no language. `Display.NONE` +gives the unpowered part. No invented demo content, ever. + +**BOM.** Every class sets `bomPolicy = SHOW_ONLY_TYPE_NAME` and returns the variant string from +`getVariantLabel()`, because `BomMaker` groups rows on type name plus value — without it every +variant of a class collapses into one line and the BOM cannot say which part to buy. + +**Shared arrays are shared by identity.** Where two variants have the same pinout they reference one +array constant and the test asserts `assertSame`, so a correction to one reaches both. Two copies +that happen to match would pass every name check and drift the day one is corrected. + +**Nested types at the bottom of the file.** Where a variant needs more than a couple of extra +figures they go into a nested value class rather than onto the enum constructor — +`OLEDDisplay.Layout`, `TFTDisplay.Glass`, `SevenSegmentDisplay.Module`. A constructor past about +fifteen arguments is the signal. + +## 3. Figures that are not measurements + +Kept visible so no one mistakes them for sourced data and builds on them. + +- **`SevenSegmentDisplay` digit widths** — 8.1 mm on both 0.56" packages (unverified) and 5.2 mm on + the 0.36" (inferred from the 0.56" ratio, so resting on an unchecked figure). **Deliberately left + as inferences:** they size the drawn glyph and nothing else, and the glyph is already a declared + departure from the package at `DIGIT_DRAW_SCALE` 0.88. An error here is cosmetic and bounded, + unlike a wrong pin array. +- **`SevenSegmentDisplay.Module` hole figures** — 1.778 and 1.524 mm, which are the 14 and 12 pixel + offsets the class drew before they were figures at all, converted exactly so the drawing did not + change. +- **`Nokia5110LCD`** — the 3.5 mm clearance from the bottom edge to the pin row. +- **`WS2812BStrip`** — tape width (10 mm / 12 mm) is a stated norm rather than a measurement, and + the end-pad lead-in derives from the pad and package footprints. +- **`TFTDisplay`** — the SD rows' horizontal placement is centred across the board by derivation; + only their offset from the edge was given. +- **`OLEDDisplay`** — the 6-pin SPI boards borrow the 7-pin boards' outlines. A 6-pin module is a + PCB of its own with one fewer pad, and nothing but its outline would say whether it matches. + +## 4. Testing + +Per-class tests in `diylc-library/src/test/java/org/diylc/components/displays/`, using +`MakerBoardTestSupport` (`assertRow`, `assertRowSpacing`, `assertBoardSize`, `assertDrawsCleanly`, +`assertScreenTextIsDrawn`, `renderPixels`). + +What these tests are for, beyond the obvious count-and-dimension checks: + +- **Assert relationships, not transcriptions.** A board is exactly as wide as its modules; a glass + clears the mounting holes; no hole is drilled where a pad is; a lit area that is measured really + does disagree with the rule it replaced. A test that restates the constant passes while the + component is wrong. +- **No duplicate node names,** on every variant. The cost is legibility, not connectivity: nodes are + identified by component and control point index and the netlist graph is keyed on position, so two + pads named `GND` stay two nodes that merely print the same, and a reader cannot tell which one a + connection reached. They are invisible on the canvas too, since `getDisplayPinLabel` cuts the + suffix off and both silk as `GND`. `Teensy` is the one board that still repeats a bare name. +- **A drawing smoke test** over every variant, state and orientation. +- When a refactor is meant to change nothing, **render before and after and compare pixels.** The + `digitCount` work was verified that way, and it caught a sub-pixel hole shift the unit tests + missed. + +`MakerComponentsTest` carries the generic discovery and icon coverage. A class ships by moving from +`unreleasedMakerComponentClasses` to `releasedMakerComponentClasses` and uncommenting its +`@ComponentDescriptor`. + +## 5. Release + +The slice is worth shipping on its own: it is archetype-complete, part of it (7-segment, character +LCD, bar graph, addressable strips) reaches builds with no microcontroller in them, and the slices +after it are large and differently shaped, so batching would mean a much later and far less +reviewable release. A release is also the only validation loop that reaches people who own the +parts. + +### 5.1 What shipping makes permanent + +Only this list needs checking beforehand, because only this list is a contract afterwards: + +- class names, and serialized field names and types +- **control-point counts** +- **control-point positions relative to point 0** — these are stored per instance, so moving a pin + relative to the body misplaces every wire in an existing file + +Everything else is fixable in a later release without breaking a file: bezel placement, colours, +silk, hole positions, digit widths. Every figure in §3 falls here, which is why none of them blocks +a release. + +### 5.2 Pre-release checklist + +1. **Audit the pin layout of all 13** — count, node names, and each pin's position relative to the + body. Not the cosmetics; those are §5.1's second list. +2. **Settle `TFTDisplay`'s 2.8" SD row.** It is the least verified thing in the package: the row was + added on the argument that the "(Touch + SD)" label implied a missing header, and its 3.0 mm + offset was read from "the same offset as the bottom row" rather than measured. Four control + points that become permanent. +3. **Re-check `TFTDisplay` generally.** It is where the churn concentrated — both ILI9341s went + 14 to 18 control points, and the 0.96", 1.8" and 1.54" each had every pin move relative to the + body when their orientation was corrected. +4. **Render a contact sheet of all 13 and look at it.** Cheap, and it caught several real errors + that the unit tests passed straight over. + +Regression samples (§6.3) are deliberately *not* on this list. They protect against future change, +which is what a released contract makes dangerous — so they are the first task after shipping, not +a blocker on it. + +### 5.3 Release notes + +One `NEW_FEATURE` per palette entry in the `6.7.0` block, part name then variant list. **A released +block is never edited.** A new variant of a component that is new in the same block goes onto that +component's existing line rather than being logged as an `IMPROVEMENT` — there is no earlier release +for it to improve on. + +## 6. Next steps + +### 6.1 Parts worth adding, ranked by how often they appear in builds + +1. **1.3" 240x240 ST7789**, and the 1.14" and 2.0" ST7789 the Pico ecosystem buys. `TFTDisplay` now + absorbs offset panels, either header edge and second rows, so each is one `Controller` constant. + Needs per board: outline, header offset and edge, pin names in printed order, hole inset and + drill, bezel height, and whether the lit area is centred or measured. +2. **MAX7219 8-digit 7-segment module** — the clock and counter staple, and this package already + models a MAX7219 cascade for the matrix. Unblocked: `DisplayType` now carries digit count, pin + array and module board figures, so this is a constant. +3. **6-digit TM1637 and 2-digit 0.56"** — the rest of the clock and thermometer range. Same. +4. **E-paper, 1.54" / 2.13" / 2.9" SSD1680.** Once judged niche; that call is stale, as ESP32 badge + builds and the Waveshare and Good Display modules made it ordinary. The flexible-cable mounting + is real new drawing work, which is the only reason it is not higher. +5. **WS2812B 16x16 and flexible 8x32 matrices** — the one addressable form the package lacks. +6. **The bare WS2812B 5050 LED**, as against a breakout. People solder these directly. +7. **16x4 character LCD.** Also the RGB-backlight 1602, which is *not* the free colour default it + looks like: it is an 18-pin part whose three backlight anodes are real pins, so it is an + `LCDInterface` constant with its own array. +8. **Nixie tubes — IN-12, IN-14, B7971.** The strongest audience overlap on this list: DIYLC already + has a `Tubes` category, and the people in it build Nixie clocks. +9. **2.42" SSD1309 OLED**, 14- and 16-segment alphanumeric, HT16K33 backpacks. Real, second tier. + +Explicitly rejected: a tint property on `TFTDisplay`. A 240x320 colour panel has no tint to set, and +offering one invites a drawing that says something false about the hardware. + +### 6.2 The "& Outputs" half of the name is unfulfilled + +Every member of this category emits light. **There is no analog panel meter, no VU meter, no speaker +or driver, and no magic-eye tube anywhere in the library** — `misc/Buzzer` and +`electromechanical/PilotLampHolder` are the whole non-display output range. + +This is a bigger gap than anything in §6.1, and it reaches the audience DIYLC already has: a VU +meter and a loudspeaker appear in amp and effect builds containing no microcontroller at all, +`Dial Scale` is already next door in `misc`, and a moving-coil meter is a simpler drawing than any +display here. Whether these belong in this category or in `Electro-Mechanical` is not an interesting +question; that they are absent is the finding. + +### 6.3 Regression coverage — the standing risk + +**No `.diy` file among the 1418 in `diylc-regression-data` references `org.diylc.components.micro` +or `org.diylc.components.displays`,** so 23 components have no rendering or netlist baseline. Unit +tests guard the figures we chose; they cannot catch a pin dropping out of the netlist or a component +misbehaving in a real project. Samples were declined for this slice; the case is stronger now that +orientations, pin arrays and geometry have all moved. + +### 6.4 Later slices + +**`sensors` (9 drafts).** Ship the four that are the unrivalled answer in their function — HC-SR04, +DHT11/22, PIR HC-SR501, MPU6050 — and leave the five commodity KY-style three-pin boards, which +carry most of the sourcing cost for the least distinctiveness. **Do not** collapse the tail into a +parameterised "three-pin sensor module": that is the generic configurable board that was already +built and reverted, and it fails for the same reason — people search the palette for a product name. + +**`modules` (21 drafts).** The largest and least homogeneous batch, and the worst geometry drift in +the tree (`ULN2003Driver` has 24 raw pixel offsets, `PCA9685ServoDriver` 22, `L298NMotorDriver` 18). +Split by function rather than shipping as one category. Settle one overlap first: the drafted +`ActiveBuzzerModule` (KY-012) against the existing `misc/Buzzer`. + +**`robotics` (4 drafts).** Blocked on an architectural decision, not on part selection. +`AbstractMotor` is a parallel fork of `AbstractMakerBoard`, re-implementing `rotatePoints`, +`drawPins`, `getFinalBorderColor` and `getCachingBounds` plus its own `PIN_COLOR`, `PIN_SIZE` and +silk fonts. Four components do not justify a second hierarchy: either lift the shared behaviour into +a common base or take the duplication deliberately. + +**i18n.** Component display names and enum labels are plain strings, absent from +`diylc-swing/src/main/resources/lang/*.txt`, as they are for the shipped boards. No work required +here; noted for a future pass.