Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-08 16:27:52 +09:00
101 changed files with 3082 additions and 495 deletions
+8
View File
@@ -262,6 +262,9 @@ export interface FordWingSpec {
angle_deg: number | null;
/** 바닥판 편측 연장(m) = 길이 × cos(각도). 각도는 관축 기준 벌어짐각. */
slab_extend_m: number;
/** 세월교 유입 날개벽 끝 수평 여유(m) — 흙막이 수평공간과 같은 칸(`inlet_revet_berm_m` · 107-3) ·
* null = 화면 기본값(1 m). */
berm_m?: number | null;
}
/** 세월교 측점의 세트 제원 — 양측 ㄴ형 측벽 + 바닥판 + 관, 관 위는 성토 채움. */
@@ -289,6 +292,8 @@ export interface FordSet {
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
export interface BoxSet {
type: "box";
/** 이 시설이 **놓인** 누가거리(m) — 걸친 옆 측점과 주인 측점을 가리는 열쇠(110). */
chainage_m?: number;
/** 사용자 입력 내공(유로) 폭·높이(m). */
inner_width_m: number;
inner_height_m: number;
@@ -561,6 +566,9 @@ export interface CrossSectionPatch {
cut_slope_ratio_user?: number;
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
pipe_length_m?: number;
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
pipe_slope_pct?: number;
box_ground_slope_pct?: number;
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
basin_adjust?: {
innerWidthM: number;
@@ -444,6 +444,9 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
pipe_count = max(int(count), 1) if count else 1
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
if wing_in["installed"]:
# 유입 날개벽 수평 여유 — 흙막이 수평공간과 같은 칸(107-3) · 빈 값 = 화면 기본값
wing_in["berm_m"] = _number(values.get("inlet_revet_berm_m"), None)
defaulted = _defaulted(
values,
[
+4
View File
@@ -184,6 +184,10 @@ class CrossSectionPatch(BaseModel):
# 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로
# 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`).
pipe_length_m: float | None = Field(default=None, ge=0)
# B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 기하가 낸 값
# (관 길이와 같은 길). 0 · 음수도 그대로 — 빈칸 판정은 B08 몫.
pipe_slope_pct: float | None = None
box_ground_slope_pct: float | None = None
class SectionConfirmRequest(BaseModel):
@@ -66,6 +66,8 @@ _AREA_KEYS = (
"cut_soil_area_m2",
"cut_rock_area_m2",
"pipe_length_m",
"pipe_slope_pct",
"box_ground_slope_pct",
)
+36 -14
View File
@@ -14,7 +14,9 @@ import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
import { groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import {
@@ -68,16 +70,15 @@ const inletStructure: InletStructureControl = {
};
/**
* 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`.
* 그 시설의 **주인 측점** 누가거리 — 시설 자리(`target`)에서 가장 가까운 측점 하나. 자리를 모르면 `null`.
*
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
*/
function pipeOwnerChainage(
section: CrossSection,
function ownerChainage(
target: number | undefined,
sections: readonly CrossSection[],
): number | null {
const target = section.culvert?.chainage_m;
if (typeof target !== "number") return null;
let best: number | null = null;
for (const item of sections) {
@@ -155,8 +156,21 @@ export const STRUCTURE_ROW_KEYS = [
"cut_soil_area_m2",
"cut_rock_area_m2",
"pipe_length_m",
// B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 같은 길로 실음.
"pipe_slope_pct",
"box_ground_slope_pct",
] as const;
/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */
function boxGroundSlopePct(section: CrossSection, box: BoxLayout): number | null {
const groundAt = groundInterpolator(section.samples);
const inlet = box.sides.find((side) => side.wingRole === "inlet");
const outlet = box.sides.find((side) => side.wingRole === "outlet");
const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0;
if (!groundAt || !inlet || !outlet || !(run > 0)) return null;
return ((groundAt(inlet.offset) - groundAt(outlet.offset)) / run) * 100;
}
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
const trim = trimOfLayouts(layouts);
@@ -195,18 +209,26 @@ function areaRowOf(
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
const ownerChainage = pipeOwnerChainage(section, sections);
const isOwner = (target: number | undefined): boolean => {
const owner = ownerChainage(target, sections);
return owner === null || Math.abs(owner - section.chainage_m) <= CHAINAGE_TOLERANCE_M;
};
// 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음.
const pipeOwner =
!!section.culvert &&
!section.culvert.body &&
(ownerChainage === null ||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
const pipeRow: Record<string, number> | null =
typeof pipeLengthM === "number" && pipeLengthM > 0
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
!!section.culvert && !section.culvert.body && isOwner(section.culvert.chainage_m);
const pipe = pipeOwner ? layouts.culvert?.pipe : undefined;
const ownRow: Record<string, number> = { chainage_m: section.chainage_m };
if (pipe && pipe.lengthM > 0) {
ownRow.pipe_length_m = Number(pipe.lengthM.toFixed(4));
ownRow.pipe_slope_pct = Number(pipe.slopePct.toFixed(4));
}
// 박스도 걸친 옆 측점이 아니라 주인 측점에만(관과 같은 규칙).
const boxSlope =
layouts.box && isOwner(section.box?.chainage_m)
? boxGroundSlopePct(section, layouts.box)
: null;
if (boxSlope !== null) ownRow.box_ground_slope_pct = Number(boxSlope.toFixed(4));
const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null;
const areaInput = areaInputOf(section, layouts);
if (!areaInput) return pipeRow;
const design = layouts.design;
@@ -280,7 +302,7 @@ export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[]
const rows: ExtraWallRow[] = [];
for (const section of sections) {
if (!section.culvert) continue;
const owner = pipeOwnerChainage(section, sections);
const owner = ownerChainage(section.culvert.chainage_m, sections);
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
const layouts = computeStoredLayouts(section, sections);
const culvert = layouts?.culvert;
+4 -6
View File
@@ -47,14 +47,13 @@ const EXTRA_ROWS = [
".b06-structure-panel__struct--span",
].join(", ");
/** 유입구·유출구·날개벽 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
* 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이(24-4a). */
/** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
* 단 수 · 옵션 · 집수정 내공(24-4a). */
const SIDE_ROWS = [
".b06-structure-panel__struct--readout",
".b06-structure-panel__struct--tier",
".b06-structure-panel__struct--options",
".b06-structure-panel__struct--basin-size",
".b06-structure-panel__struct--wall-height",
].join(", ");
/** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29).
@@ -64,12 +63,11 @@ const PIPE_INLET_ROWS = [
".b06-structure-panel__struct--basin-size",
].join(", ");
const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";
const WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height";
/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은
* 세월교 측벽 높이(바닥 손 조정 · 91-2)만 받음(좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 65-1). */
* 받는 줄 없음(측벽 높이 줄 지움 · 107-3 · 읽기는 칸에 안 둠 · 65-1). */
function rowsFor(slot: HTMLElement): string | null {
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW;
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null;
if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS;
return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS;
}
+17 -9
View File
@@ -43,9 +43,11 @@ export interface BodyGuardBase {
trim: CulvertDesignTrim;
}
/** `defaults` = 세월교 측벽 조작을 안 본 기본 자리(관 1 m 맞춤 기준 · 107-1). */
export function bodyGuardBase(
section: CrossSection,
samples: SectionSample[],
defaults = false,
): BodyGuardBase | null {
const body = section.culvert?.body;
const adjust = section.design;
@@ -74,9 +76,10 @@ export function bodyGuardBase(
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
if (body === "ford" && section.ford) {
const stored = defaults ? null : adjust?.ford_adjust;
const layout = computeFordLayout(section, samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) },
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
});
if (!layout) return null;
// 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑
@@ -143,16 +146,21 @@ export function bodyGuardAxes(
export type BodyAxes = { x: number; d: number; through: number; outward: number };
/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘.
* 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 ·
* 수량 L 은 그린 관 길이 올림 그대로). */
export function fordPipeFit(
base: BodyGuardBase | null,
section: CrossSection,
samples: SectionSample[],
culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } },
axesOf: (role: Role) => BodyAxes | null,
axesOf: (role: Role, base?: BodyGuardBase | null) => BodyAxes | null,
): [BodyAxes | null, BodyAxes | null] {
const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")];
if (!base || culvert.body !== "ford") return axes;
if (culvert.body !== "ford") return axes;
const base = bodyGuardBase(section, samples, true);
if (!base) return axes;
const roles = ["inlet", "outlet"] as const;
const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null));
const guarded = roles.map((role) => (culvert[role].structure ? axesOf(role, base) : null));
const tips = roles.map((role, i) => {
const end = base.ends[role];
const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0;
@@ -163,8 +171,8 @@ export function fordPipeFit(
const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z);
const gap = Math.ceil(length - 1e-6) - length;
const at = guarded[1] ? 1 : 0;
const fit = guarded[at];
if (!fit || gap < 1e-6) return axes;
const fit = axes[at];
if (!guarded[at] || !fit || gap < 1e-6) return axes;
axes[at] = { ...fit, x: fit.x + gap };
return axes;
}
+32 -20
View File
@@ -28,12 +28,40 @@ export type {
const SVG_NS = "http://www.w3.org/2000/svg";
/** 고르는 부재 — 좌 · 우 끝 또는 본체(구체 가운데 1/3 · 좌측 「본체」 상자 · 66-1). */
/** 고르는 부재 — 좌 · 우 끝 또는 본체(두 구체 끝 사이 · 좌측 「본체」 상자 · 66-1 · 108-3). */
export type BoxPick = BoxSideRole | "body";
/** 선택 강조를 카드 재생성 없이 갈아 끼우는 setter. */
export type BoxHighlightSetter = (role: BoxPick | null) => void;
/** 날개벽(에이프런) 없는 끝의 판정 띠 폭(m) — 구체 끝 바깥으로. */
const BARE_END_HIT_M = 0.3;
/**
* 고르기 판정면(108-3) — 본체 = 두 구체 끝 사이 · 끝 = 구체 끝(`side.offset`) ~ 에이프런 바깥
* (`apron[1]`) · 에이프런이 없으면 구체 끝 바깥 얇은 띠. 높이 = 저판 밑 ~ 상판 위. 서로 겹치지 않는다.
*/
export function boxHitBands(layout: BoxLayout): Array<[BoxPick, OffsetPoint[]]> {
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
const band = (from: number, to: number): OffsetPoint[] => [
{ offset: from, elevation: top },
{ offset: to, elevation: top },
{ offset: to, elevation: bottom },
{ offset: from, elevation: bottom },
];
return [
["body", band(layout.sides[0].offset, layout.sides[1].offset)],
...layout.sides.map((side): [BoxPick, OffsetPoint[]] => {
const outward = side.role === "left" ? 1 : -1;
const outer = side.apron.length
? side.apron[1].offset
: side.offset + outward * BARE_END_HIT_M;
return [side.role, band(side.offset, outer)];
}),
];
}
/**
* BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다.
* `onSelectSide`가 오면 좌·우 절반이 선택 대상이 된다(조정창이 그 측을 잡는다).
@@ -172,27 +200,11 @@ export function appendBoxOverlay(
label.textContent = layout.label.text;
layer.append(label);
// 투명 겹면 — 구체 가운데 1/3 = 본체, 그 바깥 양쪽 = 그 끝(66-1). 어느 것을 조정할지 클릭으로 고른다.
// 투명 겹면(108-3) — 본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥(날개벽 없으면 바깥
// 얇은 띠) · 높이 저판 밑 ~ 상판 위. 어느 것을 조정할지 클릭으로 고른다.
const hits = new Map<BoxPick, SVGPolygonElement>();
if (onSelectSide) {
const midOffset = (layout.sides[0].offset + layout.sides[1].offset) / 2;
const third = (layout.sides[0].offset - layout.sides[1].offset) / 6;
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
const band = (from: number, to: number): OffsetPoint[] => [
{ offset: from, elevation: top },
{ offset: to, elevation: top },
{ offset: to, elevation: bottom },
{ offset: from, elevation: bottom },
];
const parts: Array<[BoxPick, OffsetPoint[]]> = [
["body", band(midOffset - third, midOffset + third)],
...layout.sides.map((side): [BoxPick, OffsetPoint[]] => {
const outer = side.apron.length ? side.apron[1].offset : side.offset;
const inner = midOffset + Math.sign(side.offset - midOffset) * Math.abs(third);
return [side.role, band(inner, outer)];
}),
];
const parts: Array<[BoxPick, OffsetPoint[]]> = boxHitBands(layout);
for (const [role, points] of parts) {
const hit = polygon(
points,
+25 -9
View File
@@ -14,9 +14,11 @@ import {
bindArrowKeys,
bindDragHandle,
buildPanelShell,
clampAxis,
dpadButton,
dragReadout,
makeRow,
snapAxisDrag,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -27,6 +29,8 @@ export interface BoxPanelDeps {
nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void;
/** 그 끝의 구체 표고 ±0.1m. */
nudgeRise: (role: BoxSideRole, deltaM: number) => void;
/** 그림 끌기(108-2) — 길이(화면 좌 +) · 표고를 한 번에 반영. */
move: (role: BoxSideRole, screenDeltaM: number, riseDeltaM: number) => void;
/** 그 측을 자동 자리로 되돌린다. */
reset: (role: BoxSideRole) => void;
/** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은
@@ -48,9 +52,10 @@ export interface BoxPanelHandle {
/** 한 걸음(m) — 사용자 지정 0.1m. */
const STEP_M = 0.1;
const RISE_LIMIT_M = 10;
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. 값 칸은 없다(계획서 24-4a) —
* 오버레이 = 읽기 + 십자 + 그림 끌기 손잡이 · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
* 오버레이 = 제목 + 십자 + 그림 끌기 손잡이(108-1) · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
@@ -63,7 +68,8 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
const riseLine = document.createElement("span");
const slopeLine = document.createElement("span");
const dragLine = dragReadout();
value.append(lengthLine, riseLine, slopeLine, dragLine);
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
value.append(...(opts?.dock ? [lengthLine, riseLine, slopeLine] : []), dragLine);
let current: BoxSideRole | null = null;
const act = (run: (role: BoxSideRole) => void) => () => {
@@ -106,17 +112,27 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
const off = { attach: (): void => undefined, detach: (): void => undefined };
const arrows = opts?.dock ? off : bindArrowKeys(root, () => [moveRow.controls]);
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고(십자와 같은 걸음 · 24-4a).
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고 두 축 함께(십자와 같은 걸음 · 108-2).
// 한계 = 길이 ≥ 0(바깥으로만) · 표고 ±10 m(제어기 `_Page_Ford_Controls` 와 같음).
const drag = opts?.dock
? off
: bindDragHandle(root, {
stepM: () => (current ? STEP_M : null),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (left) deps.nudgeLength(current, left * STEP_M);
if (down) deps.nudgeRise(current, -down * STEP_M);
snap: (leftM, downM) => {
const role = current;
if (!role) return null;
const from = deps.adjustFor(role);
const lengthSign = role === "left" ? 1 : -1;
return snapAxisDrag(leftM, downM, {
stepM: STEP_M,
slopeOutward: null,
clamp: (left, down) => [
clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign,
-clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M),
],
apply: (left, down) => deps.move(role, left, -down),
});
},
preview: (text) => (dragLine.textContent = text),
});
function render(): void {
+7 -7
View File
@@ -124,14 +124,14 @@ export function appendCulvertOverlay(
segment.points,
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
: " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` +
(layout.fillSlope.roadWideningM > 0.01
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m`
: "") +
(layout.fillSlope.structureRequired
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
? " · ⚠ 흙막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
: ""),
);
}
@@ -148,7 +148,7 @@ export function appendCulvertOverlay(
? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
: " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
);
@@ -192,7 +192,7 @@ export function appendCulvertOverlay(
"b06-chart__culvert-revet",
// 높이 표기 = 순수 높이(바닥~상단, 근입 0.5 포함). 추가 기슭막이도 같은 기준으로
// 통일했다(2026-08-23 배관 벽 → 2026-08-29 추가 벽).
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
`${roleLabel(wall.role)} 흙막이 ${wall.form ?? ""} H=${(
wall.height + REVET_EMBED_DEPTH_M
).toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` +
@@ -221,8 +221,8 @@ export function appendCulvertOverlay(
basin.reason === "cut"
? "절토측 유입 — 집수정 기본(성토사면 없음)"
: basin.reason === "manual"
? "사용자 선택 집수정(규칙상 기슭막이 자리)"
: "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본";
? "사용자 선택 집수정(규칙상 흙막이 자리)"
: "유입측 성토사면 3m 이하 — 흙막이 대신 집수정 기본";
for (const part of basin.parts) {
const shape = polygon(
part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
@@ -344,7 +344,7 @@ export function appendCulvertOverlay(
const hit = polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet-hit",
`${roleLabel(wall.role)} 기슭막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
`${roleLabel(wall.role)} 흙막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
);
hit.addEventListener("click", (event) => {
event.stopPropagation();
@@ -56,7 +56,7 @@ export interface OutletExtrasInput {
}
/** 수평공간(berm) 뒤 절토(복합경사 · 91-4)로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */
function bermCut(
export function bermCut(
src: OffsetPoint,
outward: number,
berm: { lengthM: number; cut: StructureCutSpec },
@@ -172,8 +172,8 @@ export function computeCulvertLayout(
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙.
const bodyAxesOf = (role: "inlet" | "outlet") => {
const end = body?.ends[role];
const bodyAxesOf = (role: "inlet" | "outlet", base = body) => {
const end = base?.ends[role];
const [spec, info] =
role === "inlet" ? [inletWallSpec, inletInfo] : [outletWallSpec, outletInfo];
if (!end) return null;
@@ -186,7 +186,7 @@ export function computeCulvertLayout(
thickness: REVET_THICKNESS_M,
});
};
const [inletBody, outletBody] = fordPipeFit(body, culvert, bodyAxesOf);
const [inletBody, outletBody] = fordPipeFit(section, groundSamples, culvert, bodyAxesOf);
const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust =>
axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust;
const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined;
+21 -16
View File
@@ -17,7 +17,7 @@
* ========================================================================== */
import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch";
import { pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
import type {
BasinLayout,
CulvertDesignTrim,
@@ -25,12 +25,13 @@ import type {
OutletFillSegment,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { basinApproachPoints, buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin";
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { bermCut, buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
/** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */
export interface FordWallAdjust {
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 자동(바닥판이 계류 하상에 놓임). */
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 기본 = 관경 + 최소 토피(원지반 안 봄 · 107-2). */
heightM: number | null;
/** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */
lateralM: number;
@@ -103,7 +104,8 @@ export interface FordLayout {
adjust: FordAdjust;
}
/** 측벽 높이 하한 = 관경 + 최소 토피. 관이 노면 위로 솟지 않게 막는다. */
/** 측벽 높이 하한 · 기본 = 관경 + 최소 토피 — 바닥 = 노면 − (관경 + 토피)(박스 「노면 − 복토」 꼴 ·
* 원지반 안 봄 · 깊은 계류면 ▲▼ 로 내림 · 107-2). 관이 노면 위로 솟지 않게 막는다. */
function minWallHeight(ford: FordSet): number {
return ford.diameter_m + ford.min_cover_m;
}
@@ -149,15 +151,6 @@ export function computeFordLayout(
const inletOnLeft = uphill === "left";
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
// 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점.
const bedElevation = (() => {
let low = Math.min(groundAt(edges.left.offset_m), groundAt(edges.right.offset_m));
for (let offset = edges.right.offset_m; offset <= edges.left.offset_m; offset += 0.25) {
low = Math.min(low, groundAt(offset));
}
return low;
})();
const buildSide = (role: FordWallRole): FordSideLayout | null => {
const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft;
const edge = onLeft ? edges.left : edges.right;
@@ -166,8 +159,7 @@ export function computeFordLayout(
const wing = role === "inlet" ? ford.wing_in : ford.wing_out;
const wanted = role === "inlet" ? adjust.inlet : adjust.outlet;
const lateralM = Math.max(wanted.lateralM, 0);
const autoHeight = roadTopAt(edge.offset_m) - (bedElevation + ford.slab_thickness_m);
const heightM = Math.max(wanted.heightM ?? autoHeight, minWallHeight(ford));
const heightM = Math.max(wanted.heightM ?? minWallHeight(ford), minWallHeight(ford));
// 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다.
const anchor: OffsetPoint = {
offset: edge.offset_m,
@@ -235,7 +227,20 @@ export function computeFordLayout(
);
})(),
fillSegments: extras.segments,
cutLine: built.basin.cutLine,
// 유입 날개벽 끝이 묻히면 수평 여유 뒤 절토 — 흙막이 수평공간과 같은 칸 · 기본 1 m(107-3)
cutLine:
role === "inlet" && wing.installed && built.basin.cutLine
? bermCut(
floor.points[1],
outward,
{
lengthM: Math.max(wing.berm_m ?? GUARD_CUT_BERM_M, 0),
cut: structureCutSpec(section.design),
},
groundAt,
limitOffset,
).points
: built.basin.cutLine,
adjust: {
heightM: wanted.heightM === null ? null : heightM,
lateralM,
+34 -39
View File
@@ -6,8 +6,8 @@
* `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조,
* **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화).
*
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이는 폼 날개벽
* 칸으로 옮겨 붙는 줄 · 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이 줄 없음(107-3) ·
* 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
* 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다.
* ========================================================================== */
@@ -17,10 +17,11 @@ import {
bindArrowKeys,
bindDragHandle,
buildPanelShell,
clampAxis,
dpadButton,
dragReadout,
makeButton,
makeRow,
snapAxisDrag,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -29,12 +30,14 @@ export interface FordPanelDeps {
adjustFor: (role: FordWallRole) => FordWallAdjust;
/** 실제 그려진 벽 높이(m) — 자동일 때도 숫자를 보여 준다. */
heightFor: (role: FordWallRole) => number;
/** 높이 ±0.1m. */
nudgeHeight: (role: FordWallRole, deltaM: number) => void;
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
nudge: (role: FordWallRole, screenDeltaM: number) => void;
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
/** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */
outwardOf: (role: FordWallRole) => number;
/** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */
move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void;
/** 자동 자리로 되돌린다(세 축 모두). */
reset: (role: FordWallRole) => void;
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
@@ -51,11 +54,13 @@ export interface FordPanelHandle {
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
const STEP_M = 0.1;
/** 좌우 · 상하 한계(m) — 제어기 `clampMove`(`_Page_Ford_Controls`)와 같다. */
const MOVE_LIMIT_M = 10;
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a).
* `opts.dock` — 좌측 폼에 옮겨 붙을 줄(읽기 · 측벽 높이)만 만든다 — 폼에 없던 칸이다.
* 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */
* `opts.dock` — 읽기 줄만 만든다(측벽 높이 줄 없음 · 107-3).
* 오버레이는 제목 + 십자 + 그림 끌기 손잡이(108-1). */
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
@@ -68,33 +73,14 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
const moveLine = document.createElement("span");
const slabLine = document.createElement("span");
const dragLine = dragReadout();
value.append(heightLine, moveLine, slabLine, dragLine);
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
value.append(...(opts?.dock ? [heightLine, moveLine, slabLine] : []), dragLine);
let current: FordWallRole | null = null;
const act = (run: (role: FordWallRole) => void) => () => {
if (current) run(current);
};
// 측벽 높이 — 폼에 칸이 없던 값이라 폼 날개벽 칸으로 옮겨 붙는 줄(24-4a).
const heightRow = makeRow("측벽 높이");
heightRow.row.classList.add("b06-structure-panel__struct--wall-height");
const heightLabel = heightRow.row.querySelector<HTMLElement>(".b06-structure-panel__label")!;
const heightValue = document.createElement("span");
heightValue.className = "b06-structure-panel__hval";
heightRow.controls.append(
makeButton(
"−",
"측벽 높이 0.1m 낮추기",
act((role) => deps.nudgeHeight(role, -STEP_M)),
),
heightValue,
makeButton(
"+",
"측벽 높이 0.1m 높이기",
act((role) => deps.nudgeHeight(role, STEP_M)),
),
);
// 십자 조작 세트 — **창 맨 아래**(템플릿 규약).
const moveRow = makeRow("이동");
moveRow.controls.classList.add("b06-structure-panel__buttons");
@@ -126,22 +112,34 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
),
);
root.append(value, opts?.dock ? heightRow.row : moveRow.row);
// 측벽 높이 줄 없음 — 세월교 바닥은 노면 − (관경 + 토피) 기본(107-3)
root.append(value);
if (!opts?.dock) root.append(moveRow.row);
const arrows = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindArrowKeys(root, () => [moveRow.controls]);
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a).
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2).
// 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m.
const drag = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindDragHandle(root, {
stepM: () => (current ? STEP_M : null),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (left) deps.nudge(current, left * STEP_M);
if (down) deps.nudgeSlope(current, down * STEP_M);
snap: (leftM, downM) => {
const role = current;
if (!role) return null;
const from = deps.adjustFor(role);
const outward = deps.outwardOf(role);
return snapAxisDrag(leftM, downM, {
stepM: STEP_M,
slopeOutward: outward,
clamp: (left, down) => [
clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward,
clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M),
],
apply: (left, down) => deps.move(role, left, down),
});
},
preview: (text) => (dragLine.textContent = text),
});
function render(): void {
@@ -153,9 +151,6 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
`높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : "");
moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`;
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`;
heightValue.textContent = `${deps.heightFor(current).toFixed(1)}m`;
// 자동 = 바닥판이 계류 하상을 따라감 · 손 값 = 원지반이 바뀌어도 그대로(91-2) — 값 칸이 좁아 이름표에
heightLabel.textContent = adjust.heightM === null ? "측벽 높이 (자동)" : "측벽 높이";
}
return {
+76 -27
View File
@@ -10,6 +10,8 @@
* · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보.
* ========================================================================== */
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
@@ -89,19 +91,77 @@ export function arrowsBelongToProfile(): boolean {
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
/** 끌기 손잡이가 조정 창에 묻는 것 — 걸음(m)이 null 이면 지금 고른 것은 끌 수 없다. */
/** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */
export interface DragSnap {
leftM: number;
downM: number;
text: string;
apply: () => void;
}
/** 끌기 손잡이가 조정 창에 묻는 것. */
export interface DragHandleDeps {
stepM: () => number | null;
/** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */
snap: (leftM: number, downM: number) => DragSnap | null;
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
preview: (text: string) => void;
/** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */
commit: (stepsLeft: number, stepsDown: number) => void;
}
const round1 = (value: number): number => Math.round(value * 10) / 10;
const clampTo = (value: number, low: number, high: number): number =>
Math.min(Math.max(value, low), high);
/** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */
export interface AxisDragSpec {
stepM: number;
/** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */
slopeOutward: number | null;
/** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */
clamp: (left: number, down: number) => [number, number];
apply: (left: number, down: number) => void;
}
/** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */
const SLOPE_RUN = FILL_SLOPE_RATIO_MIN;
/**
* 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께.
* 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면
* `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다.
*/
export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap {
const { stepM, slopeOutward: outward } = spec;
const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM);
const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN);
const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown);
const [left, down] = spec.clamp(wantLeft, wantDown).map(round1);
return {
leftM: left + (outward ?? 0) * down,
downM: outward === null ? down : down / SLOPE_RUN,
text: moveText(left, down),
apply: () => spec.apply(left, down),
};
}
/** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */
function moveText(left: number, down: number): string {
return [
left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "",
down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "",
]
.filter(Boolean)
.join(" · ");
}
/** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */
export function clampAxis(from: number, move: number, low: number, high: number): number {
return round1(clampTo(from + move, low, high) - from);
}
/**
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
* 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다.
* 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
* 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가
* 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
*/
export function bindDragHandle(
root: HTMLElement,
@@ -126,33 +186,22 @@ export function bindDragHandle(
event.clientY <= box.bottom + 3
);
});
const stepM = deps.stepM();
const pxPerM = Number(svg.dataset.pxPerM);
const exaggeration = Number(svg.dataset.exaggeration) || 1;
const ctm = layer.getScreenCTM();
if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return;
if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return;
event.preventDefault();
event.stopPropagation();
const [startX, startY] = [event.clientX, event.clientY];
let left = 0;
let down = 0;
let snapped: DragSnap | null = null;
const move = (next: PointerEvent): void => {
const dx = next.clientX - startX;
const dy = next.clientY - startY;
const horizontal = Math.abs(dx) >= Math.abs(dy);
left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0;
down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM);
const tx = -left * stepM * pxPerM;
const ty = down * stepM * pxPerM * exaggeration;
const leftM = -(next.clientX - startX) / (pxPerM * ctm.a);
const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d);
snapped = deps.snap(leftM, downM);
const tx = -(snapped?.leftM ?? 0) * pxPerM;
const ty = (snapped?.downM ?? 0) * pxPerM * exaggeration;
shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`));
const meters = (Math.abs(left || down) * stepM).toFixed(1);
deps.preview(
left
? `${left > 0 ? "◀" : "▶"} ${meters}m`
: down
? `${down > 0 ? "▼" : "▲"} ${meters}m`
: "",
);
deps.preview(snapped?.text ?? "");
};
const up = (): void => {
window.removeEventListener("pointermove", move);
@@ -160,12 +209,12 @@ export function bindDragHandle(
window.removeEventListener("pointercancel", up);
shapes.forEach((shape) => shape.removeAttribute("transform"));
deps.preview("");
if (!left && !down) return;
if (!snapped?.text) return;
// 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다.
const swallow = (click: Event): void => click.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
deps.commit(left, down);
snapped.apply();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
@@ -25,6 +25,8 @@ import {
dragReadout,
makeButton,
makeRow,
snapAxisDrag,
type AxisDragSpec,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -66,6 +68,8 @@ export interface StructurePanelDeps {
canNudge: (key: RevetKey) => boolean;
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
fineMove: (key: RevetKey) => boolean;
/** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */
dragAxes?: (key: RevetKey, basin: boolean) => Omit<AxisDragSpec, "stepM"> | null;
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
extraState: () => { canAdd: boolean; count: number };
/** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만
@@ -407,14 +411,13 @@ export function buildStructurePanel(
return { button, set };
};
const LINK_TITLE =
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
"옆 측점 흙막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
/** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */
const LINK_LOCKED_TITLE =
"경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다.";
const LINK_LOCKED_TITLE = "경사 해제(수평) 중에는 연동을 풀 수 없다 — 흙막이 Z를 하나로 묶는다.";
const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on));
const gradeToggle = makeToggle(
"경사",
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
"종단경사 반영(3D 전용) — 켜면 3D 흙막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
(on) => deps.setFollowGrade(on),
);
@@ -457,9 +460,8 @@ export function buildStructurePanel(
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin());
basinReset.classList.add("b06-structure-panel__basin-reset");
head.append(title, basinReset, closeButton);
// 집수정 기본값은 위치 십자 가운데 ↺ 하나(108-1 — 머리줄은 제목 · 닫기만).
head.append(title, closeButton);
// 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작
// 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다.
// 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7).
@@ -478,22 +480,16 @@ export function buildStructurePanel(
: [basinRow.row, moveRow.row]),
);
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만.
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 두 축 함께 · 놓을 때 한 번(108-2). 오버레이만.
let dragKind: "wall" | "basin" | null = null;
const drag = bindDragHandle(root, {
stepM: () =>
!current || !dragKind ? null : dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (dragKind === "basin") {
if (left) deps.nudgeBasinLateral(left * BASIN_STEP_M);
if (down) moveBasinSlope(down);
return;
}
if (left) deps.nudge(current, left * moveStepOf(current));
if (down) deps.nudgeSlope(current, down * moveStepOf(current));
snap: (leftM, downM) => {
const axes = current && dragKind ? deps.dragAxes?.(current, dragKind === "basin") : null;
if (!current || !axes) return null;
const stepM = dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current);
return snapAxisDrag(leftM, downM, { stepM, ...axes });
},
preview: (text) => (dragLine.textContent = text),
});
return {
@@ -541,7 +537,6 @@ export function buildStructurePanel(
.querySelector(".b06-structure-panel__btn--equal")
?.classList.toggle("is-hidden", isOwn || key !== "outlet");
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
// I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다.
for (const entry of basinSizeRows) {
// 화면에서 숨김(54-40 · 값 · 그림 계산은 그대로)
+5 -4
View File
@@ -213,6 +213,7 @@ export function createCrossSectionCard(
box,
isCardSelected: () => isSelected,
clearArea: () => {
if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1)
if (activeArea === null) return;
activeArea = null;
setBandActive(null);
@@ -235,10 +236,10 @@ export function createCrossSectionCard(
setChipActive(null);
}
if (activeRevet !== null) {
// 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(onAreaSelect)를 먼저
// 부르면 그쪽이 카드를 선택해 버려, 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로
// 먹혀 아무 일도 일어나지 않는다(2026-08-21 화면 확인). 카드 선택 경로가 부모의
// 면적 강조를 이미 비운다.
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
// 화면 확인). 카드 선택 경로가 부모의 면적 강조를 이미 비운다.
if (wasSelected) onAreaSelect?.(section.station_id, null);
else onSelect(section.station_id);
}
@@ -30,6 +30,13 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps {
}),
nudgeRise: (role, deltaM) =>
box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }),
move: (role, screenDeltaM, riseDeltaM) => {
const from = box.adjustFor(chainageM)[role];
box.update(chainageM, role, {
lengthM: Math.round((from.lengthM + screenDeltaM * (role === "left" ? 1 : -1)) * 10) / 10,
riseM: Math.round((from.riseM + riseDeltaM) * 10) / 10,
});
},
reset: (role) => box.reset(chainageM, role),
wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"),
bodyLengthM: () => context.layout()?.bodyLengthM ?? 0,
@@ -13,7 +13,7 @@ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford";
export interface FordPanelContext {
section: CrossSection;
ford: FordControl;
/** 마지막 계산의 측벽 높이(m) — 높이 조작의 시작값. */
/** 마지막 계산의 측벽 높이(m) — 읽기 줄. */
heightFor: (role: FordWallRole) => number;
/** 마지막 계산의 바닥판 길이(m). */
slabLengthM: () => number;
@@ -31,8 +31,6 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
return {
adjustFor: (role) => ford.adjustFor(chainage)[role],
heightFor: context.heightFor,
nudgeHeight: (role, deltaM) =>
ford.update(chainage, role, { heightM: context.heightFor(role) + deltaM }),
nudge: (role, screenDeltaM) =>
ford.update(chainage, role, {
lateralM: ford.adjustFor(chainage)[role].lateralM + screenDeltaM * outwardOf(section, role),
@@ -41,6 +39,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
ford.update(chainage, role, {
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
}),
outwardOf: (role) => outwardOf(section, role),
move: (role, screenDeltaM, slopeDeltaM) => {
const from = ford.adjustFor(chainage)[role];
ford.update(chainage, role, {
lateralM: from.lateralM + screenDeltaM * outwardOf(section, role),
slopeM: from.slopeM + slopeDeltaM,
});
},
reset: (role) => ford.reset(chainage, role),
slabLengthM: context.slabLengthM,
close: context.close,
@@ -8,10 +8,13 @@
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
import {
FILL_SLOPE_RATIO_MIN,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_FORM_DEFAULT,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { clampAxis, type AxisDragSpec } from "./B06_Section_UI_Cross_Panel_Base";
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
@@ -149,6 +152,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
fineMove: (key) =>
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
extraState: () => ctx.extraState(),
isLinkedCard: () => ctx.isLinked,
basinExtraState: () => ctx.basinExtraState(),
@@ -222,6 +226,70 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
};
}
/** 끌기 한계 ±10 m — 제어기 `clampMove`(`_Page_Station_Controls`)와 같다. */
const DRAG_LIMIT_M = 10;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/**
* 벽 · 집수정 그림 끌기 두 축(108-2) — 축 뜻 · 한계 · 한 번 반영. 한계는 저장 · 기하가 거는 것 중
* 미리 알 수 있는 것(±10 m · 안쪽 금지 · 노면 위 금지 · 다단 선반 1.2·d) — 넘으면 가장 가까운 허용 자리.
* · 배관 기준벽 · 집수정 = ▲▼ 1:1.2 사면(`placePipeWall` · `_Culvert_Basin` 사면 이동)
* · 다단 · 독립 벽 = ▲▼ 순수 수직(`_Culvert_Extra` · `_Revetment` 축)
*/
function wallDragAxes(
ctx: StructurePanelContext,
key: RevetKey,
basin: boolean,
): Omit<AxisDragSpec, "stepM"> | null {
if (!ctx.revetOffset) return null;
const outward = ctx.outwardOf(basin ? "inlet" : key);
if (basin) {
const from = ctx.inletStructure?.adjustFor(ctx.section);
if (!from) return null;
const lateralMove = (left: number): number =>
clampAxis(from.lateralM, left * outward, 0, DRAG_LIMIT_M);
return {
slopeOutward: outward,
clamp: (left, down) => [
lateralMove(left) * outward,
clampAxis(from.slopeM, down, 0, DRAG_LIMIT_M),
],
apply: (left, down) =>
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
...from,
lateralM: round1(from.lateralM + left * outward),
slopeM: round1(from.slopeM + down),
}),
};
}
const adjust = ctx.adjustOf(key);
const d0 = adjust.d ?? ctx.appliedD().get(key) ?? 0;
const diagonal = key === "inlet" || key === "outlet";
// x 하한 — 배관 기준벽 = 안쪽 금지(흙막이는 본체 끝이 0점이라 안 막음 · 73-4) · 다단 = 선반 ≥ 0 ·
// 독립 = 성토선 각도(1.2·d − 기본 run)
const xLow = (d: number): number =>
isTierKey(key)
? Math.max(0, FILL_SLOPE_RATIO_MIN * d)
: key === "own"
? FILL_SLOPE_RATIO_MIN * d - PIPE_WALL_DEFAULT_RUN_M
: ctx.bodyGuard?.()
? -DRAG_LIMIT_M
: 0;
return {
slopeOutward: diagonal ? outward : null,
clamp: (left, down) => {
const dMove = clampAxis(d0, down, 0, DRAG_LIMIT_M);
const low = Math.max(xLow(d0 + dMove), -DRAG_LIMIT_M);
return [clampAxis(adjust.x, left * outward, low, DRAG_LIMIT_M) * outward, dMove];
},
apply: (left, down) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
x: round1(adjust.x + left * outward),
d: round1(d0 + down),
}),
};
}
/**
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
@@ -109,6 +109,8 @@ export interface FordControlDeps {
queuePipeOptions: (chainageM: number, patch: Record<string, number | string>) => void;
round1: (value: number) => number;
clampMove: (value: number) => number;
/** 같은 측점 벽 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1). */
releaseWall: (chainageM: number) => void;
}
export interface FordControls {
@@ -225,6 +227,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
},
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
if (role) deps.releaseWall(chainageM);
fordSelections.set(chainageM.toFixed(2), role);
},
};
@@ -425,6 +428,7 @@ export function createBoxControls(deps: FordControlDeps): {
},
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
if (role) deps.releaseWall(chainageM);
selections.set(chainageM.toFixed(2), role);
},
};
@@ -266,6 +266,10 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
select: (chainageM, key) => {
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
// 벽을 고르면 같은 측점 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
const { ford, box } = bodyControls;
if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);
if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);
revetOffsetControl.onSelectionChange?.();
},
syncApplied: (chainageM, role, applied) => {
@@ -561,6 +565,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
queuePipeOptions: culvertOptions.queue,
round1,
clampMove,
releaseWall: (chainageM) => {
if (revetSelection?.at !== chainageM.toFixed(2)) return;
revetSelection = null;
revetOffsetControl.onSelectionChange?.();
},
},
deps.sessionKey,
);
+33 -3
View File
@@ -19,6 +19,7 @@ import type {
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
@@ -197,6 +198,26 @@ export function createSectionView(
};
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
/**
* 고른 구간형 구조물(노면공 등)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1).
* 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다.
*/
let spanMarks: ReadonlyArray<StructureInstance> = [];
let spanMarkId: string | null = null;
const inSpan = (section: CrossSection): boolean => {
const mark = spanMarkId ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : null;
const { start_m: start, end_m: end } = mark ?? {};
if (start == null || end == null || start === end) return false;
const at = section.chainage_m;
return at >= Math.min(start, end) - 0.01 && at <= Math.max(start, end) + 0.01;
};
const syncSpanHighlights = (): void => {
for (const section of currentDetail?.cross_sections ?? [])
document
.getElementById(`cross-${section.station_id}`)
?.classList.toggle("b06-cross-card--span", inSpan(section));
};
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
let stationSelectListener: ((stationId: string | null) => void) | null = null;
@@ -269,8 +290,8 @@ export function createSectionView(
section: CrossSection,
forcedHeightPx?: number,
plotBase?: CrossPlotBase | null,
): HTMLElement =>
createCrossSectionCard(
): HTMLElement => {
const card = createCrossSectionCard(
section,
section.station_id === selectedStationId,
currentExaggeration,
@@ -296,6 +317,9 @@ export function createSectionView(
plotBase,
cutSlope,
);
card.classList.toggle("b06-cross-card--span", inSpan(section));
return card;
};
/**
* 옆 측점 구조물이 이 측점까지 넘어왔는지 — 기슭막이 기본 연장 10m(전 5·후 5)면
@@ -483,7 +507,11 @@ export function createSectionView(
);
}
},
selectMark: (structureId) => profile.selectMark(structureId),
selectMark: (structureId) => {
spanMarkId = structureId;
syncSpanHighlights();
profile.selectMark(structureId);
},
setStationSelectListener(listener) {
stationSelectListener = listener;
},
@@ -491,6 +519,8 @@ export function createSectionView(
structureEdit = edit;
},
setStructureMarks(structures, types) {
spanMarks = structures;
syncSpanHighlights();
profile.setMarks(structures, types);
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
// (`__corridorBuild` 등과 같은 용도).
@@ -175,6 +175,12 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
}
/* 구간형 구조물(노면공 등)을 고르면 시작~종료 안 모든 카드(106-1) — 가까운 한 장의 선택 강조보다 옅게. */
.b06-cross-card--span:not(.b06-cross-card--selected) {
border-color: var(--color-danger);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
}
/* 제목행 1행 구조(E-2/E-3): 측점 라벨 → 단면유형 pill → 지반유형 → 구조물·kind. */
.b06-cross-card > header {
display: flex;
@@ -174,8 +174,9 @@
grid-area: se;
}
.b06-structure-panel__basin-reset {
margin-left: auto;
/* 횡단도 안 조정창 = 제목 · 단추만(108-1) — 줄 이름은 좌측 사본에만. */
.b06-structure-panel:not(.b06-structure-panel--dock) .b06-structure-panel__label {
display: none;
}
.b06-structure-panel__select {
@@ -297,11 +298,6 @@
display: none;
}
/* 집수정 기본값(↺)은 9키와 한 세트라 오버레이에만 둔다. */
.b06-structure-panel--dock .b06-structure-panel__basin-reset {
display: none;
}
/* 좌측 폼과 중복된 행은 사본에서 숨긴다(2026-08-29 사용자 지시 3·5):
* 형태 = 유입구·유출구 그룹의 "형태", 구조물 형식 = 유입구 "구조"로 합쳤다. */
.b06-structure-panel--dock .b06-structure-panel__struct--form,