main_laptop_1 -> main byeonghap (4 hwangyeong 585 commits) #12
@@ -15,13 +15,32 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
/** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */
|
||||
const REPLAN_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
/** 사용자가 잡아 옮기는 제어점 하나 — 서버가 이 노드로 폴리라인을 만든다. */
|
||||
export interface RoutePlanNode {
|
||||
x: number;
|
||||
y: number;
|
||||
/** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */
|
||||
inner_angle_deg: number | null;
|
||||
/** 이 자리에 끼운 원호 반지름(m). 곡선을 안 둔 자리(내각 155° 이상)는 null. */
|
||||
radius_m: number | null;
|
||||
tangent_m: number | null;
|
||||
/** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
export interface RoutePlanResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
/** 예상노선(원본) 정점 [[x, y], …] — 사업지 좌표계(m). */
|
||||
/** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */
|
||||
expected: Array<[number, number]>;
|
||||
/** 계획노선(수정본). 고친 적이 없으면 예상노선과 같은 값. */
|
||||
/** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */
|
||||
planned: Array<[number, number]>;
|
||||
/** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */
|
||||
nodes: RoutePlanNode[];
|
||||
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
|
||||
min_radius_m: number;
|
||||
curve_count: number;
|
||||
violation_count: number;
|
||||
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
|
||||
edited: boolean;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
|
||||
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
|
||||
onStructureSelect: (structureId) => {
|
||||
// 고른 자리를 B06 으로 넘긴다 — 사이드 목록·3D 픽에는 있던 기록이 **알약에만
|
||||
// 빠져 있어** 종단에서 고른 구조물이 B06 횡단도에서 안 잡혔다(2026-09-06).
|
||||
writeStructurePick(activeProjectId, bridge.markChainage(structureId));
|
||||
const chainage = bridge.pipeMarkChainage(structureId);
|
||||
if (chainage !== null) {
|
||||
panel.structures.selectPipeByChainage(chainage);
|
||||
|
||||
@@ -157,6 +157,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
|
||||
}
|
||||
|
||||
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
|
||||
* 3D·사이드 목록과 마찬가지로 **종단 알약 선택도 B06 으로 이어져야** 해서 둔다
|
||||
* (2026-09-06: 알약만 `structure-pick` 을 안 써 B06 이 못 잡았음). */
|
||||
function markChainage(structureId: string | null): number | null {
|
||||
if (!structureId) return null;
|
||||
const pipe = pipeMarkChainage(structureId);
|
||||
if (pipe !== null) return pipe;
|
||||
const found = ownStructures.find((item) => item.structure_id === structureId);
|
||||
if (!found) return null;
|
||||
const chainage = found.chainage_m ?? found.start_m ?? null;
|
||||
return typeof chainage === "number" && Number.isFinite(chainage) ? chainage : null;
|
||||
}
|
||||
|
||||
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
|
||||
function pipeMarkChainage(structureId: string | null): number | null {
|
||||
if (!structureId?.startsWith("pipe-")) return null;
|
||||
@@ -345,6 +358,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
clearProjectedStations,
|
||||
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
|
||||
pipeMarkChainage,
|
||||
/** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */
|
||||
markChainage,
|
||||
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
|
||||
applyStructures,
|
||||
saveStructuresIfDirty,
|
||||
|
||||
@@ -76,7 +76,17 @@ export async function openRouteEditModal(
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
/** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */
|
||||
let plannedLine: Vertex[] = [];
|
||||
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
|
||||
let planned: Vertex[] = [];
|
||||
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
|
||||
let nodeInfo: Array<{
|
||||
radius_m: number | null;
|
||||
inner_angle_deg: number | null;
|
||||
violations: string[];
|
||||
}> = [];
|
||||
let minRadiusM = 0;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let sheets: PreparedLayer[] = [];
|
||||
let view: ViewState = {
|
||||
@@ -167,19 +177,31 @@ export async function openRouteEditModal(
|
||||
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
|
||||
1.6,
|
||||
);
|
||||
strokePolyline(planned, [], style.getPropertyValue("--map-route") || "#f97316", 2.4);
|
||||
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
|
||||
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
|
||||
strokePolyline(
|
||||
plannedLine.length ? plannedLine : planned,
|
||||
[],
|
||||
style.getPropertyValue("--map-route") || "#f97316",
|
||||
2.4,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
context.lineWidth = 1;
|
||||
for (const vertex of planned) {
|
||||
planned.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
|
||||
const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0;
|
||||
context.fillStyle = bad
|
||||
? style.getPropertyValue("--color-danger") || "#dc2626"
|
||||
: style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.beginPath();
|
||||
context.arc(x, y, NODE_R, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -218,6 +240,21 @@ export async function openRouteEditModal(
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다.
|
||||
* 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */
|
||||
function markEdited(): void {
|
||||
plannedLine = [];
|
||||
nodeInfo = [];
|
||||
}
|
||||
|
||||
/** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */
|
||||
function curveHint(): string {
|
||||
if (!nodeInfo.length) return minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : "";
|
||||
const bad = nodeInfo.filter((node) => node.violations.length).length;
|
||||
const curves = nodeInfo.filter((node) => node.radius_m !== null).length;
|
||||
return `곡선 ${curves}곳(R ${minRadiusM}m)${bad ? ` · 기준 미달 ${bad}곳` : ""}`;
|
||||
}
|
||||
|
||||
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
let dragNode = -1;
|
||||
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
@@ -238,6 +275,7 @@ export async function openRouteEditModal(
|
||||
const py = event.clientY - rect.top;
|
||||
if (dragNode >= 0) {
|
||||
planned[dragNode] = toMetric(px, py);
|
||||
markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다.
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
@@ -269,7 +307,8 @@ export async function openRouteEditModal(
|
||||
const segment = segmentAt(px, py);
|
||||
if (segment < 0) return;
|
||||
planned.splice(segment + 1, 0, toMetric(px, py));
|
||||
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다.`;
|
||||
markEdited();
|
||||
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다. ${curveHint()}`;
|
||||
draw();
|
||||
});
|
||||
|
||||
@@ -283,7 +322,8 @@ export async function openRouteEditModal(
|
||||
return;
|
||||
}
|
||||
planned.splice(index, 1);
|
||||
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다.`;
|
||||
markEdited();
|
||||
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다. ${curveHint()}`;
|
||||
draw();
|
||||
});
|
||||
|
||||
@@ -346,7 +386,18 @@ export async function openRouteEditModal(
|
||||
]);
|
||||
if (closed) return;
|
||||
expected = plan.expected as Vertex[];
|
||||
planned = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
|
||||
plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
|
||||
// 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다
|
||||
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
|
||||
const nodes = plan.nodes ?? [];
|
||||
planned = nodes.map((node) => [node.x, node.y] as Vertex);
|
||||
nodeInfo = nodes.map((node) => ({
|
||||
radius_m: node.radius_m,
|
||||
inner_angle_deg: node.inner_angle_deg,
|
||||
violations: node.violations ?? [],
|
||||
}));
|
||||
minRadiusM = plan.min_radius_m ?? 0;
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
const normalizer = createNormalizer(drainage.meta);
|
||||
sheets = drainage.layers
|
||||
@@ -367,7 +418,9 @@ export async function openRouteEditModal(
|
||||
view.height,
|
||||
);
|
||||
view = { ...view, ...fitted };
|
||||
status.textContent = `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "예상노선과 같음"}`;
|
||||
status.textContent =
|
||||
`노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
|
||||
curveHint();
|
||||
draw();
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
|
||||
|
||||
@@ -33,6 +33,18 @@ DUPLICATE_TOLERANCE_M = 0.5
|
||||
# 계단 모양만 걷어내고 실제 굴곡은 남긴다.
|
||||
SIMPLIFY_TOLERANCE_M = 4.0
|
||||
|
||||
# 남은 노드 사이가 이보다 멀면 그 구간만 더 촘촘히 다시 뽑는다(m).
|
||||
#
|
||||
# 왜 필요한가(2026-09-06 실측) — Douglas-Peucker 허용오차는 **절대 거리**라 굴곡이 완만하고
|
||||
# 길수록 통째로 삼켜진다. 4.5km 짜리 S자 노선에서 노드가 20개(간격 238m)·곡선 2곳만 남아
|
||||
# 「S자」가 사라졌다. 같은 4m 로 1.1km 노선은 노드 25개(간격 46m)·곡선 13곳으로 알맞았다.
|
||||
# 노선 길이로 허용오차를 바꾸면 짧고 급한 굴곡이 다시 뭉개지므로, **간격이 벌어진 구간만**
|
||||
# 골라 허용오차를 절반으로 낮춰 다시 뽑는다.
|
||||
MAX_NODE_SPACING_M = 100.0
|
||||
|
||||
# 위 되뽑기를 몇 겹까지 할지 — 겹마다 허용오차가 절반이 된다(4 → 2 → 1 → 0.5m).
|
||||
MAX_REFINE_DEPTH = 3
|
||||
|
||||
# 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다.
|
||||
ARC_STEP_DEG = 5.0
|
||||
|
||||
@@ -99,22 +111,74 @@ def dedupe_points(
|
||||
return cleaned
|
||||
|
||||
|
||||
def _perpendicular_distance(
|
||||
point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]
|
||||
) -> float:
|
||||
"""점에서 선분까지의 수직 거리(m). 선분이 한 점이면 그 점까지의 거리."""
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
if dx == 0 and dy == 0:
|
||||
return _distance(point, start)
|
||||
return abs(dy * point[0] - dx * point[1] + end[0] * start[1] - end[1] * start[0]) / math.hypot(
|
||||
dx, dy
|
||||
)
|
||||
|
||||
|
||||
def _douglas_peucker(points: list[tuple[float, float]], tolerance_m: float) -> list[int]:
|
||||
"""남길 점의 **원본 색인**을 돌려준다 — 되뽑기가 원본 구간을 다시 꺼내야 해서 색인이다."""
|
||||
if len(points) < 3:
|
||||
return list(range(len(points)))
|
||||
keep = {0, len(points) - 1}
|
||||
stack = [(0, len(points) - 1)]
|
||||
while stack:
|
||||
first, last = stack.pop()
|
||||
if last <= first + 1:
|
||||
continue
|
||||
worst, worst_index = -1.0, first
|
||||
for index in range(first + 1, last):
|
||||
gap = _perpendicular_distance(points[index], points[first], points[last])
|
||||
if gap > worst:
|
||||
worst, worst_index = gap, index
|
||||
if worst > tolerance_m:
|
||||
keep.add(worst_index)
|
||||
stack.append((first, worst_index))
|
||||
stack.append((worst_index, last))
|
||||
return sorted(keep)
|
||||
|
||||
|
||||
def simplify_to_nodes(
|
||||
points: list[tuple[float, float]], tolerance_m: float = SIMPLIFY_TOLERANCE_M
|
||||
points: list[tuple[float, float]],
|
||||
tolerance_m: float = SIMPLIFY_TOLERANCE_M,
|
||||
max_spacing_m: float = MAX_NODE_SPACING_M,
|
||||
depth: int = MAX_REFINE_DEPTH,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker.
|
||||
"""조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker + 벌어진 구간 되뽑기.
|
||||
|
||||
예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울
|
||||
접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는
|
||||
선에서 점을 걷어내므로 **모양은 그대로**다.
|
||||
|
||||
남은 노드 사이가 `max_spacing_m` 을 넘으면 **그 구간만** 허용오차를 절반으로 낮춰 다시
|
||||
뽑는다 — 완만하고 긴 굴곡이 통째로 삼켜지는 것을 막는다(2026-09-06 S자 노선 실측).
|
||||
"""
|
||||
if len(points) < 3:
|
||||
return list(points)
|
||||
from shapely.geometry import LineString
|
||||
kept = _douglas_peucker(points, tolerance_m)
|
||||
if depth <= 0 or max_spacing_m <= 0:
|
||||
return [points[index] for index in kept]
|
||||
|
||||
simplified = LineString(points).simplify(tolerance_m, preserve_topology=False)
|
||||
result = [(float(x), float(y)) for x, y in simplified.coords]
|
||||
return result if len(result) >= 2 else list(points)
|
||||
result: list[tuple[float, float]] = [points[kept[0]]]
|
||||
for previous, current in zip(kept, kept[1:]):
|
||||
if _distance(points[previous], points[current]) > max_spacing_m and current > previous + 1:
|
||||
refined = simplify_to_nodes(
|
||||
points[previous : current + 1],
|
||||
tolerance_m / 2,
|
||||
max_spacing_m,
|
||||
depth - 1,
|
||||
)
|
||||
result.extend(refined[1:])
|
||||
else:
|
||||
result.append(points[current])
|
||||
return result
|
||||
|
||||
|
||||
def _inner_angle_deg(
|
||||
@@ -207,6 +271,11 @@ def build_planned_polyline(
|
||||
|
||||
# 접선이 들어갈 자리 — 앞뒤 구간을 이웃 곡선과 나눠 쓰므로 절반까지만 쓴다.
|
||||
available = min(_distance(before, at), _distance(at, after)) / 2
|
||||
# 반지름은 **법정 하한 그대로** 쓴다 — 사용자가 「지식DB 의 R 기준값으로 선을 만들라」고
|
||||
# 한 그 값이고, 기하로도 하한이 원본에 가장 가깝다: 중앙종거 M = R(1/cos(Δ/2) − 1) 이라
|
||||
# R 이 클수록 원호가 꺾임점에서 멀어진다. 원본 굴곡에서 R 을 재어 키우는 방법도 재 봤으나
|
||||
# (2026-09-06) 직각 꺾임에서 R 70m·중앙종거 20m 가 나와 원본과 크게 어긋났다.
|
||||
# 곡선마다 R 을 다르게 주는 것은 사용자가 노드에서 고르는 별도 기능으로 둔다(PLAN 0-10).
|
||||
radius = min_radius_m
|
||||
tangent = radius * half_tan
|
||||
if tangent > available:
|
||||
|
||||
Reference in New Issue
Block a user