feat(825-1): B07 내장 캐드 그리드 — 도면 좌표에 붙임 · 1·2·5 적응 간격 · 주선 · 한 칸 크기 표시

- 그리드를 화면 고정 24px 에서 원점 기준 도면 좌표 선으로 바꿈(팬·줌을 따라감 · 보이는 범위만 계산)
- 간격 단계 1·2·5 × 10^k · 보조선 사이 12~30px · 한 방향 선 수 상한 200 · 주선 5·10 × 10^k
- 순수 함수 helpers/grid-steps.ts + 시험 · zoom-out-perf 에 그리드 켠 clear() 선 수 · 시간 시험 추가
- 주선 색 --draw-grid-major 를 공통 테마(밝음 · 어두움)에 더함 · 상태줄에 「그리드 1 칸 = ○ m」 표시

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 18:21:38 +09:00
co-authored by Claude Sonnet 5.5
parent c367ab5f56
commit 2ac6c9808a
7 changed files with 173 additions and 15 deletions
@@ -184,6 +184,12 @@ body.cad-file-dragging::after {
border-top: 1px solid var(--cad-line);
}
/* 켜기 = 켜기 칩 부품(`ui-toggle--chip` · 단 22) */
.cad-statusbar__grid {
margin-left: 8px;
color: var(--cad-text-muted);
font-size: 10px;
white-space: nowrap;
}
.cad-statusbar__hint {
margin-left: auto;
color: var(--cad-text-muted);
@@ -4,12 +4,14 @@ import {
getAngleStep,
getGridEnabled,
getSnapEnabled,
getScreenCanvasDrawController,
getSnapTrackingEnabled,
setAngleStep,
setGridEnabled,
setSnapEnabled,
setSnapTrackingEnabled,
} from '../state';
import { formatGridStep, pickGridStep } from '../helpers/grid-steps';
interface StatusBarProps {
commandLineVisible: boolean;
@@ -22,7 +24,12 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
const ortho = getAngleStep() === 90;
const polar = getAngleStep() === 45;
const snapTracking = getSnapTrackingEnabled();
// 그리드 한 칸 크기(825-1) — 지금 확대 정도의 보조선 간격
const view = getScreenCanvasDrawController();
const gridStep =
grid && view
? pickGridStep(view.getScreenScale(), Math.max(view.getCanvasSize().x, view.getCanvasSize().y))
: null;
return (
<footer className="cad-statusbar controls">
<button
@@ -85,6 +92,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
>
명령행
</button>
{gridStep && (
<span className="cad-statusbar__grid">그리드 1 칸 = {formatGridStep(gridStep.minor)}</span>
)}
<span className="cad-statusbar__hint">휠: 줌 · 휠 드래그: 초점이동 · Esc: 취소</span>
</footer>
);
@@ -1,5 +1,6 @@
import { Point, type Vector } from '@flatten-js/core';
import { WHEEL_ZOOM_EXPONENT } from '../App.consts';
import { gridLines, pickGridStep } from '../helpers/grid-steps.ts';
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
import { mapNumberRange } from '../helpers/map-number-range.ts';
import { StateVariable } from '../helpers/undo-stack.ts';
@@ -338,21 +339,40 @@ export class ScreenCanvasDrawController implements DrawController {
this.context.fillStyle = themeColor('--cad-canvas', CANVAS_BACKGROUND_FALLBACK);
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
if (getGridEnabled()) {
this.context.strokeStyle = themeColor('--draw-grid', '#242b35');
this.context.lineWidth = 1;
this.context.setLineDash([]);
this.context.beginPath();
for (let x = 0.5; x < this.canvasSize.x; x += 24) {
this.context.moveTo(x, 0);
this.context.lineTo(x, this.canvasSize.y);
}
for (let y = 0.5; y < this.canvasSize.y; y += 24) {
this.context.moveTo(0, y);
this.context.lineTo(this.canvasSize.x, y);
}
this.context.stroke();
if (getGridEnabled()) this.drawGrid();
}
/** 도면 좌표에 붙은 그리드(825-1) — 팬 · 줌을 따라가고 확대 정도에 간격이 바뀜. 보이는 범위의 선만 긋는다. */
private drawGrid() {
const { x: width, y: height } = this.canvasSize;
const scale = this.screenScale;
const step = pickGridStep(scale, Math.max(width, height));
const minorPath = new Path2D();
const majorPath = new Path2D();
for (const { value, major } of gridLines(
this.screenOffset.x,
this.screenOffset.x + width / scale,
step
)) {
const x = Math.round((value - this.screenOffset.x) * scale) + 0.5;
(major ? majorPath : minorPath).moveTo(x, 0);
(major ? majorPath : minorPath).lineTo(x, height);
}
for (const { value, major } of gridLines(
this.screenOffset.y,
this.screenOffset.y + height / scale,
step
)) {
const y = Math.round(height - (value - this.screenOffset.y) * scale) + 0.5;
(major ? majorPath : minorPath).moveTo(0, y);
(major ? majorPath : minorPath).lineTo(width, y);
}
this.context.lineWidth = 1;
this.context.setLineDash([]);
this.context.strokeStyle = themeColor('--draw-grid', '#242b35');
this.context.stroke(minorPath);
this.context.strokeStyle = themeColor('--draw-grid-major', '#3a4352');
this.context.stroke(majorPath);
}
/**
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { formatGridStep, gridLines, MAX_LINES, MIN_MINOR_PX, pickGridStep } from './grid-steps';
describe('그리드 간격(825-1)', () => {
it('여러 확대 정도에서 화면 간격이 범위 안 · 선 수 상한', () => {
for (let exp = -4; exp <= 5; exp += 0.25) {
const scale = 10 ** exp; // px/m
for (const extent of [800, 1600, 3840]) {
const step = pickGridStep(scale, extent);
const px = step.minor * scale;
const floor = Math.max(MIN_MINOR_PX, extent / MAX_LINES);
expect(px).toBeGreaterThanOrEqual(floor * 0.999);
expect(px).toBeLessThanOrEqual(floor * 2.5 * 1.001);
const lines = gridLines(-1e6, -1e6 + extent / scale, step);
expect(lines.length).toBeLessThanOrEqual(MAX_LINES + 1);
}
}
});
it('주선은 늘 5 · 10 × 10^k 위', () => {
for (const scale of [0.001, 0.07, 0.3, 1, 4, 20, 900]) {
const step = pickGridStep(scale, 1600);
const major = step.minor * step.majorEvery;
expect(Math.log10(major / 5) % 1 === 0 || Math.log10(major / 10) % 1 === 0).toBe(true);
}
});
it('팬해도 같은 도면 좌표에 선 — 원점 기준', () => {
const step = pickGridStep(2, 1600); // 12 px → 6 m 이상 → 10 m
expect(step.minor).toBe(10);
const a = gridLines(3, 95, step).map((l) => l.value);
const b = gridLines(-47, 45, step).map((l) => l.value);
expect(a.filter((v) => v <= 45)).toEqual(b.filter((v) => v >= 3));
expect(gridLines(-20, 20, step).filter((l) => l.major).map((l) => l.value)).toEqual([0]);
});
it('문구', () => {
expect(formatGridStep(1)).toBe('1 m');
expect(formatGridStep(0.5)).toBe('0.5 m');
expect(formatGridStep(5000)).toBe('5 km');
expect(formatGridStep(0.002)).toBe('2 mm');
});
});
@@ -0,0 +1,57 @@
/**
* 그리드 간격 계산(825-1) — 화면 보조 표시 전용 순수 함수. 도면 단위 = m.
* 간격 단계 = 1 · 2 · 5 × 10^k. 화면에서 보조선 사이가 MIN_MINOR_PX 이상이 되는 가장 작은 단계를 고름
* (→ 보조선 사이 12~30 px · 확대하면 나뉘고 축소하면 커짐). 주선 = 1·2 단계는 5 칸마다 · 5 단계는 2 칸마다(늘 5·10 × 10^k).
* 한 방향 선 수 상한 MAX_LINES — 화면이 아주 커도 지킴.
*/
export const MIN_MINOR_PX = 12;
export const MAX_LINES = 200;
const MANTISSAS = [1, 2, 5];
export interface GridStep {
/** 보조선 간격 (m) */
minor: number;
/** 주선 = 보조선 몇 칸마다 */
majorEvery: number;
}
/** scale = 화면 px / m · extentPx = 화면 긴 변 px */
export function pickGridStep(scale: number, extentPx: number): GridStep {
if (!(scale > 0)) return { minor: 1, majorEvery: 5 };
const minPx = Math.max(MIN_MINOR_PX, extentPx / MAX_LINES);
const minWorld = minPx / scale;
let exponent = Math.floor(Math.log10(minWorld)) - 1;
for (let guard = 0; guard < 40; guard++, exponent++) {
for (const mantissa of MANTISSAS) {
const minor = Number(`${mantissa}e${exponent}`); // 1e-3 꼴 — 곱셈 부동소수 오차 없음
if (minor >= minWorld) return { minor, majorEvery: mantissa === 5 ? 2 : 5 };
}
}
return { minor: minWorld, majorEvery: 5 };
}
export interface GridLine {
/** 도면 좌표 값 */
value: number;
major: boolean;
}
/** [lo, hi] 안에서 원점 기준 minor 간격의 선 목록 */
export function gridLines(lo: number, hi: number, step: GridStep): GridLine[] {
const lines: GridLine[] = [];
const first = Math.ceil(lo / step.minor);
const last = Math.floor(hi / step.minor);
for (let index = first; index <= last; index++) {
lines.push({ value: index * step.minor, major: index % step.majorEvery === 0 });
}
return lines;
}
/** 상태줄 문구용 — 1 · 0.5 · 10 · 1 km · 5 mm */
export function formatGridStep(minor: number): string {
if (minor >= 1000) return `${Number((minor / 1000).toPrecision(6))} km`;
if (minor >= 1) return `${Number(minor.toPrecision(6))} m`;
if (minor >= 0.1) return `${Number(minor.toPrecision(6))} m`;
return `${Number((minor * 1000).toPrecision(6))} mm`;
}
@@ -47,6 +47,7 @@ import { MeasurementEntity } from '../entities/MeasurementEntity';
import { PolyLineEntity } from '../entities/PolyLineEntity';
import { TextEntity } from '../entities/TextEntity';
import { setEntities, setGridEnabled, setLayers } from '../state';
import { MAX_LINES } from './grid-steps';
import { drawScene, invalidateSceneCache, scenePerf } from './scene-cache';
import { queryEntitiesInBox } from './spatial-index';
@@ -188,4 +189,22 @@ describe('줌아웃 그리기 비용(227-1)', () => {
setGridEnabled(false);
expect(scenePerf.sceneRebuilds).toBe(rebuilds);
});
it('그리드를 켠 채 배율별 clear() — 선 수 상한(825-1) · 한 장 시간(옛 24px 고정 = 가로 67 + 세로 38 = 105 줄)', () => {
setGridEnabled(true);
const rows = [];
for (const factor of [100, 10, 1, 0.1, 0.01, 0.0001]) {
viewAt(controller, factor, fitScale);
for (const key of Object.keys(counts)) counts[key as keyof typeof counts] = 0;
controller.clear();
const lines = counts.segments;
const startedAt = performance.now();
for (let rep = 0; rep < 200; rep++) controller.clear();
const ms = (performance.now() - startedAt) / 200;
rows.push({ factor, lines, ms: Number(ms.toFixed(3)) });
expect(lines).toBeLessThanOrEqual(2 * (MAX_LINES + 1));
}
setGridEnabled(false);
console.table(rows);
});
});
+3
View File
@@ -548,6 +548,7 @@ html.is-bm-route .m01lab-src__body * {
--draw-text-secondary: #615e6e;
--draw-text-muted: #9491a1;
--draw-grid: #e6e2e3; /* 눈금 · CAD 격자 */
--draw-grid-major: #d2cccf; /* CAD 격자 주선 */
--draw-surface: #f6f7fa;
--draw-surface-raised: #ffffff; /* 그림 바탕 · 3D 바탕 */
--draw-canvas: #ffffff; /* CAD 바탕 */
@@ -632,6 +633,7 @@ html.is-bm-route .m01lab-src__body * {
--draw-text-secondary: #b3aec6;
--draw-text-muted: #8b86a0;
--draw-grid: #322a45;
--draw-grid-major: #453b61;
--draw-surface: #1e1930;
--draw-surface-raised: #251f38;
--draw-canvas: #16121f;
@@ -648,6 +650,7 @@ html.is-bm-route .m01lab-src__body * {
--draw-text-secondary: #b3aec6;
--draw-text-muted: #8b86a0;
--draw-grid: #322a45;
--draw-grid-major: #453b61;
--draw-surface: #1e1930;
--draw-surface-raised: #251f38;
--draw-canvas: #16121f;