Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
189 lines
7.2 KiB
TypeScript
189 lines
7.2 KiB
TypeScript
import { Point } from '@flatten-js/core';
|
|
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
|
|
import { getCanvas, getScreenCanvasDrawController } from '../state.ts';
|
|
|
|
/**
|
|
* 작도 영역 가이드(169-1 · 169-3) — 도면 그림이 실제로 들어가는 사각형을 점선으로 보인다.
|
|
*
|
|
* 도면 개체가 아니라 그리기 겹판(기준점 십자와 같은 자리)이라 저장 · DXF · 인쇄에 안 섞인다.
|
|
* `editable` 이면 모서리 · 변을 끌어 영역을 고치고, 고친 값은 부모(숫자 칸)에 알린다.
|
|
* 부모 짝 = `ui_template/cad_host/cad_host.ts` `guide` 곁값 · `setGuideArea` · `onGuideChange` ·
|
|
* `setGuideVisible`(180-3 — 점선 영역 + 붉은 넘침 틀 보이기 / 숨기기 · 도각 칸선은 도면 개체라 그대로).
|
|
*/
|
|
|
|
/** [왼쪽 x, 아래 y, 오른쪽 x, 위 y] — 도면 좌표 mm */
|
|
export type GuideArea = [number, number, number, number];
|
|
|
|
export interface Guide {
|
|
area: GuideArea;
|
|
/** 넘친 칸(169-2) — 붉은 사각형으로 짚음 */
|
|
marks?: GuideArea[];
|
|
/** 끌어서 고칠 수 있나 — 도각 편집 · M02 양식만 */
|
|
editable?: boolean;
|
|
}
|
|
|
|
/** 끌 손잡이 — 모서리 넷 · 변 넷 (n = 위 · s = 아래 · e = 오른쪽 · w = 왼쪽) */
|
|
export type GuideHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
|
|
|
const GUIDE_COLOR = '#29b6f6';
|
|
const MARK_COLOR = '#ff5252';
|
|
/** 손잡이를 집었다고 볼 화면 거리(px) — 칸 모서리 끌기(`box-resize-drag`)와 같음 */
|
|
const GRAB_PIXELS = 9;
|
|
/** 영역 최소 폭 · 높이(mm) — 뒤집히거나 0 이 되지 않게 */
|
|
export const GUIDE_MIN_MM = 10;
|
|
|
|
let guide: Guide | null = null;
|
|
/** 안내선(점선 영역 + 붉은 틀) 보임 — 싣기 때 안 지움 · 숨김이면 그리지도 · 집지도 않음 */
|
|
let guideVisible = true;
|
|
|
|
const isArea = (value: unknown): value is GuideArea =>
|
|
Array.isArray(value) &&
|
|
value.length === 4 &&
|
|
value.every((n) => typeof n === 'number' && Number.isFinite(n)) &&
|
|
value[0] < value[2] &&
|
|
value[1] < value[3];
|
|
|
|
export const getGuide = (): Guide | null => guide;
|
|
|
|
export const isGuideVisible = (): boolean => guideVisible;
|
|
|
|
/** 부모 단추(180-3) — `true` 만 보임 · 그 밖 값은 보임으로 */
|
|
export function setGuideVisible(visible: unknown): void {
|
|
guideVisible = visible !== false;
|
|
}
|
|
|
|
/** 싣기 때 — 어긋난 값이면 가이드 없음 */
|
|
export function setGuide(next: unknown): void {
|
|
const candidate = next as Partial<Guide> | null | undefined;
|
|
guide =
|
|
candidate && isArea(candidate.area)
|
|
? {
|
|
area: [...candidate.area] as GuideArea,
|
|
marks: (candidate.marks ?? []).filter(isArea),
|
|
editable: candidate.editable === true,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
/** 부모 숫자 칸이 바꾼 영역 — 가이드가 있을 때만 */
|
|
export function setGuideArea(area: unknown): void {
|
|
if (guide && isArea(area)) guide = { ...guide, area: [...area] as GuideArea };
|
|
}
|
|
|
|
/** 점에서 가장 가까운 손잡이 — 모서리가 변보다 먼저 · 거리 `tolerance` 밖이면 null */
|
|
export function pickGuideHandle(
|
|
area: GuideArea,
|
|
point: { x: number; y: number },
|
|
tolerance: number
|
|
): GuideHandle | null {
|
|
const [x0, y0, x1, y1] = area;
|
|
const nearX = (x: number) => Math.abs(point.x - x) <= tolerance;
|
|
const nearY = (y: number) => Math.abs(point.y - y) <= tolerance;
|
|
const inX = point.x >= x0 - tolerance && point.x <= x1 + tolerance;
|
|
const inY = point.y >= y0 - tolerance && point.y <= y1 + tolerance;
|
|
const ns = nearY(y1) && inX ? 'n' : nearY(y0) && inX ? 's' : '';
|
|
const ew = nearX(x1) && inY ? 'e' : nearX(x0) && inY ? 'w' : '';
|
|
return ((ns + ew) as GuideHandle) || null;
|
|
}
|
|
|
|
/** 손잡이를 점까지 끈 영역 — 반대편은 그대로 · 최소 크기 지킴 · 0.1 mm 반올림 */
|
|
export function dragGuideArea(
|
|
area: GuideArea,
|
|
handle: GuideHandle,
|
|
point: { x: number; y: number }
|
|
): GuideArea {
|
|
const round = (n: number) => Math.round(n * 10) / 10;
|
|
let [x0, y0, x1, y1] = area;
|
|
if (handle.includes('w')) x0 = Math.min(round(point.x), x1 - GUIDE_MIN_MM);
|
|
if (handle.includes('e')) x1 = Math.max(round(point.x), x0 + GUIDE_MIN_MM);
|
|
if (handle.includes('s')) y0 = Math.min(round(point.y), y1 - GUIDE_MIN_MM);
|
|
if (handle.includes('n')) y1 = Math.max(round(point.y), y0 + GUIDE_MIN_MM);
|
|
return [x0, y0, x1, y1];
|
|
}
|
|
|
|
function strokeRect(drawController: ScreenCanvasDrawController, area: GuideArea): void {
|
|
const [x0, y0, x1, y1] = area;
|
|
const corners = [new Point(x0, y0), new Point(x1, y0), new Point(x1, y1), new Point(x0, y1)].map(
|
|
(point) => drawController.worldToTarget(point)
|
|
);
|
|
corners.forEach((corner, index) =>
|
|
drawController.drawLineScreen(corner, corners[(index + 1) % corners.length])
|
|
);
|
|
}
|
|
|
|
/** 그리기 겹판 — 엔티티 장면 위에 매 장 그림(`helpers/draw.ts`) */
|
|
export function drawGuide(drawController: ScreenCanvasDrawController): void {
|
|
if (!guide || !guideVisible) return;
|
|
drawController.setOpacity(0.9);
|
|
drawController.setLineStyles(false, false, GUIDE_COLOR, 1, [8, 5]);
|
|
strokeRect(drawController, guide.area);
|
|
if (guide.editable) {
|
|
const [x0, y0, x1, y1] = guide.area;
|
|
const xs = [x0, (x0 + x1) / 2, x1];
|
|
const ys = [y0, (y0 + y1) / 2, y1];
|
|
for (const x of xs) {
|
|
for (const y of ys) {
|
|
if (x === xs[1] && y === ys[1]) continue;
|
|
const at = drawController.worldToTarget(new Point(x, y));
|
|
drawController.fillRectScreen(at.x - 3, at.y - 3, 6, 6, GUIDE_COLOR, true);
|
|
}
|
|
}
|
|
}
|
|
drawController.setLineStyles(false, false, MARK_COLOR, 2, []);
|
|
for (const mark of guide.marks ?? []) strokeRect(drawController, mark);
|
|
drawController.setOpacity(1);
|
|
}
|
|
|
|
/** 마우스 위치(화면) → 도면 좌표. 캐드는 화면 y 를 아래에서 위로 잰다. */
|
|
function worldAt(event: MouseEvent): Point | null {
|
|
const canvas = getCanvas();
|
|
if (!canvas) return null;
|
|
const bounds = canvas.getBoundingClientRect();
|
|
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
|
|
return getScreenCanvasDrawController().targetToWorld(screenPoint);
|
|
}
|
|
|
|
let dragging: GuideHandle | null = null;
|
|
|
|
/** 끌기 — 손잡이를 집었을 때만 가로챔(그 밖은 그리기 도구로 흘림) · 움직일 때마다 `notify` */
|
|
export function registerGuideDrag(notify: (area: GuideArea) => void): void {
|
|
const canvas = getCanvas();
|
|
if (!canvas) return;
|
|
window.addEventListener(
|
|
'mousedown',
|
|
(event: MouseEvent) => {
|
|
if (event.button !== 0 || event.target !== canvas || !guide?.editable || !guideVisible) return;
|
|
const world = worldAt(event);
|
|
if (!world) return;
|
|
const tolerance = GRAB_PIXELS / getScreenCanvasDrawController().getScreenScale();
|
|
dragging = pickGuideHandle(guide.area, world, tolerance);
|
|
if (!dragging) return;
|
|
event.stopImmediatePropagation();
|
|
event.preventDefault();
|
|
},
|
|
true
|
|
);
|
|
window.addEventListener(
|
|
'mousemove',
|
|
(event: MouseEvent) => {
|
|
if (!dragging || !guide) return;
|
|
const world = worldAt(event);
|
|
if (!world) return;
|
|
guide = { ...guide, area: dragGuideArea(guide.area, dragging, world) };
|
|
notify(guide.area);
|
|
event.stopImmediatePropagation();
|
|
},
|
|
true
|
|
);
|
|
window.addEventListener(
|
|
'mouseup',
|
|
(event: MouseEvent) => {
|
|
if (!dragging) return;
|
|
dragging = null;
|
|
event.stopImmediatePropagation();
|
|
event.preventDefault();
|
|
},
|
|
true
|
|
);
|
|
}
|