Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
This commit is contained in:
@@ -13,7 +13,7 @@ from common_util.common_util_auth import verify_session
|
||||
|
||||
router = APIRouter(prefix="/knowledge-chat", tags=["B01_KnowledgeChat"])
|
||||
KNOWLEDGE = Path(__file__).resolve().parents[1] / "resources" / "knowledge"
|
||||
EXCLUDED = ("실무문서", "stmate", "다산소프트")
|
||||
EXCLUDED = ("실무문서", "stmate", "다산소프트", "technical_info")
|
||||
MODEL = "gemini-3.8-flash"
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
type Source = { path: string; excerpt: string };
|
||||
type ChatAnswer = { answer: string; sources: Source[]; detail?: string };
|
||||
const chatIcon = `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 5h16a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H13l-7 4v-5a5 5 0 0 1-3-4V10a5 5 0 0 1 5-5Z"/><path d="M11 19c0-5 4-8 10-8 0 6-3 10-8 10"/><path d="M11 22c2-3 5-5 9-7"/></svg>`;
|
||||
|
||||
export function buildKnowledgeChat(): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -9,25 +10,44 @@ export function buildKnowledgeChat(): HTMLElement {
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "b01-knowledge-chat__toggle";
|
||||
toggle.textContent = "원문 안내";
|
||||
toggle.innerHTML = chatIcon;
|
||||
toggle.setAttribute("aria-label", "챗봇 열기");
|
||||
toggle.title = "챗봇";
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
const panel = document.createElement("section");
|
||||
panel.className = "b01-knowledge-chat__panel";
|
||||
panel.hidden = true;
|
||||
panel.setAttribute("aria-label", "지식DB 원문 안내 채팅");
|
||||
panel.id = "b01-knowledge-chat-panel";
|
||||
panel.setAttribute("aria-label", "챗봇");
|
||||
toggle.setAttribute("aria-controls", panel.id);
|
||||
const header = document.createElement("header");
|
||||
header.textContent = "지식DB 원문 안내";
|
||||
const avatar = document.createElement("span");
|
||||
avatar.className = "b01-knowledge-chat__avatar";
|
||||
avatar.innerHTML = chatIcon;
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = "챗봇";
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "b01-knowledge-chat__close";
|
||||
close.textContent = "×";
|
||||
close.setAttribute("aria-label", "챗봇 닫기");
|
||||
header.append(avatar, title, close);
|
||||
const messages = document.createElement("div");
|
||||
messages.className = "b01-knowledge-chat__messages";
|
||||
messages.setAttribute("role", "log");
|
||||
messages.setAttribute("aria-live", "polite");
|
||||
const welcome = document.createElement("p");
|
||||
welcome.className = "b01-knowledge-chat__reply";
|
||||
welcome.textContent =
|
||||
"안녕하세요! 법령이나 지침에 대해 질문해 주세요. 근거 문서도 함께 보여드릴게요.";
|
||||
messages.append(welcome);
|
||||
const form = document.createElement("form");
|
||||
form.className = "b01-knowledge-chat__form";
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.required = true;
|
||||
input.maxLength = 500;
|
||||
input.placeholder = "법령·지침을 질문하세요";
|
||||
input.placeholder = "메시지를 입력하세요";
|
||||
input.setAttribute("aria-label", "질문");
|
||||
const submit = document.createElement("button");
|
||||
submit.type = "submit";
|
||||
@@ -36,11 +56,15 @@ export function buildKnowledgeChat(): HTMLElement {
|
||||
panel.append(header, messages, form);
|
||||
wrap.append(toggle, panel);
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
const setOpen = (open: boolean): void => {
|
||||
panel.hidden = !open;
|
||||
toggle.setAttribute("aria-expanded", String(!panel.hidden));
|
||||
if (!panel.hidden) input.focus();
|
||||
});
|
||||
toggle.setAttribute("aria-label", open ? "챗봇 닫기" : "챗봇 열기");
|
||||
if (open) input.focus();
|
||||
else toggle.focus();
|
||||
};
|
||||
toggle.addEventListener("click", () => setOpen(Boolean(panel.hidden)));
|
||||
close.addEventListener("click", () => setOpen(false));
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -535,16 +535,30 @@
|
||||
|
||||
.b01-knowledge-chat__toggle,
|
||||
.b01-knowledge-chat__form button {
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 16px #0004;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
height: min(540px, calc(100vh - 104px));
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -560,34 +574,76 @@
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__close {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__messages {
|
||||
height: calc(100% - 106px);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user,
|
||||
.b01-knowledge-chat__reply {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
max-width: 88%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user {
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply {
|
||||
align-self: flex-start;
|
||||
border-bottom-left-radius: 4px;
|
||||
background: var(--color-background, #f4f4f4);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply details {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply summary {
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -602,6 +658,7 @@
|
||||
|
||||
.b01-knowledge-chat__form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
@@ -615,3 +672,14 @@
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form button {
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat button:focus-visible,
|
||||
.b01-knowledge-chat input:focus-visible {
|
||||
outline: 2px solid var(--color-royal-amethyst, #6843a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -238,10 +238,8 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
let prevBottom = input.startBottomElevation;
|
||||
|
||||
for (let i = 0; i < input.adjusts.length; i += 1) {
|
||||
// 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료.
|
||||
if (groundAt(src.offset) >= src.elevation - 0.01) break;
|
||||
// 원지반은 단을 끊지 않는다 — 요청한 단 수 그대로 세운다(54-45).
|
||||
const trailing = trailingFill(src, outward, groundAt);
|
||||
if (trailing.lengthM < 0.7) break;
|
||||
|
||||
const adjust = input.adjusts[i];
|
||||
// 형태(B05 폼 6종)가 재질·높이 한계를 정한다 — 조작이 없으면 소유 벽 형태를 따른다
|
||||
@@ -293,8 +291,8 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
bestOffset = x;
|
||||
}
|
||||
}
|
||||
if (autoOffset === null) autoOffset = bestOffset;
|
||||
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
|
||||
// 앉을 지형이 없으면(묻힘) 이음선이 시작점 바로 밖 — 원지반과 무관하게 선다.
|
||||
autoOffset ??= bestOffset ?? src.offset + outward * jointRunOf(exposed, lean);
|
||||
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean);
|
||||
/** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */
|
||||
const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN;
|
||||
@@ -314,52 +312,11 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
FILL_SLOPE_RATIO_MIN
|
||||
: (plan[i] ?? 0);
|
||||
const requestedX = plan === "user" ? adjust.x : 0;
|
||||
/** 후보 자리의 바닥 — buildExtraWall과 같은 근입 0.5m 고정(2026-08-23). */
|
||||
const bottomAt = (_anchorX: number, baseElevation: number): number =>
|
||||
baseElevation - REVET_EMBED_DEPTH_M;
|
||||
// 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이
|
||||
// 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이
|
||||
// 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리).
|
||||
const placeable = (xShift: number, vShift: number): boolean => {
|
||||
const topE = autoTop - vShift;
|
||||
const aX = autoOffset + outward * xShift;
|
||||
const jointX = aX - outward * jointRunOf(exposed, lean);
|
||||
if (
|
||||
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
|
||||
topE - 0.01
|
||||
)
|
||||
return false;
|
||||
const baseE = topE - exposed;
|
||||
const bottom = bottomAt(aX, baseE);
|
||||
const startE = bottom + REVET_EMBED_DEPTH_M;
|
||||
const topFrontX = jointX + outward * REVET_THICKNESS_M;
|
||||
const startX = topFrontX + outward * lean * (topE - startE);
|
||||
if (groundAt(startX) < startE + 0.05) return true;
|
||||
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
|
||||
return (
|
||||
slopedCrossing(
|
||||
{ offset: startX, elevation: startE },
|
||||
outward,
|
||||
-PIPE_CONNECT_GRADE,
|
||||
groundAt,
|
||||
limitOffset,
|
||||
) !== null
|
||||
);
|
||||
};
|
||||
let appliedD = Math.max(requestedD, minV);
|
||||
/** 선반 길이가 음수가 되면 각도가 깨진다 — x가 최소 1.2·d까지 따라 나간다. */
|
||||
const shelfFloor = (v: number): number => FILL_SLOPE_RATIO_MIN * v;
|
||||
// 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45).
|
||||
let appliedX = Math.max(requestedX, 0, shelfFloor(appliedD));
|
||||
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) {
|
||||
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
|
||||
if (appliedD > minV + 1e-9) {
|
||||
appliedD = Math.max(minV, appliedD - 0.05);
|
||||
appliedX = Math.max(appliedX, shelfFloor(appliedD));
|
||||
} else if (appliedX > shelfFloor(appliedD) + 1e-9) {
|
||||
appliedX = Math.max(shelfFloor(appliedD), appliedX - 0.05);
|
||||
} else break;
|
||||
}
|
||||
if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가.
|
||||
appliedD = Math.round(appliedD * 10) / 10;
|
||||
appliedX = Math.round(appliedX * 10) / 10;
|
||||
const topElevation = autoTop - appliedD;
|
||||
@@ -378,10 +335,6 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
floatGapM: Math.max(0, base - groundBase),
|
||||
lean,
|
||||
});
|
||||
// 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을
|
||||
// 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동).
|
||||
const blocked = Math.max(0, (requestedX ?? 0) - appliedX);
|
||||
if (blocked > 0.005) wall.shiftBlockedM = Math.round(blocked * 100) / 100;
|
||||
// 하한에 눌려 요청이 통째로 무시된 경우 — 지형이 아니라 **선반 길이**가 원인이다
|
||||
// (2026-09-07 실측: d 2.6m 이면 x 하한이 3.12m 라 1.0m 요청이 아무 변화도 못 냄).
|
||||
// 사용자가 실제로 넣었을 때만 알린다 — 요청이 없으면(0) 하한은 그냥 기본 자리라
|
||||
|
||||
@@ -62,7 +62,6 @@ import {
|
||||
designInterpolator,
|
||||
slopeToeOffset,
|
||||
pipeAxisSolver,
|
||||
placeInletWall,
|
||||
placePipeWall,
|
||||
slopeLengthAlong,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
@@ -200,9 +199,8 @@ export function computeCulvertLayout(
|
||||
if (!basinReason && designAt) {
|
||||
const inletBaseAt = (offset: number): number =>
|
||||
Math.min(groundAt(offset), invertCap(inletInfo.edge));
|
||||
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
|
||||
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
|
||||
const placed = placeInletWall({
|
||||
// 4축 배치는 공용 풀이(placePipeWall — Solve). 원지반은 자리를 막지 않는다(54-45).
|
||||
const placed = placePipeWall({
|
||||
autoOffset: slopeZeroAnchor(
|
||||
inletInfo.edge,
|
||||
inletInfo.outward,
|
||||
@@ -213,30 +211,10 @@ export function computeCulvertLayout(
|
||||
height: inletWallSpec.height,
|
||||
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
|
||||
edgeElevation: inletInfo.edge.elevation_m,
|
||||
// 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게
|
||||
// 오르내린다(관이 없어 유출 수용 검사 outletGuard도 뺀다 — 위 이동을 막던 원인).
|
||||
// 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게 오르내린다.
|
||||
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge),
|
||||
adjust: adjInlet,
|
||||
defaultD: PIPE_WALL_DEFAULT_RUN_M,
|
||||
groundAt,
|
||||
limitOffset: inletInfo.limit,
|
||||
// 독립 기슭막이는 유출 벽과 같은 자리 규칙 — 매몰-무교차 자리 금지(2026-08-29).
|
||||
requireCrossing: culvert.hidden_pipe === true,
|
||||
outletGuard: culvert.hidden_pipe
|
||||
? undefined
|
||||
: {
|
||||
edge: outletInfo.edge,
|
||||
outward: outletInfo.outward,
|
||||
height: outletWallSpec.height,
|
||||
toeOffset: slopeToeOffset(
|
||||
designAt,
|
||||
groundAt,
|
||||
outletInfo.edge.offset_m,
|
||||
outletInfo.limit,
|
||||
),
|
||||
limitOffset: outletInfo.limit,
|
||||
lean: outletWallSpec.lean,
|
||||
},
|
||||
});
|
||||
appliedAdjust.inlet.x = placed.x;
|
||||
appliedAdjust.inlet.d = placed.d;
|
||||
@@ -412,7 +390,7 @@ export function computeCulvertLayout(
|
||||
? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN
|
||||
: invertAt(offset);
|
||||
if (designAt) {
|
||||
// 4축 배치(유입과 동일 풀이) + 유출 전용: 매몰-무교차 자리 금지(2026-08-22 ③).
|
||||
// 4축 배치(유입과 동일 풀이).
|
||||
const placed = placePipeWall({
|
||||
autoOffset: outletZeroAnchor,
|
||||
outward: outletInfo.outward,
|
||||
@@ -423,9 +401,6 @@ export function computeCulvertLayout(
|
||||
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation,
|
||||
adjust: adjOutlet,
|
||||
defaultD: PIPE_WALL_DEFAULT_RUN_M,
|
||||
groundAt,
|
||||
limitOffset: outletInfo.limit,
|
||||
requireCrossing: true,
|
||||
});
|
||||
appliedAdjust.outlet.x = placed.x;
|
||||
appliedAdjust.outlet.d = placed.d;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
FILL_MIN_RISE_M,
|
||||
FILL_SLOPE_MAX_LENGTH_M,
|
||||
PIPE_CONNECT_GRADE,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
REVET_HEIGHT_STEP_M,
|
||||
REVET_LEAN_RATIO,
|
||||
@@ -21,7 +20,6 @@ import type {
|
||||
PipeEnd,
|
||||
WallAdjust,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
|
||||
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다.
|
||||
* `lean` = 그 벽의 전면 기울기(표준경사 · `revetWallSpec().lean`). */
|
||||
@@ -370,8 +368,7 @@ export interface PipeWallPlacement {
|
||||
* 지점**(벽 상단 = 노견)의 자리·invert. d는 그 점 기준 절대값(null = 기본
|
||||
* defaultD, 경사길이 4m 지점), 위 한계 = 성토선 0 지점(d ≥ 0)·invert 상한.
|
||||
* · x = 노견 연장 평행이동(안쪽 금지).
|
||||
* · requireCrossing(유출): 매몰인데 0도 절토선이 원지반과 다시 못 만나는 자리는
|
||||
* 금지 — 위쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
|
||||
* · 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45).
|
||||
*/
|
||||
export function placePipeWall(input: {
|
||||
/** 성토선 0 지점(벽 상단 = 노견)의 벽 자리(하단선 중점 offset). */
|
||||
@@ -386,37 +383,14 @@ export function placePipeWall(input: {
|
||||
adjust: WallAdjust;
|
||||
/** d 미지정(null) 시의 기본 자리 — 성토사면 경사길이 4m 지점의 수평 성분. */
|
||||
defaultD: number;
|
||||
groundAt: (offset: number) => number;
|
||||
limitOffset: number;
|
||||
requireCrossing: boolean;
|
||||
}): PipeWallPlacement {
|
||||
const top0 = input.baseElevation0 + input.height;
|
||||
const dLow = Math.max(
|
||||
-FILL_SLOPE_RATIO_MIN * (input.edgeElevation - top0),
|
||||
-FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)),
|
||||
);
|
||||
let d = Math.max(input.adjust.d ?? input.defaultD, Math.ceil(dLow * 10) / 10);
|
||||
let x = Math.max(input.adjust.x, 0);
|
||||
if (input.requireCrossing) {
|
||||
const placeable = (anchorOffset: number, invertE: number): boolean =>
|
||||
invertE >= input.groundAt(anchorOffset) - 0.01 ||
|
||||
slopedCrossing(
|
||||
{ offset: anchorOffset, elevation: invertE },
|
||||
input.outward,
|
||||
-PIPE_CONNECT_GRADE,
|
||||
input.groundAt,
|
||||
input.limitOffset,
|
||||
) !== null;
|
||||
for (let pass = 0; pass < 400; pass += 1) {
|
||||
const anchorOffset = input.autoOffset + input.outward * (x + d);
|
||||
if (placeable(anchorOffset, top0 - d / FILL_SLOPE_RATIO_MIN - input.height)) break;
|
||||
if (Math.abs(d) > 1e-9) d = d > 0 ? Math.max(0, d - 0.05) : Math.min(0, d + 0.05);
|
||||
else if (x > 1e-9) x = Math.max(0, x - 0.05);
|
||||
else break;
|
||||
}
|
||||
d = Math.round(d * 10) / 10;
|
||||
x = Math.round(x * 10) / 10;
|
||||
}
|
||||
const d = Math.max(input.adjust.d ?? input.defaultD, Math.ceil(dLow * 10) / 10);
|
||||
const x = Math.max(input.adjust.x, 0);
|
||||
const topElevation = top0 - d / FILL_SLOPE_RATIO_MIN;
|
||||
return {
|
||||
x,
|
||||
@@ -427,92 +401,6 @@ export function placePipeWall(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 invert를 그 높이로 내렸을 때 **유출 벽이 성립하는가**(2026-08-22 사용자 확정).
|
||||
* 유입이 유출보다 높아야 하므로 유입을 내리면 유출도 따라 내려간다 — 그런데 유출이
|
||||
* 원지반에 묻히고 3도 절토선이 지반과 다시 만나지 못하면 그 자리는 둘 수 없다.
|
||||
* 이 경우 **유입도 더 내려가면 안 된다**(종전에는 유입만 내려가 유출이 깨졌다).
|
||||
*/
|
||||
export function outletReceivable(input: {
|
||||
inletElevation: number;
|
||||
edge: { offset_m: number; elevation_m: number };
|
||||
outward: number;
|
||||
height: number;
|
||||
toeOffset: number;
|
||||
limitOffset: number;
|
||||
groundAt: (offset: number) => number;
|
||||
/** 유출 벽 전면 기울기(표준경사). */
|
||||
lean?: number;
|
||||
}): boolean {
|
||||
const invertAt = (offset: number): number =>
|
||||
Math.min(input.groundAt(offset), input.inletElevation);
|
||||
const auto =
|
||||
minShoulderWallOffset(
|
||||
input.edge,
|
||||
input.outward,
|
||||
input.height,
|
||||
invertAt,
|
||||
input.toeOffset,
|
||||
input.limitOffset,
|
||||
input.lean,
|
||||
) ?? input.toeOffset;
|
||||
const invert = invertAt(auto);
|
||||
if (invert >= input.groundAt(auto) - 0.01) return true;
|
||||
return (
|
||||
slopedCrossing(
|
||||
{ offset: auto, elevation: invert },
|
||||
input.outward,
|
||||
-PIPE_CONNECT_GRADE,
|
||||
input.groundAt,
|
||||
input.limitOffset,
|
||||
) !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 기슭막이 4축 배치 — placePipeWall에 **유출 수용 가능성 가드**를 얹은 것.
|
||||
* 유입을 내리면 유출도 따라 내려가므로(유입 > 유출 조건), 유출이 못 받는 높이면
|
||||
* 유입도 그만큼 못 내려간다(2026-08-22 사용자 확정).
|
||||
*/
|
||||
export function placeInletWall(input: {
|
||||
autoOffset: number;
|
||||
outward: number;
|
||||
height: number;
|
||||
baseElevation0: number;
|
||||
edgeElevation: number;
|
||||
invertCap: number;
|
||||
adjust: WallAdjust;
|
||||
defaultD: number;
|
||||
groundAt: (offset: number) => number;
|
||||
limitOffset: number;
|
||||
/** 유출 쪽 수용 검사 인자 — 없으면 가드 없이 배치한다. */
|
||||
outletGuard?: {
|
||||
edge: { offset_m: number; elevation_m: number };
|
||||
outward: number;
|
||||
height: number;
|
||||
toeOffset: number;
|
||||
limitOffset: number;
|
||||
lean?: number;
|
||||
};
|
||||
/** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */
|
||||
requireCrossing?: boolean;
|
||||
}): PipeWallPlacement {
|
||||
const place = (adjust: WallAdjust): PipeWallPlacement =>
|
||||
placePipeWall({ ...input, adjust, requireCrossing: input.requireCrossing === true });
|
||||
let placed = place(input.adjust);
|
||||
const guard = input.outletGuard;
|
||||
if (!guard) return placed;
|
||||
const receivable = (invert: number): boolean =>
|
||||
outletReceivable({ inletElevation: invert, groundAt: input.groundAt, ...guard });
|
||||
for (let pass = 0; pass < 400 && placed.d > 1e-9 && !receivable(placed.invert); pass += 1) {
|
||||
placed = place({
|
||||
...input.adjust,
|
||||
d: Math.max(0, Math.round((placed.d - 0.05) * 100) / 100),
|
||||
});
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
/** 관 축·법선·양 끝단 꼭짓점을 한 번에 푸는 도구(본체 700줄 제한 — 2026-08-22). */
|
||||
export interface PipeAxisSolver {
|
||||
axis: OffsetPoint;
|
||||
|
||||
@@ -20,7 +20,6 @@ import { showToast } from "@ui/ui_template_elements";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
PIPE_CONNECT_GRADE,
|
||||
PIPE_WALL_DEFAULT_RUN_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_THICKNESS_M,
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import {
|
||||
ZERO_ADJUST,
|
||||
type CulvertDesignTrim,
|
||||
@@ -206,36 +205,12 @@ export function computeRevetmentLayout(
|
||||
* (확장량 t는 아래 폴리라인에서 역산 — t ≥ 0이 각도 유지의 한계다).
|
||||
*/
|
||||
const defaultDrop = autoRun / FILL_SLOPE_RATIO_MIN;
|
||||
/** 그 자리에 벽을 세울 수 있나 — 매몰(원지반 아래 daylight 없음) 금지. */
|
||||
const placeable = (xShift: number, drop: number): boolean => {
|
||||
const anchor = autoOffset + outward * (autoRun + xShift);
|
||||
const invert = edge.elevation_m - drop - height;
|
||||
return (
|
||||
invert >= groundAt(anchor) - 0.01 ||
|
||||
slopedCrossing(
|
||||
{ offset: anchor, elevation: invert },
|
||||
outward,
|
||||
-PIPE_CONNECT_GRADE,
|
||||
groundAt,
|
||||
limit,
|
||||
) !== null
|
||||
);
|
||||
};
|
||||
const requestedX = Math.max(adjust?.x ?? 0, 0);
|
||||
/** 각도(1:1.2)를 지키려면 그만큼은 밖으로 나가 있어야 하는 최소 수평 자리. */
|
||||
const xFloor = (drop: number): number => Math.max(0, FILL_SLOPE_RATIO_MIN * drop - autoRun);
|
||||
let appliedDrop = Math.max(adjust?.d ?? defaultDrop, 0);
|
||||
let appliedX = Math.max(requestedX, xFloor(appliedDrop));
|
||||
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedDrop); pass += 1) {
|
||||
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
|
||||
if (appliedDrop > 1e-9) {
|
||||
appliedDrop = Math.max(0, appliedDrop - 0.05);
|
||||
appliedX = Math.max(Math.min(appliedX, requestedX), xFloor(appliedDrop));
|
||||
} else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05);
|
||||
else break;
|
||||
}
|
||||
appliedX = Math.round(appliedX * 10) / 10;
|
||||
appliedDrop = Math.round(appliedDrop * 10) / 10;
|
||||
// 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45). 노면 위 금지 = d ≥ 0.
|
||||
const appliedDrop = Math.round(Math.max(adjust?.d ?? defaultDrop, 0) * 10) / 10;
|
||||
const appliedX = Math.round(Math.max(requestedX, xFloor(appliedDrop)) * 10) / 10;
|
||||
|
||||
const anchorOffset = autoOffset + outward * (autoRun + appliedX);
|
||||
const baseElevation = edge.elevation_m - appliedDrop - height;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 54-45 횡단 형상 제약표 — 배관 · 흙막이 · 옹벽
|
||||
|
||||
범위: 관 · 집수정 · 관 보호공 흙막이(유입·유출) · 추가 흙막이(다단) · 사면안정 흙막이 · 옹벽. 화면 = 서버(같은 TS 를 Node 번들로 돎 — `config/server_calc_node`). 줄 번호는 B06_Section 기준.
|
||||
|
||||
## 풂
|
||||
|
||||
| # | 무엇 | 어디 | 값 | 처리 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 관 유출 흙막이 — 원지반에 묻히면 위로 0.05m씩 되돌림(매몰-무교차 자리 금지) | 옛 `Culvert_Solve.ts:400` `requireCrossing` · 화면/서버 | 3도 절토선 교차 없으면 금지 | 이번에 풂 |
|
||||
| 2 | 독립 기슭막이 유입 벽 — 1과 같은 되돌림 | 옛 `Culvert_Geom.ts:224` · 화면/서버 | 같음 | 이번에 풂 |
|
||||
| 3 | 관 유입 흙막이 ▼ — 유출 벽이 원지반에 못 서면 유입도 못 내려감 | 옛 `Culvert_Solve.ts:436` `outletReceivable` · `placeInletWall` 가드 | 0.05m씩 되돌림 | 이번에 풂 |
|
||||
| 4 | 사면안정 흙막이 · 옹벽 — 묻히는 자리면 위 · 안쪽으로 되돌림 | 옛 `Cross_Revetment.ts:209` `placeable` | 3도 절토선 교차 없으면 금지 | 이번에 풂 |
|
||||
| 5 | 추가 흙막이 — 묻히는 자리면 d · x 되돌림 | 옛 `Culvert_Extra.ts:320` `placeable` | 이음선 위 지반 · 0.5m 이상 매몰 금지 | 이번에 풂 |
|
||||
| 6 | 추가 흙막이 — 시작점이 원지반 아래면 다단 끊음 | 옛 `Culvert_Extra.ts:241` | 단 수 줄어듦 | 이번에 풂(`Culvert_Extra.ts:241`) |
|
||||
| 7 | 추가 흙막이 — 남은 성토 0.7m 미만이면 다단 끊음 | 옛 `Culvert_Extra.ts:244` | 0.7m | 이번에 풂 |
|
||||
| 8 | 추가 흙막이 — 앉을 지형 없으면 단 안 세움 | 옛 `Culvert_Extra.ts:296` | 지형 높이 0.05m 미만 | 이번에 풂 — 시작점 바로 밖에 섬(`Culvert_Extra.ts:295`) |
|
||||
| 9 | 「좌우 이동 지형에 막힘」 값(`shiftBlockedM`) | 옛 `Culvert_Extra.ts:383` | 요청 − 적용 | 이번에 풂 — 막힘이 없어 안 나옴 |
|
||||
| 10 | 형태별 높이 한도(메 2.0 · 찰 3.0 · 콘크리트 5.0) 자르기 | 54-42 지움 | — | 54-42 풂 |
|
||||
| 11 | 추가 흙막이 원지반 맞춤 늘이기 | 54-42 지움 | — | 54-42 풂 |
|
||||
|
||||
## 남김
|
||||
|
||||
| # | 무엇 | 어디 | 값 | 남긴 까닭 |
|
||||
|---|---|---|---|---|
|
||||
| 12 | 벽 윗면 노면 위 금지 | `Cross_Wall.ts:70` · `Culvert_Solve.ts:388`(d 하한) · `Cross_Revetment.ts:212`(d ≥ 0) | 노면 표고에서 멈춤 | 브레인 규칙 ① |
|
||||
| 13 | 관 길이 m 단위 — 유출 흙막이 자리 = 관 끝 | `Culvert_Geom.ts:248` · `:435` | 올림 정수 m | 브레인 규칙 ② |
|
||||
| 14 | 최소 토피 — 유입 관 끝 ≤ 노면 − 관경 − 토피 | `Culvert_Geom.ts:128` · `Culvert_Const.ts:14` | 0.5m | 관 규칙(노면 기준 · 원지반 아님) |
|
||||
| 15 | 역경사 금지 — 유출 관 끝 ≤ 유입 관 끝 · 유출 흙막이 ▲ 한도 | `Culvert_Geom.ts:240` · `:401` · `Culvert_Solve.ts:390` | 실측 440.2: ▲ −5 요청 → d 0.5 | 물 흐름(관 규칙) · 유입이 원지반 자동값이면 간접으로 걸림 |
|
||||
| 16 | 관 시작 관 끝 자동값 = 원지반(토피 상한 안) | `Culvert_Geom.ts:129` · `:186` | 자동 자리만 | 벽을 움직이면 벽 자리를 따라감 — 잠금 아님 |
|
||||
| 17 | 관 흙막이 최소 높이 | `Culvert_Const.ts:142` | 관경 + 1.0m | 관이 벽 위로 · 바닥으로 안 나오게 |
|
||||
| 18 | 흙막이 좌우 노견 안쪽 금지 | `Culvert_Solve.ts:393` · `Cross_Revetment.ts:208` | x ≥ 0 | 원지반 무관 · 도로 안으로 못 들어감 |
|
||||
| 19 | 성토선 1:1.2 각도 유지 — 선반 하한 | `Culvert_Extra.ts:317` · `Cross_Revetment.ts:210` | x ≥ 1.2·d(실측 d 3 → x 3.6) | 원지반 무관 · 성토 물매 |
|
||||
| 20 | 다단 관통 금지 — 아랫단 윗면 ≤ 윗단 바닥 | `Culvert_Extra.ts:300` | 실측 d 0 요청 → 0.5~0.9 | 벽끼리 규칙 |
|
||||
| 21 | 다단 수 | `Cross_Structure_Panel.ts:340` | 0~9 | 화면 칸 한도 |
|
||||
| 22 | 벽 최저 높이 | `Culvert_Extra.ts:253` · `Cross_Revetment.ts:184` | 근입 + 0.1 = 0.6m | 형상이 안 무너지게 |
|
||||
| 23 | 근입 깊이 고정 | `Culvert_Const.ts:76` | 0.5m | 기하 기준선 |
|
||||
| 24 | 벽 두께 | `Culvert_Const.ts:17` | 0.45m | 기하 기준 |
|
||||
| 25 | 전면 기울기 = 표준경사 표(형태 무관) | `Cross_Lean.ts:22` | 단면유형 × 설치 측 × 높이 | 54-42 결정 |
|
||||
| 26 | 집수정 I형 벽 바닥 원지반까지 묻음 | `Culvert_Basin.ts:244` | 지반 − 부재두께 | 집수정 — 54-45 벽 범위 밖 · 사용자 확인 필요 |
|
||||
| 27 | 집수정 ㄴ·ㄷ 기본 판정(절토선 없으면 I) | `Culvert_Basin.ts:51` | 기본값만 | 고른 값은 그대로 · 잠금 아님 |
|
||||
| 28 | 집수정 내공 | `Cross_Structure_Panel.ts:309` | 1~2m | 화면 칸 한도 |
|
||||
| 29 | 집수정 좌우 노견 안쪽 금지 | `Cross_View_Structure.ts:134` | ≥ 0 | 원지반 무관 |
|
||||
| 30 | 사면안정 흙막이 · 옹벽 설치 측 빈 칸 = 성토 쪽 · 양측 절토면 안 그림 | `Cross_Revetment.ts:175` | — | 단면유형 규칙(원지반 아님) |
|
||||
| 31 | 성토사면 5m 초과 경고 | `Culvert_Const.ts:180` · `Cross_Culvert.ts:114` | 5m | 경고만 · 잠금 아님 |
|
||||
| 32 | 바닥 원지반 이격 경고 | `Cross_Culvert.ts:186` · `Cross_Revetment.ts:372` | 이격 m | 경고만 · 잠금 아님 |
|
||||
| 33 | 유입 흙막이 선택지 판정(`revetAllowed`) | `Culvert_Basin.ts:417` | 원지반 > 토피 상한 | 계산만 · 쓰는 곳 없음 — 잠금 아님 |
|
||||
|
||||
## 확인 수치(검증_sub7 · 화면 eval = 서버 번들)
|
||||
|
||||
- 440.2 · 85.1 · 720.4 관 — 유출 d 8 · x 1 그대로(깊게) · x 9 그대로(멀리 · 이격 4.3~7.2m) · 다단 2단 d 3 그대로
|
||||
- 옹벽(성토 측점에 붙임) — d 6 그대로 · ▲ −3 → 0(노면 멈춤) · 3단 중 3단
|
||||
- 440.2 유출 흙막이 ▼ 8번 누름 → 캐시 d 10 남음(되돌림 없음)
|
||||
- 서버 면적 = 화면 9건 전부 같음(절토 · 성토 ㎡ 소수 넷째 자리)
|
||||
|
||||
제약 33(남김 22)
|
||||
@@ -49,9 +49,10 @@ def test_chat_searches_all_knowledge_markdown_except_excluded_folders(monkeypatc
|
||||
result = asyncio.run(chat.ask_knowledge_chat(chat.ChatRequest(question="임도 기준"), {}))
|
||||
assert result["answer"] == "설명 [1]"
|
||||
paths = {source["path"] for source in result["sources"]}
|
||||
assert paths == {"original/법률/현행.md", "technical_info/_검토.md"}
|
||||
assert paths == {"original/법률/현행.md"}
|
||||
assert "실무문서" not in sent["prompt"]
|
||||
assert "비공개 사례" not in sent["prompt"]
|
||||
assert "STmate" not in sent["prompt"]
|
||||
assert "다산소프트" not in sent["prompt"]
|
||||
assert "기술정보 안내" not in sent["prompt"]
|
||||
chat._sections.cache_clear()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""원지반 잠금 풀기(54-45) — 흙막이 자리 · 다단 · 높이를 원지반이 막지 않음.
|
||||
|
||||
남는 규칙: 벽 윗면 노면 위 금지(d ≥ 0) · 관 보호공 흙막이 m 단위 자리(관 길이 정수).
|
||||
서버 면적은 같은 TS 를 Node 로 돌림(`B06_Section_Server_Calc_Node.ts`) — 번들도 같은 판인지 봄.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
B06 = PROJECT_ROOT / "B06_Section"
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
BUNDLE = PROJECT_ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { placePipeWall } from "./B06_Section_UI_Cross_Culvert_Solve.js";
|
||||
import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra.js";
|
||||
const pipe = (d, x) => placePipeWall({
|
||||
autoOffset: 10, outward: 1, height: 2, baseElevation0: 98, edgeElevation: 100,
|
||||
invertCap: Number.POSITIVE_INFINITY, adjust: { x, d, h: null, m: null }, defaultD: 4.8,
|
||||
});
|
||||
const extras = (ground, d) => buildOutletExtras({
|
||||
start: { offset: 10, elevation: 95 }, startBottomElevation: 94.5, outward: 1,
|
||||
groundAt: () => ground, limitOffset: 40,
|
||||
adjusts: [{ x: 0, d, h: 2, m: null }, { x: 0, d, h: 2, m: null }],
|
||||
});
|
||||
const buried = extras(100, 1);
|
||||
const high = extras(80, 3);
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
deep: pipe(6, 2), up: pipe(-5, 0),
|
||||
buriedCount: buried.walls.length, buriedD: buried.appliedAdjusts.map((a) => a.d),
|
||||
highCount: high.walls.length, highD: high.appliedAdjusts.map((a) => a.d),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_원지반이_자리_다단을_막지_않고_노면_위만_막음(tmp_path: Path) -> None:
|
||||
out = tmp_path / "js"
|
||||
for name in ("Culvert_Solve", "Culvert_Extra"):
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(B06 / f"B06_Section_UI_Cross_{name}.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--skipLibCheck",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.rglob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Culvert_Solve.js"))
|
||||
(base / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp_path / "out.json"
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(base / "runner.mjs"), str(result)], check=True, capture_output=True
|
||||
)
|
||||
got = json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
assert (got["deep"]["d"], got["deep"]["x"]) == (6, 2), "관 흙막이 깊이 · 좌우가 되돌아감"
|
||||
assert got["up"]["d"] == pytest.approx(0), "관 흙막이 윗면이 노면 위로 올라감"
|
||||
assert got["up"]["topElevation"] == pytest.approx(100), "노면 멈춤 자리 틀림"
|
||||
assert got["buriedCount"] == 2, "원지반에 묻힌 다단이 끊김"
|
||||
assert got["buriedD"] == [1, 1], "묻힌 다단의 높이가 되돌아감"
|
||||
assert got["highCount"] == 2, "원지반 위 다단이 끊김"
|
||||
assert got["highD"] == [3, 3], "뜬 다단의 높이가 되돌아감"
|
||||
|
||||
|
||||
def test_화면_서버_같은_판() -> None:
|
||||
for name in ("Culvert_Solve", "Culvert_Geom", "Revetment"):
|
||||
source = (B06 / f"B06_Section_UI_Cross_{name}.ts").read_text(encoding="utf-8")
|
||||
assert "requireCrossing" not in source, f"{name} 에 매몰 자리 금지가 남음"
|
||||
assert "outletReceivable" not in source, f"{name} 에 유출 수용 가드가 남음"
|
||||
assert "const placeable" not in (B06 / "B06_Section_UI_Cross_Revetment.ts").read_text(
|
||||
encoding="utf-8"
|
||||
), "사면안정 흙막이 · 옹벽 매몰 자리 금지가 남음"
|
||||
assert "const placeable" not in (B06 / "B06_Section_UI_Cross_Culvert_Extra.ts").read_text(
|
||||
encoding="utf-8"
|
||||
), "추가 흙막이 매몰 자리 금지가 남음"
|
||||
bundle = BUNDLE.read_text(encoding="utf-8")
|
||||
assert "outletReceivable" not in bundle and "requireCrossing" not in bundle, (
|
||||
"서버 번들이 옛 판 — npm run build:server-calc"
|
||||
)
|
||||
Reference in New Issue
Block a user