Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -13,7 +13,40 @@ import {
|
||||
buildRockBoundaryControl,
|
||||
sectionModeLabel,
|
||||
} from "./B06_Section_UI_Cross_Design";
|
||||
import { type DesignChangeHandler, L, stationLabel } from "./B06_Section_UI_Section_Common";
|
||||
import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit";
|
||||
import {
|
||||
type DesignChangeHandler,
|
||||
L,
|
||||
stationLabel,
|
||||
structureDisplayName,
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/**
|
||||
* 성토사면 경사길이 표기 칸 — 성토측이 없으면 null.
|
||||
*
|
||||
* 값은 **구조물이 끊기 전 본래 사면**(정본 설계선 기준)이라, 기슭막이·집수정이 선 측점도
|
||||
* 표준 횡단의 사면 길이를 그대로 보여 준다(2026-09-03 사용자 지시).
|
||||
*/
|
||||
function fillSlopeInfo(section: CrossSection): HTMLElement | null {
|
||||
const lengths = fillSlopeLengths(section);
|
||||
const sides = (["left", "right"] as const).filter((side) => lengths[side] !== null);
|
||||
if (!sides.length) return null;
|
||||
const both = sides.length > 1;
|
||||
const info = document.createElement("span");
|
||||
info.className = "b06-cross-card__fillslope";
|
||||
info.textContent = `${L("B06_Cross_FillSlope")} ${sides
|
||||
.map((side) => {
|
||||
const length = lengths[side] as FillSlopeLength;
|
||||
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
|
||||
const value = `${length.open ? "≥" : ""}${length.lengthM.toFixed(2)}m`;
|
||||
if (!both) return value;
|
||||
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
|
||||
return `${label} ${value}`;
|
||||
})
|
||||
.join(" · ")}`;
|
||||
info.title = L("B06_Cross_FillSlope_Tip");
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지).
|
||||
@@ -52,11 +85,14 @@ export function appendCardHeader(
|
||||
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
|
||||
meta.append(openSlope);
|
||||
}
|
||||
if (section.structure) {
|
||||
const structureName = section.structure;
|
||||
if (structureName) {
|
||||
const structure = document.createElement("span");
|
||||
structure.className = "b06-cross-card__structure";
|
||||
structure.textContent = section.structure;
|
||||
structure.title = `구조물: ${section.structure}`;
|
||||
// 배수관은 관종·관경(`파형강관 D1200`)이 아니라 표시 이름 하나로 적는다
|
||||
// (2026-09-03 사용자 지시). 관종·관경은 툴팁에 남긴다.
|
||||
structure.textContent = structureDisplayName(structureName);
|
||||
structure.title = `구조물: ${structureName}`;
|
||||
meta.append(structure);
|
||||
}
|
||||
// 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시).
|
||||
@@ -67,6 +103,10 @@ export function appendCardHeader(
|
||||
);
|
||||
meta.append(kind);
|
||||
}
|
||||
// 성토사면 경사길이는 **행 우측 끝**(2026-09-03 사용자 지시) — meta 가 제목행의 오른쪽
|
||||
// 끝이므로 그 마지막 칸에 붙인다.
|
||||
const fillSlope = fillSlopeInfo(section);
|
||||
if (fillSlope) meta.append(fillSlope);
|
||||
|
||||
// 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번).
|
||||
modePill.classList.add("b06-cross-card__mode--right");
|
||||
|
||||
@@ -58,6 +58,58 @@ export function toeFitHalfWidth(section: CrossSection): number | null {
|
||||
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
|
||||
*
|
||||
* 「본래」 = 구조물(기슭막이·집수정)이 사면을 끊기 전 표준 횡단 기준. 구조물은 프론트
|
||||
* 오버레이라 백엔드 `design_line`을 바꾸지 않으므로, 그 선으로 재면 곧 본래 길이다.
|
||||
* 구간은 노견 끝(그 측에 측구가 있으면 측구 바깥)부터 설계선이 원지반과 처음 만나는
|
||||
* 점까지이고, 그 사이 성토선은 1:n 직선이라 수평 성분만으로 경사길이가 나온다.
|
||||
*/
|
||||
export interface FillSlopeLength {
|
||||
/** 사면 경사길이(m). 미교차(`open`)면 계산 반폭까지의 **하한값**이다. */
|
||||
lengthM: number;
|
||||
/** 설계선 끝(계산 반폭)까지 원지반과 만나지 못한 사면 — 길이를 다 재지 못했다. */
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function fillSlopeLengths(section: CrossSection): {
|
||||
left: FillSlopeLength | null;
|
||||
right: FillSlopeLength | null;
|
||||
} {
|
||||
const lengths: { left: FillSlopeLength | null; right: FillSlopeLength | null } = {
|
||||
left: null,
|
||||
right: null,
|
||||
};
|
||||
const design = section.design;
|
||||
if (!design) return lengths;
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const designAt = designInterpolator(design.design_line);
|
||||
const edges = design.road_edges;
|
||||
if (!groundAt || !designAt || !edges) return lengths;
|
||||
const lineOffsets = design.design_line.map((point) => point.offset_m);
|
||||
if (lineOffsets.length < 2) return lengths;
|
||||
const { protectMax, protectMin } = protectedSpan(design, edges);
|
||||
const slant = Math.hypot(1, 1 / Math.max(design.fill_slope_ratio, 1e-6));
|
||||
for (const side of ["left", "right"] as const) {
|
||||
const mode = design.section_mode;
|
||||
if (mode === "both_cut" || mode === `${side}_cut`) continue;
|
||||
const outward = side === "left" ? 1 : -1;
|
||||
const start = side === "left" ? protectMax : protectMin;
|
||||
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
|
||||
// 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `toeFitHalfWidth`가
|
||||
// 쓰는 추세 외삽은 지반이 사면과 거의 나란한 자리에서 수십 m씩 튀어 길이 표기에는 못 쓴다
|
||||
// (2026-09-03 실측: 740m 측점 좌측 외삽 101.53m, 실제 반폭 20m까지 고저차 7.16m 유지).
|
||||
const meet = meetOffset(designAt, groundAt, start, limit, outward);
|
||||
const end = Math.abs(meet) > Math.abs(limit) ? limit : meet;
|
||||
lengths[side] = {
|
||||
lengthM: Math.abs(end - start) * slant,
|
||||
open: Math.abs(designAt(end) - groundAt(end)) > MEET_TOLERANCE_M,
|
||||
};
|
||||
}
|
||||
return lengths;
|
||||
}
|
||||
|
||||
/** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */
|
||||
function protectedSpan(
|
||||
design: CrossDesign,
|
||||
@@ -98,7 +150,12 @@ function meetOffset(
|
||||
const offset = startOffset + outward * Math.min(SCAN_STEP_M * index, span);
|
||||
const diff = designAt(offset) - groundAt(offset);
|
||||
if (Math.abs(diff) <= MEET_TOLERANCE_M) return offset;
|
||||
if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset;
|
||||
if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) {
|
||||
// 두 걸음 사이를 선형보간한다 — 반폭 맞춤에는 영향이 없지만(1m 올림), 같은 교차점을
|
||||
// 길이로 읽는 성토사면 표기가 탐색 간격(0.1m)만큼 튀는 것을 막는다(2026-09-03).
|
||||
const previous = offset - outward * SCAN_STEP_M;
|
||||
return previous + (offset - previous) * (previousDiff / (previousDiff - diff));
|
||||
}
|
||||
previousDiff = diff;
|
||||
}
|
||||
return extrapolateMeet(designAt, groundAt, limitOffset, outward);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
longitudinalMaxChainage,
|
||||
niceTickStep,
|
||||
stationLabel,
|
||||
structureDisplayName,
|
||||
svgElement,
|
||||
svgText,
|
||||
validElevation,
|
||||
@@ -327,7 +328,8 @@ export function createLongitudinalProfile(
|
||||
// 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시).
|
||||
if (station.structure) {
|
||||
structureLabels.push(
|
||||
svgText(station.structure, {
|
||||
// 배수관은 관종·관경 대신 표시 이름 하나로 적는다(2026-09-03 — 횡단 카드와 같은 규칙).
|
||||
svgText(structureDisplayName(station.structure), {
|
||||
x: stationX,
|
||||
y: heightPx - padBottom - 5,
|
||||
"text-anchor": "middle",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PIPE_TYPES } from "@config/config_frontend";
|
||||
import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
|
||||
import type {
|
||||
DesignProfile,
|
||||
@@ -56,6 +58,17 @@ export interface YScaleOptions {
|
||||
globalMaxElevation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 라벨의 **표시 이름** — 배수관은 관종·관경(`파형강관 D1200`) 대신 `배수관` 하나로
|
||||
* 적는다(2026-08-17 확정한 `PIPE_DISPLAY_NAME` 규칙, 2026-09-03 종·횡단 표기에 적용).
|
||||
*
|
||||
* 저장 라벨은 그대로 두어야 한다 — 재진입 이관(`B05_Profile_Structures_Migration`)이 라벨
|
||||
* 문자열로 구조물 종류를 되짚는다. 그래서 바꾸는 것은 화면에 찍는 순간뿐이다.
|
||||
*/
|
||||
export function structureDisplayName(label: string): string {
|
||||
return PIPE_TYPES.some((kind) => label.startsWith(`${kind} D`)) ? PIPE_DISPLAY_NAME : label;
|
||||
}
|
||||
|
||||
export function validElevation(
|
||||
sample: SectionSample,
|
||||
): sample is SectionSample & { elevation_m: number } {
|
||||
|
||||
@@ -238,6 +238,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 성토사면 경사길이 — 제목행 우측 끝(2026-09-03). meta 의 마지막 칸이라 우측 맞춤이다. */
|
||||
.b06-cross-card__fillslope {
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 지반유형 세그먼트(D-6): 제목행 1행 내 인라인 배치. 좌측 구분선(3번). */
|
||||
.b06-design__seg--header {
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -323,20 +323,55 @@ function circlePoints(center: XY, radius: number): XY[] {
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* `rect` → 닫힌 네 모서리 점열(회전 transform 적용).
|
||||
*
|
||||
* 돌쌓기 돌·돌망태 칸(`B06_Section_UI_Cross_Wall_Hatch.cell()`)은 `rect` 하나에
|
||||
* `rotate(벽기울기 cx cy)` 를 걸어 그린다. 수확기가 `rect` 를 안 보던 때는 형태 해칭
|
||||
* 80개 중 **49개(돌·칸 전부)가 CAD 에 안 실렸다**(2026-09-03 실측). 모서리 반경(`rx`)은
|
||||
* 무시한다 — 도면에서는 각진 칸으로 충분하다.
|
||||
*/
|
||||
function rectPoints(element: SVGElement): XY[] {
|
||||
const x = attr(element, "x");
|
||||
const y = attr(element, "y");
|
||||
const width = attr(element, "width");
|
||||
const height = attr(element, "height");
|
||||
const corners: XY[] = [
|
||||
[x, y],
|
||||
[x + width, y],
|
||||
[x + width, y + height],
|
||||
[x, y + height],
|
||||
[x, y],
|
||||
];
|
||||
const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec(
|
||||
element.getAttribute("transform") ?? "",
|
||||
);
|
||||
if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy));
|
||||
const angle = (Number(rotate[1]) * Math.PI) / 180;
|
||||
const [ox, oy] = [Number(rotate[2]), Number(rotate[3])];
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return corners.map(([cx, cy]) => {
|
||||
const dx = cx - ox;
|
||||
const dy = cy - oy;
|
||||
return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos);
|
||||
});
|
||||
}
|
||||
|
||||
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
|
||||
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
|
||||
const [fx0, , fx1] = frame;
|
||||
const entities: Entity[] = [];
|
||||
const segments: XY[][] = [];
|
||||
const inside = (px: number): boolean => px >= fx0 && px <= fx1;
|
||||
const nodes = root.querySelectorAll<SVGElement>("polygon, polyline, line, circle, text");
|
||||
const nodes = root.querySelectorAll<SVGElement>("polygon, polyline, line, circle, rect, text");
|
||||
for (const element of Array.from(nodes)) {
|
||||
if (element.closest(SKIP_SELECTOR)) continue;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
// 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다.
|
||||
const clip = clipPolygonOf(element);
|
||||
if (tag === "polygon" || tag === "polyline") {
|
||||
const points = parsePoints(element);
|
||||
if (tag === "polygon" || tag === "polyline" || tag === "rect") {
|
||||
const points = tag === "rect" ? rectPoints(element) : parsePoints(element);
|
||||
if (tag === "polygon" && points.length > 2) points.push(points[0]);
|
||||
const runs = clip ? clipPointsToPolygon(points, clip) : [points];
|
||||
for (const run of runs) {
|
||||
|
||||
@@ -348,6 +348,12 @@ export const ui_locales_b2 = {
|
||||
"사면이 계산 반폭 끝까지 원지반과 만나지 않아 절·성토 면적이 그 자리에서 잘렸습니다. 유토곡선·수량도 잘린 값을 씁니다.",
|
||||
"The slope never meets existing ground within the computed half-width, so the cut/fill area is truncated there. The mass haul curve and quantities use the truncated value.",
|
||||
],
|
||||
/* 성토사면 경사길이 — 구조물이 사면을 끊기 전 **본래** 길이(2026-09-03 사용자 지시). */
|
||||
B06_Cross_FillSlope: ["성토사면", "Fill slope"],
|
||||
B06_Cross_FillSlope_Tip: [
|
||||
"노견 끝(그 측에 측구가 있으면 측구 바깥)부터 성토 사면이 원지반과 만나는 곳까지의 경사길이입니다. 기슭막이·집수정이 사면을 끊기 전 본래 길이이며, 양측 성토면 좌·우를 함께 적습니다. 5m를 넘으면 옹벽·석축 등이 필요합니다(성토_비탈면 §2). 「≥」는 사면이 계산 반폭 안에서 원지반과 만나지 못해 거기까지만 잰 하한값입니다.",
|
||||
"Slant length of the fill slope, from the shoulder edge (outside the ditch if that side has one) to where it meets existing ground. It is the original slope before any revetment or catch basin cuts it short, and both sides are shown when the section is filled on both. Over 5 m a retaining structure is required. A leading ≥ marks a lower bound: the slope never meets ground within the computed half-width, so only that much could be measured.",
|
||||
],
|
||||
B06_MassHaul_CutNatural: ["절토(자연)", "Cut (natural)"],
|
||||
B06_MassHaul_CutCompacted: ["절토(다짐환산)", "Cut (compacted)"],
|
||||
B06_MassHaul_Fill: ["성토", "Fill"],
|
||||
|
||||
Reference in New Issue
Block a user