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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user