diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index 93d9c06c9..d27659b6a 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -4,7 +4,7 @@ * * ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py` * (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래 - * 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면 + * 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1 · 모서리 둥글림 · 364-3) — 한쪽만 고치면 * 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다. * `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄. * 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` @@ -348,6 +348,91 @@ function turnDeg(a: number[], m: number[], b: number[]): number { ); } +/** 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°). 짝 같은 이름. */ +export const ROUND_TURN_DEG = 15.0; +/** 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다 좁으면 그대로. */ +export const ROUND_M = 3.0; +export const ROUND_MIN_M = 0.5; +/** 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한. */ +export const ROUND_STEP_DEG = 5.0; +export const ROUND_PASSES = 200; + +const dist = (a: number[], b: number[]): number => + Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2); + +/** 가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈 — 측점(pins) · 바뀜 점 · 양 끝은 그대로. + * 돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1). 짝: `_round_corners`. */ +function roundCorners( + points: Line, + flags: boolean[], + pins: boolean[], +): [Line, boolean[], number[]] { + const pts = points.map((p) => [...p]); + const fl = [...flags]; + const src = points.map((_p, i) => i); + const pinned = (i: number): boolean => src[i] >= 0 && pins[src[i]]; + const fixed = (i: number): boolean => + i === 0 || i === pts.length - 1 || fl[i] !== fl[i - 1] || pinned(i); + /** i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지) — [남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향(i 쪽)]. */ + const walk = (i: number, step: number, length: number): [number, number[], number, number[]] => { + let gone = 0.0; + let j = i; + for (;;) { + const k = j + step; + const seg = dist(pts[j], pts[k]); + const toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]]; + if (gone + seg >= length) { + const ratio = (length - gone) / seg; + return [k, [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio], length, toward]; + } + // 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림 + if (fixed(k)) return [k, pts[k], pinned(k) ? (gone + seg) / 2.0 : gone + seg, toward]; + gone += seg; + j = k; + } + }; + /** 두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 null. */ + const control = (a: number[], ta: number[], b: number[], tb: number[], reach: number) => { + const den = ta[0] * tb[1] - ta[1] * tb[0]; + if (Math.abs(den) < 1e-12) return null; + const s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den; + const u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den; + const c = [a[0] + ta[0] * s, a[1] + ta[1] * s]; + if (s <= 0 || u <= 0 || dist(a, c) > 2 * reach || dist(b, c) > 2 * reach) return null; + return c; + }; + for (let pass = 0; pass < ROUND_PASSES; pass += 1) { + let worst = ROUND_TURN_DEG; + let at = -1; + let reach = 0.0; + for (let i = 1; i < pts.length - 1; i += 1) { + if (fixed(i)) continue; + const turn = turnDeg(pts[i - 1], pts[i], pts[i + 1]); + if (turn > worst) { + const room = Math.min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2]); + if (room >= ROUND_MIN_M) [worst, at, reach] = [turn, i, room]; + } + } + if (at < 0) break; + const [before, a, , ta] = walk(at, -1, reach); + const [after, b, , tb] = walk(at, 1, reach); + const p = control(a, ta, b, tb, reach) ?? pts[at]; + const count = Math.max(2, Math.ceil(worst / ROUND_STEP_DEG)); + const curve: Line = []; + for (let n = 0; n <= count; n += 1) { + const t = n / count; + const point = [0, 1].map((k) => + roundTo((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4), + ); + if (dist(point, pts[before]) > 1e-6 && dist(point, pts[after]) > 1e-6) curve.push(point); + } + pts.splice(before + 1, after - before - 1, ...curve); + fl.splice(before + 1, after - before - 1, ...curve.map(() => fl[at])); + src.splice(before + 1, after - before - 1, ...curve.map(() => -1)); + } + return [pts, fl, src]; +} + /** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */ function densify( chainages: number[], @@ -358,9 +443,17 @@ function densify( if (depth >= SPLIT_DEPTH) return []; const middle = (c0 + c1) / 2.0; const pm = place(middle, frameOf(middle)); + // 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3) + const q1 = place((c0 + middle) / 2.0, frameOf((c0 + middle) / 2.0)); + const q3 = place((middle + c1) / 2.0, frameOf((middle + c1) / 2.0)); + const triples = [ + ...pm.map((m, k) => [p0[k], m, p1[k]]), + ...q1.map((q, k) => [p0[k], q, pm[k]]), + ...q3.map((q, k) => [pm[k], q, p1[k]]), + ]; // 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 - const bends = pm.some((m, k) => { - const turn = turnDeg(p0[k], m, p1[k]); + const bends = triples.some(([a, m, b]) => { + const turn = turnDeg(a, m, b); return SPLIT_TURN_DEG < turn && turn < 90.0; }); if (!bends) return []; @@ -465,10 +558,18 @@ function sampleLines( left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])), right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])), }; - // 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품 - // 도면 관례) · 접혀 버린 자리는 뺌. - const ticks: Line[] = []; const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) }; + for (const side of SIDES) { + const key = `${side}_end_m` as const; + const pins = kept[key].map((i) => byChainage.has(roundTo(chainages[i], 3))); + const [line, flags, src] = roundCorners(lines[key], cut[side], pins); + lines[key] = line; + cut[side] = flags; + keptSets[side] = new Set(src.filter((j) => j >= 0).map((j) => kept[key][j])); + } + // 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품 + // 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌. + const ticks: Line[] = []; const position = new Map(chainages.map((chainage, i) => [chainage, i])); [...base] .sort((a, b) => a - b) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index ddf0b615a..7624ceb54 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -3,8 +3,8 @@ `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. ⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut · -slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림) — 종단 · 횡단 편집 중 평면 -발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. +slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림 · 모서리 둥글림) — 종단 · 횡단 +편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ @@ -226,6 +226,100 @@ def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float: return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) +#: 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°) +ROUND_TURN_DEG = 15.0 +#: 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다 +#: 좁으면 그대로 +ROUND_M = 3.0 +ROUND_MIN_M = 0.5 +#: 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한 +ROUND_STEP_DEG = 5.0 +ROUND_PASSES = 200 + + +def _dist(a: list[float], b: list[float]) -> float: + return math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) + + +def _round_corners( + points: list[list[float]], flags: list[bool], pins: list[bool] +) -> tuple[list[list[float]], list[bool], list[int]]: + """가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈. + + 측점(pins) · 깎기↔쌓기 바뀜 점 · 양 끝은 그대로 둔다. + + 돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1). + """ + pts, fl, src = [list(p) for p in points], list(flags), list(range(len(points))) + + def pinned(i: int) -> bool: + return src[i] >= 0 and pins[src[i]] + + def fixed(i: int) -> bool: + return i == 0 or i == len(pts) - 1 or fl[i] != fl[i - 1] or pinned(i) + + def walk(i: int, step: int, length: float) -> tuple[int, list[float], float, list[float]]: + """i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지). + + 돌려줌 = (남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향 — i 쪽을 향함). + """ + gone, j = 0.0, i + while True: + k = j + step + seg = _dist(pts[j], pts[k]) + toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]] + if gone + seg >= length: + ratio = (length - gone) / seg + at = [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio] + return k, at, length, toward + if fixed(k): + # 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림 + return k, pts[k], (gone + seg) / 2.0 if pinned(k) else gone + seg, toward + gone, j = gone + seg, k + + def control(a: list[float], ta: list[float], b: list[float], tb: list[float], reach: float): + """두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 None.""" + den = ta[0] * tb[1] - ta[1] * tb[0] + if abs(den) < 1e-12: + return None + s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den + u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den + c = [a[0] + ta[0] * s, a[1] + ta[1] * s] + if s <= 0 or u <= 0 or _dist(a, c) > 2 * reach or _dist(b, c) > 2 * reach: + return None + return c + + for _ in range(ROUND_PASSES): + worst, at, reach = ROUND_TURN_DEG, -1, 0.0 + for i in range(1, len(pts) - 1): + if fixed(i): + continue + turn = _turn_deg(pts[i - 1], pts[i], pts[i + 1]) + if turn > worst: + room = min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2]) + if room >= ROUND_MIN_M: + worst, at, reach = turn, i, room + if at < 0: + break + before, a, _gone, ta = walk(at, -1, reach) + after, b, _gone, tb = walk(at, 1, reach) + p = control(a, ta, b, tb, reach) or pts[at] + count = max(2, math.ceil(worst / ROUND_STEP_DEG)) + curve = [] + for n in range(count + 1): + t = n / count + point = [ + round((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4) + for k in (0, 1) + ] + if _dist(point, pts[before]) > 1e-6 and _dist(point, pts[after]) > 1e-6: + curve.append(point) + pts[before + 1 : after] = curve + fl[before + 1 : after] = [fl[at]] * len(curve) + src[before + 1 : after] = [-1] * len(curve) + return pts, fl, src + + def _densify( chainages: list[float], frame_of: Any, place: Any ) -> tuple[list[float], list[tuple[float, float, float, float]]]: @@ -236,8 +330,12 @@ def _densify( return [] middle = (c0 + c1) / 2.0 pm = place(middle, frame_of(middle)) + # 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3) + q1 = place((c0 + middle) / 2.0, frame_of((c0 + middle) / 2.0)) + q3 = place((middle + c1) / 2.0, frame_of((middle + c1) / 2.0)) + triples = [*zip(p0, pm, p1), *zip(p0, q1, pm), *zip(pm, q3, p1)] # 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 - if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)): + if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in triples): return [] return ( split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1) @@ -264,7 +362,8 @@ def slope_pieces( left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차 보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는 - 점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). + 점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). 버린 자리 · 잔 + 갈지자에 남은 비탈 끝 모서리는 측점을 두고 둥글린다(364-3). 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ sorted_records = sorted( @@ -333,10 +432,15 @@ def slope_pieces( cut = { side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides } - # 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아 - # 선다(납품 도면 관례) · 접혀 버린 자리는 뺌. - ticks: list[list[list[float]]] = [] kept_sets = {key: set(kept[key]) for key in keys} + for side in sides: + key = f"{side}_end_m" + pins = [round(chainages[i], 3) in by_chainage for i in kept[key]] + lines[key], cut[side], src = _round_corners(lines[key], cut[side], pins) + kept_sets[key] = {kept[key][j] for j in src if j >= 0} + # 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아 + # 선다(납품 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌. + ticks: list[list[list[float]]] = [] position = {chainage: i for i, chainage in enumerate(chainages)} for index, chainage in enumerate(sorted(base)): i = position[chainage] diff --git a/resources/tester/fixtures/b05_364_3_footprint_req239.json b/resources/tester/fixtures/b05_364_3_footprint_req239.json new file mode 100644 index 000000000..805b3e9a7 --- /dev/null +++ b/resources/tester/fixtures/b05_364_3_footprint_req239.json @@ -0,0 +1 @@ +{"note": "요구 239 브레인 검증 세션(검증_브레인232 · design-footprint) 측점 줄 70 + 노선 253 점 — 364-3", "route": [[0.0, 208403.2, 367920.751], [13.569, 208397.322, 367908.521], [18.854, 208395.249, 367903.66], [24.138, 208393.619, 367898.633], [29.423, 208392.445, 367893.481], [34.708, 208391.737, 367888.244], [39.992, 208391.5, 367882.965], [115.733, 208391.5, 367807.224], [119.441, 208391.672, 367803.52], [123.149, 208392.186, 367799.848], [126.857, 208393.038, 367796.239], [130.564, 208394.22, 367792.725], [134.272, 208395.723, 367789.335], [134.423, 208395.79, 367789.201], [140.756, 208398.923, 367783.696], [147.09, 208402.627, 367778.559], [153.424, 208406.861, 367773.848], [163.399, 208413.918, 367766.798], [166.51, 208416.234, 367764.72], [169.622, 208418.759, 367762.902], [172.733, 208421.464, 367761.366], [172.897, 208421.611, 367761.292], [174.748, 208423.303, 367760.543], [176.599, 208425.058, 367759.954], [178.45, 208426.86, 367759.529], [180.301, 208428.693, 367759.273], [182.152, 208430.542, 367759.188], [219.071, 208467.461, 367759.188], [224.633, 208473.017, 367759.445], [230.194, 208478.525, 367760.216], [235.756, 208483.938, 367761.494], [241.318, 208489.209, 367763.268], [246.88, 208494.294, 367765.522], [253.007, 208499.774, 367768.262], [256.344, 208502.824, 367769.614], [259.681, 208505.987, 367770.678], [263.018, 208509.235, 367771.445], [266.355, 208512.54, 367771.908], [269.692, 208515.873, 367772.062], [273.652, 208519.833, 367772.062], [275.503, 208521.682, 367771.977], [277.354, 208523.515, 367771.721], [279.205, 208525.317, 367771.296], [281.056, 208527.072, 367770.707], [282.907, 208528.764, 367769.958], [283.063, 208528.905, 367769.888], [286.179, 208531.614, 367768.348], [289.295, 208534.142, 367766.528], [292.41, 208536.46, 367764.446], [293.848, 208537.477, 367763.429], [297.811, 208540.125, 367760.481], [301.774, 208542.443, 367757.267], [305.737, 208544.403, 367753.823], [332.568, 208556.409, 367729.828], [342.551, 208561.349, 367721.153], [352.534, 208567.19, 367713.057], [362.517, 208573.866, 367705.634], [373.037, 208581.31, 367698.2], [376.146, 208583.625, 367696.125], [379.255, 208586.148, 367694.309], [382.364, 208588.851, 367692.773], [383.003, 208589.423, 367692.488], [384.669, 208590.946, 367691.814], [386.335, 208592.526, 367691.284], [388.0, 208594.147, 367690.901], [389.666, 208595.797, 367690.671], [391.332, 208597.461, 367690.594], [391.423, 208597.552, 367690.594], [393.09, 208599.217, 367690.671], [394.757, 208600.868, 367690.902], [396.424, 208602.49, 367691.285], [398.091, 208604.07, 367691.816], [399.758, 208605.595, 367692.491], [428.412, 208631.229, 367705.294], [438.373, 208639.889, 367710.215], [448.334, 208647.973, 367716.035], [458.294, 208655.388, 367722.685], [458.446, 208655.496, 367722.793], [468.427, 208662.17, 367730.214], [478.408, 208668.01, 367738.307], [488.388, 208672.949, 367746.98], [544.003, 208697.839, 367796.715], [547.954, 208699.793, 367800.148], [551.904, 208702.103, 367803.354], [555.855, 208704.74, 367806.295], [557.276, 208705.744, 367807.301], [560.408, 208708.073, 367809.394], [563.539, 208710.614, 367811.225], [566.671, 208713.337, 367812.77], [567.295, 208713.896, 367813.049], [568.959, 208715.418, 367813.721], [570.622, 208716.995, 367814.25], [572.285, 208718.614, 367814.631], [573.949, 208720.261, 367814.861], [575.612, 208721.923, 367814.938], [575.719, 208722.03, 367814.938], [577.382, 208723.692, 367814.861], [579.046, 208725.339, 367814.631], [580.709, 208726.958, 367814.25], [582.372, 208728.535, 367813.721], [584.036, 208730.057, 367813.049], [584.696, 208730.647, 367812.755], [586.544, 208732.338, 367812.008], [588.392, 208734.09, 367811.42], [590.24, 208735.889, 367810.997], [592.088, 208737.719, 367810.742], [593.936, 208739.565, 367810.656], [600.072, 208745.701, 367810.656], [604.239, 208749.864, 367810.849], [608.406, 208753.99, 367811.426], [612.573, 208758.046, 367812.383], [616.74, 208761.996, 367813.71], [620.907, 208765.806, 367815.397], [649.692, 208791.559, 367828.258], [661.693, 208801.993, 367834.188], [673.695, 208811.733, 367841.2], [685.697, 208820.667, 367849.214], [699.229, 208830.242, 367858.777], [702.33, 208832.315, 367861.082], [705.43, 208834.131, 367863.596], [708.531, 208835.668, 367866.289], [708.747, 208835.764, 367866.482], [711.833, 208837.293, 367869.162], [714.919, 208839.098, 367871.665], [718.004, 208841.159, 367873.962], [720.735, 208843.088, 367875.894], [723.866, 208845.417, 367877.988], [726.998, 208847.958, 367879.818], [730.13, 208850.681, 367881.364], [730.754, 208851.24, 367881.643], [732.417, 208852.762, 367882.315], [734.081, 208854.339, 367882.844], [735.744, 208855.958, 367883.225], [737.407, 208857.605, 367883.454], [739.071, 208859.267, 367883.531], [739.164, 208859.36, 367883.531], [740.833, 208861.027, 367883.454], [742.501, 208862.679, 367883.222], [744.17, 208864.303, 367882.839], [745.838, 208865.885, 367882.307], [747.507, 208867.41, 367881.631], [767.798, 208885.559, 367872.557], [769.466, 208887.084, 367871.88], [771.135, 208888.666, 367871.348], [772.803, 208890.29, 367870.965], [774.472, 208891.942, 367870.733], [776.14, 208893.609, 367870.656], [776.236, 208893.704, 367870.656], [777.904, 208895.371, 367870.733], [779.573, 208897.023, 367870.965], [781.241, 208898.647, 367871.348], [782.91, 208900.228, 367871.88], [784.578, 208901.754, 367872.557], [803.131, 208918.348, 367880.854], [807.087, 208921.786, 367882.809], [811.042, 208924.996, 367885.12], [814.997, 208927.94, 367887.761], [815.123, 208928.029, 367887.85], [819.091, 208930.684, 367890.8], [823.06, 208933.007, 367894.017], [827.028, 208934.972, 367897.465], [834.236, 208938.2, 367903.91], [838.182, 208940.153, 367907.339], [842.128, 208942.46, 367910.54], [846.074, 208945.095, 367913.477], [847.513, 208946.111, 367914.496], [850.637, 208948.435, 367916.585], [853.762, 208950.969, 367918.412], [856.887, 208953.686, 367919.956], [857.501, 208954.235, 367920.231], [859.167, 208955.759, 367920.905], [860.833, 208957.338, 367921.435], [862.499, 208958.959, 367921.817], [864.165, 208960.609, 367922.048], [865.831, 208962.274, 367922.125], [865.94, 208962.383, 367922.125], [867.606, 208964.047, 367922.048], [869.272, 208965.697, 367921.817], [870.938, 208967.318, 367921.435], [872.603, 208968.897, 367920.905], [874.269, 208970.421, 367920.231], [874.884, 208970.97, 367919.956], [878.008, 208973.687, 367918.412], [881.133, 208976.222, 367916.585], [884.258, 208978.545, 367914.496], [886.999, 208980.481, 367912.556], [890.085, 208982.542, 367910.259], [893.17, 208984.347, 367907.756], [896.256, 208985.876, 367905.075], [896.481, 208985.977, 367904.874], [899.576, 208987.511, 367902.186], [902.671, 208989.322, 367899.677], [905.765, 208991.39, 367897.375], [914.605, 208997.641, 367891.125], [917.699, 208999.709, 367888.823], [920.794, 209001.521, 367886.314], [923.889, 209003.054, 367883.626], [924.521, 209003.338, 367883.06], [926.195, 209004.018, 367881.531], [927.869, 209004.554, 367879.945], [929.543, 209004.939, 367878.316], [931.217, 209005.172, 367876.659], [932.89, 209005.25, 367874.987], [932.965, 209005.25, 367874.912], [934.636, 209005.172, 367873.243], [936.307, 209004.94, 367871.588], [937.978, 209004.556, 367869.962], [939.65, 209004.022, 367868.378], [941.321, 209003.344, 367866.851], [958.382, 208995.703, 367851.597], [965.066, 208992.99, 367845.487], [971.751, 208990.855, 367839.153], [978.435, 208989.317, 367832.648], [985.12, 208988.388, 367826.028], [991.805, 208988.078, 367819.35], [1004.36, 208988.078, 367806.799], [1006.22, 208988.165, 367804.942], [1008.08, 208988.423, 367803.1], [1009.93, 208988.852, 367801.29], [1011.79, 208989.447, 367799.528], [1013.66, 208990.203, 367797.829], [1013.8, 208990.27, 367797.695], [1016.91, 208991.807, 367795.002], [1020.01, 208993.622, 367792.488], [1023.11, 208995.696, 367790.183], [1040.5, 209008.002, 367777.892], [1049.95, 209014.321, 367770.864], [1059.4, 209019.849, 367763.198], [1068.85, 209024.524, 367754.984], [1074.84, 209027.199, 367749.632], [1078.17, 209028.552, 367746.582], [1081.51, 209029.616, 367743.419], [1084.85, 209030.383, 367740.171], [1088.18, 209030.845, 367736.866], [1091.52, 209031.0, 367733.533], [1095.43, 209031.0, 367729.628], [1097.28, 209030.914, 367727.776], [1099.13, 209030.657, 367725.94], [1100.99, 209030.231, 367724.135], [1102.84, 209029.64, 367722.378], [1104.7, 209028.889, 367720.683], [1105.33, 209028.603, 367720.113], [1107.0, 209027.927, 367718.588], [1108.67, 209027.395, 367717.006], [1110.34, 209027.012, 367715.382], [1112.01, 209026.78, 367713.73], [1113.68, 209026.703, 367712.063], [1113.79, 209026.703, 367711.947], [1115.86, 209026.805, 367709.879], [1117.93, 209027.11, 367707.831], [1120.0, 209027.616, 367705.824], [1122.07, 209028.316, 367703.875], [1147.47, 209038.08, 367680.426]], "records": [{"chainage_m": 0, "left_edge_m": 2, "left_end_m": 4.85, "right_edge_m": -2, "right_end_m": -2.79, "left_cut": false, "right_cut": true}, {"chainage_m": 20, "left_edge_m": 2, "left_end_m": 12.55, "right_edge_m": -2, "right_end_m": -5.69, "left_cut": false, "right_cut": true}, {"chainage_m": 40, "left_edge_m": 2, "left_end_m": 14.15, "right_edge_m": -2, "right_end_m": -4.94, "left_cut": false, "right_cut": true}, {"chainage_m": 60, "left_edge_m": 2, "left_end_m": 8.1, "right_edge_m": -2, "right_end_m": -6.04, "left_cut": false, "right_cut": true}, {"chainage_m": 80, "left_edge_m": 2, "left_end_m": 4.6, "right_edge_m": -2, "right_end_m": -6.99, "left_cut": false, "right_cut": true}, {"chainage_m": 96, "left_edge_m": 2, "left_end_m": 9.9, "right_edge_m": -2, "right_end_m": -15.94, "left_cut": false, "right_cut": true}, {"chainage_m": 100, "left_edge_m": 2, "left_end_m": 10.2, "right_edge_m": -2, "right_end_m": -4.99, "left_cut": false, "right_cut": true}, {"chainage_m": 120, "left_edge_m": 2, "left_end_m": 2.9, "right_edge_m": -2.25, "right_end_m": -4.99, "left_cut": false, "right_cut": true}, {"chainage_m": 140, "left_edge_m": 2, "left_end_m": 8.8, "right_edge_m": -2.1068, "right_end_m": -3.9968, "left_cut": false, "right_cut": true}, {"chainage_m": 160, "left_edge_m": 2, "left_end_m": 7.1, "right_edge_m": -2.4951, "right_end_m": -2.6451, "left_cut": false, "right_cut": false}, {"chainage_m": 180, "left_edge_m": 2, "left_end_m": 4.9, "right_edge_m": -3, "right_end_m": -5.69, "left_cut": false, "right_cut": true}, {"chainage_m": 194, "left_edge_m": 2, "left_end_m": 3.35, "right_edge_m": -2, "right_end_m": -3.59, "left_cut": false, "right_cut": true}, {"chainage_m": 200, "left_edge_m": 2, "left_end_m": 9.7, "right_edge_m": -2, "right_end_m": -4.69, "left_cut": false, "right_cut": true}, {"chainage_m": 220, "left_edge_m": 2, "left_end_m": 9.5, "right_edge_m": -2, "right_end_m": -4.04, "left_cut": false, "right_cut": true}, {"chainage_m": 240, "left_edge_m": 2, "left_end_m": 11.75, "right_edge_m": -2, "right_end_m": -5.79, "left_cut": false, "right_cut": true}, {"chainage_m": 260, "left_edge_m": 2.5, "left_end_m": 35, "right_edge_m": -2, "right_end_m": -5.54, "left_cut": false, "right_cut": true}, {"chainage_m": 280, "left_edge_m": 3, "left_end_m": 5.35, "right_edge_m": -2, "right_end_m": -6.49, "left_cut": false, "right_cut": true}, {"chainage_m": 291, "left_edge_m": 2.75, "left_end_m": 2.75, "right_edge_m": -2, "right_end_m": -8.24, "left_cut": false, "right_cut": true}, {"chainage_m": 300, "left_edge_m": 2.5, "left_end_m": 8.6, "right_edge_m": -2, "right_end_m": -4.49, "left_cut": false, "right_cut": true}, {"chainage_m": 320, "left_edge_m": 2, "left_end_m": 17.3, "right_edge_m": -2, "right_end_m": -5.84, "left_cut": false, "right_cut": true}, {"chainage_m": 340, "left_edge_m": 2, "left_end_m": 8.95, "right_edge_m": -2, "right_end_m": -5.74, "left_cut": false, "right_cut": true}, {"chainage_m": 360, "left_edge_m": 2, "left_end_m": 9.6, "right_edge_m": -2, "right_end_m": -4, "left_cut": false, "right_cut": true}, {"chainage_m": 380, "left_edge_m": 2, "left_end_m": 6.9, "right_edge_m": -2.8747, "right_end_m": -4.8647, "left_cut": false, "right_cut": true}, {"chainage_m": 386, "left_edge_m": 2, "left_end_m": 7.6, "right_edge_m": -3.25, "right_end_m": -7.04, "left_cut": false, "right_cut": true}, {"chainage_m": 400, "left_edge_m": 2, "left_end_m": 7.3, "right_edge_m": -3.2197, "right_end_m": -5.4097, "left_cut": false, "right_cut": true}, {"chainage_m": 420, "left_edge_m": 2, "left_end_m": 8.9, "right_edge_m": -2, "right_end_m": -6.74, "left_cut": false, "right_cut": true}, {"chainage_m": 440, "left_edge_m": 2, "left_end_m": 7.35, "right_edge_m": -2, "right_end_m": -6.89, "left_cut": false, "right_cut": true}, {"chainage_m": 460, "left_edge_m": 2, "left_end_m": 2.65, "right_edge_m": -2, "right_end_m": -7.09, "left_cut": false, "right_cut": true}, {"chainage_m": 480, "left_edge_m": 2, "left_end_m": 7.45, "right_edge_m": -2, "right_end_m": -4.59, "left_cut": false, "right_cut": true}, {"chainage_m": 482, "left_edge_m": 2, "left_end_m": 7.4, "right_edge_m": -2, "right_end_m": -4.89, "left_cut": false, "right_cut": true}, {"chainage_m": 500, "left_edge_m": 2, "left_end_m": 9.35, "right_edge_m": -2, "right_end_m": -4.34, "left_cut": false, "right_cut": true}, {"chainage_m": 520, "left_edge_m": 2, "left_end_m": 9.7, "right_edge_m": -2, "right_end_m": -2.8, "left_cut": false, "right_cut": false}, {"chainage_m": 540, "left_edge_m": 2.2998, "left_end_m": 7.0498, "right_edge_m": -2, "right_end_m": -4.14, "left_cut": false, "right_cut": true}, {"chainage_m": 560, "left_edge_m": 2.75, "left_end_m": 2.75, "right_edge_m": -2, "right_end_m": -4.19, "left_cut": false, "right_cut": true}, {"chainage_m": 579, "left_edge_m": 3.25, "left_end_m": 7.45, "right_edge_m": -2, "right_end_m": -4.19, "left_cut": false, "right_cut": true}, {"chainage_m": 580, "left_edge_m": 3.25, "left_end_m": 10.5, "right_edge_m": -2, "right_end_m": -4.24, "left_cut": false, "right_cut": true}, {"chainage_m": 600, "left_edge_m": 2, "left_end_m": 9.75, "right_edge_m": -2.3936, "right_end_m": -4.6336, "left_cut": false, "right_cut": true}, {"chainage_m": 620, "left_edge_m": 2, "left_end_m": 8.75, "right_edge_m": -2, "right_end_m": -4.89, "left_cut": false, "right_cut": true}, {"chainage_m": 640, "left_edge_m": 2, "left_end_m": 6.5, "right_edge_m": -2, "right_end_m": -3.79, "left_cut": false, "right_cut": true}, {"chainage_m": 660, "left_edge_m": 2, "left_end_m": 10, "right_edge_m": -2, "right_end_m": -4.49, "left_cut": false, "right_cut": true}, {"chainage_m": 673, "left_edge_m": 2, "left_end_m": 6.9, "right_edge_m": -2, "right_end_m": -4.24, "left_cut": false, "right_cut": true}, {"chainage_m": 680, "left_edge_m": 2, "left_end_m": 4.85, "right_edge_m": -2, "right_end_m": -4.84, "left_cut": false, "right_cut": true}, {"chainage_m": 700, "left_edge_m": 2, "left_end_m": 9.75, "right_edge_m": -2.75, "right_end_m": -5.5, "left_cut": false, "right_cut": true}, {"chainage_m": 720, "left_edge_m": 2.6949, "left_end_m": 7.7949, "right_edge_m": -2, "right_end_m": -4.74, "left_cut": false, "right_cut": true}, {"chainage_m": 740, "left_edge_m": 3.25, "left_end_m": 3.25, "right_edge_m": -2, "right_end_m": -7.84, "left_cut": true, "right_cut": true}, {"chainage_m": 760, "left_edge_m": 2, "left_end_m": 2, "right_edge_m": -2.2753, "right_end_m": -25, "left_cut": false, "right_cut": true}, {"chainage_m": 766, "left_edge_m": 2, "left_end_m": 30, "right_edge_m": -3.0253, "right_end_m": -7.0653, "left_cut": false, "right_cut": true}, {"chainage_m": 780, "left_edge_m": 2, "left_end_m": 14.15, "right_edge_m": -3.25, "right_end_m": -6.64, "left_cut": false, "right_cut": true}, {"chainage_m": 800, "left_edge_m": 2, "left_end_m": 25, "right_edge_m": -2.3434, "right_end_m": -30, "left_cut": false, "right_cut": true}, {"chainage_m": 820, "left_edge_m": 2, "left_end_m": 14.2, "right_edge_m": -2.5, "right_end_m": -12.64, "left_cut": false, "right_cut": true}, {"chainage_m": 840, "left_edge_m": 2.5, "left_end_m": 10.65, "right_edge_m": -2, "right_end_m": -8.44, "left_cut": false, "right_cut": true}, {"chainage_m": 860, "left_edge_m": 3.25, "left_end_m": 6.65, "right_edge_m": -2, "right_end_m": -6.39, "left_cut": false, "right_cut": true}, {"chainage_m": 861, "left_edge_m": 3.25, "left_end_m": 5.75, "right_edge_m": -2, "right_end_m": -7.99, "left_cut": false, "right_cut": true}, {"chainage_m": 880, "left_edge_m": 2.75, "left_end_m": 4.3, "right_edge_m": -2, "right_end_m": -7.09, "left_cut": false, "right_cut": true}, {"chainage_m": 900, "left_edge_m": 2, "left_end_m": 35, "right_edge_m": -2.75, "right_end_m": -10.09, "left_cut": false, "right_cut": true}, {"chainage_m": 920, "left_edge_m": 2.75, "left_end_m": 2.75, "right_edge_m": -2, "right_end_m": -9.69, "left_cut": false, "right_cut": true}, {"chainage_m": 940, "left_edge_m": 3.25, "left_end_m": 3.8, "right_edge_m": -2, "right_end_m": -6.79, "left_cut": false, "right_cut": true}, {"chainage_m": 956, "left_edge_m": 2, "left_end_m": 5.3, "right_edge_m": -2, "right_end_m": -5.24, "left_cut": false, "right_cut": true}, {"chainage_m": 960, "left_edge_m": 2, "left_end_m": 13.35, "right_edge_m": -2, "right_end_m": -4.19, "left_cut": false, "right_cut": true}, {"chainage_m": 980, "left_edge_m": 2, "left_end_m": 5.4, "right_edge_m": -2, "right_end_m": -6.04, "left_cut": false, "right_cut": true}, {"chainage_m": 1000, "left_edge_m": 2, "left_end_m": 13.85, "right_edge_m": -2.5644, "right_end_m": -5.2544, "left_cut": false, "right_cut": true}, {"chainage_m": 1020, "left_edge_m": 2, "left_end_m": 4.95, "right_edge_m": -2.75, "right_end_m": -6.94, "left_cut": false, "right_cut": true}, {"chainage_m": 1040, "left_edge_m": 2, "left_end_m": 18.8, "right_edge_m": -2, "right_end_m": -4.19, "left_cut": false, "right_cut": true}, {"chainage_m": 1052, "left_edge_m": 2, "left_end_m": 2, "right_edge_m": -2, "right_end_m": -4.64, "left_cut": true, "right_cut": true}, {"chainage_m": 1060, "left_edge_m": 2, "left_end_m": 2, "right_edge_m": -2, "right_end_m": -6.69, "left_cut": true, "right_cut": true}, {"chainage_m": 1080, "left_edge_m": 2.5, "left_end_m": 3, "right_edge_m": -2, "right_end_m": -7.44, "left_cut": true, "right_cut": true}, {"chainage_m": 1100, "left_edge_m": 3, "left_end_m": 30, "right_edge_m": -2, "right_end_m": -8.29, "left_cut": false, "right_cut": true}, {"chainage_m": 1120, "left_edge_m": 2, "left_end_m": 19.2, "right_edge_m": -3, "right_end_m": -5.94, "left_cut": false, "right_cut": true}, {"chainage_m": 1140, "left_edge_m": 2, "left_end_m": 10.15, "right_edge_m": -2, "right_end_m": -4.84, "left_cut": false, "right_cut": true}, {"chainage_m": 1147.47471, "left_edge_m": 2, "left_end_m": 4.75, "right_edge_m": -2, "right_end_m": -3.44, "left_cut": false, "right_cut": true}]} \ No newline at end of file diff --git a/resources/tester/test_364_3_slope_end_round.py b/resources/tester/test_364_3_slope_end_round.py new file mode 100644 index 000000000..119d24e5c --- /dev/null +++ b/resources/tester/test_364_3_slope_end_round.py @@ -0,0 +1,131 @@ +"""364-3 절/성토선 비탈 끝 모서리 둥글림 — 요구 239 브레인 검증 세션 자료로 전후 꺾임. + +자료 `fixtures/b05_364_3_footprint_req239.json` = 세션 측점 줄 70 + 노선. 고치기 전 세션 선: +비탈 끝 30° 넘는 꺾임 왼 14 · 오른 3 · 최대 140.7°(접힘 버린 자리 · S 자 촘촘히 빠짐 · 잔 갈지자). +TS(`B05_Profile_PlanFootprint.ts`) 도 같은 자료로 같은 점인지 본다. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_364_3_slope_end_round.py -q +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope as plan # noqa: E402 + +DATA = json.loads( + (ROOT / "resources/tester/fixtures/b05_364_3_footprint_req239.json").read_text(encoding="utf-8") +) +ROUTE = [tuple(point) for point in DATA["route"]] +RECORDS = DATA["records"] +END = ROUTE[-1][0] +TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def _turns(lines: list) -> list[float]: + """조각 안 꺾임(°) — 조각 끝(깎기↔쌓기 바뀜 · 양 끝)은 뺌.""" + return [ + plan._turn_deg(line[i - 1], line[i], line[i + 1]) + for line in lines + for i in range(1, len(line) - 1) + ] + + +def _pieces() -> tuple: + return plan.slope_pieces(ROUTE, RECORDS, 0.0, END, 2.0) + + +def test_비탈_끝_30도_넘는_꺾임() -> None: + edge, fill, cut, _ticks = _pieces() + fill_turns, cut_turns = _turns(fill["lines"]), _turns(cut["lines"]) + over = sum(t > 30 for t in fill_turns + cut_turns) + # 남는 하나 = 측점 860 · 861 사이 1 m 에 비탈 폭 4.4 → 6.0 m(측점 자리라 그대로) + assert over <= 1, over + assert max(fill_turns + cut_turns) < 40.0 + assert sum(t > 15 for t in fill_turns + cut_turns) <= 25 + assert max(_turns(edge["lines"])) < 10.0 # 도로 끝은 손대지 않음 + + +def test_둥글림을_끄면_꺾임이_남음() -> None: + """기준이 바뀐 길을 재는지 — 둥글림만 끄면 30° 넘는 꺾임이 그대로 나옴.""" + keep = plan.ROUND_TURN_DEG + plan.ROUND_TURN_DEG = 1e9 + try: + _edge, fill, cut, _ticks = _pieces() + finally: + plan.ROUND_TURN_DEG = keep + turns = _turns(fill["lines"]) + _turns(cut["lines"]) + assert sum(t > 30 for t in turns) >= 5 and max(turns) > 90.0 + + +def test_측점_비탈_끝은_그대로() -> None: + """남은 측점 비탈 끝 점은 둥글림 뒤에도 같은 자리 점으로 남음.""" + keep = plan.ROUND_TURN_DEG + plan.ROUND_TURN_DEG = 1e9 + try: + _e, fill0, cut0, _t = _pieces() + finally: + plan.ROUND_TURN_DEG = keep + _e, fill, cut, _t = _pieces() + before = {tuple(p) for line in fill0["lines"] + cut0["lines"] for p in line} + after = {tuple(p) for line in fill["lines"] + cut["lines"] for p in line} + for record in RECORDS: + x, y, nx, ny = plan.route_normal(ROUTE, record["chainage_m"]) + for side in ("left", "right"): + offset = record[f"{side}_end_m"] + want = (round(x + nx * offset, 4), round(y + ny * offset, 4)) + if want in before: + assert want in after, (record["chainage_m"], side) + + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { slopePieces } from "./B05_Profile_PlanFootprint.js"; +const c = JSON.parse(readFileSync(process.argv[2], "utf-8")); +writeFileSync(process.argv[3], JSON.stringify(slopePieces(c.route, c.records, 0, c.end, 2))); +""" + + +@pytest.fixture(scope="module") +def ts_pieces(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_round") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + payload = {"route": ROUTE, "records": RECORDS, "end": END} + (out / "in.json").write_text(json.dumps(payload), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def test_TS_도_같은_점(ts_pieces) -> None: + edge, fill, cut, ticks = _pieces() + for name, want, got in ( + ("도로 끝", edge["lines"], ts_pieces["edge"]), + ("쌓기", fill["lines"], ts_pieces["fill"]), + ("깎기", cut["lines"], ts_pieces["cut"]), + ("눈금", ticks["ticks"], ts_pieces["ticks"]), + ): + assert len(want) == len(got), f"{name} 선 수" + for line_w, line_g in zip(want, got): + assert len(line_w) == len(line_g), f"{name} 점 수" + for pw, pg in zip(line_w, line_g): + assert abs(pw[0] - pg[0]) < 2e-4 and abs(pw[1] - pg[1]) < 2e-4, (name, pw, pg)