Greasy Fork is available in English.
Splits a segment in one step rather than drawing a throwaway segment across the road and deleting it.
// ==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">
</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;
}
})();