From 158c5ff37fb7b1be1d7a326baa8f6ab3eb2fc949 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:20:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(B06):=20=EC=9C=A0=EC=B6=9C=EC=B8=A1=20?= =?UTF-8?q?=EC=A0=88=ED=86=A0=20=EB=B3=B5=ED=95=A9=EA=B2=BD=EC=82=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EB=8F=84=EC=9A=B0=EB=AF=B8=20=E2=80=94=20?= =?UTF-8?q?=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=88=98=ED=8F=89=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=20=EB=92=A4=20=C2=B7=20=EB=B0=95=EC=8A=A4=20=EB=81=9D?= =?UTF-8?q?=20=C2=B7=20=EC=84=B8=EC=9B=94=EA=B5=90=20=EC=B8=A1=EB=B2=BD=20?= =?UTF-8?q?=EB=81=9D=20=C2=B7=20=E3=84=B4=C2=B7=E3=84=B7=20=EC=A7=91?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=81=9D=20=C2=B7=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EC=AA=BD=20=EB=B0=B0=EA=B4=80=20=EC=9C=A0=EC=B6=9C=20=EB=B2=BD?= =?UTF-8?q?=20=EB=92=A4=20=C2=B7=20=EC=A0=88=ED=86=A0=EC=84=A0=20=EC=A0=90?= =?UTF-8?q?=EC=97=B4=20=C2=B7=203D(91-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_013rJwJMVL4EE31y77TB11AP --- ...5_Profile_UI_Corridor_Station_Structure.ts | 6 +- B06_Section/B06_Section_UI_Cross_Box.ts | 2 +- B06_Section/B06_Section_UI_Cross_Box_Geom.ts | 12 +- B06_Section/B06_Section_UI_Cross_Culvert.ts | 11 +- .../B06_Section_UI_Cross_Culvert_Basin.ts | 30 ++--- .../B06_Section_UI_Cross_Culvert_Extra.ts | 39 +++---- .../B06_Section_UI_Cross_Culvert_Geom.ts | 5 +- .../B06_Section_UI_Cross_Culvert_Tiers.ts | 3 +- .../B06_Section_UI_Cross_Culvert_Types.ts | 4 +- B06_Section/B06_Section_UI_Cross_Ford.ts | 2 +- B06_Section/B06_Section_UI_Cross_Ford_Geom.ts | 4 +- .../B06_Section_UI_Cross_Structure_Cut.ts | 107 ++++++++++++++++++ resources/tester/helper_91_ground_free.cjs | 61 ++++++++++ resources/tester/test_79_guard_shape.py | 3 +- resources/tester/test_91_ground_free.py | 31 +++++ 15 files changed, 251 insertions(+), 69 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Structure_Cut.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts index e0c6913f4..13b7a6c7e 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -211,7 +211,7 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] { points.push(floorOuter); patchPoints.push(floorOuter); const run: OffsetPoint[] = [floorOuter]; - for (const cutPoint of [side.cutLine.from, side.cutLine.to]) { + for (const cutPoint of side.cutLine) { const at = { offset_m: cutPoint.offset, elevation_m: cutPoint.elevation }; points.push(at); patchPoints.push(at); @@ -347,8 +347,8 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] } // 집수정이 원지반에 박히면 절토선까지 — 그만큼 원지반을 걷어낸다. if (layout.basin.cutLine) { - push(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]); - addRun(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]); + push(inletSide, layout.basin.cutLine); + addRun(inletSide, layout.basin.cutLine); basinCut = true; } spans[inletSide] = basinSpanOf(section); diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index c83a1a607..ab2422824 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -136,7 +136,7 @@ export function appendBoxOverlay( })); if (side.cutLine) lines.push({ - points: [side.cutLine.from, side.cutLine.to], + points: side.cutLine, text: `${mark}측 끝 절토선(계획선)`, }); for (const line of lines) { diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index a9ae4f1b3..247e0dae7 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -26,6 +26,7 @@ import type { import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const"; import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; +import { structureCutPoints, structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; /** 구체 한쪽 끝의 조작값 — 좌·우를 따로 잡는다(2026-08-25 사용자). */ export interface BoxSideAdjust { @@ -135,7 +136,8 @@ export function computeBoxLayout( // 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬. const riseM = (adjust.left.riseM + adjust.right.riseM) / 2; const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0); - const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0; + // 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4) + const cutSpec = structureCutSpec(section.design); const sideOf = (role: BoxSideRole): BoxSideLayout => { const outward = role === "left" ? 1 : -1; @@ -172,13 +174,7 @@ export function computeBoxLayout( let cutLine: BasinLayout["cutLine"] = null; let fillSegments: OutletFillSegment[] = []; if (groundAt(end.offset) > end.elevation + 0.05) { - for (let h = 0.05; h <= 20; h += 0.05) { - const probe = end.offset + outward * cutSlopeRatio * h; - if (groundAt(probe) <= end.elevation + h) { - cutLine = { from: end, to: { offset: probe, elevation: end.elevation + h } }; - break; - } - } + cutLine = structureCutPoints(end, outward, cutSpec, groundAt); } else if (groundAt(end.offset) < end.elevation - 0.05) { fillSegments = buildExtrasAt(end, { startBottomElevation: bottomElevation, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 245e18dd4..c05df849a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -225,11 +225,12 @@ export function appendCulvertOverlay( layer.append(shape); } if (basin.cutLine) { - const cut = document.createElementNS(SVG_NS, "line"); - cut.setAttribute("x1", String(x(basin.cutLine.from.offset))); - cut.setAttribute("y1", String(toDisplayY(basin.cutLine.from.elevation))); - cut.setAttribute("x2", String(x(basin.cutLine.to.offset))); - cut.setAttribute("y2", String(toDisplayY(basin.cutLine.to.elevation))); + // 점열(암 무릎 · 소단 · 91-4) + const cut = document.createElementNS(SVG_NS, "polyline"); + cut.setAttribute( + "points", + basin.cutLine.map((point) => `${x(point.offset)},${toDisplayY(point.elevation)}`).join(" "), + ); // 절토선은 **공사 계획선**이다 — 설계선과 같은 보라 실선으로 그린다 // (2026-08-20 사용자 ②). cut.setAttribute("class", "b06-chart__design-cross"); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 3ef18d3fc..3d362750a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -20,7 +20,7 @@ import { REVET_FREEBOARD_M, REVET_LEAN_RATIO, } from "./B06_Section_UI_Cross_Culvert_Const"; -import type { CulvertSideSpec } from "./B06_Section_Api_Fetch"; +import type { CrossDesign, CulvertSideSpec } from "./B06_Section_Api_Fetch"; import { minShoulderWallOffset, slopeLengthAlong, @@ -34,6 +34,7 @@ import type { OffsetPoint, InletStructureChoice, } from "./B06_Section_UI_Cross_Culvert_Types"; +import { structureCutPoints, structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; /** 자동(기본) 집수정 모양(54-36) — 좌측 폼 「(기본)」 표시도 이 값. */ export const DEFAULT_BASIN_SHAPE: BasinShape = "L"; @@ -140,8 +141,10 @@ export interface BasinBuildInput { /** 이 측의 노견 끝 점 — 벽 상단 접점·I형 높이 성장 기준. */ edge: { offset_m: number; elevation_m: number }; groundAt: (offset: number) => number; - /** 표준단면 절토 경사(1:n) — 절토 계획선용. */ - cutSlopeRatio: number; + /** 표준단면 절토 경사(1:n) — 절토 계획선용(`cutDesign` 이 없을 때 경사 하나). */ + cutSlopeRatio?: number; + /** 측점 설계값 — 있으면 절토선이 본선과 같은 복합경사(암 무릎 · 소단 · 91-4). */ + cutDesign?: Partial | null; adjust: BasinAdjust; /** 벽 두께(m). 기본 = 집수정 부재 두께. 세월교 측벽이 다른 두께를 쓸 때 넘긴다. */ memberThicknessM?: number; @@ -183,17 +186,8 @@ export const BASIN_INNER_WIDTH_M = 1.0; /** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */ export function buildBasin(input: BasinBuildInput): BasinBuildResult { - const { - anchor: baseAnchor, - outward, - shape, - reason, - diameterM, - edge, - groundAt, - cutSlopeRatio, - adjust, - } = input; + const { anchor: baseAnchor, outward, shape, reason, diameterM, edge, groundAt, adjust } = input; + const cutSpec = structureCutSpec(input.cutDesign ?? { cut_slope_ratio: input.cutSlopeRatio }); /** 이 부재의 기움 — 집수정 0.3, 세월교 0(수직). */ const lean = input.leanRatio ?? REVET_LEAN_RATIO; const movedAnchor = { @@ -339,13 +333,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { : { offset: floorOuter, elevation: anchor.elevation }; const groundHere = groundAt(refTop.offset); if (groundHere > refTop.elevation + 0.05) { - for (let h = 0.05; h <= 20; h += 0.05) { - const probe = refTop.offset + outward * cutSlopeRatio * h; - if (groundAt(probe) <= refTop.elevation + h) { - basinCut = { from: refTop, to: { offset: probe, elevation: refTop.elevation + h } }; - break; - } - } + basinCut = structureCutPoints(refTop, outward, cutSpec, groundAt); } else if (groundHere < refTop.elevation - 0.05) { // 원지반 밖으로 나왔다 — 성토부선은 유출측과 같은 체계(1:1.2 + 5m 다단)로 // 본체가 그린다(2026-08-22 사용자 확정). 여기서는 시작점만 넘긴다. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 720b9452e..fb01894dd 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -29,6 +29,8 @@ import type { WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; +import { structureCutPoints } from "./B06_Section_UI_Cross_Structure_Cut"; +import type { StructureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall"; export interface OutletExtrasInput { @@ -48,20 +50,19 @@ export interface OutletExtrasInput { equalize?: boolean; /** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */ leanFor?: (form: string, pureHeightM: number) => number; - /** 끝이 원지반에 묻힐 때 수평공간 뒤 절토 경사(1:n)로 지반까지(세월교 · 박스 흙막이 · 79-7). - * 없으면 종전 3도 선. */ - cutBerm?: { lengthM: number; slopeRatio: number }; + /** 끝이 원지반에 묻힐 때 수평공간 뒤 절토(본선과 같은 복합경사 · 91-4)로 지반까지(세월교 · 박스 + * 흙막이 · 79-7). 없으면 종전 3도 선. */ + cutBerm?: { lengthM: number; cut: StructureCutSpec }; } -/** 수평공간(berm) 뒤 절토 경사로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */ +/** 수평공간(berm) 뒤 절토(복합경사 · 91-4)로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */ function bermCut( src: OffsetPoint, outward: number, - berm: { lengthM: number; slopeRatio: number }, + berm: { lengthM: number; cut: StructureCutSpec }, groundAt: (offset: number) => number, limitOffset: number, ): OutletFillSegment { - const ratio = Math.max(berm.slopeRatio, 0.1); const flat = slopedCrossing(src, outward, 0, groundAt, limitOffset); const segment = (points: OffsetPoint[]): OutletFillSegment => ({ points, @@ -78,22 +79,16 @@ function bermCut( if (flat !== null && (flat - src.offset) * outward <= berm.lengthM + 1e-9) return segment([src, { offset: flat, elevation: src.elevation }]); const bend = { offset: src.offset + outward * berm.lengthM, elevation: src.elevation }; - const span = (limitOffset - bend.offset) * outward; - let previousGap = groundAt(bend.offset) - bend.elevation; - for (let t = 0.25; t <= span + 1e-9; t += 0.25) { - const gap = groundAt(bend.offset + outward * t) - (bend.elevation + t / ratio); - if (gap <= 0) { - const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; - const cross = t - 0.25 * (1 - fraction); - return segment([ - src, - bend, - { offset: bend.offset + outward * cross, elevation: bend.elevation + cross / ratio }, - ]); - } - previousGap = gap; - } - return segment([src, bend]); + // 소단 셈은 수평공간 끝에서 새로(91-4) + const cut = structureCutPoints( + bend, + outward, + berm.cut, + groundAt, + (limitOffset - bend.offset) * outward, + ); + const head = berm.lengthM > 1e-9 ? [src, bend] : [src]; + return segment(cut ? [...head, ...cut.slice(1)] : head.length > 1 ? head : [src, bend]); } export interface OutletExtrasResult { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 7c3b5bbd9..ebb01bf9c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -56,6 +56,7 @@ import { inletChoiceAvailability, } from "./B06_Section_UI_Cross_Culvert_Basin"; import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut"; import { buildCulvertTiers, drawnFillPoints, @@ -335,7 +336,7 @@ export function computeCulvertLayout( diameterM: diameter, edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge, groundAt, - cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, + cutDesign: section.design, adjust: basinAdjust, heightM: basinHeightM, }); @@ -615,7 +616,7 @@ export function computeCulvertLayout( const slopeOf = (wall: WallLayout): FillSlopeSegment => fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge); for (const wall of walls) { - const slope = slopeOf(wall); + const slope = withBackCut(slopeOf(wall), wall, section.design, groundAt, !culvert.hidden_pipe); // 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의 // 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다). if (wall.outward > 0) { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts index 6396cc06f..37815717a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -16,6 +16,7 @@ import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; import { fillWallBaseWidth } from "./B06_Section_UI_Cross_Culvert_Solve"; import type { OffsetPoint, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; +import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; // d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정). // 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는 @@ -89,7 +90,7 @@ export function buildCulvertTiers(input: { culvert?.body ? { lengthM: Math.max(spec?.revet_berm_m ?? GUARD_CUT_BERM_M, 0), - slopeRatio: section.design?.cut_slope_ratio ?? 1.0, + cut: structureCutSpec(section.design), } : undefined; // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 322748453..98bd07199 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -112,8 +112,8 @@ export interface BasinLayout { * "cut" = 절토측 유입(성토사면 없음), "short" = 성토사면 3m 이하, * "manual" = 규칙상 기슭막이 자리지만 사용자가 집수정을 선택. */ reason: "cut" | "short" | "manual"; - /** 집수정이 원지반 안으로 들어갈 때의 절토선(구조물 바깥 끝 → 지반 교차점). */ - cutLine: { from: OffsetPoint; to: OffsetPoint } | null; + /** 집수정이 원지반 안으로 들어갈 때의 절토선 점열(구조물 바깥 끝 → 복합경사 → 지반 교차점 · 91-4). */ + cutLine: OffsetPoint[] | null; /** 구조물이 원지반 밖으로 나올 때의 성토(되메움)선 — 경사 5도(임시), 지반 교차점까지. */ fillLine: { from: OffsetPoint; to: OffsetPoint } | null; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index f21cc38ca..6ea9ed38d 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -138,7 +138,7 @@ export function appendFordOverlay( ); } if (side.cutLine) { - planLine([side.cutLine.from, side.cutLine.to], `${role} 측벽 절토선(계획선)`); + planLine(side.cutLine, `${role} 측벽 절토선(계획선)`); } } diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index d40326c05..c33ac5554 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -148,7 +148,6 @@ export function computeFordLayout( const uphill = section.uphill_side ?? "left"; const inletOnLeft = uphill === "left"; const roadTopAt = roadTopFactory(section, edges.left, edges.right); - const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0; // 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점. const bedElevation = (() => { @@ -182,7 +181,8 @@ export function computeFordLayout( diameterM: ford.diameter_m, edge: { offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) }, groundAt, - cutSlopeRatio, + // 측벽 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4) + cutDesign: section.design, // 내공 폭 자리에 날개벽 투영 연장을 넣는다 — 바닥판이 그만큼 계류측으로 뻗는다. adjust: { innerWidthM: Math.max(wing.slab_extend_m, 0), diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts b/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts new file mode 100644 index 000000000..d24ec6819 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Structure_Cut.ts + * 구조물 끝 → 원지반 **절토선 점열**(91-4) — 본선 절토 사면과 같은 복합경사. + * + * 꼭짓점은 `common_util_cross_berm.cutProfilePoints` 그대로(암 → 토사 무릎 · 소단) · 경계 = 원지반 + + * `rock_boundary_offset_m` · 켬 = 암 프리셋 + `two_stage_slope` · 소단 = `design.berm` · 소단 셈은 + * 구조물 끝에서 새로 · 원지반 교점에서 자름. + * 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤. + * ========================================================================== */ + +import { bermSpec, cutProfilePoints } from "../common_util/common_util_cross_berm"; +import type { BermSpec } from "../common_util/common_util_cross_berm"; +import type { CrossDesign } from "./B06_Section_Api_Fetch"; +import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; + +/** 절토 제원 — 암(또는 단일) 경사 · 토사 경사 · 암 경계 오프셋(없으면 경사 하나) · 소단. */ +export interface StructureCutSpec { + cutRatio: number; + soilRatio: number; + rockOffsetM: number | null; + berm: BermSpec | null; +} + +/** 측점 설계값에서 절토 제원 — 본선 `computeCrossDesign` 과 같은 켬 조건. */ +export function structureCutSpec(design?: Partial | null): StructureCutSpec { + const cutRatio = Math.max(design?.cut_slope_ratio ?? 1.0, 0.1); + const offset = design?.rock_boundary_offset_m; + const twoStage = + design?.geometry_preset === "rock" && design.two_stage_slope === true && offset != null; + const berm = design?.berm; + return { + cutRatio, + soilRatio: Math.max(design?.soil_cut_slope_ratio ?? cutRatio, 0.1), + rockOffsetM: twoStage ? offset : null, + berm: berm ? bermSpec(berm.width_m, berm.interval_m, berm.slope_deg) : null, + }; +} + +/** 원지반 교점 찾는 걸음(m). */ +const STEP_M = 0.05; + +/** + * `from`(원지반 아래) → 바깥(`outward`)으로 절토 꼭짓점 · 마지막 점 = 원지반 교점. + * `reachM` 안에서 못 만나면 null. + */ +export function structureCutPoints( + from: OffsetPoint, + outward: number, + spec: StructureCutSpec, + groundAt: (offset: number) => number, + reachM = 50, +): OffsetPoint[] | null { + const at = (dist: number): number => from.offset + outward * dist; + const rockOffsetM = spec.rockOffsetM; + const boundary = rockOffsetM === null ? null : (dist: number) => groundAt(at(dist)) + rockOffsetM; + const profile = cutProfilePoints( + 0, + from.elevation, + spec.cutRatio, + spec.soilRatio, + boundary, + spec.berm, + Math.max(reachM, STEP_M), + // 소단이 있으면 경계를 오갈 때마다 꺾음 — 본선 `cutPoints` 와 같음. + spec.berm !== null, + ); + const points: OffsetPoint[] = [from]; + for (let i = 1; i < profile.length; i += 1) { + const [d0, z0] = profile[i - 1]; + const [d1, z1] = profile[i]; + const steps = Math.max(1, Math.ceil((d1 - d0) / STEP_M)); + let previousGap = groundAt(at(d0)) - z0; + for (let k = 1; k <= steps; k += 1) { + const t = k / steps; + const gap = groundAt(at(d0 + (d1 - d0) * t)) - (z0 + (z1 - z0) * t); + if (gap <= 0) { + // 걸음 사이 교점 보간 — 선이 지반을 넘지 않게 + const share = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; + const tc = (k - 1 + share) / steps; + points.push({ offset: at(d0 + (d1 - d0) * tc), elevation: z0 + (z1 - z0) * tc }); + return points; + } + previousGap = gap; + } + points.push({ offset: at(d1), elevation: z1 }); + } + return null; +} + +/** + * 배관 유출 벽이 절토 쪽(벽 바깥 윗모서리가 원지반 아래)이면 그 모서리부터 절토선을 사면 점열 뒤에 + * 이음 — 트림이 노견부터 설계선을 지워 절토사면이 사라지던 자리(91-4). 관 숨김(세월교 · 박스 흙막이 · + * 독립 기슭막이)은 `enabled` 거짓. + */ +export function withBackCut( + slope: T, + wall: { role: string; outward: number; points: OffsetPoint[] }, + design: Partial | null | undefined, + groundAt: (offset: number) => number, + enabled: boolean, +): T { + const corner = wall.points[2]; + if (!enabled || wall.role !== "outlet" || !corner) return slope; + if (groundAt(corner.offset) <= corner.elevation + 0.05) return slope; + const cut = structureCutPoints(corner, wall.outward, structureCutSpec(design), groundAt); + return cut ? { ...slope, points: [...slope.points, ...cut] } : slope; +} diff --git a/resources/tester/helper_91_ground_free.cjs b/resources/tester/helper_91_ground_free.cjs index 5aed020f1..47eb605a6 100644 --- a/resources/tester/helper_91_ground_free.cjs +++ b/resources/tester/helper_91_ground_free.cjs @@ -127,4 +127,65 @@ out.lean = ["left_cut", "right_cut", "both_fill", "both_cut"].map((mode) => [ leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌" }), leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌", face_role: "절토" }), ]); +// ③ 91-4 유출측 절토 복합경사 — 꼭짓점 사이 경사(수평/수직) · 사면 길이 · 끝 = 원지반 교점 +const { groundInterpolator } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve.ts")); +function profile(points, samples) { + const groundAt = groundInterpolator(samples); + const legs = points.slice(1).map((p, i) => { + const dx = Math.abs(p.offset - points[i].offset); + const dz = p.elevation - points[i].elevation; + return { ratio: dz > 1e-9 ? r3(dx / dz) : null, slant: r3(Math.hypot(dx, dz)), run: r3(dx) }; + }); + const end = points[points.length - 1]; + return { legs, endGap: r3(Math.abs(groundAt(end.offset) - end.elevation)) }; +} +const withBerm = (s) => { + s.design.berm = { width_m: 0.5, interval_m: 3, slope_deg: 0 }; +}; +// 흙막이 수평공간 뒤(박스 352.14 · 세월교 173.09) +function guardTails(base, edit = () => undefined) { + const s = clone(base); + edit(s); + const { culvert } = layoutsOf(s, base); + return [...culvert.outletFill.segments, ...culvert.basinFill.segments] + .filter((g) => g.kind === "cut") + .map((g) => profile(g.points, s.samples)); +} +out.guardTails = [...guardTails(at(352.14)), ...guardTails(FORD)]; +// 박스 끝 · 세월교 측벽 끝(흙막이 없음) +const { computeBoxLayout } = loadTs(B06("B06_Section_UI_Cross_Box_Geom.ts")); +const { computeFordLayout } = loadTs(B06("B06_Section_UI_Cross_Ford_Geom.ts")); +function endCuts(base, compute) { + const s = clone(base); + delete s.culvert; + s.uphill_side = "left"; // 절토 쪽(우측)을 유출로 + return compute(s, s.samples) + .sides.filter((side) => side.cutLine) + .map((side) => profile(side.cutLine, s.samples)); +} +out.boxEnd = endCuts(at(352.14), computeBoxLayout); +out.fordEnd = endCuts(FORD, computeFordLayout); +// 절토 쪽 배관 유출 벽 뒤(관 340 · 유출 = 우측 절토) · 소단 있음/없음 +function cutOutlet(edit = () => undefined) { + const s = clone(at(340)); + s.uphill_side = "left"; + s.culvert = { + type: "pipe", + chainage_m: 340, + pipe_kind: "흄관", + diameter_m: 1.0, + min_cover_m: 0.5, + inlet: { role: "inlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" }, + outlet: { role: "outlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" }, + }; + s.design.inlet_structure = "revet"; + edit(s); + const layout = computeCulvertLayout(s, s.samples, undefined, "revet"); + const wall = layout.walls.find((w) => w.role === "outlet"); + const pts = layout.designTrim.minSlope.points; + const from = pts.findIndex((p) => Math.abs(p.offset - wall.points[2].offset) < 1e-9); + return profile(pts.slice(from), s.samples); +} +out.cutOutlet = cutOutlet(); +out.cutOutletBerm = cutOutlet(withBerm); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index f33cbcafe..22607986f 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -80,7 +80,8 @@ def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None: tails = [tail for group in shape["cutDefault"] for tail in group] assert tails, "절토 끝선이 없음" for tail in tails: - assert tail["points"] == 3 + # 시작 · 수평공간 끝 · (암 무릎 · 소단) · 원지반 교점 — 본선 절토와 같은 복합경사(91-4) + assert tail["points"] >= 3 assert tail["flat"] == pytest.approx(0, abs=1e-6) assert tail["berm"] == pytest.approx(1.0, abs=1e-3) for tail in shape["cutBerm2"]: diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 7fc4cdf36..09e1c5de4 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -83,3 +83,34 @@ def test_지형에_막힘_잔재_없음() -> None: source = (ROOT / "B06_Section" / name).read_text(encoding="utf-8") assert "shiftBlockedM" not in source and "지형에 막힘" not in source, name assert "noticeExtraLimit" not in source and "noticeShortfall" not in source, name + + +def _knee(cut: dict) -> bool: + """암(1:0.4) 뒤 토사(1:1.0) 무릎이 있음 — 픽스처 = 리핑암 · 2단계 · 경계 −0.5 m.""" + ratios = [leg["ratio"] for leg in cut["legs"] if leg["ratio"] is not None] + return any(a == 0.4 and b == 1.0 for a, b in zip(ratios, ratios[1:])) + + +def test_흙막이_수평공간_뒤_절토_복합경사(fit: dict) -> None: + tails = fit["guardTails"] + assert tails and all(cut["endGap"] < 0.01 for cut in tails) + assert all(cut["legs"][0]["ratio"] is None for cut in tails) # 수평공간 먼저 + assert sum(_knee(cut) for cut in tails) >= 2 + + +def test_박스_끝_세월교_측벽_끝_절토_복합경사(fit: dict) -> None: + for cuts in (fit["boxEnd"], fit["fordEnd"]): + assert cuts and all(_knee(cut) and cut["endGap"] < 0.01 for cut in cuts) + + +def test_절토_쪽_배관_유출_벽_뒤_절토사면_살아남(fit: dict) -> None: + cut = fit["cutOutlet"] + assert _knee(cut) and cut["endGap"] < 0.01 + + +def test_소단은_구조물_끝에서_새로_셈(fit: dict) -> None: + legs = fit["cutOutletBerm"]["legs"] + flat = next(i for i, leg in enumerate(legs) if leg["ratio"] is None) + assert sum(leg["slant"] for leg in legs[:flat]) == pytest.approx(3.0, abs=1e-3) + assert legs[flat]["run"] == pytest.approx(0.5, abs=1e-3) + assert fit["cutOutletBerm"]["endGap"] < 0.01 From ea8495792756df33e95669cfeb34a844a010ffc3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:34:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(spreadsheet):=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=A9=94=EB=89=B4=EC=97=90=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=EB=84=A3=EA=B8=B0=20=C2=B7=20=EC=82=BD=EC=9E=85/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=E2=96=B8=20=C2=B7=20=ED=81=AC=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=C2=B7=20=ED=95=84=ED=84=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=20=EA=B7=B8=EB=A3=B9=20=C2=B7=20?= =?UTF-8?q?=EB=8D=94=EB=B3=B4=EA=B8=B0=20=EB=B6=99=EC=9E=84=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EB=B3=B5=EC=82=AC(81?= =?UTF-8?q?-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spreadsheet_menu.ts — 칸 · 행 머리 · 열 머리 메뉴에 spreadsheet_menu_items 펼침 · 구분선 - 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 execCommand(메뉴가 닫히며 초점이 빠져 빈 복사되던 흠) - 읽기 전용 · 미리보기 시트 우클릭 = 「복사」 한 줄(spreadsheet.ts 메뉴 늘 붙임) - 시험 test_stage4_menu_items.ts 34 · 크기 화면 16.3 KB(예산 72) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- A00_Common/spreadsheet/spreadsheet.ts | 4 +- A00_Common/spreadsheet/spreadsheet_menu.ts | 54 +++- .../spreadsheet/test_stage4_menu_items.ts | 304 ++++++++++++++++++ 3 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 resources/tester/spreadsheet/test_stage4_menu_items.ts diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 5596915d8..4c7e91766 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -110,7 +110,7 @@ export function createSpreadsheet( ): SpreadsheetHandle { const readOnly = !!opts.readOnly; const noTabs = opts.tabs === false; - const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음 + const bare = readOnly || !!opts.preview; // 도구 모음 없음 · 우클릭은 「복사」 한 줄 const root = el("div", { className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`, }); @@ -142,7 +142,7 @@ export function createSpreadsheet( ctx.editor = ed; const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx); const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx); - const menu: PartHandle | null = bare ? null : attachMenu(ctx); + const menu: PartHandle = attachMenu(ctx); // 읽기 전용 · 미리보기는 「복사」 한 줄(81-3) const clip = attachClipboard(ctx); const status = createStatusBar(ctx); const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p); diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index 603fa99e7..a2af5138f 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -12,10 +12,21 @@ * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import { rangeToA1 } from "./spreadsheet_address"; import { confirmMerge, dialogKeys } from "./spreadsheet_actions"; import { deleteComment, getComment, setComment } from "./spreadsheet_comments"; +import { + cellInsertItems, + cellMoreItems, + colInsertItems, + colMoreItems, + copyCut, + copyOnlyItems, + pasteItems, + rowInsertItems, + rowMoreItems, +} from "./spreadsheet_menu_items"; import { st } from "./spreadsheet_text"; import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; @@ -28,7 +39,8 @@ function within(range: CellRange, r: number, c: number): boolean { /** `ctx.root` 의 contextmenu 를 받음(root null) */ export function attachMenu(ctx: SpreadsheetContext): PartHandle { - if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} }; + // 읽기 전용 · 미리보기 = 「복사」 한 줄(81-3) + const bare = ctx.readOnly || ctx.root.classList.contains("ss--preview"); const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); @@ -39,7 +51,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { } function cutCopy(kind: "cut" | "copy"): void { - document.execCommand(kind); + copyCut(ctx, kind); } /** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는 @@ -79,7 +91,10 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], + ...pasteItems(ctx), + "-", + ...cellInsertItems(ctx, range), + "-", [ st("MenuClearContent"), () => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }), @@ -107,6 +122,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { ...(note ? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[]) : []), + ...cellMoreItems(ctx, range), ...(ctx.cellMenuExtra?.(r, c) ?? []), ]; } @@ -117,11 +133,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], - [ - st("MenuInsertRow"), - () => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }), - ], + ...pasteItems(ctx), + "-", + ...rowInsertItems(ctx, at, 수), [ st("MenuDeleteRow"), () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }), @@ -148,6 +162,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 숨김: false, }), ], + ...rowMoreItems(ctx, rows), ]; } @@ -157,11 +172,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], - [ - st("MenuInsertCol"), - () => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }), - ], + ...pasteItems(ctx), + "-", + ...colInsertItems(ctx, at, 수), [ st("MenuDeleteCol"), () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }), @@ -188,6 +201,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 숨김: false, }), ], + ...colMoreItems(ctx, cols), ]; } @@ -217,7 +231,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 활성: { r: hit.r, c: 0 }, 기준: { r: hit.r, c: 0 }, }); - menu.open(x, y, rowMenuItems(rows)); + menu.open(x, y, bare ? copyOnlyItems(ctx) : rowMenuItems(rows)); return; } @@ -235,7 +249,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 활성: { r: 0, c: hit.c }, 기준: { r: 0, c: hit.c }, }); - menu.open(x, y, colMenuItems(cols)); + menu.open(x, y, bare ? copyOnlyItems(ctx) : colMenuItems(cols)); return; } @@ -250,7 +264,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { }); } const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c); - menu.open(x, y, cellMenuItems(active, hit.r, hit.c)); + menu.open( + x, + y, + bare + ? [...copyOnlyItems(ctx), ...(ctx.cellMenuExtra?.(hit.r, hit.c) ?? [])] + : cellMenuItems(active, hit.r, hit.c), + ); } } diff --git a/resources/tester/spreadsheet/test_stage4_menu_items.ts b/resources/tester/spreadsheet/test_stage4_menu_items.ts new file mode 100644 index 000000000..3c7fe1aec --- /dev/null +++ b/resources/tester/spreadsheet/test_stage4_menu_items.ts @@ -0,0 +1,304 @@ +/* ============================================================================= + * test_stage4_menu_items.ts — 우클릭 메뉴 덧붙인 항목(81-3) 시험. + * 행 위/아래 · 열 좌우 삽입 · 셀 삽입/삭제 · 행열 삭제 · 크기 · 시트 정렬 · 필터 · 고정 · 그룹 · + * 범위 이름 · 붙여넣기 보안 문맥 갈래 · 구분선 · 읽기 전용 복사. + * 돌리기: npx tsx resources/tester/spreadsheet/test_stage4_menu_items.ts + * ========================================================================== */ + +import { register } from "node:module"; +import type { MapContextMenuItem } from "../../../ui_template/ui_template_context_menu.ts"; +import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands.ts"; +import { createCalcEngine } from "../../../A00_Common/spreadsheet/spreadsheet_graph.ts"; +import { + canMenuPaste, + cellInsertItems, + cellMoreItems, + colInsertItems, + colMoreItems, + copyOnlyItems, + menuPaste, + nameRange, + pasteItems, + resizeLines, + rowInsertItems, + rowMoreItems, + sortByColumn, + usedRange, +} from "../../../A00_Common/spreadsheet/spreadsheet_menu_items.ts"; +import type { Cell, Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts"; +import type { + Selection, + SpreadsheetContext, +} from "../../../A00_Common/spreadsheet/spreadsheet_view_types.ts"; +import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts"; + +// 늦게 부르는 창 모듈의 css 는 node 에서 빈 모듈로 +register( + "data:text/javascript,export async function load(u,c,n){return u.endsWith('.css')?{format:'module',source:'',shortCircuit:true}:n(u,c)}", +); +const tick = () => new Promise((r) => setTimeout(r, 30)); + +/** 진짜 명령 · 엔진 · 고름을 쓰는 문맥 */ +function realCtx(cells: Record): SpreadsheetContext & { sent: Command[] } { + const book: Workbook = makeBook([makeSheet("s1", cells)]); + const base = makeCtx(book, "s1"); + const sent: Command[] = []; + const ctx = base as unknown as SpreadsheetContext & { sent: Command[] }; + ctx.engine = createCalcEngine(book); + ctx.sent = sent; + ctx.dispatch = (cmd: Command) => { + sent.push(cmd); + applyCommand(book, cmd); + ctx.engine.rebuild(book); + }; + ctx.select = (sel: Selection) => void (ctx.selection = sel); + return ctx; +} +const col = (n: number) => + Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(i, 0), v(i + 1)])); +const row = (n: number) => + Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(0, i), v(i + 1)])); +const val = (ctx: SpreadsheetContext, r: number, c: number) => ctx.sheet().칸[a1(r, c)]?.값; +const leaf = (items: MapContextMenuItem[], label: string): (() => void) => { + for (const it of items) { + if (it === "-") continue; + if (Array.isArray(it)) { + if (it[0] === label) return it[1]; + } else { + const hit = it.children.find(([l]) => l === label); + if (hit) return hit[1]; + } + } + throw new Error(`항목 없음: ${label}`); +}; +const labels = (items: MapContextMenuItem[]) => + items.map((it) => (it === "-" ? "-" : Array.isArray(it) ? it[0] : `${it.label} ▸`)); + +// ── 행 위 · 아래 삽입(고른 2행 B2:B3 → at 1 · 수 2) ───────────────────────── +{ + const ctx = realCtx(col(5)); + leaf(rowInsertItems(ctx, 1, 2), "위에 2행 삽입")(); + assertEqual( + [val(ctx, 0, 0), val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)], + [1, undefined, undefined, 2], + "위에 2행 — 2행부터 밀림", + ); +} +{ + const ctx = realCtx(col(5)); + leaf(rowInsertItems(ctx, 1, 2), "아래에 2행 삽입")(); + assertEqual( + [val(ctx, 2, 0), val(ctx, 3, 0), val(ctx, 4, 0), val(ctx, 5, 0)], + [3, undefined, undefined, 4], + "아래에 2행 — 고른 줄 뒤에 끼움", + ); +} +// ── 열 왼 · 오른쪽 삽입 ────────────────────────────────────────────────── +{ + const ctx = realCtx(row(4)); + leaf(colInsertItems(ctx, 1, 1), "왼쪽에 1열 삽입")(); + assertEqual([val(ctx, 0, 0), val(ctx, 0, 1), val(ctx, 0, 2)], [1, undefined, 2], "왼쪽에 1열"); +} +{ + const ctx = realCtx(row(4)); + leaf(colInsertItems(ctx, 1, 1), "오른쪽에 1열 삽입")(); + assertEqual([val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], [2, undefined, 3], "오른쪽에 1열"); +} +// ── 칸 메뉴 — 셀 삽입/삭제 ▸ · 행열 삭제 ▸ ────────────────────────────── +{ + const g = { r0: 0, c0: 1, r1: 0, c1: 1 }; + const ctx = realCtx(row(3)); + const items = cellInsertItems(ctx, g); + assertEqual( + labels(items), + ["행 · 열 삽입 ▸", "셀 삽입 ▸", "행 · 열 삭제 ▸", "셀 삭제 ▸"], + "칸 메뉴 하위 넷", + ); + leaf(items, "셀 삽입 후 오른쪽으로 밀기")(); + await tick(); + assertEqual( + [val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], + [undefined, 2, 3], + "셀 삽입 · 오른쪽으로", + ); + leaf(cellInsertItems(ctx, g), "셀 삭제 후 왼쪽으로 당기기")(); + await tick(); + assertEqual([val(ctx, 0, 1), val(ctx, 0, 2)], [2, 3], "셀 삭제 · 왼쪽으로"); +} +{ + const ctx = realCtx(col(3)); + leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삽입 후 아래로 밀기")(); + await tick(); + assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [undefined, 2], "셀 삽입 · 아래로"); + leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삭제 후 위로 당기기")(); + await tick(); + assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [2, 3], "셀 삭제 · 위로"); + leaf(cellInsertItems(ctx, { r0: 0, c0: 0, r1: 1, c1: 0 }), "2행 삭제")(); + assertEqual(val(ctx, 0, 0), 3, "칸 메뉴 행 삭제 · 고른 2행"); +} +// ── 크기 — px → 열폭(글자) · 행높이(pt) · 잘못된 값 ───────────────────────── +{ + const ctx = realCtx(row(2)); + assertEqual( + resizeLines(ctx, false, [0, 1], "abc"), + "1 이상 숫자를 넣을 것", + "크기 · 글이면 거름", + ); + assertEqual(resizeLines(ctx, false, [0, 1], "0"), "1 이상 숫자를 넣을 것", "크기 · 0 거름"); + assertEqual(resizeLines(ctx, false, [0, 1], "100"), null, "열 너비 100px"); + const w = ctx.sheet().열!; + assertEqual(w.A?.폭 === w.B?.폭 && (w.A?.폭 ?? 0) > 10, true, "열 둘 같은 폭(한 명령)"); + resizeLines(ctx, true, [2], "40"); + assertEqual( + Math.round((ctx.sheet().행?.["3"]?.높이 ?? 0) * 100) / 100, + 30, + "행 높이 40px = 30pt", + ); +} +// ── 시트 정렬(열 머리) — 쓴 범위 통째 · 고름 되돌림 · 빈 열은 안 함 ─────────────── +{ + const ctx = realCtx({ + A1: v("이름"), + B1: v("값"), + A2: v("다"), + B2: v(3), + A3: v("가"), + B3: v(1), + A4: v("나"), + B4: v(2), + }); + const keep = ctx.selection; + await sortByColumn(ctx, 1, false); + assertEqual( + [val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)], + ["가", "나", "다"], + "B 열 기준 오름 — A 열도 같이", + ); + assertEqual(val(ctx, 0, 0), "이름", "머리 행 그대로"); + assertEqual(ctx.selection, keep, "정렬 뒤 고름 되돌림"); + await sortByColumn(ctx, 1, true); + assertEqual([val(ctx, 1, 1), val(ctx, 3, 1)], [3, 1], "내림"); + const before = ctx.sent.length; + await sortByColumn(ctx, 5, false); + assertEqual(ctx.sent.length, before, "빈 열 정렬은 명령 없음"); + assertEqual(usedRange(ctx.sheet()), { r0: 0, c0: 0, r1: 3, c1: 1 }, "쓴 범위"); +} +// ── 필터 · 고정 · 그룹 · 더보기 ───────────────────────────────────────────── +{ + const ctx = realCtx({ A1: v("머리"), A2: v(1), B2: v(2) }); + const calls: [string, unknown][] = []; + ctx.dataMenu = (kind) => calls.push([kind, structuredClone(ctx.selection.범위)]); + const more = colMoreItems(ctx, [0]); + assertEqual( + labels(more), + [ + "-", + "열 크기 조절…", + "데이터에 맞추기", + "-", + "시트 정렬 A → Z", + "시트 정렬 Z → A", + "필터 만들기", + "-", + "고정 ▸", + "그룹 ▸", + ], + "열 머리 덧붙임 순서", + ); + leaf(more, "필터 만들기")(); + await tick(); + assertEqual(calls[0], ["filter", [{ r0: 0, c0: 0, r1: 1, c1: 1 }]], "열 머리 필터 = 쓴 범위로"); + leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "드롭다운…")(); + leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "조건부 서식…")(); + assertEqual( + calls.slice(1).map((c) => c[0]), + ["validation", "condfmt"], + "더보기 → 데이터 창", + ); + leaf(rowMoreItems(ctx, [0]), "첫 행 고정")(); + await tick(); + assertEqual(ctx.sent.at(-1)?.종류, "틀고정", "고정 ▸ 첫 행"); + ctx.selection = { + 시트: "s1", + 범위: [{ r0: 1, c0: 0, r1: 1, c1: 16383 }], + 활성: { r: 1, c: 0 }, + 기준: { r: 1, c: 0 }, + }; + leaf(rowMoreItems(ctx, [1]), "그룹으로 묶기")(); + await tick(); + assertEqual(ctx.sheet().행?.["2"]?.묶음, { 층: 1 }, "그룹 ▸ 묶기"); + assertEqual( + await nameRange(ctx, "", { r0: 0, c0: 0, r1: 0, c1: 0 }), + "이름을 넣을 것", + "범위 이름 · 빈 값", + ); + assertEqual( + await nameRange(ctx, "합계칸", { r0: 1, c0: 0, r1: 1, c1: 1 }), + null, + "범위 이름 지정", + ); + assertEqual( + typeof (await nameRange(ctx, "합계칸", { r0: 0, c0: 0, r1: 0, c1: 0 })), + "string", + "같은 이름은 거름", + ); +} +// ── 붙여넣기 — 보안 문맥 갈래 ─────────────────────────────────────────────── +{ + const g = globalThis as Record; + g.isSecureContext = false; + assertEqual(canMenuPaste(), false, "http(보안 문맥 아님) — 메뉴로 못 읽음 → Ctrl+V 안내"); + g.isSecureContext = true; + assertEqual(canMenuPaste(), false, "클립보드 API 없음 — 안내"); + const clip = { html: "", text: "7\t8" }; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + read: async () => [ + { + types: ["text/plain"], + getType: async () => new Blob([clip.text]), + }, + ], + }, + }); + assertEqual(canMenuPaste(), true, "localhost · https — 메뉴로 읽음"); + class FakeData { + data: Record = {}; + setData(k: string, s: string) { + this.data[k] = s; + } + } + class FakePaste { + constructor( + public type: string, + public init: { clipboardData: FakeData }, + ) {} + } + g.DataTransfer = FakeData; + g.ClipboardEvent = FakePaste; + const ctx = realCtx({}); + const events: FakePaste[] = []; + ctx.root = { dispatchEvent: (e: FakePaste) => events.push(e) } as unknown as HTMLElement; + await menuPaste(ctx); + assertEqual( + [events[0]?.type, events[0]?.init.clipboardData.data["text/plain"]], + ["paste", "7\t8"], + "보통 붙여넣기 = 부품 paste 길로", + ); + ctx.selection = { + 시트: "s1", + 범위: [{ r0: 2, c0: 0, r1: 2, c1: 0 }], + 활성: { r: 2, c: 0 }, + 기준: { r: 2, c: 0 }, + }; + await menuPaste(ctx, "값"); + assertEqual([val(ctx, 2, 0), val(ctx, 2, 1)], [7, 8], "값만 붙여넣기 — 활성 칸부터"); + assertEqual(labels(pasteItems(ctx)), ["붙여넣기", "선택하여 붙여넣기 ▸"], "붙여넣기 항목"); +} +// ── 읽기 전용 — 복사 한 줄 ───────────────────────────────────────────────── +{ + const ctx = realCtx({}); + assertEqual(labels(copyOnlyItems(ctx)), ["복사"], "읽기 전용 · 미리보기 = 복사 한 줄"); +} +console.log("spreadsheet_menu_items 시험 끝"); From 71d15bf10ba2582c3fe3145c6dcb13c65b282d78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:37:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore(plan):=20=EB=B6=84=EC=84=9D=5F?= =?UTF-8?q?=EC=A0=95=ED=95=A8=2091-1=20=ED=8C=90=EC=A0=95=20=EC=A4=84=20?= =?UTF-8?q?=E2=80=94=20=EB=A9=94=EC=9D=B8=20=ED=8C=90=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=82=B4=EC=9A=A9=20=EB=B0=9B=EC=95=84=20=EB=91=A0?= =?UTF-8?q?(91-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_013rJwJMVL4EE31y77TB11AP --- docs/raw/요구_1008/분석_정함.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1008/분석_정함.md b/docs/raw/요구_1008/분석_정함.md index a8e8395cc..2fca9cb8a 100644 --- a/docs/raw/요구_1008/분석_정함.md +++ b/docs/raw/요구_1008/분석_정함.md @@ -56,6 +56,7 @@ - **유출 단 수(90-1)** — 관보호공 「추가 흙막이(단)」 스테퍼를 공용으로 세월교 · 박스 유출에 · 「+ 흙막이」 단추 없앰 · 단 수 → 상자 수(관보호공과 같은 방향) · 집계표도 단 수로 상자 자름 · 유출이 날개벽 · 없음인데 상자가 있던 자료 = 숨기고 집계에서 뺌(값은 파일에 남음). - **도수로 · 돌붙임(90-2)** — 두께 칸(cm · 기본 30 · 사용자 확정) · 유출 성토사면 선을 사면 안쪽으로 두께만큼 오프셋한 띠(윗면 = 사면선 · 근거 교본 「지반선보다 낮게 원지반에 설치」) · 유출 끝 ~ 성토사면이 원지반 만나는 곳(흙막이 단 사이 성토 조각 포함) · 안에 돌 무늬(흙막이 돌 그리기 재사용) · 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 · 물넘이포장(`ford_chute`) · 면적 · 터파기 영향 없음. - **제약 삭제(91)** — 형상 자체(벽 외곽)는 원지반 · 암 참조 없음(조사_G 1). 「해당」 = 세월교 바닥 · 흙막이 기본 높이(하상) · 다단 자동 자리(원지반에 앉는 x) · I형 집수정 하단. 더 = 설계선 끝 2 px 판정(줌마다 끝점 변동) · 전면 기울기 지형 자동판정. 91-1 에서 재현 목록 → 브레인 판정 → 91-3. 끝 연결선(성토부선 · 절토선이 원지반에서 끝남)은 형상 아님 → 남김. +- **91-1 판정(브레인 · 10-8)** — ① 세월교 바닥 = 끊음(91-2 손 칸) ② 다단 자동 자리 = 끊음(지형 무관 등간격 · 앞 단 바닥 + 1:1.2 사면 일정 길이) ③ I형 집수정 근입 = 남김(집수정 형상 규칙 · 흙막이 아님) ④ 설계선 끝 2 px 판정 = 끊음(수치 교점 0.01 m) ⑤ 전면 기울기 지형 자동판정 = 끊음(관보호공 · 세월교 · 박스 흙막이 = 성토부 표값 고정 · 사면안정 흙막이 · 옹벽 = 설치 측 칸 값 · B08 도 같은 함수) ⑥ 끝 연결선 = 남김(잔재 문구만 지움). - **세월교 바닥(91-2)** — 처음 놓을 때 하상값 · 손 조정 칸(박스 `riseM` 꼴) · 원지반이 바뀌어도 안 따라감 · 흙막이 기본 높이도 이 값 기준. - **복합경사(91-4)** — 공용 도우미 하나(`common_util_cross_berm.ts cutProfilePoints` · 경계 = 원지반 + `rock_boundary_offset_m` · 켬 = 암 프리셋 + `two_stage_slope` · 소단 = `design.berm`) · 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤(지금 절토사면이 사라짐) · 소단 셈은 구조물 끝에서 새로 · `cutLine` 두 점 → 점열(횡단 셋 · 3D).