feat(ui): options panel with an IndexedDB-backed lineweight display scale

Set the default lineweight scale to 11 px/mm: the value the user matched
against AutoCAD on the actual screen. The 20 px/mm derived from their
screenshot was too heavy in practice - the capture and the monitor do
not share a DPI - so the constant is now only a starting point.

Move the lineweight controls off the toolbar into an Options panel:
display toggle, scale slider plus number box, a readout of what the
drawing's own weights come out to, grid toggle, zoom speed, and a reset.
Settings persist in IndexedDB (hmw-viewer/options) rather than
localStorage, so values keep their type and the store has room to grow
per-drawing later; every call degrades quietly if storage is blocked.

Scale changes still apply to the live materials - each fat batch now
remembers its weight in mm - so dragging the slider retunes a 37MB
drawing instantly.

Verified headless: 0.35mm renders 3-4px at scale 11 and 8-9px at 25,
matching the readout, and the value survives a reload via IndexedDB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsungandClaude Opus 5 committed 2026-08-05 09:00:37 +09:00
1 parent a924b1ec34
commit 1f80442f6c
8 files changed
+6310 -102

No files matched your search

+77 -35
View File
@@ -29,6 +29,7 @@ import { setDefaultFontUrl } from './viewer2d/slugText';
import { renderPropertyInspector } from './propertyInspector';
import { LayerPanelManager } from './layerPanel';
import { makePanelDraggableAndCollapsible } from './panelUtils';
import { getAllOptions, setOption } from './optionsStore';
// parseDxfBuffer 내부:
// dxf-parser → HatchHandler → dxfAdapter
@@ -162,8 +163,7 @@ async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadParseRe
const def = pickDefaultSpace(spaces);
viewer.load(result, { spaceHandle: def?.handleHex ?? null });
viewer.resize();
syncLwtBtn();
syncLwScale();
syncOptions();
return result;
}
@@ -231,49 +231,91 @@ document.getElementById('theme')!.addEventListener('click', () => {
viewer.setTheme(dark);
fillLayers();
});
// 선가중치(LWT): 처음에는 도면의 LWDISPLAY 값을 따르고, 누르면 강제 on/off.
const lwtBtn = document.getElementById('lwt') as HTMLButtonElement;
const syncLwtBtn = () => {
const on = viewer.getLineweightEnabled();
lwtBtn.classList.toggle('primary', on);
lwtBtn.title = `선가중치 표시 ${on ? 'ON' : 'OFF'} (AutoCAD LWDISPLAY)`;
};
lwtBtn.addEventListener('click', () => {
viewer.setLineweightEnabled(!viewer.getLineweightEnabled());
syncLwtBtn();
syncLwScale();
// ── 옵션 패널 ────────────────────────────────────────────────────
// 값은 IndexedDB(hmw-viewer/options)에 남는다. 선가중치 배율은 재파싱 없이
// 즉시 반영되므로 드래그하면서 화면을 보고 맞출 수 있다.
const optionsPanel = document.getElementById('options')!;
const optLwOn = document.getElementById('optLwOn') as HTMLInputElement;
const optLwScale = document.getElementById('optLwScale') as HTMLInputElement;
const optLwScaleNum = document.getElementById('optLwScaleNum') as HTMLInputElement;
const optLwOut = document.getElementById('optLwOut')!;
const optGrid = document.getElementById('optGrid') as HTMLInputElement;
const optZoomSpeed = document.getElementById('optZoomSpeed') as HTMLInputElement;
const optZoomSpeedNum = document.getElementById('optZoomSpeedNum') as HTMLInputElement;
makePanelDraggableAndCollapsible(optionsPanel, {
collapseBtnEl: document.getElementById('collapseOptBtn')!,
});
document.getElementById('optionsBtn')!.addEventListener('click', () => {
optionsPanel.style.display = optionsPanel.style.display === 'flex' ? 'none' : 'flex';
});
document.getElementById('closeOptBtn')!.addEventListener('click', () => {
optionsPanel.style.display = 'none';
});
// 선가중치 표시 배율 (px per mm) — AutoCAD "표시 배율" 슬라이더에 해당.
// 리로드 없이 즉시 반영되고, 값은 localStorage 에 남는다.
const LW_SCALE_KEY = 'hmw:lw-scale';
const lwScaleRange = document.getElementById('lwScale') as HTMLInputElement;
const lwScaleNum = document.getElementById('lwScaleNum') as HTMLInputElement;
const lwScaleOut = document.getElementById('lwScaleOut') as HTMLSpanElement;
/** 현재 배율에서 도면이 실제로 쓰는 굵기들이 몇 px 로 그려지는지 표기. */
function syncLwScale() {
/** 컨트롤 값을 뷰어의 현재 상태로 되돌린다(도면을 새로 열었을 때도 호출). */
function syncOptions() {
const s = viewer.getLineweightScale();
lwScaleRange.value = String(s);
lwScaleNum.value = String(s);
optLwOn.checked = viewer.getLineweightEnabled();
optLwScale.value = String(s);
optLwScaleNum.value = String(s);
const classes = viewer.getLineweightClasses();
lwScaleOut.textContent = classes.length
? classes.map((mm) => `${mm.toFixed(2)}mm→${(Math.round(mm * s * 10) / 10)}px`).join(' ')
: `0.50mm→${Math.round(0.5 * s * 10) / 10}px`;
optLwOut.textContent = classes.length
? classes.map((mm) => `${mm.toFixed(2)}mm → ${Math.round(mm * s * 10) / 10}px`).join(' ')
: `이 도면에는 굵은 선이 없다 (0.50mm 였다면 ${Math.round(0.5 * s * 10) / 10}px)`;
}
function applyLwScale(v: number) {
function applyLwScale(v: number, persist = true) {
if (!Number.isFinite(v) || v <= 0) return;
viewer.setLineweightScale(v);
localStorage.setItem(LW_SCALE_KEY, String(v));
syncLwScale();
if (persist) void setOption('lineweightScale', v);
syncOptions();
}
lwScaleRange.addEventListener('input', () => applyLwScale(parseFloat(lwScaleRange.value)));
lwScaleNum.addEventListener('input', () => applyLwScale(parseFloat(lwScaleNum.value)));
optLwScale.addEventListener('input', () => applyLwScale(parseFloat(optLwScale.value)));
optLwScaleNum.addEventListener('input', () => applyLwScale(parseFloat(optLwScaleNum.value)));
const savedLwScale = parseFloat(localStorage.getItem(LW_SCALE_KEY) || '');
if (Number.isFinite(savedLwScale) && savedLwScale > 0) viewer.setLineweightScale(savedLwScale);
syncLwScale();
optLwOn.addEventListener('change', () => {
viewer.setLineweightEnabled(optLwOn.checked);
void setOption('lineweightOn', optLwOn.checked);
syncOptions();
});
optGrid.addEventListener('change', () => {
viewer.setGrid(optGrid.checked);
void setOption('grid', optGrid.checked);
});
function applyZoomSpeed(v: number, persist = true) {
if (!Number.isFinite(v) || v <= 0) return;
viewer.setZoomSpeed(v);
optZoomSpeed.value = String(v);
optZoomSpeedNum.value = String(v);
if (persist) void setOption('zoomSpeed', v);
}
optZoomSpeed.addEventListener('input', () => applyZoomSpeed(parseFloat(optZoomSpeed.value)));
optZoomSpeedNum.addEventListener('input', () => applyZoomSpeed(parseFloat(optZoomSpeedNum.value)));
document.getElementById('optReset')!.addEventListener('click', () => {
viewer.setLineweightScale(null); // 뷰어 기본 배율
viewer.setLineweightEnabled(null); // 도면의 LWDISPLAY 를 다시 따름
viewer.setGrid(false);
applyZoomSpeed(1);
optGrid.checked = false;
void setOption('lineweightScale', null);
void setOption('lineweightOn', null);
void setOption('grid', false);
syncOptions();
});
// 저장된 옵션 복원 (IndexedDB 는 비동기라 첫 도면 로드와 경쟁하지 않도록 즉시 적용)
void getAllOptions().then((o) => {
if (typeof o.lineweightScale === 'number') applyLwScale(o.lineweightScale, false);
if (typeof o.lineweightOn === 'boolean') viewer.setLineweightEnabled(o.lineweightOn);
if (o.grid === true) { viewer.setGrid(true); optGrid.checked = true; }
if (typeof o.zoomSpeed === 'number') applyZoomSpeed(o.zoomSpeed, false);
syncOptions();
});
syncOptions();
document.getElementById('layersBtn')!.addEventListener('click', () => {
layersPanel.style.display = layersPanel.style.display === 'flex' ? 'none' : 'flex';
});
+75
View File
@@ -0,0 +1,75 @@
/**
* 뷰어 옵션 영구 저장소 (IndexedDB).
*
* localStorage 대신 IndexedDB 를 쓰는 이유: 값이 문자열로 강제되지 않고(숫자/불린/
* 객체 그대로), 용량 제한이 사실상 없으며, 나중에 도면별 설정처럼 구조가 커져도
* 같은 저장소를 그대로 쓸 수 있다.
*
* DB 하나(`hmw-viewer`) 안에 key-value 스토어(`options`) 하나만 둔다.
* 실패(사생활 보호 모드, 스토리지 차단 등)해도 앱이 죽지 않도록 모든 API 는
* 조용히 기본값으로 떨어진다.
*/
const DB_NAME = 'hmw-viewer';
const DB_VERSION = 1;
const STORE = 'options';
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDb(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
if (typeof indexedDB === 'undefined') return resolve(null);
let req: IDBOpenDBRequest;
try {
req = indexedDB.open(DB_NAME, DB_VERSION);
} catch {
return resolve(null);
}
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
req.onblocked = () => resolve(null);
});
return dbPromise;
}
function tx<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T | null> {
return openDb().then(
(db) =>
new Promise<T | null>((resolve) => {
if (!db) return resolve(null);
try {
const t = db.transaction(STORE, mode);
const req = run(t.objectStore(STORE));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
}),
);
}
/** 저장된 옵션 하나. 없으면 `fallback`. */
export async function getOption<T>(key: string, fallback: T): Promise<T> {
const v = await tx<T>('readonly', (s) => s.get(key));
return v === null || v === undefined ? fallback : v;
}
/** 옵션 하나 저장. 저장이 막혀 있어도 예외를 던지지 않는다. */
export async function setOption(key: string, value: unknown): Promise<void> {
await tx('readwrite', (s) => s.put(value, key));
}
/** 전체 옵션을 한 번에 읽는다 (기동 시 1회 호출용). */
export async function getAllOptions(): Promise<Record<string, unknown>> {
const keys = (await tx<IDBValidKey[]>('readonly', (s) => s.getAllKeys())) ?? [];
const vals = (await tx<unknown[]>('readonly', (s) => s.getAll())) ?? [];
const out: Record<string, unknown> = {};
keys.forEach((k, i) => { out[String(k)] = vals[i]; });
return out;
}
+2
View File
@@ -16,6 +16,8 @@ export class Viewer2D {
setAccent(hex: string): void;
setTheme(dark: boolean): void;
setGrid(visible: boolean): void;
setZoomSpeed(speed: number): void;
getZoomSpeed(): number;
/** Lineweight display (AutoCAD LWDISPLAY); null follows the drawing's own flag. */
setLineweightEnabled(on: boolean | null): void;
getLineweightEnabled(): boolean;
+5 -5
View File
@@ -25,11 +25,11 @@ const CLICK_THRESHOLD_PX = 14;
// ── Lineweight (AutoCAD LWDISPLAY) ─────────────────────────────────────────
// DWG stores lineweight as an i16 in 1/100 mm (-1 ByLayer, -2 ByBlock,
// -3 Default). AutoCAD shows it in *screen* pixels — the width does not grow
// when you zoom in — at one pixel per 0.05 mm on the default display-scale
// slider (0.50 mm = 10 px, 2.11 mm = 42 px). Measured against an AutoCAD
// capture of the 노면 drawing: its 0.50 mm road edge is ~10 px, so 20 px/mm.
// Host apps can retune this with setLineweightScale().
const LW_PX_PER_MM = 20;
// when you zoom in. The px-per-mm factor is AutoCAD's "Adjust Display Scale"
// slider and depends on the monitor, so it is only a default: 11 px/mm is the
// value that matched an AutoCAD reference side by side (0.35 mm ≈ 4 px,
// 0.50 mm ≈ 5.5 px). Retune per host/monitor with setLineweightScale().
const LW_PX_PER_MM = 11;
// LWDEFAULT: what -3 (and a layer that never set one) resolves to.
const LW_DEFAULT_MM = 0.25;
// At or below the CAD default the line stays in the 1-px hairline batch: a