From a5f5cac73933b36c2cc2d879551f86a5b14a691d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:26:31 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(ui):=20=EB=8F=8B=EB=B3=B4=EA=B8=B0=20C?= =?UTF-8?q?AD=20=EC=98=81=EC=97=AD=20=EB=B9=BC=EA=B8=B0=20=E2=80=94=20CAD?= =?UTF-8?q?=20=EC=9C=84=20=EC=95=88=20=EC=BC=AC=20=C2=B7=20=EC=BC=9C?= =?UTF-8?q?=EC=A7=84=20=EC=B1=84=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=A9=B4=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=C2=B7=20=EB=82=98=EC=98=A4=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=B0=EC=9C=A8=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20CAD?= =?UTF-8?q?=20=EC=9C=84=20=ED=9C=A0=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7?= =?UTF-8?q?=20=EB=B3=B5=EC=A0=9C=EC=97=90=EC=84=9C=20CAD=20=EB=B9=88?= =?UTF-8?q?=EC=B9=B8=20(221-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- resources/tester/helper_218_1_loupe.cjs | 111 ++++++++++++++++++++++- resources/tester/test_218_1_loupe.py | 16 +++- resources/tester/test_221_1_loupe_cad.py | 69 ++++++++++++++ ui_template/ui_template_loupe.ts | 109 +++++++++++++--------- 4 files changed, 254 insertions(+), 51 deletions(-) create mode 100644 resources/tester/test_221_1_loupe_cad.py diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index cfa125bf1..52ac768cf 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 빼기) 단위검증 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ const fs = require("fs"); const path = require("path"); @@ -271,6 +271,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 { @@ -299,6 +391,9 @@ class El { getBoundingClientRect() { return this.rect; } + get classList() { + return { contains: (k) => this.className.split(" ").includes(k) }; + } setAttribute(k, v) { this.attrs[k] = v; } @@ -370,8 +465,13 @@ 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.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 +506,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 +529,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), 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/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index cdb4fe4c6..1703dc58a 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -7,7 +7,8 @@ * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. - * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐 · 같은 출처 iframe(CAD)은 안의 canvas 만 옮김 — 못 옮기면 그 자리 빈칸. + * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. + * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ import "./ui_template_loupe.css"; @@ -33,6 +34,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 +80,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 +123,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 +162,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 +204,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 +218,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(); @@ -328,18 +363,15 @@ function walk( 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); } + 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) scrolls.push([c, o.scrollTop, o.scrollLeft]); for (let i = 0; i < o.children.length; i++) walk(o.children[i], c.children[i], doc, vw, vh, scrolls); @@ -364,23 +396,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`, + }); } From 74c78154758c48d338e1b51eecb6d260ee39ff75 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:27:30 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2097?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 3b42827fc..2a930d328 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -120,3 +120,4 @@ 94. 캐드 프로그램에서 스케일 기능은 잘되지만 추가로 오토캐드처럼 오리진을 지정하고 숫자로 스케일 값을 입력을 받게 추가 95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문) 96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선 +97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선 From 83798367487a1ca3d53fd01ac3d47f467dae3a1d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:29:34 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(ui):=20=EB=8F=8B=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=94=B0=EB=9D=BC=EA=B0=90=20?= =?UTF-8?q?=E2=80=94=20=EB=8B=A4=EC=8B=9C=20=EC=BC=A4=20=EB=95=8C=20?= =?UTF-8?q?=EC=9B=90=EC=9D=84=20=EB=A8=BC=EC=A0=80=20=EB=B3=B4=EC=97=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=98=AE=EA=B9=80=EC=9D=B4=20?= =?UTF-8?q?=EB=A8=B9=EA=B2=8C=20=C2=B7=20=EC=BC=A0=20=EC=B1=84=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EC=9D=80=20rAF=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=EC=97=90=20=EC=B9=B8=20=C2=B7=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=A7=9E=EC=B6=A4=20=C2=B7=200.5=20?= =?UTF-8?q?=EC=B4=88=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20=EB=9C=B8=20(221-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- resources/tester/helper_218_1_loupe.cjs | 58 ++++++++++++++++++++- resources/tester/test_221_2_loupe_scroll.py | 42 +++++++++++++++ ui_template/ui_template_loupe.ts | 52 +++++++++++++----- 3 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_221_2_loupe_scroll.py diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index 52ac768cf..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 휠 배율 · 221-1 CAD 빼기) 단위검증 헬퍼 — +/* 돋보기(`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)) { @@ -374,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; @@ -391,6 +393,14 @@ 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) }; } @@ -470,6 +480,8 @@ function makePage() { }); 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, cadBox); return app; @@ -581,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/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/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index 1703dc58a..16479963a 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -7,6 +7,7 @@ * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. + * 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 0.5 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움). * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ @@ -250,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"); @@ -271,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 => { @@ -281,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(); @@ -299,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; @@ -321,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; }, @@ -329,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"); @@ -342,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( @@ -358,7 +378,7 @@ 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(); @@ -372,7 +392,13 @@ function walk( } 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) scrolls.push([c, o.scrollTop, o.scrollLeft]); + 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); } From 02e0727170317ad901c863eb4c1b7862884646d5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:29:39 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(cad):=20=EC=B6=95=EC=B2=99=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=A0=90=20=EC=A7=80=EC=A0=95=20=EB=92=A4=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90=20=EB=B0=B0=EC=9C=A8=20=EC=9E=85=EB=A0=A5=20=C2=B7=20?= =?UTF-8?q?Enter=20=EC=A0=81=EC=9A=A9=20=C2=B7=20Esc=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20=ED=95=9C=20?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=EB=AA=85=EB=A0=B9=20=EC=A4=84=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=ED=95=9C=EA=B8=80=20(226-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../openwebcad/src/tools/scale-tool.test.ts | 89 +++++++++++++++++++ .../openwebcad/src/tools/scale-tool.ts | 49 ++++++++-- 2 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts 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([]); From 91e85cfd95fb9a66a5aadcc477d8862bda50151e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:31:18 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat(ui):=20B05/B06=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=83=81=EB=8B=A8=EC=97=90=20M02=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=A7=91=EA=B3=84=ED=91=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=ED=91=9C=20(224-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고른 항목이 속한 층 · 주인의 구조물집계표를 M02 와 같은 표 부품 · 줄 글 도우미(numberLabels) · 숨김 열 규칙(comboView)으로 보임 - 같은 층 다른 조합 = 열만 옮김 · 다른 층 = 표 바뀜 · 편집 화면에서는 숨김 - 새 시험 test_224_1 · test_m02_combo_table 단정 고침 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- .../M02_MasterTemplete_Api_Fetch.ts | 7 + .../M02_MasterTemplete_Structure.ts | 11 ++ .../M02_MasterTemplete_UI_Main.ts | 9 +- .../tester/helper_224_1_picker_top_table.cjs | 167 ++++++++++++++++++ .../tester/test_224_1_picker_top_table.py | 136 ++++++++++++++ resources/tester/test_m02_combo_table.py | 7 +- ui_template/ui_template_item_picker.css | 15 ++ ui_template/ui_template_item_picker.ts | 13 +- ui_template/ui_template_item_picker_data.ts | 3 + ui_template/ui_template_item_picker_table.ts | 94 ++++++++++ ui_template/ui_template_locale_m2.ts | 1 + 11 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 resources/tester/helper_224_1_picker_top_table.cjs create mode 100644 resources/tester/test_224_1_picker_top_table.py create mode 100644 ui_template/ui_template_item_picker_table.ts 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_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/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/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_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/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"], From 66e84b56bf6a63eceb3053685abab35f714dc848 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:33:00 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EF=BB=BFfix(cad):=20=EC=A4=8C=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=8A=90=EB=A0=A4=EC=A7=90=20=E2=80=94=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=20=EC=83=89=EC=9D=B8=20=EC=B9=B8=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=20=EC=9E=90=EB=A6=84=20=C2=B7=20=EB=AA=A8=EB=88=88=EC=9D=84=20?= =?UTF-8?q?=EC=9E=A5=EB=A9=B4=20=EB=B9=84=ED=8A=B8=EB=A7=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=BA=8C(227-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭: 화면 상자 조회가 엔티티 칸(64x64) 밖 빈 칸까지 돎 — 0.01배에서 한 화면 19.3 초 - 칸 범위를 0~64 로 잘라 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms - 모눈은 화면 clear() 만 그림 — 모눈 켜도 정지 화면에서 장면 다시 그리지 않음 - 측정 · 회귀 시험 zoom-out-perf.test.ts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../openwebcad/src/helpers/scene-cache.ts | 13 +- .../openwebcad/src/helpers/spatial-index.ts | 6 +- .../src/helpers/zoom-out-perf.test.ts | 191 ++++++++++++++++++ 3 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts 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); + }); +}); From ab71a2e06a9bd12b8395f733601c817800910f37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:34:52 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(test):=20=ED=95=A9=EC=B9=9C=20=EB=92=A4?= =?UTF-8?q?=20=EB=B9=A8=EA=B0=95=20=EB=91=98=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20M02=20=EC=B9=B4=EB=93=9C=20=ED=8B=80=20Ctrl+S=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=80=EA=B0=92=20=C2=B7=20B09=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=EA=B8=80=20=E3=80=8C=EB=8D=94=ED=95=98=EA=B8=B0=E3=80=8D?= =?UTF-8?q?=E2=86=92=E3=80=8C=EC=B6=94=EA=B0=80=E3=80=8D=20(224-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts | 2 +- resources/tester/test_m02_card_shell.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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_저장_단추_알림_한_벌():