fix(B06): 유출 관 끝점·전면 근입 기준 + 줌 유지·조정창 레이아웃 정리

- 유출 관 하단 끝점 = 벽 바닥 +0.5m 기준선과 전면 경사선(1:0.3)의 교차점
  (성토부선 시작점과 동일 자리) — 정수 맞춤 재배치에도 동일 적용
- 근입 0.5m는 경사선(전면) 측 깊이 기준 — 전면 발끝 지반 아래 0.5까지
  수렴 반복으로 내림(배관 벽·추가 벽 공통, 뜬 벽은 기준선 아래 0.5)
- 카드 내부 조작(조정창 등)으로 다시 그려져도 줌·팬 배율 유지
  (측점별 상태 보관, ZoomPanState)
- 조정창: 재질 행을 높이 행 위 별도 행으로(폭 축소 — 한계는 툴팁),
  추가 기슭막이(단) 행은 유출 벽에서 항상 표시(레이아웃 널뜀 방지)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:32:30 +09:00
co-authored by Claude Opus 5
parent ff3e3b734b
commit 97bf00a8ef
6 changed files with 111 additions and 53 deletions
@@ -97,6 +97,7 @@ function buildExtraWall(
height: number,
material: RevetMaterial,
floatGapM: number,
groundAt: (offset: number) => number,
): WallLayout {
const thickness = REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
@@ -107,8 +108,16 @@ function buildExtraWall(
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
// 하단 = 기준선(anchor) 아래 근입 0.5m 고정 — 높이 기준 "근입 0.5 위~상단"과 일치.
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
// 하단 = 기준선(anchor) 아래 근입 0.5m — 근입은 **경사선(전면) 측 깊이** 기준이라
// 전면 발끝 지반이 낮으면 그 아래 0.5까지 더 내린다(2026-08-22 사용자 ②).
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
for (let pass = 0; pass < 6; pass += 1) {
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break;
bottomElevation = toeGround - REVET_EMBED_DEPTH_M;
}
}
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
@@ -228,6 +237,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
height,
material,
Math.max(0, base - groundAt(anchorX)),
groundAt,
);
walls.push(wall);
// 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부.
@@ -339,7 +339,16 @@ export function computeCulvertLayout(
// 더 높으면 그만큼 더 묻힌다. 전면 경사선(1:0.3)은 하단까지 연장.
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
// 근입 0.5m는 **경사선(전면) 측 깊이** 기준(2026-08-22 사용자 ②) — 전면 발끝
// 지반이 더 낮으면 그 아래 0.5까지 내린다. 내려가면 발끝이 더 나가므로 수렴 반복.
for (let pass = 0; pass < 6; pass += 1) {
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break;
bottomElevation = toeGround - REVET_EMBED_DEPTH_M;
}
}
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
@@ -450,11 +459,20 @@ export function computeCulvertLayout(
// 맞춤이 벽을 옮긴 뒤에도 옛 축을 그대로 써서, 가파른 지반에서 관이 벽 바닥
// 아래로 삐져나갔다(13+15.7 사용자 지적).
const pipeStart = basinPipeEnd ?? inlet;
/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의
* 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */
const outletPipeEnd = (wall: WallLayout): OffsetPoint => ({
offset:
wall.points[2].offset +
wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - wall.base),
elevation: wall.base,
});
// 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은
// 유출 쪽 — 2026-08-20 확정). 관 끝은 벽 하단선 중점을 지나 정수 길이까지 나간다.
// 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이.
if (outletWall) {
const runW = outletWallAnchor.offset - pipeStart.offset;
const riseW = outletWallAnchor.elevation - pipeStart.elevation;
const face = outletPipeEnd(outletWall);
const runW = face.offset - pipeStart.offset;
const riseW = face.elevation - pipeStart.elevation;
const lenW = Math.hypot(runW, riseW);
if (lenW > 0.5) {
const scaleW = Math.ceil(lenW - 1e-6) / lenW;
@@ -537,10 +555,10 @@ export function computeCulvertLayout(
},
outletWallSpec.material,
);
// 관 하단선은 옮겨진 벽의 **하단선 중점을 지나야** 한다 — 옛 축을 그대로 늘리면
// 가파른 지반에서 관이 벽 바닥 아래로 삐져나간다(2026-08-22 13+15.7 사용자 지적).
const runW = outletWallAnchor.offset - pipeStart.offset;
const riseW = outletWallAnchor.elevation - pipeStart.elevation;
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
const runW = face.offset - pipeStart.offset;
const riseW = face.elevation - pipeStart.elevation;
const lenW = Math.hypot(runW, riseW) || 1;
outlet.offset = pipeStart.offset + (runW / lenW) * target;
outlet.elevation = pipeStart.elevation + (riseW / lenW) * target;
@@ -126,7 +126,31 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
),
);
// 높이·재질 행(2026-08-22 사용자) — 높이 ±0.1m, 재질이 한계를 정한다.
// 재질 행(2026-08-22 사용자 — 높이 행 **위**, 폭 축소: 한계는 툴팁으로).
const materialRow = document.createElement("label");
materialRow.className = "b06-structure-panel__struct";
const materialLabelEl = document.createElement("span");
materialLabelEl.textContent = L("B06_Cross_Mat_Label");
const materialSelect = document.createElement("select");
materialSelect.className = "b06-structure-panel__select";
const MATERIAL_LABEL: Record<RevetMaterial, string> = {
dry: L("B06_Cross_Mat_Dry"),
wet: L("B06_Cross_Mat_Wet"),
concrete: L("B06_Cross_Mat_Concrete"),
};
for (const material of REVET_MATERIALS) {
const option = document.createElement("option");
option.value = material;
option.textContent = MATERIAL_LABEL[material];
option.title = `${L("B06_Cross_Height_Label")} ~${materialLimit(material).toFixed(1)}m`;
materialSelect.append(option);
}
materialSelect.addEventListener("change", () => {
if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial);
});
materialRow.append(materialLabelEl, materialSelect);
// 높이 행 — ±0.1m, 한계는 재질이 정한다.
const heightRow = document.createElement("div");
heightRow.className = "b06-structure-panel__struct";
const heightLabel = document.createElement("span");
@@ -143,23 +167,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
L("B06_Cross_Height_Plus"),
act((key) => deps.nudgeHeight(key, HEIGHT_STEP_M)),
);
const materialSelect = document.createElement("select");
materialSelect.className = "b06-structure-panel__select";
const MATERIAL_LABEL: Record<RevetMaterial, string> = {
dry: L("B06_Cross_Mat_Dry"),
wet: L("B06_Cross_Mat_Wet"),
concrete: L("B06_Cross_Mat_Concrete"),
};
for (const material of REVET_MATERIALS) {
const option = document.createElement("option");
option.value = material;
option.textContent = `${MATERIAL_LABEL[material]}(~${materialLimit(material).toFixed(1)}m)`;
materialSelect.append(option);
}
materialSelect.addEventListener("change", () => {
if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial);
});
heightRow.append(heightLabel, heightMinus, heightValue, heightPlus, materialSelect);
heightRow.append(heightLabel, heightMinus, heightValue, heightPlus);
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
const structureRow = document.createElement("label");
@@ -218,7 +226,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
head.append(title, closeButton);
root.append(head, value, structureRow, heightRow, extraRow, buttons);
root.append(head, value, structureRow, materialRow, heightRow, extraRow, buttons);
return {
root,
@@ -252,17 +260,16 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
select.value = structure;
}
buttons.classList.toggle("is-hidden", !movable);
materialRow.classList.toggle("is-hidden", !movable);
heightRow.classList.toggle("is-hidden", !movable);
if (movable) {
heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`;
materialSelect.value = deps.materialFor(key);
}
// 단 수 행은 **유출 벽에서** — 성토부 5m 이상(의무)이거나 이미 단이 있을 때.
// 단 수 행은 유출 벽에서 **항상** 보인다(2026-08-22 사용자 ⑤ — 상태 따라
// 나타났다 사라지면 레이아웃이 널뛴다).
const extra = deps.extraState();
extraRow.classList.toggle(
"is-hidden",
!(key === "outlet" && (extra.canAdd || extra.count > 0)),
);
extraRow.classList.toggle("is-hidden", key !== "outlet");
countInput.value = String(extra.count);
const adjust = deps.adjustFor(key);
// 조작 요약 — 0이면 "자동". 좌우는 안/바깥, 상하는 위/아래로 적는다.
+20 -15
View File
@@ -32,6 +32,7 @@ import type {
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import type { ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
@@ -166,14 +167,16 @@ export interface StationWidthControl {
reset: (chainageM: number) => void;
}
// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire로 옮겼다(700줄 제한) —
// 기존 import 경로 유지를 위해 재수출한다.
// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire에 있다(700줄 제한) — 재수출.
export type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
/** 측점별 줌·팬 상태 — 카드 재생성에도 배율 유지(2026-08-22 사용자 ③). */
const cardZoomStates = new Map<string, ZoomPanState>();
export function createCrossSectionCard(
section: CrossSection,
selected: boolean,
@@ -219,15 +222,14 @@ export function createCrossSectionCard(
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
let setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined;
/** 지금 그린 관 길이(m) — 조정창이 "관 길이 8m"으로 적는다. 배수관 측점이 아니면 null. */
/** 지금 그린 관 길이(m) — 조정창 표기용. 배수관 측점이 아니면 null. */
let culvertPipeLengthM: number | null = null;
/** 유입이 집수정인가 — 조정창이 ◀/▶ 표시 여부·제목을 가른다. */
let culvertInletIsBasin = false;
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */
let culvertExtraState = { canAdd: false, count: 0 };
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 높이 행 표시·높이 조작 기준. */
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. */
let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
/**
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
@@ -495,8 +497,7 @@ export function createCrossSectionCard(
toDisplayY,
);
}
// 배수관 측점 세트 — 기존 도형 위에 추가만 한다(2026-08-19). 조정창이 쓸
// 카드 상태(관 길이·집수정 여부·선택지·추가 벽 상태)를 여기서 받아 둔다.
// 배수관 세트 — 조정창이 쓸 카드 상태를 여기서 받아 둔다(2026-08-19).
culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null;
culvertInletIsBasin = !!culvertLayout?.basin;
if (culvertLayout) {
@@ -588,13 +589,19 @@ export function createCrossSectionCard(
// 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke.
const chartWrap = document.createElement("div");
chartWrap.className = "b06-cross-card__chart-wrap";
const zoomPan = attachZoomPan(svg, plotLayer, widthPx, heightPx);
const zoomPan = attachZoomPan(
svg,
plotLayer,
widthPx,
heightPx,
cardZoomStates.get(section.station_id),
(state) => cardZoomStates.set(section.station_id, state),
);
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
const readout = buildAreaReadout(section.design, toggleArea);
setChipActive = readout.setActive;
// 구조물 위치 조정은 **도면 안 오버레이 창**으로 한다(2026-08-21 사용자 확정).
// 벽이 서는 쪽(outward): 상단측(유입)이 좌측이면 유입 벽은 좌(+), 유출 벽은 우(−).
// 화면 좌(◀)로 민다 = offset이 커진다 — 벽 기준 이동량으로 환산해 넘긴다.
// 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 —
// 벽 기준 이동량(outward 부호)으로 환산해 넘긴다.
const outwardOf = (role: RevetKey): number =>
((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1;
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
@@ -608,7 +615,6 @@ export function createCrossSectionCard(
revetOffset?.update(section.chainage_m, key, {
x: adjustOf(key).x + screenDeltaM * outwardOf(key),
}),
// 상하(대각) — ▲(위) = 성토선을 타고 노견 쪽, ▼(아래) = 계류 쪽. 수평 성분 1m.
nudgeSlope: (key, deltaM) =>
revetOffset?.update(section.chainage_m, key, { d: adjustOf(key).d + deltaM }),
nudgeHeight: (key, deltaM) => {
@@ -633,8 +639,7 @@ export function createCrossSectionCard(
optionsFor: () => culvertInletOptions,
extraState: () => culvertExtraState,
setExtraCount: (count) => {
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다 — 다시
// 그려질 때 없는 키를 강조하려다 조정창이 빈 대상으로 남는 것을 막는다.
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
if (activeRevet?.startsWith("extra") && Number(activeRevet.slice(5)) >= count) {
activeRevet = "outlet";
revetOffset?.select(section.chainage_m, "outlet");
+21 -4
View File
@@ -18,11 +18,22 @@ export interface ZoomPanHandle {
reset: () => void;
}
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState {
scale: number;
tx: number;
ty: number;
}
export function attachZoomPan(
svg: SVGSVGElement,
plotLayer: SVGGElement,
widthPx: number,
heightPx: number,
/** 이전 카드의 줌·팬 상태 — 조정창 조작으로 카드가 다시 그려져도 배율 유지. */
initial?: ZoomPanState,
/** 상태가 바뀔 때마다 부른다 — 카드 밖(뷰)이 담아 뒀다가 다음 렌더에 되돌린다. */
onChange?: (state: ZoomPanState) => void,
): ZoomPanHandle {
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
const plot = {
@@ -32,11 +43,13 @@ export function attachZoomPan(
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
};
const maxScale = 8;
let scale = 1;
let tx = 0;
let ty = 0;
const applyTransform = (): void =>
let scale = initial?.scale ?? 1;
let tx = initial?.tx ?? 0;
let ty = initial?.ty ?? 0;
const applyTransform = (): void => {
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty });
};
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
const clampPan = (): void => {
tx = Math.min(Math.max(tx, (plot.x + plot.width) * (1 - scale)), plot.x * (1 - scale));
@@ -100,6 +113,10 @@ export function attachZoomPan(
/* 이미 해제됨 */
}
};
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
clampPan();
applyTransform();
}
svg.addEventListener("pointerup", endPan);
svg.addEventListener("pointercancel", endPan);
// 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다.
+1
View File
@@ -272,6 +272,7 @@ export const ui_locales_b2 = {
"{mat} height limit {limit}m — change material to go higher",
],
B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"],
B06_Cross_Mat_Label: ["재질", "Material"],
B06_Cross_Mat_Dry: ["메쌓기", "Dry masonry"],
B06_Cross_Mat_Wet: ["찰쌓기", "Wet masonry"],
B06_Cross_Mat_Concrete: ["콘크리트", "Concrete"],