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 };
}
}
}