diff --git a/B07_DesignDetail/openwebcad/src/App_Layers.css b/B07_DesignDetail/openwebcad/src/App_Layers.css index fabf5e440..c35e2aa0c 100644 --- a/B07_DesignDetail/openwebcad/src/App_Layers.css +++ b/B07_DesignDetail/openwebcad/src/App_Layers.css @@ -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); diff --git a/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx b/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx index cf8188167..a81750c2e 100644 --- a/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx @@ -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 = ({ 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 ( ); diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 496e0ab02..c4c69d5c2 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -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); } /** diff --git a/B07_DesignDetail/openwebcad/src/helpers/grid-steps.test.ts b/B07_DesignDetail/openwebcad/src/helpers/grid-steps.test.ts new file mode 100644 index 000000000..0e6d13c31 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/grid-steps.test.ts @@ -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'); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/grid-steps.ts b/B07_DesignDetail/openwebcad/src/helpers/grid-steps.ts new file mode 100644 index 000000000..8fc1373a9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/grid-steps.ts @@ -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`; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts index 754eae362..8793f2db8 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts @@ -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); + }); }); diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 04cd70a2b..4d9b75a6e 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -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;