diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a20815 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.19.0 + cache: npm + - run: npm ci + - run: npm run build + - run: node --test test/*.test.mjs + - run: npm run pack:dry diff --git a/docs/monochrome-color-mode.md b/docs/monochrome-color-mode.md new file mode 100644 index 0000000..821ab7f --- /dev/null +++ b/docs/monochrome-color-mode.md @@ -0,0 +1,25 @@ +# Monochrome CAD color mode + +`CadViewer` can render authored CAD colors or apply one fixed plot color without rewriting parser-owned data. + +```ts +import { CadViewer } from '@flyfish-dev/cad-viewer'; + +const viewer = new CadViewer({ + container, + colorMode: 'monochrome', + monochromeColor: '#000000', +}); + +await viewer.loadFile(file); + +// Switch at runtime while retaining the active pan/zoom view. +viewer.setColorMode('source'); +viewer.setColorMode('monochrome', '#000000'); +``` + +The policy is applied by the shared color resolver, so normalized DWG/DXF output is consistent across Canvas2D, retained WebGL geometry, text overlays, fills, layer colors and ByBlock inheritance. Source entity colors, line types, line weights, visibility, geometry and alpha values are preserved. + +Native DWF/DWFx/XPS rendering forwards the same fixed color to `dwf-viewer` when the installed native renderer exposes `setMonochromeColor()`. + +For lower-level rendering, use `createCadRenderDocument()` to attach a render-only policy to a shallow document view and pass that view to a renderer. `readCadColorPolicy()` inspects the active policy. diff --git a/src/core/color.ts b/src/core/color.ts index aeeb31a..b5488d7 100644 --- a/src/core/color.ts +++ b/src/core/color.ts @@ -1,3 +1,4 @@ +import { readCadColorPolicy, type CadColorMode } from './colorPolicy'; import type { CadDocument, CadEntity, CadLayer } from './types'; const BASE_ACI: Record = { @@ -30,7 +31,6 @@ const BASE_ACI: Record = { 250: '#333333', 251: '#505050', 252: '#696969', 253: '#828282', 254: '#bebebe', 255: '#ffffff' }; - export type CadColorContrastMode = 'preserve' | 'adaptive'; export interface ColorResolveOptions { @@ -39,6 +39,10 @@ export interface ColorResolveOptions { trueColorByteOrder?: 'rgb' | 'bgr'; contrastMode?: CadColorContrastMode; minColorContrast?: number; + /** Optional direct override; document render metadata is used when omitted. */ + colorMode?: CadColorMode; + /** Fixed color used by monochrome mode. Defaults to the active foreground. */ + monochromeColor?: string; } interface RgbaColor { @@ -156,7 +160,7 @@ export function resolveCadColor(entity: CadEntity, document?: CadDocument, optio color = resolveLayerColor(layer, options); } - return adaptColorForCanvas(color ?? fallback, options); + return resolveCadRenderColor(color ?? fallback, document, options); } export function resolveFillColor(entity: CadEntity, document?: CadDocument, options: ColorResolveOptions = {}): string | undefined { @@ -168,7 +172,35 @@ export function resolveFillColor(entity: CadEntity, document?: CadDocument, opti color = Math.abs(n) <= 257 ? colorFromAci(n, options.foreground ?? '#ffffff', options.foreground ?? '#ffffff') : colorFromTrueColor(n, options.trueColorByteOrder ?? 'rgb'); } if (!color && typeof entity.fillColorIndex === 'number') color = colorFromAci(entity.fillColorIndex, options.foreground ?? '#ffffff', options.foreground ?? '#ffffff'); - return color ? adaptColorForCanvas(color, options) : undefined; + return color ? resolveCadRenderColor(color, document, options) : undefined; +} + +/** Applies a monochrome plot-style override without changing source entities. */ +export function applyCadColorPolicy(color: string, document?: CadDocument, options: ColorResolveOptions = {}): string { + const policy = readCadColorPolicy(document); + const mode = options.colorMode ?? policy.mode; + if (mode !== 'monochrome') return color; + + const target = parseCssColor(options.monochromeColor ?? policy.monochromeColor ?? options.foreground ?? '#000000'); + if (!target) return color; + const targetRgba = parseRgba(target); + if (!targetRgba) return target; + const sourceRgba = parseRgba(color); + return toCssColor({ + r: targetRgba.r, + g: targetRgba.g, + b: targetRgba.b, + a: targetRgba.a * (sourceRgba?.a ?? 1) + }); +} + +function resolveCadRenderColor(color: string, document: CadDocument | undefined, options: ColorResolveOptions): string { + const policy = readCadColorPolicy(document); + const mode = options.colorMode ?? policy.mode; + const resolved = applyCadColorPolicy(color, document, options); + // A fixed plot color is deliberate; contrast adaptation must not replace it + // or discard authored transparency. + return mode === 'monochrome' ? resolved : adaptColorForCanvas(resolved, options); } export function adaptColorForCanvas(color: string, options: ColorResolveOptions = {}): string { @@ -229,7 +261,6 @@ function resolveLayerColor(layer: CadLayer | undefined, options: ColorResolveOpt return undefined; } - export function isByBlockColor(entity: CadEntity): boolean { const aci = firstNumber(entity.colorIndex, entity.colorNumber, (entity as Record).aci); if (aci === 0) return true; @@ -238,7 +269,7 @@ export function isByBlockColor(entity: CadEntity): boolean { export function applyByBlockColorInheritance(entity: CadEntity, parent: CadEntity, document?: CadDocument, options: ColorResolveOptions = {}): CadEntity { if (!isByBlockColor(entity)) return entity; - const inheritedColor = resolveCadColor(parent, document, options); + const inheritedColor = resolveCadColor(parent, document, { ...options, colorMode: 'source' }); const clone: CadEntity = { ...entity, color: inheritedColor, trueColor: undefined, colorIndex: undefined, colorNumber: undefined }; return clone; } diff --git a/src/core/colorPolicy.ts b/src/core/colorPolicy.ts new file mode 100644 index 0000000..23376d7 --- /dev/null +++ b/src/core/colorPolicy.ts @@ -0,0 +1,60 @@ +import type { CadDocument } from './types'; + +export type CadColorMode = 'source' | 'monochrome'; + +export interface CadColorPolicy { + /** Preserve authored colors or replace renderable CAD colors with one fixed color. */ + mode?: CadColorMode; + /** CSS color used when mode is `monochrome`. Defaults to the active renderer foreground. */ + monochromeColor?: string; +} + +const COLOR_MODE_KEY = 'cadColorMode'; +const MONOCHROME_COLOR_KEY = 'cadMonochromeColor'; + +/** + * Creates a shallow render-only document view carrying a color policy in + * metadata. Geometry, layers, entities, parser data and the caller-owned + * document remain untouched. + */ +export function createCadRenderDocument( + document: CadDocument, + policy: CadColorPolicy = {} +): CadDocument { + const mode = policy.mode ?? 'source'; + const current = readCadColorPolicy(document); + const nextColor = normalizeOptionalColor(policy.monochromeColor); + + if ( + current.mode === mode && + current.monochromeColor === nextColor + ) { + return document; + } + + const metadata = { ...document.metadata }; + if (mode === 'monochrome') { + metadata[COLOR_MODE_KEY] = mode; + if (nextColor) metadata[MONOCHROME_COLOR_KEY] = nextColor; + else delete metadata[MONOCHROME_COLOR_KEY]; + } else { + delete metadata[COLOR_MODE_KEY]; + delete metadata[MONOCHROME_COLOR_KEY]; + } + + return { ...document, metadata }; +} + +/** Reads the render-only color policy attached by createCadRenderDocument(). */ +export function readCadColorPolicy(document?: CadDocument): Required> & Pick { + const metadata = document?.metadata; + const mode = metadata?.[COLOR_MODE_KEY] === 'monochrome' ? 'monochrome' : 'source'; + const monochromeColor = normalizeOptionalColor(metadata?.[MONOCHROME_COLOR_KEY]); + return monochromeColor ? { mode, monochromeColor } : { mode }; +} + +function normalizeOptionalColor(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim(); + return normalized || undefined; +} diff --git a/src/index.ts b/src/index.ts index a1f4cbb..db9876f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ export { extractCadBom, serializeCadBomCsv, serializeCadBomJson } from './core/b export { computeCadDocumentBounds, resolveCadFitBounds, resolveCadSavedViewBounds } from './core/bounds'; export type { CadBoundsOptions } from './core/bounds'; export { createCadSceneDocument } from './core/scene'; +export { createCadRenderDocument, readCadColorPolicy } from './core/colorPolicy'; +export type { CadColorMode, CadColorPolicy } from './core/colorPolicy'; export { applyByBlockLineTypeInheritance, createDashedCadPrimitives, createDashedCadSegments, resolveCadLinePattern, resolveCadLineTypeReference, transformCadLineTypeGlyph } from './core/linetype'; export type { CadDashedPrimitives, CadLineTypeMarker, ResolvedCadLinePattern, ResolvedCadLinePatternRun } from './core/linetype'; export { CadShxFontRegistry } from './core/shx'; @@ -20,7 +22,8 @@ export type { CadShxGlyph, CadShxGlyphResolver } from './core/shx'; export { normalizeDwgDatabase } from './loaders/dwg/DwgParser'; export { detectCadFormat, readInputBytes } from './core/format'; export { isCadNativeRenderableLoader } from './core/types'; -export { colorFromAci, colorFromTrueColor, resolveCadColor } from './core/color'; +export { applyByBlockColorInheritance, applyCadColorPolicy, colorFromAci, colorFromTrueColor, resolveCadColor, resolveFillColor } from './core/color'; +export type { CadColorContrastMode, ColorResolveOptions } from './core/color'; export type { CadBlock, diff --git a/src/loaders/dwf/DwfLoader.ts b/src/loaders/dwf/DwfLoader.ts index 61022a0..d254574 100644 --- a/src/loaders/dwf/DwfLoader.ts +++ b/src/loaders/dwf/DwfLoader.ts @@ -3,6 +3,16 @@ import { createCadDocument, flattenPages } from '../../core/entity'; import { detectCadFormat, extensionOf, readInputBytes } from '../../core/format'; import type { CadDocument, CadEntity, CadFormat, CadLoadInput, CadLoadOptions, CadLoadResult, CadNativeRenderableLoader, CadPage, CadPathCommand, CadPoint3D } from '../../core/types'; +type CadColorLoadOptions = CadLoadOptions & { + colorMode?: 'source' | 'monochrome'; + monochromeColor?: string; + canvasOptions?: { foreground?: string }; +}; + +type DwfMonochromeOptions = { + monochromeColor?: string; +}; + export class DwfLoader implements CadNativeRenderableLoader { readonly id = 'dwf'; readonly label = 'DWF/DWFx native viewer powered by dwf-viewer'; @@ -107,6 +117,7 @@ export class DwfLoader implements CadNativeRenderableLoader { setPreferWebgl?: (value: boolean) => void; setPreferWasm?: (value: boolean) => void; setLineWeightMode?: (value: NonNullable) => void; + setMonochromeColor?: (value?: string) => void; minStrokeCssPx?: number; maxOverviewStrokeCssPx?: number; minTextCssPx?: number; @@ -122,6 +133,7 @@ export class DwfLoader implements CadNativeRenderableLoader { if (typeof options.dwfPreferWebgl === 'boolean') native.setPreferWebgl?.(options.dwfPreferWebgl); if (typeof options.dwfPreferWasm === 'boolean') native.setPreferWasm?.(options.dwfPreferWasm); if (options.dwfLineWeightMode) native.setLineWeightMode?.(options.dwfLineWeightMode); + native.setMonochromeColor?.(resolveDwfMonochromeColor(options)); setOptionalNumber(native, 'minStrokeCssPx', options.dwfMinStrokeCssPx); setOptionalNumber(native, 'maxOverviewStrokeCssPx', options.dwfMaxOverviewStrokeCssPx); setOptionalNumber(native, 'minTextCssPx', options.dwfMinTextCssPx); @@ -278,15 +290,25 @@ function resolveDwfBackground(options: CadLoadOptions): string { return canvasOptions?.background ?? '#05070d'; } +function resolveDwfMonochromeColor(options: CadLoadOptions): string | undefined { + const colorOptions = options as CadColorLoadOptions; + if (colorOptions.colorMode !== 'monochrome') return undefined; + const configured = colorOptions.monochromeColor?.trim() || colorOptions.canvasOptions?.foreground?.trim(); + return configured || '#ffffff'; +} + function buildDwfViewerOptions(options: CadLoadOptions, wasmUrl: string | undefined, background: string): DwfViewerOptions { + const monochromeColor = resolveDwfMonochromeColor(options); return { ...buildDwfLoadOptions(options, wasmUrl, background), maxDevicePixelRatio: options.dwfMaxDevicePixelRatio ?? 2, - maxCanvasPixels: options.dwfMaxCanvasPixels ?? 16_777_216 - }; + maxCanvasPixels: options.dwfMaxCanvasPixels ?? 16_777_216, + ...(monochromeColor ? { monochromeColor } : {}) + } as DwfViewerOptions & DwfMonochromeOptions; } function buildDwfLoadOptions(options: CadLoadOptions, wasmUrl: string | undefined, background: string): DwfLoadOptions { + const monochromeColor = resolveDwfMonochromeColor(options); return { wasmUrl, preferWebgl: options.dwfPreferWebgl ?? true, @@ -298,8 +320,9 @@ function buildDwfLoadOptions(options: CadLoadOptions, wasmUrl: string | undefine minStrokeCssPx: options.dwfMinStrokeCssPx, maxOverviewStrokeCssPx: options.dwfMaxOverviewStrokeCssPx, minTextCssPx: options.dwfMinTextCssPx, - minFilledAreaCssPx: options.dwfMinFilledAreaCssPx - }; + minFilledAreaCssPx: options.dwfMinFilledAreaCssPx, + ...(monochromeColor ? { monochromeColor } : {}) + } as DwfLoadOptions & DwfMonochromeOptions; } function setOptionalNumber(target: T, key: K, value: number | undefined): void { diff --git a/src/viewer/CadViewer.ts b/src/viewer/CadViewer.ts index b920c5d..1509916 100644 --- a/src/viewer/CadViewer.ts +++ b/src/viewer/CadViewer.ts @@ -2,6 +2,7 @@ import { createDefaultLoaderRegistry } from '../loaders'; import type { CadLoaderRegistry } from '../loaders/CadLoaderRegistry'; import { summarizeCadDocument } from '../core/entity'; import { extractCadBom } from '../core/bom'; +import { createCadRenderDocument, type CadColorMode } from '../core/colorPolicy'; import { CadShxFontRegistry, synchronizeCadDocumentReferences, type CadShxGlyphResolver } from '../core/shx'; import { isCadNativeRenderableLoader, type CadBom, type CadBomOptions, type CadDocument, type CadFitMode, type CadLoadInput, type CadLoadedReference, type CadLoadOptions, type CadLoadProgress, type CadLoadResult, type CadLoader, type CadMissingReference, type CadNativeRenderableLoader, type CadReferenceInput, type CadReferenceState } from '../core/types'; import { CadCanvasRenderer, type CanvasViewerOptions, type RenderStats, type ViewChangeEvent } from './CadCanvasRenderer'; @@ -21,6 +22,10 @@ export interface CadViewerOptions extends CadLoadOptions { loaders?: CadLoader[]; registry?: CadLoaderRegistry; autoFit?: boolean; + /** Preserve authored colors or render CAD vectors/text/materials with one fixed color. */ + colorMode?: CadColorMode; + /** CSS color used by monochrome mode. Defaults to the active renderer foreground. */ + monochromeColor?: string; onLoadStart?: (source: File | ArrayBuffer | Uint8Array | CadLoadInput) => void; onLoadProgress?: (progress: CadLoadProgress) => void; onLoad?: (result: CadViewerLoadResult) => void; @@ -48,7 +53,7 @@ export class CadViewer { private activeNativeLoader?: CadNativeRenderableLoader; constructor(options: CadViewerOptions = {}) { - this.options = { autoFit: true, ...options }; + this.options = { autoFit: true, colorMode: 'source', ...options }; this.externalShxGlyphResolver = options.canvasOptions?.shxGlyphResolver; this.shxGlyphResolver = { resolveShape: (shapeNumber, fontName) => this.referenceRegistry.resolveShape(shapeNumber, fontName) @@ -168,7 +173,7 @@ export class CadViewer { summary: summarizeCadDocument(document), fileName }; - this.renderer.setDocument(document); + this.renderer.setDocument(this.createRenderDocument(document)); if (!this.options.autoFit) this.renderer.render(); this.lastResult = result; this.options.onLoad?.(result); @@ -195,6 +200,27 @@ export class CadViewer { else this.renderer.resize(); } + /** + * Switches the active viewer between authored colors and a fixed plot color. + * The parsed document is never rewritten. Existing pan/zoom state is retained. + */ + setColorMode(mode: CadColorMode, monochromeColor = this.options.monochromeColor): void { + const nextMode: CadColorMode = mode === 'monochrome' ? 'monochrome' : 'source'; + const nextColor = normalizeOptionalColor(monochromeColor); + if (nextMode === this.getColorMode() && nextColor === this.getMonochromeColor()) return; + this.options.colorMode = nextMode; + this.options.monochromeColor = nextColor; + this.refreshColorPolicy(); + } + + getColorMode(): CadColorMode { + return this.options.colorMode === 'monochrome' ? 'monochrome' : 'source'; + } + + getMonochromeColor(): string | undefined { + return normalizeOptionalColor(this.options.monochromeColor); + } + setCanvasOptions(options: CanvasViewerOptions): void { this.options.canvasOptions = { ...(this.options.canvasOptions ?? {}), ...options }; if (options.shxGlyphResolver) this.externalShxGlyphResolver = options.shxGlyphResolver; @@ -235,7 +261,7 @@ export class CadViewer { getLoadResult(): CadViewerLoadResult | undefined { return this.lastResult; } getDocument(): CadDocument | undefined { return this.activeNativeLoader ? this.lastResult?.document : this.renderer.getDocument(); } - getSourceDocument(): CadDocument | undefined { return this.activeNativeLoader ? this.lastResult?.document : this.renderer.getSourceDocument(); } + getSourceDocument(): CadDocument | undefined { return this.lastResult?.document; } /** Returns a fresh BOM derived from the parser-owned WCS document, or undefined before a file is loaded. */ getBom(options: CadBomOptions = {}): CadBom | undefined { const document = this.getSourceDocument(); @@ -267,7 +293,7 @@ export class CadViewer { this.options.onLoadProgress?.({ phase: 'render', format: result.format, message: 'Rendering normalized CAD scene…', percent: 96 }); this.activateDocumentReferences(result.document); result.warnings = result.document.warnings; - this.renderer.setDocument(result.document); + this.renderer.setDocument(this.createRenderDocument(result.document)); if (!this.options.autoFit) this.renderer.render(); const value: CadViewerLoadResult = { ...result, @@ -359,6 +385,25 @@ export class CadViewer { }; } + private createRenderDocument(document: CadDocument): CadDocument { + return createCadRenderDocument(document, { + mode: this.getColorMode(), + monochromeColor: this.getMonochromeColor() + }); + } + + private refreshColorPolicy(): void { + if (this.activeNativeLoader) { + this.activeNativeLoader.setNativeOptions?.(this.mergeLoadOptions({})); + return; + } + const document = this.lastResult?.document; + if (!document) return; + const view = this.renderer.getViewState(); + this.renderer.setDocument(this.createRenderDocument(document)); + this.renderer.setViewState(view); + } + private activateDocumentReferences(document: CadDocument): void { this.referenceRegistry.setDocument(document); synchronizeCadDocumentReferences(document, this.referenceRegistry.getState()); @@ -386,6 +431,12 @@ export function createCadViewer(options: CadViewerOptions = {}): CadViewer { return new CadViewer(options); } +function normalizeOptionalColor(value: string | undefined): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim(); + return normalized || undefined; +} + function normalizeError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/test/monochrome-color.test.mjs b/test/monochrome-color.test.mjs new file mode 100644 index 0000000..22adf7a --- /dev/null +++ b/test/monochrome-color.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + applyByBlockColorInheritance, + colorFromAci, + createCadRenderDocument, + readCadColorPolicy, + resolveCadColor, + resolveFillColor +} from '../dist/cad-viewer.es.js'; + +const createDocument = () => ({ + format: 'dxf', + layers: { + Walls: { name: 'Walls', color: '#ff0000' } + }, + blocks: {}, + entities: [], + metadata: {}, + warnings: [] +}); + +test('monochrome render policy is non-destructive', () => { + const source = createDocument(); + const rendered = createCadRenderDocument(source, { + mode: 'monochrome', + monochromeColor: '#102030' + }); + + assert.notEqual(rendered, source); + assert.notEqual(rendered.metadata, source.metadata); + assert.deepEqual(source.metadata, {}); + assert.deepEqual(readCadColorPolicy(rendered), { + mode: 'monochrome', + monochromeColor: '#102030' + }); + assert.equal(readCadColorPolicy(source).mode, 'source'); +}); + +test('monochrome mode overrides entity, layer and fill colors', () => { + const rendered = createCadRenderDocument(createDocument(), { + mode: 'monochrome', + monochromeColor: '#102030' + }); + + assert.equal( + resolveCadColor({ type: 'LINE', color: '#ff0000' }, rendered), + 'rgb(16, 32, 48)' + ); + assert.equal( + resolveCadColor({ type: 'LINE', layer: 'Walls' }, rendered), + 'rgb(16, 32, 48)' + ); + assert.equal( + resolveFillColor({ type: 'HATCH', fillColor: '#00ff00' }, rendered), + 'rgb(16, 32, 48)' + ); +}); + +test('monochrome mode preserves source and target alpha', () => { + const rendered = createCadRenderDocument(createDocument(), { + mode: 'monochrome', + monochromeColor: 'rgba(16, 32, 48, 0.5)' + }); + + assert.equal( + resolveCadColor({ type: 'LINE', color: 'rgba(255, 0, 0, 0.5)' }, rendered), + 'rgba(16, 32, 48, 0.25)' + ); + assert.equal( + resolveFillColor({ type: 'HATCH', fillColor: 'rgba(0, 255, 0, 0.25)' }, rendered), + 'rgba(16, 32, 48, 0.125)' + ); +}); + +test('ByBlock inheritance applies the plot alpha exactly once', () => { + const rendered = createCadRenderDocument(createDocument(), { + mode: 'monochrome', + monochromeColor: 'rgba(16, 32, 48, 0.5)' + }); + const inherited = applyByBlockColorInheritance( + { type: 'LINE', colorIndex: 0 }, + { type: 'INSERT', color: 'rgba(255, 0, 0, 0.5)' }, + rendered + ); + + assert.equal(inherited.color, 'rgba(255, 0, 0, 0.5)'); + assert.equal(resolveCadColor(inherited, rendered), 'rgba(16, 32, 48, 0.25)'); +}); + +test('source mode preserves authored colors and the ACI table', () => { + const source = createDocument(); + assert.equal(resolveCadColor({ type: 'LINE', color: '#ff0000' }, source), '#ff0000'); + assert.equal(resolveFillColor({ type: 'HATCH', fillColor: '#00ff00' }, source), '#00ff00'); + assert.equal(colorFromAci(105), '#3f7f4f'); +});