forked from kimminsung/dwg-dxf-viewer-sample
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:
1 parent
a924b1ec34
commit
1f80442f6c
8 files changed
+6310
-102
No files matched your search
+77
-35
@@ -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';
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user