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

This commit is contained in:
2026-10-12 21:58:59 +09:00
28 changed files with 331 additions and 63 deletions
@@ -88,28 +88,31 @@ export function chuteAreaM2(widthM: number, lengthM: number): number {
}
/**
* 물넘이 포장 도수로/돌붙임 길이 ↔ 면적(287-1) — 길이를 넣으면 면적 = 폭 × 길이(폭 = 띠가 도로 방향으로
* 걸치는 월류 폭 · 비면 물넘이 기본 폭) · 길이를 비우면 면적 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만
* 물넘이 포장 · 세월교 도수로/돌붙임 길이 ↔ 면적(287-1 · 294-4) — 길이를 넣으면 면적 = 폭 × 길이(폭 = 띠가
* 도로 방향으로 걸치는 월류 폭 · 비면 물넘이 기본 폭 · 배관 · 박스는 폭 정의 없어 자동 안 함) · 길이를 비우면 면적 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만
* 다시 셈. 길이는 그림(사면 따라 그 길이) · 면적은 수량(B08 돌붙임)이 씀.
*/
export function syncChuteArea(
form: Pick<SpecForm, "state" | "setValue">,
c: SpecContainer,
item: SpecItem,
/** 도수로 상자 · 월류 폭 상자 — 물넘이 `chute` · `slope` · 세월교 `chute_add` · `ford`(294-4). */
chuteBox = "chute",
widthBox = "slope",
): void {
const byLength = c.id === "chute" && item.id === "length_m";
if (!byLength && !(c.id === "slope" && item.id === "width")) return;
const chute = form.state().chute?.[0];
const byLength = c.id === chuteBox && item.id === "length_m";
if (!byLength && !(c.id === widthBox && item.id === "width")) return;
const chute = form.state()[chuteBox]?.[0];
if (!chute) return;
const length = Number(chute.length_m ?? "");
if (!(chute.length_m ?? "").trim() || !(length > 0)) {
if (byLength)
form.setValue("chute", "area", CHUTE_ITEMS.find((i) => i.id === "area")?.default ?? "");
form.setValue(chuteBox, "area", CHUTE_ITEMS.find((i) => i.id === "area")?.default ?? "");
return;
}
const width = Number(form.state().slope?.[0]?.width ?? "");
const width = Number(form.state()[widthBox]?.[0]?.width ?? "");
form.setValue(
"chute",
chuteBox,
"area",
String(chuteAreaM2(width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, length)),
);
@@ -35,8 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
prefix: "ford_chute",
link: "gfordpavechute",
max: 1,
// 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 90-2) + 맨 아래 길이(그림 전용 · 286-3)
items: [...CHUTE_ITEMS, { id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 }],
// 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4)
items: CHUTE_ITEMS,
},
],
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
@@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
{ id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" },
// 길이(294-4 · `{접두}_{n}_length_m`) — 띠를 사면 따라 그릴 길이 · 그림 전용(수량 · 내역 · 일위대가에 안 씀)
{ id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 },
];
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
@@ -487,6 +487,8 @@ export function buildCorridorStructures(
chuteBands(
fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments),
section.ford?.chute,
undefined,
fordLayout.ground,
).forEach((band) => pushSwept("chute", band, half, half, null, "chute"));
// 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면**
// (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은
@@ -595,7 +597,12 @@ export function buildCorridorStructures(
);
}
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3).
chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) =>
chuteBands(
layout.outletFill.segments,
section.culvert?.chute,
isChuteSegment,
layout.ground,
).forEach((band) =>
pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"),
);
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
@@ -182,6 +182,8 @@ export function boxSolids(
const chutes: CorridorStructure[] = chuteBands(
layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments),
box.chute,
undefined,
layout.ground,
).map((band) => ({
chainage_m: chainage,
kind: "chute",
@@ -10,8 +10,12 @@
import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { chuteBandOutline } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
import { fordChuteLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import {
chuteBandOutline,
chuteRun,
groundLine,
} from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
/** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */
interface FillSegment {
@@ -25,10 +29,11 @@ export function chuteBands(
segments: ReadonlyArray<FillSegment>,
chute: ChuteSpec | null | undefined,
keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut",
/** 원지반 — 길이가 사면보다 길면 이어 그림(294-4 · 횡단 띠와 같은 `chuteRun`). */
ground?: readonly OffsetPoint[],
): OffsetPoint[][] {
if (!chute) return [];
return segments.flatMap((segment) => {
if (!keep(segment)) return [];
return chuteRun(segments.filter(keep), chute.length_m, ground).flatMap((segment) => {
const band = chuteBandOutline(segment.points, chute.thickness_m);
return band ? [band.outline] : [];
});
@@ -51,6 +56,12 @@ export function fordPavementChute(
);
if (owner !== section) return null;
}
const bands = chuteBands([{ points: fordChuteLine(section.design, pave.chute) }], pave.chute);
const ground = groundLine(section.samples ?? []);
const bands = chuteBands(
[{ points: outletFillLine(section.design, ground) }],
pave.chute,
undefined,
ground,
);
return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null;
}
@@ -16,7 +16,10 @@ import type {
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
return chute ? ["ch", chute.structure, chute.thickness_m] : [];
if (!chute) return [];
// 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로
const base = ["ch", chute.structure, chute.thickness_m];
return chute.length_m ? [...base, chute.length_m] : base;
}
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
@@ -384,7 +384,10 @@ export function createFacilityOptionsForm(
);
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
const fordForm: SpecForm = createSpecForm(specLinks, {
onChange: fordFlowBox.onChange,
onChange: (c, item) => (
syncChuteArea(fordForm, c, item, "chute_add", "ford"),
fordFlowBox.onChange(c, item)
),
fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)],
count: guardSoilCount(
() => chainage,
+1
View File
@@ -166,6 +166,7 @@ export function appendBoxOverlay(
box.chute,
x,
toDisplayY,
layout.ground,
);
// ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠.
for (const side of layout.sides) {
@@ -16,6 +16,7 @@
* 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다.
* ========================================================================== */
import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import type {
BasinLayout,
@@ -74,6 +75,8 @@ export interface BoxSideLayout {
export interface BoxLayout {
box: BoxSet;
/** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */
ground?: OffsetPoint[];
sides: BoxSideLayout[];
/** 상판 폴리곤(좌·우 표고가 다르면 기울어진다). */
topSlab: OffsetPoint[];
@@ -222,6 +225,7 @@ export function computeBoxLayout(
const apronLengthOf = (side: BoxSideLayout): number =>
side.apron.length ? Math.abs(side.apron[1].offset - side.apron[0].offset) : 0;
return {
ground: groundLine(groundSamples),
box,
sides: [left, right],
topSlab: quad(0, barrelTopDrop),
@@ -125,6 +125,7 @@ export function appendCulvertOverlay(
culvert.chute,
x,
toDisplayY,
layout.ground,
);
for (const segment of layout.outletFill.segments) {
// 묻힌 유출 끝 = 3도 수평 절토선(보라 · 면적에도 듦 · 139-4) — 3도 선이 원지반을
@@ -6,6 +6,7 @@
* (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`.
* ========================================================================== */
import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
revetWallSpec,
@@ -575,6 +576,7 @@ export function computeCulvertLayout(
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
return {
ground: groundLine(groundSamples),
inletOptions,
culvert,
pipe: { inlet: pipeStart, outlet, lengthM, slopePct },
@@ -201,6 +201,8 @@ export interface OutletFillSegment {
export interface CulvertLayout {
culvert: CulvertSet;
/** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */
ground?: OffsetPoint[];
pipe: { inlet: OffsetPoint; outlet: OffsetPoint; lengthM: number; slopePct: number };
/** 관 축·법선 단위벡터(그리기가 두께 방향으로 밀 때 쓴다). */
pipeAxis: { axis: OffsetPoint; normal: OffsetPoint };
+1
View File
@@ -144,6 +144,7 @@ export function appendFordOverlay(
ford.chute,
x,
toDisplayY,
layout.ground,
);
// ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계).
for (const side of layout.sides) {
@@ -19,6 +19,7 @@
* 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다.
* ========================================================================== */
import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch";
import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
import type {
@@ -108,6 +109,8 @@ export interface FordSideLayout {
export interface FordLayout {
ford: FordSet;
/** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */
ground?: OffsetPoint[];
sides: FordSideLayout[];
/** 두 측벽 사이를 잇는 바닥판. */
slabBridge: OffsetPoint[];
@@ -329,6 +332,7 @@ export function computeFordLayout(
const countText = ford.pipe_count > 1 ? ` ${ford.pipe_count}련` : "";
return {
ground: groundLine(groundSamples),
ford,
sides,
slabBridge,
@@ -13,7 +13,7 @@
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface";
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch";
/** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */
export interface FordPavementSpec {
@@ -102,6 +102,8 @@ export function appendFordPavementOverlay(
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
/** 원지반 — 도수로 띠가 원지반에 닿으면 끝 · 길이가 사면보다 길면 이어 그림(294-4). */
ground?: readonly OffsetPoint[],
): boolean {
const edges = fordEdges(design);
if (!spec || !edges) return false;
@@ -130,7 +132,7 @@ export function appendFordPavementOverlay(
),
);
if (spec.chute)
appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute) }], spec.chute, x, y);
appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute, ground) }], spec.chute, x, y);
return true;
}
@@ -143,18 +145,38 @@ export function fordEdges(design: CrossDesign): CrossDesign["road_edges"] | unde
return reach.side === "left" ? { ...edges, left: end } : { ...edges, right: end };
}
/** 원지반 꺾은선의 그 오프셋 표고(밖은 끝값). */
function groundZ(ground: readonly OffsetPoint[], offset: number): number | null {
if (!ground.length) return null;
if (offset <= ground[0].offset) return ground[0].elevation;
for (let i = 1; i < ground.length; i += 1) {
const [a, b] = [ground[i - 1], ground[i]];
if (offset > b.offset) continue;
const span = b.offset - a.offset;
return span > 1e-9
? a.elevation + ((b.elevation - a.elevation) * (offset - a.offset)) / span
: b.elevation;
}
return ground[ground.length - 1].elevation;
}
/**
* 유출측(`inflowLeft` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라
* 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림).
* 유출측(낮은 쪽) 성토사면 선(136-2 · 294-4) — 낮은 쪽 끝단(늘인 노면 끝 · 아니면 유출 쪽 노견 끝)에서
* 설계선을 바깥으로 따라 내려가는 동안만(오르면 절토라 멈춤) · 원지반(`ground`)에 닿으면 끝.
* 성토가 없으면(끝단이 이미 원지반 · 양측 절토 물넘이) 점 하나 = 띠 없음.
*/
export function outletFillLine(design: CrossDesign): OffsetPoint[] {
const edges = design.road_edges;
export function outletFillLine(
design: CrossDesign,
ground?: readonly OffsetPoint[],
): OffsetPoint[] {
const edges = fordEdges(design);
if (!edges || !design.design_line?.length) return [];
const outward = inflowLeft(design) ? -1 : 1; // +offset = 좌측 · 유입 반대
const edge =
outward > 0
? Math.max(edges.left.offset_m, edges.right.offset_m)
: Math.min(edges.left.offset_m, edges.right.offset_m);
const reach = design.tilt_reach;
const outLeft = reach ? reach.side === "left" : !inflowLeft(design); // +offset = 좌측
const outward = outLeft ? 1 : -1;
const edge = outLeft
? Math.max(edges.left.offset_m, edges.right.offset_m)
: Math.min(edges.left.offset_m, edges.right.offset_m);
const points = design.design_line
.filter((p) => (p.offset_m - edge) * outward >= -1e-6)
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
@@ -163,31 +185,25 @@ export function outletFillLine(design: CrossDesign): OffsetPoint[] {
const last = line[line.length - 1];
if (last && p.elevation_m > last.elevation + 1e-6) break;
line.push({ offset: p.offset_m, elevation: p.elevation_m });
const z = ground ? groundZ(ground, p.offset_m) : null;
if (z !== null && Math.abs(z - p.elevation_m) < 1e-3) break; // 원지반에 닿음 = 끝
}
return line;
}
/**
* 물넘이 도수로 · 돌붙임 선(286-3) — 유출 성토사면 선을 노견에서 사면 따라 `length_m` 까지 자름 ·
* 비거나 사면보다 길면 원지반까지. 횡단 띠 · 3D 띠가 같이 씀(그림 전용).
* 물넘이 도수로 · 돌붙임 선(286-3 · 294-4) — 낮은 쪽 끝단에서 성토사면 따라 · 길이 비움 = 원지반 닿으면 끝 ·
* 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어) · 양측 절토(늘인 노면 끝이 원지반) = 길이 없으면 안 그림.
* 횡단 띠 · 3D 띠가 같이 씀(그림 전용).
*/
export function fordChuteLine(design: CrossDesign, chute?: { length_m?: number }): OffsetPoint[] {
const line = outletFillLine(design);
let left = chute?.length_m ?? 0;
if (!(left > 0)) return line;
const out = line.slice(0, 1);
for (let i = 1; i < line.length && left > 1e-9; i += 1) {
const [a, b] = [line[i - 1], line[i]];
const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation);
if (step < 1e-9) continue;
const t = Math.min(left / step, 1);
out.push({
offset: a.offset + (b.offset - a.offset) * t,
elevation: a.elevation + (b.elevation - a.elevation) * t,
});
left -= step;
}
return out;
export function fordChuteLine(
design: CrossDesign,
chute?: { length_m?: number },
ground?: readonly OffsetPoint[],
): OffsetPoint[] {
return (
chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground)[0]?.points ?? []
);
}
/**
+2 -1
View File
@@ -24,7 +24,7 @@ import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick";
import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain";
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch";
import { attachChuteBandPick, groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
import {
@@ -454,6 +454,7 @@ export function createCrossSectionCard(
section.design,
x,
toDisplayY,
groundLine(section.samples ?? []),
);
// 월류 폭으로 걸친 옆 카드 = 흙막이 연동 꼴 · 주인 측점 글(294-6)
markLinkedFordPavement(plotLayer, fordFrom, section, stationInterval);
+78 -3
View File
@@ -349,6 +349,77 @@ export function chuteBandOutline(
return { top, normals, outline: [...top, ...bottom.reverse()] };
}
/** 꺾은선 앞에서부터 `lengthM` 까지(쓴 길이와 함께). */
function trimPolyline(
points: readonly OffsetPoint[],
lengthM: number,
): { points: OffsetPoint[]; used: number } {
const out = points.slice(0, 1);
let used = 0;
for (let i = 1; i < points.length && lengthM - used > 1e-9; i += 1) {
const [a, b] = [points[i - 1], points[i]];
const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation);
if (step < 1e-9) continue;
const t = Math.min((lengthM - used) / step, 1);
out.push({
offset: a.offset + (b.offset - a.offset) * t,
elevation: a.elevation + (b.elevation - a.elevation) * t,
});
used += step * t;
}
return { points: out, used };
}
/** 원지반 샘플 → 꺾은선(유효 점만 · 오프셋 차례). */
export function groundLine(
samples: ReadonlyArray<{
offset_m?: number | null;
elevation_m?: number | null;
valid?: boolean;
}>,
): OffsetPoint[] {
return samples
.filter((s) => s.valid !== false && s.offset_m != null && s.elevation_m != null)
.map((s) => ({ offset: s.offset_m as number, elevation: s.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
}
/**
* 도수로 · 돌붙임 선 길이(294-4) — 선들을 차례로 사면 따라 `lengthM` 까지 자름 · 사면보다 길면 마지막 끝에서
* 원지반(`ground`)을 바깥으로 따라 이어 그 길이까지 · 길이 없으면 그대로(원지반 닿으면 끝). 점 하나뿐인 선
* (사면 없음 · 끝이 이미 원지반)은 길이가 있으면 원지반 따라 · 없으면 안 그림. 그림 전용(수량 · 내역 무관).
*/
export function chuteRun<L extends { points: OffsetPoint[] }>(
lines: readonly L[],
lengthM?: number | null,
ground?: readonly OffsetPoint[],
): L[] {
const drawn = (list: L[]) => list.filter((line) => line.points.length > 1);
if (!(lengthM && lengthM > 0) || !lines.length) return drawn([...lines]);
let left = lengthM;
const out: L[] = [];
for (const line of lines) {
if (left <= 1e-9) break;
const cut = trimPolyline(line.points, left);
out.push({ ...line, points: cut.points });
left -= cut.used;
}
const last = out[out.length - 1];
if (left > 1e-9 && ground?.length && last) {
const first = lines[0].points[0];
const end = last.points[last.points.length - 1];
const outward = Math.sign(end.offset - first.offset) || Math.sign(end.offset) || 1;
const along = [
end,
...ground
.filter((p) => (p.offset - end.offset) * outward > 1e-6)
.sort((a, b) => (a.offset - b.offset) * outward),
];
out.push({ ...last, points: trimPolyline(along, left).points });
}
return drawn(out);
}
/** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작).
* 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */
export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean {
@@ -365,13 +436,16 @@ export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }
*/
export function appendChuteBand(
layer: SVGElement,
lines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>,
chute: { structure: string; thickness_m: number },
allLines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>,
chute: { structure: string; thickness_m: number; length_m?: number },
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
/** 원지반 — 길이가 사면보다 길면 이어 그림(294-4). */
ground?: readonly OffsetPoint[],
): void {
const thickness = chute.thickness_m;
if (!(thickness > 0)) return;
const lines = chuteRun(allLines, chute.length_m, ground);
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)];
for (const line of lines) {
@@ -383,7 +457,8 @@ export function appendChuteBand(
band.setAttribute("class", "b06-chart__culvert-pitching");
const title = document.createElementNS(SVG_NS, "title");
const along = line.kind === "cut" ? "3도 절토선" : "성토사면";
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 원지반까지`;
const reach = chute.length_m ? `길이 ${chute.length_m.toFixed(2)}m` : "원지반까지";
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 ${reach}`;
band.append(title);
clipSeq += 1;
const clipId = `b06-chute-clip-${clipSeq}`;
@@ -18,6 +18,7 @@ import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culver
import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford";
import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import { appendUnderdrainOverlay } from "../B06_Section/B06_Section_UI_Cross_Underdrain";
import { groundLine } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
@@ -486,7 +487,8 @@ function drawDesign(
y: (elevation: number) => number,
): void {
const { design, culvert, box, ford, own } = layouts;
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
const ground = groundLine(section.samples ?? []); // 물넘이 도수로 띠 끝 · 길이 이음(294-4)
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y, ground);
if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement);
appendUnderdrainOverlay(svg, section.underdrain, design, x, y); // 맹암거(288-1)
appendCrossDesignOverlay(
@@ -24,7 +24,10 @@ const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_station.ts"));
const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts"), {
"./B06_Section_UI_Cross_Wall_Hatch": { appendChuteBand() {} },
"./B06_Section_UI_Cross_Wall_Hatch": loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts"),
{ "./B06_Section_UI_Cross_Culvert_Const": {} },
),
"./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} },
});
// 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01)
@@ -55,8 +55,40 @@ pave.appendFordPavementOverlay(
);
const paveLayer = svg.children.find((k) => (k.attrs.class ?? "").includes("ford-pavement"));
// 294-4 도수로 · 돌붙임 끝단 · 길이
const hatch = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts"));
const P = (offset, elevation) => ({ offset, elevation });
const slope = [{ points: [P(0, 0), P(3, -4)], kind: "fill" }]; // 사면 5 m
const flat = [P(3, -4), P(5, -4), P(8, -4)]; // 사면 끝 바깥 원지반
const pts = (lines) =>
lines.map((l) => l.points.map((p) => [+p.offset.toFixed(4), +p.elevation.toFixed(4)]));
const dipGround = hatch.groundLine(input.dipSamples);
const fillGround = hatch.groundLine(input.fillSamples);
const calc = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts"));
const formOf = (state) => ({ state: () => state, setValue: (c, i, v) => (state[c][0][i] = v) });
const fordState = { ford: [{ width: "8" }], chute_add: [{ length_m: "2.5", area: "30" }] };
calc.syncChuteArea(formOf(fordState), { id: "chute_add" }, { id: "length_m" }, "chute_add", "ford");
const guard = load(path.join(ROOT, "A00_Common", "design", "design_structure_guard.ts"));
const fordPave = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_pavement.ts"));
const out = {
edges: pave.fordEdges(design),
pavePoints: paveLayer?.attrs.points ?? null,
runNone: pts(hatch.chuteRun(slope)),
runShort: pts(hatch.chuteRun(slope, 2)),
runLong: pts(hatch.chuteRun(slope, 7, flat)),
runPointOnly: pts(hatch.chuteRun([{ points: [P(3, -4)] }])),
runPointLength: pts(hatch.chuteRun([{ points: [P(3, -4)] }], 2, flat)),
dipLine: pave.outletFillLine(design, dipGround),
dipChute: pave.fordChuteLine(design, {}, dipGround),
dipChuteLength: pave.fordChuteLine(design, { length_m: 1.5 }, dipGround),
fillLine: pave.outletFillLine(input.fillDesign, fillGround),
fillLineOld: pave.outletFillLine(input.fillDesign),
fillChuteLength: pave.fordChuteLine(input.fillDesign, { length_m: 1 }, fillGround),
fordArea: fordState.chute_add[0].area,
chuteItems: guard.CHUTE_ITEMS.map((i) => i.id),
fordPaveChuteSame:
fordPave.FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "chute").items ===
guard.CHUTE_ITEMS,
};
process.stdout.write(JSON.stringify(out));
+2 -3
View File
@@ -55,9 +55,8 @@ def test_거름_한_벌_배선() -> None:
culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts")
assert "layout.outletFill.segments.filter(isChuteSegment)," in culvert
corridor = _src("B05_Profile/B05_Profile_UI_Corridor_Structures.ts")
assert (
"chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment)" in corridor
)
flat = " ".join(corridor.split())
assert "chuteBands( layout.outletFill.segments, section.culvert?.chute, isChuteSegment," in flat
hatch = _src("B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts")
assert '"3도 절토선"' in hatch
# 박스 · 세월교 본체 끝은 성토만(3도 규칙 안 넓힘)
@@ -174,8 +174,7 @@ def test_도수로_길이_없음_원지반까지_있음_그_길이(ts):
def test_컨테이너_같은_칸_맨_아래_길이():
spec = _read("A00_Common", "design", "design_structure_ford_pavement.ts")
assert (
'items: [...CHUTE_ITEMS, { id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 }],'
in spec
"items: CHUTE_ITEMS," in spec # 공용 칸 맨 아래 길이(294-4 · CHUTE_ITEMS 로 옮김)
)
@@ -5,6 +5,10 @@
처음 만나는 자리까지(그 쪽 절토 사면 없음 · `tilt_reach`) · 성토 쪽 그대로 · 반폭 안에서 못 만나면 옛 길 ·
서버 정본 ↔ TS 짝 같은 값 = 횡단 설계 거울(`test_b06_cross_design_mirror` 「물넘이포장 낮은 쪽 노면
연장」) · 그림 = 콘크리트(포장층)가 늘인 끝까지.
- 294-4 도수로/돌붙임 띠 = 낮은 쪽 끝단(늘인 끝 · 아니면 유출 쪽 노견 끝)에서 성토 사면 따라 · 길이 비움 =
원지반 닿으면 끝 · 양측 절토 물넘이 = 띠 없음 · 길이 = 그 길이(사면보다 길면 원지반 따라) · 다른 구조물
(배관 · 박스 · 세월교) 같은 규칙(`chuteRun` · 원지반 `layout.ground`) + 같은 「길이 (m)」 칸 · 면적 = 세월교만
월류 폭 × 길이(배관 · 박스 = 폭 정의 없어 자동 안 함) · 길이는 집계 · B08 수량에 안 실림.
진짜 TS 는 `helper_294_3_ford_extend_chute.cjs` 가 Node 로 돌림. 화면은 사용자 확인.
"""
@@ -60,7 +64,15 @@ def dip() -> dict:
@pytest.fixture(scope="module")
def ts(dip, tmp_path_factory) -> dict:
source = tmp_path_factory.mktemp("s294") / "in.json"
source.write_text(json.dumps({"design": dip}), encoding="utf-8")
fill_samples = _line(lambda o: 100.0 + 0.35 * o)
fill = compute_cross_design(fill_samples, 100.4, **KW)
payload = {
"design": dip,
"dipSamples": _dip_ground(),
"fillSamples": fill_samples,
"fillDesign": fill,
}
source.write_text(json.dumps(payload), encoding="utf-8")
proc = subprocess.run(
["node", str(HERE / "helper_294_3_ford_extend_chute.cjs"), str(source)],
capture_output=True,
@@ -110,3 +122,80 @@ def test_콘크리트는_늘인_끝까지(dip, ts):
}
first = ts["pavePoints"].split()[1] # 포장층 윗면 우 끝
assert float(first.split(",")[0]) == pytest.approx(-4.3333, abs=1e-4)
def test_띠_길이_맞춤_공용(ts):
assert ts["runNone"] == [[[0, 0], [3, -4]]] # 길이 없음 = 원지반 닿으면 끝(그대로)
assert ts["runShort"] == [[[0, 0], [1.2, -1.6]]] # 길이 2 = 사면 따라 2 m
assert ts["runLong"] == [[[0, 0], [3, -4]], [[3, -4], [5, -4]]] # 사면 5 + 원지반 2
assert ts["runPointOnly"] == [] # 끝단이 이미 원지반 · 길이 없음 = 안 그림
assert ts["runPointLength"] == [[[3, -4], [5, -4]]]
def test_양측_절토_물넘이_띠_없음(ts):
assert len(ts["dipLine"]) == 1 # 늘인 끝이 원지반
assert ts["dipLine"][0]["offset"] == pytest.approx(-4.3333, abs=1e-4)
assert ts["dipChute"] == []
tail = ts["dipChuteLength"] # 길이를 넣으면 늘인 끝에서 원지반 따라 그 길이
assert tail[0]["offset"] == pytest.approx(-4.3333, abs=1e-4) and len(tail) >= 2
def test_성토_쪽_띠는_원지반_닿으면_끝(ts):
line = ts["fillLine"]
end = line[-1]
assert end["elevation"] == pytest.approx(100.0 + 0.35 * end["offset"], abs=1e-3) # 원지반 위
assert len(ts["fillLineOld"]) > len(line) # 원지반을 모르면 내려가는 원지반까지 따라갔음
chute = ts["fillChuteLength"]
start, stop = chute[0], chute[-1]
assert (stop["offset"] - start["offset"]) ** 2 + (
stop["elevation"] - start["elevation"]
) ** 2 == pytest.approx(1.0, abs=1e-6)
def test_다른_구조물_같은_규칙과_길이_칸(ts):
assert ts["chuteItems"][-1] == "length_m"
assert ts["fordPaveChuteSame"] is True
assert ts["fordArea"] == "20" # 세월교 월류 폭 8 × 길이 2.5
hatch = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Wall_Hatch.ts").read_text(
encoding="utf-8"
)
assert "const lines = chuteRun(allLines, chute.length_m, ground);" in hatch
for name in ("Culvert", "Box", "Ford"):
overlay = (ROOT / "B06_Section" / f"B06_Section_UI_Cross_{name}.ts").read_text(
encoding="utf-8"
)
assert "layout.ground," in overlay
form = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text(
encoding="utf-8"
)
assert form.count("syncChuteArea(") == 2 # 물넘이 · 세월교만(배관 · 박스 = 폭 정의 없음)
def test_길이는_집계_수량에_안_실림():
from B06_Section.B06_Section_Engine_Culvert import _chute
from B08_Quantity.B08_Quantity_Router_Summary import _chutes
from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows
options = {
"chute_add_1_structure": "돌붙임",
"chute_add_1_bond": "찰붙임",
"chute_add_1_area": 12,
}
longer = {**options, "chute_add_1_length_m": 7}
assert _chute(longer)["length_m"] == 7 and "length_m" not in _chute(options)
class Sheet:
def __init__(self) -> None:
self.rows: list[tuple] = []
def add(self, at, key, sort, group, label, value, combos) -> None:
self.rows.append((key, label, value))
sheet = Sheet()
_chutes(sheet, 50.0, longer, {"gchuteadd1": "SS1"}, {})
assert sheet.rows == [("chute|찰붙임", "㎡", 12.0)]
item = {"type_id": "pipe", "options": options, "복사본": "pipe@50.0", "흙막이단": 0}
plain = container_rows(item, {"gchuteadd1": "SS1"})
long = container_rows({**item, "options": longer}, {"gchuteadd1": "SS1"})
for key in ("length_m", "inlet_revet_length_m", "outlet_revet_length_m", "area_m"):
assert plain[0]["options"].get(key) == long[0]["options"].get(key)
@@ -113,4 +113,7 @@ def test_정의_파일에_세월교_이름_표가_올라_있음():
os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8"
) as f:
doc = json.load(f)
assert doc["종류"]["ford_bridge"]["이름"] == names
# 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖
assert doc["종류"]["ford_bridge"]["이름"] == {
k: v for k, v in names.items() if k != "chute_add.length_m"
}
@@ -102,7 +102,8 @@ def test_정의_파일에_이름_표_그룹_선택지가_올라_있음():
os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8"
) as f:
doc = json.load(f)
assert set(names) == set(doc["종류"]["box_culvert"]["이름"])
# 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖
assert set(names) - {"chute_add.length_m"} == set(doc["종류"]["box_culvert"]["이름"])
assert [g["id"] for g in doc["그룹"]["box_culvert"]] == ["gboxbody", "gboxin", "gboxout"]
box = next(t for t in doc["types"] if t["type_id"] == "box_culvert")
wing = {o["key"]: o for o in box["options"]}
@@ -107,5 +107,6 @@ def test_정의_파일에_이름_표가_올라_있음():
) as f:
doc = json.load(f)
on_file = doc["종류"]["pipe"]["이름"]
assert set(names) == set(on_file)
# 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖
assert set(names) - {"chute_add.length_m"} == set(on_file)
assert on_file["inlet.structure=기슭막이"] == "흙막이"
@@ -73,6 +73,7 @@ def test_유입_유출은_구조와_흙막이_칸만(out):
["bond", None, "메붙임"],
["thickness", None, "45"],
["area", None, "30"],
["length_m", None, None], # 그림 전용 길이(294-4)
]
keys = out[f"{spec}Keys"]
assert keys["prefixes"] == ["soil_add_", "chute_add_"]