Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-17 12:28:59 +09:00
27 changed files with 350 additions and 74 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ function carried(section: CrossSection): Record<string, unknown> {
: null,
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
surfaceDropM: drop > 0 ? drop : 0,
};
}
@@ -308,8 +308,8 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[]
const side: "left" | "right" = wall.outward > 0 ? "left" : "right";
const slope = trimSlopeOf(side);
if (slope) pushTopSlope(side, slope.points);
// 구조물 전면 실루엣 — 이음선 상단에서 전면 발끝까지 내려온다.
push(side, [wall.topJoint, wall.bottomFront]);
// 구조물 전면 실루엣 — 성토 위 이음에서 전면 발끝까지 내려온다(371-1).
push(side, [wall.fillTop, wall.bottomFront]);
spans[side] = wallSpanOf(section, wall.role === "inlet" ? "inlet" : "outlet");
}
const outletSegments = layout.outletFill.segments.filter((segment) => segment.kind !== "cut");
@@ -321,13 +321,13 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[]
addRun(outletSide, segment.points);
}
const wall = layout.extraWalls[step];
if (wall) push(outletSide, [wall.topJoint, wall.bottomFront]);
if (wall) push(outletSide, [wall.fillTop, wall.bottomFront]);
}
layout.basinFill.segments.forEach((segment) => {
push(inletSide, segment.points);
addRun(inletSide, segment.points);
});
layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront]));
layout.basinExtras.forEach((wall) => push(inletSide, [wall.fillTop, wall.bottomFront]));
let basinCut = false;
if (layout.basin) {
+1 -1
View File
@@ -435,7 +435,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
paved: pavedBySpecs(section),
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
standard,
berm: bermAt(section.chainage_m, design),
rockBoundaryOffsetM,
+3 -2
View File
@@ -1,7 +1,7 @@
"""구조물 자리 기울기 — 물넘이포장 · 맹암거 측점의 노면(287-4 · 288-1).
그 자리 횡단 설계 = 측구 없음 · 노면 = 한 경사로 지대 낮은 쪽(`compute_cross_design(tilted=…,
tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 암거 경사(%) · 비면 표준 횡단경사.
tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 = 표준 횡단경사(암거 경사는 형상 하단 · 371-3).
· 물넘이포장 = 관 지점 정본(pipe_points) 범위(기준 ± 월류 폭/2).
· 맹암거 = 구조물 정본(structures.json) A군 점형 · 기준 측점 한 곳 — 횡단 dict 에
`underdrain`(높이 · 경사)을 얹어 횡단도가 돌 무늬 형상을 그림(TS 짝
@@ -78,7 +78,8 @@ def tilt_ranges(project_root: Path | None) -> list[TiltRange]:
half = float(spec.get("span_m") or 0.0) / 2.0
out.append((at - half, at + half, spec.get("slope_pct")))
for drain in load_underdrains(project_root):
out.append((drain["chainage_m"], drain["chainage_m"], drain["slope_pct"]))
# 노면 = 표준 경사 · 암거 경사는 맹암거 하단 경계만(371-3 · TS `facilityRanges` 짝)
out.append((drain["chainage_m"], drain["chainage_m"], None))
return out
+2 -2
View File
@@ -313,12 +313,12 @@ export function appendCulvertOverlay(
const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : "";
const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter);
const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}`;
// 관 기울기 = L 뒤 소수 한 자리 · 역경사는 0.0(371-2)
const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}, S=${Math.max(pipe.slopePct, 0).toFixed(1)}%`;
// 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1 · B06 카드만 — 그 라벨 없는 덧그림엔 안 그림)
if (!hidePipe) appendPipeInfoText(layer, pipeInfo, null);
const tip =
pipeInfo +
` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` +
` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` +
` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`;
// 외경(관 두께 바깥) → 내경 순으로 2겹. 도면처럼 관벽 두께가 보이게 한다(사용자 ③).
@@ -6,8 +6,8 @@
* (2026-08-22 사용자 확정 규칙):
* · 기슭막이 사이 성토사면은 **정확히 1:1.2** · 자동 자리 = 사면 1.0 m 뒤 벽 상단(원지반 무관 · 91-3).
* · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**.
* · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:n · 표준경사)의
* 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다).
* · 다음 단 성토선 시작점 = 윗단 벽 **발끝**(하단 수평선 ∩ 전면 경사선 · 원지반 무관 · 371-1).
* 마지막 벽 → 끝 구간만 하단 수평선 +0.5m ∩ 전면(관 하단 꼭짓점과 같은 자리) · 뜬 벽은 발끝.
* ========================================================================== */
import {
@@ -19,6 +19,7 @@ import {
materialFromForm,
revetFormOf,
REVET_EMBED_DEPTH_M,
REVET_FILL_TOP_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
WALL_MIN_PURE_HEIGHT_M,
@@ -208,11 +209,25 @@ export function inletGroundConnector(
return null;
}
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */
/** 벽 중심(하단 중점)에서 성토 위 이음까지의 수평거리 — 높이·기울기에 따라 커진다(371-1). */
function jointRunOf(height: number, lean: number): number {
return 0.25 * REVET_THICKNESS_M + (lean / 2) * height;
return 0.75 * REVET_THICKNESS_M - REVET_FILL_TOP_M + (lean / 2) * height;
}
/** 벽 아래 성토 시작점(371-1) — 아래 단이 있으면(`tiered`) 늘 발끝(전면 ∩ 하단 수평선 ·
* 원지반 무관) · 끝 구간은 발끝이 원지반 위에 뜬 벽만 발끝 · 땅에 선 · 묻힌 벽은 `grounded`
* (기준선 ∩ 전면 · 묻힘 판정 그대로). */
export const floatFillStart = (
wall: WallLayout,
grounded: OffsetPoint,
groundAt: (offset: number) => number,
tiered: boolean,
): OffsetPoint =>
tiered ||
(wall.floatGapM > 1e-6 && groundAt(wall.bottomFront.offset) < wall.bottomFront.elevation - 0.01)
? wall.bottomFront
: grounded;
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
function trailingFill(
src: OffsetPoint,
@@ -359,22 +374,28 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
? { offset: src.offset + outward * shelfRun, elevation: src.elevation }
: null;
const slopeFrom = shelf ?? src;
const run = Math.abs(wall.topJoint.offset - slopeFrom.offset);
const rise = slopeFrom.elevation - wall.topJoint.elevation;
const run = Math.abs(wall.fillTop.offset - slopeFrom.offset);
const rise = slopeFrom.elevation - wall.fillTop.elevation;
segments.push({
points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint],
points: shelf ? [src, shelf, wall.fillTop] : [src, wall.fillTop],
lengthM: Math.hypot(run, rise),
ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN,
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
});
// 다음 단 시작점 = 이 벽 바닥 +0.5m(= 기준선) 수평선과 전면
// 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준).
// 다음 단 시작점 = 벽 발끝(전면 경사 ∩ 하단 수평선 · 원지반 무관 · 371-1). 마지막 벽 →
// 끝 구간은 바닥 +0.5m(= 기준선) ∩ 전면(2026-08-22 사용자) · 뜬 벽만 발끝.
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
src = {
offset:
wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
};
src = floatFillStart(
wall,
{
offset:
wall.points[2].offset +
outward * wall.lean * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
},
groundAt,
i < input.adjusts.length - 1,
);
prevBottom = wall.bottomBack.elevation;
}
@@ -57,7 +57,7 @@ import {
BASIN_INNER_WIDTH_M,
inletChoiceAvailability,
} from "./logic/B06_Section_UI_Cross_Culvert_Basin";
import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
import { floatFillStart, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
import { withBackCut } from "./logic/B06_Section_UI_Cross_Structure_Cut";
import {
buildCulvertTiers,
@@ -377,17 +377,17 @@ export function computeCulvertLayout(
lean,
roadElevation: side.elevation_m,
});
const topJoint = wall.topJoint.offset;
const topElevation = wall.topJoint.elevation;
const fillTop = wall.fillTop.offset;
const topElevation = wall.fillTop.elevation;
const topFront = wall.points[2].offset;
const bottomFront = wall.bottomFront;
walls.push(wall);
// 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②).
// 성토 사면선은 **성토 위 이음**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ② · 371-1).
if (outward > 0) {
trimMax = topJoint;
trimMax = fillTop;
trimMaxElevation = topElevation;
} else {
trimMin = topJoint;
trimMin = fillTop;
trimMinElevation = topElevation;
}
// 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③).
@@ -507,11 +507,24 @@ export function computeCulvertLayout(
// 다단 흙막이(유출측 · 집수정 계류측) — `_Culvert_Tiers` 가 푼다.
const { extras, basinExtras, extrasForArea, basinExtrasForArea } = buildCulvertTiers({
section,
outletStart: outletWall ? pipeCorners.outlet.bottom : null,
outletStart: outletWall
? floatFillStart(
outletWall,
pipeCorners.outlet.bottom,
groundAt,
(revetShift?.extras ?? []).length > 0,
)
: null,
outletBottom: outletWall?.bottomBack.elevation ?? 0,
basinStart: culvert.hidden_pipe
? inletWall
? (guardFloorStart(section, body, inletWall) ?? pipeCorners.inlet.bottom)
? (guardFloorStart(section, body, inletWall) ??
floatFillStart(
inletWall,
pipeCorners.inlet.bottom,
groundAt,
(revetShift?.basinExtras ?? []).length > 0,
))
: null
: basinFillStart,
basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom,
@@ -9,7 +9,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
GUARD_CUT_BERM_M,
REVET_EMBED_DEPTH_M,
REVET_TRAP_TOP_M,
REVET_FILL_TOP_M,
} from "./logic/B06_Section_UI_Cross_Culvert_Const";
import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra";
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
@@ -26,7 +26,7 @@ export const slopeZeroAnchor = (
outward: number,
wallHeight: number,
lean: number,
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M);
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_FILL_TOP_M);
/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의
* 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */
@@ -106,7 +106,7 @@ export function buildCulvertTiers(input: {
};
const extras = buildExtrasAt(input.outletStart, outletInput);
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
// 유출측(`extras`)과 같은 시작점(`floatFillStart` · 371-1)·같은 `bextra` 키(2026-08-29 사용자).
const basinInput = {
cutBerm: cutBerm(culvert?.body ? (culvert.inlet.revet_berm_m ?? GUARD_CUT_BERM_M) : 0),
startBottomElevation: input.basinBottom,
@@ -123,8 +123,10 @@ export interface WallLayout {
points: OffsetPoint[];
/** 상단 배면 꼭짓점 — 성토 사면선과의 접점(설계선 트림 경계). */
topBack: OffsetPoint;
/** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 성토 사면선이 여기서 끝난다. */
/** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 안 무늬 이음선 자리. */
topJoint: OffsetPoint;
/** 성토 위 이음 — 윗면 사면 쪽 끝에서 0.1 m 안(371-1). 성토 사면선이 여기서 끝난다. */
fillTop: OffsetPoint;
/** 하단선 양 끝 — **수평 기초**(2026-08-22 사용자 확정): 발자국 최저 지반(또는 관
* invert) − 0.5m 표고의 수평선. 전면 발끝은 1:0.3 경사선을 하단까지 연장한 자리다. */
bottomBack: OffsetPoint;
@@ -22,7 +22,7 @@ import {
FILL_SLOPE_RATIO_MIN,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_THICKNESS_M,
REVET_FILL_TOP_M,
WALL_MIN_PURE_HEIGHT_M,
} from "./logic/B06_Section_UI_Cross_Culvert_Const";
import { outlineLeanOf } from "./logic/B06_Section_UI_Cross_Lean";
@@ -208,7 +208,7 @@ export function computeRevetmentLayout(
});
const roadSlope = roadSlopePerOutward(design, side, outward);
const autoOffset =
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_FILL_TOP_M);
// x 원점 = 성토사면(1:1.2) 경사길이 4m 지점 — 저장된 x 의 절대 자리 그대로(79b).
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
@@ -285,7 +285,7 @@ export function computeRevetmentLayout(
const walls: WallLayout[] = [tier1, ...extras.walls];
// 물빼기 파이프 단면 = 표 형상칸.weep(322-1 · 옹벽)
if (catalogRow(spec.type_id)?.shapeOpts.weep) for (const wall of walls) wall.retaining = true;
const jt = tier1.topJoint;
const jt = tier1.fillTop;
// 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고,
// 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정).
// D = 노견에서 벽 상단까지 바깥 방향 거리, R0 = 노견과 벽 상단의 낙차
+33 -12
View File
@@ -2,8 +2,9 @@
* B06_Section_UI_Cross_Underdrain.ts
* 맹암거 횡단 형상(288-1 · 요구 183-3).
*
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
* · 노면 = 설계 노면 — 서버 · TS 짝이 표준 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
* · 옵셋선(하단 경계) = 높은 쪽 노면 끝에서 암거 높이 아래 · 암거 경사로 낮은 쪽으로 내림(371-3) ·
* 경사가 비면 노면과 같은 각도.
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
* · 양측절토(낮은 쪽 늘인 노면 `tilt_reach`) = 늘인 노면이 원지반과 만나는 점에서 수직으로 끊음(356-1).
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 ·
@@ -42,16 +43,18 @@ export function drawCrossedStones(group: SVGElement, frame: BandFrame, heightM:
type Edge = { offset_m: number; elevation_m: number };
/** 하단 경계(옵셋선) — 표고 = `z0 + grade · (o − o0)`. */
type Bottom = { o0: number; z0: number; grade: number };
/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */
function sidePath(
edge: Edge,
outward: 1 | -1,
line: readonly Edge[],
roadGrade: number,
heightM: number,
bottom: Bottom,
): OffsetPoint[] {
const start = { offset: edge.offset_m, elevation: edge.elevation_m };
const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM;
const offZ = (o: number) => bottom.z0 + bottom.grade * (o - bottom.o0);
const outside = line
.filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6)
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
@@ -60,7 +63,8 @@ function sidePath(
if (first.elevation_m >= edge.elevation_m) {
// 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남
const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m);
const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade);
const g = bottom.grade;
const d = Math.abs(k - g) < 1e-9 ? 0 : (offZ(edge.offset_m) - edge.elevation_m) / (k - g);
const offset = edge.offset_m + d;
return [start, { offset, elevation: offZ(offset) }];
}
@@ -83,25 +87,38 @@ function sidePath(
return path;
}
/** 하단 경계 — 높은 쪽(상류) 노면 끝 − 높이에서 암거 경사로 낮은 쪽으로 내림(371-3) · 경사가 비면 노면과 평행. */
function bottomOf(left: Edge, right: Edge, heightM: number, slopePct: number | null): Bottom {
const roadGrade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
const high = left.elevation_m >= right.elevation_m ? left : right;
const low = high === left ? right : left;
const grade =
slopePct && slopePct > 0
? (Math.sign(low.offset_m - high.offset_m) * -slopePct) / 100
: roadGrade;
return { o0: high.offset_m, z0: high.elevation_m - heightM, grade };
}
/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */
export function underdrainOutline(
design: CrossDesign,
heightM: number | null,
slopePct: number | null = null,
): OffsetPoint[] | null {
const edges = design.road_edges;
if (!edges || !(heightM && heightM > 0)) return null;
const { left, right } = edges;
const span = left.offset_m - right.offset_m;
if (!(Math.abs(span) > 1e-9)) return null;
const grade = (left.elevation_m - right.elevation_m) / span;
const bottom = bottomOf(left, right, heightM, slopePct);
// 양측절토(356-1) — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음 · 너머 점 거름 → 꼬리가 수직으로 닫음
const reach = design.tilt_reach;
const outward = reach?.side === "left" ? 1 : -1;
const line = (design.design_line ?? []).filter(
(p) => !reach || (p.offset_m - reach.offset_m) * outward <= 1e-6,
);
const leftPath = sidePath(left, 1, line, grade, heightM);
const rightPath = sidePath(right, -1, line, grade, heightM);
const leftPath = sidePath(left, 1, line, bottom);
const rightPath = sidePath(right, -1, line, bottom);
return [
{ offset: left.offset_m, elevation: left.elevation_m },
...rightPath,
@@ -117,7 +134,7 @@ export function appendUnderdrainOverlay(
x: (offset: number) => number,
y: (elevation: number) => number,
): SVGGElement | null {
const outline = spec ? underdrainOutline(design, spec.height_m) : null;
const outline = spec ? underdrainOutline(design, spec.height_m, spec.slope_pct) : null;
if (!spec || !outline) return null;
const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)];
const points = outline.map((p) => px(p).join(",")).join(" ");
@@ -136,15 +153,19 @@ export function appendUnderdrainOverlay(
clip.append(clipShape);
const stones = document.createElementNS(SVG_NS, "g");
stones.setAttribute("clip-path", `url(#${clipId})`);
const height = spec.height_m!;
const offsets = outline.map((p) => p.offset);
const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)];
const { left, right } = design.road_edges;
const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m);
const bottom = bottomOf(left, right, spec.height_m!, spec.slope_pct);
const bottomZ = (o: number) => bottom.z0 + bottom.grade * (o - bottom.o0);
// 돌 띠 = 노면 ~ 하단 경계(두께가 자리마다 다름 · 켜 수는 두꺼운 쪽)
const height = Math.max(roadZ(lo) - bottomZ(lo), roadZ(hi) - bottomZ(hi), spec.height_m!);
const at = (u: number, v: number): OffsetPoint => {
const offset = lo + (hi - lo) * u;
return { offset, elevation: roadZ(offset) - height * (v + 0.5) };
const top = roadZ(offset);
return { offset, elevation: top + (bottomZ(offset) - top) * (v + 0.5) };
};
const [ax, ay] = px(at(0, 0));
const [bx, by] = px(at(1, 0));
+2
View File
@@ -14,6 +14,7 @@ import {
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
WALL_FILL_TOP_INSET_M,
WALL_MIN_PURE_HEIGHT_M,
} from "./logic/B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./logic/B06_Section_UI_Cross_Culvert_Const";
@@ -104,6 +105,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
fillTop: { offset: topFront - outward * WALL_FILL_TOP_INSET_M, elevation: topElevation },
bottomBack,
bottomFront,
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
@@ -63,8 +63,9 @@ export function facilityRanges(
const depth = num(spec.overflow_depth_m) ?? 0;
if (spec.type === "ford" && depth > 0) drops.push([at, depth]);
}
// 노면 = 표준 경사 · 암거 경사는 맹암거 하단 경계만(371-3 · 파이썬 `tilt_ranges` 짝)
for (const drain of underdrainSpecsFrom(structures))
tilt.push([drain.chainage_m, drain.chainage_m, drain.slope_pct]);
tilt.push([drain.chainage_m, drain.chainage_m, null]);
return { paved, tilt, drops };
}
@@ -31,6 +31,12 @@ export const REVET_LEAN_RATIO = 0.3;
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
/** 성토 위 이음 = 벽 윗면 사면 쪽 끝에서 이만큼 안(m · 371-1). */
export const WALL_FILL_TOP_INSET_M = 0.1;
/** 배면에서 성토 위 이음까지 수평거리(m) — 윗면 1.5t − 0.1(371-1). */
export const REVET_FILL_TOP_M = REVET_THICKNESS_M * 1.5 - WALL_FILL_TOP_INSET_M;
/**
* 집수정 상부 여유(m) — 관 상단에서 집수정 벽 상단까지의 간격. 관 벽(유입·유출)은
* 여유 없이 벽 윗면 = 관 윗면(139-1 · `revetTargetHeight`).
@@ -11,7 +11,7 @@ import {
FILL_SLOPE_RATIO_MIN,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
REVET_TRAP_TOP_M,
REVET_FILL_TOP_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type {
EndFace,
@@ -144,10 +144,10 @@ export interface FillSlopeSegment {
* 접점이 다시 어긋난다.
*/
export function fillSlopeOf(
wall: { role: "inlet" | "outlet" | "extra"; topJoint: OffsetPoint; outward: number },
wall: { role: "inlet" | "outlet" | "extra"; fillTop: OffsetPoint; outward: number },
edge: { offset_m: number; elevation_m: number },
): FillSlopeSegment {
const to = wall.topJoint;
const to = wall.fillTop;
const rise = edge.elevation_m - to.elevation;
const edgePoint: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m };
if (!(rise > 1e-6)) {
@@ -244,7 +244,7 @@ export function minShoulderWallOffset(
const offset = edge.offset_m + outward * ((span * i) / steps);
const rise = edge.elevation_m - (baseAt(offset) + height);
if (rise < FILL_MIN_RISE_M) continue;
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_FILL_TOP_M);
if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue;
if ((offset - toeOffset) * outward > 1e-6) {
const toeRise = edge.elevation_m - (baseAt(toeOffset) + height);
@@ -12,7 +12,7 @@ import { catalogRow } from "./common_util_structure_catalog";
const EDGE_TOLERANCE_M = 0.02;
/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 없으면 표준 횡단경사). */
/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 = 형상 하단 경계 · 없으면 노면과 평행 · 371-3). */
export interface UnderdrainSpec {
structure_id: string | null;
chainage_m: number;
@@ -131,7 +131,8 @@ 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")
// 시작점 바로 앞 표본까지(−1 m) — 보간이 시작점을 옛 지반에 두지 않게(371-1 벽 자리 이동)
if (t >= -1 && typeof p.elevation_m === "number")
p.elevation_m = start.elevation + rise + slope * t;
}
return study(
@@ -141,4 +142,5 @@ function reshaped(rise, slope) {
}
out.pipeDown = reshaped(0.3, -0.15); // 3도보다 가파르게 내림 → 3도 선이 만남
out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 3도 선이 못 만남 → 복합경사
out.pipeOpen = reshaped(-0.3, -0.5); // 끝 바깥이 지반 아래로 열림 → 성토부선(371-1)
process.stdout.write(JSON.stringify(out));
@@ -107,6 +107,8 @@ process.stdout.write(
attached,
sections: sections.map((s) => s.underdrain ?? null),
outline: draw.underdrainOutline(design, 1),
// 371-3 암거 경사 3 % — 하단 = 높은 쪽(좌) 노면 끝 − 1 m 에서 3 % 로 낮은 쪽(우)으로 내림
sloped: draw.underdrainOutline(design, 1, 3),
bothCut: draw.underdrainOutline(bothCut, 1),
noHeight: draw.underdrainOutline(design, null),
drew: !!group,
@@ -0,0 +1,120 @@
/* 371-2 관 글 기울기 헬퍼 — 짝 = `test_371_2_pipe_slope_text.py`.
* B07 픽스처 배관 측점에 `appendCulvertOverlay` 를 가짜 SVG 로 그려 관 글 · 툴팁을 읽음.
*/
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 noop = new Proxy({}, { get: () => () => "" });
/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */
class El {
constructor(tag) {
this.tag = tag;
this.attrs = {};
this.children = [];
this.parentNode = null;
this.ownerSVGElement = null;
this.textContent = "";
}
setAttribute(k, v) {
this.attrs[k] = String(v);
}
getAttribute(k) {
return this.attrs[k] ?? null;
}
appendChild(c) {
c.parentNode = this;
c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement;
this.children.push(c);
return c;
}
append(...cs) {
cs.forEach((c) => this.appendChild(c));
}
addEventListener() {}
all() {
return this.children.flatMap((c) => [c, ...c.all()]);
}
querySelectorAll(sel) {
return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1)));
}
querySelector(sel) {
return this.querySelectorAll(sel)[0] ?? null;
}
}
globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const resolve = (base) =>
fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`;
const req = (r) => {
if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop;
if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), r)));
};
new Function("exports", "module", "require", js)(box.exports, box, req);
cache.set(full, box.exports);
return box.exports;
}
const { computeCulvertLayout } = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Geom.ts"),
);
const { appendCulvertOverlay } = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert.ts"),
);
const sections = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
).cross_sections;
const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
/** 노폭 라벨이 있는 B06 카드 꼴 svg 에 그려 관 글 · 관 툴팁을 돌려줌. */
function draw(layout, hidePipe) {
const svg = new El("svg");
const road = new El("text");
road.setAttribute("class", "b06-chart__carriageway-label");
road.setAttribute("x", 200);
road.setAttribute("y", 180);
svg.appendChild(road);
const plot = new El("g");
svg.appendChild(plot);
appendCulvertOverlay(
plot,
layout,
(o) => o,
(z) => z,
undefined,
hidePipe,
false,
);
const titles = svg
.all()
.filter((e) => e.tag === "title")
.map((e) => e.textContent);
return {
infos: svg.querySelectorAll(".b06-chart__pipe-info").map((e) => e.textContent),
pipeTips: titles.filter((t) => t.startsWith("관매설")),
};
}
const layout = computeCulvertLayout(section, section.samples);
const reverse = { ...layout, pipe: { ...layout.pipe, slopePct: -0.43 } };
process.stdout.write(
JSON.stringify({
slopePct: layout.pipe.slopePct,
lengthM: layout.pipe.lengthM,
pipe: draw(layout, false),
reverse: draw(reverse, false),
hidden: draw(layout, true),
}),
);
+5 -2
View File
@@ -54,11 +54,14 @@ def test_3도_선이_원지반을_못_만나면_복합경사(out: dict) -> None:
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"]
# 371-1 — 벽 자리 0.35 m 도로 쪽 · 오르는 선 0.3 m 라 면적 차가 백엔드 설계선 끝에 묻힘 → 폐회로에 듦만 봄
assert study["inTrim"] is True
def test_성토_끝은_그대로(out: dict) -> None:
assert out["pipeBase"] == ["fill", "fill"] # 묻히지 않은 기본 = 성토부선 그대로
# 371-1 — 다단 사이 시작 = 윗단 발끝 · 단이 0.5 m 내려가 기본 자료 끝 벽이 0.3 m 묻힘 → 3도 선
assert out["pipeBase"] == ["fill", "cut"]
assert out["pipeOpen"]["cut"] == [] # 묻히지 않은 끝 = 성토부선 그대로
def test_유출_절토선은_그리고_면적에_넣음() -> None:
+4 -3
View File
@@ -40,10 +40,11 @@ def out(tmp_path_factory) -> dict:
def test_안_넓힌_측점은_종전_그대로(out: dict) -> None:
"""225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8)."""
"""225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8).
371-1 — 기본 자리 = 성토 위 이음(끝에서 0.1 m) 기준 · 벽 0.35 m 도로 쪽(5.772 → 5.422)."""
base = out["base"]
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8)
assert base["cut"] == pytest.approx(5.772)
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.4)
assert base["cut"] == pytest.approx(5.422)
@pytest.mark.parametrize("name, extend", [("shifted", 1.0), ("shifted2", 1.3)])
+4 -2
View File
@@ -61,10 +61,12 @@ def test_묻힌_유출_벽_설계선이_안_접힘(out: dict, case: str) -> None
def test_저장값_사본_면적(out: dict) -> None:
"""접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만."""
assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3)
# 371-1 — 성토 위 이음 = 윗면 끝에서 0.1 m · 기본 자리 새 이음 기준(34.0391 → 34.2539)
assert out["stored"]["cut"] == pytest.approx(34.2539, abs=1e-3)
assert out["stored"]["fill"] == 0
# 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929)
assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3)
# 371-1 — 기본 자리 = 새 이음 기준(16.0929 → 16.2613)
assert out["auto"]["cut"] == pytest.approx(16.2613, abs=1e-3)
def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None:
+2 -1
View File
@@ -72,7 +72,8 @@ def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None:
def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None:
"""유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음."""
lengths = [case["lengthM"] for case in out["moved"]]
assert lengths == [7.9, 8.0, 8.1, 8.2]
# 371-1 — 기본 자리 = 성토 위 이음 기준 · 벽 0.35 m 도로 쪽(7.9 → 7.5)
assert lengths == [7.5, 7.6, 7.7, 7.8]
for case in out["moved"]:
assert case["lengthM"] == round(case["cut"], 1)
+26 -8
View File
@@ -1,10 +1,12 @@
"""288 맹암거 횡단 형상(요구 183-3 · 9).
못박는 것
- 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 암거 경사로 지대 낮은 쪽 한 경사(물넘이 286 · 287 길 —
서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted`) · 제원 얹기 서버 `attach_underdrains` ↔ TS
`attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과
만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림.
- 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 표준 경사로 지대 낮은 쪽 한 경사
(물넘이 286 · 287 길 — 서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted` · 암거 경사는
노면에 안 감 371-3) · 제원 얹기 서버 `attach_underdrains` ↔ TS `attachUnderdrainSpecs`
같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과 만남 ·
절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림.
- 371-3 암거 경사 = 형상 하단 경계 — 높은 쪽 노면 끝 − 높이에서 암거 경사로 낮은 쪽으로 내림.
- 288-2 맹암거 형상 누름 = 그 구조물 id 고름(구조물 목록 창구 · 알약 · 폼 · 측점선 따라옴) · 물넘이와 같은
판정면(보라 손잡이 아래 · 끌기 = 고름 아님 · 287-5) · 노면공 포장 범위 고름과 따로.
@@ -86,8 +88,9 @@ def ts(tmp_path_factory) -> dict:
def test_맹암거_자리_기울기_서버(project):
ranges = tilt.tilt_ranges(project)
assert ranges == [(40.0, 40.0, 4.0), (80.0, 80.0, None)]
assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": 4.0}
# 371-3 — 암거 경사(4 %)는 노면에 안 감 · 노면 = 표준 경사
assert ranges == [(40.0, 40.0, None), (80.0, 80.0, None)]
assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": None}
assert tilt.tilt_args(80.01, ranges) == {"tilted": True, "tilt_pct": None}
assert tilt.tilt_args(60.0, ranges) == {"tilted": False, "tilt_pct": None}
@@ -110,8 +113,9 @@ def test_맹암거_측점_설계_측구_없음_낮은_쪽_기울기():
for o in range(-24, 25)
]
kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"}
got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=4.0, **kwargs)
assert got["ditch_enabled"] is False and got["cross_slope_pct"] == 4.0
got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=None, **kwargs)
standard = compute_cross_design(samples, 100.4, **kwargs)["cross_slope_pct"]
assert got["ditch_enabled"] is False and got["cross_slope_pct"] == standard
left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right"))
assert left > right # 지반이 좌로 오름 = 우가 낮음
@@ -131,6 +135,20 @@ def test_형상_옵셋선_성토_절토와_만남(ts):
assert ts["drew"] is True and ts["stones"] > 0
def test_하단_경계는_암거_경사(ts):
"""371-3 — 노면 5 % · 암거 3 % · 높이 1 m: 하단 = 좌(높은 쪽) 끝 99.15 에서 3 % 로 우로 내림."""
outline = [(p["offset"], p["elevation"]) for p in ts["sloped"]]
expected = [
(3, 100.15),
(-3, 99.85),
(-4.391304, 98.928261), # 우 성토 사면 ∩ 하단(99.15 − 0.03 × 7.391)
(1.872180, 99.116165), # 좌 절토 사면을 늘인 선 ∩ 하단
]
assert len(outline) == len(expected)
for got, want in zip(outline, expected):
assert got == pytest.approx(want, abs=1e-5)
def test_양측절토_늘인_노면_원지반_교점에서_끊음(ts):
"""356-1 — 낮은 쪽 노면 끝도 절토(tilt_reach) = reach 에서 수직으로 끊음 · 너머 원지반 따라가지 않음."""
outline = [(p["offset"], p["elevation"]) for p in ts["bothCut"]]
@@ -221,6 +221,9 @@ def test_포장_기울임_하강_판정_서버와_같음(r, server):
hits["tilted"] += py["tilted"]
hits["drop"] += py["drop"] > 0
assert all(hits.values()), hits # 셋 다 실제로 걸림
# 371-3 — 맹암거 자리 = 기울임 · 경사 비움(노면 표준 · 암거 경사는 형상 하단 경계)
drain = [py for ts_at, py in zip(r["ts"]["at"], r["rules"]) if ts_at["chainage"] == 160.0]
assert drain == [{"paved": False, "tilted": True, "tilt_pct": None, "drop": 0.0}]
KEYS = (
@@ -0,0 +1,49 @@
"""PLAN 371-2 — B06 횡단 카드 관 글 L 뒤 기울기(요구 240-16).
규칙: 「관매설 {관종}Φ…m/m, L=8 m (7.6), S=2.5%」(소수 한 자리 · 역경사 0.0) ·
툴팁 「매설 경사」 지움 · 세월교 · 박스 흙막이 세트(hidden_pipe = 관 숨김)는 관 글 없음 ·
B07 덧그림(노폭 라벨 없음)도 없음(225-1).
짝 = `helper_371_2_pipe_slope.cjs`.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_371_2_pipe_slope.cjs"
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_관_글_L_뒤에_기울기_소수_한_자리(out: dict) -> None:
slope = f"{out['slopePct']:.1f}"
assert out["pipe"]["infos"] == [f"관매설 파형강관 Φ1000m/m, L=8 m (7.5), S={slope}%"]
def test_역경사는_0_0(out: dict) -> None:
assert out["reverse"]["infos"][0].endswith(", S=0.0%")
def test_툴팁에_매설_경사_없음(out: dict) -> None:
tips = out["pipe"]["pipeTips"]
assert tips and all("매설 경사" not in tip for tip in tips)
def test_관_숨김_세트는_관_글_없음(out: dict) -> None:
assert out["hidden"] == {"infos": [], "pipeTips": []}
@@ -36,6 +36,9 @@ writeFileSync(process.argv[2], JSON.stringify({
under: buildRevetWallGeometry({
anchor, outward: 1, height: 2, material: "dry", role: "outlet", roadElevation: 103,
}).topJoint.elevation,
fill: (({ fillTop, topJoint, points }) => [fillTop.offset, fillTop.elevation, topJoint.offset, points[2].offset, points[2].elevation])(
buildRevetWallGeometry({ anchor, outward: 1, height: 2.5, material: "dry", role: "outlet" }),
),
dry: revetWallSpec({ revet_form: "돌쌓기(메)" }, { h: 3.0 }, true, 0.3).pureHeight,
concrete: revetWallSpec({ revet_form: "콘크리트" }, { h: 7.5 }, true, 0.3).pureHeight,
}));
@@ -84,6 +87,11 @@ def test_벽_외곽_한_벌_높이_입력_그대로_노면_멈춤(tmp_path: Path
assert got["under"] == pytest.approx(102.0), "노면 아래 벽이 잘림"
assert got["dry"] == pytest.approx(3.0), "돌쌓기(메) 3.0 이 잘림"
assert got["concrete"] == pytest.approx(7.5), "콘크리트 7.5 가 잘림"
# 371-1 — 성토 위 이음 = 윗면 사면 쪽 끝에서 0.1 m 안 · 무늬 이음선(topJoint)은 그대로
fill_offset, fill_elevation, joint_offset, front_offset, front_elevation = got["fill"]
assert fill_offset == pytest.approx(front_offset - 0.1), "성토 위 이음이 끝에서 0.1 m 아님"
assert fill_elevation == pytest.approx(front_elevation), "성토 위 이음이 윗면 표고 아님"
assert joint_offset == pytest.approx(front_offset - 0.45), "무늬 이음선이 옮겨짐"
def test_네_벽이_공용_함수로_서고_형태_한도가_없다() -> None: