diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 54b6bb940..3f8223d5c 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -12,6 +12,7 @@ import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts"; export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; +export { pipeLengthText } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1) import { PIPE_MATCH_M } from "./design_selection"; /** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a58f42145..fb5437511 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -70,6 +70,7 @@ import { pipeDrawn, pipeExtraCount, pipeLengthM, + pipeLengthText, } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -339,7 +340,7 @@ export function createFacilityOptionsForm( auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length)); + return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length); }, // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index e0cb89151..d9e5c8461 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -147,7 +147,7 @@ export type BodyAxes = { x: number; d: number; through: number; outward: number * 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 · * 수량 L 은 그린 관 길이 올림 그대로). 노견을 넓힌 쪽(139-6)은 사면 우선 — 그 쪽 흙막이는 안 밀고 * 반대쪽(안 넓힌 쪽)을 밂 · 둘 다 넓혔으면 표기만 올림. - * ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 배관 `_Culvert_Fit` 꼴로. */ + * ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 높이 역산을 더함. */ export function fordPipeFit( section: CrossSection, samples: SectionSample[], diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 640890541..6413b563e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -21,7 +21,8 @@ import type { PipeEnd, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; -import { outlineBox } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendPipeInfoText, outlineBox } from "./B06_Section_UI_Cross_Culvert_Types"; +import { pipeLengthText } from "./B06_Section_UI_Cross_Culvert_Const"; import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; import { appendChuteBand, @@ -305,8 +306,11 @@ export function appendCulvertOverlay( const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : ""; const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter); + const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}`; + // 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1 · B06 카드만 — 그 라벨 없는 덧그림엔 안 그림) + if (!hidePipe) appendPipeInfoText(layer, pipeInfo, null); const tip = - `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipe.lengthM}.0m` + + pipeInfo + ` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` + ` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` + ` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index a6ac1bf2b..73a94ed11 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -13,6 +13,14 @@ import type { SpanRole, SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; * 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */ export const MIN_PIPE_COVER_M = 0.5; +/** 관 길이 정수(사사오입 · 0.5 올림 · 225-1) — 저장 값은 실제 길이(0.1 m) 그대로. + * ⚠ 파이썬 짝 `M02_Table_Fill.pipe_lengths_from_designs`(집계 값 · 거울 시험 test_225_1). */ +export const pipeLengthWhole = (lengthM: number): number => Math.floor(lengthM + 0.5); + +/** 관 길이 표시 「8 m (7.6)」(225-1 — 카드 글 · 좌측 패널). */ +export const pipeLengthText = (lengthM: number): string => + `${pipeLengthWhole(lengthM)} m (${lengthM.toFixed(1)})`; + /** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */ export const REVET_THICKNESS_M = 0.45; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts deleted file mode 100644 index 022a7f12f..000000000 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Cross_Culvert_Fit.ts - * 관 길이 **m 단위 맞춤** — 배수관 기하(`_Cross_Culvert_Geom.ts`)에서 700줄 제한으로 분리. - * - * · 보통(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 벽 자리를 관 축 방향 바깥으로 - * 민다(벽 두께 0.45 · 높이 고정 · 벽 상단은 1:1.2 성토선을 탐). - * · 노견을 넓힌 쪽(139-6 설계선 쉬프트) — 사면 우선: 벽을 관 축을 따라 옮기되 벽 상단이 그 쪽 - * 성토선(넓힌 노견에서 같은 물매)에 닿도록 **벽 높이를 0.1 m 눈금으로 역산**. 눈금 탓에 남는 - * 차는 표기 올림. - * ========================================================================== */ - -import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; -import { - FILL_SLOPE_MAX_LENGTH_M, - FILL_SLOPE_RATIO_MAX, - FILL_SLOPE_RATIO_MIN, - REVET_EMBED_DEPTH_M, - REVET_HEIGHT_STEP_M, - WALL_MIN_PURE_HEIGHT_M, -} from "./B06_Section_UI_Cross_Culvert_Const"; - -export interface OutletFitInput { - /** 유출 쪽 노견을 넓혔나(139-6) — 높이 역산 갈래. */ - shifted: boolean; - edge: { offset_m: number; elevation_m: number }; - outward: number; - axis: () => OffsetPoint; - thickness: number; - anchor: OffsetPoint; - /** 계산용 높이(순수 − 근입). */ - height: number; - /** 보통 갈래의 invert 선(높이 고정 · 성토선 평행). */ - lineInvertAt: (offset: number) => number; - slopeAt: ( - offset: number, - thickness: number, - height: number, - ) => { ratio: number; lengthM: number }; - cutLength: () => number; - /** 벽을 새 자리 · 높이로 다시 세우고 관 축 길이 `target` 으로 겨눔 — 벽이 안 서면 false. */ - rebuild: (anchor: OffsetPoint, height: number, target: number) => boolean; -} - -/** 맞춤 뒤 벽 자리 · 높이. */ -export function fitOutletPipeLength(c: OutletFitInput): { anchor: OffsetPoint; height: number } { - const target = Math.ceil(c.cutLength() - 1e-6); - let anchor = c.anchor; - let height = c.height; - const ratio0 = c.slopeAt(anchor.offset, c.thickness, height).ratio; - for (let pass = 0; pass < 8; pass += 1) { - const gap = target - c.cutLength(); - if (Math.abs(gap) < 0.005) break; - const axis = c.axis(); - const moved = anchor.offset + axis.offset * gap; - // 노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저. - if ((moved - c.edge.offset_m) * c.outward < 0) break; - if (c.shifted) { - // 사면 우선 — invert 는 관 축을 따라 · 벽 상단 = 같은 물매 성토선 → 높이 역산(0.1 m 눈금) - if (!Number.isFinite(ratio0)) break; - const invert = anchor.elevation + axis.elevation * gap; - const run = Math.abs(moved + c.outward * (c.thickness / 2) - c.edge.offset_m); - const pure = c.edge.elevation_m - run / ratio0 - invert + REVET_EMBED_DEPTH_M; - const stepped = Math.round(pure / REVET_HEIGHT_STEP_M) * REVET_HEIGHT_STEP_M; - if (stepped < WALL_MIN_PURE_HEIGHT_M - 1e-9) break; - const next = stepped - REVET_EMBED_DEPTH_M; - if (Math.abs(next - height) < 1e-9 && Math.abs(moved - anchor.offset) < 0.01) break; - anchor = { offset: moved, elevation: invert }; - height = next; - if (!c.rebuild(anchor, height, target)) break; - continue; - } - const movedSlope = c.slopeAt(moved, c.thickness, height); - // 사면 역전(벽 상단이 노견 위)으로는 옮기지 않는다 · 성토고 0(성토선 0점)까지는 허용(2026-08-23). - if (c.edge.elevation_m - (c.lineInvertAt(moved) + height) < 0) break; - // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — - // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). - if ( - movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 && - movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && - movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 - ) { - anchor = { offset: moved, elevation: c.lineInvertAt(moved) }; - if (!c.rebuild(anchor, height, target)) break; - continue; - } - // 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다). - break; - } - return { anchor, height }; -} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 70e2cd2de..3e46b469e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -13,6 +13,7 @@ import { FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, MIN_PIPE_COVER_M, + pipeLengthWhole, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, @@ -57,7 +58,6 @@ import { } from "./B06_Section_UI_Cross_Culvert_Basin"; import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut"; -import { fitOutletPipeLength } from "./B06_Section_UI_Cross_Culvert_Fit"; import { buildCulvertTiers, drawnFillPoints, @@ -67,7 +67,6 @@ import { import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { cutLength, - outletSlopeFactory, fillSlopeOf, groundInterpolator, designInterpolator, @@ -116,7 +115,7 @@ export function computeCulvertLayout( false, true, ); - return p ? { shape: p.basin?.shape ?? null, lengthM: p.pipe.lengthM } : null; + return p ? { shape: p.basin?.shape ?? null, lengthM: pipeLengthWhole(p.pipe.lengthM) } : null; }; const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST; const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin); @@ -284,13 +283,9 @@ export function computeCulvertLayout( const rise0 = outletAnchor.elevation - inlet.elevation; const length0 = Math.hypot(run0, rise0); if (!(length0 > 0.5)) return null; - // 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정). - let lengthM = Math.ceil(length0 - 1e-6); - const scale = lengthM / length0; - const outlet: OffsetPoint = { - offset: inlet.offset + run0 * scale, - elevation: inlet.elevation + rise0 * scale, - }; + // 관 길이 = 그려진 실제 길이(0.1 m · 225-1 — 정수 올림 연장 없음 · 표시 · 집계만 반올림). + let lengthM = Math.round(length0 * 10) / 10; + const outlet: OffsetPoint = { ...outletAnchor }; const span = Math.abs(outletAnchor.offset - inlet.offset); let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0; @@ -418,8 +413,7 @@ export function computeCulvertLayout( // 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21). const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); let outletWallAnchor = outletAnchor; - // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). 관 길이 - // 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다. + // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). const outletZeroAnchor = slopeZeroAnchor( outletInfo.edge, outletInfo.outward, @@ -427,10 +421,6 @@ export function computeCulvertLayout( outletWallSpec.lean, ); const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height; - const outletLineInvertAt = (offset: number): number => - designAt - ? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN - : invertAt(offset); if (designAt) { // 4축 배치(유입과 동일 풀이). const placed = placePipeWall({ @@ -454,7 +444,7 @@ export function computeCulvertLayout( }; outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert }; } - let outletWall = buildWall( + const outletWall = buildWall( culvert.outlet, outletWallAnchor, outletInfo.outward, @@ -467,91 +457,28 @@ export function computeCulvertLayout( ); // ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다. const pipeStart = basinPipeEnd ?? inlet; - // 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은 - // 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로. + // 관 끝 = 유출 벽 전면 기준선 교차점 — 벽은 관 길이와 무관하게 0.1 m 로 움직이고 길이는 + // 벽 자리에서 셈(225-1 · 옛 정수 올림 연장 · 길이 맞춤 이동 없앰). if (outletWall) { const face = outletPipeEnd(outletWall); - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW); - if (lenW > 0.5) { - const scaleW = Math.ceil(lenW - 1e-6) / lenW; - outlet.offset = pipeStart.offset + runW * scaleW; - outlet.elevation = pipeStart.elevation + riseW * scaleW; - lengthM = Math.ceil(lenW - 1e-6); + if (Math.hypot(face.offset - pipeStart.offset, face.elevation - pipeStart.elevation) > 0.5) { + outlet.offset = face.offset; + outlet.elevation = face.elevation; } } // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질 // 때마다 다시 유도한다(2026-08-20 사용자 ③). const pipeAxis = pipeAxisSolver(pipeStart, outlet, diameter, endFaces); - const deriveAxis = pipeAxis.derive; const endCorners = pipeAxis.corners; - let pipeCorners = { + const pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), }; - // ── 관 길이 m 단위 맞춤 — `_Culvert_Fit`(보통 = 유출 벽을 관 축 바깥으로 · 노견을 넓힌 - // 쪽 = 사면 우선 + 벽 높이 역산 · 139-6). 정수 맞춤 후보 자리도 같은 성토선을 탄다(2026-08-23). - const outletThickness = REVET_THICKNESS_M; // 늘 0.45 고정(2026-08-21) — 폭으로 길이를 맞추지 않음 - // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다. - // d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값). - const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null; - if (outletWall && !outletPinned) { - const extend = - section.design?.[`shoulder_extend_${outletInfo.outward > 0 ? "left" : "right"}_m`]; - fitOutletPipeLength({ - shifted: (extend ?? 0) > 1e-9, - edge: outletInfo.edge, - outward: outletInfo.outward, - axis: () => pipeAxis.axis, - thickness: outletThickness, - anchor: outletWallAnchor, - height: outletWallSpec.height, - lineInvertAt: outletLineInvertAt, - slopeAt: (offset, thickness, height) => - outletSlopeFactory(outletInfo, outletLineInvertAt, height)(offset, thickness), - cutLength: () => cutLength(pipeCorners), - rebuild: (anchor, height, target) => { - outletWallAnchor = anchor; - walls.pop(); - outletWall = buildWall( - culvert.outlet, - anchor, - outletInfo.outward, - null, - outletThickness, - { - height, - baseElevation: anchor.elevation, - floatGapM: Math.max(0, anchor.elevation - invertAt(anchor.offset)), - }, - outletWallSpec.material, - outletWallSpec.form, - outletWallSpec.lean, - ); - if (!outletWall) return false; - // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). - const face = outletPipeEnd(outletWall); - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW) || 1; - outlet.offset = pipeStart.offset + (runW / lenW) * target; - outlet.elevation = pipeStart.elevation + (riseW / lenW) * target; - deriveAxis(); - pipeCorners = { - inlet: endCorners("inlet", pipeStart), - outlet: endCorners("outlet", outlet), - }; - return true; - }, - }); - } if (outletWall) { - // 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다). - // 수동 벽으로 실길이가 정수가 아니어도 표기만 올림한다(2026-08-22 사용자 확정). - lengthM = Math.max(1, Math.ceil(cutLength(pipeCorners) - 1e-6)); + // 관 길이 = **실제 그려진 관** 0.1 m(225-1 — 표시 · 집계 반올림은 `pipeLengthWhole`). + lengthM = Math.max(0.1, Math.round(cutLength(pipeCorners) * 10) / 10); slopePct = Math.abs(outlet.offset - pipeStart.offset) > 1e-9 ? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) * diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 94dc53ea0..8ffe3bed8 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -50,6 +50,31 @@ export function outlineBox(points: OffsetPoint[]): OffsetPoint[] { ]; } +/** + * 관 정보 글(세월교 · 관 자리) — 노폭 · 노견 라벨(`.b06-chart__carriageway-label`) 바로 위 줄(225-1). + * 같은 그림에 그 라벨이 없으면(B07 덧그림) `fallback` 자리 · fallback 도 없으면 안 그림. + */ +export function appendPipeInfoText( + layer: SVGElement, + text: string, + fallback: [number, number] | null, +): void { + const svg: Element = layer.ownerSVGElement ?? layer; // 맨 위 svg 는 ownerSVGElement = null + const road = svg.querySelector?.(".b06-chart__carriageway-label"); + if (!road && !fallback) return; + const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); + const stacked = svg.querySelectorAll?.(".b06-chart__pipe-info").length ?? 0; + const [x, y] = road + ? [Number(road.getAttribute("x")), Number(road.getAttribute("y")) - 13 * (stacked + 1)] + : fallback!; + label.setAttribute("x", String(x)); + label.setAttribute("y", String(y)); + label.setAttribute("text-anchor", "middle"); + label.setAttribute("class", "b06-chart__culvert-label b06-chart__pipe-info"); + label.textContent = text; + (road?.parentNode ?? layer).appendChild(label); +} + /** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */ export interface CulvertDesignTrim { minOffset: number; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index 769e7ce9f..fde0f514b 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -10,7 +10,11 @@ * ========================================================================== */ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom"; -import { outlineBox, type OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { + appendPipeInfoText, + outlineBox, + type OffsetPoint, +} from "./B06_Section_UI_Cross_Culvert_Types"; import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; export { @@ -196,14 +200,8 @@ export function appendFordOverlay( } } - const label = document.createElementNS(SVG_NS, "text"); - const [labelX, labelY] = toXY(layout.label.at); - label.setAttribute("x", String(labelX)); - label.setAttribute("y", String(labelY)); - label.setAttribute("text-anchor", "middle"); - label.setAttribute("class", "b06-chart__culvert-label"); - label.textContent = layout.label.text; - layer.append(label); + // 세월교 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1) · 그 라벨이 없으면 옛 자리. + appendPipeInfoText(layer, layout.label.text, toXY(layout.label.at)); return (key: FordPick | null): void => { for (const [role, shapes] of walls) { diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 0dec26028..57a37320c 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -67,7 +67,7 @@ export interface StructurePanelDeps { bodyGuard?: () => boolean; /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; - /** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */ + /** 좌우·상하 걸음이 0.1m인가(관보호공 · 독립 · 추가 기슭막이). 세월교 흙막이는 false = 1m. */ fineMove: (key: RevetKey) => boolean; /** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */ dragAxes?: (key: RevetKey, basin: boolean) => Omit | null; @@ -102,13 +102,13 @@ export interface StructurePanelHandle { } /** - * 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정). + * 세월교 흙막이 좌우·상하 한 걸음(m) — 안이 관이라 **1m**(80). * 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어). */ const STEP_M = 1.0; /** - * 독립·추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m**(2026-08-29 사용자). - * 배관 기준벽(유입·유출)만 관 길이에 맞춘 1m 걸음을 그대로 쓴다. + * 관보호공(배관 기준벽) · 독립 · 추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m** + * (관 길이와 무관 · 225-1). */ const OWN_STEP_M = 0.1; const HEIGHT_STEP_M = 0.1; diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index 00fa535c1..15cb2226a 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -92,9 +92,13 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe return { scrollKey: ctx.scrollKey, adjustFor: ctx.adjustOf, + // 배관 기준벽 d 빈 칸은 지금 자리로 묶음 — x 만 넣으면 옛 기본 d(경사길이 4m · 79b)로 튐(225-1). nudge: (key, screenDeltaM) => ctx.revetOffset?.update(ctx.adjustChainage(), key, { x: ctx.adjustOf(key).x + screenDeltaM * ctx.outwardOf(key), + ...(key === "inlet" || key === "outlet" + ? { d: ctx.adjustOf(key).d ?? ctx.appliedD().get(key) ?? null } + : {}), }), // d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점 // 기준 절대값이라 시작값이 필요하다). @@ -148,12 +152,11 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe resetBasin: () => ctx.inletStructure?.resetAdjust(ctx.adjustChainage()), // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동). canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(), - // 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽· - // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80). - // 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). + // 걸음 분류: 관보호공(배관 기준벽)도 관 길이와 무관하게 0.1m(225-1) · 세월교 흙막이만 + // 안이 관이라 1m(80) · 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). fineMove: (key) => (key !== "inlet" && key !== "outlet") || - (ctx.hiddenPipe() && !ctx.fordGuard?.()) || + !(ctx.hiddenPipe() && ctx.fordGuard?.()) || (ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false), dragAxes: (key, basin) => wallDragAxes(ctx, key, basin), extraState: () => ctx.extraState(), diff --git a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts index e985ea346..cb1b0aea8 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -1,5 +1,5 @@ import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; -import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state'; +import { getHighlightedEntityIds, setHighlightedEntityIds } from '../state'; import { drawEntities } from './draw-functions'; import { getSceneVersion } from './scene-version'; import { queryEntitiesInBox } from './spatial-index'; @@ -78,7 +78,8 @@ function rebuildScene(drawController: ScreenCanvasDrawController): void { const savedHighlight = getHighlightedEntityIds(); if (savedHighlight.length) setHighlightedEntityIds([]); drawController.withContext(offscreenContext, () => { - drawController.clear(); + // 투명 바탕 — 배경 · 모눈은 화면 clear() 가 그림(모눈이 비트맵에 박히면 매 프레임 다시 그려야 함) + offscreenContext.clearRect(0, 0, size.x, size.y); // Style-run batching + sub-pixel decimation: one stroke per style run drawController.beginBatch(); drawEntities(drawController, sceneEntitiesForViewport(drawController)); @@ -124,13 +125,7 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe const offsetChanged = !!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y); - // Grid lines are screen-fixed (drawn in clear()), so blitting a shifted - // bitmap would drag the grid along — always re-render while grid is on. - if ( - paramsChanged || - getGridEnabled() || - (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS) - ) { + if (paramsChanged || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) { rebuildScene(drawController); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts b/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts index 2c9c84532..970bf0660 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts @@ -26,8 +26,12 @@ let gridMinX = 0; let gridMinY = 0; let gridCols = 1; +/** 칸 번호 범위 — 엔티티가 놓인 칸(0~GRID_CELLS_PER_AXIS) 밖은 비었으니 잘라 냄(줌아웃 때 빈 칸 수억 개를 돌지 않게). */ function cellRange(min: number, max: number, gridMin: number): [number, number] { - return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)]; + return [ + Math.max(0, Math.floor((min - gridMin) / cellSize)), + Math.min(GRID_CELLS_PER_AXIS, Math.floor((max - gridMin) / cellSize)), + ]; } function ensureIndex(): void { diff --git a/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts new file mode 100644 index 000000000..754eae362 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts @@ -0,0 +1,191 @@ +import { Point } from '@flatten-js/core'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +/** 줌아웃 느려짐(227-1) — 가짜 캔버스로 한 화면 그리기 비용을 잰다. */ +const counts = vi.hoisted(() => { + const tally = { segments: 0, arcs: 0, strokes: 0, fills: 0, texts: 0 }; + const g = globalThis as Record; + g.window = { dispatchEvent: () => true, matchMedia: () => ({ matches: true }) }; + g.Path2D = class { + moveTo() {} + lineTo() { + tally.segments++; + } + arc() { + tally.arcs++; + } + }; + const noop = () => {}; + g.__fakeContext = { + fillRect: noop, + clearRect: noop, + setLineDash: noop, + beginPath: noop, + moveTo: noop, + lineTo: () => tally.segments++, + arc: () => tally.arcs++, + closePath: noop, + stroke: () => tally.strokes++, + fill: () => tally.fills++, + save: noop, + restore: noop, + translate: noop, + rotate: noop, + fillText: () => tally.texts++, + measureText: (label: string) => ({ width: label.length * 7 }), + drawImage: noop, + }; + return tally; +}); + +import type { Layer } from '../App.types'; +import { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import type { Entity } from '../entities/Entity'; +import { HatchEntity } from '../entities/HatchEntity'; +import { LineEntity } from '../entities/LineEntity'; +import { MeasurementEntity } from '../entities/MeasurementEntity'; +import { PolyLineEntity } from '../entities/PolyLineEntity'; +import { TextEntity } from '../entities/TextEntity'; +import { setEntities, setGridEnabled, setLayers } from '../state'; +import { drawScene, invalidateSceneCache, scenePerf } from './scene-cache'; +import { queryEntitiesInBox } from './spatial-index'; + +const LAYER = 'perf'; + +function polyline(points: Point[]): PolyLineEntity { + const segments = points + .slice(1) + .map((point, index) => new LineEntity(LAYER, points[index], point)); + return new PolyLineEntity(LAYER, segments); +} + +/** B07 횡단 장 꼴 — 횡단 40 개(지반선 · 계획선 · 절토 · 성토 해치 · 글자 · 치수 · 모눈) + 등고선 */ +function crossSectionSheet(): Entity[] { + const entities: Entity[] = []; + for (let index = 0; index < 40; index++) { + const ox = (index % 4) * 80; + const oy = Math.floor(index / 4) * 30; + const ground = Array.from( + { length: 61 }, + (_, k) => new Point(ox + k, oy + 10 + 3 * Math.sin(k / 7 + index)) + ); + entities.push(polyline(ground)); + const design = [0, 15, 20, 25, 40, 45, 60].map( + (x, k) => new Point(ox + x, oy + 9 + (k % 2 ? 1 : 0)) + ); + entities.push(polyline(design)); + const hatchOptions = { style: 'pattern', spacing: 0.3 } as const; + entities.push( + new HatchEntity( + LAYER, + [new Point(ox + 15, oy + 9), new Point(ox + 25, oy + 9), new Point(ox + 20, oy + 13)], + hatchOptions + ), + new HatchEntity( + LAYER, + [new Point(ox + 40, oy + 9), new Point(ox + 60, oy + 9), new Point(ox + 50, oy + 6)], + hatchOptions + ) + ); + for (let k = 0; k < 25; k++) { + entities.push( + new TextEntity(LAYER, `${index}+${k}.00`, new Point(ox + 2 * k, oy + 2), { fontSize: 0.5 }) + ); + } + for (let k = 0; k < 6; k++) { + entities.push( + new MeasurementEntity( + LAYER, + new Point(ox + 10 * k, oy + 8), + new Point(ox + 10 * k + 8, oy + 8), + new Point(ox + 10 * k, oy + 7) + ) + ); + } + for (let k = 0; k <= 12; k++) { + entities.push( + new LineEntity(LAYER, new Point(ox + 5 * k, oy), new Point(ox + 5 * k, oy + 20)) + ); + } + } + for (let c = 0; c < 178; c++) { + const contour = Array.from( + { length: 85 }, + (_, k) => new Point(k * 3.7, c * 1.7 + 2 * Math.sin(k / 5 + c)) + ); + entities.push(polyline(contour)); + } + return entities; +} + +function viewAt(controller: ScreenCanvasDrawController, factor: number, fitScale: number) { + const size = controller.getCanvasSize(); + const center = controller.targetToWorld(new Point(size.x / 2, size.y / 2)); + const scale = fitScale * factor; + controller.setScreenScale(scale); + controller.setScreenOffset( + new Point(center.x - size.x / 2 / scale, center.y - size.y / 2 / scale) + ); +} + +describe('줌아웃 그리기 비용(227-1)', () => { + const controller = new ScreenCanvasDrawController( + (globalThis as unknown as { __fakeContext: CanvasRenderingContext2D }).__fakeContext + ); + let fitScale = 1; + + beforeAll(() => { + const g = globalThis as Record; + g.document = { + documentElement: { hasAttribute: () => false, getAttribute: () => null }, + createElement: () => ({ width: 0, height: 0, getContext: () => g.__fakeContext }), + }; + g.getComputedStyle = () => ({ getPropertyValue: () => '' }); + setLayers( + [{ id: LAYER, name: LAYER, isVisible: true, isLocked: false } as unknown as Layer], + false + ); + setEntities(crossSectionSheet(), false); + controller.setCanvasSize(new Point(1600, 900)); + controller.zoomToFitScreen(); + fitScale = controller.getScreenScale(); + }); + + it('배율별 한 화면 다시 그리기 — 시간 · 그린 수(보고용 표)', () => { + const rows = []; + for (const factor of [10, 1, 0.1, 0.01]) { + viewAt(controller, factor, fitScale); + for (const key of Object.keys(counts)) counts[key as keyof typeof counts] = 0; + invalidateSceneCache(); + const startedAt = performance.now(); + drawScene(controller, 0); + const ms = performance.now() - startedAt; + rows.push({ factor, ms: Number(ms.toFixed(1)), ...counts }); + } + console.table(rows); + expect(rows).toHaveLength(4); + }); + + it('화면보다 아주 큰 영역 조회도 빈 칸을 돌지 않음 — 0.001배 상자 50ms 안', () => { + viewAt(controller, 1, fitScale); + const all = queryEntitiesInBox(-1e9, -1e9, 1e9, 1e9).length; + const size = controller.getCanvasSize(); + const half = (Math.max(size.x, size.y) / (fitScale * 0.001)) * 1.5; + const startedAt = performance.now(); + const found = queryEntitiesInBox(-half, -half, half, half); + const ms = performance.now() - startedAt; + expect(found.length).toBe(all); + expect(ms).toBeLessThan(50); + }); + + it('모눈을 켜도 화면이 그대로면 장면을 다시 그리지 않음', () => { + viewAt(controller, 1, fitScale); + setGridEnabled(true); + drawScene(controller, 0); + const rebuilds = scenePerf.sceneRebuilds; + drawScene(controller, 1000); + drawScene(controller, 2000); + setGridEnabled(false); + expect(scenePerf.sceneRebuilds).toBe(rebuilds); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts new file mode 100644 index 000000000..ee52ef7ae --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts @@ -0,0 +1,89 @@ +import { Point } from '@flatten-js/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { createActor } from 'xstate'; +import { LineEntity } from '../entities/LineEntity.ts'; +import { + getEntities, + resetUndoBaseline, + setEntities, + setSelectedEntityIds, + undo, +} from '../state.ts'; +import { ScaleState, scaleToolStateMachine } from './scale-tool.ts'; +import { ActorEvent } from './tool.types.ts'; + +const ends = (index: number) => { + const line = getEntities()[index] as LineEntity; + const a = line.getStartPoint(); + const b = line.getEndPoint(); + return [a.x, a.y, b.x, b.y]; +}; + +/** 기준점 (2,0) 클릭까지 — 선 A(0,0)-(10,0) 고름 · 선 B(0,5)-(4,5) 는 그대로 둘 것 */ +function startAtBasePoint() { + const a = new LineEntity('layer1', new Point(0, 0), new Point(10, 0)); + const b = new LineEntity('layer1', new Point(0, 5), new Point(4, 5)); + setEntities([a, b]); + resetUndoBaseline(); + setSelectedEntityIds([a.id]); + const actor = createActor(scaleToolStateMachine).start(); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT); + actor.send({ + type: ActorEvent.MOUSE_CLICK, + worldMouseLocation: new Point(2, 0), + screenMouseLocation: new Point(0, 0), + holdingCtrl: false, + holdingShift: false, + }); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT); + return { actor, a, b }; +} + +const typeNumber = (actor: ReturnType['actor'], value: number) => + actor.send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: new Point(99, 99) }); + +describe('축척 — 기준점 + 숫자 배율 (226-1)', () => { + beforeEach(() => setSelectedEntityIds([])); + + it('배율 2 = 기준점 고정 두 배 · 다른 객체 · 순서 그대로', () => { + const { actor, a, b } = startAtBasePoint(); + typeNumber(actor, 2); + // (0,0)→(-2,0) · (10,0)→(18,0) — 기준점 (2,0) 은 그대로 + expect(ends(0)).toEqual([-2, 0, 18, 0]); + expect(getEntities()[0].id).toBe(a.id); + expect(getEntities()[1]).toBe(b); + expect(ends(1)).toEqual([0, 5, 4, 5]); + // 끝나면 선택 풀고 다음 고르기로 + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_SELECTION); + }); + + it('배율 0.5 = 절반', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 0.5); + expect(ends(0)).toEqual([1, 0, 6, 0]); + }); + + it('되돌리기 한 번에 원래 자리', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 2); + undo(); + expect(ends(0)).toEqual([0, 0, 10, 0]); + expect(getEntities()).toHaveLength(2); + }); + + it('Esc = 취소 — 객체 그대로', () => { + const { actor, a, b } = startAtBasePoint(); + actor.send({ type: ActorEvent.ESC }); + expect(getEntities()).toEqual([a, b]); + expect(ends(0)).toEqual([0, 0, 10, 0]); + typeNumber(actor, 2); + expect(ends(0)).toEqual([0, 0, 10, 0]); + }); + + it('배율 0 은 받지 않음', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 0); + expect(ends(0)).toEqual([0, 0, 10, 0]); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts index 660a885b2..c73e27c6e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts @@ -2,15 +2,23 @@ import type { Point } from '@flatten-js/core'; import { addEntities, deleteEntities, + getEntities, getSelectedEntities, getSelectedEntityIds, setAngleGuideOriginPoint, + setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { + DrawEvent, + MouseClickEvent, + NumberInputEvent, + StateEvent, + ToolContext, +} from './tool.types'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -42,6 +50,7 @@ export enum ScaleAction { COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE', DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES', SCALE_SELECTION = 'SCALE_SELECTION', + SCALE_SELECTION_BY_FACTOR = 'SCALE_SELECTION_BY_FACTOR', DESELECT_ENTITIES = 'DESELECT_ENTITIES', RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } @@ -98,7 +107,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_SELECTION]: { description: 'Select what you want to scale', meta: { - instructions: 'Select what you want to scale, then ENTER', + instructions: '축척할 객체를 선택한 뒤 ENTER.', }, invoke: { id: 'selectToolInsideTheScaleTool', @@ -142,7 +151,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: { description: 'Select the origin of the scale operation', meta: { - instructions: 'Select the origin of the scale operation', + instructions: '기준점을 지정하십시오. (Esc 취소)', }, always: { actions: ScaleAction.ENABLE_HELPERS, @@ -161,9 +170,17 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: { description: 'Select the end of the base scale line', meta: { - instructions: 'Select the end of the base scale line', + instructions: + '배율을 입력하고 ENTER — 또는 끌어서 맞출 기준선 끝을 클릭하십시오. (Esc 취소)', }, on: { + // 오토캐드 SCALE — 기준점 뒤 숫자 배율(2 = 두 배 · 0.5 = 절반) + NUMBER_INPUT: { + guard: ({ event }) => + (event as NumberInputEvent).value > 0 && getSelectedEntityIds().length > 0, + actions: [ScaleAction.SCALE_SELECTION_BY_FACTOR, ScaleAction.DESELECT_ENTITIES], + target: ScaleState.INIT, + }, MOUSE_CLICK: { actions: [ ScaleAction.RECORD_BASE_VECTOR_END_POINT, @@ -180,7 +197,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: { description: 'Select the end of the scale line', meta: { - instructions: 'Select the end of the scale line', + instructions: '새 길이의 끝을 클릭하십시오. (Esc 취소)', }, on: { DRAW: { @@ -235,7 +252,9 @@ export const scaleToolStateMachine = createMachine( return { ...context, // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action - originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ), }; }), [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { @@ -282,6 +301,24 @@ export const scaleToolStateMachine = createMachine( setGhostHelperEntities([]); setSelectedEntityIds([]); }, + [ScaleAction.SCALE_SELECTION_BY_FACTOR]: ({ context, event }) => { + const origin = context.baseVectorStartPoint; + if (!origin) { + throw new Error('[SCALE] Calling scale by factor without a base point'); + } + const factor = (event as NumberInputEvent).value; + const selectedIds = new Set(getSelectedEntityIds()); + // 제자리 교체 — 복사본을 키워 같은 순서에 넣고 되돌리기 한 칸으로 남긴다 + setEntities( + getEntities().map((entity) => { + if (!selectedIds.has(entity.id)) return entity; + const scaled = cloneKeepingLook(entity, true); + scaled.scale(origin, factor); + return scaled; + }), + true + ); + }, [ScaleAction.DESELECT_ENTITIES]: assign(() => { setGhostHelperEntities([]); setSelectedEntityIds([]); diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 104bcc4ac..20b13dcf9 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -7,7 +7,7 @@ 흙막이 › 형태 › H=높이 m 유입(흙막이일 때) · 유출 · 흙막이 n(실제 선 단까지) · 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이 돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3) - 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로) + 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이 사사오입 정수(225-1) › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만 관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2) › 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 6d9cb5125..2d0b40f52 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -32,7 +32,7 @@ const GUIDE_MORE = "위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " + "끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " + "키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " + - "묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭"; + "묶음 줄로 바꾸기 · 추가 · 지우기 = 줄 우클릭"; let picked: string | null = null; /** 지금 그린 도구 줄 — 키가 부름 */ diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 890c7155a..655ec4775 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -20,9 +20,11 @@ 고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 같은 품목 두 번은 [저장] 때 400. 승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫. -자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 금액 무관) — 사용자 자재 = 줄 덧칸 · +자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 별도계상만 금액에 닿음) — 사용자 자재 = 줄 덧칸 · 로직 자재 = 로직 줄 `자재표시 {호표 차례: {세 칸}}`(차례가 재료 줄인 것만 · 틀리면 버림) · 빈 칸 = 읽을 때 `MARK_DEFAULT`(`mark_of` · 저장은 비움 · 옛 「일반」 = 버림 → 사급 · 162-1). +별도계상(222-1) = 그 자재 금액은 기준 · 합에서 빠지고 로직 `줄` 에서 지움(`drop_apart` · `settle` · +짝 `Labor_Calc.ts`) · `재료` · `자재`(자재목록 · B08 수량)엔 남음. % 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 · 단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수. """ @@ -224,6 +226,35 @@ def mark_of(one: Any, key: str) -> str: return marks(one).get(key) or MARK_DEFAULT[key] +def apart(one: Any) -> bool: + """별도계상(222-1) — 세 칸 「별도계상」(빈 칸 = 기본) 자재는 일위대가 셈에서 빠짐(본 프로그램에서 + 따로 계상 · 자재목록은 상세 보기라 따로 안 더함).""" + return mark_of(one, "별도계상") == "별도계상" + + +def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]: + """로직 줄 답(`run_line`)에서 별도계상 로직 자재 빼기(222-1) — 그 재료 줄 금액을 기준에서 빼고 + `줄`(일위대가 상세)에서 지움 · `재료`(자재목록 · 세 칸 고침)는 그대로. + ponytail: 「재료비의 %」 덧줄은 엔진 값 그대로(뺀 재료 몫 안 고침) — 그런 로직이 별도계상을 쓰면 엔진에 넘김.""" + shown = shown if isinstance(shown, dict) else {} + lines = got.get("줄") or [] + out = { + n + for m in got.get("재료") or [] + if (n := m["차례"]) < len(lines) and apart(shown.get(str(n))) + } + if not out or not got.get("기준"): + return got + base = {k: _number(v) or Decimal(0) for k, v in got["기준"].items()} + for n in out: + for k, v in (lines[n].get("비목") or {}).items(): + cut = _number(v) or Decimal(0) + base[k] = base.get(k, Decimal(0)) - cut + base["계"] -= cut + kept = [x for i, x in enumerate(lines) if i not in out] + return {**got, "기준": _json(base), "줄": kept} + + def _mat_marks(value: Any, logic: dict) -> dict[str, dict[str, str]] | None: """로직 자재 세 칸(148-4) `{호표 차례: {세 칸}}` — 맨 위 층 재료 줄 차례 · 허용 값만 · 빈 것 = None.""" @@ -386,6 +417,7 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []} mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} + aside = {one.get("코드") for one in doc.get("자재") or [] if apart(one)} # 222-1 합에서 뺌 zero = dict.fromkeys((*COSTS, "계"), Decimal(0)) lines, mats, bases, reflects = [], [], [], [] for line in result.get("줄") or []: @@ -399,9 +431,11 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have) price = _number(one.get("기준단가")) cost = price * qty if price is not None and qty is not None else None + mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) + if one.get("코드") in aside: + continue bases.append({**zero, "재료비": price, "계": price} if price is not None else None) reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) - mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) @@ -468,7 +502,7 @@ def calc( "배": pill.get("배") or 1, } got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) - lines.append({**line, **got}) + lines.append({**line, **drop_apart(got, one.get("자재표시"))}) mats = [ { "코드": one["코드"], diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py index c782955ac..204102bfe 100644 --- a/M02_MasterTemplete/M02_Labor_Inputs.py +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -8,6 +8,7 @@ from __future__ import annotations +import re from decimal import Decimal from typing import Any @@ -16,6 +17,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store _SPEC = ("이름", "설명", "단위", "고르기", "범위") _LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직 +_NUMBER = re.compile(r"(? Any: @@ -63,6 +65,26 @@ def _labels(whole: Any, spec: dict) -> dict[str, str]: return out +def sources(logic: dict) -> Any: + """원문 출처(222-1) — 로직 `출처` + 호표 · 입력 · 중간 · 덧줄 `출처` 가운데 같은 책(번호 앞 글이 + 같음)의 절 번호를 덧붙임(「산림품셈 12-1-2 · 12-1-3」 · 원문 패널이 절마다 찾음 · M01 + `make.source`) · 다른 책 절은 안 붙임 · 번호 없는 출처는 그대로.""" + main = str(logic.get("출처") or "") + head = _NUMBER.search(main) + if not head: + return logic.get("출처") + book, seen = main[: head.start()], set(_NUMBER.findall(main)) + extra = [] + for part in ("호표", "입력", "중간", "덧줄"): + for row in logic.get(part) or []: + src = str(row.get("출처") or "") if isinstance(row, dict) else "" + hit = _NUMBER.search(src) + if hit and src[: hit.start()] == book and hit.group() not in seen: + seen.add(hit.group()) + extra.append(hit.group()) + return " · ".join([main, *extra]) + + def _listed(logics: list[dict]) -> list[dict[str, Any]]: """로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" whole = m01.loaded()[1] @@ -84,7 +106,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]: "로직": logic.get("키"), "이름": logic.get("이름"), "결과단위": logic.get("결과단위"), - "출처": logic.get("출처"), + "출처": sources(logic), "켜쌓기": logic.get("키") in layers, "입력": specs, } diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index bc06b9ac0..6e0a14bfb 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -423,6 +423,7 @@ class Expand: got = labor.run_line( self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") ) + got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { @@ -463,7 +464,7 @@ class Expand: rows.append({k: v * qty for k, v in sub["기준"].items()}) for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈 qty = labor._number((mat.get("수량") or {}).get("손")) - if qty is None: + if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1) continue got = labor.material_price(self.whole, mat["코드"], mat.get("단위")) price = labor._number(got["기준단가"]) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index e59d594fb..7c6f6e109 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -243,6 +243,13 @@ export interface StructureNumbers { export const fetchStructureNumbers = (): Promise> => scoped("/structures/numbers"); +/** 같은 번호 목록을 **고른 층**(+ 주인)에서 — 지금 층 길을 안 바꾸고 읽음(고르기 모달 상단 표 · 224-1) */ +export const fetchStructureNumbersAt = ( + layer: Layer, + owner: string | null, +): Promise> => + call(owned(layer, `/layers/${layer}/structures/numbers`, owner)); + /** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */ export interface StructureCombo extends StructureNumbers { 키: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 529f6bba4..cea35ce81 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -4,6 +4,7 @@ * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. + * 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`). * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; @@ -79,7 +80,7 @@ export interface LaborCalc { /** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ export interface LaborQtyDoc { 담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[]; - 자재?: { 코드: string; 수량?: Qty }[]; + 자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[]; } function num(raw: unknown): number | null { @@ -130,6 +131,10 @@ export function settle( } const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null])); const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null])); + // 별도계상(222-1) — 「직접계산」 아니면(빈 · 옛 값 = 기본 별도계상) 합에서 뺌(짝 `M02_Labor_Doc.apart`) + const aside = new Set( + (doc.자재 ?? []).filter((m) => m.별도계상 !== "직접계산").map((m) => m.코드), + ); const base = zero(); const done = zero(); const add = (sum: Money, m: Money | null): void => { @@ -146,8 +151,8 @@ export function settle( const [qty, source] = pick(mhands.get(one.코드), one.알약, have); const price = num(one.기준단가); const cost = price !== null && qty !== null ? price * qty : null; - if (price !== null) add(base, { ...zero(), 재료비: price, 계: price }); - if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost }); + if (price !== null && !aside.has(one.코드)) add(base, { ...zero(), 재료비: price, 계: price }); + if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost }); return { ...one, 수량: qty, 수량출처: source, 반영: cost }; }); return { 줄, 자재, 기준합: base, 반영합: done }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 71a5e79ce..f0b65b1a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -383,14 +383,16 @@ export function mountLaborDetail( else delete cur.담은로직[i].승격; opt.onChanged(dirty()); } - // 자재 세 칸(148-4) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 + // 자재 세 칸(148-4) — 화면 캐시만 · 별도계상만 금액에 닿음(222-1 · 사용자 자재 = 짝 셈 · 로직 자재 = 서버 기준) function markMat(i: number, key: MatMarkKey, v: string): void { setMark(cur.자재[i], key, v); - opt.onChanged(dirty()); + if (key === "별도계상") changed(true); + else opt.onChanged(dirty()); } function markLogicMat(i: number, at: number, key: MatMarkKey, v: string): void { setLogicMark(cur.담은로직[i], at, key, v); - opt.onChanged(dirty()); + if (key === "별도계상") changed(); + else opt.onChanged(dirty()); } function removeMat(i: number): void { cur.자재.splice(i, 1); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 881a4607b..f4b98ca00 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -86,6 +86,17 @@ export function laborSumState( return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined; } +/** 구조물 조합 표 번호 · 합계 줄 글(86-3 · 101-1) — 관리 화면과 고르기 모달 상단 표(224-1)가 같은 한 벌 */ +export function numberLabels(numbers: () => ColumnNumbers) { + return { + laborLabel: (colId: string) => numbers()[colId]?.일위대가번 ?? "", + drawingLabel: (colId: string) => numbers()[colId]?.도번 ?? "", + areaLabel: (colId: string) => numbers()[colId]?.면적번 ?? "", + laborSumLabel: (colId: string) => laborSumText(numbers()[colId]), + laborSumState: (colId: string) => laborSumState(numbers()[colId]), + }; +} + /** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */ export interface StructureColumn { id: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index c27d84018..b4c77c65f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -22,8 +22,7 @@ import { comboMerge, comboView, createStructurePanel, - laborSumState, - laborSumText, + numberLabels, pillsFor, STRUCTURE_TABLE, type StructureColumn, @@ -381,11 +380,7 @@ export function createMain( onChange: () => (dirty = !readOnly), ...(struct ? { - laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "", - drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", - areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "", - laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3 - laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1 + ...numberLabels(struct.numbers), // 번호 · 일위대가 합계 줄(86-3 · 101-1) — 고르기 모달 상단 표와 한 벌 combo: { ...(readOnly || !extras.onHeadRename ? {} diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 1cb0db1ff..fb34caad1 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -50,7 +50,9 @@ _REF = re.compile(r"\[([^\]$@][^\]@]*)\]") def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]: - """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음.""" + """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음. + + 관 연장 = 사사오입 정수(0.5 올림 · 225-1 · ⚠ TS 짝 `pipeLengthWhole` · 거울 시험 test_225_1).""" found: dict[float, float] = {} for row in designs or []: design = row.get("design") if isinstance(row, dict) else None @@ -58,7 +60,7 @@ def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, continue length = _number(design.get("pipe_length_m")) if length and length > 0: - found[round(float(row.get("chainage_m") or 0.0), 3)] = length + found[round(float(row.get("chainage_m") or 0.0), 3)] = int(length + 0.5) return found diff --git a/docs/raw/plans/2026-10-10_plan_221-227.md b/docs/raw/plans/2026-10-10_plan_221-227.md new file mode 100644 index 000000000..0dc8f340a --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_221-227.md @@ -0,0 +1,44 @@ +# 2026-10-10 계획 221~227장 기록 — 돋보기 CAD 빼기 · 스크롤 · 기계비빔타설 · 로직 기본값 조사 · 조합 연결 상단 표 · 관 0.1 m · CAD 축척 · CAD 줌아웃 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 85~97 · 조사 AC(로직 기본값 · 사용자 결정 기다림). + +## 221. 돋보기 — CAD 영역에선 안 뜸 (sub4) +정함(브레인) = 사용자 85번 · CAD(iframe · cad_host) 위에 커서가 있으면 돋보기를 켜지 않고 · 켜진 채 CAD 위로 들어가면 숨김(나오면 다시) · 복제할 때 CAD 영역은 빼서 빈칸 · 휠 막음도 CAD 위에선 안 함 +- [x] 221-1 CAD 영역 빼기 — test_220_1 · 새 시험 + - 검증완료 — test_221_1(6) · test_220_1 · test_218_1 25 통과 · typecheck 통과 · CAD 위 켬 0회 · 휠 prevented 거짓 · 나오면 배율 2.25 그대로 · 복제 CAD 칸 자식 0 + +## 222. M02 구조물조합 일위대가 기계비빔타설(엄상준 SU-000007) — 자재 단위 · 별도계상 빼기 · 원문참고 둘 · 시멘트 출처 (sub7) +정함(브레인) = 사용자 87~90번 · 문제 둘 갈라 봄: ① 시멘트 · 모래 · 자갈 줄 단위 · 수량 · 원가가 틀린 까닭(로직 · 단위 환산 · 표시) ② 자재목록에서 별도계상 고른 자재 = 그 일위대가 단가에서 빠지고 일위대가 줄에서도 사라짐(나중에 본 프로그램에서 따로 계상 · 자재목록은 일위대가 상세 보기일 뿐 따로 셈하지 않음) · 원문참고 = 로직이 쓰는 원문 절 모두(12-1-2 · 12-1-3) · 시멘트가 어떻게 이어져 나오는지 보고 · 원본 단가산출 로직(master_data)은 고치지 않음(자료 문제면 보고) +- [x] 222-1 단위 까닭 · 별도계상 빼기 · 원문참고 여럿 · 시멘트 길 보고 — 새 시험 + - 검증완료 — test_222_1_m02_apart 12 · 관련 M02 · B08 · B09 시험 243 통과 · typecheck · 엄상준 SU-000007 사본 셈 기계비빔타설 기준 39,554,608 → 146,373원(시멘트 · 모래 · 자갈 줄 빠짐 · 자재목록 3줄 남음) · 원문 「산림품셈 12-1-2 · 12-1-3」 두 절 + +## 223. 단가산출 로직 입력값 기본값 점검 — GF000156 같은 것 (조사 AC · 조사 · 제안) +정함(브레인) = 사용자 86 · 96번 · 원본 로직은 사용자와 함께 고침(코드가 master_data 안 고침) → 모든 로직에서 물성 상수(단위중량 · 밀도 · 비중 · 할증 · 손실률 등) 입력이 빈칸이거나 기본값 없는 것 목록 + 원문(표준품셈 12-1-2 등 · 지식DB)에서 찾은 값 · 역산 값 · 근거 → 사용자 확인 뒤 반영 +- [x] 223-1 조사 문서 · 제안 표 · 사용자 보고 + - 조사완료 — 조사_AC_로직기본값.md · GF000156 기본값 있음(0 = 건조 위끝 1,700 · 1,800) · M01 견본값 1 이 망가뜨림 · 같은 꼴 6무리 · 사용자 결정 기다림 + +## 224. 구조물 조합 연결 모달에 M02 구조물조합 상단 표 (sub5) +정함(브레인) = 사용자 91번 · B05/B06 구조물 배치 → 구조물 조합 연결 모달 안에 M02 구조물조합 페이지 상단 표(조합 요약 표)를 같은 부품으로 보임(보기 전용) +- [x] 224-1 모달 상단 표 — 새 시험 + - 검증완료 — 새 test_224_1 9(고른 항목 층 · 주인의 집계표 보기 전용 · 같은 층 조합 = 열만 옮김 · 다른 층 = 표 바뀜 · 읽는 사이 바뀌면 마지막만 · 번호 실패해도 표 · 표 실패 = 까닭 글 · M02 와 같은 createSheet · numberLabels · comboView) · test_m02_combo_table(줄 글 도우미 단정 고침) · combo_screen · structure_panel · layer_screen · 173_2 · 170 · 163 · 156_2 통과 · typecheck 0 + +## 225. B06 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 (sub2) +정함(브레인) = 사용자 92 · 93번 · 세월교 · 관 자리 관 정보 글 = 노폭 · 노견 길이 라벨 위 · 관보호공 위치 = 관 길이와 무관하게 0.1 m 단위 이동 · 관 길이 표시(라벨 · 좌측 패널) = 반올림 정수 + 괄호 소수 첫째(예 「8 (7.6)」) · 구조물 집계표(B08)로 넘기는 값 = 반올림 정수 +- [x] 225-1 글 자리 · 0.1 m 이동 · 표시 · B08 값 — 새 시험 + - 검증완료 — 새 test_225_1 9(글 = 노폭 라벨 y 180 → 167 · 세월교 154 · 관보호공 0.1 m · 세월교 흙막이 1 m · ◀▶ 때 d 묶음 · 유출 벽 0.1 m 씩 → 관 7.9 · 8.0 · 8.1 · 8.2 · 「8 m (7.6)」 · B08 7.6 → 8 · 7.5 → 8 · 7.4 → 7 · 짝 거울 8값) · 정수 맞춤 없앰에 맞춰 139_6 · 186_1 · 108 · 79 기대값 고침 · 관 · B08 시험 250 통과 · typecheck 0 · test_79 유입 d 0.4≠0.6 은 변경 전부터 실패 + +## 226. CAD 축척 — 기준점 고르고 숫자 배율 입력 (sub6) +정함(브레인) = 사용자 94번 · 오토캐드 SCALE 꼴: 객체 고름 → 기준점 클릭 → 배율 숫자 입력(Enter) 또는 끌기 · 지금 끌기 축척은 그대로 +- [x] 226-1 기준점 · 숫자 배율 — vitest · CAD build + - 검증완료 — 새 vitest 5(기준점 (2,0) · 배율 2 = (-2,0)~(18,0) · 0.5 = (1,0)~(6,0) · 다른 객체 · 순서 그대로 · Esc 취소 · 배율 0 거절 · 되돌리기 한 번에 (0,0)~(10,0)) · 기존 포함 131 통과 · typecheck · CAD build 통과 + +## 227. CAD 많이 줌아웃하면 느려짐 — 까닭 · 개선 방향 (sub8 · 조사 · 제안) +정함(브레인) = 사용자 95번(질문) · 줌아웃 때 그리는 객체 수 · 글자 · 해치 · 격자 그리기 비용을 재고 개선안(작은 객체 생략 · 글자 생략 · 격자 간격 · 그리기 묶음 · 캐시) 제안 · 큰 효과 · 작은 위험 안은 바로 고쳐도 됨(전후 수치) +- [x] 227-1 측정 · 제안 · (바로 고칠 수 있는 것) 고침 + - 검증완료 — zoom-out-perf vitest(가짜 캔버스 · 횡단 40 + 등고선 178) 한 화면 다시 그리기 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms · 맞춤 23 → 21 ms(까닭 = 공간 색인 조회가 화면 상자 안 빈 칸 수억 개를 돎 → 칸 범위 자름) · 모눈 켜도 정지 화면 다시 그리기 0 회 · CAD vitest 129 · typecheck · build +- [x] 221-2 (사용자 97) 돋보기 = 페이지 · 안쪽 칸 스크롤에 따라 안 바뀌는 흠 — 복제 때 스크롤 칸들의 scrollTop · Left 를 복제본에 옮겨 적고 · 켜진 동안 스크롤(잡기 단계 · 모든 칸)이 나면 다시 맞춤(가벼우면 위치만 · 아니면 다시 뜸) — 새 시험 + - 검증완료 — test_221_2(3) · 221_1 · 220_1 · 218_1 합 28 통과 · typecheck 통과 · 스크롤 260 에서 다시 켬 = 복제 260(옛 코드 0) · 켠 채 400 + 문서 50 = rAF 1회에 400 · 페이지 -50px +- [x] 224-2 (sub1) 오늘 합친 뒤 빨강 둘 — test_m02_card_shell(214-2 Ctrl+S 판정이 key_shortcut 으로 바뀜 → 기대값) · test_modal_top_and_add_word(200-2 Bill_Level 안내 글 「더하기」 → 화면 글 규칙대로 「추가」) — 두 시험 통과 + - 검증완료 — test_m02_card_shell · test_modal_top_and_add_word 14 passed · test_200_2 4 passed · typecheck 통과 · 코드 글만 고침(Bill_Level 안내 「더하기」→「추가」) +- [x] 225-2 (sub1) test_79 관 보호공 기본 자리 d(유입) 0.6 → 0.4 로 바뀜 — 211(유입 흙막이 관 + 0.7)의 당연한 결과인지 · 흠인지 판정 · 결과면 기대값 · 흠이면 고침 — 시험 통과 + - 검증완료 — 판정 = 당연한 결과(흠 아님): 벽 바닥은 관 invert 상한(노견 − 관경 − 토피 0.5)에 고정 · 벽이 0.2 높아져 윗면 노견 − 0.5 → − 0.3 · d = 올림(1.2 × (1.5 − 1.2) · 0.1) = 0.4 · 225-1 관 길이와 무관 · 기대값 d 0.4 · 윗면 노견 − 0.3 으로 고침(시험 글에 까닭) · test_79 · 211_1 · 225_1 27 passed · typecheck 0 diff --git a/docs/raw/요구_1010_2/조사_AC_로직기본값.md b/docs/raw/요구_1010_2/조사_AC_로직기본값.md new file mode 100644 index 000000000..47a6e13b8 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AC_로직기본값.md @@ -0,0 +1,108 @@ +# 조사 AC — 단가산출 로직 물성 기본값 (요구 86 · 96 · 조사만) + +대상 = M01 로직 `GF000156` 기계비빔타설(짝 `GF000157` 인력비빔타설)의 모래 · 자갈 단위중량 + 전 로직의 물성 상수 입력. 값 반영은 사용자 확인 뒤. + +## 1. 한눈 결론 + +- 기본값은 이미 있음 — 칸에 `0` 을 넣으면 원문표(건설품셈 공통 1-2-4 3 재료의 단위중량)의 「건조」 위끝(모래 1,700 · 자갈 1,800)을 씀. 그런데 M01 화면은 견본값으로 `1` 을 채움 → 배합 (B)를 고르면 모래 828㎥ · 자갈 1,011㎥(1㎥당), 모래값만 3천만 원대로 튐(실측). +- 원문 12-1-3 배합표는 kg 만 있어 원문 혼자서는 역산 못 함. 실무 두 곳(울진대흥 2024 일위대가 · STmate)의 같은 배합 ㎥ 값으로 역산하면 모래 ≈ 1,600 · 자갈 ≈ 1,700 — 원문 「건조」 범위의 가운데와 맞음. +- 지금 위끝(1,700 · 1,800)은 실무보다 모래 · 자갈 ㎥를 5~6% 적게 냄 → 기본을 「건조 가운데」(1,600 · 1,700)로 바꾸는 안 제안. +- 같은 꼴(물성 칸에 견본 0 · 1이 들어가 값이 망가지는 것)이 6무리 — 노체 살수(함수비 · 흙 단위중량) · 골재운반 4개(qt · f · E · Cm) · 덤프 자재운반 사이클시간 · 거푸집 높이감률 3개. +- 프로그램 쪽은 견본값 규칙이 줄 머리의 `시험입력` 을 안 보는 것이 뿌리 — 시험입력을 먼저 쓰게 하면 46개 로직 55칸이 한꺼번에 바로잡힘. + +## 2. GF000156 기계비빔타설 + +| 항목 | 내용 | 자리 | +|---|---|---| +| 이름 · 출처 | 기계비빔타설 · 산림품셈 12-1-2 · 원/㎥ | `로직_산림품셈_12장_철근콘크리트.json:47` | +| 시험입력 | 자재지역 울진 · 모래단위중량 0 · 자갈단위중량 0 | 같은 파일:53 | +| 입력 | 구분 · 믹서 · 지역 · 재료배합(계상 안 함 · (A)(B)(C)) · 골재최대치수(25 · 40) · 수중 · 모래상태 · 자갈상태(건조 · 습기 · 포화) · 모래단위중량 · 자갈단위중량(㎏/㎥ · 「0 이면 고른 상태의 표 위끝」) · 자재지역 | :55-160 (단위중량 :146 · :152) | +| 중간 | 모래밀도 = 만약(모래단위중량 > 0, 모래단위중량, 찾기(CC000337, 종별='모래', 형상=모래상태).위) · 자갈밀도 같은 꼴 | :163-164 | +| 쓰이는 식 | 모래(㎥) = 배합표(QF000353) 모래 kg ÷ 모래밀도 · 자갈(㎥) = 자갈 kg ÷ 자갈밀도 | :203-219 | +| 원문 배합표 | 12-1-3 주⑤ (A)(B)(C) × 25 · 40㎜, kg/㎥ | `소요량_산림품셈_12장_철근콘크리트.json:70-86` | +| 단위중량 표 | CC000337 모래 건조 1,500~1,700 · 자갈 건조 1,600~1,800 (아래 · 위 두 칸) | `계수_건설품셈_01장_적용기준.json:23-28` | +| 규칙 | 「0 = 자동 · 넣으면 그 값이 이김」 | `resources/master_data/_틀.md:241` | + +실측(읽기 전용 계산 · 울진 · (B) 25㎜ · 건조): + +| 단위중량 칸 | 모래밀도 · 자갈밀도 | 모래 ㎥ | 자갈 ㎥ | 모래 금액 | +|---|---|---|---|---| +| 1 (M01 화면 견본) | 1 · 1 | 828 | 1,011 | 30,636,000원 | +| 0 (자동 = 위끝) | 1,700 · 1,800 | 0.487 | 0.562 | 18,021원 | +| 1,600 · 1,700 (제안) | 1,600 · 1,700 | 0.518 | 0.595 | 19,148원 | + +- 견본 1의 까닭 — 견본값 규칙이 「나누는 자리에 오는 수 = 1」(`resources/master_data/scripts/master_auto.py:4` · :97). 모래밀도가 나누는 자리라 그 식에 든 모래단위중량도 1을 받음. 화면은 빈 칸에 이 값을 채움(`M01_MasterData/M01_MasterData_UI_Logic_Calc.ts:154-164`). +- 재료배합 견본이 「계상 안 함」이라 첫 화면에선 재료 0원으로 가려져 있다가, (A)(B)(C)를 고르는 순간 드러남. +- 곁가지 — 같은 시험 계산에서 시멘트 줄이 `MT003688` 프롬시멘트 PR(3,000원/kg)로 잡힘. 보통 시멘트(`MT004176` 시멘트, 전국 152~168원/kg)보다 18배 넘게 큼 — 검색어 「시멘트 전국」 후보 고르기 따로 확인할 것. + +## 3. 원문 근거 + +| 근거 | 값 | 자리 | +|---|---|---| +| 산림품셈 12-1-3 주⑤ 배합표 | 25㎜ (A) 357 · 893 · 931 / (B) 346 · 828 · 1,011 / (C) 340 · 779 · 1,049 · 40㎜ (A) 335 · 838 · 1,032 / (B) 323 · 775 · 1,101 / (C) 318 · 728 · 1,157 (시멘트 · 모래 · 자갈 kg) · 「모래는 건조상태 기준」 | `resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-01_콘크리트타설.md:60-71` | +| 건설품셈 공통 1-2-4 3 | 모래 건조 1,500∼1,700 · 습기 1,700∼1,800 · 포화 1,800∼2,000 / 자갈 건조 1,600∼1,800 · 습기 1,700∼1,800 · 포화 1,800∼1,900 (자연상태 · 「시험으로 정함이 원칙」) | `…/건설공사_표준품셈/본문/01_공통부문/제01장_적용기준/1-02_설계_및_수량.md:262-277` | +| 건설품셈 6-1(현장비빔타설) | 품만 있고 배합표 없음 — 역산 근거 못 됨 | `…/제06장_철근콘크리트공사/6-01_콘크리트.md:47-68` | +| 실무 울진대흥 2024 일위대가 B01190 「콘크리트비빔 기계비빔40mm」 | 시멘트 323kg · 모래 0.48㎥ · 자갈 0.65㎥ (= 40㎜ (B)) | `resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)/2024년 간선임도 신설사업(…).xlsx` 일위대가표 45~48행 | +| STmate 실무 레시피 | 같은 값(323 · 0.48 · 0.65) | `…/경쟁사 프로그램/STmate 분석/30_원자료/일위대가_레시피.json:10410` | +| 실무 울진소광 2025 원단위 | 채움 0.31㎥ · 친모래 0.17 · 친자갈 0.20 · 시멘트 2.69대 — 시멘트는 25㎜ (B) 346kg 과 맞고 모래는 1,600 근처, 자갈은 안 맞음(참고만 · 약함) | `…/실무문서/_원단위라이브러리_울진소광.md:88,96` | + +역산(배합 kg ÷ 실무 ㎥): + +- 모래 775 ÷ 0.48 = 1,615 · 반올림 폭(0.475~0.485)을 넣으면 1,598~1,632 → 1,600 이 들어감 · 지금 위끝 1,700 은 밖. +- 자갈 1,101 ÷ 0.65 = 1,694 · 폭(0.645~0.655) 1,681~1,707 → 1,700 이 들어감 · 지금 위끝 1,800 은 밖. +- 1,600 · 1,700 으로 나머지 다섯 배합을 셈 — 25㎜ (A) 0.56 · 0.55 / (B) 0.52 · 0.59 / (C) 0.49 · 0.62 · 40㎜ (A) 0.52 · 0.61 / (C) 0.46 · 0.68㎥. 옛 판 ㎥ 배합표(일위대가조합 메모 「실무 수는 옛 판 값」)가 이 두 값으로 만들어진 것으로 보임(옛 판 원문 자체는 지식DB에 없음). +- 재료 할증 — 원문 1-3-1 콘크리트용(시멘트 2 · 잔골재 10 · 굵은골재 3%, 정치식)은 어느 로직도 안 씀(`계수_산림품셈_01장_적용기준.json:434` CF000021 로직키 빈칸). 실무는 일위대가에 할증 없이 원문 값 그대로, 수량산출 쪽에서 모래 10%를 붙임(`…/울진.대흥)(1공구)/_분석_수량산출.md:83`) → 로직엔 안 넣는 것이 실무와 같음. + +## 4. 비슷한 로직 목록 + +훑은 범위 = 로직 파일 전부의 「고르기 · 범위 없는 수 입력」 688칸(가격 · 자재지역 뺌) · 견본값 규칙으로 실제 채워지는 값까지 셈. + +가. 물성 칸에 견본 0 · 1이 들어가 결과가 망가지는 것 + +| 로직 | 칸 | 견본 | 결과 | 자리 | +|---|---|---|---|---| +| GF000156 · GF000157 비빔타설 | 모래 · 자갈단위중량 | 1 | 재료 ㎥ 1,000배대 | `로직_산림품셈_12장_철근콘크리트.json:146,152,289,295` | +| GF000277 노체 살수 | 최적함수비 · 자연함수비 · 흙단위중량 | 1 · 1 · 1 | 살수량 0 → 물탱크 0원 (시험입력엔 13 · 8 · 1,600 있음) | `로직_산림품셈_09장_토공.json:2515` | +| GF000133 골재운반 시간당 작업량 | qt · f · E · Cm | 0 · 0 · 0 · 1 | Q = 0 | `로직_산림품셈_10장_자재․장비_운반.json:8` | +| GF000134~136 골재운반 모래 · 잡석 · 혼합골재 | qt · f · E · Cm | 1 · 1 · 1 · 1 | 덤프 0.0167시간/㎥(뜻 없는 값) · 칸에 설명 · 출처도 없음 | 같은 파일:20,64,108 | +| GF000283 덤프 자재 운반 | 사이클시간(분) | 0 | 운반비 0 (시험입력 30) | 같은 파일:699 | +| GC000211 · GC000214 · GF000210 거푸집 | 높이감률(%) | 1 | 시공량 1% 깎임(7m 이하면 0이어야) | `로직_건설품셈_06장_철근콘크리트공사.json:999,1411` · `로직_산림품셈_12장_철근콘크리트.json:4976` | + +나. 원문에 값이 있는데 입력으로만 받는 것 + +- GF000133~136 `E` — 건설품셈 8-2-8 「E : 작업효율(0.9)」. 이웃 GF000284 덤프운반은 0.9를 식에 박아 씀. +- GF000277 — 원문 주②③ 예시 E 0.9 · L 1.0㎞ · OMC 13% · NMC 8% · 단위중량 1,600 (`…/제09장_토공/9-16_노체.md:65-75`). E · V 는 이미 표(CF000101 · CF000102)로 씀, 함수비 · 단위중량만 빈칸. + +다. 표는 있는데 어느 로직도 안 쓰는 것 + +- CF000012 체적의 변화율 L · C · CF000013 체적환산계수 f (`계수_산림품셈_01장_적용기준.json:249,278`) — 골재운반 qt · f 의 근거인데 로직키 빈칸. +- CF000021 · CC000001 재료 할증 — 3장 끝 참고(실무와 같아 그대로 둠 제안). + +라. 기본 없음이 맞는 것(조치 없음) + +- 높이할증 11곳(GF000160 등 · 0 = 할증 없음) · 추가할증 · 부속비율 · 증가율 · 비산방지살포량 · 양생모래량(원문 「없으면 0」) · 관입률(0 = 계수 없음) · 그 밖 길이 · 면적 · 수량 칸 — 설계 조건이라 0 견본이 해롭지 않음. + +## 5. 제안 표 + +| 로직 코드 | 이름 | 입력 칸 | 지금 값 | 제안 기본값 | 근거(원문 파일:표 · 역산식) | 확신 | +|---|---|---|---|---|---|---| +| GF000156 · GF000157 | 기계 · 인력비빔타설 | 모래단위중량 | 0 = 자동(건조 위끝 1,700) · 화면 견본 1 | 1,600 ㎏/㎥ (건조 가운데) — 자동 규칙을 「위」→「(아래+위)/2」로 | 1-02_설계_및_수량.md:274 건조 1,500∼1,700 · 역산 775 ÷ 0.48 = 1,615(울진대흥 B01190 · STmate:10410) | 중간 | +| GF000156 · GF000157 | 〃 | 자갈단위중량 | 0 = 자동(1,800) · 견본 1 | 1,700 ㎏/㎥ (건조 가운데) | 같은 표:271 건조 1,600∼1,800 · 역산 1,101 ÷ 0.65 = 1,694 | 중간 | +| GF000156 · GF000157 | 〃 | 습기 · 포화 고를 때 | 위끝 | 가운데(모래 1,750 · 1,900 / 자갈 1,750 · 1,850) | 같은 표 · 실무 역산 근거 없음 | 낮음 · 사용자 확인 | +| GF000277 | 노체 살수 | 최적함수비 | 없음(견본 1) | 13 % | 9-16_노체.md:65 주② 예시 | 중간(현장 시험값 우선) | +| GF000277 | 〃 | 자연함수비 | 없음(견본 1) | 8 % | 같은 줄 | 중간 | +| GF000277 | 〃 | 흙단위중량 | 없음(견본 1) | 1,600 ㎏/㎥ | 9-16_노체.md:69 예시 (10-12-1 덤프 적재 토사 1.9t/㎥ 와 다름) | 중간 | +| GF000133~136 | 골재운반 | E | 없음(견본 0 · 1) | 0.9 | 건설품셈 8-02-08_덤프트럭.md:34 「작업효율(0.9)」 | 높음 | +| GF000133~136 | 〃 | f | 없음 | 1 (골재 ㎥를 흐트러진 상태로 셀 때) · 자연상태 기준이면 1/L | CF000013 기준 q 흐트러진 → 흐트러진 1 | 낮음 · 사용자 확인 | +| GF000133~136 | 〃 | qt | 없음 | 식으로 — 15 ÷ γt × L (예 모래 15 ÷ 1.6 × 1.25 ≈ 11.7㎥) | 8-02-08_덤프트럭.md:19 q = T/γt·L · CC000337 · CF000012 모래 L 1.20∼1.30 | 낮음 · 사용자 확인 | +| GF000133~136 | 〃 | Cm | 없음 | 고정값 근거 없음 — 운반거리 입력 + 8-2-8 식(GF000284 와 같은 꼴) | 8-02-08_덤프트럭.md:35-41 | 근거 없음 · 사용자 확인 | +| GF000283 | 덤프 자재 운반 | 사이클시간 | 없음(견본 0 · 시험 30) | 원문 값 없음 — 운반거리로 식 계산 또는 설계자 입력 | 10-09 머리 「작업량은 건설품셈 적용」 | 근거 없음 · 사용자 확인 | +| GC000211 · GC000214 · GF000210 | 합판 · 유로폼 거푸집 | 높이감률 | 없음(견본 1%) | 0 % (수직고 7m 이하) | 원문 비고 「7m 초과 매 3m 9%까지」 → 7m 이하 0 | 높음 | +| GF000156 · GF000157 | 비빔타설 | 재료 할증 | 안 붙임 | 지금대로 안 붙임(수량산출에서 붙임) | CF000021 정치식 2 · 10 · 3% · 실무 수량산출 모래 10% | 중간 · 사용자 확인 | + +## 6. 프로그램 쪽 개선 거리 + +- 견본값이 `시험입력` 을 먼저 쓰게 — `resources/master_data/scripts/master_auto.py:86-97` 에서 줄 머리 시험입력에 그 칸이 있으면 그 값. GF000156 · 157(0 → 표 자동) · GF000277(13 · 8 · 1,600) · GF000283(30) 포함 46개 로직 55칸이 바로잡힘. 고칠 줄 몇 줄. +- 「0 = 자동」 칸은 화면에 실제 쓰인 값을 보임 — 입력 옆 회색 글 「자동 1,700(건조 위끝)」(계산 응답 `middle` 의 모래밀도를 그대로 읽음 · `M01_MasterData_UI_Logic_Calc.ts:111-121` 입력 상자 자리). +- 물성 칸 경고 — 단위가 ㎏/㎥ · t/㎥ · % 이거나 무단위 계수(f · E · L)인데 고르기 · 범위 · 시험입력이 다 없는 칸은 M01 입력 이름 옆 ⚠ 「기본값 없음 — 원문 확인」 · `check_master` 검사에 같은 셈 한 줄. +- 기본을 바꿀 땐 `_틀.md:241` 「표 위끝」 규칙 글과 두 로직 설명(「0 이면 고른 상태의 표 위끝」)을 같이 고침. diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index cfa125bf1..ac649dbf7 100644 --- a/resources/tester/helper_218_1_loupe.cjs +++ b/resources/tester/helper_218_1_loupe.cjs @@ -1,4 +1,4 @@ -/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율) 단위검증 헬퍼 — +/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기 · 221-2 스크롤) 단위검증 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ const fs = require("fs"); const path = require("path"); @@ -37,6 +37,8 @@ function fakeWindow(doc) { (win.calls = win.calls.filter((c) => c.type !== type || c.fn !== fn)); win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.requestAnimationFrame = (fn) => win.setTimeout(fn, 16); // 한 화면 틀 + win.cancelAnimationFrame = win.clearTimeout; win.tick = (ms) => { win.now += ms; for (const t of win.timers.filter((t) => t.at <= win.now)) { @@ -271,6 +273,98 @@ r.origin = { bottomRight: m.loupeOrigin(995, 795, 1000, 800), }; +{ + // 221-1 CAD 영역 — 위에선 안 켬 · 켜진 채 들어가면 숨김 · 나오면 보임 · 휠은 CAD 로 + const cad = { tagName: "IFRAME", closest: (sel) => (sel === m.LOUPE_SKIP ? {} : null) }; + const plain = { tagName: "DIV", closest: () => null }; + const kinds = (v) => v.log.map((l) => l[0]); + r.cad = { + isCad: [m.isCadTarget(cad), m.isCadTarget(plain), m.isCadTarget(null), m.isCadTarget({})], + skip: m.LOUPE_SKIP, + }; + { + // Z 를 CAD 위에서 누름 — 안 켬 · 나오면 보임 · 떼면 끔 + const { win, view } = setup(); + win.fire("mouseover", { target: cad }); + win.fire("keydown", { code: "KeyZ", target: cad }); + const onCad = view.log.length; + const wheel = win.fire("wheel", { deltaY: -100, target: cad }); + win.fire("mouseover", { target: plain, clientX: 50, clientY: 60 }); + const out = view.log[0]; + win.fire("keyup", { code: "KeyZ" }); + r.cad.keyOnCad = { onCad, wheelPrevented: wheel.prevented, out, off: last(view) }; + } + { + // 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임 + const { win, view } = setup(); + win.fire("keydown", { code: "KeyZ" }); + win.fire("wheel", { deltaY: -100 }); // 2.25 + win.fire("mouseover", { target: cad }); + const hidden = last(view); + const n = view.log.length; + const wheel = win.fire("wheel", { deltaY: -100, target: cad }); + win.fire("mousemove", { target: cad, clientX: 5, clientY: 5 }); + const quiet = view.log.length === n; // 숨긴 동안 move · 배율 안 부름 + win.fire("mouseover", { target: plain, clientX: 70, clientY: 80 }); + const back = view.log.slice(n); + win.fire("mousemove", { target: cad, clientX: 6, clientY: 6 }); + const moveHide = last(view); + win.fire("mousemove", { target: plain, clientX: 7, clientY: 7 }); + r.cad.enter = { + hidden, + wheel: { prevented: wheel.prevented, stopped: wheel.stopped }, + quiet, + back, + moveHide, + moveBack: kinds(view).slice(-3), + }; + } + { + // 우클릭 길게 — CAD 위면 안 켜고 메뉴 그대로 + const { win, view } = setup(); + win.fire("mousedown", { button: 2, buttons: 2, target: cad }); + win.tick(1000); + win.fire("mouseup", { button: 2, target: cad }); + const menu = win.fire("contextmenu", { target: cad }); + r.cad.press = { log: view.log.length, menuPrevented: menu.prevented }; + } + { + // 초점이 CAD iframe 으로 넘어감(cad_host) — 끄지 않고 숨김 · Z 떼기는 그쪽 창에서 + const { win, view } = setup(); + const frameCalls = []; + const frame = { + ...cad, + contentWindow: { addEventListener: (type, fn, cap) => frameCalls.push({ type, fn, cap }) }, + }; + win.document = { activeElement: frame }; + win.fire("keydown", { code: "KeyZ" }); + win.fire("blur", { target: win }); + const blurHidden = kinds(view); + win.document = { activeElement: plain }; + const up = frameCalls.find((c) => c.type === "keyup"); + up.fn({ code: "KeyZ" }); + win.fire("mouseover", { target: plain }); + const afterRelease = kinds(view); + // 우클릭 누른 채 들어감 — 나오면 다시 보임 + const b = setup(); + b.win.fire("mousedown", { button: 2, buttons: 2 }); + b.win.tick(400); + b.win.document = { activeElement: frame }; + b.win.fire("blur", { target: b.win }); + b.win.document = { activeElement: plain }; + b.win.fire("mousemove", { buttons: 2, target: plain }); + const pressBack = kinds(b.view); + b.win.fire("mouseup", { button: 2 }); + r.cad.blur = { + blurHidden, + frameKeyup: up && up.cap, + afterRelease, + pressBack, + pressOff: last(b.view), + }; + } +} + // ---- 가짜 요소 ---- const SVG_NS = "http://www.w3.org/2000/svg"; class El { @@ -282,7 +376,7 @@ class El { this.style = {}; this.dataset = {}; this.attrs = {}; - this.scrollTop = opts.scrollTop || 0; + this.st = opts.scrollTop || 0; this.scrollLeft = 0; this.width = opts.width || 0; this.height = opts.height || 0; @@ -299,6 +393,17 @@ class El { getBoundingClientRect() { return this.rect; } + // 실제 DOM 처럼 숨긴(hidden) 조상 아래에선 스크롤을 적어도 안 먹음 + get scrollTop() { + return this.st; + } + set scrollTop(v) { + for (let e = this; e; e = e.parent) if (e.hidden) return; + this.st = v; + } + get classList() { + return { contains: (k) => this.className.split(" ").includes(k) }; + } setAttribute(k, v) { this.attrs[k] = v; } @@ -370,8 +475,15 @@ function makePage() { contentDocument: frameDoc, rect: { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 }, }); + const cadBox = new El("DIV", { + rect: { left: 0, top: 300, right: 400, bottom: 600, width: 400, height: 300 }, + }); + cadBox.className = "cad-host"; + cadBox.append(new El("IFRAME"), new El("A")); + scroller.scrollHeight = 900; // 스크롤될 수 있는 칸 + scroller.clientHeight = 300; scroller.append(canvas, blank, input, check); - app.append(scroller, offscreen, table, svg, frame); + app.append(scroller, offscreen, table, svg, frame, cadBox); return app; } function fakeDocument(app) { @@ -406,7 +518,7 @@ function fakeDocument(app) { const stage = root.children[0]; const page = stage.children[0]; const copy = page.children[0]; - const [scroller, offscreen, table, svg, frame] = copy.children; + const [scroller, offscreen, table, svg, frame, cadBox] = copy.children; const [canvas, blank, input, check] = scroller.children; r.capture = { rootClass: root.className, @@ -429,7 +541,10 @@ function fakeDocument(app) { svgKept: svg.children.length, frameTag: frame.tagName, frameDrawn: frame.drawn, - frameSize: [frame.width, frame.height], + frameSize: [frame.style.width, frame.style.height], + frameEmpty: frame.children.length, + cadBoxEmpty: cadBox.children.length, + cadBoxSize: [cadBox.style.width, cadBox.style.height], watching: observers[0].watching === app, observeOpts: observers[0].opts, scrollListen: win.calls.some((c) => c.type === "scroll" && c.capture), @@ -478,4 +593,46 @@ function fakeDocument(app) { r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform]; r.hide.reuseRoot = doc.body.children.length; } +{ + // 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서) + const app = makePage(); + const scroller = app.children[0]; + const doc = fakeDocument(app); + const win = fakeWindow(doc); + win.MutationObserver = class { + observe() {} + disconnect() {} + }; + const view = m.createLoupeView(win); + const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0]; + const page = () => doc.body.children[0].children[0].children[0]; + view.show(500, 400); + view.hide(); + scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘 + view.show(500, 400); + const zero = cloneScroller().scrollTop; + view.hide(); + scroller.scrollTop = 260; // 꺼진 동안 스크롤해 둠 → 다시 켬(숨긴 원을 다시 씀) + view.show(500, 400); + const before = cloneScroller().scrollTop; + // 켠 채 스크롤 — 다음 화면 틀에 위치만 · 같은 복제 + const first = cloneScroller(); + scroller.scrollTop = 400; + app.rect = { ...app.rect, top: -50, bottom: 750 }; // 문서도 50 내려감 + const scroll = () => win.calls.filter((c) => c.type === "scroll").forEach((c) => c.fn({})); + scroll(); + scroll(); // 한 틀에 한 번으로 묶음 + const rafs = win.timers.filter((t) => t.at === win.now + 16).length; + win.tick(16); + const during = { + top: cloneScroller().scrollTop, + same: cloneScroller() === first, + pageTop: page().style.top, + }; + win.tick(484); // 0.5 초 뒤 다시 뜸 — 비워 둔 칸을 채움 · 스크롤은 그대로 + const recaptured = { same: cloneScroller() === first, top: cloneScroller().scrollTop }; + view.hide(); + const n = win.timers.length; + r.scroll = { zero, before, rafs, during, recaptured, timersAfterHide: n }; +} console.log(JSON.stringify(r)); diff --git a/resources/tester/helper_224_1_picker_top_table.cjs b/resources/tester/helper_224_1_picker_top_table.cjs new file mode 100644 index 000000000..a7b1e700a --- /dev/null +++ b/resources/tester/helper_224_1_picker_top_table.cjs @@ -0,0 +1,167 @@ +/* 고르기 모달 상단 표(224-1) 시험 헬퍼 — `ui_template_item_picker_table.ts` 를 트랜스파일해 + * 표 부품 · 집계표 모듈 · 서버 읽기를 대역으로 갈아 끼우고 돌려 부름 기록을 JSON 한 벌로 냄. + * 동적 `import()` 도 CommonJS 변환에서 같은 `require` 길을 타 대역이 먹음. + * ⚠ 확장자가 `.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 UI = path.join(ROOT, "ui_template"); +const log = []; +const state = { numbersFail: false, tableFail: null }; + +const node = (props = {}) => ({ + className: props.className ?? "", + hidden: "attrs" in props && props.attrs && "hidden" in props.attrs, + text: props.text ?? "", + children: props.children ?? [], + replaceChildren(...kids) { + this.children = kids; + }, +}); + +const STUBS = { + "./ui_template_elements": { el: (_tag, props) => node(props) }, + "./ui_template_locale": { t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중") }, + "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch": { + readTemplate: async (...args) => { + log.push(["readTemplate", ...args]); + if (state.tableFail) throw new Error(state.tableFail); + return { 문서: { 열: [], tag: `${args[0]}|${args[4]}` } }; + }, + fetchStructureNumbersAt: async (...args) => { + log.push(["numbers", ...args]); + if (state.numbersFail) throw new Error("numbers down"); + return { rv1: { 도번: "SD1" } }; + }, + }, + "../M02_MasterTemplete/M02_MasterTemplete_Structure": { + STRUCTURE_TABLE: "구조물집계표", + comboView: (doc) => ({ ...doc, comboView: true }), + numberLabels: (numbers) => ({ laborLabel: () => "", seenNumbers: numbers() }), + }, + "./sheet/ui_template_sheet": { + createSheet: (host, doc, opts) => { + const entry = { doc, opts, selected: [], destroyed: false, host }; + log.push(["createSheet", entry]); + host.replaceChildren({ sheet: true }); + return { + selectColumn: (id) => entry.selected.push(id), + destroy: () => (entry.destroyed = true), + }; + }, + }, +}; + +const cache = new Map(); +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(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); +const row = (key, 열, layer, owner) => ({ key, 열, layer, owner }); +const names = () => log.map((entry) => entry[0]); +const creates = () => log.filter((entry) => entry[0] === "createSheet").map((entry) => entry[1]); + +(async () => { + const { createComboTable } = loadTs(path.join(UI, "ui_template_item_picker_table.ts")); + const data = loadTs(path.join(UI, "ui_template_item_picker_data.ts")); + const out = {}; + const mine = row("SS1", "rv1", "personal", "1/3"); + const sameLayer = row("SS2", "rv2", "personal", "1/3"); + const master = row("SS9", "rv9", "system", null); + + const table = createComboTable(); + out.startHidden = table.root.hidden; + table.show(null); + out.nullHidden = table.root.hidden; + out.nullCalls = names(); + + table.show(mine); + out.shownHidden = table.root.hidden; + await flush(); + out.firstReads = log.filter((e) => e[0] !== "createSheet"); + const first = creates()[0]; + out.first = { + count: creates().length, + readOnly: first.opts.readOnly, + lockHead: first.opts.lockHead, + mode: first.opts.mode, + combo: !!first.opts.combo, + comboDoc: first.doc.comboView, + docTag: first.doc.tag, + numbersSeen: first.opts.seenNumbers, + selected: first.selected, + }; + + log.length = 0; + table.show(sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만 + await flush(); + out.sameLayer = { calls: names(), selected: first.selected, destroyed: first.destroyed }; + + log.length = 0; + table.show(master); // 다른 층 — 그 층 표로 바뀜 + await flush(); + out.otherLayer = { + reads: log.filter((e) => e[0] === "readTemplate"), + creates: creates().length, + oldDestroyed: first.destroyed, + selected: creates()[0].selected, + docTag: creates()[0].doc.tag, + }; + + log.length = 0; + table.show(null); + out.hideAgain = table.root.hidden; + + // 읽는 사이에 다른 조합을 고름 — 마지막 것만 그려짐 + log.length = 0; + table.show(mine); + table.show(master); + await flush(); + out.race = { creates: creates().length, docTag: creates()[0]?.doc.tag }; + + // 번호 읽기 실패 — 표는 그려짐 + log.length = 0; + state.numbersFail = true; + const lone = createComboTable(); + lone.show(mine); + await flush(); + out.numbersFail = { creates: creates().length, seen: creates()[0]?.opts.seenNumbers }; + state.numbersFail = false; + + // 표 읽기 실패 — 까닭 글 · 표 없음 + log.length = 0; + state.tableFail = "그 층은 아직 만든 것이 없음"; + const broken = createComboTable(); + broken.show(mine); + await flush(); + out.tableFail = { creates: creates().length, text: broken.root.children[0].text }; + + out.keys = { + mineSame: data.tableKey(mine) === data.tableKey(sameLayer), + layerDiffers: data.tableKey(mine) !== data.tableKey(master), + ownerDiffers: data.tableKey(mine) !== data.tableKey(row("SS1", "rv1", "personal", "1/4")), + mine: data.tableKey(mine), + }; + console.log(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/helper_225_1_pipe_round.cjs b/resources/tester/helper_225_1_pipe_round.cjs new file mode 100644 index 000000000..a56c21a89 --- /dev/null +++ b/resources/tester/helper_225_1_pipe_round.cjs @@ -0,0 +1,154 @@ +/* 225-1 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 헬퍼 — 짝 = `test_225_1_pipe_round.py`. + * 입력 = 인자(반올림 거울 값 JSON 배열) · B07 픽스처 배관 측점(139-6 과 같은 자료). + */ +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(); +const noop = new Proxy({}, { get: () => () => "" }); + +/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */ +class El { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.parentNode = null; + this.ownerSVGElement = null; + this.textContent = ""; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + appendChild(c) { + c.parentNode = this; + c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement; + this.children.push(c); + return c; + } + append(...cs) { + cs.forEach((c) => this.appendChild(c)); + } + addEventListener() {} + all() { + return this.children.flatMap((c) => [c, ...c.all()]); + } + querySelectorAll(sel) { + return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1))); + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + 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 resolve = (base) => + fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`; + const req = (r) => { + if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop; + if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), r))); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} + +const B06 = (name) => loadTs(path.join(ROOT, "B06_Section", `${name}.ts`)); +const { computeCulvertLayout } = B06("B06_Section_UI_Cross_Culvert_Geom"); +const { cutLength } = B06("B06_Section_UI_Cross_Culvert_Solve"); +const { pipeLengthText, pipeLengthWhole } = B06("B06_Section_UI_Cross_Culvert_Const"); +const { appendPipeInfoText } = B06("B06_Section_UI_Cross_Culvert_Types"); +const { structurePanelDeps } = B06("B06_Section_UI_Cross_View_Structure"); + +// ① 글 자리 — 노폭 · 노견 라벨(x 200 · y 180) 바로 위 줄 · 둘이면 한 줄 더 위 · 라벨 없으면 fallback +const svg = new El("svg"); +const road = new El("text"); +road.setAttribute("class", "b06-chart__carriageway-label"); +road.setAttribute("x", 200); +road.setAttribute("y", 180); +svg.appendChild(road); +const plot = new El("g"); +svg.appendChild(plot); +appendPipeInfoText(plot, "관매설 Φ600m/m, L=8 m (7.6)", null); +appendPipeInfoText(plot, "세월교 L8m", [10, 10]); +const infos = svg.querySelectorAll(".b06-chart__pipe-info").map((e) => ({ + x: Number(e.getAttribute("x")), + y: Number(e.getAttribute("y")), + text: e.textContent, + inSvg: e.parentNode === svg, +})); +const bare = new El("svg"); +appendPipeInfoText(bare, "관", null); +appendPipeInfoText(bare, "세월교", [10, 20]); +const bareInfos = bare.querySelectorAll(".b06-chart__pipe-info").map((e) => ({ + x: Number(e.getAttribute("x")), + y: Number(e.getAttribute("y")), + text: e.textContent, +})); + +// ② 0.1 m 이동 — 걸음 분류 · 유출 벽을 0.1 m 씩 밀면 관 길이가 벽 자리를 따라 0.1 m 로 바뀜 +const fine = (key, hiddenPipe, fordGuard) => + structurePanelDeps({ + hiddenPipe: () => hiddenPipe, + fordGuard: () => fordGuard, + isLinked: false, + }).fineMove(key); +const steps = { + pipeOutlet: fine("outlet", false, false), + pipeInlet: fine("inlet", false, false), + fordGuard: fine("outlet", true, true), + hiddenOwn: fine("outlet", true, false), +}; +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +).cross_sections; +const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const r3 = (v) => Math.round(v * 1000) / 1000; +// ◀/▶ 한 번 = x 0.1 + d 빈 칸은 지금 자리로 묶음(옛 기본 d 로 튀지 않게) +const patches = []; +const nudgeDeps = structurePanelDeps({ + adjustOf: () => ({ x: 0, d: null, h: null, m: null }), + appliedD: () => new Map([["outlet", 0.4]]), + outwardOf: () => 1, + adjustChainage: () => 0, + revetOffset: { update: (_c, key, patch) => patches.push({ key, ...patch }) }, +}); +nudgeDeps.nudge("outlet", 0.1); +nudgeDeps.nudge("extra0", 0.1); +const base = computeCulvertLayout(section, section.samples); +const d0 = base.revetShift.outlet.d; +const moved = [0, 0.1, 0.2, 0.3].map((x) => { + const layout = computeCulvertLayout(section, section.samples, { + outlet: { x, d: x ? d0 : null, h: null, m: null }, + }); + return { x, lengthM: layout.pipe.lengthM, cut: r3(cutLength(layout.pipeCorners)) }; +}); + +// ③ · ④ 표시 · 반올림 거울 +const values = JSON.parse(process.argv[2] ?? "[]"); +process.stdout.write( + JSON.stringify({ + infos, + bareInfos, + steps, + patches, + moved, + text: values.map((v) => pipeLengthText(v)), + whole: values.map((v) => pipeLengthWhole(v)), + }), +); diff --git a/resources/tester/test_108_drag_box_hit.py b/resources/tester/test_108_drag_box_hit.py index 78a1ba35d..a5033b204 100644 --- a/resources/tester/test_108_drag_box_hit.py +++ b/resources/tester/test_108_drag_box_hit.py @@ -31,9 +31,9 @@ def out() -> dict: @pytest.mark.parametrize("side", ["diag1", "diag-1"]) def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None: - """배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수.""" + """배관 기준벽(0.1 m 걸음 · 225-1) — 사면 d · 수평 x 가 한 번에 · 값은 걸음 배수.""" case = out["wall"][side] - assert case["update"] == {"role": "outlet", "x": 1, "d": 4} + assert case["update"] == {"role": "outlet", "x": 1.3, "d": 4} assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"] assert "▼ 2.0m" in case["snap"]["text"] diff --git a/resources/tester/test_139_6_pipe_fit.py b/resources/tester/test_139_6_pipe_fit.py index 34c642776..d2959f13a 100644 --- a/resources/tester/test_139_6_pipe_fit.py +++ b/resources/tester/test_139_6_pipe_fit.py @@ -1,7 +1,7 @@ """139-6 노견을 넓힌 유출 쪽 관 길이 자리 — 사면 우선(물매 1:1.2 그대로) + 유출 벽 높이 역산(0.1 m). 설계선 엔진으로 노견 확장 설계를 내고 배수관 기하를 돌린 값(`helper_139_6_pipe_fit.cjs` · B07 픽스처 -배관 85.052 · 유출 = 좌). 안 넓힌 측점은 종전 맞춤 그대로(나눔 전 코드와 같은 값 · 5.772 → 표기 6). +배관 85.052 · 유출 = 좌). 225-1 부터 정수 길이 맞춤 · 높이 역산 없음 — 관 길이 = 그려진 길이 0.1 m. """ import json @@ -40,8 +40,9 @@ def out(tmp_path_factory) -> dict: def test_안_넓힌_측점은_종전_그대로(out: dict) -> None: + """225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8).""" base = out["base"] - assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 6) + assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8) assert base["cut"] == pytest.approx(5.772) @@ -51,10 +52,9 @@ def test_넓힌_쪽은_물매_그대로_높이_역산(out: dict, name: str, exte assert case["edge"] == pytest.approx(2 + extend) and case["extend"] == extend assert case["slope"] == pytest.approx(1.2, abs=0.05) # 사면 우선 — 물매 그대로 assert round(case["pureHeight"] * 10) == pytest.approx(case["pureHeight"] * 10) # 0.1 m 눈금 - assert case["lengthM"] - case["cut"] < 0.1 # 눈금 탓 남는 차만 표기 올림 - assert float(case["lengthM"]).is_integer() + assert case["lengthM"] == round(case["cut"], 1) # 225-1 — 길이는 벽 자리에서 셈(올림 없음) def test_높이가_실제로_바뀜(out: dict) -> None: - """대조 — 1.3 m 넓히면 유출 벽 높이가 2.0 → 1.8 로 역산됨(시험이 높이 길을 실제로 잼).""" - assert out["shifted2"]["pureHeight"] == pytest.approx(1.8) + """225-1 — 길이 맞춤 높이 역산 없앰: 1.3 m 넓혀도 유출 벽 높이는 기본 2.0 그대로.""" + assert out["shifted2"]["pureHeight"] == pytest.approx(2.0) diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 9b98c4cb1..248695cfd 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -79,7 +79,8 @@ def _world_combo(world): # noqa: F811 {"로직": STONE, "수량": {"손": 1}}, {"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}}, ] - mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}] + # MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐) + mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}] with store.at(world["system"]): key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ "이름" diff --git a/resources/tester/test_186_1_outlet_fold.py b/resources/tester/test_186_1_outlet_fold.py index c477551ca..4a173b744 100644 --- a/resources/tester/test_186_1_outlet_fold.py +++ b/resources/tester/test_186_1_outlet_fold.py @@ -63,7 +63,8 @@ def test_저장값_사본_면적(out: dict) -> None: """접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만.""" assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3) assert out["stored"]["fill"] == 0 - assert out["auto"]["cut"] == pytest.approx(16.8211, abs=1e-3) + # 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929) + assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3) def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None: diff --git a/resources/tester/test_218_1_loupe.py b/resources/tester/test_218_1_loupe.py index d8f67f5b8..9568efe79 100644 --- a/resources/tester/test_218_1_loupe.py +++ b/resources/tester/test_218_1_loupe.py @@ -49,7 +49,16 @@ def test_크기_배율_상수(r): def test_창_캡처_단계로_들음(r): assert r["listeners"] == [ - [t, True] for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup") + [t, True] + for t in ( + "mousemove", + "mouseover", + "mousedown", + "mouseup", + "contextmenu", + "keydown", + "keyup", + ) ] + [["wheel", {"capture": True, "passive": False}], ["blur", True]] @@ -139,8 +148,9 @@ def test_화면_뜨기(r): assert c["offscreenChildren"] == 0 and c["offscreenSize"] == ["100px", "100px"] # 보이는 영역만 assert c["rowKept"] == 1 # 표 줄은 칸 너비 때문에 그대로 assert c["svgKept"] == 1 - assert c["frameTag"] == "CANVAS" and c["frameDrawn"] == [["cad", 5, 6, 300, 200]] - assert c["frameSize"] == [320, 240] + # iframe(CAD) 은 복제하지 않음 — 같은 크기 빈 상자(221-1) + assert c["frameTag"] == "DIV" and c["frameDrawn"] == [] and c["frameEmpty"] == 0 + assert c["frameSize"] == ["320px", "240px"] assert c["watching"] and c["observeOpts"]["subtree"] and c["scrollListen"] diff --git a/resources/tester/test_221_1_loupe_cad.py b/resources/tester/test_221_1_loupe_cad.py new file mode 100644 index 000000000..b74b31959 --- /dev/null +++ b/resources/tester/test_221_1_loupe_cad.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""돋보기 CAD 영역 빼기(계획서 221-1) 회귀검증 — CAD(cad_host 상자 · iframe) 위에선 안 켬 · +켜진 채 들어가면 숨김 · 나오면 다시 보임(배율 그대로) · CAD 위 휠은 안 막음 · 복제에서 CAD 는 빈칸. + +218-1 헬퍼(`helper_218_1_loupe.cjs`)의 CAD 장면을 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_218_1_loupe.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_CAD_판정(r): + c = r["cad"] + assert c["skip"] == ".cad-host, iframe" + assert c["isCad"] == [True, False, False, False] + + +def test_CAD_위_Z_안_켬_나오면_보임(r): + k = r["cad"]["keyOnCad"] + assert k["onCad"] == 0 and k["wheelPrevented"] is False + assert k["out"] == ["show", 50, 60] # 나온 자리에 뜸 + assert k["off"] == "hide" + + +def test_CAD_위_우클릭_길게_안_켬_메뉴_그대로(r): + assert r["cad"]["press"] == {"log": 0, "menuPrevented": False} + + +def test_켜진_채_CAD_위_숨김_휠_안_막음_나오면_배율_그대로(r): + e = r["cad"]["enter"] + assert e["hidden"] == "hide" + assert e["wheel"] == {"prevented": False, "stopped": False} # CAD 휠 줌 그대로 + assert e["quiet"] # 숨긴 동안 원을 안 움직임 + assert e["back"] == [["show", 70, 80], ["zoom", 2.25]] + assert e["moveHide"] == "hide" and e["moveBack"] == ["show", "zoom", "move"] + + +def test_초점이_CAD_로_넘어가도_끄지_않고_숨김(r): + b = r["cad"]["blur"] + assert b["blurHidden"] == ["show", "hide"] + assert b["frameKeyup"] is True # Z 떼기는 CAD 창에서 들음 + assert b["afterRelease"] == ["show", "hide"] # 뗀 뒤 나와도 안 뜸 + assert b["pressBack"] == ["show", "hide", "show", "move"] # 우클릭 누른 채 나오면 다시 + assert b["pressOff"] == "hide" + + +def test_복제에서_CAD_빈칸(r): + c = r["capture"] + assert c["frameTag"] == "DIV" and c["frameEmpty"] == 0 + assert c["frameSize"] == ["320px", "240px"] + assert c["cadBoxEmpty"] == 0 and c["cadBoxSize"] == ["400px", "300px"] diff --git a/resources/tester/test_221_2_loupe_scroll.py b/resources/tester/test_221_2_loupe_scroll.py new file mode 100644 index 000000000..feec5162c --- /dev/null +++ b/resources/tester/test_221_2_loupe_scroll.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""돋보기 스크롤 따라감(계획서 221-2) 회귀검증 — 스크롤해 둔 뒤 켜도 원 = 지금 화면 · +켠 채 스크롤하면 다음 화면 틀(rAF 한 번)에 위치만 맞춤 · 0.5 초 뒤 다시 뜸 · 끄면 예약 없음. + +218-1 헬퍼(`helper_218_1_loupe.cjs`)의 스크롤 장면을 판정함(숨긴 원 안에선 스크롤이 안 먹는 실제 동작 흉내). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def s(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_218_1_loupe.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["scroll"] + + +def test_스크롤한_뒤_켬(s): + assert s["zero"] == 0 + assert s["before"] == 260 # 다시 켤 때도 지금 스크롤 그대로 + + +def test_켠_채_스크롤_위치만_맞춤(s): + assert s["rafs"] == 1 # 스크롤 두 번 → 한 틀에 한 번 + assert s["during"] == {"top": 400, "same": True, "pageTop": "-50px"} # 칸 · 문서 스크롤 + + +def test_켠_채_스크롤_0_5초_뒤_다시_뜸(s): + assert s["recaptured"] == {"same": False, "top": 400} + assert s["timersAfterHide"] == 0 diff --git a/resources/tester/test_222_1_m02_apart.py b/resources/tester/test_222_1_m02_apart.py new file mode 100644 index 000000000..9d13b45f1 --- /dev/null +++ b/resources/tester/test_222_1_m02_apart.py @@ -0,0 +1,148 @@ +"""M02 구조물조합 일위대가 기계비빔타설(PLAN 222-1) — 단위 까닭 · 별도계상 빼기 · 원문 여럿. + +① 단위 — GF000156 모래 · 자갈 = 원문 kg(QF000353) ÷ 단위중량(㎏/㎥) · 칸에 t/㎥ 값(2 · 2.1)을 넣으면 + 838 ÷ 2 = 419 ㎥ 처럼 1000 배 · 0 = 표 위끝(1,700 · 1,800 ㎏/㎥) → 0.49 · 0.57 ㎥ · 칸을 비우면 + 자동값(master_auto 「나누는 자리 = 1」)이 1 이라 838 ㎥(자료 · 기본값 몫 — 223 보고). +② 별도계상 — 로직 자재(`자재표시` · 빈 칸 = 기본 별도계상) = 기준에서 빠지고 `줄` 에서 지움 · `재료` 는 + 남음 · 사용자 자재 = 합에서 뺌(짝 TS `settle`) · 사전조합 안 줄 · 사본 셈 같은 길. +③ 원문 — 로직 출처 + 호표 · 입력 출처 가운데 같은 책 절 번호(「산림품셈 12-1-2 · 12-1-3」). +""" + +import os +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Make as make +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Inputs as inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from resources.tester.test_m02_labor_calc_mirror import _same, _ts + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") +MIX = "GF000156" # 기계비빔타설 — 호표 3 시멘트 · 4 모래 · 5 자갈 +GIVEN = {"재료배합": "(A)", "골재최대치수": 40} +MAT = "MT000008" +DIRECT = {"별도계상": "직접계산"} + + +def _whole(): + return m01.loaded()[1] + + +def _rows(got): + return {x["이름"]: {**x, "수량": float(x["수량"])} for x in got["줄"]} + + +# ── ① 단위 ────────────────────────────────────────────────────────── +def test_단위중량_칸에_t_값이면_1000배_0이면_표_위끝(): + wrong = labor.run_line(_whole(), MIX, {**GIVEN, "모래단위중량": 2, "자갈단위중량": 2.1}) + rows = _rows(wrong) + assert rows["시멘트"]["단위"] == "kg" and rows["시멘트"]["수량"] == pytest.approx(335) + assert rows["모래(건조상태)"]["수량"] == pytest.approx(838 / 2) # 419 ㎥ + assert rows["자갈 또는 부순돌"]["수량"] == pytest.approx(1032 / 2.1) # 491.4 ㎥ + zero = {**GIVEN, "모래단위중량": 0, "자갈단위중량": 0} + right = _rows(labor.run_line(_whole(), MIX, zero)) + assert right["모래(건조상태)"]["수량"] == pytest.approx(838 / 1700) + assert right["자갈 또는 부순돌"]["수량"] == pytest.approx(1032 / 1800) + + +# ── ② 별도계상 ─────────────────────────────────────────────────────── +def _calc(shown=None, mats=()): + line = {"로직": MIX, "입력값": GIVEN, "수량": {"손": 1}} + if shown: + line["자재표시"] = shown + doc = labor.clean({"열": "rv", "담은로직": [line], "자재": list(mats)}) + return labor.calc(doc, {}) + + +def test_로직_자재_기본_별도계상은_기준_줄에서_빠지고_재료엔_남음(): + raw = labor.run_line(_whole(), MIX, GIVEN) + cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) + one = _calc()["줄"][0] + names = {x["이름"] for x in one["줄"]} + assert not names & {"시멘트", "모래(건조상태)", "자갈 또는 부순돌"} + assert {"콘크리트공", "보통인부"} <= names + assert len(one["재료"]) == 3 # 자재목록(세 칸 고침)엔 남음 + want = Decimal(str(raw["기준"]["재료비"])) - cut + assert one["기준"]["재료비"] == pytest.approx(float(want)) + assert one["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - cut)) + assert one["기준"]["노무비"] == pytest.approx(float(raw["기준"]["노무비"])) + + +def test_직접계산은_그대로_섞이면_그_자재만_빠짐(): + raw = labor.run_line(_whole(), MIX, GIVEN) + full = _calc({str(n): DIRECT for n in (3, 4, 5)})["줄"][0] + assert full["기준"]["계"] == pytest.approx(float(raw["기준"]["계"])) + assert len(full["줄"]) == len(raw["줄"]) + part = _calc({"3": DIRECT})["줄"][0] # 시멘트만 셈 + names = {x["이름"] for x in part["줄"]} + assert "시멘트" in names and "모래(건조상태)" not in names + cement = next(m for m in raw["재료"] if m["이름"] == "시멘트") + others = sum(Decimal(str(m["금액"])) for m in raw["재료"]) - Decimal(str(cement["금액"])) + assert part["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - others)) + + +def test_사용자_자재_별도계상은_합에서_빠지고_반영은_보임(): + aside = _calc(None, [{"코드": MAT, "수량": {"손": 2}}]) + direct = _calc(None, [{"코드": MAT, "수량": {"손": 2}, **DIRECT}]) + price = direct["자재"][0]["기준단가"] + assert aside["자재"][0]["반영"] == pytest.approx(price * 2) # 목록엔 금액 보임 + assert direct["반영합"]["계"] - aside["반영합"]["계"] == pytest.approx(price * 2) + assert direct["기준합"]["재료비"] - aside["기준합"]["재료비"] == pytest.approx(price) + + +@pytest.mark.parametrize("mark", [None, "직접계산", "별도계상", "옛값"]) +def test_사용자_자재_별도계상_파이썬과_TS_같은_답(mark): + one = {"코드": MAT, "수량": {"손": 3}, **({"별도계상": mark} if mark else {})} + doc = labor.clean({"열": "rv", "담은로직": [{"로직": MIX, "입력값": GIVEN}], "자재": [one]}) + doc["자재"][0].update({"별도계상": mark} if mark else {}) # 옛 값도 그대로 넘겨 봄 + answer = labor.calc(doc, {}) + values = {"기계비빔타설|㎥": 2} + _same(labor.settle(answer, doc, values), _ts(answer, doc, values)) + + +def test_사전조합_안_줄도_별도계상_뺌(): + raw = labor.run_line(_whole(), MIX, GIVEN) + cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) + doc = { + "이름": "비빔 조합", + "단위": "㎥", + "담은로직": [{"로직": MIX, "입력값": GIVEN, "수량": {"손": 1}}], + "자재": [ + {"코드": MAT, "수량": {"손": 2}}, # 기본 별도계상 = 안 셈 · 안 줄에도 없음 + ], + } + ref = {"층": "system", "주인": None, "키": "SC999999"} + got = refs.Expand(_whole(), lambda r: doc)(ref) + assert [x.get("자재") for x in got["안"]] == [None] + pill = got["안"][0] + times = Decimal(1) # 기계비빔타설 알약 배 1 + assert pill["기준"]["계"] * times == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) + assert got["기준"]["계"] == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) + + +def test_화면_별도계상_바꾸면_다시_셈(): + detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() + assert 'if (key === "별도계상") changed(true);' in detail # 사용자 자재 = 짝 셈 + assert 'if (key === "별도계상") changed();' in detail # 로직 자재 = 서버 기준 + + +# ── ③ 원문 여럿 ────────────────────────────────────────────────────── +def test_원문_출처는_같은_책_절_모두(): + src = inputs.sources(_whole().logic(MIX)) + assert src == "산림품셈 12-1-2 · 12-1-3" + assert inputs.logic_inputs([MIX])["로직"][0]["출처"] == src + assert [f["number"] for f in make.source(src)["found"]] == ["12-1-2", "12-1-3"] + + +def test_원문_출처_번호_없거나_더할_것_없으면_그대로(): + assert inputs.sources({"출처": "자체"}) == "자체" + assert inputs.sources({"출처": None}) is None + one = { + "출처": "산림품셈 3-1", + "호표": [{"출처": "건설품셈 공통 1-2-4 3"}, {"출처": "산림품셈 3-1"}], + } + assert inputs.sources(one) == "산림품셈 3-1" # 다른 책 · 같은 절은 안 붙임 diff --git a/resources/tester/test_224_1_picker_top_table.py b/resources/tester/test_224_1_picker_top_table.py new file mode 100644 index 000000000..e01ed490e --- /dev/null +++ b/resources/tester/test_224_1_picker_top_table.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""PLAN 224-1 — B05/B06 구조물 조합 고르기 모달 맨 위 = M02 구조물조합 상단 표(구조물집계표). + +① 행동(Node · 대역): 모달 상단 표 부품(`ui_template_item_picker_table.ts`)을 표 부품 · 집계표 모듈 · + 서버 읽기 대역 위에서 돌려 — 고른 항목의 층 표를 보기 전용으로 그리고, 같은 층 다른 조합 = 열만 옮김 · + 다른 층 = 표가 바뀜 · 읽는 사이 바뀌면 마지막 것만 · 번호 읽기 실패해도 표 · 표 읽기 실패 = 까닭 글. +② 같은 부품(소스): M02 관리 화면 · 모달이 같은 `createSheet` · 같은 줄 글 도우미(`numberLabels`) · 같은 + 숨김 열 규칙(`comboView`)을 씀 · 모달이 상단 표를 배선(고르면 보임 · 편집 화면에선 숨김). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +UI = ROOT / "ui_template" +M02 = ROOT / "M02_MasterTemplete" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_224_1_picker_top_table.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음엔_숨김_고르면_보임_비우면_다시_숨김(out): + assert out["startHidden"] is True + assert out["nullHidden"] is True + assert out["nullCalls"] == [] # 고른 것 없으면 서버도 안 부름 + assert out["shownHidden"] is False + assert out["hideAgain"] is True + + +def test_고른_항목_층의_집계표를_보기_전용으로(out): + # 그 층 · 그 주인의 구조물집계표 + 번호 목록 + assert out["firstReads"] == [ + ["readTemplate", "personal", "table", "구조물집계표", None, "1/3"], + ["numbers", "personal", "1/3"], + ] + first = out["first"] + assert first["count"] == 1 + assert first["readOnly"] is True # 보기 전용 — 고침은 M02 에서 + assert first["lockHead"] is True + assert first["combo"] is True # M02 「구조물 조합」 상단 표 꼴 + assert first["comboDoc"] is True # 숨김 열 규칙 = M02 와 같은 `comboView` + assert first["docTag"] == "personal|1/3" + assert first["numbersSeen"] == {"rv1": {"도번": "SD1"}} # 줄 글 도우미가 같은 번호 목록을 받음 + + +def test_같은_층_다른_조합은_열만_옮김_다른_층은_표가_바뀜(out): + same = out["sameLayer"] + assert same["calls"] == [] # 서버 · 표 다시 안 그림 + assert same["selected"] == ["rv1", "rv2"] + assert same["destroyed"] is False + other = out["otherLayer"] + assert other["reads"] == [["readTemplate", "system", "table", "구조물집계표", None, None]] + assert other["creates"] == 1 + assert other["oldDestroyed"] is True # 옛 표를 거둠 + assert other["selected"] == ["rv9"] + assert other["docTag"] == "system|null" + + +def test_읽는_사이_바뀌면_마지막_조합만(out): + assert out["race"] == {"creates": 1, "docTag": "system|null"} + + +def test_번호_읽기_실패해도_표는_그려지고_표_읽기_실패는_까닭_글(out): + assert out["numbersFail"] == {"creates": 1, "seen": {}} + assert out["tableFail"] == {"creates": 0, "text": "표 실패 그 층은 아직 만든 것이 없음"} + + +def test_표_키_같은_층_주인이면_같고_층_주인이_다르면_다름(out): + assert out["keys"] == { + "mineSame": True, + "layerDiffers": True, + "ownerDiffers": True, + "mine": "personal|1/3", + } + + +def test_모달과_M02_가_같은_표_부품_줄_글_도우미_숨김_열_규칙(): + table = _text(UI / "ui_template_item_picker_table.ts") + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + # 같은 표 부품(관리 화면은 이 파일을 glob 으로 받음) + assert 'import("./sheet/ui_template_sheet")' in table + assert '"../ui_template/sheet/ui_template_sheet.ts"' in main + # 보기 전용 · 상단 표 꼴 + assert "readOnly: true," in table and "combo: {}," in table + # 줄 글 도우미 한 벌 — 둘 다 같은 함수 + assert "export function numberLabels(" in struct + assert "...struct.numberLabels(() => numbers)," in table + assert "...numberLabels(struct.numbers)" in main + # 숨김 열 규칙 한 벌 + assert "struct.comboView(got.문서)" in table + assert "comboView(doc)" in main + # 서버 읽기 = 고른 층 · 주인 · 같은 집계표 이름 + assert 'readTemplate(layer, "table", struct.STRUCTURE_TABLE, null, owner)' in table + assert 'STRUCTURE_TABLE = "구조물집계표"' in struct + + +def test_모달_배선_고르면_보이고_편집_화면에서는_숨김(): + ts = _text(UI / "ui_template_item_picker.ts") + assert "createComboTable()" in ts + assert "comboTable.show(chosen);" in ts # 고른 항목이 바뀔 때마다 + assert "body.append(tableWrap, cols, editHost," in ts # 맨 위 + assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때 + assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때 + assert "comboTable.destroy();" in ts # 닫을 때 표 거둠 + + +def test_표_높이_제한과_숨김_css(): + css = _text(UI / "ui_template_item_picker.css") + assert ".ui-item-picker__table .ui-sheet__scroll {" in css + assert "max-height: 26vh;" in css + assert ".ui-item-picker__table-wrap[hidden]" in css + + +def test_번호_읽기_층_길_추가_지금_층을_안_바꿈(): + api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") + assert "export const fetchStructureNumbersAt = (" in api + assert "call(owned(layer, `/layers/${layer}/structures/numbers`, owner));" in api diff --git a/resources/tester/test_225_1_pipe_round.py b/resources/tester/test_225_1_pipe_round.py new file mode 100644 index 000000000..ac99b55a7 --- /dev/null +++ b/resources/tester/test_225_1_pipe_round.py @@ -0,0 +1,117 @@ +"""225-1 B06 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 · B08 집계 정수. + +화면 TS 를 그 자리에서 돌린 값(`helper_225_1_pipe_round.cjs`) + 파이썬 집계(`M02_Table_Fill`). +짝(반올림) = TS `pipeLengthWhole`(`B06_Section_UI_Cross_Culvert_Const.ts`) ↔ +파이썬 `pipe_lengths_from_designs` — 사사오입 · 0.5 올림. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from M02_MasterTemplete.M02_Table_Fill import pipe_lengths_from_designs # noqa: E402 + +HELPER = Path(__file__).parent / "helper_225_1_pipe_round.cjs" +VALUES = [0.5, 1.4, 1.5, 2.5, 7.4, 7.5, 7.6, 10.0] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER), json.dumps(VALUES)], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_관_정보_글은_노폭_라벨_바로_위(out: dict) -> None: + """노폭 · 노견 라벨(y 180) 위 줄 = 180 − 13 · 세월교 글이 또 있으면 한 줄 더 위 · 같은 x.""" + pipe, ford = out["infos"] + assert (pipe["x"], pipe["y"], pipe["inSvg"]) == (200, 167, True) + assert pipe["text"] == "관매설 Φ600m/m, L=8 m (7.6)" + assert (ford["x"], ford["y"]) == (200, 154) + + +def test_노폭_라벨_없으면_옛_자리_또는_안_그림(out: dict) -> None: + """B07 덧그림(라벨 없음) — 관 글은 안 그림 · 세월교 글은 옛 자리.""" + assert out["bareInfos"] == [{"x": 10, "y": 20, "text": "세월교"}] + + +def test_관보호공은_0_1m_걸음_세월교_흙막이만_1m(out: dict) -> None: + assert out["steps"] == { + "pipeOutlet": True, + "pipeInlet": True, + "fordGuard": False, + "hiddenOwn": True, + } + + +def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None: + """d 빈 칸 그대로 x 만 넣으면 옛 기본 d(경사길이 4 m)로 튐 — 배관 기준벽만 묶음.""" + assert out["patches"] == [ + {"key": "outlet", "x": 0.1, "d": 0.4}, + {"key": "extra0", "x": 0.1}, + ] + + +def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None: + """유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음.""" + lengths = [case["lengthM"] for case in out["moved"]] + assert lengths == [7.9, 8.0, 8.1, 8.2] + for case in out["moved"]: + assert case["lengthM"] == round(case["cut"], 1) + + +def test_표시는_정수_괄호_소수_첫째(out: dict) -> None: + assert out["text"][VALUES.index(7.6)] == "8 m (7.6)" + assert out["text"][VALUES.index(7.4)] == "7 m (7.4)" + assert out["text"][VALUES.index(7.5)] == "8 m (7.5)" + + +def test_B08_집계_값은_사사오입_정수() -> None: + designs = [ + {"chainage_m": 10, "design": {"pipe_length_m": 7.6}}, + {"chainage_m": 20, "design": {"pipe_length_m": 7.5}}, + {"chainage_m": 30, "design": {"pipe_length_m": 7.4}}, + ] + assert pipe_lengths_from_designs(designs) == {10.0: 8, 20.0: 8, 30.0: 7} + + +def test_반올림_짝_거울(out: dict) -> None: + """TS `pipeLengthWhole` ↔ 파이썬 집계 — 같은 값마다 같은 정수(0.5 올림).""" + designs = [{"chainage_m": i, "design": {"pipe_length_m": v}} for i, v in enumerate(VALUES)] + py = pipe_lengths_from_designs(designs) + assert [py[float(i)] for i in range(len(VALUES))] == out["whole"] + assert out["whole"] == [1, 1, 2, 3, 7, 8, 8, 10] + ts = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text( + encoding="utf-8" + ) + assert "Math.floor(lengthM + 0.5)" in ts and "M02_Table_Fill.pipe_lengths_from_designs" in ts + src = (ROOT / "M02_MasterTemplete" / "M02_Table_Fill.py").read_text(encoding="utf-8") + assert "int(length + 0.5)" in src and "pipeLengthWhole" in src + + +def test_카드_글과_좌측_패널이_같은_표시를_씀() -> None: + card = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert.ts").read_text(encoding="utf-8") + assert ( + "L=${pipeLengthText(pipe.lengthM)}" in card and "appendPipeInfoText(layer, pipeInfo" in card + ) + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text( + encoding="utf-8" + ) + assert '"횡단 설계 뒤 셈" : pipeLengthText(length)' in panel diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index a89e33d92..c2a12a831 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -109,13 +109,14 @@ def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None: def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None: pipe = shape["pipe"] assert pipe is not None - # 벽 윗면 = 관 윗면(139-1) · 관 위 토피 0.5 → 유입 벽 윗면 = 노견 − 0.5 = 사면 d 0.6(1:1.2) - assert pipe["d"]["inlet"] == pytest.approx(0.6, abs=1e-6) - # 유출은 관 m 단위 맞춤이 사면 아래로 민 만큼만 내려감(종전 기본 = 경사길이 4 m · d 3.07) + # 유입 벽 높이 = 관경 + 0.7(211-1) · 바닥 = 관 invert 상한(노견 − 관경 − 토피 0.5) 고정이라 + # 벽이 0.2 높아진 만큼 윗면이 노견 − 0.3 으로 올라 사면 d 0.6 → 0.4(1:1.2 · 0.1 올림) + assert pipe["d"]["inlet"] == pytest.approx(0.4, abs=1e-6) + # 유출은 기본 자리(225-1 — 관 길이 맞춤 이동 없음 · 종전 기본 = 경사길이 4 m · d 3.07) assert pipe["d"]["outlet"] < 1.0 inlet = next(w for w in pipe["walls"] if w["role"] == "inlet") - assert inlet["topBelowEdge"] == pytest.approx(0.5, abs=0.05) - assert float(pipe["lengthM"]).is_integer() + assert inlet["topBelowEdge"] == pytest.approx(0.3, abs=0.05) + assert round(pipe["lengthM"], 1) == pipe["lengthM"] # 225-1 — 실제 길이 0.1 m def test_저장_x_있는_관_보호공은_종전_자리_그대로(shape: dict) -> None: diff --git a/resources/tester/test_item_copy_complete.py b/resources/tester/test_item_copy_complete.py index 693c02fe5..66f789fa8 100644 --- a/resources/tester/test_item_copy_complete.py +++ b/resources/tester/test_item_copy_complete.py @@ -184,7 +184,10 @@ def test_입력이_없으면_빠진_입력_목록_셈은_자동값(world): assert _stuck(got) == [] and got["기준합"]["계"] > 0 -def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world): +def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world, monkeypatch): + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + + monkeypatch.setattr(labor_doc, "apart", lambda one: False) # M01 조합 셈과 대조(222-1 안 뺌) root, master_dir = world["root"], world["master"] _save(root, [_pipe()]) values = _fill_values(root) diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 91d4a39a6..6626b25ac 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -26,7 +26,7 @@ def test_틀_제목_줄_부제_상태(): assert "loading: () => showLoading(host)," in shell assert "fail: (reason, retry) => host.replaceChildren(loadingError(reason, retry))," in shell assert "setDirty: (dirty) => (dot.hidden = !dirty)," in shell - assert 'event.key.toLowerCase() !== "s"' in shell # 카드 안 Ctrl+S + assert 'isCtrlKey(event, "s")' in shell # 카드 안 Ctrl+S def test_저장_단추_알림_한_벌(): diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py index 0d7ba931c..11802c88e 100644 --- a/resources/tester/test_m02_combo_table.py +++ b/resources/tester/test_m02_combo_table.py @@ -163,8 +163,11 @@ def test_일위대가_합계_줄_표_부품과_연결(): render = _text(SHEET / "ui_template_sheet_render.ts") assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") - assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main - assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main + # 줄 글은 도우미 한 벌(`numberLabels` · 고르기 모달 상단 표와 같음 · 224-1) + assert "...numberLabels(struct.numbers)" in main + helper = _text(M02 / "M02_MasterTemplete_Structure.ts") + assert "laborSumLabel: (colId: string) => laborSumText(numbers()[colId])" in helper + assert "laborSumState: (colId: string) => laborSumState(numbers()[colId])" in helper assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움 assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text( SHEET / "ui_template_sheet.css" diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py index eb480e157..76cf3c1ac 100644 --- a/resources/tester/test_m02_item_labor.py +++ b/resources/tester/test_m02_item_labor.py @@ -87,7 +87,7 @@ def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]: {"로직": LOGICS[1], "입력값": {name: value}, "수량": {"손": HAND}}, {"로직": LOGICS[2]}, ], - "자재": [{"코드": MATERIAL}], + "자재": [{"코드": MATERIAL, "별도계상": "직접계산"}], # 222-1 합에 셈 } ) _write(system / "labor" / f"{COLUMN}.json", doc) diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index b84f490ed..c91228696 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -52,6 +52,21 @@ overflow: auto; } +/* 상단 표(224-1) — M02 구조물집계표 같은 부품 · 높이만 줄임(표 안에서 굴림) */ +.ui-item-picker__table-wrap { + flex: none; + min-width: 0; +} + +.ui-item-picker__table .ui-sheet__scroll { + max-height: 26vh; +} + +.ui-item-picker__table-wrap[hidden], +.ui-item-picker__table[hidden] { + display: none; +} + .ui-item-picker__cols[hidden], .ui-item-picker__actions[hidden], .ui-item-picker .ui-modal__actions[hidden], diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index fe68930e6..6eb58469e 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -8,6 +8,7 @@ * * 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준. * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). + * 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐). * 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭. * ========================================================================== */ @@ -43,6 +44,7 @@ import { askFields } from "./ui_template_item_picker_ask"; import { projectItems, type CreatedItem } from "./ui_template_item_picker_project"; import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; import { createEditView, type EditView } from "./ui_template_item_picker_edit"; +import { createComboTable } from "./ui_template_item_picker_table"; export type { SummaryItem } from "./ui_template_item_picker_data"; @@ -146,6 +148,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise void load(created.key), }); + const comboTable = createComboTable(); const cols = el("div", { className: "ui-item-picker__cols", children: [ @@ -156,6 +159,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { detailView.show(chosen); + comboTable.show(chosen); const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; @@ -369,6 +377,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { if (editView && !(await editView.leave())) return false; editView?.destroy(); + comboTable.destroy(); resolve({ action: "cancel" }); return true; }, mount: (body) => { - body.append(cols, editHost, ...(opts.projectId ? [actions] : [])); + body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : [])); paintDetail(); void load(null); }, diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts index 45dd71628..16aaf25e4 100644 --- a/ui_template/ui_template_item_picker_data.ts +++ b/ui_template/ui_template_item_picker_data.ts @@ -85,6 +85,9 @@ export const ownerOf = (row: PickerRow): string | null => /** 같은 항목의 자리표 — 층 · 주인 · 키 */ export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`; +/** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */ +export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`; + export function toSummary(row: PickerRow): SummaryItem { return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) }; } diff --git a/ui_template/ui_template_item_picker_table.ts b/ui_template/ui_template_item_picker_table.ts new file mode 100644 index 000000000..5d742be8a --- /dev/null +++ b/ui_template/ui_template_item_picker_table.ts @@ -0,0 +1,94 @@ +/* ============================================================================= + * ui_template_item_picker_table.ts + * 항목 고르기 모달 상단 표(PLAN 224-1) — M02 「구조물 조합」 페이지 상단의 구조물집계표를 **같은 부품** + * (`ui_template/sheet` `createSheet` · 줄 글 = `numberLabels` · 숨김 열 = `comboView`)으로 보임. + * 보기 전용(`readOnly` · 고침은 M02 에서) · 고른 항목이 속한 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐. + * 같은 층의 다른 항목을 고르면 표는 그대로 두고 열만 옮김 · 다른 층 항목이면 그 층 표로 바뀜. + * 표 부품 · 집계표 모듈은 처음 보일 때만 `import()`(B05 · B06 번들에 안 섞임). + * ========================================================================== */ + +import { el } from "./ui_template_elements"; +import { + fetchStructureNumbersAt, + readTemplate, +} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { layerOf, ownerOf, tableKey, type PickerRow } from "./ui_template_item_picker_data"; +import { t } from "./ui_template_locale"; +import type { SheetDoc, SheetHandle } from "./sheet/ui_template_sheet"; + +export interface ComboTable { + root: HTMLElement; + /** 고른 항목 — `null` 이면 표를 숨김 */ + show: (row: PickerRow | null) => void; + destroy: () => void; +} + +const failed = (error: unknown): string => + t("M02_PickerTableFailed").replace( + "{value}", + error instanceof Error ? error.message : String(error), + ); + +export function createComboTable(): ComboTable { + const root = el("div", { className: "ui-item-picker__table", attrs: { hidden: "" } }); + let seq = 0; + let shownKey: string | null = null; + let sheet: SheetHandle | null = null; + /** 가장 나중에 고른 열 — 표를 읽는 사이에 바뀌면 읽은 뒤 이 열을 고름 */ + let column = ""; + + const drop = (): void => { + sheet?.destroy(); + sheet = null; + shownKey = null; + }; + + async function paint(row: PickerRow, key: string, mine: number): Promise { + drop(); + root.replaceChildren( + el("p", { className: "ui-item-picker__hint", text: t("M02_PickerLoading") }), + ); + try { + const layer = layerOf(row); + const owner = ownerOf(row); + const [{ createSheet }, struct] = await Promise.all([ + import("./sheet/ui_template_sheet"), + import("../M02_MasterTemplete/M02_MasterTemplete_Structure"), + ]); + const [got, numbers] = await Promise.all([ + readTemplate(layer, "table", struct.STRUCTURE_TABLE, null, owner), + fetchStructureNumbersAt(layer, owner).catch(() => ({})), + ]); + if (mine !== seq) return; + sheet = createSheet(root, struct.comboView(got.문서) as SheetDoc, { + mode: "master", + readOnly: true, + lockHead: true, + combo: {}, + ...struct.numberLabels(() => numbers), + }); + shownKey = key; + sheet.selectColumn(column); + } catch (error) { + if (mine === seq) + root.replaceChildren(el("p", { className: "ui-item-picker__hint", text: failed(error) })); + } + } + + return { + root, + show: (row) => { + const mine = ++seq; + root.hidden = !row; + if (!row) return; + column = row.열; + const key = tableKey(row); + if (sheet && key === shownKey) return sheet.selectColumn(column); + void paint(row, key, mine); + }, + destroy: () => { + seq += 1; + drop(); + }, + }; +} diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 6a13b6696..31fc267ea 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -80,6 +80,7 @@ export const ui_locales_m2 = { M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"], M02_PickerLoading: ["읽는 중…", "Loading…"], M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"], + M02_PickerTableFailed: ["상단 표를 못 읽음 — {value}", "Failed to load the top table — {value}"], M02_PickerDrawing: ["도면", "Drawing"], M02_PickerArea: ["면적산출", "Area takeoff"], M02_PickerUnitPrice: ["일위대가", "Unit price"], diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index cdb4fe4c6..16479963a 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -7,7 +7,9 @@ * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. - * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐 · 같은 출처 iframe(CAD)은 안의 canvas 만 옮김 — 못 옮기면 그 자리 빈칸. + * 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 0.5 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움). + * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. + * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ import "./ui_template_loupe.css"; @@ -33,6 +35,8 @@ export const LOUPE_EDGE = 4; export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/; const SVG_NS = "http://www.w3.org/2000/svg"; +/** CAD 영역 — cad_host 가 끼운 상자와 iframe */ +export const LOUPE_SKIP = ".cad-host, iframe"; const NON_TEXT_INPUT = new Set([ "checkbox", "radio", @@ -77,6 +81,11 @@ export function isTypingTarget(target: unknown): boolean { return tag === "INPUT" && !NON_TEXT_INPUT.has((el.type || "text").toLowerCase()); } +/** CAD 영역 위인지 — 돋보기를 안 띄우는 자리(221-1) */ +export function isCadTarget(target: unknown): boolean { + return !!(target as Partial | null)?.closest?.(LOUPE_SKIP); +} + /** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */ export function nextLoupeZoom(zoom: number, deltaY: number): number { const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP; @@ -115,18 +124,36 @@ export function initLoupe( let y = win.innerHeight / 2; let press = false; // 오른쪽 길게 누름 let key = false; // Z 누름 - let shown = false; + let shown = false; // 켜짐 + let seen = false; // 원이 보임 — 켜짐이어도 CAD 위면 숨김 + let overCad = false; let holdTimer = 0; let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음 let zoom = LOUPE_ZOOM; const update = (): void => { const want = (press || key) && isOn(); - if (want && !shown) { - zoom = LOUPE_ZOOM; // 켤 때마다 2배 + if (want && !shown) zoom = LOUPE_ZOOM; // 켤 때마다 2배 + const see = want && !overCad; + if (see && !seen) { view.show(x, y); - } else if (!want && shown) view.hide(); + if (zoom !== LOUPE_ZOOM) view.setZoom(zoom); // CAD 에서 나옴 — 배율 그대로 + } else if (!see && seen) view.hide(); shown = want; + seen = see; + }; + const track = (e: MouseEvent): void => { + x = e.clientX; + y = e.clientY; + const cad = isCadTarget(e.target); + if (cad === overCad) return; + overCad = cad; + update(); + }; + const releaseZ = (e: KeyboardEvent): void => { + if (keyLetter(e) !== "z" || !key) return; + key = false; + update(); }; const stopHold = (): void => { win.clearTimeout(holdTimer); @@ -136,20 +163,21 @@ export function initLoupe( win.addEventListener(type, fn, true); on("mousemove", (e) => { - x = e.clientX; - y = e.clientY; + track(e); if (!(e.buttons & 2) && (holdTimer || press)) { stopHold(); // 창 밖에서 뗌 — mouseup 이 안 옴 press = false; update(); } - if (shown) view.move(x, y); + if (seen) view.move(x, y); }); + on("mouseover", track); // iframe 위로 들어가면 mousemove 가 안 옴 — 들어갈 때 한 번 + on("mousedown", (e) => { if (e.button !== 2) return; swallowMenu = false; stopHold(); - if (!isOn()) return; + if (!isOn() || isCadTarget(e.target)) return; x = e.clientX; y = e.clientY; holdTimer = win.setTimeout(() => { @@ -177,16 +205,12 @@ export function initLoupe( key = true; update(); }); - on("keyup", (e) => { - if (keyLetter(e) !== "z" || !key) return; - key = false; - update(); - }); + on("keyup", releaseZ); // 켜 있는 동안만 휠을 잡음 — 잡기 단계 첫 자리 · passive:false 여야 스크롤을 막음 win.addEventListener( "wheel", (e) => { - if (!shown) return; + if (!seen) return; // CAD 위 — CAD 휠 줌 그대로 e.preventDefault(); e.stopImmediatePropagation(); // 표 · 그래프 휠 줌도 안 받음 zoom = nextLoupeZoom(zoom, e.deltaY); @@ -195,7 +219,19 @@ export function initLoupe( { capture: true, passive: false }, ); on("blur", (e) => { - if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만(CAD iframe 으로 넘어감 포함) + if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만 + const active = win.document?.activeElement as HTMLIFrameElement | null | undefined; + if (isCadTarget(active)) { + // 커서가 CAD 로 들어가 초점이 넘어감(cad_host) — 끄지 않고 숨김만 · Z 떼기는 그쪽 창에서 들음 + try { + active?.contentWindow?.addEventListener("keyup", releaseZ, true); + } catch { + // 다른 출처 — 나올 때 Z 를 다시 눌러야 꺼짐 + } + overCad = true; + update(); + return; + } stopHold(); press = key = false; update(); @@ -215,6 +251,8 @@ export function createLoupeView( let at: [number, number] = [0, 0]; // 마지막 커서 — 배율만 바뀔 때 다시 놓음 let observer: MutationObserver | null = null; let refreshTimer = 0; + let sync = (): void => {}; // 스크롤 위치 맞춤 — 뜰 때마다 새로 + let syncFrame = 0; const build = (): void => { root = doc.createElement("div"); @@ -236,7 +274,7 @@ export function createLoupeView( const source = sourceOf(); const t0 = win.performance?.now() ?? 0; stage!.replaceChildren(); - if (source) captureInto(source, stage!, win); + if (source) sync = captureInto(source, stage!, win); root!.dataset.captureMs = String(Math.round((win.performance?.now() ?? 0) - t0)); }; const refresh = (): void => { @@ -246,11 +284,19 @@ export function createLoupeView( if (observer) capture(); }, LOUPE_REFRESH_MS); }; + const onScroll = (): void => { + if (!syncFrame) + syncFrame = win.requestAnimationFrame(() => { + syncFrame = 0; + sync(); + }); + refresh(); + }; const view: LoupeView = { show(x, y) { if (!root) build(); + root!.hidden = label!.hidden = false; // 숨긴 채 뜨면 복제의 스크롤이 안 먹음 — 먼저 보임 capture(); - root!.hidden = label!.hidden = false; at = [x, y]; view.setZoom(LOUPE_ZOOM); // 켤 때마다 2배 · 자리도 놓음 const source = sourceOf(); @@ -264,7 +310,7 @@ export function createLoupeView( characterData: true, }); observer = watch; - win.addEventListener("scroll", refresh, true); + win.addEventListener("scroll", onScroll, true); }, move(x, y) { if (!root || !stage || !label) return; @@ -286,7 +332,9 @@ export function createLoupeView( observer = null; win.clearTimeout(refreshTimer); refreshTimer = 0; - win.removeEventListener("scroll", refresh, true); + win.cancelAnimationFrame(syncFrame); + syncFrame = 0; + win.removeEventListener("scroll", onScroll, true); stage?.replaceChildren(); // 복제를 지움 if (root) root.hidden = label!.hidden = true; }, @@ -294,8 +342,8 @@ export function createLoupeView( return view; } -/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 */ -export function captureInto(source: Element, stage: HTMLElement, win: Window): void { +/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 — 돌려주는 함수 = 지금 스크롤 · 자리로 다시 맞춤 */ +export function captureInto(source: Element, stage: HTMLElement, win: Window): () => void { const doc = win.document; const rect = source.getBoundingClientRect(); const page = doc.createElement("div"); @@ -307,14 +355,21 @@ export function captureInto(source: Element, stage: HTMLElement, win: Window): v height: `${rect.height}px`, }); const copy = source.cloneNode(true) as Element; - const scrolls: [Element, number, number][] = []; + const scrolls: [Element, Element][] = []; // [원본, 복제] 스크롤 칸 walk(source, copy, doc, win.innerWidth, win.innerHeight, scrolls); page.append(copy); stage.append(page); - for (const [el, top, left] of scrolls) { - el.scrollTop = top; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 되돌림 - el.scrollLeft = left; - } + const sync = (): void => { + const now = source.getBoundingClientRect(); // 문서 스크롤 — 페이지 자리 + page.style.left = `${now.left}px`; + page.style.top = `${now.top}px`; + for (const [o, c] of scrolls) { + c.scrollTop = o.scrollTop; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 옮김 + c.scrollLeft = o.scrollLeft; + } + }; + sync(); + return sync; } function walk( @@ -323,24 +378,27 @@ function walk( doc: Document, vw: number, vh: number, - scrolls: [Element, number, number][], + scrolls: [Element, Element][], ): void { if (o.namespaceURI === SVG_NS) return; // SVG 는 복제 그대로 const tag = o.tagName.toUpperCase(); if (tag === "CANVAS") return drawCanvas(o as HTMLCanvasElement, c as HTMLCanvasElement); - if (tag === "IFRAME") return void c.replaceWith(frameShot(o as HTMLIFrameElement, doc)); if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return copyValue(o, c); const r = o.getBoundingClientRect(); - if (!KEEP_INSIDE.has(tag) && (r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw)) { - c.replaceChildren(); // 화면 밖 — 속을 비우고 크기만 - Object.assign((c as HTMLElement).style, { - boxSizing: "border-box", - width: `${r.width}px`, - height: `${r.height}px`, - }); - return; + if (tag === "IFRAME") { + const box = doc.createElement("div"); // 복제 iframe 은 CAD 를 또 띄움 — 빈 상자로 바꿈 + c.replaceWith(box); + return emptyBox(box, r); } - if (o.scrollTop || o.scrollLeft) scrolls.push([c, o.scrollTop, o.scrollLeft]); + const off = r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw; + if (o.classList?.contains("cad-host") || (!KEEP_INSIDE.has(tag) && off)) return emptyBox(c, r); // CAD · 화면 밖 + if ( + o.scrollTop || + o.scrollLeft || + o.scrollHeight > o.clientHeight || + o.scrollWidth > o.clientWidth + ) + scrolls.push([o, c]); // 지금 스크롤됐거나 스크롤될 수 있는 칸 for (let i = 0; i < o.children.length; i++) walk(o.children[i], c.children[i], doc, vw, vh, scrolls); } @@ -364,23 +422,12 @@ function copyValue(o: Element, c: Element): void { } } -/** iframe 자리 — 같은 출처면 안의 canvas 를 한 장에 모아 그림 · 아니면 빈칸 */ -function frameShot(frame: HTMLIFrameElement, doc: Document): HTMLCanvasElement { - const r = frame.getBoundingClientRect(); - const shot = doc.createElement("canvas"); - shot.className = frame.className; - shot.width = Math.max(1, Math.round(r.width)); - shot.height = Math.max(1, Math.round(r.height)); - Object.assign(shot.style, { display: "block", width: `${r.width}px`, height: `${r.height}px` }); - try { - const ctx = shot.getContext("2d"); - for (const canvas of Array.from(frame.contentDocument?.querySelectorAll("canvas") ?? [])) { - const cr = canvas.getBoundingClientRect(); - if (canvas.width && canvas.height) - ctx?.drawImage(canvas, cr.left, cr.top, cr.width, cr.height); - } - } catch { - // 다른 출처 · 그림 없음 — 빈칸 - } - return shot; +/** 속을 비우고 크기만 남김 — 화면 밖 · CAD 영역 */ +function emptyBox(c: Element, r: DOMRect): void { + c.replaceChildren(); + Object.assign((c as HTMLElement).style, { + boxSizing: "border-box", + width: `${r.width}px`, + height: `${r.height}px`, + }); }