feat: add native road compiler provider

This commit is contained in:
2026-08-14 15:19:57 +08:00
parent e1f3fc10ca
commit 65cf8b96d9
14 changed files with 385 additions and 83 deletions

View File

@@ -117,6 +117,7 @@ function normalizeAreaConfig(raw, options = {}) {
blender: {
treeStyle: raw.blender?.treeStyle || "natural",
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
roadProvider: roadProviderOption(raw.blender?.roadProvider),
},
compress,
budget,
@@ -172,6 +173,14 @@ function numberOption(value, fallback, label, min, max) {
return number;
}
function roadProviderOption(value) {
const provider = value ?? "osm2streets";
if (provider !== "osm2streets" && provider !== "native") {
throw new Error("blender.roadProvider must be \"osm2streets\" or \"native\".");
}
return provider;
}
function integerOption(value, fallback, label) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isInteger(number) || number < 1) {

View File

@@ -36,12 +36,19 @@ function compileRoadModel(xml, overrides) {
const roads = [];
const endpoints = [];
const byNode = new Map();
for (const way of parsed.ways) {
const sharedNodeWayIds = new Map();
for (const way of parsed.ways) for (const nodeId of new Set(way.refs)) {
if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set());
sharedNodeWayIds.get(nodeId).add(way.id);
}
for (const sourceWay of parsed.ways) {
const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds);
for (const way of segments) {
const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"];
for (const direction of directions) {
const base = roadAttributes(way.tags, direction);
const id = `road:way/${way.id}:${direction}`;
const road = { id, osmWayIds: [way.id], direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
const id = `road:way/${way.id}${way.segmentIndex === null ? "" : `:segment/${way.segmentIndex}`}:${direction}`;
const road = { id, osmWayIds: [way.id], segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`, sourceRoadId: `road:way/${way.id}:${direction}`, direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
applyRoadOverrides(road, overrides, diagnostics);
roads.push(road);
for (const side of ["start", "end"]) {
@@ -52,6 +59,7 @@ function compileRoadModel(xml, overrides) {
byNode.get(nodeId).push(endpoint);
}
}
}
}
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
const extent = roadExtent(roads);
@@ -64,6 +72,17 @@ function compileRoadModel(xml, overrides) {
return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics };
}
function splitWayAtSharedNodes(way, sharedNodeWayIds) {
const splitIndexes = [0];
for (let index = 1; index < way.refs.length - 1; index += 1) if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index);
splitIndexes.push(way.refs.length - 1);
if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }];
return splitIndexes.slice(1).map((end, index) => {
const start = splitIndexes[index];
return { ...way, refs: way.refs.slice(start, end + 1), coords: way.coords.slice(start, end + 1), segmentIndex: index + 1 };
});
}
function roadExtent(roads) {
const points = roads.flatMap((road) => road.centerline);
return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) };
@@ -113,7 +132,7 @@ function loadOverrides(file) {
function validateOverrides(value, model) {
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
const ids = new Set();
const roadIds = model ? new Set(model.roads.map((road) => road.id)) : null;
const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null;
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null;
for (const item of value.overrides) {
@@ -134,7 +153,11 @@ function validateOverrides(value, model) {
}
function applyRoadOverrides(road, overrides, diagnostics) {
for (const item of overrides.overrides.filter((entry) => entry.kind === "road" && entry.roadId === road.id)) {
const matching = overrides.overrides.filter((entry) => entry.kind === "road" && (entry.roadId === road.sourceRoadId || entry.roadId === road.id));
// A legacy whole-way edit remains the baseline; a segment-specific edit can
// deliberately refine it after the compiler has introduced split segments.
matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id));
for (const item of matching) {
for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key];
road.appliedOverrideIds.push(item.id);
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`;
@@ -172,8 +195,9 @@ function resolveConnections(endpoints, byNode, overrides, diagnostics) {
function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; }
function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
const wayId = (roadId) => roadId.split(":")[1];
return wayId(firstRoadId) === wayId(secondRoadId);
const roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId;
const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, "");
return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || secondRoadId);
}
function connectionEndpointsCompatible(model, fromId, toId) {
const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
@@ -191,31 +215,36 @@ function nearbyManualCandidates(endpoints, from) {
function compileGeometry(model, overrides = { overrides: [] }) {
const diagnostics = [...model.diagnostics];
const junctionPlans = compileJunctionPlans(model);
const features = [];
const emittedWays = new Set();
const emittedSegments = new Set();
for (const road of model.roads) {
const wayKey = road.osmWayIds.join(",");
if (emittedWays.has(wayKey)) continue;
emittedWays.add(wayKey);
const directions = model.roads.filter((item) => item.osmWayIds.join(",") === wayKey);
const segmentKey = road.segmentId;
if (emittedSegments.has(segmentKey)) continue;
emittedSegments.add(segmentKey);
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
// Road and junction asphalt share one final material. Keep the carriageway
// continuous through the semantic junction overlay; cutting it back creates
// visible wedges/gaps without improving the rendered result.
const ring = roadRing(road.centerline, totalWidth);
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`;
features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
const lanes = compileLaneCenterlines(model, diagnostics);
const sidewalks = compileSidewalkSurfaces(model, diagnostics);
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
const junctionFeatures = compileJunctionSurfaces(model, lanes, connectorResult.features, connectorResult.movements, diagnostics);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
}
function compileSidewalkSurfaces(model, diagnostics) {
function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
const features = [];
const byWay = new Map();
for (const road of model.roads) {
const key = road.osmWayIds.join(",");
const key = road.segmentId;
if (!byWay.has(key)) byWay.set(key, []);
byWay.get(key).push(road);
}
@@ -229,14 +258,85 @@ function compileSidewalkSurfaces(model, diagnostics) {
];
for (const [side, enabled] of sides) {
if (!enabled) continue;
const ring = sidewalkRing(forward.centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1);
const centerline = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1);
if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; }
features.push({ type: "Feature", properties: { native_id: `sidewalk:way/${wayKey}:${side}`, osm_way_ids: wayKey, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`;
features.push({ type: "Feature", properties: { native_id: sidewalkId, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
features.push(...compileSidewalkCorners(model, junctionPlans));
return features;
}
function compileSidewalkCorners(model, junctionPlans) {
const result = [];
for (const [nodeId, plan] of junctionPlans) {
const candidates = [];
for (const approach of plan.approaches) {
const directions = model.roads.filter((road) => road.segmentId === approach.segmentId);
const forward = directions.find((road) => road.direction === "forward") || directions[0];
if (!forward) continue;
const outwardIsForward = forward.sourceNodeIds[0] === nodeId;
const sideStates = outwardIsForward
? { left: forward.sidewalkLeft, right: forward.sidewalkRight }
: { left: forward.sidewalkRight, right: forward.sidewalkLeft };
const cutback = pointAlongLine(approach.line, plan.cutbackMeters);
if (!cutback) continue;
const heading = headingAtEndpoint(approach.line);
const halfWidth = approach.widthMeters / 2;
for (const [side, enabled] of Object.entries(sideStates)) {
if (!enabled) continue;
// offsetLine's positive normal is driver's left, which is heading -90
// in this north-based heading convention.
const sideHeading = heading + (side === "left" ? -90 : 90);
candidates.push({
wayKey: approach.segmentId,
sourceWayKey: forward.osmWayIds.join(","),
side,
normalDegrees: sideHeading,
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
});
}
}
candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb));
for (let index = 0; index < candidates.length; index += 1) {
const first = candidates[index];
const second = candidates[(index + 1) % candidates.length];
if (first.wayKey === second.wayKey) continue;
const ring = [first.curb, first.outer, second.outer, second.curb, first.curb];
if (hasSelfIntersection(ring)) continue;
if (first.sourceWayKey === second.sourceWayKey && (!samePhysicalSide(first, second) || cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches))) continue;
result.push({
type: "Feature",
properties: {
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
osm_node_id: nodeId,
kind: "corner",
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
provenance: "native-road-sidewalk-corner/v1",
},
geometry: { type: "Polygon", coordinates: [ring] },
});
}
}
return result;
}
function samePhysicalSide(first, second) {
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
return Math.cos(radians) >= 0.98;
}
function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
const center = ring.slice(0, -1).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]);
return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => {
const carriageway = roadRing(approach.line, approach.widthMeters);
return carriageway && pointInPolygon(center, carriageway);
});
}
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
const junctionByNode = new Map(junctionFeatures.map((feature) => [feature.properties.osm_node_id, feature]));
for (const connector of connectors) {
@@ -265,13 +365,13 @@ function pointOnSegment(point, a, b) {
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
}
function compileLaneCenterlines(model, diagnostics) {
function compileLaneCenterlines(model, diagnostics, junctionPlans) {
const features = [];
const byRoadId = new Map();
for (const road of model.roads) {
const lanes = [];
const laneWidth = road.widthMeters / road.laneCount;
const siblings = model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
const siblings = model.roads.filter((item) => item.segmentId === road.segmentId);
const opposite = siblings.find((item) => item.id !== road.id);
// OSM centerline is the shared carriageway center. On a two-way road,
// offset each directed carriageway to its own side before placing lanes.
@@ -280,7 +380,7 @@ function compileLaneCenterlines(model, diagnostics) {
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
// driver's left so tag positions and generated lane IDs have one meaning.
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
const coordinates = offsetLine(road.centerline, offset);
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
lanes.push(lane);
@@ -381,20 +481,10 @@ function offsetLine(line, offsetMeters) {
function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostics) {
const byNode = new Map();
for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
byNode.get(endpoint.nodeId).push(endpoint);
}
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) {
const result = [];
for (const [nodeId, endpoints] of byNode) {
const wayIds = new Set(endpoints.map((endpoint) => endpoint.roadId.split(":")[1]));
if (wayIds.size < 3 || wayIds.size > 4) continue;
const node = endpoints[0].coordinate;
const approaches = junctionApproaches(model, endpoints);
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const boundary = junctionBoundary(approaches, node, cutbackMeters);
for (const [nodeId, plan] of junctionPlans) {
const { segmentIds, node, approaches, cutbackMeters, boundary } = plan;
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
if (boundary.length < 3 || !junctionMovements.length) {
@@ -412,24 +502,45 @@ function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostic
diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
continue;
}
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, rule: "junction-approach-envelope/v2" }, geometry: { type: "Polygon", coordinates: [ring] } });
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
}
return result;
}
function compileJunctionPlans(model) {
const byNode = new Map();
for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
byNode.get(endpoint.nodeId).push(endpoint);
}
const plans = new Map();
for (const [nodeId, endpoints] of byNode) {
const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, "")));
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
const approaches = junctionApproaches(model, endpoints);
if (approaches.length !== segmentIds.size) continue;
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const node = endpoints[0].coordinate;
const boundary = junctionBoundary(approaches, node, cutbackMeters);
if (boundary.length < 3) continue;
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary });
}
return plans;
}
function junctionApproaches(model, endpoints) {
const groups = new Map();
for (const endpoint of endpoints) {
const road = model.roads.find((item) => item.id === endpoint.roadId);
if (!road) continue;
const key = road.osmWayIds.join(",");
const key = road.segmentId;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push({ endpoint, road });
}
return [...groups.values()].map((directions) => {
const { endpoint, road } = directions[0];
return { line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
return { segmentId: road.segmentId, sourceWayKey: road.osmWayIds.join(","), line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
});
}
@@ -456,9 +567,31 @@ function pointAlongLine(line, meters) {
return line.at(-1);
}
function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0;
const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0;
if (!startCutback && !endCutback) return line;
const total = lineLengthMeters(line);
// Short OSM fragments cannot safely lose both ends. Keep their source
// geometry intact and let the junction diagnostic surface the ambiguity.
if (startCutback + endCutback >= total - 0.5) return line;
const result = [];
let traversed = 0;
const start = pointAlongLine(line, startCutback);
const end = pointAlongLine(line, total - endCutback);
result.push(start);
for (let index = 1; index < line.length - 1; index += 1) {
traversed += distanceMeters(line[index - 1], line[index]);
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
}
result.push(end);
return result;
}
function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); }
function convexHull(points) {
const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);