Added 'Value Notation' to the Config menu

This commit is contained in:
Bane Stojkovic
2026-11-09 21:44:17 +01:00
parent bff9e05843
commit b2f99c7022
26 changed files with 1174 additions and 118 deletions
@@ -90,6 +90,7 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP
public static final String FONT_SCALE_FACTOR_KEY = "fontScaleFactor";
public static final String SHOW_RESIZE_DIMENSIONS_TOOLTIP_KEY = "showResizeDimensionsTooltip";
public static final String AUTO_UNIT_CONVERSION_KEY = "autoUnitConversion";
public static final String VALUE_NOTATION_KEY = "valueNotation";
public static final String RULER_IN_SUBDIVISION_2 = "base of 2";
@@ -101,6 +102,10 @@ public interface IPlugInPort extends ISelectionProcessor, IMouseProcessor, IKeyP
public static final String SNAP_TO_COMPONENTS = "Components";
public static final String SNAP_TO_DEFAULT = SNAP_TO_GRID;
public static final String VALUE_NOTATION_DECIMAL = "Decimal (4.7K, 0.22uF)";
public static final String VALUE_NOTATION_RKM = "RKM (4K7, 0u22)";
public static final String VALUE_NOTATION_DEFAULT = VALUE_NOTATION_DECIMAL;
public static final String SHOW_NODE_NAME_TOOLTIPS_KEY = "showNodeNameTooltips";
public static final String LOCKED_ALPHA = "lockedAlpha";
@@ -41,14 +41,16 @@ public class Capacitance extends AbstractMeasure<CapacitanceUnit> {
}
public static Capacitance parseCapacitance(String value) {
value = value.replace("*", "");
for (CapacitanceUnit unit : CapacitanceUnit.values()) {
if (value.toLowerCase().endsWith(unit.toString().toLowerCase())) {
value = value.substring(0, value.length() - unit.toString().length()).trim();
return new Capacitance(parse(value), unit);
}
ValueNotation.ParsedMeasure<CapacitanceUnit> parsed;
try {
parsed = ValueNotation.parse(value, CapacitanceUnit::fromSymbol);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Could not parse capacitance: " + value, e);
}
throw new IllegalArgumentException("Could not parse capacitance: " + value);
if (parsed.unit() == null) {
throw new IllegalArgumentException("Could not parse capacitance: " + value);
}
return new Capacitance(parsed.value(), parsed.unit());
}
public Double getNormalizedValue() {
@@ -56,4 +58,12 @@ public class Capacitance extends AbstractMeasure<CapacitanceUnit> {
return null;
return value * unit.getFactor();
}
@Override
public String toString() {
if (ValueNotation.getCurrent() == ValueNotation.RKM && value != null && unit != null) {
return ValueNotation.formatRkm(value, unit.getRkmSymbol());
}
return super.toString();
}
}
@@ -23,14 +23,16 @@ package org.diylc.core.measures;
public enum CapacitanceUnit implements Unit {
pF(1e-6, "pF"), nF(1e-3, "nF"), uF(1, "uF"), mF(1e3, "mF"), F(1e6, "F");
pF(1e-6, "pF", "p"), nF(1e-3, "nF", "n"), uF(1, "uF", "u"), mF(1e3, "mF", "m"), F(1e6, "F", "F");
double factor;
String display;
String rkmSymbol;
private CapacitanceUnit(double factor, String display) {
private CapacitanceUnit(double factor, String display, String rkmSymbol) {
this.factor = factor;
this.display = display;
this.rkmSymbol = rkmSymbol;
}
@Override
@@ -38,8 +40,49 @@ public enum CapacitanceUnit implements Unit {
return factor;
}
public String getRkmSymbol() {
return rkmSymbol;
}
@Override
public String toString() {
return display;
}
/**
* Resolves the unit written after a capacitance value, e.g. "nF", "n", "u", "\u00B5F" or "F".
* Case does not matter when the trailing "F" is present, which is how values have always been
* parsed. Without it, "M" is rejected so that it is never mistaken for mega.
*
* @param symbol unit text with no surrounding whitespace
* @return the unit, or null when the text is not a capacitance unit
*/
public static CapacitanceUnit fromSymbol(String symbol) {
if (symbol.equalsIgnoreCase("F")) {
return F;
}
String prefix = symbol;
if (symbol.length() == 2 && (symbol.endsWith("F") || symbol.endsWith("f"))) {
prefix = symbol.substring(0, 1).toLowerCase();
} else if (symbol.equals("M")) {
return null;
}
switch (prefix) {
case "p":
case "P":
return pF;
case "n":
case "N":
return nF;
case "u":
case "U":
case "\u00B5":
case "\u03BC":
return uF;
case "m":
return mF;
default:
return null;
}
}
}
@@ -109,14 +109,16 @@ public class Resistance extends AbstractMeasure<ResistanceUnit> {
}
public static Resistance parseResistance(String value) {
value = value.replace("*", "").replace("R", "\u2126");
for (ResistanceUnit unit : ResistanceUnit.values()) {
if (value.toLowerCase().endsWith(unit.toString().toLowerCase())) {
value = value.substring(0, value.length() - unit.toString().length()).trim();
return new Resistance(parse(value), unit);
}
ValueNotation.ParsedMeasure<ResistanceUnit> parsed;
try {
parsed = ValueNotation.parse(value, ResistanceUnit::fromSymbol);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Could not parse resistance: " + value, e);
}
throw new IllegalArgumentException("Could not parse resistance: " + value);
if (parsed.unit() == null) {
throw new IllegalArgumentException("Could not parse resistance: " + value);
}
return new Resistance(parsed.value(), parsed.unit());
}
public Double getNormalizedValue() {
@@ -124,4 +126,12 @@ public class Resistance extends AbstractMeasure<ResistanceUnit> {
return null;
return value * unit.getFactor();
}
@Override
public String toString() {
if (ValueNotation.getCurrent() == ValueNotation.RKM && value != null && unit != null) {
return ValueNotation.formatRkm(value, unit.getRkmSymbol());
}
return super.toString();
}
}
@@ -23,14 +23,16 @@ package org.diylc.core.measures;
public enum ResistanceUnit implements Unit {
R(1, "\u03A9"), K(1e3, "K"), M(1e6, "M");
R(1, "\u03A9", "R"), K(1e3, "K", "K"), M(1e6, "M", "M");
double factor;
String display;
String rkmSymbol;
private ResistanceUnit(double factor, String display) {
private ResistanceUnit(double factor, String display, String rkmSymbol) {
this.factor = factor;
this.display = display;
this.rkmSymbol = rkmSymbol;
}
@Override
@@ -39,8 +41,45 @@ public enum ResistanceUnit implements Unit {
}
public String getRkmSymbol() {
return rkmSymbol;
}
@Override
public String toString() {
return display;
}
/**
* Resolves the unit written after a resistance value, e.g. "K", "kohm", "R", "\u03A9" or "Meg".
* A lower-case "m" means mega, because there is no milliohm unit and parts lists commonly write
* "1m" for one megohm.
*
* @param symbol unit text with no surrounding whitespace
* @return the unit, or null when the text is not a resistance unit
*/
public static ResistanceUnit fromSymbol(String symbol) {
String prefix = symbol;
String lower = symbol.toLowerCase();
for (String ohm : new String[] {"ohms", "ohm", "\u03A9", "\u2126"}) {
if (lower.endsWith(ohm.toLowerCase())) {
prefix = symbol.substring(0, symbol.length() - ohm.length());
if (prefix.isEmpty()) {
return R;
}
break;
}
}
switch (prefix.toLowerCase()) {
case "r":
return R;
case "k":
return K;
case "m":
case "meg":
return M;
default:
return null;
}
}
}
@@ -0,0 +1,128 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
package org.diylc.core.measures;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Notation used to write resistance and capacitance values: plain decimal ("4.7K", "0.22uF") or
* RKM as defined by IEC 60062, where the unit symbol takes the place of the decimal point ("4K7",
* "0u22"). Parsing accepts both regardless of the selected notation.
*
* @author Branislav Stojkovic
*/
public enum ValueNotation {
DECIMAL, RKM;
private static final DecimalFormat RKM_FORMAT =
new DecimalFormat("0.####", DecimalFormatSymbols.getInstance(Locale.US));
private static final Pattern RKM_PATTERN = Pattern.compile("^(-?)(\\d*)([^\\d.\\-]+)(\\d+)([^\\d.]*)$");
// read on every repaint through toString(), so it is kept here rather than looked up in the
// configuration
private static volatile ValueNotation current = DECIMAL;
public static ValueNotation getCurrent() {
return current;
}
public static void setCurrent(ValueNotation notation) {
current = notation == null ? DECIMAL : notation;
}
/**
* Writes a value in RKM notation, e.g. 4.7 with "K" becomes "4K7" and 100 with "R" becomes
* "100R". The value is kept in the unit it is stored in and never renormalized.
*
* @param value value expressed in the unit
* @param rkmSymbol symbol that stands in for the decimal point
* @return RKM code
*/
public static String formatRkm(double value, String rkmSymbol) {
String text;
synchronized (RKM_FORMAT) {
text = RKM_FORMAT.format(value);
}
int dot = text.indexOf('.');
if (dot < 0) {
return text + rkmSymbol;
}
return text.substring(0, dot) + rkmSymbol + text.substring(dot + 1);
}
/**
* Parses a value written either in decimal notation with an optional unit ("4.7", "4.7k",
* "0.22 uF") or in RKM notation ("4K7", "R22", "2n2F").
*
* @param text text to parse
* @param unitResolver maps the unit text to a unit, returning null when it is not recognized
* @return parsed value; its unit is null when the text carries no unit
* @throws IllegalArgumentException when the text cannot be parsed
*/
public static <U extends Unit> ParsedMeasure<U> parse(String text,
Function<String, U> unitResolver) {
if (text == null) {
throw new IllegalArgumentException("Nothing to parse");
}
String value = text.replace("*", "").replace(",", ".").replaceAll("\\s", "");
if (value.isEmpty()) {
throw new IllegalArgumentException("Nothing to parse");
}
int unitStart = value.length();
while (unitStart > 0 && !Character.isDigit(value.charAt(unitStart - 1))
&& value.charAt(unitStart - 1) != '.') {
unitStart--;
}
String number = value.substring(0, unitStart);
String unitText = value.substring(unitStart);
U unit = unitText.isEmpty() ? null : unitResolver.apply(unitText);
if (unitText.isEmpty() || unit != null) {
try {
return new ParsedMeasure<U>(Double.parseDouble(number), unit);
} catch (NumberFormatException e) {
// not a decimal number, try RKM below
}
}
Matcher matcher = RKM_PATTERN.matcher(value);
if (matcher.matches()) {
unit = unitResolver.apply(matcher.group(3) + matcher.group(5));
if (unit != null) {
String integerPart = matcher.group(2).isEmpty() ? "0" : matcher.group(2);
double parsed = Double.parseDouble(integerPart + "." + matcher.group(4));
return new ParsedMeasure<U>(matcher.group(1).isEmpty() ? parsed : -parsed, unit);
}
}
throw new IllegalArgumentException("Could not parse value: " + text);
}
public record ParsedMeasure<U extends Unit>(double value, U unit) {
}
}
@@ -0,0 +1,54 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
package org.diylc.presenter;
import org.diylc.appframework.miscutils.IConfigurationManager;
import org.diylc.common.IPlugInPort;
import org.diylc.core.measures.ValueNotation;
/**
* Keeps {@link ValueNotation#getCurrent()} in sync with the value notation the user picked in the
* configuration, and redraws the project when it changes. The notation is global, so only the
* main window binds it; secondary presenters (previews, thumbnails, batch conversion, regression
* runs) run on in-memory configurations and must not reset it.
*
* @author Branislav Stojkovic
*/
public class ValueNotationBinder {
public static void bind(IConfigurationManager<?> configManager, Runnable refresh) {
apply(configManager.readString(IPlugInPort.VALUE_NOTATION_KEY,
IPlugInPort.VALUE_NOTATION_DEFAULT));
configManager.addConfigListener(IPlugInPort.VALUE_NOTATION_KEY, (key, value) -> {
apply(value == null ? null : value.toString());
// cached component images still carry the labels drawn in the previous notation
DrawingCache.Instance.clear();
refresh.run();
});
}
static void apply(String configValue) {
ValueNotation.setCurrent(IPlugInPort.VALUE_NOTATION_RKM.equalsIgnoreCase(configValue)
? ValueNotation.RKM : ValueNotation.DECIMAL);
}
}
@@ -629,6 +629,10 @@
<releaseDate>2026-11-07 00:00:00.000 CET</releaseDate>
<name></name>
<changes>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>Added 'Value Notation' to the Config menu to show resistance and capacitance values in RKM notation, e.g. 4K7, 0R22, 2n2 or 0u22, instead of decimal; values can be typed in either notation, and typing the unit together with the value, e.g. 4K7 or 2.2nF, sets the unit at the same time</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>Rotated labels in pasted components and building blocks were drawn in the wrong place and at the wrong angle while following the mouse, until the components were placed</description>
@@ -0,0 +1,203 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
package org.diylc.core.measures;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail;
import org.junit.After;
import org.junit.Test;
public class ValueNotationTest {
private static final double DELTA = 1e-9;
@After
public void resetNotation() {
ValueNotation.setCurrent(ValueNotation.DECIMAL);
}
// --- formatting ---
@Test
public void decimalResistanceIsUnchanged() {
assertEquals("4.7\u03A9", new Resistance(4.7, ResistanceUnit.R).toString());
assertEquals("0.22\u03A9", new Resistance(0.22, ResistanceUnit.R).toString());
assertEquals("4.7K", new Resistance(4.7, ResistanceUnit.K).toString());
assertEquals("1M", new Resistance(1d, ResistanceUnit.M).toString());
}
@Test
public void decimalCapacitanceIsUnchanged() {
assertEquals("0.22uF", new Capacitance(0.22, CapacitanceUnit.uF).toString());
assertEquals("100nF", new Capacitance(100d, CapacitanceUnit.nF).toString());
}
@Test
public void rkmResistance() {
ValueNotation.setCurrent(ValueNotation.RKM);
assertEquals("4R7", new Resistance(4.7, ResistanceUnit.R).toString());
assertEquals("0R22", new Resistance(0.22, ResistanceUnit.R).toString());
assertEquals("100R", new Resistance(100d, ResistanceUnit.R).toString());
assertEquals("4K7", new Resistance(4.7, ResistanceUnit.K).toString());
assertEquals("47K", new Resistance(47d, ResistanceUnit.K).toString());
assertEquals("1M", new Resistance(1d, ResistanceUnit.M).toString());
assertEquals("4700R", new Resistance(4700d, ResistanceUnit.R).toString());
assertEquals("0R", new Resistance(0d, ResistanceUnit.R).toString());
assertEquals("4K7501", new Resistance(4.75012, ResistanceUnit.K).toString());
}
@Test
public void rkmCapacitance() {
ValueNotation.setCurrent(ValueNotation.RKM);
assertEquals("0u22", new Capacitance(0.22, CapacitanceUnit.uF).toString());
assertEquals("2n2", new Capacitance(2.2, CapacitanceUnit.nF).toString());
assertEquals("100n", new Capacitance(100d, CapacitanceUnit.nF).toString());
assertEquals("4p7", new Capacitance(4.7, CapacitanceUnit.pF).toString());
assertEquals("1000u", new Capacitance(1000d, CapacitanceUnit.uF).toString());
}
// --- parsing ---
@Test
public void parsesDecimalResistance() {
assertResistance(4.7, ResistanceUnit.K, "4.7K");
assertResistance(4.7, ResistanceUnit.K, "4.7k");
assertResistance(4.7, ResistanceUnit.K, "4.7 K");
assertResistance(4.7, ResistanceUnit.K, "4,7K");
assertResistance(4.7, ResistanceUnit.K, "4.7kohm");
assertResistance(4.7, ResistanceUnit.R, "4.7ohm");
assertResistance(4.7, ResistanceUnit.R, "4.7 ohms");
assertResistance(4.7, ResistanceUnit.R, "4.7\u03A9");
assertResistance(4.7, ResistanceUnit.R, "4.7\u2126");
assertResistance(4.7, ResistanceUnit.K, "4.7K\u03A9");
assertResistance(1, ResistanceUnit.M, "1M");
assertResistance(1, ResistanceUnit.M, "1m");
assertResistance(2.2, ResistanceUnit.M, "2.2Meg");
assertResistance(100, ResistanceUnit.R, "100R");
assertResistance(100, ResistanceUnit.R, "100r");
assertResistance(0.5, ResistanceUnit.K, ".5K");
assertResistance(10, ResistanceUnit.K, "10K*");
}
@Test
public void parsesRkmResistance() {
assertResistance(4.7, ResistanceUnit.R, "4R7");
assertResistance(0.22, ResistanceUnit.R, "0R22");
assertResistance(0.22, ResistanceUnit.R, "R22");
assertResistance(4.7, ResistanceUnit.K, "4K7");
assertResistance(4.7, ResistanceUnit.K, "4k7");
assertResistance(4.75, ResistanceUnit.K, "4K75");
assertResistance(2.2, ResistanceUnit.M, "2M2");
}
@Test
public void parsesDecimalCapacitance() {
assertCapacitance(0.22, CapacitanceUnit.uF, "0.22uF");
assertCapacitance(0.22, CapacitanceUnit.uF, "0.22UF");
assertCapacitance(0.22, CapacitanceUnit.uF, "0.22u");
assertCapacitance(220, CapacitanceUnit.nF, "220 nF");
assertCapacitance(220, CapacitanceUnit.nF, "220n");
assertCapacitance(47, CapacitanceUnit.pF, "47pf");
assertCapacitance(4.7, CapacitanceUnit.uF, "4.7\u00B5F");
assertCapacitance(1, CapacitanceUnit.F, "1F");
assertCapacitance(1, CapacitanceUnit.mF, "1mF");
assertCapacitance(1, CapacitanceUnit.mF, "1MF");
assertCapacitance(1, CapacitanceUnit.mF, "1m");
}
@Test
public void parsesRkmCapacitance() {
assertCapacitance(0.22, CapacitanceUnit.uF, "0u22");
assertCapacitance(0.22, CapacitanceUnit.uF, "u22");
assertCapacitance(2.2, CapacitanceUnit.nF, "2n2");
assertCapacitance(2.2, CapacitanceUnit.nF, "2n2F");
assertCapacitance(4.7, CapacitanceUnit.uF, "4u7");
assertCapacitance(4.7, CapacitanceUnit.uF, "4\u00B57");
assertCapacitance(4.7, CapacitanceUnit.uF, "4\u03BC7");
assertCapacitance(4.7, CapacitanceUnit.pF, "4p7");
}
@Test
public void bareNumberHasNoUnit() {
ValueNotation.ParsedMeasure<ResistanceUnit> parsed =
ValueNotation.parse("4.7", ResistanceUnit::fromSymbol);
assertEquals(4.7, parsed.value(), DELTA);
assertNull(parsed.unit());
}
@Test
public void rejectsInvalidText() {
assertRejected("4u7", ResistanceUnit.class);
assertRejected("4K7K", ResistanceUnit.class);
assertRejected("K", ResistanceUnit.class);
assertRejected("", ResistanceUnit.class);
assertRejected("100", ResistanceUnit.class);
assertRejected("4R7", CapacitanceUnit.class);
assertRejected("1M", CapacitanceUnit.class);
assertRejected("4M7", CapacitanceUnit.class);
}
@Test
public void roundTripsInBothNotations() {
double[] values = {0.22, 1, 4.7, 47, 100, 4700, 0.0047};
for (ValueNotation notation : ValueNotation.values()) {
ValueNotation.setCurrent(notation);
for (double value : values) {
for (ResistanceUnit unit : ResistanceUnit.values()) {
Resistance resistance = new Resistance(value, unit);
assertEquals(resistance, Resistance.parseResistance(resistance.toString()));
}
for (CapacitanceUnit unit : CapacitanceUnit.values()) {
Capacitance capacitance = new Capacitance(value, unit);
assertEquals(capacitance, Capacitance.parseCapacitance(capacitance.toString()));
}
}
}
}
private static void assertResistance(double value, ResistanceUnit unit, String text) {
Resistance parsed = Resistance.parseResistance(text);
assertEquals(text, value, parsed.getValue(), DELTA);
assertEquals(text, unit, parsed.getUnit());
}
private static void assertCapacitance(double value, CapacitanceUnit unit, String text) {
Capacitance parsed = Capacitance.parseCapacitance(text);
assertEquals(text, value, parsed.getValue(), DELTA);
assertEquals(text, unit, parsed.getUnit());
}
private static void assertRejected(String text, Class<?> unitType) {
try {
if (unitType == ResistanceUnit.class) {
Resistance.parseResistance(text);
} else {
Capacitance.parseCapacitance(text);
}
fail("Expected " + text + " to be rejected");
} catch (IllegalArgumentException e) {
// expected
}
}
}