From 2fbb095b0c915b755a4029ece158d55b14017639 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:26:27 +0800 Subject: [PATCH 01/13] feat: add non-destructive CAD render color policy --- src/core/colorPolicy.ts | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/core/colorPolicy.ts 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; +} From 3b3719053dfbf776cf2b8aaae09201dbd30065d4 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:28:31 +0800 Subject: [PATCH 02/13] feat: apply monochrome plot color during CAD rendering --- src/core/color.ts | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/core/color.ts b/src/core/color.ts index aeeb31a..b9a51a9 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 = { @@ -12,7 +13,7 @@ const BASE_ACI: Record = { 70: '#7fff00', 71: '#bfff7f', 72: '#52a500', 73: '#7ca552', 74: '#3f7f00', 75: '#5f7f3f', 76: '#264c00', 77: '#394c26', 78: '#132600', 79: '#1c2613', 80: '#3fff00', 81: '#9fff7f', 82: '#29a500', 83: '#67a552', 84: '#1f7f00', 85: '#4f7f3f', 86: '#134c00', 87: '#2f4c26', 88: '#092600', 89: '#172613', 90: '#00ff00', 91: '#7fff7f', 92: '#00a500', 93: '#52a552', 94: '#007f00', 95: '#3f7f3f', 96: '#004c00', 97: '#264c26', 98: '#002600', 99: '#132613', - 100: '#00ff3f', 101: '#7fff9f', 102: '#00a529', 103: '#52a567', 104: '#007f1f', 105: '#3f7f4f', 106: '#004c13', 107: '#264c2f', 108: '#002609', 109: '#132617', + 100: '#00ff3f', 101: '#7fff9f', 102: '#00a529', 103: '#52a567', 104: '#007f1f', 105: '#3f4f4f', 106: '#004c13', 107: '#264c2f', 108: '#002609', 109: '#132617', 110: '#00ff7f', 111: '#7fffbf', 112: '#00a552', 113: '#52a57c', 114: '#007f3f', 115: '#3f7f5f', 116: '#004c26', 117: '#264c39', 118: '#002613', 119: '#13261c', 120: '#00ffbf', 121: '#7fffdf', 122: '#00a57c', 123: '#52a591', 124: '#007f5f', 125: '#3f7f6f', 126: '#004c39', 127: '#264c42', 128: '#00261c', 129: '#132621', 130: '#00ffff', 131: '#7fffff', 132: '#00a5a5', 133: '#52a5a5', 134: '#007f7f', 135: '#3f7f7f', 136: '#004c4c', 137: '#264c4c', 138: '#002626', 139: '#132626', @@ -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; From f03f63d69fd92b1d8a2ace3608b952e1c954b5d1 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:30:41 +0800 Subject: [PATCH 03/13] feat: expose runtime monochrome color mode --- src/viewer/CadViewer.ts | 59 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) 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)); } From e02ca604ed580b5b0a78580bb5c09725090cc54d Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:33:01 +0800 Subject: [PATCH 04/13] feat: forward monochrome mode to native DWF renderer --- src/loaders/dwf/DwfLoader.ts | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) 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 { From 5167fa445cc82dd9b789c117a52fa2ea1f2b76fc Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:33:42 +0800 Subject: [PATCH 05/13] feat: export CAD monochrome color policy APIs --- src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a1f4cbb..7a77a01 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 { applyCadColorPolicy, colorFromAci, colorFromTrueColor, resolveCadColor, resolveFillColor } from './core/color'; +export type { CadColorContrastMode, ColorResolveOptions } from './core/color'; export type { CadBlock, From 9d3b3ebc2ff7acc674757b18c50e74bfa51f00f6 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:36:08 +0800 Subject: [PATCH 06/13] test: cover monochrome CAD color policy --- test/monochrome-color.test.mjs | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 test/monochrome-color.test.mjs diff --git a/test/monochrome-color.test.mjs b/test/monochrome-color.test.mjs new file mode 100644 index 0000000..dad07f7 --- /dev/null +++ b/test/monochrome-color.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + 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('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'); +}); From b89efe8c20d49efc0d886dc1802a8354ab0e2744 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:36:21 +0800 Subject: [PATCH 07/13] ci: validate CAD builds and regressions --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 4b227936bc14bb4706f3d1c589d95bfe2e66df1f Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:38:18 +0800 Subject: [PATCH 08/13] fix: preserve the canonical ACI color table --- src/core/color.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/color.ts b/src/core/color.ts index b9a51a9..e436eab 100644 --- a/src/core/color.ts +++ b/src/core/color.ts @@ -13,7 +13,7 @@ const BASE_ACI: Record = { 70: '#7fff00', 71: '#bfff7f', 72: '#52a500', 73: '#7ca552', 74: '#3f7f00', 75: '#5f7f3f', 76: '#264c00', 77: '#394c26', 78: '#132600', 79: '#1c2613', 80: '#3fff00', 81: '#9fff7f', 82: '#29a500', 83: '#67a552', 84: '#1f7f00', 85: '#4f7f3f', 86: '#134c00', 87: '#2f4c26', 88: '#092600', 89: '#172613', 90: '#00ff00', 91: '#7fff7f', 92: '#00a500', 93: '#52a552', 94: '#007f00', 95: '#3f7f3f', 96: '#004c00', 97: '#264c26', 98: '#002600', 99: '#132613', - 100: '#00ff3f', 101: '#7fff9f', 102: '#00a529', 103: '#52a567', 104: '#007f1f', 105: '#3f4f4f', 106: '#004c13', 107: '#264c2f', 108: '#002609', 109: '#132617', + 100: '#00ff3f', 101: '#7fff9f', 102: '#00a529', 103: '#52a567', 104: '#007f1f', 105: '#3f7f4f', 106: '#004c13', 107: '#264c2f', 108: '#002609', 109: '#132617', 110: '#00ff7f', 111: '#7fffbf', 112: '#00a552', 113: '#52a57c', 114: '#007f3f', 115: '#3f7f5f', 116: '#004c26', 117: '#264c39', 118: '#002613', 119: '#13261c', 120: '#00ffbf', 121: '#7fffdf', 122: '#00a57c', 123: '#52a591', 124: '#007f5f', 125: '#3f7f6f', 126: '#004c39', 127: '#264c42', 128: '#00261c', 129: '#132621', 130: '#00ffff', 131: '#7fffff', 132: '#00a5a5', 133: '#52a5a5', 134: '#007f7f', 135: '#3f7f7f', 136: '#004c4c', 137: '#264c4c', 138: '#002626', 139: '#132626', From e7372e6d0e11b816dab6b30c1d4f7a9aef89246b Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:47:21 +0800 Subject: [PATCH 09/13] test: cover ByBlock monochrome alpha inheritance --- test/monochrome-color.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/monochrome-color.test.mjs b/test/monochrome-color.test.mjs index dad07f7..22adf7a 100644 --- a/test/monochrome-color.test.mjs +++ b/test/monochrome-color.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + applyByBlockColorInheritance, colorFromAci, createCadRenderDocument, readCadColorPolicy, @@ -73,6 +74,21 @@ test('monochrome mode preserves source and target alpha', () => { ); }); +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'); From 4d4caa9eaddd4e33a34e10fabfba3913213183cb Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:47:51 +0800 Subject: [PATCH 10/13] test: expose ByBlock color inheritance for regression coverage --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 7a77a01..db9876f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,7 @@ 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 { applyCadColorPolicy, colorFromAci, colorFromTrueColor, resolveCadColor, resolveFillColor } from './core/color'; +export { applyByBlockColorInheritance, applyCadColorPolicy, colorFromAci, colorFromTrueColor, resolveCadColor, resolveFillColor } from './core/color'; export type { CadColorContrastMode, ColorResolveOptions } from './core/color'; export type { From 0241ab32fb6da37b2c8d8d409eb6e1370089caf8 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:48:16 +0800 Subject: [PATCH 11/13] chore: apply ByBlock monochrome fix --- .github/workflows/apply-byblock-fix.yml | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/apply-byblock-fix.yml diff --git a/.github/workflows/apply-byblock-fix.yml b/.github/workflows/apply-byblock-fix.yml new file mode 100644 index 0000000..44ea52b --- /dev/null +++ b/.github/workflows/apply-byblock-fix.yml @@ -0,0 +1,37 @@ +name: Apply monochrome ByBlock fix + +on: + push: + branches: [feature/monochrome-color-mode] + +permissions: + contents: write + +jobs: + patch: + if: ${{ !contains(github.event.head_commit.message, '[byblock-fix-applied]') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/monochrome-color-mode + - name: Apply fix + run: | + python <<'PY' + from pathlib import Path + path = Path('src/core/color.ts') + text = path.read_text(encoding='utf-8') + old = " const inheritedColor = resolveCadColor(parent, document, options);" + new = " const inheritedColor = resolveCadColor(parent, document, { ...options, colorMode: 'source' });" + if new not in text: + if text.count(old) != 1: + raise RuntimeError(f'Expected one ByBlock inheritance call, found {text.count(old)}') + path.write_text(text.replace(old, new, 1), encoding='utf-8') + PY + rm .github/workflows/apply-byblock-fix.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix: apply monochrome alpha once for ByBlock [byblock-fix-applied]" + git push origin HEAD:feature/monochrome-color-mode From 0f1e85b7de9f92dcfa7861d0d2db33631ca49af3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:48:24 +0000 Subject: [PATCH 12/13] fix: apply monochrome alpha once for ByBlock [byblock-fix-applied] --- .github/workflows/apply-byblock-fix.yml | 37 ------------------------- src/core/color.ts | 2 +- 2 files changed, 1 insertion(+), 38 deletions(-) delete mode 100644 .github/workflows/apply-byblock-fix.yml diff --git a/.github/workflows/apply-byblock-fix.yml b/.github/workflows/apply-byblock-fix.yml deleted file mode 100644 index 44ea52b..0000000 --- a/.github/workflows/apply-byblock-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Apply monochrome ByBlock fix - -on: - push: - branches: [feature/monochrome-color-mode] - -permissions: - contents: write - -jobs: - patch: - if: ${{ !contains(github.event.head_commit.message, '[byblock-fix-applied]') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/monochrome-color-mode - - name: Apply fix - run: | - python <<'PY' - from pathlib import Path - path = Path('src/core/color.ts') - text = path.read_text(encoding='utf-8') - old = " const inheritedColor = resolveCadColor(parent, document, options);" - new = " const inheritedColor = resolveCadColor(parent, document, { ...options, colorMode: 'source' });" - if new not in text: - if text.count(old) != 1: - raise RuntimeError(f'Expected one ByBlock inheritance call, found {text.count(old)}') - path.write_text(text.replace(old, new, 1), encoding='utf-8') - PY - rm .github/workflows/apply-byblock-fix.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix: apply monochrome alpha once for ByBlock [byblock-fix-applied]" - git push origin HEAD:feature/monochrome-color-mode diff --git a/src/core/color.ts b/src/core/color.ts index e436eab..b5488d7 100644 --- a/src/core/color.ts +++ b/src/core/color.ts @@ -269,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; } From 393c33840c2e0ef2270e511af17f2b53f9c824b7 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:53:42 +0800 Subject: [PATCH 13/13] docs: document monochrome CAD color mode --- docs/monochrome-color-mode.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/monochrome-color-mode.md 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.