diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 56343908..b958d7b4 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -41,6 +41,8 @@ export class ScreenCanvasDrawController implements DrawController { private screenScale = 1; private screenMouseLocation: Point; private canvasSize: Point = new Point(100, 100); + /** 맞춤 뒤 사용자가 화면을 안 옮겼음 — 캔버스 크기가 바뀌면 다시 맞춘다(첫 표시 때 iframe 이 늦게 커짐). */ + private viewFitted = false; // Style-run batching (static scene rendering): consecutive stroke calls // with the same style are collected into one Path2D and stroked once. @@ -61,7 +63,9 @@ export class ScreenCanvasDrawController implements DrawController { } public setCanvasSize(newCanvasSize: Point) { + const changed = newCanvasSize.x !== this.canvasSize.x || newCanvasSize.y !== this.canvasSize.y; this.canvasSize = newCanvasSize; + if (changed && this.viewFitted) this.zoomToFitScreen(); } public getScreenScale() { @@ -70,6 +74,7 @@ export class ScreenCanvasDrawController implements DrawController { public setScreenScale(newScreenScale: number) { this.screenScale = newScreenScale; + this.viewFitted = false; triggerReactUpdate(StateVariable.screenZoom); } @@ -79,6 +84,7 @@ export class ScreenCanvasDrawController implements DrawController { public setScreenOffset(newScreenOffset: Point) { this.screenOffset = newScreenOffset; + this.viewFitted = false; triggerReactUpdate(StateVariable.screenOffset); } @@ -96,6 +102,7 @@ export class ScreenCanvasDrawController implements DrawController { } public panScreen(screenOffsetX: number, screenOffsetY: number) { + this.viewFitted = false; this.screenOffset = new Point( this.screenOffset.x - screenOffsetX / this.screenScale, this.screenOffset.y - screenOffsetY / this.screenScale @@ -145,7 +152,14 @@ export class ScreenCanvasDrawController implements DrawController { // 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다. const pickable = getPickableEntities(); const entities = pickable.length ? pickable : getEntities(); - if (!entities.length) return; + if (!entities.length) { + // 빈 도면 — 원점(0,0)을 화면 가운데로. 안 옮기면 기준점 십자가 왼쪽 아래 귀퉁이에 잘린다. + const canvasSize = this.getCanvasSize(); + const zoom = this.getScreenScale(); + this.setScreenOffset(new Point(-canvasSize.x / 2 / zoom, -canvasSize.y / 2 / zoom)); + this.viewFitted = true; + return; + } const boundingBox = getBoundingBoxOfMultipleEntities(entities); const boundingWidth = boundingBox.maxX - boundingBox.minX; const boundingHeight = boundingBox.maxY - boundingBox.minY; @@ -171,6 +185,7 @@ export class ScreenCanvasDrawController implements DrawController { worldCenterY - canvasSize.y / 2 / zoomLevel ) ); + this.viewFitted = true; } /** diff --git a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts index c54fbaf6..06d07080 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts @@ -12,6 +12,8 @@ import { getSnapPoint, isDrawingDirty, isDrawingReadOnly, + isOriginMarker, + isViewOnly, } from '../state'; import { Point } from '@flatten-js/core'; import { isEntityHidden } from './visibility'; @@ -47,6 +49,9 @@ export function registerCadDebugHook(): void { readOnly: () => isDrawingReadOnly(), dirty: () => isDrawingDirty(), meta: () => getDesignMeta(), + // 기준점 십자 · 미리보기(보기 전용) 켜짐 — 그림이 안 도는 탭에서도 수치로 본다. + originMarker: () => isOriginMarker(), + viewOnly: () => isViewOnly(), // 세계 좌표 → 화면 좌표. 그림·글자가 "제 자리에 그려졌나"를 픽셀로 판정할 때 쓴다. screen: (x: number, y: number) => { const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y)); diff --git a/B07_DesignDetail/openwebcad/src/theme.ts b/B07_DesignDetail/openwebcad/src/theme.ts index aa33bee1..da3aa759 100644 --- a/B07_DesignDetail/openwebcad/src/theme.ts +++ b/B07_DesignDetail/openwebcad/src/theme.ts @@ -3,8 +3,9 @@ * * iframe은 호스트와 **같은 오리진**이라 `localStorage["frd_theme"]`를 공유한다. * 부모가 토글하며 값을 쓰면 이 문서에는 `storage` 이벤트가 오므로, 부모 쪽에 - * 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 미지정 상태로 두어 - * `ui_template_theme.css`의 `prefers-color-scheme` 폴백이 그대로 먹는다. + * 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 부모 문서의 `data-theme` + * (호스트 index.html 기본값 light)를 따른다 — 시스템 설정을 따로 따르면 호스트와 어긋난다. + * 부모가 없을 때(단독 실행)만 `prefers-color-scheme` 폴백이 먹는다. */ import { bumpSceneVersion } from './helpers/scene-version.ts'; @@ -27,11 +28,22 @@ function applyTheme(value: string | null): void { bumpSceneVersion(); } +/** 부모(호스트) 문서의 테마 — 부모가 없거나 못 읽으면 null. */ +function parentTheme(): string | null { + try { + return window.parent === window + ? null + : window.parent.document.documentElement.getAttribute('data-theme'); + } catch { + return null; + } +} + /** 부팅 시 1회 호출 — 현재 테마 반영 + 이후 변경 구독. */ export function syncThemeFromHost(): void { - applyTheme(localStorage.getItem(THEME_STORAGE_KEY)); + applyTheme(localStorage.getItem(THEME_STORAGE_KEY) ?? parentTheme()); window.addEventListener('storage', (event) => { - if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue); + if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue ?? parentTheme()); }); // 시스템 설정 폴백으로 도는 중이면 OS 전환도 따라간다. window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 4896aa99..7650e1d2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -147,7 +147,13 @@ export function mountDrawingTemplate( values.every(Number.isFinite) && values[0] < values[2] && values[1] < values[3]; - areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위")); + // 빈 칸 · 숫자 아님은 그 칸만 짚음 — 모두 숫자인데 어긋나면 크기 순서 안내 + const own = areaInputs.map((field) => { + const raw = field.input.value.trim(); + return raw === "" ? "값을 넣을 것" : Number.isFinite(Number(raw)) ? undefined : "숫자만"; + }); + const order = own.some(Boolean) ? undefined : "왼쪽<오른쪽 · 아래<위"; + areaInputs.forEach((field, index) => field.setError(valid ? undefined : (own[index] ?? order))); areaButton.textContent = `작도 영역 ${areaInputs.map((f) => f.input.value.trim() || "?").join(" · ")}`; areaButton.classList.toggle("is-invalid", !valid); return valid ? (values as [number, number, number, number]) : null; @@ -227,6 +233,7 @@ export function mountDrawingTemplate( .then((response) => { // 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다. load(response.drawing); + options.onChanged?.(true); // 불러온 것은 아직 저장 전 — 떠날 때 묻게 함 showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); }) .catch((error) => diff --git a/resources/tester/cad/origin-marker.test.ts b/resources/tester/cad/origin-marker.test.ts index c909c36e..61ae31c8 100644 --- a/resources/tester/cad/origin-marker.test.ts +++ b/resources/tester/cad/origin-marker.test.ts @@ -58,6 +58,31 @@ describe('기준점 십자', () => { expect(b.origin.x - a.origin.x).toBeCloseTo(-100); // 세계 50 = 화면 100px(2배) — 화면을 옮기면 원점이 반대로 감 }); + it('빈 도면 맞춤은 원점을 화면 가운데로 — 십자가 귀퉁이에 안 잘림', () => { + const { context } = recordingContext(); + const controller = new ScreenCanvasDrawController(context); + controller.setCanvasSize(new Point(800, 600)); + controller.setScreenScale(2); + controller.setScreenOffset(new Point(0, 0)); + controller.zoomToFitScreen(); + const origin = controller.worldToTarget(new Point(0, 0)); + expect([origin.x, origin.y]).toEqual([400, 300]); + }); + + it('맞춘 뒤 캔버스가 커지면 다시 맞춤 · 사용자가 옮긴 뒤에는 그대로', () => { + const { context } = recordingContext(); + const controller = new ScreenCanvasDrawController(context); + controller.setCanvasSize(new Point(200, 100)); + controller.zoomToFitScreen(); + controller.setCanvasSize(new Point(800, 600)); + const refit = controller.worldToTarget(new Point(0, 0)); + expect([refit.x, refit.y]).toEqual([400, 300]); + controller.panScreen(50, 0); + controller.setCanvasSize(new Point(1000, 600)); + const kept = controller.worldToTarget(new Point(0, 0)); + expect([kept.x, kept.y]).toEqual([450, 300]); + }); + it('그리기만 — 도면 개체를 만들지 않음', () => { drawAt(1, new Point(0, 0)); expect(getEntities()).toHaveLength(0);