Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -33,7 +33,7 @@ const HIT_CLASS = "b06-chart__design-hit";
|
||||
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
|
||||
export const DESIGN_LINE_ATTR = "data-design-line";
|
||||
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
|
||||
const OCCLUDER_CLASSES = [
|
||||
export const OCCLUDER_CLASSES = [
|
||||
"b06-chart__culvert-revet",
|
||||
"b06-chart__culvert-basin",
|
||||
"b06-chart__ford-slab",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」).
|
||||
* 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각)
|
||||
* 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움.
|
||||
* 누름 판정(261-3) = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 보통 배율 얇은 띠도 영역 ·
|
||||
* 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘).
|
||||
* 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`).
|
||||
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀).
|
||||
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
|
||||
@@ -17,6 +19,7 @@ import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
|
||||
import {
|
||||
DESIGN_LINE_ATTR,
|
||||
mergeStraightPieces,
|
||||
OCCLUDER_CLASSES,
|
||||
pieceLabel,
|
||||
} from "./B06_Section_UI_Cross_Design_Pick";
|
||||
import {
|
||||
@@ -47,12 +50,13 @@ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
|
||||
};
|
||||
}
|
||||
|
||||
function mToPx(axes: CrossAxesOptions): (p: Pt) => string {
|
||||
/** m → 그림 px(`join` 하면 `x,y`). */
|
||||
function mToPx(axes: CrossAxesOptions): (p: Pt) => Pt {
|
||||
return ([offset, elevation]) => {
|
||||
const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration;
|
||||
const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter;
|
||||
const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter;
|
||||
return `${x},${y}`;
|
||||
return [x, y];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +88,12 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
|
||||
points: points.map(([offset, elevation]) => ({ offset, elevation })),
|
||||
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
|
||||
});
|
||||
else if (OCCLUDER_CLASSES.some((name) => el.classList.contains(name)))
|
||||
// 벽 · 집수정 · 본체 둘레(261-3) — 설계선 끝 · 비탈 밑을 잇는 구조물 선(닫힌 고리)
|
||||
runs.push({
|
||||
points: [...points, points[0]].map(([offset, elevation]) => ({ offset, elevation })),
|
||||
structure: true,
|
||||
});
|
||||
else if (el.classList.contains("b06-chart__cross-profile"))
|
||||
others.push({ line: "ground", points });
|
||||
else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points });
|
||||
@@ -200,6 +210,67 @@ export interface DetailSelect {
|
||||
detach: () => void;
|
||||
}
|
||||
|
||||
/** 선 누름 거리(화면 px) — 이 안이면 선 · 영역 안이면 맞은편 선까지의 ⅓ 안이어야 선(261-3). */
|
||||
export const LINE_PICK_PX = 4;
|
||||
|
||||
const segmentGap = (p: Pt, a: Pt, b: Pt): number => {
|
||||
const d = [b[0] - a[0], b[1] - a[1]];
|
||||
const size2 = d[0] ** 2 + d[1] ** 2;
|
||||
const t = size2
|
||||
? Math.max(0, Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / size2))
|
||||
: 0;
|
||||
return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]);
|
||||
};
|
||||
const pathGap = (p: Pt, path: Pt[]): number =>
|
||||
Math.min(...path.slice(1).map((q, i) => segmentGap(p, path[i], q)));
|
||||
const inside = (p: Pt, ring: Pt[]): boolean =>
|
||||
ring.reduce((inn, a, i) => {
|
||||
const b = ring[(i + 1) % ring.length];
|
||||
return a[1] > p[1] !== b[1] > p[1] &&
|
||||
p[0] < a[0] + ((p[1] - a[1]) * (b[0] - a[0])) / (b[1] - a[1])
|
||||
? !inn
|
||||
: inn;
|
||||
}, false);
|
||||
|
||||
/**
|
||||
* 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이.
|
||||
* 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다
|
||||
* 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역.
|
||||
*/
|
||||
export function pickAt(
|
||||
point: Pt,
|
||||
scale: number,
|
||||
paths: Pt[][],
|
||||
rings: Pt[][],
|
||||
areas: number[],
|
||||
pieces: SplitPiece[],
|
||||
): { piece: number } | { face: number } | null {
|
||||
const gaps = paths.map((path) => pathGap(point, path) * scale);
|
||||
const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0);
|
||||
const ends = (piece: SplitPiece): Pt[] => [
|
||||
piece.points[0],
|
||||
piece.points[piece.points.length - 1],
|
||||
];
|
||||
const touches = (a: SplitPiece, b: SplitPiece): boolean =>
|
||||
ends(a).some((p) =>
|
||||
ends(b).some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= LINE_TOLERANCE_M),
|
||||
);
|
||||
const across = Math.min(
|
||||
Infinity,
|
||||
...gaps.filter((_, i) => i !== nearest && !touches(pieces[i], pieces[nearest])),
|
||||
);
|
||||
const face = rings
|
||||
.flatMap((ring, i) => (inside(point, ring) ? [i] : []))
|
||||
.sort((a, b) => areas[a] - areas[b])[0];
|
||||
if (
|
||||
paths.length &&
|
||||
gaps[nearest] <= LINE_PICK_PX &&
|
||||
(face === undefined || gaps[nearest] * 2 <= across)
|
||||
)
|
||||
return { piece: nearest };
|
||||
return face === undefined ? null : { face };
|
||||
}
|
||||
|
||||
/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */
|
||||
const pieceKey = (piece: SplitPiece | undefined): string =>
|
||||
piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : "";
|
||||
@@ -220,6 +291,10 @@ export function attachDetailSelect(input: {
|
||||
let hits: SVGPolylineElement[] = [];
|
||||
let faces: Face[] = [];
|
||||
let faceEls: SVGPolygonElement[] = [];
|
||||
/** 그림 층 · 조각 · 영역 고리(그림 px) — 누름 판정. */
|
||||
let layer: SVGGElement | null = null;
|
||||
let paths: Pt[][] = [];
|
||||
let rings: Pt[][] = [];
|
||||
let selected: number[] = [];
|
||||
/** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */
|
||||
let chosenFaces: number[] = [];
|
||||
@@ -262,18 +337,25 @@ export function attachDetailSelect(input: {
|
||||
if (bar && label.parentElement !== bar) bar.prepend(label, put);
|
||||
};
|
||||
|
||||
/** 누른 자리(그림 안만) → 선 · 영역 — 판정 선 굵기 대신 거리로(261-3). */
|
||||
const pickOf = (event: MouseEvent): ReturnType<typeof pickAt> => {
|
||||
const ctm = layer?.getScreenCTM();
|
||||
if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null;
|
||||
const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse());
|
||||
const areas = faces.map((face) => face.area);
|
||||
return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces);
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const target = event.target as Element;
|
||||
const hit = target.closest?.(`.${PIECE_HIT_CLASS}`);
|
||||
const pick = pickOf(event);
|
||||
const shift = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||
const additive = shift || mode === "area";
|
||||
noteText = null;
|
||||
if (!hit) {
|
||||
const face = target.closest?.(`.${FACE_HIT_CLASS}`);
|
||||
if (face && !(mode === "area" && input.band?.(event))) {
|
||||
if (!pick || !("piece" in pick)) {
|
||||
if (pick && !(mode === "area" && input.band?.(event))) {
|
||||
// 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter)
|
||||
event.stopPropagation();
|
||||
const index = faceEls.indexOf(face as SVGPolygonElement);
|
||||
const index = pick.face;
|
||||
chosenFaces = toggleSelection(chosenFaces, index, shift);
|
||||
selected = regionEdges(chosenFaces.map((i) => faces[i]));
|
||||
show();
|
||||
@@ -284,9 +366,10 @@ export function attachDetailSelect(input: {
|
||||
show();
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
// 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬
|
||||
event.stopImmediatePropagation();
|
||||
chosenFaces = [];
|
||||
selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive);
|
||||
selected = toggleSelection(selected, pick.piece, additive);
|
||||
show();
|
||||
input.picked(additive);
|
||||
};
|
||||
@@ -297,31 +380,29 @@ export function attachDetailSelect(input: {
|
||||
// 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2)
|
||||
const keep = new Set(selected.map((i) => pieceKey(pieces[i])));
|
||||
const axes = input.axes();
|
||||
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
|
||||
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
|
||||
chosenFaces = [];
|
||||
faces = findFaces(pieces);
|
||||
const toPx = axes ? mToPx(axes) : null;
|
||||
paths = toPx ? pieces.map((piece) => piece.points.map(toPx)) : [];
|
||||
rings = toPx ? faces.map((face) => face.ring.map(toPx)) : [];
|
||||
// 영역 판정 면 먼저(밑) → 조각 판정 선(위)
|
||||
faceEls = toPx
|
||||
? faces.map((face) => {
|
||||
const el = document.createElementNS(SVG_NS, "polygon");
|
||||
el.setAttribute("points", face.ring.map(toPx).join(" "));
|
||||
el.setAttribute("class", FACE_HIT_CLASS);
|
||||
layer?.append(el);
|
||||
return el;
|
||||
})
|
||||
: [];
|
||||
hits = toPx
|
||||
? pieces.map((piece) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute("points", piece.points.map(toPx).join(" "));
|
||||
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`);
|
||||
layer?.append(hit);
|
||||
return hit;
|
||||
})
|
||||
: [];
|
||||
faceEls = rings.map((ring) => {
|
||||
const el = document.createElementNS(SVG_NS, "polygon");
|
||||
el.setAttribute("points", ring.join(" "));
|
||||
el.setAttribute("class", FACE_HIT_CLASS);
|
||||
layer?.append(el);
|
||||
return el;
|
||||
});
|
||||
hits = paths.map((path, i) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute("points", path.join(" "));
|
||||
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${pieces[i].line}`);
|
||||
layer?.append(hit);
|
||||
return hit;
|
||||
});
|
||||
show();
|
||||
},
|
||||
setMode(kind) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom";
|
||||
import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select";
|
||||
import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select";
|
||||
|
||||
/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */
|
||||
export const MAX_ROWS = 12;
|
||||
@@ -393,10 +393,9 @@ export function attachDetailTable(host: DetailTableHost): {
|
||||
document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
|
||||
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 조각 누름은 위가 맡음
|
||||
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 누름은 위가 막음(261-3 거리 판정)
|
||||
const onPick = (event: MouseEvent): void => {
|
||||
if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`))
|
||||
return;
|
||||
if (picking?.kind !== "area") return;
|
||||
const hit = bandAt(event);
|
||||
const source = hit ? areaSourceOf(hit) : null;
|
||||
if (!source) return;
|
||||
|
||||
@@ -185,7 +185,8 @@
|
||||
.b06-detail__piece {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 10;
|
||||
/* 누름 판정은 거리(LINE_PICK_PX 4) — 굵기는 걸침 표시만 */
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: stroke;
|
||||
|
||||
@@ -131,16 +131,18 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict
|
||||
|
||||
def route_bill_key(route: dict[str, Any], index: int = 0) -> str:
|
||||
"""거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` ·
|
||||
한 상자 둘째 행부터 `haul:r-{id}.{n}`(경로 id 에 「.」 없음 — 안 겹침)."""
|
||||
한 상자 둘째 행부터 `.{n}`(261-4 자동 상자도 · 경로 id · 묶음 키에 「.」 없음 — 안 겹침)."""
|
||||
tail = f".{index}" if index else ""
|
||||
if route.get("haul"):
|
||||
return bill_key(str(route["haul"]))
|
||||
return f"haul:r-{route.get('id')}" + (f".{index}" if index else "")
|
||||
return bill_key(str(route["haul"])) + tail
|
||||
return f"haul:r-{route.get('id')}" + tail
|
||||
|
||||
|
||||
def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""거리이정표 표 행 하나 = 운반공 줄 하나(253-1) — [{key, name, ref, user, L1, L2}] ·
|
||||
"""거리이정표 표 행 하나 = 운반공 줄 하나(253-1 · 261-4 단가연결 무관) —
|
||||
[{key, name, ref, user, haul, L1, L2}] · name = 행 항목명 그대로(없으면 거래처 · 그것도 없으면 빈칸) ·
|
||||
L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) ·
|
||||
user = 사용자 상자(자동 상자는 총괄자재 묶음 줄이 이미 있음 · 둘째 행은 안 씀)."""
|
||||
user = 사용자 상자 · haul = 자동 상자 묶음 키(첫 행은 총괄자재 묶음 줄 이름 · 거리)."""
|
||||
routes = {r.get("id"): r for r in data.get("routes") or []}
|
||||
out, seen = [], set()
|
||||
for row in table:
|
||||
@@ -149,13 +151,13 @@ def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[st
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
item = str(row.get("item") or "").strip()
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": f"{item}운반" if item else "운반",
|
||||
"name": str(row.get("item") or "").strip() or str(route.get("name") or "").strip(),
|
||||
"ref": route.get("ref"),
|
||||
"user": not route.get("haul"),
|
||||
"haul": route.get("haul"),
|
||||
"L1": row.get("paved"),
|
||||
"L2": row.get("unpaved"),
|
||||
}
|
||||
|
||||
@@ -111,48 +111,45 @@ def haul_items(
|
||||
routes: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) +
|
||||
거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) ·
|
||||
거리이정표 행(253-1 · `B08_Quantity_Haul.haul_rows` · 행 하나 = 줄 하나 · 둘째 행부터 수량 빈칸) ·
|
||||
이름 = 거리이정표 항목명 그대로(261-4 · 「운반」 안 붙임 · 비면 거래처 · 그것도 없으면 묶음 이름 · 「운반」) ·
|
||||
연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) ·
|
||||
`dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None)."""
|
||||
dists = {r["key"]: {"L1": r.get("L1"), "L2": r.get("L2")} for r in routes or []}
|
||||
rows = routes or []
|
||||
by_key = {r["key"]: r for r in rows}
|
||||
extra: dict[str, list[dict[str, Any]]] = {} # 자동 상자 둘째 행부터 — 묶음 줄 바로 뒤
|
||||
for r in rows:
|
||||
if r.get("haul") and r["key"] != bill_key(str(r["haul"])):
|
||||
extra.setdefault(str(r["haul"]), []).append(r)
|
||||
|
||||
def line(key: str, name: str, unit: str, qty: Any, note: str) -> dict[str, Any]:
|
||||
ref = (refs or {}).get(key)
|
||||
route = by_key.get(key) or {}
|
||||
return {
|
||||
"key": key,
|
||||
"section": "3",
|
||||
"name": name,
|
||||
"spec": "",
|
||||
"unit": unit,
|
||||
"qty": qty,
|
||||
"note": note,
|
||||
"ref": ref,
|
||||
"dist": {"L1": route["L1"], "L2": route["L2"]} if route else None,
|
||||
"why": WHY_LOGIC if (ref or {}).get("tab") == "logic" else WHY_HAUL,
|
||||
}
|
||||
|
||||
out = []
|
||||
for g in haul_groups(materials):
|
||||
qty = g["qty"]
|
||||
ref = (refs or {}).get(bill_key(g["key"]))
|
||||
logic = (ref or {}).get("tab") == "logic"
|
||||
key = bill_key(g["key"])
|
||||
name = (by_key.get(key) or {}).get("name") or g["name"]
|
||||
out.append(
|
||||
{
|
||||
"key": bill_key(g["key"]),
|
||||
"section": "3",
|
||||
"name": f"{g['name']}운반",
|
||||
"spec": "",
|
||||
"unit": g["unit"],
|
||||
"qty": qty,
|
||||
"note": "B08 총괄자재" if qty is not None else "",
|
||||
"ref": ref,
|
||||
"dist": dists.get(bill_key(g["key"])),
|
||||
"why": WHY_LOGIC if logic else WHY_HAUL,
|
||||
}
|
||||
)
|
||||
for r in routes or []:
|
||||
if not r.get("user"):
|
||||
continue
|
||||
ref = (refs or {}).get(r["key"])
|
||||
logic = (ref or {}).get("tab") == "logic"
|
||||
out.append(
|
||||
{
|
||||
"key": r["key"],
|
||||
"section": "3",
|
||||
"name": r["name"],
|
||||
"spec": "",
|
||||
"unit": "",
|
||||
"qty": None,
|
||||
"note": "B08 거리이정표",
|
||||
"ref": ref,
|
||||
"dist": dists.get(r["key"]),
|
||||
"why": WHY_LOGIC if logic else WHY_HAUL,
|
||||
}
|
||||
line(key, name, g["unit"], g["qty"], "B08 총괄자재" if g["qty"] is not None else "")
|
||||
)
|
||||
for r in extra.get(g["key"], []):
|
||||
out.append(line(r["key"], r.get("name") or g["name"], "", None, "B08 거리이정표"))
|
||||
for r in rows:
|
||||
if r.get("user"):
|
||||
out.append(line(r["key"], r.get("name") or "운반", "", None, "B08 거리이정표"))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
|
||||
/** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */
|
||||
const COST_ROWS: Array<[string, string, string, string, string?]> = [
|
||||
["direct_material", "순공사원가", "재료비", "직접재료비", "material"],
|
||||
["direct_material", "순공사원가", "재료비", "직접재료비", "direct"],
|
||||
["indirect_material", "순공사원가", "재료비", "간접재료비"],
|
||||
["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"],
|
||||
["material", "순공사원가", "재료비", "소계", "direct"],
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -275,6 +275,17 @@ const layerChildren = [];
|
||||
const layer = {
|
||||
querySelectorAll: () => [...layerChildren],
|
||||
append: (hit) => layerChildren.push(hit),
|
||||
// 261-3 누름 = 거리 판정 — 화면 좌표 = 그림 px(배율 1)
|
||||
getScreenCTM: () => ({ a: 1, b: 0, inverse: () => null }),
|
||||
ownerSVGElement: { contains: () => true },
|
||||
};
|
||||
globalThis.DOMPoint = class {
|
||||
constructor(x, y) {
|
||||
[this.x, this.y] = [x, y];
|
||||
}
|
||||
matrixTransform() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
const drawGround = () => {
|
||||
layerChildren.length = 0;
|
||||
@@ -302,7 +313,17 @@ const select = DTS.attachDetailSelect({
|
||||
drawGround();
|
||||
select.redraw();
|
||||
const hit = layerChildren.find((el) => el.classes.has(DTS.PIECE_HIT_CLASS));
|
||||
click({ target: hit, shiftKey: false, ctrlKey: false, metaKey: false, stopPropagation() {} });
|
||||
const [clientX, clientY] = hit.getAttribute("points").split(" ")[0].split(",").map(Number);
|
||||
click({
|
||||
target: hit,
|
||||
clientX,
|
||||
clientY,
|
||||
shiftKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
stopPropagation() {},
|
||||
stopImmediatePropagation() {},
|
||||
});
|
||||
out.detailPicked = select.source("length")?.pieces.length ?? 0;
|
||||
axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림
|
||||
drawGround();
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/* PLAN 261-3 단위검증 헬퍼 — [상세] 누름 판정(거리 · 얇은 띠) · 구조물 둘레로 닫히는 깎기 영역.
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON
|
||||
* {cases: [{name, axes, elements: [{tag, cls, dl, pts(그림 px)}], clicks: [[x, y, scale]]}]} ·
|
||||
* 결과 = 표준출력 JSON {이름: {pieces, faces, clicks}}. 짝 = `test_261_3_detail_pick.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
globalThis.window = { sessionStorage: new Map(), localStorage: new Map() };
|
||||
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
|
||||
|
||||
const STUBS = {
|
||||
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
|
||||
"./B06_Section_UI_Section_Common": { CROSS_PAD },
|
||||
};
|
||||
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
||||
function loadTs(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts"));
|
||||
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
|
||||
|
||||
const element = (e) => ({
|
||||
tagName: e.tag,
|
||||
classList: { contains: (name) => e.cls.split(/\s+/).includes(name) },
|
||||
hasAttribute: (name) => name === "data-design-line" && e.dl,
|
||||
getAttribute: (name) => {
|
||||
if (name === "points") return e.pts.map((p) => p.join(",")).join(" ");
|
||||
const m = /^([xy])([12])$/.exec(name);
|
||||
return m ? String(e.pts[+m[2] - 1][m[1] === "x" ? 0 : 1]) : null;
|
||||
},
|
||||
});
|
||||
|
||||
const { cases } = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = {};
|
||||
for (const { name, axes, elements, clicks } of cases) {
|
||||
const pieces = S.collectPieces(elements.map(element), axes);
|
||||
const faces = G.findFaces(pieces);
|
||||
const toPx = ([offset, elevation]) => [
|
||||
CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter,
|
||||
CROSS_PAD.top +
|
||||
(axes.displayMax -
|
||||
(axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) *
|
||||
axes.pixelsPerMeter,
|
||||
];
|
||||
const paths = pieces.map((piece) => piece.points.map(toPx));
|
||||
const rings = faces.map((face) => face.ring.map(toPx));
|
||||
const areas = faces.map((face) => face.area);
|
||||
out[name] = {
|
||||
pieces: pieces.map((piece) => ({ line: piece.line, points: piece.points })),
|
||||
faces: faces.map((face) => {
|
||||
const edges = face.edges.map((i) => pieces[i]);
|
||||
const source = S.selectionSource("area", edges);
|
||||
return {
|
||||
area: face.area,
|
||||
edges: face.edges,
|
||||
lines: [...new Set(edges.map((piece) => piece.line))].sort(),
|
||||
ring: face.ring,
|
||||
source,
|
||||
};
|
||||
}),
|
||||
clicks: (clicks ?? []).map(([x, y, scale]) =>
|
||||
S.pickAt([x, y], scale, paths, rings, areas, pieces),
|
||||
),
|
||||
px: { paths, rings },
|
||||
};
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -190,8 +190,8 @@ def test_항목_짜기_B08_수량():
|
||||
"key": "pipe:revet|돌쌓기(찰)|2.0",
|
||||
}
|
||||
haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")}
|
||||
assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None,
|
||||
"시멘트운반": None, "철근운반": 0.75} # fmt: skip
|
||||
assert haul == {"중기": None, "기계": None, "모래": 16, "자갈": None,
|
||||
"시멘트": None, "철근": 0.75} # fmt: skip 261-4 묶음 이름 그대로
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
|
||||
@@ -44,8 +44,8 @@ def test_묶음_장비_옛_여섯_늘_수량_없는_새_묶음_없음():
|
||||
def test_B09_운반_줄_옛_키_그대로_새_묶음_키():
|
||||
items = {i["key"]: i for i in haul_items(_mats(ROWS))}
|
||||
assert [bill_key(k) for k in ("equip_heavy", "sand", "rebar")] == ["haul:1", "haul:3", "haul:6"]
|
||||
assert items["haul:3"]["name"] == "모래운반" and items["haul:3"]["qty"] == 16
|
||||
assert items["haul:remicon"]["name"] == "레미콘운반" and items["haul:remicon"]["unit"] == "㎥"
|
||||
assert items["haul:3"]["name"] == "모래" and items["haul:3"]["qty"] == 16
|
||||
assert items["haul:remicon"]["name"] == "레미콘" and items["haul:remicon"]["unit"] == "㎥"
|
||||
assert all(i["section"] == "3" for i in items.values())
|
||||
assert {"haul:1", "haul:2", "haul:3", "haul:4", "haul:5", "haul:6"} <= set(items)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_rows_one_per_table_row():
|
||||
assert list(got) == ["haul:3", "haul:r-u1", "haul:r-u1.1"]
|
||||
assert (got["haul:3"]["L1"], got["haul:3"]["L2"], got["haul:3"]["user"]) == (25.1, 0, False)
|
||||
assert (got["haul:r-u1"]["name"], got["haul:r-u1"]["L1"], got["haul:r-u1"]["L2"]) == (
|
||||
"혼합골재운반", 10, 0.8) # fmt: skip
|
||||
"혼합골재", 10, 0.8) # fmt: skip 261-4 항목명 그대로
|
||||
assert got["haul:r-u1.1"]["L2"] == 2.8 # 중점 = 시점 → 중점 2 km 비포장 더함
|
||||
assert got["haul:r-u1"]["ref"] == LOGIC
|
||||
|
||||
@@ -47,14 +47,14 @@ def test_bill_items_user_rows():
|
||||
extra = haul_user(rows)
|
||||
items = {i["key"]: i for i in haul_items(None, extra["haul_refs"], rows)}
|
||||
assert (
|
||||
"haul:3" in items and items["haul:3"]["name"] == "모래운반"
|
||||
) # 자동 = 총괄자재 묶음 그대로
|
||||
"haul:3" in items and items["haul:3"]["name"] == "모래"
|
||||
) # 자동 = 상자 항목명(261-4)
|
||||
user = items["haul:r-u1"]
|
||||
assert (user["section"], user["name"], user["qty"], user["ref"]) == (
|
||||
"3",
|
||||
"혼합골재운반",
|
||||
"혼합골재",
|
||||
None,
|
||||
LOGIC,
|
||||
)
|
||||
assert items["haul:r-u1.1"]["name"] == "깬돌운반" and items["haul:r-u1.1"]["ref"] == LOGIC
|
||||
assert items["haul:r-u1.1"]["name"] == "깬돌" and items["haul:r-u1.1"]["ref"] == LOGIC
|
||||
assert extra["haul_dist"]["haul:r-u1"] == {"L1": 10, "L2": 0.8}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""261-2 B09 공사원가계산서 줄 이동 대상 — 모든 줄의 대상 키가 총괄 줄 키에 있음(빈 대상 = 이동 없음 허용).
|
||||
|
||||
직접재료비 → 총괄 「가. 순공사비계」(direct) · 직접노무비 · 산출경비와 같은 자리.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B09 = ROOT / "B09_Estimation"
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220}
|
||||
|
||||
BODY = f"""
|
||||
const user = {{ estimate: {{}} }};
|
||||
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
|
||||
const T = buildTotal(ctx), C = buildCost(ctx);
|
||||
console.log(JSON.stringify({{
|
||||
totalKeys: T.rows.map((r) => r.key),
|
||||
cost: C.rows.map((r, i) => [r.key, C.link(i)]),
|
||||
}}));
|
||||
"""
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
names = ("Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total")
|
||||
script = "".join(
|
||||
IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text(encoding="utf-8")) for n in names
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript"],
|
||||
input=script + BODY, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_모든_원가_줄_이동_대상이_총괄_줄에_있음():
|
||||
got = _run()
|
||||
keys = set(got["totalKeys"])
|
||||
missing = [k for k, link in got["cost"] if link and link["key"] not in keys]
|
||||
assert not missing, missing
|
||||
assert len(got["cost"]) > 20
|
||||
|
||||
|
||||
def test_직접재료비_이동_대상_direct():
|
||||
links = {k: link for k, link in _run()["cost"]}
|
||||
assert links["direct_material"] == {"tab": "total", "key": "direct"}
|
||||
assert links["direct_labor"] == links["expense"] == links["direct_material"]
|
||||
@@ -0,0 +1,237 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 261-3 — B06 [상세] 영역 고르기: 보통 배율 얇은 띠 누름 · 깎기 토사 / 암석 영역 나눔.
|
||||
|
||||
못박는 것
|
||||
- 누름 판정 = 거리(`pickAt`) — 얇은 띠(6 px) 가운데 = 영역 · 선 1 px = 선 · 영역 밖 선 가까이 = 선 ·
|
||||
넓은 영역은 선 4 px 안 = 선 · 확대 · 축소 배율에서도 같은 판정.
|
||||
- 벽 · 집수정 둘레 = 구조물 선 — 설계선 끝 ~ 깎기 비탈 밑을 이어 깎기 암석(암선 ↔ 설계선) ·
|
||||
깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘 · 면적 = 사다리꼴 셈 · 서버 거울.
|
||||
- 실측 그림(검증_랩탑메인 4+15 배수관 · 암 · 깎기 비탈) — 깎기 암석 영역이 생김 · 보통 배율 띠 누름 = 영역.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_261_3_detail_pick.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
PAD = {
|
||||
k: float(v)
|
||||
for k, v in re.findall(
|
||||
r"(\w+): (\d+)",
|
||||
re.search(
|
||||
r"export const CROSS_PAD = \{([^}]*)\}",
|
||||
(ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").read_text(encoding="utf-8"),
|
||||
).group(1),
|
||||
)
|
||||
}
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
# ── 견본: 암 지반(암선 = 지반 − 0.6 m · 보통 배율 6 px 띠) + 오른쪽 설계 비탈 + 왼쪽 집수정 · 깎기 비탈 ──
|
||||
AXES = {
|
||||
"pixelsPerMeter": 10,
|
||||
"maxOffset": 14,
|
||||
"displayMax": 106,
|
||||
"elevationMid": 100,
|
||||
"exaggeration": 1,
|
||||
}
|
||||
ROCK = -0.6
|
||||
DESIGN = {
|
||||
"design_elevation_m": 98.0,
|
||||
"geometry_preset": "rock",
|
||||
"rock_boundary_offset_m": ROCK,
|
||||
"design_line": [{"offset_m": x, "elevation_m": y} for x, y in [(6, 104), (2, 98), (-2, 98)]],
|
||||
}
|
||||
SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)]
|
||||
|
||||
|
||||
def px(offset: float, elevation: float) -> list[float]:
|
||||
return [
|
||||
PAD["left"] + (AXES["maxOffset"] - offset) * AXES["pixelsPerMeter"],
|
||||
PAD["top"] + (AXES["displayMax"] - elevation) * AXES["pixelsPerMeter"],
|
||||
]
|
||||
|
||||
|
||||
def el(tag: str, cls: str, pts, dl: bool = False) -> dict:
|
||||
return {"tag": tag, "cls": cls, "dl": dl, "pts": [px(*p) for p in pts]}
|
||||
|
||||
|
||||
GROUND = [(x, 100.0) for x in (-10, -5, 0, 5, 10)]
|
||||
ELEMENTS = [
|
||||
el("polyline", "b06-chart__cross-profile", GROUND),
|
||||
el("polyline", "b06-chart__rock-boundary", [(x, y + ROCK) for x, y in GROUND]),
|
||||
el("line", "b06-chart__design-cross", [(6, 104), (2, 98)], dl=True),
|
||||
el("line", "b06-chart__design-cross", [(2, 98), (-2, 98)], dl=True),
|
||||
el("polygon", "b06-chart__culvert-basin", [(-2, 98), (-2, 97), (-3, 97), (-3, 98)]),
|
||||
el("polyline", "b06-chart__design-cross", [(-3, 98), (-4, 100)]), # 집수정 절토선(구조물)
|
||||
]
|
||||
RIGHT_ROCK = 2 + 1.4 * 4 / 6 # 설계 비탈(4 : 6) × 암선 99.4
|
||||
RIGHT_GROUND = 2 + 2 * 4 / 6 # 설계 비탈 × 지반 100
|
||||
LEFT_ROCK = -3 - 1.4 / 2 # 깎기 비탈(1 : 0.5) × 암선
|
||||
LEFT_GROUND = -4.0
|
||||
CUT_ROCK = ((RIGHT_ROCK - LEFT_ROCK) + 5) / 2 * 1.4 # 암선 ↔ 노면 · 집수정 윗변(-3 ~ 2)
|
||||
CUT_SOIL = ((RIGHT_GROUND - LEFT_GROUND) + (RIGHT_ROCK - LEFT_ROCK)) / 2 * 0.6
|
||||
|
||||
# 누름(그림 px · 배율) — 지반 y 70 · 암선 y 76 · 노면 y 90
|
||||
CLICKS = [
|
||||
[198, 73, 1], # 띠 가운데 = 영역(토사)
|
||||
[198, 71, 1], # 지반선 1 px = 선
|
||||
[198, 75, 1], # 암선 1 px = 선
|
||||
[198, 79, 1], # 넓은 암석 영역 · 암선 3 px = 선
|
||||
[198, 81, 1], # 암선 5 px = 영역(암석)
|
||||
[198, 67, 1], # 영역 밖 · 지반선 3 px = 선
|
||||
[198, 60, 1], # 영역 밖 · 먼 곳 = 없음
|
||||
[198, 73, 3.7], # 확대 — 띠 가운데 = 영역
|
||||
[198, 73, 0.5], # 축소 — 띠 가운데 = 영역
|
||||
]
|
||||
|
||||
|
||||
def run(cases: list[dict]) -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_261_3_detail_pick.cjs")],
|
||||
input=json.dumps({"cases": cases}),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
cwd=os.fspath(ROOT),
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
basinless = [e for e in ELEMENTS if "basin" not in e["cls"]]
|
||||
return run(
|
||||
[
|
||||
{"name": "sample", "axes": AXES, "elements": ELEMENTS, "clicks": CLICKS},
|
||||
{"name": "basinless", "axes": AXES, "elements": basinless},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def face_of(case: dict, lines: list[str]) -> tuple[int, dict]:
|
||||
found = [(i, f) for i, f in enumerate(case["faces"]) if f["lines"] == lines]
|
||||
assert len(found) == 1, [f["lines"] for f in case["faces"]]
|
||||
return found[0]
|
||||
|
||||
|
||||
def piece_line(case: dict, pick: dict) -> str:
|
||||
return case["pieces"][pick["piece"]]["line"]
|
||||
|
||||
|
||||
# ── 깎기 토사 · 암석 영역 따로 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_깎기_암석_토사_영역_따로_면적(out):
|
||||
case = out["sample"]
|
||||
_, rock = face_of(case, ["design", "rock", "structure"])
|
||||
_, soil = face_of(case, ["design", "ground", "rock", "structure"])
|
||||
assert rock["area"] == pytest.approx(CUT_ROCK, abs=1e-6)
|
||||
assert soil["area"] == pytest.approx(CUT_SOIL, abs=1e-6)
|
||||
# 집수정 안 = 따로(넓이 1) · 셋뿐(열린 지반 · 암선 바깥 제외)
|
||||
assert sorted(round(f["area"], 6) for f in case["faces"]) == sorted(
|
||||
[round(CUT_ROCK, 6), round(CUT_SOIL, 6), 1.0]
|
||||
)
|
||||
|
||||
|
||||
def test_비탈_끝이_지반선에_닿기만_해도_나눔(out):
|
||||
ground_ends = {
|
||||
round(p["points"][k][0], 4)
|
||||
for p in out["sample"]["pieces"]
|
||||
if p["line"] == "ground"
|
||||
for k in (0, -1)
|
||||
}
|
||||
assert {LEFT_GROUND, round(RIGHT_GROUND, 4)} <= ground_ends
|
||||
|
||||
|
||||
def test_집수정_없으면_암석_영역_안_닫힘(out):
|
||||
# 고침 전 꼴 — 설계선 끝 ~ 비탈 밑이 안 이어져 토사 띠만 남음
|
||||
faces = out["basinless"]["faces"]
|
||||
assert [f["lines"] for f in faces] == [["design", "ground", "rock", "structure"]]
|
||||
assert faces[0]["area"] == pytest.approx(CUT_SOIL, abs=1e-6)
|
||||
|
||||
|
||||
def test_암석_영역_서버_거울(out):
|
||||
_, rock = face_of(out["sample"], ["design", "rock", "structure"])
|
||||
number, _text, again = cell_value(rock["source"], DESIGN, SAMPLES)
|
||||
assert not again and number == pytest.approx(CUT_ROCK, abs=1e-3)
|
||||
|
||||
|
||||
# ── 누름 판정(거리) ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_얇은_띠_보통_배율_가운데_누름_영역(out):
|
||||
case = out["sample"]
|
||||
soil, _ = face_of(case, ["design", "ground", "rock", "structure"])
|
||||
rock, _ = face_of(case, ["design", "rock", "structure"])
|
||||
picks = case["clicks"]
|
||||
assert picks[0] == {"face": soil}
|
||||
assert picks[4] == {"face": rock}
|
||||
assert picks[7] == {"face": soil} and picks[8] == {"face": soil}
|
||||
|
||||
|
||||
def test_선_가까이_누름_선(out):
|
||||
case = out["sample"]
|
||||
picks = case["clicks"]
|
||||
assert piece_line(case, picks[1]) == "ground"
|
||||
assert piece_line(case, picks[2]) == "rock"
|
||||
assert piece_line(case, picks[3]) == "rock"
|
||||
assert piece_line(case, picks[5]) == "ground"
|
||||
assert picks[6] is None
|
||||
|
||||
|
||||
# ── 실측 그림(검증_랩탑메인 4+15) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def real() -> dict:
|
||||
elements = json.loads(
|
||||
(HERE / "fixtures" / "b06_261_3_cross_415.json").read_text(encoding="utf-8")
|
||||
)
|
||||
ppm = 241.954642190761 / 20 # 눈금 20 m = 241.95 px · 과장 1
|
||||
axes = {
|
||||
"pixelsPerMeter": ppm,
|
||||
"maxOffset": (708 - PAD["left"]) / ppm, # 0 m 눈금 x 708
|
||||
"displayMax": 875 + (318.52362895766817 - PAD["top"]) / ppm, # 875 m 눈금 y 318.52
|
||||
"elevationMid": 870,
|
||||
"exaggeration": 1,
|
||||
}
|
||||
case = {"name": "real", "axes": axes, "elements": elements}
|
||||
first = run([case])["real"]
|
||||
band = [(i, f) for i, f in enumerate(first["faces"]) if "ground" in f["lines"]]
|
||||
soil, face = max(band, key=lambda item: item[1]["area"]) # 깎기 토사 띠
|
||||
# 띠의 지반 조각(가장 긴 것) 가운데 꼭짓점 — 그 밑 3 px(띠 가운데) · 1 px(지반선)
|
||||
ground = max(
|
||||
(i for i in face["edges"] if first["pieces"][i]["line"] == "ground"),
|
||||
key=lambda i: len(first["pieces"][i]["points"]),
|
||||
)
|
||||
path = first["px"]["paths"][ground]
|
||||
x, y = path[len(path) // 2]
|
||||
case["clicks"] = [[x, y + 3, 1], [x, y + 1, 1]]
|
||||
return {**run([case])["real"], "soil": soil, "ground": ground}
|
||||
|
||||
|
||||
def test_실측_깎기_암석_영역_생김(real):
|
||||
rock = [f for f in real["faces"] if f["lines"] == ["design", "rock", "structure"]]
|
||||
assert rock and max(f["area"] for f in rock) == pytest.approx(18.24, abs=0.05)
|
||||
soil = real["faces"][real["soil"]]
|
||||
assert soil["lines"] == ["design", "ground", "rock", "structure"]
|
||||
assert soil["area"] == pytest.approx(6.00, abs=0.05)
|
||||
|
||||
|
||||
def test_실측_보통_배율_띠_누름(real):
|
||||
assert real["clicks"][0] == {"face": real["soil"]}
|
||||
assert real["clicks"][1] == {"piece": real["ground"]}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""261-4 운반공 줄 이름 = 거리이정표 항목명 그대로(「운반」 안 붙임) · 행마다 개별 줄 · 단가연결 무관."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Haul import haul_rows # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402
|
||||
|
||||
LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}}
|
||||
DATA = {
|
||||
"site": {"mid_station_m": 2000, "mid_road": "비포장"},
|
||||
"base": {"enabled": False, "legs": []},
|
||||
"routes": [
|
||||
# 자동 상자 — 항목명 고침 · 둘째 행(같은 이름)
|
||||
{"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False,
|
||||
"legs": [{"road": "포장", "km": 25.1}],
|
||||
"rows": [{"item": "강모래", "dest": "start"}, {"item": "강모래", "dest": "mid"}]},
|
||||
# 자동 상자 — 항목명 빈칸 · 거래처 없음 = 묶음 이름
|
||||
{"id": "h-gravel", "name": "", "haul": "gravel", "via_base": False,
|
||||
"legs": [], "rows": [{"item": "", "dest": "start"}]},
|
||||
# 사용자 상자 — 「운반」 이 든 항목명 · 같은 이름 두 행 · 빈 항목명(거래처) · 연결 없음
|
||||
{"id": "u1", "name": "석산", "via_base": False, "legs": [{"road": "포장", "km": 10}],
|
||||
"rows": [{"item": "전석운반", "dest": "start"}, {"item": "전석운반", "dest": "start"},
|
||||
{"item": " ", "dest": "start"}]},
|
||||
# 사용자 상자 — 항목명 · 거래처 모두 빈칸 · 연결 있음
|
||||
{"id": "u2", "name": "", "ref": LOGIC, "via_base": False, "legs": [],
|
||||
"rows": [{"item": "", "dest": "start"}]},
|
||||
],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _items() -> dict[str, dict]:
|
||||
rows = haul_rows(DATA, compute_milestone(DATA, None))
|
||||
return {i["key"]: i for i in haul_items(None, haul_user(rows)["haul_refs"], rows)}
|
||||
|
||||
|
||||
def test_자동_상자_이름_항목명():
|
||||
items = _items()
|
||||
assert items["haul:3"]["name"] == "강모래"
|
||||
assert items["haul:4"]["name"] == "자갈" # 항목 · 거래처 빈칸 = 묶음 이름
|
||||
assert items["haul:1"]["name"] == "중기" # 상자 없음 = 묶음 이름
|
||||
|
||||
|
||||
def test_자동_상자_둘째_행_개별_줄_묶음_바로_뒤():
|
||||
items = _items()
|
||||
keys = list(items)
|
||||
assert keys.index("haul:3.1") == keys.index("haul:3") + 1
|
||||
extra = items["haul:3.1"]
|
||||
assert (extra["name"], extra["qty"], extra["dist"]["L2"]) == ("강모래", None, 2)
|
||||
|
||||
|
||||
def test_사용자_상자_이름_항목명_같은_이름_두_줄():
|
||||
items = _items()
|
||||
assert items["haul:r-u1"]["name"] == items["haul:r-u1.1"]["name"] == "전석운반"
|
||||
assert items["haul:r-u1.2"]["name"] == "석산" # 빈 항목명 = 거래처
|
||||
assert items["haul:r-u2"]["name"] == "운반" and items["haul:r-u2"]["ref"] == LOGIC
|
||||
assert items["haul:r-u1"]["ref"] is None # 연결 없어도 줄
|
||||
|
||||
|
||||
def test_운반운반_없음():
|
||||
assert not any("운반운반" in i["name"] for i in _items().values())
|
||||
Reference in New Issue
Block a user