feat(B06): 횡단도 안 조정창 = 제목 · 단추만 · 그림 끌기 두 축 함께 + 놓을 때 패널 걸음 맞춤 · 미리 보기 = 맞춘 자리(108-1 · 108-2)
- 조정창(오버레이): 읽기 줄 · 줄 이름 · 머리줄 집수정 ↺ 지움 · 끄는 중 미리 보기 글만 · 좌측 사본 그대로 - 끌기: 벽 · 집수정 · 세월교 = 수평 + 1:1.2 사면 · 다단 · 독립 벽 = 수평 + 수직 · BOX = 길이 + 표고 - 한계(±10 m · 노견 안쪽 · 노면 위 · 다단 선반 · BOX 길이 0)면 가장 가까운 허용 자리 · 반영 한 번 - 시험 test_108_drag_box_hit(끌기 · 걸음 · 미리 보기 = placePipeWall 결과 · 한계 · BOX 판정면) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
@@ -14,9 +14,11 @@ import {
|
||||
bindArrowKeys,
|
||||
bindDragHandle,
|
||||
buildPanelShell,
|
||||
clampAxis,
|
||||
dpadButton,
|
||||
dragReadout,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -27,6 +29,8 @@ export interface BoxPanelDeps {
|
||||
nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void;
|
||||
/** 그 끝의 구체 표고 ±0.1m. */
|
||||
nudgeRise: (role: BoxSideRole, deltaM: number) => void;
|
||||
/** 그림 끌기(108-2) — 길이(화면 좌 +) · 표고를 한 번에 반영. */
|
||||
move: (role: BoxSideRole, screenDeltaM: number, riseDeltaM: number) => void;
|
||||
/** 그 측을 자동 자리로 되돌린다. */
|
||||
reset: (role: BoxSideRole) => void;
|
||||
/** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은
|
||||
@@ -48,9 +52,10 @@ export interface BoxPanelHandle {
|
||||
|
||||
/** 한 걸음(m) — 사용자 지정 0.1m. */
|
||||
const STEP_M = 0.1;
|
||||
const RISE_LIMIT_M = 10;
|
||||
|
||||
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. 값 칸은 없다(계획서 24-4a) —
|
||||
* 오버레이 = 읽기 + 십자 + 그림 끌기 손잡이 · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
|
||||
* 오버레이 = 제목 + 십자 + 그림 끌기 손잡이(108-1) · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
|
||||
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
|
||||
const shell = buildPanelShell(() => deps.close());
|
||||
const { root, title } = shell;
|
||||
@@ -63,7 +68,8 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
|
||||
const riseLine = document.createElement("span");
|
||||
const slopeLine = document.createElement("span");
|
||||
const dragLine = dragReadout();
|
||||
value.append(lengthLine, riseLine, slopeLine, dragLine);
|
||||
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
|
||||
value.append(...(opts?.dock ? [lengthLine, riseLine, slopeLine] : []), dragLine);
|
||||
|
||||
let current: BoxSideRole | null = null;
|
||||
const act = (run: (role: BoxSideRole) => void) => () => {
|
||||
@@ -106,17 +112,27 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
|
||||
|
||||
const off = { attach: (): void => undefined, detach: (): void => undefined };
|
||||
const arrows = opts?.dock ? off : bindArrowKeys(root, () => [moveRow.controls]);
|
||||
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고(십자와 같은 걸음 · 24-4a).
|
||||
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고 두 축 함께(십자와 같은 걸음 · 108-2).
|
||||
// 한계 = 길이 ≥ 0(바깥으로만) · 표고 ±10 m(제어기 `_Page_Ford_Controls` 와 같음).
|
||||
const drag = opts?.dock
|
||||
? off
|
||||
: bindDragHandle(root, {
|
||||
stepM: () => (current ? STEP_M : null),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (left) deps.nudgeLength(current, left * STEP_M);
|
||||
if (down) deps.nudgeRise(current, -down * STEP_M);
|
||||
snap: (leftM, downM) => {
|
||||
const role = current;
|
||||
if (!role) return null;
|
||||
const from = deps.adjustFor(role);
|
||||
const lengthSign = role === "left" ? 1 : -1;
|
||||
return snapAxisDrag(leftM, downM, {
|
||||
stepM: STEP_M,
|
||||
slopeOutward: null,
|
||||
clamp: (left, down) => [
|
||||
clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign,
|
||||
-clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) => deps.move(role, left, -down),
|
||||
});
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
bindArrowKeys,
|
||||
bindDragHandle,
|
||||
buildPanelShell,
|
||||
clampAxis,
|
||||
dpadButton,
|
||||
dragReadout,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -32,6 +34,10 @@ export interface FordPanelDeps {
|
||||
nudge: (role: FordWallRole, screenDeltaM: number) => void;
|
||||
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
|
||||
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
|
||||
/** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */
|
||||
outwardOf: (role: FordWallRole) => number;
|
||||
/** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */
|
||||
move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void;
|
||||
/** 자동 자리로 되돌린다(세 축 모두). */
|
||||
reset: (role: FordWallRole) => void;
|
||||
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
|
||||
@@ -48,11 +54,13 @@ export interface FordPanelHandle {
|
||||
|
||||
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
|
||||
const STEP_M = 0.1;
|
||||
/** 좌우 · 상하 한계(m) — 제어기 `clampMove`(`_Page_Ford_Controls`)와 같다. */
|
||||
const MOVE_LIMIT_M = 10;
|
||||
|
||||
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
|
||||
* 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a).
|
||||
* `opts.dock` — 읽기 줄만 만든다(측벽 높이 줄 없음 · 107-3).
|
||||
* 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */
|
||||
* 오버레이는 제목 + 십자 + 그림 끌기 손잡이(108-1). */
|
||||
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
|
||||
const shell = buildPanelShell(() => deps.close());
|
||||
const { root, title } = shell;
|
||||
@@ -65,7 +73,8 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
const moveLine = document.createElement("span");
|
||||
const slabLine = document.createElement("span");
|
||||
const dragLine = dragReadout();
|
||||
value.append(heightLine, moveLine, slabLine, dragLine);
|
||||
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
|
||||
value.append(...(opts?.dock ? [heightLine, moveLine, slabLine] : []), dragLine);
|
||||
|
||||
let current: FordWallRole | null = null;
|
||||
const act = (run: (role: FordWallRole) => void) => () => {
|
||||
@@ -110,17 +119,27 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
const arrows = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindArrowKeys(root, () => [moveRow.controls]);
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a).
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2).
|
||||
// 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m.
|
||||
const drag = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindDragHandle(root, {
|
||||
stepM: () => (current ? STEP_M : null),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (left) deps.nudge(current, left * STEP_M);
|
||||
if (down) deps.nudgeSlope(current, down * STEP_M);
|
||||
snap: (leftM, downM) => {
|
||||
const role = current;
|
||||
if (!role) return null;
|
||||
const from = deps.adjustFor(role);
|
||||
const outward = deps.outwardOf(role);
|
||||
return snapAxisDrag(leftM, downM, {
|
||||
stepM: STEP_M,
|
||||
slopeOutward: outward,
|
||||
clamp: (left, down) => [
|
||||
clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward,
|
||||
clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) => deps.move(role, left, down),
|
||||
});
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보.
|
||||
* ========================================================================== */
|
||||
|
||||
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
|
||||
export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
@@ -89,19 +91,77 @@ export function arrowsBelongToProfile(): boolean {
|
||||
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
|
||||
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
|
||||
|
||||
/** 끌기 손잡이가 조정 창에 묻는 것 — 걸음(m)이 null 이면 지금 고른 것은 끌 수 없다. */
|
||||
/** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */
|
||||
export interface DragSnap {
|
||||
leftM: number;
|
||||
downM: number;
|
||||
text: string;
|
||||
apply: () => void;
|
||||
}
|
||||
|
||||
/** 끌기 손잡이가 조정 창에 묻는 것. */
|
||||
export interface DragHandleDeps {
|
||||
stepM: () => number | null;
|
||||
/** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */
|
||||
snap: (leftM: number, downM: number) => DragSnap | null;
|
||||
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
|
||||
preview: (text: string) => void;
|
||||
/** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */
|
||||
commit: (stepsLeft: number, stepsDown: number) => void;
|
||||
}
|
||||
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
const clampTo = (value: number, low: number, high: number): number =>
|
||||
Math.min(Math.max(value, low), high);
|
||||
|
||||
/** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */
|
||||
export interface AxisDragSpec {
|
||||
stepM: number;
|
||||
/** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */
|
||||
slopeOutward: number | null;
|
||||
/** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */
|
||||
clamp: (left: number, down: number) => [number, number];
|
||||
apply: (left: number, down: number) => void;
|
||||
}
|
||||
|
||||
/** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */
|
||||
const SLOPE_RUN = FILL_SLOPE_RATIO_MIN;
|
||||
|
||||
/**
|
||||
* 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께.
|
||||
* 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면
|
||||
* `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다.
|
||||
*/
|
||||
export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap {
|
||||
const { stepM, slopeOutward: outward } = spec;
|
||||
const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM);
|
||||
const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN);
|
||||
const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown);
|
||||
const [left, down] = spec.clamp(wantLeft, wantDown).map(round1);
|
||||
return {
|
||||
leftM: left + (outward ?? 0) * down,
|
||||
downM: outward === null ? down : down / SLOPE_RUN,
|
||||
text: moveText(left, down),
|
||||
apply: () => spec.apply(left, down),
|
||||
};
|
||||
}
|
||||
|
||||
/** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */
|
||||
function moveText(left: number, down: number): string {
|
||||
return [
|
||||
left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "",
|
||||
down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */
|
||||
export function clampAxis(from: number, move: number, low: number, high: number): number {
|
||||
return round1(clampTo(from + move, low, high) - from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
|
||||
* 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다.
|
||||
* 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
|
||||
* 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가
|
||||
* 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
|
||||
*/
|
||||
export function bindDragHandle(
|
||||
root: HTMLElement,
|
||||
@@ -126,33 +186,22 @@ export function bindDragHandle(
|
||||
event.clientY <= box.bottom + 3
|
||||
);
|
||||
});
|
||||
const stepM = deps.stepM();
|
||||
const pxPerM = Number(svg.dataset.pxPerM);
|
||||
const exaggeration = Number(svg.dataset.exaggeration) || 1;
|
||||
const ctm = layer.getScreenCTM();
|
||||
if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return;
|
||||
if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const [startX, startY] = [event.clientX, event.clientY];
|
||||
let left = 0;
|
||||
let down = 0;
|
||||
let snapped: DragSnap | null = null;
|
||||
const move = (next: PointerEvent): void => {
|
||||
const dx = next.clientX - startX;
|
||||
const dy = next.clientY - startY;
|
||||
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
||||
left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0;
|
||||
down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM);
|
||||
const tx = -left * stepM * pxPerM;
|
||||
const ty = down * stepM * pxPerM * exaggeration;
|
||||
const leftM = -(next.clientX - startX) / (pxPerM * ctm.a);
|
||||
const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d);
|
||||
snapped = deps.snap(leftM, downM);
|
||||
const tx = -(snapped?.leftM ?? 0) * pxPerM;
|
||||
const ty = (snapped?.downM ?? 0) * pxPerM * exaggeration;
|
||||
shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`));
|
||||
const meters = (Math.abs(left || down) * stepM).toFixed(1);
|
||||
deps.preview(
|
||||
left
|
||||
? `${left > 0 ? "◀" : "▶"} ${meters}m`
|
||||
: down
|
||||
? `${down > 0 ? "▼" : "▲"} ${meters}m`
|
||||
: "",
|
||||
);
|
||||
deps.preview(snapped?.text ?? "");
|
||||
};
|
||||
const up = (): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
@@ -160,12 +209,12 @@ export function bindDragHandle(
|
||||
window.removeEventListener("pointercancel", up);
|
||||
shapes.forEach((shape) => shape.removeAttribute("transform"));
|
||||
deps.preview("");
|
||||
if (!left && !down) return;
|
||||
if (!snapped?.text) return;
|
||||
// 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다.
|
||||
const swallow = (click: Event): void => click.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
deps.commit(left, down);
|
||||
snapped.apply();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
dragReadout,
|
||||
makeButton,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
type AxisDragSpec,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -66,6 +68,8 @@ export interface StructurePanelDeps {
|
||||
canNudge: (key: RevetKey) => boolean;
|
||||
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
|
||||
fineMove: (key: RevetKey) => boolean;
|
||||
/** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */
|
||||
dragAxes?: (key: RevetKey, basin: boolean) => Omit<AxisDragSpec, "stepM"> | null;
|
||||
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
|
||||
extraState: () => { canAdd: boolean; count: number };
|
||||
/** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만
|
||||
@@ -456,9 +460,8 @@ export function buildStructurePanel(
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "b06-structure-panel__head";
|
||||
const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin());
|
||||
basinReset.classList.add("b06-structure-panel__basin-reset");
|
||||
head.append(title, basinReset, closeButton);
|
||||
// 집수정 기본값은 위치 십자 가운데 ↺ 하나(108-1 — 머리줄은 제목 · 닫기만).
|
||||
head.append(title, closeButton);
|
||||
// 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작
|
||||
// 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다.
|
||||
// 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7).
|
||||
@@ -477,22 +480,16 @@ export function buildStructurePanel(
|
||||
: [basinRow.row, moveRow.row]),
|
||||
);
|
||||
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만.
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 두 축 함께 · 놓을 때 한 번(108-2). 오버레이만.
|
||||
let dragKind: "wall" | "basin" | null = null;
|
||||
const drag = bindDragHandle(root, {
|
||||
stepM: () =>
|
||||
!current || !dragKind ? null : dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (dragKind === "basin") {
|
||||
if (left) deps.nudgeBasinLateral(left * BASIN_STEP_M);
|
||||
if (down) moveBasinSlope(down);
|
||||
return;
|
||||
}
|
||||
if (left) deps.nudge(current, left * moveStepOf(current));
|
||||
if (down) deps.nudgeSlope(current, down * moveStepOf(current));
|
||||
snap: (leftM, downM) => {
|
||||
const axes = current && dragKind ? deps.dragAxes?.(current, dragKind === "basin") : null;
|
||||
if (!current || !axes) return null;
|
||||
const stepM = dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current);
|
||||
return snapAxisDrag(leftM, downM, { stepM, ...axes });
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -540,7 +537,6 @@ export function buildStructurePanel(
|
||||
.querySelector(".b06-structure-panel__btn--equal")
|
||||
?.classList.toggle("is-hidden", isOwn || key !== "outlet");
|
||||
basinRow.row.classList.toggle("is-hidden", !isBasin);
|
||||
basinReset.classList.toggle("is-hidden", !isBasin);
|
||||
// I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다.
|
||||
for (const entry of basinSizeRows) {
|
||||
// 화면에서 숨김(54-40 · 값 · 그림 계산은 그대로)
|
||||
|
||||
@@ -30,6 +30,13 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps {
|
||||
}),
|
||||
nudgeRise: (role, deltaM) =>
|
||||
box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }),
|
||||
move: (role, screenDeltaM, riseDeltaM) => {
|
||||
const from = box.adjustFor(chainageM)[role];
|
||||
box.update(chainageM, role, {
|
||||
lengthM: Math.round((from.lengthM + screenDeltaM * (role === "left" ? 1 : -1)) * 10) / 10,
|
||||
riseM: Math.round((from.riseM + riseDeltaM) * 10) / 10,
|
||||
});
|
||||
},
|
||||
reset: (role) => box.reset(chainageM, role),
|
||||
wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"),
|
||||
bodyLengthM: () => context.layout()?.bodyLengthM ?? 0,
|
||||
|
||||
@@ -39,6 +39,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
|
||||
ford.update(chainage, role, {
|
||||
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
|
||||
}),
|
||||
outwardOf: (role) => outwardOf(section, role),
|
||||
move: (role, screenDeltaM, slopeDeltaM) => {
|
||||
const from = ford.adjustFor(chainage)[role];
|
||||
ford.update(chainage, role, {
|
||||
lateralM: from.lateralM + screenDeltaM * outwardOf(section, role),
|
||||
slopeM: from.slopeM + slopeDeltaM,
|
||||
});
|
||||
},
|
||||
reset: (role) => ford.reset(chainage, role),
|
||||
slabLengthM: context.slabLengthM,
|
||||
close: context.close,
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import {
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
PIPE_WALL_DEFAULT_RUN_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_FORM_DEFAULT,
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { clampAxis, type AxisDragSpec } from "./B06_Section_UI_Cross_Panel_Base";
|
||||
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
@@ -149,6 +152,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
|
||||
fineMove: (key) =>
|
||||
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
|
||||
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
|
||||
extraState: () => ctx.extraState(),
|
||||
isLinkedCard: () => ctx.isLinked,
|
||||
basinExtraState: () => ctx.basinExtraState(),
|
||||
@@ -222,6 +226,70 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
};
|
||||
}
|
||||
|
||||
/** 끌기 한계 ±10 m — 제어기 `clampMove`(`_Page_Station_Controls`)와 같다. */
|
||||
const DRAG_LIMIT_M = 10;
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
|
||||
/**
|
||||
* 벽 · 집수정 그림 끌기 두 축(108-2) — 축 뜻 · 한계 · 한 번 반영. 한계는 저장 · 기하가 거는 것 중
|
||||
* 미리 알 수 있는 것(±10 m · 안쪽 금지 · 노면 위 금지 · 다단 선반 1.2·d) — 넘으면 가장 가까운 허용 자리.
|
||||
* · 배관 기준벽 · 집수정 = ▲▼ 1:1.2 사면(`placePipeWall` · `_Culvert_Basin` 사면 이동)
|
||||
* · 다단 · 독립 벽 = ▲▼ 순수 수직(`_Culvert_Extra` · `_Revetment` 축)
|
||||
*/
|
||||
function wallDragAxes(
|
||||
ctx: StructurePanelContext,
|
||||
key: RevetKey,
|
||||
basin: boolean,
|
||||
): Omit<AxisDragSpec, "stepM"> | null {
|
||||
if (!ctx.revetOffset) return null;
|
||||
const outward = ctx.outwardOf(basin ? "inlet" : key);
|
||||
if (basin) {
|
||||
const from = ctx.inletStructure?.adjustFor(ctx.section);
|
||||
if (!from) return null;
|
||||
const lateralMove = (left: number): number =>
|
||||
clampAxis(from.lateralM, left * outward, 0, DRAG_LIMIT_M);
|
||||
return {
|
||||
slopeOutward: outward,
|
||||
clamp: (left, down) => [
|
||||
lateralMove(left) * outward,
|
||||
clampAxis(from.slopeM, down, 0, DRAG_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) =>
|
||||
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
|
||||
...from,
|
||||
lateralM: round1(from.lateralM + left * outward),
|
||||
slopeM: round1(from.slopeM + down),
|
||||
}),
|
||||
};
|
||||
}
|
||||
const adjust = ctx.adjustOf(key);
|
||||
const d0 = adjust.d ?? ctx.appliedD().get(key) ?? 0;
|
||||
const diagonal = key === "inlet" || key === "outlet";
|
||||
// x 하한 — 배관 기준벽 = 안쪽 금지(흙막이는 본체 끝이 0점이라 안 막음 · 73-4) · 다단 = 선반 ≥ 0 ·
|
||||
// 독립 = 성토선 각도(1.2·d − 기본 run)
|
||||
const xLow = (d: number): number =>
|
||||
isTierKey(key)
|
||||
? Math.max(0, FILL_SLOPE_RATIO_MIN * d)
|
||||
: key === "own"
|
||||
? FILL_SLOPE_RATIO_MIN * d - PIPE_WALL_DEFAULT_RUN_M
|
||||
: ctx.bodyGuard?.()
|
||||
? -DRAG_LIMIT_M
|
||||
: 0;
|
||||
return {
|
||||
slopeOutward: diagonal ? outward : null,
|
||||
clamp: (left, down) => {
|
||||
const dMove = clampAxis(d0, down, 0, DRAG_LIMIT_M);
|
||||
const low = Math.max(xLow(d0 + dMove), -DRAG_LIMIT_M);
|
||||
return [clampAxis(adjust.x, left * outward, low, DRAG_LIMIT_M) * outward, dMove];
|
||||
},
|
||||
apply: (left, down) =>
|
||||
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
|
||||
x: round1(adjust.x + left * outward),
|
||||
d: round1(d0 + down),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
|
||||
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
|
||||
|
||||
@@ -174,8 +174,9 @@
|
||||
grid-area: se;
|
||||
}
|
||||
|
||||
.b06-structure-panel__basin-reset {
|
||||
margin-left: auto;
|
||||
/* 횡단도 안 조정창 = 제목 · 단추만(108-1) — 줄 이름은 좌측 사본에만. */
|
||||
.b06-structure-panel:not(.b06-structure-panel--dock) .b06-structure-panel__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-structure-panel__select {
|
||||
@@ -297,11 +298,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 집수정 기본값(↺)은 9키와 한 세트라 오버레이에만 둔다. */
|
||||
.b06-structure-panel--dock .b06-structure-panel__basin-reset {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 좌측 폼과 중복된 행은 사본에서 숨긴다(2026-08-29 사용자 지시 3·5):
|
||||
* 형태 = 유입구·유출구 그룹의 "형태", 구조물 형식 = 유입구 "구조"로 합쳤다. */
|
||||
.b06-structure-panel--dock .b06-structure-panel__struct--form,
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/* PLAN 108 단위검증 헬퍼 — 조정창 끌기 두 축 · 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
|
||||
* 끌기 손잡이(`bindDragHandle`)는 가로채서 창이 넘긴 `snap` 을 그대로 부른다.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
/** 가로챌 모듈 — 아래에서 채운다(창 모듈을 부르기 전에). */
|
||||
let STUBS = {};
|
||||
|
||||
// ── 화면 그릇 흉내 — 조정창이 쓰는 만큼만 ─────────────────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
||||
toggle: (name, force) => {
|
||||
const on = force === undefined ? !self._classes.has(name) : !!force;
|
||||
on ? self._classes.add(name) : self._classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
contains: (name) => self._classes.has(name),
|
||||
};
|
||||
}
|
||||
set className(value) {
|
||||
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(value) {
|
||||
this._text = String(value);
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(k));
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
}
|
||||
addEventListener() {}
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const walk = (node) =>
|
||||
node.children.forEach((kid) => {
|
||||
if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid);
|
||||
walk(kid);
|
||||
});
|
||||
walk(this);
|
||||
return found;
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
}
|
||||
globalThis.document = {
|
||||
createElement: (tag) => new FakeNode(tag),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} };
|
||||
globalThis.requestAnimationFrame = (fn) => fn();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
|
||||
return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
|
||||
const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base"));
|
||||
/** 창이 넘긴 끌기 deps — 마지막 것이 지금 창. */
|
||||
const drags = [];
|
||||
STUBS = {
|
||||
"./B06_Section_UI_Section_Common": { L: (key) => key },
|
||||
"@ui/ui_template_elements": { showToast: () => {} },
|
||||
"./B06_Section_UI_Cross_Panel_Base": {
|
||||
...base,
|
||||
bindDragHandle: (root, deps) => {
|
||||
drags.push(deps);
|
||||
return { attach: () => {}, detach: () => {} };
|
||||
},
|
||||
},
|
||||
};
|
||||
const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel"));
|
||||
const { buildBoxPanel } = loadTs(B06("B06_Section_UI_Cross_Box_Panel"));
|
||||
const { buildFordPanel } = loadTs(B06("B06_Section_UI_Cross_Ford_Panel"));
|
||||
const { structurePanelDeps } = loadTs(B06("B06_Section_UI_Cross_View_Structure"));
|
||||
const { placePipeWall } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve"));
|
||||
const { boxHitBands } = loadTs(B06("B06_Section_UI_Cross_Box"));
|
||||
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const snapOut = (s) => (s ? { leftM: r3(s.leftM), downM: r3(s.downM), text: s.text } : null);
|
||||
|
||||
// ── 벽 · 집수정 창 — 실제 배선(structurePanelDeps)으로 ─────────────────────────
|
||||
function wallCase({ key, structure = "revet", outward, adjust, appliedD = 0, basin, guard }) {
|
||||
const updates = [];
|
||||
const ctx = {
|
||||
section: { chainage_m: 100 },
|
||||
adjustChainage: () => 100,
|
||||
isLinked: false,
|
||||
revetOffset: { update: (at, role, patch) => updates.push({ role, ...patch }) },
|
||||
inletStructure: {
|
||||
valueFor: () => structure,
|
||||
adjustFor: () => basin ?? { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 },
|
||||
updateAdjust: (at, next) => updates.push({ basin: next }),
|
||||
},
|
||||
adjustOf: () => adjust,
|
||||
outwardOf: () => outward,
|
||||
heightOfWall: () => 2,
|
||||
drawnWallKeys: () => [key],
|
||||
formOfWall: () => "돌쌓기(메)",
|
||||
appliedD: () => new Map([[key, appliedD]]),
|
||||
inletIsBasin: () => structure !== "revet",
|
||||
hiddenPipe: () => false,
|
||||
bodyGuard: () => !!guard,
|
||||
fordGuard: () => false,
|
||||
inletOptions: () => ({ revetAllowed: true, basinLUAllowed: true }),
|
||||
extraState: () => ({ canAdd: true, count: 1 }),
|
||||
basinExtraState: () => ({ canAdd: false, count: 0 }),
|
||||
activeRevet: () => key,
|
||||
setActiveRevet: () => {},
|
||||
toggleRevet: () => {},
|
||||
scrollKey: "t",
|
||||
};
|
||||
const panel = buildStructurePanel(structurePanelDeps(ctx));
|
||||
panel.show(key);
|
||||
const drag = drags[drags.length - 1];
|
||||
return {
|
||||
panel,
|
||||
snap: (leftM, downM) => {
|
||||
const s = drag.snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return { snap: snapOut(s), update: updates.pop() ?? null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 배관 기준벽 자리(`placePipeWall`) — 미리 보기 = 결과 판정용. 화면 왼쪽 = offset +. */
|
||||
const wallAt = (outward, adjust) =>
|
||||
placePipeWall({
|
||||
autoOffset: 5 * outward,
|
||||
outward,
|
||||
height: 2,
|
||||
baseElevation0: 98,
|
||||
edgeElevation: 100,
|
||||
invertCap: 99,
|
||||
adjust: { x: adjust.x, d: adjust.d, h: null, m: null },
|
||||
defaultD: 0,
|
||||
});
|
||||
const shiftOf = (outward, from, to) => {
|
||||
const a = wallAt(outward, from);
|
||||
const b = wallAt(outward, to);
|
||||
return { leftM: r3(b.anchorOffset - a.anchorOffset), downM: r3(a.topElevation - b.topElevation) };
|
||||
};
|
||||
|
||||
const out = { wall: {}, box: {}, ford: {} };
|
||||
for (const outward of [1, -1]) {
|
||||
const from = { x: 0, d: 2, h: null, m: null };
|
||||
const { snap, panel } = wallCase({ key: "outlet", outward, adjust: from });
|
||||
// 대각 끌기 — 화면 왼쪽 3.3 · 아래 1.7 (outward = 1 기준), 반대편은 화면 좌우만 뒤집음
|
||||
const got = snap(3.3 * outward, 1.7);
|
||||
out.wall[`diag${outward}`] = {
|
||||
...got,
|
||||
result: got.update ? shiftOf(outward, from, got.update) : null,
|
||||
overlayHead: panel.root.children[0].children.map((c) => c.className),
|
||||
};
|
||||
}
|
||||
// 한계 — 사면 위로 넘김 · 안쪽으로 넘김 → 가장 가까운 허용 자리(d 0 · x 0)
|
||||
out.wall.limit = wallCase({
|
||||
key: "outlet",
|
||||
outward: 1,
|
||||
adjust: { x: 0.5, d: 0.5, h: null, m: null },
|
||||
}).snap(-5, -3);
|
||||
// 흙막이(세월교 · BOX) — 본체 끝이 0점이라 안쪽(음수 x)도 됨 · ±10 m 끝
|
||||
out.wall.guard = wallCase({
|
||||
key: "outlet",
|
||||
outward: 1,
|
||||
adjust: { x: 0, d: 0, h: null, m: null },
|
||||
guard: true,
|
||||
}).snap(-14, 0);
|
||||
// 다단 벽 — ▲▼ 순수 수직 · 0.1 걸음 · 선반 x ≥ 1.2·d 가 따라 나감
|
||||
out.wall.extra = wallCase({
|
||||
key: "extra0",
|
||||
outward: 1,
|
||||
adjust: { x: 0, d: 0, h: null, m: null },
|
||||
}).snap(0.04, 1.0);
|
||||
// 집수정 — 1:1.2 사면 · 0.1 걸음 · 좌우 · 사면 ≥ 0
|
||||
out.wall.basin = wallCase({
|
||||
key: "inlet",
|
||||
structure: "L",
|
||||
outward: -1,
|
||||
adjust: { x: 0, d: null, h: null, m: null },
|
||||
basin: { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0.3, slopeM: 0 },
|
||||
}).snap(-0.86, 0.25);
|
||||
out.wall.basinNone = wallCase({
|
||||
key: "inlet",
|
||||
structure: "L",
|
||||
outward: -1,
|
||||
adjust: { x: 0, d: null, h: null, m: null },
|
||||
}).snap(0.02, -0.01);
|
||||
|
||||
// ── BOX 창 — 길이(화면 좌 +) · 표고 ───────────────────────────────────────────
|
||||
function boxCase(role, adjust, leftM, downM) {
|
||||
const moves = [];
|
||||
const panel = buildBoxPanel({
|
||||
adjustFor: () => adjust,
|
||||
nudgeLength: () => {},
|
||||
nudgeRise: () => {},
|
||||
move: (r, screen, rise) => moves.push({ role: r, screen: r3(screen), rise: r3(rise) }),
|
||||
reset: () => {},
|
||||
wingRoleFor: () => "inlet",
|
||||
bodyLengthM: () => 5,
|
||||
slopeRatio: () => null,
|
||||
close: () => {},
|
||||
});
|
||||
panel.show(role);
|
||||
const s = drags[drags.length - 1].snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return {
|
||||
snap: snapOut(s),
|
||||
move: moves[0] ?? null,
|
||||
value: panel.root.children[1].children.map((c) => c.className || "line"),
|
||||
};
|
||||
}
|
||||
out.box.left = boxCase("left", { lengthM: 0, riseM: 0 }, 0.47, 0.33);
|
||||
out.box.right = boxCase("right", { lengthM: 1, riseM: 0 }, -0.47, -0.21);
|
||||
out.box.shorten = boxCase("left", { lengthM: 0.2, riseM: 9.9 }, -0.9, -0.5);
|
||||
|
||||
// ── 세월교 창 — 좌우 + 1:1.2 사면 · 0.1 걸음 ─────────────────────────────────────
|
||||
function fordCase(outward, adjust, leftM, downM) {
|
||||
const moves = [];
|
||||
const panel = buildFordPanel({
|
||||
adjustFor: () => adjust,
|
||||
heightFor: () => 1.5,
|
||||
nudge: () => {},
|
||||
nudgeSlope: () => {},
|
||||
outwardOf: () => outward,
|
||||
move: (r, screen, slope) => moves.push({ role: r, screen: r3(screen), slope: r3(slope) }),
|
||||
reset: () => {},
|
||||
slabLengthM: () => 3.2,
|
||||
close: () => {},
|
||||
});
|
||||
panel.show("inlet");
|
||||
const s = drags[drags.length - 1].snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return {
|
||||
snap: snapOut(s),
|
||||
move: moves[0] ?? null,
|
||||
value: panel.root.children[1].children.map((c) => c.className || "line"),
|
||||
};
|
||||
}
|
||||
out.ford = {
|
||||
diag: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0.53, 0.27),
|
||||
inward: fordCase(-1, { heightM: null, lateralM: 0.2, slopeM: 0 }, 0.9, 0),
|
||||
};
|
||||
|
||||
// ── BOX 판정면 — 좌측 끝 날개벽(에이프런 4 → 6) · 우측 끝 날개벽 없음 ───────────
|
||||
const layout = {
|
||||
sides: [
|
||||
{ role: "left", offset: 4, apron: [{ offset: 4 }, { offset: 6 }] },
|
||||
{ role: "right", offset: -4, apron: [] },
|
||||
],
|
||||
topSlab: [{ elevation: 99 }, { elevation: 99 }],
|
||||
bottomSlab: [{ elevation: 96.5 }, { elevation: 96.5 }],
|
||||
};
|
||||
out.hit = Object.fromEntries(
|
||||
boxHitBands(layout).map(([role, points]) => [
|
||||
role,
|
||||
{
|
||||
offsets: [...new Set(points.map((p) => r3(p.offset)))].sort((a, b) => a - b),
|
||||
elevations: [...new Set(points.map((p) => p.elevation))].sort((a, b) => a - b),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,124 @@
|
||||
"""PLAN 108 — 횡단도 안 조정창 끌기 두 축 · 패널 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Cross_Panel_Base.ts`(`snapAxisDrag` · `bindDragHandle`) ·
|
||||
`_Cross_Structure_Panel.ts` · `_Cross_View_Structure.ts`(벽 · 집수정 축 · 한계) · `_Cross_Box_Panel.ts` ·
|
||||
`_Cross_Box.ts`(`boxHitBands`). 실제 TS 를 `helper_108_drag_box_hit.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_108_drag_box_hit.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
|
||||
def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None:
|
||||
"""배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수."""
|
||||
case = out["wall"][side]
|
||||
assert case["update"] == {"role": "outlet", "x": 1, "d": 4}
|
||||
assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"]
|
||||
assert "▼ 2.0m" in case["snap"]["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
|
||||
def test_미리보기_자리는_놓은_뒤_자리와_같다(out, side) -> None:
|
||||
"""미리 보기 이동 = `placePipeWall` 로 다시 푼 벽 자리 이동(화면 왼쪽 · 아래)."""
|
||||
case = out["wall"][side]
|
||||
assert case["snap"]["leftM"] == case["result"]["leftM"]
|
||||
assert case["snap"]["downM"] == case["result"]["downM"]
|
||||
|
||||
|
||||
def test_한계면_가장_가까운_허용_자리(out) -> None:
|
||||
"""사면 위로 · 안쪽으로 넘겨 끌면 d 0 · x 0 에서 멈춤 — 원래 자리로 돌아가지 않음."""
|
||||
case = out["wall"]["limit"]
|
||||
assert case["update"] == {"role": "outlet", "x": 0, "d": 0}
|
||||
assert case["snap"] == {"leftM": -1, "downM": -0.417, "text": "▶ 0.5m · ▲ 0.5m"}
|
||||
|
||||
|
||||
def test_흙막이는_안쪽도_되고_10m_에서_멈춘다(out) -> None:
|
||||
assert out["wall"]["guard"]["update"] == {"role": "outlet", "x": -10, "d": 0}
|
||||
assert out["wall"]["guard"]["snap"]["leftM"] == -10
|
||||
|
||||
|
||||
def test_다단_벽은_순수_수직_선반이_따라_나감(out) -> None:
|
||||
"""다단 ▲▼ = 수직 하강 · x ≥ 1.2·d(선반) — 미리 보기도 그 자리."""
|
||||
case = out["wall"]["extra"]
|
||||
assert case["update"] == {"role": "extra0", "x": 1.2, "d": 1}
|
||||
assert case["snap"]["leftM"] == 1.2 and case["snap"]["downM"] == 1
|
||||
|
||||
|
||||
def test_집수정은_사면_축_0_1_걸음(out) -> None:
|
||||
case = out["wall"]["basin"]
|
||||
assert case["update"]["basin"]["lateralM"] == 0.9
|
||||
assert case["update"]["basin"]["slopeM"] == 0.3
|
||||
assert case["snap"] == {"leftM": -0.9, "downM": 0.25, "text": "▶ 0.6m · ▼ 0.3m"}
|
||||
|
||||
|
||||
def test_걸음_반도_안_끌면_반영_없음(out) -> None:
|
||||
assert out["wall"]["basinNone"] == {
|
||||
"snap": {"leftM": 0, "downM": 0, "text": ""},
|
||||
"update": None,
|
||||
}
|
||||
|
||||
|
||||
def test_BOX_끌기는_길이_표고_함께(out) -> None:
|
||||
assert out["box"]["left"]["move"] == {"role": "left", "screen": 0.5, "rise": -0.3}
|
||||
assert out["box"]["left"]["snap"]["leftM"] == 0.5
|
||||
assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0.2}
|
||||
|
||||
|
||||
def test_BOX_한계_길이_0_표고_10(out) -> None:
|
||||
"""안으로 줄이면 길이 0 에서 · 표고는 +10 m 에서 멈춤."""
|
||||
assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": 0.1}
|
||||
|
||||
|
||||
def test_세월교_끌기는_좌우_사면_함께(out) -> None:
|
||||
"""사면 d 0.3 · 좌우 0.2(0.1 걸음) — 미리 보기 = 좌 0.2 + 0.3 · 아래 0.3/1.2."""
|
||||
case = out["ford"]["diag"]
|
||||
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0.3}
|
||||
assert case["snap"] == {"leftM": 0.5, "downM": 0.25, "text": "◀ 0.2m · ▼ 0.3m"}
|
||||
|
||||
|
||||
def test_세월교_노견_안쪽은_0_에서_멈춤(out) -> None:
|
||||
"""좌우 0.2 에서 안쪽으로 0.9 끌어도 0 까지만(가장 가까운 허용 자리)."""
|
||||
case = out["ford"]["inward"]
|
||||
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0}
|
||||
assert case["snap"]["leftM"] == 0.2
|
||||
|
||||
|
||||
def test_조정창은_제목과_단추만(out) -> None:
|
||||
"""머리줄 = 제목 · 닫기(집수정 ↺ 없음) · BOX 오버레이 읽기 줄 = 끄는 중 미리 보기 하나."""
|
||||
assert out["wall"]["diag1"]["overlayHead"] == [
|
||||
"b06-structure-panel__title",
|
||||
"b06-structure-panel__btn b06-structure-panel__close",
|
||||
]
|
||||
assert out["box"]["left"]["value"] == ["b06-structure-panel__drag"]
|
||||
assert out["ford"]["diag"]["value"] == ["b06-structure-panel__drag"]
|
||||
|
||||
|
||||
def test_BOX_끝_판정면은_본체로_안_넘어감(out) -> None:
|
||||
"""본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥 · 날개벽 없는 끝 = 바깥 0.3 m 띠."""
|
||||
hit = out["hit"]
|
||||
assert hit["body"]["offsets"] == [-4, 4]
|
||||
assert hit["left"]["offsets"] == [4, 6]
|
||||
assert hit["right"]["offsets"] == [-4.3, -4]
|
||||
for role in ("body", "left", "right"):
|
||||
assert hit[role]["elevations"] == [96.5, 99]
|
||||
Reference in New Issue
Block a user