Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-09-29 12:01:34 +09:00
38 changed files with 1079 additions and 290 deletions
+11 -1
View File
@@ -2,7 +2,7 @@
* B05_Profile_Api_RouteInitial.ts
* 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다.
*
* POST /projects/{id}/route/initial { mode, weights? } → 노드 · 비교값 · 공통 제약 기준값
* POST /projects/{id}/route/initial { mode, weights?, criteria? } → 노드 · 비교값 · 공통 제약 기준값
*
* 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고,
* 받은 비교값·기준값을 보이기만 한다. 모양은 계획서 20 「API」 줄(서버 `initial_route`) —
@@ -24,9 +24,19 @@ export interface RouteInitialWeights {
grade: number;
}
/** 공통 제약 겹침 값 — 화면 네 칸(계획서 20 확정). 비우면 서버가 프로젝트 기본값을 쓴다.
* 이름은 서버 몸통과 그대로(snake_case) — 변환 없이 그대로 실어 보낸다. */
export interface RouteInitialCriteria {
max_grade_pct?: number;
min_radius_m?: number;
stream_offset_m?: number;
grade_smooth_m?: number;
}
export interface RouteInitialRequest {
mode: RouteInitialLogic;
weights?: RouteInitialWeights;
criteria?: RouteInitialCriteria;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
+58 -24
View File
@@ -23,9 +23,12 @@
· 그래도 길이 없을 때만(지형상 불가능) 벌점 탐색으로 가장 적게 넘긴 길을 내고, 넘긴 곳을
`metrics.violations`(측점 · 값 · 사유)에 적음. `over_limit` 수 = 그 목록 수.
⚠ 사용자 확정 대기 — 아래 임시값과 호출부가 넘기는 종단기울기 상한 · 최소 곡선반지름은 지식DB
(`resources/knowledge/technical_info/01_임도`) 후보일 뿐이다(PLAN 20장 기준값 후보 표).
확정되면 이 머리의 상수만 고친다.
기준값 확정(2026-09-29 사용자 결정, PLAN 20장) — 아래 임시값이 그대로 **기본값**이 됐다
(지식DB `resources/knowledge/technical_info/01_임도` 후보 표 그대로). 종단기울기 상한 ·
최소 곡선반지름은 여전히 호출부(`_road_settings`·`_plan_criteria`)가 프로젝트별로 넘기고,
계류 이격 · 기울기 잴 지반 폭은 이 머리의 상수가 기본값이다 — 넷 모두 화면(`RouteInitialCriteria`)
에서 프로젝트마다 겹쳐 쓸 수 있다(`generate_initial_route` 의 `stream_offset_m`·`grade_smooth_m`
인자, 라우터의 `max_grade`·`min_radius_m` 대입). 응답 `criteria.pending` 은 이제 늘 빈 목록이다.
"""
from __future__ import annotations
@@ -58,14 +61,10 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천")
ROAD_WIDTH_M = 4.0
# 탐색 복도 반폭(m) — 예상노선에서 이보다 멀리는 가지 않음(법령 값 아님 · 알고리즘 값).
CORRIDOR_M = 100.0
PENDING = (
"max_grade_pct",
"min_radius_m",
"stream_offset_m",
"road_width_m",
"corridor_m",
"grade_smooth_m",
)
# 기준값 확정(2026-09-29 사용자 결정) — 예전엔 여기 여섯 칸이 「사용자 확정 대기」였다.
# 이제 후보 표 값 그대로 기본값으로 굳었고(화면에서 프로젝트마다 바꿀 수 있음), 빈 목록이 곧
# 「확정됨」이다(`criteria.pending`).
PENDING: tuple[str, ...] = ()
# ── 탐색 · 측정 값 ───────────────────────────────────────────────────────────────
MODES = ("follow", "earthwork", "gentle", "weighted")
@@ -148,20 +147,40 @@ def _choice_path(project_root: Path) -> Path:
def read_initial_choice(project_root: Path) -> dict[str, Any]:
"""현재 초기값(갈래 · 비중) — [확인] 때 적어 둔 것. 없거나 못 읽으면 추종."""
"""현재 초기값(갈래 · 비중 · 제약 겹침) — [확인] 때 적어 둔 것. 없거나 못 읽으면 추종.
`criteria` 는 **사용자가 겹쳐 쓴 값만**(비운 칸은 안 담김) — 나머지는 프로젝트 기본값이라
화면이 다시 열 때 겹친 칸만 채우고 나머지는 placeholder(기본값)로 비워 둔다.
"""
try:
data = json.loads(_choice_path(project_root).read_text(encoding="utf-8"))
logic = data["logic"] if data.get("logic") in MODES else "follow"
return {"logic": logic, "weights": resolve_weights(logic, data.get("weights"))}
criteria = data.get("criteria")
return {
"logic": logic,
"weights": resolve_weights(logic, data.get("weights")),
"criteria": criteria if isinstance(criteria, dict) and criteria else None,
}
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return {"logic": "follow", "weights": resolve_weights("follow", None)}
return {"logic": "follow", "weights": resolve_weights("follow", None), "criteria": None}
def write_initial_choice(
project_root: Path, logic: str, weights: dict[str, float] | None
project_root: Path,
logic: str,
weights: dict[str, float] | None,
criteria: dict[str, float] | None = None,
) -> dict[str, Any]:
"""[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값."""
choice = {"logic": logic, "weights": resolve_weights(logic, weights)}
"""[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값.
`criteria` 는 사용자가 화면 칸에 직접 넣은 겹침 값만(빈 칸은 담지 않는다) — 프로젝트
기본값이 나중에 바뀌어도 사용자가 안 만진 칸은 그 새 기본값을 그대로 받는다.
"""
choice = {
"logic": logic,
"weights": resolve_weights(logic, weights),
"criteria": criteria or None,
}
path = _choice_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(choice, ensure_ascii=False), encoding="utf-8")
@@ -208,18 +227,27 @@ def generate_initial_route(
weights: dict[str, float] | None,
max_grade: float,
min_radius_m: float,
stream_offset_m: float | None = None,
grade_smooth_m: float | None = None,
) -> dict[str, Any]:
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
공통 제약을 넘지 않는 길을 먼저 찾고(탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 곡선 맞춤 뒤
지반 차까지 없앰), 그런 길이 없을 때만 벌점 탐색으로 가장 적게 넘긴 길을 낸다.
`stream_offset_m`·`grade_smooth_m` 을 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서
프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정).
"""
if mode not in MODES:
raise ValueError(f"모르는 갈래: {mode}")
if len(expected) < 2:
raise ValueError("예상노선 정점이 2개 미만입니다.")
used = resolve_weights(mode, weights)
grid = _SearchGrid(expected, terrain, streams)
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
grid = _SearchGrid(
expected, terrain, streams, stream_offset_m=stream_offset, grade_smooth_m=grade_smooth
)
def attempt(hard_cap: float, bound: float | None) -> tuple[Any, dict[str, Any]]:
nodes = _search_route(
@@ -243,9 +271,10 @@ def generate_initial_route(
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=STREAM_OFFSET_M,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
)
return outline, metrics
@@ -280,10 +309,11 @@ def generate_initial_route(
"criteria": {
"max_grade_pct": round(max_grade * 100.0, 2),
"min_radius_m": round(min_radius_m, 2),
"stream_offset_m": STREAM_OFFSET_M,
"stream_offset_m": round(stream_offset, 2),
"road_width_m": ROAD_WIDTH_M,
"corridor_m": CORRIDOR_M,
"grade_smooth_m": GRADE_SMOOTH_M,
"grade_smooth_m": round(grade_smooth, 2),
# 기준값 확정(PLAN 20장, 2026-09-29) — 더는 후보가 아니다. 항상 빈 목록.
"pending": list(PENDING),
},
}
@@ -323,11 +353,15 @@ class _SearchGrid:
expected: list[tuple[float, float]],
terrain: Terrain,
streams: list[list[tuple[float, float]]],
*,
stream_offset_m: float = STREAM_OFFSET_M,
grade_smooth_m: float = GRADE_SMOOTH_M,
) -> None:
step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x))))
self.xs, self.ys = terrain.x[::step], terrain.y[::step]
# 기울기 · 옆경사는 비교값과 같은 고른 지반에서 잰다(`smoothed_ground`)
self.z = smoothed_ground(terrain)[::step, ::step]
self.z = smoothed_ground(terrain, grade_smooth_m)[::step, ::step]
self.stream_offset_m = stream_offset_m
self.cell = _cell_size(self.xs)
self.d_expected = _distance_grid([expected], self.xs, self.ys, self.cell)
self.d_stream = _distance_grid(streams, self.xs, self.ys, self.cell)
@@ -372,8 +406,8 @@ class _SearchGrid:
norm = math.hypot(dr, dc)
grade = np.abs(self.z[second] - self.z[first]) / (self.cell * norm)
# 계류 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만(건너기 · 빠져나가기)
in_buffer = (self.d_stream[first] < STREAM_OFFSET_M) | (
self.d_stream[second] < STREAM_OFFSET_M
in_buffer = (self.d_stream[first] < self.stream_offset_m) | (
self.d_stream[second] < self.stream_offset_m
)
ax = self.away_x[first] + self.away_x[second]
ay = self.away_y[first] + self.away_y[second]
@@ -33,12 +33,14 @@ REASON_STREAM = "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을
REASON_STREAM_TERRAIN = "지형상 불가능 — 기울기 · 반지름을 지키며 계류 버퍼를 비켜 잇는 길 없음"
def smoothed_ground(terrain: Terrain) -> np.ndarray:
"""종단기울기를 잴 지반(`GRADE_SMOOTH_M` 로 고른 DEM) — 탐색과 비교값이 같은 것을 본다."""
def smoothed_ground(terrain: Terrain, grade_smooth_m: float = GRADE_SMOOTH_M) -> np.ndarray:
"""종단기울기를 잴 지반(`grade_smooth_m` 로 고른 DEM) — 탐색과 비교값이 같은 것을 본다.
폭은 화면에서 바꿀 수 있다(PLAN 20장 확정 · 기본값 `GRADE_SMOOTH_M`)."""
from scipy.ndimage import gaussian_filter
spacing = 0.5 * (_spacing(terrain.x) + _spacing(terrain.y))
return gaussian_filter(np.asarray(terrain.z, dtype=np.float64), GRADE_SMOOTH_M / spacing)
return gaussian_filter(np.asarray(terrain.z, dtype=np.float64), grade_smooth_m / spacing)
def station_label(chainage_m: float) -> str:
@@ -107,6 +109,7 @@ def route_metrics(
stream_offset_m: float,
road_width_m: float,
bound_pct: float | None,
grade_smooth_m: float = GRADE_SMOOTH_M,
) -> dict[str, Any]:
"""비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기.
@@ -115,7 +118,7 @@ def route_metrics(
grid = (terrain.y, terrain.x)
sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None)
smooth = RegularGridInterpolator(
grid, smoothed_ground(terrain), bounds_error=False, fill_value=None
grid, smoothed_ground(terrain, grade_smooth_m), bounds_error=False, fill_value=None
)
points, chain = _resample(vertices, SAMPLE_M)
length = float(chain[-1])
+33 -2
View File
@@ -98,9 +98,39 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
)
# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
#: 이름 속 측점 글 — 정수 m `00120` · 소수 측점 `00119.80`
CROSS_KEY_PATTERN = r"(\d+(?:\.\d{2})?)"
def cross_station(chainage_m: float) -> int | float:
"""측점 → 이름 열쇠 값 — 정수 m(0.005m 안)면 int(옛 이름 · 키 그대로) · 아니면 소수 2자리."""
value = round(float(chainage_m), 2)
whole = round(value)
return int(whole) if abs(value - whole) < 0.005 else value
def cross_key(chainage_m: float) -> str:
"""이름 속 측점 글 — `00120`(정수 · 옛 이름 그대로) · `00119.80`(소수 측점)."""
station = cross_station(chainage_m)
return f"{station:05d}" if isinstance(station, int) else f"{station:08.2f}"
def parse_cross_key(text: str) -> int | float:
"""`cross_key` 되읽기."""
return cross_station(float(text))
def cross_filename(chainage_m: float) -> str:
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
return f"cross_{int(round(float(chainage_m))):05d}m.json"
return f"cross_{cross_key(chainage_m)}m.json"
def find_cross_file(cross_dir: Path, chainage_m: float) -> Path:
"""읽기용 — 지금 이름이 없으면 옛 정수 m 이름(소수 측점이 정수로 붙던 옛 프로젝트)."""
path = cross_dir / cross_filename(chainage_m)
legacy = cross_dir / f"cross_{int(round(float(chainage_m))):05d}m.json"
return path if path.exists() or not legacy.exists() else legacy
def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
@@ -109,8 +139,9 @@ def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고
빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다).
"""
# 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 찾기 규칙(find_cross_file)으로 가려야 안 지워짐
valid = {
cross_filename(station["chainage_m"])
find_cross_file(cross_dir, station["chainage_m"]).name
for station in stations
if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float))
}
@@ -341,34 +341,19 @@ def generate_sections(
if 0.0 <= float(chainage) <= total
}
if extras:
# 횡단 파일명이 정수 미터 반올림이라(cross_filename) 한 정수 미터에 측점은 하나만 둔다.
# 관 · 구조물 자체 측점은 소수 그대로 — 격자 측점과 같은 정수 미터면 격자 대신 그 측점을
# 쓴다(시 · 종점 격자만 그대로 두고 라벨을 얹음). 비정규끼리 겹치면 앞선 것만 남긴다.
# ponytail: 파일명이 정수 미터인 동안의 가드. 소수 이름 규칙으로 바꾸면 지운다.
# 관 · 구조물 자리마다 그 소수 측점의 횡단을 따로 둔다(2026-09-29 사용자 확정) — 이름은
# 소수 2자리(cross_00119.80m · cross_filename). 같은 이름이 되는 0.005m 안 겹침만 하나로:
# 격자와 겹치면 격자 측점에 라벨을 얹고, 비정규끼리 겹치면 앞선 것만 남긴다.
grid = [round(float(value), 6) for value in station_chainage]
ends = {grid[0], grid[-1]}
buckets = {int(round(value)): value for value in grid}
taken: set[int] = set()
dropped: set[float] = set()
snapped: dict[float, str] = {}
for chainage in sorted(extras):
bucket = int(round(chainage))
if bucket in taken:
near = next((value for value in grid if abs(value - chainage) < 0.005), chainage)
if any(abs(near - taken) < 0.005 for taken in snapped):
continue
taken.add(bucket)
regular = buckets.get(bucket)
if regular in ends:
snapped[regular] = extras[chainage]
continue
if regular is not None:
dropped.add(regular)
snapped[chainage] = extras[chainage]
snapped[near] = extras[chainage]
extras = snapped
station_chainage = np.unique(
np.r_[
np.array([value for value in grid if value not in dropped], dtype=np.float64),
np.array(sorted(extras), dtype=np.float64),
]
np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)]
)
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
tangents = np.vstack(
+25
View File
@@ -94,6 +94,19 @@ class RouteInitialWeights(BaseModel):
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted."""
@@ -101,6 +114,7 @@ class RouteInitialRequest(BaseModel):
"follow", validation_alias=AliasChoices("logic", "mode")
)
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
class RouteReplanRequest(BaseModel):
@@ -574,6 +588,7 @@ async def replan_route(
project_root,
choice.mode,
choice.weights.model_dump() if choice.weights else None,
(choice.criteria.model_dump(exclude_none=True) or None) if choice.criteria else None,
)
if choice
else await asyncio.to_thread(read_initial_choice, project_root)
@@ -648,6 +663,14 @@ async def initial_route(
grade_class, design_speed, terrain_type = await _road_settings(project_id)
# 비포장 기준 — 포장 예외(18 %)는 포장 여부가 정해진 뒤 종단 설계가 따진다(사용자 확정 대기).
max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0
# 화면 제약 칸(PLAN 20장 확정) — 비운 칸은 위 프로젝트 기본값 그대로, 채운 칸만 겹쳐 쓴다.
criteria = request.criteria
if criteria and criteria.max_grade_pct is not None:
max_grade = criteria.max_grade_pct / 100.0
if criteria and criteria.min_radius_m is not None:
radius_m = criteria.min_radius_m
stream_offset_m = criteria.stream_offset_m if criteria else None
grade_smooth_m = criteria.grade_smooth_m if criteria else None
pool = get_db_pool()
async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id))
@@ -666,6 +689,8 @@ async def initial_route(
weights=weights,
max_grade=max_grade,
min_radius_m=radius_m,
stream_offset_m=stream_offset_m,
grade_smooth_m=grade_smooth_m,
)
)
except (FileNotFoundError, ValueError) as error:
@@ -10,6 +10,7 @@
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
* ========================================================================== */
import { stationPartsLabel } from "../common_util/common_util_station";
import {
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
ESCAPE_ROUTE_WIDTHS_M,
@@ -130,9 +131,9 @@ export function irregularStationId(id: string): string {
return `irregular:${id}`;
}
/** 측점번호+잔여거리 표기 (예: 3+18.0). */
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
export function irregularLabel(station: IrregularStation): string {
return `${station.station}+${station.remainder.toFixed(1)}`;
return stationPartsLabel(station.station, station.remainder);
}
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
@@ -24,7 +24,7 @@ import {
designHalfWidths,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
export interface CrossPreviewParams {
projectId: string;
+106 -9
View File
@@ -1,19 +1,25 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화]
* · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 공통 제약 겹침 네 칸 ·
* 비중 슬라이더 · [초기화] · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
*
* 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에
* 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고,
* [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시
* 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`).
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
*
* 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정)
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고(비운 칸은 서버가 알아서), 그
* 「채운 칸」만 캐시·`initial_choice.json`에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder는
* 그 칸을 처음 비운 채 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
fetchRouteInitial,
ROUTE_INITIAL_MISSING,
type RouteInitialCriteria,
type RouteInitialLogic,
type RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
@@ -21,6 +27,7 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
criteriaLine,
CRITERIA_KEYS,
DEFAULT_WEIGHTS,
metricsLine,
violationsLine,
@@ -33,8 +40,10 @@ import {
serverBase,
WEIGHT_KEYS,
writeInitialChoice,
type CriteriaKey,
type InitialBase,
type InitialChoice,
type InitialCriteria,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
@@ -44,6 +53,37 @@ const WEIGHT_NAME: Record<WeightKey, string> = {
grade: "기울기",
};
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
const CRITERIA_FIELD: Record<
CriteriaKey,
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
> = {
max_grade_pct: {
label: "기울기",
unit: "%",
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
of: (c) => c.maxGradePct,
},
min_radius_m: {
label: "최소R",
unit: "m",
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
of: (c) => c.minRadiusM,
},
stream_offset_m: {
label: "계류",
unit: "m",
title: "계류 이격(m) — 비우면 기본값 100m",
of: (c) => c.streamOffsetM,
},
grade_smooth_m: {
label: "지반",
unit: "m",
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
of: (c) => c.gradeSmoothM,
},
};
export interface InitialPanelParams {
overlay: HTMLElement;
projectId: string;
@@ -57,7 +97,11 @@ export interface InitialPanel {
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
/** [확인]에 실어 보낼 지금 초기값. */
current: () => { mode: RouteInitialLogic; weights: RouteInitialWeights | null };
current: () => {
mode: RouteInitialLogic;
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
};
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
cancel: () => void;
/** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */
@@ -80,6 +124,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
<button type="button" class="ui-btn ui-btn--glass" data-act="weights-open" disabled
aria-expanded="false" title="예상 · 토공 · 기울기 비중(합 100)으로 셈합니다">비중 계산</button>
</div>
<div class="b05-routeedit__criteria-row">
${CRITERIA_KEYS.map(
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
<input type="number" inputmode="decimal" step="0.1" min="0" disabled
data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}</label>`,
).join("")}
</div>
<div class="b05-routeedit__weights" hidden>
${WEIGHT_KEYS.map(
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
@@ -107,17 +158,41 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const openWeights = box.querySelector<HTMLButtonElement>('[data-act="weights-open"]')!;
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
const buttons = [...box.querySelectorAll<HTMLButtonElement>("button"), resetButton];
const criteriaInputs = box.querySelectorAll<HTMLInputElement>("[data-criteria]");
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
let openingChoice = readInitialChoice(projectId);
let choice: InitialChoice | null = openingChoice;
/** 노드 없이 이름만 아는 현재 초기값 — 서버가 적어 둔 것(없으면 예상노선 추종). */
let base: InitialBase = { logic: "follow", weights: null };
let base: InitialBase = { logic: "follow", weights: null, criteria: null };
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
const defaults: Partial<Record<CriteriaKey, number>> = {};
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
/** 화면 칸을 값으로 채운다(비운 칸은 빈 문자열 — placeholder 가 기본값을 보인다). */
function setCriteriaInputs(overrides: RouteInitialCriteria | null): void {
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
const value = overrides?.[key];
input.value = value === undefined ? "" : String(value);
});
}
/** 화면 칸에 지금 사용자가 넣은 값만(빈 칸은 안 담김) — 다음 요청에 실어 보낼 겹침. */
function currentCriteria(): RouteInitialCriteria {
const out: RouteInitialCriteria = {};
criteriaInputs.forEach((input) => {
const raw = input.value.trim();
if (raw === "") return;
const value = Number(raw);
if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value;
});
return out;
}
function render(): void {
const now = choice ?? base;
const logic = now.logic;
@@ -142,30 +217,47 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
}
box.querySelector<HTMLElement>("[data-weight-sum]")!.textContent =
`합 ${weights.expected + weights.earthwork + weights.grade}`;
// 공통 제약 칸 — placeholder 는 그 칸을 처음 비운 채 물었을 때 서버가 준 진짜 기본값.
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
input.placeholder = defaults[key] !== undefined ? String(defaults[key]) : "기본값";
input.disabled = busy || !opening;
});
buttons.forEach((button) => {
button.disabled = busy || !opening;
});
resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`;
}
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다.
* `useCriteria` 를 안 주면 화면 칸에 지금 든 값을 그대로 겹침으로 보낸다. */
async function run(
logic: RouteInitialLogic,
allowFallback = false,
useWeights: RouteInitialWeights | null = null,
useCriteria?: RouteInitialCriteria | null,
): Promise<void> {
if (busy || !opening) return;
busy = true;
const sent = logic === "weighted" ? { ...(useWeights ?? weights) } : null;
const criteria = useCriteria !== undefined ? (useCriteria ?? {}) : currentCriteria();
render();
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
try {
const sentCriteria = Object.keys(criteria).length ? criteria : null;
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(sent ? { weights: sent } : {}),
...(sentCriteria ? { criteria: sentCriteria } : {}),
});
if (!box.isConnected) return; // 그 사이 창이 닫혔다.
choice = normalizeRouteInitial(response, logic, sent);
choice = normalizeRouteInitial(response, logic, sent, sentCriteria);
// 비운 칸(안 겹친 칸)을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
for (const key of CRITERIA_KEYS) {
if (criteria[key] !== undefined) continue;
const resolved = choice.criteria ? CRITERIA_FIELD[key].of(choice.criteria) : null;
if (resolved !== null) defaults[key] = resolved;
}
writeInitialChoice(projectId, choice);
params.apply(
copy(choice.snapshot),
@@ -204,7 +296,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
// [초기화] — 마지막에 적용한 초기값으로. 노드가 없으면(서버가 이름만 앎) 그 갈래를 서버에 묻는다.
resetButton.addEventListener("click", () => {
if (!choice) {
void run(base.logic, true, base.weights);
setCriteriaInputs(base.criteria); // 그 사이 손댄 칸이 있으면 서버가 기억한 값으로 되돌린다.
void run(base.logic, true, base.weights, base.criteria);
return;
}
params.apply(
@@ -212,6 +305,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
`초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`,
);
});
setCriteriaInputs(choice?.criteriaOverrides ?? null);
render();
return {
@@ -219,11 +313,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
opening = copy(snapshot);
edited = wasEdited;
// 「현재 초기값」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다. 캐시는 같은 초기값의 노드를 들고 있을 때만 [초기화]가 쓴다.
// 캐시를 버린다. 캐시는 같은 초기값 · 같은 제약 겹침의 노드를 들고 있을 때만 [초기화]가 쓴다.
if (server) {
base = serverBase(server);
if (choice && !sameBase(choice, base)) choice = null;
if (base.weights) weights = { ...base.weights };
// 화면 칸도 그때 넣었던 값(캐시가 살아 있으면 캐시, 아니면 서버가 기억한 값)으로.
setCriteriaInputs(choice?.criteriaOverrides ?? base.criteria);
openingChoice = choice;
writeInitialChoice(projectId, choice);
}
@@ -231,7 +327,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
},
current: () => {
const now = choice ?? base;
return { mode: now.logic, weights: now.weights };
const criteria = choice ? choice.criteriaOverrides : base.criteria;
return { mode: now.logic, weights: now.weights, criteria };
},
cancel: () => writeInitialChoice(projectId, openingChoice),
keep: () => writeInitialChoice(projectId, choice),
@@ -5,7 +5,11 @@
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { RouteInitialLogic, RouteInitialWeights } from "./B05_Profile_Api_RouteInitial";
import type {
RouteInitialCriteria,
RouteInitialLogic,
RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
@@ -23,6 +27,15 @@ export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 2
export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const;
export type WeightKey = (typeof WEIGHT_KEYS)[number];
/** 공통 제약 겹침 네 칸 — 서버 몸통 이름 그대로(계획서 20 확정). */
export const CRITERIA_KEYS = [
"max_grade_pct",
"min_radius_m",
"stream_offset_m",
"grade_smooth_m",
] as const;
export type CriteriaKey = (typeof CRITERIA_KEYS)[number];
/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */
export function rebalanceWeights(
weights: RouteInitialWeights,
@@ -60,14 +73,13 @@ export interface InitialViolation {
reason: string;
}
/** 공통 제약 기준값 — 서버가 준 값. `pending` 이 남아 `confirmed` 가 거짓이면 지식DB 후보. */
/** 공통 제약 기준값 — 서버가 실제로 쓴 값(기본값이든 화면 겹침이든, 계획서 20 확정). */
export interface InitialCriteria {
maxGradePct: number | null;
minRadiusM: number | null;
streamOffsetM: number | null;
/** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */
gradeSmoothM: number | null;
confirmed: boolean;
}
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
@@ -77,6 +89,8 @@ export interface InitialChoice {
snapshot: RouteEditSnapshot;
metrics: InitialMetrics | null;
criteria: InitialCriteria | null;
/** 화면 제약 칸에 **사용자가 직접 넣은** 겹침 값만(비운 칸은 안 담김). [확인]·재열기에 쓴다. */
criteriaOverrides: RouteInitialCriteria | null;
/** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */
terrainBoundPct?: number | null;
}
@@ -107,11 +121,13 @@ function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
};
}
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). */
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다).
* `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */
export function normalizeRouteInitial(
response: unknown,
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteriaOverrides: RouteInitialCriteria | null = null,
): InitialChoice {
// 모양은 계획서 20 「API」 줄 — 노드 · 곡선은 `/route/plan` 과 같은 꼴이다.
const body = (response ?? {}) as Record<string, unknown>;
@@ -153,10 +169,9 @@ export function normalizeRouteInitial(
minRadiusM: pick(c, "min_radius_m"),
streamOffsetM: pick(c, "stream_offset_m"),
gradeSmoothM: pick(c, "grade_smooth_m"),
// 확정 대기 칸 이름 목록이 비어야 확정이다.
confirmed: Array.isArray(c.pending) && c.pending.length === 0,
}
: null,
criteriaOverrides,
};
}
@@ -165,16 +180,36 @@ export interface InitialBase {
logic: RouteInitialLogic;
/** 비중 계산일 때만. 나머지 갈래는 null. */
weights: RouteInitialWeights | null;
/** 사용자가 그때 화면 칸에 넣었던 제약 겹침만(비운 칸은 안 담김). */
criteria: RouteInitialCriteria | null;
}
/** 서버 `current_initial {logic, weights}` 를 편다. 모르는 값이면 예상노선 추종. */
/** 겹침 값 원시 객체를 편다 — 유한수인 칸만 담는다. 빈 결과는 null. */
function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
const out: RouteInitialCriteria = {};
for (const key of CRITERIA_KEYS) {
const value = num(source[key]);
if (value !== null) out[key] = value;
}
return Object.keys(out).length ? out : null;
}
/** 두 겹침 값이 같은가 — 없는 칸은 서로 같다고 본다. */
function criteriaEqual(a: RouteInitialCriteria | null, b: RouteInitialCriteria | null): boolean {
return CRITERIA_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null));
}
/** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추종. */
export function serverBase(server: unknown): InitialBase {
const body = (server ?? {}) as Record<string, unknown>;
const logic =
typeof body.logic === "string" && body.logic in LOGIC_LABEL
? (body.logic as RouteInitialLogic)
: "follow";
if (logic !== "weighted") return { logic, weights: null };
const criteria = parseCriteriaOverrides(body.criteria);
if (logic !== "weighted") return { logic, weights: null, criteria };
const raw = (body.weights ?? {}) as Record<string, unknown>;
const weights = {
expected: Math.round(Number(raw.expected)),
@@ -182,14 +217,19 @@ export function serverBase(server: unknown): InitialBase {
grade: Math.round(Number(raw.grade)),
};
const valid = WEIGHT_KEYS.every((key) => Number.isFinite(weights[key]));
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS } };
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria };
}
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중인가. */
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중 · 같은 제약 겹침인가. */
export function sameBase(choice: InitialChoice, base: InitialBase): boolean {
if (choice.logic !== base.logic) return false;
if (base.logic !== "weighted") return true;
return WEIGHT_KEYS.every((key) => choice.weights?.[key] === base.weights?.[key]);
if (
base.logic === "weighted" &&
!WEIGHT_KEYS.every((key) => choice.weights?.[key] === base.weights?.[key])
) {
return false;
}
return criteriaEqual(choice.criteriaOverrides, base.criteria);
}
/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */
@@ -215,7 +255,7 @@ export function metricsLine(metrics: InitialMetrics | null): string {
].join(" · ");
}
/** 공통 제약 기준값 한 줄 — 서버 값만 보인다. 서버 갈래가 없으면 `/route/plan` 의 최소 R 만. */
/** 공통 제약 기준값 한 줄 — 서버가 실제로 쓴 값(계획서 20 확정 · 화면 칸에서 바꿀 수 있음). */
export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string {
const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null);
const parts = [
@@ -226,8 +266,7 @@ export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: n
// 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다.
if (criteria?.gradeSmoothM)
parts.push(`기울기 잴 지반 ${fixed(criteria.gradeSmoothM, 0, "m")} 고름`);
const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)";
return `공통 제약 ${parts.join(" · ")}${tail}`;
return `공통 제약 ${parts.join(" · ")}`;
}
/** 목록 한 줄에 적을 구간 수 — 넘으면 「외 N곳」(전부는 글상자 풍선에). */
@@ -13,7 +13,7 @@
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
type Vertex = [number, number];
@@ -26,7 +26,7 @@ import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 노드 반지름(px). */
const NODE_R = 4;
@@ -14,7 +14,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
export interface StructureListParams {
/** 목록을 담을 <ul>. 그릴 때마다 통째로 갈아 끼운다. */
@@ -20,7 +20,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
* 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여
+10 -27
View File
@@ -36,13 +36,19 @@ import {
loadPipeForm as loadPipeFormInto,
} from "./B05_Profile_UI_Structures_Panel_Commit";
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
import { formatStation } from "./B05_Profile_Util_Station";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types";
export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import { DEFAULT_INTERVAL_LENGTH_M, GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types";
import {
DEFAULT_INTERVAL_LENGTH_M,
GROUP_LABELS,
hasComputedRange,
readBeforeOption,
readLengthOption,
} from "./B05_Profile_UI_Structures_Panel_Types";
import type {
PipeFacilityItem,
StructuresCallbacks,
@@ -89,35 +95,12 @@ export function createStructuresSection(
return type.managed_by ? (type.type_id as PipeFacility) : null;
}
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
* 준다(2026-08-19 사용자 지시). */
function hasComputedRange(type: StructureType | null): boolean {
return (
!!type &&
!type.managed_by &&
type.placement === "interval" &&
type.options.some((option) => option.key === "length_m" && isB05Option(option))
);
}
/** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */
function readLengthM(): number | null {
const entry = optionInputs.find((input) => input.key === "length_m");
if (!entry || entry.isEmpty()) return null;
const length = Number(entry.read());
return Number.isFinite(length) && length > 0 ? length : null;
return readLengthOption(optionInputs);
}
/** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0
* (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */
function readBeforeM(): number {
const entry = optionInputs.find((input) => input.key === "before_m");
if (!entry || entry.isEmpty()) return 0;
const before = Number(entry.read());
if (!Number.isFinite(before) || before < 0) return 0;
const length = readLengthM();
return length !== null && before > length ? length : before;
return readBeforeOption(optionInputs);
}
/** 기준측점 전·후 중 나중에 고친 쪽 — 길이가 바뀔 때 이쪽 값을 지키며 재배분한다. */
@@ -9,7 +9,11 @@
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import {
isB05Option,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
@@ -98,3 +102,36 @@ export interface StructuresCallbacks {
*/
onReveal?: () => void;
}
type OptionEntry = { key: string; read: () => string | number; isEmpty: () => boolean };
/** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */
export function readLengthOption(inputs: OptionEntry[]): number | null {
const entry = inputs.find((input) => input.key === "length_m");
if (!entry || entry.isEmpty()) return null;
const length = Number(entry.read());
return Number.isFinite(length) && length > 0 ? length : null;
}
/** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0
* (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */
export function readBeforeOption(inputs: OptionEntry[]): number {
const entry = inputs.find((input) => input.key === "before_m");
if (!entry || entry.isEmpty()) return 0;
const before = Number(entry.read());
if (!Number.isFinite(before) || before < 0) return 0;
const length = readLengthOption(inputs);
return length !== null && before > length ? length : before;
}
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
* 준다(2026-08-19 사용자 지시). */
export function hasComputedRange(type: StructureType | null): boolean {
return (
!!type &&
!type.managed_by &&
type.placement === "interval" &&
type.options.some((option) => option.key === "length_m" && isB05Option(option))
);
}
@@ -160,6 +160,35 @@
border-color: var(--color-primary);
}
/* 공통 제약 겹침 네 칸(계획서 20 확정) — 로직 단추 바로 아래. 비우면 placeholder 가 기본값. */
.b05-routeedit__criteria-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-12);
}
.b05-routeedit__criteria-row label {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.b05-routeedit__criteria-row input {
width: 3.6em;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
text-align: right;
}
.b05-routeedit__criteria-row input::placeholder {
color: var(--color-text-secondary);
}
.b05-routeedit__weights {
display: grid;
gap: 2px;
+2 -7
View File
@@ -4,7 +4,8 @@
*
* 구조물 위치는 저장은 누가거리(m)로 하되 **화면 입출력은 측점번호+잔여거리**로 한다
* (2026-08-17 사용자 확정). 근거: 중심선측량 측점 20m + 구조물 지점 보조말뚝
* (지식DB 설계제원_총괄 §8). 표기 예: "3+18.0" = 측점 3번에서 18.0m.
* (지식DB 설계제원_총괄 §8). 표기 예: "3+18" = 측점 3번에서 18m — 화면 표기는
* `common_util_station.stationLabel` 한 벌(거리는 정수로 보이기만 · 값은 소수).
*
* 변환식은 비정규 측점 UI(`B05_Profile_UI_IrregularStations.ts`)와 같다 —
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 호출 시점에 받는다
@@ -33,12 +34,6 @@ export function chainageToStation(
return { station, remainder };
}
/** 누가거리 → "3+18.0" 표기. */
export function formatStation(chainageM: number, intervalM: number): string {
const { station, remainder } = chainageToStation(chainageM, intervalM);
return `${station}+${remainder.toFixed(1)}`;
}
/** "3+18.0" · "3 + 18" · "76.5"(누가거리 직접) 입력을 누가거리로 해석한다.
* 해석 불가하면 null — 호출부가 칸을 붉히고 진행을 멈춘다. */
export function parseStationText(text: string, intervalM: number): number | null {
@@ -19,7 +19,7 @@ from typing import Any, Callable
import numpy as np
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B06_Section.B06_Section_Engine_Design import (
_SLOPE_CLOSE_TOLERANCE_M,
compute_cross_design,
@@ -175,7 +175,7 @@ def extend_unclosed_sections(
recomputed[key] = design[key]
section["samples"] = samples
section["design"] = recomputed
cross_path = cross_dir / cross_filename(chainage)
cross_path = find_cross_file(cross_dir, chainage)
if cross_path.is_file():
stored = json.loads(cross_path.read_text(encoding="utf-8"))
atomic_write_json(cross_path, {**stored, "samples": samples})
+10 -10
View File
@@ -406,23 +406,23 @@ async def merge_cross_section_design_by_round(
connection: aiomysql.Connection,
*,
route_id: int,
chainage_int: int,
chainage: float,
patch: dict[str, Any],
) -> bool:
"""정수 chainage(m) 측점의 data.design에 patch를 병합 저장한다.
"""측점(도면 id 측점 — 정수 m 또는 소수 2자리)의 data.design에 patch를 병합 저장한다.
B07 도면 확정(전체 설계 재계산 결과) 및 확정 해제(status 되돌림)에서 사용한다.
도면 ID가 정수 m라 ROUND로 매칭한다. 갱신 여부를 반환한다.
이름 규칙(cross_key)의 0.005m 안으로 매칭한다. 갱신 여부를 반환한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, data FROM cross_sections
WHERE route_id = %s AND ROUND(chainage_m) = %s
WHERE route_id = %s AND ABS(chainage_m - %s) < 0.005
ORDER BY id DESC
LIMIT 1
""",
(route_id, chainage_int),
(route_id, chainage),
)
row = await cursor.fetchone()
if not row:
@@ -445,21 +445,21 @@ async def merge_cross_section_design_by_round(
async def get_cross_section_design(
connection: aiomysql.Connection, route_id: int, chainage_int: int
connection: aiomysql.Connection, route_id: int, chainage: float
) -> dict[str, Any] | None:
"""정수 chainage(m)에 해당하는 측점의 잠정 설계(data.design)를 반환한다.
"""도면 id 측점(cross_{정수m}m · cross_{소수2자리}m)의 잠정 설계(data.design)를 반환한다.
B08이 도면 ID(cross_{정수m}m)로 조회하므로 ROUND로 근사 매칭한다.
이름 규칙(cross_key)의 0.005m 안으로 매칭한다.
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT data FROM cross_sections
WHERE route_id = %s AND ROUND(chainage_m) = %s
WHERE route_id = %s AND ABS(chainage_m - %s) < 0.005
ORDER BY id DESC
LIMIT 1
""",
(route_id, chainage_int),
(route_id, chainage),
)
row = await cursor.fetchone()
if not row or not row[0]:
+3 -3
View File
@@ -5,7 +5,7 @@ import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
@@ -309,7 +309,7 @@ def read_cross_design_inputs(
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
suggested = pavement.get(round(chainage_m, 3), False)
cross_dir = longitudinal_path.parent.parent / "cross_sections"
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
cross_path = find_cross_file(cross_dir, chainage_m).resolve()
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
cross = json.loads(cross_path.read_text(encoding="utf-8"))
@@ -492,7 +492,7 @@ def compute_default_designs(
ford_drops = ford_surface_drops(root)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross_path = cross_dir / cross_filename(chainage_m)
cross_path = find_cross_file(cross_dir, chainage_m)
if not cross_path.is_file():
continue
try:
@@ -9,6 +9,7 @@
import re
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import cross_key
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
CROSS_MM,
CROSS_TABLE_LAYER_ID,
@@ -38,7 +39,7 @@ from config.config_system import DRAWING_SCALE_CROSS
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?")
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?")
# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다.
# 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거).
@@ -151,7 +152,7 @@ def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[flo
return slots
def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다.
@@ -170,7 +171,7 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str,
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
"id": f"cross_s{chainages[0]:05d}m",
"id": f"cross_s{cross_key(chainages[0])}m",
"number": len(sheets) + 1,
"chainages": chainages,
"rows": max(rows, 1),
+20 -15
View File
@@ -13,6 +13,11 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Sections import (
CROSS_KEY_PATTERN,
cross_station,
parse_cross_key,
)
from B06_Section.B06_Section_Repository import (
get_confirmed_route_context,
get_cross_section_design,
@@ -65,7 +70,7 @@ from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
@@ -249,13 +254,13 @@ async def title_block_fields(project_id: UUID) -> dict[str, str]:
return {key: str(value) for key, value in fields.items() if value}
async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
async def _designs_by_chainage(route_id: int) -> dict[int | float, dict[str, Any]]:
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
pool = get_db_pool()
async with pool.acquire() as connection:
rows = await get_cross_section_designs(connection, route_id)
return {
int(round(float(row["chainage_m"]))): row["design"]
cross_station(row["chainage_m"]): row["design"]
for row in rows
if isinstance(row, dict) and isinstance(row.get("design"), dict)
}
@@ -304,7 +309,7 @@ async def get_design_drawing(
pool = get_db_pool()
async with pool.acquire() as connection:
design = await get_cross_section_design(
connection, route_id, int(cross_match.group(1))
connection, route_id, parse_cross_key(cross_match.group(1))
)
source_design = design
elif CROSS_SHEET_ID.fullmatch(drawing_id):
@@ -448,25 +453,25 @@ async def confirm_design_drawing(
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
targets: list[int] = []
targets: list[int | float] = []
if sheet is not None:
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))]
targets = [parse_cross_key(cross_match.group(1))]
confirmed_designs = [
(chainage_int, {**designs[chainage_int], "status": "confirmed"})
for chainage_int in targets
if designs.get(chainage_int)
(chainage, {**designs[chainage], "status": "confirmed"})
for chainage in targets
if designs.get(chainage)
]
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage_int, _design in confirmed_designs:
for chainage, _design in confirmed_designs:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
chainage=chainage,
patch={"status": "confirmed"},
)
async with connection.cursor() as cursor:
@@ -519,9 +524,9 @@ async def invalidate_design_drawing(
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
cross_match = _CROSS_ID.fullmatch(drawing_id)
stale: list[int] = []
stale: list[int | float] = []
if cross_match:
stale = [int(cross_match.group(1))]
stale = [parse_cross_key(cross_match.group(1))]
elif CROSS_SHEET_ID.fullmatch(drawing_id):
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
@@ -533,11 +538,11 @@ async def invalidate_design_drawing(
await connection.begin()
try:
# 확정 도면을 편집하면 담긴 측점 설계도 잠정 상태로 되돌린다.
for chainage_int in stale:
for chainage in stale:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
chainage=chainage,
patch={"status": "provisional"},
)
async with connection.cursor() as cursor:
@@ -9,6 +9,12 @@ import re
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import (
CROSS_KEY_PATTERN,
cross_station,
find_cross_file,
parse_cross_key,
)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
@@ -94,7 +100,7 @@ logger = logging.getLogger(__name__)
_STAGE_DIR = "B07_DesignDetail"
_CROSS_ID = re.compile(r"^cross_(\d+)m$")
_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
MASS_HAUL_ID = "mass_haul"
@@ -107,7 +113,7 @@ CROSS_STANDARD_ID = "cross_standard"
def _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
designs: dict[int | float, dict[str, Any]] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
@@ -226,7 +232,7 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]
def _cross_sheet_plan(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
designs: dict[int | float, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
@@ -239,8 +245,9 @@ def _cross_sheet_plan(
match = _CROSS_ID.fullmatch(path.stem)
if not match:
continue
chainage = int(match.group(1))
source = _read_json(path)
# 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎
chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))
design = (designs or {}).get(chainage)
design_line = _cross_design_line(longitudinal_path, source, design)
width, height = section_block_size(source, design_line)
@@ -352,11 +359,11 @@ def _cross_design_line(
def _cross_section_input(
longitudinal_path: Path,
longitudinal: dict[str, Any],
chainage: int,
chainage: int | float,
design: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json"
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
if not path.is_file():
return None
source = _read_json(path)
@@ -565,9 +572,12 @@ def _read_drawing(
if CROSS_SHEET_ID.fullmatch(drawing_id):
return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design)
if not _CROSS_ID.fullmatch(drawing_id):
cross_match = _CROSS_ID.fullmatch(drawing_id)
if not cross_match:
raise ValueError("올바르지 않은 도면 ID입니다.")
path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
path = find_cross_file(
longitudinal_path.parent.parent / "cross_sections", parse_cross_key(cross_match.group(1))
)
if not path.is_file():
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
source = _read_json(path)
@@ -9,7 +9,7 @@ import re
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files
from B05_Profile.B05_Profile_Engine_Sections import cross_station, prune_stale_cross_files
logger = logging.getLogger(__name__)
@@ -47,12 +47,12 @@ def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[
return files
def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]:
def _station_map(longitudinal: dict[str, Any]) -> dict[int | float, dict[str, Any]]:
stations = longitudinal.get("stations", [])
if not isinstance(stations, list):
return {}
return {
round(float(station.get("chainage_m", 0))): station
cross_station(station.get("chainage_m", 0)): station
for station in stations
if isinstance(station, dict)
}
+25
View File
@@ -0,0 +1,25 @@
/* =============================================================================
* common_util_station.ts
* 측점 화면 표기 한 벌 — B05 · B06 목록 · 폼 · 툴팁 · 종단 표시가 모두 이것을 씀
* (`common_util_svg` 가 다시 내보냄 · B05 는 `formatStation` 이름으로 가져다 씀). 가져오는 것이 없는
* 순수 함수라 서버 Node · 시험 헬퍼에서도 그대로 돎.
* ========================================================================== */
/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0` · `5+20`).
* 화면 표기만 거리를 정수로(119.8 → 5+20 · 2026-09-29 사용자 확정) — 입력 · 저장값은 소수 그대로. */
export function stationLabel(chainage: number, interval: number): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
if (Math.abs(remainder) < 0.05) remainder = 0;
if (remainder >= safeInterval - 0.05) {
stationNumber += 1;
remainder = 0;
}
return stationPartsLabel(stationNumber, remainder);
}
/** 이미 나눈 측점번호 · 잔여거리의 표기 — 거리는 정수로 보이기만. */
export function stationPartsLabel(stationNumber: number, remainder: number): string {
return `${stationNumber}+${Math.round(remainder)}`;
}
+1 -12
View File
@@ -49,15 +49,4 @@ export function inferStationInterval(stations: Array<{ chainage_m: number }>): n
);
}
/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0.0`). */
export function stationLabel(chainage: number, interval: number): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
if (Math.abs(remainder) < 0.05) remainder = 0;
if (remainder >= safeInterval - 0.05) {
stationNumber += 1;
remainder = 0;
}
return `${stationNumber}+${remainder.toFixed(1)}`;
}
export { stationLabel } from "./common_util_station";
@@ -0,0 +1,313 @@
# 지난 계획 10~19장 (2026-09-29 이관 · 모두 검증완료)
## 10. M02 마스터 템플릿 — 표 양식 · 도면 양식 (진행 · 담당 sub1 10-1 · sub3 10-2 · sub2 10-3 · sub4 10-4·10-5)
사용자 지시 — 대시보드에 M01 처럼 단추로 들어가는 마스터 템플릿 페이지(`M02_MasterTemplete`) · 공통 양식(좌측 패널 + 메인 · 안쪽 컨테이너 · 단추도 양식) · 양식 두 가지(엑셀처럼 도는 표 · 웹캐드 도면) · 프로그램 기본 양식을 두고 **프로젝트를 만들 때 복사**해 쓰고 그 뒤 사용자가 고침 · 첫 표 양식 = 구조물 집계표.
근거 — `tmp/M02_분석/1_얼개.md` · `2_웹캐드.md` · `3_표양식.md` · `4_구조물집계열.md`.
분석 결론 — 표 부품은 라이브러리 대신 **직접 만듦**(라이선스 · 겉모양 · 서버 재계산) · 수식은 옛 B08 TS 식 풀이기를 되살려 화면 · 서버 Node 한 벌 · 웹캐드는 iframe 과 주고받기를 그대로 다시 씀 · 오솔길엔 구조물 집계표 열 규칙이 없음(실무 표는 손으로 짬) · 지금 프로젝트 층 양식 복사는 없음(회사 층만 있음).
### 10-0. 사용자 확정
- 도면 기본 양식 → 마스터 템플릿 폴더로 옮김(`resources/master_template/drawing/`)
- 양식 층 넷 — **시스템 · 회사 · 개인 · 프로젝트** · 시스템은 저장소(git) · 나머지 셋은 영구저장소(storage) 나무에
- 프로젝트 만들 때 마스터를 프로젝트 폴더로 복사 · [초기화] = 그때 복사한 양식 · 회사 공식 · 같은 회사 남의 양식 · 다른 프로젝트 양식을 가져와 적용
- 구조물 집계표는 설계값을 받게 만듦 · 마스터엔 값이 없고 **열마다 「프로젝트에서 무엇이 들어가는지」**를 보임 · 시스템 관리자가 열 더하기 · 지우기 · 바꾸기
- 엑셀 같은 표 기능은 공용 폴더 아래 따로 둔 하위 폴더에(`ui_template/sheet/`)
- 머리 이름 · 열 짜임은 실무를 따르되 체계적으로(브레인 판단) · 열 묶음마다 일위대가 조합과 연결
- 커플링밴드는 관 길이 ÷ 1본 길이로 자동 — 1본 길이 **8m**(관 종류마다 칸 · 6m 가 필요하면 사용자가 확인 뒤 바꿈) · 이음 수 = 올림(L ÷ 1본) − 1
- 프로젝트 첫 복사는 **늘 시스템 양식** · 회사 공식이 있어도 · [회사 양식 적용] 을 누르면 회사 양식을 프로젝트 작업본에 덮어씀
- **이미 만든 프로젝트들**에도 프로젝트 양식(작업본 + `_initial/`)을 손으로 넣어 사용자가 확인하게 함
- 표는 50줄씩, 넘치면 다음 쪽
- 엑셀 내보내기 안 함 · 프로젝트 안쪽 화면에 양식 쓰기는 나중(구조물 정의가 끝난 뒤) · 지금은 마스터 양식 구현에 집중
브레인 임의 판정:
- (사용자 바로잡음) M02 = 시스템 관리자가 새 프로젝트 기본값(시스템 층)만 고치는 곳 · 층 고르기 · 프로젝트 고르기 · 프로젝트 층 단추는 M02 에 두지 않음(프로젝트 쪽 화면은 나중) · 서버 층 길 · 프로젝트 복사는 그대로
- 집계표 설계값 채우기 = 서버 단독(5장 ③) · 계산 열 = 표 식(5장 ② 한 벌) · 같은 측점에 관이 둘이면 줄을 나눔
- 프로젝트 표 [저장] = 양식(열 · 식 · 변수) + 손 입력 값만 작업본에 · 설계값과 펼친 열은 저장 안 함(열 때마다 설계에서 채움 · 5장 정본의 자리)
- 표 JSON = `3_표양식.md` 4장 틀 + 열 칸 `설명`(들어갈 것) · `바인딩`(설계 출처) · `일위대가` · `펼침`(마스터 한 칸 → 설계 값마다 열)
### 10-1. 페이지 틀
- [X] 대시보드 「시스템 설정」 칸에 단추 · 길 `m02-master-template` · 로그인 · 권한(SYSTEM_ADMIN) · 서버 `/api/m02`(`main.py` 관리자 전용) — `1_얼개.md` 1장 손댈 곳 10 — 검증완료(ORCA 단추 → 페이지)
- [X] 좌측 패널(양식 목록 · 표/도면 · 새로 · 본떠 만들기 · 지우기) + 메인(편집) · M01 모달을 공용 `ui_template` 으로 올려 함께 씀 — 검증완료(ORCA · 층 넷 · 프로젝트 단추 다섯 붙임 · M01 모달 계산값 같음)
- [X] 서버 저장 — 목록 · 읽기 · 저장(판 · 원자 쓰기 · 409) · 지우기 · M01 `Store` 방식 · 자리 `resources/master_template/` — 검증완료(`test_m02_store.py` · ORCA 새로·저장·다시 열기·지우기·409)
- [X] 병합 이어 붙임 — sub2·sub3·sub4 받음 · 층 길 sub4 것 · 도면 길 sub2 것 · `tsconfig` 에 `M0*` · 검증완료(ORCA 시스템 층 표 1 · 도면 5 · 집계표 master 모드 · CAD·자리표 키 · 프로젝트 층 채움 · 단추 다섯 수로 확인 · 시험 양식 지움)
- 남김 — 회사 층은 여기서 읽기만 · 비관리자는 시스템 층 단추 잠김 · 프로젝트 층 표 [저장] 은 채워진 문서를 그대로 저장(설계값 줄이 작업본에 들어감 — 브레인 판정 필요) · 랩탑_보조 프로젝트엔 포장 열이 없어 포장 A·줄눈 수는 못 봄(커플링 51칸 · 화면=서버=손계산 일치)
### 10-2. 표 양식 부품
- [X] 격자 — 셀 편집 · 줄/열 더하기 · 지우기 · 여러 층 머리(합치기) · 단위 줄 · 합계 줄 · 키보드 · M01 칸 편집 부품 다시 씀 — `ui_template/sheet/ui_template_sheet.ts` `createSheet` · master(식 · 들어갈 것 · 일위대가 줄 · 손 열) · project(바인딩 · 계산 잠금 · 손 열만 · 전구간 맨 위 · 쪽줄 50) · 틀고정 · 열 너비 끌기 · 칸에 `=` = 그 칸만 식
- [X] 수식 — 옛 `B08_Quantity_Formula.ts` 되살려 공용 · 열 참조 · 셀 참조 · SUM · INT · ROUND · 순환 막기 · 화면 즉시 + [저장] 때 서버 Node 재계산(5장 ②) · `package.json` 깨진 `build:formula` 고침 — `ui_template_sheet_recalc.ts` 한 벌 · `[$이름]` · ROUNDUP · 합계 줄 열마다 식 · 서버 `common_util_sheet_recalc.py` → `common_util_sheet_recalc_node.ts`(sheet 폴더 밖 — M02 화면이 폴더를 훑어 불러옴)
- [X] 저장 JSON — `3_표양식.md` 4장 초안(층 · 열 id · 식 · 줄 · 합계줄 · 보기) — 모양 `ui_template_sheet_types.ts` · 계약 칸(설명 · 바인딩 · 일위대가 · 펼침 · 손 · 변수 · 쪽줄 · 고정)
- 검증완료 — `test_sheet_recalc.py` 5 개(실무 울진 2공구 합계 캐시값 · Decimal 거울 40 문서) · 포트 5178 M02 메인 칸에 붙여 머리 합치기 · 마스터 조작 · 쪽 50+11 · 잠금 · 손 열 합계 · 틀고정 · 너비 끌기 · 화면 풀이 = 서버 Node 풀이(계산 칸 183 · 합계)
### 10-3. 도면 양식
- [X] B07 의 iframe · 주고받기 부모 흐름을 공용으로 떼어 M02 · B07 한 벌 · 도각 편집 모드로 양식 만들기 · 저장 · 목록 — `ui_template/cad_host/` · `mountDrawingTemplate`(getDoc 은 Promise) · 목록은 10-1
- [X] 양식 장부(이름 · 종류 · 크기 · 작도 영역 · 자리표 키 · 판) · 자리표 키 목록 보이기 · 작도 영역을 양식 칸으로(지금 `_A1_INNER` 박힘) — 양식 칸 `drawing_area` · `GET /api/m02/drawing-fields` · 이름 · 종류 · 판은 10-5 층 목록 · 크기 칸은 안 둠(작도 영역으로 갈음)
- [X] 도면 기본 양식 `resources/master_template/drawing/` 로 옮김 · B07 도각 읽는 차례 프로젝트 작업본 → 시스템 · [도각 편집] 저장 = 작업본 · [기본 도각으로] = `_initial/`
- 검증완료 — 시험 22 개 통과 · 포트 5177 B07 도형 수 그대로(표지 15 · 종단 800 · 평면 401 · 토적 287 · 표준횡단 102) · 도각 편집 열기(51) · 취소 · M02 부품 붙여 도각 51 · getDoc 51 · 자동백업 칸 `m02:*` 따로
- [X] 옛 회사 도각 → 회사 층 `storage/1/templates/drawing/00_template_A1.json` 복사(원본 그대로) · M02 도면 칸 메인 꽉 채움
- 검증완료 — 층 길 회사 목록에 보임 · [회사 양식 적용] 으로 프로젝트 작업본 덮임 · B07 도면이 그 도각으로 그려짐(표준횡단 102 · 토적 287) · [도각 편집] 저장 = 작업본(`_initial/` 그대로) · [기본 도각으로] 뒤 백업과 같음 · M02 자리표 18 · DXF 불러오기 3 · CAD 캔버스 495×571
### 10-6. 최종 확인 뒤 개선 (사용자 「반영하고 검증」)
- [X] sub1 층 단추 눌림 표시 · 한 줄 · 프로젝트 기본값(마지막 고른 것 → 내 최근 것) — 검증완료(브레인 ORCA 5173 · 시스템 단추 채움색 · 글 한 줄)
- [X] sub2 CAD 머리 글 · 저장 단추 하나 · 자동백업 칸에 층 — 검증완료(브레인 ORCA · 머리 「도면 양식 편집」 · 저장 1 · 백업 칸 m02:system · m02:project:id 갈림 · CAD 695×790)
- [X] sub3 딱지(계산 · 설계값 · 값마다 · 손) · 식 줄 이름으로 · 열 폭 · 풍선 · 50줄 쪽 나눔 시험 — 검증완료(`test_sheet_pages.py` · `test_sheet_recalc.py` 8 개 · ORCA 5178 시스템 집계표 딱지 A·줄눈·커플링 = 계산 · 식 줄 「B × L+확폭」 · 머리 잘림 0 · 프로젝트 층 줄 더하기 38 번 → 55줄 쪽 50 · 5 · 합계 2쪽만 · 다시 열기로 17줄 되돌림)
- [X] sub3 머리 칸 「/」 로 층 나눔 · 줄 머리 칸 가로 고정 · 줄 머리 칸 폭 자동 — 검증완료(`test_sheet_pages.py` 9 개 · ORCA 5178 시험 양식 새 열 「관공/Φ800/관매설」 세 층 → 저장 · 다시 열기 그대로 · 끝까지 넘겨 줄 머리 칸 9 개 왼쪽 고정 · 잘림 0 · 맨 위 · 시험 양식 지움)
- [X] sub3 수 칸에 수 아닌 글 → 오류 색 + 「수가 아님」 풍선 · 합계 칸에 「수가 아닌 칸 N 개는 빼고 더함」 — 검증완료(`test_sheet_recalc.py` 6 개 · ORCA 5178 프로젝트 층 abc · 12abc · - 오류 / 5 · 1,234 · 1e3 · 빈 칸 · 공백 · =1+1 정상 · 글 칸 abc 표시 없음 · 연달아 10번 바꿔도 알림 따라감 · 다시 열기로 되돌림)
- [X] sub4 관공 관경마다 짝 · 빈 값 글 · 계산 열 바인딩 떼기 · 옛 프로젝트 표 틀 갱신 — 검증완료(시험 21 개 · ORCA 5179 랩탑_보조 채운 표 머리 = 파형강관 Φ800 · Φ1000 · Φ1500 마다 관매설 · 커플링밴드 짝 · 「높이 미입력」 · 「Φ1000×련수 미입력」 · 「쌓기 방식 미입력 60~80 H=2.5」 · 커플링 칸 is-calc(NO.4+5.05 관 10 → 1) · 옛 프로젝트 6 개 작업본 · `_initial` 12 파일 = 시스템 판 d43943e2)
- [X] 브레인 ORCA 다시 확인 — 검증완료(랩탑_메인 오류 칸 0 · 관매설·커플링 짝 · 합계 50·5·11·1·21·2·10·1 · 랩탑_보조 다시 열기·양식 오가기 오류 0 · 틀린 판 저장 409 · 파일 안 바뀜)
- [X] 재검증(사용자 「마스터 양식 집중 · 재검증」) — 도면 저장 {} 흠 · 빈 문서 400(시스템 · 층) · 머리 / 층 나눔 · 줄 머리 칸 고정 · 저장 뒤 지우기 판 맞춤 — 검증완료(브레인 ORCA · DXF 선1·글자1 · 작도 영역 820 남음 · 스크롤 끝 줄 머리 left 345 그대로 · 저장 뒤 지우기 한 번 · storage 양식 74 파일 깨진 것 0)
- [X] 전수 점검 목록(`tmp/M02_분석/8_점검목록.md`) 두 바퀴 — 흠 10(이름 까닭 · / 이름 · 저장 안 한 고침 · 머리 메뉴로 떠남 · 개인 지우기 · 덮을까 · 저장 뒤 양식 닫힘 · CAD 💾 · 엉터리 파일 500 · 수 칸 글) 고침 — 검증완료(브레인 ORCA 목록 전 항목 됨 · 시험 통과 · 타입 검사 0 · 시험 양식·개인 파일 0 · 랩탑_메인 초기화 _initial 과 같음)
### 10-7. M02 = 시스템 층만 · 도면 칸 위 공간 줄이기 (사용자 바로잡음)
- [X] sub1 좌측 패널에서 층 단추 넷 · 프로젝트 고르기 · 프로젝트 층 단추 다섯 뺌 · 목록 = 시스템 표 · 도면 + 새로 · 본떠 · 지우기 · 제목 줄에 도면 칸이 쓸 자리(headerSlot)
- [X] sub2 도면 칸 — 위 자리표 글 줄 뺌(CAD 자리표 패널과 중복) · CAD 제목 줄 숨김 · 작도 영역 · 파일 불러오기는 제목 줄 한 줄로 · 그림 칸 위쪽 448px → 250px 안쪽
- [X] 브레인 점검 목록 재실행(층 항목은 서버 시험으로) — 검증완료(좌측 = 표 · 도면 목록 + 새로 · 본떠 · 지우기 · 그림 칸 위 448 → 246px · 높이 598 → 800 · 작도 영역 단추 값 다 보임 · 저장 815 남음 · 틀린 값 막힘 · 확인창 · 표 항목 됨 · sub1 창 입력 거절로 sub2 가 대신함)
### 10-5. 양식 층 · 복사 · 가져오기 (사용자 확인)
| 층 | 자리 | 누가 씀 |
| -------- | ------------------------------------------------------------------------------------- | ---------------------------- |
| 시스템 | `resources/master_template/{table,drawing}/` (git) | M02 시스템 관리자 |
| 회사 | `storage/{회사}/templates/` (B07 도각이 이미 여기) | 회사 관리자 — 「회사 공식」 |
| 개인 | `storage/{회사}/{사용자}/templates/` | 본인 |
| 프로젝트 | `storage/{회사}/{사용자}/{프로젝트}/templates/` + 초기 사본 `templates/_initial/` | 프로젝트 사용자 |
- [X] 프로젝트 만들 때 시스템 양식 전부를 프로젝트 `templates/` 와 `_initial/` 에 복사 · manifest 에 출처(층 · 이름 · 판) · `_initial/` 은 그 뒤 안 씀(5장 초기값 규칙) — `M02_Template_Layers.py` · 층 폴더마다 `manifest.json`
- [X] 이미 만든 프로젝트 — storage 의 프로젝트 폴더마다 시스템 양식을 작업본 · `_initial/` 로 넣음(한 번 · 도구로) · 목록을 사용자에게 보임 — `M02_Template_Migrate.py` · 6개 넣음 · 목록 `tmp/M02_분석/7_기존프로젝트_양식.md` · 표 1 + sub2 도면 5 (두 번 돌림 · 없는 것만 더함)
- [X] [초기화] = `_initial/` 을 작업본에 덮어씀 · [회사 양식 적용] = 회사 공식을 작업본에 덮어씀 · [양식 가져오기] = 시스템 · 회사 공식 · 같은 회사 사람의 개인 양식(읽기만) · 같은 회사 다른 프로젝트 양식 → 작업본을 바꿈(초기화 기준은 안 바뀜) — 서버 길 `M02_MasterTemplete_Router_Layers.py` · 단추는 10-1
- [X] [내 양식으로 저장] → 개인 층 · [회사 공식으로 저장] → 회사 층(회사 관리자만) · 읽는 차례 = 프로젝트 작업본 → 없으면 시스템(옛 프로젝트) — 회사 관리자 = ADMIN · 마스터 · 시스템 관리자
- 검증완료 — 시험 10 개 통과(임시 storage · 층 길 · 복사 · 초기화 · 회사 적용 · 가져오기 · 권한 · 넣기 도구) · 실제 storage 6 프로젝트 넣기 뒤 작업본 · `_initial/` · manifest 확인
### 10-4. 구조물 집계표 마스터 · 프로젝트 복사
- 열 규칙 확정 — `tmp/M02_분석/5_집계표_열규칙.html` (머리 4층 · 설계값 묶음 21 · 손 입력 10 · 계산 열 4 · 일위대가 후보) · **한 표**(집계 · 그외 안 나눔) · 「전구간」 = 표 맨 위 손 입력 한 줄 · 오솔길엔 구조물 집계표 없음(P&B 입력은 도면 글자만 · 횡단 물량표는 토공만)
- [X] 구조물 집계표 마스터 한 벌 — 위 열 규칙대로 · 종류는 `B05_Profile_Structure_Types.json` 을 가리킴 — `resources/master_template/table/구조물집계표.json` · 채우기 `M02_Table_Fill.py`(`GET …/tables/{이름}/filled`)
- [X] 프로젝트 만들 때 마스터 복사(`B02` `_initialize_project_storage` 끝 · `{프로젝트}/templates/` · manifest 에 판) · 읽기 차례 프로젝트 → 마스터 · [기본으로] = 마스터 다시 복사(`apply` `from: system`)
- 열 규칙과 다른 곳 — 관보호공은 「유입」 한 칸(설계에 유출 보호공 칸 없음 · 유출은 기슭막이 열) · 큰돌쌓기는 찰·메 · 돌 크기 · **높이**까지 펼침(면적 L×H 를 표 식으로) · 흄관 · VR관 1본 길이 빈칸 → 커플링 식 없음
- 검증완료 — 시험 5 개 통과 · 랩탑_보조 채운 표 = 설계와 같음(관 Φ800 6 · Φ1000 1 · Φ1500 2 · B06 관 연장 9곳 · 세월교 2 · 옹벽 반중력식 H2.0 · 돌쌓기 찰 H2.5 2 · 메 H2.0 · 메 H2.5 · 큰돌쌓기 1 · 줄 16)
- [X] 프로젝트 표 [저장] 거름 — 층 길 PUT 에서 서버가 `strip_design` · 작업본 = 양식(펼침 규칙 열 · 식 · 변수 · 머리) + 손 값(측점 줄 키) + 전구간 줄 · 채울 때 설계에 안 쓰인 펼침 열도 `양식열` 로 실어 저장 때 되살림 · 없어진 측점 손 값은 맨 아래 사용자 줄(비고 「설계에서 측점 없어짐」)
- 검증완료 — 시험 18 개 통과 · 랩탑_보조 채움 → 손 입력 둘(취수정 NO.4+5.05 · 비고 NO.8+13.09) 저장 → 작업본 열 49 · 펼친 열 0 · 설계값 0 · 줄 3 → 다시 열기 둘 다 제자리(관 연장 10 옆) → 초기화 = `_initial` 과 같음
## 11. M02 개선 — 컨테이너 · 롱기튜디널 지우기 · 구조물 도면 · 산출근거 (담당 sub1 11-1 · sub2 11-2 · sub3 11-3 · sub4 11-4)
사용자 지시 — 마스터 템플릿 = 표 양식 · 도면 양식 두 구조 · 그룹은 컨테이너 · 롱기튜디널 지움 · 집계표 일위대가 아래 도면 줄(도번 연동) · 값을 고르면 아래에 설계 컨테이너 + 상세 산출근거 표 · [도면 수정] 으로 도면 편집 · 산출근거는 엑셀과 같은 빈 표.
### 11-0. 사용자 확정
- 구조물 도면 · 산출근거 표 = **집계표 열마다 하나** · 펼침 열(관 지름 · 돌쌓기 높이)은 마스터 열 하나에 도면 하나(프로젝트에서 펼친 규격들이 같은 도면을 씀)
- 도번 = 자동(구조물 도면을 만든 차례로 `구-01` …) · 도면이 없으면 도면 줄에 「없음」
- 설계 컨테이너 = 그 열의 구조물 도면 미리보기(읽기만) + [도면 수정] · 아래에 상세 산출근거 표
- 롱기튜디널 두 양식 = 시스템 · 이미 만든 프로젝트 6곳 작업본 · `_initial/` 사본까지 지움
- 저장 = 새 폴더 `resources/master_template/structure/` · 좌측에서는 「도면 양식」 컨테이너 안 「구조물 도면」 묶음
- 산출근거 빈 표 머리 = 실무 `07-구조도-소광리.xlsx` 꼴(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)) · 줄은 비움 · 채우기는 나중
### 11-1. 좌측 컨테이너 · 롱기튜디널 지우기
- [x] 좌측 「표 양식」「도면 양식」 = M01 좌측과 같은 접히는 컨테이너(`ui-collapsible ui-sidebar-section` · `attachCollapsible`) · 「도면 양식」 안에 「구조물 도면」 묶음(11-3 목록)
- [x] `01_template_Longitudinal_table.json` · `01_template_Longitudinal_Yaxis_scale.json` 지움 — 시스템 + 프로젝트 6곳 작업본 · `_initial/` · manifest 줄(한 번 도는 정리)
- 검증완료(11-1): M02 시험 38 통과 · ORCA 5174 컨테이너 접고 펴기 · 목록에 롱기튜디널 없음 · 정리 전후 templates 파일 84→60(프로젝트 6곳 · 파일 24 · manifest 12) · SideHandle.select(kind, name) 추가 · 검증 대기
### 11-2. 집계표 도면 줄 · 칸 고르기 신호
- [x] 마스터 전용 줄(식 · 설명 · 일위대가) 아래 「도면」 줄 — 열의 구조물 도면 도번 · 없으면 「없음」(`ui_template/sheet/`) · 칸 글 = `SheetOptions.drawingLabel(colId)` · 글이 바뀌면 `recalc()`
- [x] 칸을 고르면 페이지로 알림(`SheetOptions.onSelect({ row, col, colId })` · row = 줄 열쇠 · col = 열 차례) · 표 부품은 한 벌(M02 · 뒤에 프로젝트 화면이 같이 씀)
- 검증완료(11-2): 시험 12 통과 · typecheck · ORCA 5177 구조물집계표 일위대가 아래 도면 줄 49칸 「없음」 · 잠김 · 줄 머리 고정 · 방향키 일위대가↔도면↔첫 줄 · 시험 표에서 라벨 · recalc 다시 그림 · onSelect(누르기 · 방향키 · 머리) · project 에 도면 줄 없음 · 검증 대기
### 11-3. 구조물 도면 폴더 · 서버
- [X] 양식 종류에 `structure` 더함 — 열 id 로 찾는 도면 + 산출근거 표 짝 · 도번 자동 · 목록 · 새로(A1 도각을 깐 빈 도면) · 저장 · 지우기 · 새 프로젝트 복사에도 실림
- 서버 주소 · 함수(`M02_MasterTemplete_Api_Fetch.ts`) — 목록 `GET /api/m02/templates` → `listStructures()` · 읽기 `GET /api/m02/templates/structure/{열 id}` → `readTemplate("system", "structure", id, null)` · 저장 `PUT` 같은 주소 → `saveTemplate("system", "structure", id, null, 판, 문서)` · 새로 `POST /api/m02/structures/{열 id}` → `createStructure(id)`(이미 있으면 `StaleError` · 없는 열 404) · 지우기 `DELETE` 같은 주소 `?판=` → `deleteTemplate("structure", id, 판)` · 도번 목록 `GET /api/m02/structures/numbers` → `fetchStructureNumbers()` = `{열 id: 도번}`(키 없음 = 「없음」) · 문서 타입 `StructureDoc`
- 검증완료 — 시험 42 개 통과(새 `test_m02_structure.py` 4 + 기존 M02) · 8005 서버 직접 호출: 새로 구-01 · 다시 409 · 없는 열 404 · 도각 개체 51 · 산출근거 머리 다섯 · 저장 · 도번 목록 · 층 목록 · 지우기 · 시험 파일 지움
### 11-4. 설계 컨테이너 · 산출근거 빈 표 · [도면 수정]
- [x] 집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기(읽기만) + [도면 수정](없으면 새로 만들어 엶 · 같은 페이지 CAD 편집으로 옮김 · 저장 안 한 고침 확인창)
- [x] 그 아래 상세 산출근거 표 — 11-0 머리 · 빈 줄 · 저장
- 11-4 코드 = 9d1a4f9b(dsub) · M02 시험 14 통과 · typecheck · ORCA 5174 화면 검증완료(도번 구-01 · 산출근거 저장 · 열 바꿈 · 확인창) · 7e694a88 · 검증 대기
- 브레인 임의 판정: 설계 컨테이너 미리보기는 읽기만이라 CAD 도구 모음을 숨김 · 243d00c2 검증완료(미리보기 리본 display:none · 편집 화면 높이 122)
### 11-5. 좌측 패널 공용 틀 (담당 sub1)
- [x] M01 카드 · 항목 모양을 `ui_template_overlay.css` 공용 클래스로 · M01 · M02 좌측이 같이 씀 · 처음 펼침 · 접힘 = M01 과 같게 · 새로/본떠/지우기 단추 줄은 칸 안 · 확인 = ORCA 로 M01 · M02 나란히 수치 대조(사용자가 보고 판단)
- 검증완료(11-5): ef5a7e97 · ORCA 5174 M01 · M02 머리 14px · 여백 12 · 둥글기 8 · 선택 표시 · 처음 접힘 같음 · M01 전후 같음 · 새로 · 지우기 됨 · 사용자 판단 대기
### 11-6. 구조물 도면 컨테이너 따로 · 빈 화면 (담당 sub1)
- 사용자 바로잡음: 구조물도와 도면 양식은 다른 개념 — 좌측 「구조물 도면」 은 「표 양식」「도면 양식」 과 같은 층 컨테이너(11-0 「도면 양식 안 묶음」 을 갈음) · 비어 있으면 안 됨
- [x] 좌측 컨테이너 셋(표 양식 · 도면 양식 · 구조물 도면) · 구조물 도면 = 구조물집계표 열마다 한 줄(도면 없어도 열 이름 + 「없음」)
- [x] 사용자 확정: 빈 도면도 열마다 미리 만들어 도번(구-01 … 열 차례) 연결 · 집계표에 열을 더해 [저장]하면 그 열 빈 도면도 만들어짐
- [x] 집계표에서 열을 고르면 아래에 컨테이너 둘(공용 접힘 카드) — 「도면 정보」(열 · 도번 · 미리보기 · [도면 수정]) · 「상세 산출근거」(표)
- 사용자 바로잡음: 구조물 도면 항목 = 도면만(산출근거 없음) · 구조물도는 도각 없음(그린 만큼 설계 영역)
- 검증완료(11-6): 65f43ca0 · 열 49 빈 구조물 도면(도각 없음) · 열 더해 저장 → 서버가 만듦 · 좌측 줄 누르면 CAD · 시험 49 · typecheck 0 · 흠: 도각 없는 빈 CAD 열면 빨간 오류 알림(도면 양식 새로도 같음 · 전부터)
### 11-7. 산출근거 엑셀 · 구조물 도면 다듬기 (검토 중 · sub2 엑셀 · sub4 구조물 도면)
- 사용자 확정: 산출근거 = 엑셀 · 구글 시트와 같은 자체 스프레드시트(A00_Common 새 폴더 · 시간 걸려도 됨) · 집계표 일부를 가져오는 것 아님
- 사용자 확정: 도면 정보 미리보기에 CAD 특성 · 도면층 패널 없음 · 마스터 CAD 리본 처음 접힘
- [x] 깊은 검토 — 엑셀(tmp/M02_분석/9_엑셀검토.md) · 구조물 도면(10_구조물도면검토.md) → 사용자 판단 뒤 계획
- 사용자 확정: 엑셀 파일 가져오기 안 함 → 엑셀 · 구글에서 복사해 붙여넣기 · 인쇄 · PDF · 내보내기는 비활성(나중에 한 번에) · 산출근거 = 구조물집계표 열마다 따로 파일 · 도면 치수 참조는 따로 협의
- [x] 구조물 도면 다듬기 — 미리보기 특성 · 도면층 숨김 · 도면층 없는 도면 오류 가드(sub1 마무리 중) · 마스터 CAD 리본 처음 접힘 · 편집 제목 열 머리 글(10_구조물도면검토.md 일감 A · C)
- 검증완료(11-7 다듬기): 리본 처음 접힘 26px · 단추로 펼침 122px · B07 펼침 그대로 · 제목 구-01 · 열 머리 글 · 특성 패널 숨김 · import 가드(sub1) · 서버 · 화면 새 문서 기본 도면층 + 49 파일 채움 dd7300aa
### 11-8. 산출근거 스프레드시트 1단계 (A00_Common/spreadsheet · 근거 9_엑셀검토.md)
- [x] 0 계약 — 타입 · 엔진 공개 함수 머리 · 저장 JSON 견본(열마다 파일) · 브레인 승인 뒤 A~F 병행
- 검증완료(0 계약): 26fd4fb7 · spreadsheet_types.ts · spreadsheet_view_types.ts · 겉모양 함수 · 견본 JSON · 크기 예산 압축 뒤 54 KB · 저장 resources/master_template/basis/<열 id>
- [x] A 엔진(주소 · 수식 풀이 · 의존 재계산 · 순환 · 행열 넣기 참조 옮김 · 되돌리기) · B 함수 약 45 · 숫자 형식 · 채우기
- [x] C 격자 그리기(병합 · 틀 고정 · 숨김 · 테두리) · D 입력(한글 · 수식 입력줄 · 참조 가리키기 · 채우기 끌기)
- [x] E 도구 · 시트 탭 · 클립보드(엑셀 · 구글과 서로 붙여넣기) · F 서버 같은 계산 · M02 끼우기 · 실무 수식 2,699칸 값 대조 시험(시험만 xlsx 읽음)
- 담당: A sub3 · B sub5 · C sub6 · D sub7(잇기) · E sub8 · F sub2(임시 [스프레드시트 시험] 단추 · 금빛 시험)
- 검증완료(A): 736dac4e · 엔진 · 오류 · 순환 · 딸린 칸만 재계산 · 행열 참조 옮김 · 되돌리기 · 규칙표 시험 9 묶음 · 압축 10.5 KB
- 검증완료(D): 고름 · 편집기 · 한글 조합(compositionstart) · 수식 입력줄 · 주소 상자 · 참조 가리키기 · 단축키 · 채우기 끌기 · 되돌리기 · 진짜 A 엔진과 잇기 · 규칙 시험 7 묶음 · ORCA 시험 틀 · 압축 약 10 KB · C · B 채우기 · E 는 빈 몸이라 가짜로 잇고 들어오면 다시 잇기
- 검증완료(D 잇기): 가짜 부품 걷고 A · B · C · E · 2단계와 createSpreadsheet 잇기 · 찾기 패널(Ctrl+F · Shift+F5 · Ctrl+H) · 메모 풍선 · Shift+F2 · 서식 붓 · 골라 붙여넣기 · 폭 자동 맞춤 · 함수 자동 완성 · M02 [스프레드시트 시험] ORCA 한 바퀴 수치 · 크기 시험 넘침(locale 약 45 KB · 엔진) 브레인 보고
- 검증완료(D 잇기 2): 정렬 · 필터 · 셀 서식(Ctrl+1 · [서식…]) · 조건부 서식 · 유효성(막음 · 목록 ▼) · 이름 상자 · 눈금선 · 여러 범위 · 수식 분석 · 자동 합계 · 상태 표시줄 · 시트 숨김/탭 색 · M02 ORCA 수치 · 크기 시험 통과(화면 27.2 KB) · 묶어 접기(sub6) 안 옴
- 검증완료(D 잇기 3): 칸 넣기/지우기 · 이동(F5 · Ctrl+G) · 틀 고정 · 데이터 도구 3 · 묶기/풀기(Shift+Alt+→/←) · 단축키 표 「안됨」(Ctrl+B/I/U/5 · 바깥 테두리 · Ctrl+; · Ctrl+9/0 · Ctrl+PageDown · Shift+F11 · F4) · 이름 풀이 · 자동 완성 105 · 숨긴 시트 · 탭 색 · 필터 행 숨김 · M02 ORCA 수치 · 크기 시험 통과(화면 27.0 KB · 합 60.9 KB) · 조건부 서식 색 · 초록 세모(sub6) 안 옴
- 검증완료(D 잇기 3 끝): sub6 조건부 서식 칸 색 · 초록 세모 · 필터 행 숨김 M02 ORCA 수치 · 골라 붙여넣기 늦게 불러 크기 시험 통과(화면 27.6 KB · 합 61.5 KB)
- 검증완료(D 식 오류 막음): 편집기 확정 때 parseFormula 실패면 편집 유지 · 오류 알림 · 커서 오류 자리 · M02 ORCA =SUM() 막힘 · =SUM(1,2) → 3 · 크기 시험 통과
- 검증완료(D 크기 줄임): sub2 · sub8 병합 뒤 화면 30,126 B → 28,102 B(예산 28,672) — 함수 도움말 · 이름 상자/관리 · 서식 붓 · 메모 편집 · 열 너비 맞춤을 처음 쓸 때 불러옴 · M02 ORCA 산출근거 카드 · 편집 화면 · 도구 모음 기본 글꼴(굴림/10 흐리게) 확인
- 검증완료(B): 7725b39e · 함수 48 · 숫자 형식 해석 · 채우기 · 시험 69
- A 더함: 뺀 셋(행 넣기 서식 따라감 · 자동 교차 · Sheet.기본) · Sheet.comments · 무게 시험 4 통과(sub3)
- 검증완료(E): bfd71238 · 도구 · 탭 · 우클릭 · 클립보드(안↔안 · 구글 R1C1 · 엑셀 수식 표시) · 가짜 A · B 로 ORCA · 실제 엑셀 앱 시험은 없음(앱 없음)
- 검증완료(D): 48a21351 · 한글 첫 글자 · 수식 입력줄 · 참조 가리키기 · 방향키 · 채우기 끌기 · 되돌리기 · 시험 7 · 가짜 부품으로 · 편집 좌표 = 판 안 기준(브레인 판정)
- 검증완료(F): 85fa62ae · M02 카드 · 임시 단추(inspect) · 금빛 시험 실무 수식 2,693칸 대조 갈림 0 · B 뒷일 b93e03e5(moveFormula 잇기 · 실무 숫자 형식 66 대조 · 흠 4 고침 · 분수 형식 없음)
- E 뒷일 fddaa4ed · 진짜 엔진으로 다시 · 엑셀 · 구글 HTML 견본 붙여넣기 시험 · 실제 앱 복사는 못 함(엑셀 없음 · 구글 창 포커스) · 2단계 메모 0d2d5614(sub1)
- 검증완료(C): 7d9fc6ee · 격자 4판 · 병합 · 틀 고정 · 숨김 · 폭 끌기 · hair 선 · 1만 행 스크롤 칸 수 일정 · data-addr · 메모 세모
- 잇기 ce3df392(sub7) · M02 시험 화면에서 한글 · 재계산 · 되돌리기 · 채우기 · 서식 · 값만 붙여넣기 · 바꾸기 · 메모 · 서식 붓 · 폭 맞춤 · 정렬 · 필터(sub3) · 브레인 판정: 크기 예산 엔진 32 KB · 화면 28 KB 그대로(공용 글자표 뺌)
- 3단계 더함: 조건부 서식 · 유효성 · 이름 정의(sub4) · 분수 형식 · 함수 30(d60b1ccc · 시험 135 · sub5) — 잇기는 sub7
- 셀 서식 창 Ctrl+1 여섯 탭 · 단축키 표 53(sub1 · e1fc529f)
- 격자 2단계 327226e2(sub6 · 확대 10~400% · 눈금선 · 여러 범위 · 고름 테두리 비낌 고침) · 통계 13 · 날짜 9 · 날짜 형식(sub5 · 시험 180 · EDATE 월말 흠 고침)
- 크기: 시험 단추 먼저 눌러도 63.3 KB · 213 KB 조각 안 받음(sub2 · 4e7f2e2c) · 엔진 31.4 · 화면 27.1 · css 3.9 = 62.5 / 64 KB
- 흠 고침: 이름 풀이 610ac856(sub3) · 자동 완성 105 함수(sub5) · 숨긴 시트 · 탭 색 ddfff14f(sub8) · 데이터 도구 · 칸 삽입 창 · 이동 · 틀 고정 메뉴 de94486a(sub1)
- [x] 2단계(인쇄 · 내보내기 뺌) — 찾기 · 바꾸기 · 메모 · 골라 붙여넣기 · 서식 붓 · 폭 자동 맞춤 · 여러 범위 · 확대 · 눈금선 끄기 · 함수 자동 완성 · 도움말(sub1)
- [x] 브레인 브라우저 검증 — [스프레드시트 시험] 단추 · 전수 목록 + 예외 입력 → 임시 단추 지움
- 검증완료(브레인 · 메인 5173 · 9-27 23시): 점검 39 + 예외 12 · 1차 흠 10 → 고침(상태 표시줄 글색 · 확대 % · 먼 칸 스크롤 · 자동 맞춤 255자 · ##### · 틀 풀기 · 엑셀 서식 붙여넣기 · 병합 일부 막기 · 인자 수 막기 · Enter 막기) · 2차 통과 · 남은 작은 것: 도구 모음 글꼴 칸이 기본 글꼴일 때 빔 · 임시 [스프레드시트 시험] 단추는 사용자 확인 뒤 지움
- [x] 사용자 지시(9-27 밤): 임시 시험 단추 지움 · 산출근거 카드 빈 격자 늘 보임 · 도면 정보 미리보기 카드 안에 맞춤(sub2) · 도구 모음 기본 글꼴 표시(sub8) · 뒤에 전역 깃 싱크(각 창 폴더까지) · 엑셀 실제 복사는 사용자가 확인
- [x] 사용자 지시: 산출근거 카드 행 늘려 높이 키움 · 좌측 「산출근거」 컨테이너(구조물 도면과 같게 · 열마다 · 표번 표-01 자동) · 수정은 별도 편집 컨테이너(좌측 줄 · 카드 [산출근거 수정])(sub2)
- 검증완료(9-28): 임시 단추 지움 · 빈 격자 30행 · 도면 정보 넘침 · 표번 · 좌측 산출근거 · 전체 편집 격자 높이(e4eef2a4) · 기본 글꼴 흐리게(cd7a81a4) · 크기 62.6 KB(87417b3a) · 전역 깃 싱크 = 랩탑 메인 + sub1~8 트리 53bbad2e 같음 · 데스크탑 넷은 원격만 옮김(PC 꺼짐 · 켜면 받기)
## 12. M02 구조물 번호 체계 · 집계표 줄 · 스프레드시트 다듬기 (담당 sub4 · sub2 · sub5 · sub6 · sub7 · sub8 · sub1)
- 사용자 확정: 도번 SD · 표번 SC + 숫자 6자리 [종류 2][분류1 2][분류2 2] · 없는 층 00 · 한 번 준 번호 유지(새 열은 그 무리 다음 번호 · 지운 번호 비움) · 조회 후 선택 = 다른 열 것을 복사해 가져옴(열마다 따로 동작 · 같이 쓰기 없음)
- 사용자 확정: 집계표 제목행 = 종류 · 분류1 · 분류2(글 자유) · 같은 종류에 분류1 더하기 · 마스터 줄 = 일위대가 · 도면 · 산출근거(조회 후 선택) · 열 더함 = 도번 · 표번 · 파일 자동 · 열 지움 = 같이 지움 · 빈 산출근거 = 진짜 빈 시트 · 산출근거에 시트 탭 없음
- 브레인 임의 판정: 구조물 = 꼴=수 열만 · NO · 측점 · 비고 같은 글 열은 번호 · 파일 없음
- [x] 서버 번호 · 자동 만들기/지우기 · 옛 50 파일 새 번호로 옮김 · 빈 산출근거 · 복사 가져오기 길 · 좌측 「번호 + 구조물 이름 + 변수값」(sub4 · 3a4b3066 · fc249c00 · 2623633b · 시험 29 · SD010101~SD360000 · 46+46)
- [x] 집계표 제목행 이름 · 분류1 더하기 · 산출근거 줄 · 세 줄 조회 후 선택(sub2)
- 검증완료(12 sub2): 시험 34 통과 · typecheck · ORCA 5177 층 이름 종류 · 분류1 · 분류2(두 번 눌러 고침) · 「분류1 더하기」 = 같은 종류 끝에 [종류, 새 분류, 새 열] · 산출근거 줄 SC 번호 · 글 열(NO · 측점 · 비고) 세 줄 빈칸 · 잠김 · 일위대가 목록 37 찾아 고름 · 도면 복사 = 다른 열 45(자기 · 글 열 빠짐) → 덮어쓰기 확인 → 도번 · 열 그대로 내용만 같아짐(파일 되돌림) · 검증 대기
- [x] 스프레드시트 테마 연동(sub6) · 엑셀 붙여넣기 서식(스타일 묶음 · 선 모양 · 색)(sub8) · 엑셀 이름 형식 Fixed · Standard · Currency 등(sub5) · 산출근거 시트 탭 없앰 · [산출근거 수정] 느림 원인(sub7) · 집계표 항목 고른 뒤 CAD 불러오기 오류(sub1)
- 검증완료(12 스프레드시트 쪽): 테마 ee64781a · 붙여넣기 서식 b2e2fcff · 이름 형식 4bbef4ac(₩ 는 재현 안 됨 · 실제 복사 내용 필요) · 시트 탭 없앰 · 편집 화면 0.08초(느림 재현 안 됨 · 옛 높이 0 흠 추정) · CAD 오류 22835ca9
- 검증완료(sub7 산출근거 탭 · 느림): createSpreadsheet tabs:false — 카드 · 전체 편집 탭 줄 없음 · 시트 단축키 · 시트 메뉴 꺼짐 · [산출근거 수정] 5182 실측 75~83 ms(격자 만들기 ~50 · 서버 읽기 19~24 · 새 조각 0) · 찬 서버 카드 0.2 s — 느림 재현 안 됨
- 조사(코덱스 · 9-28): 오토캐드 Ctrl+C → 웹 Ctrl+V 는 브라우저만으론 안 됨(전용 형식 · 임시 DWG 경로를 브라우저가 못 읽음) · 추천 = 오토캐드 .NET 전용 명령이 고른 개체를 DXF 글로 클립보드에 → 웹이 글 붙여넣기 → 기존 DXF 변환기(B07_DesignDetail_Engine_Frame_Import.py) 재사용 · 해치 · 치수 의미는 일부 잃음 — 착수는 사용자 판단
- 검증완료(12 · 브레인 · 메인 5173): 좌측 SD 47 · SC 47 줄 번호 맨 앞 · 옛 구-/표- 0 · 집계표 제목 종류 · 분류1 · 분류2 · 일위대가 · 도면 · 산출근거 줄 · 사용자 열 c1 = SD010102 로 살림(충돌 dbda85e8)
## 13. 집계표 분류3 · 면적산출 · 재료산출 · 알약 연결 (담당 sub3 조사 · sub4 서버 · sub2 집계표 · sub7 알약 · sub8 단위)
- 사용자 확정: 제목행 = 종류 · 분류1 · 분류2 · 분류3 · 번호 8자리 [종류][분류1][분류2][분류3] · 식 · 설명 줄 지움(식 3열 계산도 지움) · 산출근거 → 면적산출(SA) · 재료산출(SM) 줄 더함 · 도면 SD · 열마다 따로 엑셀
- 사용자 확정: 면적산출 엑셀 위 알약 = 그 열 일위대가의 입력값 · 칸을 골라 알약에 연결하면 알약 흐려짐(연결 현황) · 재료산출 알약 = 자재DB 에서 골라 칸 연결 · 엑셀에 단위 기호 고르기
- 사용자 알림: 분류2 더하기 안 됨 → 고침 · 우클릭 「열 더하기」 → 「분류2 더하기」 · 「분류3 더하기」 새로(sub2)
- [x] 일위대가 입력값 목록 · 자재DB 목록이 어디서 오는지 조사(sub3 · 13_알약자료.md) · 브레인 판정: 면적 알약 = 물량(결과단위 분모)만 · 같은 이름 · 단위 합침 · 설계 조건 빼고
- [x] 서버 — 8자리 · SA 로 옮김 · SM 새로 · 자동 만들기/지우기 · 복사 · 좌측 「면적산출」 · 「재료산출」(sub4) / 집계표 — 분류3 층 · 식 · 설명 줄 지움 · 면적산출 · 재료산출 줄 · 카드 · 편집 화면(sub2)
- 검증완료(sub2 집계표 몫) — 분류1 · 2 · 3 더하기 · 4층 · 줄 넷 · 카드 셋 · 복사 가져오기 · 알약 연결 · 풀기 · 저장 · 다시 열기(ORCA 5177) · 남음: 식 지움으로 test_m02_table_fill 둘 깨짐(sub4 서버 채우기)
- [x] 스프레드시트 알약 막대(sub7 · 연결 = Sheet.연결) · 단위 기호 고르기(sub8 · 74b6937b) · 알약 목록 fetchComboInputs · searchMaterials(sub3 · 32d73aa2 · ba91e9e6)
- 검증완료(sub7 알약): createSpreadsheet({links: {항목, 모드, 자재찾기}}) · 계약 Sheet.연결(시트에 둠 — 되돌림이 시트통째 한 길) · 시험 틀 harness_links 로 ORCA — 연결 · 흐림 · 주소 · 칸 세모 · 이동 · 우클릭 풀기 · 되돌리기/다시 · 행 넣기 밀림 · 열 지우기 빠짐 · 자재 찾기 · 고르기 · 연결 · getLinkedValues(식 칸 37.5) · 크기 화면 28,518 B
- 검증완료(13 · 브레인 · 메인 5173 · 사용자처럼): 좌측 SD · SA · SM 각 47 · 8자리 번호 맨 앞 · 제목 4층 · 줄 = 일위대가 · 도면 · 면적산출 · 재료산출 · 분류2/3/1 더하기 → SD02010600 · 02010601 · 02020000 + 파일 셋 · 열 지움 → 파일 셋 지움 · 면적 알약 10(UA000004) 연결 · 저장 · 다시 열기 · 풀기 · 재료 자재 찾기 · 연결 · 서버 계산값 · 단위 기호 · 테마 · 시트 탭 없음 · 복사 가져오기 · CAD 오류 0 · 전역 싱크 트리 2b125933(데스크탑 메인만 엑셀 잠금 파일로 대기)
- 검증완료(9-28 오후 · 브레인): ₩ 원인 = 엑셀 형식 \; 잘못 잘라 끝 역슬래시 → 굴림이 ₩ 로 그림 · 고침(e0f9df9b · 41a717f6 · c5ad429d) · 사용자 재료산출 표 ₩ 0칸 · 사용자 실제 복사본(87390278)엔 수식 없음 확인 · 크기 한도 128 KB · WEBCLIP(AutoLISP · 웹 Ctrl+V DXF · 흐린 안내) 브레인 붙여넣기 1→3 · 도면층 생김 · 되돌림 · 전역 싱크 트리 92ad16a8(랩탑 9 · 데스크탑 메인 · 보조 · 코덱스)
- [x] 사용자 확정: 엑셀 · 오토캐드에 파일 설치 안 함 → 엑셀 = 수식 복사 안내(흐린 글 · 알림)(sub8) · 오토캐드 = WBLOCK / DXFOUT 파일 끌어다 놓기 · LISP 지움 · 안내 바꿈(sub1)
- [x] 사용자 지시: 분류 없는 열도 제목 4칸(빈값 = 선택 없음) · 면적산출 · 재료산출 카드 2열 · 도면 정보 안 열림 원인(sub2)
- 검증완료(9-28 14:55 · 브레인): 분류 없는 열 제목 4칸(소단 rowspan 1) · 카드 = 도면 정보 한 줄 + 면적 · 재료 2열 · c4 · pv_b · sg 도면 정보 CAD 뜸 · CAD 편집 오른쪽 위 「오토캐드에서 WBLOCK(또는 DXFOUT)으로 저장 → 파일을 여기로 끌어다 놓기」 · 사용자 열 c2~c5 · rv 면적산출 표 살림(충돌 ec8812e8) · 전역 싱크 트리 eeb12002(랩탑 9 · 데스크탑 메인 · 보조 · 코덱스)
- [x] 시험이 실제 마스터 양식을 읽어 사용자 편집(rv 제목 바꿈)에 깨짐(test_m02_structure 5 · test_spreadsheet_store 1) — 시험을 견본 양식으로 떼기(sub4)
## 14. CAD · 스프레드시트 다듬기 · 구조물 조합 표 (담당 sub4 · sub1 · sub6)
- 사용자 확정: 집계표 단위 줄 정의 보류 · 일위대가 입력값 = 엑셀 칸 연결(이미 됨) · 마스터 집계표 = 프로젝트 복사 때 쓸 기본 정보 · 로직
- [x] 미리보기 CAD 특성 · 도면층 패널 숨김 원복 → 처음 접힘(panelsCollapsed) · 리본 펼친 뒤 밖을 누르면 접힘(sub4)
- [x] CAD 어두운 테마 자동색(7 · 검정 → 흰) · 바탕 · 격자 · 글 점검(sub1) / 스프레드시트 자동색(검정 글 · 선 · 흰 채움 → 테마)(sub6)
- [x] 사용자 확정: 「구조물집계표 조합」 = 집계표 열마다 한 줄(프론트엔드 그대로 · 서버가 집계표 저장 때 맞춤) · 브레인 판정: 키 SS000001~ · 파일 resources/master_template/구조물집계표조합.json · 구조물 열만(sub4 · 앞 일 뒤 이어서)
- [x] 좌측 패널 손잡이 본문과 안 겹치게(sub3) · CAD 가운데 단추 팬 때 페이지 안 움직임 · 미리보기 끌기 = 화면 옮기기만 · 안내 글 숨김(sub1)
- [x] 사용자 지시: [새로] · [본떠 만들기] · [지우기] 를 표 양식 · 도면 양식 컨테이너 안으로 · 본떠 만들기 기능 재확인(sub4 · 흠 셋 고침)
- [x] 사용자 지시: 구조물 도면(카드 · 편집)에서 작도 영역 없앰 · 도면 양식은 그대로(sub1)
- 검증완료(9-28 17:2x · 브레인 · 사용자 주소): 좌측 단추 = 표 · 도면 양식 컨테이너 안(밖 0) · 구조물 카드 작도 영역 없음 · CAD 뜸 · 카드 높이 561 셋 같음 · 메인 스크롤 없음(1024/1024) · 0,0 기준점은 sub1 시험 5 통과(화면 캡처는 ORCA 실패 — 사용자 눈 확인 거리) · 전역 싱크 트리 6153a6a7
## 15. 조합 · 자재 목록 고치기 · 소형 단추 · 머리 글 (담당 sub2 · sub3 검토)
- 사용자 확정: 같은 조합을 여러 열이 쓸 수도 · 안 쓸 수도 · 갈라 낸 조합을 또 고쳐도 매번 새로 · 새 이름 = 자동 + 모달에서 고침 · 키는 자동(UA · UM + 번호 · 본뜬키 칸으로 계보) · 새 조합은 M01 목록에도 보임
- 사용자 확정: 자재 목록 = 마스터 표 「자재 조합」(resources/master_data/자재조합.json · UM) · 고치면 일위대가처럼 모달 후 갈라 냄 · 분류 더하기로 만든 열은 일위대가 · 자재조합 · 도면 · 면적 · 재료가 원본을 따라감(뒤 고침은 따로) · 연결된 알약을 누르면 칸 하이라이트
- 사용자 요구: 면적산출에서 일위대가 조합(담은 로직) 고치기 · 재료산출에 자재 목록 표(더하기 · 빼기) · 고치면 모달로 「새 조합을 만듦」 알림 후 새로 만듦(자재 목록도) — 검토 먼저
- [x] 검토 — C:/Aislo_wt/sub_laptop_3/tmp/M02_분석/14_조합편집검토.md(sub3) → 사용자 판단 대기
- [x] 공용 소형 단추 size sm · M02 카드 · 좌측 컨테이너 · M01 표 안 단추 적용 · 상세 화면 번호 + 이름 잘림 없앰(sub2) — 검증완료(ff0d9775 · 검증 대기)
- [x] M01: 알약 키 이름|단위 · 본뜬키 칸 · 자재 조합 표(UM) · 화면 · 지우기 전 쓰는 열 검사(sub3) / M02 서버: branchCombo · branchMaterialCombo · 분류 더하기 따라가기(sub4) / 알약 막대: 자재 항목 호스트 콜백 · 연결 칸 하이라이트(sub7) / 화면: 조합 줄 · [조합 고치기] · 자재 목록 옆 판 · 모달(sub2) — 검증완료(sub3 b6e71c72 · 4c8ee682 · sub4 e76e6380 · 본뜬키 충돌 풂 625c1012 · sub2 e06d43a6 · 검증 대기)
- [x] 스프레드시트 메뉴 어두운 테마 검정 글 — 전수 대비 측정 · 테마 토큰(sub6) — 검증완료(2d70be32 · 브레인 재측정 대비 3 미만 0건 · 검증 대기)
- 검증완료(15 · 브레인 · 5177 IP · 사용자처럼): 조합 고치기 모달 [취소] 무변경 · [새로 만듦] UA000038(본뜬 UA000009 · 이 열만 · 옆 열 그대로) · 같은 내용 되돌림 UA000009 재사용 · 자재 조합 UM000001 → 두 번째 고침 UM000002(본뜬 UM000001) → 되돌림 재사용 · 이은 자재 빼기 확인창 · 저장 · 다시 열기 · 분류 더하기 c6 = UA · UM · SD/SA/SM 복사(연결 B3 포함) · 열 지우기 파일 셋 삭제 · SS 번호 재사용 없음 · M01 본뜬키 열 · 자재 조합 목록 · 쓰는 조합 지우기 409 · 알약 반짝임 칸 일치(B3 · O6) · 어두운 테마 메뉴 0건 · 흠 넷 → 아래
- [x] 15장 검증 흠: 옛 알약 연결 키(c4 면적산출 3개) 이름|단위로 맞춤 · 옆 판 이은 칸 즉시 갱신 · 새 조합 이름 겹침 · 첫 자재 조합 모달 글(sub2) — 검증완료(fb5aee7e · 브레인 재검증: 메인 c4 알약 10 · 연결 3 제 자리 · 따로 붙음 0 · 이음 · 풂 즉시 · 이름 「돌붙임 찰붙임」 · 첫 모달 「이 열에 새 자재 조합을 만듦」 · 전역 트리 fc58e8a1 · 검증 대기)
- [x] 진짜 마우스 검증에서 사진으로만 보인 흠: 공용 창(ui-modal) 뒤 막 투명 · 그림자 없음(확인 창처럼 어둡게) · 표시 형식 칸 흐린 글(sub2) — 검증완료(527e68db · 브레인 진짜 마우스 사진: 겹친 창 밝은 · 어두운 테마 또렷 · 흐린 글 「표시 형식」 · 전역 트리 d22732b6 · 검증 대기)
## 16. 전체 점검 · 개선 · 최종 검증 (M02 마스터 템플릿 + M01 조합 · 자재 조합)
- 사용자 지시: 전체를 다 개선하고 브레인이 최종 검증.
- 방식: 영역마다 전수 목록(기능 + 예외: 빈 값 · 같은 이름 · 긴 이름 · 취소 · 저장 안 하고 떠나기 · 다시 열기 · 쓰는 것 지우기 · 밝은/어두운 테마 · IP 주소) → 진짜 마우스 · 사진 판정 → 제 몫 흠은 고치고 목록 전체 재실행 · 남의 몫은 보고 → 사용자 열 저장 안 함 · 만든 것 되돌림.
- 사용자 조건: M01 로직(단가산출 로직 · 일위대가 계산 · 조합의 뜻)은 바꾸지 않음 — M01 은 화면 흠만 · 로직 흠은 보고
- [x] 구조물집계표 · 좌측 패널 새로 · 본떠 만들기 · 지우기 · 번호(sub4) — 검증완료(6606ef16 쓰는 양식 지우기 막음 · 16de9eed 지운 열 번호 재사용 막음 · 이름 창 커서 · Enter · 바인딩 열 손 입력 끔 · f8dda99f 도움말 대비 6.3 · 검증 대기)
- [x] 쓰는 양식 지우기 — 확인 창 전에 바로 거절 알림(sub5) — 검증완료(214c55a3 · 방위표 · 구조물집계표 알림만 · 안 쓰는 양식은 확인 창 뒤 지움 · 검증 대기) · 자재 조합 지우기도 같게(sub7 몫)
- [x] 구조물 도면 · 도면 양식 CAD(sub1) — 목록 28 · 흠 넷 고침: 값 없는 테마가 호스트(밝음)와 어긋나 CAD 만 어두움 · 첫 표시 뒤 iframe 이 커져 맞춤 어긋남(빈 도면은 기준점이 귀퉁이) · 도면 양식 📂 불러온 뒤 떠날 때 안 물음 · 작도 영역 빈 칸 안내 글 — 검증완료(IP · localhost · 밝음 · 어두움 전 목록 재실행 · 검증 대기)
- [x] 좌측 목록 두 줄 동시 선택 표시(sub5) — 검증완료(990e8e67 · 무엇으로 열든 연 직후 좌측 표시를 한 곳에서 다시 그림 · 17걸음 선택 줄 1 · 검증 대기)
- [x] 면적산출 · 재료산출 화면 · 조합 고치기 · 알약 · 자재 옆 판(sub2) — 검증완료(841af0a0 · 목록 50 · 흠 8 고침 · 검증 대기)
- [x] 조합 고치기 차례 · 메모도 내용으로(바뀌면 새 조합) · 거절 이유 글 그대로(sub2) — 검증완료(e4e2dc2a · 60246df6 떠나기 [취소] 초점 · 검증 대기)
- [x] 도구 막대 ✕ · 안내 · 확대 글 대비(sub6 — 검증완료 7389335c · 밝은 5.88 · 어두운 7.91 · 검증 대기) · 공용 확인 창 닫힘 뒤 초점 되돌림(sub2) — 로직 고르기 css 자체 로드는 sub3 검증완료 · 공용 확인 창 초점 검증완료(ee0475d0)
- [x] sub2 몫 — 같은 내용 = 차례 · 메모까지(자재는 차례) · 되돌리면 원래 키 · 거절 까닭 알림에 그대로 · 자동 이름 끝 (n) 뗌 — 검증완료(IP 차례만 · 메모만 · 되돌림 UA000009 · 422 셋 · 검증 대기)
- [x] 떠나기 확인 창 [취소] 뒤 초점을 시트 · 집계표 · 도면으로 되돌림(sub2 · sub8 발견) — 검증완료(IP 면적산출 시트 H13 · 집계표 · 화살표 이동 · 검증 대기)
- [x] 공용 확인 창 — 열 때 초점 기억 · 취소 · 확인 · Esc 로 닫히면 되돌림 · 열리면 [취소]에 초점(sub2) — 검증완료(IP M02 조합 고치기 · 떠나기 · M01 조합 뒤로 · B01 새 프로젝트 · Esc 는 확인 창만 닫음 · 검증 대기)
- [x] 조합 적용 알림 — 새로 만듦 / 같은 내용의 있는 조합 씀을 키 · 이름과 함께 · 첫 안내에 「같은 내용이 있으면 그것을 씀」(sub2) — 검증완료(IP 자재 새로 UM000001 · 다른 열 같은 내용 다시 씀 · 일위대가 되돌림 UA000009 · 검증 대기)
- [x] M01 일위대가 조합 · 자재 조합 화면(sub3) — 검증완료(ae47bcd9 · 화면 파일만 · 찾기 초점 · Enter · 떠나기 확인 · 본뜬키 칸 · 표 폭 · 금액 오른쪽 · 알림 글 · 밝은 테마 흐린 글 · 이름 60자 · 지우기 창에 키 · 이름 · 남김: 판 충돌 파일 단위 · 좌측 머리 접힘은 M01 규칙이라 그대로 · 검증 대기)
- [x] 공용 입력칸 · 고르기칸 높이 맞춤(sub3) — 검증완료(M01 미리 보기 32쌍 30px · 로직 계산 30 · 서식 창 28 · B01 날짜 42 · 밝은 · 어두운 같음 · 검증 대기)
- [x] 스프레드시트 도구 막대 · 메뉴 · 대화 창(sub6) — 검증완료(7389335c · 목록 80 · 밝은/어두운 80/80 · 검증 대기)
- [x] 스프레드시트 칸 다루기 · 식 · 복사 · 채우기 · 되돌리기(sub8) — 검증완료(52d441e3 · 흠 11 · 고름 테두리 · 채우기 손잡이 · 틀 고정 빈 띠 · 입력 형식 · 잘라 붙이기 · 어두운 테마 오류 글 · 저장본 102권 서버 재계산 같음 시험 · 검증 대기)
- [x] 긴 글 옆 빈 칸으로 넘쳐 보이기(sub8) — 검증완료(cd269045 · 수치 17 · 남김: 왼쪽 밖으로 스크롤된 칸의 넘침은 안 보임) · 떠나기 창 [취소] 뒤 초점(sub2 60246df6)
- [x] 데이터 메뉴 자리 · 머리 우클릭 잘라내기 · 붙여넣기(sub6) — 검증완료(7389335c · 검증 대기)
- [x] 사용자 지시: 자재 조합 = 마스터 템플릿 표(resources/master_template/자재조합.json · 번호는 표 안 「다음」) · M02 좌측 「자재 조합」 컨테이너(목록 · 상세 · 새로 · 본떠 만들기 · 지우기 · 쓰는 열 막기) · M01 화면 · 길에서 뺌 · 일위대가 조합은 그대로(sub7) — 검증완료(sub7 · 새로 · 이름 · 자재 빼기 · 본떠 만들기 · 찾기 · 쓰는 조합 바로 거절 · 옆 판 갈라 낸 UM 이 목록에 · 담는 자재는 자재 마스터 코드만 · 프로젝트는 복사 안 하고 마스터 키를 가리킴 · 검증 대기)
- [x] 최종 검증 흠: 자재 조합 새로 · 본떠 만들기 이름 칸 초점(sub7) · 첫 자재 조합 안내 「같은 내용이면 있는 조합」 · 새로 만듦/다시 씀 알림(sub2) · 집계표 F2 커서 끝(sub5)
- [x] 700줄 넘는 파일 나누기: spreadsheet_grid.ts 1052(sub8) · check_master.py 704(sub1) · 어두운 테마 기본 부품 color-scheme(sub3)
- [x] 전역 싱크 → 브레인 최종 검증(전체 목록 · 진짜 마우스 · 사진) → 보고 — 검증완료(12곳 트리 bedfcf83 · CAD · 스프레드시트 빌드 두 PC · 영역 9 통과 · https://claude.ai/artifact/FmWV9uoQiFBpDjQfY2FvPo)
## 17. 구조물 집계 양식 한 묶음 · 좌측 패널 재구성 (담당 sub2 · sub4)
- 확정: 구조물집계표 열(SS 키) = 일위대가(UA) · 자재조합(UM) · 도면(SD) · 면적산출(SA) · 재료산출(SM) 한 벌 — 구조물집계표조합.json 줄마다 다섯 칸 · 자재 조합 = 재료산출 알약에 쓴 자재 목록(master_template/자재조합.json).
- [x] 집계표 도구 막대에서 「손 입력 열」 · 「줄 더하기」 · 「줄 지우기」 단추 삭제(데이터 규칙은 그대로)(sub4)
- [x] 좌측: 「표 양식」 → 「구조물 집계 양식」 이름 바꿈 · 새로 · 본떠 만들기 · 지우기 단추 삭제 · 그 아래에 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 컨테이너를 하위로 넣음(도면 양식은 그대로 별도)(sub2) — 검증완료(IP 집계표 줄 하나 · 하위 넷 들여 8px · 같이 접힘 · 밝음 · 어두움 잘림 · 대비 흠 없음 · 검증 대기)
- [x] M02 첫 화면 = 구조물집계표(왼쪽 고르라는 빈 화면 대신)(sub2) — 검증완료(들어오면 집계표 · 좌측 고름 집계표 줄 · 자재 조합 지운 뒤도 집계표 · 검증 대기)
- [x] 카드 [수정]으로 연 상세(도면 · 면적산출 · 재료산출)의 왼쪽 위 제목 라벨 오른쪽에 [돌아가기] — 누르면 구조물집계표로 돌아가고 그 열이 고른 채(저장 안 한 고침이면 확인 창)(sub4)
- [x] 면적산출 · 재료산출 편집 화면의 알약 막대를 시트 아래(하단)로 옮김(sub7)
- [x] 면적산출 · 재료산출 편집 화면 오른쪽 안쪽 패널(B05 배수유역 패널처럼 끌어서 폭 조절) — 면적산출은 일위대가 조합 목록 · 재료산출은 자재 조합 목록 · 이 열의 조합 바꾸기(고르기) · 조합 고치기(갈라 내기) 를 패널 안에서(sub2) — 검증완료(공용 사이드바 카드 · 접기 · 폭 손잡이 · 접기 손잡이 그대로 · 폭 384→503 · 최소 280 · 최대 60% · 새로고침 뒤 폭 기억 · 겹침 0 · 페이지 스크롤 0 · h_rip_c UA000009→UA000011→UA000009 · 자재 UM000001 · 이름|단위 맞는 이은 칸 유지 · 밝음 · 어두움 대비 흠 없음 · 검증 대기)
- 검증완료(17 · 브레인 · 메인 5173 · 진짜 마우스 · 사진 v1 · v2): 첫 화면 집계표 · 좌측 구조물 집계 양식 하위 넷 · 도구 막대 단추 넷만 · 상세 [돌아가기] → 집계표 h_rip_c 고른 채 카드 · 알약 막대 시트 아래(y 988 · 스크롤 0) · 오른쪽 패널(이 열의 조합 · 조합 목록 · 손잡이 · 폭 384) · 흠 1: [이 열에 쓰기] 단추가 목록 끝 글과 겹침 → sub2 — 고침 071676e0(고른 줄 아래 · 겹침 0)
## 18. 계획노선(B04 평면도) 다듬기 (담당 dmain · dsub)
- [x] 종단 기울기 라벨 — 줌인해 측점 하나가 보이는 크기부터 측점과 측점 사이에 기울기(%) 라벨(dmain) — 검증완료(자리 = B05 계획노선 편집 창 · 지반고 기울기 · 측점 간격 90px 이상에서만 · 6.12px/m 에서 화면 안 11개 · 30° 회전에서 12개 · 글 바로 섬 · 2.66px/m 에서 0개 · 검증 대기)
- [x] 등고선 1m 간격 보이기 토글 단추 · 5m 간격 선은 색을 조금 짙게(dmain) — 검증완료(「1m 등고선」 단추 기본 꺼짐 · LAS 없으면 잠김 · 켬 117,489px 바뀜 · 끔 0px 차이 · 5m 선 #818cf8 · 1m 선 0.5px 옅게 · 5m 선이 솎이지 않는 배율에서만 · 검증 대기)
- [x] 계획노선 회전 값 저장 — 다시 들어오면 보던 화면(회전 · 위치 · 줌)으로(dmain) — 검증완료(화면 취향 `routeedit-view` 프로젝트 칸 · 계정에도 올라감 · 다시 열기 가운데 · 6.12px/m 그대로 · 30° 그대로 · 창 크기 달라도 같은 자리 시험 · 검증 대기)
- [x] 거리 · 기울기 측정을 예정 노선에서도(dsub) — 검증완료(자리 = B05 계획노선 편집 창 · 데스크탑_보조 · 예상노선 28+11.4→29+19.7 28.3m · 15.2% · 초록 구간이 점선을 따라감 · 계획노선 23+12.6→29+11.0 118.5m · −5.0% · 검증 대기)
- [x] 횡단도 보기 선택 표시(노선 위 작은 선)에 라벨 추가(dsub) — 검증완료(측점 눈금 = 라벨 11/11 · 27/27 · 되꺾인 노선 61/61 · 비켜 놓음 28 · 겹침 0 · 공용 `drawStationTicks` 라 B04 지도 · 배수유역도도 같음 · 검증 대기)
- [x] 횡단도 보기 항목의 좌우 횡단 반폭만큼 평면도에 선 — 노선이 바뀌면 횡단 보기 켜진 동안 실시간으로 따라감(dsub) — 검증완료(반폭 = 사면이 원지반과 만나는 좌·우 · 못 만나면 샘플 반폭 · 측점 틀 파이썬·TS 짝 + 거울 시험 · 서버 샘플 좌표와 차 ≤5e-7m(측점 5곳) · 노드 끄는 중 선 (527,491)→(519,547) · 놓으면 반폭 재계산 · 되돌리기 · 취소 뒤 노선 그대로 · 검증 대기)
## 19. 종단 저장 정합 — 사용자가 고른 값이 저장 · 다시 열기 뒤 그대로 (담당 sub5 · sub6 · sub8)
- 사용자 확정: 저장 뒤 다시 들어왔을 때 프론트에서 사용자가 고른 정보가 그대로여야 함 · B05·B06 은 한 페이지처럼 동작. 측점 기준선 통일(계획노선 CSV vs B05 경로)은 따로 대화.
- 읽기 조사 결과: 시설 속성 붙이기(attach · carry_facility_attributes)가 거리 제한 없이 가장 가까운 관에 붙어 한 칸 밀림 · 정수 m 격자 붙이기 · 잔여거리 0.1m 표시값 재읽기로 측점 재저장 · 사용자 배치(manual) 서버 미존중 · 옛 관 이동 예약(culvertmove) 저장 때 적용.
- [x] 시설 속성 붙이기 = 측점이 같은(허용 오차 안) 관에만 · 화면(B05_Profile_UI_Drainage_Facility.ts attach)과 서버(common_util_drainage_pipes.py carry_facility_attributes) 같은 규칙 · 옛 culvertmove 적용 제거(sub5) — 검증완료(sub5 · attach 같은 측점만 · 관 옮기면 속성 따라감 · culvertmove 제거 · 시험 97 · 검증 대기)
- [x] 옵션 · 메모만 바꿀 때 측점 재쓰기 금지(Structures_Fields · Panel_Commit) · 사용자가 놓은 구조물(manual)은 격자 붙이기 · 반올림에서 제외 · 비정규 측점 키 바뀜으로 station_offsets 지워지지 않게(sub6) — 검증완료(cc3a8a71 · aecf04ac · 격자 붙이기는 브레인 판정 (다)로 그대로 · 검증 대기)
- [x] 왕복 시험: 환경 배정 storage 프로젝트에서 B05 · B06 값(관 이동 · 구조물 측점 · 옵션 · 메모 · 계획고 수정)을 바꿔 저장 → 다시 열기 → 파일(pipe_points.json · structures.json · 종단) · 화면값 대조 시험 · sub5 · sub6 push 뒤 돌려 0 차이(sub8)
@@ -62,69 +62,71 @@ const moves = [
];
// ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다.
const plain = model.normalizeRouteInitial(
{
nodes: [
{ x: 0, y: 0 },
{ x: 50, y: 10, curve: true, radius_m: 30 },
{ x: 100, y: 0, curve: false },
const plainResponse = {
nodes: [
{ x: 0, y: 0 },
{ x: 50, y: 10, curve: true, radius_m: 30 },
{ x: 100, y: 0, curve: false },
],
// 계획서 20 「API」 줄 모양.
metrics: {
length_m: 1234.56,
max_grade_pct: 12.34,
avg_grade_pct: 6.1,
cut_m3: 1500.4,
fill_m3: 900,
over_limit: { grade: 1, radius: 0, stream: 1, total: 2 },
offset_avg_m: 3.25,
offset_max_m: 9.8,
raw_max_grade_pct: 41.2,
violations: [
{
kind: "grade",
from_m: 180,
to_m: 220,
station: "No.9+0.0",
value: 29.13,
limit: 14,
reason: "지형상 불가능 — 복도 안 어떤 길도 23.5% 이상 기울기를 지나야 이어짐",
},
{
kind: "radius",
from_m: 400,
to_m: 412,
station: "No.20+0.0",
value: 9.5,
limit: 12,
reason: "곡선 둘 자리 모자람 — 앞뒤 직선이 짧아 최소 반지름 원호가 안 들어감",
},
{
kind: "stream",
from_m: 600,
to_m: 700,
station: "No.30+0.0",
value: 32,
limit: 100,
reason: "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 넘음",
},
],
// 계획서 20 「API」 줄 모양.
metrics: {
length_m: 1234.56,
max_grade_pct: 12.34,
avg_grade_pct: 6.1,
cut_m3: 1500.4,
fill_m3: 900,
over_limit: { grade: 1, radius: 0, stream: 1, total: 2 },
offset_avg_m: 3.25,
offset_max_m: 9.8,
raw_max_grade_pct: 41.2,
violations: [
{
kind: "grade",
from_m: 180,
to_m: 220,
station: "No.9+0.0",
value: 29.13,
limit: 14,
reason: "지형상 불가능 — 복도 안 어떤 길도 23.5% 이상 기울기를 지나야 이어짐",
},
{
kind: "radius",
from_m: 400,
to_m: 412,
station: "No.20+0.0",
value: 9.5,
limit: 12,
reason: "곡선 둘 자리 모자람 — 앞뒤 직선이 짧아 최소 반지름 원호가 안 들어감",
},
{
kind: "stream",
from_m: 600,
to_m: 700,
station: "No.30+0.0",
value: 32,
limit: 100,
reason: "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 넘음",
},
],
},
criteria: {
max_grade_pct: 14,
min_radius_m: 12,
stream_offset_m: 5,
road_width_m: 4,
corridor_m: 100,
grade_smooth_m: 5,
pending: ["max_grade_pct", "stream_offset_m"],
},
relaxed: true,
terrain_bound_pct: 23.46,
},
"earthwork",
null,
);
criteria: {
max_grade_pct: 14,
min_radius_m: 12,
stream_offset_m: 5,
road_width_m: 4,
corridor_m: 100,
grade_smooth_m: 5,
// 기준값 확정(계획서 20) — 서버가 이제 늘 빈 목록으로 준다.
pending: [],
},
relaxed: true,
terrain_bound_pct: 23.46,
};
const plain = model.normalizeRouteInitial(plainResponse, "earthwork", null);
// 화면 칸에 겹침을 넣어 물었을 때 — `criteriaOverrides` 로 그대로 남는다(빈 칸은 안 담김).
const withOverrides = model.normalizeRouteInitial(plainResponse, "earthwork", null, {
stream_offset_m: 50,
});
const planShape = model.normalizeRouteInitial(
{
nodes: [
@@ -163,7 +165,6 @@ const labels = {
metrics: model.metricsLine(plain.metrics),
metricsNone: model.metricsLine(null),
criteria: model.criteriaLine(plain.criteria, 0),
criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0),
criteriaFallback: model.criteriaLine(null, 15),
violations: model.violationsLine(plain.metrics),
violationsNone: model.violationsLine({ ...plain.metrics, violations: [] }),
@@ -180,32 +181,31 @@ const labels = {
}),
relaxed: model.relaxedLine(plain),
notRelaxed: model.relaxedLine(planShape),
pendingEmpty: model.normalizeRouteInitial(
{
nodes: [
{ x: 0, y: 0 },
{ x: 9, y: 9 },
],
criteria: { pending: [] },
},
"gentle",
null,
).criteria.confirmed,
};
// ⑤ 서버가 적어 둔 현재 초기값(`current_initial`) — 펴기와 캐시 견주기.
// ⑤ 서버가 적어 둔 현재 초기값(`current_initial`) — 펴기와 캐시 견주기(제약 겹침 포함).
const bases = {
follow: model.serverBase({ logic: "follow", weights: { expected: 100, earthwork: 0, grade: 0 } }),
follow: model.serverBase({
logic: "follow",
weights: { expected: 100, earthwork: 0, grade: 0 },
criteria: null,
}),
weighted: model.serverBase({
logic: "weighted",
weights: { expected: 70.0, earthwork: 15.0, grade: 15.0 },
criteria: { stream_offset_m: 50.0 },
}),
unknown: model.serverBase({ logic: "nope" }),
none: model.serverBase(null),
};
const sameAsServer = {
earthVsFollow: model.sameBase(plain, bases.follow),
earthVsEarth: model.sameBase(plain, model.serverBase({ logic: "earthwork" })),
earthVsEarth: model.sameBase(plain, model.serverBase({ logic: "earthwork", criteria: null })),
// 같은 갈래라도 그때 넣었던 제약 겹침이 다르면(캐시엔 없음, 서버는 50m) 다른 것으로 본다.
earthVsEarthDiffCriteria: model.sameBase(
withOverrides,
model.serverBase({ logic: "earthwork", criteria: null }),
),
weightedSame: model.sameBase(
planShape,
model.serverBase({ logic: "weighted", weights: { expected: 60, earthwork: 20, grade: 20 } }),
@@ -217,6 +217,8 @@ const sameAsServer = {
model.writeInitialChoice("p-1", plain);
const readBack = model.readInitialChoice("p-1");
const other = model.readInitialChoice("p-2");
model.writeInitialChoice("p-3", withOverrides);
const readBackOverrides = model.readInitialChoice("p-3");
const keys = [...session._map.keys()];
session._map.set(
keys[0],
@@ -242,6 +244,7 @@ process.stdout.write(
tooShort,
labels,
readBack,
readBackOverrides,
other,
keys,
badLogic,
@@ -1,8 +1,9 @@
/* 초기노선 갈래 **저장 왕복**(계획서 20) 헬퍼 — 서버 답을 화면 코드 그대로 편다.
*
* 표준입력 JSON `{ jobs: [...] }` 를 받아 일마다 결과 한 줄을 돌려준다.
* - `apply` : `/route/initial` 답 → 편집값(`normalizeRouteInitial`) → [확인] 몸통
* (`_RouteEdit_Apply` 의 vertices 만들기 · `_Initial.current()` 의 initial)
* - `apply` : `/route/initial` 답(+보낸 `criteria` 겹침) → 편집값(`normalizeRouteInitial`)
* → [확인] 몸통(`_RouteEdit_Apply` 의 vertices 만들기 · `_Initial.current()` 의
* initial{mode, weights, criteria})
* - `reopen` : `/route/plan` 답 → 다시 연 노드(`flattenServerPlan`) · 현재 초기값(`serverBase`)
* · 캐시 초기값과 같은가(`sameBase` — [초기화]가 캐시 노드를 쓸지 서버에 물을지)
*
@@ -56,7 +57,12 @@ const edits = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ed
const { jobs } = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.kind === "apply") {
const choice = model.normalizeRouteInitial(job.response, job.logic, job.weights ?? null);
const choice = model.normalizeRouteInitial(
job.response,
job.logic,
job.weights ?? null,
job.criteria ?? null,
);
const { planned, curveOn, curveRadius } = choice.snapshot;
return {
choice,
@@ -68,7 +74,11 @@ const out = jobs.map((job) => {
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
initial: { mode: choice.logic, weights: choice.weights },
initial: {
mode: choice.logic,
weights: choice.weights,
criteria: choice.criteriaOverrides,
},
},
label: model.choiceLabel(choice.logic, choice.weights),
metrics: model.metricsLine(choice.metrics),
@@ -101,4 +101,16 @@ out.moved = store.edits();
store.resetStation(200);
out.deleted = store.edits();
// 화면 표기 한 벌 — 거리는 정수로 보이기만(PLAN 20장 · 119.8 → 5+20)
const { stationLabel, stationPartsLabel } = loadTs(
path.join(ROOT, "common_util", "common_util_station.ts"),
);
out.labels = [
stationLabel(119.8, INTERVAL),
stationLabel(120, INTERVAL),
stationLabel(84.3, INTERVAL),
stationLabel(123.37, INTERVAL),
stationPartsLabel(5, 19.8),
];
process.stdout.write(JSON.stringify(out));
@@ -22,6 +22,8 @@ from typing import Any
import pytest
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
ROOT = Path(__file__).resolve().parents[2]
PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
@@ -425,6 +427,10 @@ class Flow:
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
if not any(abs(n - c) < 0.005 for n in near):
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
# 소수 측점 횡단(PLAN 20장) — 그 자리 횡단 파일이 소수 2자리 이름으로 따로 있음
name = cross_filename(c)
if not (root / "B06_Section" / "cross_sections" / name).is_file():
d.append(f"관 측점 {c} — 횡단 파일 {name} 없음")
self._check_designs()
def _check_designs(self) -> None:
@@ -192,7 +192,7 @@ def test_matching_signature_loads_points(tmp_path):
]
# ── 횡단 파일명 충돌 — 한 정수 미터에 측점 하나 · 관 · 구조물 자체 측점 우선(PLAN 19장) ──
# ── 소수 측점 횡단 — 관 · 구조물 자리마다 따로(이름 소수 2자리 · PLAN 20장) ──────────────
def _stations(extra_stations):
@@ -204,31 +204,32 @@ def _stations(extra_stations):
return result["longitudinal"]["stations"]
def test_extra_station_in_grid_meter_replaces_grid_station():
def test_extra_station_in_grid_meter_keeps_both_with_own_names():
stations = _stations(((100.4, "X"),))
chainages = [station["chainage_m"] for station in stations]
# 관 · 구조물 자체 측점 그대로 — 같은 정수 미터 격자 100 은 빠져 파일(cross_00100m)은 한 장
assert 100.4 in chainages and 100.0 not in chainages
assert 100.4 in chainages and 100.0 in chainages
labelled = [station for station in stations if station.get("structure") == "X"]
assert [(station["chainage_m"], station["kind"]) for station in labelled] == [
(100.4, "irregular")
]
assert cross_filename(100.4) == "cross_00100.40m.json"
assert len({cross_filename(c) for c in chainages}) == len(chainages)
def test_extra_station_at_route_end_keeps_end_station():
stations = _stations(((0.3, "S"), (349.8, "E")))
chainages = [station["chainage_m"] for station in stations]
# 시 · 종점은 격자 그대로 두고 라벨만 얹음
assert chainages[0] == 0.0 and chainages[-1] == 350.0
assert 0.3 not in chainages and 349.8 not in chainages
assert stations[0].get("structure") == "S" and stations[-1].get("structure") == "E"
def test_extra_station_on_grid_within_5mm_labels_grid_station():
stations = _stations(((100.003, "X"), (0.3, "S")))
labelled = {station["chainage_m"]: station for station in stations if station.get("structure")}
assert sorted(labelled) == [0.3, 100.0]
assert labelled[100.0]["kind"] == "regular" and labelled[0.3]["kind"] == "irregular"
def test_two_extras_in_same_meter_bucket_keep_one():
def test_two_extras_in_same_meter_both_kept():
stations = _stations(((80.2, "A"), (80.4, "B")))
assert [station["chainage_m"] for station in stations if station.get("structure")] == [80.2]
assert 80.0 not in [station["chainage_m"] for station in stations]
assert [station["chainage_m"] for station in stations if station.get("structure")] == [
80.2,
80.4,
]
assert 80.0 in [station["chainage_m"] for station in stations]
def test_live_extras_stay_irregular_and_filenames_stay_unique():
@@ -287,7 +288,7 @@ def test_run_section_generation_derives_stations_from_truth_files(project):
]
cross_dir = project / "B06_Section" / "cross_sections"
for name in ("cross_00084m", "cross_00150m", "cross_00201m", "cross_00276m"):
for name in ("cross_00084.30m", "cross_00149.73m", "cross_00200.92m", "cross_00275.71m"):
assert (cross_dir / f"{name}.json").is_file()
# 파일 = DB 행 — 종전에는 파일 23 vs DB 19로 갈렸다.
assert len(result["cross_sections"]) == len(stations)
+22 -12
View File
@@ -2,8 +2,10 @@
"""계획노선 편집 창 — **초기노선 갈래**(계획서 20) 셈 회귀검증.
비중 슬라이더 셋은 합 100 을 지키고(하나를 옮기면 나머지가 비율대로), 서버 답은 노드 목록 꼴과
`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 비교값 · 기준값은 서버 값을 글로만
보이고, 확정 표시가 없으면 「사용자 확정 대기」를 붙인다. 마지막 초기값은 결과 통 프로젝트 칸.
`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 기준값 확정(2026-09-29) 뒤로는
비교값 · 기준값 글에 「후보 · 사용자 확정 대기」 꼬리표가 안 붙는다. 화면 제약 칸에 사용자가
겹쳐 넣은 값(`criteriaOverrides`)만 캐시·서버 `current_initial` 과 오가고, 그 겹침이 서버 것과
다르면 캐시를 버린다(`sameBase`). 마지막 초기값은 결과 통 프로젝트 칸.
Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
"""
@@ -80,15 +82,13 @@ def test_비교값과_기준값_글(results):
" · 기준 초과 2구간 · 이격 3.3m"
)
assert "로직을 누르면" in labels["metricsNone"]
# 기준값 확정(2026-09-29) — 「후보 · 사용자 확정 대기」 꼬리표가 더는 안 붙는다.
assert labels["criteria"] == (
"공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m · 기울기 잴 지반 5m 고름"
" (후보 · 사용자 확정 대기)"
)
assert labels["criteriaConfirmed"].endswith("지반 5m 고름")
assert labels["pendingEmpty"] is True
assert labels["criteriaFallback"] == (
"공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)"
)
assert "확정 대기" not in labels["criteria"]
assert labels["criteriaFallback"] == "공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ —"
assert "확정 대기" not in labels["criteriaFallback"]
def test_제약_못_지킨_구간과_상한_풂(results):
@@ -109,26 +109,36 @@ def test_제약_못_지킨_구간과_상한_풂(results):
def test_서버가_적어_둔_현재_초기값(results):
bases = results["bases"]
assert bases["follow"] == {"logic": "follow", "weights": None}
assert bases["follow"] == {"logic": "follow", "weights": None, "criteria": None}
assert bases["weighted"] == {
"logic": "weighted",
"weights": {"expected": 70, "earthwork": 15, "grade": 15},
"criteria": {"stream_offset_m": 50},
}
assert bases["unknown"] == {"logic": "follow", "weights": None}
assert bases["none"] == {"logic": "follow", "weights": None}
assert bases["unknown"] == {"logic": "follow", "weights": None, "criteria": None}
assert bases["none"] == {"logic": "follow", "weights": None, "criteria": None}
same = results["sameAsServer"]
assert same == {
"earthVsFollow": False,
"earthVsEarth": True,
# 같은 갈래라도 넣은 제약 겹침(계류 이격 50m)이 서버 기억(안 겹침)과 다르면 캐시를 버린다.
"earthVsEarthDiffCriteria": False,
"weightedSame": True,
"weightedOther": False,
}
def test_캐시는_결과_통_프로젝트_칸(results):
assert results["keys"] == ["aislo:result:routeedit-initial:p-1"]
assert results["keys"] == [
"aislo:result:routeedit-initial:p-1",
"aislo:result:routeedit-initial:p-3",
]
assert results["readBack"]["logic"] == "earthwork"
assert results["readBack"]["snapshot"]["planned"] == [[0, 0], [50, 10], [100, 0]]
# 화면 칸에 아무것도 안 겹쳤으면 `criteriaOverrides` 는 null.
assert results["readBack"]["criteriaOverrides"] is None
# 계류 이격만 겹쳐 물었으면 그 한 칸만 남는다(나머지는 기본값이라 안 담김).
assert results["readBackOverrides"]["criteriaOverrides"] == {"stream_offset_m": 50}
assert results["other"] is None
assert results["badLogic"] is None
assert results["cleared"] is None
@@ -8,6 +8,9 @@
④ [초기화] — 캐시가 있으면 그 노드, 없으면(다른 PC) 서버가 같은 갈래로 같은 노드를 냄
화면 쪽 셈은 `helper_b05_routeedit_initial_apply.cjs` 가 화면 코드 그대로 돌린다.
화면 제약 칸(종단기울기 · 최소 반지름 · 계류 이격 · 지반 고름, 계획서 20 확정)에 겹친 값도
같은 차례로 왕복 — 겹친 칸만 `current_initial.criteria` 에 남고 안 겹친 칸은 프로젝트 기본값.
- 파일: 프로젝트 폴더를 임시 폴더로 복사(원본 입력 · 점군 · 큰 파일 제외) ·
`STORAGE_BASE_DIR` 를 그리로.
- DB: 읽기 전용 세션으로 진짜 DB 를 읽기만 함.
@@ -134,10 +137,17 @@ def _screen(jobs: list[dict[str, Any]]) -> list[Any]:
return json.loads(out.stdout)
def _initial(api, logic: str, weights: dict[str, float] | None) -> dict[str, Any]:
def _initial(
api,
logic: str,
weights: dict[str, float] | None,
criteria: dict[str, float] | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"logic": logic}
if weights:
body["weights"] = weights
if criteria:
body["criteria"] = criteria
response = api.post(f"/api/projects/{PROJECT_ID}/route/initial", json=body)
assert response.status_code == 200, response.text
return response.json()
@@ -255,3 +265,31 @@ def test_초기화는_마지막_초기값으로(rounds, api):
# 다른 갈래들과는 다른 노선이다(되돌림이 실제로 갈래를 가림).
others = [r["applied"]["choice"]["snapshot"]["planned"] for r in rounds["rounds"][:-1]]
assert all(not _close(o, cached) for o in others)
def test_제약_겹침도_확인_뒤_그대로(api, rounds):
"""화면 제약 칸(계류 이격 30m)만 겹쳐 물으면 답에 그 값이 실리고, [확인] → 다시 열기에도
그 한 칸만 남는다(나머지는 기본값이라 안 담김 — 계획서 20 확정값이 프로젝트 기본값)."""
criteria = {"stream_offset_m": 30.0}
answer = _initial(api, "earthwork", None, criteria)
assert answer["criteria"]["stream_offset_m"] == 30.0
[applied] = _screen(
[
{
"kind": "apply",
"response": answer,
"logic": "earthwork",
"weights": None,
"criteria": criteria,
}
]
)
assert applied["body"]["initial"]["criteria"] == criteria
confirmed = api.post(f"/api/projects/{PROJECT_ID}/route/replan", json=applied["body"])
assert confirmed.status_code == 200, confirmed.text
assert confirmed.json()["current_initial"]["criteria"] == criteria
plan = api.get(f"/api/projects/{PROJECT_ID}/route/plan").json()
assert plan["current_initial"]["criteria"] == criteria
[reopened] = _screen([{"kind": "reopen", "plan": plan, "cached": applied["choice"]}])
assert reopened["base"]["criteria"] == criteria
assert reopened["sameAsCache"] is True # 캐시와 서버 겹침이 같으니 [초기화]가 캐시를 쓴다
+30 -3
View File
@@ -37,6 +37,10 @@ def test_칸을_안_고치면_실어_둔_측점_그대로(results):
assert results["r119"] == ["5", "19.8", 119.8]
def test_화면_표기는_거리를_정수로(results):
assert results["labels"] == ["5+20", "6+0", "4+4", "6+3", "5+20"]
def test_칸을_고치면_친_값(results):
assert results["edited"] == pytest.approx(125.2)
assert results["normalized"] == ["2", "10.0", 50]
@@ -96,8 +100,31 @@ def test_사용자_구조물_119_8_저장_다시_읽기_그대로(tmp_path):
SectionGenerationOptions(extra_stations=extras),
)
chainages = [round(s["chainage_m"], 3) for s in sections["longitudinal"]["stations"]]
# 횡단 측점도 자체 측점 119.8(같은 정수 미터 격자 120 대신 · 파일 이름 cross_00120m 한 장)
assert 119.8 in chainages and 120.0 not in chainages
assert cross_filename(119.8) == cross_filename(120.0)
# 소수 측점 횡단을 따로 둠(PLAN 20장) — 119.8 · 격자 120 둘 다 · 이름도 따로
assert 119.8 in chainages and 120.0 in chainages
assert (cross_filename(119.8), cross_filename(120.0)) == (
"cross_00119.80m.json",
"cross_00120m.json",
)
_, again = load_structures(str(tmp_path))
assert [s.chainage_m for s in again] == [119.8] and again[0].memo == "메모만"
def test_옛_프로젝트_정수_이름_횡단은_찾고_안_지움(tmp_path):
"""소수 측점이 정수 m 이름으로 저장된 옛 프로젝트 — 열 때 지우지 않고 그 파일을 씀."""
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file, prune_stale_cross_files
(tmp_path / "cross_00084m.json").write_text("{}", encoding="utf-8") # 옛 84.3
(tmp_path / "cross_00100m.json").write_text("{}", encoding="utf-8")
(tmp_path / "cross_00119.80m.json").write_text("{}", encoding="utf-8") # 새 이름
(tmp_path / "cross_00500m.json").write_text("{}", encoding="utf-8") # 잔재
stations = [{"chainage_m": 84.3}, {"chainage_m": 100.0}, {"chainage_m": 119.8}]
valid = prune_stale_cross_files(tmp_path, stations)
assert sorted(p.name for p in tmp_path.iterdir()) == [
"cross_00084m.json",
"cross_00100m.json",
"cross_00119.80m.json",
]
assert valid == {"cross_00084m.json", "cross_00100m.json", "cross_00119.80m.json"}
assert find_cross_file(tmp_path, 84.3).name == "cross_00084m.json"
assert find_cross_file(tmp_path, 119.8).name == "cross_00119.80m.json"
@@ -34,3 +34,9 @@ def test_행_없는_측점_수정이면_측점을_세움(monkeypatch):
def test_모든_수정에_행이_있으면_안_부름(monkeypatch):
assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == []
assert _calls(monkeypatch, [], [120.0]) == []
def test_소수_측점_수정은_정수_격자_행으로_갈음하지_않음(monkeypatch):
# 119.8 은 격자 120 과 다른 측점(횡단 cross_00119.80m · PLAN 20장) — 제 행이 없으면 세움
assert _calls(monkeypatch, [119.8], [120.0]) == [PROJECT]
assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == []
+68 -4
View File
@@ -150,20 +150,65 @@ def test_unknown_mode_rejected(hill: Terrain) -> None:
_run(hill, "shortest")
def test_criteria_pending(hill: Terrain) -> None:
assert _run(hill, "follow")["criteria"]["pending"] == list(PENDING)
def test_criteria_confirmed(hill: Terrain) -> None:
"""기준값 확정(2026-09-29 사용자 결정) — `pending` 은 이제 늘 빈 목록."""
assert _run(hill, "follow")["criteria"]["pending"] == [] == list(PENDING)
def test_criteria_overrides_change_result(hill: Terrain) -> None:
"""화면 제약 칸(계류 이격 · 지반 고름)을 겹치면 그 값으로 셈하고, 안 겹치면 기본값."""
default = generate_initial_route(
_line(200.0),
hill,
[],
mode="follow",
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
)
assert default["criteria"]["stream_offset_m"] == STREAM_OFFSET_M
overridden = generate_initial_route(
_line(200.0),
hill,
[],
mode="follow",
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
stream_offset_m=30.0,
grade_smooth_m=2.0,
)
assert overridden["criteria"]["stream_offset_m"] == 30.0
assert overridden["criteria"]["grade_smooth_m"] == 2.0
assert overridden["criteria"]["max_grade_pct"] == default["criteria"]["max_grade_pct"]
_kept(overridden)
def test_initial_choice_roundtrip(tmp_path) -> None:
"""[확인] 때 적은 현재 초기값을 다시 읽음 · 없거나 깨지면 추종."""
assert read_initial_choice(tmp_path)["logic"] == "follow"
"""[확인] 때 적은 현재 초기값(갈래 · 비중 · 제약 겹침)을 다시 읽음 · 없거나 깨지면 추종."""
assert read_initial_choice(tmp_path) == {
"logic": "follow",
"weights": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
"criteria": None,
}
write_initial_choice(tmp_path, "weighted", {"expected": 60, "earthwork": 20, "grade": 20})
assert read_initial_choice(tmp_path) == {
"logic": "weighted",
"weights": {"expected": 60.0, "earthwork": 20.0, "grade": 20.0},
"criteria": None,
}
# 사용자가 화면 칸에 겹쳐 넣은 값만(빈 칸은 안 담김) — 남으면 다시 열 때 그 칸만 채운다.
write_initial_choice(
tmp_path, "earthwork", None, {"stream_offset_m": 30.0, "grade_smooth_m": 2.0}
)
assert read_initial_choice(tmp_path) == {
"logic": "earthwork",
"weights": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
"criteria": {"stream_offset_m": 30.0, "grade_smooth_m": 2.0},
}
(tmp_path / "B05_Profile" / "route" / "initial_choice.json").write_text("{", encoding="utf-8")
assert read_initial_choice(tmp_path)["logic"] == "follow"
assert read_initial_choice(tmp_path)["criteria"] is None
def test_request_accepts_logic_name() -> None:
@@ -173,3 +218,22 @@ def test_request_accepts_logic_name() -> None:
assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork"
replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}})
assert replan.initial is not None and replan.initial.mode == "weighted"
def test_request_accepts_criteria_overrides() -> None:
"""화면 제약 칸 — 비운 칸은 None(서버 기본값), 채운 칸만 값이 실린다(계획서 20)."""
from B05_Profile.B05_Profile_Router_Replan import RouteInitialRequest, RouteReplanRequest
request = RouteInitialRequest.model_validate(
{"mode": "earthwork", "criteria": {"stream_offset_m": 30, "grade_smooth_m": 2}}
)
assert request.criteria is not None
assert request.criteria.stream_offset_m == 30
assert request.criteria.grade_smooth_m == 2
assert request.criteria.max_grade_pct is None
assert request.criteria.min_radius_m is None
replan = RouteReplanRequest.model_validate(
{"vertices": [], "initial": {"mode": "weighted", "criteria": {"max_grade_pct": 12}}}
)
assert replan.initial is not None and replan.initial.criteria is not None
assert replan.initial.criteria.max_grade_pct == 12