User:DressyPear4/MoveNos

From OpenStreetMap Wiki
Jump to navigation Jump to search

Mover nós selecionadodos

Um script que permite mover nós selecionados no mapa com precisão métrica, usando uma interface gráfica com controle direcional.

O script detecta automaticamente o ângulo do segmento selecionado (calculado a partir de dois nós) botões que movem os nós nas 4 direções relativas ao segmento.

Spinner "Passo (m)" — define a distância em metros de cada clique; aceita digitação direta ou incremento pelos botões

🔄 — sincroniza o ângulo com a seleção atual no mapa e inicia um novo grupo de movimentos

Como funciona?

  • Selecione quantidade de nós desejada
  • Escolha entre deslocamento paralelo ou perpendicular (setas direcionais no outro código javascript)
    • Todos os nós do poligono - desloca
    • Apenas parte dos nós - altera geometria

Demonstração

Imagem.gif, clique para visualizar.

Código

Python

Última atualização: 2026-08-22

from org.openstreetmap.josm.gui import MainApplication, Notification
from org.openstreetmap.josm.command import MoveCommand, SequenceCommand
from org.openstreetmap.josm.data.coor import EastNorth, LatLon
from org.openstreetmap.josm.data import UndoRedoHandler
from org.openstreetmap.josm.data.osm import Way, Node
from javax.swing import (JPanel, JLabel, JButton, BoxLayout, JSpinner, 
                         SpinnerNumberModel, UIManager, JDialog, BorderFactory)
from java.awt import Dimension, BorderLayout, Font
from javax.swing.event import ChangeListener
import math
import java.awt.event as awte

initial_positions = {}

def get_meter_to_en_ratio(node):
    lat = node.getCoor().lat()
    return 1.0 / math.cos(math.radians(lat))

def validar_requisitos():
    dataset = MainApplication.getLayerManager().getEditDataSet()
    if not dataset:
        Notification(u"Nenhuma camada de edição ativa.")\
            .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show()
        return False
        
    selected_nodes = list(dataset.getSelectedNodes())
    if len(selected_nodes) < 2:
        Notification(u"Selecione pelo menos 2 nós.\nOs dois primeiros definem a direção do eixo.")\
            .setIcon(UIManager.getIcon("OptionPane.warningIcon")).show()
        return False
    return True

def mover_nos(passo_metros, direcao_tipo, dialog_state):
    global initial_positions
    dataset = MainApplication.getLayerManager().getEditDataSet()
    if not dataset: return

    selected_nodes = list(dataset.getSelectedNodes())
    
    if len(selected_nodes) < 2:
        Notification(u"Ação interrompida: Seleção insuficiente (mínimo 2 nós).")\
            .setIcon(UIManager.getIcon("OptionPane.warningIcon")).show()
        return

    # Dois primeiros nós selecionados para definir a geometria do movimento
    n1, n2 = selected_nodes[0], selected_nodes[1]
    
    # Cálculos Geométricos
    c1, c2 = n1.getCoor(), n2.getCoor()
    lat_rad = math.radians((c1.lat() + c2.lat()) / 2.0)
    m_per_deg_lat = 111319.492
    m_per_deg_lon = m_per_deg_lat * math.cos(lat_rad)
    
    dx_m = (c2.lon() - c1.lon()) * m_per_deg_lon
    dy_m = (c2.lat() - c1.lat()) * m_per_deg_lat
    comp = math.hypot(dx_m, dy_m)
    
    if comp < 1e-6:
        Notification(u"Os nós de referência são coincidentes demais.")\
            .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show()
        return

    ux, uy = dx_m / comp, dy_m / comp
    
    if direcao_tipo == "dy":
        ux, uy = -uy, ux

    cmds = []
    scale = get_meter_to_en_ratio(n1)
    delta_en_x = ux * passo_metros * scale
    delta_en_y = uy * passo_metros * scale

    for n in selected_nodes:
        if n not in initial_positions:
            initial_positions[n] = n.getEastNorth()
        cmds.append(MoveCommand(n, delta_en_x, delta_en_y))

    if cmds:
        UndoRedoHandler.getInstance().add(SequenceCommand(u"Mover nós", cmds))
        dialog_state['has_moved'] = True

def main():
    if not validar_requisitos():
        return

    dialog_state = {'has_moved': False}
    global initial_positions
    initial_positions = {}

    # --- Construção da Interface ---
    panel = JPanel()
    panel.setLayout(BoxLayout(panel, BoxLayout.Y_AXIS))

    # Painel do Spinner
    spinner_panel = JPanel()
    spinner_panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10))
    
    model = SpinnerNumberModel(1.0, 0.0, 50.0, 1.0)
    spinner = JSpinner(model)
    spinner.setEditor(JSpinner.NumberEditor(spinner, "0.0"))
    spinner.setPreferredSize(Dimension(80, 30))

    # Lógica de Passo Dinâmico (0.1 -> 1.0 -> 10.0)
    class StepListener(ChangeListener):
        def stateChanged(self, e):
            val = float(model.getValue())
            if val < 1.0: model.setStepSize(0.1)
            elif val < 10.0: model.setStepSize(1.0)
            else: model.setStepSize(10.0)

    spinner.addChangeListener(StepListener())
    
    label_spinner = JLabel("Passo (metros): ")
    label_spinner.setFont(Font("SansSerif", Font.BOLD, 12))
    spinner_panel.add(label_spinner)
    spinner_panel.add(spinner)
    panel.add(spinner_panel)

    # Painéis de Controle (Botões + e -)
    def create_btn_panel(titulo, direcao):
        p = JPanel()
        p.setBorder(BorderFactory.createTitledBorder(titulo))
        btn_m = JButton("-")
        btn_p = JButton("+")
        btn_m.setPreferredSize(Dimension(60, 32))
        btn_p.setPreferredSize(Dimension(60, 32))
        
        def move_action(e):
            val = float(model.getValue())
            passo = val if e.getSource() == btn_p else -val
            mover_nos(passo, direcao, dialog_state)
            
        btn_m.addActionListener(move_action)
        btn_p.addActionListener(move_action)
        p.add(btn_m)
        p.add(btn_p)
        return p

    panel.add(create_btn_panel(u"Paralelo", "dx"))
    panel.add(create_btn_panel(u"Perpendicular", "dy"))

    # Rodapé de Ação
    footer = JPanel()
    btn_ok = JButton("Aceitar", UIManager.getIcon("OptionPane.yesIcon"))
    btn_cc = JButton("Cancelar", UIManager.getIcon("OptionPane.noIcon"))
    footer.add(btn_ok)
    footer.add(btn_cc)

    # Configuração da Janela (JDialog)
    parent = MainApplication.getMainFrame()
    dialog = JDialog(parent, u"Ajuste Fino de Posição", False)
    
    def on_ok(e):
        if dialog_state['has_moved']:
            Notification(u"Movimentos aplicados com sucesso!")\
            .setIcon(UIManager.getIcon("OptionPane.informationIcon")).show()
        else:
            Notification(u"Nenhuma alteração realizada.")\
                .setIcon(UIManager.getIcon("OptionPane.warningIcon")).show()
        dialog.dispose()

    def on_cancel(e):
        if dialog_state['has_moved']:
            cmds = []
            for node, pos in initial_positions.items():
                cur = node.getEastNorth()
                # Calcula o vetor de retorno ao ponto original
                cmds.append(MoveCommand(node, pos.east() - cur.east(), pos.north() - cur.north()))
            if cmds:
                UndoRedoHandler.getInstance().add(SequenceCommand(u"Reverter Ajuste Fino", cmds))
            Notification(u"Movimentos revertidos.")\
                .setIcon(UIManager.getIcon("OptionPane.warningIcon")).show()
        dialog.dispose()

    btn_ok.addActionListener(on_ok)
    btn_cc.addActionListener(on_cancel)

    content = JPanel(BorderLayout())
    content.add(panel, BorderLayout.CENTER)
    content.add(footer, BorderLayout.SOUTH)
    
    dialog.setContentPane(content)
    dialog.pack()
    dialog.setLocationRelativeTo(parent)
    dialog.setAlwaysOnTop(True)
    dialog.setVisible(True)

# Execução
main()

JavaScript

Última atualização: 2026-08-22

"use strict";

const MainApplication = Java.type("org.openstreetmap.josm.gui.MainApplication");
const Notification    = Java.type("org.openstreetmap.josm.gui.Notification");
const MoveCommand     = Java.type("org.openstreetmap.josm.command.MoveCommand");
const SequenceCommand = Java.type("org.openstreetmap.josm.command.SequenceCommand");
const UndoRedoHandler = Java.type("org.openstreetmap.josm.data.UndoRedoHandler");
const UIManager       = Java.type("javax.swing.UIManager");
const JDialog         = Java.type("javax.swing.JDialog");
const JPanel          = Java.type("javax.swing.JPanel");
const JLabel          = Java.type("javax.swing.JLabel");
const JButton         = Java.type("javax.swing.JButton");
const JSpinner        = Java.type("javax.swing.JSpinner");
const SpinnerNumberModel = Java.type("javax.swing.SpinnerNumberModel");
const BoxLayout       = Java.type("javax.swing.BoxLayout");
const BorderFactory   = Java.type("javax.swing.BorderFactory");
const BorderLayout    = Java.type("java.awt.BorderLayout");
const Dimension       = Java.type("java.awt.Dimension");
const Font            = Java.type("java.awt.Font");
const ArrayList       = Java.type("java.util.ArrayList");

(function() {
    const layer = MainApplication.getLayerManager().getEditLayer();
    if (!layer) {
        new Notification("Nenhuma camada ativa.")
            .setIcon(UIManager.getIcon("OptionPane.errorIcon"))
            .show();
        return;
    }

    const initialPositions = new Map();
    let hasMoved = false;

    // --- LÓGICA DE MOVIMENTO ---
    const aplicarMovimento = function(distancia, tipo) {
        try {
            const currentLayer = MainApplication.getLayerManager().getEditLayer();
            if (!currentLayer) return;
            const nodes = currentLayer.data.getSelectedNodes();
            
            if (nodes.size() < 2) {
                new Notification("Selecione pelo menos 2 nós.")
                    .setIcon(UIManager.getIcon("OptionPane.warningIcon"))
                    .show();
                return;
            }

            const it = nodes.iterator();
            const n1 = it.next();
            const n2 = it.next();
            const c1 = n1.getCoor();
            const c2 = n2.getCoor();
            
            const latRad = ((c1.lat() + c2.lat()) / 2.0) * (Math.PI / 180.0);
            const mPerDegLat = 111319.492;
            const mPerDegLon = mPerDegLat * Math.cos(latRad);
            
            const dxM = (c2.lon() - c1.lon()) * mPerDegLon;
            const dyM = (c2.lat() - c1.lat()) * mPerDegLat;
            const comp = Math.sqrt(dxM * dxM + dyM * dyM);
            if (comp < 1e-6) return;

            let ux = dxM / comp;
            let uy = dyM / comp;
            if (tipo === "dy") { const tmp = ux; ux = -uy; uy = tmp; }

            const scale = 1.0 / Math.cos(c1.lat() * Math.PI / 180.0);
            const dEnX = ux * distancia * scale;
            const dEnY = uy * distancia * scale;

            const cmds = new ArrayList();
            const itAll = nodes.iterator();
            while (itAll.hasNext()) {
                const n = itAll.next();
                if (!initialPositions.has(n)) initialPositions.set(n, n.getEastNorth());
                cmds.add(new MoveCommand(n, dEnX, dEnY));
            }
            UndoRedoHandler.getInstance().add(new SequenceCommand("Ajuste Fino", cmds));
            hasMoved = true;
        } catch (e) {
            print("Erro na execução: " + e);
        }
    };

    // --- INTERFACE (MODAL) ---
    const dialog = new JDialog(MainApplication.getMainFrame(), "Ajuste Fino", false);
    
    const mainPanel = new JPanel();
    mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
    mainPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));

    const model = new SpinnerNumberModel(1.0, 0.0, 50.0, 0.5);
    const spinner = new JSpinner(model);
    spinner.setPreferredSize(new Dimension(85, 28));
    const sP = new JPanel(); 
    sP.add(new JLabel("Passo (m):")); 
    sP.add(spinner);
    mainPanel.add(sP);

    const btnSize = new Dimension(65, 30);
    const btnFont = new Font("SansSerif", Font.BOLD, 14);

    const criarGrupo = (tit, tp) => {
        const p = new JPanel(); 
        p.setBorder(BorderFactory.createTitledBorder(tit));
        const bM = new JButton("-"); 
        const bP = new JButton("+");
        [bM, bP].forEach(b => { 
            b.setPreferredSize(btnSize); 
            b.setFont(btnFont); 
            p.add(b);
            b.addActionListener(function() { 
                aplicarMovimento((b.getText() === "+" ? 1 : -1) * model.getValue(), tp); 
            });
        });
        return p;
    };

    mainPanel.add(criarGrupo("Paralelo", "dx"));
    mainPanel.add(criarGrupo("Perpendicular", "dy"));

    // --- RODAPÉ COM BOTÕES E ÍCONES ---
    const footer = new JPanel();
    const btnOk = new JButton("Concluir", UIManager.getIcon("OptionPane.okIcon"));
    const btnCc = new JButton("Reverter", UIManager.getIcon("OptionPane.noIcon"));

    btnOk.setFont(new Font("SansSerif", Font.PLAIN, 12));
    btnCc.setFont(new Font("SansSerif", Font.PLAIN, 12));

    btnOk.addActionListener(function() {
        if (hasMoved) {
            new Notification("Ajustes finalizados.")
                .setIcon(UIManager.getIcon("OptionPane.informationIcon"))
                .show();
        }
        dialog.dispose();
    });

    btnCc.addActionListener(function() {
        if (hasMoved) {
            const cmds = new ArrayList();
            initialPositions.forEach((pos, node) => {
                const cur = node.getEastNorth();
                cmds.add(new MoveCommand(node, pos.east() - cur.east(), pos.north() - cur.north()));
            });
            UndoRedoHandler.getInstance().add(new SequenceCommand("Reverter Ajustes", cmds));
            new Notification("Ajustes revertidos.")
                .setIcon(UIManager.getIcon("OptionPane.warningIcon"))
                .show();
        }
        dialog.dispose();
    });

    footer.add(btnOk); 
    footer.add(btnCc);
    
    const content = new JPanel(new BorderLayout());
    content.add(mainPanel, BorderLayout.CENTER);
    content.add(footer, BorderLayout.SOUTH);

    dialog.setContentPane(content);
    dialog.pack();
    dialog.setAlwaysOnTop(false);
    dialog.setLocationRelativeTo(MainApplication.getMainFrame());

    // --- EXECUÇÃO ---
    dialog.setVisible(true); 
})();

JavaScript (dialogo diferente)

Última atualização: 2026-08-22

"use strict";

const MainApplication = Java.type("org.openstreetmap.josm.gui.MainApplication");
const Notification    = Java.type("org.openstreetmap.josm.gui.Notification");
const MoveCommand     = Java.type("org.openstreetmap.josm.command.MoveCommand");
const SequenceCommand = Java.type("org.openstreetmap.josm.command.SequenceCommand");
const UndoRedoHandler = Java.type("org.openstreetmap.josm.data.UndoRedoHandler");
const UIManager       = Java.type("javax.swing.UIManager");
const JDialog         = Java.type("javax.swing.JDialog");
const JPanel          = Java.type("javax.swing.JPanel");
const JLabel          = Java.type("javax.swing.JLabel");
const JButton         = Java.type("javax.swing.JButton");
const JSpinner        = Java.type("javax.swing.JSpinner");
const SpinnerNumberModel = Java.type("javax.swing.SpinnerNumberModel");
const Timer           = Java.type("javax.swing.Timer");
const BoxLayout       = Java.type("javax.swing.BoxLayout");
const BorderFactory   = Java.type("javax.swing.BorderFactory");
const SwingUtilities  = Java.type("javax.swing.SwingUtilities");
const Box             = Java.type("javax.swing.Box");
const ArrayList       = Java.type("java.util.ArrayList");
const Float           = Java.type("java.lang.Float");
const BorderLayout    = Java.type("java.awt.BorderLayout");
const Color           = Java.type("java.awt.Color");
const BasicStroke     = Java.type("java.awt.BasicStroke");
const RenderingHints  = Java.type("java.awt.RenderingHints");
const WindowListener  = Java.type("java.awt.event.WindowListener");
const MouseListener   = Java.type("java.awt.event.MouseListener");
const Font            = Java.type("java.awt.Font");
const GradientPaint   = Java.type("java.awt.GradientPaint");
const RoundRectangle2D = Java.type("java.awt.geom.RoundRectangle2D");
const Area            = Java.type("java.awt.geom.Area");
const GeneralPath     = Java.type("java.awt.geom.GeneralPath");

(function() {
    const currentLayer = MainApplication.getLayerManager().getEditLayer();
    if (!currentLayer) {
        new Notification("Nenhuma camada de edição ativa.")
            .setIcon(UIManager.getIcon("OptionPane.errorIcon")).show();
        return;
    }

    const initialPositions = new Map();
    let movimentosAcumulados = 0;
    let anguloAtualRad = 0.0;
    let setaPressionada = -1;
    let lblAnguloTexto;

    // Dimensões do D-pad
    const ARM_LEN  = 66;   // comprimento do braço
    const ARM_W    = 28;   // largura do braço
    const HALF_W   = ARM_W / 2;
    const R_CENTER = 19;   // raio botão central
    const ARC      = 10;   // arredondamento das pontas

    function atualizarLabelAngulo() {
        if (!lblAnguloTexto) return;
        let graus = Math.round(anguloAtualRad * (180.0 / Math.PI));
        graus = -graus;
        if (graus < 0) graus += 360;
        lblAnguloTexto.setText(
            "<html><div style='text-align:center;color:#888;font-size:10px;'>" +
            "Ângulo: <b style='color:#e65100;font-size:11px;'>" + graus + "°</b></div></html>");
    }

    function recalcularAnguloSelecao() {
        const layer = MainApplication.getLayerManager().getEditLayer();
        if (!layer) return false;
        const nodes = layer.data.getSelectedNodes();
        if (nodes && nodes.size() >= 2) {
            const it = nodes.iterator();
            const n1 = it.next(), n2 = it.next();
            const c1 = n1.getCoor(), c2 = n2.getCoor();
            const latRad = ((c1.lat() + c2.lat()) / 2.0) * (Math.PI / 180.0);
            const mPerDegLat = 111319.492;
            const mPerDegLon = mPerDegLat * Math.cos(latRad);
            const dxM = (c2.lon() - c1.lon()) * mPerDegLon;
            const dyM = (c2.lat() - c1.lat()) * mPerDegLat;
            const comp = Math.sqrt(dxM * dxM + dyM * dyM);
            if (comp > 1e-6) {
                anguloAtualRad = Math.atan2(-dyM, dxM);
                atualizarLabelAngulo();
                return true;
            }
        }
        return false;
    }

    function getValorSpinner() {
        try { spinner.commitEdit(); } catch(e) {}
        return model.getValue();
    }

    const moverNósSelecao = function(distancia, alpha) {
        try {
            const layer = MainApplication.getLayerManager().getEditLayer();
            if (!layer) return;
            const nodes = layer.data.getSelectedNodes();
            if (!recalcularAnguloSelecao() || nodes.isEmpty()) {
                new Notification("Selecione pelo menos 2 nós no mapa.")
                    .setIcon(UIManager.getIcon("OptionPane.warningIcon")).show();
                return;
            }
            const mx2 = Math.cos(alpha);
            const my2 = -Math.sin(alpha);
            const scale = 1.0 / Math.cos(nodes.iterator().next().getCoor().lat() * Math.PI / 180.0);
            const cmds = new ArrayList();
            const itAll = nodes.iterator();
            while (itAll.hasNext()) {
                const n = itAll.next();
                if (!initialPositions.has(n)) initialPositions.set(n, n.getEastNorth());
                cmds.add(new MoveCommand(n, mx2 * distancia * scale, my2 * distancia * scale));
            }
            UndoRedoHandler.getInstance().add(new SequenceCommand("Ajuste Fino Direcional", cmds));
            movimentosAcumulados++;
        } catch (e) {
            new Notification("Erro: " + e).setIcon(UIManager.getIcon("OptionPane.errorIcon")).show();
        }
    };

    // Rotaciona o ponto para o referencial do braço solicitado
    function pontoNoBraco(lx, ly, id) {
        let angle = 0;
        if (id === 0) angle = Math.PI / 2;   // Cima
        else if (id === 1) angle = -Math.PI / 2; // Baixo
        else if (id === 2) angle = Math.PI;  // Trás
        // id===3: Frente, angle=0

        const cosA = Math.cos(-angle), sinA = Math.sin(-angle);
        const rx = lx * cosA - ly * sinA;
        const ry = lx * sinA + ly * cosA;

        return rx >= HALF_W && rx <= ARM_LEN && Math.abs(ry) <= HALF_W;
    }

    // Constrói o path de um braço com ponta arredondada (apenas as duas pontas do fim)
    function buildArmPath(g2d) {
        const p = new GeneralPath();
        p.moveTo(HALF_W, -HALF_W);
        p.lineTo(ARM_LEN - ARC, -HALF_W);
        p.quadTo(ARM_LEN, -HALF_W, ARM_LEN, -HALF_W + ARC);
        p.lineTo(ARM_LEN, HALF_W - ARC);
        p.quadTo(ARM_LEN, HALF_W, ARM_LEN - ARC, HALF_W);
        p.lineTo(HALF_W, HALF_W);
        p.closePath();
        return p;
    }

    const JPanelExtended = Java.extend(JPanel);
    const controlePanel = new JPanelExtended({
        getPreferredSize: function() { return new (Java.type("java.awt.Dimension"))(200, 200); },
        getMinimumSize:   function() { return new (Java.type("java.awt.Dimension"))(200, 200); },
        paintComponent: function(g) {
            Java.super(controlePanel).paintComponent(g);
            const g2d = g;
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);

            const cx = controlePanel.getWidth() / 2;
            const cy = controlePanel.getHeight() / 2;

            g2d.translate(cx, cy);
            g2d.rotate(anguloAtualRad);

   

            // Quatro braços
            const DIRS = [
                { id: 3, angle: 0,              label: "Frente" },
                { id: 0, angle: Math.PI / 2,    label: "Cima"   },
                { id: 2, angle: Math.PI,         label: "Trás"   },
                { id: 1, angle: -Math.PI / 2,   label: "Baixo"  }
            ];

            DIRS.forEach(function(dir) {
                g2d.rotate(dir.angle);
                const arm = buildArmPath(g2d);

                // Gradiente do braço: base mais escura, ponta mais clara
                const pressed = (setaPressionada === dir.id);
                const colBase = pressed ? new Color(140, 190,  255)  : new Color(45, 48, 50);
                const colTip  = pressed ? new Color(80, 140, 255) : new Color(75, 78, 82);
                const gp = new GradientPaint(HALF_W, 0, colBase, ARM_LEN, 0, colTip);
                g2d.setPaint(gp);
                g2d.fill(arm);

                // Borda fina
                g2d.setColor(pressed ? new Color(180, 60, 0) : new Color(30, 30, 30));
                g2d.setStroke(new BasicStroke(Float.parseFloat("1.2")));
                g2d.draw(arm);

                // Seta triangular na ponta
                const tx = ARM_LEN - 14;
                const arrowSize = 7;
                const ap = new GeneralPath();
                ap.moveTo(tx + arrowSize, 0);
                ap.lineTo(tx - arrowSize / 2, -arrowSize);
                ap.lineTo(tx - arrowSize / 2,  arrowSize);
                ap.closePath();
                g2d.setColor(pressed ? new Color(255, 255, 200) : new Color(220, 220, 220));
                g2d.fill(ap);

                g2d.rotate(-dir.angle);
            });

            // Botão central
            const pressedC = (setaPressionada === 99);
            const gcBase = pressedC ? new Color(80, 140, 255) : new Color(45, 48, 50);
            const gcTip  = pressedC ? new Color(140, 190, 255): new Color(75, 78, 82);
            g2d.setPaint(new GradientPaint(-R_CENTER, -R_CENTER, gcBase, R_CENTER, R_CENTER, gcTip));
            g2d.fillOval(-R_CENTER, -R_CENTER, R_CENTER * 2, R_CENTER * 2);
            g2d.setColor(pressedC ? new Color(50, 100, 200) : new Color(25, 25, 25));
            g2d.setStroke(new BasicStroke(Float.parseFloat("1.2")));
            g2d.drawOval(-R_CENTER, -R_CENTER, R_CENTER * 2, R_CENTER * 2);

            // Ícone 🔄 no centro
            g2d.setFont(new Font("Dialog", Font.PLAIN, 15));
            const fm = g2d.getFontMetrics();
            const txt = "🔄️";
            g2d.setColor(Color.WHITE);
            g2d.drawString(txt, -fm.stringWidth(txt) / 2, fm.getAscent() / 2 - 1);

            g2d.rotate(-anguloAtualRad);
            g2d.translate(-cx, -cy);

            atualizarLabelAngulo();
        }
    });

    controlePanel.setOpaque(false);

    const MouseListenerExtended = Java.extend(MouseListener);
    controlePanel.addMouseListener(new MouseListenerExtended({
        mouseClicked: function(e) {
            const cx = controlePanel.getWidth() / 2;
            const cy = controlePanel.getHeight() / 2;
            const dx = e.getX() - cx;
            const dy = e.getY() - cy;
            const dist = Math.sqrt(dx * dx + dy * dy);

            // Botão central
            if (dist <= R_CENTER + 2) {
                setaPressionada = 99;
                controlePanel.repaint();
                if (!recalcularAnguloSelecao()) {
                    new Notification("Nenhum segmento válido para sincronia.")
                        .setIcon(UIManager.getIcon("OptionPane.warningIcon")).show();
                }
                const t = new Timer(150, function() { setaPressionada = -1; controlePanel.repaint(); });
                t.setRepeats(false); t.start();
                return;
            }

            // Converte clique para o sistema local (rotacionado)
            const cosA = Math.cos(-anguloAtualRad), sinA = Math.sin(-anguloAtualRad);
            const lx = dx * cosA - dy * sinA;
            const ly = dx * sinA + dy * cosA;

            // Testa cada braço com geometria precisa
            let hitId = -1, hitAlpha = 0;
            const DIRS = [
                { id: 3, angle: 0,              alpha: anguloAtualRad },
                { id: 0, angle: Math.PI / 2,    alpha: anguloAtualRad + Math.PI / 2 },
                { id: 2, angle: Math.PI,         alpha: anguloAtualRad + Math.PI },
                { id: 1, angle: -Math.PI / 2,   alpha: anguloAtualRad - Math.PI / 2 }
            ];

            for (let i = 0; i < DIRS.length; i++) {
                const dir = DIRS[i];
                // Rotaciona o ponto local para o referencial do braço
                const ca = Math.cos(-dir.angle), sa = Math.sin(-dir.angle);
                const bx = lx * ca - ly * sa;
                const by = lx * sa + ly * ca;
                // O braço vai de HALF_W a ARM_LEN em X e -HALF_W a HALF_W em Y
                if (bx >= HALF_W - 2 && bx <= ARM_LEN + 2 && Math.abs(by) <= HALF_W + 2) {
                    hitId = dir.id;
                    hitAlpha = dir.alpha;
                    break;
                }
            }

            if (hitId === -1) return; // clique fora de qualquer braço

            setaPressionada = hitId;
            controlePanel.repaint();
            moverNósSelecao(getValorSpinner(), hitAlpha);
            const t = new Timer(150, function() { setaPressionada = -1; controlePanel.repaint(); });
            t.setRepeats(false); t.start();
        },
        mousePressed:  function(e) {},
        mouseReleased: function(e) {},
        mouseEntered:  function(e) {},
        mouseExited:   function(e) {}
    }));

    // Interface
    const dialog = new JDialog(MainApplication.getMainFrame(), "Mover Nós Selecionados", false);
    const mainPanel = new JPanel();
    mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
    mainPanel.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));

    const model = new SpinnerNumberModel(1.0, 0.0, 100.0, 0.5);
    const spinner = new JSpinner(model);
    spinner.setPreferredSize(new (Java.type("java.awt.Dimension"))(70, 24));

    const sP = new JPanel();
    sP.setOpaque(false);
    sP.add(new JLabel("Passo (m):"));
    sP.add(spinner);
    mainPanel.add(sP);
    mainPanel.add(Box.createVerticalStrut(5));

    const centerContainer = new JPanel(new BorderLayout());
    centerContainer.setOpaque(false);
    centerContainer.add(controlePanel, BorderLayout.CENTER);
    mainPanel.add(centerContainer);
    mainPanel.add(Box.createVerticalStrut(5));

    recalcularAnguloSelecao();

    const WindowListenerExtended = Java.extend(WindowListener);
    dialog.addWindowListener(new WindowListenerExtended({
        windowActivated: function(e) {
            SwingUtilities.invokeLater(function() {
                if (recalcularAnguloSelecao()) mainPanel.repaint();
            });
        },
        windowClosed: function(e) {}, windowClosing: function(e) {},
        windowDeactivated: function(e) {}, windowIconified: function(e) {},
        windowDeiconified: function(e) {}, windowOpened: function(e) {}
    }));

    const anguloPanel = new JPanel();
    anguloPanel.setOpaque(false);
    lblAnguloTexto = new JLabel();
    atualizarLabelAngulo();
    anguloPanel.add(lblAnguloTexto);

    const footer = new JPanel();
    footer.setOpaque(false);
    const btnOk = new JButton("Concluir", UIManager.getIcon("OptionPane.okIcon"));
    const btnCc = new JButton("Cancelar", UIManager.getIcon("OptionPane.noIcon"));

    btnOk.addActionListener(function() {
        new Notification(movimentosAcumulados > 0
            ? movimentosAcumulados + " ajuste(s) aplicado(s)."
            : "Nenhuma alteração realizada."
        ).setIcon(UIManager.getIcon(movimentosAcumulados > 0
            ? "OptionPane.informationIcon" : "OptionPane.warningIcon")).show();
        dialog.dispose();
    });

    btnCc.addActionListener(function() {
        if (movimentosAcumulados > 0) {
            for (let i = 0; i < movimentosAcumulados; i++) UndoRedoHandler.getInstance().undo();
            new Notification("Cancelado: alterações revertidas.")
                .setIcon(UIManager.getIcon("OptionPane.informationIcon")).show();
        } else {
            new Notification("Operação cancelada.")
                .setIcon(UIManager.getIcon("OptionPane.warningIcon")).show();
        }
        dialog.dispose();
    });

    footer.add(btnOk);
    footer.add(btnCc);

    const painelInf = new JPanel();
    painelInf.setLayout(new BoxLayout(painelInf, BoxLayout.Y_AXIS));
    painelInf.setOpaque(false);
    painelInf.add(anguloPanel);
    painelInf.add(footer);

    const content = new JPanel(new BorderLayout());
    content.add(mainPanel, BorderLayout.CENTER);
    content.add(painelInf,  BorderLayout.SOUTH);

    dialog.setContentPane(content);
    dialog.pack();
    dialog.setResizable(false);
    dialog.setLocationRelativeTo(MainApplication.getMainFrame());
    dialog.setVisible(true);
})();