From 06e6fbebffeb9635779bc1bb24b5eb9c7ac5c96f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:49:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(b06):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A0=8C=EB=8D=94=EB=9F=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EB=B3=84=20=EB=82=98=EB=88=94=20=E2=80=94=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=EC=B0=BD=20=EB=91=90=20=EB=B2=8C=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EB=B0=98=EC=84=A0=20=C2=B7=20=ED=8C=AC=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC(137-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B06_Section/B06_Section_UI_Cross_View.ts | 155 ++++-------------- .../B06_Section_UI_Cross_View_Frame.ts | 42 ++++- .../B06_Section_UI_Cross_View_Panels.ts | 144 ++++++++++++++++ 3 files changed, 214 insertions(+), 127 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_View_Panels.ts diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 755d162fc..1015dbb3f 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -36,7 +36,12 @@ import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert"; import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; -import { appendCardFrame, applyStructureAreas } from "./B06_Section_UI_Cross_View_Frame"; +import { + appendCardFrame, + appendGroundProfile, + applyStructureAreas, + drawnContentBounds, +} from "./B06_Section_UI_Cross_View_Frame"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -48,16 +53,14 @@ import type { RevetOffsetControl, StructureSpanControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; -import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; -import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; +import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; +import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels"; import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; -import type { RevetHighlightSetter, RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert"; +import type { RevetHighlightSetter, RevetPick } from "./B06_Section_UI_Cross_Culvert"; import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; -import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { CROSS_PAD, CROSS_WIDTH, @@ -186,20 +189,8 @@ export function createCrossSectionCard( let highlightRevet: RevetPick | null = revetOffset?.highlightFor(section) ?? activeRevet; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; - let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기 - // 이 카드의 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트). 조정창 걸음이 - // 배관(1m)과 다르다(0.1m — 2026-08-29 사용자). - let culvertHiddenPipe = false; - /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ - let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; - /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */ - let culvertExtraState = { canAdd: false, count: 0 }; - /** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */ - let culvertBasinExtraState = { canAdd: false, count: 0 }; - /** 마지막 계산의 벽 제원(높이·재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */ - let culvertWallSpecs = new Map(); - /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ - let culvertAppliedD = new Map(); + /** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_Cross_View_Panels`. */ + const culvertState = emptyCardCulvertState(); /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -367,25 +358,11 @@ export function createCrossSectionCard( // 상태의 가운데 버튼 팬으로 끌어다 본다(2026-08-23 사용자 지시). 축 눈금은 팬을 따라 // 다시 그려지므로(_UI_Cross_Axes) 값이 어긋나지 않는다. const drawSamples = section.samples; - const segments: string[] = []; - let current: string[] = []; - for (const sample of drawSamples) { - if (!validElevation(sample)) { - if (current.length > 1) segments.push(current.join(" ")); - current = []; - continue; - } - const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; - current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`); - } - if (current.length > 1) segments.push(current.join(" ")); - segments.forEach((points) => - plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), - ); + const toDisplayY = (elevation: number): number => + y(elevationMid + (elevation - elevationMid) * exaggeration); + appendGroundProfile(plotLayer, drawSamples, x, toDisplayY); if (section.design) { - const toDisplayY = (elevation: number): number => - y(elevationMid + (elevation - elevationMid) * exaggeration); // 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다. // 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지 // 않고 클래스만 켜면 되므로 줌·팬이 살아남는다. @@ -460,11 +437,11 @@ export function createCrossSectionCard( // 눌러도 안 움직이는데 숫자만 커지는 것 방지. revetOffset?.syncApplied(section.chainage_m, "own", ownLayout.appliedAdjust); // 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다. - culvertWallSpecs.set("own", { + culvertState.wallSpecs.set("own", { height: ownLayout.heightM, form: section.revetment?.form ?? REVET_FORM_DEFAULT, }); - culvertAppliedD.set("own", ownLayout.appliedAdjust.d ?? 0); + culvertState.appliedD.set("own", ownLayout.appliedAdjust.d ?? 0); const baseSetActive = setRevetActive; setRevetActive = (key) => { baseSetActive(key); @@ -512,16 +489,9 @@ export function createCrossSectionCard( ); } // 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19) · 링크 카드도 채운다(2026-08-24). - culvertInletIsBasin = !!culvertLayout?.basin; - culvertHiddenPipe = culvertLayout?.culvert.hidden_pipe === true; - if (culvertLayout) { - const state = culvertCardState(culvertLayout); - culvertInletOptions = state.inletOptions; - culvertExtraState = state.extraState; - culvertBasinExtraState = state.basinExtraState; - culvertWallSpecs = state.wallSpecs; - culvertAppliedD = state.appliedD; - } + culvertState.inletIsBasin = !!culvertLayout?.basin; + culvertState.hiddenPipe = culvertLayout?.culvert.hidden_pipe === true; + if (culvertLayout) Object.assign(culvertState, culvertCardState(culvertLayout)); if (boxLayout) { bodies.attachBox( appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY, box ? bodies.toggleBox : undefined), @@ -561,7 +531,7 @@ export function createCrossSectionCard( valid, centerX: x(0), designElevation: hasDesign ? (designElevation as number) : null, - toY: (elevation) => y(elevationMid + (elevation - elevationMid) * exaggeration), + toY: toDisplayY, widthPx, heightPx, }); @@ -570,19 +540,7 @@ export function createCrossSectionCard( chartWrap.className = "b06-cross-card__chart-wrap"; chartWrap.style.setProperty("--b06-plot-left", `${CROSS_PAD.left}px`); // 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상·하단. - const drawnOffsets = drawSamples.map((sample) => sample.offset_m ?? 0); - const drawnDisplayY = drawSamples - .filter(validElevation) - .map((sample) => y(elevationMid + (sample.elevation_m - elevationMid) * exaggeration)); - const contentBounds = - drawnOffsets.length && drawnDisplayY.length - ? { - x: x(Math.max(...drawnOffsets)), - width: x(Math.min(...drawnOffsets)) - x(Math.max(...drawnOffsets)), - y: Math.min(...drawnDisplayY), - height: Math.max(...drawnDisplayY) - Math.min(...drawnDisplayY), - } - : undefined; + const contentBounds = drawnContentBounds(drawSamples, x, toDisplayY); const zoomPan = attachZoomPan( svg, plotLayer, @@ -600,79 +558,24 @@ export function createCrossSectionCard( // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. const readout = buildAreaReadout(section.design, toggleArea); setChipActive = readout.setActive; - // 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 — - // 벽 기준 이동량(outward 부호)으로 환산해 넘긴다. - // 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다. - const inletSideRole = (role: RevetKey): boolean => - role === "inlet" || role.startsWith("bextra"); - const outwardOf = (role: RevetKey): number => - // 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다. - role === "own" - ? section.revetment?.side === "우" - ? -1 - : 1 - : ((section.uphill_side ?? "left") === "left") === inletSideRole(role) - ? 1 - : -1; - const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0; - const formOfWall = (key: RevetKey): string => - culvertWallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT; - // 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값. - const adjustTarget = (): CrossSection => - isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true) - ? culvertLink.source - : section; - // 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이 - // 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30). - const adjustOf = (key: RevetKey): WallAdjust => { - const stored = revetOffset?.storedAdjustFor(adjustTarget(), key); - if (stored) return stored; - const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; - return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; - }; - const panelContext = { + // 배수관 · 흙막이 조정창 두 벌(카드 안 · 좌측 사본) — `_Cross_View_Panels`(137-1). + const panel = buildCardPanels({ section, - scrollKey: section.station_id, - adjustChainage: () => adjustTarget().chainage_m, - isLinked: isLinkedCulvert, + culvertLink, + isLinkedCulvert, revetOffset, inletStructure, extraWalls, structureSpan, revetLink, - adjustOf, - outwardOf, - heightOfWall, - drawnWallKeys: () => [...culvertWallSpecs.keys()], - formOfWall, - appliedD: () => culvertAppliedD, - inletIsBasin: () => culvertInletIsBasin, - hiddenPipe: () => culvertHiddenPipe, - bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body, - fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford", - inletOptions: () => culvertInletOptions, - extraState: () => culvertExtraState, - basinExtraState: () => culvertBasinExtraState, + state: culvertState, activeRevet: () => activeRevet, - setActiveRevet: (key: RevetPick | null) => { + setActiveRevet: (key) => { activeRevet = key; }, toggleRevet, - }; - const panel = buildStructurePanel(structurePanelDeps(panelContext)); - // 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이 - // 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다. - const dockPanel = buildStructurePanel( - structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }), - { dock: true }, - ); - showRevetControl = (visible) => { - const key = visible ? activeRevet : null; - panel.show(key); - dockPanel.show(key); - if (key) adoptAdjustPanel(dockPanel.root); - else releaseAdjustPanel(dockPanel.root); - }; + }); + showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null); // 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자). // 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23). diff --git a/B06_Section/B06_Section_UI_Cross_View_Frame.ts b/B06_Section/B06_Section_UI_Cross_View_Frame.ts index 66ef2bf9c..ca30247af 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Frame.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Frame.ts @@ -6,7 +6,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch"; import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; -import { CROSS_PAD, L, svgElement, svgText } from "./B06_Section_UI_Section_Common"; +import { CROSS_PAD, L, svgElement, svgText, validElevation } from "./B06_Section_UI_Section_Common"; type Sample = CrossSection["samples"][number]; @@ -124,3 +124,43 @@ export function applyStructureAreas( design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); } } + +/** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */ +export function appendGroundProfile( + plotLayer: SVGElement, + samples: Sample[], + x: (offset: number) => number, + toY: (elevation: number) => number, +): void { + const segments: string[] = []; + let current: string[] = []; + for (const sample of samples) { + if (!validElevation(sample)) { + if (current.length > 1) segments.push(current.join(" ")); + current = []; + continue; + } + current.push(`${x(sample.offset_m ?? 0)},${toY(sample.elevation_m)}`); + } + if (current.length > 1) segments.push(current.join(" ")); + segments.forEach((points) => + plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), + ); +} + +/** 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상 · 하단(없으면 undefined). */ +export function drawnContentBounds( + samples: Sample[], + x: (offset: number) => number, + toY: (elevation: number) => number, +): { x: number; width: number; y: number; height: number } | undefined { + const offsets = samples.map((sample) => sample.offset_m ?? 0); + const displayY = samples.filter(validElevation).map((sample) => toY(sample.elevation_m)); + if (!offsets.length || !displayY.length) return undefined; + return { + x: x(Math.max(...offsets)), + width: x(Math.min(...offsets)) - x(Math.max(...offsets)), + y: Math.min(...displayY), + height: Math.max(...displayY) - Math.min(...displayY), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View_Panels.ts b/B06_Section/B06_Section_UI_Cross_View_Panels.ts new file mode 100644 index 000000000..7face8a37 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Panels.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B06_Section_UI_Cross_View_Panels.ts + * 횡단 카드 ↔ **배수관 · 흙막이 조정창 두 벌**(카드 안 · 좌측 [횡단 조정] 사본) 배선 — + * 카드 렌더러(`_UI_Cross_View.ts`)가 700줄을 넘어 떼었다(137-1). 그리기 · 기하는 하지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert"; +import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { + CulvertLink, + ExtraWallControl, + InletStructureControl, + RevetLinkControl, + RevetOffsetControl, + StructureSpanControl, +} from "./B06_Section_UI_Cross_Culvert_Wire"; +import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; +import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; +import { structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; + +/** 조정창이 읽는 카드 상태 — 마지막 계산 결과(그릴 때마다 바뀜). */ +export interface CardCulvertState { + /** 유입이 집수정인가 — 조정창 표시 · 제목 분기. */ + inletIsBasin: boolean; + /** 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트) — 걸음 0.1 m(2026-08-29 사용자). */ + hiddenPipe: boolean; + /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ + inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean }; + /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd) · 세워진 개수. */ + extraState: { canAdd: boolean; count: number }; + /** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */ + basinExtraState: { canAdd: boolean; count: number }; + /** 마지막 계산의 벽 제원(높이 · 재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */ + wallSpecs: Map; + /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ + appliedD: Map; +} + +export const emptyCardCulvertState = (): CardCulvertState => ({ + inletIsBasin: false, + hiddenPipe: false, + inletOptions: { revetAllowed: true, basinLUAllowed: true }, + extraState: { canAdd: false, count: 0 }, + basinExtraState: { canAdd: false, count: 0 }, + wallSpecs: new Map(), + appliedD: new Map(), +}); + +export interface CardPanelsInput { + section: CrossSection; + culvertLink?: CulvertLink; + isLinkedCulvert: boolean; + revetOffset?: RevetOffsetControl; + inletStructure?: InletStructureControl; + extraWalls?: ExtraWallControl; + structureSpan?: StructureSpanControl; + revetLink?: RevetLinkControl; + state: CardCulvertState; + activeRevet: () => RevetPick | null; + setActiveRevet: (key: RevetPick | null) => void; + toggleRevet: (key: RevetPick) => void; +} + +/** 카드 안 조정창 + 좌측 사본을 만든다. `show` = 고른 벽(없으면 null)으로 두 창을 맞춤. */ +export function buildCardPanels(input: CardPanelsInput): { + root: HTMLElement; + show: (key: RevetPick | null) => void; +} { + const { section, culvertLink, isLinkedCulvert, revetOffset, revetLink, state } = input; + // 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 — + // 벽 기준 이동량(outward 부호)으로 환산해 넘긴다. + // 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다. + const inletSideRole = (role: RevetKey): boolean => role === "inlet" || role.startsWith("bextra"); + const outwardOf = (role: RevetKey): number => + // 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다. + role === "own" + ? section.revetment?.side === "우" + ? -1 + : 1 + : ((section.uphill_side ?? "left") === "left") === inletSideRole(role) + ? 1 + : -1; + const heightOfWall = (key: RevetKey): number => state.wallSpecs.get(key)?.height ?? 0; + const formOfWall = (key: RevetKey): string => + state.wallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT; + // 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값. + const adjustTarget = (): CrossSection => + isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true) + ? culvertLink.source + : section; + // 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이 + // 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30). + const adjustOf = (key: RevetKey): WallAdjust => { + const stored = revetOffset?.storedAdjustFor(adjustTarget(), key); + if (stored) return stored; + const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; + return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; + }; + const panelContext = { + section, + scrollKey: section.station_id, + adjustChainage: () => adjustTarget().chainage_m, + isLinked: isLinkedCulvert, + revetOffset, + inletStructure: input.inletStructure, + extraWalls: input.extraWalls, + structureSpan: input.structureSpan, + revetLink, + adjustOf, + outwardOf, + heightOfWall, + drawnWallKeys: () => [...state.wallSpecs.keys()], + formOfWall, + appliedD: () => state.appliedD, + inletIsBasin: () => state.inletIsBasin, + hiddenPipe: () => state.hiddenPipe, + bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body, + fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford", + inletOptions: () => state.inletOptions, + extraState: () => state.extraState, + basinExtraState: () => state.basinExtraState, + activeRevet: input.activeRevet, + setActiveRevet: input.setActiveRevet, + toggleRevet: input.toggleRevet, + }; + const panel = buildStructurePanel(structurePanelDeps(panelContext)); + // 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이 + // 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다. + const dockPanel = buildStructurePanel( + structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }), + { dock: true }, + ); + return { + root: panel.root, + show: (key) => { + panel.show(key); + dockPanel.show(key); + if (key) adoptAdjustPanel(dockPanel.root); + else releaseAdjustPanel(dockPanel.root); + }, + }; +} From f6f0f74be54e0834062957603474fee01a36c807 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:00:12 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(b06):=20=EB=B3=B4=EB=9D=BC=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EC=84=A0=20=EA=B3=A0=EB=A6=84=20=E2=80=94=20=EC=A7=81?= =?UTF-8?q?=EC=84=A0=20=EC=A1=B0=EA=B0=81=20=C2=B7=20=EA=B0=80=EB=A6=BC=20?= =?UTF-8?q?=EB=BA=8C=20=C2=B7=20=EC=B9=98=EC=88=98=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EC=97=AD=20=ED=8C=90=EC=A0=95=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=ED=95=98=EB=82=98=EB=A7=8C(137-2=20=C2=B7?= =?UTF-8?q?=20137-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 B06_Section_UI_Cross_Design_Pick.ts — 조각 합침 · 벽 · 본체 밖 조각 · 투명 판정 선 · 치수 · crossZoneAt(139-6 용) · 페이지 한 칸 고름 - 폴리곤 자르기 = A00_Common/b_polygon_clip.ts 공용(B07 Cross_Overlay 에서 옮김 · 바깥 조각 갈래) - 카드 배선: 선 고르면 벽 · 본체 · 면적 풂 + 카드 고름 · 면적 고름도 본체 · 선 풂 - 시험 test_137_design_pick.py + helper_137_design_pick.cjs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- A00_Common/b_polygon_clip.ts | 65 ++++ .../B06_Section_UI_Cross_Design_Pick.ts | 313 +++++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 63 +++- .../B06_Section_UI_Style_Cross_Areas.css | 15 + .../B06_Section_UI_Style_Cross_Controls.css | 17 +- .../B07_DesignDetail_Cross_Overlay.ts | 54 +-- resources/tester/helper_137_design_pick.cjs | 317 ++++++++++++++++++ resources/tester/test_137_design_pick.py | 110 ++++++ 8 files changed, 898 insertions(+), 56 deletions(-) create mode 100644 A00_Common/b_polygon_clip.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Design_Pick.ts create mode 100644 resources/tester/helper_137_design_pick.cjs create mode 100644 resources/tester/test_137_design_pick.py diff --git a/A00_Common/b_polygon_clip.ts b/A00_Common/b_polygon_clip.ts new file mode 100644 index 000000000..44d0f52bd --- /dev/null +++ b/A00_Common/b_polygon_clip.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * b_polygon_clip.ts + * 선분 · 점열을 폴리곤 경계에서 자르는 공용 조각 — B07 횡단 오버레이(안쪽 조각)와 + * B06 설계선 고름(벽 · 본체 밖 조각 · 137-2)이 같이 쓴다. 좌표계는 부르는 쪽 것 그대로. + * ========================================================================== */ + +export type XY = [number, number]; + +/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */ +export function pointInPolygon(point: XY, polygon: XY[]): boolean { + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + const straddles = yi > point[1] !== yj > point[1]; + if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**(`keep = "outside"` 면 바깥 조각들)만 돌려준다. */ +export function clipSegment( + start: XY, + end: XY, + polygon: XY[], + keep: "inside" | "outside" = "inside", +): XY[][] { + const [x0, y0] = start; + const [x1, y1] = end; + const dx = x1 - x0; + const dy = y1 - y0; + const cuts = [0, 1]; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [ex, ey] = polygon[j]; + const [fx, fy] = polygon[i]; + const denominator = dx * (fy - ey) - dy * (fx - ex); + if (Math.abs(denominator) < 1e-12) continue; + const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator; + const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator; + if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t); + } + cuts.sort((a, b) => a - b); + const runs: XY[][] = []; + for (let index = 0; index + 1 < cuts.length; index += 1) { + const from = cuts[index]; + const to = cuts[index + 1]; + if (to - from < 1e-9) continue; + const mid = (from + to) / 2; + if (pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon) !== (keep === "inside")) continue; + runs.push([ + [x0 + dx * from, y0 + dy * from], + [x0 + dx * to, y0 + dy * to], + ]); + } + return runs; +} + +/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */ +export function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] { + const runs: XY[][] = []; + for (let index = 0; index + 1 < points.length; index += 1) { + runs.push(...clipSegment(points[index], points[index + 1], polygon)); + } + return runs; +} diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts new file mode 100644 index 000000000..15305baca --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -0,0 +1,313 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Design_Pick.ts + * 횡단 카드 **보라 요소 고름**(설계선 · 구조물 트림 선 · 137-2). + * + * 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을 + * 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정 + * 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다. + * 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다. + * 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다. + * ========================================================================== */ + +import type { CrossDesign } from "./B06_Section_Api_Fetch"; +import { clipSegment, type XY } from "../A00_Common/b_polygon_clip"; +import { sideRole } from "@util/common_util_cross_design_geometry"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const DESIGN_CLASS = "b06-chart__design-cross"; +const HIT_CLASS = "b06-chart__design-hit"; +/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */ +export const DESIGN_LINE_ATTR = "data-design-line"; +/** 보라 선을 덮는 벽 · 본체 폴리곤. */ +const OCCLUDER_CLASSES = [ + "b06-chart__culvert-revet", + "b06-chart__culvert-basin", + "b06-chart__ford-slab", + "b06-chart__box-barrel", +]; +/** 같은 직선으로 보는 꺾임 허용(m) — 설계선 표고는 0.1 mm 반올림이라 넉넉하다. */ +const STRAIGHT_TOL_M = 0.005; +/** 가리고 남은 조각이 이보다 짧으면 버린다(m) — 누를 수도 없다. */ +const MIN_PIECE_M = 0.02; + +export interface CrossPoint { + offset: number; + elevation: number; +} +/** 그려진 보라 선 한 줄(m 좌표). `structure` = 구조물 트림 선(설계선 오버레이 밖). */ +export interface DesignRun { + points: CrossPoint[]; + structure: boolean; +} +/** 직선 조각 — 두 끝(m) · 구조물 트림 선인가. */ +export interface DesignPiece { + from: CrossPoint; + to: CrossPoint; + structure: boolean; +} + +export type CrossZone = "road" | "cut" | "fill"; +export type CrossSide = "left" | "right"; +type ZoneDesign = Pick< + CrossDesign, + "road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode" +>; + +/** + * offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + + * 구조물 트림 선. 사면 역할은 단면유형(`sideRole`)으로 · 쪽 = +offset 좌. + */ +export function crossZoneAt( + design: ZoneDesign, + offsetM: number, + structure = false, +): { zone: CrossZone; side: CrossSide } { + const side: CrossSide = offsetM >= 0 ? "left" : "right"; + const edges = design.road_edges; + const hi = edges ? Math.max(edges.left.offset_m, edges.right.offset_m) : 0; + const lo = edges ? Math.min(edges.left.offset_m, edges.right.offset_m) : 0; + if (structure) return { zone: "fill", side }; + if (offsetM >= lo - 1e-6 && offsetM <= hi + 1e-6) return { zone: "road", side }; + const ditch = design.ditch; + const ditchWidth = + design.ditch_enabled === false || !ditch || ditch.type === "none" + ? 0 + : ditch.type === "standard" + ? ditch.top_width_m + : ditch.width_m; + if (design.ditch_side === "left" && offsetM > hi && offsetM <= hi + ditchWidth + 1e-6) + return { zone: "cut", side }; + if (design.ditch_side === "right" && offsetM < lo && offsetM >= lo - ditchWidth - 1e-6) + return { zone: "cut", side }; + let roles: [string, string] = ["fill", "fill"]; + try { + roles = sideRole(design.section_mode); + } catch { + // 모르는 단면유형 = 성토로 봄 + } + return { zone: (side === "left" ? roles[0] : roles[1]) === "cut" ? "cut" : "fill", side }; +} + +/** 조각 구역 = 가운데 자리의 구역. */ +export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => + crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure); + +const gap = (a: CrossPoint, b: CrossPoint): number => + Math.hypot(a.offset - b.offset, a.elevation - b.elevation); + +/** 점열이 한 직선 위에서 한 방향으로만 가는가(되접힘 없음). */ +function isStraight(points: CrossPoint[]): boolean { + const a = points[0]; + const b = points[points.length - 1]; + const length = gap(a, b); + if (length < 1e-9) return false; + const ux = (b.offset - a.offset) / length; + const uy = (b.elevation - a.elevation) / length; + let along = -STRAIGHT_TOL_M; + for (const p of points) { + const dx = p.offset - a.offset; + const dy = p.elevation - a.elevation; + if (Math.abs(ux * dy - uy * dx) > STRAIGHT_TOL_M) return false; + const t = ux * dx + uy * dy; + if (t < along - STRAIGHT_TOL_M) return false; + along = Math.max(along, t); + } + return true; +} + +/** 그려진 선들 → 같은 방향 토막을 합친 직선 조각(설계선 · 구조물 선은 따로 합침). */ +export function mergeStraightPieces(runs: DesignRun[]): DesignPiece[] { + const chains: Array<{ points: CrossPoint[]; structure: boolean }> = []; + for (const run of runs) { + for (let i = 1; i < run.points.length; i += 1) { + const start = run.points[i - 1]; + const end = run.points[i]; + if (gap(start, end) < 1e-6) continue; + const last = chains[chains.length - 1]; + if ( + last && + last.structure === run.structure && + gap(last.points[last.points.length - 1], start) < 1e-3 && + isStraight([...last.points, end]) + ) + last.points.push(end); + else chains.push({ points: [start, end], structure: run.structure }); + } + } + // 차례가 떨어진 토막도 끝이 닿고 한 직선이면 합친다(방향이 거꾸로 그려진 선 포함). + const joined = (p: CrossPoint[], q: CrossPoint[]): CrossPoint[] | null => { + for (const a of [p, [...p].reverse()]) + for (const b of [q, [...q].reverse()]) + if (gap(a[a.length - 1], b[0]) < 1e-3 && isStraight([...a, ...b.slice(1)])) + return [...a, ...b.slice(1)]; + return null; + }; + let merged = true; + while (merged) { + merged = false; + for (let i = 0; i < chains.length && !merged; i += 1) + for (let j = i + 1; j < chains.length && !merged; j += 1) { + if (chains[i].structure !== chains[j].structure) continue; + const points = joined(chains[i].points, chains[j].points); + if (!points) continue; + chains[i] = { points, structure: chains[i].structure }; + chains.splice(j, 1); + merged = true; + } + } + return chains.map((chain) => ({ + from: chain.points[0], + to: chain.points[chain.points.length - 1], + structure: chain.structure, + })); +} + +/** 벽 · 본체 폴리곤(m)에 가린 부분을 뺀 **보이는 조각**만. */ +export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): DesignPiece[] { + const polygons = occluders + .filter((polygon) => polygon.length >= 3) + .map((polygon) => polygon.map((p) => [p.offset, p.elevation] as XY)); + const out: DesignPiece[] = []; + for (const piece of pieces) { + let runs: XY[][] = [ + [ + [piece.from.offset, piece.from.elevation], + [piece.to.offset, piece.to.elevation], + ], + ]; + for (const polygon of polygons) + runs = runs.flatMap(([a, b]) => clipSegment(a, b, polygon, "outside")); + for (const [a, b] of runs) { + const from = { offset: a[0], elevation: a[1] }; + const to = { offset: b[0], elevation: b[1] }; + if (gap(from, to) >= MIN_PIECE_M) out.push({ from, to, structure: piece.structure }); + } + } + return out; +} + +/** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */ +export function pieceMeasure( + piece: DesignPiece, + zone: CrossZone, +): { lengthM: number; horizontalM: number; heightM: number; grade: string } { + const horizontalM = Math.abs(piece.to.offset - piece.from.offset); + const heightM = Math.abs(piece.to.elevation - piece.from.elevation); + const grade = + zone === "road" + ? horizontalM < 1e-6 + ? "—" + : `${((heightM / horizontalM) * 100).toFixed(1)}%` + : heightM < 1e-4 + ? "0.0%" + : `1:${(horizontalM / heightM).toFixed(2)}`; + return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade }; +} + +export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string { + const { zone } = pieceZone(design, piece); + const m = pieceMeasure(piece, zone); + return ( + `${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` + + `수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m · ${m.grade}` + ); +} + +/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */ +let picked: { stationId: string; index: number } | null = null; +export const pickedLineAt = (stationId: string): number | null => + picked?.stationId === stationId ? picked.index : null; +export function setPickedLine(stationId: string, index: number | null): void { + if (index !== null) picked = { stationId, index }; + else if (picked?.stationId === stationId) picked = null; +} + +/* ── 카드 배선 ─────────────────────────────────────────────────────────── */ +const pointsOf = (element: Element): XY[] => { + if (element.tagName.toLowerCase() === "line") { + const n = (name: string) => Number(element.getAttribute(name)); + return [ + [n("x1"), n("y1")], + [n("x2"), n("y2")], + ]; + } + return (element.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number) as XY) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); +}; + +export interface DesignLinePick { + pieces: DesignPiece[]; + /** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */ + label: HTMLElement; + setActive: (index: number | null) => void; +} + +/** + * 그려진 보라 선에 고름을 단다 — 벽 · 관 등을 다 그린 **뒤**(가림 · 판정면 차례) 부른다. + * `x` · `toDisplayY` = 카드 좌표 함수(1차식 — 역산해 m 로 되돌림). + */ +export function attachDesignLinePick(input: { + layer: SVGElement; + design: ZoneDesign; + x: (offset: number) => number; + toDisplayY: (elevation: number) => number; + onPick: (index: number) => void; +}): DesignLinePick { + const { layer, design, x, toDisplayY } = input; + const x0 = x(0); + const kx = x(1) - x0; + const y0 = toDisplayY(0); + const ky = toDisplayY(1) - y0; + const toM = ([px, py]: XY): CrossPoint => ({ offset: (px - x0) / kx, elevation: (py - y0) / ky }); + const kids = [...layer.children]; + const runs: DesignRun[] = kids + .filter((el) => el.classList.contains(DESIGN_CLASS)) + .map((el) => ({ + points: pointsOf(el).map(toM), + structure: !el.hasAttribute(DESIGN_LINE_ATTR), + })); + const occluders = kids + .filter( + (el) => + el.tagName.toLowerCase() === "polygon" && + OCCLUDER_CLASSES.some((name) => el.classList.contains(name)), + ) + .map((el) => pointsOf(el).map(toM)); + const pieces = visiblePieces(mergeStraightPieces(runs), occluders); + + // 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김. + const firstStructureHit = kids.find((el) => + el.classList.contains("b06-chart__culvert-revet-hit"), + ); + const hits = pieces.map((piece, index) => { + const hit = document.createElementNS(SVG_NS, "polyline"); + hit.setAttribute( + "points", + [piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), + ); + hit.setAttribute("class", HIT_CLASS); + hit.addEventListener("click", (event) => { + event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음 + input.onPick(index); + }); + layer.insertBefore(hit, firstStructureHit ?? null); + return hit; + }); + + const label = document.createElement("span"); + label.className = "b06-cross-card__line-dims"; + label.hidden = true; + return { + pieces, + label, + setActive: (index) => { + const piece = index === null ? undefined : pieces[index]; + hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index)); + label.hidden = !piece; + label.textContent = piece ? pieceLabel(design, piece) : ""; + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 1015dbb3f..19222df41 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -55,6 +55,13 @@ import type { } from "./B06_Section_UI_Cross_Culvert_Wire"; import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels"; +import { + attachDesignLinePick, + DESIGN_LINE_ATTR, + type DesignLinePick, + pickedLineAt, + setPickedLine, +} from "./B06_Section_UI_Cross_Design_Pick"; import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; @@ -191,6 +198,12 @@ export function createCrossSectionCard( let showRevetControl: (visible: boolean) => void = () => undefined; /** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_Cross_View_Panels`. */ const culvertState = emptyCardCulvertState(); + /** 보라 선 고름(137-2) — 고름은 페이지 한 칸(`_Cross_Design_Pick`) · 강조는 그린 뒤 붙음. */ + let setLineActive: (index: number | null) => void = () => undefined; + const dropLine = (): void => { + setPickedLine(section.station_id, null); + setLineActive(null); + }; /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -204,6 +217,7 @@ export function createCrossSectionCard( box, isCardSelected: () => isSelected, clearArea: () => { + dropLine(); // 고름 하나만(137-3) if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1) if (activeArea === null) return; activeArea = null; @@ -227,6 +241,7 @@ export function createCrossSectionCard( setChipActive(null); } if (activeRevet !== null) { + dropLine(); // 고름 하나만(137-3) bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1) // 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이 // 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21 @@ -251,7 +266,10 @@ export function createCrossSectionCard( if (owns) revetOffset?.select(section.chainage_m, null); else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null); } - if (activeArea !== null || !nextSelected) bodies.clearSelection(); + if (activeArea !== null || !nextSelected) { + bodies.clearSelection(); + dropLine(); + } }; card.applyRevetHighlight = (key) => { highlightRevet = key; @@ -276,10 +294,34 @@ export function createCrossSectionCard( showRevetControl(false); revetOffset?.select(section.chainage_m, null); } + if (activeArea !== null) { + bodies.clearSelection(); // 고름 하나만 — 면적 강조도 본체 · 선과 같은 칸(137-3) + dropLine(); + } setBandActive(activeArea); setChipActive(activeArea); onAreaSelect?.(section.station_id, activeArea); }; + /** 보라 선 고름 — 다른 고름(벽 · 본체 · 면적)을 풀고 카드도 고름(137-3). 다시 누르면 풂. */ + const pickLine = (index: number): void => { + const next = pickedLineAt(section.station_id) === index ? null : index; + setPickedLine(section.station_id, next); + if (next !== null) { + if (activeRevet !== null) toggleRevet(activeRevet); + bodies.clearSelection(); + if (activeArea !== null) { + activeArea = null; + setBandActive(null); + setChipActive(null); + onAreaSelect?.(section.station_id, null); + } + if (!isSelected) onSelect(section.station_id); + } + setLineActive(next); + }; + // 고른 카드 · 다른 고름이 없을 때만 되살림 — 바깥 길(목록 · 3D)이 구조물을 골랐으면 선을 풂. + if (!selected || activeRevet !== null || activeArea !== null) + setPickedLine(section.station_id, null); appendCardHeader(card, section, stationInterval, onDesignChange); @@ -361,6 +403,7 @@ export function createCrossSectionCard( const toDisplayY = (elevation: number): number => y(elevationMid + (elevation - elevationMid) * exaggeration); appendGroundProfile(plotLayer, drawSamples, x, toDisplayY); + let linePick: DesignLinePick | null = null; if (section.design) { // 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다. @@ -465,6 +508,10 @@ export function createCrossSectionCard( x: (CROSS_PAD.left + widthPx - CROSS_PAD.right) / 2, y: heightPx - CROSS_PAD.bottom - 4, }); + // 설계선 오버레이가 그린 보라 선 표시 — 고름 구역에서 구조물 트림 선과 가름(137-2). + [...plotLayer.children] + .slice(designFrom) + .forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, "")); // 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남) if (fordPaved && section.ford_pavement) hideFordPavedRoad( @@ -524,6 +571,16 @@ export function createCrossSectionCard( if (highlightRevet) setRevetActive(highlightRevet); } raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5) + // 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2). + linePick = attachDesignLinePick({ + layer: plotLayer, + design: section.design, + x, + toDisplayY, + onPick: pickLine, + }); + setLineActive = linePick.setActive; + setLineActive(pickedLineAt(section.station_id)); } // 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한). @@ -589,7 +646,9 @@ export function createCrossSectionCard( // 팬으로 끌어다 본다(PLAN 79-2 · 교차점 폭으로 넓히던 것 폐지). fit: () => stationWidth.request(section.chainage_m, crossHalfWidth ?? maxOffset), }; - chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root); + const zoomBar = buildZoomControls(zoomPan, widthActions); + if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2) + chartWrap.append(svg, readout.root, zoomBar, panel.root); for (const panel of bodies.panels) chartWrap.append(panel.root); if (activeArea) { setBandActive(activeArea); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 6f08716c6..d8b8af232 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -146,6 +146,21 @@ stroke-dasharray: 4 4; } +/* 보라 선 고름(137-2) — 투명 판정 선 · 고르면 굵은 반투명 띠. */ +.b06-chart__design-hit { + fill: none; + stroke: transparent; + stroke-width: 10; + stroke-linecap: round; + pointer-events: stroke; + cursor: pointer; +} + +.b06-chart__design-hit.is-active { + stroke: color-mix(in srgb, var(--color-royal-amethyst) 45%, transparent); + stroke-width: 7; +} + /* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ .b06-chart__carriageway-tick { stroke: var(--color-royal-amethyst); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index c53b382ca..b0e894daf 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -223,6 +223,20 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } +/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 같은 줄. */ +.b06-cross-card__line-dims { + display: flex; + align-items: center; + padding: 0 var(--spacing-4); + color: var(--color-royal-amethyst); + font-size: 0.75rem; + white-space: nowrap; +} + +.b06-cross-card__line-dims[hidden] { + display: none; +} + .b06-cross-card__zoom-btn { width: 22px; height: 22px; @@ -236,7 +250,8 @@ cursor: pointer; } -.b06-cross-card__zoom-btn:first-child { +.b06-cross-card__zoom-btn:first-child, +.b06-cross-card__line-dims[hidden] + .b06-cross-card__zoom-btn { border-left: none; } diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index 7dc4f5ea0..6d3b1488a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -22,6 +22,7 @@ import { appendPavementOverlay, } from "../B06_Section/B06_Section_UI_Cross_Design"; import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment"; +import { clipPointsToPolygon, clipSegment, pointInPolygon } from "../A00_Common/b_polygon_clip"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -220,59 +221,6 @@ function clipPolygonOf(element: SVGElement): XY[] | null { return points.length >= 3 ? points : null; } -/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */ -function pointInPolygon(point: XY, polygon: XY[]): boolean { - let inside = false; - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { - const [xi, yi] = polygon[i]; - const [xj, yj] = polygon[j]; - const straddles = yi > point[1] !== yj > point[1]; - if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside; - } - return inside; -} - -/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */ -function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] { - const [x0, y0] = start; - const [x1, y1] = end; - const dx = x1 - x0; - const dy = y1 - y0; - const cuts = [0, 1]; - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { - const [ex, ey] = polygon[j]; - const [fx, fy] = polygon[i]; - const denominator = dx * (fy - ey) - dy * (fx - ex); - if (Math.abs(denominator) < 1e-12) continue; - const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator; - const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator; - if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t); - } - cuts.sort((a, b) => a - b); - const runs: XY[][] = []; - for (let index = 0; index + 1 < cuts.length; index += 1) { - const from = cuts[index]; - const to = cuts[index + 1]; - if (to - from < 1e-9) continue; - const mid = (from + to) / 2; - if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue; - runs.push([ - [x0 + dx * from, y0 + dy * from], - [x0 + dx * to, y0 + dy * to], - ]); - } - return runs; -} - -/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */ -function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] { - const runs: XY[][] = []; - for (let index = 0; index + 1 < points.length; index += 1) { - runs.push(...clipSegment(points[index], points[index + 1], polygon)); - } - return runs; -} - function circlePoints(center: XY, radius: number): XY[] { const points: XY[] = []; for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) { diff --git a/resources/tester/helper_137_design_pick.cjs b/resources/tester/helper_137_design_pick.cjs new file mode 100644 index 000000000..1b67514d8 --- /dev/null +++ b/resources/tester/helper_137_design_pick.cjs @@ -0,0 +1,317 @@ +/* PLAN 137-2 · 137-3 단위검증 헬퍼 — B06 보라 선 고름(조각 · 가림 · 치수 · 구역 · 판정 선 · 한 칸 고름). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 입력 = 손으로 짠 단면 + `fixtures/b07_cross_overlay_sections.json` 배관 측점 85.052. 짝 = `test_137_design_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, + "@ui/ui_template_elements": { showToast: () => undefined }, +}; + +// ── 화면 그릇 흉내 — 오버레이 · 판정 선이 쓰는 만큼만 ───────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = String(tag); + this.children = []; + this.attrs = {}; + this.listeners = {}; + this.hidden = false; + this._text = ""; + this._classes = new Set(); + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + toggle: (name, force) => { + const on = force === undefined ? !self._classes.has(name) : !!force; + on ? self._classes.add(name) : self._classes.delete(name); + return on; + }, + contains: (name) => self._classes.has(name), + }; + this.style = { setProperty: () => {} }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + get childElementCount() { + return this.children.length; + } + get nextElementSibling() { + const kids = this.parent?.children ?? []; + return kids[kids.indexOf(this) + 1] ?? null; + } + _detach(kid) { + if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid); + kid.parent = this; + } + append(...kids) { + kids.forEach((k) => { + this._detach(k); + this.children.push(k); + }); + } + appendChild(kid) { + this.append(kid); + return kid; + } + insertBefore(kid, ref) { + this._detach(kid); + const at = ref ? this.children.indexOf(ref) : -1; + if (at < 0) this.children.push(kid); + else this.children.splice(at, 0, kid); + return kid; + } + remove() { + if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this); + } + matches(selector) { + if (selector === "text") return this.tagName === "text"; + return selector + .split(",") + .map((part) => part.trim()) + .some((part) => + part === "text" ? this.tagName === "text" : this._classes.has(part.slice(1)), + ); + } + setAttribute(name, value) { + this.attrs[name] = String(value); + if (name === "class") this.className = value; + } + getAttribute(name) { + return name === "class" ? this.className : (this.attrs[name] ?? null); + } + hasAttribute(name) { + return name in this.attrs; + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + click() { + let stopped = false; + const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} }; + (this.listeners.click ?? []).forEach((fn) => fn(event)); + return stopped; + } +} +globalThis.document = { + createElement: (tag) => new FakeNode(tag), + createElementNS: (_ns, tag) => new FakeNode(tag), +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick")); +const { pointInPolygon } = loadTs(path.join(ROOT, "A00_Common", "b_polygon_clip.ts")); +const out = {}; +const r3 = (v) => Math.round(v * 1000) / 1000; +const pt = (offset, elevation) => ({ offset, elevation }); + +// ① 조각 — 0.5 m 토막 노면(가운데 꺾임) · 측구 · 사면이 직선 조각 다섯으로 +const sampled = (a, b, step = 0.5) => { + const n = Math.max(1, Math.round(Math.abs(b.offset - a.offset) / step)); + return Array.from({ length: n + 1 }, (_, i) => + pt( + a.offset + ((b.offset - a.offset) * i) / n, + a.elevation + ((b.elevation - a.elevation) * i) / n, + ), + ); +}; +const line = [ + ...sampled(pt(-6, 98), pt(-2.5, 99.95)), // 우 성토 사면 + ...sampled(pt(-2.5, 99.95), pt(0, 100)).slice(1), // 우 노면 2% + ...sampled(pt(0, 100), pt(2.5, 99.95)).slice(1), // 좌 노면 2% + ...sampled(pt(2.5, 99.95), pt(3, 99.65)).slice(1), // 측구 안 + ...sampled(pt(3, 99.65), pt(6, 102.65)).slice(1), // 좌 절토 사면 +].map((p) => ({ offset: r3(p.offset), elevation: Math.round(p.elevation * 1e4) / 1e4 })); +// 토막마다 한 줄(설계선 오버레이 꼴) + 차례를 뒤집은 한 토막(거꾸로 그린 선) +const runs = []; +for (let i = 1; i < line.length; i += 1) + runs.push({ points: [line[i - 1], line[i]], structure: false }); +runs.push({ points: [pt(-6.5, 98 - (0.5 * 1.95) / 3.5), pt(-6, 98)].reverse(), structure: false }); +const merged = pick.mergeStraightPieces(runs); +out.merge = { + segments: runs.length, + pieces: merged.map((p) => [r3(p.from.offset), r3(p.to.offset)].sort((a, b) => a - b)), +}; +// 구조물 선은 같은 직선이어도 설계선과 안 합침 +out.mergeStructure = pick.mergeStraightPieces([ + { points: [pt(10, 0), pt(11, 1)], structure: false }, + { points: [pt(11, 1), pt(12, 2)], structure: true }, +]).length; + +// ② 가림 — 사면을 가로지르는 벽 → 벽 밖 두 조각 +const slope = [{ from: pt(3, 99.65), to: pt(6, 102.65), structure: false }]; +const wall = [pt(4, 100), pt(4.5, 100), pt(4.5, 103), pt(4, 103)]; +const visible = pick.visiblePieces(slope, [wall]); +out.occlude = { + pieces: visible.map((p) => [r3(p.from.offset), r3(p.to.offset)]), + // 통째로 덮이면 조각 없음 + covered: pick.visiblePieces(slope, [[pt(2, 90), pt(7, 90), pt(7, 110), pt(2, 110)]]).length, +}; + +// ③ 치수 +const fillPiece = { from: pt(-2.5, 99.95), to: pt(-5.5, 97.95), structure: false }; +const roadPiece = { from: pt(0, 100), to: pt(2.5, 99.95), structure: false }; +out.measure = { + fill: pick.pieceMeasure(fillPiece, "fill"), + road: pick.pieceMeasure(roadPiece, "road"), + flatSlope: pick.pieceMeasure({ from: pt(4, 1), to: pt(5, 1), structure: false }, "cut").grade, +}; +const design = { + road_edges: { + left: { offset_m: 2.5, elevation_m: 99.95 }, + right: { offset_m: -2.5, elevation_m: 99.95 }, + }, + ditch: { type: "standard", top_width_m: 0.5, bottom_width_m: 0.2, depth_m: 0.3 }, + ditch_side: "left", + ditch_enabled: true, + section_mode: "left_cut", +}; +out.label = { fill: pick.pieceLabel(design, fillPiece), road: pick.pieceLabel(design, roadPiece) }; + +// ④ 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + 구조물 선 +const zone = (o, structure = false, d = design) => pick.crossZoneAt(d, o, structure); +out.zone = { + road: zone(1), + roadEdge: zone(-2.5).zone, + ditch: zone(2.8), + cutSlope: zone(5), + fillSlope: zone(-4), + structure: zone(5, true).zone, + rightCutDitch: zone(-2.8, false, { ...design, ditch_side: "right", section_mode: "right_cut" }) + .zone, + bothFill: zone(5, false, { ...design, ditch_enabled: false, section_mode: "both_fill" }).zone, + pieceZones: merged.map((p) => pick.pieceZone(design, p).zone), +}; + +// ⑤ 실제 측점 — 설계선 + 배관 오버레이를 그린 뒤 고름을 붙임(카드와 같은 차례) +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); +const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert")); +const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design")); +const { raiseWalls } = loadTs(B06("B06_Section_UI_Cross_Wall")); +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +); +const sections = fixture.cross_sections; +const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const layout = computeStoredLayouts(pipeSection, sections).culvert; +const x = (o) => 100 - o * 12; +const y = (e) => 900 - (e - 850) * 12; +const layer = new FakeNode("g"); +const from = layer.childElementCount; +appendCrossDesignOverlay(layer, pipeSection.design, x, y, pipeSection.samples, layout.designTrim); +layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, "")); +appendCulvertOverlay(layer, layout, x, y, () => undefined, false); +raiseWalls(layer); +const picks = []; +const handle = pick.attachDesignLinePick({ + layer, + design: pipeSection.design, + x, + toDisplayY: y, + onPick: (i) => picks.push(i), +}); +const kids = layer.children; +const hits = kids.filter((k) => k.classList.contains("b06-chart__design-hit")); +const firstStructureHit = kids.findIndex((k) => + k.classList.contains("b06-chart__culvert-revet-hit"), +); +const lastLineHit = kids.reduce( + (last, k, i) => (k.classList.contains("b06-chart__design-hit") ? i : last), + -1, +); +const walls = kids + .filter((k) => k.tagName === "polygon" && k.classList.contains("b06-chart__culvert-revet")) + .map((k) => + k + .getAttribute("points") + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)), + ); +const insideWall = handle.pieces.filter((p) => { + const mid = [x((p.from.offset + p.to.offset) / 2), y((p.from.elevation + p.to.elevation) / 2)]; + return walls.some((w) => pointInPolygon(mid, w)); +}).length; +const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-cross")).length; +const stopped = hits[0].click(); +handle.setActive(1); +const activeOn = hits.map((h) => h.classList.contains("is-active")); +const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent }; +handle.setActive(null); +out.real = { + designLines, + pieces: handle.pieces.length, + structurePieces: handle.pieces.filter((p) => p.structure).length, + hits: hits.length, + wallCount: walls.length, + insideWall, + hitsBelowStructure: firstStructureHit < 0 || lastLineHit < firstStructureHit, + picked: picks, + stopped, + activeCount: activeOn.filter(Boolean).length, + activeIndex: activeOn.indexOf(true), + labelOn, + labelOff: { hidden: handle.label.hidden, text: handle.label.textContent }, + zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(), +}; + +// ⑥ 페이지 한 칸 — 다른 측점을 고르면 앞 측점 고름은 사라짐 · 남의 측점 풀기는 안 건드림 +pick.setPickedLine("A", 2); +const a1 = pick.pickedLineAt("A"); +pick.setPickedLine("B", 1); +const a2 = pick.pickedLineAt("A"); +pick.setPickedLine("A", null); +out.slot = { a1, a2, b: pick.pickedLineAt("B") }; +pick.setPickedLine("B", null); +out.slot.cleared = pick.pickedLineAt("B"); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py new file mode 100644 index 000000000..c75c230bf --- /dev/null +++ b/resources/tester/test_137_design_pick.py @@ -0,0 +1,110 @@ +"""PLAN 137-2 · 137-3 — B06 보라 선 고름: 직선 조각 · 가림 · 치수 · 구역 · 판정 선 · 페이지 한 칸. + +짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts` · `A00_Common/b_polygon_clip.ts`(B07 에서 옮김) · +`B06_Section_UI_Cross_View.ts`(배선 · 하나만). 실제 TS 를 `helper_137_design_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_137_design_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _src(rel: str) -> str: + with open(os.path.join(ROOT, rel), encoding="utf-8") as f: + return f.read() + + +def test_같은_방향_토막은_직선_조각_하나(out) -> None: + """0.5 m 토막 25개 → 성토 사면 · 노면 둘(가운데 꺾임) · 측구 · 절토 사면 = 다섯 · 거꾸로 그린 토막도 합침.""" + assert out["merge"]["segments"] == 25 + assert out["merge"]["pieces"] == [[-6.5, -2.5], [-2.5, 0], [0, 2.5], [2.5, 3], [3, 6]] + assert out["mergeStructure"] == 2 # 구조물 선은 설계선과 안 합침 + + +def test_벽에_가린_부분은_빼고_보이는_조각만(out) -> None: + assert out["occlude"]["pieces"] == [[3, 4], [4.5, 6]] + assert out["occlude"]["covered"] == 0 + + +def test_치수_사면길이_수평_높이_구배(out) -> None: + fill = out["measure"]["fill"] + assert (fill["horizontalM"], fill["heightM"], fill["grade"]) == (3, 2, "1:1.50") + assert abs(fill["lengthM"] - 13**0.5) < 1e-9 + assert out["measure"]["road"]["grade"] == "2.0%" # 노면 = % + assert out["measure"]["flatSlope"] == "0.0%" + assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m · 높이 2.00m · 1:1.50" + assert out["label"]["road"] == "길이 2.50m · 수평 2.50m · 높이 0.05m · 2.0%" + + +def test_구역_도로_절토_성토(out) -> None: + zone = out["zone"] + assert zone["road"] == {"zone": "road", "side": "left"} + assert zone["roadEdge"] == "road" + assert zone["ditch"] == {"zone": "cut", "side": "left"} # 측구 = 절토 + assert zone["cutSlope"] == {"zone": "cut", "side": "left"} + assert zone["fillSlope"] == {"zone": "fill", "side": "right"} + assert zone["structure"] == "fill" # 구조물 트림 선 = 성토 + assert zone["rightCutDitch"] == "cut" + assert zone["bothFill"] == "fill" + assert zone["pieceZones"] == ["fill", "road", "road", "cut", "cut"] + + +def test_실제_배관_측점_조각과_판정_선(out) -> None: + real = out["real"] + assert real["pieces"] < real["designLines"] # 토막이 조각으로 합쳐짐 + assert real["structurePieces"] > 0 # 구조물 트림 선도 고름 대상 + assert real["hits"] == real["pieces"] + assert real["wallCount"] == 2 and real["insideWall"] == 0 # 벽 안 조각 없음 + assert real["hitsBelowStructure"] is True # 구조물 판정면이 위 + assert real["picked"] == [0] and real["stopped"] is True # 카드 고름으로 안 번짐 + assert real["activeCount"] == 1 and real["activeIndex"] == 1 + assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("사면길이 ") + assert real["labelOff"] == {"hidden": True, "text": ""} + assert set(real["zones"]) <= {"road", "cut", "fill"} + + +def test_페이지_한_칸_고름(out) -> None: + assert out["slot"] == {"a1": 2, "a2": None, "b": 1, "cleared": None} + + +def test_카드_배선_하나만() -> None: + """137-3 — 선을 고르면 벽 · 본체 · 면적을 풀고 카드도 고름 · 다른 고름은 선을 풂.""" + view = _src("B06_Section/B06_Section_UI_Cross_View.ts") + assert "const pickLine = (index: number): void => {" in view + assert "if (!isSelected) onSelect(section.station_id);" in view + assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적 + assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림 + assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽 + css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") + assert ".b06-chart__design-hit.is-active {" in css + + +def test_폴리곤_자르기는_공용_한_벌() -> None: + b07 = _src("B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts") + assert "function clipSegment(" not in b07 and "function pointInPolygon(" not in b07 + assert 'from "../A00_Common/b_polygon_clip"' in b07 From 53abd0f3ef766f5fc3169ba26c5274998e92fb32 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:01:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(b06):=20=EC=B8=A1=EC=A0=90=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=3D=20=ED=9A=A1=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A7=8C=20=E2=80=94=20selectMark=20span=20?= =?UTF-8?q?=EA=B3=81=EA=B0=92=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=EB=A6=AC=EC=8A=A4=EB=84=88=EB=8A=94=20span=20?= =?UTF-8?q?=EB=81=94(137-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- A00_Common/design/design_view_cross.ts | 2 +- B06_Section/B06_Section_UI_Section_View.ts | 5 +++-- B06_Section/B06_Section_UI_Section_View_Types.ts | 5 +++-- .../tester/test_106_span_highlight_wording.py | 14 +++++++++++++- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 7147f89a0..d84a5f93b 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -399,7 +399,7 @@ export async function mountCrossView( // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) const pipeAt = structuresPanel.currentChainage(); const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); - if (mark) sectionView.selectMark(mark); + if (mark) sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 목록 길은 뒤에 여럿 structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 301272a01..97276c9e6 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -507,8 +507,9 @@ export function createSectionView( ); } }, - selectMark: (structureId) => { - spanMarkId = structureId; + selectMark: (structureId, span = true) => { + // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) + spanMarkId = span ? structureId : null; syncSpanHighlights(); profile.selectMark(structureId); }, diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 24c05dbc1..fe0dc87f1 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -40,8 +40,9 @@ export interface SectionViewController { /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; - /** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). */ - selectMark: (structureId: string | null) => void; + /** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). + * `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */ + selectMark: (structureId: string | null, span?: boolean) => void; /** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을 * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py index eef9f9cea..3e9ca6238 100644 --- a/resources/tester/test_106_span_highlight_wording.py +++ b/resources/tester/test_106_span_highlight_wording.py @@ -23,11 +23,23 @@ VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts") def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다(): assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard - assert re.search(r"selectMark: \(structureId\) => \{\s+spanMarkId = structureId;", VIEW) + assert re.search( + r"selectMark: \(structureId, span = true\) => \{\s+(//[^\n]*\s+)?spanMarkId = span \? structureId : null;", + VIEW, + ) assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW) assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW +def test_측점_고름은_카드_하나_목록_고름은_여럿(): + """137-4 — 측점 고름 리스너는 span 끔 · 목록(노면공) 길 · 알약은 기본(여럿) 그대로.""" + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "if (mark) sectionView.selectMark(mark, false);" in cross + assert "markStructure: (structureId) => sectionView.selectMark(structureId)," in cross + types = _read("B06_Section", "B06_Section_UI_Section_View_Types.ts") + assert "selectMark: (structureId: string | null, span?: boolean) => void;" in types + + def test_범위_판정은_시작_종료가_다른_구간형만(): assert "start == null || end == null || start === end" in VIEW From e055de13e1007f30fd6d14520dbd19259b4aa10c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:12:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(b06):=20=EB=AC=BB=ED=9E=8C=20=ED=9D=99?= =?UTF-8?q?=EB=A7=89=EC=9D=B4=20=EB=81=9D=20=3D=203=EB=8F=84=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=20=EC=A0=88=ED=86=A0=EC=84=A0=20=C2=B7=20=EB=AA=BB=20?= =?UTF-8?q?=EB=A7=8C=EB=82=98=EB=A9=B4=20=EB=B3=B5=ED=95=A9=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=20=C2=B7=20=EC=9C=A0=EC=B6=9C=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=20=EB=B0=98=EC=98=81(139-4=20=C2=B7=20139-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 끝 연결선 한 규칙: 묻히면 3도 내림 선 먼저 · 원지반 못 만나면 수평공간 뒤 절토 경사(배관 · 세월교 · 박스) - 유출 3도 절토선 = 보라 선으로 그림 · 폐회로 절토 면적에 넣음(집수정 계류측은 그림만) - 시험 test_139_4_buried_outlet.py 새로 · test_79 · test_91 유출 기대 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 24 +-- .../B06_Section_UI_Cross_Culvert_Extra.ts | 11 +- .../B06_Section_UI_Cross_Culvert_Geom.ts | 2 +- .../B06_Section_UI_Cross_Culvert_Tiers.ts | 19 +-- .../tester/helper_139_4_buried_outlet.cjs | 142 ++++++++++++++++++ resources/tester/test_139_4_buried_outlet.py | 73 +++++++++ resources/tester/test_79_guard_shape.py | 10 +- resources/tester/test_91_ground_free.py | 5 +- 8 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 resources/tester/helper_139_4_buried_outlet.cjs create mode 100644 resources/tester/test_139_4_buried_outlet.py diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 874f65baf..45a79ad0c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -102,6 +102,13 @@ export function appendCulvertOverlay( line.append(title); layer.append(line); }; + // 묻힌 끝 절토선 툴팁 — 3도 내림 선(끝이 시작보다 낮음) / 못 만나 복합경사 절토(139-4 · 139-5). + const cutLineTip = (name: string, segment: (typeof layout.outletFill.segments)[number]) => { + const [first, last] = [segment.points[0], segment.points[segment.points.length - 1]]; + return last.elevation < first.elevation + ? `${name} 3도 절토선 — 구조물이 원지반에 묻혀 원지반 교차까지 ${segment.lengthM.toFixed(2)}m` + : `${name} 절토선 — 3도 선이 원지반을 못 만나 절토 경사로 원지반까지`; + }; const drawOutletFill = (): void => { // 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림 if (culvert.chute && !linked) @@ -113,13 +120,10 @@ export function appendCulvertOverlay( toDisplayY, ); for (const segment of layout.outletFill.segments) { - // 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이 - // 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은 - // 건드리지 않는다(그림만 — 면적은 전면 개편 대상). - // 세월교 · 박스 흙막이는 수평공간 뒤 절토선을 그림(79-7 · 면적에도 듦). - if (segment.kind === "cut" && !culvert.body) continue; + // 묻힌 유출 끝 = 3도 수평 절토선(보라 · 면적에도 듦 · 139-4) — 3도 선이 원지반을 + // 못 만나면 복합경사 절토선(89-4). if (segment.kind === "cut") { - planLine(segment.points, `유출부 절토선 — 수평공간 뒤 절토 경사로 원지반까지`); + planLine(segment.points, cutLineTip("유출부", segment)); continue; } planLine( @@ -144,11 +148,9 @@ export function appendCulvertOverlay( for (const segment of layout.basinFill.segments) { planLine( segment.points, - segment.kind === "cut" && culvert.body - ? `${basinFillName} 절토선 — 수평공간 뒤 절토 경사로 원지반까지` - : segment.kind === "cut" - ? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m` - : `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + + segment.kind === "cut" + ? cutLineTip(basinFillName, segment) + : `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + (segment.overLimit ? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)" : " (법정 5m 이내)") + diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index b466cebe8..234aa2bb1 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -50,8 +50,8 @@ export interface OutletExtrasInput { equalize?: boolean; /** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */ leanFor?: (form: string, pureHeightM: number) => number; - /** 끝이 원지반에 묻힐 때 수평공간 뒤 절토(본선과 같은 복합경사 · 91-4)로 지반까지(세월교 · 박스 - * 흙막이 · 79-7). 없으면 종전 3도 선. */ + /** 끝이 원지반에 묻혀 3도 선이 원지반을 못 만날 때만 — 수평공간 뒤 절토(본선과 같은 복합경사 · + * 91-4)로 지반까지(139-4). 없으면 3도 선만(못 만나면 선 없음). */ cutBerm?: { lengthM: number; cut: StructureCutSpec }; } @@ -379,14 +379,13 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult } // 끝 구간(2026-08-22 사용자 ③): 시작점이 지반 위면 성토부선, 지반 아래(매몰)면 - // **0도 절토선**을 원지반 교차까지 연장한다(교차가 없는 자리는 placeable이 막는다). + // **3도 내림 절토선**을 원지반 교차까지(139-4 · 139-5 — 끝 연결선 한 규칙). 3도 선이 + // 원지반을 못 만나면(바깥이 오르막) 그때만 수평공간 뒤 복합경사 절토(`cutBerm` · 89-4). let addable = false; if (groundAt(src.offset) < src.elevation - 0.01) { const tail = trailingFill(src, outward, groundAt); segments.push(tail); addable = tail.overLimit; - } else if (input.cutBerm) { - segments.push(bermCut(src, outward, input.cutBerm, groundAt, limitOffset)); } else { const cross = slopedCrossing(src, outward, -PIPE_CONNECT_GRADE, groundAt, limitOffset); if (cross !== null) { @@ -400,6 +399,8 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult overLimit: false, kind: "cut", }); + } else if (input.cutBerm) { + segments.push(bermCut(src, outward, input.cutBerm, groundAt, limitOffset)); } } return { walls, segments, appliedAdjusts, addable }; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 1d59b2136..96334b0aa 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -642,7 +642,7 @@ export function computeCulvertLayout( trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] }; } }; - extendTrimSlope(drawnFillPoints(extrasForArea, !!body), outletInfo.outward); + extendTrimSlope(drawnFillPoints(extrasForArea, true), outletInfo.outward); // 묻힌 유출 3도 절토(139-4) extendTrimSlope(drawnFillPoints(basinExtrasForArea, !!body), inletInfo.outward); const ownTrim: CulvertDesignTrim | null = diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts index ecc4e047b..c97709e44 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -42,8 +42,8 @@ export const outletPipeEnd = (wall: WallLayout): OffsetPoint => { // 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라 // **물량이 안 바뀐다**(2026-09-07 사용자 확정). 화면이 그리는 선(`outletFill.segments`)과 -// 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다. -// 세월교 · 박스 흙막이(`withCut`)는 수평공간 뒤 절토선을 그리므로 넣음(79-7). +// 같은 선을 면적도 보게 맞춘다 — `withCut` 이면 묻힌 끝 절토선(`cut` 갈래)도 넣음(유출 = 늘 · +// 139-4 · 계류측 = 세월교 · 박스만 79-7). 집수정 계류측 3도 선은 그림만. export const drawnFillPoints = (result: OutletExtrasResult, withCut = false): OffsetPoint[] => result.segments .filter((segment) => withCut || segment.kind !== "cut") @@ -85,15 +85,16 @@ export function buildCulvertTiers(input: { section_mode: section.design?.section_mode, side: culvert?.hidden_pipe ? culvert.inlet.face_side : null, }); - // 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선). - const cutBerm = (lengthM: number) => - culvert?.body - ? { lengthM: Math.max(lengthM, 0), cut: structureCutSpec(section.design) } - : undefined; + // 묻힌 끝 = 3도 절토선 먼저 · 원지반을 못 만나면 수평공간 뒤 절토 경사(139-4 · 139-5 — 배관 · + // 세월교 · 박스 한 규칙). 수평공간 = 세월교 · 박스 유입 흙막이 값(79-7)만 · 나머지는 0. + const cutBerm = (lengthM: number) => ({ + lengthM: Math.max(lengthM, 0), + cut: structureCutSpec(section.design), + }); // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). // 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다. const outletInput = { - cutBerm: cutBerm(0), // 유출 수평공간 없음 — 끝이 원지반에 묻히면 바로 절토(89-4) + cutBerm: cutBerm(0), // 유출 수평공간 없음 — 3도 선이 못 만날 때만 바로 절토(89-4) startBottomElevation: input.outletBottom, outward: input.outletInfo.outward, groundAt, @@ -107,7 +108,7 @@ export function buildCulvertTiers(input: { // 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 — // 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자). const basinInput = { - cutBerm: cutBerm(culvert?.inlet.revet_berm_m ?? GUARD_CUT_BERM_M), + cutBerm: cutBerm(culvert?.body ? (culvert.inlet.revet_berm_m ?? GUARD_CUT_BERM_M) : 0), startBottomElevation: input.basinBottom, outward: input.inletInfo.outward, groundAt, diff --git a/resources/tester/helper_139_4_buried_outlet.cjs b/resources/tester/helper_139_4_buried_outlet.cjs new file mode 100644 index 000000000..a8d7075b9 --- /dev/null +++ b/resources/tester/helper_139_4_buried_outlet.cjs @@ -0,0 +1,142 @@ +/* PLAN 139-4 · 139-5 헬퍼 — 묻힌 흙막이 유출 끝 = 3도 절토선(면적에 듦) · 3도 선이 원지반을 못 만나면 복합경사. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. 짝 = `test_139_4_buried_outlet.py`. + * 입력 = 박스 352.14(`b06_73_4_body_guard_sections.json`) · 배관 85.052(`b07_cross_overlay_sections.json` · 원지반 고친 사본). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); +const { computeStructureAreas } = loadTs( + path.join(ROOT, "common_util", "common_util_cross_structure_areas.ts"), +); +const { PIPE_CONNECT_GRADE } = loadTs(B06("B06_Section_UI_Cross_Culvert_Const")); +const fixture = (name) => + JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", name), "utf8")).cross_sections; +const clone = (v) => JSON.parse(JSON.stringify(v)); +const r3 = (v) => Math.round(v * 1000) / 1000; +const same = (a, b) => + Math.abs(a.offset - b.offset) < 1e-9 && Math.abs(a.elevation - b.elevation) < 1e-9; + +const groundOf = (s) => + s.samples + .filter((p) => p.valid !== false && typeof p.elevation_m === "number") + .map((p) => ({ offset: p.offset_m ?? 0, elevation: p.elevation_m })) + .sort((a, b) => a.offset - b.offset); +const groundZ = (s, offset) => { + const g = groundOf(s); + for (let i = 1; i < g.length; i += 1) + if (offset <= g[i].offset) + return ( + g[i - 1].elevation + + ((g[i].elevation - g[i - 1].elevation) * (offset - g[i - 1].offset)) / + (g[i].offset - g[i - 1].offset || 1) + ); + return g[g.length - 1].elevation; +}; +const areasOf = (s, trim) => + computeStructureAreas({ + designLine: s.design.design_line, + ground: groundOf(s), + trim, + rockBoundaryOffsetM: null, + }); + +/** 측점 하나 — 유출 끝 절토선 모양 · 그 선을 넣은 면적 대 뺀 면적. */ +function study(section, list) { + const culvert = computeStoredLayouts(section, list).culvert; + const cut = culvert.outletFill.segments.filter((g) => g.kind === "cut"); + const trim = culvert.designTrim; + // 옛 면적 = 유출 절토선 꼭짓점(시작점 빼고)을 사면 폴리선에서 걷어 낸 것 + const drop = cut.flatMap((g) => g.points.slice(1)); + const strip = (slope) => + slope && { points: slope.points.filter((p) => !drop.some((q) => same(p, q))) }; + const without = { ...trim, minSlope: strip(trim.minSlope), maxSlope: strip(trim.maxSlope) }; + const withCut = areasOf(section, trim); + const withoutCut = areasOf(section, without); + return { + cut: cut.map((g) => { + const [a, b] = [g.points[0], g.points[g.points.length - 1]]; + return { + points: g.points.length, + grade: r3((a.elevation - g.points[1].elevation) / Math.abs(g.points[1].offset - a.offset)), + rises: b.elevation > a.elevation, + endGap: r3(Math.abs(groundZ(section, b.offset) - b.elevation)), + }; + }), + cutArea: r3(withCut?.cutAreaM2 ?? 0), + cutAreaWithout: r3(withoutCut?.cutAreaM2 ?? 0), + inTrim: + drop.length > 0 && + [trim.minSlope, trim.maxSlope].some((sl) => + sl?.points.some((p) => drop.some((q) => same(p, q))), + ), + }; +} + +const out = { grade3: r3(PIPE_CONNECT_GRADE) }; +// ① 박스 352.14 — 유출 흙막이 끝이 원지반에 묻힘 +const bodies = fixture("b06_73_4_body_guard_sections.json"); +out.box = study( + bodies.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6), + bodies, +); + +// ② 배관 85.052 — 유출 끝 바깥 원지반을 고쳐 묻힘(내림) · 오르막 두 갈래 +const pipes = fixture("b07_cross_overlay_sections.json"); +const pipe = pipes.find((s) => s.culvert && !s.culvert.hidden_pipe); +const base = computeStoredLayouts(pipe, pipes).culvert; +// 끝 구간 시작점(마지막 단 다음 자리) — 그 바깥 원지반만 고침 +const tail = base.outletFill.segments[base.outletFill.segments.length - 1]; +const start = tail.points[0]; +const outward = Math.sign(tail.points[1].offset - start.offset) || 1; +out.pipeBase = base.outletFill.segments.map((g) => g.kind ?? "fill"); +function reshaped(rise, slope) { + const s = clone(pipe); + for (const p of s.samples) { + const t = ((p.offset_m ?? 0) - start.offset) * outward; + if (t >= 0 && typeof p.elevation_m === "number") + p.elevation_m = start.elevation + rise + slope * t; + } + return study( + s, + pipes.map((x) => (x === pipe ? s : x)), + ); +} +out.pipeDown = reshaped(0.3, -0.15); // 3도보다 가파르게 내림 → 3도 선이 만남 +out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 3도 선이 못 만남 → 복합경사 +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_139_4_buried_outlet.py b/resources/tester/test_139_4_buried_outlet.py new file mode 100644 index 000000000..6ddb7d15e --- /dev/null +++ b/resources/tester/test_139_4_buried_outlet.py @@ -0,0 +1,73 @@ +"""PLAN 139-4 · 139-5 — 묻힌 흙막이 유출 끝 = 3도 수평 절토선(보라 · 절토 면적) · 못 만나면 복합경사. + +짝: `B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts`(끝 구간 규칙) · `_Culvert_Tiers.ts`(채널 입력) · +`_Culvert_Geom.ts`(유출 절토선 면적에 넣음) · `_Culvert.ts`(그림 · 툴팁). 실제 TS 를 `helper_139_4_buried_outlet.cjs` 가 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_139_4_buried_outlet.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _src(rel: str) -> str: + with open(os.path.join(ROOT, rel), encoding="utf-8") as f: + return f.read() + + +@pytest.mark.parametrize("case", ["box", "pipeDown"]) +def test_묻힌_유출_끝은_3도_절토선_면적에_듦(out: dict, case: str) -> None: + """세월교 · 박스 흙막이도 배관 관보호공도 같은 3도 선 · 원지반 교점에서 끝 · 절토 면적 > 0.""" + study = out[case] + assert study["cut"] == [{"points": 2, "grade": out["grade3"], "rises": False, "endGap": 0}] + assert study["inTrim"] is True + assert study["cutArea"] - study["cutAreaWithout"] > 0.1 + + +def test_3도_선이_원지반을_못_만나면_복합경사(out: dict) -> None: + """유출 바깥이 오르막 — 그때만 절토 경사(1:1.0)로 원지반까지.""" + study = out["pipeUp"] + assert len(study["cut"]) == 1 + tail = study["cut"][0] + assert tail["rises"] is True and tail["grade"] == -1 and tail["endGap"] == 0 + assert study["cutArea"] > study["cutAreaWithout"] + + +def test_성토_끝은_그대로(out: dict) -> None: + assert out["pipeBase"] == ["fill", "fill"] # 묻히지 않은 기본 = 성토부선 그대로 + + +def test_유출_절토선은_그리고_면적에_넣음() -> None: + culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts") + assert 'if (segment.kind === "cut" && !culvert.body) continue;' not in culvert + assert 'planLine(segment.points, cutLineTip("유출부", segment));' in culvert + geom = _src("B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts") + assert "extendTrimSlope(drawnFillPoints(extrasForArea, true), outletInfo.outward);" in geom + # 집수정 계류측 3도 선은 그림만(면적 밖) — 세월교 · 박스 계류측만 면적 + assert ( + "extendTrimSlope(drawnFillPoints(basinExtrasForArea, !!body), inletInfo.outward);" in geom + ) diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index 81f6bb302..c925e5aa5 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -8,6 +8,7 @@ """ import json +import math import subprocess import sys from pathlib import Path @@ -87,13 +88,16 @@ def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None: assert tail["flat"] == pytest.approx(0, abs=1e-6) assert tail["berm"] == pytest.approx(1.0, abs=1e-3) else: - # 유출은 수평공간 없음 — 묻히면 첫 꺾임부터 바로 절토(89-4) - assert tail["flat"] > 0 + # 유출 = 묻히면 3도 내림 절토선 하나로 원지반까지(139-4) + assert tail["points"] == 2 + assert tail["flat"] == pytest.approx( + -tail["berm"] * math.tan(math.radians(3)), abs=2e-3 + ) for tail in shape["cutBerm2"]: if tail["role"] == "inlet": assert tail["berm"] == pytest.approx(2.0, abs=1e-3) else: - assert tail["flat"] > 0 + assert tail["points"] == 2 and tail["flat"] < 0 # 유출 수평공간 칸 무시 · 3도 선 def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None: diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 821be3e19..f59c6511d 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -96,9 +96,10 @@ def _knee(cut: dict) -> bool: def test_흙막이_수평공간_뒤_절토_복합경사(fit: dict) -> None: tails = fit["guardTails"] assert tails and all(cut["endGap"] < 0.01 for cut in tails) - # 유입 = 수평공간 먼저 · 유출 = 수평공간 없이 바로 절토(89-4) + # 유입 = 3도 선이 못 만나 수평공간 뒤 복합경사 · 유출 = 묻히면 3도 내림 선 하나(139-4) for cut in tails: - assert (cut["legs"][0]["ratio"] is None) == (cut["role"] == "inlet") + assert cut["legs"][0]["ratio"] is None + assert (len(cut["legs"]) == 1) == (cut["role"] == "outlet") assert sum(_knee(cut) for cut in tails) >= 2