001package jmri.jmrit.roster;
002
003import java.awt.FlowLayout;
004import java.awt.GridBagConstraints;
005import java.awt.GridBagLayout;
006import java.awt.Insets;
007import java.util.MissingResourceException;
008
009import javax.swing.ButtonGroup;
010import javax.swing.JButton;
011import javax.swing.JCheckBox;
012import javax.swing.JComboBox;
013import javax.swing.JLabel;
014import javax.swing.JPanel;
015import javax.swing.JRadioButton;
016import javax.swing.JSpinner;
017import javax.swing.SpinnerNumberModel;
018import javax.swing.JTextArea;
019import javax.swing.UIManager;
020
021/**
022 * Display and enable editing of Physics parameters for a {@link RosterEntry}.
023 * This is intended to be hosted as its own tab in the programmer window.
024 */
025public class RosterPhysicsPane extends JPanel {
026
027    private static final long serialVersionUID = 1L;
028
029    private RosterEntry re;
030
031    // Persist unit selections per roster entry (stored as non-localised codes in RosterEntry attributes)
032    private static final String PHYSICS_WEIGHT_UNIT_ATTR = "physicsWeightUnit";
033    private static final String PHYSICS_POWER_UNIT_ATTR = "physicsPowerUnit";
034    private static final String PHYSICS_TE_UNIT_ATTR = "physicsTractiveEffortUnit";
035    private static final String PHYSICS_SPEED_UNIT_ATTR = "physicsMaxSpeedUnit";
036
037
038    // --- Physics (locomotive-level) controls ---
039    private final JRadioButton physicsSteamRadio = new JRadioButton(Bundle.getMessage("PhysicsSteam"));
040    private final JRadioButton physicsDieselElectricRadio =
041            new JRadioButton(Bundle.getMessage("PhysicsDieselElectric"));
042    private final JCheckBox physicsMechanicalTransmissionCheck =
043            new JCheckBox(Bundle.getMessage("PhysicsMechanicalTransmission"));
044    private final ButtonGroup physicsTractionGroup = new ButtonGroup();
045
046    // Unit combos per field (display units; storage stays metric in RosterEntry)
047    private final JComboBox<String> physicsWeightUnitCombo = new JComboBox<>();
048    private final JComboBox<String> physicsPowerUnitCombo = new JComboBox<>();
049    private final JComboBox<String> physicsTeUnitCombo = new JComboBox<>();
050    private final JComboBox<String> physicsSpeedUnitCombo = new JComboBox<>();
051
052    // Editors (spinners)
053    private final JSpinner physicsWeightSpinner = new JSpinner();
054    private final JSpinner physicsPowerSpinner = new JSpinner();
055    private final JSpinner physicsTractiveEffortSpinner = new JSpinner();
056    private final JSpinner physicsMaxSpeedSpinner = new JSpinner();
057
058    private boolean refreshing = false;
059
060    /**
061     * Return the localized title to use when this pane is added as a tab. This
062     * method exists so callers in other packages don't need direct access to
063     * this package's Bundle helper.
064     *
065     * @return localized tab title
066     */
067    public static String getTabTitle() {
068        return Bundle.getMessage("PhysicsTabTitle");
069    }
070
071    private static String getPhysicsPaneExplanation() {
072        try {
073            return Bundle.getMessage("PhysicsPaneExplanation");
074        } catch (MissingResourceException ex) {
075            // Fallback to avoid hard failure if the resource key is not present.
076            return "These controls are primarily used for physics-based acceleration. They affect how JMRI calculates throttle changes over time when accelerating.";
077        }
078    }
079
080    public RosterPhysicsPane(RosterEntry r) {
081        super();
082        re = r;
083
084        initGui();
085        wireListeners();
086
087        // Apply any per-roster saved unit selections before showing values
088        applyUnitSelectionsFromRosterEntry(re);
089        // Initialize display from current roster entry (metric storage -> chosen units)
090        refreshFromRosterEntry();
091    }
092
093    private void initGui() {
094        GridBagLayout gbLayout = new GridBagLayout();
095        GridBagConstraints cL = new GridBagConstraints();
096        GridBagConstraints cR = new GridBagConstraints();
097
098        setLayout(gbLayout);
099
100        // Explanation text for the Physics pane
101        JTextArea explanation = new JTextArea(getPhysicsPaneExplanation());
102        explanation.setEditable(false);
103        explanation.setLineWrap(true);
104        explanation.setWrapStyleWord(true);
105        explanation.setOpaque(false);
106        explanation.setFocusable(false);
107        explanation.setFont(UIManager.getFont("Label.font"));
108        explanation.setForeground(UIManager.getColor("Label.foreground"));
109        GridBagConstraints cE = new GridBagConstraints();
110        cE.gridx = 0;
111        cE.gridy = 0;
112        cE.gridwidth = 2;
113        cE.anchor = GridBagConstraints.WEST;
114        cE.fill = GridBagConstraints.HORIZONTAL;
115        cE.weightx = 1.0;
116        cE.insets = new Insets(0, 10, 20, 10);
117        gbLayout.setConstraints(explanation, cE);
118        add(explanation);
119
120
121        cL.gridx = 0;
122        cL.gridy = 1;
123        cL.ipadx = 3;
124        cL.anchor = GridBagConstraints.NORTHWEST;
125        cL.insets = new Insets(0, 10, 0, 15);
126
127        cR.gridx = 1;
128        cR.gridy = 1;
129        cR.anchor = GridBagConstraints.WEST;
130        cR.insets = new Insets(0, 0, 0, 10);
131
132        // Traction type (Steam / Diesel/Electric)
133        JLabel physicsTractionLabel = new JLabel(Bundle.getMessage("PhysicsTractionType") + ":");
134        gbLayout.setConstraints(physicsTractionLabel, cL);
135        add(physicsTractionLabel);
136
137        JPanel tractionRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
138        physicsTractionGroup.add(physicsSteamRadio);
139        physicsTractionGroup.add(physicsDieselElectricRadio);
140        tractionRow.add(physicsSteamRadio);
141        tractionRow.add(physicsDieselElectricRadio);
142
143        physicsSteamRadio.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsSteam"));
144        physicsDieselElectricRadio.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsDieselElectric"));
145
146        gbLayout.setConstraints(tractionRow, cR);
147        add(tractionRow);
148
149        // Transmission (mechanical DMU option)
150        cL.gridy++;
151        cR.gridy = cL.gridy;
152
153        JLabel physicsTransLabel = new JLabel(Bundle.getMessage("PhysicsTransmission") + ":");
154        gbLayout.setConstraints(physicsTransLabel, cL);
155        add(physicsTransLabel);
156
157        gbLayout.setConstraints(physicsMechanicalTransmissionCheck, cR);
158        add(physicsMechanicalTransmissionCheck);
159
160        physicsMechanicalTransmissionCheck.getAccessibleContext()
161                .setAccessibleName(Bundle.getMessage("PhysicsMechanicalTransmission"));
162        physicsMechanicalTransmissionCheck.setToolTipText(Bundle.getMessage("ToolTipPhysicsMechanicalTransmission"));
163
164        // Locomotive weight
165        cL.gridy++;
166        cR.gridy = cL.gridy;
167
168        JLabel physicsWeightLabel = new JLabel(Bundle.getMessage("PhysicsWeight") + ":");
169        gbLayout.setConstraints(physicsWeightLabel, cL);
170        add(physicsWeightLabel);
171
172        JPanel weightRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
173        physicsWeightSpinner.setModel(new SpinnerNumberModel(0.0d, 0.0d, 1_000_000.0d, 0.1d));
174        physicsWeightSpinner.setEditor(new JSpinner.NumberEditor(physicsWeightSpinner, "0.0"));
175        physicsWeightUnitCombo.addItem(Bundle.getMessage("PhysicsWeightUnitTonne"));
176        physicsWeightUnitCombo.addItem(Bundle.getMessage("PhysicsWeightUnitLongTon"));
177        physicsWeightUnitCombo.addItem(Bundle.getMessage("PhysicsWeightUnitShortTon"));
178        weightRow.add(physicsWeightSpinner);
179        weightRow.add(physicsWeightUnitCombo);
180
181        physicsWeightSpinner.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsWeightValue"));
182        physicsWeightUnitCombo.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsWeightUnits"));
183
184        gbLayout.setConstraints(weightRow, cR);
185        add(weightRow);
186
187        // Continuous power
188        cL.gridy++;
189        cR.gridy = cL.gridy;
190
191        JLabel physicsPowerLabel = new JLabel(Bundle.getMessage("PhysicsPower") + ":");
192        gbLayout.setConstraints(physicsPowerLabel, cL);
193        add(physicsPowerLabel);
194
195        JPanel powerRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
196        physicsPowerSpinner.setModel(new SpinnerNumberModel(0.0d, 0.0d, 1_000_000.0d, 0.1d));
197        physicsPowerSpinner.setEditor(new JSpinner.NumberEditor(physicsPowerSpinner, "0.0"));
198        physicsPowerUnitCombo.addItem(Bundle.getMessage("PhysicsPowerUnitKW"));
199        physicsPowerUnitCombo.addItem(Bundle.getMessage("PhysicsPowerUnitHP"));
200        powerRow.add(physicsPowerSpinner);
201        powerRow.add(physicsPowerUnitCombo);
202
203        physicsPowerSpinner.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsPowerValue"));
204        physicsPowerUnitCombo.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsPowerUnits"));
205
206        gbLayout.setConstraints(powerRow, cR);
207        add(powerRow);
208
209        // Tractive effort
210        cL.gridy++;
211        cR.gridy = cL.gridy;
212
213        JLabel physicsTeLabel = new JLabel(Bundle.getMessage("PhysicsTractiveEffort") + ":");
214        gbLayout.setConstraints(physicsTeLabel, cL);
215        add(physicsTeLabel);
216
217        JPanel teRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
218        physicsTractiveEffortSpinner.setModel(new SpinnerNumberModel(0.0d, 0.0d, 1_000_000.0d, 0.1d));
219        physicsTractiveEffortSpinner.setEditor(new JSpinner.NumberEditor(physicsTractiveEffortSpinner, "0.0"));
220        physicsTeUnitCombo.addItem(Bundle.getMessage("PhysicsTeUnitKN"));
221        physicsTeUnitCombo.addItem(Bundle.getMessage("PhysicsTeUnitLbf"));
222        teRow.add(physicsTractiveEffortSpinner);
223        teRow.add(physicsTeUnitCombo);
224
225        physicsTractiveEffortSpinner.getAccessibleContext()
226                .setAccessibleName(Bundle.getMessage("PhysicsTractiveEffortValue"));
227        physicsTeUnitCombo.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsTractiveEffortUnits"));
228
229        gbLayout.setConstraints(teRow, cR);
230        add(teRow);
231
232        // Maximum speed
233        cL.gridy++;
234        cR.gridy = cL.gridy;
235
236        JLabel physicsSpeedLabel = new JLabel(Bundle.getMessage("PhysicsMaxSpeed") + ":");
237        gbLayout.setConstraints(physicsSpeedLabel, cL);
238        add(physicsSpeedLabel);
239
240        JPanel speedRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
241        physicsMaxSpeedSpinner.setModel(new SpinnerNumberModel(0.0d, 0.0d, 1_000.0d, 0.1d));
242        physicsMaxSpeedSpinner.setEditor(new JSpinner.NumberEditor(physicsMaxSpeedSpinner, "0.0"));
243        physicsSpeedUnitCombo.addItem(Bundle.getMessage("PhysicsSpeedUnitKmh"));
244        physicsSpeedUnitCombo.addItem(Bundle.getMessage("PhysicsSpeedUnitMph"));
245        speedRow.add(physicsMaxSpeedSpinner);
246        speedRow.add(physicsSpeedUnitCombo);
247
248        physicsMaxSpeedSpinner.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsMaxSpeedValue"));
249        physicsSpeedUnitCombo.getAccessibleContext().setAccessibleName(Bundle.getMessage("PhysicsSpeedUnits"));
250
251        gbLayout.setConstraints(speedRow, cR);
252        add(speedRow);
253
254        // Profile tools row: single button to open JMRI Speed Profiling UI
255        cL.gridy++;
256        cR.gridy = cL.gridy;
257
258        JLabel profileToolsLabel = new JLabel(Bundle.getMessage("PhysicsProfileTools") + ":");
259        gbLayout.setConstraints(profileToolsLabel, cL);
260        add(profileToolsLabel);
261
262        JButton physicsSpeedProfileButton = new JButton(Bundle.getMessage("PhysicsSpeedProfileButton"));
263        gbLayout.setConstraints(physicsSpeedProfileButton, cR);
264        add(physicsSpeedProfileButton);
265
266        physicsSpeedProfileButton.addActionListener(ev -> {
267            jmri.util.swing.WindowInterface wi = getWindowInterfaceOrNull();
268            jmri.jmrit.roster.swing.speedprofile.SpeedProfileAction act =
269                    (wi != null)
270                            ? new jmri.jmrit.roster.swing.speedprofile.SpeedProfileAction(
271                                    Bundle.getMessage("PhysicsSpeedProfilingTitle"), wi)
272                            : new jmri.jmrit.roster.swing.speedprofile.SpeedProfileAction(
273                                    Bundle.getMessage("PhysicsSpeedProfilingTitle"));
274            act.actionPerformed(new java.awt.event.ActionEvent(this,
275                    java.awt.event.ActionEvent.ACTION_PERFORMED, "open"));
276        });
277    }
278
279    private void wireListeners() {
280        physicsSteamRadio.addActionListener(ev -> {
281            if (refreshing)
282                return;
283            if (re != null)
284                re.setPhysicsTractionType(RosterEntry.TractionType.STEAM);
285        });
286        physicsDieselElectricRadio.addActionListener(ev -> {
287            if (refreshing)
288                return;
289            if (re != null)
290                re.setPhysicsTractionType(RosterEntry.TractionType.DIESEL_ELECTRIC);
291        });
292        physicsMechanicalTransmissionCheck.addActionListener(ev -> {
293            if (refreshing)
294                return;
295            if (re != null)
296                re.setPhysicsMechanicalTransmission(physicsMechanicalTransmissionCheck.isSelected());
297        });
298
299        // Unit combo changes -> refresh display from stored metric
300        physicsWeightUnitCombo.addActionListener(ev -> {
301            if (refreshing)
302                return;
303            persistUnitSelectionOnRosterEntry(re, PHYSICS_WEIGHT_UNIT_ATTR,
304                    weightUnitIndexToCode(physicsWeightUnitCombo.getSelectedIndex()));
305            refreshFromRosterEntry();
306        });
307        physicsPowerUnitCombo.addActionListener(ev -> {
308            if (refreshing)
309                return;
310            persistUnitSelectionOnRosterEntry(re, PHYSICS_POWER_UNIT_ATTR,
311                    powerUnitIndexToCode(physicsPowerUnitCombo.getSelectedIndex()));
312            refreshFromRosterEntry();
313        });
314        physicsTeUnitCombo.addActionListener(ev -> {
315            if (refreshing)
316                return;
317            persistUnitSelectionOnRosterEntry(re, PHYSICS_TE_UNIT_ATTR,
318                    teUnitIndexToCode(physicsTeUnitCombo.getSelectedIndex()));
319            refreshFromRosterEntry();
320        });
321        physicsSpeedUnitCombo.addActionListener(ev -> {
322            if (refreshing)
323                return;
324            persistUnitSelectionOnRosterEntry(re, PHYSICS_SPEED_UNIT_ATTR,
325                    speedUnitIndexToCode(physicsSpeedUnitCombo.getSelectedIndex()));
326            refreshFromRosterEntry();
327        });
328
329        // Spinner changes -> update stored metric in RosterEntry
330        physicsWeightSpinner.addChangeListener(ev -> {
331            if (refreshing)
332                return;
333            if (re != null)
334                re.setPhysicsWeightKg(displayWeightToKg(((Number) physicsWeightSpinner.getValue()).floatValue()));
335        });
336        physicsPowerSpinner.addChangeListener(ev -> {
337            if (refreshing)
338                return;
339            if (re != null)
340                re.setPhysicsPowerKw(displayPowerToKw(((Number) physicsPowerSpinner.getValue()).floatValue()));
341        });
342        physicsTractiveEffortSpinner.addChangeListener(ev -> {
343            if (refreshing)
344                return;
345            if (re != null)
346                re.setPhysicsTractiveEffortKn(
347                        displayTeToKn(((Number) physicsTractiveEffortSpinner.getValue()).floatValue()));
348        });
349        physicsMaxSpeedSpinner.addChangeListener(ev -> {
350            if (refreshing)
351                return;
352            if (re != null)
353                re.setPhysicsMaxSpeedKmh(displaySpeedToKmh(((Number) physicsMaxSpeedSpinner.getValue()).floatValue()));
354        });
355    }
356
357    /**
358     * Update the roster entry from the GUI contents.
359     *
360     * @param r roster entry to update
361     */
362    public void update(RosterEntry r) {
363        if (r == null)
364            return;
365        // Persist current unit selections onto the roster entry being saved
366        persistUnitSelectionOnRosterEntry(r, PHYSICS_WEIGHT_UNIT_ATTR,
367                weightUnitIndexToCode(physicsWeightUnitCombo.getSelectedIndex()));
368        persistUnitSelectionOnRosterEntry(r, PHYSICS_POWER_UNIT_ATTR,
369                powerUnitIndexToCode(physicsPowerUnitCombo.getSelectedIndex()));
370        persistUnitSelectionOnRosterEntry(r, PHYSICS_TE_UNIT_ATTR,
371                teUnitIndexToCode(physicsTeUnitCombo.getSelectedIndex()));
372        persistUnitSelectionOnRosterEntry(r, PHYSICS_SPEED_UNIT_ATTR,
373                speedUnitIndexToCode(physicsSpeedUnitCombo.getSelectedIndex()));
374
375        RosterEntry.TractionType t = physicsSteamRadio.isSelected()
376                ? RosterEntry.TractionType.STEAM
377                : RosterEntry.TractionType.DIESEL_ELECTRIC;
378        r.setPhysicsTractionType(t);
379        r.setPhysicsWeightKg(displayWeightToKg(((Number) physicsWeightSpinner.getValue()).floatValue()));
380        r.setPhysicsPowerKw(displayPowerToKw(((Number) physicsPowerSpinner.getValue()).floatValue()));
381        r.setPhysicsTractiveEffortKn(displayTeToKn(((Number) physicsTractiveEffortSpinner.getValue()).floatValue()));
382        r.setPhysicsMaxSpeedKmh(displaySpeedToKmh(((Number) physicsMaxSpeedSpinner.getValue()).floatValue()));
383        r.setPhysicsMechanicalTransmission(physicsMechanicalTransmissionCheck.isSelected());
384    }
385
386    /**
387     * Fill GUI from roster contents.
388     *
389     * @param r roster entry to display
390     */
391    public void updateGUI(RosterEntry r) {
392        re = r;
393        applyUnitSelectionsFromRosterEntry(re);
394        refreshFromRosterEntry();
395    }
396
397    /**
398     * Compare GUI with roster contents.
399     *
400     * @param r roster entry to compare
401     * @return true if GUI differs from r
402     */
403    public boolean guiChanged(RosterEntry r) {
404        if (r == null)
405            return false;
406
407        RosterEntry.TractionType t = physicsSteamRadio.isSelected()
408                ? RosterEntry.TractionType.STEAM
409                : RosterEntry.TractionType.DIESEL_ELECTRIC;
410        if (r.getPhysicsTractionType() != t)
411            return true;
412
413        if (r.isPhysicsMechanicalTransmission() != physicsMechanicalTransmissionCheck.isSelected())
414            return true;
415
416        // use a small epsilon to avoid noise from float/double conversions
417        final float eps = 0.0005f;
418
419        float wKg = displayWeightToKg(((Number) physicsWeightSpinner.getValue()).floatValue());
420        if (Math.abs(r.getPhysicsWeightKg() - wKg) > eps)
421            return true;
422
423        float pKw = displayPowerToKw(((Number) physicsPowerSpinner.getValue()).floatValue());
424        if (Math.abs(r.getPhysicsPowerKw() - pKw) > eps)
425            return true;
426
427        float teKn = displayTeToKn(((Number) physicsTractiveEffortSpinner.getValue()).floatValue());
428        if (Math.abs(r.getPhysicsTractiveEffortKn() - teKn) > eps)
429            return true;
430
431        float vKmh = displaySpeedToKmh(((Number) physicsMaxSpeedSpinner.getValue()).floatValue());
432        if (Math.abs(r.getPhysicsMaxSpeedKmh() - vKmh) > eps)
433            return true;
434
435        return false;
436    }
437
438    // --- Helpers: persist unit selections per roster entry (non-localised codes) ---
439    private String weightUnitIndexToCode(int ix) {
440        switch (ix) {
441            case 1:
442                return "LT"; // long ton
443            case 2:
444                return "ST"; // short ton
445            case 0:
446            default:
447                return "T"; // metric tonne
448        }
449    }
450
451    private int weightUnitCodeToIndex(String code) {
452        if (code == null) {
453            return 0;
454        }
455        if ("LT".equalsIgnoreCase(code)) {
456            return 1;
457        }
458        if ("ST".equalsIgnoreCase(code)) {
459            return 2;
460        }
461        return 0;
462    }
463
464    private String powerUnitIndexToCode(int ix) {
465        switch (ix) {
466            case 1:
467                return "HP";
468            case 0:
469            default:
470                return "KW";
471        }
472    }
473
474    private int powerUnitCodeToIndex(String code) {
475        if (code == null) {
476            return 0;
477        }
478        if ("HP".equalsIgnoreCase(code)) {
479            return 1;
480        }
481        return 0;
482    }
483
484    private String teUnitIndexToCode(int ix) {
485        switch (ix) {
486            case 1:
487                return "LBF";
488            case 0:
489            default:
490                return "KN";
491        }
492    }
493
494    private int teUnitCodeToIndex(String code) {
495        if (code == null) {
496            return 0;
497        }
498        if ("LBF".equalsIgnoreCase(code)) {
499            return 1;
500        }
501        return 0;
502    }
503
504    private String speedUnitIndexToCode(int ix) {
505        switch (ix) {
506            case 1:
507                return "MPH";
508            case 0:
509            default:
510                return "KMH";
511        }
512    }
513
514    private int speedUnitCodeToIndex(String code) {
515        if (code == null) {
516            return 0;
517        }
518        if ("MPH".equalsIgnoreCase(code)) {
519            return 1;
520        }
521        return 0;
522    }
523
524    private void persistUnitSelectionOnRosterEntry(RosterEntry target, String key, String code) {
525        if (target == null) {
526            return;
527        }
528        if (refreshing) {
529            return;
530        }
531        target.putAttribute(key, (code != null) ? code : "");
532    }
533
534    private String readUnitSelectionFromRosterEntry(RosterEntry target, String key) {
535        if (target == null) {
536            return null;
537        }
538        return target.getAttribute(key);
539    }
540
541    private void applyUnitSelectionsFromRosterEntry(RosterEntry target) {
542        if (target == null) {
543            return;
544        }
545        int wIx = weightUnitCodeToIndex(readUnitSelectionFromRosterEntry(target, PHYSICS_WEIGHT_UNIT_ATTR));
546        int pIx = powerUnitCodeToIndex(readUnitSelectionFromRosterEntry(target, PHYSICS_POWER_UNIT_ATTR));
547        int teIx = teUnitCodeToIndex(readUnitSelectionFromRosterEntry(target, PHYSICS_TE_UNIT_ATTR));
548        int sIx = speedUnitCodeToIndex(readUnitSelectionFromRosterEntry(target, PHYSICS_SPEED_UNIT_ATTR));
549
550        if (wIx >= 0 && wIx < physicsWeightUnitCombo.getItemCount()) {
551            physicsWeightUnitCombo.setSelectedIndex(wIx);
552        }
553        if (pIx >= 0 && pIx < physicsPowerUnitCombo.getItemCount()) {
554            physicsPowerUnitCombo.setSelectedIndex(pIx);
555        }
556        if (teIx >= 0 && teIx < physicsTeUnitCombo.getItemCount()) {
557            physicsTeUnitCombo.setSelectedIndex(teIx);
558        }
559        if (sIx >= 0 && sIx < physicsSpeedUnitCombo.getItemCount()) {
560            physicsSpeedUnitCombo.setSelectedIndex(sIx);
561        }
562    }
563
564    // --- Helpers: convert between metric storage (RosterEntry) and display units ---
565
566    private void refreshFromRosterEntry() {
567        if (re == null)
568            return;
569
570        refreshing = true;
571        try {
572            // Traction type
573            if (re.getPhysicsTractionType() == RosterEntry.TractionType.STEAM) {
574                physicsSteamRadio.setSelected(true);
575            } else {
576                physicsDieselElectricRadio.setSelected(true);
577            }
578            physicsMechanicalTransmissionCheck.setSelected(re.isPhysicsMechanicalTransmission());
579
580            // Weight kg -> display
581            physicsWeightSpinner.setValue(Double.valueOf(kgToDisplayWeight(re.getPhysicsWeightKg())));
582
583            // Power kW -> display
584            physicsPowerSpinner.setValue(Double.valueOf(kwToDisplayPower(re.getPhysicsPowerKw())));
585
586            // Tractive effort kN -> display
587            physicsTractiveEffortSpinner.setValue(Double.valueOf(knToDisplayTe(re.getPhysicsTractiveEffortKn())));
588
589            // Max speed km/h -> display
590            physicsMaxSpeedSpinner.setValue(Double.valueOf(kmhToDisplaySpeed(re.getPhysicsMaxSpeedKmh())));
591        } finally {
592            refreshing = false;
593        }
594    }
595
596    private float displayWeightToKg(float displayVal) {
597        switch (physicsWeightUnitCombo.getSelectedIndex()) {
598            case 0:
599                return displayVal * 1000.0f; // t -> kg
600            case 1:
601                return displayVal * 1016.0469f; // long ton -> kg
602            case 2:
603                return displayVal * 907.18474f; // short ton -> kg
604            default:
605                return displayVal;
606        }
607    }
608
609    private float kgToDisplayWeight(float kg) {
610        switch (physicsWeightUnitCombo.getSelectedIndex()) {
611            case 0:
612                return kg / 1000.0f; // t
613            case 1:
614                return kg / 1016.0469f; // long ton
615            case 2:
616                return kg / 907.18474f; // short ton
617            default:
618                return kg;
619        }
620    }
621
622    private float displayPowerToKw(float displayVal) {
623        switch (physicsPowerUnitCombo.getSelectedIndex()) {
624            case 0:
625                return displayVal; // kW
626            case 1:
627                return displayVal * 0.7456999f; // hp -> kW
628            default:
629                return displayVal;
630        }
631    }
632
633    private float displayTeToKn(float displayVal) {
634        switch (physicsTeUnitCombo.getSelectedIndex()) {
635            case 0:
636                return displayVal; // kN
637            case 1:
638                return displayVal / 224.80894f; // lbf -> kN
639            default:
640                return displayVal;
641        }
642    }
643
644    private float knToDisplayTe(float kn) {
645        switch (physicsTeUnitCombo.getSelectedIndex()) {
646            case 0:
647                return kn; // kN
648            case 1:
649                return kn * 224.80894f; // kN -> lbf
650            default:
651                return kn;
652        }
653    }
654
655    private float displaySpeedToKmh(float displayVal) {
656        switch (physicsSpeedUnitCombo.getSelectedIndex()) {
657            case 0:
658                return displayVal; // km/h
659            case 1:
660                return displayVal * 1.609344f; // mph -> km/h
661            default:
662                return displayVal;
663        }
664    }
665
666    private float kmhToDisplaySpeed(float kmh) {
667        switch (physicsSpeedUnitCombo.getSelectedIndex()) {
668            case 0:
669                return kmh; // km/h
670            case 1:
671                return kmh / 1.609344f; // km/h -> mph
672            default:
673                return kmh;
674        }
675    }
676
677    private float kwToDisplayPower(float kw) {
678        switch (physicsPowerUnitCombo.getSelectedIndex()) {
679            case 0:
680                return kw; // kW
681            case 1:
682                return kw / 0.7456999f; // kW -> hp
683            default:
684                return kw;
685        }
686    }
687
688    private jmri.util.swing.WindowInterface getWindowInterfaceOrNull() {
689        java.awt.Window w = javax.swing.SwingUtilities.getWindowAncestor(this);
690        return (w instanceof jmri.util.swing.WindowInterface)
691                ? (jmri.util.swing.WindowInterface) w
692                : null;
693    }
694}