Merge remote-tracking branch 'origin/안티그래비티' into 안티그래비티

This commit is contained in:
2026-10-20 14:16:51 +09:00
9 changed files with 276 additions and 9 deletions
+3
View File
@@ -176,6 +176,7 @@ def followed_pipes(
"""관 따라감 + 새 세류선 교차점 기본 관(저장 안 함) → (짝, 교차 수, 새 노선 정점) — 체인 ·
추천 초기 종단(476) 한 벌."""
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
from B04_PreProcess.B04_PreProcess_Router_Basins import _apply_new_defaults
from common_util.common_util_drainage_pipes import fill_pipe_coordinates
vertices = vertices_of([(p["x"], p["y"]) for p in points])
@@ -183,6 +184,8 @@ def followed_pipes(
pairs = follow_pipes(old_pipes, vertices)
total = float(vertices[-1].chainage_m)
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
# 새 교차 관만 B04 와 같은 등록부 기본값(54-39 · 494-2) — 옛 관 빈칸은 미확정 그대로
_apply_new_defaults([pipe for index, pipe in pairs if index < 0])
return pairs, crossing, vertices
+14 -5
View File
@@ -30,6 +30,7 @@ import type {
} from "../A00_Common/design/logic/design_pipeline_types";
import {
nearestWithin,
readSelection,
selectDesign,
subscribeSelection,
type DesignSelection,
@@ -316,8 +317,13 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
stopPick();
};
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */
function showStation(key: string | null, chainageM: number, structure = false): void {
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄(`open`). */
function showStation(
key: string | null,
chainageM: number,
structure = false,
open = true,
): void {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, interval());
@@ -326,11 +332,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
shown = null;
redraw();
const panels = sidePanelsOf.get(params.side);
if (panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
if (open && panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
}
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드 · 평면 마커) 고름 → 그 측점 횡단(구조물 · 관도 · 411-A). */
function follow(selection: DesignSelection): void {
function follow(selection: DesignSelection, open = true): void {
attach();
const at = selection.chainageM;
if (at === null) {
@@ -343,12 +349,15 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
}
const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM);
const structure = !!selection.structureId || (!!station && !isGridStation(station));
showStation(station?.key ?? null, station?.chainageM ?? at, structure);
showStation(station?.key ?? null, station?.chainageM ?? at, structure, open);
}
const stopPick = subscribeSelection(params.projectId, (selection, from) => {
if (from !== PLAN_PICK_FROM) follow(selection);
});
attach();
// 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄
const picked = readSelection(params.projectId);
if (picked) follow(picked, false);
/** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */
const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => {
+9 -1
View File
@@ -127,16 +127,24 @@ export function staleKey(
.join("|");
}
/** 화면 밖 카드 줄이 쉬는 틈을 기다리는 한도(ms). */
const IDLE_TIMEOUT_MS = 200;
/**
* 쉬는 틈 일거리 줄(346-2) — 화면 밖 횡단 카드를 몇 장씩 갈아 끼움(카드 하나 ≈ 8 ms · 0.54 s / 66).
* 줄에 든 측점은 `run` 이 꺼내 그림 · 통째로 다시 그리면 `queue.clear()`.
* 쉬는 틈이 안 오는 창(ORCA 내장 · 가려진 창)도 늦어도 `IDLE_TIMEOUT_MS` 에 그림 — 안 그러면 새 구조물 측점
* 카드가 빈 설계 그대로 남음(494-1).
*/
export function createIdleQueue(run: (chainageM: number) => void, batch = 6) {
const queue = new Set<number>();
let waiting = 0;
const drain = (): void => {
if (waiting || !queue.size) return;
const idle = window.requestIdleCallback ?? ((next: () => void) => window.setTimeout(next, 16));
const idle = (next: () => void): number =>
window.requestIdleCallback
? window.requestIdleCallback(next, { timeout: IDLE_TIMEOUT_MS })
: window.setTimeout(next, 16);
waiting = idle(() => {
waiting = 0;
for (const chainageM of [...queue].slice(0, batch)) {
@@ -32,3 +32,24 @@
## 요구 290 (10-20 · 사용자 · 289 물음 답)
1. 메인 화면에서 앞 측점 방향의 곡선을 변경하면 뒤에있는 구조물이나 일반, 곡선 측점은 자연스럽게 해당 로직에 맞춰 자동이동이됨. 구조물의 경우에는 최대한 평면도 상의 위치를 고수하기 위한 근접 위치로 옮겨갈거고 일반측점과 곡선측점은 거리값을 지키려고 할거야. 지금 그렇게 반영되어있을텐데 물어보는 이유는?
## 요구 291 (10-20 · 사용자 · 492-3 찌꺼기 정리)
1. 찌꺼기삭제는 너가 직접 워크트리 에이전트가 아닌 클로드 에이전트 기능으로 실행
## 요구 292 (10-20 · 사용자 · 추가 수정)
1. 추가 수정 지금 b05 b06의 구조물 측점을 선택할때 나오는 횡단도의 구조물 형상 연결기능 확인. 횡단도 구조물 형상 안보임
## 요구 293 (10-20 · 사용자 · 292 에 덧붙임 · 데이터 흐름)
1. m02의 구조물조합을 연결하여 나중에 수량산출이나 내역서로 넘어가지만 형상 자체는 별개라는 말이야.
2. 측점에 연결된구조물 조합에서 도면 정보는 b07의 구조물도로 연결되어야하는거야. 이런 데이터 흐름은 벌써 이전 자료를 찾아보면 쉬워.
## 요구 294 (10-20 · 사용자 · 292 이어서)
1. 검증_브레인232 프로젝트 확인
## 요구 295 (10-20 · 사용자 · 494-2 기존 빈 관 물음 답)
1. 지금 진행중인 조사건들을 완료 후 빈채로 있는것들은 그냥 노선 추천에서 다른 항목으로 변경하여 저장하면 되는거 아니야,
@@ -36,6 +36,8 @@ global.ResizeObserver = class {
};
let listener = null;
/** 들어올 때 이미 고른 것(`readSelection` · 494-1). */
let stored = null;
let stations = [];
const route = { id: 1 };
const subscribers = [];
@@ -57,6 +59,7 @@ const STUBS = {
if (Math.abs(of(item) - at) < gap) [best, gap] = [item, Math.abs(of(item) - at)];
return best;
},
readSelection: () => stored,
selectDesign() {},
subscribeSelection: (_projectId, fn) => ((listener = fn), () => {}),
},
@@ -157,4 +160,19 @@ out.latchedMoved = shownNow();
// ⑦ 다른 보기가 풂(null · b05) = 판 비움(382-2 그대로)
pick(null, null, "b05");
out.otherRelease = { ...shownNow(), watched: preview.watched() };
// ⑧ 고른 구조물을 들고 들어옴(494-1) — 판을 만들자마자 그 측점 횡단(빈 판 아님)
stored = { chainageM: 45, structureId: "A" };
stations = [...grid, station("structure:A", 45, "structure", "A")];
const entrySide = fakeElement();
const entry = cross.createCrossPreview({
projectId: "p1",
side: entrySide,
request: () => ({ vertices: [], min_radius_m: 15, station_interval_m: 20 }),
});
const entryPane = entrySide.children[0];
out.entryPick = {
station: entryPane.querySelector(".b05-routeedit__cross-station").textContent,
foot: entryPane.querySelector(".b05-routeedit__cross-foot").textContent,
structure: entry.watched()?.structure ?? null,
};
console.log(JSON.stringify(out));
@@ -0,0 +1,51 @@
/* PLAN 494-1 단위검증 헬퍼 — 화면 밖 횡단 카드 줄(`createIdleQueue`)이 쉬는 틈이 안 와도 그려지는가.
* `B06_Section_UI_Section_Common.ts` 에서 그 함수만 꺼내 트랜스파일해 Node 로 돌린다(다른 import 안 끎).
* 가짜 창 = 쉬는 틈이 영영 안 오는 브라우저(ORCA 내장 · 가려진 창) — 시간 제한을 준 부탁만 그 시간에 불림.
* 결과는 표준출력에 JSON 한 벌.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "B06_Section", "B06_Section_UI_Section_Common.ts");
const src = fs.readFileSync(file, "utf-8").split("\r\n").join("\n");
const body = src.match(/export function createIdleQueue[\s\S]*?\n}\n/);
if (!body) throw new Error("createIdleQueue 못 찾음");
// 함수가 쓰는 파일 머리 상수(대문자 이름 한 줄)
const consts = [...src.matchAll(/^const [A-Z_]+ = [^;\n]+;$/gm)].map((m) => m[0]).join("\n");
const js = ts.transpileModule(`${consts}\n${body[0]}`, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
let now = 0;
const timers = []; // { at, fn }
const window = {
requestIdleCallback: (fn, options) => {
// 쉬는 틈 없음 — 시간 제한이 있을 때만 그 시간에 부름(didTimeout)
if (options && Number.isFinite(options.timeout))
timers.push({
at: now + options.timeout,
fn: () => fn({ didTimeout: true, timeRemaining: () => 0 }),
});
return timers.length + 1;
},
setTimeout: (fn, ms) => (timers.push({ at: now + (ms || 0), fn }), timers.length + 1),
};
const mod = { exports: {} };
new Function("module", "exports", "window", js)(mod, mod.exports, window);
const { createIdleQueue } = mod.exports;
const ran = [];
const q = createIdleQueue((m) => ran.push(m), 2);
[145, 150, 155, 160, 165].forEach((m) => q.queue.add(m));
q.drain();
// 2 초 흐름 — 그 사이 불릴 것 모두 부름
for (;;) {
timers.sort((a, b) => a.at - b.at);
const next = timers[0];
if (!next || next.at > 2000) break;
timers.shift();
now = next.at;
next.fn();
}
process.stdout.write(JSON.stringify({ ran, left: [...q.queue], elapsedMs: now }));
@@ -12,7 +12,7 @@
⑥ 추천 [적용] 실패 — 초기값 사본(`initial_snapshot`)까지 되돌림
⑦ 떠 두기 중 뒤 미리 셈이 파일을 지움(`_derived` 원자 쓰기 · 정리) — 건너뜀(옛 = 500
FileNotFoundError)
492 코드 = ① ④(손 고침) ⑤ 실패 · 옛 떠 두기 = ⑦ 실패.
492 코드 = ① ④(손 고침) ⑤ 실패 · 옛 떠 두기 = ⑦ 실패 · 새 교차 관 = B04 새 관 기본값(494-2).
체인 · API 는 346-3 가짜 세계(`chain_world` · `api_world`)를 빌려 씀 — 파일은 임시 폴더만.
@@ -43,8 +43,10 @@ import B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze as analyze # noqa
import B05_Profile.B05_Profile_Route_Save as save # noqa: E402
import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402
import B06_Section.B06_Section_Repository as b06_repo # noqa: E402
from B04_PreProcess.B04_PreProcess_Router_Basins import _apply_new_defaults # noqa: E402
from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations, save_plus_stations # noqa: E402
from B05_Profile.B05_Profile_Structures_Repository import load_structures # noqa: E402
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
from common_util.common_util_json import atomic_write_json # noqa: E402
FIXTURE = HERE / "fixtures" / "req283_sub8_route.json"
@@ -70,6 +72,13 @@ WALL = {
CROSSING = SimpleNamespace(chainage_m=158.31, x=150.0, y=30.0)
def _new_pipe_options() -> dict:
"""새 교차 관 옵션 = B04 유역 길의 새 관 기본값(494-2 · 54-39)."""
pipe = PipePoint(chainage_m=0.0, source="stream")
_apply_new_defaults([pipe])
return pipe.options
def _side(xy, x: float, y: float) -> float:
from shapely.geometry import LineString, Point
@@ -137,7 +146,7 @@ def _assert_reset(world, result: dict, cross_seen: list) -> None:
assert load_plus_stations(world.root) == []
points = json.loads(world.pipes_file.read_text("utf-8"))["points"]
assert len(points) == 1, points # 옛 사용자 관 버림 · 새 교차 관만
assert points[0]["source"] == "stream" and not points[0].get("options")
assert points[0]["source"] == "stream" and points[0]["options"] == _new_pipe_options()
assert _side(base.NEW, points[0]["x"], points[0]["y"]) < 1e-6
assert "profile" not in world.order # 계획고 옮김 없음 = 1차 계획선
assert cross_seen == [[]] # 횡단 손 입력 안 넘김
@@ -257,7 +266,7 @@ def test_검증_sub8_추천_적용은_교차_하나(sub8, chain_world, cross_see
points = json.loads(chain_world.pipes_file.read_text("utf-8"))["points"]
assert [round(p["chainage_m"]) for p in points] == [388]
assert _side(sub8["new_xy"], points[0]["x"], points[0]["y"]) < 0.01
assert not points[0].get("options")
assert points[0]["options"] == _new_pipe_options() # 494-2 새 교차 관 = 기본값
assert result["route_reset"]["dropped"]["pipes"] == 1
+53
View File
@@ -0,0 +1,53 @@
"""PLAN 494-1 — B06 화면 밖 횡단 카드가 쉬는 틈이 안 와도 다시 그려지는가.
구조물을 놓으면 새 측점 카드가 빈 설계로 먼저 서고, 다시 셈 뒤 화면 밖 카드는 쉬는 틈 줄
(`createIdleQueue`)에서 갈아 끼움. 쉬는 틈이 안 오는 창(ORCA 내장 · 가려진 창)에서는 줄이 영영 안
비어 설계선 · 구조물 형상이 없는 카드가 남았음. 짝: `B06_Section/B06_Section_UI_Section_Common.ts`.
실제 TS 를 `helper_494_idle_queue.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
HERE = os.path.dirname(__file__)
def _run() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_494_idle_queue.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_쉬는_틈이_없어도_줄의_카드를_모두_그린다() -> None:
out = _run()
assert out["ran"] == [145, 150, 155, 160, 165]
assert out["left"] == []
def test_늦어도_1초_안에_다_그린다() -> None:
"""카드 다섯 · 두 장씩 = 세 번 — 시간 제한 안에 끝남."""
assert _run()["elapsedMs"] <= 1000
def test_B05_평면_횡단판은_들어올_때_이미_고른_측점을_그린다() -> None:
"""B05 「횡단」 판 — 고른 구조물을 들고 들어오면 빈 판이었음(다시 누르면 고름이 풀림).
`_RouteEdit_Cross` 진짜를 `helper_411_a_cross_structure.cjs` 가 돌림(⑧)."""
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_411_a_cross_structure.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
got = json.loads(proc.stdout)["entryPick"]
assert got == {"station": "45", "foot": "횡단 45", "structure": True}
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""494-2 노선 바뀜 길의 새 세류선 교차 관 = B04 와 같은 등록부 기본값(54-39).
못박는 것
- 손 고침 [저장] · 추천 [적용](체인 `_place_pipes`) · 추천 초기 종단(`reset_facilities`) 셋 다
`followed_pipes` 를 지나 새 교차 관이 B04 `_apply_new_defaults` 와 같은 옵션을 받음.
- 이미 저장된 빈 관 · 사용자가 옵션을 고른 관은 그대로(10-05 「기존 빈칸 = 미확정」).
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze as analyze # noqa: E402
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402
import B05_Profile.B05_Profile_Engine_InitialProfile as initial_profile # noqa: E402
import B05_Profile.B05_Profile_Route_Save as save # noqa: E402
import common_util.common_util_drainage_pipes as drainage_pipes # noqa: E402
from B04_PreProcess.B04_PreProcess_Router_Basins import _apply_new_defaults # noqa: E402
from common_util.common_util_drainage_pipes import PIPE_SOURCE_STREAM, PipePoint # noqa: E402
OLD = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0), (400.0, 0.0)]
NEW = [(0.0, 0.0), (100.0, 0.0), (200.0, 60.0), (300.0, 0.0), (400.0, 0.0)]
# 새 노선 북쪽 휨 위(옛 노선에서 먼) 세류선 교차 하나
CROSSING = SimpleNamespace(chainage_m=150.4, x=150.0, y=30.0)
def _b04_defaults() -> dict:
"""B04 유역 길이 새 관에 주는 옵션 — 비교 기준."""
pipe = PipePoint(chainage_m=150.0, source=PIPE_SOURCE_STREAM)
_apply_new_defaults([pipe])
return pipe.options
def _old_pipes() -> list[PipePoint]:
return [
PipePoint(chainage_m=50.0, x=50.0, y=0.0), # 저장된 빈 관 — 미확정 그대로
PipePoint(chainage_m=350.0, x=350.0, y=0.0, options={"pipe_kind": "흄관"}), # 사용자 고름
]
def _points(xy) -> list[dict[str, float]]:
return [{"x": x, "y": y} for x, y in xy]
def _fake_crossings(monkeypatch) -> None:
monkeypatch.setattr(analyze, "_base_pipes", lambda vertices, streams: [CROSSING])
def _check(pipes: list[PipePoint], kept: bool) -> None:
new = [pipe for pipe in pipes if abs(pipe.chainage_m - 150.0) < 1]
assert len(new) == 1 and new[0].options == _b04_defaults()
assert new[0].options.get("pipe_diameter_mm") # 관경 빈칸 아님(횡단 관 그림 · 54-39)
if kept: # 누가거리 순 = 빈 관 · 새 관 · 사용자 관
assert [pipe.options for pipe in pipes[::2]] == [None, {"pipe_kind": "흄관"}]
def test_손_고침_저장_followed_pipes(monkeypatch):
_fake_crossings(monkeypatch)
old = _old_pipes()
pairs, crossing, _ = save.followed_pipes(old, [list(p) for p in OLD], _points(NEW), [])
assert crossing == 1
_check([pipe for _, pipe in pairs], kept=True)
assert old[0].options is None # 옛 목록 그대로
def test_추천_적용_체인_관_저장(monkeypatch):
"""체인 [적용](492 초기화) — 옛 관 버림 · 새 교차 관 = 기본값으로 정본 저장."""
_fake_crossings(monkeypatch)
written: list = []
async def prepare(_project_id):
return {"streams": []}
monkeypatch.setattr(watershed, "_prepare", prepare)
monkeypatch.setattr(
drainage_pipes, "save_pipe_points", lambda path, sig, pipes, verts: written.extend(pipes)
)
pairs, counts = asyncio.run(save._place_pipes(None, "unused", [], [], _points(NEW)))
assert counts["crossing"] == 1
_check(written, kept=False)
def test_추천_초기_종단_reset_facilities(monkeypatch):
_fake_crossings(monkeypatch)
pipes, structures, plus = initial_profile.reset_facilities(NEW, [])
assert structures == [] and plus == []
_check(pipes, kept=False)