From fc65328ed2c3b6a7c12b637f8f81c9e9213c4306 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:39:00 +0800 Subject: [PATCH 1/2] feat: add non-destructive monochrome color rendering --- .github/workflows/ci.yml | 1 + scripts/validate-monochrome.mjs | 85 +++++ src/render/monochrome.ts | 217 +++++++++++++ src/viewer/DwfViewer.ts | 548 ++++++-------------------------- src/viewer/DwfViewerBase.ts | 484 ++++++++++++++++++++++++++++ 5 files changed, 881 insertions(+), 454 deletions(-) create mode 100644 scripts/validate-monochrome.mjs create mode 100644 src/render/monochrome.ts create mode 100644 src/viewer/DwfViewerBase.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49c809e..907bc99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: cache: npm - run: npm ci - run: npm run build + - run: node scripts/validate-monochrome.mjs - run: npm run validate:production - run: npm run check:examples - run: npm run build:demo diff --git a/scripts/validate-monochrome.mjs b/scripts/validate-monochrome.mjs new file mode 100644 index 0000000..e332928 --- /dev/null +++ b/scripts/validate-monochrome.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { + applyXpsMonochromeXml, + createMonochromePage, + monochromeColorWithAlpha, + normalizeMonochromeColor +} from '../dist/render/monochrome.js'; + +assert.equal(normalizeMonochromeColor('#000'), 'rgb(0, 0, 0)'); +assert.equal(normalizeMonochromeColor('not-a-color'), undefined); +assert.equal(monochromeColorWithAlpha('#000000', '#80ff0000'), 'rgba(0, 0, 0, 0.501961)'); + +const w2d = { + id: 'page-1', + name: 'Sheet 1', + kind: 'w2d-text', + sourcePath: 'sheet.w2d', + width: 100, + height: 100, + diagnostics: [], + primitives: [ + { type: 'polyline', points: [0, 0, 10, 10] }, + { type: 'polygon', points: [0, 0, 10, 0, 10, 10], fill: '#4000ff00' }, + { type: 'text', x: 1, y: 2, text: 'A', size: 12 }, + { type: 'path', commands: [], stroke: 'Transparent' } + ] +}; +const w2dMono = createMonochromePage(w2d, '#000000'); +assert.notEqual(w2dMono, w2d); +assert.equal(w2d.primitives[0].stroke, undefined, 'source page must not be mutated'); +assert.equal(w2dMono.primitives[0].stroke, 'rgb(0, 0, 0)'); +assert.equal(w2dMono.primitives[1].fill, 'rgba(0, 0, 0, 0.25098)'); +assert.equal(w2dMono.primitives[1].stroke, undefined, 'monochrome must not add polygon outlines'); +assert.equal(w2dMono.primitives[2].fill, 'rgb(0, 0, 0)'); +assert.equal(w2dMono.primitives[3].stroke, 'rgba(0, 0, 0, 0)'); +assert.equal(createMonochromePage(w2d, '#000000'), w2dMono, 'transformed pages should be cached'); + +const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); +const indices = new Uint16Array([0, 1, 2]); +const w3d = { + id: 'page-2', + name: 'Model', + kind: 'w3d-model', + sourcePath: 'model.w3d', + width: 100, + height: 100, + diagnostics: [], + model: { + kind: 'w3d-model', + title: 'Model', + meshes: [{ + id: 'mesh-1', + name: 'Mesh', + positions, + indices, + vertexCount: 3, + triangleCount: 1, + bounds: { min: [0, 0, 0], max: [1, 1, 0] }, + color: [1, 0, 0], + sourceStart: 0, + sourceEnd: 1, + decodeKind: 'uncompressed' + }], + materials: [{ id: 'material-1', color: [1, 0, 0], opacity: 0.35 }], + bounds: { min: [0, 0, 0], max: [1, 1, 0], center: [0.5, 0.5, 0], radius: 1 }, + stats: { meshCount: 1, vertexCount: 3, triangleCount: 1, decodedBytes: positions.byteLength }, + diagnostics: [] + } +}; +const w3dMono = createMonochromePage(w3d, '#ffffff'); +assert.deepEqual(w3dMono.model.meshes[0].color, [1, 1, 1]); +assert.equal(w3dMono.model.meshes[0].positions, positions, 'geometry buffers must be shared'); +assert.equal(w3dMono.model.materials[0].opacity, 0.35, 'material opacity must be preserved'); +assert.deepEqual(w3d.model.meshes[0].color, [1, 0, 0], 'source model must not be mutated'); + +const xps = ''; +const xpsMono = applyXpsMonochromeXml(xps, '#000000'); +assert.match(xpsMono, /Fill="rgba\(0, 0, 0, 0\.501961\)"/); +assert.match(xpsMono, /Stroke="\{StaticResource Pen\}"/); +assert.match(xpsMono, /Color="rgb\(0, 0, 0\)"/); +assert.match(xpsMono, /Color="rgba\(0, 0, 0, 0\.25\)"/); +assert.match(xpsMono, /Opacity="0\.4"/); +assert.match(xpsMono, /ImageSource="Resources\/image\.png"/); + +console.log('Monochrome color validation passed.'); diff --git a/src/render/monochrome.ts b/src/render/monochrome.ts new file mode 100644 index 0000000..acddce7 --- /dev/null +++ b/src/render/monochrome.ts @@ -0,0 +1,217 @@ +import type { PageData, W2dPrimitive } from '../format/document.js'; + +interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} + +const pageCache = new WeakMap>(); + +const NAMED_COLORS: Record = { + black: { r: 0, g: 0, b: 0, a: 1 }, + white: { r: 255, g: 255, b: 255, a: 1 }, + red: { r: 255, g: 0, b: 0, a: 1 }, + green: { r: 0, g: 128, b: 0, a: 1 }, + blue: { r: 0, g: 0, b: 255, a: 1 }, + yellow: { r: 255, g: 255, b: 0, a: 1 }, + cyan: { r: 0, g: 255, b: 255, a: 1 }, + magenta: { r: 255, g: 0, b: 255, a: 1 }, + gray: { r: 128, g: 128, b: 128, a: 1 }, + grey: { r: 128, g: 128, b: 128, a: 1 }, + transparent: { r: 0, g: 0, b: 0, a: 0 } +}; + +/** + * Normalizes the supported fixed color forms to one renderer-safe CSS value. + * Empty or non-color values disable the monochrome override. + */ +export function normalizeMonochromeColor(value: string | undefined): string | undefined { + if (typeof value !== 'string') return undefined; + const parsed = parseColor(value); + return parsed ? toCssColor(parsed) : undefined; +} + +/** + * Returns a page view with vector/text/material colors replaced while keeping + * geometry, line weights, source diagnostics, images and source objects intact. + */ +export function createMonochromePage(page: PageData, color: string): PageData { + const normalized = normalizeMonochromeColor(color); + if (!normalized) return page; + if (page.kind !== 'w2d-text' && page.kind !== 'w3d-model') return page; + + let colors = pageCache.get(page); + if (!colors) { + colors = new Map(); + pageCache.set(page, colors); + } + const cached = colors.get(normalized); + if (cached) return cached; + + let transformed: PageData; + if (page.kind === 'w2d-text') { + transformed = { + ...page, + primitives: page.primitives.map((primitive) => monochromePrimitive(primitive, normalized)) + }; + } else { + const target = parseColor(normalized) ?? NAMED_COLORS.black; + const rgb = [target.r / 255, target.g / 255, target.b / 255] as [number, number, number]; + transformed = { + ...page, + model: { + ...page.model, + meshes: page.model.meshes.map((mesh) => ({ + ...mesh, + color: [rgb[0], rgb[1], rgb[2]] + })), + materials: page.model.materials?.map((material) => ({ + ...material, + color: [rgb[0], rgb[1], rgb[2]] + })) + } + }; + } + + colors.set(normalized, transformed); + return transformed; +} + +/** + * Rewrites only direct XPS color attributes. Resource references and image + * brushes are left untouched; referenced SolidColorBrush/GradientStop colors + * are rewritten when their XML resource part is read. + */ +export function applyXpsMonochromeXml(xml: string, color: string): string { + const normalized = normalizeMonochromeColor(color); + if (!normalized || !xml) return xml; + return xml.replace( + /(\b(?:Fill|Stroke|Color)\s*=\s*)(["'])([^"']*)\2/gi, + (match, prefix: string, quote: string, source: string) => { + if (!parseColor(source)) return match; + return `${prefix}${quote}${monochromeColorWithAlpha(normalized, source)}${quote}`; + } + ); +} + +/** Preserves source brush alpha and combines it with an optional target alpha. */ +export function monochromeColorWithAlpha(color: string, sourceColor?: string): string { + const target = parseColor(color) ?? NAMED_COLORS.black; + const source = sourceColor ? parseColor(sourceColor) : undefined; + return toCssColor({ + r: target.r, + g: target.g, + b: target.b, + a: clamp01(target.a * (source?.a ?? 1)) + }); +} + +function monochromePrimitive(primitive: W2dPrimitive, color: string): W2dPrimitive { + const next = { ...primitive } as W2dPrimitive; + if (primitive.stroke !== undefined) next.stroke = monochromeColorWithAlpha(color, primitive.stroke); + if (primitive.fill !== undefined) next.fill = monochromeColorWithAlpha(color, primitive.fill); + + // W2D polylines and text have an implicit black fallback in the renderer. + // Materialize that fallback so a white/custom monochrome target works too. + if (primitive.type === 'polyline' && primitive.stroke === undefined) next.stroke = color; + if (primitive.type === 'text' && primitive.stroke === undefined && primitive.fill === undefined) next.fill = color; + return next; +} + +function parseColor(value: string): RgbaColor | undefined { + const source = value.trim(); + if (!source) return undefined; + const named = NAMED_COLORS[source.toLowerCase()]; + if (named) return { ...named }; + + if (/^#[0-9a-f]{3}$/i.test(source)) { + return { + r: parseInt(source[1]! + source[1]!, 16), + g: parseInt(source[2]! + source[2]!, 16), + b: parseInt(source[3]! + source[3]!, 16), + a: 1 + }; + } + if (/^#[0-9a-f]{4}$/i.test(source)) { + return { + r: parseInt(source[1]! + source[1]!, 16), + g: parseInt(source[2]! + source[2]!, 16), + b: parseInt(source[3]! + source[3]!, 16), + a: parseInt(source[4]! + source[4]!, 16) / 255 + }; + } + if (/^#[0-9a-f]{6}$/i.test(source)) { + return { + r: parseInt(source.slice(1, 3), 16), + g: parseInt(source.slice(3, 5), 16), + b: parseInt(source.slice(5, 7), 16), + a: 1 + }; + } + if (/^#[0-9a-f]{8}$/i.test(source)) { + // FixedPage/XPS stores eight-digit colors as #AARRGGBB. + return { + r: parseInt(source.slice(3, 5), 16), + g: parseInt(source.slice(5, 7), 16), + b: parseInt(source.slice(7, 9), 16), + a: parseInt(source.slice(1, 3), 16) / 255 + }; + } + + if (/^sc#/i.test(source)) { + const values = source.slice(3).split(',').map((item) => Number(item.trim())); + if (values.length === 3 && values.every(Number.isFinite)) { + return { r: values[0]! * 255, g: values[1]! * 255, b: values[2]! * 255, a: 1 }; + } + if (values.length === 4 && values.every(Number.isFinite)) { + return { r: values[1]! * 255, g: values[2]! * 255, b: values[3]! * 255, a: clamp01(values[0]!) }; + } + } + + const rgb = source.match(/^rgba?\(([^)]+)\)$/i); + if (rgb) { + const parts = rgb[1]!.split(',').map((item) => item.trim()); + if (parts.length === 3 || parts.length === 4) { + const channels = parts.slice(0, 3).map(parseChannel); + const alpha = parts[3] === undefined ? 1 : parseAlpha(parts[3]); + if (channels.every((item): item is number => item !== undefined) && alpha !== undefined) { + return { r: channels[0], g: channels[1], b: channels[2], a: alpha }; + } + } + } + return undefined; +} + +function parseChannel(value: string): number | undefined { + const percent = value.endsWith('%'); + const number = Number(percent ? value.slice(0, -1) : value); + if (!Number.isFinite(number)) return undefined; + return clamp255(percent ? number * 2.55 : number); +} + +function parseAlpha(value: string): number | undefined { + const percent = value.endsWith('%'); + const number = Number(percent ? value.slice(0, -1) : value); + if (!Number.isFinite(number)) return undefined; + return clamp01(percent ? number / 100 : number); +} + +function toCssColor(color: RgbaColor): string { + const r = clamp255(color.r); + const g = clamp255(color.g); + const b = clamp255(color.b); + const a = clamp01(color.a); + return a >= 0.999999 + ? `rgb(${r}, ${g}, ${b})` + : `rgba(${r}, ${g}, ${b}, ${Math.round(a * 1_000_000) / 1_000_000})`; +} + +function clamp255(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} diff --git a/src/viewer/DwfViewer.ts b/src/viewer/DwfViewer.ts index aa1ecda..d29da76 100644 --- a/src/viewer/DwfViewer.ts +++ b/src/viewer/DwfViewer.ts @@ -1,484 +1,124 @@ -import { openDwfDocument } from '../format/open.js'; -import type { LoadedDwfDocument, W3dSceneNodeData } from '../format/document.js'; -import type { Matrix2D } from '../render/style.js'; -import { transformPoint } from '../render/style.js'; -import { fitPageMatrix, matrixForW2d } from '../render/viewport.js'; -import type { PageRenderOptions, RenderStats } from '../format/types.js'; -import { PageRenderer } from '../render/PageRenderer.js'; +import { DwfViewer as BaseDwfViewer } from './DwfViewerBase.js'; +import type { + DwfViewerOptions as BaseDwfViewerOptions, + LoadOptions as BaseLoadOptions +} from './DwfViewerBase.js'; +import type { LoadedDwfDocument } from '../format/document.js'; +import type { RenderStats } from '../format/types.js'; +import type { PageRenderer } from '../render/PageRenderer.js'; +import { + applyXpsMonochromeXml, + createMonochromePage, + normalizeMonochromeColor +} from '../render/monochrome.js'; + +export interface DwfViewerOptions extends BaseDwfViewerOptions { + /** + * Overrides vector, text and model-material colors with one fixed color. + * Images, page background, geometry, line weights and opacity are preserved. + * Omit the value to render source colors. + */ + monochromeColor?: string; +} -export interface DwfViewerOptions { - wasmUrl?: string; - lineWeightMode?: 'adaptive' | 'physical' | 'hairline'; - minStrokeCssPx?: number; - maxOverviewStrokeCssPx?: number; - minTextCssPx?: number; - minFilledAreaCssPx?: number; - preferWebgl?: boolean; - preferWasm?: boolean; - background?: string; - maxDevicePixelRatio?: number; - maxCanvasPixels?: number; - maxGpuCacheBytes?: number; - maxCachedScenes?: number; +export interface LoadOptions extends BaseLoadOptions { + /** Per-load override for the viewer's monochrome color. */ + monochromeColor?: string; } -export interface LoadOptions extends PageRenderOptions { - fileName?: string; +interface DwfViewerInternals { + doc?: LoadedDwfDocument; + renderer?: PageRenderer; + pageIndex: number; + rendering: boolean; + requestRender(): void; } -export class DwfViewer { - readonly root: HTMLDivElement; - readonly canvas: HTMLCanvasElement; - readonly webglCanvas: HTMLCanvasElement; - readonly pageSelect: HTMLSelectElement; - readonly status: HTMLSpanElement; - readonly treePanel: HTMLDivElement; - private readonly zoomInButton: HTMLButtonElement; - private readonly zoomOutButton: HTMLButtonElement; - private readonly resetButton: HTMLButtonElement; - private doc?: LoadedDwfDocument; - private renderer?: PageRenderer; - private pageIndex = 0; - private zoom = 1; - private panX = 0; - private panY = 0; - private preferWebgl: boolean; - private preferWasm: boolean; - private wasmUrl?: string; - private background: string; - private maxDevicePixelRatio: number; - private maxCanvasPixels: number; - private maxGpuCacheBytes?: number; - private maxCachedScenes?: number; - private lineWeightMode: 'adaptive' | 'physical' | 'hairline'; - private minStrokeCssPx?: number; - private maxOverviewStrokeCssPx?: number; - private minTextCssPx?: number; - private minFilledAreaCssPx?: number; - private drag?: { x: number; y: number; panX: number; panY: number; yaw: number; pitch: number; mode: 'pan2d' | 'rotate3d' | 'pan3d' }; - private yaw = -0.78; - private pitch = 0.55; - private pendingRender?: Promise; - private rendering = false; - private renderAgain = false; - private renderRaf = 0; - private renderSeq = 0; - private currentDpr = 1; +/** + * Adds a non-destructive monochrome plot-style view on top of the native DWF + * renderer. The parsed document remains source-colored for metadata consumers. + */ +export class DwfViewer extends BaseDwfViewer { + private monochromeColor?: string; + private preparedRenderer?: PageRenderer; + private preparedColor?: string; constructor(container: HTMLElement, options: DwfViewerOptions = {}) { - this.preferWebgl = options.preferWebgl ?? true; - this.preferWasm = options.preferWasm ?? true; - this.wasmUrl = options.wasmUrl; - this.background = options.background ?? '#ffffff'; - this.maxDevicePixelRatio = options.maxDevicePixelRatio ?? 2; - this.maxCanvasPixels = options.maxCanvasPixels ?? 16_777_216; - this.maxGpuCacheBytes = options.maxGpuCacheBytes; - this.maxCachedScenes = options.maxCachedScenes; - this.lineWeightMode = options.lineWeightMode ?? 'adaptive'; - this.minStrokeCssPx = options.minStrokeCssPx; - this.maxOverviewStrokeCssPx = options.maxOverviewStrokeCssPx; - this.minTextCssPx = options.minTextCssPx; - this.minFilledAreaCssPx = options.minFilledAreaCssPx; - - this.root = document.createElement('div'); - this.root.className = 'dwfv-root'; - const toolbar = document.createElement('div'); - toolbar.className = 'dwfv-toolbar'; - this.pageSelect = document.createElement('select'); - this.zoomOutButton = button('−'); - this.zoomInButton = button('+'); - this.resetButton = button('适应'); - this.status = document.createElement('span'); - this.status.className = 'dwfv-status'; - toolbar.append('页: ', this.pageSelect, this.zoomOutButton, this.zoomInButton, this.resetButton, this.status); - - const workspace = document.createElement('div'); - workspace.className = 'dwfv-workspace'; - this.treePanel = document.createElement('div'); - this.treePanel.className = 'dwfv-tree'; - this.treePanel.style.display = 'none'; - const stage = document.createElement('div'); - stage.className = 'dwfv-stage'; - this.webglCanvas = document.createElement('canvas'); - this.webglCanvas.className = 'dwfv-canvas dwfv-webgl-canvas'; - this.webglCanvas.style.visibility = 'hidden'; - this.canvas = document.createElement('canvas'); - this.canvas.className = 'dwfv-canvas dwfv-overlay-canvas'; - this.canvas.style.touchAction = 'none'; - stage.append(this.webglCanvas, this.canvas); - workspace.append(this.treePanel, stage); - this.root.append(toolbar, workspace); - container.replaceChildren(this.root); - - this.pageSelect.addEventListener('change', () => { this.pageIndex = this.pageSelect.selectedIndex; this.resetView(); this.populateModelTree(); this.requestRender(); }); - this.zoomOutButton.addEventListener('click', () => { this.zoomAtCenter(0.8); this.requestRender(); }); - this.zoomInButton.addEventListener('click', () => { this.zoomAtCenter(1.25); this.requestRender(); }); - this.resetButton.addEventListener('click', () => { this.resetView(); this.requestRender(); }); - this.canvas.addEventListener('wheel', (e) => this.onWheel(e), { passive: false }); - this.canvas.addEventListener('pointerdown', (e) => this.onPointerDown(e)); - window.addEventListener('pointermove', (e) => this.onPointerMove(e)); - window.addEventListener('pointerup', () => { this.drag = undefined; }); - this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); - - new ResizeObserver(() => this.requestRender()).observe(stage); - this.setStatus('选择 .dwf/.dwfx 文件'); - } - - setPreferWebgl(value: boolean): void { - this.preferWebgl = value; - this.requestRender(); - } - - setPreferWasm(value: boolean): void { - this.preferWasm = value; - this.requestRender(); - } - - setLineWeightMode(value: 'adaptive' | 'physical' | 'hairline'): void { - this.lineWeightMode = value; - this.renderer?.dispose(); - this.requestRender(); - } - - async load(input: ArrayBuffer | Uint8Array | Blob | File, options: LoadOptions = {}): Promise { - this.setStatus('解析文件中…'); - this.renderer?.dispose(); - this.doc = await openDwfDocument(input, { fileName: options.fileName }); - this.renderer = new PageRenderer(this.doc); - this.pageIndex = options.pageIndex ?? 0; - this.zoom = 1; - this.panX = this.panY = 0; - this.yaw = -0.78; - this.pitch = 0.55; - this.preferWebgl = options.preferWebgl ?? this.preferWebgl; - this.preferWasm = options.preferWasm ?? this.preferWasm; - this.wasmUrl = options.wasmUrl ?? this.wasmUrl; - this.background = options.background ?? this.background; - this.maxGpuCacheBytes = options.maxGpuCacheBytes ?? this.maxGpuCacheBytes; - this.maxCachedScenes = options.maxCachedScenes ?? this.maxCachedScenes; - this.lineWeightMode = options.lineWeightMode ?? this.lineWeightMode; - this.minStrokeCssPx = options.minStrokeCssPx ?? this.minStrokeCssPx; - this.maxOverviewStrokeCssPx = options.maxOverviewStrokeCssPx ?? this.maxOverviewStrokeCssPx; - this.minTextCssPx = options.minTextCssPx ?? this.minTextCssPx; - this.minFilledAreaCssPx = options.minFilledAreaCssPx ?? this.minFilledAreaCssPx; - this.populatePages(); - this.populateModelTree(); - await this.render(); - } - - async render(): Promise { - if (!this.renderer || !this.doc) return undefined; - if (this.rendering) { - this.renderAgain = true; - return this.pendingRender; - } - this.rendering = true; - try { - this.resizeCanvasToDisplaySize(); - const page = this.doc.pageData[this.pageIndex]; - if (!page) return undefined; - const seq = ++this.renderSeq; - const task = this.renderer.render(this.pageIndex, this.canvas, { - zoom: this.zoom, - panX: this.panX, - panY: this.panY, - preferWebgl: this.preferWebgl, - preferWasm: this.preferWasm, - wasmUrl: this.wasmUrl, - background: this.background, - maxGpuCacheBytes: this.maxGpuCacheBytes, - maxCachedScenes: this.maxCachedScenes, - webglCanvas: this.webglCanvas, - yaw: this.yaw, - pitch: this.pitch, - lineWeightMode: this.lineWeightMode, - minStrokeCssPx: this.minStrokeCssPx, - maxOverviewStrokeCssPx: this.maxOverviewStrokeCssPx, - minTextCssPx: this.minTextCssPx, - minFilledAreaCssPx: this.minFilledAreaCssPx - }); - this.pendingRender = task; - try { - const stats = await task; - if (this.pendingRender === task && seq === this.renderSeq) { - const warnCount = stats.warnings.filter(w => w.level !== 'info').length; - const dprText = this.currentDpr > 1 ? ` · DPR ${this.currentDpr.toFixed(2)}` : ''; - this.setStatus(`${this.doc.kind.toUpperCase()} · ${page.kind} · ${stats.backend} · ${stats.commands} ops${dprText}${warnCount ? ` · ${warnCount} 警告` : ''}`, warnCount > 0); - } - return stats; - } catch (err) { - if (seq === this.renderSeq) this.setStatus(`渲染失败:${String(err)}`, true); - throw err; - } - } finally { - this.rendering = false; - if (this.renderAgain) { - this.renderAgain = false; - this.requestRender(); - } - } - } - - getDocument(): LoadedDwfDocument | undefined { - return this.doc; - } - - fit(): void { - this.resetView(); - this.requestRender(); - } - - dispose(): void { - if (this.renderRaf) cancelAnimationFrame(this.renderRaf); - this.renderRaf = 0; - this.renderer?.dispose(); - this.renderer = undefined; - this.doc = undefined; - this.root.replaceChildren(); - } - - private requestRender(): void { - if (this.renderRaf) return; - this.renderRaf = requestAnimationFrame(() => { - this.renderRaf = 0; - void this.render(); - }); + super(container, options); + this.monochromeColor = normalizeMonochromeColor(options.monochromeColor); } - private populatePages(): void { - this.pageSelect.replaceChildren(); - const pages = this.doc?.pageData ?? []; - for (const [i, p] of pages.entries()) { - const opt = document.createElement('option'); - opt.value = String(i); - opt.textContent = `${i + 1}. ${p.name} (${p.kind})`; - this.pageSelect.append(opt); + override async load( + input: ArrayBuffer | Uint8Array | Blob | File, + options: LoadOptions = {} + ): Promise { + if (Object.prototype.hasOwnProperty.call(options, 'monochromeColor')) { + this.updateMonochromeColor(options.monochromeColor, false); } - this.pageSelect.selectedIndex = Math.max(0, Math.min(this.pageIndex, pages.length - 1)); + await super.load(input, options); } - - private populateModelTree(): void { - const page = this.doc?.pageData[this.pageIndex]; - if (!page || page.kind !== 'w3d-model' || !(page.model.sceneTree?.length)) { - this.treePanel.style.display = 'none'; - this.treePanel.replaceChildren(); - return; - } - this.treePanel.style.display = ''; - const header = document.createElement('div'); - header.className = 'dwfv-tree-header'; - header.textContent = `模型结构 · ${page.model.stats.nodeCount ?? 0} 节点`; - const stats = document.createElement('div'); - stats.className = 'dwfv-tree-stats'; - stats.textContent = `${page.model.stats.meshCount} meshes · ${page.model.stats.triangleCount} triangles · ${(page.model.stats.textureCount ?? 0)} textures`; - const content = document.createElement('div'); - content.className = 'dwfv-tree-content'; - for (const node of page.model.sceneTree) content.append(renderTreeNode(node)); - this.treePanel.replaceChildren(header, stats, content); + /** Switches between source colors (undefined) and a fixed plot color. */ + setMonochromeColor(value?: string): void { + this.updateMonochromeColor(value, true); } - private resizeCanvasToDisplaySize(): void { - const rect = this.canvas.getBoundingClientRect(); - const cssW = Math.max(1, rect.width); - const cssH = Math.max(1, rect.height); - let dpr = Math.max(1, Math.min(this.maxDevicePixelRatio, window.devicePixelRatio || 1)); - const pixels = cssW * cssH * dpr * dpr; - if (pixels > this.maxCanvasPixels) dpr *= Math.sqrt(this.maxCanvasPixels / pixels); - this.currentDpr = dpr; - const w = Math.max(1, Math.floor(cssW * dpr)); - const h = Math.max(1, Math.floor(cssH * dpr)); - if (this.canvas.width !== w || this.canvas.height !== h) { - this.canvas.width = w; - this.canvas.height = h; - } - if (this.webglCanvas.width !== w || this.webglCanvas.height !== h) { - this.webglCanvas.width = w; - this.webglCanvas.height = h; - } + getMonochromeColor(): string | undefined { + return this.monochromeColor; } - private resetView(): void { - this.zoom = 1; - this.panX = 0; - this.panY = 0; - this.yaw = -0.78; - this.pitch = 0.55; - const page = this.doc?.pageData[this.pageIndex]; - if (page?.kind === 'w3d-model') { - const cam = page.model.initialView?.camera; - const pos = cam?.position; - const target = cam?.target; - if (pos && target) { - const dx = pos[0] - target[0]; - const dy = pos[1] - target[1]; - const dz = pos[2] - target[2]; - const dist = Math.hypot(dx, dy, dz); - if (dist > 1e-6) { - this.pitch = Math.max(-1.45, Math.min(1.45, Math.asin(dy / dist))); - this.yaw = Math.atan2(dx, dz); - const radius = Math.max(1e-6, page.model.bounds.radius); - this.zoom = Math.max(0.05, Math.min(100, radius * 2.55 / dist)); - } - } - } - } + override async render(): Promise { + const internals = this as unknown as DwfViewerInternals; - private zoomAtCenter(factor: number): void { - this.resizeCanvasToDisplaySize(); - if (this.is3dPage()) { - this.zoom = Math.max(0.05, Math.min(100, this.zoom * factor)); - return; - } - const cx = this.canvas.width / 2; - const cy = this.canvas.height / 2; - this.zoomAroundPoint(factor, cx, cy); - } - - private zoomAroundPoint(factor: number, cx: number, cy: number): void { - const oldZoom = this.zoom; - const nextZoom = Math.max(0.05, Math.min(64, oldZoom * factor)); - if (nextZoom === oldZoom) return; + // Let the base viewer coalesce a concurrent request. Mutating pageData while + // an existing render is active would otherwise make cache invalidation race. + if (internals.rendering) return super.render(); - // Keep the drawing coordinate under the cursor fixed. A simple - // `pan = cursor - (cursor - pan) * ratio` is wrong for fit-to-page - // transforms, because the fit center also changes with zoom. - const anchoredPoint = this.pagePointAtCanvasPoint(cx, cy, oldZoom, this.panX, this.panY); - if (anchoredPoint) { - const baseMatrix = this.pageMatrixAt(nextZoom, 0, 0); - if (baseMatrix) { - const [sx, sy] = transformPoint(baseMatrix, anchoredPoint.x, anchoredPoint.y); - this.panX = cx - sx; - this.panY = cy - sy; - this.zoom = nextZoom; - return; + const renderer = internals.renderer; + const color = this.monochromeColor; + if (renderer) { + if (this.preparedRenderer === renderer && this.preparedColor !== color) { + renderer.dispose(); } + this.preparedRenderer = renderer; + this.preparedColor = color; } - // Generic fallback for unsupported page kinds. - const ratio = nextZoom / oldZoom; - this.panX = cx - (cx - this.panX) * ratio; - this.panY = cy - (cy - this.panY) * ratio; - this.zoom = nextZoom; - } - - private onWheel(e: WheelEvent): void { - if (!this.doc) return; - e.preventDefault(); - this.resizeCanvasToDisplaySize(); - const rect = this.canvas.getBoundingClientRect(); - const cx = (e.clientX - rect.left) * this.currentDpr; - const cy = (e.clientY - rect.top) * this.currentDpr; - const delta = e.deltaMode === WheelEvent.DOM_DELTA_LINE - ? e.deltaY * 16 - : e.deltaMode === WheelEvent.DOM_DELTA_PAGE - ? e.deltaY * Math.max(1, rect.height) - : e.deltaY; - const factor = Math.exp(-delta * 0.0015); - if (this.is3dPage()) this.zoom = Math.max(0.05, Math.min(100, this.zoom * factor)); - else this.zoomAroundPoint(factor, cx, cy); - this.requestRender(); - } - - private onPointerDown(e: PointerEvent): void { - this.canvas.setPointerCapture?.(e.pointerId); - if (this.is3dPage()) e.preventDefault(); - const mode = this.is3dPage() ? ((e.button === 2 || e.shiftKey) ? 'pan3d' : 'rotate3d') : 'pan2d'; - this.drag = { x: e.clientX, y: e.clientY, panX: this.panX, panY: this.panY, yaw: this.yaw, pitch: this.pitch, mode }; - } - - private onPointerMove(e: PointerEvent): void { - if (!this.drag) return; - const dx = e.clientX - this.drag.x; - const dy = e.clientY - this.drag.y; - if (this.drag.mode === 'rotate3d') { - this.yaw = this.drag.yaw + dx * 0.008; - this.pitch = Math.max(-1.45, Math.min(1.45, this.drag.pitch + dy * 0.008)); - } else { - this.panX = this.drag.panX + dx * this.currentDpr; - this.panY = this.drag.panY + dy * this.currentDpr; + const doc = internals.doc; + const pageIndex = internals.pageIndex; + const sourcePage = doc?.pageData[pageIndex]; + if (!color || !doc || !sourcePage) return super.render(); + + const renderedPage = createMonochromePage(sourcePage, color); + doc.pageData[pageIndex] = renderedPage; + + const opc = doc.opc; + const sourceReadText = opc?.readText; + if (opc && sourceReadText) { + opc.readText = async (path: string) => { + const xml = await sourceReadText.call(opc, path); + return applyXpsMonochromeXml(xml, color); + }; } - this.requestRender(); - } - - - private is3dPage(): boolean { - const page = this.doc?.pageData[this.pageIndex]; - return page?.kind === 'w3d-model'; - } - - private pagePointAtCanvasPoint(cx: number, cy: number, zoom: number, panX: number, panY: number): { x: number; y: number } | undefined { - const m = this.pageMatrixAt(zoom, panX, panY); - if (!m) return undefined; - const det = m.a * m.d - m.b * m.c; - if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return undefined; - const dx = cx - m.e; - const dy = cy - m.f; - return { - x: (m.d * dx - m.c * dy) / det, - y: (-m.b * dx + m.a * dy) / det - }; - } - private pageMatrixAt(zoom: number, panX: number, panY: number): Matrix2D | undefined { - const page = this.doc?.pageData[this.pageIndex]; - if (!page) return undefined; - const canvasWidth = Math.max(1, this.canvas.width); - const canvasHeight = Math.max(1, this.canvas.height); - if (page.kind === 'w2d-text') return matrixForW2d(page, canvasWidth, canvasHeight, zoom, panX, panY); - if (page.kind === 'xps-fixed-page') { - return fitPageMatrix({ - canvasWidth, - canvasHeight, - pageWidth: Math.max(1, page.width), - pageHeight: Math.max(1, page.height), - zoom, - panX, - panY - }); - } - if (page.kind === 'image') { - return fitPageMatrix({ - canvasWidth, - canvasHeight, - pageWidth: Math.max(1, page.width), - pageHeight: Math.max(1, page.height), - zoom, - panX, - panY, - margin: 0 - }); + try { + return await super.render(); + } finally { + if (doc.pageData[pageIndex] === renderedPage) doc.pageData[pageIndex] = sourcePage; + if (opc && sourceReadText) opc.readText = sourceReadText; } - return undefined; } - private setStatus(text: string, warn = false): void { - this.status.textContent = text; - this.status.classList.toggle('dwfv-warn', warn); + override dispose(): void { + this.preparedRenderer = undefined; + this.preparedColor = undefined; + super.dispose(); } -} - -function button(text: string): HTMLButtonElement { - const b = document.createElement('button'); - b.type = 'button'; - b.textContent = text; - return b; -} - -function renderTreeNode(node: W3dSceneNodeData): HTMLElement { - const details = document.createElement('details'); - details.open = node.children.length > 0 && node.children.length < 20; - const summary = document.createElement('summary'); - summary.textContent = node.label || node.id; - if (node.meshIds.length > 0) summary.title = `${node.meshIds.length} mesh(es)`; - details.append(summary); - if (node.contentRefs.length > 0) { - const meta = document.createElement('div'); - meta.className = 'dwfv-tree-meta'; - meta.textContent = node.contentRefs.slice(0, 3).join(', '); - details.append(meta); + private updateMonochromeColor(value: string | undefined, requestRender: boolean): void { + const normalized = normalizeMonochromeColor(value); + if (normalized === this.monochromeColor) return; + this.monochromeColor = normalized; + if (requestRender) (this as unknown as DwfViewerInternals).requestRender(); } - for (const child of node.children) details.append(renderTreeNode(child)); - return details; } diff --git a/src/viewer/DwfViewerBase.ts b/src/viewer/DwfViewerBase.ts new file mode 100644 index 0000000..aa1ecda --- /dev/null +++ b/src/viewer/DwfViewerBase.ts @@ -0,0 +1,484 @@ +import { openDwfDocument } from '../format/open.js'; +import type { LoadedDwfDocument, W3dSceneNodeData } from '../format/document.js'; +import type { Matrix2D } from '../render/style.js'; +import { transformPoint } from '../render/style.js'; +import { fitPageMatrix, matrixForW2d } from '../render/viewport.js'; +import type { PageRenderOptions, RenderStats } from '../format/types.js'; +import { PageRenderer } from '../render/PageRenderer.js'; + +export interface DwfViewerOptions { + wasmUrl?: string; + lineWeightMode?: 'adaptive' | 'physical' | 'hairline'; + minStrokeCssPx?: number; + maxOverviewStrokeCssPx?: number; + minTextCssPx?: number; + minFilledAreaCssPx?: number; + preferWebgl?: boolean; + preferWasm?: boolean; + background?: string; + maxDevicePixelRatio?: number; + maxCanvasPixels?: number; + maxGpuCacheBytes?: number; + maxCachedScenes?: number; +} + +export interface LoadOptions extends PageRenderOptions { + fileName?: string; +} + +export class DwfViewer { + readonly root: HTMLDivElement; + readonly canvas: HTMLCanvasElement; + readonly webglCanvas: HTMLCanvasElement; + readonly pageSelect: HTMLSelectElement; + readonly status: HTMLSpanElement; + readonly treePanel: HTMLDivElement; + private readonly zoomInButton: HTMLButtonElement; + private readonly zoomOutButton: HTMLButtonElement; + private readonly resetButton: HTMLButtonElement; + private doc?: LoadedDwfDocument; + private renderer?: PageRenderer; + private pageIndex = 0; + private zoom = 1; + private panX = 0; + private panY = 0; + private preferWebgl: boolean; + private preferWasm: boolean; + private wasmUrl?: string; + private background: string; + private maxDevicePixelRatio: number; + private maxCanvasPixels: number; + private maxGpuCacheBytes?: number; + private maxCachedScenes?: number; + private lineWeightMode: 'adaptive' | 'physical' | 'hairline'; + private minStrokeCssPx?: number; + private maxOverviewStrokeCssPx?: number; + private minTextCssPx?: number; + private minFilledAreaCssPx?: number; + private drag?: { x: number; y: number; panX: number; panY: number; yaw: number; pitch: number; mode: 'pan2d' | 'rotate3d' | 'pan3d' }; + private yaw = -0.78; + private pitch = 0.55; + private pendingRender?: Promise; + private rendering = false; + private renderAgain = false; + private renderRaf = 0; + private renderSeq = 0; + private currentDpr = 1; + + constructor(container: HTMLElement, options: DwfViewerOptions = {}) { + this.preferWebgl = options.preferWebgl ?? true; + this.preferWasm = options.preferWasm ?? true; + this.wasmUrl = options.wasmUrl; + this.background = options.background ?? '#ffffff'; + this.maxDevicePixelRatio = options.maxDevicePixelRatio ?? 2; + this.maxCanvasPixels = options.maxCanvasPixels ?? 16_777_216; + this.maxGpuCacheBytes = options.maxGpuCacheBytes; + this.maxCachedScenes = options.maxCachedScenes; + this.lineWeightMode = options.lineWeightMode ?? 'adaptive'; + this.minStrokeCssPx = options.minStrokeCssPx; + this.maxOverviewStrokeCssPx = options.maxOverviewStrokeCssPx; + this.minTextCssPx = options.minTextCssPx; + this.minFilledAreaCssPx = options.minFilledAreaCssPx; + + this.root = document.createElement('div'); + this.root.className = 'dwfv-root'; + const toolbar = document.createElement('div'); + toolbar.className = 'dwfv-toolbar'; + this.pageSelect = document.createElement('select'); + this.zoomOutButton = button('−'); + this.zoomInButton = button('+'); + this.resetButton = button('适应'); + this.status = document.createElement('span'); + this.status.className = 'dwfv-status'; + toolbar.append('页: ', this.pageSelect, this.zoomOutButton, this.zoomInButton, this.resetButton, this.status); + + const workspace = document.createElement('div'); + workspace.className = 'dwfv-workspace'; + this.treePanel = document.createElement('div'); + this.treePanel.className = 'dwfv-tree'; + this.treePanel.style.display = 'none'; + const stage = document.createElement('div'); + stage.className = 'dwfv-stage'; + this.webglCanvas = document.createElement('canvas'); + this.webglCanvas.className = 'dwfv-canvas dwfv-webgl-canvas'; + this.webglCanvas.style.visibility = 'hidden'; + this.canvas = document.createElement('canvas'); + this.canvas.className = 'dwfv-canvas dwfv-overlay-canvas'; + this.canvas.style.touchAction = 'none'; + stage.append(this.webglCanvas, this.canvas); + workspace.append(this.treePanel, stage); + this.root.append(toolbar, workspace); + container.replaceChildren(this.root); + + this.pageSelect.addEventListener('change', () => { this.pageIndex = this.pageSelect.selectedIndex; this.resetView(); this.populateModelTree(); this.requestRender(); }); + this.zoomOutButton.addEventListener('click', () => { this.zoomAtCenter(0.8); this.requestRender(); }); + this.zoomInButton.addEventListener('click', () => { this.zoomAtCenter(1.25); this.requestRender(); }); + this.resetButton.addEventListener('click', () => { this.resetView(); this.requestRender(); }); + this.canvas.addEventListener('wheel', (e) => this.onWheel(e), { passive: false }); + this.canvas.addEventListener('pointerdown', (e) => this.onPointerDown(e)); + window.addEventListener('pointermove', (e) => this.onPointerMove(e)); + window.addEventListener('pointerup', () => { this.drag = undefined; }); + this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + + new ResizeObserver(() => this.requestRender()).observe(stage); + this.setStatus('选择 .dwf/.dwfx 文件'); + } + + setPreferWebgl(value: boolean): void { + this.preferWebgl = value; + this.requestRender(); + } + + setPreferWasm(value: boolean): void { + this.preferWasm = value; + this.requestRender(); + } + + setLineWeightMode(value: 'adaptive' | 'physical' | 'hairline'): void { + this.lineWeightMode = value; + this.renderer?.dispose(); + this.requestRender(); + } + + async load(input: ArrayBuffer | Uint8Array | Blob | File, options: LoadOptions = {}): Promise { + this.setStatus('解析文件中…'); + this.renderer?.dispose(); + this.doc = await openDwfDocument(input, { fileName: options.fileName }); + this.renderer = new PageRenderer(this.doc); + this.pageIndex = options.pageIndex ?? 0; + this.zoom = 1; + this.panX = this.panY = 0; + this.yaw = -0.78; + this.pitch = 0.55; + this.preferWebgl = options.preferWebgl ?? this.preferWebgl; + this.preferWasm = options.preferWasm ?? this.preferWasm; + this.wasmUrl = options.wasmUrl ?? this.wasmUrl; + this.background = options.background ?? this.background; + this.maxGpuCacheBytes = options.maxGpuCacheBytes ?? this.maxGpuCacheBytes; + this.maxCachedScenes = options.maxCachedScenes ?? this.maxCachedScenes; + this.lineWeightMode = options.lineWeightMode ?? this.lineWeightMode; + this.minStrokeCssPx = options.minStrokeCssPx ?? this.minStrokeCssPx; + this.maxOverviewStrokeCssPx = options.maxOverviewStrokeCssPx ?? this.maxOverviewStrokeCssPx; + this.minTextCssPx = options.minTextCssPx ?? this.minTextCssPx; + this.minFilledAreaCssPx = options.minFilledAreaCssPx ?? this.minFilledAreaCssPx; + this.populatePages(); + this.populateModelTree(); + await this.render(); + } + + async render(): Promise { + if (!this.renderer || !this.doc) return undefined; + if (this.rendering) { + this.renderAgain = true; + return this.pendingRender; + } + this.rendering = true; + try { + this.resizeCanvasToDisplaySize(); + const page = this.doc.pageData[this.pageIndex]; + if (!page) return undefined; + const seq = ++this.renderSeq; + const task = this.renderer.render(this.pageIndex, this.canvas, { + zoom: this.zoom, + panX: this.panX, + panY: this.panY, + preferWebgl: this.preferWebgl, + preferWasm: this.preferWasm, + wasmUrl: this.wasmUrl, + background: this.background, + maxGpuCacheBytes: this.maxGpuCacheBytes, + maxCachedScenes: this.maxCachedScenes, + webglCanvas: this.webglCanvas, + yaw: this.yaw, + pitch: this.pitch, + lineWeightMode: this.lineWeightMode, + minStrokeCssPx: this.minStrokeCssPx, + maxOverviewStrokeCssPx: this.maxOverviewStrokeCssPx, + minTextCssPx: this.minTextCssPx, + minFilledAreaCssPx: this.minFilledAreaCssPx + }); + this.pendingRender = task; + try { + const stats = await task; + if (this.pendingRender === task && seq === this.renderSeq) { + const warnCount = stats.warnings.filter(w => w.level !== 'info').length; + const dprText = this.currentDpr > 1 ? ` · DPR ${this.currentDpr.toFixed(2)}` : ''; + this.setStatus(`${this.doc.kind.toUpperCase()} · ${page.kind} · ${stats.backend} · ${stats.commands} ops${dprText}${warnCount ? ` · ${warnCount} 警告` : ''}`, warnCount > 0); + } + return stats; + } catch (err) { + if (seq === this.renderSeq) this.setStatus(`渲染失败:${String(err)}`, true); + throw err; + } + } finally { + this.rendering = false; + if (this.renderAgain) { + this.renderAgain = false; + this.requestRender(); + } + } + } + + getDocument(): LoadedDwfDocument | undefined { + return this.doc; + } + + fit(): void { + this.resetView(); + this.requestRender(); + } + + dispose(): void { + if (this.renderRaf) cancelAnimationFrame(this.renderRaf); + this.renderRaf = 0; + this.renderer?.dispose(); + this.renderer = undefined; + this.doc = undefined; + this.root.replaceChildren(); + } + + private requestRender(): void { + if (this.renderRaf) return; + this.renderRaf = requestAnimationFrame(() => { + this.renderRaf = 0; + void this.render(); + }); + } + + private populatePages(): void { + this.pageSelect.replaceChildren(); + const pages = this.doc?.pageData ?? []; + for (const [i, p] of pages.entries()) { + const opt = document.createElement('option'); + opt.value = String(i); + opt.textContent = `${i + 1}. ${p.name} (${p.kind})`; + this.pageSelect.append(opt); + } + this.pageSelect.selectedIndex = Math.max(0, Math.min(this.pageIndex, pages.length - 1)); + } + + + private populateModelTree(): void { + const page = this.doc?.pageData[this.pageIndex]; + if (!page || page.kind !== 'w3d-model' || !(page.model.sceneTree?.length)) { + this.treePanel.style.display = 'none'; + this.treePanel.replaceChildren(); + return; + } + this.treePanel.style.display = ''; + const header = document.createElement('div'); + header.className = 'dwfv-tree-header'; + header.textContent = `模型结构 · ${page.model.stats.nodeCount ?? 0} 节点`; + const stats = document.createElement('div'); + stats.className = 'dwfv-tree-stats'; + stats.textContent = `${page.model.stats.meshCount} meshes · ${page.model.stats.triangleCount} triangles · ${(page.model.stats.textureCount ?? 0)} textures`; + const content = document.createElement('div'); + content.className = 'dwfv-tree-content'; + for (const node of page.model.sceneTree) content.append(renderTreeNode(node)); + this.treePanel.replaceChildren(header, stats, content); + } + + private resizeCanvasToDisplaySize(): void { + const rect = this.canvas.getBoundingClientRect(); + const cssW = Math.max(1, rect.width); + const cssH = Math.max(1, rect.height); + let dpr = Math.max(1, Math.min(this.maxDevicePixelRatio, window.devicePixelRatio || 1)); + const pixels = cssW * cssH * dpr * dpr; + if (pixels > this.maxCanvasPixels) dpr *= Math.sqrt(this.maxCanvasPixels / pixels); + this.currentDpr = dpr; + const w = Math.max(1, Math.floor(cssW * dpr)); + const h = Math.max(1, Math.floor(cssH * dpr)); + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w; + this.canvas.height = h; + } + if (this.webglCanvas.width !== w || this.webglCanvas.height !== h) { + this.webglCanvas.width = w; + this.webglCanvas.height = h; + } + } + + private resetView(): void { + this.zoom = 1; + this.panX = 0; + this.panY = 0; + this.yaw = -0.78; + this.pitch = 0.55; + const page = this.doc?.pageData[this.pageIndex]; + if (page?.kind === 'w3d-model') { + const cam = page.model.initialView?.camera; + const pos = cam?.position; + const target = cam?.target; + if (pos && target) { + const dx = pos[0] - target[0]; + const dy = pos[1] - target[1]; + const dz = pos[2] - target[2]; + const dist = Math.hypot(dx, dy, dz); + if (dist > 1e-6) { + this.pitch = Math.max(-1.45, Math.min(1.45, Math.asin(dy / dist))); + this.yaw = Math.atan2(dx, dz); + const radius = Math.max(1e-6, page.model.bounds.radius); + this.zoom = Math.max(0.05, Math.min(100, radius * 2.55 / dist)); + } + } + } + } + + private zoomAtCenter(factor: number): void { + this.resizeCanvasToDisplaySize(); + if (this.is3dPage()) { + this.zoom = Math.max(0.05, Math.min(100, this.zoom * factor)); + return; + } + const cx = this.canvas.width / 2; + const cy = this.canvas.height / 2; + this.zoomAroundPoint(factor, cx, cy); + } + + private zoomAroundPoint(factor: number, cx: number, cy: number): void { + const oldZoom = this.zoom; + const nextZoom = Math.max(0.05, Math.min(64, oldZoom * factor)); + if (nextZoom === oldZoom) return; + + // Keep the drawing coordinate under the cursor fixed. A simple + // `pan = cursor - (cursor - pan) * ratio` is wrong for fit-to-page + // transforms, because the fit center also changes with zoom. + const anchoredPoint = this.pagePointAtCanvasPoint(cx, cy, oldZoom, this.panX, this.panY); + if (anchoredPoint) { + const baseMatrix = this.pageMatrixAt(nextZoom, 0, 0); + if (baseMatrix) { + const [sx, sy] = transformPoint(baseMatrix, anchoredPoint.x, anchoredPoint.y); + this.panX = cx - sx; + this.panY = cy - sy; + this.zoom = nextZoom; + return; + } + } + + // Generic fallback for unsupported page kinds. + const ratio = nextZoom / oldZoom; + this.panX = cx - (cx - this.panX) * ratio; + this.panY = cy - (cy - this.panY) * ratio; + this.zoom = nextZoom; + } + + private onWheel(e: WheelEvent): void { + if (!this.doc) return; + e.preventDefault(); + this.resizeCanvasToDisplaySize(); + const rect = this.canvas.getBoundingClientRect(); + const cx = (e.clientX - rect.left) * this.currentDpr; + const cy = (e.clientY - rect.top) * this.currentDpr; + const delta = e.deltaMode === WheelEvent.DOM_DELTA_LINE + ? e.deltaY * 16 + : e.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? e.deltaY * Math.max(1, rect.height) + : e.deltaY; + const factor = Math.exp(-delta * 0.0015); + if (this.is3dPage()) this.zoom = Math.max(0.05, Math.min(100, this.zoom * factor)); + else this.zoomAroundPoint(factor, cx, cy); + this.requestRender(); + } + + private onPointerDown(e: PointerEvent): void { + this.canvas.setPointerCapture?.(e.pointerId); + if (this.is3dPage()) e.preventDefault(); + const mode = this.is3dPage() ? ((e.button === 2 || e.shiftKey) ? 'pan3d' : 'rotate3d') : 'pan2d'; + this.drag = { x: e.clientX, y: e.clientY, panX: this.panX, panY: this.panY, yaw: this.yaw, pitch: this.pitch, mode }; + } + + private onPointerMove(e: PointerEvent): void { + if (!this.drag) return; + const dx = e.clientX - this.drag.x; + const dy = e.clientY - this.drag.y; + if (this.drag.mode === 'rotate3d') { + this.yaw = this.drag.yaw + dx * 0.008; + this.pitch = Math.max(-1.45, Math.min(1.45, this.drag.pitch + dy * 0.008)); + } else { + this.panX = this.drag.panX + dx * this.currentDpr; + this.panY = this.drag.panY + dy * this.currentDpr; + } + this.requestRender(); + } + + + private is3dPage(): boolean { + const page = this.doc?.pageData[this.pageIndex]; + return page?.kind === 'w3d-model'; + } + + private pagePointAtCanvasPoint(cx: number, cy: number, zoom: number, panX: number, panY: number): { x: number; y: number } | undefined { + const m = this.pageMatrixAt(zoom, panX, panY); + if (!m) return undefined; + const det = m.a * m.d - m.b * m.c; + if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return undefined; + const dx = cx - m.e; + const dy = cy - m.f; + return { + x: (m.d * dx - m.c * dy) / det, + y: (-m.b * dx + m.a * dy) / det + }; + } + + private pageMatrixAt(zoom: number, panX: number, panY: number): Matrix2D | undefined { + const page = this.doc?.pageData[this.pageIndex]; + if (!page) return undefined; + const canvasWidth = Math.max(1, this.canvas.width); + const canvasHeight = Math.max(1, this.canvas.height); + if (page.kind === 'w2d-text') return matrixForW2d(page, canvasWidth, canvasHeight, zoom, panX, panY); + if (page.kind === 'xps-fixed-page') { + return fitPageMatrix({ + canvasWidth, + canvasHeight, + pageWidth: Math.max(1, page.width), + pageHeight: Math.max(1, page.height), + zoom, + panX, + panY + }); + } + if (page.kind === 'image') { + return fitPageMatrix({ + canvasWidth, + canvasHeight, + pageWidth: Math.max(1, page.width), + pageHeight: Math.max(1, page.height), + zoom, + panX, + panY, + margin: 0 + }); + } + return undefined; + } + + private setStatus(text: string, warn = false): void { + this.status.textContent = text; + this.status.classList.toggle('dwfv-warn', warn); + } +} + +function button(text: string): HTMLButtonElement { + const b = document.createElement('button'); + b.type = 'button'; + b.textContent = text; + return b; +} + + +function renderTreeNode(node: W3dSceneNodeData): HTMLElement { + const details = document.createElement('details'); + details.open = node.children.length > 0 && node.children.length < 20; + const summary = document.createElement('summary'); + summary.textContent = node.label || node.id; + if (node.meshIds.length > 0) summary.title = `${node.meshIds.length} mesh(es)`; + details.append(summary); + if (node.contentRefs.length > 0) { + const meta = document.createElement('div'); + meta.className = 'dwfv-tree-meta'; + meta.textContent = node.contentRefs.slice(0, 3).join(', '); + details.append(meta); + } + for (const child of node.children) details.append(renderTreeNode(child)); + return details; +} From 1feff212f828f3761d69c781466fda3afdb64c3c Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:24:01 +0800 Subject: [PATCH 2/2] fix: satisfy strict monochrome color typing --- src/render/monochrome.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/render/monochrome.ts b/src/render/monochrome.ts index acddce7..7549a0a 100644 --- a/src/render/monochrome.ts +++ b/src/render/monochrome.ts @@ -57,7 +57,7 @@ export function createMonochromePage(page: PageData, color: string): PageData { primitives: page.primitives.map((primitive) => monochromePrimitive(primitive, normalized)) }; } else { - const target = parseColor(normalized) ?? NAMED_COLORS.black; + const target = parseColor(normalized) ?? NAMED_COLORS.black!; const rgb = [target.r / 255, target.g / 255, target.b / 255] as [number, number, number]; transformed = { ...page, @@ -98,7 +98,7 @@ export function applyXpsMonochromeXml(xml: string, color: string): string { /** Preserves source brush alpha and combines it with an optional target alpha. */ export function monochromeColorWithAlpha(color: string, sourceColor?: string): string { - const target = parseColor(color) ?? NAMED_COLORS.black; + const target = parseColor(color) ?? NAMED_COLORS.black!; const source = sourceColor ? parseColor(sourceColor) : undefined; return toCssColor({ r: target.r, @@ -177,7 +177,7 @@ function parseColor(value: string): RgbaColor | undefined { const channels = parts.slice(0, 3).map(parseChannel); const alpha = parts[3] === undefined ? 1 : parseAlpha(parts[3]); if (channels.every((item): item is number => item !== undefined) && alpha !== undefined) { - return { r: channels[0], g: channels[1], b: channels[2], a: alpha }; + return { r: channels[0]!, g: channels[1]!, b: channels[2]!, a: alpha }; } } }