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
new file mode 100644
index 00000000..484c4fb3
--- /dev/null
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/displays/WS2812BRing.java
@@ -0,0 +1,304 @@
+/*
+ *
+ * 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.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.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 = "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 {
+
+ 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"};
+
+ private RingSize ringSize = RingSize._16_LED;
+
+ public WS2812BRing() {
+ super();
+ this.bodyColor = NEO_BLACK;
+ updateControlPoints();
+ }
+
+ @EditableProperty(name = "Ring Size")
+ public RingSize getRingSize() {
+ return ringSize;
+ }
+
+ public void setRingSize(RingSize ringSize) {
+ this.ringSize = ringSize;
+ updateControlPoints();
+ invalidateCache();
+ }
+
+ @Override
+ public String getControlPointNodeName(int index) {
+ if (index >= 0 && index < PIN_NAMES.length) {
+ return PIN_NAMES[index];
+ }
+ return Integer.toString(index + 1);
+ }
+
+ 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;
+ }
+
+ 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;
+ }
+
+ @Override
+ protected void updateControlPoints() {
+ Point2D firstPoint = controlPoints[0];
+ double midR = getMidRadius();
+ double theta0 = getPadAngle(0);
+
+ double[][] relativeOffsets = new double[4][2];
+ for (int i = 0; i < 4; 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));
+ }
+
+ rotatePoints(firstPoint, relativeOffsets);
+ }
+
+ private Point2D getCenter() {
+ Point2D p0 = controlPoints[0];
+ double midR = getMidRadius();
+ 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));
+ }
+
+ @Override
+ public Shape getBodyShape() {
+ Point2D center = getCenter();
+ double outerR = new Size(ringSize.getOuterDiameterMm() / 2.0, SizeUnit.mm).convertToPixels();
+ 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 = 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);
+
+ Composite oldComposite = applyAlpha(g2d, componentState);
+
+ drawingObserver.startTracking();
+ g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : bodyColor);
+ g2d.fill(outerArea);
+ drawingObserver.stopTracking();
+
+ g2d.setColor(getFinalBorderColor(componentState, outlineMode));
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f));
+ g2d.draw(outerArea);
+
+ 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;
+
+ // Draw all LEDs around the circular ring
+ 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));
+ }
+
+ // 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);
+
+ // 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);
+ }
+ }
+
+ g2d.setTransform(oldTx);
+
+ // Draw the 4 solder pads directly on the circular ring PCB
+ drawSolderPads(g2d, 0, 4, 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;
+ int innerR = outerR / 2;
+
+ Area outer = new Area(new Ellipse2D.Double(cx - outerR, cy - outerR, outerR * 2, outerR * 2));
+ Area inner = new Area(new Ellipse2D.Double(cx - innerR, cy - innerR, innerR * 2, innerR * 2));
+ outer.subtract(inner);
+ g2d.setColor(NEO_BLACK);
+ g2d.fill(outer);
+ g2d.setColor(Color.DARK_GRAY);
+ g2d.draw(outer);
+
+ Color[] rainbow = new Color[] {
+ Color.decode("#FF3333"),
+ Color.decode("#FFD700"),
+ Color.decode("#00E676"),
+ Color.decode("#00E5FF"),
+ Color.decode("#E040FB"),
+ Color.decode("#FF6B35")
+ };
+
+ double midR = (outerR + innerR) / 2.0;
+ int dotR = Math.max(2, (int)(midR * 0.35));
+ int dotCount = 6;
+
+ for (int i = 0; i < dotCount; i++) {
+ double angle = 2 * Math.PI * i / dotCount - Math.PI / 2.0;
+ double lx = cx + midR * Math.cos(angle);
+ double ly = cy + midR * Math.sin(angle);
+ g2d.setColor(rainbow[i % rainbow.length]);
+ g2d.fill(new Ellipse2D.Double(lx - dotR, ly - dotR, dotR * 2, dotR * 2));
+ }
+ }
+}
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/electromechanical/BatteryHolder18650.java b/diylc/diylc-library/src/main/java/org/diylc/components/electromechanical/BatteryHolder18650.java
new file mode 100644
index 00000000..b520b5ab
--- /dev/null
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/electromechanical/BatteryHolder18650.java
@@ -0,0 +1,337 @@
+/*
+ *
+ * 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.electromechanical;
+
+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.Rectangle2D;
+import java.awt.geom.RoundRectangle2D;
+
+import org.diylc.appframework.miscutils.ConfigurationManager;
+import org.diylc.awt.StringUtils;
+import org.diylc.common.HorizontalAlignment;
+import org.diylc.common.IPlugInPort;
+import org.diylc.common.ObjectCache;
+import org.diylc.common.Orientation;
+import org.diylc.common.VerticalAlignment;
+import org.diylc.components.AbstractTransparentComponent;
+import org.diylc.core.ComponentState;
+import org.diylc.core.IDIYComponent;
+import org.diylc.core.IDrawingObserver;
+import org.diylc.core.Project;
+import org.diylc.core.Theme;
+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;
+
+@ComponentDescriptor(name = "18650 Battery Holder", category = "Electro-Mechanical",
+ author = "Branislav Stojkovic", description = "PCB-mount 18650 Li-ion Battery Holder (1S or 2S)",
+ instanceNamePrefix = "BTR", zOrder = IDIYComponent.COMPONENT,
+ bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME,
+ enableCache = true)
+public class BatteryHolder18650 extends AbstractTransparentComponent {
+
+ private static final long serialVersionUID = 1L;
+
+ public enum CellCount {
+ _1S("1S (3.7V) — Single Cell", 1, 77.0, 21.0),
+ _2S("2S (7.4V) — Two Cells", 2, 77.0, 42.0);
+
+ private final String label;
+ private final int cells;
+ private final double lengthMm;
+ private final double widthMm;
+
+ CellCount(String label, int cells, double lengthMm, double widthMm) {
+ this.label = label;
+ this.cells = cells;
+ this.lengthMm = lengthMm;
+ this.widthMm = widthMm;
+ }
+
+ @Override public String toString() { return label; }
+ public int getCells() { return cells; }
+ public double getLengthMm() { return lengthMm; }
+ public double getWidthMm() { return widthMm; }
+ }
+
+ public static Color HOLDER_BODY = Color.decode("#2C2C2C");
+ public static Color HOLDER_BODY_LIGHT = Color.decode("#3C3C3C");
+ public static Color SPRING_COLOR = Color.decode("#C0C0C0");
+ public static Color SPRING_BORDER = Color.decode("#909090");
+ public static Color PLATE_COLOR = Color.decode("#D0D0D0");
+ public static Color CELL_CAVITY = Color.decode("#1A1A1A");
+
+ private static final Font LABEL_FONT = new Font("SansSerif", Font.BOLD, 12);
+
+ private String value = "";
+ private CellCount cellCount = CellCount._1S;
+ private Orientation orientation = Orientation.DEFAULT;
+
+ // Two control points: Point 0 = + (left), Point 1 = - (right)
+ private Point2D[] controlPoints = new Point2D[] {
+ new Point2D.Double(0, 0),
+ new Point2D.Double(0, 0)
+ };
+
+ public BatteryHolder18650() {
+ super();
+ updateControlPoints();
+ }
+
+ @EditableProperty(name = "Cell Count")
+ public CellCount getCellCount() {
+ return cellCount;
+ }
+
+ public void setCellCount(CellCount cellCount) {
+ this.cellCount = cellCount;
+ updateControlPoints();
+ }
+
+ @EditableProperty(name = "Orientation")
+ public Orientation getOrientation() {
+ return orientation;
+ }
+
+ public void setOrientation(Orientation orientation) {
+ this.orientation = orientation;
+ updateControlPoints();
+ }
+
+ private void updateControlPoints() {
+ double length = new Size(cellCount.getLengthMm(), SizeUnit.mm).convertToPixels();
+ Point2D p0 = controlPoints[0];
+
+ double theta = orientation.toRadians();
+ // + at p0 (left), - at p0 + (length, 0) rotated (right)
+ double dx = Math.cos(theta) * length;
+ double dy = Math.sin(theta) * length;
+ controlPoints[1] = new Point2D.Double(p0.getX() + dx, p0.getY() + dy);
+ }
+
+ @Override
+ public int getControlPointCount() { return 2; }
+
+ @Override
+ public Point2D getControlPoint(int index) { return controlPoints[index]; }
+
+ @Override
+ public void setControlPoint(Point2D point, int index) {
+ controlPoints[index].setLocation(point);
+ if (index == 0) {
+ updateControlPoints();
+ }
+ }
+
+ @Override
+ public boolean isControlPointSticky(int index) { return true; }
+
+ @Override
+ public VisibilityPolicy getControlPointVisibilityPolicy(int index) {
+ return VisibilityPolicy.WHEN_SELECTED;
+ }
+
+ @Override
+ public boolean canPointMoveFreely(int pointIndex) { return false; }
+
+ @Override
+ public boolean canControlPointOverlap(int index) { return false; }
+
+ @Override
+ public String getControlPointNodeName(int index) {
+ return index == 0 ? "+ (VCC)" : "- (GND)";
+ }
+
+ @Override
+ public String getValue() { return value; }
+
+ @Override
+ public void setValue(String value) { this.value = value; }
+
+ @Override
+ public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project,
+ IDrawingObserver drawingObserver) {
+
+ Point2D p0 = controlPoints[0];
+ double x = p0.getX();
+ double y = p0.getY();
+ double length = new Size(cellCount.getLengthMm(), SizeUnit.mm).convertToPixels();
+ double width = new Size(cellCount.getWidthMm(), SizeUnit.mm).convertToPixels();
+ double cellD = new Size(18.5d, SizeUnit.mm).convertToPixels();
+
+ AffineTransform oldTx = g2d.getTransform();
+ if (orientation != Orientation.DEFAULT) {
+ g2d.rotate(orientation.toRadians(), x, y);
+ }
+
+ Composite oldComposite = applyAlpha(g2d, componentState);
+
+ // Border color
+ Color borderColor;
+ if (outlineMode) {
+ Theme theme = (Theme) ConfigurationManager.getInstance().readObject(IPlugInPort.THEME_KEY, Constants.DEFAULT_THEME);
+ borderColor = (componentState == ComponentState.SELECTED || componentState == ComponentState.DRAGGING)
+ ? SELECTION_COLOR : (theme != null ? theme.getOutlineColor() : Color.BLACK);
+ } else {
+ borderColor = (componentState == ComponentState.SELECTED || componentState == ComponentState.DRAGGING)
+ ? SELECTION_COLOR : HOLDER_BODY.darker();
+ }
+
+ double bodyY = y - width / 2.0;
+
+ drawingObserver.startTracking();
+ g2d.setColor(outlineMode ? Constants.TRANSPARENT_COLOR : HOLDER_BODY);
+ g2d.fill(new RoundRectangle2D.Double(x, bodyY, length, width, 10, 10));
+ drawingObserver.stopTracking();
+
+ g2d.setColor(borderColor);
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f));
+ g2d.draw(new RoundRectangle2D.Double(x, bodyY, length, width, 10, 10));
+
+ if (!outlineMode) {
+ int cells = cellCount.getCells();
+ double cellWidth = (width - 8.0) / cells;
+
+ for (int i = 0; i < cells; i++) {
+ double cavityX = x + 6;
+ double cavityY = bodyY + 4 + i * cellWidth;
+ double cavityW = length - 12;
+ double cavityH = cellWidth - 4;
+
+ // Cell cavity (dark inset)
+ g2d.setColor(CELL_CAVITY);
+ g2d.fill(new RoundRectangle2D.Double(cavityX, cavityY, cavityW, cavityH, 6, 6));
+ g2d.setColor(HOLDER_BODY_LIGHT);
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f));
+ g2d.draw(new RoundRectangle2D.Double(cavityX, cavityY, cavityW, cavityH, 6, 6));
+
+ // Positive flat plate button contact on left (x)
+ double plateX = x + 6;
+ double plateW = 6;
+ double plateH = Math.min(14, cellH(cellD));
+ double contactY = cavityY + cavityH / 2.0;
+ g2d.setColor(PLATE_COLOR);
+ g2d.fill(new Rectangle2D.Double(plateX, contactY - plateH / 2.0, plateW, plateH));
+ g2d.setColor(SPRING_BORDER);
+ g2d.draw(new Rectangle2D.Double(plateX, contactY - plateH / 2.0, plateW, plateH));
+
+ // Negative spring contact on right (x + length)
+ double springX = x + length - 16;
+ double springW = 10;
+ double springH = Math.min(14, cellH(cellD));
+ g2d.setColor(SPRING_COLOR);
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f));
+ g2d.draw(new Rectangle2D.Double(springX, contactY - springH / 2.0, springW, springH));
+ g2d.draw(new Rectangle2D.Double(springX + 3, contactY - springH / 2.0 + 2, springW - 6, springH - 4));
+
+ // Polarity markings (+ on left, - on right)
+ g2d.setColor(Color.WHITE);
+ g2d.setFont(LABEL_FONT);
+ StringUtils.drawCenteredText(g2d, "+", x + 20, contactY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ StringUtils.drawCenteredText(g2d, "-", x + length - 20, contactY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+
+ // 18650 text in cavity
+ g2d.setColor(Color.decode("#555555"));
+ StringUtils.drawCenteredText(g2d, "18650", x + length / 2.0, contactY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ }
+
+ // Series connector bar between cells for 2S
+ if (cells > 1) {
+ g2d.setColor(SPRING_COLOR);
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(2f));
+ double midY = bodyY + width / 2.0;
+ g2d.draw(new Rectangle2D.Double(x + length - 10, midY - 6, 6, 12));
+ }
+ }
+
+ g2d.setTransform(oldTx);
+
+ // Draw control point solder tabs (Point 0 = + left, Point 1 = - right)
+ if (!outlineMode) {
+ for (Point2D cp : controlPoints) {
+ g2d.setColor(PLATE_COLOR);
+ g2d.fill(new Ellipse2D.Double(cp.getX() - 5, cp.getY() - 5, 10, 10));
+ g2d.setColor(SPRING_BORDER);
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f));
+ g2d.draw(new Ellipse2D.Double(cp.getX() - 5, cp.getY() - 5, 10, 10));
+ // Inner hole
+ g2d.setColor(Constants.CANVAS_COLOR);
+ g2d.fill(new Ellipse2D.Double(cp.getX() - 2, cp.getY() - 2, 4, 4));
+ }
+ }
+
+ g2d.setComposite(oldComposite);
+ }
+
+ private double cellH(double cellD) {
+ return cellD * 0.45;
+ }
+
+ @Override
+ public void drawIcon(Graphics2D g2d, int width, int height) {
+ // Dark holder body
+ g2d.setColor(HOLDER_BODY);
+ g2d.fill(new RoundRectangle2D.Double(2, height / 2.0 - 8, width - 4, 16, 4, 4));
+ g2d.setColor(HOLDER_BODY.brighter());
+ g2d.draw(new RoundRectangle2D.Double(2, height / 2.0 - 8, width - 4, 16, 4, 4));
+
+ // Cell cavity
+ g2d.setColor(CELL_CAVITY);
+ g2d.fill(new RoundRectangle2D.Double(5, height / 2.0 - 6, width - 10, 12, 3, 3));
+
+ // Plate on left (+), spring on right (-)
+ g2d.setColor(PLATE_COLOR);
+ g2d.fillRect(5, height / 2 - 3, 3, 6);
+ g2d.setColor(SPRING_COLOR);
+ g2d.fillRect(width - 9, height / 2 - 3, 4, 6);
+
+ // Polarity
+ g2d.setColor(Color.WHITE);
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 7));
+ StringUtils.drawCenteredText(g2d, "+", 12, height / 2.0, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ StringUtils.drawCenteredText(g2d, "-", width - 13, height / 2.0, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ }
+
+ @Override
+ public Rectangle2D getCachingBounds() {
+ double minX = Math.min(controlPoints[0].getX(), controlPoints[1].getX());
+ double minY = Math.min(controlPoints[0].getY(), controlPoints[1].getY());
+ double maxX = Math.max(controlPoints[0].getX(), controlPoints[1].getX());
+ double maxY = Math.max(controlPoints[0].getY(), controlPoints[1].getY());
+ double width = new Size(cellCount.getWidthMm(), SizeUnit.mm).convertToPixels();
+ int margin = 50;
+ return new Rectangle2D.Double(minX - margin, minY - width / 2.0 - margin,
+ (maxX - minX) + 2 * margin, (maxY - minY) + width + 2 * margin);
+ }
+}
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/modules/ActiveBuzzerModule.java b/diylc/diylc-library/src/main/java/org/diylc/components/modules/ActiveBuzzerModule.java
new file mode 100644
index 00000000..80784f37
--- /dev/null
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/modules/ActiveBuzzerModule.java
@@ -0,0 +1,225 @@
+/*
+ *
+ * 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.modules;
+
+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.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 = "Active Buzzer Module (KY-012)", category = "Modules & Breakouts",
+ author = "Branislav Stojkovic", description = "KY-012 Active Buzzer Module — produces 2.5kHz continuous tone on HIGH signal",
+ instanceNamePrefix = "MOD", zOrder = IDIYComponent.COMPONENT,
+ bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME,
+ enableCache = true)
+public class ActiveBuzzerModule extends AbstractMakerBoard {
+
+ private static final long serialVersionUID = 1L;
+
+ public static Color PCB_GREEN = Color.decode("#1A6B1A");
+ public static Color BUZZER_BLACK = Color.decode("#1A1A1A");
+ public static Color BUZZER_VENT_COLOR = Color.decode("#333333");
+
+ public static Size BOARD_WIDTH = new Size(18.5d, SizeUnit.mm);
+ public static Size BOARD_HEIGHT = new Size(15.0d, SizeUnit.mm);
+
+ // Standard Keyes KY-012 3-pin layout from left to right: - (GND), + (VCC), S (Signal)
+ private static final String[] PIN_NAMES = {"- (GND)", "+ (VCC)", "S (Signal)"};
+
+ public ActiveBuzzerModule() {
+ super();
+ this.bodyColor = PCB_GREEN;
+ updateControlPoints();
+ }
+
+ @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();
+
+ // 3 pins along the bottom edge: - (GND), + (VCC), S (Signal)
+ double[][] relativeOffsets = new double[][] {
+ { 0, 0 },
+ { spacing, 0 },
+ { spacing * 2, 0 }
+ };
+
+ 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 - 2 * PIN_SPACING.convertToPixels()) / 2.0;
+ double boardY = y - boardH + 12;
+ return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 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);
+ }
+
+ double boardW = BOARD_WIDTH.convertToPixels();
+ double boardH = BOARD_HEIGHT.convertToPixels();
+ double boardX = x - (boardW - 2 * PIN_SPACING.convertToPixels()) / 2.0;
+ double boardY = y - boardH + 12;
+ double spacing = PIN_SPACING.convertToPixels();
+
+ 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) {
+ // Buzzer cylinder — circular element on the board
+ double buzzerD = new Size(11.0d, SizeUnit.mm).convertToPixels();
+ double buzzerX = boardX + (boardW - buzzerD) / 2.0;
+ double buzzerY = boardY + 4;
+
+ // Outer black cylinder
+ g2d.setColor(BUZZER_BLACK);
+ g2d.fill(new Ellipse2D.Double(buzzerX, buzzerY, buzzerD, buzzerD));
+ g2d.setColor(BUZZER_BLACK.brighter());
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f));
+ g2d.draw(new Ellipse2D.Double(buzzerX, buzzerY, buzzerD, buzzerD));
+
+ // Vent hole ring (inner concentric)
+ double ventD = buzzerD * 0.55;
+ double ventX = buzzerX + (buzzerD - ventD) / 2.0;
+ double ventY = buzzerY + (buzzerD - ventD) / 2.0;
+ g2d.setColor(BUZZER_VENT_COLOR);
+ g2d.draw(new Ellipse2D.Double(ventX, ventY, ventD, ventD));
+
+ // Small central sound vent hole
+ double holeD = buzzerD * 0.15;
+ double holeX = buzzerX + (buzzerD - holeD) / 2.0;
+ double holeY = buzzerY + (buzzerD - holeD) / 2.0;
+ g2d.setColor(Color.decode("#111111"));
+ g2d.fill(new Ellipse2D.Double(holeX, holeY, holeD, holeD));
+
+ // Polarity "+" marking on buzzer casing
+ g2d.setColor(Color.WHITE);
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 10));
+ StringUtils.drawCenteredText(g2d, "+", buzzerX + 6, buzzerY + 7, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+
+ // "KY-012" model text in top-left silkscreen corner
+ g2d.setColor(Color.WHITE);
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 8));
+ StringUtils.drawCenteredText(g2d, "KY-012", boardX + 14, boardY + 8, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+
+ // Silkscreen pin labels (-, +, S) directly above the 3 header pins
+ g2d.setColor(Color.WHITE);
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 9));
+ double labelY = y - 9;
+ StringUtils.drawCenteredText(g2d, "-", x, labelY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ StringUtils.drawCenteredText(g2d, "+", x + spacing, labelY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ StringUtils.drawCenteredText(g2d, "S", x + spacing * 2, labelY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ }
+
+ g2d.setTransform(oldTx);
+ drawPins(g2d, 0, controlPoints.length, false, outlineMode, drawingObserver);
+ g2d.setComposite(oldComposite);
+ }
+
+ @Override
+ public void drawIcon(Graphics2D g2d, int width, int height) {
+ // Green PCB
+ g2d.setColor(PCB_GREEN);
+ g2d.fill(new RoundRectangle2D.Double(3, 5, width - 6, height - 10, 3, 3));
+ g2d.setColor(PCB_GREEN.darker());
+ g2d.draw(new RoundRectangle2D.Double(3, 5, width - 6, height - 10, 3, 3));
+
+ // Black buzzer cylinder
+ int bD = width - 10;
+ int bX = (width - bD) / 2;
+ int bY = 5;
+ g2d.setColor(BUZZER_BLACK);
+ g2d.fillOval(bX, bY, bD, bD);
+ g2d.setColor(Color.DARK_GRAY);
+ g2d.drawOval(bX, bY, bD, bD);
+
+ // Vent circle
+ int vD = bD / 2;
+ g2d.setColor(BUZZER_VENT_COLOR);
+ g2d.drawOval(bX + (bD - vD) / 2, bY + (bD - vD) / 2, vD, vD);
+
+ // 3 pin dots
+ g2d.setColor(PIN_COLOR);
+ for (int i = 0; i < 3; i++) {
+ g2d.fillRect(6 + i * 4, height - 5, 2, 2);
+ }
+ }
+}
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/modules/GPSModuleNEO6M.java b/diylc/diylc-library/src/main/java/org/diylc/components/modules/GPSModuleNEO6M.java
new file mode 100644
index 00000000..0dca2218
--- /dev/null
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/modules/GPSModuleNEO6M.java
@@ -0,0 +1,245 @@
+/*
+ *
+ * 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.modules;
+
+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.Line2D;
+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.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 = "GPS Module (NEO-6M)", category = "Modules & Breakouts",
+ author = "Branislav Stojkovic", description = "u-blox NEO-6M GPS Module with ceramic patch antenna",
+ instanceNamePrefix = "MOD", zOrder = IDIYComponent.COMPONENT,
+ bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME,
+ enableCache = true)
+public class GPSModuleNEO6M extends AbstractMakerBoard {
+
+ private static final long serialVersionUID = 1L;
+
+ public static Color PCB_GREEN = Color.decode("#1A6B1A");
+ public static Color ANTENNA_BEIGE = Color.decode("#D5C4A1");
+ public static Color ANTENNA_BORDER = Color.decode("#928374");
+ public static Color SHIELD_COLOR = Color.decode("#B8B8B8");
+ public static Color SHIELD_BORDER = Color.decode("#888888");
+ public static Color BATTERY_COLOR = Color.decode("#D0D3D4");
+
+ public static Size BOARD_WIDTH = new Size(35.5d, SizeUnit.mm);
+ public static Size BOARD_HEIGHT = new Size(25.5d, SizeUnit.mm);
+
+ // Standard GY-GPS6MV2 pinout from pin 1 to 4: VCC, RX, TX, GND
+ private static final String[] PIN_NAMES = {"VCC", "RX", "TX", "GND"};
+
+ public GPSModuleNEO6M() {
+ super();
+ this.bodyColor = PCB_GREEN;
+ updateControlPoints();
+ }
+
+ @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();
+
+ // 4 pins along the left edge: VCC, RX, TX, GND
+ double[][] relativeOffsets = new double[][] {
+ { 0, 0 },
+ { 0, spacing },
+ { 0, spacing * 2 },
+ { 0, spacing * 3 }
+ };
+
+ 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 spacing = PIN_SPACING.convertToPixels();
+ double boardX = x - 14.0;
+ double boardY = y - (boardH - 3 * spacing) / 2.0;
+ return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 6, 6);
+ }
+
+ @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 spacing = PIN_SPACING.convertToPixels();
+ double boardX = x - 14.0;
+ double boardY = y - (boardH - 3 * spacing) / 2.0;
+
+ 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) {
+ // 4 Small Mounting holes in the corners
+ double holeMargin = 7.0;
+ drawMountingHole(g2d, boardX + holeMargin, boardY + holeMargin, 8.0);
+ drawMountingHole(g2d, boardX + holeMargin, boardY + boardH - holeMargin, 8.0);
+ drawMountingHole(g2d, boardX + boardW - holeMargin, boardY + holeMargin, 8.0);
+ drawMountingHole(g2d, boardX + boardW - holeMargin, boardY + boardH - holeMargin, 8.0);
+
+ // Silkscreen pin labels (VCC, RX, TX, GND) placed neatly next to left header
+ g2d.setColor(Color.WHITE);
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 8));
+ String[] pinLabels = new String[] {"VCC", "RX", "TX", "GND"};
+ for (int i = 0; i < 4; i++) {
+ StringUtils.drawCenteredText(g2d, pinLabels[i], x + 9, y + i * spacing, HorizontalAlignment.LEFT, VerticalAlignment.CENTER);
+ }
+
+ // NEO-6M metal shield can (center of board, strictly to the right of pin labels)
+ double icShieldX = boardX + 46.0;
+ double icShieldW = 42.0;
+ double icShieldH = 36.0;
+ double icShieldY = boardY + (boardH - icShieldH) / 2.0 - 8.0;
+
+ g2d.setColor(SHIELD_COLOR);
+ g2d.fill(new RoundRectangle2D.Double(icShieldX, icShieldY, icShieldW, icShieldH, 4, 4));
+ g2d.setColor(SHIELD_BORDER);
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f));
+ g2d.draw(new RoundRectangle2D.Double(icShieldX, icShieldY, icShieldW, icShieldH, 4, 4));
+
+ g2d.setColor(Color.decode("#333333"));
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 8));
+ StringUtils.drawCenteredText(g2d, "NEO-6M", icShieldX + icShieldW / 2.0, icShieldY + icShieldH / 2.0 - 4,
+ HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ g2d.setFont(new Font("SansSerif", Font.PLAIN, 6));
+ StringUtils.drawCenteredText(g2d, "GPS", icShieldX + icShieldW / 2.0, icShieldY + icShieldH / 2.0 + 5,
+ HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+
+ // Rechargeable backup coin cell battery (CR1220 / MS621) below shield
+ double battD = 18.0;
+ double battX = boardX + 58.0;
+ double battY = boardY + boardH - battD - 7.0;
+ g2d.setColor(BATTERY_COLOR);
+ g2d.fill(new Ellipse2D.Double(battX, battY, battD, battD));
+ g2d.setColor(BATTERY_COLOR.darker());
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f));
+ g2d.draw(new Ellipse2D.Double(battX, battY, battD, battD));
+
+ // Ceramic patch antenna on right side (20x20mm)
+ double antSize = new Size(18.0d, SizeUnit.mm).convertToPixels();
+ double antX = boardX + boardW - antSize - 8.0;
+ double antY = boardY + (boardH - antSize) / 2.0;
+
+ g2d.setColor(ANTENNA_BEIGE);
+ g2d.fill(new RoundRectangle2D.Double(antX, antY, antSize, antSize, 2, 2));
+ g2d.setColor(ANTENNA_BORDER);
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.2f));
+ g2d.draw(new RoundRectangle2D.Double(antX, antY, antSize, antSize, 2, 2));
+
+ // Center silver electrode dot on antenna
+ double antCX = antX + antSize / 2.0;
+ double antCY = antY + antSize / 2.0;
+ g2d.setColor(Color.decode("#7F8C8D"));
+ g2d.fill(new Ellipse2D.Double(antCX - 2, antCY - 2, 4, 4));
+
+ // Status LED (top left area)
+ g2d.setColor(Color.RED);
+ g2d.fill(new Ellipse2D.Double(boardX + 24.0, boardY + 8.0, 5, 5));
+ }
+
+ g2d.setTransform(oldTx);
+ drawPins(g2d, 0, controlPoints.length, false, outlineMode, drawingObserver);
+ g2d.setComposite(oldComposite);
+ }
+
+ @Override
+ public void drawIcon(Graphics2D g2d, int width, int height) {
+ // Green PCB
+ g2d.setColor(PCB_GREEN);
+ g2d.fill(new RoundRectangle2D.Double(2, 4, width - 4, height - 8, 3, 3));
+ g2d.setColor(PCB_GREEN.darker());
+ g2d.draw(new RoundRectangle2D.Double(2, 4, width - 4, height - 8, 3, 3));
+
+ // Beige ceramic patch antenna (right half)
+ int antS = height - 12;
+ g2d.setColor(ANTENNA_BEIGE);
+ g2d.fillRect(width - antS - 3, (height - antS) / 2, antS, antS);
+ g2d.setColor(ANTENNA_BORDER);
+ g2d.drawRect(width - antS - 3, (height - antS) / 2, antS, antS);
+
+ // Shield
+ g2d.setColor(SHIELD_COLOR);
+ g2d.fillRoundRect(4, (height - 10) / 2, 10, 10, 2, 2);
+ }
+}
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/modules/INA219CurrentSensor.java b/diylc/diylc-library/src/main/java/org/diylc/components/modules/INA219CurrentSensor.java
new file mode 100644
index 00000000..861cf4dd
--- /dev/null
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/modules/INA219CurrentSensor.java
@@ -0,0 +1,317 @@
+/*
+ *
+ * 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.modules;
+
+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.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.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 = "Current/Power Sensor (INA219)", category = "Modules & Breakouts",
+ author = "Branislav Stojkovic", description = "INA219 I2C High-Side Current and Power Sensor Breakout",
+ instanceNamePrefix = "MOD", zOrder = IDIYComponent.COMPONENT,
+ bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME,
+ enableCache = true)
+public class INA219CurrentSensor extends AbstractMakerBoard {
+
+ private static final long serialVersionUID = 1L;
+
+ public static Color PCB_GREEN = Color.decode("#115E1B");
+ public static Color TERMINAL_BLUE = Color.decode("#1565C0");
+ public static Color TERMINAL_BLUE_BORDER = Color.decode("#0D47A1");
+ public static Color SHUNT_BODY = Color.decode("#7D6608");
+ public static Color SHUNT_METAL = Color.decode("#D4AC0D");
+ public static Color SHUNT_CORE = Color.decode("#2C3E50");
+ public static Color SILK_YELLOW = Color.decode("#F4D03F");
+
+ public static Size BOARD_WIDTH = new Size(25.0d, SizeUnit.mm);
+ public static Size BOARD_HEIGHT = new Size(25.0d, SizeUnit.mm);
+
+ // 6 header pins at bottom (0-5) + 2 screw terminal pins at top (6-7)
+ private static final String[] PIN_NAMES = {
+ "VCC", "GND", "SCL", "SDA", "VIN-", "VIN+",
+ "VIN- (Term)", "VIN+ (Term)"
+ };
+
+ public INA219CurrentSensor() {
+ super();
+ this.bodyColor = PCB_GREEN;
+ updateControlPoints();
+ }
+
+ @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();
+ double boardH = BOARD_HEIGHT.convertToPixels();
+
+ // 8 control points total:
+ // Pins 0-5: Bottom horizontal 6-pin header (VCC, GND, SCL, SDA, VIN-, VIN+)
+ // Pins 6-7: Top 2-pin screw terminal block (VIN-, VIN+)
+ double termSpacing = new Size(5.08d, SizeUnit.mm).convertToPixels();
+ double termCenterX = 2.5 * spacing;
+ double termY = -boardH + 24.0;
+
+ double[][] relativeOffsets = new double[][] {
+ { 0, 0 },
+ { spacing, 0 },
+ { spacing * 2, 0 },
+ { spacing * 3, 0 },
+ { spacing * 4, 0 },
+ { spacing * 5, 0 },
+ { termCenterX - termSpacing / 2.0, termY },
+ { termCenterX + termSpacing / 2.0, termY }
+ };
+
+ 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 spacing = PIN_SPACING.convertToPixels();
+ double boardX = x - (boardW - 5 * spacing) / 2.0;
+ double boardY = y - boardH + 12;
+ return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 6, 6);
+ }
+
+ @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 spacing = PIN_SPACING.convertToPixels();
+ double boardX = x - (boardW - 5 * spacing) / 2.0;
+ double boardY = y - boardH + 12;
+
+ 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) {
+ // 4 Large Gold Ring Mounting Holes in corners (20mm x 20mm pitch)
+ double holeMargin = 10.0;
+ drawMountingHole(g2d, boardX + holeMargin, boardY + holeMargin, 13.0);
+ drawMountingHole(g2d, boardX + boardW - holeMargin, boardY + holeMargin, 13.0);
+ drawMountingHole(g2d, boardX + holeMargin, boardY + boardH - holeMargin, 13.0);
+ drawMountingHole(g2d, boardX + boardW - holeMargin, boardY + boardH - holeMargin, 13.0);
+
+ // Top Blue Screw Terminal Block (5.08mm 2-pin block)
+ double termW = 44.0;
+ double termH = 30.0;
+ double termX = boardX + (boardW - termW) / 2.0;
+ double termY = boardY + 2.0;
+
+ // Blue outer block body
+ g2d.setColor(TERMINAL_BLUE);
+ g2d.fill(new RoundRectangle2D.Double(termX, termY, termW, termH, 3, 3));
+ g2d.setColor(TERMINAL_BLUE_BORDER);
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f));
+ g2d.draw(new RoundRectangle2D.Double(termX, termY, termW, termH, 3, 3));
+
+ // Terminal block wire entry slots on top
+ g2d.setColor(Color.decode("#0D47A1"));
+ g2d.fillRect((int) (termX + 4), (int) (termY + 1), (int) (termW - 8), 4);
+
+ // Screw contacts
+ double termSpacing = new Size(5.08d, SizeUnit.mm).convertToPixels();
+ double screw1X = termX + termW / 2.0 - termSpacing / 2.0;
+ double screw2X = termX + termW / 2.0 + termSpacing / 2.0;
+ double screwY = termY + termH / 2.0 + 2.0;
+ double screwD = 14.0;
+
+ g2d.setColor(SCREW_CIRCLE_COLOR);
+ g2d.fill(new Ellipse2D.Double(screw1X - screwD / 2.0, screwY - screwD / 2.0, screwD, screwD));
+ g2d.fill(new Ellipse2D.Double(screw2X - screwD / 2.0, screwY - screwD / 2.0, screwD, screwD));
+ g2d.setColor(SCREW_CIRCLE_COLOR.darker());
+ g2d.draw(new Ellipse2D.Double(screw1X - screwD / 2.0, screwY - screwD / 2.0, screwD, screwD));
+ g2d.draw(new Ellipse2D.Double(screw2X - screwD / 2.0, screwY - screwD / 2.0, screwD, screwD));
+ // Screw diagonal slots
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1.5f));
+ g2d.drawLine((int) (screw1X - 4), (int) (screwY - 4), (int) (screw1X + 4), (int) (screwY + 4));
+ g2d.drawLine((int) (screw2X - 4), (int) (screwY - 4), (int) (screw2X + 4), (int) (screwY + 4));
+
+ // Shunt Resistor (Center-Upper, directly below terminal block)
+ double shuntW = 32.0;
+ double shuntH = 14.0;
+ double shuntX = boardX + (boardW - shuntW) / 2.0;
+ double shuntY = boardY + termH + 6.0;
+
+ // Copper outer tabs
+ g2d.setColor(Color.decode("#C0392B"));
+ g2d.fill(new RoundRectangle2D.Double(shuntX, shuntY, 7, shuntH, 2, 2));
+ g2d.fill(new RoundRectangle2D.Double(shuntX + shuntW - 7, shuntY, 7, shuntH, 2, 2));
+
+ // Metal sense element (arched center)
+ g2d.setColor(Color.decode("#BDC3C7"));
+ g2d.fill(new Rectangle2D.Double(shuntX + 6, shuntY + 1, shuntW - 12, shuntH - 2));
+ g2d.setColor(Color.decode("#7F8C8D"));
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f));
+ g2d.draw(new Rectangle2D.Double(shuntX + 6, shuntY + 1, shuntW - 12, shuntH - 2));
+
+ // "R100" label on shunt
+ g2d.setColor(Color.decode("#2C3E50"));
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 7));
+ StringUtils.drawCenteredText(g2d, "R100", shuntX + shuntW / 2.0, shuntY + shuntH / 2.0,
+ HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+
+ // INA219 SOIC-8 IC (Center-Lower, directly below shunt)
+ double icW = 20.0;
+ double icH = 14.0;
+ double icX = boardX + (boardW - icW) / 2.0;
+ double icY = shuntY + shuntH + 4.0;
+
+ // IC body
+ g2d.setColor(Color.decode("#1C2833"));
+ g2d.fill(new RoundRectangle2D.Double(icX, icY, icW, icH, 2, 2));
+ g2d.setColor(Color.decode("#111111"));
+ g2d.draw(new RoundRectangle2D.Double(icX, icY, icW, icH, 2, 2));
+
+ // 8 silver IC pins (4 top, 4 bottom)
+ g2d.setColor(Color.decode("#BDC3C7"));
+ for (int i = 0; i < 4; i++) {
+ double px = icX + 2.5 + i * 4.5;
+ g2d.fillRect((int) px, (int) (icY - 2), 2, 2);
+ g2d.fillRect((int) px, (int) (icY + icH), 2, 2);
+ }
+ // Pin 1 dot
+ g2d.setColor(Color.decode("#F4D03F"));
+ g2d.fill(new Ellipse2D.Double(icX + 2.5, icY + 2.5, 2.5, 2.5));
+
+ // Vertical silkscreen text on left edge
+ AffineTransform preTextTx = g2d.getTransform();
+ g2d.translate(boardX + 14, boardY + boardH / 2.0);
+ g2d.rotate(-Math.PI / 2.0);
+ g2d.setColor(SILK_YELLOW);
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 7));
+ StringUtils.drawCenteredText(g2d, "INA219 SENSOR", 0, 0, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ g2d.setTransform(preTextTx);
+
+ // Vertical silkscreen labels above bottom 6 header pins (VCC, GND, SCL, SDA, VIN-, VIN+)
+ String[] headerLabels = new String[] {"VCC", "GND", "SCL", "SDA", "VIN-", "VIN+"};
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 7));
+ g2d.setColor(SILK_YELLOW);
+
+ for (int i = 0; i < 6; i++) {
+ double pinX = x + i * spacing;
+ AffineTransform pTx = g2d.getTransform();
+ g2d.translate(pinX, y - 11);
+ g2d.rotate(-Math.PI / 2.0);
+ StringUtils.drawCenteredText(g2d, headerLabels[i], 0, 0, HorizontalAlignment.LEFT, VerticalAlignment.CENTER);
+ g2d.setTransform(pTx);
+ }
+ }
+
+ g2d.setTransform(oldTx);
+
+ // Draw bottom 6 header pins
+ drawPins(g2d, 0, 6, false, outlineMode, drawingObserver);
+
+ g2d.setComposite(oldComposite);
+ }
+
+ @Override
+ public void drawIcon(Graphics2D g2d, int width, int height) {
+ // Green square PCB
+ g2d.setColor(PCB_GREEN);
+ g2d.fill(new RoundRectangle2D.Double(2, 2, width - 4, height - 4, 3, 3));
+ g2d.setColor(PCB_GREEN.darker());
+ g2d.draw(new RoundRectangle2D.Double(2, 2, width - 4, height - 4, 3, 3));
+
+ // Blue terminal block at top
+ g2d.setColor(TERMINAL_BLUE);
+ g2d.fillRoundRect(width / 2 - 8, 3, 16, 8, 2, 2);
+
+ // Shunt in middle
+ g2d.setColor(Color.decode("#C0392B"));
+ g2d.fillRect(width / 2 - 6, 13, 12, 4);
+ g2d.setColor(Color.decode("#BDC3C7"));
+ g2d.fillRect(width / 2 - 3, 13, 6, 4);
+
+ // Small IC below shunt
+ g2d.setColor(Color.decode("#1C2833"));
+ g2d.fillRect(width / 2 - 4, 19, 8, 5);
+
+ // 6 pin dots at bottom
+ g2d.setColor(PIN_COLOR);
+ for (int i = 0; i < 6; i++) {
+ g2d.fillRect(4 + i * 4, height - 5, 2, 2);
+ }
+ }
+}
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/sensors/IRReceiverModule.java b/diylc/diylc-library/src/main/java/org/diylc/components/sensors/IRReceiverModule.java
new file mode 100644
index 00000000..d95edb5a
--- /dev/null
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/sensors/IRReceiverModule.java
@@ -0,0 +1,214 @@
+/*
+ *
+ * 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.sensors;
+
+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.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 = "IR Receiver Module (KY-022)", category = "Sensors",
+ author = "Branislav Stojkovic", description = "38kHz Infrared Receiver Module (VS1838B) for remote control",
+ instanceNamePrefix = "SEN", zOrder = IDIYComponent.COMPONENT,
+ bomPolicy = BomPolicy.SHOW_ONLY_TYPE_NAME, keywordPolicy = KeywordPolicy.SHOW_TYPE_NAME,
+ enableCache = true)
+public class IRReceiverModule extends AbstractMakerBoard {
+
+ private static final long serialVersionUID = 1L;
+
+ public static Color PCB_GREEN = Color.decode("#1A6B1A");
+ public static Color SENSOR_METAL = Color.decode("#85929E");
+ public static Color SENSOR_DOME = Color.decode("#1C2833");
+
+ public static Size BOARD_WIDTH = new Size(18.5d, SizeUnit.mm);
+ public static Size BOARD_HEIGHT = new Size(15.0d, SizeUnit.mm);
+
+ // Standard Keyes KY-022 3-pin layout from left to right: - (GND), + (VCC), S (Signal)
+ private static final String[] PIN_NAMES = {"- (GND)", "+ (VCC)", "S (Signal)"};
+
+ public IRReceiverModule() {
+ super();
+ this.bodyColor = PCB_GREEN;
+ updateControlPoints();
+ }
+
+ @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();
+
+ // 3 pins along the bottom edge: - (GND), + (VCC), S (Signal)
+ double[][] relativeOffsets = new double[][] {
+ { 0, 0 },
+ { spacing, 0 },
+ { spacing * 2, 0 }
+ };
+
+ 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 - 2 * PIN_SPACING.convertToPixels()) / 2.0;
+ double boardY = y - boardH + 12;
+ return new RoundRectangle2D.Double(boardX, boardY, boardW, boardH, 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);
+ }
+
+ double boardW = BOARD_WIDTH.convertToPixels();
+ double boardH = BOARD_HEIGHT.convertToPixels();
+ double boardX = x - (boardW - 2 * PIN_SPACING.convertToPixels()) / 2.0;
+ double boardY = y - boardH + 12;
+ double spacing = PIN_SPACING.convertToPixels();
+
+ 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) {
+ // VS1838B sensor element — metal-shielded rectangular body with central black IR dome
+ double sensorW = new Size(7.5d, SizeUnit.mm).convertToPixels();
+ double sensorH = new Size(6.0d, SizeUnit.mm).convertToPixels();
+ double sensorX = boardX + (boardW - sensorW) / 2.0;
+ double sensorY = boardY + 6;
+
+ // Metal shielded box
+ g2d.setColor(SENSOR_METAL);
+ g2d.fill(new RoundRectangle2D.Double(sensorX, sensorY, sensorW, sensorH, 3, 3));
+ g2d.setColor(SENSOR_METAL.darker());
+ g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f));
+ g2d.draw(new RoundRectangle2D.Double(sensorX, sensorY, sensorW, sensorH, 3, 3));
+
+ // Central dark IR lens dome
+ double domeD = sensorH * 0.75;
+ g2d.setColor(SENSOR_DOME);
+ g2d.fill(new Ellipse2D.Double(sensorX + (sensorW - domeD) / 2.0, sensorY + (sensorH - domeD) / 2.0, domeD, domeD));
+ g2d.setColor(Color.BLACK);
+ g2d.draw(new Ellipse2D.Double(sensorX + (sensorW - domeD) / 2.0, sensorY + (sensorH - domeD) / 2.0, domeD, domeD));
+
+ // Red status LED (top right)
+ g2d.setColor(Color.RED);
+ g2d.fill(new Ellipse2D.Double(boardX + boardW - 12, boardY + 6, 6, 6));
+
+ // "KY-022" silkscreen text in the open space between the sensor and pin labels
+ g2d.setColor(Color.WHITE);
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 9));
+ StringUtils.drawCenteredText(g2d, "KY-022", boardX + boardW / 2.0, boardY + 36, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+
+ // Silkscreen pin labels (-, +, S) directly above the 3 header pins
+ g2d.setColor(Color.WHITE);
+ g2d.setFont(new Font("SansSerif", Font.BOLD, 9));
+ double labelY = y - 9;
+ StringUtils.drawCenteredText(g2d, "-", x, labelY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ StringUtils.drawCenteredText(g2d, "+", x + spacing, labelY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ StringUtils.drawCenteredText(g2d, "S", x + spacing * 2, labelY, HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
+ }
+
+ g2d.setTransform(oldTx);
+ drawPins(g2d, 0, controlPoints.length, false, outlineMode, drawingObserver);
+ g2d.setComposite(oldComposite);
+ }
+
+ @Override
+ public void drawIcon(Graphics2D g2d, int width, int height) {
+ // Green PCB
+ g2d.setColor(PCB_GREEN);
+ g2d.fill(new RoundRectangle2D.Double(3, 5, width - 6, height - 10, 3, 3));
+ g2d.setColor(PCB_GREEN.darker());
+ g2d.draw(new RoundRectangle2D.Double(3, 5, width - 6, height - 10, 3, 3));
+
+ // Metal shield + dome
+ g2d.setColor(SENSOR_METAL);
+ g2d.fillRoundRect(width / 2 - 5, 6, 10, 8, 2, 2);
+ g2d.setColor(SENSOR_DOME);
+ g2d.fillOval(width / 2 - 3, 7, 6, 6);
+
+ // Red LED
+ g2d.setColor(Color.RED);
+ g2d.fillOval(width - 7, 7, 4, 4);
+
+ // 3 pin dots
+ g2d.setColor(PIN_COLOR);
+ for (int i = 0; i < 3; i++) {
+ g2d.fillRect(6 + i * 4, height - 5, 2, 2);
+ }
+ }
+}
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 396722be..cf48ff52 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
@@ -33,6 +33,7 @@ import org.diylc.common.ComponentType;
import org.diylc.common.DefaultComponentTransformer;
import org.diylc.common.Orientation;
import org.diylc.components.displays.*;
+import org.diylc.components.electromechanical.*;
import org.diylc.components.micro.*;
import org.diylc.components.modules.*;
import org.diylc.components.robotics.*;
@@ -78,6 +79,7 @@ public class MakerComponentsTest {
BME280Sensor.class,
SoilMoistureSensor.class,
TCRT5000Sensor.class,
+ IRReceiverModule.class,
// Displays & Outputs
CharacterLCD.class,
@@ -86,6 +88,7 @@ public class MakerComponentsTest {
LEDMatrix8x8.class,
TFTDisplay.class,
WS2812BStick.class,
+ WS2812BRing.class,
// Modules & Breakouts
LogicLevelConverter.class,
@@ -106,6 +109,12 @@ public class MakerComponentsTest {
ULN2003Driver.class,
PCA9685ServoDriver.class,
MOSFETSwitchModule.class,
+ ActiveBuzzerModule.class,
+ GPSModuleNEO6M.class,
+ INA219CurrentSensor.class,
+
+ // Electro-Mechanical
+ BatteryHolder18650.class,
// Robotics
MicroServoSG90.class,