WME Segment Scissors

Splits a segment in one step rather than drawing a throwaway segment across the road and deleting it.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name           WME Segment Scissors
// @namespace      https://gitlab.com/cnfw/waze-dev
// @version        2026.07.25.002
// @description    Splits a segment in one step rather than drawing a throwaway segment across the road and deleting it.
// @author         cnfw
// @license        GPL-3.0
// @match          https://www.waze.com/editor*
// @match          https://www.waze.com/*/editor*
// @match          https://beta.waze.com/editor*
// @match          https://beta.waze.com/*/editor*
// @exclude        https://www.waze.com/*user/*editor/*
// @exclude        https://beta.waze.com/*user/*editor/*
// @exclude        https://www.waze.com/discuss/*
// @exclude        https://www.waze.com/editor/sdk/*
// @exclude        https://beta.waze.com/editor/sdk/*
// @grant          none
// ==/UserScript==
(() => {
  // shared/logger.js
  function createLogger(scriptName, { sink: sink2 = null } = {}) {
    let prefix = `[${scriptName}]`, emit = (level, consoleFn, args) => {
      consoleFn(prefix, ...args), sink2 && sink2.record(level, args);
    };
    return {
      log: (...args) => emit("log", console.log, args),
      warn: (...args) => emit("warn", console.warn, args),
      error: (...args) => emit("error", console.error, args),
      /** Dev-only. Stripped from release builds. */
      debug: (...args) => {
      },
      /**
       * Dev-only collapsed group, for dumping structured state without flooding the console.
       * @param {string} label
       * @param {() => void} body
       */
      group: (label, body) => {
      }
    };
  }

  // shared/settings.js
  function createSettingsStore({ storageKey, defaults, logger: logger2 }) {
    let frozenDefaults = Object.freeze({ ...defaults }), current = { ...frozenDefaults };
    function load() {
      try {
        let raw = localStorage.getItem(storageKey), parsed = raw ? JSON.parse(raw) : null;
        current = { ...frozenDefaults, ...parsed ?? {} }, logger2.debug("settings loaded", current);
      } catch (err) {
        logger2.error("could not load settings, using defaults", err), current = { ...frozenDefaults };
      }
      return current;
    }
    function save() {
      try {
        localStorage.setItem(storageKey, JSON.stringify(current)), logger2.debug("settings saved", current);
      } catch (err) {
        logger2.error("could not save settings", err);
      }
    }
    return {
      load,
      save,
      /** @returns {Readonly<T>} */
      get all() {
        return current;
      },
      /** @param {keyof T} key */
      get(key) {
        return current[key];
      },
      /**
       * Set one key and persist immediately.
       * @param {keyof T} key
       * @param {unknown} value
       */
      set(key, value) {
        if (!(key in frozenDefaults)) {
          logger2.error(`refusing to set unknown setting "${String(key)}"`);
          return;
        }
        current[key] = value, save();
      },
      /** Restore defaults and persist. */
      reset() {
        current = { ...frozenDefaults }, save();
      },
      defaults: frozenDefaults
    };
  }

  // shared/wme.js
  async function initWmeSdk({ scriptId, scriptName, logger: logger2 }) {
    let sdkInitialized = window.SDK_INITIALIZED ?? (typeof unsafeWindow < "u" ? unsafeWindow.SDK_INITIALIZED : void 0);
    if (!sdkInitialized)
      throw new Error("SDK_INITIALIZED is undefined — not a WME editor page, or WME changed");
    await sdkInitialized;
    let factory = window.getWmeSdk ?? (typeof unsafeWindow < "u" ? unsafeWindow.getWmeSdk : void 0);
    if (typeof factory != "function")
      throw new Error("getWmeSdk is unavailable after SDK_INITIALIZED resolved");
    let sdk2 = factory({ scriptId, scriptName });
    return await waitForWmeReady(sdk2), sdk2;
  }
  function waitForWmeReady(sdk2) {
    return sdk2.State.isReady() ? Promise.resolve() : sdk2.Events.once({ eventName: "wme-ready" });
  }
  async function registerTab({ sdk: sdk2, label, title }) {
    let { tabLabel, tabPane } = await sdk2.Sidebar.registerScriptTab();
    return tabLabel.textContent = label, tabLabel.title = title, { tabLabel, tabPane };
  }

  // scripts/wme-segment-scissors/src/logic/messages.js
  function formatMetres(metres) {
    return Number.isFinite(metres) ? metres < 10 ? `${metres.toFixed(1)} m` : metres < 2e3 ? `${Math.round(metres)} m` : `${(metres / 1e3).toFixed(1)} km` : "?";
  }
  function describeOutcome(outcome) {
    if (outcome?.ok) {
      let pieces = Array.isArray(outcome.newIds) && outcome.newIds.length === 2 ? ` → ${outcome.newIds[0]} + ${outcome.newIds[1]}` : "", split = Number.isFinite(outcome.alongM) && Number.isFinite(outcome.totalM) ? ` (${formatMetres(outcome.alongM)} / ${formatMetres(outcome.totalM - outcome.alongM)})` : "";
      return { tone: "success", text: `Split segment ${outcome.segmentId}${pieces}${split}` };
    }
    switch (outcome?.reason) {
      case "aborted":
        return { tone: "info", text: "Cancelled — nothing was changed." };
      case "no-point":
        return { tone: "error", text: "WME returned a point we could not read. Nothing was changed." };
      case "no-segments":
        return { tone: "warn", text: "No roads loaded here. Zoom in until segments are drawn, then try again." };
      case "too-far":
        return {
          tone: "warn",
          text: `Could not tell which segment you meant — the nearest one is ${formatMetres(Number(outcome.nearestDistanceM))} from that point. Click directly on a road.`
        };
      case "ambiguous":
        return {
          tone: "warn",
          text: `Two segments overlap here (${(outcome.segmentIds ?? []).join(" and ")}), so the cut would be a coin toss. Select the one you want and split it from the segment panel.`
        };
      case "segment-too-short":
        return {
          tone: "warn",
          text: `That segment is only ${formatMetres(Number(outcome.totalM))} long — too short to split and leave ${formatMetres(Number(outcome.minPieceLengthM))} on each side.`
        };
      case "too-close-to-end":
        return {
          tone: "warn",
          text: `That's ${formatMetres(Number(outcome.gapM))} from the ${outcome.atStart ? "start" : "end"} of the segment. Splitting there would leave a stub — click at least ${formatMetres(Number(outcome.minPieceLengthM))} from either end.`
        };
      case "segment-deleted":
        return {
          tone: "warn",
          text: `Segment ${outcome.segmentId} is deleted in your unsaved edits, so there is nothing there to split.`
        };
      case "editing-not-allowed":
        return { tone: "warn", text: "Editing is not available right now, so nothing was cut." };
      case "drawing-in-progress":
        return { tone: "warn", text: "WME is already drawing something. Finish or cancel that first." };
      case "no-permission":
        return {
          tone: "warn",
          text: `You do not have permission to change segment ${outcome.segmentId} — it is probably locked above your rank.`
        };
      case "split-failed":
        return {
          tone: "error",
          text: `WME refused the split${outcome.detail ? `: ${outcome.detail}` : ""}. Nothing was changed.`
        };
      default:
        return { tone: "error", text: "Something went wrong. Check the console for details." };
    }
  }
  function describeState({ armed: armed2, shortcutKeys: shortcutKeys2, splitCount: splitCount2 }) {
    let tally = splitCount2 === 1 ? "1 split this session" : `${splitCount2} splits this session`;
    return armed2 ? `Armed — click a road to cut it. Esc to cancel. (${tally})` : shortcutKeys2 ? `Idle. Press ${describeShortcut(shortcutKeys2)} over the map to arm. (${tally})` : `Idle. No shortcut key is bound — see below. (${tally})`;
  }
  function describeShortcut(shortcutKeys2) {
    if (!shortcutKeys2) return "no key";
    let [modifiers, key] = shortcutKeys2.includes("+") ? [shortcutKeys2.slice(0, shortcutKeys2.lastIndexOf("+")), shortcutKeys2.slice(shortcutKeys2.lastIndexOf("+") + 1)] : ["", shortcutKeys2], names = { A: "Alt", C: "Ctrl", S: "Shift" }, parts = [...modifiers].map((m) => names[m]).filter(Boolean);
    return parts.push(key.toUpperCase()), parts.join("-");
  }

  // scripts/wme-segment-scissors/src/logic/geo.js
  var EARTH_RADIUS_M = 63710088e-1, DEG = Math.PI / 180;
  function cosLatitude(latitude) {
    return Math.max(Math.cos(latitude * DEG), 1e-12);
  }
  function coordinatesOf(value) {
    let pair = Array.isArray(value) ? value : value?.coordinates;
    if (!Array.isArray(pair) || pair.length < 2) return null;
    let [lon, lat] = pair;
    return !Number.isFinite(lon) || !Number.isFinite(lat) ? null : [lon, lat];
  }
  function distanceMetres([lon1, lat1], [lon2, lat2]) {
    let halfDLat = (lat2 - lat1) * DEG / 2, halfDLon = (lon2 - lon1) * DEG / 2, h = Math.sin(halfDLat) ** 2 + Math.cos(lat1 * DEG) * Math.cos(lat2 * DEG) * Math.sin(halfDLon) ** 2;
    return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
  }
  function projectPointOntoLeg(point, a, b) {
    let scaleLon = EARTH_RADIUS_M * DEG * cosLatitude(a[1]), scaleLat = EARTH_RADIUS_M * DEG, px = (point[0] - a[0]) * scaleLon, py = (point[1] - a[1]) * scaleLat, bx = (b[0] - a[0]) * scaleLon, by = (b[1] - a[1]) * scaleLat, lengthSquared = bx * bx + by * by, t = lengthSquared === 0 ? 0 : Math.min(1, Math.max(0, (px * bx + py * by) / lengthSquared)), footX = bx * t, footY = by * t, distanceM = Math.hypot(px - footX, py - footY);
    return {
      t,
      point: [a[0] + footX / scaleLon, a[1] + footY / scaleLat],
      distanceM
    };
  }
  function projectPointOntoPolyline(point, coordinates) {
    if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
    let best = null, bestAlongM = 0, travelled = 0;
    for (let i = 1; i < coordinates.length; i += 1) {
      let a = coordinates[i - 1], b = coordinates[i], legLength = distanceMetres(a, b), candidate = projectPointOntoLeg(point, a, b);
      (best === null || candidate.distanceM < best.distanceM) && (best = { ...candidate, legIndex: i - 1 }, bestAlongM = travelled + candidate.t * legLength), travelled += legLength;
    }
    return best === null ? null : {
      distanceM: best.distanceM,
      point: best.point,
      alongM: bestAlongM,
      totalM: travelled,
      legIndex: best.legIndex,
      t: best.t
    };
  }
  function bboxOfCoordinates(coordinates) {
    if (!Array.isArray(coordinates) || coordinates.length === 0) return null;
    let minLon = 1 / 0, minLat = 1 / 0, maxLon = -1 / 0, maxLat = -1 / 0;
    for (let pair of coordinates) {
      let point = coordinatesOf(pair);
      point && (minLon = Math.min(minLon, point[0]), minLat = Math.min(minLat, point[1]), maxLon = Math.max(maxLon, point[0]), maxLat = Math.max(maxLat, point[1]));
    }
    return minLon === 1 / 0 ? null : [minLon, minLat, maxLon, maxLat];
  }
  function isBboxWithinMetres(bbox, point, metres) {
    if (!bbox) return !1;
    let [lon, lat] = point, latPad = metres / (EARTH_RADIUS_M * DEG), lonPad = metres / (EARTH_RADIUS_M * DEG * cosLatitude(lat));
    return lon >= bbox[0] - lonPad && lon <= bbox[2] + lonPad && lat >= bbox[1] - latPad && lat <= bbox[3] + latPad;
  }

  // scripts/wme-segment-scissors/src/logic/target.js
  var TOLERANCES = {
    /** Beyond this, we failed to attribute a clicked point to a segment — refuse. */
    maxSnapDistanceM: 2,
    /** Refuse to leave a piece shorter than this on either side of the cut. */
    minPieceLengthM: 5,
    /** Two segments closer together than this at the split point are treated as ambiguous. */
    coincidentToleranceM: 0.5,
    /** Pre-filter radius when attributing a clicked point. Generous; only an optimisation. */
    candidateRadiusM: 100,
    /** How far the preview line will reach for a road. Overridden from pixels at runtime. */
    previewRadiusM: 120
  };
  function findSplitTarget({ point, segments, tolerances = {} }) {
    let limits = resolveTolerances(tolerances), target = coordinatesOf(point);
    if (!target) return { ok: !1, reason: "no-point" };
    let usable = usableSegments(segments);
    if (usable.length === 0) return { ok: !1, reason: "no-segments" };
    let ranked = rankByDistance(target, usable, limits.candidateRadiusM), finalRanking = ranked.length > 0 ? ranked : rankByDistance(target, usable, 1 / 0);
    if (finalRanking.length === 0) return { ok: !1, reason: "no-segments" };
    let [best, runnerUp] = finalRanking;
    if (best.distanceM > limits.maxSnapDistanceM)
      return {
        ok: !1,
        reason: "too-far",
        nearestSegmentId: best.id,
        nearestDistanceM: best.distanceM,
        maxSnapDistanceM: limits.maxSnapDistanceM
      };
    let ambiguity = checkAmbiguity(best, runnerUp, limits);
    if (ambiguity) return ambiguity;
    let verdict = evaluateCut(best, limits);
    return verdict.ok ? {
      ok: !0,
      segmentId: best.id,
      distanceM: best.distanceM,
      alongM: best.alongM,
      totalM: best.totalM,
      projected: best.projected
    } : verdict;
  }
  function findPreviewTarget({ point, segments, tolerances = {} }) {
    let limits = resolveTolerances(tolerances), cursor = coordinatesOf(point);
    if (!cursor) return { found: !1 };
    let usable = usableSegments(segments);
    if (usable.length === 0) return { found: !1 };
    let ranked = rankByDistance(cursor, usable, limits.previewRadiusM), [best, runnerUp] = ranked;
    if (!best || best.distanceM > limits.previewRadiusM) return { found: !1 };
    let verdict = checkAmbiguity(best, runnerUp, limits) ?? evaluateCut(best, limits);
    return {
      found: !0,
      ok: verdict.ok === !0,
      ...verdict.ok === !0 ? {} : { reason: verdict.reason },
      segmentId: best.id,
      projected: best.projected,
      cursor,
      distanceM: best.distanceM,
      alongM: best.alongM,
      totalM: best.totalM
    };
  }
  function evaluateCut(best, limits) {
    if (best.totalM < limits.minPieceLengthM * 2)
      return {
        ok: !1,
        reason: "segment-too-short",
        segmentId: best.id,
        totalM: best.totalM,
        minPieceLengthM: limits.minPieceLengthM
      };
    let fromStartM = best.alongM, fromEndM = best.totalM - best.alongM, gapM = Math.min(fromStartM, fromEndM);
    return gapM < limits.minPieceLengthM ? {
      ok: !1,
      reason: "too-close-to-end",
      segmentId: best.id,
      gapM,
      atStart: fromStartM <= fromEndM,
      totalM: best.totalM,
      minPieceLengthM: limits.minPieceLengthM
    } : { ok: !0 };
  }
  function checkAmbiguity(best, runnerUp, limits) {
    return !runnerUp || runnerUp.distanceM - best.distanceM >= limits.coincidentToleranceM ? null : {
      ok: !1,
      reason: "ambiguous",
      segmentIds: [best.id, runnerUp.id],
      distanceM: best.distanceM
    };
  }
  function usableSegments(segments) {
    return (Array.isArray(segments) ? segments : []).filter((segment) => Number.isFinite(segment?.id) && segment.geometry?.coordinates?.length >= 2);
  }
  function resolveTolerances(overrides) {
    let limits = { ...TOLERANCES };
    for (let key of Object.keys(TOLERANCES)) {
      let value = Number(overrides?.[key]);
      Number.isFinite(value) && value > 0 && (limits[key] = value);
    }
    return limits;
  }
  function rankByDistance(target, segments, radiusM) {
    let results = [];
    for (let segment of segments) {
      let { coordinates } = segment.geometry;
      if (Number.isFinite(radiusM) && !isBboxWithinMetres(segment.bbox ?? bboxOfCoordinates(coordinates), target, radiusM))
        continue;
      let projection = projectPointOntoPolyline(target, coordinates);
      projection && results.push({
        id: segment.id,
        distanceM: projection.distanceM,
        alongM: projection.alongM,
        totalM: projection.totalM,
        projected: projection.point
      });
    }
    return results.sort((a, b) => a.distanceM - b.distanceM);
  }

  // scripts/wme-segment-scissors/src/wme/scissors.js
  var SEGMENTS_MODEL = "segments", EDIT_GEOMETRY = "EDIT_GEOMETRY";
  async function splitWhereClicked(sdk2, { tolerances, logger: logger2 }) {
    if (!sdk2.Editing.isEditingAllowed()) return { ok: !1, reason: "editing-not-allowed" };
    if (sdk2.Editing.isDrawingInProgress()) return { ok: !1, reason: "drawing-in-progress" };
    let drawn = await requestSnappedPoint(sdk2, logger2);
    if (!drawn.ok) return drawn;
    let target = findSplitTarget({
      point: drawn.point,
      segments: collectSegments(sdk2, logger2),
      tolerances
    });
    if (!target.ok) return target;
    let { segmentId } = target;
    if (isDeleted(sdk2, segmentId, logger2)) return { ok: !1, reason: "segment-deleted", segmentId };
    if (!hasSplitPermission(sdk2, segmentId, logger2)) return { ok: !1, reason: "no-permission", segmentId };
    let split = applySplit(sdk2, { segmentId, splitPoint: drawn.point, logger: logger2 });
    return split.ok ? {
      ok: !0,
      segmentId,
      newIds: split.newIds,
      alongM: target.alongM,
      totalM: target.totalM
    } : split;
  }
  async function requestSnappedPoint(sdk2, logger2) {
    try {
      return { ok: !0, point: await sdk2.Map.drawPoint({ snapTo: "segment" }) };
    } catch (err) {
      return logger2.debug("drawPoint did not produce a point", err), { ok: !1, reason: "aborted" };
    }
  }
  function collectSegments(sdk2, logger2) {
    try {
      return sdk2.DataModel.Segments.getAll().map((segment) => ({
        id: segment.id,
        geometry: segment.geometry,
        length: segment.length
      }));
    } catch (err) {
      return logger2.error("could not read the segment model", err), [];
    }
  }
  function isDeleted(sdk2, segmentId, logger2) {
    try {
      return sdk2.DataModel.isDeleted({ dataModelName: SEGMENTS_MODEL, objectId: segmentId });
    } catch (err) {
      return logger2.debug(`isDeleted check failed for segment ${segmentId}`, err), !1;
    }
  }
  function hasSplitPermission(sdk2, segmentId, logger2) {
    try {
      return sdk2.DataModel.Segments.hasPermissions({ segmentId, permission: EDIT_GEOMETRY });
    } catch (err) {
      return logger2.debug(`permission check failed for segment ${segmentId}`, err), !0;
    }
  }
  function applySplit(sdk2, { segmentId, splitPoint, logger: logger2 }) {
    try {
      return { ok: !0, newIds: sdk2.DataModel.Segments.splitSegment({ segmentId, splitPoint }) };
    } catch (err) {
      return logger2.error(`splitSegment failed for segment ${segmentId}`, err), { ok: !1, reason: "split-failed", detail: err?.name ?? "unknown error" };
    }
  }
  function selectSegments(sdk2, ids, logger2) {
    if (!(!Array.isArray(ids) || ids.length === 0))
      try {
        sdk2.Editing.setSelection({ selection: { objectType: "segment", ids } });
      } catch (err) {
        logger2.debug("could not select the new segments", err);
      }
  }
  function registerArmingShortcut(sdk2, { shortcutId, description, callback, candidateKeys, logger: logger2 }) {
    for (let shortcutKeys2 of candidateKeys)
      try {
        if (sdk2.Shortcuts.areShortcutKeysInUse({ shortcutKeys: shortcutKeys2 })) {
          logger2.debug(`${shortcutKeys2} is already in use, trying the next candidate`);
          continue;
        }
        return sdk2.Shortcuts.createShortcut({ shortcutId, description, shortcutKeys: shortcutKeys2, callback }), logger2.debug(`bound ${shortcutKeys2}`), shortcutKeys2;
      } catch (err) {
        logger2.debug(`could not bind ${shortcutKeys2}`, err);
      }
    return logger2.warn(`no keyboard shortcut could be bound (tried ${candidateKeys.join(", ")})`), null;
  }

  // scripts/wme-segment-scissors/src/wme/preview.js
  var SNAP_RADIUS_PX = 110, SCALE_PROBE_PX = 100, FALLBACK_RADIUS_M = 120, MIN_LEADER_M = 1.5;
  function createPreview({ sdk: sdk2, layerName, logger: logger2, getMinPieceLengthM }) {
    let index = [], metresPerPixel = null, unsubscribers = [], pendingFrame = null, cursor = null, active = !1;
    return createLayer(), { start, stop };
    function createLayer() {
      try {
        sdk2.Map.addLayer({
          layerName,
          // Later rules win, per the SDK docs, but these predicates are mutually exclusive
          // so the order carries no meaning and can't be broken by reordering.
          styleRules: [
            {
              predicate: (p) => p.kind === "leader" && p.ok,
              style: leaderStyle("#ffffff")
            },
            {
              predicate: (p) => p.kind === "leader" && !p.ok,
              style: leaderStyle("#ffb300")
            },
            {
              predicate: (p) => p.kind === "target" && p.ok,
              style: targetStyle("#2f7ed8")
            },
            {
              predicate: (p) => p.kind === "target" && !p.ok,
              style: targetStyle("#ffb300")
            }
          ]
        }), logger2.debug(`preview layer "${layerName}" created`);
      } catch (err) {
        logger2.error("could not create the preview layer", err);
      }
    }
    function start() {
      active || (active = !0, rebuildIndex(), measureScale(), subscribe("wme-map-mouse-move", (event) => {
        cursor = { lon: event.lon, lat: event.lat }, scheduleRender();
      }), subscribe("wme-map-mouse-out", clearFeatures), subscribe("wme-map-zoom-changed", measureScale), subscribe("wme-map-data-loaded", rebuildIndex), setVisible(!0));
    }
    function stop() {
      active = !1, cursor = null, unsubscribers.forEach((off) => {
        try {
          off();
        } catch (err) {
          logger2.debug("preview unsubscribe failed", err);
        }
      }), unsubscribers = [], pendingFrame !== null && (cancelAnimationFrame(pendingFrame), pendingFrame = null), clearFeatures(), setVisible(!1);
    }
    function subscribe(eventName, eventHandler) {
      try {
        unsubscribers.push(sdk2.Events.on({ eventName, eventHandler }));
      } catch (err) {
        logger2.debug(`could not subscribe to ${eventName}`, err);
      }
    }
    function scheduleRender() {
      pendingFrame === null && (pendingFrame = requestAnimationFrame(() => {
        pendingFrame = null, render();
      }));
    }
    function render() {
      if (!active || !cursor) return;
      let radiusM = (metresPerPixel ?? null) === null ? FALLBACK_RADIUS_M : SNAP_RADIUS_PX * metresPerPixel, target;
      try {
        target = findPreviewTarget({
          point: [cursor.lon, cursor.lat],
          segments: index,
          tolerances: {
            previewRadiusM: radiusM,
            minPieceLengthM: getMinPieceLengthM()
          }
        });
      } catch (err) {
        logger2.error("preview calculation failed", err), clearFeatures();
        return;
      }
      if (!target.found) {
        clearFeatures();
        return;
      }
      let features = [{
        id: `${layerName}-target`,
        type: "Feature",
        geometry: { type: "Point", coordinates: target.projected },
        properties: { kind: "target", ok: target.ok }
      }];
      target.distanceM >= MIN_LEADER_M && features.push({
        id: `${layerName}-leader`,
        type: "Feature",
        geometry: {
          type: "LineString",
          coordinates: [target.cursor, target.projected]
        },
        properties: { kind: "leader", ok: target.ok }
      });
      try {
        sdk2.Map.removeAllFeaturesFromLayer({ layerName }), sdk2.Map.addFeaturesToLayer({ features, layerName });
      } catch (err) {
        logger2.debug("could not draw the preview", err);
      }
    }
    function rebuildIndex() {
      try {
        index = sdk2.DataModel.Segments.getAll().map((segment) => ({
          id: segment.id,
          geometry: segment.geometry,
          bbox: bboxOfCoordinates(segment.geometry?.coordinates)
        })), logger2.debug(`preview index: ${index.length} segments`);
      } catch (err) {
        logger2.error("could not index segments for the preview", err), index = [];
      }
    }
    function measureScale() {
      try {
        let from = sdk2.Map.getLonLatFromMapPixel({ x: 0, y: 0 }), to = sdk2.Map.getLonLatFromMapPixel({ x: SCALE_PROBE_PX, y: 0 }), spanM = distanceMetres([from.lon, from.lat], [to.lon, to.lat]);
        metresPerPixel = spanM > 0 ? spanM / SCALE_PROBE_PX : null, logger2.debug(`map scale: ${metresPerPixel?.toFixed(3) ?? "unknown"} m/px`);
      } catch (err) {
        logger2.debug("could not measure the map scale — using a fixed snap radius", err), metresPerPixel = null;
      }
    }
    function clearFeatures() {
      try {
        sdk2.Map.removeAllFeaturesFromLayer({ layerName });
      } catch (err) {
        logger2.debug("could not clear the preview layer", err);
      }
    }
    function setVisible(visible) {
      try {
        sdk2.Map.setLayerVisibility({ layerName, visibility: visible });
      } catch (err) {
        logger2.debug("could not change preview layer visibility", err);
      }
    }
  }
  function leaderStyle(strokeColor) {
    return {
      strokeColor,
      strokeWidth: 2,
      strokeDashstyle: "dash",
      strokeOpacity: 0.95,
      strokeLinecap: "round",
      fill: !1,
      fillOpacity: 0
    };
  }
  function targetStyle(fillColor) {
    return {
      pointRadius: 6,
      fillColor,
      fillOpacity: 0.9,
      strokeColor: "#ffffff",
      strokeWidth: 2,
      strokeOpacity: 1
    };
  }

  // scripts/wme-segment-scissors/src/ui/panel.js
  var PREFIX = "wmeSegmentScissors", MIN_PIECE_BOUNDS = { min: 0.5, max: 100 };
  function buildPanel({ settings: settings2, onSettingChange, onArm }) {
    let element = document.createElement("div");
    element.style.cssText = "padding:8px;font-size:12px", element.innerHTML = `
        <h4 style="margin:0 0 6px">✂️ Segment Scissors</h4>

        <p style="margin:0 0 8px;opacity:0.8">
            Splits a segment where you click, in one step — no throwaway segment to draw and delete.
        </p>

        <ol style="margin:0 0 10px;padding-left:18px;opacity:0.85">
            <li>Press <kbd id="${PREFIX}Key">…</kbd>, or use the button below.</li>
            <li>Move near a road — a dashed line points at where the cut will land.</li>
            <li>Click. The segment splits there.</li>
        </ol>
        <p style="margin:0 0 10px;opacity:0.7">
            The line turns amber when the cut would be refused, so you can see it before clicking.
        </p>

        <button type="button" id="${PREFIX}Arm"
                style="width:100%;padding:6px 8px;margin-bottom:10px;cursor:pointer;font-weight:600">
            ✂️ Arm scissors
        </button>

        <div id="${PREFIX}Status"
             style="margin-bottom:10px;padding:6px;background:rgba(0,0,0,0.05);border-radius:3px">
            &nbsp;
        </div>

        <label style="display:block;margin-bottom:4px;cursor:pointer">
            <input type="checkbox" id="${PREFIX}ContinuousMode" data-setting="continuousMode">
            Stay armed after each cut
        </label>
        <p style="margin:0 0 10px;padding-left:18px;opacity:0.7">
            For chopping one road into several pieces. Esc stops, and so does anything the tool
            declines to cut.
        </p>

        <label style="display:block;margin-bottom:10px;cursor:pointer">
            <input type="checkbox" id="${PREFIX}SelectAfterSplit" data-setting="selectAfterSplit">
            Select both halves when finished
        </label>

        <label style="display:block;margin-bottom:4px">
            Keep clear of junctions by
            <input type="number" id="${PREFIX}MinPieceLength" data-setting="minPieceLengthM"
                   min="${MIN_PIECE_BOUNDS.min}" max="${MIN_PIECE_BOUNDS.max}" step="0.5"
                   style="width:64px;margin-left:4px"> m
        </label>
        <p style="margin:0 0 10px;opacity:0.7">
            Refuses a cut that would leave a segment shorter than this.
        </p>

        <p style="margin:0;opacity:0.7;font-style:italic">
            The key can be rebound in WME's own keyboard-shortcut settings.
        </p>
    `;
    let statusEl = element.querySelector(`#${PREFIX}Status`), keyEl = element.querySelector(`#${PREFIX}Key`), armButton = element.querySelector(`#${PREFIX}Arm`);
    reflectSettings(), armButton.addEventListener("click", onArm), element.addEventListener("change", (event) => {
      let input = event.target, key = input?.dataset?.setting;
      if (key) {
        if (input.type === "checkbox") {
          onSettingChange(key, input.checked);
          return;
        }
        if (input.type === "number") {
          let parsed = Number(input.value);
          if (!Number.isFinite(parsed) || parsed < MIN_PIECE_BOUNDS.min || parsed > MIN_PIECE_BOUNDS.max) {
            reflectSettings();
            return;
          }
          onSettingChange(key, parsed);
          return;
        }
        onSettingChange(key, input.value);
      }
    });
    function reflectSettings() {
      element.querySelectorAll("[data-setting]").forEach((input) => {
        let value = settings2.get(input.dataset.setting);
        input.type === "checkbox" ? input.checked = !!value : input.value = String(value ?? "");
      });
    }
    return {
      element,
      setStatus(text) {
        statusEl && (statusEl.textContent = text);
      },
      setShortcut(keys) {
        keyEl && (keyEl.textContent = describeShortcut(keys), keyEl.title = keys ? `Bound to ${describeShortcut(keys)}` : "No key could be bound — every candidate was already taken by WME or another script");
      },
      setArmed(armed2) {
        armButton.textContent = armed2 ? "✂️ Armed — Esc to cancel" : "✂️ Arm scissors", armButton.disabled = armed2;
      }
    };
  }

  // scripts/wme-segment-scissors/src/ui/toast.js
  var PREFIX2 = "wmeSegmentScissors", STYLE_ID = `${PREFIX2}Styles`, TOAST_ID = `${PREFIX2}Toast`, ARMED_CLASS = `${PREFIX2}Armed`, LINGER_MS = { success: 2600, info: 2e3, warn: 6e3, error: 8e3 }, CSS = `
#${TOAST_ID} {
  position: fixed;
  top: 72px;
  left: 50%;
  transform: translateX(-50%) translateY(-8px);
  z-index: 10000;
  max-width: min(560px, 78vw);
  padding: 10px 16px;
  border-radius: 6px;
  font-size: 13px;
  line-height: 1.45;
  font-weight: 500;
  text-align: center;
  color: #fff;
  background: #3a4149;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
  pointer-events: none;
  opacity: 0;
  transition: opacity 140ms ease, transform 140ms ease;
}

#${TOAST_ID}.${PREFIX2}Visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

#${TOAST_ID}.${PREFIX2}Success { background: #1f8a4c; }
#${TOAST_ID}.${PREFIX2}Info    { background: #3a4149; }
#${TOAST_ID}.${PREFIX2}Warn    { background: #b26a00; }
#${TOAST_ID}.${PREFIX2}Error   { background: #b3261e; }

/* The map viewport while the tool is armed. The descendant selector and !important are both
   load-bearing: the element actually under the pointer belongs to whichever WME layer is on
   top, and it carries its own cursor, so setting the cursor on the viewport alone is
   invisible. */
.${ARMED_CLASS},
.${ARMED_CLASS} * {
  cursor: crosshair !important;
}
`, TONE_CLASS = {
    success: `${PREFIX2}Success`,
    info: `${PREFIX2}Info`,
    warn: `${PREFIX2}Warn`,
    error: `${PREFIX2}Error`
  };
  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    let style = document.createElement("style");
    style.id = STYLE_ID, style.textContent = CSS, document.head.appendChild(style);
  }
  function createToast() {
    injectStyles();
    let element = document.createElement("div");
    element.id = TOAST_ID, element.setAttribute("role", "status"), element.setAttribute("aria-live", "polite"), document.body.appendChild(element);
    let hideTimer = null;
    return {
      show({ text, tone = "info" }) {
        hideTimer && clearTimeout(hideTimer), element.textContent = text, element.className = `${PREFIX2}Visible ${TONE_CLASS[tone] ?? TONE_CLASS.info}`, hideTimer = setTimeout(() => {
          element.classList.remove(`${PREFIX2}Visible`), hideTimer = null;
        }, LINGER_MS[tone] ?? LINGER_MS.info);
      },
      hide() {
        hideTimer && clearTimeout(hideTimer), hideTimer = null, element.classList.remove(`${PREFIX2}Visible`);
      }
    };
  }
  function applyArmedCursor(viewport2, armed2) {
    viewport2 && viewport2.classList.toggle(ARMED_CLASS, armed2);
  }

  // scripts/wme-segment-scissors/src/main.js
  var STORAGE_KEY = "wme-segment-scissors_settings", SHORTCUT_ID = "wme-segment-scissors-arm", PREVIEW_LAYER = "wme-segment-scissors_preview", SHORTCUT_CANDIDATES = ["C", "S+C", "A+C", "AS+C"], DEFAULTS = {
    /** Stay armed after a successful cut, for chopping one road into several pieces. */
    continuousMode: !1,
    /** Select the two halves afterwards, so the result is visible and immediately editable. */
    selectAfterSplit: !0,
    /** Refuse a cut leaving a piece shorter than this. Our policy, not an SDK limit. */
    minPieceLengthM: 5
  }, sink = null, logger = createLogger("WME Segment Scissors", { sink }), settings = createSettingsStore({ storageKey: STORAGE_KEY, defaults: DEFAULTS, logger }), sdk, panel = null, toast = null, preview = null, shortcutKeys = null, cachedViewport, armed = !1, splitCount = 0;
  main();
  async function main() {
    try {
      sdk = await initWmeSdk({ scriptId: "wme-segment-scissors", scriptName: "WME Segment Scissors", logger });
    } catch (err) {
      logger.error("bootstrap failed", err);
      return;
    }
    settings.load();
    try {
      toast = createToast(), preview = createPreview({
        sdk,
        layerName: PREVIEW_LAYER,
        logger,
        // Read per frame rather than captured, so the preview's verdict tracks the setting
        // and can't drift out of step with what the click will decide.
        getMinPieceLengthM: () => settings.get("minPieceLengthM")
      }), await setupUi(), setupShortcut(), refreshStatus(), logger.log("v2026.07.25.001 ready");
    } catch (err) {
      logger.error("initialisation failed", err);
    }
  }
  async function setupUi() {
    let { tabPane } = await registerTab({
      sdk,
      label: "✂️",
      title: "WME Segment Scissors"
    });
    panel = buildPanel({
      settings,
      onSettingChange: (key, value) => settings.set(key, value),
      onArm: arm
    }), tabPane.appendChild(panel.element);
  }
  function setupShortcut() {
    shortcutKeys = registerArmingShortcut(sdk, {
      shortcutId: SHORTCUT_ID,
      description: "Split a segment where you click",
      callback: arm,
      candidateKeys: SHORTCUT_CANDIDATES,
      logger
    }), panel?.setShortcut(shortcutKeys);
  }
  async function arm() {
    if (armed) {
      logger.debug("already armed — ignoring, WME owns the next click");
      return;
    }
    setArmed(!0), toast?.show({ tone: "info", text: "Click a road to split it there. Esc to cancel." });
    let lastHalves = null;
    try {
      let again = !0;
      for (; again; ) {
        let outcome = await splitWhereClicked(sdk, {
          tolerances: { minPieceLengthM: settings.get("minPieceLengthM") },
          logger
        });
        report(outcome), outcome.ok && (splitCount += 1, lastHalves = outcome.newIds), again = outcome.ok && !!settings.get("continuousMode"), again && refreshStatus();
      }
    } catch (err) {
      logger.error("the split gesture failed unexpectedly", err), toast?.show(describeOutcome({ ok: !1, reason: "unexpected" }));
    } finally {
      setArmed(!1), lastHalves && settings.get("selectAfterSplit") && selectSegments(sdk, lastHalves, logger);
    }
  }
  function report(outcome) {
    let message = describeOutcome(outcome);
    toast?.show(message), outcome.ok ? logger.log(message.text) : logger.debug(`declined: ${outcome.reason}`, outcome);
  }
  function setArmed(value) {
    armed = value, panel?.setArmed(value), applyArmedCursor(viewport(), value), value ? preview?.start() : preview?.stop(), refreshStatus();
  }
  function refreshStatus() {
    panel?.setStatus(describeState({ armed, shortcutKeys, splitCount }));
  }
  function viewport() {
    if (cachedViewport !== void 0) return cachedViewport;
    try {
      cachedViewport = sdk.Map.getMapViewportElement();
    } catch (err) {
      logger.debug("no map viewport element available — skipping the armed cursor", err), cachedViewport = null;
    }
    return cachedViewport;
  }
})();